feat(pi): add first-class Coding Agent support

This commit is contained in:
SaladDay
2026-08-03 09:21:23 +00:00
parent ac764b4818
commit 9d71e355da
159 changed files with 39663 additions and 632 deletions
+12 -1
View File
@@ -783,6 +783,8 @@ dependencies = [
"indexmap 2.13.0",
"json-five",
"json5",
"jsonc-parser",
"libc",
"log",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
@@ -2799,6 +2801,15 @@ dependencies = [
"serde",
]
[[package]]
name = "jsonc-parser"
version = "0.33.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0560e3f9a9a03ea6b6e90b41138c5db9e21526c99eb192c1a26c68176593285"
dependencies = [
"serde_json",
]
[[package]]
name = "jsonptr"
version = "0.6.3"
@@ -4687,7 +4698,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
+6 -1
View File
@@ -23,7 +23,8 @@ test-hooks = []
tauri-build = { version = "2.4.0", features = [] }
[dependencies]
serde_json = { version = "1.0", features = ["preserve_order"] }
serde_json = { version = "1.0", features = ["arbitrary_precision", "preserve_order"] }
jsonc-parser = { version = "0.33", features = ["cst", "serde_json"] }
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
chrono = { version = "0.4", features = ["serde"] }
@@ -78,6 +79,7 @@ indexmap = { version = "2", features = ["serde"] }
rust_decimal = "1.33"
uuid = { version = "1.11", features = ["v4"] }
sha2 = "0.10"
libc = "0.2"
hmac = "0.12"
json5 = "0.4"
json-five = "0.3.1"
@@ -94,6 +96,9 @@ winreg = "0.52"
windows-sys = { version = "0.61", features = [
"Win32_Globalization",
"Win32_Storage_FileSystem",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_JobObjects",
"Win32_System_Threading",
"Win32_UI_Shell",
] }
+35 -30
View File
@@ -32,6 +32,7 @@ impl McpApps {
AppType::OpenCode => self.opencode,
AppType::OpenClaw => false, // OpenClaw doesn't support MCP
AppType::Hermes => self.hermes,
AppType::Pi => false, // Pi core has no native MCP registry.
AppType::ClaudeDesktop => false,
}
}
@@ -46,6 +47,7 @@ impl McpApps {
AppType::OpenCode => self.opencode = enabled,
AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore
AppType::Hermes => self.hermes = enabled,
AppType::Pi => {} // Pi core has no native MCP registry.
AppType::ClaudeDesktop => {} // Claude Desktop 3P provider config doesn't support MCP here
}
}
@@ -100,6 +102,8 @@ pub struct SkillApps {
pub opencode: bool,
#[serde(default)]
pub hermes: bool,
#[serde(default)]
pub pi: bool,
}
impl SkillApps {
@@ -112,6 +116,7 @@ impl SkillApps {
AppType::GrokBuild => self.grokbuild,
AppType::OpenCode => self.opencode,
AppType::Hermes => self.hermes,
AppType::Pi => self.pi,
AppType::OpenClaw => false, // OpenClaw doesn't support Skills
AppType::ClaudeDesktop => false,
}
@@ -126,6 +131,7 @@ impl SkillApps {
AppType::GrokBuild => self.grokbuild = enabled,
AppType::OpenCode => self.opencode = enabled,
AppType::Hermes => self.hermes = enabled,
AppType::Pi => self.pi = enabled,
AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore
AppType::ClaudeDesktop => {} // Claude Desktop 3P profiles don't use CC Switch skill sync
}
@@ -152,6 +158,9 @@ impl SkillApps {
if self.hermes {
apps.push(AppType::Hermes);
}
if self.pi {
apps.push(AppType::Pi);
}
apps
}
@@ -163,6 +172,7 @@ impl SkillApps {
&& !self.grokbuild
&& !self.opencode
&& !self.hermes
&& !self.pi
}
/// 仅启用指定应用(其他应用设为禁用)
@@ -357,6 +367,8 @@ pub struct PromptRoot {
pub openclaw: PromptConfig,
#[serde(default)]
pub hermes: PromptConfig,
#[serde(default)]
pub pi: PromptConfig,
}
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
@@ -381,6 +393,7 @@ pub enum AppType {
OpenCode,
OpenClaw,
Hermes,
Pi,
}
impl AppType {
@@ -394,6 +407,7 @@ impl AppType {
AppType::OpenCode => "opencode",
AppType::OpenClaw => "openclaw",
AppType::Hermes => "hermes",
AppType::Pi => "pi",
}
}
@@ -419,6 +433,7 @@ impl AppType {
AppType::OpenCode,
AppType::OpenClaw,
AppType::Hermes,
AppType::Pi,
]
.into_iter()
}
@@ -438,10 +453,11 @@ impl FromStr for AppType {
"opencode" => Ok(AppType::OpenCode),
"openclaw" => Ok(AppType::OpenClaw),
"hermes" => Ok(AppType::Hermes),
"pi" => Ok(AppType::Pi),
other => Err(AppError::localized(
"unsupported_app",
format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes。"),
format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes."),
format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes, pi"),
format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes, pi."),
)),
}
}
@@ -467,6 +483,9 @@ pub struct CommonConfigSnippets {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hermes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pi: Option<String>,
}
impl CommonConfigSnippets {
@@ -481,6 +500,7 @@ impl CommonConfigSnippets {
AppType::OpenCode => self.opencode.as_ref(),
AppType::OpenClaw => self.openclaw.as_ref(),
AppType::Hermes => self.hermes.as_ref(),
AppType::Pi => self.pi.as_ref(),
}
}
@@ -495,6 +515,7 @@ impl CommonConfigSnippets {
AppType::OpenCode => self.opencode = snippet,
AppType::OpenClaw => self.openclaw = snippet,
AppType::Hermes => self.hermes = snippet,
AppType::Pi => self.pi = snippet,
}
}
}
@@ -539,6 +560,7 @@ impl Default for MultiAppConfig {
apps.insert("opencode".to_string(), ProviderManager::default());
apps.insert("openclaw".to_string(), ProviderManager::default());
apps.insert("hermes".to_string(), ProviderManager::default());
apps.insert("pi".to_string(), ProviderManager::default());
Self {
version: 2,
@@ -626,6 +648,12 @@ impl MultiAppConfig {
.insert("gemini".to_string(), ProviderManager::default());
updated = true;
}
if !config.apps.contains_key("pi") {
config
.apps
.insert("pi".to_string(), ProviderManager::default());
updated = true;
}
// 执行 MCP 迁移(v3.6.x → v3.7.0
let migrated = config.migrate_mcp_to_unified()?;
@@ -691,34 +719,6 @@ impl MultiAppConfig {
}
}
/// 获取指定客户端的 MCP 配置(不可变引用)
pub fn mcp_for(&self, app: &AppType) -> &McpConfig {
match app {
AppType::Claude => &self.mcp.claude,
AppType::ClaudeDesktop => &self.mcp.claude_desktop,
AppType::Codex => &self.mcp.codex,
AppType::Gemini => &self.mcp.gemini,
AppType::GrokBuild => &self.mcp.grokbuild,
AppType::OpenCode => &self.mcp.opencode,
AppType::OpenClaw => &self.mcp.openclaw,
AppType::Hermes => &self.mcp.hermes,
}
}
/// 获取指定客户端的 MCP 配置(可变引用)
pub fn mcp_for_mut(&mut self, app: &AppType) -> &mut McpConfig {
match app {
AppType::Claude => &mut self.mcp.claude,
AppType::ClaudeDesktop => &mut self.mcp.claude_desktop,
AppType::Codex => &mut self.mcp.codex,
AppType::Gemini => &mut self.mcp.gemini,
AppType::GrokBuild => &mut self.mcp.grokbuild,
AppType::OpenCode => &mut self.mcp.opencode,
AppType::OpenClaw => &mut self.mcp.openclaw,
AppType::Hermes => &mut self.mcp.hermes,
}
}
/// 创建默认配置并自动导入已存在的提示词文件
fn default_with_auto_import() -> Result<Self, AppError> {
log::info!("首次启动,创建默认配置并检测提示词文件");
@@ -733,6 +733,7 @@ impl MultiAppConfig {
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenClaw)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Hermes)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Pi)?;
Ok(config)
}
@@ -757,6 +758,7 @@ impl MultiAppConfig {
|| !self.prompts.opencode.prompts.is_empty()
|| !self.prompts.openclaw.prompts.is_empty()
|| !self.prompts.hermes.prompts.is_empty()
|| !self.prompts.pi.prompts.is_empty()
{
return Ok(false);
}
@@ -772,6 +774,7 @@ impl MultiAppConfig {
AppType::OpenCode,
AppType::OpenClaw,
AppType::Hermes,
AppType::Pi,
] {
// 复用已有的单应用导入逻辑
if Self::auto_import_prompt_if_exists(self, app)? {
@@ -846,6 +849,7 @@ impl MultiAppConfig {
AppType::OpenCode => &mut config.prompts.opencode.prompts,
AppType::OpenClaw => &mut config.prompts.openclaw.prompts,
AppType::Hermes => &mut config.prompts.hermes.prompts,
AppType::Pi => &mut config.prompts.pi.prompts,
};
prompts.insert(id, prompt);
@@ -889,6 +893,7 @@ impl MultiAppConfig {
AppType::OpenCode => &self.mcp.opencode.servers,
AppType::OpenClaw => continue, // OpenClaw MCP is still in development, skip
AppType::Hermes => continue, // Hermes didn't exist in v3.6.x, skip
AppType::Pi => continue, // Pi didn't exist in v3.6.x, skip
};
for (id, entry) in old_servers {
File diff suppressed because it is too large Load Diff
+14
View File
@@ -135,6 +135,18 @@ pub async fn get_config_status(
Ok(ConfigStatus { exists, path })
}
AppType::Pi => {
let config_path =
crate::pi_config::native::get_pi_models_path().map_err(|e| e.to_string())?;
let path = crate::pi_config::native::get_pi_agent_dir()
.map_err(|e| e.to_string())?
.to_string_lossy()
.to_string();
Ok(ConfigStatus {
exists: config_path.exists(),
path,
})
}
}
}
@@ -156,6 +168,7 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
AppType::Pi => crate::pi_config::native::get_pi_agent_dir().map_err(|e| e.to_string())?,
};
Ok(dir.to_string_lossy().to_string())
@@ -174,6 +187,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
AppType::Pi => crate::pi_config::native::get_pi_agent_dir().map_err(|e| e.to_string())?,
};
if !config_dir.exists() {
+173
View File
@@ -2,6 +2,7 @@
//!
//! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段)
use crate::app_config::AppType;
use crate::database::FailoverQueueItem;
use crate::provider::Provider;
use crate::store::AppState;
@@ -39,6 +40,50 @@ pub async fn add_to_failover_queue(
app_type: String,
provider_id: String,
) -> Result<(), String> {
if app_type == "pi" {
let _guard = state
.proxy_service
.lock_switch_for_app(AppType::Pi.as_str())
.await;
if state
.db
.get_provider_aggregate("pi", &provider_id)
.map_err(|error| error.to_string())?
.is_none()
{
return Err(format!("Pi provider does not exist: {provider_id}"));
}
let was_member = state
.db
.is_in_failover_queue("pi", &provider_id)
.map_err(|error| error.to_string())?;
let epoch = state.proxy_service.begin_pi_catalog_mutation().await;
if let Err(error) = state.db.add_to_failover_queue("pi", &provider_id) {
let _ = state
.proxy_service
.reconcile_pi_runtime_at_epoch(epoch)
.await;
return Err(error.to_string());
}
if let Err(error) = state
.proxy_service
.reconcile_pi_runtime_at_epoch(epoch)
.await
{
if !was_member {
let _ = state.db.remove_from_failover_queue("pi", &provider_id);
}
let rollback_epoch = state.proxy_service.begin_pi_catalog_mutation().await;
let _ = state
.proxy_service
.reconcile_pi_runtime_at_epoch(rollback_epoch)
.await;
return Err(format!(
"Pi failover queue changed but runtime publication failed: {error}"
));
}
return Ok(());
}
state
.db
.add_to_failover_queue(&app_type, &provider_id)
@@ -52,6 +97,42 @@ pub async fn remove_from_failover_queue(
app_type: String,
provider_id: String,
) -> Result<(), String> {
if app_type == "pi" {
let _guard = state
.proxy_service
.lock_switch_for_app(AppType::Pi.as_str())
.await;
let was_member = state
.db
.is_in_failover_queue("pi", &provider_id)
.map_err(|error| error.to_string())?;
let epoch = state.proxy_service.begin_pi_catalog_mutation().await;
if let Err(error) = state.db.remove_from_failover_queue("pi", &provider_id) {
let _ = state
.proxy_service
.reconcile_pi_runtime_at_epoch(epoch)
.await;
return Err(error.to_string());
}
if let Err(error) = state
.proxy_service
.reconcile_pi_runtime_at_epoch(epoch)
.await
{
if was_member {
let _ = state.db.add_to_failover_queue("pi", &provider_id);
}
let rollback_epoch = state.proxy_service.begin_pi_catalog_mutation().await;
let _ = state
.proxy_service
.reconcile_pi_runtime_at_epoch(rollback_epoch)
.await;
return Err(format!(
"Pi failover queue changed but runtime publication failed: {error}"
));
}
return Ok(());
}
state
.db
.remove_from_failover_queue(&app_type, &provider_id)
@@ -64,6 +145,9 @@ pub async fn get_auto_failover_enabled(
state: tauri::State<'_, AppState>,
app_type: String,
) -> Result<bool, String> {
if app_type == "pi" {
return Ok(crate::settings::get_pi_proxy_settings().auto_failover_enabled);
}
state
.db
.get_proxy_config_for_app(&app_type)
@@ -86,6 +170,10 @@ pub async fn set_auto_failover_enabled(
"[Failover] Setting auto_failover_enabled: app_type='{app_type}', enabled={enabled}"
);
if app_type == "pi" {
return set_pi_auto_failover_enabled(&app, state.inner(), enabled).await;
}
// 读取当前配置
let mut config = state
.db
@@ -180,3 +268,88 @@ pub async fn set_auto_failover_enabled(
Ok(())
}
async fn set_pi_auto_failover_enabled(
app: &tauri::AppHandle,
state: &AppState,
enabled: bool,
) -> Result<(), String> {
let _guard = state
.proxy_service
.lock_switch_for_app(AppType::Pi.as_str())
.await;
let previous_config = crate::settings::get_pi_proxy_settings();
if enabled && !crate::settings::pi_takeover_enabled() {
return Err("Pi gateway takeover must be enabled before failover".to_string());
}
let mut auto_added = None;
if enabled
&& state
.db
.get_failover_queue("pi")
.map_err(|error| error.to_string())?
.is_empty()
{
let current =
crate::services::pi_catalog::PiCatalogCoordinator::current_native_provider(state)
.map_err(|error| error.to_string())?
.ok_or_else(|| {
"Pi failover queue is empty and no current provider is selected".to_string()
})?;
state
.db
.add_to_failover_queue("pi", &current)
.map_err(|error| error.to_string())?;
auto_added = Some(current);
}
let mut next = previous_config.clone();
next.auto_failover_enabled = enabled;
let epoch = state.proxy_service.begin_pi_catalog_mutation().await;
if let Err(error) = crate::settings::update_pi_proxy_settings(next) {
if let Some(provider_id) = auto_added {
let _ = state.db.remove_from_failover_queue("pi", &provider_id);
}
let _ = state
.proxy_service
.reconcile_pi_runtime_at_epoch(epoch)
.await;
return Err(error.to_string());
}
if let Err(error) = state
.proxy_service
.reconcile_pi_runtime_at_epoch(epoch)
.await
{
let _ = crate::settings::update_pi_proxy_settings(previous_config);
if let Some(provider_id) = auto_added {
let _ = state.db.remove_from_failover_queue("pi", &provider_id);
}
let rollback_epoch = state.proxy_service.begin_pi_catalog_mutation().await;
let _ = state
.proxy_service
.reconcile_pi_runtime_at_epoch(rollback_epoch)
.await;
return Err(format!(
"Pi failover preference changed but runtime publication failed: {error}"
));
}
let _ = app.emit(
"provider-switched",
serde_json::json!({
"appType": "pi",
"providerId":
crate::services::pi_catalog::PiCatalogCoordinator::current_native_provider(state)
.map_err(|error| error.to_string())?,
"source": "failoverPreferenceChanged"
}),
);
if let Ok(new_menu) = crate::tray::create_tray_menu(app, state) {
if let Some(tray) = app.tray_by_id(crate::tray::TRAY_ID) {
let _ = tray.set_menu(Some(new_menu));
}
}
Ok(())
}
+87 -17
View File
@@ -44,26 +44,56 @@ pub async fn import_config_from_file(
state: State<'_, AppState>,
) -> Result<Value, String> {
let db = state.db.clone();
let db_for_sync = db.clone();
tauri::async_runtime::spawn_blocking(move || {
let path_buf = PathBuf::from(&filePath);
let backup_id = db.import_sql(&path_buf)?;
let warning = post_sync_warning_from_result(Ok(run_post_import_sync(db_for_sync)));
if let Some(msg) = warning.as_ref() {
log::warn!("[Import] post-import sync warning: {msg}");
let app_state = state.inner().clone();
let pi_guard = app_state
.proxy_service
.lock_switch_for_app(crate::app_config::AppType::Pi.as_str())
.await;
app_state
.proxy_service
.prepare_pi_portable_import_under_lock(&pi_guard)
.await
.map_err(|error| format!("导入前恢复 Pi 直连投影失败: {error}"))?;
let import_path = filePath.clone();
let import_result =
tauri::async_runtime::spawn_blocking(move || db.import_sql(&PathBuf::from(import_path)))
.await
.map_err(|error| AppError::Message(format!("SQL import task failed: {error}")))
.and_then(|result| result);
let backup_id = match import_result {
Ok(backup_id) => backup_id,
Err(error) => {
let recovery = app_state
.proxy_service
.recover_pi_after_aborted_portable_import_under_lock(&pi_guard)
.await;
return Err(match recovery {
Ok(()) => error.to_string(),
Err(recovery) => {
format!("{error}; Pi gateway recovery after aborted import failed: {recovery}")
}
});
}
Ok::<_, AppError>(success_payload_with_warning(backup_id, warning))
})
.await
.map_err(|e| format!("导入配置失败: {e}"))?
.map_err(|e: AppError| e.to_string())
};
drop(pi_guard);
let sync_state = app_state.clone();
let warning = post_sync_warning_from_result(
tauri::async_runtime::spawn_blocking(move || run_post_import_sync(&sync_state))
.await
.map_err(|error| error.to_string()),
);
if let Some(msg) = warning.as_ref() {
log::warn!("[Import] post-import sync warning: {msg}");
}
Ok(success_payload_with_warning(backup_id, warning))
}
#[tauri::command]
pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result<Value, String> {
let db = state.db.clone();
let app_state = state.inner().clone();
tauri::async_runtime::spawn_blocking(move || {
let app_state = AppState::new(db);
ProviderService::sync_current_to_live(&app_state)?;
Ok::<_, AppError>(json!({
"success": true,
@@ -154,10 +184,50 @@ pub async fn restore_db_backup(
filename: String,
) -> Result<String, String> {
let db = state.db.clone();
tauri::async_runtime::spawn_blocking(move || db.restore_from_backup(&filename))
let app_state = state.inner().clone();
let pi_guard = app_state
.proxy_service
.lock_switch_for_app(crate::app_config::AppType::Pi.as_str())
.await;
app_state
.proxy_service
.prepare_pi_portable_import_under_lock(&pi_guard)
.await
.map_err(|e| format!("Restore failed: {e}"))?
.map_err(|e: AppError| e.to_string())
.map_err(|error| format!("Restore preparation failed: {error}"))?;
let restore_result =
tauri::async_runtime::spawn_blocking(move || db.restore_from_backup(&filename))
.await
.map_err(|error| AppError::Message(format!("Restore task failed: {error}")))
.and_then(|result| result);
let restored = match restore_result {
Ok(restored) => restored,
Err(error) => {
let recovery = app_state
.proxy_service
.recover_pi_after_aborted_portable_import_under_lock(&pi_guard)
.await;
return Err(match recovery {
Ok(()) => error.to_string(),
Err(recovery) => {
format!("{error}; Pi gateway recovery after aborted restore failed: {recovery}")
}
});
}
};
drop(pi_guard);
let sync_state = app_state.clone();
match tauri::async_runtime::spawn_blocking(move || run_post_import_sync(&sync_state)).await {
Ok(Ok(())) => {}
Ok(Err(error)) => {
log::warn!("[Restore] database restored but post-restore sync failed: {error}");
}
Err(error) => {
log::warn!("[Restore] database restored but post-restore sync task failed: {error}");
}
}
Ok(restored)
}
/// Rename a database backup file
+39 -2
View File
@@ -111,8 +111,8 @@ pub struct ToolVersion {
wsl_distro: Option<String>,
}
const VALID_TOOLS: [&str; 7] = [
"claude", "codex", "gemini", "grok", "opencode", "openclaw", "hermes",
const VALID_TOOLS: [&str; 8] = [
"claude", "codex", "gemini", "grok", "opencode", "openclaw", "hermes", "pi",
];
#[derive(Debug, Clone, serde::Deserialize)]
@@ -433,6 +433,7 @@ fn tool_display_name(tool: &str) -> &'static str {
"opencode" => "OpenCode",
"openclaw" => "OpenClaw",
"hermes" => "Hermes",
"pi" => "Pi",
_ => "Unknown",
}
}
@@ -513,6 +514,7 @@ fn npm_install_command_for(tool: &str) -> Option<&'static str> {
"grok" => Some("npm i -g @xai-official/grok@latest"),
"opencode" => Some("npm i -g opencode-ai@latest"),
"openclaw" => Some("npm i -g openclaw@latest"),
"pi" => Some("npm i -g @earendil-works/pi-coding-agent@latest"),
_ => None,
}
}
@@ -807,6 +809,9 @@ async fn get_single_tool_version_impl(
}
"openclaw" => fetch_npm_latest_for_tool(&client, "openclaw", tool, local).await,
"hermes" => fetch_pypi_latest_version(&client, "hermes-agent").await,
"pi" => {
fetch_npm_latest_for_tool(&client, "@earendil-works/pi-coding-agent", tool, local).await
}
_ => None,
};
@@ -2071,6 +2076,7 @@ fn npm_package_for(tool: &str) -> Option<&'static str> {
"grok" => Some("@xai-official/grok"),
"opencode" => Some("opencode-ai"),
"openclaw" => Some("openclaw"),
"pi" => Some("@earendil-works/pi-coding-agent"),
_ => None,
}
}
@@ -2789,6 +2795,7 @@ fn wsl_distro_for_tool(tool: &str) -> Option<String> {
"opencode" => crate::settings::get_opencode_override_dir(),
"openclaw" => crate::settings::get_openclaw_override_dir(),
"hermes" => crate::settings::get_hermes_override_dir(),
"pi" => crate::settings::get_pi_override_dir(),
_ => None,
}?;
@@ -3926,6 +3933,24 @@ mod tests {
);
}
#[test]
fn pi_lifecycle_metadata_matches_pinned_distribution() {
let requested = vec!["unsupported".to_string(), "pi".to_string()];
assert_eq!(normalize_requested_tools(&requested), vec!["pi"]);
assert_eq!(tool_display_name("pi"), "Pi");
assert_eq!(
npm_package_for("pi"),
Some("@earendil-works/pi-coding-agent")
);
assert_eq!(
npm_install_command_for("pi"),
Some("npm i -g @earendil-works/pi-coding-agent@latest")
);
// The verified distribution exposes `pi --version`, but no updater
// contract is assumed; upgrades stay on the package-manager path.
assert_eq!(official_update_args("pi"), None);
}
#[test]
fn test_compare_semver() {
use std::cmp::Ordering;
@@ -5331,6 +5356,13 @@ mod tests {
assert_eq!(cmd, "npm i -g openclaw@latest");
}
#[test]
fn pi_install_uses_the_verified_pinned_package() {
let cmd = install_command_for("pi");
assert_eq!(cmd, "npm i -g @earendil-works/pi-coding-agent@latest");
assert!(!cmd.contains("||"));
}
#[test]
fn update_fallbacks_use_official_cli_only_when_supported() {
assert_eq!(
@@ -5360,6 +5392,11 @@ mod tests {
static_fallback_command("openclaw"),
"openclaw update --yes || npm i -g openclaw@latest"
);
assert_eq!(
static_fallback_command("pi"),
"npm i -g @earendil-works/pi-coding-agent@latest"
);
assert!(!static_fallback_command("pi").contains("pi update"));
}
#[test]
+2
View File
@@ -17,6 +17,7 @@ mod misc;
mod model_fetch;
mod omo;
mod openclaw;
mod pi;
mod plugin;
mod profile;
mod prompt;
@@ -53,6 +54,7 @@ pub use misc::*;
pub use model_fetch::*;
pub use omo::*;
pub use openclaw::*;
pub(crate) use pi::*;
pub use plugin::*;
pub use profile::*;
pub use prompt::*;
+76
View File
@@ -0,0 +1,76 @@
use crate::pi_config::model::PiNativeDiagnostic;
use crate::pi_config::native_settings::{read_pi_native_defaults, PiNativeDefaults};
use crate::services::pi_catalog::{PiCatalogCoordinator, PiCatalogMutation};
use crate::session_manager::providers::pi::PiSessionDiscovery;
use crate::store::AppState;
use tauri::State;
/// Read-only diagnostics come exclusively from the Pre-C certified inspection
/// service. This command does not infer manageability or gateway status.
#[tauri::command]
pub(crate) fn get_pi_native_catalog(
state: State<'_, AppState>,
) -> Result<Vec<PiNativeDiagnostic>, String> {
PiCatalogCoordinator::inspect_native(state.inner()).map_err(|error| error.to_string())
}
#[tauri::command]
pub(crate) fn import_pi_native_provider(
state: State<'_, AppState>,
#[allow(non_snake_case)] providerKey: String,
#[allow(non_snake_case)] expectedFingerprint: String,
) -> Result<String, String> {
let result = PiCatalogCoordinator::apply(
state.inner(),
PiCatalogMutation::ImportNative {
provider_key: providerKey,
expected_fingerprint: expectedFingerprint,
},
)
.map_err(|error| error.to_string())?;
result
.provider_id
.ok_or_else(|| "Pi import did not return a provider id".to_string())
}
#[tauri::command]
pub(crate) fn set_pi_default_model(
state: State<'_, AppState>,
#[allow(non_snake_case)] providerId: String,
#[allow(non_snake_case)] modelId: String,
) -> Result<bool, String> {
PiCatalogCoordinator::apply(
state.inner(),
PiCatalogMutation::SetDefault {
provider_id: providerId,
model_id: modelId,
},
)
.map(|_| true)
.map_err(|error| error.to_string())
}
#[tauri::command]
pub(crate) fn get_pi_native_defaults() -> Result<PiNativeDefaults, String> {
read_pi_native_defaults().map_err(|error| error.to_string())
}
#[tauri::command]
pub(crate) fn get_pi_session_discovery() -> PiSessionDiscovery {
crate::session_manager::providers::pi::session_discovery()
}
/// Explicitly rotate the device-local gateway bearer and republish every
/// managed Pi projection. Existing Pi processes must restart because they
/// retain the previous projected credential in memory.
#[tauri::command]
pub(crate) async fn reset_pi_gateway_credential(
state: State<'_, AppState>,
) -> Result<bool, String> {
state
.proxy_service
.rotate_pi_gateway_token()
.await
.map(|()| true)
.map_err(|error| error.to_string())
}
+63 -1
View File
@@ -5,7 +5,11 @@ use tauri::State;
use crate::app_config::AppType;
use crate::prompt::Prompt;
use crate::services::PromptService;
use crate::services::pi_prompt_files::{
PiPromptFileKind, PiPromptFileService, PiPromptFileSnapshot, PiPromptTemplate,
PiPromptTemplateService,
};
use crate::services::prompt::{PiPromptLibraryStatus, PromptService};
use crate::store::AppState;
#[tauri::command]
@@ -62,3 +66,61 @@ pub async fn get_current_prompt_file_content(app: String) -> Result<Option<Strin
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
PromptService::get_current_file_content(app_type).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_pi_prompt_library_status(
state: State<'_, AppState>,
) -> Result<PiPromptLibraryStatus, String> {
PromptService::get_pi_library_status(&state).map_err(|error| error.to_string())
}
#[tauri::command]
pub async fn reconcile_pi_prompt_library(state: State<'_, AppState>) -> Result<(), String> {
PromptService::reconcile_pi_library(&state).map_err(|error| error.to_string())
}
#[tauri::command]
pub async fn get_pi_prompt_file(kind: PiPromptFileKind) -> Result<PiPromptFileSnapshot, String> {
PiPromptFileService::read(kind).map_err(|error| error.to_string())
}
#[tauri::command]
pub async fn replace_pi_prompt_file(
kind: PiPromptFileKind,
#[allow(non_snake_case)] expectedRevision: String,
content: String,
) -> Result<PiPromptFileSnapshot, String> {
PiPromptFileService::replace(kind, &expectedRevision, &content)
.map_err(|error| error.to_string())
}
#[tauri::command]
pub async fn delete_pi_prompt_file(
kind: PiPromptFileKind,
#[allow(non_snake_case)] expectedRevision: String,
) -> Result<bool, String> {
PiPromptFileService::delete(kind, &expectedRevision).map_err(|error| error.to_string())
}
#[tauri::command]
pub async fn list_pi_prompt_templates() -> Result<Vec<PiPromptTemplate>, String> {
PiPromptTemplateService::list().map_err(|error| error.to_string())
}
#[tauri::command]
pub async fn upsert_pi_prompt_template(
slug: String,
#[allow(non_snake_case)] expectedRevision: String,
content: String,
) -> Result<PiPromptTemplate, String> {
PiPromptTemplateService::upsert(&slug, &expectedRevision, &content)
.map_err(|error| error.to_string())
}
#[tauri::command]
pub async fn delete_pi_prompt_template(
slug: String,
#[allow(non_snake_case)] expectedRevision: String,
) -> Result<bool, String> {
PiPromptTemplateService::delete(&slug, &expectedRevision).map_err(|error| error.to_string())
}
+58
View File
@@ -26,6 +26,7 @@ pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(),
|| takeover.grokbuild
|| takeover.opencode
|| takeover.openclaw
|| takeover.pi
{
return Err(
"仍有应用处于代理接管状态,请先在设置中关闭对应应用接管后再停止本地路由。".to_string(),
@@ -120,6 +121,9 @@ pub async fn get_proxy_config_for_app(
state: tauri::State<'_, AppState>,
app_type: String,
) -> Result<AppProxyConfig, String> {
if app_type == "pi" {
return Ok(crate::settings::get_pi_app_proxy_config());
}
let db = &state.db;
db.get_proxy_config_for_app(&app_type)
.await
@@ -138,6 +142,60 @@ pub async fn update_proxy_config_for_app(
let app_type = config.app_type.clone();
let circuit_config = CircuitBreakerConfig::from(&config);
if app_type == "pi" {
let _guard = state
.proxy_service
.lock_switch_for_app(crate::app_config::AppType::Pi.as_str())
.await;
let previous = crate::settings::get_pi_proxy_settings();
if config.enabled != crate::settings::pi_takeover_enabled() {
return Err(
"Pi enabled state is owned by set_proxy_takeover_for_app, not proxy config"
.to_string(),
);
}
let next = crate::settings::PiProxySettings {
auto_failover_enabled: config.auto_failover_enabled,
max_retries: config.max_retries,
streaming_first_byte_timeout: config.streaming_first_byte_timeout,
streaming_idle_timeout: config.streaming_idle_timeout,
non_streaming_timeout: config.non_streaming_timeout,
circuit_failure_threshold: config.circuit_failure_threshold,
circuit_success_threshold: config.circuit_success_threshold,
circuit_timeout_seconds: config.circuit_timeout_seconds,
circuit_error_rate_threshold: config.circuit_error_rate_threshold,
circuit_min_requests: config.circuit_min_requests,
};
let epoch = state.proxy_service.begin_pi_catalog_mutation().await;
if let Err(error) = crate::settings::update_pi_proxy_settings(next) {
let _ = state
.proxy_service
.reconcile_pi_runtime_at_epoch(epoch)
.await;
return Err(error.to_string());
}
if let Err(error) = state
.proxy_service
.reconcile_pi_runtime_at_epoch(epoch)
.await
{
let _ = crate::settings::update_pi_proxy_settings(previous);
let rollback_epoch = state.proxy_service.begin_pi_catalog_mutation().await;
let _ = state
.proxy_service
.reconcile_pi_runtime_at_epoch(rollback_epoch)
.await;
return Err(format!(
"Pi proxy config changed but runtime publication failed: {error}"
));
}
state
.proxy_service
.update_circuit_breaker_config_for_app(&app_type, circuit_config)
.await?;
return Ok(());
}
db.update_proxy_config_for_app(config)
.await
.map_err(|e| e.to_string())?;
+31 -5
View File
@@ -107,18 +107,44 @@ pub async fn s3_sync_upload(state: State<'_, AppState>) -> Result<Value, String>
#[tauri::command]
pub async fn s3_sync_download(state: State<'_, AppState>) -> Result<Value, String> {
let db = state.db.clone();
let db_for_sync = db.clone();
let app_state = state.inner().clone();
let mut settings = require_enabled_s3_settings()?;
let _auto_sync_suppression = crate::services::s3_auto_sync::AutoSyncSuppressionGuard::new();
let pi_guard = app_state
.proxy_service
.lock_switch_for_app(crate::app_config::AppType::Pi.as_str())
.await;
app_state
.proxy_service
.prepare_pi_portable_import_under_lock(&pi_guard)
.await
.map_err(|error| format!("S3 下载前恢复 Pi 直连投影失败: {error}"))?;
let sync_result = run_with_s3_lock(s3_sync_service::download(&db, &mut settings)).await;
let mut result = map_sync_result(sync_result, |error| {
persist_sync_error(&mut settings, error, "manual")
})?;
let mut result = match sync_result {
Ok(result) => result,
Err(error) => {
persist_sync_error(&mut settings, &error, "manual");
let recovery = app_state
.proxy_service
.recover_pi_after_aborted_portable_import_under_lock(&pi_guard)
.await;
return Err(match recovery {
Ok(()) => error.to_string(),
Err(recovery) => {
format!(
"{error}; Pi gateway recovery after aborted S3 download failed: {recovery}"
)
}
});
}
};
drop(pi_guard);
// Post-download sync is best-effort: snapshot restore has already succeeded.
let sync_state = app_state.clone();
let warning = post_sync_warning_from_result(
tauri::async_runtime::spawn_blocking(move || run_post_import_sync(db_for_sync))
tauri::async_runtime::spawn_blocking(move || run_post_import_sync(&sync_state))
.await
.map_err(|e| e.to_string()),
);
+59 -2
View File
@@ -48,6 +48,13 @@ fn merge_settings_for_save(
// 开关)后、前端 query 缓存刷新前的一次全量保存会把旧 marker 重放回来,
// 重新开启时被"复活"的标记挡住而漏迁。
incoming.local_migrations = existing.local_migrations.clone();
// Pi gateway credential is an installation secret. Settings IPC can
// neither observe it (frontend projection clears it) nor mutate it.
incoming.pi_gateway_token = existing.pi_gateway_token.clone();
// Pi proxy behavior is committed through the proxy commands so a generic
// settings round-trip cannot bypass the switch/epoch publication boundary.
incoming.pi_takeover_enabled = existing.pi_takeover_enabled;
incoming.pi_proxy = existing.pi_proxy.clone();
incoming
}
@@ -63,12 +70,29 @@ pub async fn save_settings(
state: tauri::State<'_, crate::store::AppState>,
settings: crate::settings::AppSettings,
) -> Result<bool, String> {
// The frontend settings projection intentionally cannot mutate Pi's
// takeover bit or gateway secret. Serialize the read/merge/write with Pi
// catalog mutations so a concurrent toggle cannot be overwritten by a
// stale full-settings payload.
let pi_guard = state
.proxy_service
.lock_switch_for_app(crate::app_config::AppType::Pi.as_str())
.await;
let existing = crate::settings::get_settings();
let merged = merge_settings_for_save(settings, &existing);
let unify_codex_changed =
merged.unify_codex_session_history != existing.unify_codex_session_history;
let unify_codex_enabled = merged.unify_codex_session_history;
crate::settings::update_settings(merged).map_err(|e| e.to_string())?;
state
.proxy_service
.replace_settings_with_pi_directory_boundary_under_lock(
&pi_guard,
&existing,
merged.clone(),
)
.await
.map_err(|e| e.to_string())?;
drop(pi_guard);
// 统一会话开关变更时立即重写当前官方 Codex 供应商的 live 配置,
// 不必等下一次切换才生效。
@@ -82,7 +106,18 @@ pub async fn save_settings(
crate::services::provider::reapply_current_codex_official_live(state.inner())
{
log::warn!("统一 Codex 会话历史开关变更后重写 live 配置失败,回滚设置: {err}");
if let Err(rollback_err) = crate::settings::update_settings(existing) {
let pi_guard = state
.proxy_service
.lock_switch_for_app(crate::app_config::AppType::Pi.as_str())
.await;
let current = crate::settings::get_settings();
if let Err(rollback_err) = state
.proxy_service
.replace_settings_with_pi_directory_boundary_under_lock(
&pi_guard, &current, existing,
)
.await
{
log::error!("回滚统一会话开关设置失败: {rollback_err}");
}
return Err(format!(
@@ -618,6 +653,28 @@ mod tests {
assert!(merged.local_migrations.is_none());
}
#[test]
fn save_settings_cannot_bypass_pi_gateway_publication_ownership() {
let existing = AppSettings {
pi_takeover_enabled: true,
pi_proxy: crate::settings::PiProxySettings {
max_retries: 7,
..crate::settings::PiProxySettings::default()
},
..AppSettings::default()
};
let incoming = AppSettings {
pi_takeover_enabled: false,
pi_proxy: crate::settings::PiProxySettings::default(),
..AppSettings::default()
};
let merged = merge_settings_for_save(incoming, &existing);
assert!(merged.pi_takeover_enabled);
assert_eq!(merged.pi_proxy.max_retries, 7);
}
}
/// 获取开机自启状态
+9
View File
@@ -11,7 +11,9 @@ use crate::services::skill::{
SkillService, SkillStorageLocation, SkillUninstallResult, SkillUpdateInfo,
SkillsShSearchResult,
};
use crate::services::skill_deployment::{PiSkillDeploymentService, SkillAppStatus};
use crate::store::AppState;
use std::collections::BTreeMap;
use std::str::FromStr;
use std::sync::Arc;
use tauri::State;
@@ -32,6 +34,13 @@ pub fn get_installed_skills(app_state: State<'_, AppState>) -> Result<Vec<Instal
SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_pi_skill_statuses(
app_state: State<'_, AppState>,
) -> Result<BTreeMap<String, SkillAppStatus>, String> {
PiSkillDeploymentService::inspect_all(&app_state.db).map_err(|error| error.to_string())
}
#[tauri::command]
pub fn get_skill_backups() -> Result<Vec<SkillBackupEntry>, String> {
SkillService::list_backups().map_err(|e| e.to_string())
+5 -7
View File
@@ -1,15 +1,13 @@
use serde_json::{json, Value};
use std::sync::Arc;
use crate::database::Database;
use crate::error::AppError;
use crate::services::provider::ProviderService;
use crate::services::PromptService;
use crate::settings;
use crate::store::AppState;
use serde_json::{json, Value};
pub(crate) fn run_post_import_sync(db: Arc<Database>) -> Result<(), AppError> {
let app_state = AppState::new(db);
ProviderService::sync_current_to_live(&app_state)?;
pub(crate) fn run_post_import_sync(app_state: &AppState) -> Result<(), AppError> {
PromptService::reconcile_pi_portable_import(app_state)?;
ProviderService::sync_current_to_live(app_state)?;
settings::reload_settings()?;
Ok(())
}
+31 -5
View File
@@ -115,18 +115,44 @@ pub async fn webdav_sync_upload(state: State<'_, AppState>) -> Result<Value, Str
#[tauri::command]
pub async fn webdav_sync_download(state: State<'_, AppState>) -> Result<Value, String> {
let db = state.db.clone();
let db_for_sync = db.clone();
let app_state = state.inner().clone();
let mut settings = require_enabled_webdav_settings()?;
let _auto_sync_suppression = crate::services::webdav_auto_sync::AutoSyncSuppressionGuard::new();
let pi_guard = app_state
.proxy_service
.lock_switch_for_app(crate::app_config::AppType::Pi.as_str())
.await;
app_state
.proxy_service
.prepare_pi_portable_import_under_lock(&pi_guard)
.await
.map_err(|error| format!("WebDAV 下载前恢复 Pi 直连投影失败: {error}"))?;
let sync_result = run_with_webdav_lock(webdav_sync_service::download(&db, &mut settings)).await;
let mut result = map_sync_result(sync_result, |error| {
persist_sync_error(&mut settings, error, "manual")
})?;
let mut result = match sync_result {
Ok(result) => result,
Err(error) => {
persist_sync_error(&mut settings, &error, "manual");
let recovery = app_state
.proxy_service
.recover_pi_after_aborted_portable_import_under_lock(&pi_guard)
.await;
return Err(match recovery {
Ok(()) => error.to_string(),
Err(recovery) => {
format!(
"{error}; Pi gateway recovery after aborted WebDAV download failed: {recovery}"
)
}
});
}
};
drop(pi_guard);
// Post-download sync is best-effort: snapshot restore has already succeeded.
let sync_state = app_state.clone();
let warning = post_sync_warning_from_result(
tauri::async_runtime::spawn_blocking(move || run_post_import_sync(db_for_sync))
tauri::async_runtime::spawn_blocking(move || run_post_import_sync(&sync_state))
.await
.map_err(|e| e.to_string()),
);
+95 -35
View File
@@ -295,6 +295,22 @@ pub fn write_text_file(path: &Path, data: &str) -> Result<(), AppError> {
/// 原子写入:写入临时文件后 rename 替换,避免半写状态
pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
atomic_write_durable(path, data, None)
}
/// Durable same-directory atomic replacement.
///
/// Existing permissions are preserved. `new_file_mode` controls only a newly
/// created Unix file (settings and other local secrets pass `0o600`). The
/// temporary file is created exclusively, synced before replacement, and the
/// containing directory is synced afterwards on Unix.
pub(crate) fn atomic_write_durable(
path: &Path,
data: &[u8],
new_file_mode: Option<u32>,
) -> Result<(), AppError> {
#[cfg(not(unix))]
let _ = new_file_mode;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
@@ -302,51 +318,95 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
let parent = path
.parent()
.ok_or_else(|| AppError::Config("无效的路径".to_string()))?;
let mut tmp = parent.to_path_buf();
let file_name = path
.file_name()
.ok_or_else(|| AppError::Config("无效的文件名".to_string()))?
.to_string_lossy()
.to_string();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
tmp.push(format!("{file_name}.tmp.{ts}"));
let tmp = parent.join(format!(
".{file_name}.{}.tmp",
uuid::Uuid::new_v4().simple()
));
{
let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?;
f.write_all(data).map_err(|e| AppError::io(&tmp, e))?;
f.flush().map_err(|e| AppError::io(&tmp, e))?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(path) {
let perm = meta.permissions().mode();
let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(perm));
let result = (|| -> Result<(), AppError> {
let mut options = fs::OpenOptions::new();
options.create_new(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(new_file_mode.unwrap_or(0o666));
}
}
let mut file = options
.open(&tmp)
.map_err(|error| AppError::io(&tmp, error))?;
file.write_all(data)
.map_err(|error| AppError::io(&tmp, error))?;
file.flush().map_err(|error| AppError::io(&tmp, error))?;
file.sync_all().map_err(|error| AppError::io(&tmp, error))?;
drop(file);
#[cfg(windows)]
{
// Windows 上 rename 目标存在会失败,先移除再重命名(尽量接近原子性)
if path.exists() {
let _ = fs::remove_file(path);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(path)
.map(|metadata| metadata.permissions().mode())
.unwrap_or_else(|_| new_file_mode.unwrap_or(0o666));
fs::set_permissions(&tmp, fs::Permissions::from_mode(mode))
.map_err(|error| AppError::io(&tmp, error))?;
}
fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
source: e,
})?;
}
#[cfg(not(windows))]
{
fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
source: e,
})?;
replace_file_atomically(&tmp, path)?;
#[cfg(unix)]
fs::File::open(parent)
.and_then(|directory| directory.sync_all())
.map_err(|error| AppError::io(parent, error))?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(&tmp);
}
result
}
#[cfg(not(windows))]
fn replace_file_atomically(temp_path: &Path, path: &Path) -> Result<(), AppError> {
fs::rename(temp_path, path).map_err(|source| AppError::IoContext {
context: format!(
"原子替换失败: {} -> {}",
temp_path.display(),
path.display()
),
source,
})
}
#[cfg(windows)]
fn replace_file_atomically(temp_path: &Path, path: &Path) -> Result<(), AppError> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::{
MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
};
let source: Vec<u16> = temp_path.as_os_str().encode_wide().chain(Some(0)).collect();
let destination: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
// SAFETY: both buffers are NUL-terminated and remain alive for the
// duration of this synchronous Win32 call.
let moved = unsafe {
MoveFileExW(
source.as_ptr(),
destination.as_ptr(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
)
};
if moved == 0 {
return Err(AppError::IoContext {
context: format!(
"原子替换失败: {} -> {}",
temp_path.display(),
path.display()
),
source: std::io::Error::last_os_error(),
});
}
Ok(())
}
+3
View File
@@ -4,6 +4,8 @@
pub mod failover;
pub mod mcp;
pub(crate) mod pi_catalog;
pub mod pi_projections;
pub mod profiles;
pub mod prompts;
pub mod provider_write;
@@ -13,6 +15,7 @@ pub mod providers;
pub mod providers_seed;
pub mod proxy;
pub mod settings;
pub mod skill_deployments;
pub mod skills;
pub mod stream_check;
pub mod universal_providers;
+276
View File
@@ -0,0 +1,276 @@
//! Transactional database half of Pi catalog coordination.
//!
//! Provider row/endpoint SQL remains owned by the certified provider-write
//! primitives. This module only composes those primitives with Pi's exact-key
//! ownership ledger in one SQLite transaction.
use super::pi_projections::PiProviderProjection;
use super::provider_write::{
insert_endpoint, insert_row, restore_provider_aggregate_on_tx, NewEndpoint,
NewProviderAggregate, ProviderKey, ProviderRowUpdate,
};
use super::providers::delete_provider_on_tx;
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::provider::{ProviderAggregate, ProviderMutationInput};
use indexmap::IndexMap;
use rusqlite::params;
impl Database {
pub(crate) fn restore_pi_catalog_snapshot(
&self,
aggregates: &IndexMap<String, ProviderAggregate>,
projections: &[PiProviderProjection],
current_provider: Option<&str>,
) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
tx.execute("DELETE FROM pi_provider_projections", [])
.map_err(|error| AppError::Database(error.to_string()))?;
let current_ids = {
let mut statement = tx
.prepare("SELECT id FROM providers WHERE app_type = 'pi'")
.map_err(|error| AppError::Database(error.to_string()))?;
let ids = statement
.query_map([], |row| row.get::<_, String>(0))
.map_err(|error| AppError::Database(error.to_string()))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| AppError::Database(error.to_string()))?;
ids
};
for provider_id in current_ids
.iter()
.filter(|provider_id| !aggregates.contains_key(provider_id.as_str()))
{
// Only rows created after the snapshot are removed. Updating
// providers which existed in the snapshot preserves dependent
// provider_health history instead of triggering ON DELETE CASCADE.
delete_provider_on_tx(&tx, "pi", provider_id)?;
}
for aggregate in aggregates.values() {
let key = ProviderKey::new("pi", aggregate.provider.id.clone())?;
let mut input = provider_mutation_input(aggregate);
if let Some(meta) = input.meta.as_mut() {
meta.custom_endpoints.clear();
}
let row = ProviderRowUpdate::from_input(&input)?;
let endpoints = aggregate
.endpoints
.values()
.cloned()
.map(NewEndpoint::try_from)
.collect::<Result<Vec<_>, _>>()?;
restore_provider_aggregate_on_tx(
&tx,
&key,
&row,
aggregate.provider.created_at,
aggregate.provider.sort_index,
current_provider == Some(key.id()),
aggregate.provider.in_failover_queue,
&endpoints,
)?;
}
for projection in projections {
tx.execute(
"INSERT INTO pi_provider_projections
(provider_id, provider_key, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4)",
params![
projection.provider_id,
projection.provider_key,
projection.created_at,
projection.updated_at
],
)
.map_err(|error| AppError::Database(error.to_string()))?;
}
tx.commit()
.map_err(|error| AppError::Database(error.to_string()))
}
pub(crate) fn create_pi_catalog_provider(
&self,
input: NewProviderAggregate,
provider_key: &str,
) -> Result<PiProviderProjection, AppError> {
if input.key.app_type() != "pi" || provider_key.trim().is_empty() {
return Err(AppError::InvalidInput(
"Pi catalog create requires app_type=pi and a non-empty native key".to_string(),
));
}
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
insert_row(
&tx,
&input.key,
&input.row.content,
input.row.created_at,
input.sort_index,
false,
input.in_failover_queue,
)?;
for endpoint in &input.initial_endpoints {
insert_endpoint(&tx, &input.key, endpoint)?;
}
let now = chrono::Utc::now().timestamp_millis();
tx.execute(
"INSERT INTO pi_provider_projections
(provider_id, provider_key, created_at, updated_at)
VALUES (?1, ?2, ?3, ?3)",
params![input.key.id(), provider_key, now],
)
.map_err(|error| match &error {
rusqlite::Error::SqliteFailure(code, _)
if matches!(
code.extended_code,
rusqlite::ffi::SQLITE_CONSTRAINT_PRIMARYKEY
| rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE
) =>
{
AppError::Conflict(format!(
"Pi native provider key '{provider_key}' is already claimed"
))
}
_ => AppError::Database(error.to_string()),
})?;
tx.commit()
.map_err(|error| AppError::Database(error.to_string()))?;
Ok(PiProviderProjection {
provider_id: input.key.id().to_string(),
provider_key: provider_key.to_string(),
created_at: now,
updated_at: now,
})
}
pub(crate) fn update_pi_catalog_provider(
&self,
key: &ProviderKey,
row: &ProviderRowUpdate,
) -> Result<(), AppError> {
if key.app_type() != "pi" {
return Err(AppError::InvalidInput(
"Pi catalog update requires app_type=pi".to_string(),
));
}
self.update_provider(key, row)
}
pub(crate) fn delete_pi_catalog_provider(
&self,
provider_id: &str,
) -> Result<Option<PiProviderProjection>, AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
let projection = tx
.query_row(
"SELECT provider_id, provider_key, created_at, updated_at
FROM pi_provider_projections
WHERE provider_id = ?1",
[provider_id],
|row| {
Ok(PiProviderProjection {
provider_id: row.get(0)?,
provider_key: row.get(1)?,
created_at: row.get(2)?,
updated_at: row.get(3)?,
})
},
)
.optional()
.map_err(|error| AppError::Database(error.to_string()))?;
delete_provider_on_tx(&tx, "pi", provider_id)?;
tx.execute(
"DELETE FROM pi_provider_projections WHERE provider_id = ?1",
[provider_id],
)
.map_err(|error| AppError::Database(error.to_string()))?;
tx.commit()
.map_err(|error| AppError::Database(error.to_string()))?;
Ok(projection)
}
pub(crate) fn restore_pi_catalog_provider(
&self,
aggregate: &ProviderAggregate,
was_current: bool,
projection: Option<&PiProviderProjection>,
) -> Result<(), AppError> {
let key = ProviderKey::new("pi", aggregate.provider.id.clone())?;
let mut input = provider_mutation_input(aggregate);
if let Some(meta) = input.meta.as_mut() {
meta.custom_endpoints.clear();
}
let row = ProviderRowUpdate::from_input(&input)?;
let endpoints = aggregate
.endpoints
.values()
.cloned()
.map(NewEndpoint::try_from)
.collect::<Result<Vec<_>, _>>()?;
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
restore_provider_aggregate_on_tx(
&tx,
&key,
&row,
aggregate.provider.created_at,
aggregate.provider.sort_index,
was_current,
aggregate.provider.in_failover_queue,
&endpoints,
)?;
tx.execute(
"DELETE FROM pi_provider_projections WHERE provider_id = ?1",
[key.id()],
)
.map_err(|error| AppError::Database(error.to_string()))?;
if let Some(projection) = projection {
tx.execute(
"INSERT INTO pi_provider_projections
(provider_id, provider_key, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4)",
params![
projection.provider_id,
projection.provider_key,
projection.created_at,
projection.updated_at
],
)
.map_err(|error| AppError::Database(error.to_string()))?;
}
tx.commit()
.map_err(|error| AppError::Database(error.to_string()))
}
}
fn provider_mutation_input(aggregate: &ProviderAggregate) -> ProviderMutationInput {
let provider = &aggregate.provider;
ProviderMutationInput {
id: provider.id.clone(),
name: provider.name.clone(),
settings_config: provider.settings_config.clone(),
website_url: provider.website_url.clone(),
category: provider.category.clone(),
created_at: provider.created_at,
sort_index: provider.sort_index,
notes: provider.notes.clone(),
meta: provider.meta.clone(),
icon: provider.icon.clone(),
icon_color: provider.icon_color.clone(),
in_failover_queue: provider.in_failover_queue,
}
}
use rusqlite::OptionalExtension;
@@ -0,0 +1,204 @@
//! Device-local ownership ledger for exact keys in Pi's shared models.json.
// The projection writer is introduced in a later contract-ordered commit.
#![allow(dead_code)]
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use indexmap::IndexMap;
use rusqlite::{params, OptionalExtension};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PiProviderProjection {
pub provider_id: String,
pub provider_key: String,
pub created_at: i64,
pub updated_at: i64,
}
fn decode_projection(row: &rusqlite::Row<'_>) -> rusqlite::Result<PiProviderProjection> {
Ok(PiProviderProjection {
provider_id: row.get(0)?,
provider_key: row.get(1)?,
created_at: row.get(2)?,
updated_at: row.get(3)?,
})
}
impl Database {
pub(crate) fn get_pi_projection(
&self,
provider_id: &str,
) -> Result<Option<PiProviderProjection>, AppError> {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT provider_id, provider_key, created_at, updated_at
FROM pi_provider_projections WHERE provider_id = ?1",
[provider_id],
decode_projection,
)
.optional()
.map_err(|error| AppError::Database(error.to_string()))
}
pub(crate) fn get_pi_projection_for_key(
&self,
provider_key: &str,
) -> Result<Option<PiProviderProjection>, AppError> {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT provider_id, provider_key, created_at, updated_at
FROM pi_provider_projections WHERE provider_key = ?1",
[provider_key],
decode_projection,
)
.optional()
.map_err(|error| AppError::Database(error.to_string()))
}
pub(crate) fn get_pi_projection_manifest(
&self,
) -> Result<IndexMap<String, PiProviderProjection>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT provider_id, provider_key, created_at, updated_at
FROM pi_provider_projections ORDER BY provider_id",
)
.map_err(|error| AppError::Database(error.to_string()))?;
let rows = stmt
.query_map([], decode_projection)
.map_err(|error| AppError::Database(error.to_string()))?;
let mut manifest = IndexMap::new();
for row in rows {
let projection = row.map_err(|error| AppError::Database(error.to_string()))?;
manifest.insert(projection.provider_id.clone(), projection);
}
Ok(manifest)
}
/// Claim an exact key. Existing exact claims are idempotent; either-side
/// collisions fail and are never rewritten.
pub(crate) fn claim_pi_projection_key(
&self,
provider_id: &str,
provider_key: &str,
) -> Result<PiProviderProjection, AppError> {
if provider_id.trim().is_empty() || provider_key.trim().is_empty() {
return Err(AppError::Config(
"Pi projection provider id and key must be non-empty".to_string(),
));
}
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
let by_provider = tx
.query_row(
"SELECT provider_id, provider_key, created_at, updated_at
FROM pi_provider_projections WHERE provider_id = ?1",
[provider_id],
decode_projection,
)
.optional()
.map_err(|error| AppError::Database(error.to_string()))?;
if let Some(existing) = by_provider {
if existing.provider_key != provider_key {
return Err(AppError::Config(format!(
"Pi provider '{provider_id}' already owns key '{}', not '{provider_key}'",
existing.provider_key
)));
}
tx.commit()
.map_err(|error| AppError::Database(error.to_string()))?;
return Ok(existing);
}
if let Some(existing_owner) = tx
.query_row(
"SELECT provider_id FROM pi_provider_projections WHERE provider_key = ?1",
[provider_key],
|row| row.get::<_, String>(0),
)
.optional()
.map_err(|error| AppError::Database(error.to_string()))?
{
return Err(AppError::Config(format!(
"Pi key '{provider_key}' is already owned by provider '{existing_owner}'"
)));
}
let now = chrono::Utc::now().timestamp_millis();
tx.execute(
"INSERT INTO pi_provider_projections
(provider_id, provider_key, created_at, updated_at)
VALUES (?1, ?2, ?3, ?3)",
params![provider_id, provider_key, now],
)
.map_err(|error| AppError::Database(error.to_string()))?;
tx.commit()
.map_err(|error| AppError::Database(error.to_string()))?;
Ok(PiProviderProjection {
provider_id: provider_id.to_string(),
provider_key: provider_key.to_string(),
created_at: now,
updated_at: now,
})
}
pub(crate) fn delete_pi_projection_key(
&self,
provider_id: &str,
expected_key: &str,
) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let removed = conn
.execute(
"DELETE FROM pi_provider_projections
WHERE provider_id = ?1 AND provider_key = ?2",
params![provider_id, expected_key],
)
.map_err(|error| AppError::Database(error.to_string()))?;
if removed == 0
&& conn
.query_row(
"SELECT 1 FROM pi_provider_projections WHERE provider_id = ?1",
[provider_id],
|_| Ok(()),
)
.optional()
.map_err(|error| AppError::Database(error.to_string()))?
.is_some()
{
return Err(AppError::Config(format!(
"refusing to delete Pi projection '{provider_id}': expected key changed"
)));
}
Ok(removed == 1)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn projection_claims_are_exact_idempotent_and_collision_safe() -> Result<(), AppError> {
let db = Database::memory()?;
let first = db.claim_pi_projection_key("provider-a", "native-a")?;
let repeated = db.claim_pi_projection_key("provider-a", "native-a")?;
assert_eq!(first, repeated);
assert!(db
.claim_pi_projection_key("provider-a", "native-b")
.is_err());
assert!(db
.claim_pi_projection_key("provider-b", "native-a")
.is_err());
assert_eq!(db.get_pi_projection_manifest()?.len(), 1);
assert!(db.delete_pi_projection_key("provider-a", "wrong").is_err());
assert!(db.get_pi_projection("provider-a")?.is_some());
assert!(db.delete_pi_projection_key("provider-a", "native-a")?);
assert!(db.get_pi_projection_for_key("native-a")?.is_none());
Ok(())
}
}
+164 -40
View File
@@ -6,51 +6,106 @@ use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::prompt::Prompt;
use indexmap::IndexMap;
use rusqlite::params;
use rusqlite::{params, Connection, Transaction};
fn query_prompts(conn: &Connection, app_type: &str) -> Result<IndexMap<String, Prompt>, AppError> {
let mut stmt = conn
.prepare(
"SELECT id, name, content, description, enabled, created_at, updated_at
FROM prompts WHERE app_type = ?1
ORDER BY created_at ASC, id ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
let prompt_iter = stmt
.query_map(params![app_type], |row| {
let id: String = row.get(0)?;
let name: String = row.get(1)?;
let content: String = row.get(2)?;
let description: Option<String> = row.get(3)?;
let enabled: bool = row.get(4)?;
let created_at: Option<i64> = row.get(5)?;
let updated_at: Option<i64> = row.get(6)?;
Ok((
id.clone(),
Prompt {
id,
name,
content,
description,
enabled,
created_at,
updated_at,
},
))
})
.map_err(|e| AppError::Database(e.to_string()))?;
let mut prompts = IndexMap::new();
for prompt_res in prompt_iter {
let (id, prompt) = prompt_res.map_err(|e| AppError::Database(e.to_string()))?;
prompts.insert(id, prompt);
}
Ok(prompts)
}
fn validate_prompt_selection(prompts: &IndexMap<String, Prompt>) -> Result<(), AppError> {
if prompts.values().filter(|prompt| prompt.enabled).count() > 1 {
return Err(AppError::InvalidInput(
"at most one prompt may be enabled for an app".to_string(),
));
}
Ok(())
}
fn replace_prompt_rows(
transaction: &Transaction<'_>,
app_type: &str,
prompts: &IndexMap<String, Prompt>,
) -> Result<(), AppError> {
transaction
.execute("DELETE FROM prompts WHERE app_type = ?1", [app_type])
.map_err(|error| AppError::Database(error.to_string()))?;
let mut statement = transaction
.prepare(
"INSERT OR REPLACE INTO prompts (
id, app_type, name, content, description, enabled, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
)
.map_err(|error| AppError::Database(error.to_string()))?;
for prompt in prompts.values() {
statement
.execute(params![
prompt.id,
app_type,
prompt.name,
prompt.content,
prompt.description,
prompt.enabled,
prompt.created_at,
prompt.updated_at,
])
.map_err(|error| AppError::Database(error.to_string()))?;
}
Ok(())
}
fn prompt_libraries_equal(
left: &IndexMap<String, Prompt>,
right: &IndexMap<String, Prompt>,
) -> bool {
left.len() == right.len()
&& left
.iter()
.all(|(id, prompt)| right.get(id) == Some(prompt))
}
impl Database {
/// 获取指定应用类型的所有提示词
pub fn get_prompts(&self, app_type: &str) -> Result<IndexMap<String, Prompt>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT id, name, content, description, enabled, created_at, updated_at
FROM prompts WHERE app_type = ?1
ORDER BY created_at ASC, id ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
let prompt_iter = stmt
.query_map(params![app_type], |row| {
let id: String = row.get(0)?;
let name: String = row.get(1)?;
let content: String = row.get(2)?;
let description: Option<String> = row.get(3)?;
let enabled: bool = row.get(4)?;
let created_at: Option<i64> = row.get(5)?;
let updated_at: Option<i64> = row.get(6)?;
Ok((
id.clone(),
Prompt {
id,
name,
content,
description,
enabled,
created_at,
updated_at,
},
))
})
.map_err(|e| AppError::Database(e.to_string()))?;
let mut prompts = IndexMap::new();
for prompt_res in prompt_iter {
let (id, prompt) = prompt_res.map_err(|e| AppError::Database(e.to_string()))?;
prompts.insert(id, prompt);
}
Ok(prompts)
query_prompts(&conn, app_type)
}
/// 保存提示词
@@ -75,6 +130,75 @@ impl Database {
Ok(())
}
/// Persist a complete prompt-library selection atomically.
///
/// Pi projects the single enabled row into AGENTS.md. A sequence of
/// individual `save_prompt` calls can expose two enabled rows (or none) to
/// concurrent readers, so selection changes use one SQLite transaction.
pub(crate) fn save_prompt_selection(
&self,
app_type: &str,
prompts: &IndexMap<String, Prompt>,
) -> Result<(), AppError> {
validate_prompt_selection(prompts)?;
let mut conn = lock_conn!(self.conn);
let transaction = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
replace_prompt_rows(&transaction, app_type, prompts)?;
transaction
.commit()
.map_err(|error| AppError::Database(error.to_string()))
}
/// Atomically publish a complete prompt library only while its full
/// before-image still matches. This is the database half of Pi's
/// native-file/portable-library compare-and-swap boundary.
pub(crate) fn compare_exchange_prompt_selection(
&self,
app_type: &str,
expected: &IndexMap<String, Prompt>,
replacement: &IndexMap<String, Prompt>,
) -> Result<(), AppError> {
validate_prompt_selection(replacement)?;
self.compare_exchange_prompt_selection_unchecked(app_type, expected, replacement)
}
/// Restore a captured before-image only if the database still contains the
/// exact attempted projection. The before-image may predate the current
/// single-selection invariant, so compensation must preserve it byte for
/// byte instead of refusing to restore legacy rows.
pub(crate) fn restore_prompt_selection_if_attempted(
&self,
app_type: &str,
attempted: &IndexMap<String, Prompt>,
before: &IndexMap<String, Prompt>,
) -> Result<(), AppError> {
self.compare_exchange_prompt_selection_unchecked(app_type, attempted, before)
}
fn compare_exchange_prompt_selection_unchecked(
&self,
app_type: &str,
expected: &IndexMap<String, Prompt>,
replacement: &IndexMap<String, Prompt>,
) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let transaction = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
let observed = query_prompts(&transaction, app_type)?;
if !prompt_libraries_equal(&observed, expected) {
return Err(AppError::Conflict(format!(
"{app_type} prompt library changed since it was read"
)));
}
replace_prompt_rows(&transaction, app_type, replacement)?;
transaction
.commit()
.map_err(|error| AppError::Database(error.to_string()))
}
/// 删除提示词
pub fn delete_prompt(&self, app_type: &str, id: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
+52 -37
View File
@@ -37,14 +37,14 @@ impl ProviderKey {
#[derive(Debug, Clone)]
pub struct ProviderRowUpdate {
name: String,
settings_config: Value,
website_url: Option<String>,
category: Option<String>,
notes: Option<String>,
meta: ProviderMeta,
icon: Option<String>,
icon_color: Option<String>,
pub(super) name: String,
pub(super) settings_config: Value,
pub(super) website_url: Option<String>,
pub(super) category: Option<String>,
pub(super) notes: Option<String>,
pub(super) meta: ProviderMeta,
pub(super) icon: Option<String>,
pub(super) icon_color: Option<String>,
}
impl ProviderRowUpdate {
@@ -71,15 +71,15 @@ impl ProviderRowUpdate {
#[derive(Debug, Clone)]
pub struct ProviderRowCreate {
content: ProviderRowUpdate,
created_at: Option<i64>,
pub(super) content: ProviderRowUpdate,
pub(super) created_at: Option<i64>,
}
#[derive(Debug, Clone)]
pub struct NewEndpoint {
url: String,
added_at: Option<i64>,
last_used: Option<i64>,
pub(super) url: String,
pub(super) added_at: Option<i64>,
pub(super) last_used: Option<i64>,
}
impl NewEndpoint {
@@ -116,11 +116,11 @@ impl TryFrom<CustomEndpoint> for NewEndpoint {
#[derive(Debug, Clone)]
pub struct NewProviderAggregate {
key: ProviderKey,
row: ProviderRowCreate,
sort_index: Option<usize>,
in_failover_queue: bool,
initial_endpoints: Vec<NewEndpoint>,
pub(super) key: ProviderKey,
pub(super) row: ProviderRowCreate,
pub(super) sort_index: Option<usize>,
pub(super) in_failover_queue: bool,
pub(super) initial_endpoints: Vec<NewEndpoint>,
}
impl NewProviderAggregate {
@@ -219,7 +219,7 @@ fn encode_row(row: &ProviderRowUpdate) -> Result<(String, String), AppError> {
Ok((settings_config, meta))
}
fn insert_row(
pub(super) fn insert_row(
tx: &Transaction<'_>,
key: &ProviderKey,
row: &ProviderRowUpdate,
@@ -272,7 +272,7 @@ fn insert_row(
Ok(())
}
fn insert_endpoint(
pub(super) fn insert_endpoint(
tx: &Transaction<'_>,
key: &ProviderKey,
endpoint: &NewEndpoint,
@@ -357,7 +357,7 @@ pub(super) fn restore_provider_aggregate_on_tx(
Ok(())
}
fn update_row(
pub(super) fn update_row(
tx: &Transaction<'_>,
key: &ProviderKey,
row: &ProviderRowUpdate,
@@ -618,23 +618,38 @@ impl Database {
pub(crate) fn update_provider_sort_index(
&self,
key: &ProviderKey,
sort_index: usize,
updates: &[(ProviderKey, usize)],
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
if conn
.execute(
"UPDATE providers SET sort_index = ?1 WHERE id = ?2 AND app_type = ?3",
params![sort_index, key.id, key.app_type],
)
.map_err(|error| AppError::Database(error.to_string()))?
!= 1
{
return Err(AppError::NotFound(format!(
"provider '{}/{}'",
key.app_type, key.id
)));
let mut seen = std::collections::HashSet::with_capacity(updates.len());
for (key, _) in updates {
if !seen.insert((key.app_type().to_string(), key.id().to_string())) {
return Err(AppError::InvalidInput(format!(
"duplicate provider sort update for '{}/{}'",
key.app_type(),
key.id()
)));
}
}
Ok(())
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
for (key, sort_index) in updates {
if tx
.execute(
"UPDATE providers SET sort_index = ?1 WHERE id = ?2 AND app_type = ?3",
params![sort_index, key.id, key.app_type],
)
.map_err(|error| AppError::Database(error.to_string()))?
!= 1
{
return Err(AppError::NotFound(format!(
"provider '{}/{}'",
key.app_type, key.id
)));
}
}
tx.commit()
.map_err(|error| AppError::Database(error.to_string()))
}
}
+28
View File
@@ -6,6 +6,24 @@ use indexmap::IndexMap;
use rusqlite::{params, OptionalExtension, Row};
use std::collections::{HashMap, HashSet};
pub(super) fn delete_provider_on_tx(
tx: &rusqlite::Transaction<'_>,
app_type: &str,
id: &str,
) -> Result<(), AppError> {
if tx
.execute(
"DELETE FROM providers WHERE id = ?1 AND app_type = ?2",
params![id, app_type],
)
.map_err(|error| AppError::Database(error.to_string()))?
!= 1
{
return Err(AppError::NotFound(format!("provider '{app_type}/{id}'")));
}
Ok(())
}
pub(super) struct StoredProviderRow {
id: String,
name: String,
@@ -295,6 +313,16 @@ impl Database {
Ok(())
}
pub(crate) fn clear_current_provider_for_app(&self, app_type: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE providers SET is_current = 0 WHERE app_type = ?1",
params![app_type],
)
.map_err(|error| AppError::Database(error.to_string()))?;
Ok(())
}
pub fn set_omo_provider_current(
&self,
app_type: &str,
@@ -0,0 +1,290 @@
//! Device-local evidence for Pi Skill deployments.
// Pi skill reconciliation consumes this ledger in a later contract-ordered commit.
#![allow(dead_code)]
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use rusqlite::{params, OptionalExtension};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum SkillDeploymentMethod {
Symlink,
Copy,
}
impl SkillDeploymentMethod {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Symlink => "symlink",
Self::Copy => "copy",
}
}
}
impl FromStr for SkillDeploymentMethod {
type Err = AppError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"symlink" => Ok(Self::Symlink),
"copy" => Ok(Self::Copy),
_ => Err(AppError::Database(format!(
"unknown Pi Skill deployment method '{value}'"
))),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SkillDeployment {
pub skill_id: String,
pub destination: String,
pub destination_key: String,
pub method: SkillDeploymentMethod,
pub source_identity: String,
pub deployed_digest: Option<String>,
pub created_at: i64,
pub updated_at: i64,
}
fn decode_deployment(row: &rusqlite::Row<'_>) -> rusqlite::Result<SkillDeployment> {
let method: String = row.get(3)?;
let method = method.parse().map_err(|error: AppError| {
rusqlite::Error::FromSqlConversionFailure(3, rusqlite::types::Type::Text, Box::new(error))
})?;
Ok(SkillDeployment {
skill_id: row.get(0)?,
destination: row.get(1)?,
destination_key: row.get(2)?,
method,
source_identity: row.get(4)?,
deployed_digest: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
})
}
impl Database {
pub(crate) fn set_pi_skill_desired(
&self,
skill_id: &str,
desired_enabled: bool,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
let changed = conn
.execute(
"UPDATE skills SET enabled_pi = ?1 WHERE id = ?2",
params![desired_enabled, skill_id],
)
.map_err(|error| AppError::Database(error.to_string()))?;
if changed != 1 {
return Err(AppError::Conflict(format!(
"Pi Skill '{skill_id}' disappeared before desired state was saved"
)));
}
Ok(())
}
pub(crate) fn get_pi_skill_deployment(
&self,
skill_id: &str,
destination_key: &str,
) -> Result<Option<SkillDeployment>, AppError> {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT skill_id, destination, destination_key, method,
source_identity, deployed_digest, created_at, updated_at
FROM skill_deployments
WHERE app_type = 'pi' AND skill_id = ?1 AND destination_key = ?2",
params![skill_id, destination_key],
decode_deployment,
)
.optional()
.map_err(|error| AppError::Database(error.to_string()))
}
pub(crate) fn get_pi_skill_deployments(
&self,
skill_id: &str,
) -> Result<Vec<SkillDeployment>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT skill_id, destination, destination_key, method,
source_identity, deployed_digest, created_at, updated_at
FROM skill_deployments
WHERE app_type = 'pi' AND skill_id = ?1
ORDER BY created_at, destination_key",
)
.map_err(|error| AppError::Database(error.to_string()))?;
let rows = stmt
.query_map([skill_id], decode_deployment)
.map_err(|error| AppError::Database(error.to_string()))?;
rows.map(|row| row.map_err(|error| AppError::Database(error.to_string())))
.collect()
}
pub(crate) fn save_pi_skill_deployment(
&self,
deployment: &SkillDeployment,
) -> Result<(), AppError> {
self.save_pi_skill_deployment_with_desired(deployment, None)
}
/// Commit ledger evidence and, for a user toggle, the desired Pi bit in
/// the same SQLite transaction. Filesystem publication happens before
/// this point; a failed transaction is therefore safe to compensate by
/// restoring the staged destination without exposing split DB authority.
pub(crate) fn save_pi_skill_deployment_with_desired(
&self,
deployment: &SkillDeployment,
desired_enabled: Option<bool>,
) -> Result<(), AppError> {
if deployment.skill_id.trim().is_empty()
|| deployment.destination.trim().is_empty()
|| deployment.destination_key.trim().is_empty()
|| deployment.source_identity.trim().is_empty()
{
return Err(AppError::Config(
"Pi Skill deployment identity fields must be non-empty".to_string(),
));
}
let mut conn = lock_conn!(self.conn);
let transaction = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
transaction
.execute(
"INSERT INTO skill_deployments (
app_type, skill_id, destination, destination_key, method,
source_identity, deployed_digest, created_at, updated_at
) VALUES ('pi', ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(app_type, skill_id, destination_key) DO UPDATE SET
destination = excluded.destination,
method = excluded.method,
source_identity = excluded.source_identity,
deployed_digest = excluded.deployed_digest,
updated_at = excluded.updated_at",
params![
deployment.skill_id,
deployment.destination,
deployment.destination_key,
deployment.method.as_str(),
deployment.source_identity,
deployment.deployed_digest,
deployment.created_at,
deployment.updated_at,
],
)
.map_err(|error| AppError::Database(error.to_string()))?;
if let Some(desired_enabled) = desired_enabled {
let changed = transaction
.execute(
"UPDATE skills SET enabled_pi = ?1 WHERE id = ?2",
params![desired_enabled, deployment.skill_id],
)
.map_err(|error| AppError::Database(error.to_string()))?;
if changed != 1 {
return Err(AppError::Conflict(format!(
"Pi Skill '{}' disappeared before deployment commit",
deployment.skill_id
)));
}
}
transaction
.commit()
.map_err(|error| AppError::Database(error.to_string()))
}
pub(crate) fn delete_pi_skill_deployment(
&self,
skill_id: &str,
destination_key: &str,
) -> Result<bool, AppError> {
self.delete_pi_skill_deployment_with_desired(skill_id, destination_key, None)
}
pub(crate) fn delete_pi_skill_deployment_with_desired(
&self,
skill_id: &str,
destination_key: &str,
desired_enabled: Option<bool>,
) -> Result<bool, AppError> {
let mut conn = lock_conn!(self.conn);
let transaction = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
if let Some(desired_enabled) = desired_enabled {
let changed = transaction
.execute(
"UPDATE skills SET enabled_pi = ?1 WHERE id = ?2",
params![desired_enabled, skill_id],
)
.map_err(|error| AppError::Database(error.to_string()))?;
if changed != 1 {
return Err(AppError::Conflict(format!(
"Pi Skill '{skill_id}' disappeared before deployment cleanup"
)));
}
}
let removed = transaction
.execute(
"DELETE FROM skill_deployments
WHERE app_type = 'pi' AND skill_id = ?1 AND destination_key = ?2",
params![skill_id, destination_key],
)
.map_err(|error| AppError::Database(error.to_string()))?
== 1;
transaction
.commit()
.map_err(|error| AppError::Database(error.to_string()))?;
Ok(removed)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn deployment(skill_id: &str, destination_key: &str) -> SkillDeployment {
SkillDeployment {
skill_id: skill_id.into(),
destination: format!("/tmp/{destination_key}"),
destination_key: destination_key.into(),
method: SkillDeploymentMethod::Copy,
source_identity: format!("source:{skill_id}"),
deployed_digest: Some("sha256:initial".into()),
created_at: 10,
updated_at: 10,
}
}
#[test]
fn skill_ledger_preserves_created_at_and_rejects_destination_collision() -> Result<(), AppError>
{
let db = Database::memory()?;
db.save_pi_skill_deployment(&deployment("one", "destination"))?;
let mut updated = deployment("one", "destination");
updated.updated_at = 20;
updated.deployed_digest = Some("sha256:updated".into());
db.save_pi_skill_deployment(&updated)?;
let saved = db
.get_pi_skill_deployment("one", "destination")?
.expect("deployment");
assert_eq!(saved.created_at, 10);
assert_eq!(saved.updated_at, 20);
assert_eq!(saved.deployed_digest.as_deref(), Some("sha256:updated"));
assert!(db
.save_pi_skill_deployment(&deployment("two", "destination"))
.is_err());
assert_eq!(db.get_pi_skill_deployments("one")?.len(), 1);
assert!(db.delete_pi_skill_deployment("one", "destination")?);
Ok(())
}
}
+84 -13
View File
@@ -23,7 +23,8 @@ impl Database {
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild,
enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at
enabled_opencode, enabled_hermes, enabled_pi,
installed_at, content_hash, updated_at
FROM skills ORDER BY name ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -46,10 +47,11 @@ impl Database {
grokbuild: row.get(11)?,
opencode: row.get(12)?,
hermes: row.get(13)?,
pi: row.get(14)?,
},
installed_at: row.get(14)?,
content_hash: row.get(15)?,
updated_at: row.get::<_, i64>(16).unwrap_or(0),
installed_at: row.get(15)?,
content_hash: row.get(16)?,
updated_at: row.get::<_, i64>(17).unwrap_or(0),
})
})
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -69,7 +71,8 @@ impl Database {
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild,
enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at
enabled_opencode, enabled_hermes, enabled_pi,
installed_at, content_hash, updated_at
FROM skills WHERE id = ?1",
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -91,10 +94,11 @@ impl Database {
grokbuild: row.get(11)?,
opencode: row.get(12)?,
hermes: row.get(13)?,
pi: row.get(14)?,
},
installed_at: row.get(14)?,
content_hash: row.get(15)?,
updated_at: row.get::<_, i64>(16).unwrap_or(0),
installed_at: row.get(15)?,
content_hash: row.get(16)?,
updated_at: row.get::<_, i64>(17).unwrap_or(0),
})
});
@@ -109,11 +113,28 @@ impl Database {
pub fn save_skill(&self, skill: &InstalledSkill) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT OR REPLACE INTO skills
"INSERT INTO skills
(id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes,
installed_at, content_hash, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
enabled_pi, installed_at, content_hash, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
directory = excluded.directory,
repo_owner = excluded.repo_owner,
repo_name = excluded.repo_name,
repo_branch = excluded.repo_branch,
readme_url = excluded.readme_url,
enabled_claude = excluded.enabled_claude,
enabled_codex = excluded.enabled_codex,
enabled_gemini = excluded.enabled_gemini,
enabled_grokbuild = excluded.enabled_grokbuild,
enabled_opencode = excluded.enabled_opencode,
enabled_hermes = excluded.enabled_hermes,
installed_at = excluded.installed_at,
content_hash = excluded.content_hash,
updated_at = excluded.updated_at",
params![
skill.id,
skill.name,
@@ -129,6 +150,7 @@ impl Database {
skill.apps.grokbuild,
skill.apps.opencode,
skill.apps.hermes,
skill.apps.pi,
skill.installed_at,
skill.content_hash,
skill.updated_at,
@@ -160,8 +182,8 @@ impl Database {
let conn = lock_conn!(self.conn);
let affected = conn
.execute(
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_grokbuild = ?4, enabled_opencode = ?5, enabled_hermes = ?6 WHERE id = ?7",
params![apps.claude, apps.codex, apps.gemini, apps.grokbuild, apps.opencode, apps.hermes, id],
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_grokbuild = ?4, enabled_opencode = ?5, enabled_hermes = ?6, enabled_pi = ?7 WHERE id = ?8",
params![apps.claude, apps.codex, apps.gemini, apps.grokbuild, apps.opencode, apps.hermes, apps.pi, id],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0)
@@ -262,3 +284,52 @@ impl Database {
Ok(count)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn installed_skill() -> InstalledSkill {
InstalledSkill {
id: "owner/repo:skill".into(),
name: "Skill".into(),
description: Some("before".into()),
directory: "skill".into(),
repo_owner: Some("owner".into()),
repo_name: Some("repo".into()),
repo_branch: Some("main".into()),
readme_url: None,
apps: SkillApps::default(),
installed_at: 10,
content_hash: Some("sha256:before".into()),
updated_at: 11,
}
}
#[test]
fn legacy_skill_save_preserves_pi_desired_state() -> Result<(), AppError> {
let db = Database::memory()?;
let mut skill = installed_skill();
db.save_skill(&skill)?;
{
let conn = lock_conn!(db.conn);
conn.execute(
"UPDATE skills SET enabled_pi = 1 WHERE id = ?1",
[&skill.id],
)?;
}
skill.name = "Updated".into();
skill.content_hash = Some("sha256:after".into());
db.save_skill(&skill)?;
let conn = lock_conn!(db.conn);
let saved: (String, String, bool) = conn.query_row(
"SELECT name, content_hash, enabled_pi FROM skills WHERE id = ?1",
[&skill.id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
assert_eq!(saved, ("Updated".into(), "sha256:after".into(), true));
Ok(())
}
}
+2
View File
@@ -32,6 +32,7 @@ mod schema;
mod tests;
// DAO 类型导出供外部使用
pub(crate) use dao::pi_projections::PiProviderProjection;
pub use dao::provider_write::{
NewEndpoint, NewProviderAggregate, ProviderKey, ProviderRowUpdate, RenameProvider,
};
@@ -43,6 +44,7 @@ pub(crate) use dao::proxy::{
validate_cost_multiplier, validate_pricing_source, PRICING_SOURCE_REQUEST,
PRICING_SOURCE_RESPONSE,
};
pub(crate) use dao::skill_deployments::{SkillDeployment, SkillDeploymentMethod};
pub use dao::FailoverQueueItem;
pub use dao::Profile;
+4
View File
@@ -66,6 +66,10 @@ pub struct DeepLinkImportRequest {
/// Optional model name
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Native API identifier. Pi provider links require this explicitly;
/// cc-switch never infers a protocol from a URL or model name.
#[serde(skip_serializing_if = "Option::is_none")]
pub api: Option<String>,
/// Optional notes/description
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
+27 -4
View File
@@ -81,10 +81,10 @@ fn parse_provider_deeplink(
// Validate app type
if !matches!(
app.as_str(),
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes"
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes" | "pi"
) {
return Err(AppError::InvalidInput(format!(
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{app}'"
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', 'hermes', or 'pi', got '{app}'"
)));
}
@@ -116,6 +116,7 @@ fn parse_provider_deeplink(
// Extract optional fields
let model = params.get("model").cloned();
let api = params.get("api").cloned();
let notes = params.get("notes").cloned();
let haiku_model = params.get("haikuModel").cloned();
let sonnet_model = params.get("sonnetModel").cloned();
@@ -127,6 +128,24 @@ fn parse_provider_deeplink(
let config = params.get("config").cloned();
let config_format = params.get("configFormat").cloned();
let config_url = params.get("configUrl").cloned();
if app == "pi" {
if model.as_deref().is_none_or(|value| value.trim().is_empty()) {
return Err(AppError::InvalidInput(
"Pi provider deep links require a non-empty 'model' parameter".to_string(),
));
}
if api.as_deref().is_none_or(|value| value.trim().is_empty()) {
return Err(AppError::InvalidInput(
"Pi provider deep links require an explicit non-empty 'api' parameter".to_string(),
));
}
if config.is_some() || config_url.is_some() {
return Err(AppError::InvalidInput(
"Pi provider deep links use explicit endpoint/api/model fields; embedded or remote config payloads are not supported"
.to_string(),
));
}
}
let enabled = params.get("enabled").and_then(|v| v.parse::<bool>().ok());
// Extract usage script fields (v3.9+)
@@ -153,6 +172,7 @@ fn parse_provider_deeplink(
api_key,
icon,
model,
api,
notes,
haiku_model,
sonnet_model,
@@ -190,10 +210,10 @@ fn parse_prompt_deeplink(
// Validate app type
if !matches!(
app.as_str(),
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes"
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes" | "pi"
) {
return Err(AppError::InvalidInput(format!(
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{app}'"
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', 'hermes', or 'pi', got '{app}'"
)));
}
@@ -225,6 +245,7 @@ fn parse_prompt_deeplink(
endpoint: None,
api_key: None,
model: None,
api: None,
notes: None,
haiku_model: None,
sonnet_model: None,
@@ -298,6 +319,7 @@ fn parse_mcp_deeplink(
endpoint: None,
api_key: None,
model: None,
api: None,
notes: None,
haiku_model: None,
sonnet_model: None,
@@ -353,6 +375,7 @@ fn parse_skill_deeplink(
endpoint: None,
api_key: None,
model: None,
api: None,
notes: None,
haiku_model: None,
sonnet_model: None,
+40
View File
@@ -160,6 +160,7 @@ pub(crate) fn build_provider_from_request(
AppType::OpenCode => build_opencode_settings(request),
AppType::OpenClaw => build_additive_app_settings(request),
AppType::Hermes => build_hermes_settings(request),
AppType::Pi => build_pi_settings(request)?,
};
// Build usage script configuration if provided
@@ -591,6 +592,45 @@ fn build_hermes_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
json!(config)
}
/// Pi deep links intentionally carry one explicit model, endpoint and native
/// API identifier. Map only that closed subset; richer Pi catalogs use native
/// inspection/import or the Pi editor. No URL/model heuristic may invent the
/// protocol or model identity.
fn build_pi_settings(request: &DeepLinkImportRequest) -> Result<serde_json::Value, AppError> {
let endpoint = get_primary_endpoint(request);
let model = request
.model
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::InvalidInput(
"Pi provider deep links require a non-empty model identifier".to_string(),
)
})?;
let api = request
.api
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::InvalidInput(
"Pi provider deep links require an explicit API identifier".to_string(),
)
})?;
Ok(json!({
"name": request.name,
"baseUrl": endpoint,
"apiKey": request.api_key,
"api": api,
"models": [{
"id": model,
"name": model
}]
}))
}
// =============================================================================
// Config Merge Logic
// =============================================================================
+65
View File
@@ -89,6 +89,61 @@ fn test_parse_deeplink_with_notes() {
assert_eq!(request.notes, Some("Test notes".to_string()));
}
#[test]
fn pi_provider_deeplink_requires_and_preserves_explicit_native_identity() {
use super::provider::build_provider_from_request;
let request = parse_deeplink_url(
"ccswitch://v1/import?resource=provider&app=pi&name=Pi%20Provider&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&apiKey=sk-test&model=opaque-model&api=future-native-api",
)
.expect("parse explicit Pi provider link");
assert_eq!(request.app.as_deref(), Some("pi"));
assert_eq!(request.api.as_deref(), Some("future-native-api"));
assert_eq!(request.model.as_deref(), Some("opaque-model"));
let provider = build_provider_from_request(&AppType::Pi, &request).expect("build Pi provider");
assert_eq!(
provider.settings_config,
serde_json::json!({
"name": "Pi Provider",
"baseUrl": "https://api.example.com/v1",
"apiKey": "sk-test",
"api": "future-native-api",
"models": [{
"id": "opaque-model",
"name": "opaque-model"
}]
}),
"deeplinks must not invent a model, protocol, capability, pricing, or limit field"
);
}
#[test]
fn pi_provider_deeplink_rejects_implicit_model_or_protocol() {
let missing_api = "ccswitch://v1/import?resource=provider&app=pi&name=Pi&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test&model=opaque-model";
assert!(parse_deeplink_url(missing_api)
.expect_err("Pi api must be explicit")
.to_string()
.contains("'api'"));
let missing_model = "ccswitch://v1/import?resource=provider&app=pi&name=Pi&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test&api=openai-responses";
assert!(parse_deeplink_url(missing_model)
.expect_err("Pi model must be explicit")
.to_string()
.contains("'model'"));
}
#[test]
fn pi_prompt_deeplink_is_accepted_by_the_shared_prompt_path() {
let content = BASE64_STANDARD.encode("Pinned Pi AGENTS content");
let url = format!(
"ccswitch://v1/import?resource=prompt&app=pi&name=Pi%20AGENTS&content={content}&enabled=false"
);
let request = parse_deeplink_url(&url).expect("parse Pi prompt deeplink");
assert_eq!(request.app.as_deref(), Some("pi"));
assert_eq!(request.content.as_deref(), Some(content.as_str()));
}
#[test]
fn test_parse_grokbuild_provider() {
use super::provider::build_provider_from_request;
@@ -210,6 +265,7 @@ fn test_build_gemini_provider_with_model() {
api_key: Some("test-api-key".to_string()),
icon: None,
model: Some("gemini-2.0-flash".to_string()),
api: None,
notes: None,
haiku_model: None,
sonnet_model: None,
@@ -263,6 +319,7 @@ fn test_build_gemini_provider_without_model() {
api_key: Some("test-api-key".to_string()),
icon: None,
model: None,
api: None,
notes: None,
haiku_model: None,
sonnet_model: None,
@@ -309,6 +366,7 @@ fn test_deeplink_usage_script_does_not_copy_provider_credentials() {
api_key: Some("sk-main".to_string()),
icon: None,
model: None,
api: None,
notes: None,
haiku_model: None,
sonnet_model: None,
@@ -356,6 +414,7 @@ fn usage_script_request(code: &str, usage_enabled: Option<bool>) -> DeepLinkImpo
api_key: Some("sk-main".to_string()),
icon: None,
model: None,
api: None,
notes: None,
haiku_model: None,
sonnet_model: None,
@@ -439,6 +498,7 @@ fn test_deeplink_usage_script_omits_explicit_credentials_that_match_provider() {
api_key: Some("sk-main".to_string()),
icon: None,
model: None,
api: None,
notes: None,
haiku_model: None,
sonnet_model: None,
@@ -487,6 +547,7 @@ fn test_deeplink_usage_script_preserves_distinct_usage_credentials() {
api_key: Some("sk-main".to_string()),
icon: None,
model: None,
api: None,
notes: None,
haiku_model: None,
sonnet_model: None,
@@ -540,6 +601,7 @@ fn test_parse_and_merge_config_claude() {
api_key: None,
icon: None,
model: None,
api: None,
notes: None,
haiku_model: None,
sonnet_model: None,
@@ -663,6 +725,7 @@ fn test_parse_and_merge_config_url_override() {
api_key: Some("sk-new".to_string()), // URL param should override
icon: None,
model: None,
api: None,
notes: None,
haiku_model: None,
sonnet_model: None,
@@ -726,6 +789,7 @@ fn test_build_claude_provider_preserves_custom_env_fields() {
icon: None,
// URL param: must win over the same key in config (haiku-from-config)
model: Some("main-model".to_string()),
api: None,
notes: None,
haiku_model: Some("haiku-from-url".to_string()),
sonnet_model: None,
@@ -781,6 +845,7 @@ fn test_build_claude_provider_without_config_unchanged() {
api_key: Some("sk".to_string()),
icon: None,
model: None,
api: None,
notes: None,
haiku_model: None,
sonnet_model: None,
+68 -8
View File
@@ -25,6 +25,7 @@ mod model_capabilities;
mod openclaw_config;
mod opencode_config;
mod panic_hook;
mod pi_config;
mod prompt;
mod prompt_files;
mod provider;
@@ -38,6 +39,9 @@ mod tray;
mod usage_events;
mod usage_script;
#[cfg(test)]
mod architecture_tests;
pub use app_config::{AppType, InstalledSkill, McpApps, McpServer, MultiAppConfig, SkillApps};
pub use codex_config::{
get_codex_auth_path, get_codex_config_path, read_codex_live_settings, write_codex_live_atomic,
@@ -949,6 +953,7 @@ pub fn run() {
crate::app_config::AppType::OpenCode,
crate::app_config::AppType::OpenClaw,
crate::app_config::AppType::Hermes,
crate::app_config::AppType::Pi,
] {
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
&app_state,
@@ -1327,6 +1332,12 @@ pub fn run() {
commands::remove_provider_from_live_config,
commands::switch_provider,
commands::import_default_config,
commands::get_pi_native_catalog,
commands::import_pi_native_provider,
commands::set_pi_default_model,
commands::get_pi_native_defaults,
commands::get_pi_session_discovery,
commands::reset_pi_gateway_credential,
commands::get_claude_desktop_status,
commands::get_claude_desktop_default_routes,
commands::import_claude_desktop_providers_from_claude,
@@ -1411,6 +1422,14 @@ pub fn run() {
commands::enable_prompt,
commands::import_prompt_from_file,
commands::get_current_prompt_file_content,
commands::get_pi_prompt_library_status,
commands::reconcile_pi_prompt_library,
commands::get_pi_prompt_file,
commands::replace_pi_prompt_file,
commands::delete_pi_prompt_file,
commands::list_pi_prompt_templates,
commands::upsert_pi_prompt_template,
commands::delete_pi_prompt_template,
// Profile management (项目配置方案)
commands::list_profiles,
commands::create_profile,
@@ -1465,6 +1484,7 @@ pub fn run() {
commands::restore_env_backup,
// Skill management (v3.10.0+ unified)
commands::get_installed_skills,
commands::get_pi_skill_statuses,
commands::get_skill_backups,
commands::delete_skill_backup,
commands::install_skill_unified,
@@ -1830,7 +1850,11 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
}
};
let live_taken_over = proxy_service.detect_takeover_in_live_configs();
let needs_restore = has_backups || live_taken_over;
let needs_restore = cleanup_before_exit_needed(
has_backups,
live_taken_over,
crate::settings::pi_takeover_enabled(),
);
if needs_restore {
log::info!("检测到接管残留,开始恢复 Live 配置(保留代理状态)...");
@@ -1854,6 +1878,14 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
}
}
fn cleanup_before_exit_needed(
has_live_backups: bool,
legacy_live_taken_over: bool,
pi_takeover_enabled: bool,
) -> bool {
has_live_backups || legacy_live_taken_over || pi_takeover_enabled
}
/// 主动从系统托盘移除托盘图标。
///
/// `std::process::exit` 会绕过 Tauri 运行时,触发不了 `TrayIcon::drop()`
@@ -1884,7 +1916,10 @@ pub(crate) fn remove_tray_icon_before_exit(app_handle: &tauri::AppHandle) {
/// 则自动启动代理服务并接管对应应用的 Live 配置。
const PROXY_STARTUP_APP_TYPES: [&str; 4] = ["claude", "codex", "gemini", "grokbuild"];
async fn enabled_proxy_apps_on_startup(db: &database::Database) -> Vec<&'static str> {
async fn enabled_proxy_apps_on_startup(
db: &database::Database,
pi_takeover_enabled: bool,
) -> Vec<&'static str> {
let mut apps = Vec::new();
for app_type in PROXY_STARTUP_APP_TYPES {
if db
@@ -1895,12 +1930,16 @@ async fn enabled_proxy_apps_on_startup(db: &database::Database) -> Vec<&'static
apps.push(app_type);
}
}
if pi_takeover_enabled {
apps.push("pi");
}
apps
}
async fn restore_proxy_state_on_startup(state: &store::AppState) {
// 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取)
let apps_to_restore = enabled_proxy_apps_on_startup(&state.db).await;
let apps_to_restore =
enabled_proxy_apps_on_startup(&state.db, crate::settings::pi_takeover_enabled()).await;
if apps_to_restore.is_empty() {
log::debug!("启动时无需恢复代理状态");
@@ -1921,7 +1960,15 @@ async fn restore_proxy_state_on_startup(state: &store::AppState) {
}
Err(e) => {
log::error!("✗ 恢复 {app_type} 的代理接管状态失败: {e}");
// 失败时清除该应用的状态,避免下次启动再次尝试
// Pi desired state is device-local user intent. Keep it
// pending/degraded so a transient bind or projection failure
// is retried on the next startup.
if app_type == "pi" {
continue;
}
// Legacy live-config apps retain their historical cleanup
// behavior because their enabled bit also describes a live
// file takeover, not an independent desired/operational pair.
if let Err(clear_err) = state
.proxy_service
.set_takeover_for_app(app_type, false)
@@ -2223,9 +2270,9 @@ pub fn restart_process(app_handle: &tauri::AppHandle) -> ! {
#[cfg(test)]
mod tests {
use super::{
classify_exit_request, enabled_proxy_apps_on_startup, redact_url_for_log,
redact_url_for_log_with_secrets, redact_url_origin_for_log, runtime_log_level_allows,
ExitRequestAction,
classify_exit_request, cleanup_before_exit_needed, enabled_proxy_apps_on_startup,
redact_url_for_log, redact_url_for_log_with_secrets, redact_url_origin_for_log,
runtime_log_level_allows, ExitRequestAction,
};
use crate::database::Database;
@@ -2347,8 +2394,21 @@ mod tests {
.await
.expect("enable Grok Build proxy config");
let apps = enabled_proxy_apps_on_startup(&db).await;
let apps = enabled_proxy_apps_on_startup(&db, false).await;
assert_eq!(apps, vec!["grokbuild"]);
}
#[tokio::test]
async fn startup_restore_republishes_persisted_pi_takeover() {
let db = Database::memory().expect("initialize database");
let apps = enabled_proxy_apps_on_startup(&db, true).await;
assert_eq!(apps, vec!["pi"]);
}
#[test]
fn process_exit_cleanup_includes_pi_takeover_without_legacy_live_backups() {
assert!(cleanup_before_exit_needed(false, false, true));
assert!(!cleanup_before_exit_needed(false, false, false));
}
}
+979
View File
@@ -0,0 +1,979 @@
//! Credential-blind Pi native model composition.
//!
//! The only Pi-layer input is [`PiRawValidProvider`]. This module does not
//! import managed DTOs or gateway families, and it never resolves credentials,
//! environment variables, commands, files, or network resources.
#![allow(dead_code)]
use super::{
merge_pi_compat,
raw_schema::{PiRawApiId, PiRawValidProvider},
};
use serde_json::{json, Map, Value};
use std::collections::{BTreeMap, HashSet};
const PROVIDER_FIELDS: &[&str] = &[
"name",
"baseUrl",
"apiKey",
"api",
"oauth",
"headers",
"compat",
"authHeader",
"models",
"modelOverrides",
];
const MODEL_FIELDS: &[&str] = &[
"id",
"name",
"baseUrl",
"api",
"reasoning",
"thinkingLevelMap",
"input",
"cost",
"contextWindow",
"maxTokens",
"headers",
"compat",
];
const OVERRIDE_FIELDS: &[&str] = &[
"name",
"reasoning",
"thinkingLevelMap",
"input",
"cost",
"contextWindow",
"maxTokens",
"headers",
"compat",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PiComposerStatus {
Composed,
Failed,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PiComposerReasonCode {
CatalogRequired,
MissingExplicitModels,
MissingEffectiveApi,
MissingEffectiveEndpoint,
NonPositiveModelLimit,
UnrepresentableCompat,
CompositionFailed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PiComposerReason {
pub code: PiComposerReasonCode,
pub json_pointer: String,
}
/// One configured header together with the source pointer that Pi resolves.
///
/// `headers` remains the pinned composer's flattened observable result, while
/// these entries retain the provider-vs-model boundary needed to reproduce
/// the later `ModelRuntime` merge on the wire.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PiComposedHeader {
pub name: String,
pub value: String,
pub json_pointer: String,
}
/// The lossless native result of pinned Pi composition.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct PiComposedNativeModel {
pub id: String,
pub name: String,
pub api: PiRawApiId,
pub provider: String,
pub base_url: String,
pub reasoning: bool,
pub thinking_level_map: Option<Value>,
pub input: Value,
pub cost: Value,
pub context_window: Value,
pub max_tokens: Value,
pub headers: BTreeMap<String, String>,
pub provider_headers: Vec<PiComposedHeader>,
pub model_headers: Vec<PiComposedHeader>,
pub compat: Option<Value>,
pub api_key: Option<String>,
pub oauth: Option<Value>,
pub auth_header: bool,
pub provider_extra: BTreeMap<String, Value>,
pub model_extra: BTreeMap<String, Value>,
pub override_extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct PiNativeComposition {
pub status: PiComposerStatus,
pub provider_id: Option<String>,
pub provider_name: Option<String>,
pub provider_base_url: Option<String>,
pub models: Vec<PiComposedNativeModel>,
pub ignored_override_keys: Vec<String>,
pub reasons: Vec<PiComposerReason>,
}
impl PiNativeComposition {
pub(super) fn unavailable_without_valid_raw() -> Self {
Self {
status: PiComposerStatus::Unknown,
provider_id: None,
provider_name: None,
provider_base_url: None,
models: Vec::new(),
ignored_override_keys: Vec::new(),
reasons: Vec::new(),
}
}
pub(super) fn catalog_required(pointer: &str) -> Self {
Self {
status: PiComposerStatus::Unknown,
provider_id: None,
provider_name: None,
provider_base_url: None,
models: Vec::new(),
ignored_override_keys: Vec::new(),
reasons: vec![PiComposerReason {
code: PiComposerReasonCode::CatalogRequired,
json_pointer: pointer.to_string(),
}],
}
}
fn failed(code: PiComposerReasonCode, pointer: impl Into<String>) -> Self {
Self {
status: PiComposerStatus::Failed,
provider_id: None,
provider_name: None,
provider_base_url: None,
models: Vec::new(),
ignored_override_keys: Vec::new(),
reasons: vec![PiComposerReason {
code,
json_pointer: pointer.into(),
}],
}
}
fn unknown(code: PiComposerReasonCode, pointer: impl Into<String>) -> Self {
Self {
status: PiComposerStatus::Unknown,
provider_id: None,
provider_name: None,
provider_base_url: None,
models: Vec::new(),
ignored_override_keys: Vec::new(),
reasons: vec![PiComposerReason {
code,
json_pointer: pointer.into(),
}],
}
}
}
pub(super) fn compose_explicit_custom_catalog(
provider_id: &str,
provider: &PiRawValidProvider,
) -> PiNativeComposition {
let Some(provider_object) = provider.raw().as_object() else {
return PiNativeComposition::failed(PiComposerReasonCode::CompositionFailed, "");
};
let Some(definitions) = provider_object
.get("models")
.and_then(Value::as_array)
.filter(|models| !models.is_empty())
else {
return PiNativeComposition::failed(PiComposerReasonCode::MissingExplicitModels, "/models");
};
let provider_api = provider_object.get("api").and_then(Value::as_str);
let provider_base_url = provider_object.get("baseUrl").and_then(Value::as_str);
if provider_object.get("oauth").and_then(Value::as_str) == Some("radius")
&& provider_base_url.is_none()
{
return PiNativeComposition::failed(
PiComposerReasonCode::MissingEffectiveEndpoint,
"/baseUrl",
);
}
let provider_compat = provider_object.get("compat").cloned();
let provider_header_entries = header_entries(provider_object.get("headers"), "/headers");
let provider_headers = provider_header_entries
.iter()
.map(|entry| (entry.name.clone(), entry.value.clone()))
.collect::<BTreeMap<_, _>>();
let provider_extra = unknown_fields(provider_object, PROVIDER_FIELDS);
let api_key = provider_object
.get("apiKey")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let oauth = provider_object.get("oauth").cloned();
let auth_header = provider_object
.get("authHeader")
.and_then(Value::as_bool)
.unwrap_or(false);
let overrides = provider_object
.get("modelOverrides")
.and_then(Value::as_object);
let mut models: Vec<PiComposedNativeModel> = Vec::with_capacity(definitions.len());
for (index, definition_value) in definitions.iter().enumerate() {
let Some(definition) = definition_value.as_object() else {
return PiNativeComposition::failed(
PiComposerReasonCode::CompositionFailed,
format!("/models/{index}"),
);
};
let Some(id) = definition.get("id").and_then(Value::as_str) else {
return PiNativeComposition::failed(
PiComposerReasonCode::CompositionFailed,
format!("/models/{index}/id"),
);
};
let existing_index = models.iter().position(|model| model.id == id);
let defaults = existing_index
.and_then(|position| models.get(position))
.or_else(|| models.first());
let api_value = definition
.get("api")
.and_then(Value::as_str)
.or(provider_api)
.or_else(|| defaults.map(|model| model.api.as_str()));
let Some(api_value) = api_value else {
return PiNativeComposition::failed(
PiComposerReasonCode::MissingEffectiveApi,
format!("/models/{index}/api"),
);
};
let Some(api) = PiRawApiId::new(api_value) else {
return PiNativeComposition::failed(
PiComposerReasonCode::MissingEffectiveApi,
format!("/models/{index}/api"),
);
};
let base_url = definition
.get("baseUrl")
.and_then(Value::as_str)
.or(provider_base_url)
.or_else(|| defaults.map(|model| model.base_url.as_str()));
let Some(base_url) = base_url.filter(|value| !value.is_empty()) else {
return PiNativeComposition::failed(
PiComposerReasonCode::MissingEffectiveEndpoint,
format!("/models/{index}/baseUrl"),
);
};
for (field, code) in [
("contextWindow", PiComposerReasonCode::NonPositiveModelLimit),
("maxTokens", PiComposerReasonCode::NonPositiveModelLimit),
] {
if definition
.get(field)
.and_then(Value::as_f64)
.is_some_and(|value| value <= 0.0)
{
return PiNativeComposition::failed(code, format!("/models/{index}/{field}"));
}
}
let compat =
match merge_pi_compat(provider_compat.clone(), definition.get("compat").cloned()) {
Ok(compat) => compat,
Err(_) => {
return PiNativeComposition::unknown(
PiComposerReasonCode::UnrepresentableCompat,
format!("/models/{index}/compat"),
)
}
};
let model = PiComposedNativeModel {
id: id.to_string(),
name: definition
.get("name")
.and_then(Value::as_str)
.unwrap_or(id)
.to_string(),
api,
provider: provider_id.to_string(),
base_url: base_url.to_string(),
reasoning: definition
.get("reasoning")
.and_then(Value::as_bool)
.unwrap_or(false),
thinking_level_map: definition.get("thinkingLevelMap").cloned(),
input: definition
.get("input")
.cloned()
.unwrap_or_else(|| json!(["text"])),
cost: definition.get("cost").cloned().unwrap_or_else(default_cost),
context_window: definition
.get("contextWindow")
.cloned()
.unwrap_or_else(|| json!(128000)),
max_tokens: definition
.get("maxTokens")
.cloned()
.unwrap_or_else(|| json!(16384)),
headers: BTreeMap::new(),
provider_headers: provider_header_entries.clone(),
model_headers: Vec::new(),
compat,
api_key: api_key.clone(),
oauth: oauth.clone(),
auth_header,
provider_extra: provider_extra.clone(),
model_extra: unknown_fields(definition, MODEL_FIELDS),
override_extra: BTreeMap::new(),
};
if let Some(existing_index) = existing_index {
models[existing_index] = model;
} else {
models.push(model);
}
}
for model in &mut models {
// Pinned Pi's rawModelHeaders uses Array.find, so duplicate model
// definitions obtain request headers from the first definition even
// though the later definition replaces the composed model slot.
let (definition_index, definition) = definitions
.iter()
.enumerate()
.find_map(|(index, definition)| {
definition
.as_object()
.filter(|definition| {
definition.get("id").and_then(Value::as_str) == Some(model.id.as_str())
})
.map(|definition| (index, definition))
})
.expect("raw-valid composed model has a source definition");
let model_override =
overrides.and_then(|overrides| overrides.get(&model.id).and_then(Value::as_object));
// rawModelHeaders constructs one case-sensitive JavaScript object from
// override headers followed by the first matching model definition.
// Exact-name replacement keeps its insertion slot; differently-cased
// names remain distinct until ModelRuntime performs its later
// case-insensitive HTTP merge.
let mut model_headers = Vec::new();
if let Some(model_override) = model_override {
overlay_header_entries(
&mut model_headers,
header_entries(
model_override.get("headers"),
&format!("/modelOverrides/{}/headers", escape_json_pointer(&model.id)),
),
);
}
overlay_header_entries(
&mut model_headers,
header_entries(
definition.get("headers"),
&format!("/models/{definition_index}/headers"),
),
);
let mut headers = provider_headers.clone();
for entry in &model_headers {
headers.insert(entry.name.clone(), entry.value.clone());
}
model.headers = headers;
model.model_headers = model_headers;
if let Some(model_override) = model_override {
if let Some(name) = model_override.get("name").and_then(Value::as_str) {
model.name = name.to_string();
}
if let Some(reasoning) = model_override.get("reasoning").and_then(Value::as_bool) {
model.reasoning = reasoning;
}
if let Some(override_map) = model_override
.get("thinkingLevelMap")
.and_then(Value::as_object)
{
let mut merged = model
.thinking_level_map
.take()
.and_then(|value| value.as_object().cloned())
.unwrap_or_default();
merged.extend(override_map.clone());
model.thinking_level_map = Some(Value::Object(merged));
}
if let Some(input) = model_override.get("input") {
model.input = input.clone();
}
if let Some(cost) = model_override.get("cost").and_then(Value::as_object) {
model.cost = merge_cost(&model.cost, cost);
}
if let Some(context_window) = model_override.get("contextWindow") {
model.context_window = context_window.clone();
}
if let Some(max_tokens) = model_override.get("maxTokens") {
model.max_tokens = max_tokens.clone();
}
model.compat = match merge_pi_compat(
model.compat.clone(),
model_override.get("compat").cloned(),
) {
Ok(compat) => compat,
Err(_) => {
return PiNativeComposition::unknown(
PiComposerReasonCode::UnrepresentableCompat,
format!("/modelOverrides/{}/compat", escape_json_pointer(&model.id)),
)
}
};
model.override_extra = unknown_fields(model_override, OVERRIDE_FIELDS);
}
}
let model_ids = models
.iter()
.map(|model| model.id.as_str())
.collect::<HashSet<_>>();
let ignored_override_keys = overrides
.into_iter()
.flat_map(|overrides| overrides.keys())
.filter(|model_id| !model_ids.contains(model_id.as_str()))
.cloned()
.collect();
PiNativeComposition {
status: PiComposerStatus::Composed,
provider_id: Some(provider_id.to_string()),
provider_name: Some(
provider_object
.get("name")
.and_then(Value::as_str)
.unwrap_or(provider_id)
.to_string(),
),
provider_base_url: provider_base_url.map(ToOwned::to_owned),
models,
ignored_override_keys,
reasons: Vec::new(),
}
}
fn default_cost() -> Value {
json!({
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
})
}
fn merge_cost(base: &Value, overlay: &Map<String, Value>) -> Value {
let base = base.as_object();
let mut merged = Map::new();
for key in ["input", "output", "cacheRead", "cacheWrite", "tiers"] {
if let Some(value) = overlay
.get(key)
.or_else(|| base.and_then(|base| base.get(key)))
{
merged.insert(key.to_string(), value.clone());
}
}
Value::Object(merged)
}
fn header_entries(value: Option<&Value>, base_pointer: &str) -> Vec<PiComposedHeader> {
value
.and_then(Value::as_object)
.into_iter()
.flat_map(|object| object.iter())
.filter_map(|(name, value)| {
value.as_str().map(|value| PiComposedHeader {
name: name.clone(),
value: value.to_string(),
json_pointer: format!("{base_pointer}/{}", escape_json_pointer(name)),
})
})
.collect()
}
fn overlay_header_entries(
base: &mut Vec<PiComposedHeader>,
overlay: impl IntoIterator<Item = PiComposedHeader>,
) {
for entry in overlay {
if let Some(existing) = base.iter_mut().find(|existing| existing.name == entry.name) {
*existing = entry;
} else {
base.push(entry);
}
}
}
fn escape_json_pointer(segment: &str) -> String {
segment.replace('~', "~0").replace('/', "~1")
}
fn unknown_fields(object: &Map<String, Value>, recognized: &[&str]) -> BTreeMap<String, Value> {
object
.iter()
.filter(|(key, _)| !recognized.contains(&key.as_str()))
.map(|(key, value)| (key.clone(), value.clone()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pi_config::raw_schema::{evaluate_provider_value, PiRawValidity};
use serde::Deserialize;
const COMPOSER_ORACLE_SOURCE: &str =
include_str!("../../../tests/fixtures/pi/native-oracle/composer-oracle-v1.json");
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ComposerOracle {
cases: Vec<ComposerOracleCase>,
fail_closed_cases: Vec<FailClosedCase>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ComposerOracleCase {
id: String,
provider_id: String,
input: Value,
execution: Execution,
#[serde(default)]
auth_execution: Option<Value>,
#[serde(default)]
expected: Option<Value>,
#[serde(default)]
expected_error: Option<String>,
}
#[derive(Deserialize)]
struct Execution {
status: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct FailClosedCase {
id: String,
rust_expected_status: String,
reason_code: String,
}
fn model_as_oracle_value(model: &PiComposedNativeModel) -> Value {
let mut object = Map::new();
object.insert("id".into(), json!(model.id));
object.insert("name".into(), json!(model.name));
object.insert("api".into(), json!(model.api.as_str()));
object.insert("provider".into(), json!(model.provider));
object.insert("baseUrl".into(), json!(model.base_url));
object.insert("reasoning".into(), json!(model.reasoning));
if let Some(thinking) = &model.thinking_level_map {
object.insert("thinkingLevelMap".into(), thinking.clone());
}
object.insert("input".into(), model.input.clone());
object.insert("cost".into(), model.cost.clone());
object.insert("contextWindow".into(), model.context_window.clone());
object.insert("maxTokens".into(), model.max_tokens.clone());
object.insert("authHeader".into(), json!(model.auth_header));
if let Some(compat) = &model.compat {
object.insert("compat".into(), compat.clone());
}
if !model.headers.is_empty() {
object.insert("headers".into(), json!(model.headers));
}
Value::Object(object)
}
fn provider_as_oracle_value(composition: &PiNativeComposition) -> Value {
let mut object = Map::new();
object.insert(
"id".into(),
json!(composition
.provider_id
.as_ref()
.expect("composed provider id")),
);
object.insert(
"name".into(),
json!(composition
.provider_name
.as_ref()
.expect("composed provider name")),
);
if let Some(base_url) = &composition.provider_base_url {
object.insert("baseUrl".into(), json!(base_url));
}
Value::Object(object)
}
fn json_numbers_equal(left: &Value, right: &Value) -> bool {
match (left, right) {
(Value::Number(left), Value::Number(right)) => left.as_f64() == right.as_f64(),
(Value::Array(left), Value::Array(right)) => {
left.len() == right.len()
&& left
.iter()
.zip(right)
.all(|(left, right)| json_numbers_equal(left, right))
}
(Value::Object(left), Value::Object(right)) => {
left.len() == right.len()
&& left.iter().all(|(key, left)| {
right
.get(key)
.is_some_and(|right| json_numbers_equal(left, right))
})
}
_ => left == right,
}
}
#[test]
fn rust_composer_matches_actual_pinned_upstream_execution() {
let oracle: ComposerOracle =
serde_json::from_str(COMPOSER_ORACLE_SOURCE).expect("parse composer oracle");
for case in oracle.cases {
let raw = evaluate_provider_value(&case.input);
if case.execution.status == "error" {
assert!(
case.expected_error.is_some(),
"upstream error vector '{}' records its actual error",
case.id
);
match raw.validity {
PiRawValidity::Invalid => {}
PiRawValidity::Valid => {
let result = compose_explicit_custom_catalog(
&case.provider_id,
raw.valid_provider.as_ref().expect("raw-valid provider"),
);
assert_eq!(
result.status,
PiComposerStatus::Failed,
"raw-valid upstream error case '{}'",
case.id
);
}
PiRawValidity::Unknown => {
panic!("oracle case '{}' unexpectedly became Unknown", case.id)
}
}
continue;
}
assert_eq!(raw.validity, PiRawValidity::Valid, "case '{}'", case.id);
let result = compose_explicit_custom_catalog(
&case.provider_id,
raw.valid_provider.as_ref().expect("raw-valid provider"),
);
assert_eq!(
result.status,
PiComposerStatus::Composed,
"case '{}'",
case.id
);
let auth_execution = case
.auth_execution
.as_ref()
.expect("successful composer case records actual auth execution");
assert_eq!(
auth_execution.pointer("/status").and_then(Value::as_str),
Some("success"),
"case '{}'",
case.id
);
let actual_resolved_key = auth_execution
.pointer("/result/auth/apiKey")
.and_then(Value::as_str)
.expect("successful literal composer vector resolves an API key");
assert!(
result
.models
.iter()
.all(|model| { model.api_key.as_deref() == Some(actual_resolved_key) }),
"case '{}' preserves the same literal key that pinned Pi resolved",
case.id
);
if case
.input
.get("authHeader")
.and_then(Value::as_bool)
.unwrap_or(false)
{
let expected_bearer = format!("Bearer {actual_resolved_key}");
assert_eq!(
auth_execution
.pointer("/result/auth/headers/Authorization")
.and_then(Value::as_str),
Some(expected_bearer.as_str()),
"case '{}' uses pinned Pi authHeader behavior",
case.id
);
}
let actual = json!({
"provider": provider_as_oracle_value(&result),
"models": result
.models
.iter()
.map(model_as_oracle_value)
.collect::<Vec<_>>(),
"ignoredOverrideKeys": result.ignored_override_keys,
});
let expected = case.expected.expect("successful upstream expected output");
assert!(
json_numbers_equal(&actual, &expected),
"oracle case '{}'\nactual: {actual:#}\nexpected: {expected:#}",
case.id
);
}
}
#[test]
fn unavailable_upstream_catalog_semantics_are_explicitly_unknown() {
let oracle: ComposerOracle =
serde_json::from_str(COMPOSER_ORACLE_SOURCE).expect("parse composer oracle");
assert_eq!(oracle.fail_closed_cases.len(), 2);
for case in oracle.fail_closed_cases {
assert_eq!(case.rust_expected_status, "unknown", "case '{}'", case.id);
assert_eq!(case.reason_code, "catalog_required", "case '{}'", case.id);
let result = PiNativeComposition::catalog_required("/models");
assert_eq!(result.status, PiComposerStatus::Unknown);
assert_eq!(
result.reasons[0].code,
PiComposerReasonCode::CatalogRequired
);
}
}
#[test]
fn credential_expressions_are_preserved_without_execution() {
let value = json!({
"api": "openai-responses",
"baseUrl": "https://example.test/v1",
"apiKey": "!read-secret",
"oauth": "radius",
"authHeader": true,
"headers": {"x-tenant": "${TENANT}"},
"models": [{"id": "m"}]
});
let raw = evaluate_provider_value(&value);
let composed = compose_explicit_custom_catalog(
"deferred",
raw.valid_provider.as_ref().expect("raw-valid"),
);
assert_eq!(composed.status, PiComposerStatus::Composed);
assert_eq!(composed.models[0].api_key.as_deref(), Some("!read-secret"));
assert_eq!(composed.models[0].oauth, Some(json!("radius")));
assert!(composed.models[0].auth_header);
assert_eq!(composed.models[0].headers["x-tenant"], "${TENANT}");
}
#[test]
fn pinned_cost_override_reconstructs_only_known_cost_members() {
let value = json!({
"api": "anthropic-messages",
"baseUrl": "https://cost.example",
"apiKey": "literal",
"models": [{
"id": "m",
"cost": {
"input": 1,
"output": 2,
"cacheRead": 0.1,
"cacheWrite": 0.2,
"futureRate": 9
}
}],
"modelOverrides": {
"m": {"cost": {"output": 3}}
}
});
let raw = evaluate_provider_value(&value);
let composed = compose_explicit_custom_catalog(
"cost-shape",
raw.valid_provider.as_ref().expect("raw-valid"),
);
assert_eq!(
composed.models[0].cost,
json!({
"input": 1,
"output": 3,
"cacheRead": 0.1,
"cacheWrite": 0.2
}),
"pinned applyModelOverride drops unknown base cost keys when an override exists"
);
}
#[test]
fn compat_spread_matches_pinned_composer_request_capture() {
let value = json!({
"api": "openai-responses",
"baseUrl": "https://compat.example/v1",
"apiKey": "literal",
"compat": {
"openRouterRouting": ["first", "second"],
"chatTemplateKwargs": "ab",
"baseOnly": true
},
"models": [{
"id": "m",
"compat": {"supportsStore": true}
}],
"modelOverrides": {
"m": {
"compat": {
"openRouterRouting": null,
"chatTemplateKwargs": {"named": true},
"overlayOnly": true
}
}
}
});
let raw = evaluate_provider_value(&value);
let composed = compose_explicit_custom_catalog(
"compat-spread",
raw.valid_provider.as_ref().expect("raw-valid"),
);
assert_eq!(
composed.models[0].compat,
Some(json!({
"openRouterRouting": {"0": "first", "1": "second"},
"chatTemplateKwargs": {"0": "a", "1": "b", "named": true},
"baseOnly": true,
"supportsStore": true,
"overlayOnly": true
})),
"captured by scripts/pi-transport-capture.mjs at the pinned Pi commit"
);
}
#[test]
fn compat_spread_fails_closed_when_pinned_output_requires_lone_surrogates() {
let value = json!({
"api": "openai-responses",
"baseUrl": "https://compat.example/v1",
"apiKey": "literal",
"compat": {"chatTemplateKwargs": "😀"},
"models": [{"id": "m"}],
"modelOverrides": {
"m": {"compat": {"chatTemplateKwargs": {"named": true}}}
}
});
let raw = evaluate_provider_value(&value);
let composition = compose_explicit_custom_catalog(
"compat-surrogate",
raw.valid_provider.as_ref().expect("raw-valid"),
);
assert_eq!(composition.status, PiComposerStatus::Unknown);
assert_eq!(
composition.reasons,
vec![PiComposerReason {
code: PiComposerReasonCode::UnrepresentableCompat,
json_pointer: "/modelOverrides/m/compat".to_string(),
}],
"capture records UTF-16 d83d/de00 as two lone-surrogate values, which \
serde_json::Value cannot represent"
);
}
#[test]
fn header_layers_retain_runtime_precedence_and_source_pointers() {
let value = json!({
"api": "anthropic-messages",
"baseUrl": "https://headers.example",
"apiKey": "literal",
"headers": {"authorization": "Bearer provider"},
"models": [{
"id": "m",
"headers": {"Authorization": "Bearer model"}
}],
"modelOverrides": {
"m": {"headers": {"x-layer": "override"}}
}
});
let raw = evaluate_provider_value(&value);
let composed = compose_explicit_custom_catalog(
"header-layers",
raw.valid_provider.as_ref().expect("raw-valid"),
);
let model = &composed.models[0];
assert_eq!(
model.provider_headers[0].json_pointer,
"/headers/authorization"
);
assert_eq!(
model
.model_headers
.iter()
.map(|entry| (entry.name.as_str(), entry.value.as_str()))
.collect::<Vec<_>>(),
vec![("x-layer", "override"), ("Authorization", "Bearer model")]
);
}
#[test]
fn unknown_provider_model_and_override_fields_are_retained_losslessly() {
let value = json!({
"api": "future-wire-v9",
"baseUrl": "https://example.test/v9",
"apiKey": "literal",
"futureProviderShape": {
"nested": [1, {"flag": true}]
},
"models": [{
"id": "m",
"futureModelShape": {
"mode": "novel",
"threshold": 0.125
}
}],
"modelOverrides": {
"m": {
"futureOverrideShape": [
null,
{"preserve": "exactly"}
]
}
}
});
let raw = evaluate_provider_value(&value);
let composed = compose_explicit_custom_catalog(
"lossless",
raw.valid_provider.as_ref().expect("raw-valid"),
);
assert_eq!(composed.status, PiComposerStatus::Composed);
let model = &composed.models[0];
assert_eq!(
model.provider_extra["futureProviderShape"],
json!({"nested": [1, {"flag": true}]})
);
assert_eq!(
model.model_extra["futureModelShape"],
json!({"mode": "novel", "threshold": 0.125})
);
assert_eq!(
model.override_extra["futureOverrideShape"],
json!([null, {"preserve": "exactly"}])
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+218
View File
@@ -0,0 +1,218 @@
//! Pi Coding Agent integration boundaries.
//!
//! This module deliberately separates the managed control-plane model from
//! Pi's shared files and from the proxy data plane. Callers must use the
//! typed model resolver rather than reimplementing provider/model inheritance.
use indexmap::IndexMap;
use serde_json::{Map, Value};
pub(crate) mod composer;
pub(crate) mod document;
pub(crate) mod gateway;
pub(crate) mod model;
pub(crate) mod native;
#[cfg(test)]
mod native_inspection_certification;
pub(crate) mod native_settings;
pub(crate) mod raw_schema;
pub(crate) mod shared_file;
const PI_COMPAT_NESTED_SPREAD_KEYS: [&str; 3] = [
"openRouterRouting",
"vercelGatewayRouting",
"chatTemplateKwargs",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PiCompatMergeError;
#[derive(Debug, Clone, PartialEq)]
enum JavaScriptSpreadValue {
Json(Value),
LoneSurrogate,
}
type JavaScriptSpreadMap = IndexMap<String, JavaScriptSpreadValue>;
/// Mirror pinned Pi's `mergeCompat` JavaScript object-spread semantics.
///
/// Arrays expose numeric enumerable properties, strings expose character
/// properties, objects expose their own fields, and the remaining JSON
/// primitives expose none. Existing key positions are retained when an
/// overlay replaces their values, matching object spread.
///
/// A JavaScript string is indexed by UTF-16 code unit. Spreading an astral
/// character therefore creates lone-surrogate string values, which cannot be
/// represented by Rust `String` or `serde_json::Value`. That shape is rejected
/// explicitly so callers can fail closed instead of emitting a different
/// composed model.
fn merge_pi_compat(
base: Option<Value>,
overlay: Option<Value>,
) -> Result<Option<Value>, PiCompatMergeError> {
let Some(overlay) = overlay else {
return Ok(base);
};
if !javascript_truthy(&overlay) {
return Ok(base);
}
let mut merged = javascript_object_spread(base.as_ref());
merged.extend(javascript_object_spread(Some(&overlay)));
for key in PI_COMPAT_NESTED_SPREAD_KEYS {
let base_value = javascript_property(base.as_ref(), key);
let overlay_value = javascript_property(Some(&overlay), key);
if base_value.is_some_and(javascript_is_object)
|| overlay_value.is_some_and(javascript_is_object)
{
let mut nested = javascript_object_spread(base_value);
nested.extend(javascript_object_spread(overlay_value));
merged.insert(
key.to_string(),
JavaScriptSpreadValue::Json(Value::Object(finish_javascript_object_spread(
nested,
)?)),
);
}
}
Ok(Some(Value::Object(finish_javascript_object_spread(
merged,
)?)))
}
fn javascript_truthy(value: &Value) -> bool {
match value {
Value::Null | Value::Bool(false) => false,
Value::Number(value) => value.as_f64().is_none_or(|value| value != 0.0),
Value::String(value) => !value.is_empty(),
Value::Bool(true) | Value::Array(_) | Value::Object(_) => true,
}
}
fn javascript_is_object(value: &Value) -> bool {
matches!(value, Value::Array(_) | Value::Object(_))
}
fn javascript_property<'a>(value: Option<&'a Value>, key: &str) -> Option<&'a Value> {
value.and_then(Value::as_object)?.get(key)
}
fn javascript_object_spread(value: Option<&Value>) -> JavaScriptSpreadMap {
match value {
Some(Value::Object(object)) => object
.iter()
.map(|(key, value)| (key.clone(), JavaScriptSpreadValue::Json(value.clone())))
.collect(),
Some(Value::Array(values)) => values
.iter()
.enumerate()
.map(|(index, value)| {
(
index.to_string(),
JavaScriptSpreadValue::Json(value.clone()),
)
})
.collect(),
Some(Value::String(value)) => value
.encode_utf16()
.enumerate()
.map(|(index, unit)| {
let value = char::from_u32(u32::from(unit))
.map(|character| {
JavaScriptSpreadValue::Json(Value::String(character.to_string()))
})
.unwrap_or(JavaScriptSpreadValue::LoneSurrogate);
(index.to_string(), value)
})
.collect(),
Some(Value::Null | Value::Bool(_) | Value::Number(_)) | None => IndexMap::new(),
}
}
fn finish_javascript_object_spread(
spread: JavaScriptSpreadMap,
) -> Result<Map<String, Value>, PiCompatMergeError> {
spread
.into_iter()
.map(|(key, value)| match value {
JavaScriptSpreadValue::Json(value) => Ok((key, value)),
JavaScriptSpreadValue::LoneSurrogate => Err(PiCompatMergeError),
})
.collect()
}
#[cfg(test)]
mod compat_spread_tests {
use super::*;
use serde_json::json;
#[test]
fn compat_nested_values_follow_javascript_object_spread() {
let merged = merge_pi_compat(
Some(json!({
"openRouterRouting": ["first", "second"],
"chatTemplateKwargs": "ab",
"baseOnly": true
})),
Some(json!({
"openRouterRouting": null,
"chatTemplateKwargs": {"named": true},
"overlayOnly": true
})),
)
.expect("representable compat spread")
.expect("truthy overlay produces an object");
assert_eq!(
merged,
json!({
"openRouterRouting": {"0": "first", "1": "second"},
"chatTemplateKwargs": {"0": "a", "1": "b", "named": true},
"baseOnly": true,
"overlayOnly": true
})
);
}
#[test]
fn compat_falsy_overlay_returns_base_without_spreading() {
let base = Some(json!({"openRouterRouting": ["kept"]}));
assert_eq!(merge_pi_compat(base.clone(), Some(Value::Null)), Ok(base));
}
#[test]
fn compat_spread_rejects_unrepresentable_javascript_surrogates() {
assert_eq!(
merge_pi_compat(
Some(json!({"chatTemplateKwargs": "😀"})),
Some(json!({"chatTemplateKwargs": {"named": true}})),
),
Err(PiCompatMergeError)
);
}
#[test]
fn compat_spread_checks_surrogates_after_later_properties_override_them() {
assert_eq!(
merge_pi_compat(
Some(json!({"chatTemplateKwargs": "😀"})),
Some(json!({
"chatTemplateKwargs": {
"0": "repaired-high",
"1": "repaired-low",
"named": true
}
})),
),
Ok(Some(json!({
"chatTemplateKwargs": {
"0": "repaired-high",
"1": "repaired-low",
"named": true
}
})))
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,834 @@
#![cfg(test)]
//! 只读 native inspection 契约测试。
//!
//! ## 目标
//! **pinned Pi 决定什么是合法**。本仓库的 DTO 形状、网关支持范围、头部策略
//! 都不得成为"合法性"的来源:schema 接受的,managed 不得拒绝也不得丢值;
//! Pi 会发出的,网关不得降级;Pi 不接受的形态,我们也不假装支持。
//!
//! ## 六条裁决及其上游证据
//! C1【无损性】pinned schema 对 `thinkingLevelMap` 只约束 7 个标准键
//! (string|null;oracle 实证 `low: 2` 非法),额外键无约束(oracle 实证
//! `future: {nested:true}` 合法);`cost`/tier 同样接受未来键。managed 与
//! **effective 边界**(`effective_pi_model` 是 projection/routing/failover
//! 的共同入口)都必须无损,**空容器与缺席必须保持可区分**(`{}` 之于
//! thinkingLevelMap、`[]` 之于 cost.tiers 同理)。
//! 据此取代两个既有测试中把收窄固化为断言的部分:
//! `managed_narrowing_rejects_duplicates_or_unknown_thinking_keys` 与
//! `unknown_thinking_shape_is_lossless_for_composer_and_narrowed_separately`
//! (授权改写、可改名;DuplicateModelId 与 composer 无损两个语义由本套件
//! 直接接管)。若 `InvalidThinkingLevel` 变体因此不再可构造,授权移除。
//! C2【认证头】authorization / x-api-key / x-goog-api-key 是候选认证头,
//! 不是 protected。取值次序据 pinned SDK 与 composer 源码:authHeader 未
//! 设时显式头优先于 apiKey 合成值(Anthropic/OpenAI SDK 按"合成 auth →
//! 显式 headers"合并,后项覆盖);authHeader:true 时合成 Bearer 反过来
//! 优先(pinned provider-composer 在自定义头之后写入,且只写
//! Authorization、不动 x-api-key)。**header-only 凭证对四族都不是 Pi 原生
//! 可请求形态**:pinned `ModelRuntime.prepareRequest()` 先解析 auth,得不到
//! AuthResult 即抛 "Provider is not configured",在合并 headers 之前返回,
//! 而 headers 本身永不产生 AuthResult(Google adapter 更是无条件要 apiKey)。
//! 故无 apiKey 时维持 MissingCredential 降级,但认证头本身仍不得被报为
//! ProtectedHeader。
//! C3【传输层】放宽认证头不得连带放宽传输层:逐跳头完整覆盖并以 `proxy-`
//! **前缀**拒绝;契约 header 六分类中的 Gateway/HTTP owned(proxy trace /
//! CDN 客户端身份 / 分布式追踪)同样拒绝,清单与生产 forwarder 无条件
//! 剥离的集合对齐。
//! C4【deferred 值的校验时机】pinned `resolveConfigValueOrThrow()` 先执行
//! `!command` / 展开 `${ENV}`,再使用结果;**从不按 HTTP 头规则校验原始
//! 表达式**(命令输出 trim,环境模板不 trim,解析结果亦不做头合法性校验)。
//! 因此原始表达式含头非法字符、而解析结果合法的配置必须被接受;头合法性
//! 校验只能发生在物化之后(这是网关自身的传输约束,保留)。**字面量值仍在
//! 判定期校验,且该规则对 credential 与 header 一视同仁**——判定期说
//! "可代理"而每次物化必然失败,是判定层与执行层自相矛盾。
//!
//! C5【凭证种类,2026-08-02 新增,**已 request-capture 实证**】pinned
//! Anthropic 传输层以 `apiKey.includes("sk-ant-oat")`(子串,非前缀)判定
//! OAuth,命中则以 `Authorization: Bearer` 发送、**不发 x-api-key**,并附
//! `anthropic-beta: claude-code-20250219,oauth-2025-04-20,...`;**models.json
//! 里的字面量 apiKey 同样会走该分支**;该判定**只在 Anthropic 族**,同形
//! token 在 OpenAI 族仍按普通 Bearer 发送。因此网关不得把这类凭证当普通
//! x-api-key 代理:字面量命中即判定期 DirectOnly 并给结构化理由(不得是
//! MissingCredential);deferred 凭证判定期不可知,则**物化期解析出命中值
//! 时必须失败**,绝不发出错误的认证形态。
//! **完整 OAuth 传输(Bearer + oauth beta 值)不在前置 C 范围**——按
//! 项目范围划分,gateway 数据面属主工程,且需要
//! 先补 request-capture oracle。本工程只保证判定诚实、不发错凭证。
//! C6【entry 隔离,2026-08-02 新增】pinned Pi 逐 entry 做 TypeBox 判定,
//! 单个 entry 的取值错误(如 `contextWindow: 1e400`)只令该 entry 非法;
//! 整文件解析失败会让合法的兄弟 entry 被连坐隐藏,违反四层判定"每个
//! entry 独立"的核心设计。
//!
//! ## 实现方义务(不在本文件断言,交盲审核查)
//! O1 `compat` 需复现 JavaScript object-spread 对嵌套值(尤其数组)的语义;
//! O2 架构扫描器:cfg 布尔语义(`cfg(not(test))` 的生产代码必须被扫描)、
//! 不得按 `tests/` 路径整体跳过文件、嵌套模块须继承父层归属。
//!
//! ## 上游实证(request-capture,2026-08-02)
//! `scripts/pi-transport-capture.mjs` 以本地抓包端点作 baseUrl,用 pinned Pi
//! 的 adapter 真发请求,实测矩阵(据此 C2/C5 不再是"读源码推断"):
//! - anthropic 普通 key → `x-api-key: <key>`;
//! - anthropic `sk-ant-oat...` → `authorization: Bearer <token>` +
//! `anthropic-beta: claude-code-20250219,oauth-2025-04-20,...`,**无 x-api-key**;
//! - anthropic apiKey + 显式 `x-api-key` → 发**显式值**(显式覆盖合成);
//! - anthropic apiKey + 显式 `authorization` → 两者**并存**
//! (`authorization` 取显式值,`x-api-key` 取合成值);
//! - openai responses/completions + 显式 `authorization` → 发**显式值**;
//! - openai + `sk-ant-oat` 形状 token → 仍是普通 `Bearer`,无 OAuth 特殊处理;
//! - openai completions + 显式 `x-api-key` → 与合成 `authorization` **并存**。
//!
//! ## 残余
//! Google 族两值并存的优先级、头名大小写变体未实测;命令输出 trim 与环境模板
//! 不 trim 的差异属数据面语义,本只读面不断言;完整 OAuth 传输实现按范围表
//! 归主工程(harness 已就位,可直接扩为受冻结的 transport oracle);
//! 其余按盲审 finding 处理。
use super::composer::compose_explicit_custom_catalog;
use super::gateway::{assess_composition, PiGatewayCapability, PiGatewayReasonCode};
use super::model::{
effective_pi_model, validate_pi_managed_provider, PiConfigError, PiManagedAssessment,
PiManagedProviderConfig, PiManagementStatus, PiRawNativeValidity,
};
use super::native::{inspect_pi_native_catalog, inspect_pi_native_entry};
use super::raw_schema::evaluate_provider_value;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
fn repo_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root")
.to_path_buf()
}
fn write_catalog(value: &Value) -> (tempfile::TempDir, PathBuf) {
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("models.json");
fs::write(&path, serde_json::to_string_pretty(value).expect("encode")).expect("write");
(temp, path)
}
fn composed_catalog(value: Value) -> super::composer::PiNativeComposition {
let raw = evaluate_provider_value(&value);
compose_explicit_custom_catalog(
"candidate",
raw.valid_provider.as_ref().expect("raw-valid input"),
)
}
fn has_gateway_reason(
gateway: &super::gateway::PiGatewayAssessment,
code: PiGatewayReasonCode,
) -> bool {
gateway.reasons.iter().any(|reason| reason.code == code)
}
// ---------------------------------------------------------------------------
// pinned 夹具冻结——oracle 是上游出处工件,不得为过测试再生成
// ---------------------------------------------------------------------------
const PINNED_FIXTURES: &[(&str, &str)] = &[
(
"tests/fixtures/pi/native-oracle/composer-oracle-v1.json",
"f7e54bb84e5fd6d50e5762dc304834410fa73ef608c2f9c42475c5983f8e0cf5",
),
(
"tests/fixtures/pi/native-oracle/field-coverage-v1.json",
"b8b85e611cf1dbef86c611df185ba8ac2d64160087d0c6e47747f838a0fafe42",
),
(
"tests/fixtures/pi/native-oracle/provenance-v1.json",
"6b2f9570ecc58d54ebe3da094530fee1c8c0d4a8265fa9c3199218582cb8dbcb",
),
(
"tests/fixtures/pi/native-oracle/provider-schema.snapshot.json",
"e498c9f1b344eee1bd3c3ba74d1b648dcb835378cfad92800ec80078b825745c",
),
(
"tests/fixtures/pi/native-oracle/raw-oracle-v1.json",
"5aaa37160f96a0fe50867d900ca38c73f13aba769e156a883324368d9dbeeb9a",
),
(
"tests/fixtures/pi/native-oracle/transport-oracle-v1.json",
"b2c816e53b60da5cd6352d2c23934939e9f6dd0077971488fe9dd36fa723e855",
),
(
"tests/fixtures/pi/module-boundaries-v1.json",
"a69ab84fc0db323d5eb8ddc63555a9c69613dda865962f077cd8691639951b4d",
),
];
#[test]
fn certify_pinned_fixtures_are_frozen() {
for (relative, expected) in PINNED_FIXTURES {
let bytes = fs::read(repo_root().join(relative))
.unwrap_or_else(|e| panic!("read fixture {relative}: {e}"));
assert_eq!(
&format!("{:x}", Sha256::digest(bytes)),
expected,
"pinned fixture '{relative}' drifted; fixtures are upstream provenance \
artifacts and may only change under adjudication"
);
}
}
// ---------------------------------------------------------------------------
// 被取代测试中必须保留的语义,由本套件直接接管
// ---------------------------------------------------------------------------
#[test]
fn certify_duplicate_model_id_rejection_is_preserved() {
let config: PiManagedProviderConfig = serde_json::from_value(json!({
"api": "anthropic-messages",
"baseUrl": "https://dup.example",
"apiKey": "literal",
"models": [{"id": "same"}, {"id": "same"}]
}))
.expect("deserialize managed provider");
assert_eq!(
validate_pi_managed_provider(&config),
Err(PiConfigError::DuplicateModelId("same".into())),
"duplicate model ids must keep being rejected"
);
}
#[test]
fn certify_composer_thinking_losslessness_guard() {
let odd_map = json!({"high": "h", "future": {"opaque": true}});
let composition = composed_catalog(json!({
"api": "anthropic-messages",
"baseUrl": "https://thinking.example",
"apiKey": "literal",
"models": [{"id": "m", "thinkingLevelMap": odd_map}]
}));
assert_eq!(
composition.models[0].thinking_level_map.as_ref(),
Some(&odd_map),
"composer keeps the raw thinkingLevelMap value verbatim"
);
}
// ---------------------------------------------------------------------------
// C1:schema 合法值必须无损直到 effective 边界
// ---------------------------------------------------------------------------
#[test]
fn certify_managed_losslessness_through_effective_boundary() {
// 标准键只取 schema 允许的 string|null;额外键覆盖全部 JSON 类型。
let model_map = json!({
"high": "native-high",
"medium": null,
"future-level": "textual",
"vendor": {"opaque": {"nested": true}},
"budget": 42,
"enabled": true
});
// 与 model_map 共有 "high",用于绑定 override 的覆盖方向。
let override_map = json!({
"high": "override-high",
"low": "override-low",
"another-future": [1, "two", null]
});
let cost = json!({
"input": 1.5,
"output": 2.5,
"cacheRead": 0.5,
"cacheWrite": 0.25,
"futureRate": 9.0,
"tiers": [{
"inputTokensAbove": 100.0,
"input": 1.0,
"output": 2.0,
"cacheRead": 0.5,
"cacheWrite": 0.25,
"futureTierField": "opaque"
}]
});
let catalog = json!({
"providers": {
"thinking": {
"api": "anthropic-messages",
"baseUrl": "https://thinking.example",
"apiKey": "literal",
"models": [
{"id": "m", "thinkingLevelMap": model_map.clone(), "cost": cost.clone()},
{"id": "empty-map", "thinkingLevelMap": {}},
{"id": "absent-map"}
],
"modelOverrides": {"m": {"thinkingLevelMap": override_map.clone()}}
}
}
});
let (_temp, path) = write_catalog(&catalog);
let inspection = inspect_pi_native_entry(&path, "thinking", &BTreeMap::new())
.expect("inspect")
.expect("entry present");
assert_eq!(
inspection.diagnostic.raw_validity,
PiRawNativeValidity::Valid,
"the pinned schema accepts additional thinkingLevelMap and cost members"
);
assert_eq!(
inspection.diagnostic.managed_assessment,
PiManagedAssessment::Manageable,
"managed must not reject what the executed pin accepts"
);
assert_eq!(
inspection.diagnostic.management_status,
PiManagementStatus::Importable
);
// 以序列化后的字符串码断言,便于 InvalidThinkingLevel 变体被整体移除。
let reasons = serde_json::to_value(&inspection.diagnostic.reasons).expect("serialize reasons");
assert!(
!reasons
.as_array()
.expect("reasons array")
.iter()
.any(|reason| reason["code"] == "invalid_thinking_level"),
"no invalid_thinking_level reason may fire for schema-valid input"
);
let managed = inspection.managed_config.expect("managed config");
let round_trip = serde_json::to_value(&managed).expect("serialize managed config");
assert_eq!(
round_trip.pointer("/models/0/thinkingLevelMap"),
Some(&model_map),
"model thinkingLevelMap must round-trip losslessly"
);
assert_eq!(
round_trip.pointer("/modelOverrides/m/thinkingLevelMap"),
Some(&override_map),
"override thinkingLevelMap must round-trip losslessly"
);
assert_eq!(
round_trip.pointer("/models/0/cost"),
Some(&cost),
"cost and tier members must round-trip losslessly, including future keys"
);
// 空对象与缺席是两种原生形态,序列化必须保持可区分。
assert_eq!(
round_trip.pointer("/models/1/thinkingLevelMap"),
Some(&json!({})),
"an explicitly empty thinkingLevelMap must survive as an empty object"
);
assert_eq!(
round_trip.pointer("/models/2/thinkingLevelMap"),
None,
"an absent thinkingLevelMap must stay absent"
);
// effective 是 projection / runtime / routing / failover 的共同入口:
// DTO 修好后在这里二次收窄同样是丢值。
let effective = effective_pi_model(&managed, "m").expect("effective model");
let effective_value = serde_json::to_value(&effective).expect("serialize effective model");
let mut merged = model_map.as_object().expect("model map").clone();
for (key, value) in override_map.as_object().expect("override map") {
merged.insert(key.clone(), value.clone());
}
assert_eq!(
effective_value.pointer("/thinkingLevelMap"),
Some(&Value::Object(merged)),
"the effective model must carry the merged map losslessly, with override \
entries winning on shared keys"
);
assert_eq!(
effective_value.pointer("/cost"),
Some(&cost),
"the effective model must not drop cost members either"
);
}
// ---------------------------------------------------------------------------
// C2:候选认证头不是 protected
// ---------------------------------------------------------------------------
#[test]
fn certify_auth_candidate_headers_are_not_protected() {
// (a) Anthropic:显式 x-api-key 不得被拒,取值优先于 apiKey 合成值。
let explicit = composed_catalog(json!({
"api": "anthropic-messages",
"baseUrl": "https://anthropic.example",
"apiKey": "synthesized-secret",
"headers": {"x-api-key": "explicit-secret"},
"models": [{"id": "m"}]
}));
let gateway = assess_composition(&explicit);
assert!(
!has_gateway_reason(&gateway, PiGatewayReasonCode::ProtectedHeader),
"x-api-key is candidate-auth, not protected"
);
assert_eq!(gateway.capability, PiGatewayCapability::Proxyable);
let materialized = gateway.plans[0]
.materialize(&|_: &str| None)
.expect("materialize literal candidate");
assert_eq!(
materialized.headers[&http::HeaderName::from_static("x-api-key")],
http::HeaderValue::from_static("explicit-secret"),
"explicit config header value takes precedence over synthesized family auth"
);
// 认证头永远不进 failover 协议身份。
if let Some((_, protocol_headers)) = materialized.failover_protocol_identity() {
assert!(
!protocol_headers.contains_key(http::HeaderName::from_static("x-api-key")),
"auth headers must stay out of the failover protocol identity"
);
}
// (b) OpenAI-Responses:显式 authorization 同理。
let bearer = composed_catalog(json!({
"api": "openai-responses",
"baseUrl": "https://openai.example/v1",
"apiKey": "synthesized-secret",
"headers": {"authorization": "Bearer configured-token"},
"models": [{"id": "m"}]
}));
let gateway = assess_composition(&bearer);
assert!(
!has_gateway_reason(&gateway, PiGatewayReasonCode::ProtectedHeader),
"authorization is candidate-auth, not protected"
);
assert_eq!(gateway.capability, PiGatewayCapability::Proxyable);
assert_eq!(
gateway.plans[0]
.materialize(&|_: &str| None)
.expect("materialize")
.headers[&http::HeaderName::from_static("authorization")],
http::HeaderValue::from_static("Bearer configured-token")
);
// (c) Google:显式认证头与 apiKey 并存,不得拒绝、不得降级
// (取值优先级不断言——Google SDK 顺序无上游证据)。
let google = composed_catalog(json!({
"api": "google-generative-ai",
"baseUrl": "https://gemini.example",
"apiKey": "literal",
"headers": {"x-goog-api-key": "explicit-secret"},
"models": [{"id": "m"}]
}));
let gateway = assess_composition(&google);
assert!(
!has_gateway_reason(&gateway, PiGatewayReasonCode::ProtectedHeader),
"x-goog-api-key is candidate-auth, not protected"
);
assert_eq!(gateway.capability, PiGatewayCapability::Proxyable);
}
// ---------------------------------------------------------------------------
// C2:authHeader:true 时合成 Bearer 覆盖显式 Authorization
// ---------------------------------------------------------------------------
#[test]
fn certify_auth_header_bearer_overrides_explicit_authorization() {
let composition = composed_catalog(json!({
"api": "anthropic-messages",
"baseUrl": "https://anthropic.example",
"apiKey": "synthesized-secret",
"authHeader": true,
"headers": {"authorization": "Bearer explicit-token"},
"models": [{"id": "m"}]
}));
let gateway = assess_composition(&composition);
assert_eq!(
gateway.capability,
PiGatewayCapability::Proxyable,
"an explicit authorization header must not downgrade an authHeader model"
);
let materialized = gateway.plans[0]
.materialize(&|_: &str| None)
.expect("materialize literal candidate");
assert_eq!(
materialized.headers[&http::HeaderName::from_static("authorization")],
http::HeaderValue::from_static("Bearer synthesized-secret"),
"with authHeader:true the synthesized Bearer wins (pinned composer writes it \
after the explicit headers)"
);
assert_eq!(
materialized.headers[&http::HeaderName::from_static("x-api-key")],
http::HeaderValue::from_static("synthesized-secret"),
"the Bearer step only rewrites Authorization; family auth stays synthesized"
);
}
// ---------------------------------------------------------------------------
// C3:传输层与网关自有身份头
// ---------------------------------------------------------------------------
/// 逐跳/传输头。末四项是合成名字:精确枚举无法覆盖,必须按 `proxy-` 前缀拒绝。
const HOP_BY_HOP_HEADERS: &[&str] = &[
"host",
"connection",
"content-length",
"transfer-encoding",
"te",
"trailer",
"upgrade",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"proxy-connection",
"proxy-future-extension",
"proxy-tenant-routing",
"proxy-x9",
];
/// Gateway/HTTP owned:proxy trace / CDN 客户端身份 / 分布式追踪。
/// 与生产 forwarder 无条件剥离的集合对齐,两侧同进退。
const GATEWAY_OWNED_HEADERS: &[&str] = &[
"forwarded",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-port",
"x-forwarded-proto",
"x-real-ip",
"cf-connecting-ip",
"cf-ipcountry",
"cf-ray",
"cf-visitor",
"true-client-ip",
"fastly-client-ip",
"x-azure-clientip",
"x-azure-fdid",
"x-azure-ref",
"akamai-origin-hop",
"x-akamai-config-log-detail",
"x-request-id",
"x-correlation-id",
"x-trace-id",
"x-amzn-trace-id",
"x-b3-traceid",
"x-b3-spanid",
"x-b3-parentspanid",
"x-b3-sampled",
"traceparent",
"tracestate",
];
#[test]
fn certify_transport_owned_headers_stay_protected() {
let cases = HOP_BY_HOP_HEADERS
.iter()
.map(|header| ("hop-by-hop", *header))
.chain(
GATEWAY_OWNED_HEADERS
.iter()
.map(|header| ("gateway-owned", *header)),
);
for (class, header) in cases {
let composition = composed_catalog(json!({
"api": "openai-responses",
"baseUrl": "https://openai.example/v1",
"apiKey": "literal",
"headers": {header: "value"},
"models": [{"id": "m"}]
}));
let gateway = assess_composition(&composition);
assert!(
has_gateway_reason(&gateway, PiGatewayReasonCode::ProtectedHeader),
"{class} header '{header}' must be reported as ProtectedHeader"
);
assert_eq!(
gateway.capability,
PiGatewayCapability::DirectOnly,
"{class} header '{header}' must keep the model DirectOnly"
);
}
}
// ---------------------------------------------------------------------------
// C2:header-only 凭证四族皆非 Pi 原生可请求形态
// ---------------------------------------------------------------------------
#[test]
fn certify_header_only_credentials_stay_direct_only() {
// pinned ModelRuntime.prepareRequest() 先解析 auth,得不到 AuthResult 即抛
// "Provider is not configured",在合并 headers 之前返回;headers 永不产生
// AuthResult。因此"只有认证头、无 apiKey"必须降级——但认证头本身依然是
// candidate-auth,不得被报为 ProtectedHeader。
for (api, header) in [
("anthropic-messages", "x-api-key"),
("openai-completions", "authorization"),
("openai-responses", "authorization"),
("google-generative-ai", "x-goog-api-key"),
] {
let composition = composed_catalog(json!({
"api": api,
"baseUrl": "https://example.test/v1",
"headers": {header: "header-secret"},
"models": [{"id": "m"}]
}));
let gateway = assess_composition(&composition);
assert_eq!(
gateway.capability,
PiGatewayCapability::DirectOnly,
"{api}: header-only credentials are not a requestable pinned Pi form"
);
assert!(
has_gateway_reason(&gateway, PiGatewayReasonCode::MissingCredential),
"{api}: a missing apiKey must be reported as MissingCredential"
);
assert!(
!has_gateway_reason(&gateway, PiGatewayReasonCode::ProtectedHeader),
"{api}: the auth header itself must not be reported as protected"
);
}
}
// ---------------------------------------------------------------------------
// C4:deferred 值只能在物化之后校验
// ---------------------------------------------------------------------------
#[test]
fn certify_deferred_header_values_are_validated_after_resolution() {
// 原始表达式含头非法字符(非可见 ASCII),解析结果合法。pinned Pi 先执行
// 再用结果,从不校验原始表达式,故这类配置必须被接受。
let expression = "!echo café";
let deferred = composed_catalog(json!({
"api": "openai-responses",
"baseUrl": "https://openai.example/v1",
"apiKey": "literal",
"headers": {"x-tenant": expression},
"models": [{"id": "m"}]
}));
let gateway = assess_composition(&deferred);
assert!(
!has_gateway_reason(&gateway, PiGatewayReasonCode::InvalidHeaderValue),
"a deferred expression must not be validated as an HTTP header value before \
it is resolved"
);
assert_eq!(gateway.capability, PiGatewayCapability::Proxyable);
let materialized = gateway.plans[0]
.materialize(&|value: &str| (value == expression).then(|| "resolved-secret".to_string()))
.expect("materialize resolved candidate");
assert_eq!(
materialized.headers[&http::HeaderName::from_static("x-tenant")],
http::HeaderValue::from_static("resolved-secret"),
"the resolved value is what reaches the candidate"
);
// 防过度放宽:字面量(非 deferred)含头非法字符仍必须当场拒绝。
let literal = composed_catalog(json!({
"api": "openai-responses",
"baseUrl": "https://openai.example/v1",
"apiKey": "literal",
"headers": {"x-tenant": "café"},
"models": [{"id": "m"}]
}));
let gateway = assess_composition(&literal);
assert!(
has_gateway_reason(&gateway, PiGatewayReasonCode::InvalidHeaderValue),
"a literal header value outside visible ASCII must still be rejected"
);
}
// ---------------------------------------------------------------------------
// C5:OAuth 凭证绝不能按 x-api-key 代理
// ---------------------------------------------------------------------------
#[test]
fn certify_oauth_credentials_are_never_proxied_as_api_key() {
// 字面量命中:判定期即可知,必须 DirectOnly 并给出结构化理由——
// 而不是宣称可代理再发出错误的认证形态。
let literal = composed_catalog(json!({
"api": "anthropic-messages",
"baseUrl": "https://anthropic.example",
"apiKey": "sk-ant-oat01-example-token",
"models": [{"id": "m"}]
}));
let gateway = assess_composition(&literal);
assert_eq!(
gateway.capability,
PiGatewayCapability::DirectOnly,
"pinned Pi sends an sk-ant-oat credential as an OAuth Bearer with oauth beta \
headers; proxying it as x-api-key would send the wrong auth form"
);
assert!(
!gateway.reasons.is_empty(),
"the downgrade must carry a structured reason"
);
assert!(
!has_gateway_reason(&gateway, PiGatewayReasonCode::MissingCredential),
"the credential is present; MissingCredential would misreport the cause"
);
// deferred 凭证:判定期不可知,允许 Proxyable;但物化解析出命中值时必须
// 失败,绝不发出错误的认证形态。
let deferred = composed_catalog(json!({
"api": "anthropic-messages",
"baseUrl": "https://anthropic.example",
"apiKey": "!load-token",
"models": [{"id": "m"}]
}));
let gateway = assess_composition(&deferred);
assert_eq!(
gateway.capability,
PiGatewayCapability::Proxyable,
"a deferred credential's kind is unknowable at plan time"
);
assert!(
gateway.plans[0]
.materialize(&|_: &str| Some("sk-ant-oat01-resolved".to_string()))
.is_err(),
"materialising a resolved OAuth credential must fail rather than send it as \
a plain api key"
);
// 防过度收窄:普通 Anthropic key 不受影响;非 Anthropic 族不适用该判定
// (pinned 的 includes 检查只在 Anthropic 传输层)。
for (api, key) in [
("anthropic-messages", "sk-ant-api03-plain"),
("openai-responses", "sk-ant-oat01-not-anthropic"),
] {
let plain = composed_catalog(json!({
"api": api,
"baseUrl": "https://plain.example/v1",
"apiKey": key,
"models": [{"id": "m"}]
}));
assert_eq!(
assess_composition(&plain).capability,
PiGatewayCapability::Proxyable,
"{api}: the OAuth rule must not over-reach"
);
}
}
// ---------------------------------------------------------------------------
// C4 扩展:字面量凭证必须在判定期校验
// ---------------------------------------------------------------------------
#[test]
fn certify_literal_credentials_are_validated_at_plan_time() {
// 判定期宣称"可代理"、而每次物化必然失败,是判定层与执行层自相矛盾。
let illegal = composed_catalog(json!({
"api": "openai-responses",
"baseUrl": "https://openai.example/v1",
"apiKey": "café",
"models": [{"id": "m"}]
}));
let gateway = assess_composition(&illegal);
assert!(
gateway.plans.is_empty() || gateway.plans[0].materialize(&|_: &str| None).is_err(),
"sanity: this literal credential can never materialise"
);
assert_eq!(
gateway.capability,
PiGatewayCapability::DirectOnly,
"a literal credential that can never materialise must not be judged proxyable"
);
assert!(
!gateway.reasons.is_empty(),
"the downgrade must carry a structured reason"
);
// 对称约束:deferred 凭证仍不得因原始表达式在判定期被拒(C4)。
let deferred = composed_catalog(json!({
"api": "openai-responses",
"baseUrl": "https://openai.example/v1",
"apiKey": "!echo café",
"models": [{"id": "m"}]
}));
assert_eq!(
assess_composition(&deferred).capability,
PiGatewayCapability::Proxyable,
"a deferred credential must not be validated as a header value before it is \
resolved"
);
}
// ---------------------------------------------------------------------------
// C1 扩展:空容器与缺席必须可区分
// ---------------------------------------------------------------------------
#[test]
fn certify_empty_containers_stay_distinct_from_absent() {
let base_rates = json!({
"input": 1.0, "output": 2.0, "cacheRead": 0.5, "cacheWrite": 0.25
});
let mut with_empty = base_rates.as_object().expect("rates").clone();
with_empty.insert("tiers".into(), json!([]));
let catalog = json!({
"providers": {
"tiers": {
"api": "anthropic-messages",
"baseUrl": "https://tiers.example",
"apiKey": "literal",
"models": [
{"id": "empty-tiers", "cost": Value::Object(with_empty)},
{"id": "absent-tiers", "cost": base_rates.clone()}
]
}
}
});
let (_temp, path) = write_catalog(&catalog);
let managed = inspect_pi_native_entry(&path, "tiers", &BTreeMap::new())
.expect("inspect")
.expect("entry present")
.managed_config
.expect("managed config");
let round_trip = serde_json::to_value(&managed).expect("serialize managed config");
assert_eq!(
round_trip.pointer("/models/0/cost/tiers"),
Some(&json!([])),
"an explicitly empty tiers list must survive as an empty list"
);
assert_eq!(
round_trip.pointer("/models/1/cost/tiers"),
None,
"an absent tiers list must stay absent"
);
}
// ---------------------------------------------------------------------------
// C6:单个 entry 的错误不得连坐兄弟 entry
// ---------------------------------------------------------------------------
#[test]
fn certify_one_bad_entry_does_not_hide_its_siblings() {
// pinned Pi 逐 entry 判定:`contextWindow: 1e400` 只令该 entry 非法。
// 整文件解析失败会让合法条目一并消失,破坏"每个 entry 独立"的判定设计。
let source = r#"{
"providers": {
"healthy": {
"api": "anthropic-messages",
"baseUrl": "https://healthy.example",
"apiKey": "literal",
"models": [{"id": "m"}]
},
"overflow": {
"api": "anthropic-messages",
"baseUrl": "https://overflow.example",
"apiKey": "literal",
"models": [{"id": "m", "contextWindow": 1e400}]
}
}
}"#;
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("models.json");
fs::write(&path, source).expect("write");
let diagnostics = inspect_pi_native_catalog(&path, &BTreeMap::new())
.expect("one malformed entry must not fail the whole catalog");
assert_eq!(diagnostics.len(), 2, "both entries must still be reported");
let healthy = diagnostics
.iter()
.find(|diagnostic| diagnostic.provider_key == "healthy")
.expect("healthy entry present");
assert_eq!(
healthy.raw_validity,
PiRawNativeValidity::Valid,
"a legal sibling must not be hidden by a malformed entry"
);
assert_eq!(healthy.management_status, PiManagementStatus::Importable);
let overflow = diagnostics
.iter()
.find(|diagnostic| diagnostic.provider_key == "overflow")
.expect("overflow entry present");
assert_ne!(
overflow.raw_validity,
PiRawNativeValidity::Valid,
"the out-of-range contextWindow entry itself must not be judged valid"
);
}
+324
View File
@@ -0,0 +1,324 @@
//! Exact-field access to Pi's shared `settings.json`.
//!
//! cc-switch owns only `defaultProvider` and `defaultModel`. Every other field
//! remains Pi/user-owned and survives each mutation unchanged.
use crate::error::AppError;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
use super::shared_file::{
compare_exchange_shared_file_bytes, delete_shared_file, read_shared_file, replace_shared_file,
SharedFileSnapshot,
};
const MAX_PI_SETTINGS_BYTES: u64 = 1024 * 1024;
const MAX_WRITE_ATTEMPTS: usize = 3;
static SETTINGS_WRITE_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PiNativeDefaults {
#[serde(skip_serializing_if = "Option::is_none")]
pub default_provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_dir: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PiNativeDefaultsReceipt {
path: PathBuf,
before: SharedFileSnapshot,
after: SharedFileSnapshot,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PiNativeDefaultsRollback {
Restored,
Superseded,
}
impl PiNativeDefaultsReceipt {
/// Restore the exact file revision replaced by this write. A newer Pi/user
/// edit wins and is reported as Superseded rather than being overwritten.
pub(crate) fn rollback(&self) -> Result<PiNativeDefaultsRollback, AppError> {
let result = match self.before.bytes.as_deref() {
Some(bytes) => replace_shared_file(
&self.path,
&self.after.revision,
bytes,
MAX_PI_SETTINGS_BYTES,
None,
"Pi settings rollback",
)
.map(|_| ()),
None => delete_shared_file(
&self.path,
&self.after.revision,
MAX_PI_SETTINGS_BYTES,
"Pi settings rollback",
)
.map(|_| ()),
};
match result {
Ok(()) => Ok(PiNativeDefaultsRollback::Restored),
Err(AppError::Conflict(_)) => Ok(PiNativeDefaultsRollback::Superseded),
Err(error) => Err(error),
}
}
}
pub(crate) fn get_pi_settings_path() -> Result<PathBuf, AppError> {
Ok(super::native::get_pi_agent_dir()?.join("settings.json"))
}
pub(crate) fn read_pi_native_defaults() -> Result<PiNativeDefaults, AppError> {
read_pi_native_defaults_at(&get_pi_settings_path()?)
}
pub(crate) fn read_pi_native_defaults_at(path: &Path) -> Result<PiNativeDefaults, AppError> {
let document = read_settings_document(path)?;
let root = document.as_object().ok_or_else(|| {
AppError::Config(format!(
"Pi settings root must be an object: {}",
path.display()
))
})?;
Ok(PiNativeDefaults {
default_provider: optional_string(root, "defaultProvider", path)?,
default_model: optional_string(root, "defaultModel", path)?,
session_dir: optional_string(root, "sessionDir", path)?,
})
}
pub(crate) fn set_pi_native_default_with_receipt(
provider_key: &str,
model_id: &str,
) -> Result<PiNativeDefaultsReceipt, AppError> {
if provider_key.trim().is_empty() || model_id.trim().is_empty() {
return Err(AppError::InvalidInput(
"Pi default provider and model must be non-empty".to_string(),
));
}
mutate_settings_document(&get_pi_settings_path()?, |root| {
root.insert(
"defaultProvider".to_string(),
Value::String(provider_key.to_string()),
);
root.insert(
"defaultModel".to_string(),
Value::String(model_id.to_string()),
);
Ok(())
})
}
fn optional_string(
root: &Map<String, Value>,
key: &str,
path: &Path,
) -> Result<Option<String>, AppError> {
match root.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(value)) => Ok(Some(value.clone())),
Some(_) => Err(AppError::Config(format!(
"Pi settings field '{key}' must be a string: {}",
path.display()
))),
}
}
fn mutate_settings_document(
path: &Path,
mut mutator: impl FnMut(&mut Map<String, Value>) -> Result<(), AppError>,
) -> Result<PiNativeDefaultsReceipt, AppError> {
let _guard = SETTINGS_WRITE_LOCK
.lock()
.map_err(|error| AppError::Config(format!("Pi settings lock is poisoned: {error}")))?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| AppError::io(parent, error))?;
}
for _ in 0..MAX_WRITE_ATTEMPTS {
let before = read_shared_file(path, MAX_PI_SETTINGS_BYTES, "Pi settings")?;
let mut document = match before.bytes.as_deref() {
Some(bytes) => {
serde_json::from_slice(bytes).map_err(|error| AppError::json(path, error))?
}
None => Value::Object(Map::new()),
};
let root = document.as_object_mut().ok_or_else(|| {
AppError::Config(format!(
"Pi settings root must be an object: {}",
path.display()
))
})?;
mutator(root)?;
let mut serialized = serde_json::to_vec_pretty(&document)
.map_err(|source| AppError::JsonSerialize { source })?;
serialized.push(b'\n');
match compare_exchange_shared_file_bytes(
path,
before.bytes.as_deref(),
&serialized,
MAX_PI_SETTINGS_BYTES,
None,
"Pi settings",
) {
Ok(after) => {
return Ok(PiNativeDefaultsReceipt {
path: path.to_path_buf(),
before,
after,
})
}
Err(AppError::Conflict(_)) => continue,
Err(error) => return Err(error),
}
}
Err(AppError::Conflict(format!(
"Pi settings changed concurrently too many times: {}",
path.display()
)))
}
fn read_settings_document(path: &Path) -> Result<Value, AppError> {
match read_shared_file(path, MAX_PI_SETTINGS_BYTES, "Pi settings")?.bytes {
Some(bytes) => serde_json::from_slice(&bytes).map_err(|error| AppError::json(path, error)),
None => Ok(Value::Object(Map::new())),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn default_patch_preserves_every_unowned_field() {
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("settings.json");
fs::write(
&path,
serde_json::to_vec_pretty(&json!({
"theme": "custom",
"packages": ["npm:foreign"],
"sessionDir": "/tmp/pi-sessions",
"defaultProvider": "old",
"defaultModel": "old-model"
}))
.expect("serialize"),
)
.expect("write");
mutate_settings_document(&path, |root| {
root.insert("defaultProvider".into(), json!("managed"));
root.insert("defaultModel".into(), json!("model"));
Ok(())
})
.expect("mutate");
let saved: Value = serde_json::from_slice(&fs::read(&path).expect("read")).expect("parse");
assert_eq!(saved["theme"], "custom");
assert_eq!(saved["packages"], json!(["npm:foreign"]));
assert_eq!(saved["sessionDir"], "/tmp/pi-sessions");
assert_eq!(saved["defaultProvider"], "managed");
assert_eq!(saved["defaultModel"], "model");
}
#[test]
fn external_rename_during_settings_patch_is_reparsed_before_retry() {
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("settings.json");
fs::write(
&path,
br#"{"theme":"before","defaultProvider":"old","defaultModel":"old"}"#,
)
.expect("seed");
crate::pi_config::shared_file::replace_before_next_compare_exchange(
&path,
br#"{"theme":"external","packages":["foreign"],"defaultProvider":"old","defaultModel":"old"}"#,
);
mutate_settings_document(&path, |root| {
root.insert("defaultProvider".into(), json!("managed"));
root.insert("defaultModel".into(), json!("model"));
Ok(())
})
.expect("retry mutation");
let saved: Value = serde_json::from_slice(&fs::read(&path).expect("read")).expect("parse");
assert_eq!(saved["theme"], "external");
assert_eq!(saved["packages"], json!(["foreign"]));
assert_eq!(saved["defaultProvider"], "managed");
assert_eq!(saved["defaultModel"], "model");
}
#[cfg(unix)]
#[test]
fn settings_symlink_is_rejected() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().expect("tempdir");
let target = temp.path().join("target.json");
let path = temp.path().join("settings.json");
fs::write(&target, "{}").expect("target");
symlink(&target, &path).expect("symlink");
assert!(read_pi_native_defaults_at(&path).is_err());
}
#[test]
fn rollback_receipt_never_overwrites_a_newer_external_default() {
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("settings.json");
fs::write(
&path,
serde_json::to_vec_pretty(&json!({
"theme": "before",
"defaultProvider": "old",
"defaultModel": "old-model"
}))
.expect("serialize"),
)
.expect("write");
let receipt = mutate_settings_document(&path, |root| {
root.insert("defaultProvider".into(), json!("attempted"));
root.insert("defaultModel".into(), json!("attempted-model"));
Ok(())
})
.expect("write attempted defaults");
fs::write(
&path,
serde_json::to_vec_pretty(&json!({
"theme": "external",
"defaultProvider": "external",
"defaultModel": "external-model"
}))
.expect("serialize external"),
)
.expect("external write");
assert_eq!(
receipt.rollback().expect("rollback decision"),
PiNativeDefaultsRollback::Superseded
);
assert_eq!(
read_pi_native_defaults_at(&path)
.expect("live defaults")
.default_provider
.as_deref(),
Some("external")
);
assert_eq!(
read_settings_document(&path).expect("live document")["theme"],
"external"
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Prompt {
pub id: String,
pub name: String,
+2
View File
@@ -26,6 +26,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
AppType::OpenCode => get_opencode_dir(),
AppType::OpenClaw => get_openclaw_dir(),
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
AppType::Pi => crate::pi_config::native::get_pi_agent_dir()?,
AppType::ClaudeDesktop => unreachable!("handled above"),
};
@@ -35,6 +36,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
AppType::Gemini => "GEMINI.md",
AppType::GrokBuild | AppType::OpenCode | AppType::OpenClaw => "AGENTS.md",
AppType::Hermes => "SOUL.md",
AppType::Pi => "AGENTS.md",
AppType::ClaudeDesktop => unreachable!("handled above"),
};
+4
View File
@@ -346,6 +346,10 @@ impl Provider {
str_at(settings.get("base_url")),
str_at(settings.get("api_key")),
),
AppType::Pi => (
str_at(settings.get("baseUrl")),
str_at(settings.get("apiKey")),
),
// OpenClaw (openclaw.json) flattens credentials at the top level, camelCase.
AppType::OpenClaw => (
str_at(settings.get("baseUrl")),
+7
View File
@@ -4,6 +4,7 @@
use crate::app_config::AppType;
use crate::proxy::usage::parser::TokenUsage;
use crate::proxy::usage::InputTokenSemantics;
use serde_json::Value;
/// 使用量解析器类型别名
@@ -31,6 +32,8 @@ pub struct UsageParserConfig {
pub model_extractor: StreamModelExtractor,
/// 流式 usage 事件预过滤器
pub stream_event_filter: Option<StreamUsageEventFilter>,
/// Semantics of `TokenUsage::input_tokens` produced by these parsers.
pub input_token_semantics: InputTokenSemantics,
/// 应用类型字符串(用于日志记录)
pub app_type_str: &'static str,
}
@@ -141,6 +144,7 @@ pub const CLAUDE_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
response_parser: TokenUsage::from_claude_response,
model_extractor: claude_model_extractor,
stream_event_filter: Some(claude_stream_usage_event_filter),
input_token_semantics: InputTokenSemantics::FreshExcludesCache,
app_type_str: "claude",
};
@@ -150,6 +154,7 @@ pub const OPENAI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
response_parser: TokenUsage::from_openai_response,
model_extractor: openai_model_extractor,
stream_event_filter: Some(openai_stream_usage_event_filter),
input_token_semantics: InputTokenSemantics::TotalIncludesCacheBuckets,
app_type_str: "codex",
};
@@ -159,6 +164,7 @@ pub const CODEX_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
response_parser: TokenUsage::from_codex_response_auto,
model_extractor: codex_auto_model_extractor,
stream_event_filter: Some(codex_stream_usage_event_filter),
input_token_semantics: InputTokenSemantics::TotalIncludesCacheBuckets,
app_type_str: "codex",
};
@@ -168,6 +174,7 @@ pub const GEMINI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
response_parser: TokenUsage::from_gemini_response,
model_extractor: gemini_model_extractor,
stream_event_filter: Some(gemini_stream_usage_event_filter),
input_token_semantics: InputTokenSemantics::TotalIncludesCacheBuckets,
app_type_str: "gemini",
};
+11 -1
View File
@@ -39,7 +39,7 @@ use super::{
server::ProxyState,
sse::{strip_sse_field, take_sse_block},
types::*,
usage::parser::TokenUsage,
usage::{parser::TokenUsage, InputTokenSemantics},
ProxyError,
};
use crate::app_config::AppType;
@@ -338,6 +338,7 @@ async fn write_claude_usage_log(state: &ProxyState, log: ClaudeUsageLog) {
&log.model,
&log.request_model,
&log.outbound_model,
InputTokenSemantics::FreshExcludesCache,
log.usage,
log.latency_ms,
None,
@@ -465,6 +466,7 @@ async fn handle_claude_transform(
&model,
&request_model,
&outbound_model,
InputTokenSemantics::FreshExcludesCache,
usage,
latency_ms,
first_token_ms,
@@ -1133,6 +1135,7 @@ async fn handle_codex_responses_namespace_restore(
&model,
&request_model,
&outbound_model,
InputTokenSemantics::TotalIncludesCacheBuckets,
usage,
latency_ms,
None,
@@ -1245,6 +1248,7 @@ async fn handle_codex_chat_to_responses_transform(
&model,
&request_model,
&outbound_model,
InputTokenSemantics::TotalIncludesCacheBuckets,
usage,
latency_ms,
first_token_ms,
@@ -1366,6 +1370,7 @@ async fn handle_codex_chat_to_responses_transform(
&model,
&request_model,
&outbound_model,
InputTokenSemantics::TotalIncludesCacheBuckets,
usage,
latency_ms,
None,
@@ -1531,6 +1536,7 @@ async fn handle_codex_anthropic_to_responses_transform(
&model,
&request_model,
&outbound_model,
InputTokenSemantics::TotalIncludesCacheBuckets,
usage,
latency_ms,
None,
@@ -1618,6 +1624,7 @@ fn build_codex_anthropic_sse_response(
&model,
&request_model,
&outbound_model,
InputTokenSemantics::TotalIncludesCacheBuckets,
usage,
latency_ms,
first_token_ms,
@@ -2590,6 +2597,7 @@ fn log_forward_error(
is_streaming,
Some(ctx.session_id.clone()),
None,
InputTokenSemantics::FreshExcludesCache,
) {
log::warn!("记录失败请求日志失败: {e}");
}
@@ -2607,6 +2615,7 @@ async fn log_usage(
model: &str,
request_model: &str,
outbound_model: &str,
input_token_semantics: InputTokenSemantics,
usage: TokenUsage,
latency_ms: u64,
first_token_ms: Option<u64>,
@@ -2640,6 +2649,7 @@ async fn log_usage(
model.to_string(),
request_model.to_string(),
pricing_model.to_string(),
input_token_semantics,
usage,
multiplier,
latency_ms,
+2
View File
@@ -21,6 +21,8 @@ pub(crate) mod json_canonical;
pub mod log_codes;
pub mod media_sanitizer;
pub mod model_mapper;
pub(crate) mod pi_handler;
pub(crate) mod pi_runtime;
pub mod provider_router;
pub mod providers;
pub mod response_processor;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -29,6 +29,16 @@ impl ProviderRouter {
}
}
async fn app_proxy_config(
&self,
app_type: &str,
) -> Result<crate::proxy::types::AppProxyConfig, AppError> {
if app_type == AppType::Pi.as_str() {
return Ok(crate::settings::get_pi_app_proxy_config());
}
self.db.get_proxy_config_for_app(app_type).await
}
/// 选择可用的供应商(支持故障转移)
///
/// 返回按优先级排序的可用供应商列表:
@@ -40,7 +50,7 @@ impl ProviderRouter {
let mut circuit_open_count = 0usize;
// 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取)
let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await {
let auto_failover_enabled = match self.app_proxy_config(app_type).await {
Ok(config) => config.auto_failover_enabled,
Err(e) => {
log::error!("[{app_type}] 读取 proxy_config 失败: {e},默认禁用故障转移");
@@ -132,7 +142,7 @@ impl ProviderRouter {
error_msg: Option<String>,
) -> Result<(), AppError> {
// 1. 按应用独立获取熔断器配置
let failure_threshold = match self.db.get_proxy_config_for_app(app_type).await {
let failure_threshold = match self.app_proxy_config(app_type).await {
Ok(app_config) => app_config.circuit_failure_threshold,
Err(_) => 5, // 默认值
};
@@ -251,7 +261,7 @@ impl ProviderRouter {
let app_type = key.split(':').next().unwrap_or("claude");
// 按应用独立读取熔断器配置
let config = match self.db.get_proxy_config_for_app(app_type).await {
let config = match self.app_proxy_config(app_type).await {
Ok(app_config) => crate::proxy::circuit_breaker::CircuitBreakerConfig {
failure_threshold: app_config.circuit_failure_threshold,
success_threshold: app_config.circuit_success_threshold,
+10 -2
View File
@@ -205,7 +205,11 @@ impl ProviderType {
ProviderType::Gemini
}
AppType::GrokBuild => ProviderType::Codex,
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => ProviderType::Codex,
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::Pi => {
// Generic callers cannot infer Pi's wire family from AppType;
// the dedicated Pi runtime routes by effective model API.
ProviderType::Codex
}
}
}
@@ -259,7 +263,11 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
AppType::Codex => Box::new(CodexAdapter::new()),
AppType::Gemini => Box::new(GeminiAdapter::new()),
AppType::GrokBuild => Box::new(CodexAdapter::new()),
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Box::new(CodexAdapter::new()),
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::Pi => {
// Pi requests use the dedicated per-model adapter path. Keep the
// generic fallback deterministic for non-routing utilities.
Box::new(CodexAdapter::new())
}
}
}
+16 -5
View File
@@ -254,11 +254,11 @@ pub async fn handle_non_streaming(
spawn_log_usage(
state,
ctx,
parser_config.input_token_semantics,
usage,
&model,
&ctx.request_model,
status.as_u16(),
false,
);
} else {
let model = json_value
@@ -271,11 +271,11 @@ pub async fn handle_non_streaming(
spawn_log_usage(
state,
ctx,
parser_config.input_token_semantics,
TokenUsage::default(),
&model,
&ctx.request_model,
status.as_u16(),
false,
);
log::debug!(
"[{}] 未能解析 usage 信息,跳过记录",
@@ -291,11 +291,11 @@ pub async fn handle_non_streaming(
spawn_log_usage(
state,
ctx,
parser_config.input_token_semantics,
TokenUsage::default(),
ctx.outbound_model.as_deref().unwrap_or(&ctx.request_model),
&ctx.request_model,
status.as_u16(),
false,
);
}
} else {
@@ -488,6 +488,7 @@ pub(crate) fn create_usage_collector(
let start_time = ctx.start_time;
let stream_parser = parser_config.stream_parser;
let model_extractor = parser_config.model_extractor;
let input_token_semantics = parser_config.input_token_semantics;
let session_id = ctx.session_id.clone();
Some(SseUsageCollector::new(
@@ -512,6 +513,7 @@ pub(crate) fn create_usage_collector(
&model,
&request_model,
&outbound_model,
input_token_semantics,
usage,
latency_ms,
first_token_ms,
@@ -538,6 +540,7 @@ pub(crate) fn create_usage_collector(
&model,
&request_model,
&outbound_model,
input_token_semantics,
TokenUsage::default(),
latency_ms,
first_token_ms,
@@ -557,11 +560,11 @@ pub(crate) fn create_usage_collector(
fn spawn_log_usage(
state: &ProxyState,
ctx: &RequestContext,
input_token_semantics: super::usage::InputTokenSemantics,
usage: TokenUsage,
model: &str,
request_model: &str,
status_code: u16,
is_streaming: bool,
) {
// Check enable_logging before spawning the log task
if let Ok(config) = state.config.try_read() {
@@ -591,10 +594,11 @@ fn spawn_log_usage(
&model,
&request_model,
&outbound_model,
input_token_semantics,
usage,
latency_ms,
None,
is_streaming,
false,
status_code,
Some(session_id),
)
@@ -624,6 +628,7 @@ async fn log_usage_internal(
model: &str,
request_model: &str,
outbound_model: &str,
input_token_semantics: super::usage::InputTokenSemantics,
usage: TokenUsage,
latency_ms: u64,
first_token_ms: Option<u64>,
@@ -661,6 +666,7 @@ async fn log_usage_internal(
model.to_string(),
request_model.to_string(),
pricing_model.to_string(),
input_token_semantics,
usage,
multiplier,
latency_ms,
@@ -1001,6 +1007,8 @@ mod tests {
codex_chat_history: Arc::new(CodexChatHistoryStore::default()),
app_handle: None,
failover_manager: Arc::new(FailoverSwitchManager::new(db)),
pi_runtime: Arc::new(crate::proxy::pi_runtime::PiRuntimeStore::default()),
pi_server_generation: 0,
}
}
@@ -1072,6 +1080,7 @@ mod tests {
"resp-model",
"req-model",
"req-model",
crate::proxy::usage::InputTokenSemantics::FreshExcludesCache,
usage,
10,
None,
@@ -1142,6 +1151,7 @@ mod tests {
"resp-model",
"req-model",
"outbound-model",
crate::proxy::usage::InputTokenSemantics::FreshExcludesCache,
usage,
10,
None,
@@ -1222,6 +1232,7 @@ mod tests {
"resp-model",
"req-model",
"req-model",
crate::proxy::usage::InputTokenSemantics::FreshExcludesCache,
usage,
10,
None,
+22
View File
@@ -12,6 +12,7 @@ use super::{
failover_switch::FailoverSwitchManager,
handlers,
log_codes::srv as log_srv,
pi_runtime::PiRuntimeStore,
provider_router::ProviderRouter,
providers::{codex_chat_history::CodexChatHistoryStore, gemini_shadow::GeminiShadowStore},
types::*,
@@ -48,6 +49,11 @@ pub struct ProxyState {
pub app_handle: Option<tauri::AppHandle>,
/// 故障转移切换管理器
pub failover_manager: Arc<FailoverSwitchManager>,
/// Immutable Pi catalog publication point shared with `ProxyService`.
pub pi_runtime: Arc<PiRuntimeStore>,
/// Listener instance identity. A runtime built for an older listener can
/// never admit requests through this state.
pub pi_server_generation: u64,
}
/// 代理HTTP服务器
@@ -57,6 +63,7 @@ pub struct ProxyServer {
shutdown_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>,
/// 服务器任务句柄,用于等待服务器实际关闭
server_handle: Arc<RwLock<Option<JoinHandle<()>>>>,
pi_server_generation: u64,
}
impl ProxyServer {
@@ -64,6 +71,8 @@ impl ProxyServer {
config: ProxyConfig,
db: Arc<Database>,
app_handle: Option<tauri::AppHandle>,
pi_runtime: Arc<PiRuntimeStore>,
pi_server_generation: u64,
) -> Self {
// 创建共享的 ProviderRouter(熔断器状态将跨所有请求保持)
let provider_router = Arc::new(ProviderRouter::new(db.clone()));
@@ -81,6 +90,8 @@ impl ProxyServer {
codex_chat_history: Arc::new(CodexChatHistoryStore::default()),
app_handle,
failover_manager,
pi_runtime,
pi_server_generation,
};
Self {
@@ -88,9 +99,14 @@ impl ProxyServer {
state,
shutdown_tx: Arc::new(RwLock::new(None)),
server_handle: Arc::new(RwLock::new(None)),
pi_server_generation,
}
}
pub(crate) fn pi_server_generation(&self) -> u64 {
self.pi_server_generation
}
pub async fn start(&self) -> Result<ProxyServerInfo, ProxyError> {
// 检查是否已在运行
if self.shutdown_tx.read().await.is_some() {
@@ -364,6 +380,12 @@ impl ProxyServer {
.route("/gemini/v1beta/*path", any(handlers::handle_gemini))
// Gemini 的 GA 版本也叫 /v1,给原 SDK 留一条出口
.route("/gemini/v1/*path", any(handlers::handle_gemini))
// Pi native SDK requests retain their family-specific path below
// the opaque provider route token.
.route(
"/pi/:route_token/*path",
any(super::pi_handler::handle_pi_native),
)
// 提高默认请求体大小限制(避免 413 Payload Too Large
.layer(DefaultBodyLimit::max(200 * 1024 * 1024))
.with_state(self.state.clone())
+11
View File
@@ -116,6 +116,17 @@ pub struct ProxyTakeoverStatus {
pub grokbuild: bool,
pub opencode: bool,
pub openclaw: bool,
pub pi: bool,
pub pi_operational_state: PiTakeoverOperationalState,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PiTakeoverOperationalState {
#[default]
Disabled,
Active,
Degraded,
}
/// Provider健康状态
+32 -20
View File
@@ -3,6 +3,7 @@
//! 使用高精度 Decimal 类型避免浮点数精度问题
use super::parser::TokenUsage;
use super::semantics::InputTokenSemantics;
use rust_decimal::Decimal;
use std::str::FromStr;
@@ -46,13 +47,17 @@ impl CostCalculator {
pricing: &ModelPricing,
cost_multiplier: Decimal,
) -> CostBreakdown {
Self::calculate_with_cache_semantics(usage, pricing, cost_multiplier, false)
Self::calculate_with_input_semantics(
InputTokenSemantics::FreshExcludesCache,
usage,
pricing,
cost_multiplier,
)
}
/// 按 app_type 选择输入 token 语义后计算成本。
///
/// Codex/OpenAI Responses 与 Gemini 的输入 token 字段包含 cache read 部分;
/// Claude/Anthropic 的 input_tokens 已经是 fresh input。
/// Compatibility helper for existing callers. Live request paths use
/// [`Self::calculate_with_input_semantics`] so product app ownership never
/// stands in for the actual response parser/wire family.
pub fn calculate_for_app(
app_type: &str,
usage: &TokenUsage,
@@ -61,32 +66,37 @@ impl CostCalculator {
) -> CostBreakdown {
let input_includes_cache_read =
crate::services::sql_helpers::is_cache_inclusive_app(app_type);
Self::calculate_with_cache_semantics(
Self::calculate_with_input_semantics(
if input_includes_cache_read {
InputTokenSemantics::TotalIncludesCacheBuckets
} else {
InputTokenSemantics::FreshExcludesCache
},
usage,
pricing,
cost_multiplier,
input_includes_cache_read,
)
}
fn calculate_with_cache_semantics(
pub fn calculate_with_input_semantics(
input_semantics: InputTokenSemantics,
usage: &TokenUsage,
pricing: &ModelPricing,
cost_multiplier: Decimal,
input_includes_cache_read: bool,
) -> CostBreakdown {
let million = Decimal::from(1_000_000);
// OpenAI/Gemini 风格的 input_tokens 包含缓存读取和写入,需要扣除后再按输入价计费;
// Claude/Anthropic 风格的 input_tokens 已经是 fresh input,不能再次扣减。
let billable_input_tokens = if input_includes_cache_read {
usage
.input_tokens
.saturating_sub(usage.cache_read_tokens)
.saturating_sub(usage.cache_creation_tokens)
} else {
usage.input_tokens
};
let billable_input_tokens =
if input_semantics == InputTokenSemantics::TotalIncludesCacheBuckets {
usage
.input_tokens
.saturating_sub(usage.cache_read_tokens)
.saturating_sub(usage.cache_creation_tokens)
} else {
usage.input_tokens
};
// 各项基础成本(不含倍率)
let input_cost =
@@ -112,13 +122,15 @@ impl CostCalculator {
}
}
pub fn try_calculate_for_app(
app_type: &str,
pub fn try_calculate_with_input_semantics(
input_semantics: InputTokenSemantics,
usage: &TokenUsage,
pricing: Option<&ModelPricing>,
cost_multiplier: Decimal,
) -> Option<CostBreakdown> {
pricing.map(|p| Self::calculate_for_app(app_type, usage, p, cost_multiplier))
pricing.map(|pricing| {
Self::calculate_with_input_semantics(input_semantics, usage, pricing, cost_multiplier)
})
}
}
+21 -10
View File
@@ -2,9 +2,9 @@
use super::calculator::{CostBreakdown, CostCalculator, ModelPricing};
use super::parser::TokenUsage;
use super::semantics::InputTokenSemantics;
use crate::database::{Database, PRICING_SOURCE_REQUEST, PRICING_SOURCE_RESPONSE};
use crate::error::AppError;
use crate::services::sql_helpers::{INPUT_TOKEN_SEMANTICS_FRESH, INPUT_TOKEN_SEMANTICS_TOTAL};
use crate::services::usage_stats::{find_model_pricing_row, is_placeholder_pricing_model};
use rusqlite::OptionalExtension;
use rust_decimal::Decimal;
@@ -72,6 +72,9 @@ pub struct RequestLog {
/// 用 model/request_model 猜——路由接管下三者可能各不相同。
/// 错误行(未计价)为空字符串。
pub pricing_model: String,
/// Copied from the response parser/wire family at request admission.
/// Product app ownership is intentionally not consulted at write time.
pub input_token_semantics: InputTokenSemantics,
pub usage: TokenUsage,
pub cost: Option<CostBreakdown>,
pub latency_ms: u64,
@@ -121,12 +124,7 @@ impl<'a> UsageLogger<'a> {
};
let created_at = chrono::Utc::now().timestamp();
let input_token_semantics =
if crate::services::sql_helpers::is_cache_inclusive_app(log.app_type.as_str()) {
INPUT_TOKEN_SEMANTICS_TOTAL
} else {
INPUT_TOKEN_SEMANTICS_FRESH
};
let input_token_semantics = log.input_token_semantics.stored_value();
let semantic = UsageSemantic::from_log(log, input_token_semantics);
let existing = Self::load_existing_semantic(&conn, &log.request_id)?;
@@ -266,6 +264,7 @@ impl<'a> UsageLogger<'a> {
status_code: u16,
error_message: String,
latency_ms: u64,
input_token_semantics: InputTokenSemantics,
) -> Result<(), AppError> {
let request_model = model.clone();
let log = RequestLog {
@@ -276,6 +275,7 @@ impl<'a> UsageLogger<'a> {
request_model,
// 错误行未经过计价,留空(回填的 has_usage 闸门也不会碰全 0 行)
pricing_model: String::new(),
input_token_semantics,
usage: TokenUsage::default(),
cost: None,
latency_ms,
@@ -307,6 +307,7 @@ impl<'a> UsageLogger<'a> {
is_streaming: bool,
session_id: Option<String>,
provider_type: Option<String>,
input_token_semantics: InputTokenSemantics,
) -> Result<(), AppError> {
let request_model = model.clone();
let log = RequestLog {
@@ -317,6 +318,7 @@ impl<'a> UsageLogger<'a> {
request_model,
// 错误行未经过计价,留空(回填的 has_usage 闸门也不会碰全 0 行)
pricing_model: String::new(),
input_token_semantics,
usage: TokenUsage::default(),
cost: None,
latency_ms,
@@ -451,6 +453,7 @@ impl<'a> UsageLogger<'a> {
model: String,
request_model: String,
pricing_model: String,
input_token_semantics: InputTokenSemantics,
usage: TokenUsage,
cost_multiplier: Decimal,
latency_ms: u64,
@@ -471,8 +474,8 @@ impl<'a> UsageLogger<'a> {
log::warn!("[USG-002] 模型定价未找到,成本将记录为 0: {pricing_model}");
}
let cost = CostCalculator::try_calculate_for_app(
&app_type,
let cost = CostCalculator::try_calculate_with_input_semantics(
input_token_semantics,
&usage,
pricing.as_ref(),
cost_multiplier,
@@ -485,6 +488,7 @@ impl<'a> UsageLogger<'a> {
model,
request_model,
pricing_model,
input_token_semantics,
usage,
cost,
latency_ms,
@@ -513,6 +517,7 @@ mod tests {
model: "gpt-5.6".to_string(),
request_model: "gpt-5.6".to_string(),
pricing_model: "gpt-5.6".to_string(),
input_token_semantics: InputTokenSemantics::TotalIncludesCacheBuckets,
usage: TokenUsage {
input_tokens,
output_tokens: 5,
@@ -566,6 +571,7 @@ mod tests {
"test-model".to_string(),
"req-model".to_string(),
"test-model".to_string(),
InputTokenSemantics::FreshExcludesCache,
usage,
Decimal::from(1),
100,
@@ -751,6 +757,7 @@ mod tests {
500,
"Internal Server Error".to_string(),
50,
InputTokenSemantics::FreshExcludesCache,
)?;
// 验证错误记录已插入
@@ -778,6 +785,7 @@ mod tests {
model: "grok-4.5".to_string(),
request_model: "grok-4.5".to_string(),
pricing_model: String::new(),
input_token_semantics: InputTokenSemantics::TotalIncludesCacheBuckets,
usage: TokenUsage::default(),
cost: None,
latency_ms: 1,
@@ -798,7 +806,10 @@ mod tests {
[],
|row| row.get(0),
)?;
assert_eq!(semantics, INPUT_TOKEN_SEMANTICS_TOTAL);
assert_eq!(
semantics,
InputTokenSemantics::TotalIncludesCacheBuckets.stored_value()
);
Ok(())
}
}
+3
View File
@@ -5,6 +5,7 @@
pub mod calculator;
pub mod logger;
pub mod parser;
pub mod semantics;
// 仅导出内部使用的类型,避免未使用警告
#[allow(unused_imports)]
@@ -13,3 +14,5 @@ pub use calculator::{CostBreakdown, CostCalculator, ModelPricing};
pub use logger::{RequestLog, UsageLogger};
#[allow(unused_imports)]
pub use parser::TokenUsage;
#[allow(unused_imports)]
pub use semantics::InputTokenSemantics;
+32
View File
@@ -0,0 +1,32 @@
//! Input-token semantics carried with every live proxy request.
//!
//! `app_type` is a product/UI ownership dimension. It must not decide whether
//! an upstream's input count already contains cache buckets: Pi can route the
//! same logical app through four different wire families.
use crate::pi_config::gateway::PiGatewayApiFamily;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i64)]
pub enum InputTokenSemantics {
/// OpenAI Responses/Completions and Google usage totals include cached
/// input. Fresh input is total minus the reported cache buckets.
TotalIncludesCacheBuckets = 1,
/// Anthropic reports fresh input separately from cache reads/creation.
FreshExcludesCache = 2,
}
impl InputTokenSemantics {
pub const fn stored_value(self) -> i64 {
self as i64
}
pub const fn for_pi_family(family: PiGatewayApiFamily) -> Self {
match family {
PiGatewayApiFamily::AnthropicMessages => Self::FreshExcludesCache,
PiGatewayApiFamily::OpenAiCompletions
| PiGatewayApiFamily::OpenAiResponses
| PiGatewayApiFamily::GoogleGenerativeAi => Self::TotalIncludesCacheBuckets,
}
}
}
+4
View File
@@ -138,6 +138,10 @@ impl ConfigService {
AppType::Hermes => {
// Hermes uses additive mode, no live sync needed
}
AppType::Pi => {
// Pi's shared models/settings documents are owned by the
// catalog coordinator, never by this legacy live-sync path.
}
}
Ok(())
+47 -1
View File
@@ -147,6 +147,13 @@ impl McpService {
AppType::Hermes => {
mcp::sync_single_server_to_hermes(&Default::default(), &server.id, &server.server)?;
}
AppType::Pi => {
return Err(AppError::localized(
"mcp.pi.unsupported",
"固定版本的 Pi 核心没有原生 MCP 注册表",
"The pinned Pi core has no native MCP registry",
));
}
}
Ok(())
}
@@ -183,6 +190,13 @@ impl McpService {
AppType::Hermes => {
mcp::remove_server_from_hermes(id)?;
}
AppType::Pi => {
return Err(AppError::localized(
"mcp.pi.unsupported",
"固定版本的 Pi 核心没有原生 MCP 注册表",
"The pinned Pi core has no native MCP registry",
));
}
}
Ok(())
}
@@ -227,7 +241,10 @@ impl McpService {
servers: &IndexMap<String, McpServer>,
app: &AppType,
) -> Result<(), AppError> {
if matches!(app, AppType::OpenClaw | AppType::ClaudeDesktop) {
if matches!(
app,
AppType::OpenClaw | AppType::ClaudeDesktop | AppType::Pi
) {
return Ok(());
}
@@ -544,3 +561,32 @@ impl McpService {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::Database;
use std::sync::Arc;
#[test]
fn global_mcp_projection_treats_pi_as_explicitly_not_applicable() {
let state = AppState::new(Arc::new(Database::memory().expect("database")));
let mut servers = IndexMap::new();
servers.insert(
"example".to_string(),
McpServer {
id: "example".to_string(),
name: "Example".to_string(),
server: serde_json::json!({"command": "example"}),
apps: Default::default(),
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
McpService::project_servers_to_app(&state, &servers, &AppType::Pi)
.expect("Pi is intentionally outside the pinned core MCP registry");
}
}
+3
View File
@@ -8,6 +8,8 @@ pub mod mcp;
pub mod model_fetch;
pub mod model_pricing;
pub mod omo;
pub(crate) mod pi_catalog;
pub mod pi_prompt_files;
pub mod profile;
pub mod prompt;
pub mod provider;
@@ -21,6 +23,7 @@ pub mod session_usage_gemini;
pub mod session_usage_grokbuild;
pub mod session_usage_opencode;
pub mod skill;
pub(crate) mod skill_deployment;
pub mod speedtest;
pub mod sql_helpers;
pub mod stream_check;
File diff suppressed because it is too large Load Diff
+433
View File
@@ -0,0 +1,433 @@
//! Pi native instruction files and prompt templates.
//!
//! AGENTS.md is also the Prompt-library projection. SYSTEM.md and
//! APPEND_SYSTEM.md are direct native resources: file presence is activation
//! and there is no shadow enabled flag.
use crate::error::AppError;
use crate::pi_config::native::get_pi_agent_dir;
use crate::pi_config::shared_file::{delete_shared_file, read_shared_file, replace_shared_file};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock};
use tokio::sync::{Mutex, OwnedMutexGuard};
const MAX_PROMPT_FILE_BYTES: u64 = 1024 * 1024;
const MAX_TEMPLATE_SLUG_BYTES: usize = 128;
static INSTRUCTION_FILE_LOCK: LazyLock<Arc<Mutex<()>>> = LazyLock::new(|| Arc::new(Mutex::new(())));
pub(crate) type PiInstructionFileGuard = OwnedMutexGuard<()>;
pub(crate) fn lock_instruction_files() -> Result<PiInstructionFileGuard, AppError> {
Ok(futures::executor::block_on(
INSTRUCTION_FILE_LOCK.clone().lock_owned(),
))
}
pub(crate) async fn lock_instruction_files_async() -> PiInstructionFileGuard {
INSTRUCTION_FILE_LOCK.clone().lock_owned().await
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PiPromptFileKind {
GlobalContext,
SystemOverride,
SystemAppend,
}
impl PiPromptFileKind {
fn filename(self) -> &'static str {
match self {
Self::GlobalContext => "AGENTS.md",
Self::SystemOverride => "SYSTEM.md",
Self::SystemAppend => "APPEND_SYSTEM.md",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiPromptFileSnapshot {
pub kind: PiPromptFileKind,
pub path: String,
pub exists: bool,
pub revision: String,
pub content: String,
}
pub struct PiPromptFileService;
impl PiPromptFileService {
pub fn read(kind: PiPromptFileKind) -> Result<PiPromptFileSnapshot, AppError> {
let guard = lock_instruction_files()?;
Self::read_under_guard(&guard, kind)
}
pub fn replace(
kind: PiPromptFileKind,
expected_revision: &str,
content: &str,
) -> Result<PiPromptFileSnapshot, AppError> {
if kind == PiPromptFileKind::GlobalContext {
return Err(AppError::InvalidInput(
"Pi AGENTS.md is managed through the Prompt library".to_string(),
));
}
validate_direct_instruction_content(content)?;
let guard = lock_instruction_files()?;
Self::replace_under_guard(&guard, kind, expected_revision, content)
}
pub fn delete(kind: PiPromptFileKind, expected_revision: &str) -> Result<bool, AppError> {
if kind == PiPromptFileKind::GlobalContext {
return Err(AppError::InvalidInput(
"Pi AGENTS.md is managed through the Prompt library".to_string(),
));
}
let guard = lock_instruction_files()?;
Self::delete_under_guard(&guard, kind, expected_revision)
}
pub(crate) fn read_under_guard(
_guard: &PiInstructionFileGuard,
kind: PiPromptFileKind,
) -> Result<PiPromptFileSnapshot, AppError> {
Self::read_at(&get_pi_agent_dir()?, kind)
}
pub(crate) fn replace_under_guard(
_guard: &PiInstructionFileGuard,
kind: PiPromptFileKind,
expected_revision: &str,
content: &str,
) -> Result<PiPromptFileSnapshot, AppError> {
Self::replace_at(&get_pi_agent_dir()?, kind, expected_revision, content)
}
pub(crate) fn delete_under_guard(
_guard: &PiInstructionFileGuard,
kind: PiPromptFileKind,
expected_revision: &str,
) -> Result<bool, AppError> {
Self::delete_at(&get_pi_agent_dir()?, kind, expected_revision)
}
fn read_at(root: &Path, kind: PiPromptFileKind) -> Result<PiPromptFileSnapshot, AppError> {
let path = root.join(kind.filename());
let snapshot = read_shared_file(&path, MAX_PROMPT_FILE_BYTES, "Pi prompt file")?;
let exists = snapshot.exists();
let content = match snapshot.bytes {
Some(bytes) => String::from_utf8(bytes).map_err(|error| {
AppError::InvalidInput(format!(
"Pi prompt file must be UTF-8 ({}): {error}",
path.display()
))
})?,
None => String::new(),
};
Ok(PiPromptFileSnapshot {
kind,
path: path.to_string_lossy().into_owned(),
exists,
revision: snapshot.revision,
content,
})
}
fn replace_at(
root: &Path,
kind: PiPromptFileKind,
expected_revision: &str,
content: &str,
) -> Result<PiPromptFileSnapshot, AppError> {
fs::create_dir_all(root).map_err(|error| AppError::io(root, error))?;
let path = root.join(kind.filename());
replace_shared_file(
&path,
expected_revision,
content.as_bytes(),
MAX_PROMPT_FILE_BYTES,
Some(0o600),
"Pi prompt file",
)?;
Self::read_at(root, kind)
}
fn delete_at(
root: &Path,
kind: PiPromptFileKind,
expected_revision: &str,
) -> Result<bool, AppError> {
delete_shared_file(
&root.join(kind.filename()),
expected_revision,
MAX_PROMPT_FILE_BYTES,
"Pi prompt file",
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiPromptTemplate {
pub slug: String,
pub content: String,
pub revision: String,
}
pub struct PiPromptTemplateService;
impl PiPromptTemplateService {
pub fn list() -> Result<Vec<PiPromptTemplate>, AppError> {
Self::list_at(&get_pi_agent_dir()?.join("prompts"))
}
pub fn upsert(
slug: &str,
expected_revision: &str,
content: &str,
) -> Result<PiPromptTemplate, AppError> {
Self::upsert_at(
&get_pi_agent_dir()?.join("prompts"),
slug,
expected_revision,
content,
)
}
pub fn delete(slug: &str, expected_revision: &str) -> Result<bool, AppError> {
validate_template_slug(slug)?;
delete_shared_file(
&template_path(&get_pi_agent_dir()?.join("prompts"), slug),
expected_revision,
MAX_PROMPT_FILE_BYTES,
"Pi prompt template",
)
}
fn list_at(dir: &Path) -> Result<Vec<PiPromptTemplate>, AppError> {
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(AppError::io(dir, error)),
};
let mut templates = Vec::new();
for entry in entries {
let entry = entry.map_err(|error| AppError::io(dir, error))?;
let path = entry.path();
let Some(slug) = path.file_stem().and_then(|value| value.to_str()) else {
continue;
};
if path.extension().and_then(|value| value.to_str()) != Some("md")
|| validate_template_slug(slug).is_err()
{
continue;
}
let snapshot = read_shared_file(&path, MAX_PROMPT_FILE_BYTES, "Pi prompt template")?;
let Some(bytes) = snapshot.bytes else {
continue;
};
let content = String::from_utf8(bytes).map_err(|error| {
AppError::InvalidInput(format!(
"Pi prompt template must be UTF-8 ({}): {error}",
path.display()
))
})?;
templates.push(PiPromptTemplate {
slug: slug.to_string(),
content,
revision: snapshot.revision,
});
}
templates.sort_by(|left, right| left.slug.cmp(&right.slug));
Ok(templates)
}
fn upsert_at(
dir: &Path,
slug: &str,
expected_revision: &str,
content: &str,
) -> Result<PiPromptTemplate, AppError> {
validate_template_slug(slug)?;
fs::create_dir_all(dir).map_err(|error| AppError::io(dir, error))?;
let snapshot = replace_shared_file(
&template_path(dir, slug),
expected_revision,
content.as_bytes(),
MAX_PROMPT_FILE_BYTES,
Some(0o600),
"Pi prompt template",
)?;
Ok(PiPromptTemplate {
slug: slug.to_string(),
content: content.to_string(),
revision: snapshot.revision,
})
}
}
fn template_path(dir: &Path, slug: &str) -> PathBuf {
dir.join(format!("{slug}.md"))
}
fn validate_direct_instruction_content(content: &str) -> Result<(), AppError> {
if content.trim().is_empty() {
Err(AppError::InvalidInput(
"Pi SYSTEM.md and APPEND_SYSTEM.md content cannot be blank; delete the file to deactivate it"
.to_string(),
))
} else {
Ok(())
}
}
fn validate_template_slug(slug: &str) -> Result<(), AppError> {
let valid = !slug.is_empty()
&& slug.len() <= MAX_TEMPLATE_SLUG_BYTES
&& slug != "."
&& slug != ".."
&& slug.trim() == slug
&& !slug.starts_with('.')
&& !slug.ends_with('.')
&& !slug
.chars()
.any(|character| character.is_control() || matches!(character, '/' | '\\'));
if valid {
Ok(())
} else {
Err(AppError::InvalidInput(
"Pi prompt-template slug must be one visible filename (1-128 UTF-8 bytes)".to_string(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_instruction_files_use_presence_and_revision_as_native_state() {
let temp = tempfile::tempdir().expect("tempdir");
for kind in [
PiPromptFileKind::GlobalContext,
PiPromptFileKind::SystemOverride,
PiPromptFileKind::SystemAppend,
] {
let missing = PiPromptFileService::read_at(temp.path(), kind).expect("missing");
assert!(!missing.exists);
// scripts/pi-transport-capture.mjs executes pinned Pi's
// DefaultResourceLoader at
// ab366ebe94cacd419d986be454f12b1b9913aaca and records all three
// zero-byte files as present resources.
let empty = PiPromptFileService::replace_at(temp.path(), kind, "missing", "")
.expect("create empty instruction file");
assert!(empty.exists);
assert_eq!(empty.content, "");
let saved =
PiPromptFileService::replace_at(temp.path(), kind, &empty.revision, "content")
.expect("replace");
assert!(saved.exists);
assert_eq!(saved.content, "content");
assert!(PiPromptFileService::delete_at(temp.path(), kind, "missing").is_err());
assert!(
PiPromptFileService::delete_at(temp.path(), kind, &saved.revision).expect("delete")
);
}
}
#[test]
fn direct_instruction_save_rejects_blank_content_without_redefining_native_presence() {
for content in ["", " \n\t"] {
assert!(validate_direct_instruction_content(content).is_err());
}
assert!(validate_direct_instruction_content("# Explicit override").is_ok());
}
#[cfg(unix)]
#[test]
fn direct_instruction_entry_never_reports_failure_with_its_attempt_live() {
let temp = tempfile::tempdir().expect("tempdir");
let path = temp
.path()
.join(PiPromptFileKind::SystemOverride.filename());
crate::pi_config::shared_file::fail_next_parent_sync_for_test(&path);
PiPromptFileService::replace_at(
temp.path(),
PiPromptFileKind::SystemOverride,
"missing",
"created",
)
.expect_err("failed create must be compensated");
assert!(!path.exists());
let before = PiPromptFileService::replace_at(
temp.path(),
PiPromptFileKind::SystemOverride,
"missing",
"before",
)
.expect("seed");
crate::pi_config::shared_file::fail_next_parent_sync_for_test(&path);
PiPromptFileService::replace_at(
temp.path(),
PiPromptFileKind::SystemOverride,
&before.revision,
"after",
)
.expect_err("failed replace must restore its before-image");
assert_eq!(
fs::read_to_string(&path).expect("before restored"),
"before"
);
let before = PiPromptFileService::read_at(temp.path(), PiPromptFileKind::SystemOverride)
.expect("snapshot");
crate::pi_config::shared_file::fail_next_parent_sync_for_test(&path);
PiPromptFileService::delete_at(
temp.path(),
PiPromptFileKind::SystemOverride,
&before.revision,
)
.expect_err("failed delete must restore its before-image");
assert_eq!(
fs::read_to_string(&path).expect("before restored"),
"before"
);
}
#[test]
fn templates_reject_ambiguous_or_traversing_slugs() {
for slug in [
"",
".",
"..",
".hidden",
"trailing.",
" padded",
"a/b",
r"a\b",
] {
assert!(validate_template_slug(slug).is_err(), "{slug:?}");
}
for slug in ["review-pr", "release.v2", "评审", "SYSTEM"] {
assert!(validate_template_slug(slug).is_ok(), "{slug:?}");
}
}
#[test]
fn empty_template_is_present_and_round_trips_like_pinned_pi() {
// scripts/pi-transport-capture.mjs executes pinned Pi
// ab366ebe94cacd419d986be454f12b1b9913aaca and confirms that an empty
// prompts/empty.md is discovered as an active template.
let temp = tempfile::tempdir().expect("tempdir");
let created = PiPromptTemplateService::upsert_at(temp.path(), "empty", "missing", "")
.expect("create empty template");
assert_eq!(created.content, "");
let listed = PiPromptTemplateService::list_at(temp.path()).expect("list templates");
assert_eq!(listed, vec![created]);
}
}
File diff suppressed because it is too large Load Diff
+26
View File
@@ -530,6 +530,7 @@ fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet:
| AppType::OpenCode
| AppType::OpenClaw
| AppType::Hermes
| AppType::Pi
| AppType::ClaudeDesktop => false,
}
}
@@ -604,6 +605,7 @@ pub(crate) fn remove_common_config_from_settings(
| AppType::OpenCode
| AppType::OpenClaw
| AppType::Hermes
| AppType::Pi
| AppType::ClaudeDesktop => Ok(settings.clone()),
}
}
@@ -663,6 +665,7 @@ fn apply_common_config_to_settings(
| AppType::OpenCode
| AppType::OpenClaw
| AppType::Hermes
| AppType::Pi
| AppType::ClaudeDesktop => Ok(settings.clone()),
}
}
@@ -1165,6 +1168,13 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
crate::hermes_config::set_provider(&provider.id, provider.settings_config.clone())?;
log::debug!("Hermes provider '{}' written to live config", provider.id);
}
AppType::Pi => {
return Err(AppError::localized(
"pi.live.requires_catalog_coordinator",
"Pi 的共享 models.json 必须通过 Pi 目录协调器写入",
"Pi's shared models.json must be written through the Pi catalog coordinator",
));
}
}
Ok(())
}
@@ -1281,6 +1291,10 @@ fn sync_current_provider_for_app_respecting_takeover(
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
// Sync providers based on mode
for app_type in AppType::all() {
if matches!(app_type, AppType::Pi) {
crate::services::pi_catalog::PiCatalogCoordinator::reconcile_portable_import(state)?;
continue;
}
if app_type.is_additive_mode() {
// Provider rename and every additive live mutation share this
// per-app lock. Acquire it before reading the catalog so a key
@@ -1426,6 +1440,11 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
let config = crate::hermes_config::yaml_to_json(&yaml_config)?;
Ok(config)
}
AppType::Pi => Err(AppError::localized(
"pi.live.requires_catalog_inspection",
"Pi 的共享 models.json 必须通过 Pi 原生目录检查服务读取",
"Pi's shared models.json must be read through the Pi native catalog inspection service",
)),
}
}
@@ -1534,6 +1553,13 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
"config": config_obj
})
}
AppType::Pi => {
return Err(AppError::localized(
"pi.import.requires_catalog_coordinator",
"Pi 原生供应商必须通过 Pi 目录导入流程导入",
"Native Pi providers must be imported through the Pi catalog import flow",
));
}
// OpenCode, OpenClaw and Hermes use additive mode and are handled by early return above
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
unreachable!("additive mode apps are handled by early return")
+334 -7
View File
@@ -20,6 +20,7 @@ use crate::database::{
use crate::error::AppError;
use crate::provider::{Provider, ProviderMutationInput, UsageResult};
use crate::services::mcp::McpService;
use crate::services::pi_catalog::{PiCatalogCoordinator, PiCatalogMutation};
use crate::settings::CustomEndpoint;
use crate::store::AppState;
@@ -478,6 +479,32 @@ mod tests {
}
}
fn pi_provider(id: &str) -> Provider {
Provider {
id: id.to_string(),
name: format!("Pi Provider {id}"),
settings_config: json!({
"name": format!("Pi Provider {id}"),
"api": "openai-responses",
"baseUrl": "https://pi.example/v1",
"apiKey": "test-key",
"models": [
{"id": "model-a", "name": "Model A"},
{"id": "model-b", "name": "Model B"}
]
}),
website_url: None,
category: Some("custom".to_string()),
created_at: Some(1),
sort_index: Some(0),
notes: None,
meta: None,
icon: Some("pi".to_string()),
icon_color: None,
in_failover_queue: false,
}
}
fn opencode_provider(id: &str) -> Provider {
Provider {
id: id.to_string(),
@@ -632,6 +659,174 @@ mod tests {
});
}
#[test]
#[serial]
fn pi_provider_service_create_hydrates_all_endpoints_and_publishes_native_default() {
with_test_home(|state, home| {
let original_settings = crate::settings::get_settings();
let mut isolated_settings = original_settings.clone();
isolated_settings.pi_config_dir = Some(
home.join(".pi")
.join("agent")
.to_string_lossy()
.into_owned(),
);
isolated_settings.current_provider_pi = None;
crate::settings::update_settings(isolated_settings)
.expect("install isolated Pi settings");
let outcome = (|| -> Result<_, AppError> {
let expected_endpoints = HashMap::from([
(
"https://one.pi.example".to_string(),
endpoint("https://one.pi.example", None, Some(11)),
),
(
"https://two.pi.example".to_string(),
endpoint("https://two.pi.example", Some(20), None),
),
]);
let mut provider = pi_provider("managed-pi");
provider.meta = Some(ProviderMeta {
custom_endpoints: expected_endpoints.clone(),
..Default::default()
});
ProviderService::add(
state,
AppType::Pi,
provider_to_mutation_input(provider),
false,
)?;
let aggregate = state
.db
.get_provider_aggregate("pi", "managed-pi")?
.ok_or_else(|| AppError::NotFound("managed Pi aggregate".to_string()))?;
let hydrated = aggregate.endpoints.into_iter().collect::<HashMap<_, _>>();
let models: Value = serde_json::from_slice(
&fs::read(home.join(".pi/agent/models.json"))
.map_err(|error| AppError::io(home, error))?,
)
.map_err(|error| AppError::json(home, error))?;
let defaults = crate::pi_config::native_settings::read_pi_native_defaults()?;
Ok((expected_endpoints, hydrated, models, defaults))
})();
crate::settings::update_settings(original_settings).expect("restore process settings");
let (expected, hydrated, models, defaults) = outcome.expect("Pi service create");
assert_eq!(hydrated, expected);
assert_eq!(
models.pointer("/providers/managed-pi/models/0/id"),
Some(&json!("model-a"))
);
assert_eq!(defaults.default_provider.as_deref(), Some("managed-pi"));
assert_eq!(defaults.default_model.as_deref(), Some("model-a"));
});
}
#[test]
#[serial]
fn pi_provider_service_update_rehomes_a_removed_active_model() {
with_test_home(|state, home| {
let original_settings = crate::settings::get_settings();
let mut isolated_settings = original_settings.clone();
isolated_settings.pi_config_dir = Some(
home.join(".pi")
.join("agent")
.to_string_lossy()
.into_owned(),
);
isolated_settings.current_provider_pi = None;
crate::settings::update_settings(isolated_settings)
.expect("install isolated Pi settings");
let outcome = (|| -> Result<_, AppError> {
let provider = pi_provider("managed-pi-update");
ProviderService::add(
state,
AppType::Pi,
provider_to_mutation_input(provider.clone()),
false,
)?;
let mut updated = provider;
updated.settings_config["models"] = json!([{"id": "model-b", "name": "Model B"}]);
ProviderService::update(
state,
AppType::Pi,
Some("managed-pi-update"),
provider_to_mutation_input(updated),
)?;
let defaults = crate::pi_config::native_settings::read_pi_native_defaults()?;
let models: Value = serde_json::from_slice(
&fs::read(home.join(".pi/agent/models.json"))
.map_err(|error| AppError::io(home, error))?,
)
.map_err(|error| AppError::json(home, error))?;
Ok((defaults, models))
})();
crate::settings::update_settings(original_settings).expect("restore process settings");
let (defaults, models) = outcome.expect("Pi service update");
assert_eq!(
defaults.default_provider.as_deref(),
Some("managed-pi-update")
);
assert_eq!(defaults.default_model.as_deref(), Some("model-b"));
assert_eq!(
models.pointer("/providers/managed-pi-update/models"),
Some(&json!([{"id": "model-b", "name": "Model B"}]))
);
});
}
#[test]
#[serial]
fn pi_provider_service_current_follows_the_native_default_after_external_edit() {
with_test_home(|state, home| {
let original_settings = crate::settings::get_settings();
let mut isolated_settings = original_settings.clone();
isolated_settings.pi_config_dir = Some(
home.join(".pi")
.join("agent")
.to_string_lossy()
.into_owned(),
);
isolated_settings.current_provider_pi = None;
crate::settings::update_settings(isolated_settings)
.expect("install isolated Pi settings");
let outcome = (|| -> Result<_, AppError> {
for provider_id in ["native-first", "native-second"] {
ProviderService::add(
state,
AppType::Pi,
provider_to_mutation_input(pi_provider(provider_id)),
false,
)?;
}
assert_eq!(
state.db.get_current_provider("pi")?.as_deref(),
Some("native-first")
);
crate::pi_config::native_settings::set_pi_native_default_with_receipt(
"native-second",
"model-b",
)?;
ProviderService::current(state, AppType::Pi)
})();
crate::settings::update_settings(original_settings).expect("restore process settings");
assert_eq!(
outcome.expect("resolve current Pi provider"),
"native-second",
"the UI current marker must follow Pi's live settings, not a stale DB marker"
);
});
}
#[test]
#[serial]
fn provider_service_create_canonicalizes_initial_endpoint_identity() {
@@ -2567,8 +2762,13 @@ requires_openai_auth = true
..Default::default()
});
ProviderService::update(&state, AppType::ClaudeDesktop, None, updated.clone())
.expect("update current provider");
ProviderService::update(
&state,
AppType::ClaudeDesktop,
None,
provider_to_mutation_input(updated.clone()),
)
.expect("update current provider");
let backup = db
.get_live_backup("claude-desktop")
@@ -3404,6 +3604,10 @@ impl ProviderService {
if app_type.is_additive_mode() {
return Ok(String::new());
}
if matches!(app_type, AppType::Pi) {
return PiCatalogCoordinator::current_native_provider(state)
.map(|provider| provider.unwrap_or_default());
}
crate::settings::get_effective_current_provider(&state.db, &app_type)
.map(|opt| opt.unwrap_or_default())
}
@@ -3415,6 +3619,18 @@ impl ProviderService {
input: ProviderMutationInput,
add_to_live: bool,
) -> Result<bool, AppError> {
if matches!(app_type, AppType::Pi) {
let provider_key = input.id.clone();
PiCatalogCoordinator::apply(
state,
PiCatalogMutation::CreateProvider {
input,
provider_key,
activate_if_first: true,
},
)?;
return Ok(true);
}
let _provider_mutation_guard = lock_additive_provider_mutation(state, &app_type);
let mut provider: Provider = input.into();
// Normalize Claude model keys
@@ -3473,6 +3689,16 @@ impl ProviderService {
// Reject endpoint-bearing edit payloads before any live or DB side
// effect. Endpoints have their own typed mutation API.
ProviderRowUpdate::from_input(&input)?;
if matches!(app_type, AppType::Pi) {
let original_id = original_id.unwrap_or(input.id.as_str());
if original_id != input.id {
return Err(AppError::InvalidInput(
"Pi provider identity and native projection key cannot be renamed".to_string(),
));
}
PiCatalogCoordinator::apply(state, PiCatalogMutation::UpdateProvider { input })?;
return Ok(true);
}
let _provider_mutation_guard = lock_additive_provider_mutation(state, &app_type);
let mut provider: Provider = input.into();
let original_id = original_id.unwrap_or(provider.id.as_str()).to_string();
@@ -3713,6 +3939,15 @@ impl ProviderService {
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
/// 对于累加模式应用(OpenCode, OpenClaw),可以随时删除任意供应商,同时从 live 配置中移除。
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
if matches!(app_type, AppType::Pi) {
PiCatalogCoordinator::apply(
state,
PiCatalogMutation::DeleteProvider {
provider_id: id.to_string(),
},
)?;
return Ok(());
}
let _provider_mutation_guard = lock_additive_provider_mutation(state, &app_type);
// Additive mode apps - no current provider concept
if app_type.is_additive_mode() {
@@ -3853,6 +4088,32 @@ impl ProviderService {
/// d. Write target provider config to live files
/// e. Sync MCP configuration
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<SwitchResult, AppError> {
if matches!(app_type, AppType::Pi) {
let provider = state
.db
.get_provider_aggregate(AppType::Pi.as_str(), id)?
.ok_or_else(|| AppError::NotFound(format!("Pi provider '{id}'")))?;
let config: crate::pi_config::model::PiManagedProviderConfig =
serde_json::from_value(provider.provider.settings_config).map_err(|error| {
AppError::Config(format!("managed Pi provider '{id}' is invalid: {error}"))
})?;
let model_id = config
.models
.first()
.ok_or_else(|| {
AppError::InvalidInput(format!("Pi provider '{id}' has no selectable models"))
})?
.id
.clone();
PiCatalogCoordinator::apply(
state,
PiCatalogMutation::SetDefault {
provider_id: id.to_string(),
model_id,
},
)?;
return Ok(SwitchResult::default());
}
// The same per-app lock also guards additive provider key changes and
// bulk live sync. Acquire it before observing the provider map so a
// queued rename cannot leave this switch holding a stale source key.
@@ -4393,6 +4654,7 @@ impl ProviderService {
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config),
AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets
AppType::Pi => Ok(String::new()), // Pi owns a shared exact-key catalog, not snippets
}
}
@@ -4410,6 +4672,7 @@ impl ProviderService {
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config),
AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets
AppType::Pi => Ok(String::new()),
}
}
@@ -4979,6 +5242,16 @@ impl ProviderService {
provider_id: &str,
url: String,
) -> Result<(), AppError> {
if matches!(app_type, AppType::Pi) {
PiCatalogCoordinator::apply(
state,
PiCatalogMutation::AddEndpoint {
provider_id: provider_id.to_string(),
url,
},
)?;
return Ok(());
}
endpoints::add_custom_endpoint(state, app_type, provider_id, url)
}
@@ -4989,6 +5262,16 @@ impl ProviderService {
provider_id: &str,
url: String,
) -> Result<(), AppError> {
if matches!(app_type, AppType::Pi) {
PiCatalogCoordinator::apply(
state,
PiCatalogMutation::RemoveEndpoint {
provider_id: provider_id.to_string(),
url,
},
)?;
return Ok(());
}
endpoints::remove_custom_endpoint(state, app_type, provider_id, url)
}
@@ -4999,6 +5282,13 @@ impl ProviderService {
provider_id: &str,
url: String,
) -> Result<(), AppError> {
let _pi_switch_guard = matches!(app_type, AppType::Pi).then(|| {
futures::executor::block_on(
state
.proxy_service
.lock_switch_for_app(AppType::Pi.as_str()),
)
});
endpoints::update_endpoint_last_used(state, app_type, provider_id, url)
}
@@ -5008,12 +5298,18 @@ impl ProviderService {
app_type: AppType,
updates: Vec<ProviderSortUpdate>,
) -> Result<bool, AppError> {
for update in updates {
let key = ProviderKey::new(app_type.as_str(), update.id)?;
state
.db
.update_provider_sort_index(&key, update.sort_index)?;
// Validate the whole payload before entering the app-specific
// ordering boundary.
let updates = updates
.into_iter()
.map(|update| {
ProviderKey::new(app_type.as_str(), update.id).map(|key| (key, update.sort_index))
})
.collect::<Result<Vec<_>, _>>()?;
if matches!(app_type, AppType::Pi) {
return PiCatalogCoordinator::update_route_order(state, updates);
}
state.db.update_provider_sort_index(&updates)?;
Ok(true)
}
@@ -5176,6 +5472,25 @@ impl ProviderService {
));
}
}
AppType::Pi => {
let config: crate::pi_config::model::PiManagedProviderConfig =
serde_json::from_value(provider.settings_config.clone()).map_err(|error| {
AppError::localized(
"provider.pi.settings.invalid",
format!("Pi 配置无法解析: {error}"),
format!("Pi configuration cannot be decoded: {error}"),
)
})?;
crate::pi_config::model::validate_pi_managed_provider(&config).map_err(
|error| {
AppError::localized(
"provider.pi.settings.invalid",
format!("Pi 配置无效: {error}"),
format!("Invalid Pi configuration: {error}"),
)
},
)?;
}
}
// Validate and clean UsageScript configuration (common for all app types)
@@ -5404,6 +5719,18 @@ impl ProviderService {
Ok((api_key, base_url))
}
AppType::Pi => {
let config: crate::pi_config::model::PiManagedProviderConfig =
serde_json::from_value(provider.settings_config.clone()).map_err(|error| {
AppError::Config(format!("invalid Pi provider configuration: {error}"))
})?;
let model = config.models.first().ok_or_else(|| {
AppError::Config("Pi provider has no configured model".to_string())
})?;
let effective = crate::pi_config::model::effective_pi_model(&config, &model.id)
.map_err(|error| AppError::Config(format!("invalid Pi model: {error}")))?;
Ok((effective.api_key.unwrap_or_default(), effective.base_url))
}
}
}
}
File diff suppressed because it is too large Load Diff
+662 -58
View File
@@ -565,6 +565,9 @@ impl SkillService {
return Ok(custom.join("skills"));
}
}
AppType::Pi => {
return Ok(crate::pi_config::native::get_pi_agent_dir()?.join("skills"));
}
}
// 默认路径:回退到用户主目录下的标准位置。
@@ -581,6 +584,7 @@ impl SkillService {
AppType::OpenCode => home.join(".config").join("opencode").join("skills"),
AppType::OpenClaw => home.join(".openclaw").join("skills"),
AppType::Hermes => crate::hermes_config::get_hermes_dir().join("skills"),
AppType::Pi => crate::pi_config::native::get_pi_agent_dir()?.join("skills"),
})
}
@@ -637,8 +641,19 @@ impl SkillService {
// 同一仓库的同名 skill,返回现有记录(可能需要更新启用状态)
let mut updated = existing.clone();
updated.apps.set_enabled_for(current_app, true);
if matches!(current_app, AppType::Pi) {
let guard = crate::services::skill_deployment::PiSkillDeploymentService::operation_guard();
crate::services::skill_deployment::PiSkillDeploymentService::toggle_under_guard(
&guard,
db,
&mut updated,
true,
)
.map_err(|error| anyhow!(error.to_string()))?;
return Ok(updated);
}
db.save_skill(&updated)?;
Self::sync_to_app_dir(&updated.directory, current_app)?;
Self::sync_installed_skill_to_app(db, &updated, current_app)?;
log::info!(
"Skill {} 已存在,更新 {:?} 启用状态",
updated.name,
@@ -671,6 +686,7 @@ impl SkillService {
}
let dest = ssot_dir.join(&install_name);
let destination_preexisted = dest.exists();
let mut repo_branch = skill.repo_branch.clone();
@@ -788,11 +804,39 @@ impl SkillService {
updated_at: 0,
};
// 保存到数据库
db.save_skill(&installed_skill)?;
// 同步到当前应用目录
Self::sync_to_app_dir(&install_name, current_app)?;
let installed_skill = if matches!(current_app, AppType::Pi) {
let guard =
crate::services::skill_deployment::PiSkillDeploymentService::operation_guard();
let mut persisted = installed_skill.clone();
// Let the deployment coordinator commit the desired bit and ledger
// evidence together. Until then the row is deliberately disabled.
persisted.apps.pi = false;
if let Err(error) = db.save_skill(&persisted) {
if !destination_preexisted {
let _ = fs::remove_dir_all(&dest);
}
return Err(error.into());
}
if let Err(error) =
crate::services::skill_deployment::PiSkillDeploymentService::toggle_under_guard(
&guard,
db,
&mut persisted,
true,
)
{
let _ = db.delete_skill(&persisted.id);
if !destination_preexisted {
let _ = fs::remove_dir_all(&dest);
}
return Err(anyhow!(error.to_string()));
}
persisted
} else {
db.save_skill(&installed_skill)?;
Self::sync_installed_skill_to_app(db, &installed_skill, current_app)?;
installed_skill
};
log::info!(
"Skill {} 安装成功,已启用 {:?}",
@@ -810,6 +854,8 @@ impl SkillService {
/// 2. 从 SSOT 删除
/// 3. 从数据库删除
pub fn uninstall(db: &Arc<Database>, id: &str) -> Result<SkillUninstallResult> {
let deployment_guard =
crate::services::skill_deployment::PiSkillDeploymentService::operation_guard();
// 获取 skill 信息
let skill = db
.get_installed_skill(id)?
@@ -828,8 +874,15 @@ impl SkillService {
let backup_path = Self::create_uninstall_backup(&skill)?
.map(|path| path.to_string_lossy().to_string());
crate::services::skill_deployment::PiSkillDeploymentService::remove_before_uninstall_under_guard(
&deployment_guard,
db,
&skill,
)
.map_err(|error| anyhow!(error.to_string()))?;
// 从所有应用目录删除
for app in AppType::all() {
for app in AppType::all().filter(|app| !matches!(app, AppType::Pi)) {
let _ = Self::remove_from_app(&directory, &app);
}
@@ -1113,15 +1166,40 @@ impl SkillService {
))
})?;
// All Pi deployment mutations, SSOT replacement, and ledger
// reconciliation share one process boundary. Downloading remains
// outside the lock so a slow network cannot block toggles.
let deployment_guard =
crate::services::skill_deployment::PiSkillDeploymentService::operation_guard();
// 备份旧文件
let _ = Self::create_uninstall_backup(&skill);
// 删除旧 SSOT 目录并复制新文件
// Stage the exact old SSOT tree in the same directory. Reconstructing
// it from the remote source is not a rollback: local files may differ.
let dest = ssot_dir.join(&skill.directory);
if dest.exists() {
fs::remove_dir_all(&dest)?;
let staged_previous = if fs::symlink_metadata(&dest).is_ok() {
let staged = ssot_dir.join(format!(
".{}.cc-switch-update-{}",
skill.directory,
uuid::Uuid::new_v4().simple()
));
fs::rename(&dest, &staged)?;
Some(staged)
} else {
None
};
if let Err(error) = Self::copy_dir_recursive(&source, &dest) {
if let Some(staged) = staged_previous.as_deref() {
fs::rename(staged, &dest).with_context(|| {
format!(
"Skill update copy failed ({error}); restoring {} also failed",
dest.display()
)
})?;
}
return Err(error);
}
Self::copy_dir_recursive(&source, &dest)?;
// 计算新哈希 + 解析新元数据
let new_hash = Self::compute_dir_hash(&dest).ok();
@@ -1151,14 +1229,71 @@ impl SkillService {
updated_at: chrono::Utc::now().timestamp(),
};
db.save_skill(&updated_skill)?;
if let Err(error) = db.save_skill(&updated_skill) {
let _ = Self::remove_path(&dest);
if let Some(staged) = staged_previous.as_deref() {
fs::rename(staged, &dest).with_context(|| {
format!(
"Skill metadata update failed ({error}); restoring {} also failed",
dest.display()
)
})?;
}
return Err(error.into());
}
// 同步到所有已启用的应用目录
for app in updated_skill.apps.enabled_apps() {
if let Err(e) = Self::sync_to_app_dir(&updated_skill.directory, &app) {
// Pi is the consistency-critical consumer: update its owned
// deployment before best-effort legacy app copies.
if updated_skill.apps.pi {
if let Err(error) =
crate::services::skill_deployment::PiSkillDeploymentService::reconcile_skill_under_guard(
&deployment_guard,
db,
&updated_skill,
)
{
let db_rollback = db.save_skill(&skill);
let file_rollback = Self::remove_path(&dest).and_then(|_| {
if let Some(staged) = staged_previous.as_deref() {
fs::rename(staged, &dest).map_err(anyhow::Error::from)
} else {
Ok(())
}
});
return match (db_rollback, file_rollback) {
(Ok(()), Ok(())) => Err(anyhow!(error.to_string())),
(db_result, file_result) => Err(anyhow!(
"Pi Skill update failed ({error}); DB rollback: {}; file rollback: {}",
db_result
.err()
.map_or_else(|| "ok".to_string(), |value| value.to_string()),
file_result
.err()
.map_or_else(|| "ok".to_string(), |value| value.to_string())
)),
};
}
}
// 同步到所有已启用的其他应用目录
for app in updated_skill
.apps
.enabled_apps()
.into_iter()
.filter(|app| !matches!(app, AppType::Pi))
{
if let Err(e) = Self::sync_installed_skill_to_app(db, &updated_skill, &app) {
log::warn!("同步更新后的 skill 到 {:?} 失败: {e}", app);
}
}
if let Some(staged) = staged_previous {
if let Err(error) = Self::remove_path(&staged) {
log::warn!(
"Failed to remove committed Skill update rollback staging '{}': {error}",
staged.display()
);
}
}
log::info!("Skill {} 更新成功", updated_skill.name);
Ok(updated_skill)
@@ -1201,7 +1336,10 @@ impl SkillService {
/// 迁移 Skill 存储位置(在两个 SSOT 目录间移动文件)
///
/// 安全策略:先移文件,后改设置。中途崩溃时设置仍指向旧目录。
/// Safety strategy: copy first while the old SSOT remains live, switch the
/// setting, reconcile every app, then delete the old trees. Keeping both
/// roots during reconciliation lets the Pi ownership ledger verify its old
/// symlink/copy before atomically replacing it.
pub fn migrate_storage(
db: &Arc<Database>,
target: SkillStorageLocation,
@@ -1215,6 +1353,9 @@ impl SkillService {
});
}
let deployment_guard =
crate::services::skill_deployment::PiSkillDeploymentService::operation_guard();
// 1. 解析旧目录和新目录(不改设置)
let old_dir = Self::get_ssot_dir()?;
let new_dir = match target {
@@ -1225,18 +1366,18 @@ impl SkillService {
};
fs::create_dir_all(&new_dir)?;
// 2. 逐个移动 skill 目录
// 2. Copy every valid tree. Do not rename/delete the old root before
// Pi has verified the ownership identity recorded in its ledger.
let skills = db.get_all_installed_skills()?;
let mut result = MigrationResult {
migrated_count: 0,
skipped_count: 0,
errors: vec![],
};
let mut copied = Vec::<(PathBuf, PathBuf)>::new();
for skill in skills.values() {
// 下面是 rename 与 remove_dir_all,脏 directory 可把任意目录搬走或删掉。
// 软失败:本函数已有 errors 收集通道,记一条继续处理其余 skill,
// 不要整体中断——用户只是在切换存储位置。
// Invalid DB rows are reported but never joined to either root.
let directory = match Self::require_valid_directory(&skill.directory) {
Ok(directory) => directory,
Err(err) => {
@@ -1253,32 +1394,90 @@ impl SkillService {
result.skipped_count += 1;
continue;
}
if dst.exists() {
result.skipped_count += 1;
continue;
if fs::symlink_metadata(&dst).is_ok() {
for (_, copied_destination) in copied.iter().rev() {
let _ = Self::remove_path(copied_destination);
}
return Err(anyhow!(
"Skill storage target already contains an unowned entry: {}",
dst.display()
));
}
// 优先 rename(同文件系统原子操作),失败则 copy+delete
match fs::rename(&src, &dst) {
Ok(()) => result.migrated_count += 1,
Err(_) => match Self::copy_dir_recursive(&src, &dst) {
Ok(()) => {
let _ = fs::remove_dir_all(&src);
result.migrated_count += 1;
}
Err(e) => {
result.errors.push(format!("{}: {e}", skill.directory));
}
},
if let Err(error) = Self::copy_dir_recursive(&src, &dst) {
let _ = Self::remove_path(&dst);
for (_, copied_destination) in copied.iter().rev() {
let _ = Self::remove_path(copied_destination);
}
return Err(error);
}
copied.push((src, dst));
result.migrated_count += 1;
}
// 3. 文件移动完成后才持久化设置
crate::settings::set_skill_storage_location(target)?;
// 3. Switch authority only after every new tree is complete.
if let Err(error) = crate::settings::set_skill_storage_location(target) {
for (_, copied_destination) in copied.iter().rev() {
let _ = Self::remove_path(copied_destination);
}
return Err(error.into());
}
// 4. 刷新所有应用目录的 symlink(指向新 SSOT
for app in AppType::all() {
let _ = Self::sync_to_app(db, &app);
// 4. Reconcile Pi under the same mutex, then all legacy app views.
let reconcile_result =
crate::services::skill_deployment::PiSkillDeploymentService::reconcile_all_under_guard(
&deployment_guard,
db,
)
.map_err(|error| anyhow!(error.to_string()))
.and_then(|()| {
for app in AppType::all().filter(|app| !matches!(app, AppType::Pi)) {
Self::sync_to_app(db, &app)?;
}
Ok(())
});
if let Err(error) = reconcile_result {
let mut rollback_errors = Vec::new();
if let Err(rollback) = crate::settings::set_skill_storage_location(current) {
rollback_errors.push(format!("settings: {rollback}"));
} else {
if let Err(rollback) =
crate::services::skill_deployment::PiSkillDeploymentService::reconcile_all_under_guard(
&deployment_guard,
db,
)
{
rollback_errors.push(format!("Pi deployment: {rollback}"));
}
for app in AppType::all().filter(|app| !matches!(app, AppType::Pi)) {
if let Err(rollback) = Self::sync_to_app(db, &app) {
rollback_errors.push(format!("{app:?}: {rollback}"));
}
}
}
for (_, copied_destination) in copied.iter().rev() {
if let Err(rollback) = Self::remove_path(copied_destination) {
rollback_errors.push(format!("{}: {rollback}", copied_destination.display()));
}
}
return if rollback_errors.is_empty() {
Err(error)
} else {
Err(anyhow!(
"Skill storage migration failed ({error}); rollback failures: {}",
rollback_errors.join("; ")
))
};
}
// 5. Only after every consumer points at the new root may the old
// sources be removed. Cleanup errors are visible but do not roll back
// an already-consistent authority switch.
for (old_source, _) in &copied {
if let Err(error) = Self::remove_path(old_source) {
result
.errors
.push(format!("{}: {error}", old_source.display()));
}
}
log::info!(
@@ -1403,7 +1602,7 @@ impl SkillService {
}
if !restored_skill.apps.is_empty() {
if let Err(err) = Self::sync_to_app_dir(&restored_skill.directory, current_app) {
if let Err(err) = Self::sync_installed_skill_to_app(db, &restored_skill, current_app) {
let _ = db.delete_skill(&restored_skill.id);
let _ = fs::remove_dir_all(&restore_path);
return Err(err);
@@ -1429,6 +1628,14 @@ impl SkillService {
.get_installed_skill(id)?
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
if matches!(app, AppType::Pi) {
crate::services::skill_deployment::PiSkillDeploymentService::toggle(
db, &mut skill, enabled,
)
.map_err(|error| anyhow!(error.to_string()))?;
return Ok(());
}
// 更新状态
skill.apps.set_enabled_for(app, enabled);
@@ -1517,6 +1724,11 @@ impl SkillService {
db: &Arc<Database>,
imports: Vec<ImportSkillSelection>,
) -> Result<Vec<InstalledSkill>> {
// Import can explicitly acquire or release Pi filesystem ownership.
// Serialize the source scan, SSOT establishment, ownership decision and
// desired-state transaction with every other Pi deployment operation.
let deployment_guard =
crate::services::skill_deployment::PiSkillDeploymentService::operation_guard();
let ssot_dir = Self::get_ssot_dir()?;
let agents_lock = parse_agents_lock();
let mut imported = Vec::new();
@@ -1579,8 +1791,12 @@ impl SkillService {
// 复制到 SSOT
let dest = ssot_dir.join(&dir_name);
if !dest.exists() {
Self::copy_dir_recursive(&source, &dest)?;
let created_ssot = !dest.exists();
if created_ssot {
if let Err(error) = Self::copy_dir_recursive(&source, &dest) {
let _ = Self::remove_path(&dest);
return Err(error);
}
}
// 解析元数据
@@ -1588,7 +1804,7 @@ impl SkillService {
let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
// 启用状态仅信任用户本次显式选择,不再根据“在哪些位置找到”自动推断。
let apps = selection.apps;
let requested_apps = selection.apps;
// 从 lock 文件提取仓库信息
let (id, repo_owner, repo_name, repo_branch, readme_url) =
@@ -1599,7 +1815,8 @@ impl SkillService {
let content_hash = Self::compute_dir_hash(&ssot_skill_dir).ok();
// 创建记录
let skill = InstalledSkill {
let previous = db.get_installed_skill(&id)?;
let mut skill = InstalledSkill {
id,
name,
description,
@@ -1608,14 +1825,54 @@ impl SkillService {
repo_name,
repo_branch,
readme_url,
apps,
// save_skill intentionally preserves an existing Pi desired bit.
// For a new row keep it disabled until the deployment ledger and
// desired bit can commit in one transaction below.
apps: SkillApps {
pi: previous.as_ref().is_some_and(|installed| installed.apps.pi),
..requested_apps.clone()
},
installed_at: chrono::Utc::now().timestamp(),
content_hash,
updated_at: 0,
};
// 保存到数据库
db.save_skill(&skill)?;
if let Err(error) = db.save_skill(&skill) {
if created_ssot {
let _ = Self::remove_path(&dest);
}
return Err(error.into());
}
if let Err(error) = crate::services::skill_deployment::PiSkillDeploymentService::import_desired_state_under_guard(
&deployment_guard,
db,
&mut skill,
requested_apps.pi,
) {
let db_rollback = if let Some(previous) = previous.as_ref() {
db.save_skill(previous).map(|_| ())
} else {
db.delete_skill(&skill.id).map(|_| ())
};
let file_rollback = if created_ssot {
Self::remove_path(&dest)
} else {
Ok(())
};
return match (db_rollback, file_rollback) {
(Ok(()), Ok(())) => Err(anyhow!(error.to_string())),
(db_result, file_result) => Err(anyhow!(
"Pi Skill import failed ({error}); DB rollback: {}; SSOT rollback: {}",
db_result
.err()
.map_or_else(|| "ok".to_string(), |value| value.to_string()),
file_result
.err()
.map_or_else(|| "ok".to_string(), |value| value.to_string())
)),
};
}
imported.push(skill);
}
@@ -1655,6 +1912,19 @@ impl SkillService {
crate::settings::get_skill_sync_method()
}
fn sync_installed_skill_to_app(
db: &Arc<Database>,
skill: &InstalledSkill,
app: &AppType,
) -> Result<()> {
if matches!(app, AppType::Pi) {
crate::services::skill_deployment::PiSkillDeploymentService::reconcile_skill(db, skill)
.map_err(|error| anyhow!(error.to_string()))
} else {
Self::sync_to_app_dir(&skill.directory, app)
}
}
/// 同步 Skill 到应用目录(使用 symlink 或 copy
///
/// 根据配置和平台选择最佳同步方式:
@@ -1665,6 +1935,11 @@ impl SkillService {
if matches!(app, AppType::ClaudeDesktop) {
return Ok(());
}
if matches!(app, AppType::Pi) {
return Err(anyhow!(
"Pi Skill deployment requires the ownership ledger; use the database-aware reconciler"
));
}
// directory 可能来自被污染的 DB 行(如同步导入的远端快照),join 前必须校验。
let directory = Self::require_valid_directory(directory)?;
@@ -1861,6 +2136,10 @@ impl SkillService {
if matches!(app, AppType::ClaudeDesktop) {
return Ok(());
}
if matches!(app, AppType::Pi) {
return crate::services::skill_deployment::PiSkillDeploymentService::reconcile_all(db)
.map_err(|error| anyhow!(error.to_string()));
}
let skills = db.get_all_installed_skills()?;
let ssot_dir = Self::get_ssot_dir()?;
@@ -2113,7 +2392,7 @@ impl SkillService {
}
/// 静态方法:解析技能元数据
fn parse_skill_metadata_static(path: &Path) -> Result<SkillMetadata> {
pub(crate) fn parse_skill_metadata_static(path: &Path) -> Result<SkillMetadata> {
let content = fs::read_to_string(path)?;
let content = content.trim_start_matches('\u{feff}');
@@ -2733,6 +3012,35 @@ impl SkillService {
Ok(())
}
/// Copy into a unique sibling and publish with an OS no-replace rename.
/// A concurrent installer can win, but its directory is never overwritten.
fn copy_dir_noreplace(src: &Path, dest: &Path) -> Result<()> {
let parent = dest
.parent()
.ok_or_else(|| anyhow!("Skill destination has no parent: {}", dest.display()))?;
fs::create_dir_all(parent)?;
let name = dest
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| anyhow!("Skill destination has an invalid name: {}", dest.display()))?;
let staged = parent.join(format!(
".{name}.cc-switch-install-{}",
uuid::Uuid::new_v4().simple()
));
if let Err(error) = Self::copy_dir_recursive(src, &staged) {
let _ = fs::remove_dir_all(&staged);
return Err(error);
}
if let Err(error) = crate::pi_config::shared_file::publish_path_noreplace(&staged, dest) {
let _ = fs::remove_dir_all(&staged);
return Err(anyhow!(
"Skill destination was created concurrently ({}): {error}",
dest.display()
));
}
Ok(())
}
fn resolve_uninstall_backup_source(skill: &InstalledSkill) -> Result<Option<PathBuf>> {
// 返回值会被整目录复制进 ~/.cc-switch/skill-backups/ 并由 get_skill_backups
// 在界面上列出——脏 directory 在这里等于任意文件读取 + 外泄通道。
@@ -2996,6 +3304,10 @@ impl SkillService {
let ssot_dir = Self::get_ssot_dir()?;
let mut installed = Vec::new();
let existing_skills = db.get_all_installed_skills()?;
let mut claimed_directories = existing_skills
.values()
.map(|skill| skill.directory.to_ascii_lowercase())
.collect::<HashSet<_>>();
let zip_stem = zip_path
.file_stem()
.and_then(|s| s.to_str())
@@ -3062,6 +3374,32 @@ impl SkillService {
);
continue;
}
if claimed_directories.contains(&install_name.to_ascii_lowercase()) {
log::warn!(
"Skill directory '{}' appears more than once in the archive, skipping",
install_name
);
continue;
}
if matches!(current_app, AppType::Pi)
&& meta.as_ref().is_none_or(|metadata| {
metadata
.name
.as_deref()
.is_none_or(|name| name.trim().is_empty())
|| metadata
.description
.as_deref()
.is_none_or(|description| description.trim().is_empty())
})
{
return Err(anyhow!(format_skill_error(
"INVALID_SKILL_DIRECTORY",
&[("directory", &install_name)],
Some("checkSkillManifest"),
)));
}
let (name, description) = match meta {
Some(m) => (
@@ -3071,18 +3409,35 @@ impl SkillService {
None => (install_name.clone(), None),
};
let deployment_guard = matches!(current_app, AppType::Pi)
.then(crate::services::skill_deployment::PiSkillDeploymentService::operation_guard);
let pi_source_digest = if deployment_guard.is_some() {
Some(
crate::services::skill_deployment::PiSkillDeploymentService::source_digest(
&skill_dir,
)
.map_err(|error| anyhow!(error.to_string()))?,
)
} else {
None
};
// 复制到 SSOT
let dest = ssot_dir.join(&install_name);
if dest.exists() {
let _ = fs::remove_dir_all(&dest);
if fs::symlink_metadata(&dest).is_ok() {
return Err(anyhow!(format_skill_error(
"SKILL_DIRECTORY_CONFLICT",
&[("directory", &install_name)],
Some("uninstallFirst"),
)));
}
Self::copy_dir_recursive(&skill_dir, &dest)?;
Self::copy_dir_noreplace(&skill_dir, &dest)?;
// 计算内容哈希
let content_hash = Self::compute_dir_hash(&dest).ok();
// 创建 InstalledSkill 记录
let skill = InstalledSkill {
let mut skill = InstalledSkill {
id: format!("local:{install_name}"),
name,
description,
@@ -3097,17 +3452,75 @@ impl SkillService {
updated_at: 0,
};
// 保存到数据库
db.save_skill(&skill)?;
// 同步到当前应用目录
Self::sync_to_app_dir(&install_name, current_app)?;
if let Some(guard) = deployment_guard.as_ref() {
// The coordinator commits Pi desired state and ownership
// evidence together. Until then the portable row is inert.
skill.apps.pi = false;
if let Err(error) = db.save_skill(&skill) {
let cleanup =
crate::services::skill_deployment::PiSkillDeploymentService::remove_source_if_unchanged(
&dest,
pi_source_digest
.as_deref()
.expect("Pi ZIP publication has a source digest"),
);
return match cleanup {
Ok(()) => Err(error.into()),
Err(cleanup) => Err(anyhow!(
"Pi Skill ZIP database write failed ({error}); SSOT rollback failed ({cleanup})"
)),
};
}
if let Err(error) =
crate::services::skill_deployment::PiSkillDeploymentService::toggle_under_guard(
guard, db, &mut skill, true,
)
{
if let Err(deployment_rollback) =
crate::services::skill_deployment::PiSkillDeploymentService::remove_before_uninstall_under_guard(
guard, db, &skill,
)
{
return Err(anyhow!(
"Pi Skill ZIP install failed ({error}); native ownership rollback failed ({deployment_rollback}); the DB row and SSOT were retained as recovery evidence"
));
}
let db_rollback = db.delete_skill(&skill.id);
if !matches!(&db_rollback, Ok(true)) {
return Err(anyhow!(
"Pi Skill ZIP install failed ({error}); DB rollback failed ({}); SSOT was retained",
match db_rollback {
Ok(false) => "row missing".to_string(),
Err(value) => value.to_string(),
Ok(true) => unreachable!(),
}
));
}
let file_rollback =
crate::services::skill_deployment::PiSkillDeploymentService::remove_source_if_unchanged(
&dest,
pi_source_digest
.as_deref()
.expect("Pi ZIP publication has a source digest"),
);
return match file_rollback {
Ok(()) => Err(anyhow!(error.to_string())),
Err(file_error) => Err(anyhow!(
"Pi Skill ZIP install failed ({error}); SSOT rollback failed ({file_error})"
)),
};
}
} else {
db.save_skill(&skill)?;
Self::sync_installed_skill_to_app(db, &skill, current_app)?;
}
log::info!(
"Skill {} installed from ZIP, enabled for {:?}",
skill.name,
current_app
);
claimed_directories.insert(install_name.to_ascii_lowercase());
installed.push(skill);
}
@@ -4013,6 +4426,34 @@ mod tests {
);
}
#[test]
fn no_replace_directory_publish_preserves_an_existing_destination() {
let temp = tempdir().expect("tempdir");
let source = temp.path().join("source");
let destination = temp.path().join("destination");
fs::create_dir(&source).expect("source");
fs::create_dir(&destination).expect("destination");
fs::write(source.join("SKILL.md"), "managed").expect("source manifest");
fs::write(destination.join("SKILL.md"), "external").expect("external manifest");
SkillService::copy_dir_noreplace(&source, &destination)
.expect_err("an existing destination must win atomically");
assert_eq!(
fs::read_to_string(destination.join("SKILL.md")).expect("external destination"),
"external"
);
assert!(
fs::read_dir(temp.path())
.expect("temp root")
.all(|entry| !entry
.expect("entry")
.file_name()
.to_string_lossy()
.contains("cc-switch-install")),
"a rejected staged publication must be cleaned"
);
}
#[test]
fn extract_local_zip_hands_back_a_guard_that_owns_the_tree() {
use std::io::Write;
@@ -4142,6 +4583,169 @@ mod tests {
}
}
#[test]
#[serial_test::serial]
fn importing_a_native_pi_skill_adopts_exact_content_and_can_disable_it() {
struct PiDirGuard(Option<std::ffi::OsString>);
impl Drop for PiDirGuard {
fn drop(&mut self) {
match self.0.take() {
Some(value) => std::env::set_var("PI_CODING_AGENT_DIR", value),
None => std::env::remove_var("PI_CODING_AGENT_DIR"),
}
}
}
struct StorageLocationGuard(SkillStorageLocation);
impl Drop for StorageLocationGuard {
fn drop(&mut self) {
let _ = crate::settings::set_skill_storage_location(self.0);
}
}
let temp = tempdir().expect("tempdir");
let _home_guard = TestHomeGuard::set(temp.path());
let pi_agent_dir = temp.path().join("pi-agent");
let _pi_dir_guard = PiDirGuard(std::env::var_os("PI_CODING_AGENT_DIR"));
std::env::set_var("PI_CODING_AGENT_DIR", &pi_agent_dir);
let _storage_guard = StorageLocationGuard(crate::settings::get_skill_storage_location());
crate::settings::set_skill_storage_location(SkillStorageLocation::CcSwitch)
.expect("select isolated SSOT");
let native = pi_agent_dir.join("skills").join("native-skill");
write_skill(&native, "Native Skill");
fs::write(native.join("details.txt"), "pinned native bytes").expect("native detail");
let db = Arc::new(Database::memory().expect("memory db"));
let imported = SkillService::import_from_apps(
&db,
vec![ImportSkillSelection {
directory: "native-skill".to_string(),
apps: SkillApps::only(&AppType::Pi),
}],
)
.expect("explicit Pi import should adopt the exact native tree");
assert_eq!(imported.len(), 1);
assert!(imported[0].apps.pi);
let statuses =
crate::services::skill_deployment::PiSkillDeploymentService::inspect_all(&db)
.expect("inspect Pi deployment");
let status = statuses
.get(&imported[0].id)
.expect("imported status must exist");
assert_eq!(
status.ownership,
crate::services::skill_deployment::PiSkillOwnership::Owned
);
assert_eq!(
status.discovery,
crate::services::skill_deployment::PiSkillDiscovery::Active
);
assert!(status.effectively_discovered);
SkillService::toggle_app(&db, &imported[0].id, &AppType::Pi, false)
.expect("owned imported Pi Skill can be disabled");
assert!(
!native.exists(),
"disabling an explicitly adopted native tree removes that owned deployment"
);
assert!(
SkillService::get_ssot_dir()
.expect("SSOT")
.join("native-skill")
.join("SKILL.md")
.is_file(),
"disabling Pi must preserve the managed SSOT"
);
assert!(
!db.get_installed_skill(&imported[0].id)
.expect("read imported row")
.expect("row")
.apps
.pi
);
}
#[test]
#[serial_test::serial]
fn pi_zip_collision_rolls_back_database_and_ssot_without_touching_native_skill() {
use std::io::Write;
use zip::write::SimpleFileOptions;
struct PiDirGuard(Option<std::ffi::OsString>);
impl Drop for PiDirGuard {
fn drop(&mut self) {
match self.0.take() {
Some(value) => std::env::set_var("PI_CODING_AGENT_DIR", value),
None => std::env::remove_var("PI_CODING_AGENT_DIR"),
}
}
}
struct StorageLocationGuard(SkillStorageLocation);
impl Drop for StorageLocationGuard {
fn drop(&mut self) {
let _ = crate::settings::set_skill_storage_location(self.0);
}
}
let temp = tempdir().expect("tempdir");
let _home_guard = TestHomeGuard::set(temp.path());
let pi_agent_dir = temp.path().join("pi-agent");
let _pi_dir_guard = PiDirGuard(std::env::var_os("PI_CODING_AGENT_DIR"));
std::env::set_var("PI_CODING_AGENT_DIR", &pi_agent_dir);
let _storage_guard = StorageLocationGuard(crate::settings::get_skill_storage_location());
crate::settings::set_skill_storage_location(SkillStorageLocation::CcSwitch)
.expect("isolated SSOT");
let native = pi_agent_dir.join("skills").join("collision");
write_skill(&native, "Native collision");
fs::write(native.join("native.txt"), "must survive").expect("native bytes");
let mut archive = Vec::new();
{
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut archive));
let options = SimpleFileOptions::default();
zip.start_file("collision/SKILL.md", options)
.expect("manifest entry");
zip.write_all(b"---\nname: Imported\ndescription: Imported collision\n---\n")
.expect("manifest bytes");
zip.start_file("collision/imported.txt", options)
.expect("payload entry");
zip.write_all(b"must not remain").expect("payload bytes");
zip.finish().expect("finish zip");
}
let zip_path = temp.path().join("collision.zip");
fs::write(&zip_path, archive).expect("write zip");
let db = Arc::new(Database::memory().expect("database"));
SkillService::install_from_zip(&db, &zip_path, &AppType::Pi)
.expect_err("unowned native collision must fail");
assert_eq!(
fs::read_to_string(native.join("native.txt")).expect("native survives"),
"must survive"
);
assert!(
db.get_installed_skill("local:collision")
.expect("read skill")
.is_none(),
"failed ZIP install must not leave desired state"
);
assert!(
db.get_pi_skill_deployments("local:collision")
.expect("read ledger")
.is_empty(),
"failed ZIP install must not create ownership evidence"
);
assert!(
!SkillService::get_ssot_dir()
.expect("SSOT")
.join("collision")
.exists(),
"failed ZIP install must compensate its SSOT copy"
);
}
fn poisoned_skill(id: &str, directory: &str) -> InstalledSkill {
InstalledSkill {
id: id.to_string(),
File diff suppressed because it is too large Load Diff
+35 -2
View File
@@ -54,8 +54,7 @@ pub fn fresh_input_sql(alias: &str) -> String {
format!(
"CASE \
WHEN {prefix}input_token_semantics = {INPUT_TOKEN_SEMANTICS_FRESH} THEN {prefix}input_tokens \
WHEN {prefix}app_type IN ({app_type_list}) \
AND {prefix}input_token_semantics = {INPUT_TOKEN_SEMANTICS_TOTAL} \
WHEN {prefix}input_token_semantics = {INPUT_TOKEN_SEMANTICS_TOTAL} \
AND {prefix}input_tokens >= ({prefix}cache_read_tokens + {prefix}cache_creation_tokens) \
THEN ({prefix}input_tokens - {prefix}cache_read_tokens - {prefix}cache_creation_tokens) \
WHEN {prefix}app_type IN ({app_type_list}) \
@@ -144,6 +143,40 @@ mod tests {
assert_eq!(total, 400 + 500 + 450 + 200);
}
#[test]
fn stored_wire_semantics_override_logical_pi_app_type() {
let conn = setup_conn();
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, app_type, input_tokens, cache_read_tokens,
cache_creation_tokens, input_token_semantics
) VALUES
('pi-openai', 'pi', 1000, 700, 100, 1),
('pi-anthropic', 'pi', 1000, 700, 100, 2)",
[],
)
.unwrap();
let sql = format!(
"SELECT request_id, {} FROM proxy_request_logs ORDER BY request_id",
fresh_input_sql("")
);
let values: Vec<(String, i64)> = conn
.prepare(&sql)
.unwrap()
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
assert_eq!(
values,
vec![
("pi-anthropic".to_string(), 1000),
("pi-openai".to_string(), 200),
]
);
}
#[test]
fn fresh_input_handles_codex_with_cache_exceeding_input() {
// Defensive: if a malformed Codex row somehow has cache > input,
+56
View File
@@ -181,6 +181,7 @@ impl StreamCheckService {
}
AppType::OpenClaw => Self::extract_openclaw_base_url(provider),
AppType::Hermes => Self::extract_hermes_base_url(provider),
AppType::Pi => Self::extract_pi_base_url(provider),
AppType::ClaudeDesktop => ClaudeAdapter::new()
.extract_base_url(provider)
.map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}"))),
@@ -323,6 +324,31 @@ impl StreamCheckService {
})
}
/// Pi endpoint inheritance is owned by the pinned composer. Reachability
/// checks deliberately consume its first effective model instead of
/// reimplementing provider/model fallback rules.
fn extract_pi_base_url(provider: &Provider) -> Result<String, AppError> {
let config: crate::pi_config::model::PiManagedProviderConfig =
serde_json::from_value(provider.settings_config.clone()).map_err(|error| {
AppError::InvalidInput(format!(
"Pi provider '{}' is not a managed native configuration: {error}",
provider.id
))
})?;
let composition =
crate::pi_config::native::compose_managed_pi_provider(&provider.id, &config)?;
composition
.models
.first()
.map(|model| model.base_url.clone())
.ok_or_else(|| {
AppError::InvalidInput(format!(
"Pi provider '{}' has no effective models",
provider.id
))
})
}
/// OpenCode: `{ npm, options: { baseURL, apiKey }, ... }`
///
/// 用户未显式填 `options.baseURL` 时,按 `npm`AI SDK 包)回退到包自带默认端点。
@@ -501,6 +527,36 @@ mod tests {
);
}
#[test]
fn pi_reachability_uses_composer_effective_model_endpoint() {
let provider = make_provider(serde_json::json!({
"name": "Pi",
"api": "openai-responses",
"baseUrl": "https://provider.example/v1",
"apiKey": "literal",
"models": [{
"id": "model",
"name": "Model",
"baseUrl": "https://model.example/custom",
"reasoning": false,
"input": ["text"],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 128000,
"maxTokens": 8192
}]
}));
assert_eq!(
StreamCheckService::resolve_base_url(&AppType::Pi, &provider).unwrap(),
"https://model.example/custom"
);
}
#[test]
fn test_resolve_base_url_uses_explicit_url_or_errors_when_missing() {
// 有显式 base_url → 直接用
+67 -19
View File
@@ -137,8 +137,9 @@ pub struct RequestLogDetail {
pub output_tokens: u32,
pub cache_read_tokens: u32,
pub cache_creation_tokens: u32,
/// Internal storage semantics; omitted from the UI/API payload.
#[serde(skip)]
/// Persisted request-level semantics used by both pricing and UI cache
/// normalization. This must cross IPC; app-type inference is only a legacy
/// fallback for rows written before the semantics column existed.
pub input_token_semantics: i64,
pub input_cost_usd: String,
pub output_cost_usd: String,
@@ -1653,10 +1654,10 @@ impl Database {
let detail_sql = format!(
"SELECT l.request_id, l.provider_id, {detail_pname} as provider_name, l.app_type, l.model,
l.request_model, l.cost_multiplier,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
is_streaming, latency_ms, first_token_ms, duration_ms,
status_code, error_message, created_at, l.data_source, l.pricing_model,
l.input_tokens, l.output_tokens, l.cache_read_tokens, l.cache_creation_tokens,
l.input_cost_usd, l.output_cost_usd, l.cache_read_cost_usd, l.cache_creation_cost_usd, l.total_cost_usd,
l.is_streaming, l.latency_ms, l.first_token_ms, l.duration_ms,
l.status_code, l.error_message, l.created_at, l.data_source, l.pricing_model,
l.input_token_semantics
FROM proxy_request_logs l
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
@@ -1897,19 +1898,18 @@ impl Database {
// 1. 历史 cache-inclusive 行只包含 cache read;新 total 行还包含 cache write。
// 2. Claude/Anthropic 的 input_tokens 已经是 fresh input,不能再次扣减
// 3. 各项成本是基础成本(不含倍率),倍率只作用于最终总价
let cache_inclusive_app =
crate::services::sql_helpers::is_cache_inclusive_app(log.app_type.as_str());
let billable_input_tokens =
if !cache_inclusive_app || log.input_token_semantics == INPUT_TOKEN_SEMANTICS_FRESH {
log.input_tokens as u64
} else if log.input_token_semantics == INPUT_TOKEN_SEMANTICS_TOTAL {
(log.input_tokens as u64)
.saturating_sub(log.cache_read_tokens as u64)
.saturating_sub(log.cache_creation_tokens as u64)
} else {
// v12 and earlier: input included cache reads but excluded cache writes.
(log.input_tokens as u64).saturating_sub(log.cache_read_tokens as u64)
};
let billable_input_tokens = if log.input_token_semantics == INPUT_TOKEN_SEMANTICS_FRESH {
log.input_tokens as u64
} else if log.input_token_semantics == INPUT_TOKEN_SEMANTICS_TOTAL {
(log.input_tokens as u64)
.saturating_sub(log.cache_read_tokens as u64)
.saturating_sub(log.cache_creation_tokens as u64)
} else if crate::services::sql_helpers::is_cache_inclusive_app(log.app_type.as_str()) {
// v12 and earlier: input included cache reads but excluded cache writes.
(log.input_tokens as u64).saturating_sub(log.cache_read_tokens as u64)
} else {
log.input_tokens as u64
};
let input_cost =
rust_decimal::Decimal::from(billable_input_tokens) * pricing.input / million;
let output_cost =
@@ -2407,6 +2407,54 @@ mod tests {
Ok(())
}
#[test]
fn paginated_and_detail_ipc_serialize_persisted_input_semantics() -> Result<(), AppError> {
let db = Database::memory()?;
{
let conn = lock_conn!(db.conn);
insert_usage_log(
&conn,
"pi-semantics-ipc",
"pi",
"pi-provider",
"gpt-test",
"request",
1,
1_000,
5,
800,
0,
200,
"0",
)?;
conn.execute(
"UPDATE proxy_request_logs
SET input_token_semantics = ?1
WHERE request_id = 'pi-semantics-ipc'",
[INPUT_TOKEN_SEMANTICS_TOTAL],
)?;
}
let page = db.get_request_logs(&LogFilters::default(), 0, 10)?;
let page_json =
serde_json::to_value(&page).map_err(|error| AppError::Database(error.to_string()))?;
assert_eq!(
page_json["data"][0]["inputTokenSemantics"],
INPUT_TOKEN_SEMANTICS_TOTAL
);
let detail = db
.get_request_detail("pi-semantics-ipc")?
.expect("request detail");
let detail_json =
serde_json::to_value(detail).map_err(|error| AppError::Database(error.to_string()))?;
assert_eq!(
detail_json["inputTokenSemantics"],
INPUT_TOKEN_SEMANTICS_TOTAL
);
Ok(())
}
fn create_legacy_nullable_logs_table(conn: &Connection) -> Result<(), AppError> {
conn.execute(
"CREATE TABLE proxy_request_logs (
+8 -2
View File
@@ -4,7 +4,7 @@ pub mod terminal;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use providers::{claude, codex, gemini, grokbuild, hermes, openclaw, opencode};
use providers::{claude, codex, gemini, grokbuild, hermes, openclaw, opencode, pi};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -56,7 +56,7 @@ pub struct DeleteSessionOutcome {
}
pub fn scan_sessions() -> Vec<SessionMeta> {
let (r1, r2, r3, r4, r5, r6, r7) = std::thread::scope(|s| {
let (r1, r2, r3, r4, r5, r6, r7, r8) = std::thread::scope(|s| {
let h1 = s.spawn(codex::scan_sessions);
let h2 = s.spawn(claude::scan_sessions);
let h3 = s.spawn(opencode::scan_sessions);
@@ -64,6 +64,7 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
let h5 = s.spawn(gemini::scan_sessions);
let h6 = s.spawn(hermes::scan_sessions);
let h7 = s.spawn(grokbuild::scan_sessions);
let h8 = s.spawn(pi::scan_sessions);
(
h1.join().unwrap_or_default(),
h2.join().unwrap_or_default(),
@@ -72,6 +73,7 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
h5.join().unwrap_or_default(),
h6.join().unwrap_or_default(),
h7.join().unwrap_or_default(),
h8.join().unwrap_or_default(),
)
});
@@ -83,6 +85,7 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
sessions.extend(r5);
sessions.extend(r6);
sessions.extend(r7);
sessions.extend(r8);
sessions.sort_by(|a, b| {
let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0);
@@ -111,6 +114,7 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<Session
"gemini" => gemini::load_messages(path),
"grokbuild" => grokbuild::load_messages(path),
"hermes" => hermes::load_messages(path),
"pi" => pi::load_messages(path),
_ => Err(format!("Unsupported provider: {provider_id}")),
}
}
@@ -173,6 +177,7 @@ fn delete_session_with_roots(
grokbuild::delete_session(&validated_root, &validated_source, session_id)
}
"hermes" => hermes::delete_session(&validated_root, &validated_source, session_id),
"pi" => pi::delete_session(&validated_root, &validated_source, session_id),
_ => Err(format!("Unsupported provider: {provider_id}")),
};
}
@@ -203,6 +208,7 @@ fn provider_roots(provider_id: &str) -> Result<Vec<PathBuf>, String> {
"gemini" => vec![crate::gemini_config::get_gemini_dir().join("tmp")],
"grokbuild" => grokbuild::session_roots(),
"hermes" => vec![crate::hermes_config::get_hermes_dir().join("sessions")],
"pi" => pi::session_roots(),
_ => return Err(format!("Unsupported provider: {provider_id}")),
};
@@ -5,4 +5,5 @@ pub mod grokbuild;
pub mod hermes;
pub mod openclaw;
pub mod opencode;
pub mod pi;
mod utils;
@@ -0,0 +1,731 @@
use std::collections::{HashMap, HashSet};
use std::fs::{self, File};
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use serde::Serialize;
use serde_json::Value;
use crate::session_manager::{SessionMessage, SessionMeta};
use super::utils::{
extract_text, parse_timestamp_to_ms, path_basename, truncate_summary, TITLE_MAX_CHARS,
};
const PROVIDER_ID: &str = "pi";
const MAX_TREE_ENTRIES: usize = 500_000;
const MAX_TREE_ID_BYTES: usize = 256;
const MAX_SCAN_DEPTH: usize = 8;
const MAX_SESSION_BYTES: u64 = 128 * 1024 * 1024;
#[derive(Debug, PartialEq, Eq)]
enum SessionRootResolution {
Available {
root: PathBuf,
source: &'static str,
},
RequiresProjectContext {
configured_path: String,
source: &'static str,
},
Unavailable {
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum PiSessionDiscovery {
Available {
root: String,
source: &'static str,
},
RequiresProjectContext {
#[serde(rename = "configuredPath")]
configured_path: String,
source: &'static str,
},
Unavailable {
reason: String,
},
}
#[derive(Debug)]
struct SessionHeader {
id: String,
cwd: String,
timestamp: Option<i64>,
version: u64,
}
#[derive(Debug)]
struct SessionTree {
header: SessionHeader,
active_ids: HashSet<String>,
}
#[derive(Default)]
struct ActiveSessionData {
messages: Vec<SessionMessage>,
first_user_message: Option<String>,
last_message: Option<String>,
explicit_name: Option<Option<String>>,
last_active_at: Option<i64>,
}
/// Pi keeps a relative `sessionDir` relative through SessionManager creation;
/// its file operations therefore depend on the launching process cwd. A global
/// session browser has no authoritative launch cwd, so relative values are
/// deliberately non-enumerable and never fall back to another root.
pub fn session_roots() -> Vec<PathBuf> {
match resolve_session_root() {
SessionRootResolution::Available { root, .. } => vec![root],
SessionRootResolution::RequiresProjectContext { .. }
| SessionRootResolution::Unavailable { .. } => Vec::new(),
}
}
pub fn session_discovery() -> PiSessionDiscovery {
match resolve_session_root() {
SessionRootResolution::Available { root, source } => PiSessionDiscovery::Available {
root: root.to_string_lossy().into_owned(),
source,
},
SessionRootResolution::RequiresProjectContext {
configured_path,
source,
} => PiSessionDiscovery::RequiresProjectContext {
configured_path,
source,
},
SessionRootResolution::Unavailable { reason } => PiSessionDiscovery::Unavailable { reason },
}
}
fn resolve_session_root() -> SessionRootResolution {
let home = crate::config::get_home_dir();
if let Some(raw) = std::env::var_os("PI_CODING_AGENT_SESSION_DIR") {
if !raw.is_empty() {
return classify_configured_session_dir(
raw.to_string_lossy().as_ref(),
&home,
"environment",
);
}
}
match crate::pi_config::native_settings::read_pi_native_defaults() {
Ok(defaults) => {
if let Some(value) = defaults.session_dir.filter(|value| !value.is_empty()) {
return classify_configured_session_dir(&value, &home, "settings");
}
}
Err(error) => {
return SessionRootResolution::Unavailable {
reason: error.to_string(),
};
}
}
match crate::pi_config::native::get_pi_agent_dir() {
Ok(agent_dir) => SessionRootResolution::Available {
root: agent_dir.join("sessions"),
source: "default",
},
Err(error) => SessionRootResolution::Unavailable {
reason: error.to_string(),
},
}
}
fn classify_configured_session_dir(
value: &str,
home: &Path,
source: &'static str,
) -> SessionRootResolution {
match resolve_global_session_dir(value, home) {
Some(root) => SessionRootResolution::Available { root, source },
None => SessionRootResolution::RequiresProjectContext {
configured_path: value.to_string(),
source,
},
}
}
fn resolve_global_session_dir(value: &str, home: &Path) -> Option<PathBuf> {
let path = if value == "~" {
home.to_path_buf()
} else if let Some(suffix) = value
.strip_prefix("~/")
.or_else(|| value.strip_prefix("~\\"))
{
home.join(suffix)
} else {
PathBuf::from(value)
};
path.is_absolute().then_some(path)
}
pub fn scan_sessions() -> Vec<SessionMeta> {
let Some(root) = session_roots().into_iter().next() else {
match session_discovery() {
PiSessionDiscovery::RequiresProjectContext {
configured_path, ..
} => log::warn!(
"Pi sessionDir '{configured_path}' requires a project cwd and cannot be globally enumerated"
),
PiSessionDiscovery::Unavailable { reason } => {
log::warn!("Pi session discovery unavailable: {reason}")
}
PiSessionDiscovery::Available { .. } => {}
}
return Vec::new();
};
scan_sessions_in_root(&root)
}
fn scan_sessions_in_root(root: &Path) -> Vec<SessionMeta> {
let mut files = Vec::new();
collect_jsonl_files(root, 0, &mut files);
files
.into_iter()
.filter_map(|path| match parse_session(&path) {
Ok(session) => Some(session),
Err(error) => {
log::debug!("Skipping invalid Pi session {}: {error}", path.display());
None
}
})
.collect()
}
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
let root = session_roots()
.into_iter()
.next()
.ok_or_else(|| "Relative Pi sessionDir cannot be globally resolved".to_string())?;
load_messages_with_root(&root, path)
}
fn load_messages_with_root(root: &Path, path: &Path) -> Result<Vec<SessionMessage>, String> {
let (_, source) = validate_source_under_root(root, path)?;
let tree = read_tree(&source)?;
Ok(read_active_data(&source, &tree)?.messages)
}
pub fn delete_session(root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
if !is_valid_tree_id(session_id) {
return Err("Invalid Pi session ID".to_string());
}
let (_, source) = validate_source_under_root(root, path)?;
let tree = read_tree(&source)?;
if tree.header.id != session_id {
return Err(format!(
"Pi session ID mismatch: expected {session_id}, found {}",
tree.header.id
));
}
fs::remove_file(&source)
.map_err(|error| format!("Failed to delete Pi session {}: {error}", source.display()))?;
Ok(true)
}
fn parse_session(path: &Path) -> Result<SessionMeta, String> {
let source = path
.canonicalize()
.map_err(|error| format!("Failed to resolve Pi session {}: {error}", path.display()))?;
let source_path = source
.to_str()
.ok_or_else(|| "Pi session path is not valid UTF-8".to_string())?
.to_string();
let tree = read_tree(&source)?;
let data = read_active_data(&source, &tree)?;
let title = data.explicit_name.flatten().or_else(|| {
data.first_user_message
.as_deref()
.map(|message| truncate_summary(message, TITLE_MAX_CHARS))
.filter(|message| !message.is_empty())
.or_else(|| path_basename(&tree.header.cwd))
});
let summary = data
.last_message
.as_deref()
.map(|message| truncate_summary(message, 160))
.filter(|message| !message.is_empty());
Ok(SessionMeta {
provider_id: PROVIDER_ID.to_string(),
session_id: tree.header.id.clone(),
title,
summary,
project_dir: (!tree.header.cwd.trim().is_empty()).then(|| tree.header.cwd.clone()),
created_at: tree.header.timestamp,
last_active_at: data.last_active_at.or(tree.header.timestamp),
source_path: Some(source_path.clone()),
resume_command: Some(format!(
"pi --session {}",
crate::session_manager::terminal::shell_escape(&source_path)
)),
})
}
fn read_tree(path: &Path) -> Result<SessionTree, String> {
validate_file_size(path)?;
let reader = BufReader::new(
File::open(path).map_err(|error| format!("Failed to open Pi session: {error}"))?,
);
let mut header = None;
let mut parents = HashMap::<String, Option<String>>::new();
let mut latest_id = None;
let mut legacy_previous_id = None;
let mut entry_index = 0usize;
for line in reader.lines() {
let line = line.map_err(|error| format!("Failed to read Pi session: {error}"))?;
if line.trim().is_empty() {
continue;
}
let Ok(value) = serde_json::from_str::<Value>(&line) else {
continue;
};
if header.is_none() {
header = Some(parse_header(&value)?);
continue;
}
entry_index += 1;
if entry_index > MAX_TREE_ENTRIES {
return Err(format!(
"Pi session exceeds the {MAX_TREE_ENTRIES}-entry safety limit"
));
}
let version = header
.as_ref()
.map_or(1, |item: &SessionHeader| item.version);
let Some((id, parent_id)) =
entry_identity(&value, version, entry_index, legacy_previous_id.as_deref())
else {
continue;
};
if parents.insert(id.clone(), parent_id).is_some() {
return Err(format!("Pi session contains duplicate entry ID: {id}"));
}
latest_id = Some(id.clone());
legacy_previous_id = Some(id);
}
let header = header.ok_or_else(|| "Pi session has no valid header".to_string())?;
let mut active_ids = HashSet::new();
let mut current = latest_id;
while let Some(id) = current {
if !active_ids.insert(id.clone()) {
return Err(format!("Pi session tree contains a cycle at entry {id}"));
}
current = parents
.get(&id)
.ok_or_else(|| format!("Pi session entry references missing parent: {id}"))?
.clone();
}
Ok(SessionTree { header, active_ids })
}
fn read_active_data(path: &Path, tree: &SessionTree) -> Result<ActiveSessionData, String> {
validate_file_size(path)?;
let reader = BufReader::new(
File::open(path).map_err(|error| format!("Failed to open Pi session: {error}"))?,
);
let mut data = ActiveSessionData::default();
let mut saw_header = false;
let mut entry_index = 0usize;
let mut legacy_previous_id = None;
for line in reader.lines() {
let line = line.map_err(|error| format!("Failed to read Pi session: {error}"))?;
if line.trim().is_empty() {
continue;
}
let Ok(value) = serde_json::from_str::<Value>(&line) else {
continue;
};
if !saw_header {
if value.get("type").and_then(Value::as_str) == Some("session") {
saw_header = true;
}
continue;
}
entry_index += 1;
if entry_index > MAX_TREE_ENTRIES {
return Err(format!(
"Pi session exceeds the {MAX_TREE_ENTRIES}-entry safety limit"
));
}
let Some((id, _)) = entry_identity(
&value,
tree.header.version,
entry_index,
legacy_previous_id.as_deref(),
) else {
continue;
};
legacy_previous_id = Some(id.clone());
if value.get("type").and_then(Value::as_str) == Some("session_info") {
data.explicit_name = Some(
value
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_string),
);
}
if !tree.active_ids.contains(&id) {
continue;
}
let entry_timestamp = value.get("timestamp").and_then(parse_timestamp_to_ms);
if let Some(timestamp) = entry_timestamp {
data.last_active_at = Some(timestamp);
}
match value.get("type").and_then(Value::as_str) {
Some("session_info") => {}
Some("message") => {
let Some((role, content)) = value.get("message").and_then(parse_message) else {
continue;
};
let timestamp = value
.get("message")
.and_then(|message| message.get("timestamp"))
.and_then(parse_timestamp_to_ms)
.or(entry_timestamp);
if role == "user" && data.first_user_message.is_none() {
data.first_user_message = Some(content.clone());
}
if matches!(role.as_str(), "user" | "assistant") {
data.last_message = Some(content.clone());
}
data.messages.push(SessionMessage {
role,
content,
ts: timestamp,
});
}
Some("compaction") | Some("branch_summary") => {
push_system(
&mut data.messages,
value
.get("summary")
.and_then(Value::as_str)
.unwrap_or_default(),
entry_timestamp,
);
}
Some("custom_message")
if value.get("display").and_then(Value::as_bool) != Some(false) =>
{
push_system(
&mut data.messages,
&value.get("content").map(extract_text).unwrap_or_default(),
entry_timestamp,
);
}
_ => {}
}
}
Ok(data)
}
fn push_system(messages: &mut Vec<SessionMessage>, content: &str, ts: Option<i64>) {
if !content.trim().is_empty() {
messages.push(SessionMessage {
role: "system".to_string(),
content: content.to_string(),
ts,
});
}
}
fn parse_header(value: &Value) -> Result<SessionHeader, String> {
if value.get("type").and_then(Value::as_str) != Some("session") {
return Err("Pi session header must be the first valid JSON entry".to_string());
}
let id = value
.get("id")
.and_then(Value::as_str)
.filter(|id| is_valid_tree_id(id))
.ok_or_else(|| "Pi session header has an invalid ID".to_string())?
.to_string();
let version = value.get("version").and_then(Value::as_u64).unwrap_or(1);
if !(1..=3).contains(&version) {
return Err(format!("Unsupported Pi session version: {version}"));
}
Ok(SessionHeader {
id,
cwd: value
.get("cwd")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
timestamp: value.get("timestamp").and_then(parse_timestamp_to_ms),
version,
})
}
fn entry_identity(
value: &Value,
version: u64,
entry_index: usize,
legacy_previous_id: Option<&str>,
) -> Option<(String, Option<String>)> {
if version < 2 {
return Some((
format!("legacy-{entry_index}"),
legacy_previous_id.map(str::to_string),
));
}
let id = value
.get("id")
.and_then(Value::as_str)
.filter(|id| is_valid_tree_id(id))?
.to_string();
let parent_id = match value.get("parentId") {
None | Some(Value::Null) => None,
Some(Value::String(parent)) if is_valid_tree_id(parent) => Some(parent.clone()),
_ => return None,
};
Some((id, parent_id))
}
fn parse_message(message: &Value) -> Option<(String, String)> {
let role = message.get("role").and_then(Value::as_str)?;
let (display_role, content) = match role {
"user" | "assistant" => (
role.to_string(),
message.get("content").map(extract_text).unwrap_or_default(),
),
"toolResult" => (
"tool".to_string(),
message.get("content").map(extract_text).unwrap_or_default(),
),
"bashExecution" => (
"tool".to_string(),
format!(
"$ {}\n{}",
message
.get("command")
.and_then(Value::as_str)
.unwrap_or_default(),
message
.get("output")
.and_then(Value::as_str)
.unwrap_or_default()
),
),
"branchSummary" | "compactionSummary" => (
"system".to_string(),
message
.get("summary")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
),
_ => return None,
};
(!content.trim().is_empty()).then_some((display_role, content))
}
fn validate_source_under_root(root: &Path, path: &Path) -> Result<(PathBuf, PathBuf), String> {
let root = root.canonicalize().map_err(|error| {
format!(
"Failed to resolve Pi session root {}: {error}",
root.display()
)
})?;
let source = path
.canonicalize()
.map_err(|error| format!("Failed to resolve Pi session {}: {error}", path.display()))?;
if !source.starts_with(&root) {
return Err(format!(
"Pi session source is outside the session root: {}",
path.display()
));
}
let metadata = fs::symlink_metadata(&source)
.map_err(|error| format!("Failed to inspect Pi session {}: {error}", source.display()))?;
if !metadata.file_type().is_file()
|| source.extension().and_then(|value| value.to_str()) != Some("jsonl")
|| metadata.len() > MAX_SESSION_BYTES
{
return Err(format!("Invalid Pi session file: {}", source.display()));
}
Ok((root, source))
}
fn validate_file_size(path: &Path) -> Result<(), String> {
let metadata =
fs::metadata(path).map_err(|error| format!("Failed to inspect Pi session: {error}"))?;
if metadata.len() > MAX_SESSION_BYTES {
Err(format!(
"Pi session exceeds the {MAX_SESSION_BYTES}-byte safety limit"
))
} else {
Ok(())
}
}
fn is_valid_tree_id(id: &str) -> bool {
let bytes = id.as_bytes();
!bytes.is_empty()
&& bytes.len() <= MAX_TREE_ID_BYTES
&& bytes.first().is_some_and(u8::is_ascii_alphanumeric)
&& bytes.last().is_some_and(u8::is_ascii_alphanumeric)
&& bytes
.iter()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
}
fn collect_jsonl_files(root: &Path, depth: usize, output: &mut Vec<PathBuf>) {
if depth > MAX_SCAN_DEPTH {
return;
}
let Ok(entries) = fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
let path = entry.path();
if file_type.is_dir() {
collect_jsonl_files(&path, depth + 1, output);
} else if file_type.is_file()
&& path.extension().and_then(|value| value.to_str()) == Some("jsonl")
&& entry
.metadata()
.is_ok_and(|metadata| metadata.len() <= MAX_SESSION_BYTES)
{
output.push(path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn latest_leaf_defines_the_active_branch() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path().join("sessions");
fs::create_dir_all(&root).expect("root");
let path = root.join("tree.jsonl");
fs::write(
&path,
"{\"type\":\"session\",\"version\":3,\"id\":\"session-1\",\"cwd\":\"/work\"}\n\
{\"type\":\"message\",\"id\":\"root\",\"parentId\":null,\"message\":{\"role\":\"user\",\"content\":\"question\"}}\n\
{\"type\":\"message\",\"id\":\"dead\",\"parentId\":\"root\",\"message\":{\"role\":\"assistant\",\"content\":\"abandoned\"}}\n\
{\"type\":\"message\",\"id\":\"live\",\"parentId\":\"root\",\"message\":{\"role\":\"assistant\",\"content\":\"active\"}}\n",
)
.expect("session");
let messages = load_messages_with_root(&root, &path).expect("messages");
assert_eq!(
messages
.into_iter()
.map(|message| message.content)
.collect::<Vec<_>>(),
vec!["question", "active"]
);
}
#[test]
fn capture_matches_global_name_and_malformed_line_semantics() {
// Executed by scripts/pi-transport-capture.mjs against pinned Pi:
// getSessionName() keeps the latest global session_info even when its
// branch is inactive, and SessionManager.open() skips a malformed line.
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("captured.jsonl");
fs::write(
&path,
"{\"type\":\"session\",\"version\":3,\"id\":\"session-1\",\"cwd\":\"/work\"}\n\
{\"type\":\"message\",\"id\":\"root\",\"parentId\":null,\"message\":{\"role\":\"user\",\"content\":\"root\"}}\n\
{not valid json\n\
{\"type\":\"session_info\",\"id\":\"dead-name\",\"parentId\":\"root\",\"name\":\"Abandoned branch name\"}\n\
{\"type\":\"message\",\"id\":\"dead\",\"parentId\":\"dead-name\",\"message\":{\"role\":\"assistant\",\"content\":\"abandoned\"}}\n\
{\"type\":\"message\",\"id\":\"live\",\"parentId\":\"root\",\"message\":{\"role\":\"user\",\"content\":\"active branch\"}}\n",
)
.expect("captured session");
let session = parse_session(&path).expect("parse capture semantics");
assert_eq!(session.title.as_deref(), Some("Abandoned branch name"));
assert_eq!(session.summary.as_deref(), Some("active branch"));
}
#[test]
fn relative_root_is_explicitly_non_enumerable() {
assert_eq!(
resolve_global_session_dir(".pi/sessions", Path::new("/home/pi")),
None
);
assert_eq!(
classify_configured_session_dir(".pi/sessions", Path::new("/home/pi"), "settings"),
SessionRootResolution::RequiresProjectContext {
configured_path: ".pi/sessions".to_string(),
source: "settings",
}
);
}
#[test]
fn capture_generated_v3_shape_round_trips_all_consumed_fields() {
// Generated by scripts/pi-transport-capture.mjs against pinned Pi
// ab366ebe94cacd419d986be454f12b1b9913aaca using SessionManager APIs.
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path().join("sessions");
fs::create_dir_all(&root).expect("root");
let path = root.join("captured.jsonl");
fs::write(
&path,
"{\"type\":\"session\",\"version\":3,\"id\":\"cc-switch-capture-session\",\"timestamp\":\"2023-11-14T22:13:20.000Z\",\"cwd\":\"/work/captured\",\"parentSession\":null}\n\
{\"type\":\"session_info\",\"id\":\"00000000-0000-7000-8000-000000000001\",\"parentId\":null,\"timestamp\":\"2023-11-14T22:13:20.100Z\",\"name\":\"Captured session\"}\n\
{\"type\":\"message\",\"id\":\"00000000-0000-7000-8000-000000000002\",\"parentId\":\"00000000-0000-7000-8000-000000000001\",\"timestamp\":\"2023-11-14T22:13:20.200Z\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"captured question\"}],\"timestamp\":1700000000000}}\n\
{\"type\":\"message\",\"id\":\"00000000-0000-7000-8000-000000000003\",\"parentId\":\"00000000-0000-7000-8000-000000000002\",\"timestamp\":\"2023-11-14T22:13:21.200Z\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"captured answer\"}],\"api\":\"openai-responses\",\"provider\":\"capture\",\"model\":\"capture-model\",\"usage\":{\"input\":1,\"output\":1,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":2,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"stop\",\"timestamp\":1700000001000}}\n",
)
.expect("captured session");
let session = parse_session(&path).expect("parse capture-generated session");
assert_eq!(session.session_id, "cc-switch-capture-session");
assert_eq!(session.title.as_deref(), Some("Captured session"));
assert_eq!(session.summary.as_deref(), Some("captured answer"));
assert_eq!(session.project_dir.as_deref(), Some("/work/captured"));
assert_eq!(session.created_at, Some(1_700_000_000_000));
assert_eq!(session.last_active_at, Some(1_700_000_001_200));
// scripts/pi-transport-capture.mjs executes pinned Pi's parseArgs with
// ["--session", <SessionManager.getSessionFile()>] and records that the
// exact path is returned in Args.session.
assert!(session
.resume_command
.as_deref()
.is_some_and(|command| command.starts_with("pi --session ")));
let messages = load_messages_with_root(&root, &path).expect("load messages");
assert_eq!(
messages
.iter()
.map(|message| (message.role.as_str(), message.content.as_str(), message.ts))
.collect::<Vec<_>>(),
vec![
("user", "captured question", Some(1_700_000_000_000)),
("assistant", "captured answer", Some(1_700_000_001_000)),
]
);
}
#[test]
fn deletion_requires_containment_and_matching_header_id() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path().join("sessions");
fs::create_dir_all(&root).expect("root");
let path = root.join("session.jsonl");
fs::write(
&path,
"{\"type\":\"session\",\"version\":3,\"id\":\"session-1\",\"cwd\":\"/work\"}\n",
)
.expect("session");
assert!(delete_session(&root, &path, "other").is_err());
assert!(path.exists());
assert!(delete_session(&root, &path, "session-1").expect("delete"));
}
}
@@ -333,7 +333,7 @@ fn build_shell_command(command: &str, cwd: Option<&str>) -> String {
///
/// 单引号内不做任何展开,唯一的特例是 `'` 自身无法被表示:用「闭合-转义-重开」
/// 的 `'\''` 序列绕过。
fn shell_escape(value: &str) -> String {
pub(crate) fn shell_escape(value: &str) -> String {
format!("'{}'", value.replace('\'', r"'\''"))
}
+256 -27
View File
@@ -17,10 +17,156 @@ pub struct CustomEndpoint {
pub last_used: Option<i64>,
}
/// Device-local Pi gateway behavior. The frozen `proxy_config` schema has a
/// closed four-app domain, so Pi must not manufacture an out-of-contract row.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiProxySettings {
#[serde(default)]
pub auto_failover_enabled: bool,
#[serde(default = "default_pi_max_retries")]
pub max_retries: u32,
#[serde(default = "default_pi_first_byte_timeout")]
pub streaming_first_byte_timeout: u32,
#[serde(default = "default_pi_idle_timeout")]
pub streaming_idle_timeout: u32,
#[serde(default = "default_pi_request_timeout")]
pub non_streaming_timeout: u32,
#[serde(default = "default_pi_circuit_failure_threshold")]
pub circuit_failure_threshold: u32,
#[serde(default = "default_pi_circuit_success_threshold")]
pub circuit_success_threshold: u32,
#[serde(default = "default_pi_circuit_timeout")]
pub circuit_timeout_seconds: u32,
#[serde(default = "default_pi_circuit_error_rate")]
pub circuit_error_rate_threshold: f64,
#[serde(default = "default_pi_circuit_min_requests")]
pub circuit_min_requests: u32,
}
const fn default_pi_max_retries() -> u32 {
3
}
const fn default_pi_first_byte_timeout() -> u32 {
60
}
const fn default_pi_idle_timeout() -> u32 {
120
}
const fn default_pi_request_timeout() -> u32 {
600
}
const fn default_pi_circuit_failure_threshold() -> u32 {
4
}
const fn default_pi_circuit_success_threshold() -> u32 {
2
}
const fn default_pi_circuit_timeout() -> u32 {
60
}
const fn default_pi_circuit_min_requests() -> u32 {
10
}
fn default_pi_circuit_error_rate() -> f64 {
0.6
}
impl Default for PiProxySettings {
fn default() -> Self {
Self {
auto_failover_enabled: false,
max_retries: default_pi_max_retries(),
streaming_first_byte_timeout: default_pi_first_byte_timeout(),
streaming_idle_timeout: default_pi_idle_timeout(),
non_streaming_timeout: default_pi_request_timeout(),
circuit_failure_threshold: default_pi_circuit_failure_threshold(),
circuit_success_threshold: default_pi_circuit_success_threshold(),
circuit_timeout_seconds: default_pi_circuit_timeout(),
circuit_error_rate_threshold: default_pi_circuit_error_rate(),
circuit_min_requests: default_pi_circuit_min_requests(),
}
}
}
impl PiProxySettings {
pub(crate) fn validate(&self) -> Result<(), AppError> {
if !self.circuit_error_rate_threshold.is_finite()
|| !(0.0..=1.0).contains(&self.circuit_error_rate_threshold)
{
return Err(AppError::InvalidInput(
"Pi circuit error-rate threshold must be finite and within [0, 1]".to_string(),
));
}
if self.max_retries > 32 {
return Err(AppError::InvalidInput(
"Pi max retries cannot exceed 32".to_string(),
));
}
Ok(())
}
pub(crate) fn app_config(&self, enabled: bool) -> crate::proxy::types::AppProxyConfig {
crate::proxy::types::AppProxyConfig {
app_type: "pi".to_string(),
enabled,
auto_failover_enabled: self.auto_failover_enabled,
max_retries: self.max_retries,
streaming_first_byte_timeout: self.streaming_first_byte_timeout,
streaming_idle_timeout: self.streaming_idle_timeout,
non_streaming_timeout: self.non_streaming_timeout,
circuit_failure_threshold: self.circuit_failure_threshold,
circuit_success_threshold: self.circuit_success_threshold,
circuit_timeout_seconds: self.circuit_timeout_seconds,
circuit_error_rate_threshold: self.circuit_error_rate_threshold,
circuit_min_requests: self.circuit_min_requests,
}
}
}
fn default_true() -> bool {
true
}
/// Device-local bearer used only between Pi and cc-switch's loopback gateway.
///
/// Deliberately redacts `Debug`; the value must never be returned by settings
/// IPC, copied into SQLite, or emitted to logs.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct GatewayToken(String);
impl GatewayToken {
fn generate() -> Self {
Self(format!(
"ccs_pi_{}{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
))
}
pub(crate) fn expose(&self) -> &str {
&self.0
}
pub(crate) fn constant_time_eq(&self, candidate: &str) -> bool {
let expected = self.0.as_bytes();
let candidate = candidate.as_bytes();
let mut difference = expected.len() ^ candidate.len();
let shared = expected.len().min(candidate.len());
for index in 0..shared {
difference |= usize::from(expected[index] ^ candidate[index]);
}
difference == 0
}
}
impl std::fmt::Debug for GatewayToken {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("GatewayToken(<redacted>)")
}
}
/// 主页面显示的应用配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -46,6 +192,8 @@ pub struct VisibleApps {
pub openclaw: bool,
#[serde(default)]
pub hermes: bool,
#[serde(default = "default_true")]
pub pi: bool,
}
impl Default for VisibleApps {
@@ -59,6 +207,7 @@ impl Default for VisibleApps {
opencode: true,
openclaw: true,
hermes: false, // 默认不显示,需用户手动启用
pi: true,
}
}
}
@@ -75,6 +224,7 @@ impl VisibleApps {
AppType::OpenCode => self.opencode,
AppType::OpenClaw => self.openclaw,
AppType::Hermes => self.hermes,
AppType::Pi => self.pi,
}
}
}
@@ -422,6 +572,8 @@ pub struct AppSettings {
pub openclaw_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hermes_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pi_config_dir: Option<String>,
// ===== 当前供应商 ID(设备级)=====
/// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current
@@ -448,6 +600,29 @@ pub struct AppSettings {
/// 当前 Hermes 供应商 ID(本地存储,保持结构一致)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_hermes: Option<String>,
/// 当前 Pi 供应商 ID(本地存储)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_pi: Option<String>,
/// Device-local desired state for Pi's native `models.json` gateway
/// projection. Unlike the shared proxy-config row, this survives database
/// replacement and is reconciled against the live listener on startup.
#[serde(default)]
pub pi_takeover_enabled: bool,
#[serde(default)]
pub pi_proxy: PiProxySettings,
/// Stable device-installation secret for Pi's loopback gateway.
///
/// This field is serialized only to the device settings file. The frontend
/// projection clears it and settings-save merge restores the existing
/// value, so ordinary IPC cannot read, replace, or clear it.
#[serde(default, skip_serializing_if = "Option::is_none")]
// Public so integration tests and downstream Rust callers can continue to
// use struct-update syntax with `AppSettings`. IPC still cannot observe or
// mutate the value: the settings command clears it on reads and restores
// the persisted value on writes.
pub pi_gateway_token: Option<GatewayToken>,
// ===== Skill 同步设置 =====
/// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
@@ -533,6 +708,7 @@ impl Default for AppSettings {
opencode_config_dir: None,
openclaw_config_dir: None,
hermes_config_dir: None,
pi_config_dir: None,
current_provider_claude: None,
current_provider_claude_desktop: None,
current_provider_codex: None,
@@ -541,6 +717,10 @@ impl Default for AppSettings {
current_provider_opencode: None,
current_provider_openclaw: None,
current_provider_hermes: None,
current_provider_pi: None,
pi_takeover_enabled: false,
pi_proxy: PiProxySettings::default(),
pi_gateway_token: None,
skill_sync_method: SyncMethod::default(),
skill_storage_location: SkillStorageLocation::default(),
webdav_sync: None,
@@ -614,6 +794,13 @@ impl AppSettings {
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.pi_config_dir = self
.pi_config_dir
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.language = self
.language
.as_ref()
@@ -672,31 +859,9 @@ fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
let json = serde_json::to_string_pretty(&normalized)
let json = serde_json::to_vec_pretty(&normalized)
.map_err(|e| AppError::JsonSerialize { source: e })?;
#[cfg(unix)]
{
use std::fs::OpenOptions;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(&path)
.map_err(|e| AppError::io(&path, e))?;
file.write_all(json.as_bytes())
.map_err(|e| AppError::io(&path, e))?;
}
#[cfg(not(unix))]
{
fs::write(&path, json).map_err(|e| AppError::io(&path, e))?;
}
Ok(())
crate::config::atomic_write_durable(&path, &json, Some(0o600))
}
static SETTINGS_STORE: OnceLock<RwLock<AppSettings>> = OnceLock::new();
@@ -705,7 +870,7 @@ fn settings_store() -> &'static RwLock<AppSettings> {
SETTINGS_STORE.get_or_init(|| RwLock::new(AppSettings::load_from_file()))
}
fn resolve_override_path(raw: &str) -> PathBuf {
pub(crate) fn resolve_override_path(raw: &str) -> PathBuf {
if raw == "~" {
if let Some(home) = dirs::home_dir() {
return home;
@@ -742,17 +907,17 @@ pub fn get_settings_for_frontend() -> AppSettings {
s3.secret_access_key.clear();
}
settings.webdav_backup = None;
settings.pi_gateway_token = None;
settings
}
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
new_settings.normalize_paths();
save_settings_file(&new_settings)?;
let mut guard = settings_store().write().unwrap_or_else(|e| {
log::warn!("设置锁已毒化,使用恢复值: {e}");
e.into_inner()
});
save_settings_file(&new_settings)?;
*guard = new_settings;
Ok(())
}
@@ -933,6 +1098,64 @@ pub fn get_hermes_override_dir() -> Option<PathBuf> {
.map(|p| resolve_override_path(p))
}
pub fn get_pi_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
.pi_config_dir
.as_ref()
.map(|p| resolve_override_path(p))
}
pub(crate) fn get_or_create_pi_gateway_token() -> Result<GatewayToken, AppError> {
let mut token = None;
mutate_settings(|settings| {
let stored = settings
.pi_gateway_token
.get_or_insert_with(GatewayToken::generate);
token = Some(stored.clone());
})?;
token.ok_or_else(|| AppError::Config("无法创建 Pi 网关凭据".to_string()))
}
pub(crate) fn get_pi_gateway_token() -> Result<GatewayToken, AppError> {
get_settings().pi_gateway_token.ok_or_else(|| {
AppError::Conflict(
"Pi takeover is active but its gateway credential is unavailable".to_string(),
)
})
}
pub(crate) fn reset_pi_gateway_token() -> Result<GatewayToken, AppError> {
let generated = GatewayToken::generate();
mutate_settings(|settings| settings.pi_gateway_token = Some(generated.clone()))?;
Ok(generated)
}
pub(crate) fn replace_pi_gateway_token(token: GatewayToken) -> Result<(), AppError> {
mutate_settings(|settings| settings.pi_gateway_token = Some(token))
}
pub(crate) fn pi_takeover_enabled() -> bool {
get_settings().pi_takeover_enabled
}
pub(crate) fn set_pi_takeover_enabled(enabled: bool) -> Result<(), AppError> {
mutate_settings(|settings| settings.pi_takeover_enabled = enabled)
}
pub(crate) fn get_pi_proxy_settings() -> PiProxySettings {
get_settings().pi_proxy
}
pub(crate) fn update_pi_proxy_settings(settings: PiProxySettings) -> Result<(), AppError> {
settings.validate()?;
mutate_settings(|current| current.pi_proxy = settings)
}
pub(crate) fn get_pi_app_proxy_config() -> crate::proxy::types::AppProxyConfig {
get_pi_proxy_settings().app_config(pi_takeover_enabled())
}
pub fn preserve_codex_official_auth_on_switch() -> bool {
settings_store()
.read()
@@ -970,6 +1193,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option<String> {
AppType::OpenCode => settings.current_provider_opencode.clone(),
AppType::OpenClaw => settings.current_provider_openclaw.clone(),
AppType::Hermes => settings.current_provider_hermes.clone(),
AppType::Pi => settings.current_provider_pi.clone(),
}
}
@@ -988,6 +1212,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(),
AppType::OpenCode => settings.current_provider_opencode = id_owned.clone(),
AppType::OpenClaw => settings.current_provider_openclaw = id_owned.clone(),
AppType::Hermes => settings.current_provider_hermes = id_owned.clone(),
AppType::Pi => settings.current_provider_pi = id_owned.clone(),
})
}
@@ -1161,6 +1386,10 @@ mod tests {
.expect("visible apps");
assert!(visible.is_visible(&AppType::ClaudeDesktop));
assert!(
visible.is_visible(&AppType::Pi),
"Pi is a first-class app and must be visible when older settings omit its field"
);
}
#[test]
+1
View File
@@ -3,6 +3,7 @@ use crate::services::{ProxyService, UsageCache};
use std::sync::Arc;
/// 全局应用状态
#[derive(Clone)]
pub struct AppState {
pub db: Arc<Database>,
pub proxy_service: ProxyService,