From 87002e75edb2846939503f24ac458d03df767fbb Mon Sep 17 00:00:00 2001 From: Devin-Pi <89199086+Devin-Pi@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:29:04 +0800 Subject: [PATCH 01/12] feat(omp): add native Oh My Pi provider support Integrate OMP providers, models, roles, prompts, usage, and session discovery into the CLI and TUI while preserving OMP-native YAML semantics. --- README.md | 18 +- README_ZH.md | 30 +- src-tauri/src/app_config.rs | 57 +- src-tauri/src/cli/commands/app_targets.rs | 14 +- src-tauri/src/cli/commands/config_common.rs | 12 +- src-tauri/src/cli/commands/env.rs | 186 + src-tauri/src/cli/commands/failover.rs | 4 +- src-tauri/src/cli/commands/mcp.rs | 7 +- src-tauri/src/cli/commands/mod.rs | 1 + src-tauri/src/cli/commands/omp.rs | 279 + src-tauri/src/cli/commands/prompts.rs | 198 +- src-tauri/src/cli/commands/provider.rs | 116 +- src-tauri/src/cli/commands/provider_input.rs | 151 +- .../src/cli/commands/provider_inspect.rs | 483 +- .../src/cli/commands/provider_usage_query.rs | 62 +- src-tauri/src/cli/commands/proxy.rs | 2 +- src-tauri/src/cli/commands/sessions.rs | 34 +- src-tauri/src/cli/commands/settings.rs | 3 + src-tauri/src/cli/commands/skills.rs | 7 +- src-tauri/src/cli/failover_policy.rs | 4 +- src-tauri/src/cli/i18n.rs | 255 +- src-tauri/src/cli/mod.rs | 49 +- src-tauri/src/cli/tui/app/app_state.rs | 17 + src-tauri/src/cli/tui/app/content_entities.rs | 203 +- src-tauri/src/cli/tui/app/editor_state.rs | 11 + .../src/cli/tui/app/form_handlers/provider.rs | 234 +- src-tauri/src/cli/tui/app/helpers.rs | 14 +- src-tauri/src/cli/tui/app/menu.rs | 19 + .../cli/tui/app/overlay_handlers/dialogs.rs | 23 + .../cli/tui/app/overlay_handlers/pickers.rs | 5 +- src-tauri/src/cli/tui/app/tests.rs | 105 + src-tauri/src/cli/tui/app/types.rs | 13 + src-tauri/src/cli/tui/data.rs | 336 +- src-tauri/src/cli/tui/form/provider_json.rs | 77 +- src-tauri/src/cli/tui/form/provider_state.rs | 123 +- .../cli/tui/form/provider_state_loading.rs | 15 +- .../src/cli/tui/form/provider_templates.rs | 13 +- src-tauri/src/cli/tui/form/tests.rs | 106 + src-tauri/src/cli/tui/help.rs | 54 +- src-tauri/src/cli/tui/keymap.rs | 169 +- src-tauri/src/cli/tui/mod.rs | 10 +- src-tauri/src/cli/tui/route.rs | 31 + .../tui/runtime_actions/claude_temp_launch.rs | 1 + .../tui/runtime_actions/codex_temp_launch.rs | 1 + .../src/cli/tui/runtime_actions/editor.rs | 127 +- .../src/cli/tui/runtime_actions/helpers.rs | 1 + src-tauri/src/cli/tui/runtime_actions/mod.rs | 47 + src-tauri/src/cli/tui/runtime_actions/omp.rs | 47 + .../src/cli/tui/runtime_actions/providers.rs | 28 +- src-tauri/src/cli/tui/runtime_systems/mod.rs | 10 +- .../src/cli/tui/runtime_systems/types.rs | 199 +- .../src/cli/tui/runtime_systems/workers.rs | 35 +- src-tauri/src/cli/tui/tests.rs | 286 + src-tauri/src/cli/tui/theme.rs | 2 + src-tauri/src/cli/tui/ui.rs | 5 + src-tauri/src/cli/tui/ui/chrome.rs | 22 + src-tauri/src/cli/tui/ui/forms/provider.rs | 44 +- src-tauri/src/cli/tui/ui/forms/shared.rs | 15 + src-tauri/src/cli/tui/ui/header_tests.rs | 2 + src-tauri/src/cli/tui/ui/omp.rs | 353 + src-tauri/src/cli/tui/ui/overlay/pickers.rs | 2 + src-tauri/src/cli/tui/ui/prompts.rs | 8 + src-tauri/src/cli/tui/ui/providers.rs | 9 +- src-tauri/src/cli/tui/ui/skills/helpers.rs | 3 + src-tauri/src/cli/tui/ui/skills/installed.rs | 7 +- src-tauri/src/cli/tui/ui/tests.rs | 168 +- src-tauri/src/cli/ui/colors.rs | 2 + src-tauri/src/database/dao/skills.rs | 2 + src-tauri/src/deeplink/mod.rs | 2 + src-tauri/src/deeplink/parser.rs | 44 +- src-tauri/src/deeplink/provider.rs | 226 +- src-tauri/src/lib.rs | 1 + src-tauri/src/main.rs | 11 +- src-tauri/src/omp_config.rs | 5705 +++++++++++++++++ src-tauri/src/openclaw_config.rs | 8 + src-tauri/src/prompt_files.rs | 2 + src-tauri/src/provider.rs | 15 +- src-tauri/src/provider_preset_sponsors.rs | 2 +- src-tauri/src/proxy/providers/mod.rs | 10 +- src-tauri/src/services/config.rs | 2 +- src-tauri/src/services/env_checker.rs | 18 + src-tauri/src/services/env_manager.rs | 7 + src-tauri/src/services/local_env_check.rs | 10 +- src-tauri/src/services/mcp.rs | 4 +- src-tauri/src/services/mod.rs | 1 + src-tauri/src/services/pi_prompt_files.rs | 316 +- src-tauri/src/services/prompt.rs | 32 +- .../src/services/provider/common_config.rs | 29 +- src-tauri/src/services/provider/live.rs | 3 + src-tauri/src/services/provider/mod.rs | 139 +- src-tauri/src/services/provider/omp.rs | 1338 ++++ src-tauri/src/services/provider/usage.rs | 7 + src-tauri/src/services/proxy.rs | 11 +- src-tauri/src/services/session_usage.rs | 251 +- src-tauri/src/services/session_usage_omp.rs | 1379 ++++ src-tauri/src/services/session_usage_pi.rs | 167 +- src-tauri/src/services/skill.rs | 42 +- .../services/stream_check/provider_extract.rs | 1 + src-tauri/src/services/usage_stats.rs | 5 +- src-tauri/src/services/visible_apps.rs | 7 +- src-tauri/src/session_manager/mod.rs | 5 + .../src/session_manager/providers/mod.rs | 1 + .../src/session_manager/providers/omp.rs | 135 + src-tauri/src/settings.rs | 29 +- src-tauri/src/store.rs | 12 + src-tauri/src/sync_policy.rs | 1 + src-tauri/src/test_support.rs | 4 + src-tauri/tests/deeplink_import.rs | 138 + src-tauri/tests/settings_current_provider.rs | 2 + src-tauri/tests/settings_visible_apps.rs | 14 +- 110 files changed, 14678 insertions(+), 434 deletions(-) create mode 100644 src-tauri/src/cli/commands/omp.rs create mode 100644 src-tauri/src/cli/tui/runtime_actions/omp.rs create mode 100644 src-tauri/src/cli/tui/ui/omp.rs create mode 100644 src-tauri/src/omp_config.rs create mode 100644 src-tauri/src/services/provider/omp.rs create mode 100644 src-tauri/src/services/session_usage_omp.rs create mode 100644 src-tauri/src/session_manager/providers/omp.rs diff --git a/README.md b/README.md index 8243f3dc5..63f385405 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## CC-Switch CLI -**Manage Claude Code, Codex, Gemini, OpenCode, Hermes, OpenClaw, and Pi from one interactive TUI or scriptable CLI.** +**Manage Claude Code, Codex, Gemini, OpenCode, Hermes, OpenClaw, Pi, and OMP (oh-my-pi) from one interactive TUI or scriptable CLI.** [![Version](https://img.shields.io/badge/version-5.10.4-blue.svg)](https://github.com/saladday/cc-switch-cli/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/saladday/cc-switch-cli/releases) @@ -185,8 +185,13 @@ cc-switch --app gemini prompts list # List Gemini prompts cc-switch --app hermes provider list # Manage Hermes providers cc-switch --app openclaw provider list # Manage OpenClaw providers cc-switch --app pi provider list # Manage Pi providers +cc-switch --app omp provider list # Manage OMP providers +cc-switch --app omp provider set-default [--model ] # Set modelRoles.default +cc-switch --app omp model list # List models and show the active native path +cc-switch --app omp role list # List model roles and show the active native path +cc-switch --app omp env check # Compare CC-Switch and official OMP paths -# Supported apps: `claude` (default), `codex`, `gemini`, `opencode`, `hermes`, `openclaw`, `pi` +# Supported apps: `claude` (default), `codex`, `gemini`, `opencode`, `hermes`, `openclaw`, `pi`, `omp` ``` Use `cc-switch start` when you want different providers in multiple terminals. It only affects the Claude or Codex session launched by that command; `provider switch` and `use` still change the global provider. In the TUI, select a provider on the Providers page and press `o` for the same behavior. @@ -318,7 +323,9 @@ copy target\release\cc-switch.exe C:\Windows\System32\ ### 🔌 Provider Management -Manage API configurations for **Claude Code**, **Codex**, **Gemini**, **OpenCode**, **Hermes**, **OpenClaw**, and **Pi**. +Manage API configurations for **Claude Code**, **Codex**, **Gemini**, **OpenCode**, **Hermes**, **OpenClaw**, **Pi**, and **OMP**. + +OMP uses native additive YAML files. CC-Switch manages explicit `providers` and `models` in `models.yml`, and OMP's effective `modelRoles` in `config.yml`; providers remain side-by-side and the `default` role selects the active `provider/model` (or an OMP role alias such as `@smol`/`*`, optionally with a thinking suffix). When `modelRoleStorage: project` is effective, role assignments are written to the current directory's `.omp/config.yml` and overlaid on global roles. OMP login credentials are never rewritten. The OMP TUI also edits `SYSTEM.md`, `APPEND_SYSTEM.md`, and `TITLE_SYSTEM.md` using OMP's project-first lookup with revision checks, and shows the exact active prompt path. `SYSTEM.md` follows OMP's native provider (nearest non-empty ancestor `.omp` project directory, then the active native agent directory, including `PI_CODING_AGENT_DIR`); `APPEND_SYSTEM.md` and `TITLE_SYSTEM.md` use the generic cwd-only project lookup and profile/config-root user lookup. `disabledProviders` is honored when marking providers and setting defaults. Use `cc-switch --app omp model list`, `cc-switch --app omp role list`, and `cc-switch --app omp env check` to verify the files and effective settings OMP is using. OMP paths always follow the official `PI_CODING_AGENT_DIR`, `PI_CONFIG_DIR`, `OMP_PROFILE`, and legacy `PI_PROFILE` resolution; CC-Switch does not maintain a separate OMP directory override. Pi provider management follows Pi's native additive model: membership comes from `models.json.providers`. CC-Switch does not modify Pi login credentials or its global default provider/model. The Pi TUI keeps the same table/form/shortcut conventions as the other apps and exposes Presets, System Prompts, and Prompt Templates as separate pages. @@ -391,6 +398,8 @@ cc-switch prompts show # Display full content cc-switch prompts delete # Delete prompt cc-switch --app pi prompts system edit append # Edit APPEND_SYSTEM.md cc-switch --app pi prompts templates list # List Pi prompt templates +cc-switch --app omp prompts system edit append # Edit OMP APPEND_SYSTEM.md +cc-switch --app omp prompts system edit title # Edit OMP TITLE_SYSTEM.md ``` ### 🎯 Skills Management @@ -517,7 +526,7 @@ Inspect environment conflicts and whether required local CLIs are installed. ```bash cc-switch env check # Check environment conflicts cc-switch env list # List relevant environment variables -cc-switch env tools # Check Claude/Codex/Gemini/OpenCode/Hermes/OpenClaw/Pi CLIs +cc-switch env tools # Check Claude/Codex/Gemini/OpenCode/Hermes/OpenClaw/Pi/OMP CLIs ``` ### 🌐 Multi-language Support @@ -584,6 +593,7 @@ When `CC_SWITCH_CONFIG_DIR` is set, CC-Switch uses that directory as its config - Hermes: `~/.hermes/config.yaml` (providers + MCP + memory settings), `~/.hermes/AGENTS.md` (prompts), `~/.hermes/skills/` (skills), `~/.hermes/memories/` (memory) - OpenClaw: `~/.openclaw/openclaw.json` (providers + env/tools/agents defaults), `~/.openclaw/AGENTS.md` (prompts) - Pi: `~/.pi/agent/models.json` (additive providers), `~/.pi/agent/settings.json` (read-only defaults/session location), `~/.pi/agent/AGENTS.md`, `SYSTEM.md`, `APPEND_SYSTEM.md`, `prompts/`, `skills/`, and `sessions/` +- OMP (oh-my-pi): `~/.omp/agent/models.yml` (additive providers and models; `models.yaml` and legacy `models.json` are also supported), `~/.omp/agent/config.yml` (`modelRoles` and native settings), plus optional `SYSTEM.md`, `APPEND_SYSTEM.md`, and `TITLE_SYSTEM.md` resolved with OMP's project-first lookup. `SYSTEM.md` follows the native nearest-ancestor/provider path (including `PI_CODING_AGENT_DIR`), while `APPEND_SYSTEM.md` and `TITLE_SYSTEM.md` use the generic cwd-only project lookup and profile/config-root user lookup. Model/role files follow `PI_CONFIG_DIR` and profile resolution. `cc-switch env check --app omp` compares the adapter path with `omp config path`. --- diff --git a/README_ZH.md b/README_ZH.md index e111c5e11..b224bd0c4 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -4,7 +4,7 @@ ## CC-Switch CLI -**通过交互式 TUI 或脚本化 CLI,统一管理 Claude Code、Codex、Gemini、OpenCode、Hermes、OpenClaw 和 Pi。** +**通过交互式 TUI 或脚本化 CLI,统一管理 Claude Code、Codex、Gemini、OpenCode、Hermes、OpenClaw、Pi 和 OMP(oh-my-pi)。** [![Version](https://img.shields.io/badge/version-5.10.4-blue.svg)](https://github.com/saladday/cc-switch-cli/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/saladday/cc-switch-cli/releases) @@ -184,8 +184,13 @@ cc-switch --app gemini prompts list # 列出 Gemini 提示词 cc-switch --app hermes provider list # 管理 Hermes 供应商 cc-switch --app openclaw provider list # 管理 OpenClaw 供应商 cc-switch --app pi provider list # 管理 Pi 供应商 +cc-switch --app omp provider list # 管理 OMP 供应商 +cc-switch --app omp provider set-default [--model ] # 设置 OMP 的 modelRoles.default +cc-switch --app omp model list # 列出 models.yml 中的模型并显示实际路径 +cc-switch --app omp role list # 列出 config.yml 中的 modelRoles 并显示实际路径 +cc-switch --app omp env check # 检查 OMP 官方路径与 CC-Switch 路径是否一致 -# 支持的应用:`claude`(默认)、`codex`、`gemini`、`opencode`、`hermes`、`openclaw`、`pi` +# 支持的应用:`claude`(默认)、`codex`、`gemini`、`opencode`、`hermes`、`openclaw`、`pi`、`omp` ``` 需要在多个终端同时使用不同供应商时,请使用 `cc-switch start`。它只影响由该命令启动的 Claude 或 Codex 会话;`provider switch` 和 `use` 仍会切换全局供应商。在 TUI 的供应商页选中供应商后按 `o`,效果相同。 @@ -321,7 +326,21 @@ copy target\release\cc-switch.exe C:\Windows\System32\ ### 🔌 供应商管理 -管理 **Claude Code**、**Codex**、**Gemini**、**OpenCode**、**Hermes**、**OpenClaw** 与 **Pi** 的 API 配置。 +管理 **Claude Code**、**Codex**、**Gemini**、**OpenCode**、**Hermes**、**OpenClaw**、**Pi** 与 **OMP** 的 API 配置。 + +OMP 供应商遵循原生的累加管理模型:配置保存在 OMP 实际 agent 目录的 `models.yml` 的 `providers` 节点中,多个供应商可以同时存在。活动模型由生效配置中的 `modelRoles.default`(`provider/model[:thinking]`,也支持 `@smol`、`@slow`、`*` 等 OMP 角色别名)决定;如果 `modelRoleStorage: project`,角色会写入当前目录的 `.omp/config.yml` 并覆盖同名全局角色。在 OMP 页面按“设为默认”会更新该角色,不会删除其他供应商;`disabledProviders` 中的供应商会被明确标记为不可用,不能设为默认。CC-Switch 不会修改 OMP 登录凭据。OMP 路径始终遵循官方的 `PI_CODING_AGENT_DIR`、`PI_CONFIG_DIR`、`OMP_PROFILE` 和兼容的 `PI_PROFILE` 解析,CC-Switch 不再维护独立的 OMP 目录覆盖设置。 + +OMP 的系统提示词文件遵循 project-first 查找:当前目录的 `.omp`、`.claude`、`.codex`、`.gemini` 文件优先,其次才是用户级文件。用户级 `.omp` 提示词目录遵循 `PI_CONFIG_DIR`/profile 解析;官方的 `PI_CODING_AGENT_DIR` 只迁移 OMP 运行时 agent 目录,不改变这类共享配置文件的查找位置。TUI 会显示并编辑实际生效的 `SYSTEM.md`、`APPEND_SYSTEM.md` 和 `TITLE_SYSTEM.md`,同时对 `models.yml`、`config.yml` 与提示词文件使用外部修改检测,发现文件在编辑期间被改动时会拒绝覆盖。 + +要确认 CC-Switch 与 OMP 使用的是同一份文件,请分别运行: + +```bash +cc-switch --app omp model list # 显示 CC-Switch 正在读取的 models.yml +cc-switch --app omp role list # 显示 CC-Switch 正在读取的 config.yml +omp config path # 显示 OMP 官方解析出的 agent 目录 +``` + +旧版 CC-Switch 设置中的“自定义 OMP 目录”仅作为历史字段兼容读取,不会再决定 OMP 文件路径;否则会出现 CC-Switch 显示成功但 `omp` 读取另一份配置的问题。请使用 OMP 官方环境变量 `PI_CODING_AGENT_DIR`、`PI_CONFIG_DIR`、`OMP_PROFILE`(兼容 `PI_PROFILE`),并用 `omp config path` 与 `cc-switch --app omp env check` 校验两者是否一致。 Pi 供应商遵循原生的增量管理模型:是否启用完全取决于 `models.json.providers` 中的成员关系。CC-Switch 不会修改 Pi 的登录凭据或全局默认供应商/模型。 Pi TUI 延续其他应用的表格、表单与快捷键交互,并将预设、系统提示词和 Prompt Templates 分为独立页面。 @@ -394,6 +413,8 @@ cc-switch prompts show # 显示完整内容 cc-switch prompts delete # 删除提示词 cc-switch --app pi prompts system edit append # 编辑 APPEND_SYSTEM.md cc-switch --app pi prompts templates list # 列出 Pi prompt templates +cc-switch --app omp prompts system edit append # 编辑 OMP APPEND_SYSTEM.md +cc-switch --app omp prompts system edit title # 编辑 OMP TITLE_SYSTEM.md ``` ### 🎯 Skills 管理 @@ -519,7 +540,7 @@ cc-switch proxy serve --takeover claude # 前台调试模式;存在 ```bash cc-switch env check # 检查环境变量冲突 cc-switch env list # 列出相关环境变量 -cc-switch env tools # 检查 Claude/Codex/Gemini/OpenCode/Hermes/OpenClaw/Pi CLI +cc-switch env tools # 检查 Claude/Codex/Gemini/OpenCode/Hermes/OpenClaw/Pi/OMP CLI ``` ### 🌐 多语言支持 @@ -586,6 +607,7 @@ cc-switch update --version vX.Y.Z # 更新到指定版本 - Hermes: `~/.hermes/config.yaml`(供应商 + MCP + 记忆设置), `~/.hermes/AGENTS.md`(提示词), `~/.hermes/skills/`(技能), `~/.hermes/memories/`(记忆) - OpenClaw: `~/.openclaw/openclaw.json`(供应商 + Env/Tools/Agents Defaults), `~/.openclaw/AGENTS.md`(提示词) - Pi: `~/.pi/agent/models.json`(增量供应商), `~/.pi/agent/settings.json`(只读默认项 / 会话位置), `~/.pi/agent/AGENTS.md`、`SYSTEM.md`、`APPEND_SYSTEM.md`、`prompts/`、`skills/` 与 `sessions/` +- OMP(oh-my-pi): agent 目录下的 `models.yml`(增量供应商与模型;兼容 `models.yaml`/旧版 `models.json`)、`config.yml`(`modelRoles` 与原生设置),以及按 OMP project-first 规则查找的 `SYSTEM.md`、`APPEND_SYSTEM.md`、`TITLE_SYSTEM.md`。其中 `SYSTEM.md` 遵循原生 provider 的最近非空祖先 `.omp` 查找,并使用当前 native agent 目录(包括 `PI_CODING_AGENT_DIR`);`APPEND_SYSTEM.md` 与 `TITLE_SYSTEM.md` 使用当前目录的通用 project-first 查找和 profile/config-root 用户目录。模型/角色 agent 目录由 `~/.omp/agent`、`PI_CONFIG_DIR` 与 profile 决定。CC-Switch 设置里的 OMP 配置目录不会自动改变已启动 shell 中 `omp` 的环境变量。 --- diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index b5eeb47ad..b63009767 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -30,6 +30,7 @@ impl McpApps { AppType::Hermes => self.hermes, AppType::OpenClaw => false, AppType::Pi => false, + AppType::Omp => false, } } @@ -43,6 +44,7 @@ impl McpApps { AppType::Hermes => self.hermes = enabled, AppType::OpenClaw => {} AppType::Pi => {} + AppType::Omp => {} } } @@ -88,6 +90,8 @@ pub struct SkillApps { pub hermes: bool, #[serde(default)] pub pi: bool, + #[serde(default)] + pub omp: bool, } impl SkillApps { @@ -100,6 +104,7 @@ impl SkillApps { AppType::Hermes => self.hermes, AppType::OpenClaw => false, AppType::Pi => self.pi, + AppType::Omp => self.omp, } } @@ -112,11 +117,18 @@ impl SkillApps { AppType::Hermes => self.hermes = enabled, AppType::OpenClaw => {} AppType::Pi => self.pi = enabled, + AppType::Omp => self.omp = enabled, } } pub fn is_empty(&self) -> bool { - !self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes && !self.pi + !self.claude + && !self.codex + && !self.gemini + && !self.opencode + && !self.hermes + && !self.pi + && !self.omp } pub fn only(app: &AppType) -> Self { @@ -142,6 +154,7 @@ impl SkillApps { self.opencode |= other.opencode; self.hermes |= other.hermes; self.pi |= other.pi; + self.omp |= other.omp; } } @@ -254,6 +267,8 @@ pub struct McpRoot { pub openclaw: McpConfig, #[serde(skip)] pub pi: McpConfig, + #[serde(skip)] + pub omp: McpConfig, } impl Default for McpRoot { @@ -269,6 +284,7 @@ impl Default for McpRoot { hermes: McpConfig::default(), openclaw: McpConfig::default(), pi: McpConfig::default(), + omp: McpConfig::default(), } } } @@ -297,6 +313,8 @@ pub struct PromptRoot { pub openclaw: PromptConfig, #[serde(default)] pub pi: PromptConfig, + #[serde(default)] + pub omp: PromptConfig, } use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file}; @@ -315,6 +333,8 @@ pub enum AppType { Hermes, OpenClaw, Pi, + #[cfg_attr(feature = "cli", value(alias = "oh-my-pi"))] + Omp, } impl AppType { @@ -327,13 +347,14 @@ impl AppType { AppType::Hermes => "hermes", AppType::OpenClaw => "openclaw", AppType::Pi => "pi", + AppType::Omp => "omp", } } pub fn is_additive_mode(&self) -> bool { matches!( self, - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Omp ) } @@ -350,6 +371,7 @@ impl AppType { AppType::Hermes, AppType::OpenClaw, AppType::Pi, + AppType::Omp, ] .into_iter() } @@ -374,13 +396,14 @@ impl FromStr for AppType { "hermes" => Ok(AppType::Hermes), "openclaw" => Ok(AppType::OpenClaw), "pi" => Ok(AppType::Pi), + "omp" | "oh-my-pi" => Ok(AppType::Omp), other => Err(AppError::localized( "unsupported_app", format!( - "不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, hermes, openclaw, pi。" + "不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, hermes, openclaw, pi, omp。" ), format!( - "Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, hermes, openclaw, pi." + "Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, hermes, openclaw, pi, omp." ), )), } @@ -420,6 +443,7 @@ impl CommonConfigSnippets { AppType::Hermes => self.hermes.as_ref(), AppType::OpenClaw => self.openclaw.as_ref(), AppType::Pi => None, + AppType::Omp => None, } } @@ -433,6 +457,7 @@ impl CommonConfigSnippets { AppType::Hermes => self.hermes = snippet, AppType::OpenClaw => self.openclaw = snippet, AppType::Pi => {} + AppType::Omp => {} } } } @@ -476,6 +501,7 @@ impl Default for MultiAppConfig { apps.insert("hermes".to_string(), ProviderManager::default()); apps.insert("openclaw".to_string(), ProviderManager::default()); apps.insert("pi".to_string(), ProviderManager::default()); + apps.insert("omp".to_string(), ProviderManager::default()); Self { version: 2, @@ -590,6 +616,13 @@ impl MultiAppConfig { updated = true; } + if !config.apps.contains_key("omp") { + config + .apps + .insert("omp".to_string(), ProviderManager::default()); + updated = true; + } + // 执行 MCP 迁移(v3.6.x → v3.7.0) let migrated = config.migrate_mcp_to_unified()?; if migrated { @@ -657,6 +690,7 @@ impl MultiAppConfig { AppType::Hermes => &self.mcp.hermes, AppType::OpenClaw => &self.mcp.openclaw, AppType::Pi => &self.mcp.pi, + AppType::Omp => &self.mcp.omp, } } @@ -670,6 +704,7 @@ impl MultiAppConfig { AppType::Hermes => &mut self.mcp.hermes, AppType::OpenClaw => &mut self.mcp.openclaw, AppType::Pi => &mut self.mcp.pi, + AppType::Omp => &mut self.mcp.omp, } } @@ -708,6 +743,7 @@ impl MultiAppConfig { AppType::Hermes => &self.mcp.hermes.servers, AppType::OpenClaw => continue, AppType::Pi => continue, + AppType::Omp => continue, }; for (id, entry) in old_servers { @@ -1065,4 +1101,17 @@ mod tests { .expect("deserialize MCP root"); assert!(parsed.pi.is_empty()); } + + #[test] + fn skill_apps_keep_pi_and_omp_independent_when_merged() { + let mut pi = SkillApps::only(&AppType::Pi); + let omp = SkillApps::only(&AppType::Omp); + + assert!(pi.is_enabled_for(&AppType::Pi)); + assert!(!pi.is_enabled_for(&AppType::Omp)); + pi.merge_enabled(&omp); + + assert!(pi.is_enabled_for(&AppType::Pi)); + assert!(pi.is_enabled_for(&AppType::Omp)); + } } diff --git a/src-tauri/src/cli/commands/app_targets.rs b/src-tauri/src/cli/commands/app_targets.rs index 7f5c9fee0..0a39a5769 100644 --- a/src-tauri/src/cli/commands/app_targets.rs +++ b/src-tauri/src/cli/commands/app_targets.rs @@ -4,7 +4,7 @@ use crate::app_config::AppType; use crate::error::AppError; pub(crate) fn supported_app_target_labels() -> &'static str { - "claude, codex, gemini, opencode, hermes, pi" + "claude, codex, gemini, opencode, hermes, pi, omp" } fn supported_app_target_labels_for(feature: &str) -> &'static str { @@ -58,7 +58,13 @@ pub(crate) fn parse_app_targets( } fn parse_app_target(value: &str, feature: &str) -> Result { - let normalized = value.trim().to_lowercase().replace('-', ""); + let raw = value.trim().to_lowercase(); + let normalized = match raw.as_str() { + "open-code" => "opencode", + "open-claw" => "openclaw", + "oh-my-pi" => "omp", + other => other, + }; let app = AppType::from_str(&normalized).map_err(|_| { AppError::InvalidInput(format!( "Unsupported app id: '{value}'. Supported apps: {}", @@ -73,9 +79,9 @@ fn parse_app_target(value: &str, feature: &str) -> Result { ))); } - if matches!(app, AppType::Pi) && feature.eq_ignore_ascii_case("MCP") { + if matches!(app, AppType::Pi | AppType::Omp) && feature.eq_ignore_ascii_case("MCP") { return Err(AppError::InvalidInput(format!( - "{feature} does not support pi. Supported apps: {}", + "{feature} does not support pi or omp. Supported apps: {}", supported_app_target_labels_for(feature) ))); } diff --git a/src-tauri/src/cli/commands/config_common.rs b/src-tauri/src/cli/commands/config_common.rs index dc0720ac3..3a98d1cb0 100644 --- a/src-tauri/src/cli/commands/config_common.rs +++ b/src-tauri/src/cli/commands/config_common.rs @@ -174,9 +174,9 @@ fn read_required_text( } fn ensure_common_config_mutation_supported(app_type: &AppType) -> Result<(), AppError> { - if matches!(app_type, AppType::Pi) { + if matches!(app_type, AppType::Pi | AppType::Omp) { return Err(AppError::InvalidInput( - "Pi does not support common config snippets".to_string(), + "Pi and OMP do not support common config snippets".to_string(), )); } Ok(()) @@ -194,7 +194,8 @@ fn canonical_common_snippet(app_type: AppType, raw: &str) -> Result { + | AppType::Pi + | AppType::Omp => { let value: serde_json::Value = serde_json::from_str(raw).map_err(|e| { AppError::InvalidInput(texts::tui_toast_invalid_json(&e.to_string())) })?; @@ -703,11 +704,14 @@ mod tests { set(AppType::Pi, Some("{}"), None, false), extract(AppType::Pi, None, Some("{}"), None, true), clear(AppType::Pi, false), + set(AppType::Omp, Some("{}"), None, false), + extract(AppType::Omp, None, Some("{}"), None, true), + clear(AppType::Omp, false), ] { assert!(matches!( result, Err(AppError::InvalidInput(message)) - if message == "Pi does not support common config snippets" + if message == "Pi and OMP do not support common config snippets" )); } } diff --git a/src-tauri/src/cli/commands/env.rs b/src-tauri/src/cli/commands/env.rs index e056d5293..e992459cf 100644 --- a/src-tauri/src/cli/commands/env.rs +++ b/src-tauri/src/cli/commands/env.rs @@ -6,7 +6,11 @@ use crate::services::env_checker; use crate::services::local_env_check::{check_local_environment, ToolCheckStatus}; use clap::Subcommand; use serde_json::Value; +use std::io::Read; use std::path::Path; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; #[derive(Subcommand)] pub enum EnvCommand { @@ -96,6 +100,7 @@ fn run_app_doctor(app_type: &AppType) -> Result<(), AppError> { AppType::Claude => check_claude_doctor(), AppType::Codex => check_codex_doctor(), AppType::Gemini => check_gemini_doctor(), + AppType::Omp => check_omp_doctor(), AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { println!( "{}", @@ -109,6 +114,187 @@ fn run_app_doctor(app_type: &AppType) -> Result<(), AppError> { } } +/// Check that CC-Switch is editing the same native directory that the OMP +/// executable resolves. Unlike most apps, OMP's files are not projected into +/// CC-Switch's database: a custom directory stored in CC-Switch settings is a +/// local adapter override and cannot change an already-running shell's +/// environment. Reporting both paths makes that failure mode explicit. +fn check_omp_doctor() -> Result<(), AppError> { + let agent_dir = match crate::omp_config::get_omp_agent_dir() { + Ok(path) => path, + Err(error) => { + print_doctor_rows(vec![warn_row( + "OMP agent directory", + format!("cannot resolve CC-Switch path: {error}"), + )]); + return Ok(()); + } + }; + let mut rows = vec![ + ok_row( + "CC-Switch OMP agent directory", + agent_dir.display().to_string(), + ), + check_file_exists("OMP models.yml", &crate::omp_config::get_omp_models_path()?), + check_file_exists( + "OMP config.yml", + &crate::omp_config::get_omp_settings_path()?, + ), + ]; + + match crate::omp_config::get_omp_shared_config_agent_dir() { + Ok(path) => rows.push(ok_row( + "OMP shared config directory", + path.display().to_string(), + )), + Err(error) => rows.push(warn_row( + "OMP shared config directory", + format!("cannot resolve: {error}"), + )), + } + + if let Ok(roles) = crate::omp_config::read_omp_model_roles() { + rows.push(match roles.get("default") { + Some(selector) => ok_row("OMP default role", selector.clone()), + None => warn_row( + "OMP default role", + "modelRoles.default is not configured; OMP will use its built-in resolution" + .to_string(), + ), + }); + } + + match crate::omp_config::read_omp_disabled_providers() { + Ok(disabled) if disabled.is_empty() => rows.push(ok_row( + "OMP disabledProviders", + "no providers are disabled for the current directory".to_string(), + )), + Ok(disabled) => rows.push(warn_row( + "OMP disabledProviders", + format!("{} (these providers cannot be selected)", { + let mut ids = disabled.into_iter().collect::>(); + ids.sort(); + ids.join(", ") + }), + )), + Err(error) => rows.push(warn_row( + "OMP disabledProviders", + format!("cannot read effective disabledProviders: {error}"), + )), + } + + match official_omp_agent_dir() { + Ok(Some(path)) => { + rows.push(ok_row( + "OMP executable agent directory", + path.display().to_string(), + )); + if path == agent_dir { + rows.push(ok_row( + "OMP path alignment", + "CC-Switch and the OMP executable resolve the same directory".to_string(), + )); + } else { + let detail = format!( + "mismatch: CC-Switch edits {}, but omp reads {}; use the same official OMP environment selector before launching both", + agent_dir.display(), + path.display() + ); + rows.push(warn_row("OMP path alignment", detail)); + } + } + Ok(None) => rows.push(warn_row( + "OMP executable agent directory", + "omp config path returned no path".to_string(), + )), + Err(error) => rows.push(warn_row("OMP executable agent directory", error)), + } + + print_doctor_rows(rows); + Ok(()) +} + +fn official_omp_agent_dir() -> Result, String> { + let mut child = Command::new("omp") + .args(["config", "path"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| format!("cannot run `omp config path`: {error}"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "`omp config path` stdout was not captured".to_string())?; + let stderr = child + .stderr + .take() + .ok_or_else(|| "`omp config path` stderr was not captured".to_string())?; + let (stdout_tx, stdout_rx) = std::sync::mpsc::sync_channel(1); + let (stderr_tx, stderr_rx) = std::sync::mpsc::sync_channel(1); + thread::spawn(move || { + let mut bytes = Vec::new(); + let result = stdout + .take((64 * 1024 + 1) as u64) + .read_to_end(&mut bytes) + .map(|_| bytes); + let _ = stdout_tx.send(result); + }); + thread::spawn(move || { + let mut bytes = Vec::new(); + let result = stderr + .take((64 * 1024 + 1) as u64) + .read_to_end(&mut bytes) + .map(|_| bytes); + let _ = stderr_tx.send(result); + }); + let deadline = Instant::now() + Duration::from_secs(3); + loop { + if child + .try_wait() + .map_err(|error| format!("failed waiting for `omp config path`: {error}"))? + .is_some() + { + break; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err("`omp config path` timed out after 3 seconds".to_string()); + } + thread::sleep(Duration::from_millis(25)); + } + let status = child + .try_wait() + .map_err(|error| format!("failed waiting for `omp config path`: {error}"))? + .ok_or_else(|| "`omp config path` exited unexpectedly".to_string())?; + let stdout = stdout_rx + .recv_timeout(Duration::from_secs(1)) + .map_err(|_| "timed out reading `omp config path` stdout".to_string())? + .map_err(|error| format!("failed reading `omp config path` stdout: {error}"))?; + let stderr = stderr_rx + .recv_timeout(Duration::from_secs(1)) + .map_err(|_| "timed out reading `omp config path` stderr".to_string())? + .map_err(|error| format!("failed reading `omp config path` stderr: {error}"))?; + if stdout.len() > 64 * 1024 || stderr.len() > 64 * 1024 { + return Err("`omp config path` output exceeded the 64 KiB limit".to_string()); + } + if !status.success() { + let stderr = String::from_utf8_lossy(&stderr); + return Err(format!( + "`omp config path` exited with {}; {}", + status, + truncate_value(stderr.trim(), 160) + )); + } + let path = String::from_utf8_lossy(&stdout) + .lines() + .map(str::trim) + .rev() + .find(|line| !line.is_empty()) + .map(std::path::PathBuf::from); + Ok(path) +} + fn check_claude_doctor() -> Result<(), AppError> { let db = Database::open_readonly_current_schema()?; let current = crate::settings::get_effective_current_provider(&db, &AppType::Claude)?; diff --git a/src-tauri/src/cli/commands/failover.rs b/src-tauri/src/cli/commands/failover.rs index efc9d246f..fb7283b2c 100644 --- a/src-tauri/src/cli/commands/failover.rs +++ b/src-tauri/src/cli/commands/failover.rs @@ -409,7 +409,9 @@ fn takeover_enabled_for(takeovers: &ProxyTakeoverStatus, app_type: &AppType) -> AppType::Claude => takeovers.claude, AppType::Codex => takeovers.codex, AppType::Gemini => takeovers.gemini, - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Omp => { + false + } } } diff --git a/src-tauri/src/cli/commands/mcp.rs b/src-tauri/src/cli/commands/mcp.rs index f0ded37e1..f1947c020 100644 --- a/src-tauri/src/cli/commands/mcp.rs +++ b/src-tauri/src/cli/commands/mcp.rs @@ -68,9 +68,9 @@ pub enum McpCommand { pub fn execute(cmd: McpCommand, app: Option) -> Result<(), AppError> { let app_type = app.unwrap_or(AppType::Claude); - if matches!(app_type, AppType::Pi) { + if matches!(app_type, AppType::Pi | AppType::Omp) { return Err(AppError::InvalidInput( - "Pi does not support MCP management".to_string(), + "Pi and OMP do not support MCP management".to_string(), )); } @@ -474,7 +474,8 @@ mod tests { let error = execute(command, Some(AppType::Pi)).expect_err("Pi MCP must be rejected"); assert!(matches!( error, - AppError::InvalidInput(message) if message == "Pi does not support MCP management" + AppError::InvalidInput(message) + if message == "Pi and OMP do not support MCP management" )); } } diff --git a/src-tauri/src/cli/commands/mod.rs b/src-tauri/src/cli/commands/mod.rs index b258b3fe6..862c3bb8d 100644 --- a/src-tauri/src/cli/commands/mod.rs +++ b/src-tauri/src/cli/commands/mod.rs @@ -14,6 +14,7 @@ pub mod failover; pub mod hermes; pub mod internal; pub mod mcp; +pub mod omp; pub mod prompts; pub mod provider; pub mod provider_input; diff --git a/src-tauri/src/cli/commands/omp.rs b/src-tauri/src/cli/commands/omp.rs new file mode 100644 index 000000000..724d52c1d --- /dev/null +++ b/src-tauri/src/cli/commands/omp.rs @@ -0,0 +1,279 @@ +//! OMP-native model and model-role commands. +//! +//! OMP does not have a single "current provider". Providers live alongside +//! one another in `models.yml`; the model selector resolves a concrete model +//! through `config.yml`'s `modelRoles` map. These commands deliberately edit +//! those native files instead of projecting them onto CC Switch's legacy +//! single-provider model. + +use clap::Subcommand; +use serde_json::{Map, Value}; + +use crate::app_config::AppType; +use crate::cli::ui::{create_table, highlight, info, success}; +use crate::error::AppError; + +#[derive(Debug, Subcommand)] +pub enum OmpModelCommand { + /// List models from all OMP providers, or one provider. + List { + /// Restrict output to one provider key. + #[arg(long)] + provider: Option, + }, + /// Add a model to an OMP provider. + Add(ModelSetArgs), + /// Update an existing OMP model. + Edit(ModelSetArgs), + /// Remove a model from an OMP provider. + Delete { + /// OMP provider key. + provider: String, + /// Model identifier. + model: String, + }, +} + +#[derive(Debug, clap::Args)] +pub struct ModelSetArgs { + /// OMP provider key. + pub provider: String, + /// Model identifier. + pub model: String, + /// Complete model object as JSON. When omitted, field options update the + /// existing model (or build a minimal object for `add`). + #[arg(long, conflicts_with_all = ["name", "api", "reasoning", "context_window", "max_tokens"])] + pub config: Option, + /// Display name. + #[arg(long)] + pub name: Option, + /// Per-model OMP API protocol override. + #[arg(long)] + pub api: Option, + /// Whether the model supports reasoning. + #[arg(long)] + pub reasoning: Option, + /// Context window size in tokens. + #[arg(long)] + pub context_window: Option, + /// Maximum output token count. + #[arg(long)] + pub max_tokens: Option, +} + +#[derive(Debug, Subcommand)] +pub enum OmpRoleCommand { + /// List model-role assignments from OMP config.yml. + List, + /// Set one role to a provider/model selector. + Set { + /// Role name, for example `default`, `slow`, or `plan`. + role: String, + /// OMP selector such as `openai/gpt-5.6:high`, `@smol`, or `*`. + selector: String, + }, + /// Remove one role assignment so OMP falls back to its default resolution. + Delete { + /// Role name. + role: String, + }, +} + +/// Execute one of the top-level `cc-switch --app omp model ...` commands. +pub fn execute_model(command: OmpModelCommand, app_type: Option) -> Result<(), AppError> { + require_omp(app_type)?; + match command { + OmpModelCommand::List { provider } => list_models(provider), + OmpModelCommand::Add(args) => set_model(args, false), + OmpModelCommand::Edit(args) => set_model(args, true), + OmpModelCommand::Delete { provider, model } => { + if crate::omp_config::remove_omp_model(&provider, &model)? { + println!( + "{}", + success(&format!("Removed OMP model '{provider}/{model}'")) + ); + } else { + println!( + "{}", + info(&format!("OMP model '{provider}/{model}' was not found")) + ); + } + Ok(()) + } + } +} + +/// Execute one of the top-level `cc-switch --app omp role ...` commands. +pub fn execute_role(command: OmpRoleCommand, app_type: Option) -> Result<(), AppError> { + require_omp(app_type)?; + match command { + OmpRoleCommand::List => list_roles(), + OmpRoleCommand::Set { role, selector } => { + crate::omp_config::set_omp_model_role(&role, Some(&selector), None)?; + println!( + "{}", + success(&format!("Set OMP model role '{role}' to '{selector}'")) + ); + Ok(()) + } + OmpRoleCommand::Delete { role } => { + crate::omp_config::set_omp_model_role(&role, None, None)?; + println!("{}", success(&format!("Removed OMP model role '{role}'"))); + Ok(()) + } + } +} + +fn require_omp(app_type: Option) -> Result<(), AppError> { + if app_type == Some(AppType::Omp) { + Ok(()) + } else { + Err(AppError::InvalidInput( + "OMP model and role commands require --app omp".to_string(), + )) + } +} + +fn list_models(provider_filter: Option) -> Result<(), AppError> { + println!( + "OMP models: {}", + crate::omp_config::get_omp_models_path()?.display() + ); + let providers = crate::omp_config::read_omp_native_providers()?; + let models = crate::omp_config::read_omp_native_models()?; + let models = models + .into_iter() + .filter(|model| { + provider_filter + .as_deref() + .is_none_or(|id| id == model.provider_id) + }) + .collect::>(); + + if models.is_empty() { + println!("{}", info("No OMP models found.")); + return Ok(()); + } + + let mut table = create_table(); + table.set_header(vec!["Provider", "Model", "Name", "API", "Reasoning"]); + for model in models { + let name = model + .config + .get("name") + .and_then(Value::as_str) + .unwrap_or(""); + let api = model + .config + .get("api") + .and_then(Value::as_str) + .or_else(|| { + providers + .get(&model.provider_id) + .and_then(|provider| provider.get("api")) + .and_then(Value::as_str) + }) + .unwrap_or(""); + let reasoning = model + .config + .get("reasoning") + .and_then(Value::as_bool) + .map(|value| if value { "yes" } else { "no" }) + .unwrap_or(""); + table.add_row(vec![ + model.provider_id, + model.model_id, + name.to_string(), + api.to_string(), + reasoning.to_string(), + ]); + } + println!("{}", table); + Ok(()) +} + +fn list_roles() -> Result<(), AppError> { + let (roles, path, _) = crate::omp_config::read_omp_model_roles_with_metadata()?; + println!("OMP role writes: {}", path.display()); + if roles.is_empty() { + println!("{}", info("No OMP model roles configured.")); + println!( + "{}", + highlight(&format!( + "Built-in roles: {}", + crate::omp_config::OMP_BUILTIN_MODEL_ROLES.join(", ") + )) + ); + return Ok(()); + } + let mut table = create_table(); + table.set_header(vec!["Role", "Model"]); + for (role, selector) in roles { + table.add_row(vec![role, selector]); + } + println!("{}", table); + Ok(()) +} + +fn set_model(args: ModelSetArgs, editing: bool) -> Result<(), AppError> { + let (_, expected_revision) = crate::omp_config::read_omp_models_yaml()?; + let existing = crate::omp_config::read_omp_native_models()? + .into_iter() + .find(|model| model.provider_id == args.provider && model.model_id == args.model) + .map(|model| model.config); + + if editing && existing.is_none() { + return Err(AppError::InvalidInput(format!( + "OMP model '{}/{}' not found", + args.provider, args.model + ))); + } + if !editing && existing.is_some() { + return Err(AppError::InvalidInput(format!( + "OMP model '{}/{}' already exists", + args.provider, args.model + ))); + } + + let mut model = match args.config { + Some(config) => serde_json::from_str::(&config).map_err(|error| { + AppError::InvalidInput(format!("--config must be valid JSON: {error}")) + })?, + None => existing.unwrap_or_else(|| Value::Object(Map::new())), + }; + let object = model.as_object_mut().ok_or_else(|| { + AppError::InvalidInput("OMP model configuration must be a JSON object".to_string()) + })?; + object.insert("id".to_string(), Value::String(args.model.clone())); + if let Some(value) = args.name { + object.insert("name".to_string(), Value::String(value)); + } + if let Some(value) = args.api { + crate::omp_config::validate_api_protocol(&value)?; + object.insert("api".to_string(), Value::String(value)); + } + if let Some(value) = args.reasoning { + object.insert("reasoning".to_string(), Value::Bool(value)); + } + if let Some(value) = args.context_window { + object.insert("contextWindow".to_string(), Value::from(value)); + } + if let Some(value) = args.max_tokens { + object.insert("maxTokens".to_string(), Value::from(value)); + } + crate::omp_config::upsert_omp_model_checked( + &args.provider, + &args.model, + model, + &expected_revision, + )?; + let action = if editing { "Updated" } else { "Added" }; + println!( + "{}", + success(&format!( + "{action} OMP model '{}/{}'", + args.provider, args.model + )) + ); + Ok(()) +} diff --git a/src-tauri/src/cli/commands/prompts.rs b/src-tauri/src/cli/commands/prompts.rs index 9af98c843..f6177a8af 100644 --- a/src-tauri/src/cli/commands/prompts.rs +++ b/src-tauri/src/cli/commands/prompts.rs @@ -4,7 +4,7 @@ use crate::app_config::AppType; use crate::cli::ui::{create_table, highlight, info, success}; use crate::error::AppError; use crate::services::pi_prompt_files::{ - PiPromptFileKind, PiPromptFileService, PiPromptTemplateService, + OmpPromptFileService, PiPromptFileKind, PiPromptFileService, PiPromptTemplateService, }; use crate::services::PromptService; use crate::store::AppState; @@ -71,7 +71,7 @@ pub enum PromptsCommand { /// Prompt preset ID id: String, }, - /// Manage Pi's native SYSTEM.md or APPEND_SYSTEM.md + /// Manage Pi/OMP native system prompt files System { #[command(subcommand)] command: PiSystemPromptCommand, @@ -89,6 +89,8 @@ pub enum PiSystemPromptKind { Append, /// SYSTEM.md (replaces Pi's built-in system prompt) Override, + /// TITLE_SYSTEM.md (OMP only) + Title, } impl From for PiPromptFileKind { @@ -96,17 +98,18 @@ impl From for PiPromptFileKind { match value { PiSystemPromptKind::Append => Self::SystemAppend, PiSystemPromptKind::Override => Self::SystemOverride, + PiSystemPromptKind::Title => Self::TitleSystem, } } } #[derive(Subcommand)] pub enum PiSystemPromptCommand { - /// Show a native Pi system prompt file + /// Show a native Pi/OMP system prompt file Show { kind: PiSystemPromptKind }, - /// Edit a native Pi system prompt file + /// Edit a native Pi/OMP system prompt file Edit { kind: PiSystemPromptKind }, - /// Delete a native Pi system prompt file + /// Delete a native Pi/OMP system prompt file Delete { kind: PiSystemPromptKind }, } @@ -169,19 +172,38 @@ fn require_pi(app_type: &AppType, resource: &str) -> Result<(), AppError> { } } +fn require_native_prompt_app(app_type: &AppType) -> Result<(), AppError> { + if matches!(app_type, AppType::Pi | AppType::Omp) { + Ok(()) + } else { + Err(AppError::InvalidInput( + "Native system prompts are only available with --app pi or --app omp".to_string(), + )) + } +} + fn manage_pi_system_prompt( app_type: AppType, command: PiSystemPromptCommand, ) -> Result<(), AppError> { - require_pi(&app_type, "Pi native system prompts")?; + if matches!(app_type, AppType::Omp) { + return manage_omp_system_prompt(command); + } + require_native_prompt_app(&app_type)?; let (kind, action) = match command { PiSystemPromptCommand::Show { kind } => (kind, "show"), PiSystemPromptCommand::Edit { kind } => (kind, "edit"), PiSystemPromptCommand::Delete { kind } => (kind, "delete"), }; + if matches!(kind, PiSystemPromptKind::Title) { + return Err(AppError::InvalidInput( + "TITLE_SYSTEM.md is only available with --app omp".to_string(), + )); + } let filename = match kind { PiSystemPromptKind::Append => "APPEND_SYSTEM.md", PiSystemPromptKind::Override => "SYSTEM.md", + PiSystemPromptKind::Title => "TITLE_SYSTEM.md", }; let native_kind = kind.into(); @@ -246,6 +268,80 @@ fn manage_pi_system_prompt( Ok(()) } +fn manage_omp_system_prompt(command: PiSystemPromptCommand) -> Result<(), AppError> { + let (kind, action) = match command { + PiSystemPromptCommand::Show { kind } => (kind, "show"), + PiSystemPromptCommand::Edit { kind } => (kind, "edit"), + PiSystemPromptCommand::Delete { kind } => (kind, "delete"), + }; + let filename = match kind { + PiSystemPromptKind::Append => "APPEND_SYSTEM.md", + PiSystemPromptKind::Override => "SYSTEM.md", + PiSystemPromptKind::Title => "TITLE_SYSTEM.md", + }; + let native_kind: PiPromptFileKind = kind.into(); + + match action { + "show" => { + let snapshot = OmpPromptFileService::read(native_kind)?; + if snapshot.exists { + println!("{}", highlight(filename)); + println!("{}", snapshot.content); + } else { + println!("{}", info(&format!("{filename} does not exist."))); + } + } + "edit" => { + let snapshot = OmpPromptFileService::read(native_kind)?; + if matches!(kind, PiSystemPromptKind::Override) && !snapshot.exists { + println!( + "{}", + info("SYSTEM.md replaces OMP's built-in system prompt; APPEND_SYSTEM.md is recommended for normal additions.") + ); + let confirmed = inquire::Confirm::new("Create SYSTEM.md?") + .with_default(false) + .prompt() + .map_err(|error| AppError::Message(format!("Prompt failed: {error}")))?; + if !confirmed { + println!("{}", info("Cancelled.")); + return Ok(()); + } + } + let initial = if snapshot.exists { + snapshot.content.as_str() + } else { + "# Write the OMP system prompt here\n" + }; + let edited = crate::cli::editor::open_external_editor(initial)?; + if snapshot.exists && edited == snapshot.content { + println!("{}", info("No changes detected.")); + return Ok(()); + } + OmpPromptFileService::replace(native_kind, &snapshot.revision, &edited)?; + println!("{}", success(&format!("✓ Saved {filename}"))); + } + "delete" => { + let snapshot = OmpPromptFileService::read(native_kind)?; + if !snapshot.exists { + println!("{}", info(&format!("{filename} does not exist."))); + return Ok(()); + } + let confirmed = inquire::Confirm::new(&format!("Delete {filename}?")) + .with_default(false) + .prompt() + .map_err(|error| AppError::Message(format!("Prompt failed: {error}")))?; + if !confirmed { + println!("{}", info("Cancelled.")); + return Ok(()); + } + OmpPromptFileService::delete(native_kind, &snapshot.revision)?; + println!("{}", success(&format!("✓ Deleted {filename}"))); + } + _ => unreachable!(), + } + Ok(()) +} + fn manage_pi_prompt_templates( app_type: AppType, command: PiPromptTemplatesCommand, @@ -341,7 +437,78 @@ fn get_state() -> Result { AppState::try_new() } +fn omp_prompt_kinds() -> [PiPromptFileKind; 3] { + [ + PiPromptFileKind::SystemOverride, + PiPromptFileKind::SystemAppend, + PiPromptFileKind::TitleSystem, + ] +} + +fn omp_prompt_filename(kind: PiPromptFileKind) -> &'static str { + match kind { + PiPromptFileKind::SystemOverride => "SYSTEM.md", + PiPromptFileKind::SystemAppend => "APPEND_SYSTEM.md", + PiPromptFileKind::TitleSystem => "TITLE_SYSTEM.md", + } +} + +fn list_omp_system_prompt_files() -> Result<(), AppError> { + let mut table = create_table(); + table.set_header(vec!["", "File", "Characters", "Resolved path"]); + for kind in omp_prompt_kinds() { + let snapshot = OmpPromptFileService::read(kind)?; + let path = OmpPromptFileService::active_path(kind)?; + table.add_row(vec![ + if snapshot.exists { "✓" } else { " " }.to_string(), + omp_prompt_filename(kind).to_string(), + snapshot.content.chars().count().to_string(), + path.display().to_string(), + ]); + } + println!("{table}"); + println!( + "{}", + info("OMP reads these files with project-first lookup.") + ); + Ok(()) +} + +fn show_omp_system_prompt_files() -> Result<(), AppError> { + println!("{}", highlight("OMP Native Prompt Files")); + println!("{}", "=".repeat(50)); + for kind in omp_prompt_kinds() { + let snapshot = OmpPromptFileService::read(kind)?; + let path = OmpPromptFileService::active_path(kind)?; + println!( + "\n{} ({})", + highlight(omp_prompt_filename(kind)), + path.display() + ); + if snapshot.exists { + println!("{}", snapshot.content); + } else { + println!("{}", info("Not present.")); + } + } + Ok(()) +} + +fn reject_omp_prompt_presets(app_type: &AppType) -> Result<(), AppError> { + if matches!(app_type, AppType::Omp) { + Err(AppError::InvalidInput( + "OMP uses native prompt files; use 'cc-switch --app omp prompts system ...' instead of prompt presets." + .to_string(), + )) + } else { + Ok(()) + } +} + fn list_prompts(app_type: AppType) -> Result<(), AppError> { + if matches!(app_type, AppType::Omp) { + return list_omp_system_prompt_files(); + } let state = get_state()?; let prompts = PromptService::get_prompts(&state, app_type.clone())?; @@ -403,6 +570,9 @@ fn list_prompts(app_type: AppType) -> Result<(), AppError> { } fn show_current(app_type: AppType) -> Result<(), AppError> { + if matches!(app_type, AppType::Omp) { + return show_omp_system_prompt_files(); + } let state = get_state()?; let prompts = PromptService::get_prompts(&state, app_type.clone())?; @@ -458,6 +628,9 @@ fn show_current(app_type: AppType) -> Result<(), AppError> { } fn show_live_prompt(app_type: AppType) -> Result<(), AppError> { + if matches!(app_type, AppType::Omp) { + return show_omp_system_prompt_files(); + } let content = PromptService::get_current_file_content(app_type.clone())?; match content { @@ -488,6 +661,12 @@ fn show_live_prompt(app_type: AppType) -> Result<(), AppError> { } fn import_prompt(app_type: AppType) -> Result<(), AppError> { + if matches!(app_type, AppType::Omp) { + return Err(AppError::InvalidInput( + "OMP uses native prompt files; use 'cc-switch --app omp prompts system ...' instead of importing presets." + .to_string(), + )); + } let state = get_state()?; let id = PromptService::import_from_file(&state, app_type.clone())?; let prompts = PromptService::get_prompts(&state, app_type.clone())?; @@ -514,6 +693,7 @@ fn import_prompt(app_type: AppType) -> Result<(), AppError> { } fn activate_prompt(app_type: AppType, id: &str) -> Result<(), AppError> { + reject_omp_prompt_presets(&app_type)?; let state = get_state()?; let app_str = app_type.as_str().to_string(); @@ -544,6 +724,7 @@ fn activate_prompt(app_type: AppType, id: &str) -> Result<(), AppError> { } fn delete_prompt(app_type: AppType, id: &str) -> Result<(), AppError> { + reject_omp_prompt_presets(&app_type)?; let state = get_state()?; // 检查 prompt 是否存在 @@ -592,6 +773,7 @@ fn delete_prompt(app_type: AppType, id: &str) -> Result<(), AppError> { } fn show_prompt(app_type: AppType, id: &str) -> Result<(), AppError> { + reject_omp_prompt_presets(&app_type)?; let state = get_state()?; let prompts = PromptService::get_prompts(&state, app_type)?; @@ -641,6 +823,7 @@ fn create_prompt( name: Option, description: Option, ) -> Result<(), AppError> { + reject_omp_prompt_presets(&app_type)?; let state = get_state()?; let default_name = format!("Prompt {}", chrono::Local::now().format("%Y-%m-%d %H:%M")); let name = match name { @@ -686,6 +869,7 @@ fn create_prompt( } fn deactivate_prompt(app_type: AppType) -> Result<(), AppError> { + reject_omp_prompt_presets(&app_type)?; let state = get_state()?; let prompts = PromptService::get_prompts(&state, app_type.clone())?; @@ -721,6 +905,7 @@ fn deactivate_prompt(app_type: AppType) -> Result<(), AppError> { } fn edit_prompt(_app_type: AppType, id: &str) -> Result<(), AppError> { + reject_omp_prompt_presets(&_app_type)?; let state = get_state()?; let prompts = PromptService::get_prompts(&state, _app_type.clone())?; let Some(mut prompt) = prompts.get(id).cloned() else { @@ -759,6 +944,7 @@ fn rename_prompt( name: Option, description: Option, ) -> Result<(), AppError> { + reject_omp_prompt_presets(&app_type)?; let state = get_state()?; let prompts = PromptService::get_prompts(&state, app_type.clone())?; let Some(prompt) = prompts.get(id) else { diff --git a/src-tauri/src/cli/commands/provider.rs b/src-tauri/src/cli/commands/provider.rs index 201a48065..036250e4b 100644 --- a/src-tauri/src/cli/commands/provider.rs +++ b/src-tauri/src/cli/commands/provider.rs @@ -498,9 +498,12 @@ fn prompt_and_apply_provider_api_format( match app_type { AppType::Claude => prompt_and_apply_claude_api_format(app_type, provider), AppType::Codex => prompt_and_apply_codex_api_format(app_type, provider), - AppType::Gemini | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { - Ok(()) - } + AppType::Gemini + | AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw + | AppType::Pi + | AppType::Omp => Ok(()), } } @@ -783,7 +786,7 @@ pub enum ProviderCommand { SetDefault { /// Provider ID to set as default id: String, - /// OpenClaw model ID to set as primary; defaults to the first live model + /// OpenClaw/OMP model ID to set as primary; defaults to the first live model #[arg(long)] model: Option, }, @@ -935,6 +938,7 @@ pub enum ModelFetchAuthArg { Bearer, Anthropic, GoogleApiKey, + AzureApiKey, } impl From for provider_inspect::ProviderModelFetchStrategy { @@ -943,6 +947,7 @@ impl From for provider_inspect::ProviderModelFetchStrategy { ModelFetchAuthArg::Bearer => Self::Bearer, ModelFetchAuthArg::Anthropic => Self::Anthropic, ModelFetchAuthArg::GoogleApiKey => Self::GoogleApiKey, + ModelFetchAuthArg::AzureApiKey => Self::AzureApiKey, } } } @@ -992,8 +997,8 @@ fn resolve_provider_for_switch( fn switch_provider(app_type: AppType, id: &str) -> Result<(), AppError> { let state = get_state()?; let app_str = app_type.as_str().to_string(); - let skip_live_sync = - !matches!(app_type, AppType::Pi) && !crate::sync_policy::should_sync_live(&app_type); + let skip_live_sync = !matches!(app_type, AppType::Pi | AppType::Omp) + && !crate::sync_policy::should_sync_live(&app_type); // 检查 provider 是否存在(支持按 id 或名称解析) let providers = ProviderService::list(&state, app_type.clone())?; @@ -1011,7 +1016,9 @@ fn switch_provider(app_type: AppType, id: &str) -> Result<(), AppError> { ); } - if app_type.is_additive_mode() { + if matches!(app_type, AppType::Omp) { + println!("{}", success(&format!("✓ Enabled OMP provider '{}'", id))); + } else if app_type.is_additive_mode() { println!( "{}", success(&texts::provider_added_to_app_config(id, &app_str)) @@ -1441,6 +1448,51 @@ fn build_add_settings_config( } Ok(settings) } + AppType::Omp => { + let base_url = non_empty(args.base_url.clone()); + let model = non_empty(args.model.clone()); + if model.is_some() && base_url.is_none() { + return Err(add_missing_field_error("--base-url")); + } + if let Some(base_url) = base_url.as_deref() { + if !crate::omp_config::is_valid_request_url(base_url) { + return Err(AppError::InvalidInput( + "OMP --base-url must be an absolute HTTP(S) URL".to_string(), + )); + } + } + // Custom OMP models require a provider-level API protocol. Keep + // the CLI ergonomic by defaulting to OMP's native protocol when + // `--api-format` is omitted; override-only entries may omit it. + let api = non_empty(args.api_format.clone()).or_else(|| { + model + .as_ref() + .map(|_| crate::omp_config::OMP_DEFAULT_API_PROTOCOL.to_string()) + }); + if let Some(api) = api.as_deref() { + crate::omp_config::validate_api_protocol(api)?; + } + let api_key = non_empty(args.api_key.clone()); + let mut settings = serde_json::Map::new(); + if let Some(base_url) = base_url { + settings.insert("baseUrl".to_string(), serde_json::json!(base_url)); + } + if let Some(api) = api { + settings.insert("api".to_string(), serde_json::json!(api)); + } + if let Some(model) = model { + settings.insert("models".to_string(), serde_json::json!([{ "id": model }])); + } + if let Some(api_key) = api_key { + settings.insert("apiKey".to_string(), serde_json::Value::String(api_key)); + } else { + settings.insert( + "auth".to_string(), + serde_json::Value::String("none".to_string()), + ); + } + Ok(serde_json::Value::Object(settings)) + } AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => { let current = current.ok_or_else(|| add_additive_requires_config_error(app_type))?; let api_key = non_empty(args.api_key.clone()); @@ -1530,6 +1582,11 @@ fn apply_add_provider_api_format( } AppType::Gemini | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { } + AppType::Omp => { + if let Some(raw) = api_format { + crate::omp_config::validate_api_protocol(raw)?; + } + } } Ok(()) } @@ -1854,6 +1911,9 @@ fn existing_provider_ids_for_duplicate( .into_iter() .map(|(id, _)| id) .collect::>(), + AppType::Omp => crate::omp_config::read_omp_native_providers()? + .into_keys() + .collect::>(), _ => Vec::new(), }; ids.extend(live_ids); @@ -2262,6 +2322,48 @@ mod tests { assert_eq!(settings["models"][0]["id"], "model-a"); } + #[test] + fn noninteractive_omp_add_allows_keyless_custom_provider() { + let settings = build_add_settings_config( + &AppType::Omp, + &AddProviderArgs { + base_url: Some("http://127.0.0.1:11434/v1".to_string()), + model: Some("local-model".to_string()), + ..Default::default() + }, + None, + None, + "Local OMP", + &mut None, + ) + .expect("keyless OMP provider should be constructible"); + + assert_eq!(settings["auth"], "none"); + assert_eq!(settings["api"], crate::omp_config::OMP_DEFAULT_API_PROTOCOL); + assert!(settings.get("apiKey").is_none()); + } + + #[test] + fn noninteractive_omp_add_allows_modeless_override_provider() { + let settings = build_add_settings_config( + &AppType::Omp, + &AddProviderArgs { + api_key: Some("OMP_KEY".to_string()), + ..Default::default() + }, + None, + None, + "OMP override", + &mut None, + ) + .expect("model-less OMP provider should be constructible"); + + crate::omp_config::validate_provider_node("override", &settings) + .expect("override-only provider should pass OMP validation"); + assert_eq!(settings["apiKey"], "OMP_KEY"); + assert!(settings.get("models").is_none()); + } + #[test] fn claude_api_format_effective_value_prefers_meta_over_legacy_settings() { let mut provider = claude_provider(json!({ diff --git a/src-tauri/src/cli/commands/provider_input.rs b/src-tauri/src/cli/commands/provider_input.rs index a61da2022..de3d65157 100644 --- a/src-tauri/src/cli/commands/provider_input.rs +++ b/src-tauri/src/cli/commands/provider_input.rs @@ -144,7 +144,9 @@ pub fn common_snippet_has_effective_config( .ok() .and_then(|value| value.as_object().cloned()) .is_some_and(|obj| !obj.is_empty()), - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Omp => { + false + } } } @@ -209,7 +211,7 @@ pub fn provider_add_template_choices(app_type: &AppType) -> Vec { + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Omp => { vec![ProviderAddTemplateChoice { template: ProviderAddTemplate::Custom, label: "Custom", @@ -657,7 +659,7 @@ fn build_sponsor_template_settings_config( }) } } - AppType::Pi => Err(unsupported_template_error(ProviderAddTemplate::Custom)), + AppType::Pi | AppType::Omp => Err(unsupported_template_error(ProviderAddTemplate::Custom)), } } @@ -713,10 +715,17 @@ pub fn apply_additive_template_field_overrides( models_with_primary_override(current, model), ) } - AppType::Pi => { + AppType::Pi | AppType::Omp => { let mut updated = current.clone(); let object = updated.as_object_mut().ok_or_else(|| { - AppError::InvalidInput("Pi provider configuration must be an object".to_string()) + AppError::InvalidInput(format!( + "{} provider configuration must be an object", + if matches!(app_type, AppType::Omp) { + "OMP" + } else { + "Pi" + } + )) })?; if let Some(api_key) = api_key { object.insert("apiKey".to_string(), Value::String(api_key.to_string())); @@ -2535,10 +2544,12 @@ requires_openai_auth = true }] }); - assert!(validate_pi_prompt_request_url(None, &invalid).is_err()); - assert!(validate_pi_prompt_request_url(Some(&invalid), &invalid).is_ok()); - assert!(validate_pi_prompt_request_url(Some(&invalid), &changed_invalid).is_err()); - assert!(validate_pi_prompt_request_url(None, &valid_model_url).is_ok()); + assert!(validate_pi_prompt_request_url(None, &invalid, &AppType::Pi).is_err()); + assert!(validate_pi_prompt_request_url(Some(&invalid), &invalid, &AppType::Pi).is_ok()); + assert!( + validate_pi_prompt_request_url(Some(&invalid), &changed_invalid, &AppType::Pi).is_err() + ); + assert!(validate_pi_prompt_request_url(None, &valid_model_url, &AppType::Pi).is_ok()); } } @@ -3774,10 +3785,14 @@ pub fn prompt_basic_fields( Ok((name, website_url)) } -fn prompt_pi_config(current: Option<&Value>) -> Result { +fn prompt_pi_config(current: Option<&Value>, app_type: &AppType) -> Result { + let is_omp = matches!(app_type, AppType::Omp); let mut config = current.cloned().unwrap_or_else(|| json!({})); let object = config.as_object_mut().ok_or_else(|| { - AppError::InvalidInput("Pi provider configuration must be an object".to_string()) + AppError::InvalidInput(format!( + "{} provider configuration must be an object", + if is_omp { "OMP" } else { "Pi" } + )) })?; let current_base_url = object @@ -3801,24 +3816,55 @@ fn prompt_pi_config(current: Option<&Value>) -> Result { .get("apiKey") .and_then(Value::as_str) .unwrap_or_default(); + let had_api_key = !current_api_key.trim().is_empty(); + let had_explicit_api_key_auth = object.get("auth").and_then(Value::as_str) == Some("apiKey"); + let had_models = object + .get("models") + .and_then(Value::as_array) + .is_some_and(|models| !models.is_empty()); let api_key = Text::new("API Key:") .with_initial_value(current_api_key) .prompt() .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?; if api_key.trim().is_empty() { + if is_omp { + let existing_auth = object.get("auth").and_then(Value::as_str); + // A native OMP override may intentionally omit both apiKey and + // auth, relying on OMP's environment/stored credential resolver. + // Preserve that implicit api-key mode while still representing a + // newly keyless custom-model provider (or an explicitly cleared + // existing key) as `auth: none`. + if !matches!(existing_auth, Some("none") | Some("oauth")) + && (current.is_none() || had_api_key || had_explicit_api_key_auth || had_models) + { + object.insert("auth".to_string(), json!("none")); + } + } object.remove("apiKey"); } else { object.insert( "apiKey".to_string(), Value::String(api_key.trim().to_string()), ); + if is_omp + && matches!( + object.get("auth").and_then(Value::as_str), + Some("none") | Some("oauth") + ) + { + object.remove("auth"); + } } let current_api = object .get("api") .and_then(Value::as_str) .unwrap_or(if current.is_none() { - "openai-completions" + if is_omp { + crate::omp_config::OMP_DEFAULT_API_PROTOCOL + } else { + "openai-completions" + } } else { "" }); @@ -3827,9 +3873,15 @@ fn prompt_pi_config(current: Option<&Value>) -> Result { .with_help_message("For example: openai-completions or anthropic-messages") .prompt() .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?; - if current.is_none() && !crate::openclaw_config::OPENCLAW_API_PROTOCOLS.contains(&api.trim()) { + let protocols: &[&str] = if is_omp { + &crate::omp_config::OMP_API_PROTOCOLS + } else { + &crate::openclaw_config::OPENCLAW_API_PROTOCOLS + }; + if current.is_none() && !protocols.contains(&api.trim()) { return Err(AppError::InvalidInput(format!( - "Unsupported Pi API protocol: {}", + "Unsupported {} API protocol: {}", + if is_omp { "OMP" } else { "Pi" }, api.trim() ))); } @@ -3843,35 +3895,66 @@ fn prompt_pi_config(current: Option<&Value>) -> Result { let model = Text::new("Model ID:") .prompt() .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?; - if model.trim().is_empty() { - return Err(AppError::InvalidInput( - "A custom Pi provider requires at least one model ID".to_string(), - )); + if model.trim().is_empty() && !is_omp { + return Err(AppError::InvalidInput(format!( + "A custom {} provider requires at least one model ID", + if is_omp { "OMP" } else { "Pi" } + ))); + } + // OMP also supports override-only providers (for example a + // discovery/remote-compaction/header override with no custom model + // list). Allow an empty model prompt in that mode and leave `models` + // absent so the native semantic validator can accept the override. + if !model.trim().is_empty() { + object.insert("models".to_string(), json!([{ "id": model.trim() }])); } - object.insert("models".to_string(), json!([{ "id": model.trim() }])); } - validate_pi_prompt_request_url(current, &config)?; + validate_pi_prompt_request_url(current, &config, app_type)?; Ok(config) } -fn validate_pi_prompt_request_url(current: Option<&Value>, edited: &Value) -> Result<(), AppError> { +fn validate_pi_prompt_request_url( + current: Option<&Value>, + edited: &Value, + app_type: &AppType, +) -> Result<(), AppError> { let request_url = |config: &Value| { - crate::pi_config::provider_base_url(config) - .ok() - .map(|url| url.trim().to_string()) + let result = if matches!(app_type, AppType::Omp) { + crate::omp_config::provider_base_url(config) + } else { + crate::pi_config::provider_base_url(config) + }; + result.ok().map(|url| url.trim().to_string()) }; let original_request_url = current.and_then(request_url); let edited_request_url = request_url(edited); + // OMP supports override-only providers (headers, compat settings, + // discovery, remote compaction, and similar native options) that do not + // define a request URL themselves. Leave that semantic decision to the + // OMP provider validator instead of rejecting the interactive form solely + // because no URL can be derived. + if matches!(app_type, AppType::Omp) && edited_request_url.is_none() { + return Ok(()); + } if (current.is_none() || edited_request_url != original_request_url) - && edited_request_url - .as_deref() - .is_none_or(|url| !crate::pi_config::is_valid_request_url(url)) + && edited_request_url.as_deref().is_none_or(|url| { + if matches!(app_type, AppType::Omp) { + !crate::omp_config::is_valid_request_url(url) + } else { + !crate::pi_config::is_valid_request_url(url) + } + }) { - return Err(AppError::InvalidInput( - "Pi Base URL must be an absolute HTTP(S) URL".to_string(), - )); + return Err(AppError::InvalidInput(format!( + "{} Base URL must be an absolute HTTP(S) URL", + if matches!(app_type, AppType::Omp) { + "OMP" + } else { + "Pi" + } + ))); } Ok(()) } @@ -3917,9 +4000,9 @@ pub fn prompt_settings_config( AppType::OpenCode => prompt_opencode_config(current).map(SettingsConfigPromptResult::new), AppType::Hermes => prompt_hermes_config(current).map(SettingsConfigPromptResult::new), AppType::OpenClaw => prompt_openclaw_config(current).map(SettingsConfigPromptResult::new), - AppType::Pi => { - let mut config = prompt_pi_config(current)?; - if current.is_none() { + AppType::Pi | AppType::Omp => { + let mut config = prompt_pi_config(current, app_type)?; + if current.is_none() && matches!(app_type, AppType::Pi) { config["name"] = Value::String(provider_name.trim().to_string()); } Ok(SettingsConfigPromptResult::new(config)) @@ -4658,7 +4741,7 @@ pub fn display_provider_summary(provider: &Provider, app_type: &AppType) { println!(" {}: {}", texts::model_label(), models.len()); } } - AppType::Pi => { + AppType::Pi | AppType::Omp => { if provider.configured_api_key(app_type).is_some() { println!( " {}: {}", diff --git a/src-tauri/src/cli/commands/provider_inspect.rs b/src-tauri/src/cli/commands/provider_inspect.rs index b1d37f2d9..fe8dc1654 100644 --- a/src-tauri/src/cli/commands/provider_inspect.rs +++ b/src-tauri/src/cli/commands/provider_inspect.rs @@ -20,9 +20,13 @@ const AUTH_PROVIDER_CODEX_OAUTH: &str = "codex_oauth"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ProviderModelFetchStrategy { + Anonymous, + Ollama, + LlamaCpp, Bearer, Anthropic, GoogleApiKey, + AzureApiKey, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -38,6 +42,12 @@ struct ModelFetchTarget { auth_value: Option, custom_user_agent: Option, request_headers: Option>, + /// OMP `discovery.injectV1` override for openai-models-list discovery. + discovery_inject_v1: Option, + /// OMP `discovery.timeoutMs` (with OMP's ten-second default) for model + /// discovery requests. `None` keeps the legacy five-second timeout used by + /// other applications and one-off fetches. + discovery_timeout_ms: Option, strategy: ProviderModelFetchStrategy, } @@ -91,6 +101,39 @@ pub(crate) fn list_providers(app_type: AppType) -> Result<(), AppError> { } else { None }; + let omp_state = if matches!(app_type, AppType::Omp) { + match ( + crate::omp_config::read_omp_native_providers(), + crate::omp_config::read_omp_disabled_providers(), + ) { + (Ok(providers), Ok(disabled)) => Some(( + providers + .into_keys() + .collect::>(), + disabled, + )), + (Err(error), _) => { + println!( + "{}", + warning(&format!( + "OMP models.yml could not be read; enabled state is unavailable: {error}" + )) + ); + None + } + (_, Err(error)) => { + println!( + "{}", + warning(&format!( + "OMP disabledProviders could not be read; enabled state is unavailable: {error}" + )) + ); + None + } + } + } else { + None + }; if providers.is_empty() { println!("{}", info("No providers found.")); @@ -110,12 +153,18 @@ pub(crate) fn list_providers(app_type: AppType) -> Result<(), AppError> { }); for (id, provider) in provider_list { - let current_marker = if pi_state.as_ref().is_some_and(|state| { + let current_marker = if omp_state + .as_ref() + .is_some_and(|(_, disabled)| disabled.contains(&id)) + { + "⊘" + } else if pi_state.as_ref().is_some_and(|state| { state .enabled_provider_ids .iter() .any(|enabled| enabled == &id) - }) || (!matches!(app_type, AppType::Pi) && id == current_id) + }) || omp_state.as_ref().is_some_and(|(ids, _)| ids.contains(&id)) + || (!matches!(app_type, AppType::Pi | AppType::Omp) && id == current_id) { "✓" } else { @@ -130,6 +179,11 @@ pub(crate) fn list_providers(app_type: AppType) -> Result<(), AppError> { println!("\n{} Application: {}", info("ℹ"), app_str); if matches!(app_type, AppType::Pi) { println!("{} ✓ = present in Pi models.json", info("→")); + } else if matches!(app_type, AppType::Omp) { + println!( + "{} ✓ = enabled in OMP models.yml; ⊘ = disabled by effective disabledProviders", + info("→") + ); } else { println!("{} Current: {}", info("→"), highlight(¤t_id)); } @@ -140,10 +194,29 @@ pub(crate) fn list_providers(app_type: AppType) -> Result<(), AppError> { pub(crate) fn show_current(app_type: AppType) -> Result<(), AppError> { if matches!(app_type, AppType::Pi) { return Err(AppError::InvalidInput( - "Pi owns the current/default provider; CC Switch only manages explicit models.json entries" + "Pi owns the current/default provider; CC Switch only manages explicit native provider entries" .to_string(), )); } + if matches!(app_type, AppType::Omp) { + let (roles, role_path, _) = crate::omp_config::read_omp_model_roles_with_metadata()?; + let models_path = crate::omp_config::get_omp_models_path()?; + println!("{}", highlight("Current OMP Model")); + println!("{}", "═".repeat(60)); + println!("\n{}", highlight("Default Role")); + println!( + " modelRoles.default: {}", + roles + .get("default") + .map(String::as_str) + .unwrap_or("(not configured)") + ); + println!("\n{}", highlight("Native Configuration")); + println!(" Roles/config: {}", role_path.display()); + println!(" Models: {}", models_path.display()); + println!("\n{}", "─".repeat(60)); + return Ok(()); + } let state = get_state()?; let current_id = ProviderService::current(&state, app_type.clone())?; let providers = ProviderService::list(&state, app_type.clone())?; @@ -213,12 +286,26 @@ pub(crate) fn show_current(app_type: AppType) -> Result<(), AppError> { println!("\n{}", highlight("API 配置 / API Configuration")); let api_url = extract_api_url(provider, &app_type).unwrap_or_else(|| "N/A".to_string()); println!(" API URL: {}", api_url); - println!( - " API Key: {}", + let api_key = if matches!(app_type, AppType::Omp) { + provider + .settings_config + .get("apiKey") + .and_then(Value::as_str) + .map(|value| { + if value.trim().is_empty() { + "N/A" + } else { + "***" + } + }) + .unwrap_or("N/A") + .to_string() + } else { provider .configured_api_key(&app_type) .unwrap_or_else(|| "N/A".to_string()) - ); + }; + println!(" API Key: {}", api_key); } println!("\n{}", "─".repeat(60)); @@ -384,13 +471,15 @@ fn fetch_models_from_source(source: &ModelFetchSource) -> Result, Ap match &source { ModelFetchSource::Http(target) => runtime.block_on(async { - crate::cli::tui::fetch_provider_models_for_tui( + crate::cli::tui::fetch_provider_models_for_tui_with_options( &target.base_url, target.is_full_url, target.auth_value.as_deref(), target.custom_user_agent.as_deref(), to_tui_strategy(target.strategy), target.request_headers.as_ref(), + target.discovery_inject_v1, + target.discovery_timeout_ms, ) .await .map_err(AppError::Message) @@ -846,6 +935,8 @@ fn model_fetch_target( auth_value: Some(auth_value), custom_user_agent, request_headers: None, + discovery_inject_v1: None, + discovery_timeout_ms: None, strategy, }) } @@ -858,6 +949,8 @@ fn model_fetch_target( )?), custom_user_agent, request_headers: None, + discovery_inject_v1: None, + discovery_timeout_ms: None, strategy: ProviderModelFetchStrategy::Bearer, }) } @@ -869,6 +962,8 @@ fn model_fetch_target( auth_value: Some(auth_value), custom_user_agent, request_headers: None, + discovery_inject_v1: None, + discovery_timeout_ms: None, strategy, }) } @@ -890,6 +985,8 @@ fn model_fetch_target( ), custom_user_agent, request_headers: None, + discovery_inject_v1: None, + discovery_timeout_ms: None, strategy: ProviderModelFetchStrategy::Bearer, }), AppType::Hermes => Ok(ModelFetchTarget { @@ -910,6 +1007,8 @@ fn model_fetch_target( ), custom_user_agent, request_headers: None, + discovery_inject_v1: None, + discovery_timeout_ms: None, strategy: ProviderModelFetchStrategy::Bearer, }), AppType::OpenClaw => Ok(ModelFetchTarget { @@ -929,15 +1028,69 @@ fn model_fetch_target( ), custom_user_agent, request_headers: None, + discovery_inject_v1: None, + discovery_timeout_ms: None, strategy: ProviderModelFetchStrategy::Bearer, }), - AppType::Pi => { - let api = provider.settings_config.get("api").and_then(Value::as_str); - let strategy = match api { - Some("anthropic-messages") => ProviderModelFetchStrategy::Anthropic, - Some("google-generative-ai") => ProviderModelFetchStrategy::GoogleApiKey, - _ => ProviderModelFetchStrategy::Bearer, - }; + AppType::Pi | AppType::Omp => { + let api = provider + .settings_config + .get("api") + .and_then(Value::as_str) + .or_else(|| { + provider + .settings_config + .get("models") + .and_then(Value::as_array) + .and_then(|models| { + models + .iter() + .find_map(|model| model.get("api").and_then(Value::as_str)) + }) + }); + let discovery_type = provider + .settings_config + .get("discovery") + .and_then(Value::as_object) + .and_then(|discovery| discovery.get("type")) + .and_then(Value::as_str); + let omp_auth_none = matches!( + provider.settings_config.get("auth").and_then(Value::as_str), + Some("none") + ); + let mut strategy = + if matches!(app_type, AppType::Omp) && discovery_type == Some("ollama") { + ProviderModelFetchStrategy::Ollama + } else if matches!(app_type, AppType::Omp) && discovery_type == Some("llama.cpp") { + ProviderModelFetchStrategy::LlamaCpp + } else if matches!(app_type, AppType::Omp) && discovery_type.is_some() { + // OMP's native discovery clients always obtain the + // provider credential through the bearer resolver. This + // is independent of the model wire protocol (`api`), so a + // Google/Vertex/Azure/Anthropic provider still probes its + // model registry with Authorization: Bearer. + if omp_auth_none { + ProviderModelFetchStrategy::Anonymous + } else { + ProviderModelFetchStrategy::Bearer + } + } else { + match api { + _ if matches!( + provider.settings_config.get("auth").and_then(Value::as_str), + Some("none") + ) => + { + ProviderModelFetchStrategy::Anonymous + } + Some("anthropic-messages") => ProviderModelFetchStrategy::Anthropic, + Some("google-generative-ai" | "google-vertex") => { + ProviderModelFetchStrategy::GoogleApiKey + } + Some("azure-openai-responses") => ProviderModelFetchStrategy::AzureApiKey, + _ => ProviderModelFetchStrategy::Bearer, + } + }; let mut request_headers = provider .settings_config .get("headers") @@ -946,28 +1099,79 @@ fn model_fetch_target( headers .iter() .filter_map(|(name, value)| { - value - .as_str() - .map(|value| (name.clone(), value.to_string())) + value.as_str().and_then(|value| { + let resolved = if matches!(app_type, AppType::Omp) { + crate::omp_config::resolve_header_value(value) + } else { + Some(value.to_string()) + }?; + Some((name.clone(), resolved)) + }) }) .collect::>() }) .filter(|headers| !headers.is_empty()); - let api_key = provider - .settings_config - .get("apiKey") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); + let api_key = if matches!(app_type, AppType::Omp) { + crate::omp_config::resolve_api_key(provider.settings_config.get("apiKey")) + } else { + provider + .settings_config + .get("apiKey") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }; + // OMP's built-in discovery providers support anonymous registries + // (notably local llama.cpp/LM Studio and public model lists). Do + // not make a missing optional apiKey block those probes. + if matches!(app_type, AppType::Omp) + && discovery_type.is_some() + && api_key.is_none() + && !matches!( + strategy, + ProviderModelFetchStrategy::Ollama | ProviderModelFetchStrategy::LlamaCpp + ) + { + strategy = ProviderModelFetchStrategy::Anonymous; + } + let has_omp_oauth_descriptor = + provider.settings_config.get("oauth").is_some_and(|value| { + value.as_str().is_some_and(|value| !value.trim().is_empty()) + || value.is_object() + }); + if matches!(app_type, AppType::Omp) + && (matches!( + provider.settings_config.get("auth").and_then(Value::as_str), + Some("oauth") + ) || has_omp_oauth_descriptor) + && api_key.is_none() + { + return Err(AppError::Message(format!( + "OMP provider '{}' uses native OAuth; CC Switch cannot fetch its model list without an apiKey or exported OAuth token", + provider.id + ))); + } let auth_value = if matches!(api, Some("anthropic-messages")) { - if let Some(api_key) = api_key.as_ref() { - request_headers - .get_or_insert_with(BTreeMap::new) - .entry("x-api-key".to_string()) - .or_insert_with(|| api_key.clone()); + if matches!(app_type, AppType::Omp) && discovery_type.is_some() { + // Discovery authentication is bearer-based in OMP; do not + // synthesize Anthropic's x-api-key header from the model + // protocol here. + api_key + } else { + if let Some(api_key) = api_key.as_ref() { + request_headers + .get_or_insert_with(BTreeMap::new) + .entry("x-api-key".to_string()) + .or_insert_with(|| api_key.clone()); + } + None } - None + } else if matches!(api, Some("google-gemini-cli")) { + api_key + .as_deref() + .and_then(parse_access_token_blob) + .or(api_key) } else { api_key }; @@ -977,6 +1181,31 @@ fn model_fetch_target( auth_value, custom_user_agent, request_headers, + discovery_inject_v1: if matches!(app_type, AppType::Omp) + && matches!( + provider + .settings_config + .get("discovery") + .and_then(Value::as_object) + .and_then(|discovery| discovery.get("type")) + .and_then(Value::as_str), + Some("openai-models-list" | "lm-studio" | "litellm") + ) { + provider + .settings_config + .get("discovery") + .and_then(Value::as_object) + .and_then(|discovery| discovery.get("injectV1")) + .and_then(Value::as_bool) + .or(Some(true)) + } else { + None + }, + discovery_timeout_ms: if matches!(app_type, AppType::Omp) { + crate::omp_config::omp_discovery_timeout_ms(&provider.settings_config) + } else { + None + }, strategy, }) } @@ -1007,6 +1236,8 @@ fn one_off_model_fetch_target( auth_value, custom_user_agent: None, request_headers: None, + discovery_inject_v1: None, + discovery_timeout_ms: None, strategy, }) } @@ -1015,9 +1246,12 @@ fn default_one_off_model_fetch_strategy(app_type: &AppType) -> ProviderModelFetc match app_type { AppType::Claude => ProviderModelFetchStrategy::Anthropic, AppType::Gemini => ProviderModelFetchStrategy::GoogleApiKey, - AppType::Codex | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { - ProviderModelFetchStrategy::Bearer - } + AppType::Codex + | AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw + | AppType::Pi + | AppType::Omp => ProviderModelFetchStrategy::Bearer, } } @@ -1109,11 +1343,15 @@ fn parse_access_token_blob(raw: &str) -> Option { fn to_tui_strategy(strategy: ProviderModelFetchStrategy) -> crate::cli::tui::ModelFetchStrategy { match strategy { + ProviderModelFetchStrategy::Anonymous => crate::cli::tui::ModelFetchStrategy::Anonymous, + ProviderModelFetchStrategy::Ollama => crate::cli::tui::ModelFetchStrategy::Ollama, + ProviderModelFetchStrategy::LlamaCpp => crate::cli::tui::ModelFetchStrategy::LlamaCpp, ProviderModelFetchStrategy::Bearer => crate::cli::tui::ModelFetchStrategy::Bearer, ProviderModelFetchStrategy::Anthropic => crate::cli::tui::ModelFetchStrategy::Anthropic, ProviderModelFetchStrategy::GoogleApiKey => { crate::cli::tui::ModelFetchStrategy::GoogleApiKey } + ProviderModelFetchStrategy::AzureApiKey => crate::cli::tui::ModelFetchStrategy::AzureApiKey, } } @@ -1482,6 +1720,185 @@ base_url = "https://current.example.com/v1" ); } + #[test] + fn omp_model_fetch_maps_native_protocol_auth_headers() { + let cases = [ + ("openai-completions", ProviderModelFetchStrategy::Bearer), + ("openai-responses", ProviderModelFetchStrategy::Bearer), + ("openai-codex-responses", ProviderModelFetchStrategy::Bearer), + ( + "azure-openai-responses", + ProviderModelFetchStrategy::AzureApiKey, + ), + ("anthropic-messages", ProviderModelFetchStrategy::Anthropic), + ( + "bedrock-converse-stream", + ProviderModelFetchStrategy::Bearer, + ), + ( + "google-generative-ai", + ProviderModelFetchStrategy::GoogleApiKey, + ), + ("google-gemini-cli", ProviderModelFetchStrategy::Bearer), + ("google-vertex", ProviderModelFetchStrategy::GoogleApiKey), + ]; + + for (api, expected_strategy) in cases { + let provider = Provider::with_id( + format!("omp-{api}"), + "OMP provider".to_string(), + json!({ + "baseUrl": "https://omp.example.com/v1", + "api": api, + "apiKey": "omp-secret", + "models": [{ "id": "model-a" }] + }), + None, + ); + let target = model_fetch_target(&provider, &AppType::Omp) + .expect("OMP protocol should resolve a model-fetch target"); + assert_eq!(target.strategy, expected_strategy, "protocol {api}"); + } + } + + #[test] + fn omp_ollama_discovery_uses_native_model_fetch_strategy() { + let provider = Provider::with_id( + "omp-ollama".to_string(), + "OMP Ollama".to_string(), + json!({ + "discovery": {"type": "ollama"}, + "api": "openai-completions" + }), + None, + ); + let target = model_fetch_target(&provider, &AppType::Omp) + .expect("OMP Ollama provider should resolve"); + assert_eq!(target.strategy, ProviderModelFetchStrategy::Ollama); + assert_eq!(target.auth_value, None); + } + + #[test] + fn omp_keyless_llama_cpp_discovery_uses_native_model_fetch_strategy() { + let provider = Provider::with_id( + "omp-local".to_string(), + "OMP local".to_string(), + json!({ + "baseUrl": "http://127.0.0.1:8080", + "discovery": {"type": "llama.cpp"}, + "api": "openai-completions" + }), + None, + ); + let target = model_fetch_target(&provider, &AppType::Omp) + .expect("keyless OMP discovery should resolve"); + assert_eq!(target.strategy, ProviderModelFetchStrategy::LlamaCpp); + assert_eq!(target.auth_value, None); + } + + #[test] + fn omp_discovery_uses_bearer_auth_independent_of_wire_protocol() { + for (discovery_type, expected_strategy) in [ + ("openai-models-list", ProviderModelFetchStrategy::Bearer), + ("proxy", ProviderModelFetchStrategy::Bearer), + ("litellm", ProviderModelFetchStrategy::Bearer), + ("llama.cpp", ProviderModelFetchStrategy::LlamaCpp), + ] { + let provider = Provider::with_id( + format!("omp-discovery-{discovery_type}"), + "OMP discovery".to_string(), + json!({ + "baseUrl": "https://omp.example.com/v1", + "api": "google-vertex", + "apiKey": "omp-bearer-secret", + "discovery": {"type": discovery_type} + }), + None, + ); + let target = model_fetch_target(&provider, &AppType::Omp) + .expect("OMP discovery should resolve a model-fetch target"); + assert_eq!( + target.strategy, expected_strategy, + "discovery {discovery_type}" + ); + assert_eq!(target.auth_value.as_deref(), Some("omp-bearer-secret")); + } + } + + #[test] + fn omp_litellm_and_lm_studio_discovery_force_v1_model_endpoint() { + for discovery_type in ["litellm", "lm-studio"] { + let provider = Provider::with_id( + format!("omp-v1-{discovery_type}"), + "OMP discovery".to_string(), + json!({ + "baseUrl": "https://omp.example.com", + "api": "openai-completions", + "apiKey": "omp-secret", + "discovery": {"type": discovery_type} + }), + None, + ); + let target = model_fetch_target(&provider, &AppType::Omp) + .expect("OMP discovery should resolve a model-fetch target"); + assert_eq!(target.discovery_inject_v1, Some(true)); + } + } + + #[test] + fn omp_discovery_timeout_uses_native_value_and_safe_default() { + let provider = Provider::with_id( + "omp-timeout".to_string(), + "OMP timeout".to_string(), + json!({ + "baseUrl": "https://omp.example.com/v1", + "api": "openai-completions", + "apiKey": "omp-secret", + "discovery": {"type": "openai-models-list", "timeoutMs": 25_000} + }), + None, + ); + let target = model_fetch_target(&provider, &AppType::Omp).expect("target should resolve"); + assert_eq!(target.discovery_timeout_ms, Some(25_000)); + + let default_provider = Provider::with_id( + "omp-timeout-default".to_string(), + "OMP timeout default".to_string(), + json!({ + "baseUrl": "https://omp.example.com/v1", + "api": "openai-completions", + "apiKey": "omp-secret", + "discovery": {"type": "proxy"} + }), + None, + ); + let default_target = + model_fetch_target(&default_provider, &AppType::Omp).expect("target should resolve"); + assert_eq!( + default_target.discovery_timeout_ms, + Some(crate::omp_config::OMP_DEFAULT_DISCOVERY_TIMEOUT_MS) + ); + } + + #[test] + fn omp_gemini_cli_model_fetch_extracts_access_token_blob() { + let provider = Provider::with_id( + "omp-gemini-cli".to_string(), + "OMP Gemini CLI".to_string(), + json!({ + "baseUrl": "https://omp.example.com/v1", + "api": "google-gemini-cli", + "apiKey": "{\"access_token\":\"ya29.omp-token\"}", + "models": [{ "id": "model-a" }] + }), + None, + ); + let target = model_fetch_target(&provider, &AppType::Omp) + .expect("OMP Gemini CLI provider should resolve"); + assert_eq!(target.strategy, ProviderModelFetchStrategy::Bearer); + assert_eq!(target.auth_value.as_deref(), Some("ya29.omp-token")); + } + #[test] fn model_fetch_target_for_claude_supports_openrouter_bearer_mode() { let provider = Provider::with_id( diff --git a/src-tauri/src/cli/commands/provider_usage_query.rs b/src-tauri/src/cli/commands/provider_usage_query.rs index 95439650e..1801ab61d 100644 --- a/src-tauri/src/cli/commands/provider_usage_query.rs +++ b/src-tauri/src/cli/commands/provider_usage_query.rs @@ -290,8 +290,12 @@ fn set(app_type: AppType, command: ProviderUsageQuerySetCommand) -> Result<(), A apply_template_credentials(&mut script, &template, &command); validate_usage_script_for_save(&script)?; - if app_type == AppType::Pi { - ProviderService::update_pi_usage_script(&state, &provider.id, script)?; + if matches!(app_type, AppType::Pi | AppType::Omp) { + if app_type == AppType::Omp { + ProviderService::update_omp_usage_script(&state, &provider.id, script)?; + } else { + ProviderService::update_pi_usage_script(&state, &provider.id, script)?; + } } else { provider .meta @@ -333,8 +337,12 @@ fn validate_usage_template_compatibility( fn clear(app_type: AppType, id: &str) -> Result<(), AppError> { let state = get_state()?; - if app_type == AppType::Pi { - ProviderService::clear_pi_usage_script(&state, id)?; + if matches!(app_type, AppType::Pi | AppType::Omp) { + if app_type == AppType::Omp { + ProviderService::clear_omp_usage_script(&state, id)?; + } else { + ProviderService::clear_pi_usage_script(&state, id)?; + } println!("{}", success("✓ Usage Query configuration cleared")); return Ok(()); } @@ -349,6 +357,22 @@ fn clear(app_type: AppType, id: &str) -> Result<(), AppError> { } fn find_provider(state: &AppState, app_type: &AppType, id: &str) -> Result { + // Pi and OMP use additive native registries. Their live providers are + // imported lazily by ProviderService::list(), so a fresh invocation must + // sync before looking in the CC Switch catalog (the startup path + // intentionally does not import additive apps). Use the returned map + // directly because the in-memory config snapshot may still predate that + // import. + if matches!(app_type, AppType::Pi | AppType::Omp) { + let providers = ProviderService::list(state, app_type.clone())?; + return providers.get(id).cloned().ok_or_else(|| { + AppError::localized( + "provider.not_found", + format!("供应商不存在: {id}"), + format!("Provider not found: {id}"), + ) + }); + } let config = state.config.read().unwrap(); let manager = config .get_manager(app_type) @@ -749,6 +773,13 @@ fn provider_comment_credentials<'a>( .map(str::to_string), settings.get("apiKey").and_then(Value::as_str), ), + AppType::Omp => ( + settings + .get("baseUrl") + .and_then(Value::as_str) + .map(str::to_string), + settings.get("apiKey").and_then(Value::as_str), + ), } } @@ -763,6 +794,8 @@ fn provider_codex_base_url(provider: &Provider) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::omp_config::test_support::TestAgentDir as OmpTestAgentDir; + use serial_test::serial; fn set_command(template: Option) -> ProviderUsageQuerySetCommand { ProviderUsageQuerySetCommand { @@ -780,6 +813,27 @@ mod tests { } } + #[test] + #[serial] + fn find_provider_imports_fresh_omp_native_entries_before_usage_query() { + let home = tempfile::tempdir().expect("create isolated home"); + let _env = crate::test_support::TestEnvGuard::isolated(home.path()); + let _agent = OmpTestAgentDir::new(); + let native = serde_json::json!({ + "baseUrl": "https://omp.example.test/v1", + "api": "openai-completions", + "apiKey": "test-key", + "models": [{"id": "test-model"}] + }); + crate::omp_config::insert_omp_provider("native", &native) + .expect("seed native OMP provider"); + let state = AppState::try_new().expect("create isolated app state"); + + let provider = find_provider(&state, &AppType::Omp, "native") + .expect("usage-query lookup imports native provider"); + assert_eq!(provider.id, "native"); + } + #[test] fn default_usage_script_matches_upstream_defaults() { let script = default_usage_script(); diff --git a/src-tauri/src/cli/commands/proxy.rs b/src-tauri/src/cli/commands/proxy.rs index 3fbfb3f58..ef98d1ee1 100644 --- a/src-tauri/src/cli/commands/proxy.rs +++ b/src-tauri/src/cli/commands/proxy.rs @@ -507,7 +507,7 @@ fn build_proxy_overview_lines( takeovers: &crate::proxy::types::ProxyTakeoverStatus, ) -> Vec { let current_providers = AppType::all() - .filter(|app| !matches!(app, AppType::Pi)) + .filter(|app| !matches!(app, AppType::Pi | AppType::Omp)) .map(|app| { let current = state .db diff --git a/src-tauri/src/cli/commands/sessions.rs b/src-tauri/src/cli/commands/sessions.rs index 756432be4..38f0cc891 100644 --- a/src-tauri/src/cli/commands/sessions.rs +++ b/src-tauri/src/cli/commands/sessions.rs @@ -125,6 +125,24 @@ struct SessionMessagesOutput<'a> { } pub fn execute(cmd: SessionsCommand, app: Option) -> Result<(), AppError> { + let provider_is_omp = match &cmd { + SessionsCommand::List { provider, .. } + | SessionsCommand::Show { provider, .. } + | SessionsCommand::Messages { provider, .. } + | SessionsCommand::Resume { provider, .. } + | SessionsCommand::Delete { provider, .. } + | SessionsCommand::SyncUsage { provider, .. } => { + provider.as_ref().is_some_and(|p| *p == AppType::Omp) + } + SessionsCommand::Search { .. } => false, + }; + if (matches!(app, Some(AppType::Omp)) || provider_is_omp) + && !matches!(cmd, SessionsCommand::SyncUsage { .. }) + { + return Err(AppError::InvalidInput( + "OMP session browsing is not supported yet; use `sessions sync-usage` for usage import.".to_string(), + )); + } match cmd { SessionsCommand::List { provider, @@ -588,8 +606,9 @@ fn sync_usage_for_provider( AppType::Gemini => crate::services::session_usage_gemini::sync_gemini_usage(db), AppType::OpenCode => crate::services::session_usage_opencode::sync_opencode_usage(db), AppType::Pi => crate::services::session_usage_pi::sync_pi_usage(db), + AppType::Omp => crate::services::session_usage_omp::sync_omp_usage(db), other => Err(AppError::InvalidInput(format!( - "session usage sync is only supported for claude, codex, gemini, opencode, and pi; got {}", + "session usage sync is only supported for claude, codex, gemini, opencode, pi, and omp; got {}", other.as_str() ))), } @@ -610,6 +629,12 @@ fn resolve_scanned_session( .as_ref() .and_then(|(provider, _)| app_type_from_provider_id(provider)) .or(provider); + if matches!(forced_provider, Some(AppType::Omp)) { + return Err(AppError::InvalidInput( + "OMP session browsing is not supported yet; use `sessions sync-usage` for usage import." + .to_string(), + )); + } let include_all = all && forced_provider.is_none(); let scope = session_scope(app, forced_provider, include_all); let reader = @@ -769,9 +794,10 @@ fn parse_scoped_selector(selector: &str) -> Option<(String, String)> { } fn parse_session_provider(value: &str) -> Result { - app_type_from_provider_id(value).ok_or_else(|| { - format!("unsupported provider '{value}'. Allowed: claude, codex, gemini, opencode, openclaw, hermes, pi") - }) + let app = app_type_from_provider_id(value).ok_or_else(|| { + format!("unsupported provider '{value}'. Allowed: claude, codex, gemini, opencode, openclaw, hermes, pi, omp") + })?; + Ok(app) } fn app_type_from_provider_id(provider_id: &str) -> Option { diff --git a/src-tauri/src/cli/commands/settings.rs b/src-tauri/src/cli/commands/settings.rs index dfacaede4..8fda60117 100644 --- a/src-tauri/src/cli/commands/settings.rs +++ b/src-tauri/src/cli/commands/settings.rs @@ -513,6 +513,7 @@ fn set_visible_apps_list(apps: Vec) -> Result<(), AppError> { hermes: false, openclaw: false, pi: false, + omp: false, }; for app in apps { visible_apps.set_enabled_for(&app, true); @@ -889,6 +890,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + omp: false, }) .expect("save manual visible apps"); @@ -913,6 +915,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + omp: false, }) .expect_err("empty visible apps should be rejected"); diff --git a/src-tauri/src/cli/commands/skills.rs b/src-tauri/src/cli/commands/skills.rs index 56f4ee497..e6c7d5e83 100644 --- a/src-tauri/src/cli/commands/skills.rs +++ b/src-tauri/src/cli/commands/skills.rs @@ -243,6 +243,7 @@ fn list_installed() -> Result<(), AppError> { "OpenCode", "Hermes", "Pi", + "OMP", ]); for skill in skills { table.add_row(vec![ @@ -254,6 +255,7 @@ fn list_installed() -> Result<(), AppError> { if skill.apps.opencode { "✓" } else { " " }.to_string(), if skill.apps.hermes { "✓" } else { " " }.to_string(), if skill.apps.pi { "✓" } else { " " }.to_string(), + if skill.apps.omp { "✓" } else { " " }.to_string(), ]); } @@ -527,13 +529,14 @@ fn show_skill_info(spec: &str) -> Result<(), AppError> { println!("Desc: {}", desc); } println!( - "Enabled: claude={} codex={} gemini={} opencode={} hermes={} pi={}", + "Enabled: claude={} codex={} gemini={} opencode={} hermes={} pi={} omp={}", record.apps.claude, record.apps.codex, record.apps.gemini, record.apps.opencode, record.apps.hermes, - record.apps.pi + record.apps.pi, + record.apps.omp ); Ok(()) diff --git a/src-tauri/src/cli/failover_policy.rs b/src-tauri/src/cli/failover_policy.rs index 1b7edf2c5..fd15c8511 100644 --- a/src-tauri/src/cli/failover_policy.rs +++ b/src-tauri/src/cli/failover_policy.rs @@ -85,6 +85,8 @@ fn takeover_enabled_for(takeover: &ProxyTakeoverStatus, app_type: &AppType) -> b AppType::Claude => takeover.claude, AppType::Codex => takeover.codex, AppType::Gemini => takeover.gemini, - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Omp => { + false + } } } diff --git a/src-tauri/src/cli/i18n.rs b/src-tauri/src/cli/i18n.rs index 54c1eedc7..c0bdb5b24 100644 --- a/src-tauri/src/cli/i18n.rs +++ b/src-tauri/src/cli/i18n.rs @@ -2532,7 +2532,33 @@ pub mod texts { } } + pub fn tui_label_models() -> &'static str { + if is_chinese() { + "模型列表" + } else { + "Models" + } + } + pub fn tui_label_openclaw_models() -> &'static str { + tui_label_models() + } + + pub fn tui_label_omp_models() -> &'static str { + tui_label_models() + } + + pub fn tui_omp_models_summary(total: usize) -> String { + if is_chinese() { + format!("已配置 {total} 个模型") + } else if total == 1 { + "1 model configured".to_string() + } else { + format!("{total} models configured") + } + } + + pub fn tui_omp_models_editor_title() -> &'static str { if is_chinese() { "模型列表" } else { @@ -2540,6 +2566,162 @@ pub mod texts { } } + pub fn tui_omp_roles_summary(total: usize) -> String { + if is_chinese() { + format!("{total} 个角色") + } else if total == 1 { + "1 role".to_string() + } else { + format!("{total} roles") + } + } + + pub fn tui_omp_system_prompts_summary(total: usize) -> String { + if is_chinese() { + format!("{total} 个原生提示词文件") + } else if total == 1 { + "1 native prompt file".to_string() + } else { + format!("{total} native prompt files") + } + } + + pub fn tui_omp_config_error_title() -> &'static str { + if is_chinese() { + "配置需要处理" + } else { + "Configuration needs attention" + } + } + + pub fn tui_omp_models_empty_title() -> &'static str { + if is_chinese() { + "暂无模型配置" + } else { + "No models configured" + } + } + + pub fn tui_omp_models_empty_subtitle() -> &'static str { + if is_chinese() { + "按 e 编辑 models.yml" + } else { + "Press e to edit models.yml" + } + } + + pub fn tui_omp_roles_empty_title() -> &'static str { + if is_chinese() { + "暂无模型角色配置" + } else { + "No model roles configured" + } + } + + pub fn tui_omp_roles_empty_subtitle() -> &'static str { + if is_chinese() { + "按 e 编辑 config.yml" + } else { + "Press e to edit config.yml" + } + } + + pub fn tui_omp_prompt_error_title() -> &'static str { + if is_chinese() { + "提示词文件需要处理" + } else { + "Prompt file needs attention" + } + } + + pub fn tui_omp_models_provider_header() -> &'static str { + if is_chinese() { + "供应商" + } else { + "Provider" + } + } + + pub fn tui_omp_models_model_header() -> &'static str { + if is_chinese() { + "模型" + } else { + "Model" + } + } + + pub fn tui_omp_models_name_header() -> &'static str { + if is_chinese() { + "名称" + } else { + "Name" + } + } + + pub fn tui_omp_models_api_header() -> &'static str { + if is_chinese() { + "API" + } else { + "API" + } + } + + pub fn tui_omp_models_reasoning_header() -> &'static str { + if is_chinese() { + "思考" + } else { + "Reasoning" + } + } + + pub fn tui_omp_models_context_header() -> &'static str { + if is_chinese() { + "上下文" + } else { + "Context" + } + } + + pub fn tui_omp_models_max_tokens_header() -> &'static str { + if is_chinese() { + "最大 Token" + } else { + "Max Tokens" + } + } + + pub fn tui_omp_roles_role_header() -> &'static str { + if is_chinese() { + "角色" + } else { + "Role" + } + } + + pub fn tui_omp_roles_selector_header() -> &'static str { + if is_chinese() { + "模型选择器" + } else { + "Model Selector" + } + } + + pub fn tui_omp_prompt_active_path_header() -> &'static str { + if is_chinese() { + "生效路径" + } else { + "Active Path" + } + } + + pub fn tui_omp_default_model_label() -> &'static str { + if is_chinese() { + "默认模型" + } else { + "Default Model" + } + } + pub fn tui_label_hermes_api_mode() -> &'static str { if is_chinese() { "API 模式" @@ -4038,6 +4220,14 @@ pub mod texts { } } + pub fn tui_key_set_default_model() -> &'static str { + if is_chinese() { + "设置默认模型" + } else { + "set default model" + } + } + pub fn tui_key_enable() -> &'static str { if is_chinese() { "启用" @@ -5819,14 +6009,15 @@ pub mod texts { opencode: usize, hermes: usize, pi: usize, + omp: usize, ) -> String { if is_chinese() { format!( - "Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes} · Pi: {pi}" + "Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes} · Pi: {pi} · OMP: {omp}" ) } else { format!( - "Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes} · Pi: {pi}" + "Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes} · Pi: {pi} · OMP: {omp}" ) } } @@ -9597,6 +9788,45 @@ pub mod texts { ("📝 Prompt Templates", "📝 提示词模板") } + pub fn menu_omp_models() -> &'static str { + let (en, zh) = menu_omp_models_variants(); + if is_chinese() { + zh + } else { + en + } + } + + pub fn menu_omp_models_variants() -> (&'static str, &'static str) { + ("🧱 Models", "🧱 模型") + } + + pub fn menu_omp_roles() -> &'static str { + let (en, zh) = menu_omp_roles_variants(); + if is_chinese() { + zh + } else { + en + } + } + + pub fn menu_omp_roles_variants() -> (&'static str, &'static str) { + ("🎭 Model Roles", "🎭 模型角色") + } + + pub fn menu_omp_system_prompts() -> &'static str { + let (en, zh) = menu_omp_system_prompts_variants(); + if is_chinese() { + zh + } else { + en + } + } + + pub fn menu_omp_system_prompts_variants() -> (&'static str, &'static str) { + ("🧠 System Prompts", "🧠 系统提示词") + } + pub fn menu_manage_config() -> &'static str { let (en, zh) = menu_manage_config_variants(); if is_chinese() { @@ -13591,6 +13821,27 @@ mod tests { assert!(!texts::tui_help_line_settings().contains("Settings:")); } + #[test] + fn omp_menu_uses_shared_resource_wording() { + { + let _lang = use_test_language(Language::English); + assert_eq!(texts::menu_omp_models(), "🧱 Models"); + assert_eq!(texts::menu_omp_roles(), "🎭 Model Roles"); + assert_eq!(texts::menu_omp_system_prompts(), "🧠 System Prompts"); + assert_eq!(texts::tui_omp_default_model_label(), "Default Model"); + assert_eq!(texts::tui_omp_models_empty_title(), "No models configured"); + } + + { + let _lang = use_test_language(Language::Chinese); + assert_eq!(texts::menu_omp_models(), "🧱 模型"); + assert_eq!(texts::menu_omp_roles(), "🎭 模型角色"); + assert_eq!(texts::menu_omp_system_prompts(), "🧠 系统提示词"); + assert_eq!(texts::tui_omp_default_model_label(), "默认模型"); + assert_eq!(texts::tui_omp_models_empty_title(), "暂无模型配置"); + } + } + #[test] fn config_dir_validation_messages_are_localized() { { diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs index ebe59f4d3..49be3ccbd 100644 --- a/src-tauri/src/cli/mod.rs +++ b/src-tauri/src/cli/mod.rs @@ -24,8 +24,8 @@ use crate::app_config::AppType; #[command( name = "cc-switch", version, - about = "All-in-One Assistant for Claude Code, Codex, Gemini & OpenCode CLI", - long_about = "Unified management for Claude Code, Codex, Gemini, and OpenCode CLI provider configurations, MCP servers, skills, prompts, local proxy routes, and environment checks.\n\nRun without arguments to enter interactive mode." + about = "All-in-One Assistant for Claude Code, Codex, Gemini, OpenCode, Pi & OMP", + long_about = "Unified management for Claude Code, Codex, Gemini, OpenCode, Hermes, OpenClaw, Pi, and OMP (oh-my-pi) provider configurations, MCP servers, skills, prompts, local proxy routes, and environment checks.\n\nRun without arguments to enter interactive mode." )] pub struct Cli { /// Specify the application type @@ -50,6 +50,14 @@ pub enum Commands { #[command(subcommand)] Provider(commands::provider::ProviderCommand), + /// Manage OMP-native models in models.yml (requires --app omp) + #[command(subcommand, name = "model")] + Model(commands::omp::OmpModelCommand), + + /// Manage OMP-native model roles in config.yml (requires --app omp) + #[command(subcommand, name = "role")] + Role(commands::omp::OmpRoleCommand), + /// Switch to a provider (shortcut for `provider switch `) Use { /// Provider ID to switch to @@ -151,6 +159,7 @@ mod tests { fn long_help_mentions_prompts_and_proxy_routes() { let mut cmd = Cli::command(); let help = cmd.render_long_help().to_string(); + let help = help.split_whitespace().collect::>().join(" "); assert!(help.contains("prompts, local proxy routes, and environment checks")); } @@ -226,6 +235,14 @@ mod tests { } } + #[test] + fn parses_omp_app_and_oh_my_pi_alias() { + for app in ["omp", "oh-my-pi"] { + let cli = Cli::parse_from(["cc-switch", "--app", app, "provider", "list"]); + assert_eq!(cli.app, Some(AppType::Omp)); + } + } + #[test] fn parses_proxy_serve_takeover_flags() { let cli = Cli::parse_from([ @@ -2161,4 +2178,32 @@ mod tests { )) )); } + + #[test] + fn parses_omp_native_model_and_role_commands() { + let model = Cli::parse_from([ + "cc-switch", + "--app", + "omp", + "model", + "add", + "openai", + "gpt-5", + "--reasoning", + "true", + ]); + assert!(matches!(model.command, Some(Commands::Model(_)))); + + let role = Cli::parse_from([ + "cc-switch", + "--app", + "oh-my-pi", + "role", + "set", + "default", + "openai/gpt-5:high", + ]); + assert!(matches!(role.command, Some(Commands::Role(_)))); + assert_eq!(role.app, Some(AppType::Omp)); + } } diff --git a/src-tauri/src/cli/tui/app/app_state.rs b/src-tauri/src/cli/tui/app/app_state.rs index 8a779da4a..45a870d06 100644 --- a/src-tauri/src/cli/tui/app/app_state.rs +++ b/src-tauri/src/cli/tui/app/app_state.rs @@ -134,6 +134,7 @@ pub enum Action { custom_user_agent: Option, api_protocol: Option, request_headers: Option>, + discovery_timeout_ms: Option, codex_oauth: bool, codex_oauth_account_id: Option, field: ProviderAddField, @@ -206,6 +207,19 @@ pub enum Action { kind: crate::services::pi_prompt_files::PiPromptFileKind, expected_revision: String, }, + OmpModelDelete { + provider_id: String, + model_id: String, + expected_revision: String, + }, + OmpRoleDelete { + role: String, + expected_revision: String, + }, + OmpSystemPromptDelete { + kind: crate::services::pi_prompt_files::PiPromptFileKind, + expected_revision: String, + }, PiPromptTemplateDelete { slug: String, expected_revision: String, @@ -772,6 +786,9 @@ pub struct App { pub prompt_idx: usize, pub pi_system_prompt_idx: usize, pub pi_prompt_template_idx: usize, + pub omp_model_idx: usize, + pub omp_role_idx: usize, + pub omp_system_prompt_idx: usize, pub skills_idx: usize, pub skills_discover_idx: usize, pub skills_repo_idx: usize, diff --git a/src-tauri/src/cli/tui/app/content_entities.rs b/src-tauri/src/cli/tui/app/content_entities.rs index 8b0fbb8ba..14c2dcf8e 100644 --- a/src-tauri/src/cli/tui/app/content_entities.rs +++ b/src-tauri/src/cli/tui/app/content_entities.rs @@ -192,7 +192,10 @@ impl App { } fn provider_set_default_action(&mut self, row: &super::data::ProviderRow) -> Action { - if !matches!(self.app_type, AppType::OpenClaw | AppType::Hermes) { + if !matches!( + self.app_type, + AppType::OpenClaw | AppType::Hermes | AppType::Omp + ) { return Action::None; } if !row.is_in_config { @@ -203,7 +206,7 @@ impl App { return Action::None; } let model_id = row.primary_model_id.clone().unwrap_or_default(); - if matches!(self.app_type, AppType::OpenClaw) && model_id.is_empty() { + if matches!(self.app_type, AppType::OpenClaw | AppType::Omp) && model_id.is_empty() { self.push_toast( texts::tui_toast_provider_default_model_missing(), ToastKind::Warning, @@ -238,20 +241,25 @@ impl App { return Action::None; }; - if matches!(self.app_type, AppType::Pi) + if matches!(self.app_type, AppType::Pi | AppType::Omp) && data.providers.pi_membership_unknown && matches!( intent, Intent::Primary | Intent::Add | Intent::Edit | Intent::Switch | Intent::Delete ) { - self.push_toast( + let message = if matches!(self.app_type, AppType::Omp) { + crate::t!( + "OMP models.yml could not be read. Fix or reload it before changing providers.", + "无法读取 OMP models.yml;请修复或重新加载后再修改供应商。" + ) + } else { crate::t!( "Pi models.json could not be read. Fix or reload it before changing providers.", "无法读取 Pi models.json;请修复或重新加载后再修改供应商。" - ), - ToastKind::Warning, - ); + ) + }; + self.push_toast(message, ToastKind::Warning); return Action::None; } @@ -685,6 +693,186 @@ impl App { } } + pub(crate) fn on_omp_models_key(&mut self, key: KeyEvent, data: &UiData) -> Action { + use crate::cli::tui::keymap::omp_models::{self, Intent}; + + let models = &data.omp.models; + match key.code { + KeyCode::Up => { + self.omp_model_idx = self.omp_model_idx.saturating_sub(1); + } + KeyCode::Down => { + if !models.is_empty() { + self.omp_model_idx = (self.omp_model_idx + 1).min(models.len() - 1); + } + } + _ if matches!(omp_models::intent_for(key.code), Some(Intent::View)) => { + let Some(model) = models.get(self.omp_model_idx) else { + return Action::None; + }; + let content = serde_yaml::to_string(&model.config) + .unwrap_or_else(|_| "".to_string()); + self.overlay = Overlay::TextView(TextViewState { + title: format!("{}/{}", model.provider_id, model.model_id), + lines: content.lines().map(str::to_string).collect(), + scroll: 0, + action: None, + }); + } + _ if matches!(omp_models::intent_for(key.code), Some(Intent::Edit)) => { + self.open_editor( + data.omp.models_path.display().to_string(), + EditorKind::Plain, + data.omp.models_yaml.clone(), + EditorSubmit::OmpModels { + expected_revision: data.omp.models_revision.clone(), + }, + ); + } + _ if matches!(omp_models::intent_for(key.code), Some(Intent::Delete)) => { + let Some(model) = models.get(self.omp_model_idx) else { + return Action::None; + }; + self.overlay = Overlay::Confirm(ConfirmOverlay { + title: crate::t!("Delete model", "删除模型").to_string(), + message: crate::t!( + format!("Delete {}/{}?", model.provider_id, model.model_id), + format!("删除 {}/{}?", model.provider_id, model.model_id) + ), + action: ConfirmAction::OmpModelDelete { + provider_id: model.provider_id.clone(), + model_id: model.model_id.clone(), + expected_revision: data.omp.models_revision.clone(), + }, + }); + } + _ => {} + } + Action::None + } + + pub(crate) fn on_omp_roles_key(&mut self, key: KeyEvent, data: &UiData) -> Action { + use crate::cli::tui::keymap::omp_roles::{self, Intent}; + + let roles = &data.omp.model_roles; + let entries = roles.iter().collect::>(); + match key.code { + KeyCode::Up => self.omp_role_idx = self.omp_role_idx.saturating_sub(1), + KeyCode::Down => { + if !entries.is_empty() { + self.omp_role_idx = (self.omp_role_idx + 1).min(entries.len() - 1); + } + } + _ if matches!(omp_roles::intent_for(key.code), Some(Intent::View)) => { + let Some((role, selector)) = entries.get(self.omp_role_idx) else { + return Action::None; + }; + self.overlay = Overlay::TextView(TextViewState { + title: (*role).clone(), + lines: vec![(*selector).clone()], + scroll: 0, + action: None, + }); + } + _ if matches!(omp_roles::intent_for(key.code), Some(Intent::Edit)) => { + self.open_editor( + data.omp.roles_path.display().to_string(), + EditorKind::Plain, + data.omp.roles_yaml.clone(), + EditorSubmit::OmpConfig { + path: data.omp.roles_path.clone(), + expected_revision: data.omp.roles_revision.clone(), + }, + ); + } + _ if matches!(omp_roles::intent_for(key.code), Some(Intent::Delete)) => { + let Some((role, _)) = entries.get(self.omp_role_idx) else { + return Action::None; + }; + self.overlay = Overlay::Confirm(ConfirmOverlay { + title: crate::t!("Delete role", "删除角色").to_string(), + message: crate::t!( + format!("Delete role '{role}'?"), + format!("删除角色“{role}”?") + ), + action: ConfirmAction::OmpRoleDelete { + role: (*role).clone(), + expected_revision: data.omp.roles_revision.clone(), + }, + }); + } + _ => {} + } + Action::None + } + + pub(crate) fn on_omp_system_prompts_key(&mut self, key: KeyEvent, data: &UiData) -> Action { + use crate::cli::tui::keymap::omp_system_prompts::{self, Intent}; + + let rows = &data.pi_prompts.system_files; + match key.code { + KeyCode::Up => { + self.omp_system_prompt_idx = self.omp_system_prompt_idx.saturating_sub(1) + } + KeyCode::Down => { + if !rows.is_empty() { + self.omp_system_prompt_idx = + (self.omp_system_prompt_idx + 1).min(rows.len() - 1); + } + } + _ if matches!(omp_system_prompts::intent_for(key.code), Some(Intent::View)) => { + let Some((kind, snapshot)) = rows.get(self.omp_system_prompt_idx) else { + return Action::None; + }; + self.overlay = Overlay::TextView(TextViewState { + title: pi_system_prompt_filename(*kind).to_string(), + lines: snapshot.content.lines().map(str::to_string).collect(), + scroll: 0, + action: None, + }); + } + _ if matches!(omp_system_prompts::intent_for(key.code), Some(Intent::Edit)) => { + let Some((kind, snapshot)) = rows.get(self.omp_system_prompt_idx) else { + return Action::None; + }; + self.open_editor( + pi_system_prompt_filename(*kind), + EditorKind::Plain, + snapshot.content.clone(), + EditorSubmit::OmpSystemPrompt { + kind: *kind, + expected_revision: snapshot.revision.clone(), + }, + ); + } + _ if matches!( + omp_system_prompts::intent_for(key.code), + Some(Intent::Delete) + ) => + { + let Some((kind, snapshot)) = rows.get(self.omp_system_prompt_idx) else { + return Action::None; + }; + if !snapshot.exists { + return Action::None; + } + self.overlay = Overlay::Confirm(ConfirmOverlay { + title: crate::t!("Delete system prompt", "删除系统提示词").to_string(), + message: crate::t!( + format!("Delete {}?", pi_system_prompt_filename(*kind)), + format!("删除 {}?", pi_system_prompt_filename(*kind)) + ), + action: ConfirmAction::OmpSystemPromptDelete { + kind: *kind, + expected_revision: snapshot.revision.clone(), + }, + }); + } + _ => {} + } + Action::None + } + pub(crate) fn on_sessions_key(&mut self, key: KeyEvent, data: &UiData) -> Action { use super::paged_list::PageDirection; use crate::cli::tui::keymap::sessions::Intent; @@ -1303,6 +1491,7 @@ fn pi_system_prompt_filename( match kind { crate::services::pi_prompt_files::PiPromptFileKind::SystemAppend => "APPEND_SYSTEM.md", crate::services::pi_prompt_files::PiPromptFileKind::SystemOverride => "SYSTEM.md", + crate::services::pi_prompt_files::PiPromptFileKind::TitleSystem => "TITLE_SYSTEM.md", } } diff --git a/src-tauri/src/cli/tui/app/editor_state.rs b/src-tauri/src/cli/tui/app/editor_state.rs index 78a9033e4..42cb35268 100644 --- a/src-tauri/src/cli/tui/app/editor_state.rs +++ b/src-tauri/src/cli/tui/app/editor_state.rs @@ -29,6 +29,17 @@ pub enum EditorSubmit { kind: crate::services::pi_prompt_files::PiPromptFileKind, expected_revision: String, }, + OmpModels { + expected_revision: String, + }, + OmpConfig { + path: std::path::PathBuf, + expected_revision: String, + }, + OmpSystemPrompt { + kind: crate::services::pi_prompt_files::PiPromptFileKind, + expected_revision: String, + }, PiPromptTemplate { slug: String, original_slug: Option, diff --git a/src-tauri/src/cli/tui/app/form_handlers/provider.rs b/src-tauri/src/cli/tui/app/form_handlers/provider.rs index b814efbf9..21a140ad7 100644 --- a/src-tauri/src/cli/tui/app/form_handlers/provider.rs +++ b/src-tauri/src/cli/tui/app/form_handlers/provider.rs @@ -93,10 +93,14 @@ impl App { ProviderValidationTarget::Main(ProviderAddField::OpenCodeBaseUrl), texts::base_url_empty_error().to_string(), )) - } else if matches!(provider.app_type, crate::app_config::AppType::Pi) - && !provider.mode.is_edit() - && !crate::openclaw_config::OPENCLAW_API_PROTOCOLS - .contains(&provider.opencode_npm_package.value.trim()) + } else if !provider.mode.is_edit() + && ((matches!(provider.app_type, crate::app_config::AppType::Pi) + && !crate::openclaw_config::OPENCLAW_API_PROTOCOLS + .contains(&provider.opencode_npm_package.value.trim())) + || (matches!(provider.app_type, crate::app_config::AppType::Omp) + && !provider.opencode_npm_package.value.trim().is_empty() + && !crate::omp_config::OMP_API_PROTOCOLS + .contains(&provider.opencode_npm_package.value.trim()))) { Some(( ProviderValidationTarget::Main(ProviderAddField::OpenClawApiProtocol), @@ -324,10 +328,10 @@ impl App { Some(self.handle_provider_model_fetch(selected)) } KeyCode::Char('f') if selected == ProviderAddField::OpenClawModels => { - let is_pi = self.form.as_ref().is_some_and(|form| { - matches!(form, FormState::ProviderAdd(provider) if provider.app_type == AppType::Pi) + let supports_native_fetch = self.form.as_ref().is_some_and(|form| { + matches!(form, FormState::ProviderAdd(provider) if matches!(provider.app_type, AppType::Pi | AppType::Omp)) }); - is_pi.then(|| self.handle_provider_model_fetch(selected)) + supports_native_fetch.then(|| self.handle_provider_model_fetch(selected)) } KeyCode::Char('f') if matches!( @@ -492,6 +496,7 @@ impl App { provider .opencode_npm_package .set(next_openclaw_api_protocol( + &provider.app_type, &provider.opencode_npm_package.value, )); Action::None @@ -632,8 +637,13 @@ impl App { let Some(FormState::ProviderAdd(provider)) = self.form.as_ref() else { return Action::None; }; + let title = if provider.app_type == AppType::Omp { + texts::tui_omp_models_editor_title() + } else { + texts::tui_openclaw_models_editor_title() + }; self.open_editor( - texts::tui_openclaw_models_editor_title(), + title, EditorKind::Json, provider.openclaw_models_editor_text(), EditorSubmit::ProviderFormApplyOpenClawModels, @@ -1273,6 +1283,7 @@ impl App { .then(|| provider.custom_user_agent.value.clone()), api_protocol: None, request_headers: None, + discovery_timeout_ms: None, codex_oauth: false, codex_oauth_account_id: None, field: ProviderAddField::CodexLocalRouting, @@ -1523,6 +1534,7 @@ impl App { .then(|| provider.custom_user_agent.value.clone()), api_protocol: None, request_headers: None, + discovery_timeout_ms: None, codex_oauth: false, codex_oauth_account_id: None, field: ProviderAddField::HermesModels, @@ -1563,47 +1575,183 @@ impl App { ProviderAddField::HermesModels => (!provider.hermes_api_key.value.trim().is_empty()) .then(|| provider.hermes_api_key.value.clone()), ProviderAddField::OpenClawModels => { - (!provider.opencode_api_key.value.trim().is_empty()) - .then(|| provider.opencode_api_key.value.clone()) + if provider.opencode_api_key.value.trim().is_empty() { + None + } else if matches!(provider.app_type, AppType::Omp) { + let settings = provider.to_provider_json_value(); + let settings = settings.get("settingsConfig"); + let protocol = provider.omp_model_fetch_api_protocol().or_else(|| { + settings + .and_then(|settings| settings.get("api")) + .and_then(Value::as_str) + .map(str::to_string) + }); + crate::omp_config::resolve_api_key_for_protocol( + settings.and_then(|settings| settings.get("apiKey")), + protocol.as_deref(), + ) + } else { + Some(provider.opencode_api_key.value.clone()) + } } _ => None, }; - let base_url = match selected { + let mut base_url = match selected { ProviderAddField::CodexModel => provider.codex_base_url.value.clone(), ProviderAddField::GeminiModel => provider.gemini_base_url.value.clone(), ProviderAddField::OpenCodeModelId => provider.opencode_base_url.value.clone(), ProviderAddField::HermesModels => provider.hermes_base_url.value.clone(), - ProviderAddField::OpenClawModels if matches!(provider.app_type, AppType::Pi) => { + ProviderAddField::OpenClawModels + if matches!(provider.app_type, AppType::Pi | AppType::Omp) => + { provider.current_provider_base_url() } ProviderAddField::OpenClawModels => provider.opencode_base_url.value.clone(), _ => String::new(), }; - let (api_protocol, mut request_headers) = - if selected == ProviderAddField::OpenClawModels && provider.app_type == AppType::Pi { - let settings = provider.to_provider_json_value()["settingsConfig"].clone(); - let protocol = settings - .get("api") - .and_then(Value::as_str) - .map(str::to_string); - let headers = settings - .get("headers") + let (api_protocol, mut request_headers) = if selected == ProviderAddField::OpenClawModels + && matches!(provider.app_type, AppType::Pi | AppType::Omp) + { + let settings = provider.to_provider_json_value()["settingsConfig"].clone(); + let discovery_type = settings + .get("discovery") + .and_then(Value::as_object) + .and_then(|discovery| discovery.get("type")) + .and_then(Value::as_str); + if matches!(provider.app_type, AppType::Omp) + && matches!( + discovery_type, + Some("openai-models-list" | "lm-studio" | "litellm") + ) + { + // The worker's generic candidate builder probes both + // `/models` and `/v1/models`. OMP's discovery.injectV1 + // setting is authoritative, so shape the one-shot URL here + // to avoid probing the wrong route in the TUI. + let inject_v1 = settings + .get("discovery") .and_then(Value::as_object) - .map(|headers| { - headers - .iter() - .filter_map(|(name, value)| { - value - .as_str() - .map(|value| (name.clone(), value.to_string())) - }) - .collect::>() + .and_then(|discovery| discovery.get("injectV1")) + .and_then(Value::as_bool) + .unwrap_or(true); + if let Ok(mut parsed) = url::Url::parse(base_url.trim()) { + parsed.set_query(None); + parsed.set_fragment(None); + let mut path = parsed.path().trim_end_matches('/').to_string(); + if inject_v1 { + if !path.ends_with("/v1") && !path.ends_with("/models") { + path.push_str("/v1"); + } + } else if !path.ends_with("/models") { + path.push_str("/models"); + } + parsed.set_path(&path); + base_url = parsed.to_string(); + } + } + let protocol = if matches!(provider.app_type, AppType::Omp) + && discovery_type == Some("llama.cpp") + { + // OMP's llama.cpp discovery always probes the native + // `{baseUrl}/models` endpoint. Users commonly configure the + // OpenAI-compatible `/v1` URL, so remove that suffix before + // handing the request to the generic models worker. + if let Ok(mut parsed) = url::Url::parse(base_url.trim()) { + let path = parsed.path().trim_end_matches('/').to_string(); + let native_path = path + .strip_suffix("/v1") + .filter(|value| !value.is_empty()) + .unwrap_or(&path) + .to_string(); + parsed.set_path(&native_path); + parsed.set_query(None); + parsed.set_fragment(None); + base_url = parsed.to_string().trim_end_matches('/').to_string(); + } else if base_url.trim_end_matches('/').ends_with("/v1") { + base_url = base_url + .trim_end_matches('/') + .strip_suffix("/v1") + .unwrap_or(base_url.trim_end_matches('/')) + .to_string(); + } + // Use a distinct marker so the worker selects an anonymous + // (or header-authenticated) OpenAI-compatible probe while + // preserving the native endpoint URL above. + Some("llama.cpp".to_string()) + } else if matches!(provider.app_type, AppType::Omp) && discovery_type == Some("ollama") + { + // The OMP Ollama discovery endpoint is `/api/tags`, not an + // OpenAI-compatible `/models` route. Pass a synthetic worker + // protocol marker so the background fetch uses that native + // endpoint while retaining configured request headers. + Some("ollama".to_string()) + } else if matches!(provider.app_type, AppType::Omp) { + if settings.get("discovery").is_some() { + // OMP's native discovery clients use bearer auth for all + // discovery types other than Ollama, independent of the + // model wire protocol. Keep anonymous mode explicit when + // no key (or auth: none) is configured. + if api_key.is_none() + || settings.get("auth").and_then(Value::as_str) == Some("none") + { + Some("none".to_string()) + } else { + Some("omp-discovery".to_string()) + } + } else { + provider.omp_model_fetch_api_protocol().or_else(|| { + settings + .get("api") + .and_then(Value::as_str) + .map(str::to_string) }) - .filter(|headers| !headers.is_empty()); - (protocol, headers) + } + } else { + settings + .get("api") + .and_then(Value::as_str) + .map(str::to_string) + }; + let protocol = if matches!(provider.app_type, AppType::Omp) + && settings.get("auth").and_then(Value::as_str) == Some("none") + && protocol.as_deref() != Some("ollama") + { + // Keep the authentication mode explicit for the worker. OMP + // model discovery is allowed to issue an unauthenticated GET + // even when the model's wire protocol is OpenAI-compatible. + Some("none".to_string()) } else { - (None, None) + protocol }; + let headers = settings + .get("headers") + .and_then(Value::as_object) + .map(|headers| { + headers + .iter() + .filter_map(|(name, value)| { + value.as_str().and_then(|value| { + // OMP resolves header values using the same + // env/.env/!command semantics as apiKey. + // Resolve them before handing the request to + // the background model-fetch worker; sending + // the indirection token verbatim breaks + // header-authenticated discovery in the TUI. + let resolved = if matches!(provider.app_type, AppType::Omp) { + crate::omp_config::resolve_header_value(value) + } else { + Some(value.to_string()) + }?; + Some((name.clone(), resolved)) + }) + }) + .collect::>() + }) + .filter(|headers| !headers.is_empty()); + (protocol, headers) + } else { + (None, None) + }; if api_protocol.as_deref() == Some("anthropic-messages") { if let Some(key) = api_key.take() { request_headers @@ -1612,6 +1760,14 @@ impl App { .or_insert(key); } } + let discovery_timeout_ms = if matches!(provider.app_type, AppType::Omp) + && selected == ProviderAddField::OpenClawModels + { + let settings = provider.to_provider_json_value()["settingsConfig"].clone(); + crate::omp_config::omp_discovery_timeout_ms(&settings) + } else { + None + }; Action::ProviderModelFetch { base_url, is_full_url: provider.is_full_url && matches!(selected, ProviderAddField::CodexModel), @@ -1620,6 +1776,7 @@ impl App { .then(|| provider.custom_user_agent.value.clone()), api_protocol, request_headers, + discovery_timeout_ms, codex_oauth: false, codex_oauth_account_id: None, field: selected, @@ -2097,9 +2254,16 @@ fn is_provider_divider_field(field: Option<&ProviderAddField>) -> bool { ) } -fn next_openclaw_api_protocol(current: &str) -> &'static str { +fn next_openclaw_api_protocol( + app_type: &crate::app_config::AppType, + current: &str, +) -> &'static str { let current = current.trim(); - let protocols = &form::OPENCLAW_API_PROTOCOLS; + let protocols: &[&str] = if matches!(app_type, crate::app_config::AppType::Omp) { + &crate::omp_config::OMP_API_PROTOCOLS + } else { + &form::OPENCLAW_API_PROTOCOLS + }; let next_idx = protocols .iter() .position(|candidate| *candidate == current) diff --git a/src-tauri/src/cli/tui/app/helpers.rs b/src-tauri/src/cli/tui/app/helpers.rs index da5f05104..9d5e7ea5b 100644 --- a/src-tauri/src/cli/tui/app/helpers.rs +++ b/src-tauri/src/cli/tui/app/helpers.rs @@ -695,6 +695,9 @@ pub(crate) fn route_has_content_list(route: &Route) -> bool { | Route::Prompts | Route::PiSystemPrompts | Route::PiPromptTemplates + | Route::OmpModels + | Route::OmpRoles + | Route::OmpSystemPrompts | Route::HermesMemory | Route::Config | Route::ConfigOpenClawWorkspace @@ -1761,6 +1764,7 @@ pub(crate) fn app_type_picker_index(app_type: &AppType) -> usize { AppType::Hermes => 4, AppType::OpenClaw => 5, AppType::Pi => 6, + AppType::Omp => 7, } } @@ -1771,15 +1775,16 @@ pub(crate) fn four_app_picker_index(app_type: &AppType) -> usize { pub(crate) fn skills_app_picker_index(app_type: &AppType) -> usize { match app_type { AppType::Pi | AppType::OpenClaw => 5, + AppType::Omp => 6, _ => app_type_picker_index(app_type), } } pub(crate) fn skill_app_type_for_picker_index(index: usize) -> AppType { - if index == 5 { - AppType::Pi - } else { - app_type_for_picker_index(index) + match index { + 5 => AppType::Pi, + 6 => AppType::Omp, + _ => app_type_for_picker_index(index), } } @@ -1791,6 +1796,7 @@ pub(crate) fn app_type_for_picker_index(index: usize) -> AppType { 4 => AppType::Hermes, 5 => AppType::OpenClaw, 6 => AppType::Pi, + 7 => AppType::Omp, _ => AppType::Claude, } } diff --git a/src-tauri/src/cli/tui/app/menu.rs b/src-tauri/src/cli/tui/app/menu.rs index cb0ec346d..e39018091 100644 --- a/src-tauri/src/cli/tui/app/menu.rs +++ b/src-tauri/src/cli/tui/app/menu.rs @@ -102,6 +102,9 @@ impl App { prompt_idx: 0, pi_system_prompt_idx: 0, pi_prompt_template_idx: 0, + omp_model_idx: 0, + omp_role_idx: 0, + omp_system_prompt_idx: 0, skills_idx: 0, skills_discover_idx: 0, skills_repo_idx: 0, @@ -190,6 +193,9 @@ impl App { Route::Prompts => NavItem::Prompts, Route::PiSystemPrompts => NavItem::PiSystemPrompts, Route::PiPromptTemplates => NavItem::PiPromptTemplates, + Route::OmpModels => NavItem::OmpModels, + Route::OmpRoles => NavItem::OmpRoles, + Route::OmpSystemPrompts => NavItem::OmpSystemPrompts, Route::HermesMemory => NavItem::HermesMemory, Route::Config => NavItem::Config, Route::ConfigOpenClawWorkspace | Route::ConfigOpenClawDailyMemory => { @@ -1114,6 +1120,9 @@ impl App { Route::Prompts => self.on_prompts_key(key, data), Route::PiSystemPrompts => self.on_pi_system_prompts_key(key, data), Route::PiPromptTemplates => self.on_pi_prompt_templates_key(key, data), + Route::OmpModels => self.on_omp_models_key(key, data), + Route::OmpRoles => self.on_omp_roles_key(key, data), + Route::OmpSystemPrompts => self.on_omp_system_prompts_key(key, data), Route::HermesMemory => self.on_hermes_memory_key(key, data), Route::Config => self.on_config_key(key, data), Route::ConfigOpenClawWorkspace => self.on_config_openclaw_workspace_key(key, data), @@ -1193,6 +1202,16 @@ impl App { self.prompt_idx = self.prompt_idx.min(prompt_len - 1); } + self.omp_model_idx = self + .omp_model_idx + .min(data.omp.models.len().saturating_sub(1)); + self.omp_role_idx = self + .omp_role_idx + .min(data.omp.model_roles.len().saturating_sub(1)); + self.omp_system_prompt_idx = self + .omp_system_prompt_idx + .min(data.pi_prompts.system_files.len().saturating_sub(1)); + let visible_session_rows = visible_sessions_for_state( &self.filter, &self.app_type, diff --git a/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs b/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs index e5f2703e3..22cf08f2b 100644 --- a/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs +++ b/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs @@ -104,6 +104,29 @@ impl App { kind: *kind, expected_revision: expected_revision.clone(), }, + ConfirmAction::OmpModelDelete { + provider_id, + model_id, + expected_revision, + } => Action::OmpModelDelete { + provider_id: provider_id.clone(), + model_id: model_id.clone(), + expected_revision: expected_revision.clone(), + }, + ConfirmAction::OmpRoleDelete { + role, + expected_revision, + } => Action::OmpRoleDelete { + role: role.clone(), + expected_revision: expected_revision.clone(), + }, + ConfirmAction::OmpSystemPromptDelete { + kind, + expected_revision, + } => Action::OmpSystemPromptDelete { + kind: *kind, + expected_revision: expected_revision.clone(), + }, ConfirmAction::PiPromptTemplateDelete { slug, expected_revision, diff --git a/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs b/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs index c86fd7b2f..6ae7896c6 100644 --- a/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs +++ b/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs @@ -1071,6 +1071,7 @@ impl App { .then(|| provider.custom_user_agent.value.clone()), api_protocol: None, request_headers: None, + discovery_timeout_ms: None, codex_oauth, codex_oauth_account_id, field: ProviderAddField::ClaudeModelConfig, @@ -1627,7 +1628,7 @@ impl App { Action::None } KeyCode::Down => { - *selected = (*selected + 1).min(6); + *selected = (*selected + 1).min(7); Action::None } KeyCode::Char(' ') => { @@ -1696,7 +1697,7 @@ impl App { Action::None } KeyCode::Down => { - *selected = (*selected + 1).min(5); + *selected = (*selected + 1).min(6); Action::None } KeyCode::Char(' ') => { diff --git a/src-tauri/src/cli/tui/app/tests.rs b/src-tauri/src/cli/tui/app/tests.rs index 1050e5b00..69d797cde 100644 --- a/src-tauri/src/cli/tui/app/tests.rs +++ b/src-tauri/src/cli/tui/app/tests.rs @@ -971,6 +971,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + omp: false, }, }; @@ -1166,6 +1167,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + omp: false, }) .expect("save visible apps"); let mut app = App::new(Some(AppType::Claude)); @@ -1192,6 +1194,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + omp: false, }) .expect("save visible apps"); let mut app = App::new(Some(AppType::Claude)); @@ -1226,6 +1229,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + omp: false, }) .expect("save visible apps"); let mut app = App::new(Some(AppType::Gemini)); @@ -1268,6 +1272,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + omp: false, }) .expect("save visible apps"); @@ -1292,6 +1297,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + omp: false, }) .expect("save visible apps"); @@ -1320,6 +1326,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + omp: false, }) .expect("save visible apps"); @@ -1344,6 +1351,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + omp: false, }) .expect("save visible apps"); @@ -2345,6 +2353,7 @@ mod tests { custom_user_agent: Some(custom_user_agent), api_protocol: None, request_headers: None, + discovery_timeout_ms: None, codex_oauth: false, codex_oauth_account_id: None, field: ProviderAddField::HermesModels, @@ -5412,6 +5421,97 @@ mod tests { ); } + #[test] + fn provider_omp_model_fetch_uses_selected_model_api_protocol() { + let mut app = App::new(Some(AppType::Omp)); + app.route = Route::Providers; + app.focus = Focus::Content; + let provider = Provider::with_id( + "omp-native".to_string(), + "OMP Native".to_string(), + json!({ + "apiKey": "omp-secret", + "models": [{ + "id": "claude", + "api": "anthropic-messages", + "baseUrl": "https://omp.example.test/v1" + }] + }), + None, + ); + let mut form = ProviderAddFormState::from_provider(AppType::Omp, &provider); + form.focus = FormFocus::Fields; + form.field_idx = form + .fields() + .iter() + .position(|field| *field == ProviderAddField::OpenClawModels) + .expect("OMP models field"); + app.form = Some(FormState::ProviderAdd(form)); + + let action = app.on_key(key(KeyCode::Char('f')), &data()); + let Action::ProviderModelFetch { + api_key, + api_protocol, + request_headers, + field, + .. + } = action + else { + panic!("expected OMP model fetch action"); + }; + assert_eq!(api_key, None, "Anthropic auth is carried by x-api-key"); + assert_eq!(api_protocol.as_deref(), Some("anthropic-messages")); + assert_eq!(field, ProviderAddField::OpenClawModels); + assert_eq!( + request_headers + .as_ref() + .and_then(|headers| headers.get("x-api-key")) + .map(String::as_str), + Some("omp-secret") + ); + } + + #[test] + fn provider_omp_model_fetch_uses_provider_api_protocol_without_models() { + let mut app = App::new(Some(AppType::Omp)); + app.route = Route::Providers; + app.focus = Focus::Content; + let provider = Provider::with_id( + "omp-native".to_string(), + "OMP Native".to_string(), + json!({ + "apiKey": "omp-secret", + "baseUrl": "https://omp.example.test/v1", + "api": "openai-responses" + }), + None, + ); + let mut form = ProviderAddFormState::from_provider(AppType::Omp, &provider); + form.focus = FormFocus::Fields; + form.field_idx = form + .fields() + .iter() + .position(|field| *field == ProviderAddField::OpenClawModels) + .expect("OMP models field"); + app.form = Some(FormState::ProviderAdd(form)); + + let action = app.on_key(key(KeyCode::Char('f')), &data()); + let Action::ProviderModelFetch { + base_url, + api_key, + api_protocol, + field, + .. + } = action + else { + panic!("expected OMP model fetch action"); + }; + assert_eq!(base_url, "https://omp.example.test/v1"); + assert_eq!(api_key.as_deref(), Some("omp-secret")); + assert_eq!(api_protocol.as_deref(), Some("openai-responses")); + assert_eq!(field, ProviderAddField::OpenClawModels); + } + #[test] fn provider_pi_model_fetch_selection_adds_a_model_entry() { let mut app = App::new(Some(AppType::Pi)); @@ -11226,6 +11326,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + omp: false, }) .expect("save visible apps"); crate::settings::set_visible_apps_mode(crate::settings::VisibleAppsMode::Manual) @@ -11277,6 +11378,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + omp: false, }) .expect("save visible apps"); @@ -11315,6 +11417,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + omp: false, }; settings.visible_apps_settings.mode = crate::settings::VisibleAppsMode::Auto; settings.visible_apps_settings.auto_prompt_decided = true; @@ -11354,6 +11457,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + omp: false, }; settings.visible_apps_settings.mode = crate::settings::VisibleAppsMode::Auto; settings.visible_apps_settings.auto_prompt_decided = true; @@ -11392,6 +11496,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + omp: false, }; let mut settings = crate::settings::get_settings(); settings.visible_apps = initial.clone(); diff --git a/src-tauri/src/cli/tui/app/types.rs b/src-tauri/src/cli/tui/app/types.rs index 846f434fd..8a295037e 100644 --- a/src-tauri/src/cli/tui/app/types.rs +++ b/src-tauri/src/cli/tui/app/types.rs @@ -4367,6 +4367,19 @@ pub enum ConfirmAction { kind: crate::services::pi_prompt_files::PiPromptFileKind, expected_revision: String, }, + OmpModelDelete { + provider_id: String, + model_id: String, + expected_revision: String, + }, + OmpRoleDelete { + role: String, + expected_revision: String, + }, + OmpSystemPromptDelete { + kind: crate::services::pi_prompt_files::PiPromptFileKind, + expected_revision: String, + }, PiPromptTemplateDelete { slug: String, expected_revision: String, diff --git a/src-tauri/src/cli/tui/data.rs b/src-tauri/src/cli/tui/data.rs index a6794aef0..c40190261 100644 --- a/src-tauri/src/cli/tui/data.rs +++ b/src-tauri/src/cli/tui/data.rs @@ -25,8 +25,8 @@ use crate::prompt_files::prompt_file_path; use crate::provider::Provider; use crate::services::config::BackupInfo; use crate::services::pi_prompt_files::{ - PiPromptFileKind, PiPromptFileService, PiPromptFileSnapshot, PiPromptTemplate, - PiPromptTemplateService, + OmpPromptFileService, PiPromptFileKind, PiPromptFileService, PiPromptFileSnapshot, + PiPromptTemplate, PiPromptTemplateService, }; use crate::services::{ConfigService, McpService, PromptService, ProviderService, SkillService}; use crate::store::AppState; @@ -223,8 +223,14 @@ pub struct ProvidersSnapshot { pub current_id: String, pub rows: Vec, pub live_ids: HashSet, - /// Pi provider membership could not be read from models.json. Mutating - /// membership actions must fail closed while this is true. + /// OMP providers present in models.yml but suppressed by the effective + /// disabledProviders settings layer. Kept separate from `live_ids` so the + /// provider actions continue to treat disabled entries as inactive while + /// the renderer can show an explicit disabled marker. + pub disabled_ids: HashSet, + /// Pi/OMP native provider membership could not be read from the live + /// registry. Mutating membership actions must fail closed while this is + /// true. pub pi_membership_unknown: bool, /// True only for the transient projection shown while a cold-switched app's /// real data is still loading. Lets the renderer show a "loading" state @@ -266,6 +272,51 @@ pub struct PromptsSnapshot { pub struct PiPromptsSnapshot { pub system_files: Vec<(PiPromptFileKind, PiPromptFileSnapshot)>, pub templates: Vec, + pub read_error: Option, +} + +#[derive(Debug, Clone)] +pub struct OmpSnapshot { + pub providers: IndexMap, + pub models: Vec, + pub model_roles: IndexMap, + pub models_path: PathBuf, + pub config_path: PathBuf, + /// The config file receiving model-role writes. This may be the project + /// `.omp/config.yml` when OMP's effective `modelRoleStorage` is `project`. + pub roles_path: PathBuf, + pub models_revision: String, + pub config_revision: String, + pub roles_revision: String, + pub models_yaml: String, + pub config_yaml: String, + pub roles_yaml: String, + pub models_error: Option, + pub config_error: Option, + pub read_error: Option, +} + +impl Default for OmpSnapshot { + fn default() -> Self { + let fallback = PathBuf::from("~/.omp/agent"); + Self { + models: Vec::new(), + providers: IndexMap::new(), + model_roles: IndexMap::new(), + models_path: fallback.join("models.yml"), + config_path: fallback.join("config.yml"), + roles_path: fallback.join("config.yml"), + models_revision: "missing".to_string(), + config_revision: "missing".to_string(), + roles_revision: "missing".to_string(), + models_yaml: "providers: {}\n".to_string(), + config_yaml: "{}\n".to_string(), + roles_yaml: "{}\n".to_string(), + models_error: None, + config_error: None, + read_error: None, + } + } } #[derive(Debug, Clone, Default)] @@ -409,6 +460,7 @@ impl ProxySnapshot { AppType::Hermes => None, AppType::OpenClaw => None, AppType::Pi => None, + AppType::Omp => None, } } @@ -1187,6 +1239,7 @@ pub struct UiData { pub mcp: McpSnapshot, pub prompts: PromptsSnapshot, pub pi_prompts: PiPromptsSnapshot, + pub omp: OmpSnapshot, pub config: ConfigSnapshot, pub skills: SkillsSnapshot, pub proxy: ProxySnapshot, @@ -1234,6 +1287,10 @@ impl UiData { Ok(()) } + pub(crate) fn refresh_current_app_omp_data(&mut self) { + self.omp = load_omp_snapshot(); + } + pub fn load(app_type: &AppType) -> Result { let state = load_state()?; @@ -1277,6 +1334,11 @@ impl UiData { let mcp = load_mcp(state)?; let prompts = load_prompts(state, app_type)?; let pi_prompts = load_pi_prompts(app_type)?; + let omp = if matches!(app_type, AppType::Omp) { + load_omp_snapshot() + } else { + OmpSnapshot::default() + }; let config = load_config_snapshot(state, app_type)?; let skills = match provider_load_mode { ProviderLoadMode::SyncLive => load_skills_snapshot()?, @@ -1289,6 +1351,7 @@ impl UiData { mcp, prompts, pi_prompts, + omp, config, skills, proxy, @@ -1314,6 +1377,7 @@ impl UiData { mcp: self.mcp.clone(), prompts: PromptsSnapshot::default(), pi_prompts: PiPromptsSnapshot::default(), + omp: OmpSnapshot::default(), config: self.config.loading_projection(app_type), skills: self.skills.clone(), proxy, @@ -1534,6 +1598,12 @@ fn load_providers_with_mode( } else { (None, false) }; + let (omp_live_ids, omp_disabled_ids, omp_membership_unknown) = + if matches!(app_type, AppType::Omp) { + read_omp_live_ids() + } else { + (HashSet::new(), HashSet::new(), false) + }; let pi_live_ids = pi_state .as_ref() .map(|current| { @@ -1558,6 +1628,18 @@ fn load_providers_with_mode( .as_ref() .and_then(|model| openclaw_default_model_ref_parts(&model.primary)) .map(|(provider_id, _)| provider_id.to_string()); + let omp_default_provider_id = if matches!(app_type, AppType::Omp) { + crate::omp_config::read_omp_model_roles() + .ok() + .and_then(|roles| { + roles.get("default").and_then(|selector| { + crate::omp_config::omp_selector_provider_id(selector, &roles) + }) + }) + .filter(|provider| omp_live_ids.contains(provider)) + } else { + None + }; let mut rows = sorted .into_iter() @@ -1578,12 +1660,14 @@ fn load_providers_with_mode( AppType::Hermes => hermes_live_ids.contains(&id), AppType::OpenClaw => openclaw_live_ids.contains(&id), AppType::Pi => pi_live_ids.contains(&id), + AppType::Omp => omp_live_ids.contains(&id), _ => true, }, is_saved: true, is_default_model: match app_type { AppType::Hermes => hermes_current_provider_id.as_deref() == Some(id.as_str()), AppType::Pi => pi_default_provider_id.as_deref() == Some(id.as_str()), + AppType::Omp => omp_default_provider_id.as_deref() == Some(id.as_str()), _ => openclaw_primary_default_provider_id.as_deref() == Some(id.as_str()), }, primary_model_id: extract_primary_model_id( @@ -1648,6 +1732,7 @@ fn load_providers_with_mode( AppType::Hermes => hermes_live_ids, AppType::OpenClaw => openclaw_live_providers.keys().cloned().collect(), AppType::Pi => pi_live_ids, + AppType::Omp => omp_live_ids, _ => HashSet::new(), }; @@ -1655,11 +1740,45 @@ fn load_providers_with_mode( current_id, rows, live_ids, - pi_membership_unknown, + disabled_ids: omp_disabled_ids, + // This flag is shared by the Pi and OMP additive native registries; + // both must fail closed when their live membership cannot be read. + pi_membership_unknown: pi_membership_unknown || omp_membership_unknown, loading: false, }) } +/// Read OMP's native provider membership for the TUI. The saved CC Switch +/// catalog remains useful when the external YAML is malformed or temporarily +/// unreadable, so membership is reported as unknown instead of aborting the +/// whole provider-page load. +fn read_omp_live_ids() -> (HashSet, HashSet, bool) { + let providers = match crate::omp_config::read_omp_native_providers() { + Ok(providers) => providers, + Err(error) => { + log::warn!("Failed to read OMP provider membership: {error}"); + return (HashSet::new(), HashSet::new(), true); + } + }; + let disabled = match crate::omp_config::read_omp_disabled_providers() { + Ok(disabled) => disabled, + Err(error) => { + log::warn!("Failed to read OMP disabledProviders: {error}"); + return (HashSet::new(), HashSet::new(), true); + } + }; + let provider_ids = providers.into_keys().collect::>(); + let disabled_ids = disabled + .intersection(&provider_ids) + .cloned() + .collect::>(); + let live_ids = provider_ids + .difference(&disabled_ids) + .cloned() + .collect::>(); + (live_ids, disabled_ids, false) +} + fn current_provider_for_mode( state: &AppState, app_type: &AppType, @@ -1742,6 +1861,7 @@ fn extract_api_url(settings_config: &Value, app_type: &AppType) -> Option crate::pi_config::provider_base_url(settings_config).ok(), + AppType::Omp => crate::omp_config::provider_base_url(settings_config).ok(), } } @@ -1756,6 +1876,7 @@ fn extract_primary_model_id( Some(live_provider) => openclaw_primary_model_id(live_provider), None => openclaw_primary_model_id(settings_config), }, + AppType::Omp => omp_primary_model_id(settings_config), _ => None, } } @@ -1808,6 +1929,29 @@ fn openclaw_primary_model_id(provider_value: &Value) -> Option { .map(str::to_string) } +/// Return the first native OMP model for provider-level actions. +/// +/// OMP stores a provider's catalog under `models`, just like Pi. The +/// provider table has one row per provider, so actions such as "set default" +/// need a deterministic model to pass to the native `modelRoles.default` +/// writer. Keep this deliberately conservative: malformed/empty catalogs +/// remain unset and the UI can explain that a model must be configured first. +fn omp_primary_model_id(provider_value: &Value) -> Option { + provider_value + .get("models") + .and_then(Value::as_array) + .and_then(|models| { + models.iter().find_map(|model| { + model + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(ToOwned::to_owned) + }) + }) +} + fn openclaw_default_model_ids_by_provider( default_model: Option<&crate::openclaw_config::OpenClawDefaultModel>, ) -> HashMap { @@ -1849,6 +1993,12 @@ fn load_mcp(state: &AppState) -> Result { } fn load_prompts(state: &AppState, app_type: &AppType) -> Result { + // OMP has no CC Switch prompt-preset database. Its native system prompt + // files are loaded separately by load_pi_prompts and rendered on the + // dedicated OMP page. + if matches!(app_type, AppType::Omp) { + return Ok(PromptsSnapshot::default()); + } let prompts = PromptService::get_prompts(state, app_type.clone())?; let mut rows = prompts .into_iter() @@ -1870,23 +2020,127 @@ fn load_prompts(state: &AppState, app_type: &AppType) -> Result Result { - if !matches!(app_type, AppType::Pi) { + if !matches!(app_type, AppType::Pi | AppType::Omp) { return Ok(PiPromptsSnapshot::default()); } - let system_files = [ - PiPromptFileKind::SystemAppend, - PiPromptFileKind::SystemOverride, - ] - .into_iter() - .map(|kind| PiPromptFileService::read(kind).map(|snapshot| (kind, snapshot))) - .collect::, _>>()?; - let templates = PiPromptTemplateService::list()?; + let kinds = if matches!(app_type, AppType::Omp) { + vec![ + PiPromptFileKind::SystemAppend, + PiPromptFileKind::SystemOverride, + PiPromptFileKind::TitleSystem, + ] + } else { + vec![ + PiPromptFileKind::SystemAppend, + PiPromptFileKind::SystemOverride, + ] + }; + let mut system_files = Vec::new(); + let mut read_error = None; + for kind in kinds { + let result = if matches!(app_type, AppType::Omp) { + OmpPromptFileService::read(kind) + } else { + PiPromptFileService::read(kind) + }; + match result { + Ok(snapshot) => system_files.push((kind, snapshot)), + Err(error) if matches!(app_type, AppType::Omp) => { + read_error = Some(error.to_string()); + } + Err(error) => return Err(error), + } + } + let templates = if matches!(app_type, AppType::Omp) { + Vec::new() + } else { + PiPromptTemplateService::list()? + }; Ok(PiPromptsSnapshot { system_files, templates, + read_error, }) } +fn load_omp_snapshot() -> OmpSnapshot { + let mut snapshot = OmpSnapshot::default(); + let mut errors = Vec::new(); + + if let Ok(agent_dir) = crate::omp_config::get_omp_agent_dir() { + snapshot.models_path = crate::omp_config::get_omp_models_path() + .unwrap_or_else(|_| agent_dir.join("models.yml")); + snapshot.config_path = crate::omp_config::get_omp_settings_path() + .unwrap_or_else(|_| agent_dir.join("config.yml")); + } + + match crate::omp_config::read_omp_native_models() { + Ok(models) => snapshot.models = models, + Err(error) => { + snapshot.models_error = Some(error.to_string()); + errors.push(format!("models.yml: {error}")); + } + } + match crate::omp_config::read_omp_native_providers() { + Ok(providers) => snapshot.providers = providers, + Err(error) => errors.push(format!("models.yml providers: {error}")), + } + match crate::omp_config::read_omp_model_roles_with_metadata() { + Ok((roles, path, revision)) => { + snapshot.model_roles = roles; + snapshot.roles_path = path; + snapshot.roles_revision = revision; + if let Ok((yaml, revision)) = + crate::omp_config::read_omp_config_yaml_at(&snapshot.roles_path) + { + // If config.yml is still represented by legacy settings.json, + // the advanced editor must carry the legacy source revision + // so its eventual migration write can detect concurrent edits. + snapshot.roles_revision = revision; + snapshot.roles_yaml = if yaml.trim().is_empty() { + "{}\n".to_string() + } else { + yaml + }; + } + } + Err(error) => { + snapshot.config_error = Some(error.to_string()); + errors.push(format!("config.yml modelRoles: {error}")); + } + } + match crate::omp_config::read_omp_models_yaml() { + Ok((yaml, revision)) => { + snapshot.models_yaml = yaml; + snapshot.models_revision = revision; + } + Err(error) => { + snapshot + .models_error + .get_or_insert_with(|| error.to_string()); + errors.push(format!("models.yml: {error}")); + } + } + match crate::omp_config::read_omp_config_yaml() { + Ok((yaml, revision)) => { + snapshot.config_yaml = if yaml.trim().is_empty() { + "{}\n".to_string() + } else { + yaml + }; + snapshot.config_revision = revision; + } + Err(error) => { + snapshot + .config_error + .get_or_insert_with(|| error.to_string()); + errors.push(format!("config.yml: {error}")); + } + } + snapshot.read_error = (!errors.is_empty()).then(|| errors.join("; ")); + snapshot +} + fn sort_prompt_rows(rows: &mut [PromptRow]) { rows.sort_by(|a, b| { a.prompt @@ -3513,6 +3767,8 @@ fn usage_provider_name_sql(log_alias: &str, provider_alias: &str) -> String { WHEN '_codex_session' THEN 'Codex (Session)' \ WHEN '_gemini_session' THEN 'Gemini (Session)' \ WHEN '_opencode_session' THEN 'OpenCode (Session)' \ + WHEN '_pi_session' THEN 'Pi (Session)' \ + WHEN '_omp_session' THEN 'OMP (Session)' \ ELSE {log_alias}.provider_id END)" ) } @@ -3725,13 +3981,51 @@ fn skills_snapshot_from_index(index: crate::services::skill::SkillsIndex) -> Ski #[cfg(test)] mod tests { use super::*; + use crate::omp_config::test_support::TestAgentDir; use crate::prompt::Prompt; use crate::provider::{AuthBinding, AuthBindingSource, ProviderMeta, UsageScript}; use serde_json::json; use serial_test::serial; + use std::fs; use std::path::Path; use tempfile::tempdir; + #[test] + #[serial] + fn omp_live_membership_degrades_when_native_file_is_malformed() { + let _agent = TestAgentDir::new(); + let path = crate::omp_config::get_omp_models_path().expect("OMP models path"); + fs::create_dir_all(path.parent().expect("OMP models parent")).expect("create parent"); + fs::write(path, "providers: [not-an-object]\n").expect("write malformed OMP models"); + + let (live_ids, disabled_ids, membership_unknown) = read_omp_live_ids(); + assert!(live_ids.is_empty()); + assert!(disabled_ids.is_empty()); + assert!(membership_unknown); + } + + #[test] + #[serial] + fn omp_live_membership_separates_disabled_native_providers() { + let _agent = TestAgentDir::new(); + let models_path = crate::omp_config::get_omp_models_path().expect("OMP models path"); + fs::create_dir_all(models_path.parent().expect("OMP models parent")) + .expect("create OMP directory"); + fs::write( + &models_path, + "providers:\n enabled:\n baseUrl: https://enabled.example/v1\n apiKey: key\n models:\n - id: model\n disabled:\n baseUrl: https://disabled.example/v1\n apiKey: key\n models:\n - id: model\n", + ) + .expect("write OMP models"); + let settings_path = crate::omp_config::get_omp_settings_path().expect("OMP config path"); + fs::write(&settings_path, "disabledProviders: [disabled]\n").expect("write OMP config"); + + let (live_ids, disabled_ids, membership_unknown) = read_omp_live_ids(); + assert!(!membership_unknown); + assert!(live_ids.contains("enabled")); + assert!(!live_ids.contains("disabled")); + assert!(disabled_ids.contains("disabled")); + } + use crate::settings::{get_settings, update_settings, AppSettings}; use crate::test_support::{lock_test_home_and_settings, set_test_home_override}; @@ -6269,6 +6563,20 @@ base_url = "https://current.example.com/v1" ); } + #[test] + fn omp_primary_model_id_skips_malformed_and_empty_entries() { + let value = serde_json::json!({ + "models": [ + {"name": "missing id"}, + {"id": " "}, + {"id": "gpt-5.6"} + ] + }); + + assert_eq!(omp_primary_model_id(&value).as_deref(), Some("gpt-5.6")); + assert_eq!(omp_primary_model_id(&serde_json::json!({})), None); + } + #[test] fn extract_primary_model_id_openclaw_prefers_live_provider_models() { let saved = json!({ diff --git a/src-tauri/src/cli/tui/form/provider_json.rs b/src-tauri/src/cli/tui/form/provider_json.rs index 8c7d22881..8e174c3c9 100644 --- a/src-tauri/src/cli/tui/form/provider_json.rs +++ b/src-tauri/src/cli/tui/form/provider_json.rs @@ -681,9 +681,15 @@ impl ProviderAddFormState { &self.hermes_rate_limit_delay.value, ); } - AppType::OpenClaw | AppType::Pi => { + AppType::OpenClaw | AppType::Pi | AppType::Omp => { let is_pi = matches!(self.app_type, AppType::Pi); - let original_pi_settings = is_pi + let is_omp = matches!(self.app_type, AppType::Omp); + // Keep the initial native snapshot for both Pi and OMP. OMP + // providers may intentionally omit `apiKey` and `auth` so + // runtime credentials can come from the environment; an edit + // that leaves the key field blank must not silently turn that + // implicit api-key mode into `auth: none`. + let original_pi_settings = (is_pi || is_omp) .then(|| { self.extra .pointer("/settingsConfig") @@ -707,6 +713,13 @@ impl ProviderAddFormState { ); } } + if is_omp + && !crate::services::provider::is_opaque_extension_config(&Value::Object( + settings_obj.clone(), + )) + { + settings_obj.remove("name"); + } if !is_pi { settings_obj.remove("npm"); settings_obj.remove("options"); @@ -723,6 +736,52 @@ impl ProviderAddFormState { { set_or_remove_trimmed(settings_obj, "apiKey", &self.opencode_api_key.value); } + + // OMP supports keyless custom providers (for example local + // Ollama/llama.cpp servers) via `auth: none`. The compact + // form has no separate auth selector, so an empty API-key + // field on an OMP provider is treated as an explicit keyless + // configuration unless an existing OAuth/none mode is already + // present. Conversely, entering a key restores the default + // api-key mode when the form previously inserted `none`. + if is_omp { + let is_opaque_extension = crate::services::provider::is_opaque_extension_config( + &Value::Object(settings_obj.clone()), + ); + if self.opencode_api_key.value.trim().is_empty() && !is_opaque_extension { + let existing_auth = settings_obj.get("auth").and_then(Value::as_str); + let has_oauth_descriptor = settings_obj.get("oauth").is_some_and(|value| { + value.as_str().is_some_and(|value| !value.trim().is_empty()) + || value.is_object() + }); + let had_api_key = original_pi_settings + .and_then(|settings| settings.get("apiKey")) + .and_then(Value::as_str) + .is_some_and(|value| !value.trim().is_empty()); + let had_explicit_api_key_auth = original_pi_settings + .and_then(|settings| settings.get("auth")) + .and_then(Value::as_str) + == Some("apiKey"); + let has_models = settings_obj + .get("models") + .and_then(Value::as_array) + .is_some_and(|models| !models.is_empty()); + if !matches!(existing_auth, Some("none") | Some("oauth")) + && !has_oauth_descriptor + && (!self.mode.is_edit() + || had_api_key + || had_explicit_api_key_auth + || has_models) + { + settings_obj.insert("auth".to_string(), json!("none")); + } + } else if matches!( + settings_obj.get("auth").and_then(Value::as_str), + Some("none") | Some("oauth") + ) { + settings_obj.remove("auth"); + } + } if !is_pi || pi_native_string_field_changed( original_pi_settings, @@ -734,20 +793,24 @@ impl ProviderAddFormState { } let api_value = self.opencode_npm_package.value.trim(); - if !is_pi + if !is_pi && !is_omp || pi_native_string_field_changed( original_pi_settings, "api", &self.opencode_npm_package.value, ) { - if is_pi && api_value.is_empty() { + if (is_pi || is_omp) && api_value.is_empty() { settings_obj.remove("api"); } else { settings_obj.insert( "api".to_string(), json!(if api_value.is_empty() { - OPENCLAW_DEFAULT_API_PROTOCOL + if is_omp { + crate::omp_config::OMP_DEFAULT_API_PROTOCOL + } else { + OPENCLAW_DEFAULT_API_PROTOCOL + } } else { api_value }), @@ -755,7 +818,7 @@ impl ProviderAddFormState { } } - if !is_pi { + if matches!(self.app_type, AppType::OpenClaw) { let mut headers_obj = match settings_obj.remove("headers") { Some(Value::Object(map)) => map, _ => serde_json::Map::new(), @@ -1503,7 +1566,7 @@ pub(crate) fn strip_common_config_from_settings( ) .map_err(|e| e.to_string())?; } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => {} + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Omp => {} AppType::Codex => { *settings_value = ProviderService::remove_common_config_from_settings_for_preview( app_type, diff --git a/src-tauri/src/cli/tui/form/provider_state.rs b/src-tauri/src/cli/tui/form/provider_state.rs index ceb4923cd..0e6ae7af3 100644 --- a/src-tauri/src/cli/tui/form/provider_state.rs +++ b/src-tauri/src/cli/tui/form/provider_state.rs @@ -121,7 +121,12 @@ impl ProviderAddFormState { let is_codex = matches!(app_type, AppType::Codex); let is_gemini = matches!(app_type, AppType::Gemini); let openclaw_api_default = match app_type { - AppType::OpenClaw | AppType::Pi => OPENCLAW_DEFAULT_API_PROTOCOL, + AppType::OpenClaw => OPENCLAW_DEFAULT_API_PROTOCOL, + AppType::Pi => OPENCLAW_DEFAULT_API_PROTOCOL, + // OMP's provider-level `api` is optional when every model carries + // its own protocol or discovery uses a proxy. Keep a blank field + // as the native default rather than materializing a new key. + AppType::Omp => "", _ => "@ai-sdk/openai-compatible", }; @@ -272,7 +277,7 @@ impl ProviderAddFormState { }; form.focus = FormFocus::Fields; form.extra = serde_json::to_value(provider).unwrap_or_else(|_| json!({})); - if matches!(app_type, AppType::Pi) { + if matches!(app_type, AppType::Pi | AppType::Omp) { form.initial_pi_settings_config = Some(provider.settings_config.clone()); } @@ -375,7 +380,11 @@ impl ProviderAddFormState { .ok() .and_then(|value| value.as_object().cloned()) .is_some_and(|env| !env.is_empty()), - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw + | AppType::Pi + | AppType::Omp => false, } } @@ -431,7 +440,11 @@ impl ProviderAddFormState { app_type, settings, &snippet, ) } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw + | AppType::Pi + | AppType::Omp => false, } } @@ -456,7 +469,7 @@ impl ProviderAddFormState { } pub fn initial_pi_settings_config(&self) -> Option { - (matches!(self.app_type, AppType::Pi) && self.mode.is_edit()) + (matches!(self.app_type, AppType::Pi | AppType::Omp) && self.mode.is_edit()) .then(|| self.initial_pi_settings_config.clone()) .flatten() } @@ -491,7 +504,7 @@ impl ProviderAddFormState { if matches!( self.app_type, - AppType::Hermes | AppType::OpenClaw | AppType::Pi + AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Omp ) && self.copy_source_id.is_none() { fields.insert(0, ProviderAddField::Id); @@ -573,7 +586,7 @@ impl ProviderAddFormState { fields.push(ProviderAddField::OpenClawUserAgent); fields.push(ProviderAddField::OpenClawModels); } - AppType::Pi => { + AppType::Pi | AppType::Omp => { fields.push(ProviderAddField::OpenClawApiProtocol); fields.push(ProviderAddField::OpenCodeApiKey); fields.push(ProviderAddField::OpenCodeBaseUrl); @@ -1193,7 +1206,8 @@ impl ProviderAddFormState { | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw - | AppType::Pi => {} + | AppType::Pi + | AppType::Omp => {} } Ok(()) } @@ -1425,7 +1439,8 @@ impl ProviderAddFormState { | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw - | AppType::Pi => false, + | AppType::Pi + | AppType::Omp => false, } } @@ -2233,9 +2248,16 @@ impl ProviderAddFormState { AppType::Gemini => self.gemini_base_url.value.clone(), AppType::Hermes => self.hermes_base_url.value.clone(), AppType::OpenCode | AppType::OpenClaw => self.opencode_base_url.value.clone(), - AppType::Pi => { + AppType::Pi | AppType::Omp => { let provider = self.to_provider_json_value(); - crate::pi_config::provider_base_url(&provider["settingsConfig"]).unwrap_or_default() + match self.app_type { + AppType::Pi => crate::pi_config::provider_base_url(&provider["settingsConfig"]), + AppType::Omp => { + crate::omp_config::provider_base_url(&provider["settingsConfig"]) + } + _ => unreachable!(), + } + .unwrap_or_default() } } } @@ -2248,7 +2270,7 @@ impl ProviderAddFormState { ); } - if matches!(self.app_type, AppType::Pi) { + if matches!(self.app_type, AppType::Pi | AppType::Omp) { let base_url = self.current_provider_base_url(); return ( Self::usage_query_comment_value(&self.opencode_api_key.value), @@ -2264,7 +2286,7 @@ impl ProviderAddFormState { AppType::OpenCode | AppType::OpenClaw => { (&self.opencode_api_key.value, &self.opencode_base_url.value) } - AppType::Pi => unreachable!("Pi credentials are resolved above"), + AppType::Pi | AppType::Omp => unreachable!("native credentials are resolved above"), }; ( Self::usage_query_comment_value(api_key), @@ -2518,7 +2540,8 @@ impl ProviderAddFormState { | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw - | AppType::Pi => false, + | AppType::Pi + | AppType::Omp => false, } } @@ -3005,6 +3028,69 @@ impl ProviderAddFormState { } } + /// Return the API protocol that should be used when discovering models + /// for an OMP provider draft. + /// + /// OMP permits the provider-level `api` to be omitted when each model + /// carries its own protocol. The compact TUI form normally displays a + /// default provider protocol, so inspect the original native settings + /// first and fall back to the selected (or sole) model's `api` value. + pub(crate) fn omp_model_fetch_api_protocol(&self) -> Option { + if !matches!(self.app_type, AppType::Omp) { + return None; + } + + let settings = self + .extra + .pointer("/settingsConfig") + .and_then(Value::as_object); + if let Some(api) = settings + .and_then(|settings| settings.get("api")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|api| !api.is_empty()) + { + return Some(api.to_string()); + } + + let models = if !self.openclaw_models.is_empty() { + &self.openclaw_models + } else { + settings + .and_then(|settings| settings.get("models")) + .and_then(Value::as_array) + .unwrap_or(&self.openclaw_models) + }; + let selected_id = self.openclaw_primary_model_id(); + if let Some(selected_id) = selected_id { + if let Some(api) = models + .iter() + .find(|model| model.get("id").and_then(Value::as_str) == Some(selected_id.as_str())) + .and_then(|model| model.get("api")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|api| !api.is_empty()) + { + return Some(api.to_string()); + } + } + + let mut unique = None; + for api in models + .iter() + .filter_map(|model| model.get("api").and_then(Value::as_str)) + .map(str::trim) + .filter(|api| !api.is_empty()) + { + match unique { + None => unique = Some(api), + Some(previous) if previous == api => {} + Some(_) => return None, + } + } + unique.map(str::to_string) + } + pub(crate) fn cycle_hermes_api_mode(&mut self) { let current = HERMES_API_MODES .iter() @@ -3036,6 +3122,10 @@ impl ProviderAddFormState { texts::tui_openclaw_models_summary(total) } + pub(crate) fn omp_models_summary(&self) -> String { + texts::tui_omp_models_summary(self.openclaw_models.len()) + } + pub(crate) fn codex_model_catalog_summary(&self) -> String { let count = self.codex_model_catalog.len(); if crate::cli::i18n::is_chinese() { @@ -3076,7 +3166,10 @@ impl ProviderAddFormState { } pub fn apply_openclaw_models_value(&mut self, models_value: Value) -> Result<(), String> { - if !matches!(self.app_type, AppType::OpenClaw | AppType::Pi) { + if !matches!( + self.app_type, + AppType::OpenClaw | AppType::Pi | AppType::Omp + ) { return Ok(()); } if !models_value.is_array() { diff --git a/src-tauri/src/cli/tui/form/provider_state_loading.rs b/src-tauri/src/cli/tui/form/provider_state_loading.rs index d1706cc4f..8c2e6002c 100644 --- a/src-tauri/src/cli/tui/form/provider_state_loading.rs +++ b/src-tauri/src/cli/tui/form/provider_state_loading.rs @@ -28,7 +28,7 @@ pub(super) fn populate_form_from_provider( AppType::OpenCode => populate_opencode_form(form, provider), AppType::Hermes => populate_hermes_form(form, provider), AppType::OpenClaw => populate_openclaw_form(form, provider), - AppType::Pi => populate_openclaw_form(form, provider), + AppType::Pi | AppType::Omp => populate_openclaw_form(form, provider), } form.is_full_url = form.supports_full_url_mode() && provider @@ -459,16 +459,17 @@ fn populate_openclaw_form(form: &mut ProviderAddFormState, provider: &Provider) .and_then(|value| value.as_str()) { form.opencode_npm_package.set(api); - } else if matches!(form.app_type, AppType::Pi) { + } else if matches!(form.app_type, AppType::Pi | AppType::Omp) { form.opencode_npm_package.set(""); } else { form.opencode_npm_package.set(OPENCLAW_DEFAULT_API_PROTOCOL); } - if provider - .settings_config - .get("headers") - .and_then(|value| value.as_object()) - .is_some_and(|headers| headers.contains_key("User-Agent")) + if matches!(form.app_type, AppType::OpenClaw) + && provider + .settings_config + .get("headers") + .and_then(|value| value.as_object()) + .is_some_and(|headers| headers.contains_key("User-Agent")) { form.openclaw_user_agent = true; } diff --git a/src-tauri/src/cli/tui/form/provider_templates.rs b/src-tauri/src/cli/tui/form/provider_templates.rs index 7945baae9..587536311 100644 --- a/src-tauri/src/cli/tui/form/provider_templates.rs +++ b/src-tauri/src/cli/tui/form/provider_templates.rs @@ -259,7 +259,7 @@ pub(super) fn provider_builtin_template_defs(app_type: &AppType) -> &'static [Pr AppType::OpenCode => &PROVIDER_TEMPLATE_DEFS_OPENCODE, AppType::Hermes => &PROVIDER_TEMPLATE_DEFS_HERMES, AppType::OpenClaw => &PROVIDER_TEMPLATE_DEFS_OPENCLAW, - AppType::Pi => &PROVIDER_TEMPLATE_DEFS_OPENCLAW, + AppType::Pi | AppType::Omp => &PROVIDER_TEMPLATE_DEFS_OPENCLAW, } } @@ -281,9 +281,12 @@ pub(super) fn provider_after_sponsor_template_defs( match app_type { AppType::Claude => &PROVIDER_TEMPLATE_DEFS_CLAUDE_AFTER_SPONSORS, AppType::Codex => &PROVIDER_TEMPLATE_DEFS_CODEX_AFTER_SPONSORS, - AppType::Gemini | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { - &[] - } + AppType::Gemini + | AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw + | AppType::Pi + | AppType::Omp => &[], } } @@ -848,7 +851,7 @@ impl ProviderAddFormState { self.opencode_model_original_id = None; } } - AppType::Pi => {} + AppType::Pi | AppType::Omp => {} } if matches!(self.app_type, AppType::Codex) { diff --git a/src-tauri/src/cli/tui/form/tests.rs b/src-tauri/src/cli/tui/form/tests.rs index 568f33b3a..1d63f3719 100644 --- a/src-tauri/src/cli/tui/form/tests.rs +++ b/src-tauri/src/cli/tui/form/tests.rs @@ -7990,6 +7990,112 @@ fn provider_add_form_pi_uses_native_api_default() { ); } +#[test] +fn provider_add_form_omp_preserves_omp_api_default_by_omission() { + let mut form = ProviderAddFormState::new(AppType::Omp); + form.name.set("Custom OMP"); + + assert!(form.opencode_npm_package.value.is_empty()); + assert!(form.to_provider_json_value()["settingsConfig"] + .get("api") + .is_none()); + assert!(form.to_provider_json_value()["settingsConfig"] + .get("name") + .is_none()); +} + +#[test] +fn provider_edit_form_omp_preserves_missing_provider_api() { + let provider = Provider::with_id( + "proxy-discovery".to_string(), + "Proxy discovery".to_string(), + json!({ + "apiKey": "secret", + "discovery": { "type": "proxy" }, + "models": [{ "id": "model", "api": "openai-completions" }] + }), + None, + ); + let mut form = ProviderAddFormState::from_provider(AppType::Omp, &provider); + form.name.set("Renamed proxy discovery"); + let settings = form.to_provider_json_value()["settingsConfig"].clone(); + assert!(settings.get("api").is_none()); + assert_eq!(settings["discovery"]["type"], "proxy"); +} + +#[test] +fn provider_edit_form_omp_preserves_implicit_api_key_auth() { + // OMP may resolve credentials from the environment or its native auth + // store when an override-only provider omits both apiKey and auth. Merely + // opening/saving the compact form must not convert that into auth: none. + let provider = Provider::with_id( + "env-backed".to_string(), + "Environment-backed".to_string(), + json!({ + "baseUrl": "https://api.example.test/v1", + "api": "openai-completions" + }), + None, + ); + let form = ProviderAddFormState::from_provider(AppType::Omp, &provider); + let settings = form.to_provider_json_value()["settingsConfig"].clone(); + assert!(settings.get("apiKey").is_none()); + assert!(settings.get("auth").is_none()); +} + +#[test] +fn provider_add_form_omp_blank_api_key_uses_keyless_auth() { + let mut form = ProviderAddFormState::new(AppType::Omp); + form.name.set("Local OMP"); + form.opencode_base_url.set("http://127.0.0.1:11434/v1"); + form.openclaw_models = vec![json!({ "id": "local-model" })]; + + let settings = form.to_provider_json_value()["settingsConfig"].clone(); + assert_eq!(settings["auth"], "none"); + assert!(settings.get("apiKey").is_none()); +} + +#[test] +fn provider_add_form_omp_model_fetch_uses_model_level_api_protocol() { + let provider = Provider::with_id( + "model-level-api".to_string(), + "Model-level API".to_string(), + json!({ + "baseUrl": "https://api.example.test/v1", + "apiKey": "secret", + "models": [{ + "id": "claude", + "api": "anthropic-messages" + }] + }), + None, + ); + let form = ProviderAddFormState::from_provider(AppType::Omp, &provider); + assert_eq!( + form.omp_model_fetch_api_protocol().as_deref(), + Some("anthropic-messages") + ); +} + +#[test] +fn provider_edit_form_omp_preserves_oauth_descriptor_when_key_is_blank() { + let provider = Provider::with_id( + "google-gemini-cli".to_string(), + "Gemini CLI".to_string(), + json!({ + "baseUrl": "https://generativelanguage.googleapis.com/v1beta", + "api": "google-gemini-cli", + "oauth": "google-gemini-cli", + "models": [{ "id": "gemini-2.5-pro" }] + }), + None, + ); + let form = ProviderAddFormState::from_provider(AppType::Omp, &provider); + let settings = form.to_provider_json_value()["settingsConfig"].clone(); + assert_eq!(settings["oauth"], "google-gemini-cli"); + assert!(settings.get("auth").is_none()); +} + #[test] fn provider_edit_form_pi_updates_only_a_native_name_that_followed_the_display_name() { let following = Provider::with_id( diff --git a/src-tauri/src/cli/tui/help.rs b/src-tauri/src/cli/tui/help.rs index bb4b2cb52..d8407930a 100644 --- a/src-tauri/src/cli/tui/help.rs +++ b/src-tauri/src/cli/tui/help.rs @@ -52,6 +52,9 @@ enum HelpTarget { Sessions, PiSystemPrompts, PiPromptTemplates, + OmpModels, + OmpRoles, + OmpSystemPrompts, FailoverQueue, PreferredEditor, SkillStorageLocation, @@ -162,6 +165,9 @@ fn current_help_target(app: &App) -> HelpTarget { super::route::Route::Sessions => return HelpTarget::Sessions, super::route::Route::PiSystemPrompts => return HelpTarget::PiSystemPrompts, super::route::Route::PiPromptTemplates => return HelpTarget::PiPromptTemplates, + super::route::Route::OmpModels => return HelpTarget::OmpModels, + super::route::Route::OmpRoles => return HelpTarget::OmpRoles, + super::route::Route::OmpSystemPrompts => return HelpTarget::OmpSystemPrompts, _ => {} } @@ -361,6 +367,27 @@ fn help_for_target(target: HelpTarget, app: &App, data: &UiData) -> HelpContent "This page manages Pi's native slash-command templates under prompts/*.md; the filename is the /template name. Press a to create, Enter to view, e to edit, r to rename, and d to delete.\nTemplate names must be safe single filenames without path separators or traversal. Save and rename check the revision captured on open and report a conflict when an external edit is detected.", ), ), + HelpTarget::OmpModels => HelpContent::new( + texts::menu_omp_models(), + help_lines( + "这里编辑 OMP 原生 models.yml。OMP 允许多个供应商同时存在,模型由 provider/model 选择器引用;e 打开完整 YAML 编辑器,Enter 查看模型,d 删除模型。保存会校验 YAML 和打开时的文件版本,不会覆盖外部修改。", + "This page edits OMP's native models.yml. OMP keeps multiple providers together and resolves models through provider/model selectors. Press e to edit the complete YAML, Enter to view a model, and d to delete one. Saves validate YAML and the revision captured on open, so external edits are never silently overwritten.", + ), + ), + HelpTarget::OmpRoles => HelpContent::new( + texts::menu_omp_roles(), + help_lines( + "这里管理 OMP 生效的 modelRoles。内置角色包括 default、smol、slow、vision、plan、designer、commit、tiny、task、advisor,也可以保留扩展角色。若 modelRoleStorage 为 project,角色会写入当前目录的 .omp/config.yml;角色值可带 :low/:medium/:high/:xhigh/:max 推理级别。按 e 编辑,Enter 查看,d 删除。", + "This page manages OMP's effective modelRoles. Built-in roles include default, smol, slow, vision, plan, designer, commit, tiny, task, and advisor; extensions may add more. With modelRoleStorage=project, role writes target the current directory's .omp/config.yml. Values may include :low/:medium/:high/:xhigh/:max thinking levels. Press e to edit, Enter to view, and d to delete.", + ), + ), + HelpTarget::OmpSystemPrompts => HelpContent::new( + texts::menu_omp_system_prompts(), + help_lines( + "这里按 OMP 的实际查找规则管理 SYSTEM.md、APPEND_SYSTEM.md 和 TITLE_SYSTEM.md:SYSTEM.md 使用最近非空祖先 .omp,并可跟随 PI_CODING_AGENT_DIR;APPEND_SYSTEM.md/TITLE_SYSTEM.md 使用当前目录下 .omp/.claude/.codex/.gemini 的通用 project-first 查找。表格会显示实际生效路径;SYSTEM.md 替换默认提示词,APPEND_SYSTEM.md 追加内容,TITLE_SYSTEM.md 定制自动标题。", + "This page follows OMP's effective lookup rules for SYSTEM.md, APPEND_SYSTEM.md, and TITLE_SYSTEM.md. SYSTEM.md uses the nearest non-empty ancestor .omp directory and may follow PI_CODING_AGENT_DIR; APPEND_SYSTEM.md and TITLE_SYSTEM.md use the generic cwd-only .omp/.claude/.codex/.gemini project-first lookup. The table shows the active path. SYSTEM.md replaces the default prompt, APPEND_SYSTEM.md appends text, and TITLE_SYSTEM.md customizes automatic titles.", + ), + ), HelpTarget::FailoverQueue => HelpContent::new( crate::t!("Failover Queue", "故障转移队列"), help_lines( @@ -893,13 +920,26 @@ fn provider_field_help(app_type: AppType, field: ProviderAddField) -> HelpConten "Controls whether requests include the default User-Agent. Some providers use it to identify the client.", ), ), - ProviderAddField::OpenClawModels => HelpContent::new( - texts::tui_label_openclaw_models(), - help_lines( - "编辑 OpenClaw/Pi 模型列表。Pi 表单中可按 f 从原生端点拉取模型。", - "Edits OpenClaw/Pi model entries. In a Pi form, press f to fetch from the native endpoint.", - ), - ), + ProviderAddField::OpenClawModels => { + let (title, body) = if matches!(app_type, AppType::Omp) { + ( + texts::tui_label_omp_models(), + help_lines( + "编辑 OMP 原生 models.yml 中该供应商的模型列表。聚焦此行时按 f 获取远程模型,选择后会加入当前表单;按 Enter 可手动编辑模型 JSON。保存供应商后才会写入 models.yml。", + "Edits this provider's OMP model entries. With this row focused, press f to fetch remote models and add a selection; press Enter to edit the model JSON manually. The models.yml file is updated when you save the provider.", + ), + ) + } else { + ( + texts::tui_label_openclaw_models(), + help_lines( + "编辑 OpenClaw/Pi 模型列表。Pi 表单中可按 f 从原生端点拉取模型。", + "Edits OpenClaw/Pi model entries. In a Pi form, press f to fetch from the native endpoint.", + ), + ) + }; + HelpContent::new(title, body) + } ProviderAddField::OpenCodeModelContextLimit => HelpContent::new( texts::tui_label_context_limit(), help_lines( diff --git a/src-tauri/src/cli/tui/keymap.rs b/src-tauri/src/cli/tui/keymap.rs index f4803aa67..e48d5500b 100644 --- a/src-tauri/src/cli/tui/keymap.rs +++ b/src-tauri/src/cli/tui/keymap.rs @@ -270,13 +270,17 @@ pub(crate) mod providers { } fn set_default_shown(app: &App, data: &UiData) -> bool { - matches!(app.app_type, AppType::OpenClaw | AppType::Hermes) - && selected_row(app, data).is_some_and(|row| row.is_in_config) + matches!( + app.app_type, + AppType::OpenClaw | AppType::Hermes | AppType::Omp + ) && selected_row(app, data).is_some_and(|row| row.is_in_config) } fn set_default_label(app: &App, _data: &UiData) -> &'static str { if matches!(app.app_type, AppType::Hermes) { texts::tui_key_enable() + } else if matches!(app.app_type, AppType::Omp) { + texts::tui_key_set_default_model() } else { texts::tui_key_set_default() } @@ -441,6 +445,167 @@ pub(crate) mod prompts { } } +pub(crate) mod omp_models { + use crossterm::event::KeyCode; + + use super::Binding; + use crate::cli::i18n::texts; + use crate::cli::tui::app::App; + use crate::cli::tui::data::UiData; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(crate) enum Intent { + View, + Edit, + Delete, + } + + pub(crate) const BINDINGS: &[Binding] = &[ + Binding { + display: "Enter", + keys: &[KeyCode::Enter, KeyCode::Char('v')], + intent: Intent::View, + label: |_, _| texts::tui_key_view(), + shown: any_visible, + }, + Binding { + // `a` remains a backwards-compatible alias for the full-file + // editor, while `e` is the canonical label shown to users. + display: "e", + keys: &[KeyCode::Char('e'), KeyCode::Char('a')], + intent: Intent::Edit, + label: |_, _| texts::tui_key_edit(), + shown: |_, _| true, + }, + Binding { + display: "d", + keys: &[KeyCode::Char('d')], + intent: Intent::Delete, + label: |_, _| texts::tui_key_delete(), + shown: any_visible, + }, + ]; + + pub(crate) fn intent_for(key: KeyCode) -> Option { + super::intent_for(BINDINGS, key) + } + + pub(crate) fn key_bar_items(app: &App, data: &UiData) -> Vec<(&'static str, &'static str)> { + super::key_bar_items(BINDINGS, app, data) + } + + fn any_visible(app: &App, data: &UiData) -> bool { + data.omp.models.get(app.omp_model_idx).is_some() + } +} + +pub(crate) mod omp_roles { + use crossterm::event::KeyCode; + + use super::Binding; + use crate::cli::i18n::texts; + use crate::cli::tui::app::App; + use crate::cli::tui::data::UiData; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(crate) enum Intent { + View, + Edit, + Delete, + } + + pub(crate) const BINDINGS: &[Binding] = &[ + Binding { + display: "Enter", + keys: &[KeyCode::Enter, KeyCode::Char('v')], + intent: Intent::View, + label: |_, _| texts::tui_key_view(), + shown: any_visible, + }, + Binding { + display: "e", + keys: &[KeyCode::Char('e'), KeyCode::Char('a')], + intent: Intent::Edit, + label: |_, _| texts::tui_key_edit(), + shown: |_, _| true, + }, + Binding { + display: "d", + keys: &[KeyCode::Char('d')], + intent: Intent::Delete, + label: |_, _| texts::tui_key_delete(), + shown: any_visible, + }, + ]; + + pub(crate) fn intent_for(key: KeyCode) -> Option { + super::intent_for(BINDINGS, key) + } + + pub(crate) fn key_bar_items(app: &App, data: &UiData) -> Vec<(&'static str, &'static str)> { + super::key_bar_items(BINDINGS, app, data) + } + + fn any_visible(app: &App, data: &UiData) -> bool { + data.omp.model_roles.iter().nth(app.omp_role_idx).is_some() + } +} + +pub(crate) mod omp_system_prompts { + use crossterm::event::KeyCode; + + use super::Binding; + use crate::cli::i18n::texts; + use crate::cli::tui::app::App; + use crate::cli::tui::data::UiData; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(crate) enum Intent { + View, + Edit, + Delete, + } + + pub(crate) const BINDINGS: &[Binding] = &[ + Binding { + display: "Enter", + keys: &[KeyCode::Enter, KeyCode::Char('v')], + intent: Intent::View, + label: |_, _| texts::tui_key_view(), + shown: any_visible, + }, + Binding { + display: "e", + keys: &[KeyCode::Char('e')], + intent: Intent::Edit, + label: |_, _| texts::tui_key_edit(), + shown: any_visible, + }, + Binding { + display: "d", + keys: &[KeyCode::Char('d')], + intent: Intent::Delete, + label: |_, _| texts::tui_key_delete(), + shown: any_visible, + }, + ]; + + pub(crate) fn intent_for(key: KeyCode) -> Option { + super::intent_for(BINDINGS, key) + } + + pub(crate) fn key_bar_items(app: &App, data: &UiData) -> Vec<(&'static str, &'static str)> { + super::key_bar_items(BINDINGS, app, data) + } + + fn any_visible(app: &App, data: &UiData) -> bool { + data.pi_prompts + .system_files + .get(app.omp_system_prompt_idx) + .is_some() + } +} + pub(crate) mod skills_installed { use crossterm::event::KeyCode; diff --git a/src-tauri/src/cli/tui/mod.rs b/src-tauri/src/cli/tui/mod.rs index 1894e473f..152b79b37 100644 --- a/src-tauri/src/cli/tui/mod.rs +++ b/src-tauri/src/cli/tui/mod.rs @@ -42,12 +42,15 @@ use runtime_skills::{ }; pub(crate) use runtime_systems::build_stream_check_result_lines; #[cfg(test)] +pub(crate) use runtime_systems::fetch_provider_models_for_tui; +#[cfg(test)] use runtime_systems::{ - apply_webdav_jianguoyun_quick_setup, build_model_fetch_candidate_urls, drain_latest_webdav_req, + apply_webdav_jianguoyun_quick_setup, build_model_fetch_candidate_urls, + build_model_fetch_candidate_urls_with_inject_v1, drain_latest_webdav_req, model_fetch_strategy_for_field, parse_model_ids_from_response, update_webdav_last_error_with, UpdateMsg, WebDavReqKind, }; -pub(crate) use runtime_systems::{fetch_provider_models_for_tui, ModelFetchStrategy}; +pub(crate) use runtime_systems::{fetch_provider_models_for_tui_with_options, ModelFetchStrategy}; use runtime_systems::{ handle_codex_history_msg, handle_local_env_msg, handle_managed_auth_msg, handle_model_fetch_msg, handle_proxy_msg, handle_quota_msg, handle_session_msg, @@ -2308,6 +2311,9 @@ fn cache_invalidation_for_action(action: &Action) -> CacheInvalidation { | Action::PromptSave { .. } | Action::PromptDelete { .. } | Action::PiSystemPromptDelete { .. } + | Action::OmpModelDelete { .. } + | Action::OmpRoleDelete { .. } + | Action::OmpSystemPromptDelete { .. } | Action::PiPromptTemplateDelete { .. } | Action::PiPromptTemplateRename { .. } | Action::ConfigBackup { .. } diff --git a/src-tauri/src/cli/tui/route.rs b/src-tauri/src/cli/tui/route.rs index 6a91aa9ce..8aa1f0351 100644 --- a/src-tauri/src/cli/tui/route.rs +++ b/src-tauri/src/cli/tui/route.rs @@ -13,6 +13,9 @@ pub enum Route { Prompts, PiSystemPrompts, PiPromptTemplates, + OmpModels, + OmpRoles, + OmpSystemPrompts, HermesMemory, Config, ConfigOpenClawWorkspace, @@ -43,6 +46,9 @@ pub enum NavItem { Prompts, PiSystemPrompts, PiPromptTemplates, + OmpModels, + OmpRoles, + OmpSystemPrompts, HermesMemory, Config, Skills, @@ -108,11 +114,24 @@ impl NavItem { NavItem::Exit, ]; + pub const OMP_ALL: [NavItem; 9] = [ + NavItem::Main, + NavItem::Providers, + NavItem::OmpModels, + NavItem::OmpRoles, + NavItem::OmpSystemPrompts, + NavItem::Skills, + NavItem::Usage, + NavItem::Settings, + NavItem::Exit, + ]; + pub fn all_for_app(app_type: &AppType) -> &'static [NavItem] { match app_type { AppType::OpenClaw => &Self::OPENCLAW_ALL, AppType::Hermes => &Self::HERMES_ALL, AppType::Pi => &Self::PI_ALL, + AppType::Omp => &Self::OMP_ALL, _ => &Self::ALL, } } @@ -127,6 +146,9 @@ impl NavItem { NavItem::Prompts => Some(Route::Prompts), NavItem::PiSystemPrompts => Some(Route::PiSystemPrompts), NavItem::PiPromptTemplates => Some(Route::PiPromptTemplates), + NavItem::OmpModels => Some(Route::OmpModels), + NavItem::OmpRoles => Some(Route::OmpRoles), + NavItem::OmpSystemPrompts => Some(Route::OmpSystemPrompts), NavItem::HermesMemory => Some(Route::HermesMemory), NavItem::Config => Some(Route::Config), NavItem::Skills => Some(Route::Skills), @@ -208,6 +230,7 @@ mod tests { NavItem::OPENCLAW_ALL.as_slice(), NavItem::HERMES_ALL.as_slice(), NavItem::PI_ALL.as_slice(), + NavItem::OMP_ALL.as_slice(), ] { assert!(nav_items .iter() @@ -247,4 +270,12 @@ mod tests { .iter() .any(|item| matches!(item, NavItem::Config))); } + + #[test] + fn omp_nav_does_not_expose_pi_native_prompt_pages() { + assert!(!NavItem::OMP_ALL.iter().any(|item| matches!( + item, + NavItem::Prompts | NavItem::PiSystemPrompts | NavItem::PiPromptTemplates + ))); + } } diff --git a/src-tauri/src/cli/tui/runtime_actions/claude_temp_launch.rs b/src-tauri/src/cli/tui/runtime_actions/claude_temp_launch.rs index f9c76869b..e1d3a7712 100644 --- a/src-tauri/src/cli/tui/runtime_actions/claude_temp_launch.rs +++ b/src-tauri/src/cli/tui/runtime_actions/claude_temp_launch.rs @@ -139,6 +139,7 @@ mod tests { current_id: current_id.to_string(), rows, live_ids: Default::default(), + disabled_ids: Default::default(), pi_membership_unknown: false, loading: false, }, diff --git a/src-tauri/src/cli/tui/runtime_actions/codex_temp_launch.rs b/src-tauri/src/cli/tui/runtime_actions/codex_temp_launch.rs index 52142a529..5fde1dbc2 100644 --- a/src-tauri/src/cli/tui/runtime_actions/codex_temp_launch.rs +++ b/src-tauri/src/cli/tui/runtime_actions/codex_temp_launch.rs @@ -138,6 +138,7 @@ mod tests { current_id: current_id.to_string(), rows, live_ids: Default::default(), + disabled_ids: Default::default(), pi_membership_unknown: false, loading: false, }, diff --git a/src-tauri/src/cli/tui/runtime_actions/editor.rs b/src-tauri/src/cli/tui/runtime_actions/editor.rs index 188481d56..195096cbe 100644 --- a/src-tauri/src/cli/tui/runtime_actions/editor.rs +++ b/src-tauri/src/cli/tui/runtime_actions/editor.rs @@ -54,16 +54,43 @@ fn validate_provider_submit( } } - if matches!(app_type, AppType::Pi) { + if matches!(app_type, AppType::Pi | AppType::Omp) { let settings = &provider.settings_config; - let request_url = crate::pi_config::provider_base_url(settings).ok(); + + // OMP has a richer native schema than Pi: override-only and discovery + // providers may legitimately omit a provider-level URL/model list, + // while model-level URLs are resolved by the native validator. Defer + // add-form validation to that schema instead of imposing Pi's + // provider-level URL + model requirements here. + if matches!(app_type, AppType::Omp) { + if is_edit { + return None; + } + let provider_key = if provider.id.trim().is_empty() { + "omp-provider" + } else { + provider.id.as_str() + }; + if crate::omp_config::validate_provider_for_live_write(provider_key, settings).is_err() + { + return Some(texts::tui_toast_provider_add_missing_fields()); + } + return None; + } + + let request_url = match app_type { + AppType::Pi => crate::pi_config::provider_base_url(settings).ok(), + _ => None, + }; let unchanged_legacy_url = is_edit - && expected_pi_settings - .and_then(|expected| crate::pi_config::provider_base_url(expected).ok()) - == request_url; - let valid_base_url = request_url - .as_deref() - .is_some_and(crate::pi_config::is_valid_request_url); + && expected_pi_settings.and_then(|expected| match app_type { + AppType::Pi => crate::pi_config::provider_base_url(expected).ok(), + _ => None, + }) == request_url; + let valid_base_url = request_url.as_deref().is_some_and(|url| match app_type { + AppType::Pi => crate::pi_config::is_valid_request_url(url), + _ => false, + }); let missing_existing_url = is_edit && request_url.is_none(); if !valid_base_url && !unchanged_legacy_url && !missing_existing_url { return Some(texts::base_url_empty_error()); @@ -83,9 +110,16 @@ fn validate_provider_submit( .is_some_and(|id| !id.trim().is_empty()) }) }); - if api.is_none_or(|value| !crate::openclaw_config::OPENCLAW_API_PROTOCOLS.contains(&value)) - || !has_model - { + let valid_api = match app_type { + AppType::Pi => api.is_some_and(|value| { + crate::openclaw_config::OPENCLAW_API_PROTOCOLS.contains(&value) + }), + AppType::Omp => { + api.is_some_and(|value| crate::omp_config::OMP_API_PROTOCOLS.contains(&value)) + } + _ => false, + }; + if !valid_api || !has_model { return Some(texts::tui_toast_provider_add_missing_fields()); } } @@ -299,6 +333,17 @@ pub(super) fn submit( kind, expected_revision, } => submit_pi_system_prompt(ctx, kind, expected_revision, content), + EditorSubmit::OmpModels { expected_revision } => { + submit_omp_models(ctx, expected_revision, content) + } + EditorSubmit::OmpConfig { + path, + expected_revision, + } => submit_omp_config(ctx, path, expected_revision, content), + EditorSubmit::OmpSystemPrompt { + kind, + expected_revision, + } => submit_omp_system_prompt(ctx, kind, expected_revision, content), EditorSubmit::PiPromptTemplate { slug, original_slug, @@ -365,6 +410,55 @@ fn submit_pi_system_prompt( Ok(()) } +fn submit_omp_models( + ctx: &mut RuntimeActionContext<'_>, + expected_revision: String, + content: String, +) -> Result<(), AppError> { + crate::omp_config::replace_omp_models_yaml(&content, &expected_revision)?; + ctx.app.editor = None; + ctx.app + .push_toast(crate::t!("Models saved", "模型已保存"), ToastKind::Success); + *ctx.data = UiData::load(&ctx.app.app_type)?; + Ok(()) +} + +fn submit_omp_config( + ctx: &mut RuntimeActionContext<'_>, + path: std::path::PathBuf, + expected_revision: String, + content: String, +) -> Result<(), AppError> { + crate::omp_config::replace_omp_config_yaml_at(&path, &content, &expected_revision)?; + ctx.app.editor = None; + ctx.app.push_toast( + crate::t!("Configuration saved", "配置已保存"), + ToastKind::Success, + ); + *ctx.data = UiData::load(&ctx.app.app_type)?; + Ok(()) +} + +fn submit_omp_system_prompt( + ctx: &mut RuntimeActionContext<'_>, + kind: crate::services::pi_prompt_files::PiPromptFileKind, + expected_revision: String, + content: String, +) -> Result<(), AppError> { + crate::services::pi_prompt_files::OmpPromptFileService::replace( + kind, + &expected_revision, + &content, + )?; + ctx.app.editor = None; + ctx.app.push_toast( + crate::t!("System prompt saved", "系统提示词已保存"), + ToastKind::Success, + ); + *ctx.data = UiData::load(&ctx.app.app_type)?; + Ok(()) +} + fn submit_pi_prompt_template( ctx: &mut RuntimeActionContext<'_>, slug: String, @@ -1017,13 +1111,22 @@ fn submit_provider_add( } let state = load_state()?; - let existing_ids = { + let mut existing_ids = { let config = state.config.read().map_err(AppError::from)?; config .get_manager(&ctx.app.app_type) .map(|manager| manager.providers.keys().cloned().collect::>()) .unwrap_or_default() }; + // Additive native registries reserve IDs independently of the CC Switch + // database. Include OMP's live keys when generating an ID for an + // interactive add/copy form, while keeping a malformed native file + // non-blocking (the service will surface a precise write error if needed). + if matches!(ctx.app.app_type, AppType::Omp) { + if let Ok(native) = crate::omp_config::read_omp_native_providers() { + existing_ids.extend(native.into_keys()); + } + } let Some(provider_id) = crate::cli::tui::form::resolve_provider_id_for_submit( &ctx.app.app_type, &provider.name, diff --git a/src-tauri/src/cli/tui/runtime_actions/helpers.rs b/src-tauri/src/cli/tui/runtime_actions/helpers.rs index 679300648..3331d7f30 100644 --- a/src-tauri/src/cli/tui/runtime_actions/helpers.rs +++ b/src-tauri/src/cli/tui/runtime_actions/helpers.rs @@ -68,6 +68,7 @@ pub(crate) fn app_display_name(app_type: &AppType) -> &'static str { AppType::Hermes => "Hermes", AppType::OpenClaw => "OpenClaw", AppType::Pi => "Pi", + AppType::Omp => "OMP", } } diff --git a/src-tauri/src/cli/tui/runtime_actions/mod.rs b/src-tauri/src/cli/tui/runtime_actions/mod.rs index 898f8936e..d740e8127 100644 --- a/src-tauri/src/cli/tui/runtime_actions/mod.rs +++ b/src-tauri/src/cli/tui/runtime_actions/mod.rs @@ -18,6 +18,7 @@ mod config; mod editor; mod helpers; mod mcp; +mod omp; mod pricing; mod prompts; mod providers; @@ -93,6 +94,25 @@ fn normalize_route_for_app(app_type: &AppType, route: &super::route::Route) -> s | super::route::Route::SettingsManagedAccounts => route.clone(), _ => super::route::Route::Main, }, + AppType::Omp => match route { + super::route::Route::Main + | super::route::Route::Providers + | super::route::Route::Usage + | super::route::Route::UsageLogs + | super::route::Route::UsageLogDetail { .. } + | super::route::Route::Pricing + | super::route::Route::OmpModels + | super::route::Route::OmpRoles + | super::route::Route::OmpSystemPrompts + | super::route::Route::Skills + | super::route::Route::SkillsDiscover + | super::route::Route::SkillsRepos + | super::route::Route::SkillDetail { .. } + | super::route::Route::Settings + | super::route::Route::SettingsProxy + | super::route::Route::SettingsManagedAccounts => route.clone(), + _ => super::route::Route::Main, + }, _ => match route { super::route::Route::ConfigOpenClawWorkspace | super::route::Route::ConfigOpenClawDailyMemory @@ -935,6 +955,7 @@ pub(crate) fn handle_action( custom_user_agent, api_protocol, request_headers, + discovery_timeout_ms, codex_oauth, codex_oauth_account_id, field, @@ -947,6 +968,7 @@ pub(crate) fn handle_action( custom_user_agent, api_protocol, request_headers, + discovery_timeout_ms, codex_oauth, codex_oauth_account_id, field, @@ -978,6 +1000,19 @@ pub(crate) fn handle_action( kind, expected_revision, } => prompts::delete_pi_system_prompt(&mut ctx, kind, expected_revision), + Action::OmpModelDelete { + provider_id, + model_id, + expected_revision, + } => omp::delete_model(&mut ctx, provider_id, model_id, expected_revision), + Action::OmpRoleDelete { + role, + expected_revision, + } => omp::delete_role(&mut ctx, role, expected_revision), + Action::OmpSystemPromptDelete { + kind, + expected_revision, + } => omp::delete_system_prompt(&mut ctx, kind, expected_revision), Action::PiPromptTemplateDelete { slug, expected_revision, @@ -1868,6 +1903,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + omp: false, }) .expect("save initial visible apps"); @@ -1879,6 +1915,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + omp: false, }; let mut app = App::new(Some(AppType::OpenClaw)); app.route = Route::ConfigOpenClawTools; @@ -1940,6 +1977,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + omp: false, }; crate::settings::set_visible_apps(initial_visible_apps.clone()) .expect("save initial visible apps"); @@ -1963,6 +2001,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + omp: false, }, }, ) @@ -1992,6 +2031,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + omp: false, }) .expect("save initial visible apps"); write_invalid_legacy_config(temp_home.path()); @@ -2004,6 +2044,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + omp: false, }; let mut app = App::new(Some(AppType::Claude)); let mut data = UiData::default(); @@ -2042,6 +2083,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + omp: false, }; crate::settings::set_visible_apps(initial_visible_apps.clone()) .expect("save initial visible apps"); @@ -2062,6 +2104,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + omp: false, }, }, ) @@ -2092,6 +2135,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + omp: false, }; settings.visible_apps_settings.mode = crate::settings::VisibleAppsMode::Auto; settings.visible_apps_settings.auto_prompt_decided = true; @@ -2105,6 +2149,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + omp: false, }; let mut app = App::new(Some(AppType::Claude)); let mut data = UiData::default(); @@ -2152,6 +2197,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + omp: false, }; let mut settings = crate::settings::get_settings(); settings.visible_apps = initial_visible_apps.clone(); @@ -2179,6 +2225,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + omp: false, }, selected: 5, }, diff --git a/src-tauri/src/cli/tui/runtime_actions/omp.rs b/src-tauri/src/cli/tui/runtime_actions/omp.rs new file mode 100644 index 000000000..ab3719c5f --- /dev/null +++ b/src-tauri/src/cli/tui/runtime_actions/omp.rs @@ -0,0 +1,47 @@ +use crate::error::AppError; + +use super::super::app::ToastKind; +use super::super::data::UiData; +use super::RuntimeActionContext; + +pub(super) fn delete_model( + ctx: &mut RuntimeActionContext<'_>, + provider_id: String, + model_id: String, + expected_revision: String, +) -> Result<(), AppError> { + crate::omp_config::remove_omp_model_checked(&provider_id, &model_id, &expected_revision)?; + ctx.app + .push_toast(crate::t!("Model deleted", "模型已删除"), ToastKind::Success); + *ctx.data = UiData::load(&ctx.app.app_type)?; + ctx.app.clamp_selections(ctx.data); + Ok(()) +} + +pub(super) fn delete_role( + ctx: &mut RuntimeActionContext<'_>, + role: String, + expected_revision: String, +) -> Result<(), AppError> { + crate::omp_config::set_omp_model_role(&role, None, Some(&expected_revision))?; + ctx.app + .push_toast(crate::t!("Role deleted", "角色已删除"), ToastKind::Success); + *ctx.data = UiData::load(&ctx.app.app_type)?; + ctx.app.clamp_selections(ctx.data); + Ok(()) +} + +pub(super) fn delete_system_prompt( + ctx: &mut RuntimeActionContext<'_>, + kind: crate::services::pi_prompt_files::PiPromptFileKind, + expected_revision: String, +) -> Result<(), AppError> { + crate::services::pi_prompt_files::OmpPromptFileService::delete(kind, &expected_revision)?; + ctx.app.push_toast( + crate::t!("System prompt deleted", "系统提示词已删除"), + ToastKind::Success, + ); + *ctx.data = UiData::load(&ctx.app.app_type)?; + ctx.app.clamp_selections(ctx.data); + Ok(()) +} diff --git a/src-tauri/src/cli/tui/runtime_actions/providers.rs b/src-tauri/src/cli/tui/runtime_actions/providers.rs index 892b64417..38b209a47 100644 --- a/src-tauri/src/cli/tui/runtime_actions/providers.rs +++ b/src-tauri/src/cli/tui/runtime_actions/providers.rs @@ -89,6 +89,9 @@ fn refresh_provider_data_after_write_with_config( state.reload_config_snapshot_from_db()?; ctx.data .refresh_current_app_provider_data(state, &app_type)?; + if matches!(app_type, crate::app_config::AppType::Omp) { + ctx.data.refresh_current_app_omp_data(); + } if refresh_config { ctx.data.refresh_current_app_config_data(state, &app_type)?; } @@ -428,7 +431,8 @@ pub(super) fn remove_from_config( } crate::app_config::AppType::OpenCode | crate::app_config::AppType::Hermes - | crate::app_config::AppType::Pi => { + | crate::app_config::AppType::Pi + | crate::app_config::AppType::Omp => { let state = load_state()?; ProviderService::remove_from_live_config(&state, ctx.app.app_type.clone(), &id)?; ctx.app.push_toast( @@ -445,11 +449,25 @@ pub(super) fn remove_from_config( pub(super) fn set_default_model( ctx: &mut RuntimeActionContext<'_>, provider_id: String, - _model_id: String, + model_id: String, ) -> Result<(), AppError> { let state = load_state()?; - let default = - ProviderService::set_default_model(&state, ctx.app.app_type.clone(), &provider_id, None)?; + // OMP's provider row is backed by its native model catalog, so the row's + // first model is the concrete selector that must be written to + // `modelRoles.default`. OpenClaw keeps resolving its live primary model + // here; the snapshot model id can be stale while another process edits the + // live config, so preserve that existing behavior for OpenClaw. + let selected_model = if matches!(ctx.app.app_type, crate::app_config::AppType::Omp) { + (!model_id.trim().is_empty()).then_some(model_id.as_str()) + } else { + None + }; + let default = ProviderService::set_default_model( + &state, + ctx.app.app_type.clone(), + &provider_id, + selected_model, + )?; let message = if matches!(ctx.app.app_type, crate::app_config::AppType::Hermes) { texts::tui_toast_provider_enabled(&provider_id) } else { @@ -530,6 +548,7 @@ pub(super) fn model_fetch( custom_user_agent: Option, api_protocol: Option, request_headers: Option>, + discovery_timeout_ms: Option, codex_oauth: bool, codex_oauth_account_id: Option, field: ProviderAddField, @@ -570,6 +589,7 @@ pub(super) fn model_fetch( custom_user_agent, api_protocol, request_headers, + discovery_timeout_ms, codex_oauth, codex_oauth_account_id, field, diff --git a/src-tauri/src/cli/tui/runtime_systems/mod.rs b/src-tauri/src/cli/tui/runtime_systems/mod.rs index 4ed048c1b..465249814 100644 --- a/src-tauri/src/cli/tui/runtime_systems/mod.rs +++ b/src-tauri/src/cli/tui/runtime_systems/mod.rs @@ -12,13 +12,15 @@ pub(crate) use handlers::{ handle_webdav_msg, ProxyMsgEffect, }; #[cfg(test)] +pub(crate) use types::fetch_provider_models_for_tui; +#[cfg(test)] pub(crate) use types::{ - build_model_fetch_candidate_urls, model_fetch_strategy_for_field, - parse_model_ids_from_response, ManagedAuthMsg, ManagedSessionOutcome, ProxyMsg, QuotaMsg, - UpdateMsg, + build_model_fetch_candidate_urls, build_model_fetch_candidate_urls_with_inject_v1, + model_fetch_strategy_for_field, parse_model_ids_from_response, ManagedAuthMsg, + ManagedSessionOutcome, ProxyMsg, QuotaMsg, UpdateMsg, }; pub(crate) use types::{ - build_stream_check_result_lines, fetch_provider_models_for_tui, ModelFetchStrategy, + build_stream_check_result_lines, fetch_provider_models_for_tui_with_options, ModelFetchStrategy, }; pub(crate) use types::{ next_model_fetch_request_id, AppDataLoadKind, AppDataMsg, AppDataReq, CodexHistoryReq, diff --git a/src-tauri/src/cli/tui/runtime_systems/types.rs b/src-tauri/src/cli/tui/runtime_systems/types.rs index 65f94bbbb..4fa16cd3d 100644 --- a/src-tauri/src/cli/tui/runtime_systems/types.rs +++ b/src-tauri/src/cli/tui/runtime_systems/types.rs @@ -812,6 +812,7 @@ pub(crate) enum ModelFetchReq { custom_user_agent: Option, api_protocol: Option, request_headers: Option>, + discovery_timeout_ms: Option, codex_oauth: bool, codex_oauth_account_id: Option, field: ProviderAddField, @@ -836,9 +837,19 @@ pub(crate) struct ModelFetchSystem { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ModelFetchStrategy { + /// Fetch a models endpoint without adding an authentication header. + /// OMP uses this for providers configured with `auth: none`. + Anonymous, + /// Fetch an OMP Ollama discovery registry (`GET /api/tags`) without + /// imposing OpenAI-compatible authentication or URL suffixes. + Ollama, + /// Fetch an OMP llama.cpp discovery registry (`GET /models`) from the + /// native server root, stripping a configured `/v1` request suffix. + LlamaCpp, Bearer, Anthropic, GoogleApiKey, + AzureApiKey, } pub(crate) fn model_fetch_strategy_for_field(field: ProviderAddField) -> ModelFetchStrategy { @@ -853,12 +864,36 @@ pub(crate) fn build_model_fetch_candidate_urls( base_url: &str, strategy: ModelFetchStrategy, is_full_url: bool, +) -> Vec { + build_model_fetch_candidate_urls_with_inject_v1(base_url, strategy, is_full_url, None) +} + +/// Build model-list endpoints, optionally honoring OMP's +/// `discovery.injectV1` setting for `openai-models-list` providers. +pub(crate) fn build_model_fetch_candidate_urls_with_inject_v1( + base_url: &str, + strategy: ModelFetchStrategy, + is_full_url: bool, + inject_v1: Option, ) -> Vec { let base = base_url.trim().trim_end_matches('/'); if base.is_empty() { return Vec::new(); } + // OMP ignores query strings while constructing discovery endpoints. Do + // the same here so a configured `baseUrl?token=...` does not become the + // malformed path `...?token=.../models`. + let base_without_query = match url::Url::parse(base) { + Ok(mut parsed) => { + parsed.set_query(None); + parsed.set_fragment(None); + parsed.to_string().trim_end_matches('/').to_string() + } + Err(_) => base.to_string(), + }; + let base = base_without_query.as_str(); + if is_full_url { let mut urls = Vec::new(); if let Some(index) = base.find("/v1/") { @@ -887,7 +922,25 @@ pub(crate) fn build_model_fetch_candidate_urls( }; let mut urls: Vec = Vec::new(); + // OMP's openai-models-list discovery defaults to `/v1/models`; callers + // can explicitly disable injection to probe the bare `/models` route. + if let Some(inject_v1) = inject_v1 { + if inject_v1 { + return vec![append_versioned_models.unwrap_or(append_models)]; + } + return vec![append_models]; + } + match strategy { + ModelFetchStrategy::Ollama => { + let root = crate::omp_config::normalize_ollama_base_url(base) + .unwrap_or_else(|| base.to_string()); + return vec![format!("{root}/api/tags")]; + } + ModelFetchStrategy::LlamaCpp => { + let root = strip_trailing_v1(base); + return vec![format!("{root}/models")]; + } ModelFetchStrategy::Anthropic => { if let Some(versioned) = append_versioned_models.as_ref() { urls.push(versioned.clone()); @@ -905,7 +958,10 @@ pub(crate) fn build_model_fetch_candidate_urls( urls.push(append_models); } } - ModelFetchStrategy::Bearer | ModelFetchStrategy::GoogleApiKey => { + ModelFetchStrategy::Anonymous + | ModelFetchStrategy::Bearer + | ModelFetchStrategy::GoogleApiKey + | ModelFetchStrategy::AzureApiKey => { urls.push(append_models); if let Some(v1) = append_versioned_models.as_ref() { urls.push(v1.clone()); @@ -918,6 +974,17 @@ pub(crate) fn build_model_fetch_candidate_urls( urls } +fn strip_trailing_v1(base: &str) -> String { + let trimmed = base.trim_end_matches('/'); + if trimmed.len() >= 3 && trimmed[trimmed.len() - 3..].eq_ignore_ascii_case("/v1") { + let root = trimmed[..trimmed.len() - 3].trim_end_matches('/'); + if !root.is_empty() { + return root.to_string(); + } + } + trimmed.to_string() +} + fn strip_compat_suffix(base: &str) -> Option<&str> { let lower = base.to_ascii_lowercase(); KNOWN_COMPAT_SUFFIXES.iter().find_map(|suffix| { @@ -930,34 +997,52 @@ fn strip_compat_suffix(base: &str) -> Option<&str> { pub(crate) fn parse_model_ids_from_response(payload: &Value) -> Vec { let mut out: Vec = Vec::new(); - if let Some(data) = payload.get("data").and_then(|v| v.as_array()) { - for item in data { - if let Some(id) = item.get("id").and_then(|v| v.as_str()) { - out.push(id.to_string()); - } - } - } - - if out.is_empty() { - if let Some(models) = payload.get("models").and_then(|v| v.as_array()) { - for item in models { - if let Some(name) = item.get("name").and_then(|v| v.as_str()) { - out.push(name.strip_prefix("models/").unwrap_or(name).to_string()); + fn collect(value: &Value, in_collection: bool, out: &mut Vec) { + match value { + Value::Array(items) => { + for item in items { + collect(item, true, out); } } - } - } + Value::Object(object) => { + // OpenAI-style entries use `id`; Gemini/Ollama-style entries + // use `model` or `name`. Only accept names from collection members so an + // envelope's own descriptive `name` cannot become a model ID. + if in_collection { + if let Some(id) = object.get("id").and_then(Value::as_str) { + let id = id.trim(); + if !id.is_empty() { + out.push(id.to_string()); + } + } else if let Some(model) = object.get("model").and_then(Value::as_str) { + let model = model.trim(); + if !model.is_empty() { + out.push(model.to_string()); + } + } else if let Some(name) = object.get("name").and_then(Value::as_str) { + let name = name.trim(); + if !name.is_empty() { + out.push(name.strip_prefix("models/").unwrap_or(name).to_string()); + } + } + } - if out.is_empty() { - if let Some(arr) = payload.as_array() { - for item in arr { - if let Some(id) = item.get("id").and_then(|v| v.as_str()) { - out.push(id.to_string()); + // Discovery servers commonly wrap their list in one or more + // of these keys (`data`, `models`, `result`, or `items`). + // Recurse through objects as well as arrays to tolerate + // envelopes such as {"result":{"items":[...]}}. + for key in ["data", "models", "result", "items"] { + if let Some(nested) = object.get(key) { + collect(nested, true, out); + } } } + _ => {} } } + collect(payload, false, &mut out); + let mut seen = HashSet::new(); out.retain(|model| seen.insert(model.clone())); out @@ -971,7 +1056,56 @@ pub(crate) async fn fetch_provider_models_for_tui( strategy: ModelFetchStrategy, request_headers: Option<&BTreeMap>, ) -> Result, String> { - let candidate_urls = build_model_fetch_candidate_urls(base_url, strategy, is_full_url); + fetch_provider_models_for_tui_with_inject_v1( + base_url, + is_full_url, + api_key, + custom_user_agent, + strategy, + request_headers, + None, + ) + .await +} + +pub(crate) async fn fetch_provider_models_for_tui_with_inject_v1( + base_url: &str, + is_full_url: bool, + api_key: Option<&str>, + custom_user_agent: Option<&str>, + strategy: ModelFetchStrategy, + request_headers: Option<&BTreeMap>, + inject_v1: Option, +) -> Result, String> { + fetch_provider_models_for_tui_with_options( + base_url, + is_full_url, + api_key, + custom_user_agent, + strategy, + request_headers, + inject_v1, + None, + ) + .await +} + +/// Fetch provider models with optional OMP discovery URL and timeout +/// overrides. Existing callers can continue using the two compatibility +/// wrappers above, while OMP provider inspection/TUI flows pass through the +/// native `discovery.timeoutMs` value. +pub(crate) async fn fetch_provider_models_for_tui_with_options( + base_url: &str, + is_full_url: bool, + api_key: Option<&str>, + custom_user_agent: Option<&str>, + strategy: ModelFetchStrategy, + request_headers: Option<&BTreeMap>, + inject_v1: Option, + discovery_timeout_ms: Option, +) -> Result, String> { + let candidate_urls = + build_model_fetch_candidate_urls_with_inject_v1(base_url, strategy, is_full_url, inject_v1); if candidate_urls.is_empty() { return Err(if is_full_url && !base_url.trim().is_empty() { "Cannot derive models endpoint from full URL".to_string() @@ -986,7 +1120,12 @@ pub(crate) async fn fetch_provider_models_for_tui( let custom_user_agent = crate::provider::parse_custom_user_agent(custom_user_agent) .ok() .flatten(); - if key.is_none() && request_headers.is_none_or(BTreeMap::is_empty) { + if !matches!( + strategy, + ModelFetchStrategy::Anonymous | ModelFetchStrategy::Ollama | ModelFetchStrategy::LlamaCpp + ) && key.is_none() + && request_headers.is_none_or(BTreeMap::is_empty) + { return Err("API Key or request headers are required to fetch models".to_string()); } if request_headers.is_some_and(|headers| headers.len() > 64) { @@ -995,15 +1134,27 @@ pub(crate) async fn fetch_provider_models_for_tui( let mut last_err = String::from("unknown error"); for url in candidate_urls { - let mut req = client.get(&url).timeout(Duration::from_secs(5)); + let timeout = discovery_timeout_ms + .map(|timeout| timeout.clamp(1, crate::omp_config::OMP_MAX_DISCOVERY_TIMEOUT_MS)) + .map(Duration::from_millis) + .unwrap_or_else(|| Duration::from_secs(5)); + let mut req = client.get(&url).timeout(timeout); + if matches!(strategy, ModelFetchStrategy::Ollama) { + req = req.header(reqwest::header::ACCEPT, "application/json"); + } if let Some(key) = key { req = match strategy { + ModelFetchStrategy::Anonymous | ModelFetchStrategy::Ollama => req, + ModelFetchStrategy::LlamaCpp => { + req.header("Authorization", format!("Bearer {key}")) + } ModelFetchStrategy::Bearer => req.header("Authorization", format!("Bearer {key}")), ModelFetchStrategy::Anthropic => req .header("Authorization", format!("Bearer {key}")) .header("x-api-key", key) .header("anthropic-version", "2023-06-01"), ModelFetchStrategy::GoogleApiKey => req.header("x-goog-api-key", key), + ModelFetchStrategy::AzureApiKey => req.header("api-key", key), }; } if let Some(user_agent) = &custom_user_agent { diff --git a/src-tauri/src/cli/tui/runtime_systems/workers.rs b/src-tauri/src/cli/tui/runtime_systems/workers.rs index 6efb91f28..eae7f5619 100644 --- a/src-tauri/src/cli/tui/runtime_systems/workers.rs +++ b/src-tauri/src/cli/tui/runtime_systems/workers.rs @@ -18,16 +18,16 @@ use super::super::data::{ UsageRangePreset, }; use super::types::{ - fetch_provider_models_for_tui, model_fetch_strategy_for_field, AppDataLoadKind, AppDataMsg, - AppDataReq, AppDataSystem, CodexHistoryMsg, CodexHistoryReq, CodexHistorySystem, - LoadedMessagePage, LocalEnvMsg, LocalEnvReq, LocalEnvSystem, ManagedAuthMsg, ManagedAuthReq, - ManagedAuthSystem, ManagedSessionOutcome, ModelFetchMsg, ModelFetchReq, ModelFetchStrategy, - ModelFetchSystem, ProxyMsg, ProxyReq, ProxySystem, QuotaMsg, QuotaReq, QuotaSystem, - RefreshedMessagePages, SessionMsg, SessionReq, SessionSystem, SessionUsageSyncMsg, - SessionUsageSyncReq, SessionUsageSyncSystem, SkillsMsg, SkillsReq, SkillsSystem, SpeedtestMsg, - SpeedtestSystem, StreamCheckMsg, StreamCheckReq, StreamCheckSystem, UpdateMsg, UpdateReq, - UpdateSystem, UsageLogLoadError, UsagePricingLoadError, UsagePricingMsg, UsagePricingReq, - UsagePricingSystem, WebDavDone, WebDavErr, WebDavMsg, WebDavReq, WebDavReqKind, WebDavSystem, + model_fetch_strategy_for_field, AppDataLoadKind, AppDataMsg, AppDataReq, AppDataSystem, + CodexHistoryMsg, CodexHistoryReq, CodexHistorySystem, LoadedMessagePage, LocalEnvMsg, + LocalEnvReq, LocalEnvSystem, ManagedAuthMsg, ManagedAuthReq, ManagedAuthSystem, + ManagedSessionOutcome, ModelFetchMsg, ModelFetchReq, ModelFetchStrategy, ModelFetchSystem, + ProxyMsg, ProxyReq, ProxySystem, QuotaMsg, QuotaReq, QuotaSystem, RefreshedMessagePages, + SessionMsg, SessionReq, SessionSystem, SessionUsageSyncMsg, SessionUsageSyncReq, + SessionUsageSyncSystem, SkillsMsg, SkillsReq, SkillsSystem, SpeedtestMsg, SpeedtestSystem, + StreamCheckMsg, StreamCheckReq, StreamCheckSystem, UpdateMsg, UpdateReq, UpdateSystem, + UsageLogLoadError, UsagePricingLoadError, UsagePricingMsg, UsagePricingReq, UsagePricingSystem, + WebDavDone, WebDavErr, WebDavMsg, WebDavReq, WebDavReqKind, WebDavSystem, }; static SESSION_SCAN_GENERATION: AtomicU64 = AtomicU64::new(0); @@ -634,6 +634,7 @@ fn model_fetch_worker_loop(rx: mpsc::Receiver, tx: mpsc::Sender, tx: mpsc::Sender ModelFetchStrategy::Anonymous, + Some("ollama") => ModelFetchStrategy::Ollama, + Some("omp-discovery") => ModelFetchStrategy::Bearer, + Some("llama.cpp") => ModelFetchStrategy::LlamaCpp, Some("anthropic-messages") => ModelFetchStrategy::Anthropic, - Some("google-generative-ai") => ModelFetchStrategy::GoogleApiKey, + Some("google-generative-ai" | "google-vertex") => ModelFetchStrategy::GoogleApiKey, + Some("azure-openai-responses") => ModelFetchStrategy::AzureApiKey, _ => model_fetch_strategy_for_field(field), }; rt.block_on(async { - fetch_provider_models_for_tui( + crate::cli::tui::fetch_provider_models_for_tui_with_options( &base_url, is_full_url, api_key.as_deref(), custom_user_agent.as_deref(), strategy, request_headers.as_ref(), + None, + discovery_timeout_ms, ) .await }) diff --git a/src-tauri/src/cli/tui/tests.rs b/src-tauri/src/cli/tui/tests.rs index 3682832a2..605a81f3c 100644 --- a/src-tauri/src/cli/tui/tests.rs +++ b/src-tauri/src/cli/tui/tests.rs @@ -5354,6 +5354,83 @@ fn model_fetch_candidate_urls_for_gemini_v1beta_keeps_models_endpoint() { ); } +#[test] +fn model_fetch_candidate_urls_honor_omp_inject_v1_and_ignore_query() { + assert_eq!( + build_model_fetch_candidate_urls_with_inject_v1( + "https://relay.example/v3/compat?api-version=1", + ModelFetchStrategy::Bearer, + false, + Some(true), + ), + vec!["https://relay.example/v3/compat/v1/models".to_string()] + ); + assert_eq!( + build_model_fetch_candidate_urls_with_inject_v1( + "https://relay.example/v3/compat?api-version=1", + ModelFetchStrategy::Bearer, + false, + Some(false), + ), + vec!["https://relay.example/v3/compat/models".to_string()] + ); +} + +#[test] +fn model_fetch_candidate_urls_prefer_v1_for_omp_local_openai_discovery() { + assert_eq!( + build_model_fetch_candidate_urls_with_inject_v1( + "https://relay.example", + ModelFetchStrategy::Bearer, + false, + Some(true), + ), + vec!["https://relay.example/v1/models".to_string()] + ); +} + +#[test] +fn model_fetch_candidate_urls_strip_v1_for_omp_llama_cpp() { + assert_eq!( + build_model_fetch_candidate_urls( + "http://127.0.0.1:8080/v1", + ModelFetchStrategy::LlamaCpp, + false, + ), + vec!["http://127.0.0.1:8080/models".to_string()] + ); +} + +#[test] +fn model_fetch_candidate_urls_use_ollama_native_tags_endpoint() { + assert_eq!( + build_model_fetch_candidate_urls( + "http://ollama.example:11434/v1", + ModelFetchStrategy::Ollama, + false, + ), + vec!["http://ollama.example:11434/api/tags".to_string()] + ); +} + +#[test] +fn model_fetch_candidate_urls_use_llama_cpp_native_root() { + // The OMP handler strips a configured `/v1` suffix before dispatching the + // generic worker; the first candidate must therefore be the native root + // endpoint rather than `/v1/models`. + assert_eq!( + build_model_fetch_candidate_urls( + "http://127.0.0.1:8080", + ModelFetchStrategy::Bearer, + false, + ), + vec![ + "http://127.0.0.1:8080/models".to_string(), + "http://127.0.0.1:8080/v1/models".to_string(), + ] + ); +} + #[test] fn model_fetch_candidate_urls_derive_models_endpoint_from_full_url() { assert_eq!( @@ -5390,6 +5467,79 @@ async fn model_fetch_full_url_reports_when_models_endpoint_cannot_be_derived() { assert_eq!(error, "Cannot derive models endpoint from full URL"); } +#[tokio::test] +async fn model_fetch_anonymous_allows_keyless_omp_provider() { + use axum::{http::HeaderMap, routing::get, Router}; + + let app = Router::new().route( + "/v1/models", + get(|headers: HeaderMap| async move { + assert!(headers.get("authorization").is_none()); + axum::Json(json!({ "data": [{ "id": "local-model" }] })) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind anonymous model fetch test server"); + let address = listener + .local_addr() + .expect("anonymous model fetch listener address"); + let server = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("anonymous model fetch test server should run"); + }); + + let models = fetch_provider_models_for_tui( + &format!("http://{address}"), + false, + None, + None, + ModelFetchStrategy::Anonymous, + None, + ) + .await + .expect("keyless model fetch should succeed"); + server.abort(); + + assert_eq!(models, vec!["local-model"]); +} + +#[tokio::test] +async fn model_fetch_ollama_uses_native_tags_endpoint() { + use axum::{routing::get, Router}; + + let app = Router::new().route( + "/api/tags", + get(|| async { axum::Json(json!({ "models": [{ "name": "llama3" }] })) }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind Ollama model fetch test server"); + let address = listener + .local_addr() + .expect("Ollama model fetch listener address"); + let server = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("Ollama model fetch test server should run"); + }); + + let models = fetch_provider_models_for_tui( + &format!("http://{address}/v1"), + false, + None, + None, + ModelFetchStrategy::Ollama, + None, + ) + .await + .expect("Ollama model fetch should use /api/tags"); + server.abort(); + + assert_eq!(models, vec!["llama3"]); +} + #[tokio::test] async fn model_fetch_sends_trimmed_custom_user_agent() { use std::sync::{Arc, Mutex}; @@ -5446,6 +5596,116 @@ async fn model_fetch_sends_trimmed_custom_user_agent() { ); } +#[tokio::test] +async fn model_fetch_uses_azure_api_key_header() { + use std::sync::{Arc, Mutex}; + + use axum::{http::HeaderMap, routing::get, Router}; + + let observed = Arc::new(Mutex::new(None::)); + let handler_observed = Arc::clone(&observed); + let app = Router::new().route( + "/v1/models", + get(move |headers: HeaderMap| { + let observed = Arc::clone(&handler_observed); + async move { + *observed.lock().expect("capture Azure model-fetch header") = headers + .get("api-key") + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + axum::Json(json!({ "data": [{ "id": "model-a" }] })) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind model fetch test server"); + let address = listener.local_addr().expect("model fetch listener address"); + let server = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("model fetch test server should run"); + }); + + let models = fetch_provider_models_for_tui( + &format!("http://{address}"), + false, + Some("azure-secret"), + None, + ModelFetchStrategy::AzureApiKey, + None, + ) + .await + .expect("Azure model fetch should succeed"); + server.abort(); + + assert_eq!(models, vec!["model-a"]); + assert_eq!( + observed + .lock() + .expect("read Azure model-fetch header") + .as_deref(), + Some("azure-secret") + ); +} + +#[tokio::test] +async fn model_fetch_omp_discovery_uses_bearer_header_for_google_wire_api() { + use axum::{http::HeaderMap, routing::get, Router}; + + let observed = std::sync::Arc::new(std::sync::Mutex::new(None::)); + let handler_observed = std::sync::Arc::clone(&observed); + let app = Router::new().route( + "/v1/models", + get(move |headers: HeaderMap| { + let observed = std::sync::Arc::clone(&handler_observed); + async move { + *observed.lock().expect("capture OMP discovery headers") = Some(headers); + axum::Json(json!({ "data": [{ "id": "model-a" }] })) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind OMP discovery model fetch test server"); + let address = listener + .local_addr() + .expect("OMP discovery model fetch listener address"); + let server = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("OMP discovery model fetch test server should run"); + }); + + let models = fetch_provider_models_for_tui_with_options( + &format!("http://{address}"), + false, + Some("omp-bearer-secret"), + None, + ModelFetchStrategy::Bearer, + None, + Some(true), + Some(1_000), + ) + .await + .expect("OMP discovery model fetch should succeed"); + server.abort(); + + assert_eq!(models, vec!["model-a"]); + let headers = observed + .lock() + .expect("read OMP discovery headers") + .clone() + .expect("server should capture request headers"); + assert_eq!( + headers + .get("authorization") + .and_then(|value| value.to_str().ok()), + Some("Bearer omp-bearer-secret") + ); + assert!(headers.get("x-goog-api-key").is_none()); +} + #[test] #[serial(home_settings)] fn startup_hidden_requested_app_bootstrap_uses_visible_app_normalization_before_loading_data() { @@ -5459,6 +5719,7 @@ fn startup_hidden_requested_app_bootstrap_uses_visible_app_normalization_before_ hermes: false, openclaw: true, pi: false, + omp: false, }) .expect("save visible apps"); @@ -5512,6 +5773,31 @@ fn parse_model_ids_supports_multiple_shapes_and_dedups_stably() { parse_model_ids_from_response(&gemini_payload), vec!["gemini-2.0-pro", "gemini-2.0-flash"] ); + + let ollama_payload = json!({ + "models": [ + {"model": "llama3.2:latest"}, + {"name": "qwen3:latest"} + ] + }); + assert_eq!( + parse_model_ids_from_response(&ollama_payload), + vec!["llama3.2:latest", "qwen3:latest"] + ); + + let nested_payload = json!({ + "result": { + "items": [ + {"id": "nested-a"}, + {"name": "models/nested-b"}, + {"id": "nested-a"} + ] + } + }); + assert_eq!( + parse_model_ids_from_response(&nested_payload), + vec!["nested-a", "nested-b"] + ); } #[test] diff --git a/src-tauri/src/cli/tui/theme.rs b/src-tauri/src/cli/tui/theme.rs index a1fe08440..d28b20d66 100644 --- a/src-tauri/src/cli/tui/theme.rs +++ b/src-tauri/src/cli/tui/theme.rs @@ -253,6 +253,7 @@ fn accent_rgb(app: &AppType, light: bool) -> (u8, u8, u8) { AppType::Hermes => LIGHT_YELLOW, AppType::OpenClaw => LIGHT_CORAL, AppType::Pi => LIGHT_CYAN, + AppType::Omp => LIGHT_CYAN, }; } @@ -264,6 +265,7 @@ fn accent_rgb(app: &AppType, light: bool) -> (u8, u8, u8) { AppType::Hermes => DRACULA_YELLOW, AppType::OpenClaw => OPENCLAW_CORAL, AppType::Pi => DRACULA_CYAN, + AppType::Omp => DRACULA_CYAN, } } diff --git a/src-tauri/src/cli/tui/ui.rs b/src-tauri/src/cli/tui/ui.rs index e99f205c4..12c02265a 100644 --- a/src-tauri/src/cli/tui/ui.rs +++ b/src-tauri/src/cli/tui/ui.rs @@ -41,6 +41,7 @@ mod forms; mod home_chart; mod main_page; mod mcp; +mod omp; mod overlay; mod pricing; mod prompts; @@ -64,6 +65,7 @@ use forms::*; use home_chart::*; use main_page::*; use mcp::*; +use omp::*; use overlay::*; use pricing::*; use prompts::*; @@ -173,6 +175,9 @@ fn render_content( Route::PiPromptTemplates => { render_pi_prompt_templates(frame, app, data, content_area, theme) } + Route::OmpModels => render_omp_models(frame, app, data, content_area, theme), + Route::OmpRoles => render_omp_roles(frame, app, data, content_area, theme), + Route::OmpSystemPrompts => render_omp_system_prompts(frame, app, data, content_area, theme), Route::HermesMemory => render_hermes_memory(frame, app, data, content_area, theme), Route::Config => render_config(frame, app, data, content_area, theme), Route::ConfigOpenClawWorkspace | Route::ConfigOpenClawDailyMemory => { diff --git a/src-tauri/src/cli/tui/ui/chrome.rs b/src-tauri/src/cli/tui/ui/chrome.rs index 456c5d216..b4ba05eb0 100644 --- a/src-tauri/src/cli/tui/ui/chrome.rs +++ b/src-tauri/src/cli/tui/ui/chrome.rs @@ -66,6 +66,7 @@ fn header_status_label(app_type: &AppType) -> &'static str { match app_type { AppType::OpenCode => texts::tui_opencode_config_status_label(), AppType::OpenClaw => texts::tui_openclaw_agents_primary_model(), + AppType::Omp => texts::tui_omp_default_model_label(), _ => strip_trailing_colon(texts::provider_label()), } } @@ -85,6 +86,19 @@ pub(super) fn header_status_value(app: &App, data: &UiData, available_width: u16 ); } + if matches!(app.app_type, AppType::Omp) { + let value = data + .omp + .model_roles + .get("default") + .cloned() + .unwrap_or_else(|| texts::none().to_string()); + return truncate_to_display_width( + &value, + available_width.min(HEADER_STATUS_VALUE_MAX_WIDTH), + ); + } + let provider_name = match data.providers.rows.iter().find(|row| row.is_current) { Some(row) => header_provider_display_name(&app.app_type, row), None => texts::none(), @@ -260,6 +274,9 @@ pub(super) fn nav_label(item: NavItem) -> &'static str { NavItem::Prompts => texts::menu_manage_prompts(), NavItem::PiSystemPrompts => texts::menu_pi_system_prompts(), NavItem::PiPromptTemplates => texts::menu_pi_prompt_templates(), + NavItem::OmpModels => texts::menu_omp_models(), + NavItem::OmpRoles => texts::menu_omp_roles(), + NavItem::OmpSystemPrompts => texts::menu_omp_system_prompts(), NavItem::HermesMemory => texts::menu_hermes_memory(), NavItem::Config => texts::menu_manage_config(), NavItem::Skills => texts::menu_manage_skills(), @@ -282,6 +299,9 @@ pub(super) fn nav_label_variants(item: NavItem) -> (&'static str, &'static str) NavItem::Prompts => texts::menu_manage_prompts_variants(), NavItem::PiSystemPrompts => texts::menu_pi_system_prompts_variants(), NavItem::PiPromptTemplates => texts::menu_pi_prompt_templates_variants(), + NavItem::OmpModels => texts::menu_omp_models_variants(), + NavItem::OmpRoles => texts::menu_omp_roles_variants(), + NavItem::OmpSystemPrompts => texts::menu_omp_system_prompts_variants(), NavItem::HermesMemory => texts::menu_hermes_memory_variants(), NavItem::Config => texts::menu_manage_config_variants(), NavItem::Skills => texts::menu_manage_skills_variants(), @@ -306,6 +326,8 @@ pub(super) fn nav_pane_width(theme: &super::theme::Theme) -> u16 { .iter() .chain(NavItem::OPENCLAW_ALL.iter()) .chain(NavItem::HERMES_ALL.iter()) + .chain(NavItem::PI_ALL.iter()) + .chain(NavItem::OMP_ALL.iter()) .flat_map(|item| { let (en, zh) = nav_label_variants(*item); [en, zh] diff --git a/src-tauri/src/cli/tui/ui/forms/provider.rs b/src-tauri/src/cli/tui/ui/forms/provider.rs index 7fd4c1383..91f78d840 100644 --- a/src-tauri/src/cli/tui/ui/forms/provider.rs +++ b/src-tauri/src/cli/tui/ui/forms/provider.rs @@ -236,9 +236,12 @@ fn common_json_preview_value(app_type: &AppType, common_snippet: &str) -> Option AppType::Gemini => serde_json::from_str::(common_snippet) .ok() .map(|env| json!({ "env": env })), - AppType::Codex | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { - None - } + AppType::Codex + | AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw + | AppType::Pi + | AppType::Omp => None, } .filter(Value::is_object) } @@ -467,16 +470,21 @@ pub(crate) fn render_provider_add_form( }; let selected_field_for_keys = fields.get(selected_idx).copied(); - render_key_bar( - frame, - chunks[0], - theme, - &add_form_key_items( + let key_items = if matches!(provider.app_type, AppType::Pi | AppType::Omp) { + add_form_key_items_for_app( provider.focus, provider.is_editing_main_text(), selected_field_for_keys, - ), - ); + Some(&provider.app_type), + ) + } else { + add_form_key_items( + provider.focus, + provider.is_editing_main_text(), + selected_field_for_keys, + ) + }; + render_key_bar(frame, chunks[0], theme, &key_items); let rows_data = fields .iter() @@ -1989,7 +1997,13 @@ pub(crate) fn provider_field_label_and_value( ProviderAddField::GeminiModel => texts::model_label().to_string(), ProviderAddField::OpenClawApiProtocol => texts::tui_label_openclaw_api().to_string(), ProviderAddField::OpenClawUserAgent => texts::tui_label_openclaw_user_agent().to_string(), - ProviderAddField::OpenClawModels => texts::tui_label_openclaw_models().to_string(), + ProviderAddField::OpenClawModels => { + if provider.app_type == AppType::Omp { + texts::tui_label_omp_models().to_string() + } else { + texts::tui_label_openclaw_models().to_string() + } + } ProviderAddField::OpenCodeNpmPackage => { if provider.app_type == AppType::OpenClaw { texts::tui_label_openclaw_api().to_string() @@ -2148,7 +2162,13 @@ pub(crate) fn provider_field_label_and_value( "[ ]".to_string() } } - ProviderAddField::OpenClawModels => provider.openclaw_models_summary(), + ProviderAddField::OpenClawModels => { + if provider.app_type == AppType::Omp { + provider.omp_models_summary() + } else { + provider.openclaw_models_summary() + } + } ProviderAddField::HermesApiMode => { texts::tui_hermes_api_mode_value(provider.hermes_api_mode_value()).to_string() } diff --git a/src-tauri/src/cli/tui/ui/forms/shared.rs b/src-tauri/src/cli/tui/ui/forms/shared.rs index 19401909c..dc5eb86d5 100644 --- a/src-tauri/src/cli/tui/ui/forms/shared.rs +++ b/src-tauri/src/cli/tui/ui/forms/shared.rs @@ -1,4 +1,5 @@ use super::super::*; +use crate::app_config::AppType; use std::collections::BTreeSet; pub(crate) fn focus_block_style(active: bool, theme: &super::theme::Theme) -> Style { @@ -15,6 +16,15 @@ pub(crate) fn add_form_key_items( focus: FormFocus, editing: bool, selected_field: Option, +) -> Vec<(&'static str, &'static str)> { + add_form_key_items_for_app(focus, editing, selected_field, None) +} + +pub(crate) fn add_form_key_items_for_app( + focus: FormFocus, + editing: bool, + selected_field: Option, + app_type: Option<&AppType>, ) -> Vec<(&'static str, &'static str)> { if editing && matches!(focus, FormFocus::Fields) { return vec![ @@ -87,6 +97,11 @@ pub(crate) fn add_form_key_items( ) { keys.push(("f", texts::tui_key_fetch_model())); } + if matches!(selected_field, Some(ProviderAddField::OpenClawModels)) + && matches!(app_type, Some(AppType::Pi | AppType::Omp)) + { + keys.push(("f", texts::tui_key_fetch_model())); + } } } FormFocus::JsonPreview => { diff --git a/src-tauri/src/cli/tui/ui/header_tests.rs b/src-tauri/src/cli/tui/ui/header_tests.rs index 9f98904dd..bc8e9b337 100644 --- a/src-tauri/src/cli/tui/ui/header_tests.rs +++ b/src-tauri/src/cli/tui/ui/header_tests.rs @@ -197,6 +197,7 @@ fn header_openclaw_sacrifices_tabs_before_losing_the_only_status_badge() { hermes: false, openclaw: true, pi: false, + omp: false, }); let _lang = use_test_language(Language::English); let _no_color = super::tests::EnvGuard::remove("NO_COLOR"); @@ -238,6 +239,7 @@ fn header_openclaw_truncates_long_default_model_without_fake_proxy_gap() { hermes: false, openclaw: true, pi: false, + omp: false, }); let _lang = use_test_language(Language::English); let _no_color = super::tests::EnvGuard::remove("NO_COLOR"); diff --git a/src-tauri/src/cli/tui/ui/omp.rs b/src-tauri/src/cli/tui/ui/omp.rs new file mode 100644 index 000000000..4983fc2b8 --- /dev/null +++ b/src-tauri/src/cli/tui/ui/omp.rs @@ -0,0 +1,353 @@ +use super::*; + +pub(super) fn render_omp_models( + frame: &mut Frame<'_>, + app: &App, + data: &UiData, + area: Rect, + theme: &super::theme::Theme, +) { + let summary = format!( + "{} · {}", + texts::tui_omp_models_summary(data.omp.models.len()), + data.omp.models_path.display() + ); + let body = render_page_frame( + frame, + area, + theme, + app, + texts::menu_omp_models(), + &crate::cli::tui::keymap::omp_models::key_bar_items(app, data), + Some(summary), + ); + if let Some(error) = &data.omp.models_error { + render_empty_state( + frame, + body, + theme, + texts::tui_omp_config_error_title(), + error, + ); + return; + } + if data.omp.models.is_empty() { + render_empty_state( + frame, + body, + theme, + texts::tui_omp_models_empty_title(), + texts::tui_omp_models_empty_subtitle(), + ); + return; + } + let table_area = inset_left(body, CONTENT_INSET_LEFT); + let columns = omp_model_columns(table_area.width); + let header = Row::new( + columns + .iter() + .map(|column| Cell::from(omp_model_column_header(*column))) + .collect::>(), + ) + .style(Style::default().fg(theme.dim).add_modifier(Modifier::BOLD)); + let rows = data.omp.models.iter().map(|model| { + Row::new( + columns + .iter() + .map(|column| Cell::from(omp_model_column_value(*column, model, data))) + .collect::>(), + ) + }); + let table = Table::new(rows, omp_model_column_constraints(&columns)) + .header(header) + .block(Block::default().borders(Borders::NONE)) + .row_highlight_style(selection_style(theme)) + .highlight_symbol(highlight_symbol(theme)); + let mut state = TableState::default(); + state.select(Some( + app.omp_model_idx + .min(data.omp.models.len().saturating_sub(1)), + )); + frame.render_stateful_widget(table, table_area, &mut state); +} + +#[derive(Clone, Copy)] +enum OmpModelColumn { + Provider, + Model, + Name, + Api, + Reasoning, + Context, + MaxTokens, +} + +fn omp_model_columns(width: u16) -> Vec { + // Keep the full metadata view on wide terminals, but prefer the identity + // and transport columns on smaller screens. The complete model object is + // still available through Enter, so no information is lost when columns + // are hidden for readability. + if width >= 118 { + vec![ + OmpModelColumn::Provider, + OmpModelColumn::Model, + OmpModelColumn::Name, + OmpModelColumn::Api, + OmpModelColumn::Reasoning, + OmpModelColumn::Context, + OmpModelColumn::MaxTokens, + ] + } else if width >= 70 { + vec![ + OmpModelColumn::Provider, + OmpModelColumn::Model, + OmpModelColumn::Api, + OmpModelColumn::Reasoning, + OmpModelColumn::Context, + ] + } else { + vec![ + OmpModelColumn::Provider, + OmpModelColumn::Model, + OmpModelColumn::Api, + ] + } +} + +fn omp_model_column_header(column: OmpModelColumn) -> &'static str { + match column { + OmpModelColumn::Provider => texts::tui_omp_models_provider_header(), + OmpModelColumn::Model => texts::tui_omp_models_model_header(), + OmpModelColumn::Name => texts::tui_omp_models_name_header(), + OmpModelColumn::Api => texts::tui_omp_models_api_header(), + OmpModelColumn::Reasoning => texts::tui_omp_models_reasoning_header(), + OmpModelColumn::Context => texts::tui_omp_models_context_header(), + OmpModelColumn::MaxTokens => texts::tui_omp_models_max_tokens_header(), + } +} + +fn omp_model_column_value( + column: OmpModelColumn, + model: &crate::omp_config::OMPNativeModel, + data: &UiData, +) -> String { + let obj = model.config.as_object(); + let text = |key: &str| { + obj.and_then(|o| o.get(key)) + .map(|value| match value { + Value::String(s) => s.clone(), + _ => value.to_string(), + }) + .unwrap_or_else(|| texts::tui_na().to_string()) + }; + match column { + OmpModelColumn::Provider => model.provider_id.clone(), + OmpModelColumn::Model => model.model_id.clone(), + OmpModelColumn::Name => text("name"), + OmpModelColumn::Api => obj + .and_then(|o| o.get("api")) + .or_else(|| { + data.omp + .providers + .get(&model.provider_id) + .and_then(|p| p.get("api")) + }) + .map(|value| match value { + Value::String(s) => s.clone(), + _ => value.to_string(), + }) + .unwrap_or_else(|| texts::tui_na().to_string()), + OmpModelColumn::Reasoning => text("reasoning"), + OmpModelColumn::Context => text("contextWindow"), + OmpModelColumn::MaxTokens => text("maxTokens"), + } +} + +fn omp_model_column_constraints(columns: &[OmpModelColumn]) -> Vec { + match columns { + [OmpModelColumn::Provider, OmpModelColumn::Model, OmpModelColumn::Name, OmpModelColumn::Api, OmpModelColumn::Reasoning, OmpModelColumn::Context, OmpModelColumn::MaxTokens] => + { + vec![ + Constraint::Length(16), + Constraint::Length(24), + Constraint::Length(20), + Constraint::Length(24), + Constraint::Length(10), + Constraint::Length(12), + Constraint::Min(12), + ] + } + [OmpModelColumn::Provider, OmpModelColumn::Model, OmpModelColumn::Api, OmpModelColumn::Reasoning, OmpModelColumn::Context] => + { + vec![ + Constraint::Length(14), + Constraint::Min(18), + Constraint::Length(18), + Constraint::Length(10), + Constraint::Length(10), + ] + } + _ => vec![ + Constraint::Length(12), + Constraint::Min(12), + Constraint::Min(8), + ], + } +} + +#[cfg(test)] +pub(super) fn omp_model_column_count(width: u16) -> usize { + omp_model_columns(width).len() +} + +pub(super) fn render_omp_roles( + frame: &mut Frame<'_>, + app: &App, + data: &UiData, + area: Rect, + theme: &super::theme::Theme, +) { + let body = render_page_frame( + frame, + area, + theme, + app, + texts::menu_omp_roles(), + &crate::cli::tui::keymap::omp_roles::key_bar_items(app, data), + Some(format!( + "{} · {}", + texts::tui_omp_roles_summary(data.omp.model_roles.len()), + data.omp.roles_path.display() + )), + ); + if let Some(error) = &data.omp.config_error { + render_empty_state( + frame, + body, + theme, + texts::tui_omp_config_error_title(), + error, + ); + return; + } + if data.omp.model_roles.is_empty() { + render_empty_state( + frame, + body, + theme, + texts::tui_omp_roles_empty_title(), + texts::tui_omp_roles_empty_subtitle(), + ); + return; + } + let header = Row::new(vec![ + texts::tui_omp_roles_role_header(), + texts::tui_omp_roles_selector_header(), + ]) + .style(Style::default().fg(theme.dim).add_modifier(Modifier::BOLD)); + let rows = data.omp.model_roles.iter().map(|(role, selector)| { + Row::new(vec![Cell::from(role.clone()), Cell::from(selector.clone())]) + }); + let table = Table::new(rows, [Constraint::Length(18), Constraint::Min(24)]) + .header(header) + .block(Block::default().borders(Borders::NONE)) + .row_highlight_style(selection_style(theme)) + .highlight_symbol(highlight_symbol(theme)); + let mut state = TableState::default(); + state.select(Some( + app.omp_role_idx + .min(data.omp.model_roles.len().saturating_sub(1)), + )); + frame.render_stateful_widget(table, inset_left(body, CONTENT_INSET_LEFT), &mut state); +} + +pub(super) fn render_omp_system_prompts( + frame: &mut Frame<'_>, + app: &App, + data: &UiData, + area: Rect, + theme: &super::theme::Theme, +) { + let body = render_page_frame( + frame, + area, + theme, + app, + texts::menu_omp_system_prompts(), + &crate::cli::tui::keymap::omp_system_prompts::key_bar_items(app, data), + Some(texts::tui_omp_system_prompts_summary( + data.pi_prompts.system_files.len(), + )), + ); + if let Some(error) = &data.pi_prompts.read_error { + render_empty_state( + frame, + body, + theme, + texts::tui_omp_prompt_error_title(), + error, + ); + return; + } + let rows = data.pi_prompts.system_files.iter().map(|(kind, snapshot)| { + let filename = match kind { + crate::services::pi_prompt_files::PiPromptFileKind::SystemAppend => "APPEND_SYSTEM.md", + crate::services::pi_prompt_files::PiPromptFileKind::SystemOverride => "SYSTEM.md", + crate::services::pi_prompt_files::PiPromptFileKind::TitleSystem => "TITLE_SYSTEM.md", + }; + let mode = match kind { + crate::services::pi_prompt_files::PiPromptFileKind::SystemAppend => { + crate::t!("Append", "追加") + } + crate::services::pi_prompt_files::PiPromptFileKind::SystemOverride => { + crate::t!("Override", "覆盖") + } + crate::services::pi_prompt_files::PiPromptFileKind::TitleSystem => { + crate::t!("Title", "标题") + } + }; + let path = crate::services::pi_prompt_files::OmpPromptFileService::active_path(*kind) + .map(|path| path.display().to_string()) + .unwrap_or_else(|_| texts::tui_na().to_string()); + Row::new(vec![ + Cell::from(if snapshot.exists { + texts::tui_marker_active() + } else { + texts::tui_marker_inactive() + }), + Cell::from(filename), + Cell::from(mode), + Cell::from(snapshot.content.chars().count().to_string()), + Cell::from(path), + ]) + }); + let table = Table::new( + rows, + [ + Constraint::Length(2), + Constraint::Length(24), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Min(24), + ], + ) + .header( + Row::new(vec![ + "", + crate::t!("File", "文件"), + crate::t!("Mode", "模式"), + crate::t!("Characters", "字符数"), + texts::tui_omp_prompt_active_path_header(), + ]) + .style(Style::default().fg(theme.dim).add_modifier(Modifier::BOLD)), + ) + .block(Block::default().borders(Borders::NONE)) + .row_highlight_style(selection_style(theme)) + .highlight_symbol(highlight_symbol(theme)); + let mut state = TableState::default(); + state.select(Some( + app.omp_system_prompt_idx + .min(data.pi_prompts.system_files.len().saturating_sub(1)), + )); + frame.render_stateful_widget(table, inset_left(body, CONTENT_INSET_LEFT), &mut state); +} diff --git a/src-tauri/src/cli/tui/ui/overlay/pickers.rs b/src-tauri/src/cli/tui/ui/overlay/pickers.rs index 6ec0042af..f6a895613 100644 --- a/src-tauri/src/cli/tui/ui/overlay/pickers.rs +++ b/src-tauri/src/cli/tui/ui/overlay/pickers.rs @@ -2413,6 +2413,7 @@ pub(super) fn render_visible_apps_picker_overlay( crate::app_config::AppType::Hermes, crate::app_config::AppType::OpenClaw, crate::app_config::AppType::Pi, + crate::app_config::AppType::Omp, ], ); } @@ -2440,6 +2441,7 @@ pub(super) fn render_skills_apps_picker_overlay( crate::app_config::AppType::OpenCode, crate::app_config::AppType::Hermes, crate::app_config::AppType::Pi, + crate::app_config::AppType::Omp, ], ); } diff --git a/src-tauri/src/cli/tui/ui/prompts.rs b/src-tauri/src/cli/tui/ui/prompts.rs index 012f3f403..4e5b137db 100644 --- a/src-tauri/src/cli/tui/ui/prompts.rs +++ b/src-tauri/src/cli/tui/ui/prompts.rs @@ -120,6 +120,7 @@ pub(super) fn render_pi_system_prompts( let filename = match kind { crate::services::pi_prompt_files::PiPromptFileKind::SystemAppend => "APPEND_SYSTEM.md", crate::services::pi_prompt_files::PiPromptFileKind::SystemOverride => "SYSTEM.md", + crate::services::pi_prompt_files::PiPromptFileKind::TitleSystem => "TITLE_SYSTEM.md", }; let mode = match kind { crate::services::pi_prompt_files::PiPromptFileKind::SystemAppend => { @@ -136,6 +137,13 @@ pub(super) fn render_pi_system_prompts( "Override" } } + crate::services::pi_prompt_files::PiPromptFileKind::TitleSystem => { + if crate::cli::i18n::is_chinese() { + "标题" + } else { + "Title" + } + } }; Row::new(vec![ Cell::from(if snapshot.exists { diff --git a/src-tauri/src/cli/tui/ui/providers.rs b/src-tauri/src/cli/tui/ui/providers.rs index d4cae4e20..67b07f8d3 100644 --- a/src-tauri/src/cli/tui/ui/providers.rs +++ b/src-tauri/src/cli/tui/ui/providers.rs @@ -194,8 +194,13 @@ pub(super) fn render_providers( } else { String::new() } - } else if matches!(app.app_type, AppType::OpenCode | AppType::Pi) { - if row.is_in_config { + } else if matches!(app.app_type, AppType::OpenCode | AppType::Pi | AppType::Omp) { + if matches!(app.app_type, AppType::Omp) && data.providers.disabled_ids.contains(&row.id) + { + "⊘".to_string() + } else if matches!(app.app_type, AppType::Omp) && row.is_default_model { + "*".to_string() + } else if row.is_in_config { "+".to_string() } else { String::new() diff --git a/src-tauri/src/cli/tui/ui/skills/helpers.rs b/src-tauri/src/cli/tui/ui/skills/helpers.rs index 937614cdb..fc4667fb8 100644 --- a/src-tauri/src/cli/tui/ui/skills/helpers.rs +++ b/src-tauri/src/cli/tui/ui/skills/helpers.rs @@ -48,6 +48,9 @@ pub(super) fn enabled_skill_apps_text(apps: &crate::app_config::SkillApps) -> St if apps.pi { enabled.push("Pi"); } + if apps.omp { + enabled.push("OMP"); + } if enabled.is_empty() { texts::none().to_string() diff --git a/src-tauri/src/cli/tui/ui/skills/installed.rs b/src-tauri/src/cli/tui/ui/skills/installed.rs index 2561fe6e6..50e55fbfb 100644 --- a/src-tauri/src/cli/tui/ui/skills/installed.rs +++ b/src-tauri/src/cli/tui/ui/skills/installed.rs @@ -28,6 +28,7 @@ pub(super) fn render_skills_installed( Cell::from(crate::app_config::AppType::OpenCode.as_str()), Cell::from(crate::app_config::AppType::Hermes.as_str()), Cell::from(crate::app_config::AppType::Pi.as_str()), + Cell::from(crate::app_config::AppType::Omp.as_str()), ]) .style(Style::default().fg(theme.dim).add_modifier(Modifier::BOLD)); @@ -46,6 +47,7 @@ pub(super) fn render_skills_installed( Cell::from(skill_marker(skill.apps.opencode)), Cell::from(skill_marker(skill.apps.hermes)), Cell::from(skill_marker(skill.apps.pi)), + Cell::from(skill_marker(skill.apps.omp)), ]) }); @@ -59,6 +61,7 @@ pub(super) fn render_skills_installed( Constraint::Length(10), Constraint::Length(8), Constraint::Length(5), + Constraint::Length(6), ], ) .header(header) @@ -114,6 +117,7 @@ fn installed_summary(app: &App, data: &UiData) -> String { .filter(|s| s.apps.hermes) .count(); let enabled_pi = data.skills.installed.iter().filter(|s| s.apps.pi).count(); + let enabled_omp = data.skills.installed.iter().filter(|s| s.apps.omp).count(); let counts = texts::tui_skills_installed_counts( enabled_claude, @@ -122,12 +126,13 @@ fn installed_summary(app: &App, data: &UiData) -> String { enabled_opencode, enabled_hermes, enabled_pi, + enabled_omp, ); if app.skill_updates.is_empty() { counts } else { format!( - "{counts} · {}", + "{} · {counts}", texts::tui_skills_updates_available(app.skill_updates.len()) ) } diff --git a/src-tauri/src/cli/tui/ui/tests.rs b/src-tauri/src/cli/tui/ui/tests.rs index e6938451e..831d1e84c 100644 --- a/src-tauri/src/cli/tui/ui/tests.rs +++ b/src-tauri/src/cli/tui/ui/tests.rs @@ -4039,6 +4039,7 @@ pub(super) fn minimal_data(_app_type: &AppType) -> UiData { providers: ProvidersSnapshot { current_id: "p0".to_string(), live_ids: Default::default(), + disabled_ids: Default::default(), rows: vec![ProviderRow { id: "p1".to_string(), provider, @@ -4056,6 +4057,7 @@ pub(super) fn minimal_data(_app_type: &AppType) -> UiData { mcp: McpSnapshot::default(), prompts: PromptsSnapshot::default(), pi_prompts: Default::default(), + omp: Default::default(), config: ConfigSnapshot::default(), skills: SkillsSnapshot::default(), proxy: ProxySnapshot::default(), @@ -4155,6 +4157,7 @@ fn installed_skill(directory: &str, name: &str) -> InstalledSkill { opencode: false, hermes: false, pi: false, + omp: false, }, installed_at: 1, content_hash: None, @@ -4654,6 +4657,7 @@ fn header_only_renders_selected_visible_apps() { hermes: false, openclaw: true, pi: false, + omp: false, }) .expect("save visible apps"); @@ -4684,6 +4688,7 @@ fn header_keeps_all_app_tabs_visible_with_proxy_chip() { hermes: false, openclaw: true, pi: false, + omp: false, }) .expect("save visible apps"); @@ -4714,6 +4719,7 @@ fn settings_page_shows_visible_apps_row_value() { hermes: false, openclaw: true, pi: false, + omp: false, }) .expect("save visible apps"); @@ -5388,6 +5394,7 @@ fn zero_selection_warning_toast_renders_after_picker_rejection() { hermes: false, openclaw: false, pi: false, + omp: false, }, }; app.push_toast( @@ -5428,6 +5435,7 @@ fn visible_apps_picker_uses_space_toggle_key() { hermes: false, openclaw: false, pi: false, + omp: false, }, }; @@ -5460,6 +5468,7 @@ fn visible_apps_picker_auto_mode_does_not_append_auto_suffix_to_apps() { hermes: true, openclaw: true, pi: false, + omp: false, }, }; @@ -5618,6 +5627,7 @@ fn header_centers_tabs_when_room_allows() { hermes: true, openclaw: true, pi: false, + omp: false, }) .expect("save visible apps"); @@ -5664,6 +5674,7 @@ fn header_keeps_title_and_right_badges_visible_without_large_gap_in_chinese() { hermes: true, openclaw: true, pi: false, + omp: false, }) .expect("save visible apps"); @@ -6183,6 +6194,7 @@ fn home_connection_card_labels_mcp_and_skills_with_active_counts() { opencode: false, hermes: false, pi: false, + omp: false, }, installed_at: 0, content_hash: None, @@ -7103,7 +7115,7 @@ fn skills_page_renders_sync_method_and_installed_rows() { let buf = render(&app, &data); let all = all_text(&buf); - assert!(all.contains(&texts::tui_skills_installed_counts(1, 0, 0, 0, 0, 0))); + assert!(all.contains(&texts::tui_skills_installed_counts(1, 0, 0, 0, 0, 0, 0))); assert!(!all.contains(texts::tui_header_directory())); assert!(all.contains(AppType::Claude.as_str())); assert!(all.contains(AppType::Codex.as_str())); @@ -7111,6 +7123,7 @@ fn skills_page_renders_sync_method_and_installed_rows() { assert!(all.contains(AppType::OpenCode.as_str())); assert!(all.contains(AppType::Hermes.as_str())); assert!(all.contains(AppType::Pi.as_str())); + assert!(all.contains(AppType::Omp.as_str())); assert!(!all.contains("hello-skill")); assert!(all.contains("Hello Skill")); } @@ -7163,7 +7176,7 @@ fn skills_page_empty_state_keeps_summary_and_shows_guidance() { // The summary bar stays; the blank table body is replaced with the // shared empty-state guidance (same style as MCP/Prompts/Providers). - assert!(all.contains(&texts::tui_skills_installed_counts(0, 0, 0, 0, 0, 0))); + assert!(all.contains(&texts::tui_skills_installed_counts(0, 0, 0, 0, 0, 0, 0))); assert!(all.contains(texts::tui_skills_empty_title())); assert!(all.contains(texts::tui_skills_empty_subtitle())); } @@ -7224,6 +7237,7 @@ fn skills_page_shows_opencode_summary() { opencode: true, hermes: false, pi: false, + omp: false, }; data.skills.installed = vec![skill]; @@ -7251,6 +7265,7 @@ fn skills_page_shows_hermes_column_and_summary() { opencode: false, hermes: true, pi: false, + omp: false, }; data.skills.installed = vec![skill]; @@ -7281,6 +7296,7 @@ fn skill_detail_page_shows_opencode_enabled_state() { opencode: true, hermes: false, pi: false, + omp: false, }; data.skills.installed = vec![skill]; @@ -7312,6 +7328,7 @@ fn skill_detail_page_shows_hermes_enabled_state() { opencode: false, hermes: true, pi: false, + omp: false, }; data.skills.installed = vec![skill]; @@ -10800,6 +10817,136 @@ fn workspace_non_openclaw_nav_keeps_generic_labels() { } } +#[test] +fn omp_nav_uses_shared_resource_labels() { + let _lock = lock_env(); + let _lang = use_test_language(Language::English); + let _no_color = EnvGuard::remove("NO_COLOR"); + + let app = App::new(Some(AppType::Omp)); + let all = nav_text(&app, &render(&app, &minimal_data(&app.app_type))); + let expected = [ + NavItem::Main, + NavItem::Providers, + NavItem::OmpModels, + NavItem::OmpRoles, + NavItem::OmpSystemPrompts, + NavItem::Skills, + NavItem::Usage, + NavItem::Settings, + NavItem::Exit, + ] + .map(nav_label_text); + let positions = expected + .iter() + .map(|label| all.find(label).expect("OMP nav label should render")) + .collect::>(); + + assert!(positions.windows(2).all(|pair| pair[0] < pair[1]), "{all}"); + assert!(!all.contains("OMP Models"), "{all}"); + assert!(!all.contains("OMP Roles"), "{all}"); + assert!(!all.contains("OMP System Prompts"), "{all}"); +} + +#[test] +fn omp_pages_use_shared_titles_and_action_labels() { + let _lock = lock_env(); + let _lang = use_test_language(Language::English); + let _no_color = EnvGuard::remove("NO_COLOR"); + + for (route, title, empty_title) in [ + ( + Route::OmpModels, + texts::menu_omp_models(), + texts::tui_omp_models_empty_title(), + ), + ( + Route::OmpRoles, + texts::menu_omp_roles(), + texts::tui_omp_roles_empty_title(), + ), + ] { + let mut app = App::new(Some(AppType::Omp)); + app.route = route; + app.focus = Focus::Content; + let content = content_text(&app, &render(&app, &minimal_data(&app.app_type))); + assert!(content.contains(&buffer_cell_text(title)), "{content}"); + assert!(content.contains(empty_title), "{content}"); + assert!(content.contains(texts::tui_key_edit()), "{content}"); + assert!(!content.contains("OMP Models"), "{content}"); + assert!(!content.contains("OMP Roles"), "{content}"); + } +} + +#[test] +fn omp_system_prompts_page_uses_shared_copy_and_headers() { + let _lock = lock_env(); + let _lang = use_test_language(Language::English); + let _no_color = EnvGuard::remove("NO_COLOR"); + + let mut app = App::new(Some(AppType::Omp)); + app.route = Route::OmpSystemPrompts; + app.focus = Focus::Content; + let mut data = minimal_data(&app.app_type); + data.pi_prompts.system_files = vec![( + crate::services::pi_prompt_files::PiPromptFileKind::SystemOverride, + crate::services::pi_prompt_files::PiPromptFileSnapshot { + exists: true, + revision: "rev".to_string(), + content: "Use the configured role.".to_string(), + }, + )]; + + let content = content_text(&app, &render(&app, &data)); + assert!(content.contains(&buffer_cell_text(texts::menu_omp_system_prompts()))); + assert!(content.contains("1 native prompt file"), "{content}"); + assert!(content.contains("File"), "{content}"); + assert!(content.contains("Mode"), "{content}"); + assert!(content.contains("Characters"), "{content}"); + assert!( + content.contains(texts::tui_omp_prompt_active_path_header()), + "{content}" + ); + assert!(content.contains(texts::tui_key_view()), "{content}"); + assert!(content.contains(texts::tui_key_edit()), "{content}"); + assert!(content.contains(texts::tui_key_delete()), "{content}"); + assert!(!content.contains("OMP System Prompts"), "{content}"); +} + +#[test] +fn omp_models_table_adapts_to_narrow_terminals() { + let _lock = lock_env(); + let _lang = use_test_language(Language::English); + let _no_color = EnvGuard::remove("NO_COLOR"); + + let mut app = App::new(Some(AppType::Omp)); + app.route = Route::OmpModels; + app.focus = Focus::Content; + let mut data = minimal_data(&app.app_type); + data.omp.models = vec![crate::omp_config::OMPNativeModel { + provider_id: "company".to_string(), + model_id: "gpt-5.6".to_string(), + config: json!({ + "name": "GPT 5.6", + "api": "openai-completions", + "reasoning": true, + "contextWindow": 400000, + "maxTokens": 128000, + }), + }]; + + assert_eq!(super::omp_model_column_count(64), 3); + assert_eq!(super::omp_model_column_count(90), 5); + let narrow = content_text(&app, &render_with_size(&app, &data, 64, 24)); + assert!(narrow.contains("company"), "{narrow}"); + assert!(narrow.contains("gpt-5.6"), "{narrow}"); + + let wide = content_text(&app, &render_with_size(&app, &data, 300, 24)); + assert_eq!(super::omp_model_column_count(300), 7); + assert!(wide.contains("Reasoning"), "{wide}"); + assert!(wide.contains("Max Tokens"), "{wide}"); +} + #[test] fn workspace_route_render_shows_workspace_files_and_daily_memory_entry() { let _lock = lock_env(); @@ -12768,6 +12915,23 @@ fn provider_form_model_field_hints_enter_edit_and_f_fetch() { ); } +#[test] +fn omp_provider_model_field_key_bar_advertises_fetch() { + let _lang = use_test_language(Language::English); + let keys = super::add_form_key_items_for_app( + FormFocus::Fields, + false, + Some(ProviderAddField::OpenClawModels), + Some(&AppType::Omp), + ); + assert_eq!( + keys.iter() + .find(|(key, _label)| *key == "f") + .map(|(_key, label)| *label), + Some(texts::tui_key_fetch_model()) + ); +} + #[test] fn provider_template_field_key_bar_advertises_select() { let _lang = use_test_language(Language::English); diff --git a/src-tauri/src/cli/ui/colors.rs b/src-tauri/src/cli/ui/colors.rs index d9c147e51..f969d2803 100644 --- a/src-tauri/src/cli/ui/colors.rs +++ b/src-tauri/src/cli/ui/colors.rs @@ -37,6 +37,7 @@ fn inquire_color_for_app(app_type: &AppType) -> InquireColor { AppType::Hermes => InquireColor::LightBlue, AppType::OpenClaw => InquireColor::LightRed, AppType::Pi => InquireColor::LightCyan, + AppType::Omp => InquireColor::LightCyan, } } @@ -90,6 +91,7 @@ fn highlight_color_for_app(app_type: &AppType) -> Color { AppType::Hermes => Color::BrightBlue, AppType::OpenClaw => Color::BrightRed, AppType::Pi => Color::BrightCyan, + AppType::Omp => Color::BrightCyan, } } diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index 7668d9c85..af20e85fd 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -46,6 +46,7 @@ impl Database { opencode: row.get(11)?, hermes: row.get(12)?, pi: false, + omp: false, }, installed_at: row.get(13)?, content_hash: row.get(14)?, @@ -91,6 +92,7 @@ impl Database { opencode: row.get(11)?, hermes: row.get(12)?, pi: false, + omp: false, }, installed_at: row.get(13)?, content_hash: row.get(14)?, diff --git a/src-tauri/src/deeplink/mod.rs b/src-tauri/src/deeplink/mod.rs index 47b588aba..3c0e9d5b6 100644 --- a/src-tauri/src/deeplink/mod.rs +++ b/src-tauri/src/deeplink/mod.rs @@ -93,4 +93,6 @@ pub struct DeepLinkImportRequest { #[serde(skip)] pub(crate) openclaw_config: Option, + #[serde(skip)] + pub(crate) omp_config: Option, } diff --git a/src-tauri/src/deeplink/parser.rs b/src-tauri/src/deeplink/parser.rs index aa195e7c9..de2ec5073 100644 --- a/src-tauri/src/deeplink/parser.rs +++ b/src-tauri/src/deeplink/parser.rs @@ -81,10 +81,10 @@ fn parse_provider_deeplink( // Validate app type if !matches!( app.as_str(), - "claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes" + "claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes" | "omp" | "oh-my-pi" ) { return Err(AppError::InvalidInput(format!( - "Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'" + "Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', 'hermes', or 'omp', got '{app}'" ))); } @@ -174,6 +174,7 @@ fn parse_provider_deeplink( usage_user_id, usage_auto_interval, openclaw_config: None, + omp_config: None, }) } @@ -191,10 +192,18 @@ fn parse_prompt_deeplink( // Validate app type if !matches!( app.as_str(), - "claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes" | "pi" + "claude" + | "codex" + | "gemini" + | "opencode" + | "openclaw" + | "hermes" + | "pi" + | "omp" + | "oh-my-pi" ) { return Err(AppError::InvalidInput(format!( - "Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', 'hermes', or 'pi', got '{app}'" + "Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', 'hermes', 'pi', or 'omp', got '{app}'" ))); } @@ -245,6 +254,7 @@ fn parse_prompt_deeplink( usage_user_id: None, usage_auto_interval: None, openclaw_config: None, + omp_config: None, }) } @@ -311,6 +321,7 @@ fn parse_mcp_deeplink( usage_user_id: None, usage_auto_interval: None, openclaw_config: None, + omp_config: None, }) } @@ -334,6 +345,28 @@ fn parse_skill_deeplink( let directory = params.get("directory").cloned(); let branch = params.get("branch").cloned(); + let apps = params.get("apps").cloned(); + if let Some(raw_apps) = apps.as_deref() { + for app in raw_apps.split(',') { + let trimmed = app.trim(); + if !matches!( + trimmed, + "claude" + | "codex" + | "gemini" + | "opencode" + | "openclaw" + | "hermes" + | "pi" + | "omp" + | "oh-my-pi" + ) { + return Err(AppError::InvalidInput(format!( + "Invalid app in 'apps': unsupported app '{trimmed}'" + ))); + } + } + } Ok(DeepLinkImportRequest { version, @@ -355,7 +388,7 @@ fn parse_skill_deeplink( opus_model: None, content: None, description: None, - apps: None, + apps, config: None, config_format: None, config_url: None, @@ -367,5 +400,6 @@ fn parse_skill_deeplink( usage_user_id: None, usage_auto_interval: None, openclaw_config: None, + omp_config: None, }) } diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 77621b7c2..feb47c48e 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -29,26 +29,32 @@ pub fn import_provider_from_deeplink( .clone() .ok_or_else(|| AppError::InvalidInput("Missing 'app' field for provider".to_string()))?; - let api_key = merged_request.api_key.as_ref().ok_or_else(|| { - AppError::InvalidInput("API key is required (either in URL or config file)".to_string()) - })?; - if api_key.is_empty() { - return Err(AppError::InvalidInput( - "API key cannot be empty".to_string(), - )); + let is_omp = matches!(app_str.as_str(), "omp" | "oh-my-pi"); + if !is_omp { + let api_key = merged_request.api_key.as_ref().ok_or_else(|| { + AppError::InvalidInput("API key is required (either in URL or config file)".to_string()) + })?; + if api_key.is_empty() { + return Err(AppError::InvalidInput( + "API key cannot be empty".to_string(), + )); + } } - let endpoint_str = merged_request.endpoint.as_ref().ok_or_else(|| { - AppError::InvalidInput("Endpoint is required (either in URL or config file)".to_string()) - })?; - let all_endpoints: Vec = endpoint_str + let all_endpoints: Vec = merged_request + .endpoint + .as_deref() + .unwrap_or_default() .split(',') .map(|e| e.trim().to_string()) .filter(|e| !e.is_empty()) .collect(); - let primary_endpoint = all_endpoints - .first() - .ok_or_else(|| AppError::InvalidInput("Endpoint cannot be empty".to_string()))?; + if all_endpoints.is_empty() && !is_omp { + return Err(AppError::InvalidInput( + "Endpoint is required (either in URL or config file)".to_string(), + )); + } + let primary_endpoint = all_endpoints.first().map(String::as_str); for (i, endpoint) in all_endpoints.iter().enumerate() { validate_url(endpoint, &format!("endpoint[{i}]"))?; @@ -59,7 +65,7 @@ pub fn import_provider_from_deeplink( .as_ref() .is_none_or(|s| s.is_empty()) { - merged_request.homepage = infer_homepage_from_endpoint(primary_endpoint); + merged_request.homepage = primary_endpoint.and_then(infer_homepage_from_endpoint); if merged_request.homepage.is_none() { merged_request.homepage = match merged_request.app.as_deref() { @@ -68,6 +74,9 @@ pub fn import_provider_from_deeplink( Some("gemini") => Some("https://ai.google.dev".to_string()), Some("opencode") => Some("https://opencode.ai".to_string()), Some("hermes") => Some("https://hermes.sh".to_string()), + Some("omp") | Some("oh-my-pi") => { + Some("https://github.com/can1357/oh-my-pi".to_string()) + } _ => None, }; } @@ -147,6 +156,7 @@ fn build_provider_from_request( "Pi providers must be added from the Pi provider page".to_string(), )); } + AppType::Omp => build_omp_settings(request), }; let meta = build_provider_meta(request)?; @@ -444,6 +454,96 @@ fn build_openclaw_settings(request: &DeepLinkImportRequest) -> serde_json::Value serde_json::Value::Object(settings) } +fn build_omp_settings(request: &DeepLinkImportRequest) -> serde_json::Value { + let endpoint = get_primary_endpoint(request); + let mut settings = request + .omp_config + .as_ref() + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + if !endpoint.is_empty() { + settings.insert("baseUrl".to_string(), json!(endpoint)); + } + if let Some(api_key) = request.api_key.as_deref().filter(|value| !value.is_empty()) { + settings.insert("apiKey".to_string(), json!(api_key)); + // URL parameters have higher precedence than inline config. Remove a + // conflicting auth mode so an explicit key is not silently ignored. + if matches!( + settings.get("auth").and_then(Value::as_str), + Some("none") | Some("oauth") + ) { + settings.remove("auth"); + } + } + if request.omp_config.is_none() { + settings.insert( + "api".to_string(), + json!(crate::omp_config::OMP_DEFAULT_API_PROTOCOL), + ); + } + if let Some(model) = request + .model + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + settings.insert("models".to_string(), json!([{ "id": model }])); + // A URL-level model replaces the inline model list. If the inline + // provider omitted a provider-level API, inherit the first model's + // protocol when available; otherwise use OMP's native default. This + // keeps the generated bare model valid under OMP's strict schema. + if !settings.contains_key("api") { + let inherited_api = request + .omp_config + .as_ref() + .and_then(Value::as_object) + .and_then(|config| config.get("models")) + .and_then(Value::as_array) + .and_then(|models| { + models.iter().find_map(|model| { + model + .as_object() + .and_then(|model| model.get("api")) + .and_then(Value::as_str) + .filter(|api| !api.trim().is_empty()) + }) + }); + settings.insert( + "api".to_string(), + json!(inherited_api.unwrap_or(crate::omp_config::OMP_DEFAULT_API_PROTOCOL)), + ); + } + } + + // A compact OMP deep link with a model but no credential is an explicit + // keyless provider (for example a local Ollama/llama.cpp endpoint). OMP's + // native validator requires that intent to be represented as `auth: none`; + // preserve an explicit auth mode, OAuth descriptor, or extension-owned + // entry instead of overriding it. + let has_models = settings + .get("models") + .and_then(Value::as_array) + .is_some_and(|models| !models.is_empty()); + let has_api_key = settings + .get("apiKey") + .and_then(Value::as_str) + .is_some_and(|value| !value.trim().is_empty()); + let has_oauth_descriptor = settings.get("oauth").is_some_and(|value| { + value.as_str().is_some_and(|value| !value.trim().is_empty()) || value.is_object() + }); + if has_models + && !has_api_key + && !settings.contains_key("auth") + && !has_oauth_descriptor + && !settings.contains_key("extension") + { + settings.insert("auth".to_string(), json!("none")); + } + + Value::Object(settings) +} + /// Parse and merge configuration from Base64 encoded config or remote URL. /// /// Priority: URL params > inline config > remote config. @@ -476,6 +576,12 @@ pub fn parse_and_merge_config( serde_json::to_value(toml_value) .map_err(|e| AppError::Message(format!("Failed to convert TOML to JSON: {e}")))? } + "yaml" | "yml" => { + let yaml_value: serde_yaml::Value = serde_yaml::from_str(&config_content) + .map_err(|e| AppError::InvalidInput(format!("Invalid YAML config: {e}")))?; + serde_json::to_value(yaml_value) + .map_err(|e| AppError::Message(format!("Failed to convert YAML to JSON: {e}")))? + } _ => { return Err(AppError::InvalidInput(format!( "Unsupported config format: {format}" @@ -495,6 +601,7 @@ pub fn parse_and_merge_config( "opencode" => merge_additive_config(&mut merged, &config_value)?, "hermes" => merge_additive_config(&mut merged, &config_value)?, "openclaw" => merge_openclaw_config(&mut merged, &config_value)?, + "omp" | "oh-my-pi" => merge_omp_config(&mut merged, &config_value)?, "" => return Ok(merged), other => return Err(AppError::InvalidInput(format!("Invalid app type: {other}"))), } @@ -650,6 +757,46 @@ fn merge_gemini_config( Ok(()) } +fn merge_omp_config( + request: &mut DeepLinkImportRequest, + config: &serde_json::Value, +) -> Result<(), AppError> { + let object = config + .as_object() + .ok_or_else(|| AppError::InvalidInput("OMP config must be a JSON object".to_string()))?; + + if request + .api_key + .as_ref() + .is_none_or(|value| value.is_empty()) + { + if let Some(api_key) = object.get("apiKey").and_then(Value::as_str) { + request.api_key = Some(api_key.to_string()); + } + } + if request + .endpoint + .as_ref() + .is_none_or(|value| value.is_empty()) + { + if let Some(base_url) = object.get("baseUrl").and_then(Value::as_str) { + request.endpoint = Some(base_url.to_string()); + } + } + if request + .homepage + .as_ref() + .is_none_or(|value| value.is_empty()) + { + if let Some(endpoint) = request.endpoint.as_ref().filter(|value| !value.is_empty()) { + request.homepage = infer_homepage_from_endpoint(endpoint); + } + } + + request.omp_config = Some(config.clone()); + Ok(()) +} + fn merge_additive_config( request: &mut DeepLinkImportRequest, config: &serde_json::Value, @@ -792,6 +939,7 @@ fn reject_legacy_openclaw_aliases(config: &Map) -> Result<(), App #[cfg(test)] mod tests { use super::*; + use base64::Engine; fn import_request() -> DeepLinkImportRequest { serde_json::from_value(json!({ @@ -840,4 +988,52 @@ wire_api = "responses" assert_eq!(request.endpoint, None); } + + #[test] + fn omp_model_override_adds_api_for_inline_config_without_provider_api() { + let mut request = import_request(); + request.model = Some("gpt-4o".to_string()); + request.omp_config = Some(json!({ + "baseUrl": "https://api.example/v1", + "apiKey": "KEY" + })); + + let settings = build_omp_settings(&request); + assert_eq!(settings["api"], "openai-completions"); + assert_eq!(settings["models"][0]["id"], "gpt-4o"); + } + + #[test] + fn omp_model_override_inherits_inline_model_api() { + let mut request = import_request(); + request.model = Some("claude-3".to_string()); + request.omp_config = Some(json!({ + "baseUrl": "https://api.example/v1", + "models": [{"id": "old", "api": "anthropic-messages"}] + })); + + let settings = build_omp_settings(&request); + assert_eq!(settings["api"], "anthropic-messages"); + } + + #[test] + fn omp_yaml_config_is_decoded_and_merged() { + let yaml = "baseUrl: https://api.example/v1\napiKey: yaml-key\napi: openai-completions\n"; + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(yaml); + let mut request = import_request(); + request.app = Some("omp".to_string()); + request.config = Some(encoded); + request.config_format = Some("yaml".to_string()); + + let merged = parse_and_merge_config(&request).expect("merge YAML config"); + assert_eq!(merged.endpoint.as_deref(), Some("https://api.example/v1")); + assert_eq!(merged.api_key.as_deref(), Some("yaml-key")); + assert_eq!( + merged + .omp_config + .as_ref() + .and_then(|value| value["api"].as_str()), + Some("openai-completions") + ); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cbfb91bc4..86be0f674 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -25,6 +25,7 @@ mod import_export; mod init_status; mod mcp; mod model_capabilities; +mod omp_config; mod openclaw_config; mod opencode_config; mod pi_config; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 1493b28e8..bebc42729 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -54,6 +54,10 @@ fn run(cli: Cli) -> Result<(), AppError> { Some(Commands::Provider(cmd)) => { cc_switch_lib::cli::commands::provider::execute(cmd, cli.app) } + Some(Commands::Model(cmd)) => { + cc_switch_lib::cli::commands::omp::execute_model(cmd, cli.app) + } + Some(Commands::Role(cmd)) => cc_switch_lib::cli::commands::omp::execute_role(cmd, cli.app), Some(Commands::Use { id }) => cc_switch_lib::cli::commands::provider::execute( cc_switch_lib::cli::commands::provider::ProviderCommand::Switch { id }, cli.app, @@ -96,6 +100,8 @@ fn command_requires_startup_state(command: &Option) -> bool { match command { Some(Commands::Completions(_)) | Some(Commands::Auth(_)) + | Some(Commands::Model(_)) + | Some(Commands::Role(_)) | Some(Commands::Update(_)) | Some(Commands::Internal(_)) | Some(Commands::Sessions(_)) @@ -129,7 +135,10 @@ fn command_uses_deferred_codex_migration(command: &Option) -> bool { fn database_access_required(command: &Option) -> bool { !matches!( command, - Some(Commands::Completions(_)) | Some(Commands::Update(_)) + Some(Commands::Completions(_)) + | Some(Commands::Model(_)) + | Some(Commands::Role(_)) + | Some(Commands::Update(_)) ) } diff --git a/src-tauri/src/omp_config.rs b/src-tauri/src/omp_config.rs new file mode 100644 index 000000000..d8742ad68 --- /dev/null +++ b/src-tauri/src/omp_config.rs @@ -0,0 +1,5705 @@ +//! Thin adapter for OMP's native files. +//! +//! OMP owns account login and model selection in its native YAML files. +//! CC Switch only manages explicit provider entries in `models.yml`. + +use crate::config::{atomic_write_private, get_home_dir}; +use crate::error::AppError; +use indexmap::IndexMap; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io::Read; +#[cfg(unix)] +use std::os::unix::process::CommandExt; +use std::path::{Component, Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::{mpsc, LazyLock, Mutex, MutexGuard}; +use std::thread; +use std::time::{Duration, Instant}; +use url::Url; + +const MAX_OMP_FILE_BYTES: u64 = 1024 * 1024; +const MAX_COMMAND_OUTPUT_BYTES: usize = 64 * 1024; +const MISSING_MODELS_REVISION: &str = "missing"; +const MISSING_CONFIG_REVISION: &str = "missing"; +pub const OMP_DEFAULT_API_PROTOCOL: &str = "openai-completions"; +pub const OMP_API_PROTOCOLS: [&str; 9] = [ + "openai-completions", + "openai-responses", + "openai-codex-responses", + "azure-openai-responses", + "anthropic-messages", + "bedrock-converse-stream", + "google-generative-ai", + "google-gemini-cli", + "google-vertex", +]; +/// OMP's default timeout for native model discovery requests. +pub const OMP_DEFAULT_DISCOVERY_TIMEOUT_MS: u64 = 10_000; +/// Keep user-configured discovery requests bounded even when a malformed or +/// hostile native file contains an impractically large timeout. +pub const OMP_MAX_DISCOVERY_TIMEOUT_MS: u64 = 120_000; +/// Roles understood by the stock OMP model selector. OMP also accepts custom +/// role names through `modelTags`; those names are intentionally not +/// hard-coded by the adapter and are returned when present in `config.yml`. +pub const OMP_BUILTIN_MODEL_ROLES: [&str; 10] = [ + "default", "smol", "slow", "vision", "plan", "designer", "commit", "tiny", "task", "advisor", +]; +static MODELS_FILE_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); +static COMMAND_VALUE_CACHE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); +static COMMAND_FAILURE_CACHE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); +#[cfg(test)] +static TEST_AGENT_DIR: LazyLock>> = LazyLock::new(|| Mutex::new(None)); + +/// Resolve directory-affecting variables the way OMP's dotenv loader does. +/// The process environment wins; otherwise values are read from the current +/// project, active agent/config roots, and home `.env` files. A second pass +/// covers a custom `PI_CODING_AGENT_DIR` or `PI_CONFIG_DIR` introduced by one +/// of those files without recursing through `get_omp_agent_dir`. +fn resolve_omp_path_environment() -> HashMap { + let mut resolved = std::env::vars_os() + .map(|(key, value)| (key.to_string_lossy().into_owned(), value)) + .collect::>(); + // Only OMP_PROFILE is a documented OMP_* spelling for a PI_* variable. + // Do not invent aliases for arbitrary OMP_* names (for example + // OMP_CONFIG_DIR): the native executable ignores those process variables, + // so treating them as PI_CONFIG_DIR here would make CC-Switch edit a + // directory that `omp` never reads. + let mut aliases = resolved + .iter() + .filter(|(key, _)| key.as_str() == "OMP_PROFILE") + .map(|(_, value)| ("PI_PROFILE".to_string(), value.clone())) + .collect::>(); + for (key, value) in aliases.drain(..) { + // OMP_PROFILE is canonical and must override the legacy PI_PROFILE + // value when both are exported, matching OMP's profile resolver. + resolved.insert(key, value); + } + + let home = get_home_dir(); + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let mut paths = vec![cwd.join(".env")]; + let config_dir = resolved + .get("PI_CONFIG_DIR") + .cloned() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".omp")); + let config_root = omp_config_root(&home, config_dir); + let profile = effective_profile_lossy(&resolved); + let profile_root = profile + .as_ref() + .map(|name| config_root.join("profiles").join(name)) + .unwrap_or_else(|| config_root.clone()); + let agent_dir = effective_agent_dir_lossy(&resolved, &profile, &config_root) + .unwrap_or_else(|| profile_root.join("agent")); + paths.extend([ + agent_dir.join(".env"), + profile_root.join(".env"), + home.join(".env"), + ]); + merge_omp_path_dotenv_values(&mut resolved, &paths); + + // Values loaded from the first pass can redirect the roots themselves. + let config_dir = resolved + .get("PI_CONFIG_DIR") + .cloned() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".omp")); + let config_root = omp_config_root(&home, config_dir); + let profile = effective_profile_lossy(&resolved); + let profile_root = profile + .as_ref() + .map(|name| config_root.join("profiles").join(name)) + .unwrap_or_else(|| config_root.clone()); + let agent_dir = effective_agent_dir_lossy(&resolved, &profile, &config_root) + .unwrap_or_else(|| profile_root.join("agent")); + merge_omp_path_dotenv_values(&mut resolved, &{ + // OMP's named profiles load dotenv from the active profile root + // only. The base `~/.omp/.env` belongs to the default profile and + // must not redirect a named profile during this second pass. + let mut paths = vec![agent_dir.join(".env"), profile_root.join(".env")]; + if profile.is_none() { + paths.push(config_root.join(".env")); + } + paths.push(home.join(".env")); + paths + }); + resolved +} + +/// Return the profile selected by the process environment, preserving the +/// distinction between an explicitly empty canonical value and a missing +/// value. OMP snapshots these variables before loading dotenv files, so a +/// process-level `PI_PROFILE` must not be overridden by a project `.env` +/// `OMP_PROFILE`. +fn process_profile_override() -> Result>, AppError> { + if let Some(value) = std::env::var_os("OMP_PROFILE") { + return Ok(Some(normalize_profile(value.to_string_lossy().as_ref())?)); + } + if let Some(value) = std::env::var_os("PI_PROFILE") { + return Ok(Some(normalize_profile(value.to_string_lossy().as_ref())?)); + } + Ok(None) +} + +fn effective_profile( + _path_env: &HashMap, +) -> Result, AppError> { + // OMP resolves the active profile before loading any dotenv files. A + // profile mentioned only in ~/.omp/.env (or another dotenv layer) must + // therefore not redirect CC Switch into a profile that the native `omp` + // process will never use. `path_env` still carries dotenv-backed path + // overrides such as PI_CODING_AGENT_DIR and PI_CONFIG_DIR, but profile + // selection is intentionally process-environment-only. + Ok(process_profile_override()?.flatten()) +} + +fn effective_profile_lossy(path_env: &HashMap) -> Option { + effective_profile(path_env).ok().flatten() +} + +/// Resolve the agent directory using OMP's source-sensitive precedence. A +/// named process profile derives its own agent directory and suppresses any +/// `PI_CODING_AGENT_DIR`; profiles loaded from dotenv do not, so an explicit +/// process or dotenv agent override remains authoritative. +fn effective_agent_dir_lossy( + path_env: &HashMap, + profile: &Option, + config_root: &Path, +) -> Option { + let process_named_profile = process_profile_override() + .ok() + .flatten() + .flatten() + .is_some(); + if process_named_profile { + return None; + } + path_env + .get("PI_CODING_AGENT_DIR") + .map(resolve_omp_env_agent_path) + .filter(|value| { + !is_profile_derived_agent_dir_from_env(config_root, &value.clone().into_os_string()) + }) + .or_else(|| { + profile + .as_ref() + .map(|name| config_root.join("profiles").join(name).join("agent")) + }) +} + +fn merge_omp_path_dotenv_values( + resolved: &mut HashMap, + paths: &[PathBuf], +) { + for path in paths { + for (key, value) in read_omp_dotenv_file(path) { + // Preserve an explicitly empty canonical profile in the merged + // dotenv view for command-backed values, but never use it to + // select a profile: active profile resolution is process-only. + // Other empty path settings are ignored so they cannot turn a + // directory into cwd. + if value.trim().is_empty() && key != "OMP_PROFILE" { + continue; + } + if resolved.contains_key(&key) { + continue; + } + resolved.insert(key.clone(), std::ffi::OsString::from(&value)); + if key == "OMP_PROFILE" { + resolved + .entry("PI_PROFILE".to_string()) + .or_insert_with(|| std::ffi::OsString::from(&value)); + } + } + } +} + +pub(crate) fn get_omp_agent_dir() -> Result { + let home = get_home_dir(); + let path_env = resolve_omp_path_environment(); + let config_dir = path_env + .get("PI_CONFIG_DIR") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".omp")); + // OMP treats `PI_CONFIG_DIR` as a directory name relative to the user's + // home (for example `.omp`), including when a caller supplies a leading + // separator. Keep that upstream behavior while preventing path escape. + let config_root = omp_config_root(&home, config_dir); + let profile = effective_profile(&path_env)?; + let default_path = profile + .as_ref() + .map(|profile| config_root.join("profiles").join(profile).join("agent")) + .unwrap_or_else(|| config_root.join("agent")); + #[cfg(test)] + if let Some(path) = TEST_AGENT_DIR + .lock() + .expect("lock OMP test directory") + .clone() + { + return resolve_omp_agent_dir(Some(path), None, default_path); + } + + let env_override = if process_profile_override()?.flatten().is_none() { + path_env + .get("PI_CODING_AGENT_DIR") + .cloned() + .filter(|value| { + // When a named profile was active, OMP may have exported its + // derived agent path through PI_CODING_AGENT_DIR. If the + // caller switches back to the default profile while that + // inherited value remains, ignore it rather than reopening + // the previous profile's directory. Explicit, unrelated + // overrides continue to work in default mode. + !is_profile_derived_agent_dir_from_env(&config_root, value) + }) + } else { + None + }; + + // `settings.omp_config_dir` is a CC-Switch-only legacy setting. OMP does + // not inherit it when launched from the user's shell, so using it here + // would make a successful write invisible to the real runtime. Only the + // official OMP selectors are authoritative for native file paths. + resolve_omp_agent_dir(None, env_override, default_path) +} + +/// Resolve the native OMP session directory. OMP stores session data in the +/// XDG data root after `omp config migrate` (for example +/// `$XDG_DATA_HOME/omp/sessions`) while models/config remain under the agent +/// directory. Explicit `PI_CODING_AGENT_DIR` overrides continue to use their +/// own `sessions` child, matching OMP's `agentSubdir` semantics. +pub(crate) fn get_omp_sessions_dir() -> Result { + let agent_dir = get_omp_agent_dir()?; + let path_env = resolve_omp_path_environment(); + let config_dir = path_env + .get("PI_CONFIG_DIR") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".omp")); + let config_root = omp_config_root(&get_home_dir(), config_dir); + let profile = effective_profile(&path_env)?; + let default_agent = profile + .as_ref() + .map(|name| config_root.join("profiles").join(name).join("agent")) + .unwrap_or_else(|| config_root.join("agent")); + let explicit_agent_override = path_env + .get("PI_CODING_AGENT_DIR") + .filter(|value| !value.is_empty()) + .is_some_and(|value| !is_profile_derived_agent_dir_from_env(&config_root, value)); + + if !explicit_agent_override && agent_dir == default_agent { + if let Some(xdg_data) = path_env + .get("XDG_DATA_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + { + let app_root = xdg_data.join("omp"); + let migrated_root = profile + .as_ref() + .map(|name| app_root.join("profiles").join(name)) + .unwrap_or(app_root); + let migrated_sessions = migrated_root.join("sessions"); + if migrated_sessions.is_dir() { + return Ok(migrated_sessions); + } + } + } + Ok(agent_dir.join("sessions")) +} + +/// Resolve OMP's shared user-config directory used by the cross-agent prompt +/// discovery layer. Unlike native models/MCP files, shared prompt lookup uses +/// the configured OMP base/profile and intentionally ignores an arbitrary +/// `PI_CODING_AGENT_DIR` override. +pub(crate) fn get_omp_shared_config_agent_dir() -> Result { + #[cfg(test)] + if let Some(path) = TEST_AGENT_DIR + .lock() + .expect("lock OMP test directory") + .clone() + { + return Ok(path); + } + + let home = get_home_dir(); + let path_env = resolve_omp_path_environment(); + let config_dir = path_env + .get("PI_CONFIG_DIR") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".omp")); + let config_root = omp_config_root(&home, config_dir); + let profile = effective_profile(&path_env)?; + Ok(profile + .map(|profile| config_root.join("profiles").join(profile).join("agent")) + .unwrap_or_else(|| config_root.join("agent"))) +} + +fn is_profile_derived_agent_dir_from_env(config_root: &Path, value: &std::ffi::OsString) -> bool { + let candidate = resolve_omp_env_agent_path(value); + ["OMP_PROFILE", "PI_PROFILE"].iter().any(|key| { + std::env::var_os(key) + .and_then(|raw| { + normalize_profile(raw.to_string_lossy().as_ref()) + .ok() + .flatten() + }) + .is_some_and(|profile| { + candidate == config_root.join("profiles").join(profile).join("agent") + }) + }) +} + +fn omp_config_root(home: &Path, config_dir: PathBuf) -> PathBuf { + // OMP treats PI_CONFIG_DIR as a directory *name* under the user's home + // (its upstream implementation uses `path.join(os.homedir(), value)`). + // Strip a leading separator so an accidentally absolute-looking value + // cannot escape that root while retaining the same join semantics. + let config_dir = config_dir.to_string_lossy(); + normalize_omp_path(home.join(config_dir.trim_start_matches(['/', '\\']))) +} + +/// Resolve an OMP `PI_CODING_AGENT_DIR` value the same way its Node runtime +/// does: absolute values are kept, while relative values are rooted at the +/// current working directory. Unlike CC-Switch's user-facing settings, +/// OMP does not expand a leading `~` in this variable. +fn resolve_omp_env_agent_path(value: &std::ffi::OsString) -> PathBuf { + let raw = PathBuf::from(value); + let path = if raw.is_absolute() { + raw + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(raw) + }; + normalize_omp_path(path) +} + +/// Lexically normalize a path without requiring the target to exist. OMP's +/// `path.join` removes `.` and `..`; Rust's `PathBuf::join` intentionally does +/// not, so normalizing here keeps the adapter and the official resolver +/// comparable even for not-yet-created profile directories. +fn normalize_omp_path(path: PathBuf) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + Component::RootDir => normalized.push(std::path::MAIN_SEPARATOR.to_string()), + Component::CurDir => {} + Component::ParentDir => { + if !normalized.pop() { + normalized.push(component.as_os_str()); + } + } + Component::Normal(value) => normalized.push(value), + } + } + normalized +} + +fn normalize_profile(raw: &str) -> Result, AppError> { + let profile = raw.trim(); + // OMP reserves only the lowercase `default` profile name. Other casing + // is rejected by its lowercase profile-name grammar rather than treated + // as the default profile. + if profile.is_empty() || profile == "default" { + return Ok(None); + } + let valid = profile.len() <= 64 + && profile.chars().enumerate().all(|(index, ch)| { + ch.is_ascii_lowercase() + || ch.is_ascii_digit() + || (index > 0 && matches!(ch, '.' | '_' | '-')) + }); + if !valid + || profile == "." + || profile == ".." + || profile.ends_with('.') + || is_windows_reserved_profile_name(profile) + { + return Err(AppError::InvalidInput(format!( + "Invalid OMP profile '{raw}'. Use 1-64 lowercase letters, digits, '.', '_' or '-'; Windows reserved device names are not allowed." + ))); + } + Ok(Some(profile.to_string())) +} + +/// Windows reserves device names such as `CON` and `COM1` as path aliases, +/// including when followed by an extension (`con.foo`). OMP rejects these +/// names up front so profile directories behave consistently across hosts. +fn is_windows_reserved_profile_name(profile: &str) -> bool { + let basename = profile.split('.').next().unwrap_or(profile); + matches!(basename, "con" | "prn" | "aux" | "nul") + || (basename.len() == 4 + && (basename.starts_with("com") || basename.starts_with("lpt")) + && basename.as_bytes()[3].is_ascii_digit()) +} + +fn resolve_omp_agent_dir( + settings_override: Option, + env_override: Option, + default_path: PathBuf, +) -> Result { + // Match OMP's own precedence: an explicit PI_CODING_AGENT_DIR controls + // the process, while the CC-Switch setting is only a fallback for native + // files when no official environment override is present. This prevents + // the adapter from silently editing a different directory than `omp`. + let (path, source) = match env_override { + Some(value) if !value.is_empty() => { + (resolve_omp_env_agent_path(&value), "PI_CODING_AGENT_DIR") + } + _ => match settings_override { + Some(path) => (normalize_omp_path(path), "OMP settings override"), + None => (default_path, "OMP default"), + }, + }; + if !path.is_absolute() { + return Err(AppError::InvalidInput(format!( + "{source} must resolve to an absolute directory: {}", + path.display() + ))); + } + Ok(path) +} + +pub(crate) fn get_omp_models_path() -> Result { + let dir = get_omp_agent_dir()?; + let canonical = dir.join("models.yml"); + if canonical.exists() { + return Ok(canonical); + } + let fallback = dir.join("models.yaml"); + if fallback.exists() { + return Ok(fallback); + } + let legacy = dir.join("models.json"); + if legacy.exists() { + let bytes = read_file_limited(&legacy, "OMP legacy models")?; + let source_revision = revision(&bytes); + // OMP's legacy registry is JSONC (JSON5), not YAML. Parse it with the + // same relaxed grammar used by the native migration (comments, + // trailing commas, and unquoted keys are all accepted), then emit a + // canonical YAML document at the path OMP reads going forward. + // Serializing the parsed value also prevents carrying JSONC syntax + // into `models.yml`, where the YAML parser would reject it later. + let source = String::from_utf8(bytes).map_err(|error| { + AppError::Config(format!( + "OMP legacy models file must be UTF-8 ({}): {error}", + legacy.display() + )) + })?; + let document: Value = json5::from_str(&source).map_err(|error| { + AppError::Config(format!( + "OMP legacy models file is not valid JSON/JSONC ({}): {error}", + legacy.display() + )) + })?; + let yaml = serde_yaml::to_string(&document).map_err(|error| { + AppError::Config(format!( + "failed to serialize migrated OMP models ({}): {error}", + legacy.display() + )) + })?; + validate_omp_models_root(&document, &canonical)?; + // Legacy migration writes the first sensitive native file directly + // from this path resolver, so apply the same directory safety checks + // used by every later models/config mutation before creating it. + ensure_private_omp_parent(&canonical)?; + // The legacy source remains authoritative until OMP (or this adapter) + // materializes `models.yml`. Re-check it after parsing/serializing so + // an external edit cannot be silently shadowed by stale YAML. + ensure_omp_legacy_models_revision(&legacy, &source_revision)?; + // Another process may have created the canonical file while this + // migration was in progress. Never overwrite that newer native file. + if canonical.exists() { + return Ok(canonical); + } + atomic_write_private(&canonical, yaml.as_bytes())?; + return Ok(canonical); + } + Ok(canonical) +} + +pub(crate) fn get_omp_settings_path() -> Result { + let dir = get_omp_agent_dir()?; + let canonical = dir.join("config.yml"); + if canonical.exists() { + return Ok(canonical); + } + let fallback = dir.join("config.yaml"); + if fallback.exists() { + return Ok(fallback); + } + Ok(canonical) +} + +/// Legacy global OMP settings were stored as JSONC in `settings.json`. +/// OMP only consults this file when no YAML config exists, then migrates it to +/// `config.yml`; the adapter keeps the same read precedence without mutating +/// the user's files merely by inspecting them. +fn get_omp_legacy_settings_path() -> Result { + Ok(get_omp_agent_dir()?.join("settings.json")) +} + +/// Project settings retain the historical JSONC filename as a lower-priority +/// layer. OMP loads `.omp/settings.json` first and then lets `.omp/config.yml` +/// override matching keys. +fn get_omp_project_legacy_settings_path() -> Result { + let cwd = std::env::current_dir().map_err(|error| { + AppError::Config(format!("failed to resolve current directory: {error}")) + })?; + Ok(cwd.join(".omp").join("settings.json")) +} + +/// OMP keeps project settings in the working directory rather than beneath +/// the user agent directory. `config.yml` is the canonical write target, +/// while an existing `config.yaml` is retained for compatibility. +pub(crate) fn get_omp_project_settings_path() -> Result { + let cwd = std::env::current_dir().map_err(|error| { + AppError::Config(format!("failed to resolve current directory: {error}")) + })?; + let root = cwd.join(".omp"); + let canonical = root.join("config.yml"); + if canonical.exists() { + return Ok(canonical); + } + let fallback = root.join("config.yaml"); + if fallback.exists() { + return Ok(fallback); + } + Ok(canonical) +} + +fn merge_omp_documents(base: &mut Value, overlay: &Value) { + let Some(base_object) = base.as_object_mut() else { + *base = overlay.clone(); + return; + }; + let Some(overlay_object) = overlay.as_object() else { + *base = overlay.clone(); + return; + }; + for (key, value) in overlay_object { + match (base_object.get_mut(key), value) { + (Some(existing), Value::Object(_)) if existing.is_object() => { + merge_omp_documents(existing, value) + } + _ => { + base_object.insert(key.clone(), value.clone()); + } + } + } +} + +fn parse_json5_document(path: &Path, label: &str) -> Result<(Value, String), AppError> { + if !path.exists() { + return Ok(( + Value::Object(Map::new()), + MISSING_CONFIG_REVISION.to_string(), + )); + } + let bytes = read_file_limited(path, label)?; + let revision = revision(&bytes); + if bytes.iter().all(u8::is_ascii_whitespace) { + return Ok((Value::Object(Map::new()), revision)); + } + let source = String::from_utf8(bytes).map_err(|error| { + AppError::Config(format!( + "{label} file must be UTF-8 ({}): {error}", + path.display() + )) + })?; + let document = json5::from_str::(&source).map_err(|error| { + AppError::Config(format!( + "{label} file is not valid JSON/JSONC ({}): {error}", + path.display() + )) + })?; + if !document.is_object() { + return Err(AppError::Config(format!( + "{label} root must be an object: {}", + path.display() + ))); + } + Ok((document, revision)) +} + +/// Read the effective global settings layer. A legacy `settings.json` is +/// consulted only when neither `config.yml` nor `config.yaml` exists, matching +/// OMP's one-time migration behavior. The returned path is always the YAML +/// write target, even when the source currently is legacy JSONC. +fn read_omp_global_settings_document() -> Result<(Value, PathBuf), AppError> { + let target = get_omp_settings_path()?; + if target.exists() { + return read_config_document(&target).map(|document| (document, target)); + } + let legacy = get_omp_legacy_settings_path()?; + if legacy.exists() { + return parse_json5_document(&legacy, "OMP legacy settings") + .map(|(document, _)| (document, target)); + } + Ok((Value::Object(Map::new()), target)) +} + +/// Read the project settings JSONC layer followed by the native YAML overlay. +/// `None` means neither project file exists. The second tuple member is the +/// canonical project YAML path used for writes. +fn read_omp_project_settings_layer() -> Result, AppError> { + let project_yaml = get_omp_project_settings_path()?; + let project_json = get_omp_project_legacy_settings_path()?; + let mut merged = Value::Object(Map::new()); + let mut present = false; + if project_json.exists() { + let (document, _) = parse_json5_document(&project_json, "OMP project legacy settings")?; + merge_omp_documents(&mut merged, &document); + present = true; + } + if project_yaml.exists() { + let document = read_config_document(&project_yaml)?; + merge_omp_documents(&mut merged, &document); + present = true; + } + Ok(present.then_some((merged, project_yaml))) +} + +/// A model entry as it appears in OMP's native `models.yml` registry. +/// +/// The complete model object is retained so callers can display or edit +/// forward-compatible OMP fields without reducing them to CC Switch's older +/// provider model shape. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct OMPNativeModel { + pub provider_id: String, + pub model_id: String, + pub config: Value, +} + +/// Read one config document's `modelRoles` map. +fn model_role_entries_from_document( + document: &Value, + path: &Path, +) -> Result>, AppError> { + let Some(value) = document.get("modelRoles") else { + return Ok(IndexMap::new()); + }; + let roles = value.as_object().ok_or_else(|| { + AppError::Config(format!( + "OMP config 'modelRoles' must be an object: {}", + path.display() + )) + })?; + let mut result = IndexMap::new(); + for (role, value) in roles { + validate_model_role_name(role)?; + match value { + Value::Null => { + result.insert(role.clone(), None); + } + Value::String(selector) if !selector.trim().is_empty() => { + validate_model_selector(selector)?; + result.insert(role.clone(), Some(selector.clone())); + } + Value::String(_) => {} + _ => { + return Err(AppError::Config(format!( + "OMP config modelRoles.{role} must be a string or null: {}", + path.display() + ))) + } + } + } + Ok(result) +} + +fn model_roles_from_document( + document: &Value, + path: &Path, +) -> Result, AppError> { + Ok(model_role_entries_from_document(document, path)? + .into_iter() + .filter_map(|(role, selector)| selector.map(|selector| (role, selector))) + .collect()) +} + +fn omp_model_roles_use_project_storage() -> Result { + let (global, global_path) = read_omp_global_settings_document()?; + let mut storage = match global.get("modelRoleStorage") { + None => "global".to_string(), + Some(value) => value + .as_str() + .ok_or_else(|| { + AppError::Config(format!( + "OMP config modelRoleStorage must be 'global' or 'project': {}", + global_path.display() + )) + })? + .trim() + .to_ascii_lowercase(), + }; + + if let Some((project, project_path)) = read_omp_project_settings_layer()? { + if let Some(value) = project.get("modelRoleStorage") { + storage = value + .as_str() + .ok_or_else(|| { + AppError::Config(format!( + "OMP config modelRoleStorage must be 'global' or 'project': {}", + project_path.display() + )) + })? + .trim() + .to_ascii_lowercase(); + } + } + + match storage.as_str() { + "global" | "" => Ok(false), + "project" => Ok(true), + _ => Err(AppError::Config(format!( + "OMP config modelRoleStorage must be 'global' or 'project', got '{storage}'" + ))), + } +} + +fn omp_model_roles_target_path() -> Result { + if omp_model_roles_use_project_storage()? { + get_omp_project_settings_path() + } else { + get_omp_settings_path() + } +} + +/// Read OMP's effective `modelRoles` map and the file that owns role writes. +/// In project storage mode, project assignments overlay global assignments and +/// missing project roles continue to fall back to the global file. When the +/// target YAML has not been created yet, the returned revision is taken from +/// the legacy JSONC source that will be migrated by the next write. +pub(crate) fn read_omp_model_roles_with_metadata( +) -> Result<(IndexMap, PathBuf, String), AppError> { + let _guard = lock_models_file()?; + read_omp_model_roles_with_metadata_locked() +} + +fn read_omp_model_roles_with_metadata_locked( +) -> Result<(IndexMap, PathBuf, String), AppError> { + let (global, global_path) = read_omp_global_settings_document()?; + let mut roles = model_roles_from_document(&global, &global_path)?; + let target_path = if omp_model_roles_use_project_storage()? { + let project_path = get_omp_project_settings_path()?; + if let Some((project, _)) = read_omp_project_settings_layer()? { + for (role, selector) in model_role_entries_from_document(&project, &project_path)? { + if let Some(selector) = selector { + roles.insert(role, selector); + } + } + } + project_path + } else { + global_path + }; + let (_, revision) = read_omp_config_yaml_at(&target_path)?; + Ok((roles, target_path, revision)) +} + +/// Read OMP's effective `modelRoles` map from global/project config. +pub(crate) fn read_omp_model_roles() -> Result, AppError> { + read_omp_model_roles_with_metadata().map(|(roles, _, _)| roles) +} + +fn effective_omp_settings_document() -> Result<(Value, PathBuf), AppError> { + let (mut effective, global_path) = read_omp_global_settings_document()?; + if let Some((project, project_path)) = read_omp_project_settings_layer()? { + let project_has_disabled = project.get("disabledProviders").is_some(); + merge_omp_documents(&mut effective, &project); + if project_has_disabled { + return Ok((effective, project_path)); + } + } + Ok((effective, global_path)) +} + +fn disabled_provider_values( + value: Option<&Value>, + path: &Path, +) -> Result, AppError> { + let Some(value) = value else { + return Ok(HashSet::new()); + }; + let entries = value.as_array().ok_or_else(|| { + AppError::Config(format!( + "OMP config disabledProviders must be an array: {}", + path.display() + )) + })?; + let cwd = std::env::current_dir().map_err(|error| { + AppError::Config(format!("failed to resolve current directory: {error}")) + })?; + let mut result = HashSet::new(); + for entry in entries { + match entry { + Value::String(id) if !id.trim().is_empty() => { + result.insert(id.trim().to_string()); + } + Value::Object(object) => { + if !disabled_provider_entry_applies_to_cwd(object, &cwd) { + continue; + } + let values = object + .get("providers") + .or_else(|| object.get("values")) + .or_else(|| object.get("items")); + if let Some(values) = values { + result.extend( + string_or_array_values(values) + .into_iter() + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_string), + ); + } + } + _ => {} + } + } + Ok(result) +} + +fn disabled_provider_entry_applies_to_cwd(object: &Map, cwd: &Path) -> bool { + let paths = ["path", "pathPrefix", "paths", "pathPrefixes"] + .into_iter() + .filter_map(|key| object.get(key)) + .flat_map(string_or_array_values) + .map(PathBuf::from) + .collect::>(); + paths.is_empty() || paths.iter().any(|path| path_matches_cwd(path, cwd)) +} + +/// OMP accepts either one string or an array of strings for scoped +/// `disabledProviders` fields. Invalid values are ignored while reading so a +/// malformed extension entry cannot hide otherwise valid provider settings; +/// writes use `validate_string_or_array_field` to reject them explicitly. +fn string_or_array_values(value: &Value) -> Vec<&str> { + match value { + Value::String(value) => vec![value.as_str()], + Value::Array(values) => values.iter().filter_map(Value::as_str).collect(), + _ => Vec::new(), + } +} + +fn validate_string_or_array_field(value: &Value, context: &str) -> Result<(), AppError> { + match value { + Value::String(_) => Ok(()), + Value::Array(values) if values.iter().all(Value::is_string) => Ok(()), + Value::Array(_) => Err(AppError::InvalidInput(format!( + "{context} values must be strings" + ))), + _ => Err(AppError::InvalidInput(format!( + "{context} must be a string or array" + ))), + } +} + +fn path_matches_cwd(raw: &Path, cwd: &Path) -> bool { + let raw = raw.to_string_lossy(); + let expanded = if raw == "~" { + get_home_dir() + } else if let Some(rest) = raw.strip_prefix("~/") { + get_home_dir().join(rest) + } else if raw.starts_with('/') { + raw.into_owned().into() + } else { + cwd.join(raw.as_ref()) + }; + let expanded = normalize_omp_path(expanded); + cwd == expanded || cwd.starts_with(&expanded) +} + +/// Return the effective OMP provider ids disabled for the current directory. +/// A project `disabledProviders` array replaces the global array, matching +/// OMP's settings merge semantics. +pub(crate) fn read_omp_disabled_providers() -> Result, AppError> { + let (document, path) = effective_omp_settings_document()?; + disabled_provider_values(document.get("disabledProviders"), &path) +} + +/// Add or remove a provider id from the active disabledProviders array while +/// preserving path-scoped entries and unrelated settings. +pub(crate) fn set_omp_provider_disabled(provider_id: &str, disabled: bool) -> Result<(), AppError> { + validate_provider_key(provider_id)?; + let _guard = lock_models_file()?; + // Enabling must fail closed when native settings cannot be parsed. A + // successful return while `disabledProviders` remains unreadable or + // unchanged is indistinguishable from a working provider to callers, but + // OMP would still ignore it (or reject the whole config). + let (global_document, global_path) = read_omp_global_settings_document()?; + let project_path = get_omp_project_settings_path()?; + let project_layer = read_omp_project_settings_layer()?; + let project_has_disabled = project_layer + .as_ref() + .is_some_and(|(document, _)| document.get("disabledProviders").is_some()); + let path = if project_has_disabled { + project_path.clone() + } else { + global_path.clone() + }; + + // OMP's legacy global JSON is a read-only source once YAML exists. When + // editing for the first time, seed the new YAML document from that source + // so unknown settings are not discarded. Likewise, a project + // `settings.json` remains the lower layer until we create an overriding + // `.omp/config.yml`; carry its disabledProviders array into that override. + let mut legacy_source_revision = None; + let (mut document, revision) = if path == global_path { + if !path.exists() { + let legacy = get_omp_legacy_settings_path()?; + if legacy.exists() { + let (_, source_revision) = parse_json5_document(&legacy, "OMP legacy settings")?; + legacy_source_revision = Some((legacy, source_revision)); + } + } + let (_, revision) = read_config_document_with_revision(&path)?; + (global_document, revision) + } else if path.exists() { + let (mut document, revision) = read_config_document_with_revision(&path)?; + if document.get("disabledProviders").is_none() && project_has_disabled { + if let Some((project, _)) = &project_layer { + if let Some(value) = project.get("disabledProviders") { + document + .as_object_mut() + .expect("validated OMP project config root") + .insert("disabledProviders".to_string(), value.clone()); + } + } + } + (document, revision) + } else if project_has_disabled { + let (project, _) = + project_layer.expect("project layer exists when disabledProviders is set"); + let project_legacy = get_omp_project_legacy_settings_path()?; + if project_legacy.exists() { + let (_, source_revision) = + parse_json5_document(&project_legacy, "OMP project legacy settings")?; + legacy_source_revision = Some((project_legacy, source_revision)); + } + let (_, revision) = read_config_document_with_revision(&path)?; + (project, revision) + } else { + let (_, revision) = read_config_document_with_revision(&path)?; + (Value::Object(Map::new()), revision) + }; + + let root = document.as_object_mut().ok_or_else(|| { + AppError::Config(format!( + "OMP config root must be an object: {}", + path.display() + )) + })?; + let entries = match root.get_mut("disabledProviders") { + Some(value) => value.as_array_mut().ok_or_else(|| { + AppError::Config(format!( + "OMP config disabledProviders must be an array: {}", + path.display() + )) + })?, + None if !disabled => return Ok(()), + None => root + .entry("disabledProviders".to_string()) + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .expect("inserted disabledProviders array"), + }; + if disabled { + if !entries + .iter() + .any(|entry| entry.as_str().is_some_and(|id| id.trim() == provider_id)) + { + entries.push(Value::String(provider_id.to_string())); + } + } else { + let cwd = std::env::current_dir().map_err(|error| { + AppError::Config(format!("failed to resolve current directory: {error}")) + })?; + entries.retain_mut(|entry| { + if entry.as_str().is_some_and(|id| id.trim() == provider_id) { + return false; + } + let Some(object) = entry.as_object_mut() else { + return true; + }; + // A path-scoped object is a complete policy for its own paths. + // Enabling a provider in the current project must not silently + // remove the same provider from an unrelated project scope. + if !disabled_provider_entry_applies_to_cwd(object, &cwd) { + return true; + } + for key in ["providers", "values", "items"] { + let remove_key = match object.get_mut(key) { + Some(Value::String(value)) => value.trim() == provider_id, + Some(Value::Array(values)) => { + values.retain(|value| { + value.as_str().is_none_or(|id| id.trim() != provider_id) + }); + values.is_empty() + } + _ => false, + }; + if remove_key { + object.remove(key); + } + } + let has_provider_values = ["providers", "values", "items"].iter().any(|key| { + object + .get(*key) + .and_then(Value::as_array) + .is_some_and(|values| !values.is_empty()) + }); + let is_scope_only = object.keys().all(|key| { + matches!( + key.as_str(), + "path" | "pathPrefix" | "paths" | "pathPrefixes" + ) + }); + if !has_provider_values && is_scope_only { + return false; + } + true + }); + // If project settings.json supplied the disabledProviders layer, + // retaining an explicit empty array is necessary to override it. + // Removing the key would make OMP fall back to the JSON list again. + let project_json_has_disabled = if project_has_disabled { + let project_json = get_omp_project_legacy_settings_path()?; + project_json.exists() + && parse_json5_document(&project_json, "OMP project legacy settings")? + .0 + .get("disabledProviders") + .is_some() + } else { + false + }; + if entries.is_empty() && !project_json_has_disabled { + root.remove("disabledProviders"); + } + } + if let Some((source_path, source_revision)) = legacy_source_revision { + ensure_omp_legacy_revision(&source_path, &source_revision)?; + } + write_config_document(&path, &document, &revision) +} + +/// Set or remove one OMP model-role assignment while preserving every other +/// key in `config.yml`, including keys added by newer OMP releases. +pub(crate) fn set_omp_model_role( + role: &str, + selector: Option<&str>, + expected_revision: Option<&str>, +) -> Result<(), AppError> { + validate_model_role_name(role)?; + if let Some(selector) = selector { + validate_model_selector(selector)?; + } + + let _guard = lock_models_file()?; + set_omp_model_role_locked(role, selector, expected_revision) +} + +fn set_omp_model_role_locked( + role: &str, + selector: Option<&str>, + expected_revision: Option<&str>, +) -> Result<(), AppError> { + let path = omp_model_roles_target_path()?; + let mut legacy_source_revision = None; + let (mut document, revision) = if path == get_omp_settings_path()? { + if !path.exists() { + let legacy = get_omp_legacy_settings_path()?; + if legacy.exists() { + let (_, source_revision) = parse_json5_document(&legacy, "OMP legacy settings")?; + legacy_source_revision = Some((legacy, source_revision)); + } + } + let (document, _) = read_omp_global_settings_document()?; + let (_, revision) = read_config_document_with_revision(&path)?; + (document, revision) + } else if path.exists() { + read_config_document_with_revision(&path)? + } else if let Some((project, _)) = read_omp_project_settings_layer()? { + let project_legacy = get_omp_project_legacy_settings_path()?; + if !path.exists() && project_legacy.exists() { + let (_, source_revision) = + parse_json5_document(&project_legacy, "OMP project legacy settings")?; + legacy_source_revision = Some((project_legacy, source_revision)); + } + let (_, revision) = read_config_document_with_revision(&path)?; + (project, revision) + } else { + read_config_document_with_revision(&path)? + }; + if let Some(expected) = expected_revision { + let compare_revision = legacy_source_revision + .as_ref() + .map(|(_, source_revision)| source_revision.as_str()) + .unwrap_or(revision.as_str()); + if expected != compare_revision { + return Err(AppError::Conflict(format!( + "OMP config.yml changed outside CC Switch: {}", + path.display() + ))); + } + } + let root = document.as_object_mut().ok_or_else(|| { + AppError::Config(format!( + "OMP config root must be an object: {}", + path.display() + )) + })?; + let roles = root + .entry("modelRoles".to_string()) + .or_insert_with(|| Value::Object(Map::new())); + let roles = roles.as_object_mut().ok_or_else(|| { + AppError::Config(format!( + "OMP config 'modelRoles' must be an object: {}", + path.display() + )) + })?; + match selector { + Some(selector) => { + roles.insert( + role.trim().to_string(), + Value::String(selector.trim().to_string()), + ); + } + None => { + if path == get_omp_project_settings_path()? { + // Project role storage is an overlay. OMP treats a project + // null as "clear this project assignment" and then falls + // back to the global role. Refuse to report success when the + // role is inherited from global only, because there is no + // project value for this operation to remove. + let project_entries = read_omp_project_settings_layer()? + .map(|(project, project_path)| { + model_role_entries_from_document(&project, &project_path) + }) + .transpose()? + .unwrap_or_default(); + let global_entries = model_role_entries_from_document( + &read_omp_global_settings_document()?.0, + &get_omp_settings_path()?, + )?; + if !project_entries.contains_key(role.trim()) { + if global_entries.get(role.trim()).is_some_and(Option::is_some) { + return Err(AppError::InvalidInput(format!( + "OMP role '{}' is inherited from global config; switch modelRoleStorage to global or edit the global config to remove it", + role.trim() + ))); + } + return Ok(()); + } + // Persist null rather than deleting the key so a lower-priority + // project settings.json value is also cleared by OMP's merge. + roles.insert(role.trim().to_string(), Value::Null); + } else { + roles.remove(role.trim()); + if roles.is_empty() { + root.remove("modelRoles"); + } + } + } + } + if let Some((source_path, source_revision)) = legacy_source_revision { + ensure_omp_legacy_revision(&source_path, &source_revision)?; + } + write_config_document(&path, &document, &revision) +} + +/// Atomically validate and assign OMP's default model. Provider/model +/// membership and the role write share the native-file lock, so another +/// CC-Switch operation cannot remove the selected model between validation and +/// assignment. The models revision is also rechecked immediately before the +/// config write to detect edits made by external processes. +pub(crate) fn set_omp_default_model( + provider_id: &str, + model_id: Option<&str>, +) -> Result { + validate_provider_key(provider_id)?; + let _guard = lock_models_file()?; + if read_omp_disabled_providers()?.contains(provider_id) { + return Err(AppError::InvalidInput(format!( + "OMP provider '{provider_id}' is disabled by config.yml disabledProviders" + ))); + } + + let models_path = get_omp_models_path()?; + let (models_document, models_revision) = read_models_document_with_revision(&models_path)?; + let provider = providers(&models_document, &models_path)? + .get(provider_id) + .ok_or_else(|| { + AppError::InvalidInput(format!( + "OMP provider '{provider_id}' is not enabled in models.yml" + )) + })?; + // Selecting a default model must obey the same provider semantics as a + // CC-Switch-created entry. Membership alone is insufficient: an opaque or + // malformed provider could otherwise be assigned to OMP's default role and + // make every invocation fail at runtime. + // Native transports may use non-HTTP base URLs (for example unix://), so + // apply semantic validation without imposing CC Switch's HTTP-only probe + // restriction. + validate_provider_node_for_editor(provider_id, provider)?; + let models = provider + .get("models") + .and_then(Value::as_array) + .ok_or_else(|| { + AppError::InvalidInput(format!("OMP provider '{provider_id}' has no model catalog")) + })?; + let selected = match model_id.map(str::trim) { + Some(model_id) + if models.iter().any(|model| { + model.get("id").and_then(Value::as_str).map(str::trim) == Some(model_id) + }) => + { + model_id.to_string() + } + Some(model_id) => { + return Err(AppError::InvalidInput(format!( + "OMP model '{provider_id}/{model_id}' is not present in models.yml" + ))) + } + None => models + .iter() + .find_map(|model| model.get("id").and_then(Value::as_str).map(str::trim)) + .filter(|id| !id.is_empty()) + .ok_or_else(|| { + AppError::InvalidInput(format!("OMP provider '{provider_id}' has no models")) + })? + .to_string(), + }; + + // External OMP edits are not coordinated by our mutex. Refuse to write a + // role if the registry changed during validation rather than reporting a + // successful assignment to a model that may no longer exist. + let (_, actual_revision) = read_models_document_with_revision(&models_path)?; + if actual_revision != models_revision { + return Err(AppError::Conflict(format!( + "OMP models.yml changed outside CC Switch: {}", + models_path.display() + ))); + } + let selector = format!("{provider_id}/{selected}"); + set_omp_model_role_locked("default", Some(&selector), None)?; + Ok(selector) +} + +/// Return the native config file bytes' revision for TUI compare-and-swap +/// editing. The canonical path is returned even when the file does not exist. +/// Read the exact YAML text OMP will consume, together with its content +/// revision (or the legacy JSONC source revision before migration). The TUI +/// uses this for an advanced editor so comments and fields unknown to CC +/// Switch survive a round trip unchanged unless the user explicitly edits +/// them. +pub(crate) fn read_omp_config_yaml() -> Result<(String, String), AppError> { + let path = get_omp_settings_path()?; + read_omp_config_yaml_at(&path) +} + +pub(crate) fn read_omp_config_yaml_at(path: &Path) -> Result<(String, String), AppError> { + // Before OMP's YAML migration runs, expose the legacy JSONC document as + // the exact text shown in the advanced editor. Return the source revision + // as the CAS token so a concurrent settings.json edit cannot be lost. + if !path.exists() { + let legacy = path.with_file_name("settings.json"); + if legacy.exists() { + let (document, source_revision) = parse_json5_document(&legacy, "OMP legacy settings")?; + let text = serde_yaml::to_string(&document).map_err(|error| { + AppError::Config(format!("failed to serialize OMP legacy settings: {error}")) + })?; + return Ok((text, source_revision)); + } + } + read_yaml_text_with_revision(path, "OMP config") +} + +/// Read the exact native model registry text and its revision. When the file +/// does not exist, return an empty but valid OMP document so the first edit can +/// be saved without forcing the user to create the directory by hand. +pub(crate) fn read_omp_models_yaml() -> Result<(String, String), AppError> { + let path = get_omp_models_path()?; + let (text, revision) = read_yaml_text_with_revision(&path, "OMP models")?; + if text.trim().is_empty() { + return Ok(("providers: {}\n".to_string(), revision)); + } + Ok((text, revision)) +} + +/// Replace the complete native config document after validating its YAML +/// shape. This is used by the TUI's advanced editor so unrelated OMP settings +/// remain available without requiring CC Switch to model the entire schema. +pub(crate) fn replace_omp_config_yaml_at( + path: &Path, + content: &str, + expected_revision: &str, +) -> Result<(), AppError> { + let document: Value = serde_yaml::from_str(content).map_err(|error| { + AppError::InvalidInput(format!("OMP config is not valid YAML: {error}")) + })?; + if !document.is_object() { + return Err(AppError::InvalidInput( + "OMP config root must be an object".to_string(), + )); + } + validate_omp_config_document(&document, path)?; + let _guard = lock_models_file()?; + let (_, actual_revision) = read_config_document_with_revision(path)?; + if path.exists() { + if actual_revision != expected_revision { + return Err(AppError::Conflict(format!( + "OMP config.yml changed outside CC Switch: {}", + path.display() + ))); + } + return write_config_document(path, &document, &actual_revision); + } + + // When the editor was opened against legacy settings.json, compare that + // source file rather than the still-missing YAML destination. The write + // itself remains guarded by the canonical missing-file revision. + let legacy = path.with_file_name("settings.json"); + if legacy.exists() { + let (_, legacy_revision) = parse_json5_document(&legacy, "OMP legacy settings")?; + if legacy_revision != expected_revision { + return Err(AppError::Conflict(format!( + "OMP legacy settings changed outside CC Switch: {}", + legacy.display() + ))); + } + } else if expected_revision != actual_revision { + return Err(AppError::Conflict(format!( + "OMP config.yml changed outside CC Switch: {}", + path.display() + ))); + } + write_config_document(path, &document, &actual_revision) +} + +/// Validate the portions of `config.yml` that CC Switch reads or edits while +/// retaining unknown keys for forward compatibility with newer OMP releases. +fn validate_omp_config_document(document: &Value, path: &Path) -> Result<(), AppError> { + let root = document.as_object().ok_or_else(|| { + AppError::InvalidInput(format!( + "OMP config root must be an object: {}", + path.display() + )) + })?; + + // Reuse the same role parser used by normal reads so the advanced editor + // cannot save a document that the role page (or OMP itself) cannot load. + model_role_entries_from_document(document, path)?; + + if let Some(storage) = root.get("modelRoleStorage") { + let storage = storage.as_str().ok_or_else(|| { + AppError::InvalidInput(format!( + "OMP config modelRoleStorage must be 'global' or 'project': {}", + path.display() + )) + })?; + if !matches!( + storage.trim().to_ascii_lowercase().as_str(), + "global" | "project" + ) { + return Err(AppError::InvalidInput(format!( + "OMP config modelRoleStorage must be 'global' or 'project', got '{storage}'" + ))); + } + } + + if let Some(disabled) = root.get("disabledProviders") { + let entries = disabled.as_array().ok_or_else(|| { + AppError::InvalidInput(format!( + "OMP config disabledProviders must be an array: {}", + path.display() + )) + })?; + for (index, entry) in entries.iter().enumerate() { + match entry { + Value::String(id) if !id.trim().is_empty() => {} + Value::Object(object) => { + // OMP supports path-scoped objects. Validate only the + // fields consumed by this adapter and leave extension + // fields untouched for forward compatibility. + for key in ["path", "pathPrefix", "paths", "pathPrefixes"] { + if let Some(value) = object.get(key) { + validate_string_or_array_field( + value, + &format!("OMP config disabledProviders[{index}].{key}"), + )?; + } + } + for key in ["providers", "values", "items"] { + if let Some(value) = object.get(key) { + validate_string_or_array_field( + value, + &format!("OMP config disabledProviders[{index}].{key}"), + )?; + } + } + } + _ => { + return Err(AppError::InvalidInput(format!( + "OMP config disabledProviders[{index}] must be a provider id string or path-scoped object" + ))) + } + } + } + } + + Ok(()) +} + +/// Replace the complete native models document after validating its root and +/// provider map. Provider values are validated with the import validator so +/// extension-owned forward-compatible entries remain round-trippable. +pub(crate) fn replace_omp_models_yaml( + content: &str, + expected_revision: &str, +) -> Result<(), AppError> { + let path = get_omp_models_path()?; + let document: Value = serde_yaml::from_str(content).map_err(|error| { + AppError::InvalidInput(format!("OMP models are not valid YAML: {error}")) + })?; + validate_omp_models_root(&document, &path)?; + let provider_map = providers(&document, &path)?; + for (provider_id, provider) in provider_map { + // A full-file edit must preserve every configuration accepted by + // OMP's native schema, including non-HTTP transports. Keep semantic + // provider checks (models/api/override requirements), but defer URL + // restrictions to CC Switch's HTTP-only discovery/test paths. + validate_provider_node_for_editor(provider_id, provider)?; + } + let _guard = lock_models_file()?; + let (_, actual_revision) = read_models_document_with_revision(&path)?; + if actual_revision != expected_revision { + return Err(AppError::Conflict(format!( + "OMP models.yml changed outside CC Switch: {}", + path.display() + ))); + } + write_models_document(&path, &document, expected_revision) +} + +/// Read all valid model entries from every explicit OMP provider. OMP files are +/// forward-compatible and may contain one malformed extension/model node; a +/// single bad entry should not hide every other usable model from the TUI. +pub(crate) fn read_omp_native_models() -> Result, AppError> { + let _guard = lock_models_file()?; + let path = get_omp_models_path()?; + let document = read_models_document(&path)?; + let provider_map = providers(&document, &path)?; + let mut result = Vec::new(); + for (provider_id, provider) in provider_map { + let Some(models) = provider.get("models") else { + continue; + }; + let Some(models) = models.as_array() else { + log::warn!( + "Skipping OMP provider '{provider_id}' because models is not an array: {}", + path.display() + ); + continue; + }; + for model in models { + let Some(model_id) = model + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| !id.is_empty()) + else { + log::warn!( + "Skipping malformed OMP model without an id under provider '{provider_id}': {}", + path.display() + ); + continue; + }; + result.push(OMPNativeModel { + provider_id: provider_id.clone(), + model_id: model_id.to_string(), + config: model.clone(), + }); + } + } + Ok(result) +} + +/// Add or replace one model in a provider's native model array. The complete +/// model object is supplied by the caller and all unrelated provider/root +/// fields are preserved. +pub(crate) fn upsert_omp_model_checked( + provider_id: &str, + model_id: &str, + model: Value, + expected_revision: &str, +) -> Result<(), AppError> { + upsert_omp_model_inner(provider_id, model_id, model, Some(expected_revision)) +} + +fn upsert_omp_model_inner( + provider_id: &str, + model_id: &str, + model: Value, + expected_revision: Option<&str>, +) -> Result<(), AppError> { + validate_provider_key(provider_id)?; + validate_model_object(model_id, &model)?; + let _guard = lock_models_file()?; + let path = get_omp_models_path()?; + let (mut document, file_revision) = read_models_document_with_revision(&path)?; + if let Some(expected) = expected_revision { + if expected != file_revision { + return Err(AppError::Conflict(format!( + "OMP models.yml changed outside CC Switch: {}", + path.display() + ))); + } + } + let provider = providers_mut(&mut document, &path)? + .get_mut(provider_id) + .ok_or_else(|| AppError::InvalidInput(format!("OMP provider '{provider_id}' not found")))?; + let object = provider.as_object_mut().ok_or_else(|| { + AppError::Config(format!( + "OMP provider '{provider_id}' must be an object: {}", + path.display() + )) + })?; + let models = object + .entry("models".to_string()) + .or_insert_with(|| Value::Array(Vec::new())); + let models = models.as_array_mut().ok_or_else(|| { + AppError::Config(format!( + "OMP provider '{provider_id}' models must be an array: {}", + path.display() + )) + })?; + let model_id = model_id.trim(); + if let Some(existing) = models.iter_mut().find(|item| { + item.get("id") + .and_then(Value::as_str) + .is_some_and(|id| id == model_id) + }) { + *existing = model; + } else { + models.push(model); + } + write_models_document(&path, &document, &file_revision) +} + +pub(crate) fn remove_omp_model(provider_id: &str, model_id: &str) -> Result { + remove_omp_model_inner(provider_id, model_id, None) +} + +pub(crate) fn remove_omp_model_checked( + provider_id: &str, + model_id: &str, + expected_revision: &str, +) -> Result { + remove_omp_model_inner(provider_id, model_id, Some(expected_revision)) +} + +fn remove_omp_model_inner( + provider_id: &str, + model_id: &str, + expected_revision: Option<&str>, +) -> Result { + validate_provider_key(provider_id)?; + let model_id = model_id.trim(); + if model_id.is_empty() { + return Err(AppError::InvalidInput( + "OMP model id cannot be empty".to_string(), + )); + } + let _guard = lock_models_file()?; + let path = get_omp_models_path()?; + let (mut document, file_revision) = read_models_document_with_revision(&path)?; + if let Some(expected) = expected_revision { + if expected != file_revision { + return Err(AppError::Conflict(format!( + "OMP models.yml changed outside CC Switch: {}", + path.display() + ))); + } + } + // Check existence before role references so a missing target produces a + // clear not-found result rather than an unrelated dangling-reference + // error from another entry in modelRoles. + let provider = providers(&document, &path)? + .get(provider_id) + .ok_or_else(|| AppError::InvalidInput(format!("OMP provider '{provider_id}' not found")))?; + let models = provider + .get("models") + .and_then(Value::as_array) + .ok_or_else(|| { + AppError::Config(format!( + "OMP provider '{provider_id}' models must be an array: {}", + path.display() + )) + })?; + if !models + .iter() + .any(|item| item.get("id").and_then(Value::as_str) == Some(model_id)) + { + return Ok(false); + } + + // Keep the role-reference check under the same process-wide lock as the + // models mutation. Otherwise a concurrent CC Switch role write could + // land between the check and deletion, leaving a dangling selector. + ensure_model_role_references_clear_locked(provider_id, Some(model_id), &document)?; + let provider = providers_mut(&mut document, &path)? + .get_mut(provider_id) + .expect("provider existence checked above"); + let models = provider + .get_mut("models") + .and_then(Value::as_array_mut) + .expect("provider models type checked above"); + models.retain(|item| item.get("id").and_then(Value::as_str) != Some(model_id)); + write_models_document(&path, &document, &file_revision)?; + Ok(true) +} + +pub(crate) fn validate_provider_key(provider_id: &str) -> Result<(), AppError> { + if provider_id.trim().is_empty() + || provider_id.len() > 128 + || provider_id + .chars() + .any(|ch| ch.is_control() || ch.is_whitespace()) + || provider_id.contains('/') + { + return Err(AppError::InvalidInput( + "OMP provider key must be 1-128 non-whitespace characters and cannot contain '/'" + .to_string(), + )); + } + Ok(()) +} + +fn validate_model_role_name(role: &str) -> Result<(), AppError> { + let role = role.trim(); + if role.is_empty() || role.len() > 128 || role.chars().any(|ch| ch.is_control()) { + return Err(AppError::InvalidInput( + "OMP model role must be 1-128 non-control characters".to_string(), + )); + } + Ok(()) +} + +fn validate_model_selector(selector: &str) -> Result<(), AppError> { + let selector = selector.trim(); + if selector.is_empty() || selector.len() > 512 || selector.chars().any(|ch| ch.is_control()) { + return Err(AppError::InvalidInput( + "OMP model selector must be 1-512 non-control characters".to_string(), + )); + } + // OMP accepts concrete `provider/model` selectors as well as role aliases + // (`@smol`, `@slow`) and the wildcard (`*`). Bare model ids are also + // resolved by the native model resolver, so the adapter must not reject + // them merely because they lack a slash. + Ok(()) +} + +fn validate_model_object(model_id: &str, model: &Value) -> Result<(), AppError> { + let object = model.as_object().ok_or_else(|| { + AppError::InvalidInput("OMP model configuration must be an object".to_string()) + })?; + let actual = object.get("id").and_then(Value::as_str).map(str::trim); + if actual != Some(model_id.trim()) || model_id.trim().is_empty() { + return Err(AppError::InvalidInput( + "OMP model configuration id must match the requested model id".to_string(), + )); + } + validate_model_metadata(object, &format!("OMP model '{model_id}'"), true)?; + if let Some(base_url) = object.get("baseUrl") { + let base_url = base_url.as_str().ok_or_else(|| { + AppError::InvalidInput("OMP model baseUrl must be a string".to_string()) + })?; + if base_url.trim().is_empty() || !is_valid_request_url(base_url) { + return Err(AppError::InvalidInput( + "OMP model baseUrl must be an absolute HTTP(S) URL".to_string(), + )); + } + } + if let Some(api) = object.get("api") { + let api = api + .as_str() + .ok_or_else(|| AppError::InvalidInput("OMP model api must be a string".to_string()))?; + validate_api_protocol(api)?; + } + Ok(()) +} + +pub(crate) fn read_omp_native_providers() -> Result, AppError> { + let _guard = lock_models_file()?; + read_omp_native_providers_locked(&get_omp_models_path()?) +} + +pub(crate) fn read_omp_native_provider(provider_key: &str) -> Result, AppError> { + let _guard = lock_models_file()?; + let path = get_omp_models_path()?; + let document = read_models_document(&path)?; + Ok(providers(&document, &path)?.get(provider_key).cloned()) +} + +pub(crate) fn omp_provider_exists(provider_key: &str) -> Result { + let _guard = lock_models_file()?; + let path = get_omp_models_path()?; + let document = read_models_document(&path)?; + Ok(providers(&document, &path)?.contains_key(provider_key)) +} + +pub(crate) fn insert_omp_provider(provider_key: &str, config: &Value) -> Result { + validate_provider_node(provider_key, config)?; + let _guard = lock_models_file()?; + let path = get_omp_models_path()?; + let (mut document, expected_revision) = read_models_document_with_revision(&path)?; + let providers = providers_mut(&mut document, &path)?; + + match providers.get(provider_key) { + Some(current) if current == config => return Ok(false), + Some(_) => { + return Err(AppError::InvalidInput(format!( + "OMP provider key '{provider_key}' already exists in models.yml" + ))) + } + None => {} + } + + providers.insert(provider_key.to_string(), config.clone()); + write_models_document(&path, &document, &expected_revision)?; + Ok(true) +} + +pub(crate) fn replace_omp_provider( + provider_key: &str, + expected: &Value, + replacement: &Value, +) -> Result<(), AppError> { + replace_omp_provider_inner(provider_key, expected, replacement, true) +} + +pub(crate) fn replace_omp_provider_for_import( + provider_key: &str, + expected: &Value, + replacement: &Value, +) -> Result<(), AppError> { + replace_omp_provider_inner(provider_key, expected, replacement, false) +} + +fn replace_omp_provider_inner( + provider_key: &str, + expected: &Value, + replacement: &Value, + enforce_semantics: bool, +) -> Result<(), AppError> { + if enforce_semantics { + validate_provider_node(provider_key, replacement)?; + } else { + validate_provider_node_for_import(provider_key, replacement)?; + } + let _guard = lock_models_file()?; + let path = get_omp_models_path()?; + let (mut document, expected_revision) = read_models_document_with_revision(&path)?; + let providers = providers_mut(&mut document, &path)?; + let current = providers.get(provider_key).ok_or_else(|| { + AppError::Conflict(format!( + "OMP provider '{provider_key}' is no longer present in models.yml" + )) + })?; + if !provider_configs_equal_ignoring_name(current, expected) { + return Err(AppError::Conflict(format!( + "OMP provider '{provider_key}' changed outside CC Switch" + ))); + } + if provider_configs_equal_ignoring_name(current, replacement) { + return Ok(()); + } + providers.insert(provider_key.to_string(), replacement.clone()); + write_models_document(&path, &document, &expected_revision) +} + +/// Replace a provider when it exists, but only if its current native value +/// still matches the value CC Switch previously loaded. This closes the +/// read/modify/write race where an external edit to `models.yml` could be +/// silently overwritten by a normal provider update. +pub(crate) fn replace_omp_provider_if_present_checked( + provider_key: &str, + expected: &Value, + replacement: &Value, +) -> Result, AppError> { + replace_omp_provider_if_present_inner(provider_key, Some(expected), replacement, true) +} + +pub(crate) fn replace_omp_provider_if_present_for_import_checked( + provider_key: &str, + expected: &Value, + replacement: &Value, +) -> Result, AppError> { + replace_omp_provider_if_present_inner(provider_key, Some(expected), replacement, false) +} + +fn replace_omp_provider_if_present_inner( + provider_key: &str, + expected: Option<&Value>, + replacement: &Value, + enforce_semantics: bool, +) -> Result, AppError> { + if enforce_semantics { + validate_provider_node(provider_key, replacement)?; + } else { + validate_provider_node_for_import(provider_key, replacement)?; + } + let _guard = lock_models_file()?; + let path = get_omp_models_path()?; + let (mut document, expected_revision) = read_models_document_with_revision(&path)?; + let providers = providers_mut(&mut document, &path)?; + let Some(current) = providers.get(provider_key).cloned() else { + return Ok(None); + }; + if expected.is_some_and(|expected| !provider_configs_equal_ignoring_name(¤t, expected)) { + return Err(AppError::Conflict(format!( + "OMP provider '{provider_key}' changed outside CC Switch" + ))); + } + if provider_configs_equal_ignoring_name(¤t, replacement) { + return Ok(Some(current)); + } + providers.insert(provider_key.to_string(), replacement.clone()); + write_models_document(&path, &document, &expected_revision)?; + Ok(Some(current)) +} + +pub(crate) fn remove_omp_provider_if_matches( + provider_key: &str, + expected: &Value, +) -> Result { + remove_omp_provider_inner(provider_key, Some(expected)).map(|removed| removed.is_some()) +} + +/// Remove the current native provider value after the caller has explicitly +/// requested a live-config removal. The latest native value is returned so the +/// service can preserve it in the CC Switch catalog for a later restore. +pub(crate) fn remove_omp_provider(provider_key: &str) -> Result, AppError> { + remove_omp_provider_inner(provider_key, None) +} + +/// Remove a provider only when the native value still matches the snapshot +/// imported by CC Switch. This is the destructive counterpart to the update +/// compare-and-swap path and prevents an external `models.yml` edit from being +/// silently discarded by a later delete/remove action. +pub(crate) fn remove_omp_provider_checked( + provider_key: &str, + expected: &Value, +) -> Result, AppError> { + remove_omp_provider_inner(provider_key, Some(expected)) +} + +fn remove_omp_provider_inner( + provider_key: &str, + expected: Option<&Value>, +) -> Result, AppError> { + let _guard = lock_models_file()?; + let path = get_omp_models_path()?; + let (mut document, expected_revision) = read_models_document_with_revision(&path)?; + let current = providers(&document, &path)?.get(provider_key).cloned(); + let Some(current) = current else { + return Ok(None); + }; + if expected.is_some_and(|expected| !provider_configs_equal_ignoring_name(¤t, expected)) { + return Err(AppError::Conflict(format!( + "OMP provider '{provider_key}' changed outside CC Switch" + ))); + } + // The role check must be inside the same lock and immediately before the + // destructive write; see remove_omp_model_inner for the invariant. + ensure_model_role_references_clear_locked(provider_key, None, &document)?; + let providers = providers_mut(&mut document, &path)?; + providers.remove(provider_key); + write_models_document(&path, &document, &expected_revision)?; + Ok(Some(current)) +} + +fn ensure_model_role_references_clear_locked( + provider_id: &str, + model_id: Option<&str>, + models_document: &Value, +) -> Result<(), AppError> { + // Destructive removal must fail closed when the role configuration cannot + // be parsed. Proceeding without knowing whether `default` or another + // role references the target recreates the very dangling-selector bug this + // guard is intended to prevent. + let roles = read_omp_model_roles_with_metadata_locked()?.0; + let model_providers = model_provider_index(models_document); + let mut references = Vec::new(); + for (role, selector) in roles.iter() { + if selector_references_omp_target( + selector, + provider_id, + model_id, + &roles, + Some(&model_providers), + &mut HashSet::new(), + )? { + references.push(role.clone()); + } + } + if references.is_empty() { + return Ok(()); + } + references.sort(); + let subject = model_id + .map(|model| format!("{provider_id}/{model}")) + .unwrap_or_else(|| provider_id.to_string()); + Err(AppError::InvalidInput(format!( + "cannot remove OMP model/provider '{subject}': modelRoles references it ({})", + references.join(", ") + ))) +} + +fn model_provider_index(document: &Value) -> HashMap> { + let mut index: HashMap> = HashMap::new(); + let Some(providers) = document.get("providers").and_then(Value::as_object) else { + return index; + }; + for (provider_id, provider) in providers { + let Some(models) = provider.get("models").and_then(Value::as_array) else { + continue; + }; + for model in models { + if let Some(model_id) = model.get("id").and_then(Value::as_str) { + index + .entry(model_id.trim().to_string()) + .or_default() + .insert(provider_id.clone()); + } + } + } + index +} + +fn selector_references_omp_target( + selector: &str, + provider_id: &str, + model_id: Option<&str>, + roles: &IndexMap, + model_providers: Option<&HashMap>>, + visited_roles: &mut HashSet, +) -> Result { + let selector = selector.trim(); + if selector.is_empty() { + return Ok(false); + } + let base = strip_omp_thinking_suffix(selector); + + // `pi/` is OMP's legacy role-alias spelling. Recognize it before + // treating the slash as a provider/model separator when the suffix names + // a known or configured role. + if let Some(role_name) = base.strip_prefix("pi/") { + if roles.contains_key(role_name) || OMP_BUILTIN_MODEL_ROLES.contains(&role_name) { + return selector_references_omp_target( + &format!("@{role_name}"), + provider_id, + model_id, + roles, + model_providers, + visited_roles, + ); + } + } + + // Explicit provider/model selectors are unambiguous and are the common + // case. Provider wildcards matter when removing an entire provider, but a + // model-specific delete does not make `provider/*` dangling. + if let Some((selector_provider, selector_model)) = base.split_once('/') { + if selector_provider != provider_id { + return Ok(false); + } + return Ok(match model_id { + Some(target_model) => selector_model == target_model, + None => true, + }); + } + + // OMP's `*`, `@role`, and legacy `pi/role` forms are aliases. Resolve + // them recursively so deleting a model cannot leave an indirect role + // reference behind. Cycles are malformed native config; fail closed. + let role_name = if base == "*" { + Some("default") + } else if let Some(name) = base.strip_prefix('@') { + Some(name) + } else { + base.strip_prefix("pi/") + }; + if let Some(role_name) = role_name { + if !visited_roles.insert(role_name.to_string()) { + return Err(AppError::InvalidInput(format!( + "cannot remove OMP model/provider '{provider_id}': cyclic modelRoles alias involving '{role_name}'" + ))); + } + let result = roles + .get(role_name) + .map(|value| { + selector_references_omp_target( + value, + provider_id, + model_id, + roles, + model_providers, + visited_roles, + ) + }) + .transpose()? + .unwrap_or(false); + visited_roles.remove(role_name); + return Ok(result); + } + + // Bare model ids are resolved by OMP against its available registry. They + // are only unambiguous for destructive checks when the id belongs to one + // provider; in that case block deletion of that provider/model. + if let Some(target_model) = model_id { + if base != target_model { + return Ok(false); + } + return Ok(model_providers + .and_then(|index| index.get(base)) + .is_some_and(|providers| providers.len() == 1 && providers.contains(provider_id))); + } + + // Removing a provider: a bare selector can point at any of its models. + Ok(model_providers + .and_then(|index| index.get(base)) + .is_some_and(|providers| providers.len() == 1 && providers.contains(provider_id))) +} + +fn strip_omp_thinking_suffix(selector: &str) -> &str { + let Some((base, suffix)) = selector.rsplit_once(':') else { + return selector; + }; + matches!( + suffix, + "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "auto" + ) + .then_some(base) + .unwrap_or(selector) +} + +/// Resolve the provider portion of an OMP selector for UI/status purposes. +/// +/// OMP accepts concrete `provider/model` selectors, wildcard/default aliases, +/// and recursive `@role`/`pi/role` aliases. Bare model ids cannot be mapped +/// safely without the complete native registry and therefore return `None`. +pub(crate) fn omp_selector_provider_id( + selector: &str, + roles: &IndexMap, +) -> Option { + fn resolve( + selector: &str, + roles: &IndexMap, + visited: &mut HashSet, + ) -> Option { + let selector = strip_omp_thinking_suffix(selector.trim()); + if selector.is_empty() { + return None; + } + if let Some((provider, model)) = selector.split_once('/') { + if provider == "pi" + && (roles.contains_key(model) || OMP_BUILTIN_MODEL_ROLES.contains(&model)) + { + return resolve(&format!("@{model}"), roles, visited); + } + return (!provider.is_empty() && !model.is_empty()).then(|| provider.to_string()); + } + let role = if selector == "*" { + Some("default") + } else { + selector.strip_prefix('@') + }; + let role = role?; + if !visited.insert(role.to_string()) { + return None; + } + let result = roles + .get(role) + .and_then(|selector| resolve(selector, roles, visited)); + visited.remove(role); + result + } + + resolve(selector, roles, &mut HashSet::new()) +} + +pub(crate) fn restore_omp_provider_if_missing( + provider_key: &str, + config: &Value, +) -> Result<(), AppError> { + let _guard = lock_models_file()?; + let path = get_omp_models_path()?; + let (mut document, expected_revision) = read_models_document_with_revision(&path)?; + let providers = providers_mut(&mut document, &path)?; + match providers.get(provider_key) { + Some(current) if current == config => Ok(()), + Some(_) => Err(AppError::Conflict(format!( + "cannot restore OMP provider '{provider_key}' because another value now owns the key" + ))), + None => { + providers.insert(provider_key.to_string(), config.clone()); + write_models_document(&path, &document, &expected_revision) + } + } +} + +/// Validate the shape CC Switch can persist as one +/// `models.yml.providers.` node. +/// +/// Provider ownership is intentionally source-based: every explicit object in +/// `models.yml.providers` is manageable, including keys also built into OMP. +/// OMP's `/login` credentials live in `auth.json` and are never read here. +pub(crate) fn validate_provider_node(provider_key: &str, config: &Value) -> Result<(), AppError> { + validate_provider_node_inner(provider_key, config, true, true) +} + +/// Validate a complete native provider document for the advanced editor. +/// OMP's schema permits any non-empty `baseUrl` string (for example a native +/// transport URI); CC Switch only requires HTTP(S) when it actively probes a +/// provider. Keep semantic checks while allowing those native URLs to round +/// trip unchanged. +pub(crate) fn validate_provider_node_for_editor( + provider_key: &str, + config: &Value, +) -> Result<(), AppError> { + validate_provider_node_inner(provider_key, config, true, false) +} + +/// Validate only the syntactic/typed portions of an OMP provider node. +/// +/// Native `models.yml` entries can be owned by OMP itself or by extensions and +/// may intentionally contain only forward-compatible fields. Importing those +/// entries must preserve them even when CC Switch cannot apply its stricter +/// custom-provider semantics. Writes originating from CC Switch continue to +/// use [`validate_provider_node`]. +pub(crate) fn validate_provider_node_for_import( + provider_key: &str, + config: &Value, +) -> Result<(), AppError> { + validate_provider_node_inner(provider_key, config, false, false) +} + +fn validate_provider_node_inner( + provider_key: &str, + config: &Value, + enforce_semantics: bool, + enforce_http_urls: bool, +) -> Result<(), AppError> { + validate_provider_key(provider_key)?; + let object = config.as_object().ok_or_else(|| { + AppError::InvalidInput("OMP provider configuration must be an object".to_string()) + })?; + if object.is_empty() { + return Err(AppError::InvalidInput( + "OMP provider configuration cannot be empty".to_string(), + )); + } + if let Some(base_url) = object.get("baseUrl") { + let base_url = base_url.as_str().ok_or_else(|| { + AppError::InvalidInput("OMP provider baseUrl must be a string".to_string()) + })?; + if base_url.trim().is_empty() { + return Err(AppError::InvalidInput( + "OMP provider baseUrl must be a non-empty string".to_string(), + )); + } + if enforce_http_urls && !is_valid_request_url(base_url) { + return Err(AppError::InvalidInput( + "OMP provider baseUrl must be an absolute HTTP(S) URL".to_string(), + )); + } + } + if let Some(api_key) = object.get("apiKey") { + let api_key = api_key.as_str().ok_or_else(|| { + AppError::InvalidInput("OMP provider apiKey must be a string".to_string()) + })?; + if api_key.trim().is_empty() { + return Err(AppError::InvalidInput( + "OMP provider apiKey must be a non-empty string".to_string(), + )); + } + } + if let Some(headers) = object.get("headers") { + let headers = headers.as_object().ok_or_else(|| { + AppError::InvalidInput("OMP provider headers must be an object".to_string()) + })?; + if headers.values().any(|value| !value.is_string()) { + return Err(AppError::InvalidInput( + "OMP provider headers values must be strings".to_string(), + )); + } + } + if let Some(api) = object.get("api") { + let api = api.as_str().ok_or_else(|| { + AppError::InvalidInput("OMP provider api must be a string".to_string()) + })?; + validate_api_protocol(api)?; + } + if let Some(auth) = object.get("auth") { + let auth = auth.as_str().ok_or_else(|| { + AppError::InvalidInput("OMP provider auth must be a string".to_string()) + })?; + if !matches!(auth, "apiKey" | "none" | "oauth") { + return Err(AppError::InvalidInput(format!( + "Unsupported OMP provider auth '{}'. Supported: apiKey, none, oauth", + auth + ))); + } + } + validate_optional_bool(object, "authHeader", "OMP provider authHeader")?; + validate_optional_bool( + object, + "disableStrictTools", + "OMP provider disableStrictTools", + )?; + validate_optional_string( + object, + "guardrailIdentifier", + "OMP provider guardrailIdentifier", + )?; + validate_optional_string(object, "guardrailVersion", "OMP provider guardrailVersion")?; + validate_optional_enum( + object, + "guardrailTrace", + &["enabled", "disabled", "enabled_full"], + "OMP provider guardrailTrace", + )?; + validate_optional_enum( + object, + "transport", + &["pi-native"], + "OMP provider transport", + )?; + if let Some(request_metadata) = object.get("requestMetadata") { + if !request_metadata.is_object() { + return Err(AppError::InvalidInput( + "OMP provider requestMetadata must be an object".to_string(), + )); + } + } + if let Some(discovery) = object.get("discovery") { + let discovery = discovery.as_object().ok_or_else(|| { + AppError::InvalidInput("OMP provider discovery must be an object".to_string()) + })?; + let discovery_type = discovery + .get("type") + .and_then(Value::as_str) + .ok_or_else(|| { + AppError::InvalidInput("OMP provider discovery.type must be a string".to_string()) + })?; + if !matches!( + discovery_type, + "ollama" | "llama.cpp" | "lm-studio" | "openai-models-list" | "proxy" | "litellm" + ) { + return Err(AppError::InvalidInput(format!( + "Unsupported OMP discovery type '{}'", + discovery_type + ))); + } + if let Some(timeout) = discovery.get("timeoutMs") { + let timeout = timeout.as_f64().ok_or_else(|| { + AppError::InvalidInput( + "OMP provider discovery.timeoutMs must be a number".to_string(), + ) + })?; + if !timeout.is_finite() || timeout <= 0.0 { + return Err(AppError::InvalidInput( + "OMP provider discovery.timeoutMs must be positive".to_string(), + )); + } + } + if let Some(inject_v1) = discovery.get("injectV1") { + if !inject_v1.is_boolean() { + return Err(AppError::InvalidInput( + "OMP provider discovery.injectV1 must be a boolean".to_string(), + )); + } + if discovery_type != "openai-models-list" { + return Err(AppError::InvalidInput( + "OMP provider discovery.injectV1 is only valid for openai-models-list" + .to_string(), + )); + } + } + } + if let Some(remote) = object.get("remoteCompaction") { + validate_remote_compaction(remote, "OMP provider remoteCompaction")?; + } + if let Some(compat) = object.get("compat") { + validate_compat(compat, "OMP provider compat")?; + } + if let Some(overrides) = object.get("modelOverrides") { + let overrides = overrides.as_object().ok_or_else(|| { + AppError::InvalidInput("OMP provider modelOverrides must be an object".to_string()) + })?; + if overrides.values().any(|value| !value.is_object()) { + return Err(AppError::InvalidInput( + "OMP provider modelOverrides values must be objects".to_string(), + )); + } + for (model_id, override_value) in overrides { + let override_object = override_value + .as_object() + .expect("modelOverrides values checked above"); + validate_model_metadata( + override_object, + &format!("OMP modelOverrides '{model_id}'"), + false, + )?; + } + } + if let Some(models) = object.get("models") { + let models = models.as_array().ok_or_else(|| { + AppError::InvalidInput("OMP provider models must be an array".to_string()) + })?; + for model in models { + let model = model.as_object().ok_or_else(|| { + AppError::InvalidInput("OMP provider model must be an object".to_string()) + })?; + let model_id = model.get("id").ok_or_else(|| { + AppError::InvalidInput("OMP provider model id is required".to_string()) + })?; + let model_id = model_id.as_str().ok_or_else(|| { + AppError::InvalidInput("OMP provider model id must be a string".to_string()) + })?; + if model_id.trim().is_empty() { + return Err(AppError::InvalidInput( + "OMP provider model id must be a non-empty string".to_string(), + )); + } + validate_model_metadata(model, &format!("OMP model '{model_id}'"), true)?; + if let Some(base_url) = model.get("baseUrl") { + let base_url = base_url.as_str().ok_or_else(|| { + AppError::InvalidInput("OMP model baseUrl must be a string".to_string()) + })?; + if base_url.trim().is_empty() { + return Err(AppError::InvalidInput( + "OMP model baseUrl must be a non-empty string".to_string(), + )); + } + if enforce_http_urls && !is_valid_request_url(base_url) { + return Err(AppError::InvalidInput( + "OMP model baseUrl must be an absolute HTTP(S) URL".to_string(), + )); + } + } + if let Some(headers) = model.get("headers") { + let headers = headers.as_object().ok_or_else(|| { + AppError::InvalidInput("OMP model headers must be an object".to_string()) + })?; + if headers.values().any(|value| !value.is_string()) { + return Err(AppError::InvalidInput( + "OMP model headers values must be strings".to_string(), + )); + } + } + if let Some(api) = model.get("api") { + let api = api.as_str().ok_or_else(|| { + AppError::InvalidInput("OMP model api must be a string".to_string()) + })?; + validate_api_protocol(api)?; + } + for field in ["contextWindow", "maxTokens"] { + if let Some(value) = model.get(field) { + let valid = value + .as_f64() + .is_some_and(|number| number.is_finite() && number > 0.0); + if !valid { + return Err(AppError::InvalidInput(format!( + "OMP model {field} must be a positive number" + ))); + } + } + } + } + } + + if enforce_semantics { + // Keep provider-level semantic requirements aligned with OMP's + // `validateProviderConfiguration()`. Extension-owned native nodes may + // be opaque (for example `{ extension: {...} }`), so those are retained even + // without a recognized model/override field. + let models = object.get("models").and_then(Value::as_array); + let has_models = models.is_some_and(|models| !models.is_empty()); + let provider_api = object.get("api").and_then(Value::as_str); + if has_models { + let has_provider_base_url = object + .get("baseUrl") + .and_then(Value::as_str) + .is_some_and(|value| !value.trim().is_empty()); + if !has_provider_base_url { + return Err(AppError::InvalidInput( + "OMP provider baseUrl is required when defining custom models".to_string(), + )); + } + let auth = object + .get("auth") + .and_then(Value::as_str) + .unwrap_or("apiKey"); + // Native OAuth-backed custom providers may omit an inline apiKey; + // OMP resolves their credentials through its own auth store. + if object + .get("apiKey") + .and_then(Value::as_str) + .is_none_or(|value| value.trim().is_empty()) + && auth != "none" + && auth != "oauth" + { + return Err(AppError::InvalidInput(format!( + "OMP provider '{provider_key}' with models requires apiKey unless auth is none or oauth" + ))); + } + if provider_api.is_none() + && models.is_some_and(|models| { + models + .iter() + .any(|model| model.get("api").and_then(Value::as_str).is_none()) + }) + { + return Err(AppError::InvalidInput( + "OMP provider api is required at provider or every model".to_string(), + )); + } + } else { + let has_model_overrides = object + .get("modelOverrides") + .and_then(Value::as_object) + .is_some_and(|overrides| !overrides.is_empty()); + let has_nonempty_base_url = object + .get("baseUrl") + .and_then(Value::as_str) + .is_some_and(|value| !value.trim().is_empty()); + let has_nonempty_api_key = object + .get("apiKey") + .and_then(Value::as_str) + .is_some_and(|value| !value.trim().is_empty()); + let has_nonempty_headers = object + .get("headers") + .and_then(Value::as_object) + .is_some_and(|headers| !headers.is_empty()); + let has_nonempty_compat = object + .get("compat") + .and_then(Value::as_object) + .is_some_and(|compat| !compat.is_empty()); + let has_nonempty_request_metadata = object + .get("requestMetadata") + .and_then(Value::as_object) + .is_some_and(|metadata| !metadata.is_empty()); + let auth_none = object.get("auth").and_then(Value::as_str) == Some("none"); + let has_disable_strict_tools = object + .get("disableStrictTools") + .and_then(Value::as_bool) + .unwrap_or(false); + let has_guardrail = object + .get("guardrailIdentifier") + .and_then(Value::as_str) + .is_some_and(|value| !value.trim().is_empty()); + let has_known_override = has_nonempty_base_url + || has_nonempty_api_key + || auth_none + || has_nonempty_headers + || has_nonempty_compat + || has_nonempty_request_metadata + || has_disable_strict_tools + || has_guardrail + || object.get("remoteCompaction").is_some() + || has_model_overrides + || object.get("discovery").is_some(); + // Unknown fields are preserved verbatim for forward compatibility, + // but they do not satisfy OMP's semantic requirement on their own. + // Extension-owned nodes are the sole exception: the explicit + // `extension` marker tells OMP that the extension supplies the rest of + // the provider behavior. + let has_extension_marker = object.contains_key("extension"); + if !has_known_override && !has_extension_marker { + return Err(AppError::InvalidInput(format!( + "OMP provider '{provider_key}' must specify an override or at least one model" + ))); + } + } + + if let Some(discovery) = object.get("discovery").and_then(Value::as_object) { + let discovery_type = discovery + .get("type") + .and_then(Value::as_str) + .unwrap_or_default(); + if discovery_type != "proxy" && provider_api.is_none() { + return Err(AppError::InvalidInput( + "OMP provider api is required when discovery is enabled unless discovery.type is proxy" + .to_string(), + )); + } + } + } + Ok(()) +} + +fn validate_optional_bool( + object: &Map, + key: &str, + label: &str, +) -> Result<(), AppError> { + if object.get(key).is_some_and(|value| !value.is_boolean()) { + return Err(AppError::InvalidInput(format!("{label} must be a boolean"))); + } + Ok(()) +} + +fn validate_optional_string( + object: &Map, + key: &str, + label: &str, +) -> Result<(), AppError> { + if object.get(key).is_some_and(|value| !value.is_string()) { + return Err(AppError::InvalidInput(format!("{label} must be a string"))); + } + Ok(()) +} + +fn validate_optional_nonempty_string( + object: &Map, + key: &str, + label: &str, +) -> Result<(), AppError> { + let Some(value) = object.get(key) else { + return Ok(()); + }; + let value = value + .as_str() + .ok_or_else(|| AppError::InvalidInput(format!("{label} must be a string")))?; + if value.trim().is_empty() { + return Err(AppError::InvalidInput(format!("{label} must be non-empty"))); + } + Ok(()) +} + +fn validate_optional_enum( + object: &Map, + key: &str, + allowed: &[&str], + label: &str, +) -> Result<(), AppError> { + let Some(value) = object.get(key) else { + return Ok(()); + }; + let value = value + .as_str() + .ok_or_else(|| AppError::InvalidInput(format!("{label} must be a string")))?; + if !allowed.contains(&value) { + return Err(AppError::InvalidInput(format!( + "Unsupported {label} '{value}'. Supported: {}", + allowed.join(", ") + ))); + } + Ok(()) +} + +fn validate_model_metadata( + model: &Map, + context: &str, + require_complete_cost: bool, +) -> Result<(), AppError> { + validate_optional_nonempty_string(model, "name", &format!("{context} name"))?; + validate_optional_bool(model, "reasoning", &format!("{context} reasoning"))?; + validate_optional_bool(model, "supportsTools", &format!("{context} supportsTools"))?; + validate_optional_bool( + model, + "omitMaxOutputTokens", + &format!("{context} omitMaxOutputTokens"), + )?; + validate_optional_bool( + model, + "preferWebsockets", + &format!("{context} preferWebsockets"), + )?; + validate_optional_nonempty_string( + model, + "contextPromotionTarget", + &format!("{context} contextPromotionTarget"), + )?; + validate_optional_nonempty_string( + model, + "compactionModel", + &format!("{context} compactionModel"), + )?; + validate_optional_enum( + model, + "imageInputDecoder", + &["stb"], + &format!("{context} imageInputDecoder"), + )?; + validate_optional_enum( + model, + "tokenizer", + &[ + "claude-v3", + "claude-v47", + "claude-v5", + "claude-v5-sonnet", + "qwen3", + "deepseek-v3", + "kimi-k2", + "glm5", + ], + &format!("{context} tokenizer"), + )?; + if let Some(input) = model.get("input") { + let input = input + .as_array() + .ok_or_else(|| AppError::InvalidInput(format!("{context} input must be an array")))?; + if input.iter().any(|value| { + !value + .as_str() + .is_some_and(|value| matches!(value, "text" | "image")) + }) { + return Err(AppError::InvalidInput(format!( + "{context} input values must be 'text' or 'image'" + ))); + } + } + if let Some(thinking) = model.get("thinking") { + validate_thinking(thinking, &format!("{context} thinking"))?; + } + if let Some(headers) = model.get("headers") { + let headers = headers.as_object().ok_or_else(|| { + AppError::InvalidInput(format!("{context} headers must be an object")) + })?; + if headers.values().any(|value| !value.is_string()) { + return Err(AppError::InvalidInput(format!( + "{context} headers values must be strings" + ))); + } + } + if let Some(compat) = model.get("compat") { + validate_compat(compat, &format!("{context} compat"))?; + } + if let Some(cost) = model.get("cost") { + let cost = cost + .as_object() + .ok_or_else(|| AppError::InvalidInput(format!("{context} cost must be an object")))?; + if require_complete_cost + && ["input", "output", "cacheRead", "cacheWrite"] + .iter() + .any(|key| !cost.contains_key(*key)) + { + return Err(AppError::InvalidInput(format!( + "{context} cost must contain input, output, cacheRead, and cacheWrite" + ))); + } + if cost.values().any(|value| !value.is_number()) { + return Err(AppError::InvalidInput(format!( + "{context} cost values must be numbers" + ))); + } + } + for field in ["premiumMultiplier", "contextWindow", "maxTokens"] { + if let Some(value) = model.get(field) { + let number = value.as_f64().ok_or_else(|| { + AppError::InvalidInput(format!("{context} {field} must be a number")) + })?; + // OMP uses premiumMultiplier as a cost multiplier and permits + // zero to explicitly disable the premium surcharge. Context and + // output limits remain strictly positive below. + let valid = if field == "premiumMultiplier" { + number.is_finite() && number >= 0.0 + } else { + number.is_finite() && number > 0.0 + }; + if !valid { + return Err(AppError::InvalidInput(format!( + "{context} {field} must be {}", + if field == "premiumMultiplier" { + "non-negative" + } else { + "positive" + } + ))); + } + } + } + if let Some(remote) = model.get("remoteCompaction") { + validate_remote_compaction(remote, &format!("{context} remoteCompaction"))?; + } + Ok(()) +} + +fn validate_remote_compaction(value: &Value, context: &str) -> Result<(), AppError> { + let object = value + .as_object() + .ok_or_else(|| AppError::InvalidInput(format!("{context} must be an object")))?; + validate_optional_bool(object, "enabled", &format!("{context} enabled"))?; + validate_optional_enum(object, "api", &OMP_API_PROTOCOLS, &format!("{context} api"))?; + for field in ["endpoint", "model", "v2Endpoint", "streamingEndpoint"] { + validate_optional_nonempty_string(object, field, &format!("{context} {field}"))?; + } + validate_optional_bool( + object, + "v2StreamingEnabled", + &format!("{context} v2StreamingEnabled"), + )?; + Ok(()) +} + +fn validate_thinking(value: &Value, context: &str) -> Result<(), AppError> { + let object = value + .as_object() + .ok_or_else(|| AppError::InvalidInput(format!("{context} must be an object")))?; + validate_optional_enum( + object, + "mode", + &[ + "effort", + "budget", + "google-level", + "anthropic-adaptive", + "anthropic-budget-effort", + ], + &format!("{context} mode"), + )?; + if !object.contains_key("mode") { + return Err(AppError::InvalidInput(format!( + "{context} mode is required" + ))); + } + for key in ["efforts", "levels"] { + if let Some(values) = object.get(key) { + let values = values.as_array().ok_or_else(|| { + AppError::InvalidInput(format!("{context} {key} must be an array")) + })?; + if values.iter().any(|value| { + !value.as_str().is_some_and(|value| { + matches!( + value, + "minimal" | "low" | "medium" | "high" | "xhigh" | "max" + ) + }) + }) { + return Err(AppError::InvalidInput(format!( + "{context} {key} values are invalid" + ))); + } + } + } + for key in ["defaultLevel", "minLevel", "maxLevel"] { + validate_optional_enum( + object, + key, + &["minimal", "low", "medium", "high", "xhigh", "max"], + &format!("{context} {key}"), + )?; + } + validate_optional_bool( + object, + "supportsDisplay", + &format!("{context} supportsDisplay"), + )?; + validate_optional_bool( + object, + "requiresEffort", + &format!("{context} requiresEffort"), + )?; + if let Some(map) = object.get("effortMap") { + validate_string_map(map, &format!("{context} effortMap"))?; + } + let has_efforts = object + .get("efforts") + .or_else(|| object.get("levels")) + .is_some(); + let has_range = object.contains_key("minLevel") && object.contains_key("maxLevel"); + if !has_efforts && !has_range { + return Err(AppError::InvalidInput(format!( + "{context} requires efforts, levels, or minLevel/maxLevel" + ))); + } + Ok(()) +} + +fn validate_compat(value: &Value, context: &str) -> Result<(), AppError> { + let object = value + .as_object() + .ok_or_else(|| AppError::InvalidInput(format!("{context} must be an object")))?; + + for key in [ + "supportsStore", + "supportsDeveloperRole", + "supportsMultipleSystemMessages", + "supportsReasoningEffort", + "supportsUsageInStreaming", + "requiresToolResultName", + "requiresMistralToolIds", + "requiresAssistantAfterToolResult", + "requiresThinkingAsText", + "requiresReasoningContentForToolCalls", + "allowsSyntheticReasoningContentForToolCalls", + "requiresAssistantContentForToolCalls", + "supportsToolChoice", + "supportsForcedToolChoice", + "disableReasoningOnForcedToolChoice", + "disableReasoningOnToolChoice", + "qwenTemplateReasoningEffort", + "supportsStrictMode", + "supportsLongPromptCacheRetention", + "supportsReasoningParams", + "supportsReasoningSummary", + "alwaysSendMaxTokens", + "strictResponsesPairing", + "supportsImageDetailOriginal", + "supportsContextManagement", + "supportsEagerToolInputStreaming", + "allowAnthropicHeaderOverrides", + "requiresToolResultId", + "replayUnsignedThinking", + ] { + validate_optional_bool(object, key, &format!("{context} {key}"))?; + } + + validate_optional_enum( + object, + "maxTokensField", + &["max_completion_tokens", "max_tokens"], + &format!("{context} maxTokensField"), + )?; + validate_optional_enum( + object, + "reasoningContentField", + &["reasoning_content", "reasoning", "reasoning_text"], + &format!("{context} reasoningContentField"), + )?; + validate_optional_enum( + object, + "thinkingFormat", + &["openai", "openrouter", "zai", "qwen", "qwen-chat-template"], + &format!("{context} thinkingFormat"), + )?; + validate_optional_enum( + object, + "cacheControlFormat", + &["anthropic"], + &format!("{context} cacheControlFormat"), + )?; + validate_optional_enum( + object, + "toolStrictMode", + &["all_strict", "none"], + &format!("{context} toolStrictMode"), + )?; + validate_optional_enum( + object, + "streamMarkupHealingPattern", + &["kimi", "dsml", "qwen", "thinking"], + &format!("{context} streamMarkupHealingPattern"), + )?; + validate_optional_enum( + object, + "promptCacheMode", + &["none", "automatic", "explicit"], + &format!("{context} promptCacheMode"), + )?; + + if let Some(map) = object.get("reasoningEffortMap") { + validate_string_map(map, &format!("{context} reasoningEffortMap"))?; + } + if let Some(map) = object.get("openRouterRouting") { + validate_routing_map(map, &format!("{context} openRouterRouting"))?; + } + if let Some(map) = object.get("vercelGatewayRouting") { + validate_routing_map(map, &format!("{context} vercelGatewayRouting"))?; + } + if let Some(extra_body) = object.get("extraBody") { + if !extra_body.is_object() { + return Err(AppError::InvalidInput(format!( + "{context} extraBody must be an object" + ))); + } + } + for field in [ + "streamIdleTimeoutMs", + "promptCacheMinimumTokens", + "promptCacheMaximumCheckpoints", + ] { + if let Some(value) = object.get(field) { + let valid = value + .as_f64() + .is_some_and(|number| number.is_finite() && number >= 0.0); + if !valid { + return Err(AppError::InvalidInput(format!( + "{context} {field} must be a non-negative number" + ))); + } + } + } + if let Some(when_thinking) = object.get("whenThinking") { + validate_compat(when_thinking, &format!("{context} whenThinking"))?; + } + Ok(()) +} + +fn validate_string_map(value: &Value, context: &str) -> Result<(), AppError> { + let object = value + .as_object() + .ok_or_else(|| AppError::InvalidInput(format!("{context} must be an object")))?; + if object.values().any(|value| !value.is_string()) { + return Err(AppError::InvalidInput(format!( + "{context} values must be strings" + ))); + } + Ok(()) +} + +fn validate_routing_map(value: &Value, context: &str) -> Result<(), AppError> { + let object = value + .as_object() + .ok_or_else(|| AppError::InvalidInput(format!("{context} must be an object")))?; + for key in ["only", "order"] { + if let Some(values) = object.get(key) { + let values = values.as_array().ok_or_else(|| { + AppError::InvalidInput(format!("{context} {key} must be an array")) + })?; + if values.iter().any(|value| !value.is_string()) { + return Err(AppError::InvalidInput(format!( + "{context} {key} values must be strings" + ))); + } + } + } + Ok(()) +} + +/// Validate a provider before CC Switch writes it into OMP's live registry. +/// OMP permits keyless custom model providers only when authentication is +/// explicitly disabled with `none`; a full custom model list otherwise needs a +/// configured API key. Existing native entries are still imported verbatim so +/// built-in/provider-extension credentials remain intact. +pub(crate) fn validate_provider_for_live_write( + provider_key: &str, + config: &Value, +) -> Result<(), AppError> { + validate_provider_node(provider_key, config) +} + +pub(crate) fn validate_api_protocol(api: &str) -> Result<(), AppError> { + if OMP_API_PROTOCOLS.contains(&api.trim()) { + Ok(()) + } else { + Err(AppError::InvalidInput(format!( + "Unsupported OMP API protocol '{}'. Supported: {}", + api.trim(), + OMP_API_PROTOCOLS.join(", ") + ))) + } +} + +pub(crate) fn provider_base_url(config: &Value) -> Result { + let provider = config.as_object().ok_or_else(|| { + AppError::InvalidInput("OMP provider configuration must be an object".to_string()) + })?; + if let Some(url) = nonempty_string(provider.get("baseUrl")) { + return Ok(url.to_string()); + } + if let Some(url) = provider + .get("models") + .and_then(Value::as_array) + .and_then(|models| { + models + .iter() + .find_map(|model| nonempty_string(model.get("baseUrl"))) + }) + { + return Ok(url.to_string()); + } + + // OMP's built-in discovery providers are usable without an explicit + // provider baseUrl. Mirror the native defaults so CC Switch model fetch, + // stream checks, and TUI forms can address those local gateways too. + if let Some(discovery_type) = provider + .get("discovery") + .and_then(Value::as_object) + .and_then(|discovery| discovery.get("type")) + .and_then(Value::as_str) + { + let env_url = |name: &str| resolve_omp_env_value(name); + let default = match discovery_type { + "ollama" => env_url("OLLAMA_BASE_URL") + .and_then(|value| normalize_ollama_base_url(&value)) + .or_else(|| { + resolve_omp_env_value("OLLAMA_HOST") + .and_then(|value| normalize_ollama_host_env(&value)) + }) + .unwrap_or_else(|| "http://127.0.0.1:11434".to_string()), + "llama.cpp" => { + env_url("LLAMA_CPP_BASE_URL").unwrap_or_else(|| "http://127.0.0.1:8080".to_string()) + } + "lm-studio" => env_url("LM_STUDIO_BASE_URL") + .unwrap_or_else(|| "http://127.0.0.1:1234/v1".to_string()), + "openai-models-list" => "http://127.0.0.1:1234/v1".to_string(), + // OMP's proxy discovery is a local OpenAI-compatible gateway; + // the native implementation supplies this endpoint when the + // provider omits an explicit baseUrl. + "proxy" => "http://127.0.0.1:1234/v1".to_string(), + "litellm" => env_url("LITELLM_BASE_URL") + .unwrap_or_else(|| "http://localhost:4000/v1".to_string()), + _ => { + return Err(AppError::InvalidInput( + "OMP provider has no request URL".to_string(), + )) + } + }; + return Ok(default); + } + + Err(AppError::InvalidInput( + "OMP provider has no request URL".to_string(), + )) +} + +/// Normalize Ollama's host-style environment variable into the URL form used +/// by its model discovery client. OLLAMA_HOST accepts values such as +/// `127.0.0.1:11434`, `:11434`, `//ollama.example`, and full HTTP(S) URLs; +/// discovery always addresses the host root and supplies the native HTTP +/// default port when one is omitted. +fn normalize_ollama_host_env(value: &str) -> Option { + const DEFAULT_PORT: u16 = 11434; + + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + let candidate = if trimmed.contains("://") { + trimmed.to_string() + } else if trimmed.starts_with("//") { + format!("http:{trimmed}") + } else if trimmed.starts_with(':') { + format!("http://127.0.0.1{trimmed}") + } else { + format!("http://{trimmed}") + }; + + let mut parsed = Url::parse(&candidate).ok()?; + if parsed.host_str().is_none() || !matches!(parsed.scheme(), "http" | "https") { + return None; + } + if parsed.port().is_none() && parsed.scheme() == "http" { + parsed.set_port(Some(DEFAULT_PORT)).ok()?; + } + + // OMP's implicit discovery URL consists only of scheme, host, and port; + // paths, credentials, queries, and fragments in OLLAMA_HOST are ignored. + let host = parsed.host_str()?; + let host = if host.contains(':') && !host.starts_with('[') { + format!("[{host}]") + } else { + host.to_string() + }; + let authority = parsed + .port() + .map(|port| format!("{host}:{port}")) + .unwrap_or(host); + Some(format!("{}://{authority}", parsed.scheme())) +} + +pub(crate) fn normalize_ollama_base_url(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + let mut parsed = Url::parse(trimmed).ok()?; + if parsed.host_str().is_none() || !matches!(parsed.scheme(), "http" | "https") { + return None; + } + + // OMP's OLLAMA_BASE_URL is an origin override, unlike OLLAMA_HOST's + // host-style syntax. Preserve an explicit port but discard path, query, + // fragment, and credentials before discovery appends /api/tags. + parsed.set_username("").ok()?; + parsed.set_password(None).ok()?; + parsed.set_path(""); + parsed.set_query(None); + parsed.set_fragment(None); + Some(parsed.to_string().trim_end_matches('/').to_string()) +} + +/// Return the effective timeout for an OMP provider's native discovery. +/// +/// OMP defaults discovery probes to ten seconds. Preserve that default when +/// a discovery block is present but does not specify `timeoutMs`, round +/// fractional values up to the next millisecond, and cap the result so a +/// native file cannot make CC Switch block indefinitely. +pub(crate) fn omp_discovery_timeout_ms(config: &Value) -> Option { + let discovery = config.get("discovery")?.as_object()?; + let timeout = discovery + .get("timeoutMs") + .and_then(Value::as_f64) + .filter(|value| value.is_finite() && *value > 0.0) + .map(|value| value.ceil() as u64) + .unwrap_or(OMP_DEFAULT_DISCOVERY_TIMEOUT_MS); + Some(timeout.clamp(1, OMP_MAX_DISCOVERY_TIMEOUT_MS)) +} + +/// Resolve an OMP discovery environment variable with shell variables taking +/// precedence over the dotenv files OMP loads for the current project/profile. +fn resolve_omp_env_value(name: &str) -> Option { + env_value_exact(name) + .filter(|value| !value.trim().is_empty()) + .or_else(|| resolve_omp_dotenv_value(name)) +} + +/// Resolve an OMP `apiKey` value using OMP's env-name-or-literal semantics. +/// +/// OMP treats the configured value as `!command`, an environment variable name, +/// or a literal string (in that order). Command output is bounded and cached so +/// native provider credentials behave consistently without repeated execution. +pub(crate) fn resolve_api_key(value: Option<&Value>) -> Option { + let configured = value.and_then(Value::as_str)?.trim(); + if configured.is_empty() { + return None; + } + if let Some(command) = configured.strip_prefix('!') { + return resolve_omp_command_value(command.trim()); + } + env_value_exact(configured) + .filter(|resolved| !resolved.trim().is_empty()) + .or_else(|| resolve_omp_dotenv_value(configured)) + .or_else(|| Some(configured.to_string())) +} + +/// Resolve OMP's `!command` secret form with the same bounded behavior as the +/// native runtime: shell execution, trimmed stdout, a ten-second timeout, and +/// process-lifetime caching of successful values. +pub(crate) fn resolve_header_value(value: &str) -> Option { + let value = value.trim(); + if let Some(command) = value.strip_prefix('!') { + return resolve_omp_command_value(command.trim()); + } + env_value_exact(value) + .filter(|resolved| !resolved.trim().is_empty()) + .or_else(|| resolve_omp_dotenv_value(value)) + .or_else(|| Some(value.to_string())) +} + +/// Read a user-supplied environment variable name without Windows' implicit +/// case folding. OMP uses an exact-name lookup for configured API-key/header +/// references so a literal such as `public` cannot be hijacked by the system's +/// differently-cased `PUBLIC` variable. +fn env_value_exact(name: &str) -> Option { + #[cfg(windows)] + { + return std::env::vars() + .find(|(key, _)| key == name) + .map(|(_, value)| value); + } + + #[cfg(not(windows))] + { + std::env::var(name).ok() + } +} + +fn resolve_omp_command_value(command: &str) -> Option { + if command.is_empty() { + return None; + } + if let Ok(cache) = COMMAND_VALUE_CACHE.lock() { + if let Some(value) = cache.get(command) { + return Some(value.clone()); + } + } + if let Ok(mut failures) = COMMAND_FAILURE_CACHE.lock() { + if let Some(failed_at) = failures.get(command).copied() { + if failed_at.elapsed() < Duration::from_secs(30) { + return None; + } + failures.remove(command); + } + } + + let result = (|| { + let mut child = spawn_omp_shell(command, Stdio::piped()).ok()?; + let stdout = child.stdout.take()?; + let (output_tx, output_rx) = mpsc::sync_channel(1); + thread::spawn(move || { + let mut bytes = Vec::new(); + let result = stdout + .take((MAX_COMMAND_OUTPUT_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map(|_| bytes); + let _ = output_tx.send(result); + }); + + let mut captured_output = None; + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Ok(result) = output_rx.try_recv() { + match result { + Ok(bytes) if bytes.len() <= MAX_COMMAND_OUTPUT_BYTES => { + captured_output = Some(bytes); + } + _ => { + cleanup_omp_command(&mut child); + return None; + } + } + } + match child.try_wait() { + Ok(Some(status)) => { + // A shell can leave background descendants behind even + // after it exits successfully. Kill the dedicated process + // group before collecting output so command-backed secrets + // cannot leak long-lived processes. + cleanup_omp_command(&mut child); + if !status.success() { + return None; + } + if captured_output.is_none() { + let bytes = output_rx + .recv_timeout(Duration::from_secs(1)) + .ok() + .and_then(Result::ok)?; + if bytes.len() > MAX_COMMAND_OUTPUT_BYTES { + return None; + } + captured_output = Some(bytes); + } + break; + } + Ok(None) if Instant::now() < deadline => { + thread::sleep(Duration::from_millis(25)); + } + Ok(None) => { + cleanup_omp_command(&mut child); + return None; + } + Err(_) => { + cleanup_omp_command(&mut child); + return None; + } + } + } + + let bytes = captured_output?; + let value = String::from_utf8(bytes).ok()?.trim().to_string(); + (!value.is_empty()).then_some(value) + })(); + + match result { + Some(value) => { + if let Ok(mut cache) = COMMAND_VALUE_CACHE.lock() { + cache.insert(command.to_string(), value.clone()); + } + if let Ok(mut failures) = COMMAND_FAILURE_CACHE.lock() { + failures.remove(command); + } + Some(value) + } + None => { + if let Ok(mut failures) = COMMAND_FAILURE_CACHE.lock() { + failures.insert(command.to_string(), Instant::now()); + } + None + } + } +} + +fn spawn_omp_shell(command: &str, stdout: Stdio) -> std::io::Result { + #[cfg(unix)] + let mut shell = Command::new("/bin/sh"); + #[cfg(windows)] + let mut shell = Command::new("cmd.exe"); + + #[cfg(unix)] + // Put the shell and every descendant it starts into a private process + // group. This lets timeout/completion cleanup terminate the whole command + // tree rather than only the direct shell process. + unsafe { + shell.pre_exec(|| { + if libc::setsid() == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + + // OMP eagerly folds project/agent/config-root/home dotenv files into the + // Bun process environment before resolving `!command` values. Mirror that + // behavior here while preserving the parent process environment's + // precedence for explicitly exported variables. + let dotenv_env = resolve_omp_dotenv_environment(); + + #[cfg(unix)] + shell.arg("-c").arg(command); + #[cfg(windows)] + // Bun's shell-backed command execution uses the native Windows command + // interpreter. `sh` is not guaranteed to be installed on Windows, while + // cmd.exe is always available and understands the same `%VAR%` expansion + // users commonly rely on in OMP `!command` credentials. + shell.args(["/D", "/S", "/C", command]); + + shell + .stdin(Stdio::null()) + .stdout(stdout) + .stderr(Stdio::null()) + .envs(dotenv_env) + .spawn() +} + +fn cleanup_omp_command(child: &mut Child) { + #[cfg(unix)] + { + // The shell's PID is also the process-group ID created by setsid. + // A negative PID targets the complete group; ESRCH is harmless when + // the shell already exited and no descendants remain. + let pgid = child.id() as libc::pid_t; + unsafe { + let _ = libc::kill(-pgid, libc::SIGKILL); + } + } + let _ = child.kill(); + let _ = child.wait(); +} + +/// Resolve a named OMP environment variable using the same dotenv locations +/// that OMP loads for an agent process. The shell environment wins, followed +/// by the project, agent, OMP config-root, and home dotenv files. +fn resolve_omp_dotenv_value(name: &str) -> Option { + resolve_omp_dotenv_environment() + .get(name) + .filter(|value| !value.trim().is_empty()) + .cloned() +} + +/// Load OMP's dotenv files in native precedence order. Values already present +/// in the process environment win; earlier files win over later files. The +/// dotenv parser mirrors OMP's compatibility behavior for the documented +/// `OMP_PROFILE`/`PI_PROFILE` pair without inventing aliases for other names. +fn resolve_omp_dotenv_environment() -> HashMap { + let home = get_home_dir(); + // Resolve path selectors through the same two-pass dotenv loader used by + // native path discovery. In particular, PI_CONFIG_DIR may be supplied by + // ~/.env; deriving the config root from process-only variables would then + // omit the selected custom root's own .env file. + let path_env = resolve_omp_path_environment(); + let config_dir = path_env + .get("PI_CONFIG_DIR") + .filter(|value| !value.is_empty()) + .cloned() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".omp")); + let config_root = omp_config_root(&home, config_dir); + let profile = effective_profile(&path_env).ok().flatten(); + let profile_root = profile + .as_ref() + .map(|profile| config_root.join("profiles").join(profile)) + .unwrap_or_else(|| config_root.clone()); + // If native path resolution rejects an invalid profile or directory, + // avoid guessing a fallback agent root for credential commands. The + // profile/config dotenv layers below remain safe to inspect, while a + // malformed selector cannot redirect command execution to an unrelated + // directory. + let agent_dir = get_omp_agent_dir().ok(); + let mut paths = Vec::new(); + if let Ok(cwd) = std::env::current_dir() { + paths.push(cwd.join(".env")); + } + if let Some(agent_dir) = agent_dir { + paths.push(agent_dir.join(".env")); + } + paths.push(profile_root.join(".env")); + paths.push(home.join(".env")); + + let process_env: HashMap = std::env::vars().collect(); + // Preserve the process environment (which OMP inherits). Only the + // documented OMP_PROFILE/PI_PROFILE pair is mirrored; arbitrary + // OMP_* variables are not path aliases in the native executable. + let mut resolved = process_env.clone(); + let process_aliases = process_env + .iter() + .filter(|(key, _)| *key == "OMP_PROFILE") + .map(|(_, value)| ("PI_PROFILE".to_string(), value.clone())) + .collect::>(); + for (alias, value) in process_aliases { + // An explicitly exported PI_* value wins over the compatibility + // alias, matching OMP's credential resolver and avoiding surprising + // replacement of a user's legacy variable. + resolved.entry(alias).or_insert(value); + } + let mut seen = std::collections::HashSet::new(); + for path in paths { + if !seen.insert(path.clone()) { + continue; + } + for (key, value) in read_omp_dotenv_file(&path) { + if value.trim().is_empty() { + continue; + } + if process_env.contains_key(&key) || resolved.contains_key(&key) { + continue; + } + resolved.insert(key.clone(), value.clone()); + // `read_omp_dotenv_file` already materializes the documented + // profile compatibility alias. No other aliases are synthesized. + } + } + resolved +} + +fn read_omp_dotenv_file(path: &Path) -> HashMap { + let Ok(contents) = read_file_limited(path, "OMP dotenv") else { + return HashMap::new(); + }; + let Ok(contents) = String::from_utf8(contents) else { + return HashMap::new(); + }; + let mut values = HashMap::new(); + for line in contents.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + // OMP accepts `export` followed by any horizontal whitespace (spaces + // or tabs), not just one literal ASCII space. + let line = if let Some(rest) = line.strip_prefix("export") { + if rest + .as_bytes() + .first() + .is_some_and(|byte| *byte == b' ' || *byte == b'\t') + { + rest.trim_start() + } else { + line + } + } else { + line + }; + let Some((raw_name, raw_value)) = line.split_once('=') else { + continue; + }; + let key = raw_name.trim(); + let mut chars = key.chars(); + let valid_name = chars + .next() + .is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic()) + && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()); + if !valid_name { + continue; + } + let value = parse_omp_dotenv_value(raw_value.trim()); + // OMP drops dotenv entries containing NUL bytes; retaining them would + // later make HeaderValue construction fail (or produce divergent + // credential resolution). + if value.contains('\0') { + continue; + } + values.insert(key.to_string(), value); + } + + // Only the profile selector has a documented OMP_/PI_ compatibility pair. + // OMP_PROFILE is canonical and overrides PI_PROFILE within the same file. + if let Some(value) = values.get("OMP_PROFILE").cloned() { + values.insert("PI_PROFILE".to_string(), value); + } + + values +} + +#[cfg(test)] +fn read_omp_dotenv_value(path: &Path, name: &str) -> Option { + let values = read_omp_dotenv_file(path); + values + .get(name) + .filter(|value| !value.trim().is_empty()) + .cloned() +} + +fn parse_omp_dotenv_value(raw: &str) -> String { + if let Some(quote @ ('"' | '\'' | '`')) = raw.chars().next() { + let mut close = raw[1..].find(quote).map(|index| index + 1); + while let Some(index) = close { + if index == 0 || raw.as_bytes().get(index - 1) != Some(&b'\\') { + return raw[1..index].to_string(); + } + close = raw[index + quote.len_utf8()..] + .find(quote) + .map(|next| index + quote.len_utf8() + next); + } + // Match OMP's permissive dotenv parser for an unterminated quote: + // strip the opening quote instead of treating it as part of the + // secret value. + return raw[quote.len_utf8()..].to_string(); + } + + raw.split_once(" #") + .map(|(value, _)| value.trim_end()) + .unwrap_or(raw) + .to_string() +} + +/// Resolve a provider credential for model-list requests. OMP's Gemini CLI +/// integration stores a JSON credential blob; its model endpoint expects the +/// embedded OAuth access token rather than the serialized object. +pub(crate) fn resolve_api_key_for_protocol( + value: Option<&Value>, + protocol: Option<&str>, +) -> Option { + let resolved = resolve_api_key(value)?; + if protocol == Some("google-gemini-cli") { + if let Ok(blob) = serde_json::from_str::(&resolved) { + if let Some(token) = blob + .get("access_token") + .and_then(Value::as_str) + .map(str::trim) + .filter(|token| !token.is_empty()) + { + return Some(token.to_string()); + } + } + } + Some(resolved) +} + +pub(crate) fn is_valid_request_url(raw: &str) -> bool { + url::Url::parse(raw.trim()) + .is_ok_and(|url| matches!(url.scheme(), "http" | "https") && url.host_str().is_some()) +} + +fn lock_models_file() -> Result, AppError> { + MODELS_FILE_LOCK + .lock() + .map_err(|error| AppError::Config(format!("OMP models file lock is poisoned: {error}"))) +} + +fn read_omp_native_providers_locked(path: &Path) -> Result, AppError> { + let document = read_models_document(path)?; + let providers = providers(&document, path)?; + Ok(providers + .iter() + .map(|(provider_key, config)| (provider_key.clone(), config.clone())) + .collect()) +} + +fn read_models_document(path: &Path) -> Result { + read_models_document_with_revision(path).map(|(document, _)| document) +} + +fn read_config_document(path: &Path) -> Result { + read_config_document_with_revision(path).map(|(document, _)| document) +} + +fn read_config_document_with_revision(path: &Path) -> Result<(Value, String), AppError> { + if !path.exists() { + return Ok(( + Value::Object(Map::new()), + MISSING_CONFIG_REVISION.to_string(), + )); + } + let bytes = read_file_limited(path, "OMP config")?; + let revision = revision(&bytes); + if bytes.iter().all(u8::is_ascii_whitespace) { + return Ok((Value::Object(Map::new()), revision)); + } + let document = parse_yaml_value(path, "OMP config", bytes)?; + Ok((document, revision)) +} + +fn read_models_document_with_revision(path: &Path) -> Result<(Value, String), AppError> { + if !path.exists() { + return Ok(( + Value::Object(Map::new()), + MISSING_MODELS_REVISION.to_string(), + )); + } + let bytes = read_file_limited(path, "OMP models")?; + let revision = revision(&bytes); + if bytes.iter().all(u8::is_ascii_whitespace) { + return Ok((Value::Object(Map::new()), revision)); + } + let document = parse_yaml_value(path, "OMP models", bytes)?; + Ok((document, revision)) +} + +fn read_yaml_text_with_revision(path: &Path, label: &str) -> Result<(String, String), AppError> { + let bytes = match fs::File::open(path) { + Ok(_) => read_file_limited(path, label)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + // Checked writers use a stable sentinel for an absent native + // document. A content hash of the empty byte string made the + // first `model add`/TUI save fail its compare-and-swap check. + let missing_revision = if label == "OMP models" { + MISSING_MODELS_REVISION + } else { + MISSING_CONFIG_REVISION + }; + return Ok((String::new(), missing_revision.to_string())); + } + Err(error) => return Err(AppError::io(path, error)), + }; + let revision = revision(&bytes); + let text = String::from_utf8(bytes).map_err(|error| { + AppError::Config(format!( + "{label} must be UTF-8 ({}): {error}", + path.display() + )) + })?; + Ok((text, revision)) +} + +fn read_file_limited(path: &Path, label: &str) -> Result, AppError> { + let file = fs::File::open(path).map_err(|error| AppError::io(path, error))?; + let metadata = file.metadata().map_err(|error| AppError::io(path, error))?; + if metadata.len() > MAX_OMP_FILE_BYTES { + return Err(AppError::InvalidInput(format!( + "{label} file exceeds the 1 MiB limit: {}", + path.display() + ))); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take(MAX_OMP_FILE_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| AppError::io(path, error))?; + if bytes.len() as u64 > MAX_OMP_FILE_BYTES { + return Err(AppError::InvalidInput(format!( + "{label} file exceeds the 1 MiB limit: {}", + path.display() + ))); + } + Ok(bytes) +} + +fn parse_yaml_value(path: &Path, label: &str, bytes: Vec) -> Result { + let source = String::from_utf8(bytes).map_err(|error| { + AppError::Config(format!( + "{label} file must be UTF-8 ({}): {error}", + path.display() + )) + })?; + serde_yaml::from_str(&source).map_err(|error| { + AppError::Config(format!( + "{label} file is not valid YAML ({}): {error}", + path.display() + )) + }) +} + +fn providers<'a>(document: &'a Value, path: &Path) -> Result<&'a Map, AppError> { + let root = document.as_object().ok_or_else(|| { + AppError::Config(format!( + "OMP models root must be an object: {}", + path.display() + )) + })?; + // Reads stay tolerant of root fields introduced by newer OMP releases so + // the provider list remains inspectable. Writes preserve those fields + // while validating only the provider map that CC Switch mutates. + match root.get("providers") { + None => Ok(empty_json_object()), + Some(Value::Object(providers)) => Ok(providers), + Some(_) => Err(AppError::Config(format!( + "OMP models 'providers' must be an object: {}", + path.display() + ))), + } +} + +/// Validate the managed shape of the `models.yml` root. +/// +/// OMP evolves this document by adding top-level fields. Preserve those +/// unknown fields so provider edits cannot destroy settings from a newer +/// native release; only the provider map's type is constrained because it is +/// the portion CC Switch mutates. +fn validate_omp_models_root(document: &Value, path: &Path) -> Result<(), AppError> { + let root = document.as_object().ok_or_else(|| { + AppError::InvalidInput(format!( + "OMP models root must be an object: {}", + path.display() + )) + })?; + if let Some(value) = root.get("providers") { + if !value.is_object() { + return Err(AppError::InvalidInput(format!( + "OMP models 'providers' must be an object: {}", + path.display() + ))); + } + } + Ok(()) +} + +fn providers_mut<'a>( + document: &'a mut Value, + path: &Path, +) -> Result<&'a mut Map, AppError> { + let root = document.as_object_mut().ok_or_else(|| { + AppError::Config(format!( + "OMP models root must be an object: {}", + path.display() + )) + })?; + let value = root + .entry("providers".to_string()) + .or_insert_with(|| Value::Object(Map::new())); + value.as_object_mut().ok_or_else(|| { + AppError::Config(format!( + "OMP models 'providers' must be an object: {}", + path.display() + )) + }) +} + +fn empty_json_object() -> &'static Map { + static EMPTY: LazyLock> = LazyLock::new(Map::new); + &EMPTY +} + +fn write_models_document( + path: &Path, + document: &Value, + expected_revision: &str, +) -> Result<(), AppError> { + validate_omp_models_root(document, path)?; + let bytes = serde_yaml::to_string(document) + .map_err(|error| AppError::Config(format!("failed to serialize OMP models: {error}")))? + .into_bytes(); + ensure_private_omp_parent(path)?; + ensure_models_revision(path, expected_revision)?; + atomic_write_private(path, &bytes) +} + +fn write_config_document( + path: &Path, + document: &Value, + expected_revision: &str, +) -> Result<(), AppError> { + let bytes = serde_yaml::to_string(document) + .map_err(|error| AppError::Config(format!("failed to serialize OMP config: {error}")))? + .into_bytes(); + ensure_private_omp_parent(path)?; + ensure_config_revision(path, expected_revision)?; + atomic_write_private(path, &bytes) +} + +fn provider_configs_equal_ignoring_name(left: &Value, right: &Value) -> bool { + let mut left = left.clone(); + let mut right = right.clone(); + strip_native_name(&mut left); + strip_native_name(&mut right); + left == right +} + +fn strip_native_name(config: &mut Value) { + if let Some(object) = config.as_object_mut() { + object.remove("name"); + } +} + +fn ensure_models_revision(path: &Path, expected_revision: &str) -> Result<(), AppError> { + let actual_revision = match fs::File::open(path) { + Ok(_) => revision(&read_file_limited(path, "OMP models")?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + MISSING_MODELS_REVISION.to_string() + } + Err(error) => return Err(AppError::io(path, error)), + }; + if actual_revision == expected_revision { + Ok(()) + } else { + Err(AppError::Conflict(format!( + "OMP models.yml changed outside CC Switch: {}", + path.display() + ))) + } +} + +fn ensure_config_revision(path: &Path, expected_revision: &str) -> Result<(), AppError> { + let actual_revision = match fs::File::open(path) { + Ok(_) => revision(&read_file_limited(path, "OMP config")?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + MISSING_CONFIG_REVISION.to_string() + } + Err(error) => return Err(AppError::io(path, error)), + }; + if actual_revision == expected_revision { + Ok(()) + } else { + Err(AppError::Conflict(format!( + "OMP config.yml changed outside CC Switch: {}", + path.display() + ))) + } +} + +/// Guard the legacy JSONC source while materializing its first native YAML +/// overlay. The target YAML is intentionally absent at that point, so its +/// `missing` revision alone cannot detect a concurrent edit to the source +/// document that was copied into the new file. +fn ensure_omp_legacy_revision(path: &Path, expected_revision: &str) -> Result<(), AppError> { + let actual_revision = match fs::File::open(path) { + Ok(_) => revision(&read_file_limited(path, "OMP legacy settings")?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(AppError::Conflict(format!( + "OMP legacy settings changed outside CC Switch: {}", + path.display() + ))); + } + Err(error) => return Err(AppError::io(path, error)), + }; + if actual_revision == expected_revision { + Ok(()) + } else { + Err(AppError::Conflict(format!( + "OMP legacy settings changed outside CC Switch: {}", + path.display() + ))) + } +} + +/// Guard the legacy `models.json` source while materializing its first native +/// YAML overlay. A missing `models.yml` revision alone cannot detect a +/// concurrent edit to the JSONC document that was parsed into the new file. +fn ensure_omp_legacy_models_revision(path: &Path, expected_revision: &str) -> Result<(), AppError> { + let actual_revision = match fs::File::open(path) { + Ok(_) => revision(&read_file_limited(path, "OMP legacy models")?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(AppError::Conflict(format!( + "OMP legacy models changed outside CC Switch: {}", + path.display() + ))); + } + Err(error) => return Err(AppError::io(path, error)), + }; + if actual_revision == expected_revision { + Ok(()) + } else { + Err(AppError::Conflict(format!( + "OMP legacy models changed outside CC Switch: {}", + path.display() + ))) + } +} + +fn revision(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +/// Ensure a user-level OMP managed file is created below a private directory. +/// +/// Models/config files and user-level prompt overrides share the same native +/// agent directory. Keep the permission check in one place so a prompt write +/// cannot accidentally weaken the privacy guarantees applied to credentials. +pub(crate) fn ensure_private_omp_parent(path: &Path) -> Result<(), AppError> { + let parent = path.parent().ok_or_else(|| { + AppError::Config(format!( + "OMP managed path has no parent directory: {}", + path.display() + )) + })?; + + // Capture directories that do not exist before creation so only those + // newly materialized below an existing root receive private permissions. + // Walking with symlink_metadata (rather than exists/metadata) lets us + // reject dangling or redirected ancestors before create_dir_all follows + // them. + let mut missing = Vec::new(); + let mut cursor = parent; + loop { + match fs::symlink_metadata(cursor) { + Ok(_) => break, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + missing.push(cursor.to_path_buf()); + let Some(next) = cursor.parent() else { + break; + }; + if next == cursor { + break; + } + cursor = next; + } + Err(error) => return Err(AppError::io(cursor, error)), + } + } + fs::create_dir_all(parent).map_err(|source| AppError::io(parent, source))?; + + // Validate every existing ancestor after creation as well. This closes + // the common symlink/permission gap where an intermediate component was + // absent during the initial check but redirected before the write. + let mut ancestors = Vec::new(); + let mut cursor = parent; + loop { + ancestors.push(cursor.to_path_buf()); + let Some(next) = cursor.parent() else { + break; + }; + if next == cursor { + break; + } + cursor = next; + } + + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + for ancestor in ancestors { + let metadata = + fs::symlink_metadata(&ancestor).map_err(|source| AppError::io(&ancestor, source))?; + if metadata.file_type().is_symlink() { + return Err(AppError::InvalidInput(format!( + "OMP managed path cannot contain a symlinked directory: {}", + ancestor.display() + ))); + } + if !metadata.is_dir() { + return Err(AppError::InvalidInput(format!( + "OMP managed path component is not a directory: {}", + ancestor.display() + ))); + } + + #[cfg(unix)] + { + let mut mode = metadata.permissions().mode() & 0o7777; + if missing.iter().any(|path| path == &ancestor) { + fs::set_permissions(&ancestor, fs::Permissions::from_mode(0o700)) + .map_err(|source| AppError::io(&ancestor, source))?; + mode = 0o700; + } + let writable = mode & 0o022 != 0; + let world_writable = mode & 0o002 != 0; + // A sticky world-writable ancestor such as /tmp is safe as a + // shared container root; the managed directory itself must still + // remain private. Group-writable ancestors are retained for + // compatibility with user-managed workspace roots, while a + // non-sticky world-writable ancestor can redirect the path. + let shared_sticky_root = mode & 0o1000 != 0; + if (ancestor == parent && writable) + || (ancestor != parent && world_writable && !shared_sticky_root) + { + return Err(AppError::InvalidInput(format!( + "OMP managed directory cannot be group/other writable: {} ({mode:04o}); run chmod 700 {}", + ancestor.display(), + ancestor.display() + ))); + } + } + } + + Ok(()) +} + +fn nonempty_string(value: Option<&Value>) -> Option<&str> { + value + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) +} + +#[cfg(test)] +pub(crate) mod test_support { + use std::path::{Path, PathBuf}; + use std::sync::{Mutex, MutexGuard, OnceLock}; + + static CURRENT_DIR_LOCK: OnceLock> = OnceLock::new(); + + pub(crate) struct TestAgentDir { + _dir: Option, + previous: Option, + } + + impl TestAgentDir { + pub(crate) fn new() -> Self { + let dir = tempfile::tempdir().expect("create OMP test directory"); + let agent_dir = dir.path().join("agent"); + std::fs::create_dir_all(&agent_dir).expect("create OMP test agent directory"); + restrict_test_directory(&agent_dir); + Self::set(agent_dir, Some(dir)) + } + + pub(crate) fn at(agent_dir: &Path) -> Self { + if let Err(error) = std::fs::create_dir_all(agent_dir) { + panic!("create OMP test agent directory: {error}"); + } + restrict_test_directory(agent_dir); + Self::set(agent_dir.to_path_buf(), None) + } + + fn set(agent_dir: PathBuf, dir: Option) -> Self { + let previous = super::TEST_AGENT_DIR + .lock() + .expect("lock OMP test directory") + .replace(agent_dir); + Self { + _dir: dir, + previous, + } + } + } + + fn restrict_test_directory(path: &Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .expect("restrict OMP test agent directory"); + } + } + + impl Drop for TestAgentDir { + fn drop(&mut self) { + *super::TEST_AGENT_DIR + .lock() + .expect("lock OMP test directory") = self.previous.take(); + } + } + + /// Process-wide current-directory guard for tests that exercise OMP's + /// project-layer resolution. `std::env::set_current_dir` is process-global, + /// so tests must serialize it and restore the directory even when an + /// assertion panics; otherwise a dropped `TempDir` leaves later tests in a + /// non-existent working directory. + pub(crate) struct CurrentDirGuard { + _lock: MutexGuard<'static, ()>, + previous: PathBuf, + } + + impl CurrentDirGuard { + pub(crate) fn change_to(path: &Path) -> Self { + let lock = CURRENT_DIR_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let previous = std::env::current_dir().expect("read current directory"); + std::env::set_current_dir(path).expect("switch current directory"); + Self { + _lock: lock, + previous, + } + } + } + + impl Drop for CurrentDirGuard { + fn drop(&mut self) { + std::env::set_current_dir(&self.previous).expect("restore current directory"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use serial_test::serial; + + fn provider() -> Value { + json!({ + "name": "Example", + "baseUrl": "https://aomp.example.com/v1", + "api": "openai-completions", + "apiKey": "secret", + "models": [{"id": "example-model"}] + }) + } + + #[test] + fn provider_node_accepts_unknown_native_fields() { + let mut value = provider(); + value["sdkOption"] = json!({"timeout": 30}); + value["models"][0]["compat"] = json!({"supportsDeveloperRole": true}); + validate_provider_node("cc-switch-example", &value).expect("valid provider"); + assert!(validate_provider_node("unknown-only", &json!({"sdkOption": {}})).is_err()); + + let custom_scheme = json!({ + "baseUrl": "unix:///var/run/omp.sock", + "api": "openai-completions", + "apiKey": "secret", + "models": [{"id": "socket-model"}] + }); + validate_provider_node_for_import("socket-provider", &custom_scheme) + .expect("native import accepts non-HTTP provider schemes"); + assert!(validate_provider_node("socket-provider", &custom_scheme).is_err()); + } + + #[test] + fn provider_request_metadata_is_a_valid_semantic_override() { + validate_provider_node( + "metadata-only", + &json!({"requestMetadata": {"client": "cc-switch"}}), + ) + .expect("OMP accepts requestMetadata-only providers"); + assert!(validate_provider_node( + "invalid-metadata", + &json!({ + "requestMetadata": ["not-an-object"] + }) + ) + .is_err()); + } + + #[test] + fn models_root_preserves_unknown_fields_on_write() { + let path = PathBuf::from("/tmp/omp/models.yml"); + assert!(validate_omp_models_root(&json!({"providers": {}}), &path).is_ok()); + assert!( + validate_omp_models_root(&json!({"providers": {}, "futureSetting": true}), &path) + .is_ok() + ); + assert!(validate_omp_models_root(&json!({"providers": []}), &path).is_err()); + } + + #[test] + fn provider_node_ownership_depends_on_models_json_membership() { + let mut oauth = provider(); + oauth["oauth"] = json!("anthroompc"); + validate_provider_node("cc-switch-example", &oauth) + .expect("an explicit models.yml node stays manageable"); + validate_provider_node("anthroompc", &json!({"baseUrl": "https://example.com/v1"})) + .expect("a built-in provider key may be explicitly configured"); + assert!(validate_provider_node("", &json!({})).is_err()); + assert!(validate_provider_for_live_write("anthroompc", &json!({})).is_err()); + assert!(validate_provider_node("anthroompc", &json!("invalid")).is_err()); + } + + #[test] + fn extension_owned_provider_without_models_is_importable() { + validate_provider_node( + "extension-provider", + &json!({"extension": {"type": "custom"}}), + ) + .expect("opaque extension providers do not need model metadata"); + } + + #[test] + fn relative_agent_directory_matches_omp_cwd_resolution() { + let resolved = resolve_omp_agent_dir( + None, + Some("relative/omp-agent".into()), + PathBuf::from("default"), + ) + .expect("relative OMP directory should resolve from cwd"); + assert!(resolved.is_absolute()); + assert!(resolved.ends_with("relative/omp-agent")); + } + + #[test] + fn config_dir_absolute_path_is_rebased_under_home() { + let home = PathBuf::from("/home/example"); + let absolute = PathBuf::from("/tmp/omp-config"); + assert_eq!( + omp_config_root(&home, absolute), + home.join("tmp/omp-config") + ); + assert_eq!( + omp_config_root(&home, PathBuf::from(".omp")), + home.join(".omp") + ); + assert_eq!( + omp_config_root(&home, PathBuf::from("../shared-omp")), + PathBuf::from("/home/shared-omp") + ); + } + + #[test] + fn profile_names_match_omp_validation_and_windows_reserved_names() { + assert_eq!(normalize_profile("default").unwrap(), None); + assert_eq!( + normalize_profile("work-2.0_a").unwrap().as_deref(), + Some("work-2.0_a") + ); + for name in [ + "WORK", "Work", "con", "con.json", "prn", "aux.txt", "nul", "com0", "com9.foo", "lpt0", + "lpt9.bar", + ] { + assert!( + normalize_profile(name).is_err(), + "profile should be rejected: {name}" + ); + } + assert!(normalize_profile("com10").is_ok()); + } + + #[test] + #[serial] + fn default_profile_ignores_inherited_named_profile_agent_override() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join(".omp"); + let derived = root.join("profiles").join("work").join("agent"); + let previous_omp_profile = std::env::var_os("OMP_PROFILE"); + let previous_pi_profile = std::env::var_os("PI_PROFILE"); + let previous_agent_dir = std::env::var_os("PI_CODING_AGENT_DIR"); + std::env::set_var("OMP_PROFILE", "default"); + std::env::set_var("PI_PROFILE", "work"); + std::env::set_var("PI_CODING_AGENT_DIR", &derived); + assert!(is_profile_derived_agent_dir_from_env( + &root, + &derived.into_os_string() + )); + match previous_omp_profile { + Some(value) => std::env::set_var("OMP_PROFILE", value), + None => std::env::remove_var("OMP_PROFILE"), + } + match previous_pi_profile { + Some(value) => std::env::set_var("PI_PROFILE", value), + None => std::env::remove_var("PI_PROFILE"), + } + match previous_agent_dir { + Some(value) => std::env::set_var("PI_CODING_AGENT_DIR", value), + None => std::env::remove_var("PI_CODING_AGENT_DIR"), + } + } + + #[test] + fn official_environment_directory_precedes_settings_override() { + let temp = tempfile::tempdir().expect("tempdir"); + let settings_dir = temp.path().join("settings-agent"); + let env_dir = temp.path().join("env-agent"); + + assert_eq!( + resolve_omp_agent_dir( + Some(settings_dir), + Some(env_dir.clone().into_os_string()), + temp.path().join("default-agent"), + ) + .expect("resolve OMP directory"), + env_dir + ); + } + + #[test] + #[serial] + fn dotenv_profile_keeps_explicit_agent_directory_override() { + let home = tempfile::tempdir().expect("create isolated home"); + let _env = crate::test_support::TestEnvGuard::isolated(home.path()); + let _cwd = test_support::CurrentDirGuard::change_to(home.path()); + let previous = [ + ("OMP_PROFILE", std::env::var_os("OMP_PROFILE")), + ("PI_PROFILE", std::env::var_os("PI_PROFILE")), + ( + "PI_CODING_AGENT_DIR", + std::env::var_os("PI_CODING_AGENT_DIR"), + ), + ("PI_CONFIG_DIR", std::env::var_os("PI_CONFIG_DIR")), + ]; + std::env::remove_var("OMP_PROFILE"); + std::env::remove_var("PI_PROFILE"); + std::env::remove_var("PI_CODING_AGENT_DIR"); + std::env::remove_var("PI_CONFIG_DIR"); + fs::write( + home.path().join(".env"), + "OMP_PROFILE=omp-dotenv\nPI_CODING_AGENT_DIR=dotenv-agent\nPI_CONFIG_DIR=.dotenvcfg\n", + ) + .expect("write project dotenv"); + + assert_eq!( + get_omp_agent_dir().expect("resolve dotenv agent directory"), + home.path().join("dotenv-agent") + ); + + std::env::set_var("PI_CODING_AGENT_DIR", "proc-agent"); + assert_eq!( + get_omp_agent_dir().expect("resolve process agent directory"), + home.path().join("proc-agent") + ); + + for (key, value) in previous { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + + #[test] + #[serial] + fn dotenv_profile_does_not_select_an_omp_profile() { + let home = tempfile::tempdir().expect("create isolated home"); + let _env = crate::test_support::TestEnvGuard::isolated(home.path()); + let _cwd = test_support::CurrentDirGuard::change_to(home.path()); + let previous = [ + ("OMP_PROFILE", std::env::var_os("OMP_PROFILE")), + ("PI_PROFILE", std::env::var_os("PI_PROFILE")), + ( + "PI_CODING_AGENT_DIR", + std::env::var_os("PI_CODING_AGENT_DIR"), + ), + ("PI_CONFIG_DIR", std::env::var_os("PI_CONFIG_DIR")), + ]; + for (key, _) in &previous { + std::env::remove_var(key); + } + fs::write(home.path().join(".env"), "OMP_PROFILE=dotenv-only\n") + .expect("write dotenv profile"); + + assert_eq!( + get_omp_shared_config_agent_dir().expect("resolve default OMP profile"), + home.path().join(".omp/agent") + ); + + for (key, value) in previous { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + + #[test] + #[serial] + fn named_profile_does_not_read_base_config_dotenv_on_second_pass() { + let home = tempfile::tempdir().expect("create isolated home"); + let _env = crate::test_support::TestEnvGuard::isolated(home.path()); + let _cwd = test_support::CurrentDirGuard::change_to(home.path()); + let previous = [ + ("OMP_PROFILE", std::env::var_os("OMP_PROFILE")), + ("PI_PROFILE", std::env::var_os("PI_PROFILE")), + ("PI_CONFIG_DIR", std::env::var_os("PI_CONFIG_DIR")), + ( + "PI_CODING_AGENT_DIR", + std::env::var_os("PI_CODING_AGENT_DIR"), + ), + ]; + std::env::set_var("OMP_PROFILE", "work"); + std::env::remove_var("PI_PROFILE"); + std::env::remove_var("PI_CONFIG_DIR"); + std::env::remove_var("PI_CODING_AGENT_DIR"); + + let base_env = home.path().join(".omp/.env"); + fs::create_dir_all(base_env.parent().expect("base config root")) + .expect("create base config root"); + fs::write(&base_env, "PI_CONFIG_DIR=.wrong-from-base\n") + .expect("write base profile dotenv"); + + assert_eq!( + get_omp_shared_config_agent_dir().expect("resolve named profile"), + home.path().join(".omp/profiles/work/agent") + ); + + for (key, value) in previous { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + + #[test] + #[serial] + fn legacy_config_editor_uses_source_revision_and_preserves_unknown_fields() { + let _agent = test_support::TestAgentDir::new(); + let path = get_omp_settings_path().expect("OMP config path"); + let legacy = path.with_file_name("settings.json"); + ensure_private_omp_parent(&legacy).expect("create OMP config directory"); + fs::write( + &legacy, + r#"{ modelRoles: { default: "legacy/provider" }, futureSetting: { enabled: true } }"#, + ) + .expect("write legacy settings"); + + let (yaml, revision) = read_omp_config_yaml().expect("read migrated editor text"); + assert!(yaml.contains("futureSetting")); + let edited = format!("{yaml}modelRoleStorage: global\n"); + fs::write( + &legacy, + "{ modelRoles: { default: \"changed/provider\" }, futureSetting: { enabled: true } }", + ) + .expect("edit legacy source externally"); + let error = replace_omp_config_yaml_at(&path, &edited, &revision) + .expect_err("stale legacy source must be rejected"); + assert!(matches!(error, AppError::Conflict(_))); + + let (yaml, revision) = read_omp_config_yaml().expect("reread migrated editor text"); + let edited = format!("{yaml}modelRoleStorage: global\n"); + replace_omp_config_yaml_at(&path, &edited, &revision).expect("migrate config safely"); + assert!(path.exists()); + assert!(fs::read_to_string(&path) + .expect("read migrated config") + .contains("futureSetting")); + } + + #[test] + #[serial] + fn unsupported_omp_directory_env_does_not_override_pi_config_dir() { + let previous = [ + ("OMP_CONFIG_DIR", std::env::var_os("OMP_CONFIG_DIR")), + ("PI_CONFIG_DIR", std::env::var_os("PI_CONFIG_DIR")), + ("OMP_PROFILE", std::env::var_os("OMP_PROFILE")), + ("PI_PROFILE", std::env::var_os("PI_PROFILE")), + ]; + std::env::set_var("PI_CONFIG_DIR", ".pi-config"); + std::env::set_var("OMP_CONFIG_DIR", ".omp-config"); + std::env::remove_var("OMP_PROFILE"); + std::env::remove_var("PI_PROFILE"); + + let env = resolve_omp_path_environment(); + assert_eq!( + env.get("PI_CONFIG_DIR") + .map(|value| value.to_string_lossy()), + Some(".pi-config".into()) + ); + + for (key, value) in previous { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + + #[test] + #[serial] + fn unsupported_omp_directory_dotenv_does_not_select_a_config_root() { + let home = tempfile::tempdir().expect("create isolated home"); + let _env = crate::test_support::TestEnvGuard::isolated(home.path()); + let _cwd = test_support::CurrentDirGuard::change_to(home.path()); + let previous = [ + ("OMP_CONFIG_DIR", std::env::var_os("OMP_CONFIG_DIR")), + ("PI_CONFIG_DIR", std::env::var_os("PI_CONFIG_DIR")), + ("OMP_PROFILE", std::env::var_os("OMP_PROFILE")), + ("PI_PROFILE", std::env::var_os("PI_PROFILE")), + ( + "PI_CODING_AGENT_DIR", + std::env::var_os("PI_CODING_AGENT_DIR"), + ), + ]; + for (key, _) in &previous { + std::env::remove_var(key); + } + fs::write(home.path().join(".env"), "OMP_CONFIG_DIR=.ignored\n") + .expect("write project dotenv"); + + let env = resolve_omp_path_environment(); + assert!(env.get("PI_CONFIG_DIR").is_none()); + assert_eq!( + get_omp_shared_config_agent_dir().expect("resolve default config root"), + home.path().join(".omp/agent") + ); + + for (key, value) in previous { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + + #[test] + #[serial] + fn explicitly_empty_omp_profile_selects_default_over_legacy_profile() { + let temp = tempfile::tempdir().expect("tempdir"); + let _env = crate::test_support::TestEnvGuard::isolated(temp.path()); + let previous = [ + ("OMP_PROFILE", std::env::var_os("OMP_PROFILE")), + ("PI_PROFILE", std::env::var_os("PI_PROFILE")), + ]; + std::env::set_var("OMP_PROFILE", ""); + std::env::set_var("PI_PROFILE", "work"); + + assert_eq!( + get_omp_shared_config_agent_dir().expect("resolve shared config directory"), + temp.path().join(".omp/agent") + ); + + for (key, value) in previous { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + + #[test] + #[serial] + fn process_pi_profile_wins_over_empty_omp_profile_from_dotenv() { + let home = tempfile::tempdir().expect("create isolated home"); + let _env = crate::test_support::TestEnvGuard::isolated(home.path()); + let _cwd = test_support::CurrentDirGuard::change_to(home.path()); + let previous_omp_profile = std::env::var_os("OMP_PROFILE"); + let previous_pi_profile = std::env::var_os("PI_PROFILE"); + std::env::remove_var("OMP_PROFILE"); + std::env::set_var("PI_PROFILE", "work"); + fs::write(home.path().join(".env"), "OMP_PROFILE=\n") + .expect("write empty canonical profile"); + + let resolved = resolve_omp_path_environment(); + assert_eq!( + resolved + .get("OMP_PROFILE") + .map(|value| value.to_string_lossy()), + Some("".into()) + ); + assert_eq!( + get_omp_shared_config_agent_dir().expect("resolve shared config directory"), + home.path().join(".omp/profiles/work/agent") + ); + + match previous_omp_profile { + Some(value) => std::env::set_var("OMP_PROFILE", value), + None => std::env::remove_var("OMP_PROFILE"), + } + match previous_pi_profile { + Some(value) => std::env::set_var("PI_PROFILE", value), + None => std::env::remove_var("PI_PROFILE"), + } + } + + #[test] + #[serial] + fn models_yaml_is_used_when_models_yml_is_absent() { + let _agent = test_support::TestAgentDir::new(); + let yaml_path = get_omp_agent_dir().unwrap().join("models.yaml"); + ensure_private_omp_parent(&yaml_path).expect("create private agent directory"); + fs::write(&yaml_path, "providers: {}\n").unwrap(); + assert_eq!(get_omp_models_path().unwrap(), yaml_path); + insert_omp_provider("fallback", &provider()).expect("write fallback models file"); + assert!(yaml_path.exists()); + assert!(!yaml_path.with_file_name("models.yml").exists()); + } + + #[test] + #[serial] + fn migrated_xdg_data_home_is_used_for_omp_sessions() { + let home = tempfile::tempdir().expect("create isolated home"); + let _env = crate::test_support::TestEnvGuard::isolated(home.path()); + let xdg_data_home = home.path().join("xdg-data"); + std::fs::create_dir_all(xdg_data_home.join("omp/sessions")) + .expect("create migrated OMP sessions directory"); + std::fs::create_dir_all(home.path().join(".omp/agent")) + .expect("create native OMP agent directory"); + std::env::set_var("XDG_DATA_HOME", &xdg_data_home); + + assert_eq!( + get_omp_sessions_dir().expect("resolve migrated OMP sessions directory"), + xdg_data_home.join("omp/sessions") + ); + } + + #[test] + #[serial] + fn missing_models_yaml_uses_checked_writer_revision_sentinel() { + let _agent = test_support::TestAgentDir::new(); + let models_path = get_omp_models_path().expect("models path"); + assert!(!models_path.exists()); + + let (text, revision) = read_omp_models_yaml().expect("read missing models document"); + assert_eq!(text, "providers: {}\n"); + assert_eq!(revision, MISSING_MODELS_REVISION); + + let (_, config_revision) = read_omp_config_yaml().expect("read missing config document"); + assert_eq!(config_revision, MISSING_CONFIG_REVISION); + } + + #[test] + #[serial] + fn legacy_models_json_is_migrated_to_models_yml() { + let _agent = test_support::TestAgentDir::new(); + let agent_dir = get_omp_agent_dir().unwrap(); + fs::create_dir_all(&agent_dir).unwrap(); + let legacy_path = agent_dir.join("models.json"); + fs::write( + &legacy_path, + r#"// OMP legacy JSONC supports comments and trailing commas. + { + providers: { + legacy: { + baseUrl: 'https://legacy.example/v1', + api: 'openai-completions', + apiKey: 'KEY', + models: [{ id: 'model', }], + }, + }, + }"#, + ) + .unwrap(); + + let path = get_omp_models_path().expect("migrate legacy models file"); + assert_eq!(path, agent_dir.join("models.yml")); + assert!(path.exists()); + let migrated = fs::read_to_string(&path).expect("read migrated YAML"); + assert!(!migrated.contains("// OMP legacy")); + assert!(migrated.contains("providers:")); + assert!(read_omp_native_providers() + .expect("read migrated providers") + .contains_key("legacy")); + } + + #[test] + #[serial] + fn legacy_models_revision_guard_rejects_external_changes() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("models.json"); + fs::write(&path, "{ providers: {} }\n").expect("write legacy models"); + let original = read_file_limited(&path, "OMP legacy models").expect("read legacy models"); + let original_revision = revision(&original); + fs::write(&path, "{ providers: { newer: {} } }\n").expect("edit legacy models"); + + let error = ensure_omp_legacy_models_revision(&path, &original_revision) + .expect_err("stale legacy models must be rejected"); + assert!(matches!(error, AppError::Conflict(_))); + } + + #[test] + fn all_upstream_api_protocols_are_accepted() { + for api in OMP_API_PROTOCOLS { + validate_api_protocol(api).expect("upstream OMP protocol"); + } + assert!(validate_api_protocol("openai-chat").is_err()); + } + + #[test] + #[serial] + fn api_key_resolves_environment_name_before_literal_fallback() { + let key_name = "CC_SWITCH_OMP_TEST_API_KEY"; + std::env::set_var(key_name, "resolved-secret"); + assert_eq!( + resolve_api_key(Some(&json!(key_name))).as_deref(), + Some("resolved-secret") + ); + std::env::remove_var(key_name); + assert_eq!( + resolve_api_key(Some(&json!("literal-secret"))).as_deref(), + Some("literal-secret") + ); + assert_eq!( + resolve_api_key_for_protocol( + Some(&json!(r#"{"access_token":"oauth-secret"}"#)), + Some("google-gemini-cli"), + ) + .as_deref(), + Some("oauth-secret") + ); + } + + #[test] + #[cfg(windows)] + #[serial] + fn environment_resolution_requires_exact_case_on_windows() { + let name = "CcSwitchOmpExactCase_9F3A"; + let lowercase_name = name.to_ascii_lowercase(); + std::env::set_var(name, "resolved-secret"); + assert_eq!( + resolve_api_key(Some(&json!(name))).as_deref(), + Some("resolved-secret") + ); + assert_eq!( + resolve_api_key(Some(&json!(lowercase_name))).as_deref(), + Some(lowercase_name.as_str()) + ); + std::env::remove_var(name); + } + + #[test] + #[cfg(unix)] + fn command_backed_values_are_resolved_and_cached() { + let command = "printf command-secret"; + assert_eq!( + resolve_api_key(Some(&json!(format!("!{command}")))).as_deref(), + Some("command-secret") + ); + assert_eq!( + resolve_header_value(&format!("!{command}")).as_deref(), + Some("command-secret") + ); + } + + #[test] + #[cfg(windows)] + fn command_backed_values_use_the_native_windows_shell() { + let command = "echo windows-command-secret"; + assert_eq!( + resolve_api_key(Some(&json!(format!("!{command}")))).as_deref(), + Some("windows-command-secret") + ); + } + + #[test] + #[cfg(unix)] + #[serial] + fn command_backed_values_inherit_omp_agent_dotenv() { + let _agent = test_support::TestAgentDir::new(); + let agent_dir = get_omp_agent_dir().expect("agent directory"); + fs::create_dir_all(&agent_dir).expect("create agent directory"); + let env_name = "OMP_CC_SWITCH_COMMAND_DOTENV_9F3A"; + std::env::remove_var(env_name); + fs::write(agent_dir.join(".env"), format!("{env_name}=from-agent\n")) + .expect("write OMP agent dotenv"); + + let command = format!("printf %s \"${env_name}\""); + assert_eq!( + resolve_api_key(Some(&json!(format!("!{command}")))).as_deref(), + Some("from-agent") + ); + } + + #[test] + #[serial] + fn command_backed_values_follow_dotenv_config_dir_override() { + let home = tempfile::tempdir().expect("create isolated home"); + let _env = crate::test_support::TestEnvGuard::isolated(home.path()); + let _cwd = test_support::CurrentDirGuard::change_to(home.path()); + let previous = [ + ("PI_CONFIG_DIR", std::env::var_os("PI_CONFIG_DIR")), + ( + "PI_CODING_AGENT_DIR", + std::env::var_os("PI_CODING_AGENT_DIR"), + ), + ("OMP_PROFILE", std::env::var_os("OMP_PROFILE")), + ("PI_PROFILE", std::env::var_os("PI_PROFILE")), + ]; + for (key, _) in &previous { + std::env::remove_var(key); + } + + fs::write(home.path().join(".env"), "PI_CONFIG_DIR=.custom-omp\n") + .expect("write home dotenv"); + let custom_agent = home.path().join(".custom-omp/agent"); + fs::create_dir_all(&custom_agent).expect("create custom agent directory"); + fs::write( + custom_agent.join(".env"), + "OMP_CC_SWITCH_CUSTOM_CONFIG_SECRET=from-custom-config\n", + ) + .expect("write custom agent dotenv"); + + assert_eq!( + resolve_api_key(Some(&json!("OMP_CC_SWITCH_CUSTOM_CONFIG_SECRET"))).as_deref(), + Some("from-custom-config") + ); + + for (key, value) in previous { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + + #[test] + #[cfg(unix)] + fn command_backed_values_reject_oversized_output_without_unbounded_buffering() { + let command = "yes x | head -c 70000"; + assert!(resolve_api_key(Some(&json!(format!("!{command}")))).is_none()); + } + + #[test] + fn dotenv_api_key_values_support_export_and_quotes() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join(".env"); + fs::write( + &path, + "# comment\nexport OMP_TEST_QUOTED=\"secret-value\" # trailing\nexport\tOMP_TEST_TAB=tab-value\nOMP_TEST_PLAIN=plain-value\n", + ) + .expect("write dotenv file"); + + assert_eq!( + read_omp_dotenv_value(&path, "OMP_TEST_QUOTED").as_deref(), + Some("secret-value") + ); + assert_eq!( + read_omp_dotenv_value(&path, "OMP_TEST_PLAIN").as_deref(), + Some("plain-value") + ); + assert_eq!( + read_omp_dotenv_value(&path, "OMP_TEST_TAB").as_deref(), + Some("tab-value") + ); + fs::write(&path, "1INVALID=bad\n_VALID=good\n").expect("rewrite dotenv file"); + assert!(read_omp_dotenv_value(&path, "1INVALID").is_none()); + assert_eq!( + read_omp_dotenv_value(&path, "_VALID").as_deref(), + Some("good") + ); + + fs::write(&path, "PI_TEST_ALIAS=legacy\nOMP_TEST_ALIAS=canonical\n") + .expect("rewrite non-profile dotenv names"); + assert_eq!( + read_omp_dotenv_value(&path, "PI_TEST_ALIAS").as_deref(), + Some("legacy") + ); + fs::write(&path, "OMP_TEST_ALIAS=canonical\n").expect("remove legacy variable"); + assert!(read_omp_dotenv_value(&path, "PI_TEST_ALIAS").is_none()); + + fs::write(&path, "PI_PROFILE=legacy\nOMP_PROFILE=canonical\n") + .expect("rewrite profile aliases"); + assert_eq!( + read_omp_dotenv_value(&path, "PI_PROFILE").as_deref(), + Some("canonical") + ); + } + + #[test] + #[serial] + fn api_key_resolves_exact_omp_agent_dotenv_names_without_invented_aliases() { + let _agent = test_support::TestAgentDir::new(); + let agent_dir = get_omp_agent_dir().expect("agent directory"); + fs::create_dir_all(&agent_dir).expect("create agent directory"); + let path = agent_dir.join(".env"); + fs::write(&path, "OMP_CC_SWITCH_DOTENV_SECRET=from-agent\n") + .expect("write OMP agent dotenv"); + + assert_eq!( + resolve_api_key(Some(&json!("OMP_CC_SWITCH_DOTENV_SECRET"))).as_deref(), + Some("from-agent") + ); + assert_eq!( + resolve_api_key(Some(&json!("PI_CC_SWITCH_DOTENV_SECRET"))).as_deref(), + Some("PI_CC_SWITCH_DOTENV_SECRET") + ); + } + + #[test] + #[serial] + fn api_key_does_not_invent_process_omp_aliases() { + let omp_name = "OMP_CC_SWITCH_PROCESS_ALIAS_9F3A"; + let pi_name = "PI_CC_SWITCH_PROCESS_ALIAS_9F3A"; + std::env::set_var(omp_name, "from-omp"); + std::env::remove_var(pi_name); + assert_eq!( + resolve_api_key(Some(&json!(pi_name))).as_deref(), + Some(pi_name) + ); + + std::env::set_var(pi_name, "from-pi"); + assert_eq!( + resolve_api_key(Some(&json!(pi_name))).as_deref(), + Some("from-pi") + ); + std::env::remove_var(omp_name); + std::env::remove_var(pi_name); + } + + #[test] + fn known_provider_and_model_fields_are_type_checked() { + let mut invalid_provider = provider(); + invalid_provider["authHeader"] = json!("yes"); + assert!(validate_provider_node("invalid-auth-header", &invalid_provider).is_err()); + + let mut invalid_transport = provider(); + invalid_transport["transport"] = json!("http"); + assert!(validate_provider_node("invalid-transport", &invalid_transport).is_err()); + + let mut invalid_override = provider(); + invalid_override["modelOverrides"] = json!({ + "example-model": { "contextWindow": "large" } + }); + assert!(validate_provider_node("invalid-override", &invalid_override).is_err()); + + let mut invalid_model = provider(); + invalid_model["models"][0]["name"] = json!(" "); + assert!(validate_provider_node("invalid-model-name", &invalid_model).is_err()); + + let mut invalid_compaction = provider(); + invalid_compaction["remoteCompaction"] = json!({"endpoint": ""}); + assert!(validate_provider_node("invalid-compaction", &invalid_compaction).is_err()); + + let mut invalid_compat = provider(); + invalid_compat["compat"] = json!({ "supportsStore": "yes" }); + assert!(validate_provider_node("invalid-compat", &invalid_compat).is_err()); + + let mut invalid_thinking = provider(); + invalid_thinking["models"][0]["thinking"] = json!({ "mode": "effort" }); + assert!(validate_provider_node("invalid-thinking", &invalid_thinking).is_err()); + } + + #[test] + fn custom_model_provider_requirements_match_omp_schema() { + let mut missing_key = provider(); + missing_key + .as_object_mut() + .expect("provider object") + .remove("apiKey"); + assert!(validate_provider_for_live_write("missing-key", &missing_key).is_err()); + + let mut missing_api = provider(); + missing_api + .as_object_mut() + .expect("provider object") + .remove("api"); + assert!(validate_provider_for_live_write("missing-api", &missing_api).is_err()); + + let mut missing_base_url = provider(); + missing_base_url + .as_object_mut() + .expect("provider object") + .remove("baseUrl"); + assert!(validate_provider_for_live_write("missing-url", &missing_base_url).is_err()); + + let mut invalid_model = provider(); + invalid_model["models"] = json!([{"id": ""}]); + assert!(validate_provider_for_live_write("invalid-model", &invalid_model).is_err()); + + let mut partial_cost = provider(); + partial_cost["models"][0]["cost"] = json!({"input": 1.25}); + assert!(validate_provider_for_live_write("partial-cost", &partial_cost).is_err()); + + let mut partial_override = provider(); + partial_override["modelOverrides"] = json!({ + "example-model": {"cost": {"input": 1.25}} + }); + validate_provider_for_live_write("partial-override", &partial_override) + .expect("OMP permits partial model override cost metadata"); + + validate_provider_for_live_write( + "keyless-local", + &json!({ + "baseUrl": "http://127.0.0.1:4000/v1", + "api": "openai-completions", + "auth": "none", + "models": [{"id": "local"}] + }), + ) + .expect("auth none permits keyless custom models"); + + validate_provider_for_live_write( + "keyless-oauth", + &json!({ + "baseUrl": "https://api.example.com/v1", + "api": "openai-completions", + "auth": "oauth", + "models": [{"id": "model-a"}] + }), + ) + .expect("auth oauth permits custom models without an inline apiKey"); + + assert!(validate_provider_for_live_write( + "per-model-endpoints", + &json!({ + "api": "openai-completions", + "apiKey": "secret", + "models": [{ + "id": "model-a", + "baseUrl": "https://model-a.example.com/v1" + }] + }), + ) + .is_err()); + + validate_provider_for_live_write( + "proxy-discovery", + &json!({ + "baseUrl": "https://example.com/v1", + "discovery": {"type": "proxy"} + }), + ) + .expect("proxy discovery may omit provider api"); + assert!(validate_provider_for_live_write( + "bad-discovery", + &json!({ + "baseUrl": "https://example.com/v1", + "discovery": {"type": "ollama"} + }) + ) + .is_err()); + + let mut zero_premium = provider(); + zero_premium["models"][0]["premiumMultiplier"] = json!(0); + validate_provider_for_live_write("zero-premium", &zero_premium) + .expect("zero premium multiplier is valid OMP metadata"); + } + + #[test] + #[serial] + fn discovery_provider_base_url_uses_native_defaults_and_env_overrides() { + for (discovery_type, expected) in [ + ("ollama", "http://127.0.0.1:11434"), + ("llama.cpp", "http://127.0.0.1:8080"), + ("lm-studio", "http://127.0.0.1:1234/v1"), + ("openai-models-list", "http://127.0.0.1:1234/v1"), + ("proxy", "http://127.0.0.1:1234/v1"), + ("litellm", "http://localhost:4000/v1"), + ] { + let config = json!({"discovery": {"type": discovery_type}}); + assert_eq!(provider_base_url(&config).unwrap(), expected); + } + + let previous_ollama_base_url = std::env::var_os("OLLAMA_BASE_URL"); + std::env::set_var( + "OLLAMA_BASE_URL", + "https://ollama.example/custom/path?token=secret#fragment", + ); + assert_eq!( + provider_base_url(&json!({"discovery": {"type": "ollama"}})).unwrap(), + "https://ollama.example" + ); + if let Some(value) = previous_ollama_base_url { + std::env::set_var("OLLAMA_BASE_URL", value); + } else { + std::env::remove_var("OLLAMA_BASE_URL"); + } + } + + #[test] + fn ollama_host_values_are_normalized_like_omp() { + for (input, expected) in [ + ("127.0.0.1:11434", "http://127.0.0.1:11434"), + ("127.0.0.1", "http://127.0.0.1:11434"), + (":11434", "http://127.0.0.1:11434"), + ("//ollama.example", "http://ollama.example:11434"), + ("https://ollama.example/path", "https://ollama.example"), + ("[::1]:1234", "http://[::1]:1234"), + ] { + assert_eq!(normalize_ollama_host_env(input).as_deref(), Some(expected)); + } + assert!(normalize_ollama_host_env("ftp://ollama.example").is_none()); + assert!(normalize_ollama_host_env(" ").is_none()); + + assert_eq!( + normalize_ollama_base_url("http://ollama.example/custom/path?x=1#frag").as_deref(), + Some("http://ollama.example") + ); + assert_eq!( + normalize_ollama_base_url("https://ollama.example:9443/v1/").as_deref(), + Some("https://ollama.example:9443") + ); + } + + #[test] + #[serial] + fn discovery_provider_base_url_reads_omp_agent_dotenv() { + let _agent = test_support::TestAgentDir::new(); + let agent_dir = get_omp_agent_dir().expect("OMP agent directory"); + fs::create_dir_all(&agent_dir).expect("create OMP agent directory"); + fs::write( + agent_dir.join(".env"), + "OLLAMA_BASE_URL=https://dotenv-ollama.example/custom/path\n", + ) + .expect("write OMP dotenv"); + + let previous = std::env::var_os("OLLAMA_BASE_URL"); + std::env::remove_var("OLLAMA_BASE_URL"); + assert_eq!( + provider_base_url(&json!({"discovery": {"type": "ollama"}})).unwrap(), + "https://dotenv-ollama.example" + ); + if let Some(value) = previous { + std::env::set_var("OLLAMA_BASE_URL", value); + } + } + + #[test] + #[serial] + fn empty_models_file_is_treated_as_an_empty_registry() { + let _agent = test_support::TestAgentDir::new(); + let path = get_omp_models_path().expect("OMP models path"); + fs::create_dir_all(path.parent().expect("models parent")).expect("create parent"); + fs::write(&path, "\n \n").expect("write empty models file"); + let providers = read_omp_native_providers().expect("read empty models file"); + assert!(providers.is_empty()); + } + + #[test] + #[serial] + fn duplicate_provider_key_is_validation_not_a_write_conflict() { + let _agent = test_support::TestAgentDir::new(); + insert_omp_provider("duplicate", &provider()).expect("insert provider"); + let mut replacement = provider(); + replacement["name"] = json!("Other"); + + let error = insert_omp_provider("duplicate", &replacement) + .expect_err("duplicate provider key must be rejected"); + assert!(matches!(error, AppError::InvalidInput(_))); + } + + #[cfg(unix)] + #[test] + #[serial] + fn newly_created_models_file_and_agent_directory_are_private() { + use std::os::unix::fs::PermissionsExt; + + let _agent = test_support::TestAgentDir::new(); + insert_omp_provider("cc-switch-private", &provider()).expect("write private models file"); + + let path = get_omp_models_path().expect("models path"); + let file_mode = fs::metadata(&path) + .expect("models metadata") + .permissions() + .mode() + & 0o777; + let directory_mode = fs::metadata(path.parent().expect("agent directory")) + .expect("agent directory metadata") + .permissions() + .mode() + & 0o777; + + assert_eq!(file_mode, 0o600); + assert_eq!(directory_mode, 0o700); + } + + #[test] + #[serial] + fn stale_models_revision_does_not_overwrite_an_external_edit() { + let _agent = test_support::TestAgentDir::new(); + let path = get_omp_models_path().expect("models path"); + ensure_private_omp_parent(&path).expect("create agent directory"); + fs::write(&path, r#"{"providers":{"external":{"models":[]}}}"#) + .expect("write initial models"); + let (_, stale_revision) = + read_models_document_with_revision(&path).expect("read models revision"); + + let external = r#"{"providers":{"external":{"models":[]},"omp-added":{"models":[]}}}"#; + fs::write(&path, external).expect("edit models externally"); + + let replacement = json!({"providers": {"cc-switch": provider()}}); + let error = write_models_document(&path, &replacement, &stale_revision) + .expect_err("stale write must fail"); + assert!(matches!(error, AppError::Conflict(_))); + assert_eq!( + fs::read_to_string(path).expect("read external models"), + external + ); + } + + #[test] + #[serial] + fn full_models_editor_preserves_native_transports_and_rejects_malformed_nodes() { + let _agent = test_support::TestAgentDir::new(); + let path = get_omp_models_path().expect("models path"); + ensure_private_omp_parent(&path).expect("create agent directory"); + fs::write(&path, "providers: {}\n").expect("seed models file"); + let (_, revision) = read_omp_models_yaml().expect("read models"); + + // OMP's schema accepts non-HTTP baseUrl strings used by native + // transports. The full-file editor must round-trip those entries even + // though CC Switch's own discovery/test operations are HTTP-only. + replace_omp_models_yaml( + "providers:\n socket:\n baseUrl: unix:///var/run/omp.sock\n api: openai-completions\n apiKey: secret\n models:\n - id: socket-model\n", + &revision, + ) + .expect("native transport should be accepted by full-file editor"); + let (_, revision) = read_omp_models_yaml().expect("read native transport models"); + + let error = + replace_omp_models_yaml("providers:\n malformed:\n sdkOption: {}\n", &revision) + .expect_err("ordinary malformed providers must not bypass write validation"); + assert!(error.to_string().contains("must specify an override")); + } + + #[test] + #[serial] + fn project_model_roles_overlay_and_receive_writes_when_selected() { + let _agent = test_support::TestAgentDir::new(); + let temp = tempfile::tempdir().expect("create project directory"); + let _cwd = test_support::CurrentDirGuard::change_to(temp.path()); + + let global_path = get_omp_settings_path().expect("global config path"); + ensure_private_omp_parent(&global_path).expect("create global config directory"); + fs::write( + &global_path, + "modelRoleStorage: project\nmodelRoles:\n default: global/provider\n slow: global/slow\n", + ) + .expect("write global config"); + let project_path = get_omp_project_settings_path().expect("project config path"); + ensure_private_omp_parent(&project_path).expect("create project config directory"); + fs::write(&project_path, "modelRoles:\n default: project/provider\n") + .expect("write project config"); + + let (roles, target, revision) = + read_omp_model_roles_with_metadata().expect("read effective roles"); + assert_eq!(target, project_path); + assert_eq!( + roles.get("default").map(String::as_str), + Some("project/provider") + ); + assert_eq!(roles.get("slow").map(String::as_str), Some("global/slow")); + + set_omp_model_role("default", Some("project/updated"), Some(&revision)) + .expect("write project role"); + let written = fs::read_to_string(&target).expect("read project config"); + assert!(written.contains("project/updated")); + assert!(written.contains("modelRoles:")); + } + + #[test] + #[serial] + fn project_role_deletion_clears_project_override_without_hiding_global_role() { + let _agent = test_support::TestAgentDir::new(); + let temp = tempfile::tempdir().expect("create project directory"); + let _cwd = test_support::CurrentDirGuard::change_to(temp.path()); + + let global_path = get_omp_settings_path().expect("global config path"); + ensure_private_omp_parent(&global_path).expect("create global config directory"); + fs::write( + &global_path, + "modelRoleStorage: project\nmodelRoles:\n default: global/provider\n", + ) + .expect("write global config"); + + let project_path = get_omp_project_settings_path().expect("project config path"); + let (_, _, revision) = read_omp_model_roles_with_metadata().expect("read project roles"); + let error = set_omp_model_role("default", None, Some(&revision)) + .expect_err("inherited global role must not report a no-op as deletion"); + assert!(error.to_string().contains("inherited from global")); + + ensure_private_omp_parent(&project_path).expect("create project config directory"); + fs::write(&project_path, "modelRoles:\n default: project/provider\n") + .expect("write project override"); + let (_, _, revision) = read_omp_model_roles_with_metadata().expect("reread project roles"); + set_omp_model_role("default", None, Some(&revision)).expect("clear project override"); + + let written: Value = + serde_yaml::from_str(&fs::read_to_string(&project_path).expect("read project config")) + .expect("parse project config"); + assert_eq!( + written + .get("modelRoles") + .and_then(Value::as_object) + .and_then(|roles| roles.get("default")), + Some(&Value::Null) + ); + assert_eq!( + read_omp_model_roles() + .expect("read effective roles") + .get("default") + .map(String::as_str), + Some("global/provider") + ); + } + + #[test] + #[serial] + fn full_config_editor_rejects_invalid_managed_fields() { + let _agent = test_support::TestAgentDir::new(); + let path = get_omp_settings_path().expect("OMP config path"); + ensure_private_omp_parent(&path).expect("create config directory"); + fs::write(&path, "futureSetting: true\n").expect("seed config"); + let (_, revision) = read_omp_config_yaml().expect("read config"); + + for invalid in [ + "modelRoles: 123\n", + "modelRoles:\n default: [bad]\n", + "modelRoleStorage: workspace\n", + "disabledProviders: invalid\n", + ] { + assert!( + replace_omp_config_yaml_at(&path, invalid, &revision).is_err(), + "invalid config should be rejected: {invalid}" + ); + assert_eq!( + fs::read_to_string(&path).expect("read unchanged config"), + "futureSetting: true\n" + ); + } + + replace_omp_config_yaml_at( + &path, + "futureSetting: true\nmodelRoleStorage: project\nmodelRoles:\n default: provider/model\ndisabledProviders: []\n", + &revision, + ) + .expect("valid config should be accepted"); + } + + #[test] + fn selector_provider_resolution_supports_aliases_and_cycles() { + let roles = IndexMap::from([ + ("default".to_string(), "@slow:high".to_string()), + ("slow".to_string(), "provider/model".to_string()), + ]); + assert_eq!( + omp_selector_provider_id("*", &roles).as_deref(), + Some("provider") + ); + assert_eq!( + omp_selector_provider_id("pi/default", &roles).as_deref(), + Some("provider") + ); + assert_eq!(omp_selector_provider_id("bare-model", &roles), None); + + let cycle = IndexMap::from([ + ("default".to_string(), "@slow".to_string()), + ("slow".to_string(), "@default".to_string()), + ]); + assert_eq!(omp_selector_provider_id("*", &cycle), None); + } + + #[test] + #[serial] + fn project_disabled_providers_replace_global_and_can_be_cleared() { + let _agent = test_support::TestAgentDir::new(); + let temp = tempfile::tempdir().expect("create project directory"); + let _cwd = test_support::CurrentDirGuard::change_to(temp.path()); + + let global_path = get_omp_settings_path().expect("global config path"); + ensure_private_omp_parent(&global_path).expect("create global config directory"); + fs::write(&global_path, "disabledProviders: [global]\n").expect("write global config"); + let project_path = get_omp_project_settings_path().expect("project config path"); + ensure_private_omp_parent(&project_path).expect("create project config directory"); + fs::write(&project_path, "disabledProviders: [project]\n").expect("write project config"); + + let disabled = read_omp_disabled_providers().expect("read disabled providers"); + assert!(disabled.contains("project")); + assert!(!disabled.contains("global")); + set_omp_provider_disabled("project", false).expect("clear disabled provider"); + assert!(!read_omp_disabled_providers() + .expect("reread disabled providers") + .contains("project")); + } + + #[test] + #[serial] + fn enabling_provider_preserves_unrelated_path_scoped_disabled_entries() { + let _agent = test_support::TestAgentDir::new(); + let temp = tempfile::tempdir().expect("create project directory"); + let _cwd = test_support::CurrentDirGuard::change_to(temp.path()); + let unrelated = temp.path().join("unrelated"); + + let global_path = get_omp_settings_path().expect("global config path"); + ensure_private_omp_parent(&global_path).expect("create global config directory"); + fs::write( + &global_path, + format!( + "disabledProviders:\n - path: {}\n providers: [victim]\n - path: {}\n providers: [victim, keep]\n - global\n", + unrelated.display(), + temp.path().display() + ), + ) + .expect("write global config"); + + set_omp_provider_disabled("victim", false).expect("enable provider in current project"); + + let written = fs::read_to_string(&global_path).expect("read updated global config"); + let document: Value = serde_yaml::from_str(&written).expect("parse updated config"); + let entries = document + .get("disabledProviders") + .and_then(Value::as_array) + .expect("disabledProviders array"); + assert!(entries.iter().any(|entry| { + entry.get("path").and_then(Value::as_str) == Some(unrelated.to_string_lossy().as_ref()) + && entry + .get("providers") + .and_then(Value::as_array) + .is_some_and(|providers| { + providers.iter().any(|id| id.as_str() == Some("victim")) + }) + })); + assert!(entries.iter().any(|entry| { + entry.get("path").and_then(Value::as_str) + == Some(temp.path().to_string_lossy().as_ref()) + && entry + .get("providers") + .and_then(Value::as_array) + .is_some_and(|providers| { + providers.len() == 1 && providers[0].as_str() == Some("keep") + }) + })); + assert!(entries.iter().any(|entry| entry.as_str() == Some("global"))); + } + + #[test] + #[serial] + fn disabled_provider_scopes_accept_string_forms_and_enable_removes_them() { + let _agent = test_support::TestAgentDir::new(); + let temp = tempfile::tempdir().expect("create project directory"); + let _cwd = test_support::CurrentDirGuard::change_to(temp.path()); + let path = get_omp_settings_path().expect("global config path"); + ensure_private_omp_parent(&path).expect("create global config directory"); + fs::write( + &path, + format!( + "disabledProviders:\n - path: {}\n providers: victim\n - pathPrefix: {}\n values: [keep, victim]\n", + temp.path().display(), + temp.path().display() + ), + ) + .expect("write string-form scoped providers"); + + let disabled = read_omp_disabled_providers().expect("read disabled providers"); + assert!(disabled.contains("victim")); + assert!(disabled.contains("keep")); + + set_omp_provider_disabled("victim", false).expect("enable provider"); + let written: Value = + serde_yaml::from_str(&fs::read_to_string(&path).expect("read updated config")) + .expect("parse updated config"); + let entries = written + .get("disabledProviders") + .and_then(Value::as_array) + .expect("disabledProviders array"); + assert!(entries + .iter() + .all(|entry| { entry.get("providers").and_then(Value::as_str) != Some("victim") })); + assert!(entries.iter().any(|entry| { + entry + .get("values") + .and_then(Value::as_array) + .is_some_and(|values| values.iter().any(|value| value.as_str() == Some("keep"))) + })); + } + + #[test] + #[serial] + fn project_settings_json_is_loaded_before_yaml_and_yaml_wins() { + let _agent = test_support::TestAgentDir::new(); + let temp = tempfile::tempdir().expect("create project directory"); + let _cwd = test_support::CurrentDirGuard::change_to(temp.path()); + + let project_root = temp.path().join(".omp"); + fs::create_dir_all(&project_root).expect("create project config directory"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&project_root, fs::Permissions::from_mode(0o700)) + .expect("make project config directory private"); + } + fs::write( + project_root.join("settings.json"), + r#"{ + modelRoleStorage: "project", + modelRoles: { default: "json/provider", slow: "json/slow" }, + disabledProviders: ["json-disabled"] + }"#, + ) + .expect("write project JSON settings"); + fs::write( + project_root.join("config.yml"), + "modelRoles:\n default: yaml/provider\n", + ) + .expect("write project YAML settings"); + + let (roles, target, _) = read_omp_model_roles_with_metadata().expect("read project roles"); + assert_eq!(target, project_root.join("config.yml")); + assert_eq!( + roles.get("default").map(String::as_str), + Some("yaml/provider") + ); + assert_eq!(roles.get("slow").map(String::as_str), Some("json/slow")); + assert!(read_omp_disabled_providers() + .expect("read JSON disabled providers") + .contains("json-disabled")); + } + + #[test] + #[serial] + fn project_yaml_disabled_providers_override_json_layer() { + let _agent = test_support::TestAgentDir::new(); + let temp = tempfile::tempdir().expect("create project directory"); + let _cwd = test_support::CurrentDirGuard::change_to(temp.path()); + + let project_root = temp.path().join(".omp"); + fs::create_dir_all(&project_root).expect("create project config directory"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&project_root, fs::Permissions::from_mode(0o700)) + .expect("make project config directory private"); + } + fs::write( + project_root.join("settings.json"), + r#"{ disabledProviders: ["json-disabled"] }"#, + ) + .expect("write project JSON settings"); + fs::write( + project_root.join("config.yml"), + "disabledProviders: [yaml-disabled]\n", + ) + .expect("write project YAML settings"); + + let disabled = read_omp_disabled_providers().expect("read effective disabled providers"); + assert!(disabled.contains("yaml-disabled")); + assert!(!disabled.contains("json-disabled")); + } + + #[test] + #[serial] + fn editing_json_disabled_provider_creates_yaml_override_instead_of_mutating_json() { + let _agent = test_support::TestAgentDir::new(); + let temp = tempfile::tempdir().expect("create project directory"); + let _cwd = test_support::CurrentDirGuard::change_to(temp.path()); + + let project_root = temp.path().join(".omp"); + fs::create_dir_all(&project_root).expect("create project config directory"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&project_root, fs::Permissions::from_mode(0o700)) + .expect("make project config directory private"); + } + let json_path = project_root.join("settings.json"); + fs::write(&json_path, r#"{ disabledProviders: ["json-disabled"] }"#) + .expect("write project JSON settings"); + + set_omp_provider_disabled("json-disabled", false).expect("enable JSON provider"); + let written = fs::read_to_string(project_root.join("config.yml")) + .expect("read project YAML override"); + assert!(written.contains("disabledProviders")); + assert!(!read_omp_disabled_providers() + .expect("read effective disabled providers") + .contains("json-disabled")); + assert!(fs::read_to_string(json_path) + .expect("read original project JSON") + .contains("json-disabled")); + } + + #[test] + #[serial] + fn legacy_global_settings_json_is_read_until_yaml_is_created() { + let _agent = test_support::TestAgentDir::new(); + let temp = tempfile::tempdir().expect("create agent directory"); + let agent_dir = temp.path().join("agent"); + let _agent_override = test_support::TestAgentDir::at(&agent_dir); + fs::create_dir_all(&agent_dir).expect("create agent directory"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&agent_dir, fs::Permissions::from_mode(0o700)) + .expect("make agent directory private"); + } + fs::write( + agent_dir.join("settings.json"), + r#"{ modelRoles: { default: "legacy/provider" }, futureSetting: { enabled: true } }"#, + ) + .expect("write legacy settings"); + + let (roles, target, revision) = + read_omp_model_roles_with_metadata().expect("read legacy global roles"); + assert_eq!( + roles.get("default").map(String::as_str), + Some("legacy/provider") + ); + assert_eq!(target, agent_dir.join("config.yml")); + let (_, legacy_revision) = + parse_json5_document(&agent_dir.join("settings.json"), "OMP legacy settings") + .expect("read legacy revision"); + assert_eq!(revision, legacy_revision); + + set_omp_model_role("default", Some("new/provider"), Some(&revision)) + .expect("write migrated YAML role"); + let written = fs::read_to_string(agent_dir.join("config.yml")).expect("read YAML config"); + assert!(written.contains("new/provider")); + assert!(written.contains("futureSetting")); + assert!(agent_dir.join("settings.json").exists()); + } + + #[test] + fn provider_selector_identifiers_reject_ambiguous_keys() { + assert!(validate_provider_key("bad/provider").is_err()); + assert!(validate_provider_key("bad provider").is_err()); + assert!(validate_model_selector("provider/model").is_ok()); + assert!(validate_model_selector("@smol").is_ok()); + assert!(validate_model_selector("*").is_ok()); + assert!(validate_model_selector("provider").is_ok()); + assert!(validate_model_selector("\u{0000}").is_err()); + } + + #[test] + fn model_role_reference_checks_strip_every_omp_reasoning_suffix() { + assert_eq!( + strip_omp_thinking_suffix("provider/model:minimal"), + "provider/model" + ); + assert_eq!( + strip_omp_thinking_suffix("provider/model:off"), + "provider/model" + ); + assert_eq!( + strip_omp_thinking_suffix("provider/model:xhigh"), + "provider/model" + ); + assert_eq!( + strip_omp_thinking_suffix("provider/model:unknown"), + "provider/model:unknown" + ); + } + + #[test] + #[serial] + fn directory_selectors_are_loaded_from_project_dotenv() { + let temp = tempfile::tempdir().expect("create project directory"); + let previous = [ + ("PI_CONFIG_DIR", std::env::var_os("PI_CONFIG_DIR")), + ("OMP_PROFILE", std::env::var_os("OMP_PROFILE")), + ("PI_PROFILE", std::env::var_os("PI_PROFILE")), + ( + "PI_CODING_AGENT_DIR", + std::env::var_os("PI_CODING_AGENT_DIR"), + ), + ]; + for (key, _) in &previous { + std::env::remove_var(key); + } + let _cwd = test_support::CurrentDirGuard::change_to(temp.path()); + fs::write(temp.path().join(".env"), "PI_CONFIG_DIR=.omp-dotenv\n") + .expect("write project dotenv"); + let env = resolve_omp_path_environment(); + assert_eq!( + env.get("PI_CONFIG_DIR") + .map(|value| value.to_string_lossy()), + Some(".omp-dotenv".into()) + ); + + drop(_cwd); + for (key, value) in previous { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + + #[test] + #[serial] + fn deleting_referenced_models_is_rejected_before_native_mutation() { + let _agent = test_support::TestAgentDir::new(); + insert_omp_provider("provider", &provider()).expect("seed provider"); + set_omp_model_role("default", Some("provider/example-model"), None).expect("seed role"); + + let error = remove_omp_model("provider", "example-model") + .expect_err("referenced model must not be deleted"); + assert!(error.to_string().contains("modelRoles references it")); + assert!(read_omp_native_provider("provider") + .expect("read provider") + .is_some()); + } + + #[test] + #[serial] + fn deleting_bare_or_aliased_model_roles_is_rejected_when_unambiguous() { + let _agent = test_support::TestAgentDir::new(); + insert_omp_provider("provider", &provider()).expect("seed provider"); + set_omp_model_role("smol", Some("example-model"), None).expect("seed bare role"); + set_omp_model_role("default", Some("pi/smol"), None).expect("seed legacy role alias"); + + let error = remove_omp_model("provider", "example-model") + .expect_err("bare/aliased role must prevent model deletion"); + assert!(error.to_string().contains("modelRoles references it")); + } + + #[test] + #[serial] + fn deleting_provider_with_ambiguous_bare_model_is_allowed() { + let _agent = test_support::TestAgentDir::new(); + insert_omp_provider("provider-a", &provider()).expect("seed first provider"); + let mut second = provider(); + second["models"][0]["id"] = json!("example-model"); + insert_omp_provider("provider-b", &second).expect("seed second provider"); + set_omp_model_role("default", Some("example-model"), None).expect("seed bare role"); + + remove_omp_provider("provider-a").expect("ambiguous bare selector is not dangling"); + } + + #[test] + #[serial] + fn unknown_root_fields_survive_provider_crud_write() { + let _agent = test_support::TestAgentDir::new(); + let path = get_omp_models_path().expect("models path"); + ensure_private_omp_parent(&path).expect("create agent directory"); + fs::write( + &path, + "version: 2\nproviders:\n external:\n baseUrl: https://external.example/v1\n", + ) + .expect("write models with root metadata"); + + insert_omp_provider("cc-switch-root-metadata", &provider()) + .expect("unknown root fields should be preserved"); + let written = fs::read_to_string(&path).expect("read updated models"); + assert!(written.contains("version: 2")); + assert!(written.contains("cc-switch-root-metadata:")); + } +} diff --git a/src-tauri/src/openclaw_config.rs b/src-tauri/src/openclaw_config.rs index 98352bb8b..04d9ac83d 100644 --- a/src-tauri/src/openclaw_config.rs +++ b/src-tauri/src/openclaw_config.rs @@ -1171,6 +1171,8 @@ mod tests { #[serial(home_settings)] fn set_and_remove_provider_only_touch_target_entry() { let _guard = lock_test_home_and_settings(); + let home = tempdir().expect("create test home"); + let _home = HomeGuard::set(home.path()); let dir = tempdir().expect("create tempdir"); let _settings = SettingsGuard::with_openclaw_dir(dir.path()); @@ -1220,6 +1222,8 @@ mod tests { #[serial(home_settings)] fn remove_last_provider_keeps_empty_providers_map() { let _guard = lock_test_home_and_settings(); + let home = tempdir().expect("create test home"); + let _home = HomeGuard::set(home.path()); let dir = tempdir().expect("create tempdir"); let _settings = SettingsGuard::with_openclaw_dir(dir.path()); @@ -1248,6 +1252,8 @@ mod tests { #[serial(home_settings)] fn remove_last_provider_rewrites_models_section_like_upstream() { let _guard = lock_test_home_and_settings(); + let home = tempdir().expect("create test home"); + let _home = HomeGuard::set(home.path()); let dir = tempdir().expect("create tempdir"); let _settings = SettingsGuard::with_openclaw_dir(dir.path()); @@ -1397,6 +1403,8 @@ mod tests { #[serial(home_settings)] fn default_model_round_trip_preserves_existing_providers() { let _guard = lock_test_home_and_settings(); + let home = tempdir().expect("create test home"); + let _home = HomeGuard::set(home.path()); let dir = tempdir().expect("create tempdir"); let _settings = SettingsGuard::with_openclaw_dir(dir.path()); diff --git a/src-tauri/src/prompt_files.rs b/src-tauri/src/prompt_files.rs index ad4769088..39ad3e398 100644 --- a/src-tauri/src/prompt_files.rs +++ b/src-tauri/src/prompt_files.rs @@ -18,6 +18,7 @@ pub fn prompt_file_path(app: &AppType) -> Result { AppType::Hermes => get_hermes_override_dir().unwrap_or_else(default_hermes_dir), AppType::OpenClaw => get_openclaw_override_dir().unwrap_or_else(default_openclaw_dir), AppType::Pi => crate::pi_config::get_pi_agent_dir()?, + AppType::Omp => crate::omp_config::get_omp_agent_dir()?, }; let filename = match app { @@ -28,6 +29,7 @@ pub fn prompt_file_path(app: &AppType) -> Result { AppType::Hermes => "AGENTS.md", AppType::OpenClaw => "AGENTS.md", AppType::Pi => "AGENTS.md", + AppType::Omp => "AGENTS.md", }; Ok(base_dir.join(filename)) diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index dcfb496fb..39b5d00c9 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -147,6 +147,9 @@ impl Provider { .or_else(|| self.settings_config.get("api_key")), AppType::OpenClaw => self.settings_config.get("apiKey"), AppType::Pi => self.settings_config.get("apiKey"), + AppType::Omp => { + return crate::omp_config::resolve_api_key(self.settings_config.get("apiKey")); + } }; value @@ -253,7 +256,11 @@ impl Provider { AppType::Claude => "claude", AppType::Codex => "codex", AppType::Gemini => "gemini", - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => return None, + AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw + | AppType::Pi + | AppType::Omp => return None, }; if self.category.as_deref() == Some("official") { @@ -299,7 +306,11 @@ impl Provider { .is_none_or(|value| value.trim().is_empty()); api_key_missing && base_url_missing } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw + | AppType::Pi + | AppType::Omp => false, }; is_official.then_some(tool) diff --git a/src-tauri/src/provider_preset_sponsors.rs b/src-tauri/src/provider_preset_sponsors.rs index 5d1e828e4..712b45d2f 100644 --- a/src-tauri/src/provider_preset_sponsors.rs +++ b/src-tauri/src/provider_preset_sponsors.rs @@ -250,7 +250,7 @@ pub(crate) fn sponsor_provider_presets_for_app( AppType::Codex => &CODEX_SPONSOR_PRESETS, AppType::Gemini => &GEMINI_SPONSOR_PRESETS, AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => &ADDITIVE_SPONSOR_PRESETS, - AppType::Pi => &[], + AppType::Pi | AppType::Omp => &[], } } diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index 329d5ef4b..f1c2b05f8 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -146,9 +146,11 @@ impl ProviderType { } ProviderType::Gemini } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { - ProviderType::Codex - } + AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw + | AppType::Pi + | AppType::Omp => ProviderType::Codex, } } @@ -200,7 +202,7 @@ pub fn get_adapter(app_type: &AppType) -> Option> { AppType::OpenCode => Box::new(CodexAdapter::new()), AppType::Hermes => Box::new(CodexAdapter::new()), AppType::OpenClaw => Box::new(CodexAdapter::new()), - AppType::Pi => return None, + AppType::Pi | AppType::Omp => return None, }) } diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index ece915908..99f2c4862 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -315,7 +315,7 @@ impl ConfigService { AppType::OpenCode => {} AppType::Hermes => {} AppType::OpenClaw => {} - AppType::Pi => {} + AppType::Pi | AppType::Omp => {} } Ok(()) diff --git a/src-tauri/src/services/env_checker.rs b/src-tauri/src/services/env_checker.rs index 2ed7fafdb..522a3ffcb 100644 --- a/src-tauri/src/services/env_checker.rs +++ b/src-tauri/src/services/env_checker.rs @@ -37,6 +37,15 @@ fn get_keywords_for_app(app: &str) -> Vec<&str> { "claude" => vec!["ANTHROPIC"], "codex" => vec!["OPENAI"], "gemini" => vec!["GEMINI", "GOOGLE_GEMINI"], + // OMP inherits Pi's environment names. These variables affect the + // native agent directory and can make CC-Switch appear ineffective + // when a shell points `omp` at another profile. + "omp" | "oh-my-pi" => vec![ + "PI_CONFIG_DIR", + "PI_PROFILE", + "OMP_PROFILE", + "PI_CODING_AGENT_DIR", + ], _ => vec![], } } @@ -163,6 +172,15 @@ mod tests { get_keywords_for_app("gemini"), vec!["GEMINI", "GOOGLE_GEMINI"] ); + assert_eq!( + get_keywords_for_app("omp"), + vec![ + "PI_CONFIG_DIR", + "PI_PROFILE", + "OMP_PROFILE", + "PI_CODING_AGENT_DIR", + ] + ); assert_eq!(get_keywords_for_app("unknown"), Vec::<&str>::new()); } } diff --git a/src-tauri/src/services/env_manager.rs b/src-tauri/src/services/env_manager.rs index 175a124f5..3153a3ce5 100644 --- a/src-tauri/src/services/env_manager.rs +++ b/src-tauri/src/services/env_manager.rs @@ -243,6 +243,13 @@ mod tests { let temp = tempfile::tempdir().expect("create temp dir"); let _env = crate::test_support::TestEnvGuard::isolated(temp.path()); let backup_dir = get_backup_dir().expect("get backup dir"); + // The managed root must already satisfy the private-directory + // invariant before we exercise the intentional preservation of an + // existing backups/ mode below. + let config_root = backup_dir.parent().expect("config root"); + std::fs::create_dir_all(config_root).expect("create config root"); + std::fs::set_permissions(config_root, std::fs::Permissions::from_mode(0o700)) + .expect("restrict config root"); std::fs::create_dir_all(&backup_dir).expect("create existing backup dir"); std::fs::set_permissions(&backup_dir, std::fs::Permissions::from_mode(0o755)) .expect("set existing backup dir permissions"); diff --git a/src-tauri/src/services/local_env_check.rs b/src-tauri/src/services/local_env_check.rs index bcb520841..2a81b0fac 100644 --- a/src-tauri/src/services/local_env_check.rs +++ b/src-tauri/src/services/local_env_check.rs @@ -29,10 +29,11 @@ pub enum LocalTool { Hermes, OpenClaw, Pi, + Omp, } impl LocalTool { - pub const ALL: [LocalTool; 7] = [ + pub const ALL: [LocalTool; 8] = [ LocalTool::Claude, LocalTool::Codex, LocalTool::Gemini, @@ -40,6 +41,7 @@ impl LocalTool { LocalTool::Hermes, LocalTool::OpenClaw, LocalTool::Pi, + LocalTool::Omp, ]; pub fn all() -> &'static [LocalTool] { @@ -55,6 +57,7 @@ impl LocalTool { LocalTool::Hermes => "Hermes", LocalTool::OpenClaw => "OpenClaw", LocalTool::Pi => "Pi", + LocalTool::Omp => "OMP", } } @@ -67,6 +70,7 @@ impl LocalTool { LocalTool::Hermes => "hermes", LocalTool::OpenClaw => "openclaw", LocalTool::Pi => "pi", + LocalTool::Omp => "omp", } } @@ -79,6 +83,7 @@ impl LocalTool { LocalTool::Hermes => &["--version", "version"], LocalTool::OpenClaw => &["--version", "version"], LocalTool::Pi => &["--version"], + LocalTool::Omp => &["--version"], } } @@ -106,6 +111,7 @@ impl LocalTool { AppType::Hermes => LocalTool::Hermes, AppType::OpenClaw => LocalTool::OpenClaw, AppType::Pi => LocalTool::Pi, + AppType::Omp => LocalTool::Omp, } } } @@ -859,7 +865,7 @@ mod tests { assert_eq!( display_names, - vec!["Claude", "Codex", "Gemini", "OpenCode", "Hermes", "OpenClaw", "Pi"] + vec!["Claude", "Codex", "Gemini", "OpenCode", "Hermes", "OpenClaw", "Pi", "OMP"] ); assert_eq!(LocalTool::Hermes.binary_name(), "hermes"); assert_eq!(LocalTool::OpenClaw.binary_name(), "openclaw"); diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index b6a6bdf16..1fa34ae17 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -217,7 +217,7 @@ impl McpService { mcp::sync_single_server_to_hermes(cfg, &server.id, &server.server)?; } AppType::OpenClaw => {} - AppType::Pi => {} + AppType::Pi | AppType::Omp => {} } Ok(()) } @@ -243,7 +243,7 @@ impl McpService { AppType::OpenCode => mcp::remove_server_from_opencode(id)?, AppType::Hermes => mcp::remove_server_from_hermes(id)?, AppType::OpenClaw => {} - AppType::Pi => {} + AppType::Pi | AppType::Omp => {} } Ok(()) } diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index dd7f9f684..a796cc026 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -28,6 +28,7 @@ pub mod session_usage; pub mod session_usage_codex; pub mod session_usage_driver; pub mod session_usage_gemini; +pub mod session_usage_omp; pub mod session_usage_opencode; pub mod session_usage_pi; pub mod skill; diff --git a/src-tauri/src/services/pi_prompt_files.rs b/src-tauri/src/services/pi_prompt_files.rs index 454116c66..af3e0a3e0 100644 --- a/src-tauri/src/services/pi_prompt_files.rs +++ b/src-tauri/src/services/pi_prompt_files.rs @@ -84,6 +84,10 @@ impl PiAgentsFileGuard { pub enum PiPromptFileKind { SystemOverride, SystemAppend, + /// OMP uses this file for the prompt sent to its automatic title + /// generator. Pi does not expose it in its native UI, but sharing the + /// enum keeps the compare-and-swap file service identical for both apps. + TitleSystem, } impl PiPromptFileKind { @@ -91,6 +95,7 @@ impl PiPromptFileKind { match self { Self::SystemOverride => "SYSTEM.md", Self::SystemAppend => "APPEND_SYSTEM.md", + Self::TitleSystem => "TITLE_SYSTEM.md", } } } @@ -108,7 +113,7 @@ pub struct PiPromptFileService; impl PiPromptFileService { pub fn read(kind: PiPromptFileKind) -> Result { let _guard = lock_prompt_files()?; - read_prompt_file(&get_pi_agent_dir()?, kind) + read_prompt_file(&get_pi_agent_dir()?, kind, "Pi prompt file") } pub fn replace( @@ -123,7 +128,7 @@ impl PiPromptFileService { let path = root.join(kind.filename()); ensure_revision(&path, expected_revision, "Pi prompt file")?; atomic_write(&path, content.as_bytes())?; - read_prompt_file(&root, kind) + read_prompt_file(&root, kind, "Pi prompt file") } pub fn delete(kind: PiPromptFileKind, expected_revision: &str) -> Result { @@ -138,6 +143,151 @@ impl PiPromptFileService { } } +/// OMP uses the same user-level instruction filenames as Pi, but resolves +/// them from OMP's shared config search roots. Keeping a separate service +/// prevents an OMP edit from ever writing into `~/.pi` and mirrors OMP's +/// distinction between shared config lookup and its runtime agent directory. +pub struct OmpPromptFileService; + +impl OmpPromptFileService { + pub(crate) fn active_path(kind: PiPromptFileKind) -> Result { + resolve_omp_prompt_path(kind) + } + + pub fn read(kind: PiPromptFileKind) -> Result { + let _guard = lock_prompt_files()?; + let path = resolve_omp_prompt_path(kind)?; + read_prompt_file( + path.parent().unwrap_or(Path::new(".")), + kind, + "OMP prompt file", + ) + } + + pub fn replace( + kind: PiPromptFileKind, + expected_revision: &str, + content: &str, + ) -> Result { + validate_instruction_content_for_app(content, "OMP instruction")?; + let _guard = lock_prompt_files()?; + let path = resolve_omp_prompt_path(kind)?; + ensure_revision(&path, expected_revision, "OMP prompt file")?; + ensure_omp_prompt_parent(&path)?; + crate::config::atomic_write(&path, content.as_bytes())?; + read_prompt_file( + path.parent().unwrap_or(Path::new(".")), + kind, + "OMP prompt file", + ) + } + + pub fn delete(kind: PiPromptFileKind, expected_revision: &str) -> Result { + let _guard = lock_prompt_files()?; + let path = resolve_omp_prompt_path(kind)?; + ensure_revision(&path, expected_revision, "OMP prompt file")?; + if path.exists() { + ensure_omp_prompt_parent(&path)?; + } + match fs::remove_file(&path) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(AppError::io(&path, error)), + } + } +} + +/// User-level OMP prompt files live beside `models.yml` and `config.yml`, so +/// their parent must satisfy the same private-directory policy. Project +/// prompt files remain ordinary repository files and are intentionally not +/// chmod'd by CC Switch. +fn ensure_omp_prompt_parent(path: &Path) -> Result<(), AppError> { + let user_root = crate::omp_config::get_omp_shared_config_agent_dir()?; + let native_root = crate::omp_config::get_omp_agent_dir()?; + if path.starts_with(&user_root) || path.starts_with(&native_root) { + crate::omp_config::ensure_private_omp_parent(path)?; + } + Ok(()) +} + +/// Resolve OMP's effective prompt search. +/// +/// OMP has two subtly different discovery paths here. `SYSTEM.md` is exposed +/// by the native OMP provider, which selects the nearest non-empty ancestor +/// `.omp` directory and uses its file (if present); its user-level file follows +/// `getAgentDir()`, including an explicit `PI_CODING_AGENT_DIR` override. +/// `APPEND_SYSTEM.md` and `TITLE_SYSTEM.md` use the generic shared-config +/// helper instead: project lookup is cwd-only and user lookup follows the +/// configured `.omp`/profile agent directory, then the other agent bases. +fn resolve_omp_prompt_path(kind: PiPromptFileKind) -> Result { + let cwd = std::env::current_dir().map_err(|error| { + AppError::Config(format!("failed to resolve current directory: {error}")) + })?; + + if matches!(kind, PiPromptFileKind::SystemOverride) { + // The native provider stops at the first non-empty ancestor `.omp` + // directory. A nearer non-empty directory without SYSTEM.md still + // shadows a farther project config, so do not continue past it. + let mut ancestor = cwd.clone(); + loop { + let project_dir = ancestor.join(".omp"); + if is_non_empty_directory(&project_dir) { + let candidate = project_dir.join(kind.filename()); + if candidate.exists() { + return Ok(candidate); + } + break; + } + if !ancestor.pop() { + break; + } + } + } + + // The generic shared-config path checks project config bases directly under + // cwd. The order is part of OMP's precedence contract: `.omp` wins over + // `.claude`, then `.codex`, then `.gemini` when multiple files exist. + for base in [".omp", ".claude", ".codex", ".gemini"] { + let candidate = cwd.join(base).join(kind.filename()); + if candidate.exists() { + return Ok(candidate); + } + } + + if matches!(kind, PiPromptFileKind::SystemOverride) { + // Native OMP discovery uses getAgentDir(), so an explicit + // PI_CODING_AGENT_DIR can be the effective SYSTEM.md location. Keep + // the generic shared `.omp` path as a fallback for installations that + // only have a shared-config file there. + let native_candidate = crate::omp_config::get_omp_agent_dir()?.join(kind.filename()); + if native_candidate.exists() { + return Ok(native_candidate); + } + } + + let home = crate::config::get_home_dir(); + let user_candidates = [ + crate::omp_config::get_omp_shared_config_agent_dir()?, + crate::config::get_claude_config_dir(), + home.join(".codex"), + home.join(".gemini"), + ]; + for root in user_candidates { + let candidate = root.join(kind.filename()); + if candidate.exists() { + return Ok(candidate); + } + } + Ok(crate::omp_config::get_omp_shared_config_agent_dir()?.join(kind.filename())) +} + +fn is_non_empty_directory(path: &Path) -> bool { + let Ok(mut entries) = fs::read_dir(path) else { + return false; + }; + entries.next().is_some() +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiPromptTemplate { @@ -249,15 +399,19 @@ fn lock_prompt_files() -> Result, AppError> { .map_err(|error| AppError::Config(format!("Pi prompt file lock is poisoned: {error}"))) } -fn read_prompt_file(root: &Path, kind: PiPromptFileKind) -> Result { +fn read_prompt_file( + root: &Path, + kind: PiPromptFileKind, + label: &str, +) -> Result { let path = root.join(kind.filename()); let (exists, content, file_revision) = match fs::File::open(&path) { Ok(file) => { - let bytes = read_open_file_limited(file, &path, "Pi prompt file")?; + let bytes = read_open_file_limited(file, &path, label)?; let file_revision = revision(&bytes); let content = String::from_utf8(bytes).map_err(|error| { AppError::InvalidInput(format!( - "Pi prompt file must be UTF-8 ({}): {error}", + "{label} must be UTF-8 ({}): {error}", path.display() )) })?; @@ -319,12 +473,16 @@ fn read_open_file_limited(file: fs::File, path: &Path, label: &str) -> Result Result<(), AppError> { + validate_instruction_content_for_app(content, "Pi instruction") +} + +fn validate_instruction_content_for_app(content: &str, label: &str) -> Result<(), AppError> { if content.trim().is_empty() { - return Err(AppError::InvalidInput( - "Pi instruction cannot be blank; remove the file to deactivate it".to_string(), - )); + return Err(AppError::InvalidInput(format!( + "{label} cannot be blank; remove the file to deactivate it" + ))); } - validate_content_size(content, "Pi instruction") + validate_content_size(content, label) } fn validate_content_size(content: &str, label: &str) -> Result<(), AppError> { @@ -396,6 +554,7 @@ pub(crate) fn validate_template_slug(slug: &str) -> Result<(), AppError> { #[cfg(test)] mod tests { use super::*; + use crate::omp_config::test_support::TestAgentDir as OmpTestAgentDir; use crate::pi_config::test_support::TestAgentDir; use serial_test::serial; @@ -518,4 +677,143 @@ mod tests { .expect_err("oversized AGENTS.md must be rejected"); assert!(error.to_string().contains("1 MiB limit")); } + + #[test] + #[serial] + fn omp_title_system_prompt_uses_omp_agent_directory() { + let _agent = OmpTestAgentDir::new(); + let missing = OmpPromptFileService::read(PiPromptFileKind::TitleSystem) + .expect("read missing OMP title prompt"); + assert!(!missing.exists); + let saved = OmpPromptFileService::replace( + PiPromptFileKind::TitleSystem, + &missing.revision, + "Generate a concise title.", + ) + .expect("save OMP title prompt"); + assert_eq!(saved.content, "Generate a concise title."); + assert!(crate::omp_config::get_omp_agent_dir() + .expect("OMP agent directory") + .join("TITLE_SYSTEM.md") + .exists()); + } + + #[test] + #[serial] + fn omp_prompt_files_use_project_first_resolution() { + let _agent = OmpTestAgentDir::new(); + let temp = tempfile::tempdir().expect("create project directory"); + let _cwd = crate::omp_config::test_support::CurrentDirGuard::change_to(temp.path()); + let project_path = temp.path().join(".omp/SYSTEM.md"); + fs::create_dir_all(project_path.parent().expect("project prompt parent")) + .expect("create project prompt directory"); + fs::write(&project_path, "project prompt").expect("write project prompt"); + + let snapshot = OmpPromptFileService::read(PiPromptFileKind::SystemOverride) + .expect("read project prompt"); + assert!(snapshot.exists); + assert_eq!(snapshot.content, "project prompt"); + OmpPromptFileService::replace( + PiPromptFileKind::SystemOverride, + &snapshot.revision, + "updated project prompt", + ) + .expect("update project prompt"); + assert_eq!( + fs::read_to_string(&project_path).expect("read updated prompt"), + "updated project prompt" + ); + } + + #[test] + #[serial] + fn omp_system_prompt_uses_nearest_non_empty_ancestor_project() { + let _agent = OmpTestAgentDir::new(); + let temp = tempfile::tempdir().expect("create project directory"); + let project_root = temp.path().join("repo"); + let nested = project_root.join("packages/app/src"); + fs::create_dir_all(&nested).expect("create nested project directory"); + let project_path = project_root.join(".omp/SYSTEM.md"); + fs::create_dir_all(project_path.parent().expect("project prompt parent")) + .expect("create project prompt directory"); + fs::write(&project_path, "ancestor prompt").expect("write ancestor prompt"); + let _cwd = crate::omp_config::test_support::CurrentDirGuard::change_to(&nested); + + let active = OmpPromptFileService::active_path(PiPromptFileKind::SystemOverride) + .expect("resolve ancestor project prompt"); + assert_eq!(active, project_path); + + // A nearer non-empty .omp directory shadows farther ancestors even + // when it does not contain SYSTEM.md, matching OMP's native provider. + let nearer = nested.join(".omp"); + fs::create_dir_all(&nearer).expect("create nearer OMP directory"); + fs::write(nearer.join("settings.json"), "{}").expect("make nearer config non-empty"); + let active = OmpPromptFileService::active_path(PiPromptFileKind::SystemOverride) + .expect("resolve shadowed project prompt"); + assert_eq!( + active, + crate::omp_config::get_omp_shared_config_agent_dir() + .unwrap() + .join("SYSTEM.md") + ); + } + + #[test] + #[cfg(unix)] + #[serial] + fn omp_user_prompt_write_rejects_shared_writable_agent_directory() { + use std::os::unix::fs::PermissionsExt; + + let _agent = OmpTestAgentDir::new(); + let agent_dir = crate::omp_config::get_omp_shared_config_agent_dir() + .expect("OMP shared agent directory"); + fs::set_permissions(&agent_dir, fs::Permissions::from_mode(0o775)) + .expect("make OMP agent directory group writable"); + + let missing = OmpPromptFileService::read(PiPromptFileKind::SystemOverride) + .expect("read missing prompt"); + let error = OmpPromptFileService::replace( + PiPromptFileKind::SystemOverride, + &missing.revision, + "must not be written", + ) + .expect_err("shared writable OMP directory must be rejected"); + assert!(error.to_string().contains("group/other writable")); + assert!(!agent_dir.join("SYSTEM.md").exists()); + } + + #[test] + #[serial] + fn omp_system_prompt_lookup_uses_runtime_agent_override() { + let home = tempfile::tempdir().expect("create isolated home"); + let _env = crate::test_support::TestEnvGuard::isolated(home.path()); + let _cwd = crate::omp_config::test_support::CurrentDirGuard::change_to(home.path()); + + let default_agent = home.path().join(".omp/agent"); + let runtime_override = home.path().join("omp-runtime-agent"); + fs::create_dir_all(&default_agent).expect("create default OMP agent directory"); + fs::create_dir_all(&runtime_override).expect("create runtime override directory"); + fs::write(default_agent.join("SYSTEM.md"), "native shared prompt") + .expect("write native shared prompt"); + fs::write(runtime_override.join("SYSTEM.md"), "runtime-only prompt") + .expect("write runtime override prompt"); + std::env::set_var("PI_CODING_AGENT_DIR", &runtime_override); + + let active = OmpPromptFileService::active_path(PiPromptFileKind::SystemOverride) + .expect("resolve OMP shared prompt path"); + assert_eq!(active, runtime_override.join("SYSTEM.md")); + let snapshot = OmpPromptFileService::read(PiPromptFileKind::SystemOverride) + .expect("read native runtime prompt"); + assert_eq!(snapshot.content, "runtime-only prompt"); + + // Generic APPEND_SYSTEM.md lookup intentionally keeps the shared + // config base and does not follow an arbitrary runtime agent override. + fs::write(default_agent.join("APPEND_SYSTEM.md"), "shared append") + .expect("write shared append prompt"); + fs::write(runtime_override.join("APPEND_SYSTEM.md"), "runtime append") + .expect("write runtime append prompt"); + let active = OmpPromptFileService::active_path(PiPromptFileKind::SystemAppend) + .expect("resolve generic append prompt path"); + assert_eq!(active, default_agent.join("APPEND_SYSTEM.md")); + } } diff --git a/src-tauri/src/services/prompt.rs b/src-tauri/src/services/prompt.rs index 76b90b334..b428e5003 100644 --- a/src-tauri/src/services/prompt.rs +++ b/src-tauri/src/services/prompt.rs @@ -17,6 +17,12 @@ fn get_unix_timestamp() -> Result { pub struct PromptService; +fn omp_native_prompt_error() -> AppError { + AppError::InvalidInput( + "OMP does not use CC Switch prompt presets; manage SYSTEM.md, APPEND_SYSTEM.md, or TITLE_SYSTEM.md with 'cc-switch --app omp prompts system ...'".to_string(), + ) +} + impl PromptService { pub fn validate_prompt_id(id: &str) -> Result<(), AppError> { let trimmed = id.trim(); @@ -74,6 +80,9 @@ impl PromptService { state: &AppState, app: AppType, ) -> Result, AppError> { + if matches!(app, AppType::Omp) { + return Err(omp_native_prompt_error()); + } if matches!(app, AppType::Pi) { return get_pi_prompts(state); } @@ -86,6 +95,9 @@ impl PromptService { id: &str, prompt: Prompt, ) -> Result<(), AppError> { + if matches!(app, AppType::Omp) { + return Err(omp_native_prompt_error()); + } if matches!(app, AppType::Pi) { return upsert_pi_prompt(state, id, prompt); } @@ -103,6 +115,9 @@ impl PromptService { } pub fn delete_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> { + if matches!(app, AppType::Omp) { + return Err(omp_native_prompt_error()); + } if matches!(app, AppType::Pi) { return delete_pi_prompt(state, id); } @@ -152,6 +167,9 @@ impl PromptService { description: Option, content: Option, ) -> Result { + if matches!(app, AppType::Omp) { + return Err(omp_native_prompt_error()); + } let new_id = new_id.trim(); Self::validate_prompt_id(new_id)?; @@ -258,6 +276,9 @@ impl PromptService { } pub fn enable_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> { + if matches!(app, AppType::Omp) { + return Err(omp_native_prompt_error()); + } if matches!(app, AppType::Pi) { return enable_pi_prompt(state, id); } @@ -325,6 +346,9 @@ impl PromptService { } pub fn disable_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> { + if matches!(app, AppType::Omp) { + return Err(omp_native_prompt_error()); + } if matches!(app, AppType::Pi) { let mut prompt = get_pi_prompts(state)? .get(id) @@ -359,6 +383,9 @@ impl PromptService { } pub fn import_from_file(state: &AppState, app: AppType) -> Result { + if matches!(app, AppType::Omp) { + return Err(omp_native_prompt_error()); + } let content = if matches!(app, AppType::Pi) { PiAgentsFileGuard::acquire()? .read()? @@ -392,6 +419,9 @@ impl PromptService { } pub fn get_current_file_content(app: AppType) -> Result, AppError> { + if matches!(app, AppType::Omp) { + return Err(omp_native_prompt_error()); + } if matches!(app, AppType::Pi) { return Ok(PiAgentsFileGuard::acquire()?.read()?.content); } @@ -408,7 +438,7 @@ impl PromptService { let mut active_prompts = Vec::new(); for app in AppType::all() { - if matches!(app, AppType::Pi) { + if matches!(app, AppType::Pi | AppType::Omp) { continue; } let prompts = state.db.get_prompts(app.as_str())?; diff --git a/src-tauri/src/services/provider/common_config.rs b/src-tauri/src/services/provider/common_config.rs index d8b3733aa..c778044ae 100644 --- a/src-tauri/src/services/provider/common_config.rs +++ b/src-tauri/src/services/provider/common_config.rs @@ -502,7 +502,7 @@ fn parse_json_object_snippet(app_type: &AppType, snippet: &str) -> Result { + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Omp => { AppError::localized( "common_config.opencode.invalid_json", format!("OpenCode 通用配置片段不是有效的 JSON:{e}"), @@ -524,13 +524,15 @@ fn parse_json_object_snippet(app_type: &AppType, snippet: &str) -> Result { - AppError::localized( - "common_config.opencode.not_object", - "OpenCode 通用配置片段必须是 JSON 对象", - "OpenCode common config snippet must be a JSON object", - ) - } + AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw + | AppType::Pi + | AppType::Omp => AppError::localized( + "common_config.opencode.not_object", + "OpenCode 通用配置片段必须是 JSON 对象", + "OpenCode common config snippet must be a JSON object", + ), AppType::Codex => AppError::Config("Unexpected JSON common config type".into()), }); } @@ -561,7 +563,8 @@ pub(super) fn validate_common_config_snippet( | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw - | AppType::Pi => { + | AppType::Pi + | AppType::Omp => { parse_json_object_snippet(app_type, snippet)?; } AppType::Codex => { @@ -617,7 +620,9 @@ pub(super) fn settings_contain_common_config( } _ => false, }, - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Omp => { + false + } } } @@ -682,7 +687,7 @@ pub(super) fn apply_common_config_to_settings( } Ok(result) } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Omp => { Ok(settings.clone()) } } @@ -733,7 +738,7 @@ pub(super) fn remove_common_config_from_settings( } Ok(result) } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Omp => { Ok(settings.clone()) } } diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index e1a7da2c6..93aba2541 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -177,6 +177,9 @@ pub(super) fn capture_live_snapshot(app_type: &AppType) -> Result Err(AppError::Config( "Pi providers use the Pi provider service".to_string(), )), + AppType::Omp => Err(AppError::Config( + "OMP providers use the OMP provider service".to_string(), + )), } } diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index e1180832c..77616ef6c 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -12,6 +12,7 @@ mod gemini_auth; mod live; pub(crate) mod live_merge; mod models; +mod omp; mod pi; #[cfg(test)] mod tests; @@ -38,6 +39,7 @@ use live::LiveSnapshot; pub use common::migrate_legacy_codex_config; #[cfg(test)] use common::strip_codex_common_config_from_full_text; +pub(crate) use omp::is_opaque_extension_config; /// 统一会话开关变更后,立即按新开关状态重写当前官方 Codex 供应商的 /// live 配置,使开关即时生效(无需等下一次切换)。 @@ -317,7 +319,10 @@ enum PreparedCodexAuthWrite { impl ProviderService { pub fn is_provider_key_app(app_type: &AppType) -> bool { - matches!(app_type, AppType::OpenClaw | AppType::Hermes | AppType::Pi) + matches!( + app_type, + AppType::OpenClaw | AppType::Hermes | AppType::Pi | AppType::Omp + ) } pub fn is_valid_provider_key(value: &str) -> bool { @@ -391,6 +396,16 @@ impl ProviderService { app_type: &AppType, provider_id: &str, ) -> Result<(), AppError> { + // OMP provider keys are YAML map keys and the upstream catalog uses + // identifiers such as `llama.cpp`; unlike the other additive apps, + // they are not restricted to lowercase letters, digits, and hyphens. + // They are nevertheless part of OMP's provider/model selector grammar, + // so reject separators/control characters before a broken selector can + // be persisted. + if matches!(app_type, AppType::Omp) { + return crate::omp_config::validate_provider_key(provider_id) + .map_err(|_| provider_key_invalid_error()); + } if Self::is_provider_key_app(app_type) && !Self::is_valid_provider_key(provider_id) { return Err(provider_key_invalid_error()); } @@ -432,6 +447,10 @@ impl ProviderService { .into_iter() .map(|(id, _)| id) .collect(), + AppType::Omp => crate::omp_config::read_omp_native_providers()? + .into_iter() + .map(|(id, _)| id) + .collect(), _ => HashSet::new(), }; Ok(ids) @@ -508,6 +527,18 @@ impl ProviderService { pi::add(state, duplicate.clone(), false)?; return Ok(duplicate); } + if matches!(app_type, AppType::Omp) { + let providers = omp::list(state)?; + let source = providers.get(source_id).ok_or_else(|| { + AppError::InvalidInput(format!("OMP provider '{source_id}' not found")) + })?; + let mut existing_ids = providers.keys().cloned().collect::>(); + existing_ids.extend(Self::live_provider_ids(&app_type)?); + let duplicate = + Self::duplicate_provider_with_overrides(source, provider_override, &existing_ids); + omp::add(state, duplicate.clone(), false)?; + return Ok(duplicate); + } let app_type_clone = app_type.clone(); let source_id = source_id.to_string(); let live_ids = if app_type.is_additive_mode() { @@ -604,15 +635,25 @@ impl ProviderService { } fn normalize_usage_script_credential_overrides(app_type: &AppType, provider: &mut Provider) { - let current_credentials = if matches!(app_type, AppType::Pi) { + let current_credentials = if matches!(app_type, AppType::Pi | AppType::Omp) { ( - crate::pi_config::provider_base_url(&provider.settings_config).unwrap_or_default(), - provider - .settings_config - .get("apiKey") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(), + match app_type { + AppType::Pi => crate::pi_config::provider_base_url(&provider.settings_config), + AppType::Omp => crate::omp_config::provider_base_url(&provider.settings_config), + _ => unreachable!(), + } + .unwrap_or_default(), + if matches!(app_type, AppType::Omp) { + crate::omp_config::resolve_api_key(provider.settings_config.get("apiKey")) + .unwrap_or_default() + } else { + provider + .settings_config + .get("apiKey") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + }, ) } else { (String::new(), String::new()) @@ -1422,7 +1463,7 @@ impl ProviderService { } state.save()?; } - AppType::Pi => {} + AppType::Pi | AppType::Omp => {} } Ok(()) } @@ -1478,7 +1519,11 @@ impl ProviderService { strict_current_provider_id, old_snippet, ), - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => Ok(()), + AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw + | AppType::Pi + | AppType::Omp => Ok(()), }; match result { @@ -1608,7 +1653,11 @@ impl ProviderService { } AppType::Gemini => live_settings.get("env") != provider_settings.get("env"), AppType::Claude => live_settings != provider_settings, - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw + | AppType::Pi + | AppType::Omp => false, } } @@ -1766,7 +1815,7 @@ impl ProviderService { AppType::OpenCode => Self::extract_opencode_common_config(settings_config), AppType::Hermes => Self::extract_opencode_common_config(settings_config), AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config), - AppType::Pi => Ok(String::new()), + AppType::Pi | AppType::Omp => Ok(String::new()), } } @@ -2112,6 +2161,9 @@ impl ProviderService { if matches!(app_type, AppType::Pi) { return pi::list(state); } + if matches!(app_type, AppType::Omp) { + return omp::list(state); + } let config = state.config.read().map_err(AppError::from)?; let manager = config .get_manager(&app_type) @@ -2142,6 +2194,9 @@ impl ProviderService { if matches!(app_type, AppType::Pi) { return pi::add(state, provider, true); } + if matches!(app_type, AppType::Omp) { + return omp::add(state, provider, true); + } let mut provider = provider; // 归一化 Claude 模型键 Self::normalize_provider_if_claude(&app_type, &mut provider); @@ -2241,6 +2296,9 @@ impl ProviderService { if matches!(app_type, AppType::Pi) { return pi::update(state, None, provider); } + if matches!(app_type, AppType::Omp) { + return omp::update(state, None, provider); + } let _mutation_guard = if app_type.is_additive_mode() { None } else { @@ -2473,7 +2531,7 @@ impl ProviderService { AppType::OpenCode => unreachable!("additive mode apps are handled earlier"), AppType::Hermes => unreachable!("additive mode apps are handled earlier"), AppType::OpenClaw => unreachable!("additive mode apps are handled earlier"), - AppType::Pi => unreachable!("Pi uses native provider import"), + AppType::Pi | AppType::Omp => unreachable!("native provider import"), }; let mut provider = Provider::with_id( @@ -2616,6 +2674,9 @@ impl ProviderService { AppType::Pi => Err(AppError::InvalidInput( "Pi providers are read from models.json".to_string(), )), + AppType::Omp => Err(AppError::InvalidInput( + "OMP providers are read from models.yml".to_string(), + )), } } @@ -2650,6 +2711,9 @@ impl ProviderService { if matches!(app_type, AppType::Pi) { return pi::remove(state, provider_id); } + if matches!(app_type, AppType::Omp) { + return omp::remove(state, provider_id); + } if !app_type.is_additive_mode() { return Err(AppError::localized( "provider.remove_from_live_config.unsupported", @@ -2741,6 +2805,7 @@ impl ProviderService { AppType::OpenClaw => Self::import_openclaw_providers_from_live(state), AppType::Hermes => Self::import_hermes_providers_from_live(state), AppType::Pi => Self::import_pi_providers_from_live(state), + AppType::Omp => Self::import_omp_providers_from_live(state), _ => Self::import_default_config(state, app_type).map(usize::from), } } @@ -2757,14 +2822,22 @@ impl ProviderService { Ok(provider_id.to_string()) } AppType::OpenClaw => Self::set_openclaw_default_model(provider_id, model_id), + AppType::Omp => Self::set_omp_default_model(provider_id, model_id), _ => Err(AppError::localized( "provider.set_default_model.unsupported", - "只有 Hermes 和 OpenClaw 支持设置默认供应商/模型", - "Only Hermes and OpenClaw support setting a default provider/model", + "只有 Hermes、OpenClaw 和 OMP 支持设置默认供应商/模型", + "Only Hermes, OpenClaw, and OMP support setting a default provider/model", )), } } + fn set_omp_default_model( + provider_id: &str, + model_id: Option<&str>, + ) -> Result { + crate::omp_config::set_omp_default_model(provider_id, model_id) + } + fn set_openclaw_default_model( provider_id: &str, model_id: Option<&str>, @@ -3031,7 +3104,7 @@ impl ProviderService { AppType::OpenCode => unreachable!("additive mode handled above"), AppType::Hermes => unreachable!("additive mode handled above"), AppType::OpenClaw => unreachable!("additive mode handled above"), - AppType::Pi => unreachable!("Pi switch is handled by the native provider service"), + AppType::Pi | AppType::Omp => unreachable!("native provider service"), }; Ok(PostCommitAction { @@ -3055,6 +3128,9 @@ impl ProviderService { if matches!(app_type, AppType::Pi) { return pi::enable(state, provider_id).map(|_| ()); } + if matches!(app_type, AppType::Omp) { + return omp::enable(state, provider_id).map(|_| ()); + } if !app_type.is_additive_mode() { let providers = state.db.get_all_providers(app_type.as_str())?; providers.get(provider_id).ok_or_else(|| { @@ -3250,7 +3326,7 @@ impl ProviderService { .map_err(Self::normalize_openclaw_live_write_error)?; Ok(PreparedLiveWrite::OpenClaw { models }) } - AppType::Pi => Ok(PreparedLiveWrite::Noop), + AppType::Pi | AppType::Omp => Ok(PreparedLiveWrite::Noop), } } @@ -3492,6 +3568,9 @@ impl ProviderService { AppType::Pi => Err(AppError::Config( "Pi does not support proxy takeover backups".into(), )), + AppType::Omp => Err(AppError::Config( + "OMP does not support proxy takeover backups".into(), + )), } } @@ -3589,6 +3668,9 @@ impl ProviderService { AppType::Pi => { crate::pi_config::validate_provider_node(&provider.id, &provider.settings_config)? } + AppType::Omp => { + crate::omp_config::validate_provider_node(&provider.id, &provider.settings_config)? + } } // 🔧 验证并清理 UsageScript 配置(所有应用类型通用) @@ -3678,10 +3760,25 @@ impl ProviderService { pi::clear_usage_script(state, id) } + pub(crate) fn update_omp_usage_script( + state: &AppState, + id: &str, + script: UsageScript, + ) -> Result { + omp::update_usage_script(state, id, script) + } + + pub(crate) fn clear_omp_usage_script(state: &AppState, id: &str) -> Result { + omp::clear_usage_script(state, id) + } + pub fn delete(state: &AppState, app_type: AppType, provider_id: &str) -> Result<(), AppError> { if matches!(app_type, AppType::Pi) { return pi::delete(state, provider_id); } + if matches!(app_type, AppType::Omp) { + return omp::delete(state, provider_id); + } let (local_current_provider, stored_current_provider) = if app_type.is_additive_mode() { (None, None) } else { @@ -3789,7 +3886,7 @@ impl ProviderService { AppType::OpenClaw => { let _ = provider_snapshot; } - AppType::Pi => unreachable!("Pi deletion is handled by the native provider service"), + AppType::Pi | AppType::Omp => unreachable!("native provider service"), } { @@ -3827,6 +3924,10 @@ impl ProviderService { pi::import_from_live(state) } + pub fn import_omp_providers_from_live(state: &AppState) -> Result { + omp::import_from_live(state) + } + pub fn import_hermes_providers_from_live(state: &AppState) -> Result { live::import_hermes_providers_from_live(state) } diff --git a/src-tauri/src/services/provider/omp.rs b/src-tauri/src/services/provider/omp.rs new file mode 100644 index 000000000..dd02a150b --- /dev/null +++ b/src-tauri/src/services/provider/omp.rs @@ -0,0 +1,1338 @@ +use super::{ProviderService, SwitchResult}; +use crate::app_config::AppType; +use crate::error::AppError; +use crate::provider::{Provider, ProviderMeta, UsageScript}; +use crate::store::AppState; +use indexmap::IndexMap; +use serde_json::Value; + +const OMP_APP: &str = "omp"; + +pub(super) fn list(state: &AppState) -> Result, AppError> { + let _guard = futures::executor::block_on(state.proxy_service.lock_switch_for_app(OMP_APP)); + match crate::omp_config::read_omp_native_providers() { + Ok(native) => { + if let Err(error) = sync_native_locked(state, &native) { + log::warn!("Failed to sync OMP providers from native config: {error}"); + } + } + Err(error) => { + log::warn!("Failed to read OMP providers; showing saved catalog: {error}"); + } + } + state.db.get_all_providers(OMP_APP) +} + +pub(super) fn import_from_live(state: &AppState) -> Result { + let _guard = futures::executor::block_on(state.proxy_service.lock_switch_for_app(OMP_APP)); + let native = crate::omp_config::read_omp_native_providers()?; + sync_native_locked(state, &native) +} + +pub(super) fn add( + state: &AppState, + mut provider: Provider, + add_to_live: bool, +) -> Result { + let app_type = AppType::Omp; + let _guard = + futures::executor::block_on(state.proxy_service.lock_switch_for_app(app_type.as_str())); + strip_unsupported_omp_metadata(&mut provider); + ProviderService::validate_provider_settings(&app_type, &provider)?; + if add_to_live { + crate::omp_config::validate_provider_for_live_write( + &provider.id, + &provider.settings_config, + )?; + } + ProviderService::normalize_usage_script_credential_overrides(&app_type, &mut provider); + + if state + .db + .get_provider_by_id(&provider.id, app_type.as_str())? + .is_some() + { + return Err(AppError::InvalidInput(format!( + "OMP provider '{}' already exists", + provider.id + ))); + } + + if !add_to_live && crate::omp_config::omp_provider_exists(&provider.id)? { + return Err(AppError::InvalidInput(format!( + "OMP provider key '{}' already exists in models.yml", + provider.id + ))); + } + + let native_inserted = if add_to_live { + crate::omp_config::insert_omp_provider(&provider.id, &provider.settings_config)? + } else { + false + }; + + if let Err(error) = state.db.save_provider(app_type.as_str(), &provider) { + if native_inserted { + if let Err(rollback) = crate::omp_config::remove_omp_provider_if_matches( + &provider.id, + &provider.settings_config, + ) { + return Err(AppError::Config(format!( + "failed to save OMP provider: {error}; native rollback failed: {rollback}" + ))); + } + } + return Err(error); + } + Ok(true) +} + +pub(super) fn update_usage_script( + state: &AppState, + id: &str, + script: UsageScript, +) -> Result { + let app_type = AppType::Omp; + let _guard = + futures::executor::block_on(state.proxy_service.lock_switch_for_app(app_type.as_str())); + ProviderService::validate_usage_script(&script)?; + + let mut provider = state + .db + .get_provider_by_id(id, app_type.as_str())? + .ok_or_else(|| AppError::InvalidInput(format!("OMP provider '{id}' not found")))?; + provider + .meta + .get_or_insert_with(ProviderMeta::default) + .usage_script = Some(script); + strip_unsupported_omp_metadata(&mut provider); + ProviderService::normalize_usage_script_credential_overrides(&app_type, &mut provider); + state.db.save_provider(app_type.as_str(), &provider)?; + Ok(true) +} + +pub(super) fn clear_usage_script(state: &AppState, id: &str) -> Result { + let app_type = AppType::Omp; + let _guard = + futures::executor::block_on(state.proxy_service.lock_switch_for_app(app_type.as_str())); + + let mut provider = state + .db + .get_provider_by_id(id, app_type.as_str())? + .ok_or_else(|| AppError::InvalidInput(format!("OMP provider '{id}' not found")))?; + if let Some(meta) = provider.meta.as_mut() { + meta.usage_script = None; + } + strip_unsupported_omp_metadata(&mut provider); + state.db.save_provider(app_type.as_str(), &provider)?; + Ok(true) +} + +pub(super) fn update( + state: &AppState, + original_id: Option<&str>, + mut provider: Provider, +) -> Result { + let app_type = AppType::Omp; + let _guard = + futures::executor::block_on(state.proxy_service.lock_switch_for_app(app_type.as_str())); + let original_id = original_id.unwrap_or(&provider.id).to_string(); + if original_id != provider.id { + return Err(AppError::InvalidInput( + "OMP provider keys cannot be renamed".to_string(), + )); + } + + let existing = state + .db + .get_provider_by_id(&original_id, app_type.as_str())? + .ok_or_else(|| AppError::InvalidInput(format!("OMP provider '{original_id}' not found")))?; + strip_unsupported_omp_metadata(&mut provider); + // Ordinary CC Switch-created providers use strict OMP semantic checks. + // A provider imported from OMP may intentionally be opaque to us (for + // example a built-in node containing only future fields); allow edits to + // such an existing entry while retaining all typed-field validation. + // Only extension-owned native nodes are opaque. A malformed ordinary + // provider must not be able to bypass OMP's semantic requirements merely + // because its current persisted form is invalid. + let allow_opaque_update = is_opaque_extension_config(&existing.settings_config); + let strict_validation = ProviderService::validate_provider_settings(&app_type, &provider); + if let Err(error) = strict_validation { + if !allow_opaque_update { + return Err(error); + } + crate::omp_config::validate_provider_node_for_import( + &original_id, + &provider.settings_config, + )?; + if let Some(meta) = provider.meta.as_ref() { + if let Some(script) = meta.usage_script.as_ref() { + ProviderService::validate_usage_script(script)?; + } + } + } + ProviderService::normalize_usage_script_credential_overrides(&app_type, &mut provider); + + // Compare-and-swap against the native value that was imported into the + // database. If a user edited models.yml after loading the provider, fail + // with Conflict instead of silently replacing that external change. + let previous_native = if crate::omp_config::read_omp_native_provider(&original_id)?.is_some() { + if allow_opaque_update { + crate::omp_config::replace_omp_provider_if_present_for_import_checked( + &original_id, + &existing.settings_config, + &provider.settings_config, + )? + } else { + crate::omp_config::replace_omp_provider_if_present_checked( + &original_id, + &existing.settings_config, + &provider.settings_config, + )? + } + } else { + None + }; + if let Err(error) = state.db.save_provider(app_type.as_str(), &provider) { + if let Some(previous_native) = previous_native.as_ref() { + let rollback_result = if allow_opaque_update { + crate::omp_config::replace_omp_provider_for_import( + &original_id, + &provider.settings_config, + previous_native, + ) + } else { + crate::omp_config::replace_omp_provider( + &original_id, + &provider.settings_config, + previous_native, + ) + }; + if let Err(rollback) = rollback_result { + return Err(AppError::Config(format!( + "failed to save OMP provider: {error}; native rollback failed: {rollback}" + ))); + } + } + return Err(error); + } + Ok(true) +} + +pub(super) fn delete(state: &AppState, id: &str) -> Result<(), AppError> { + let app_type = AppType::Omp; + let _guard = + futures::executor::block_on(state.proxy_service.lock_switch_for_app(app_type.as_str())); + let Some(provider) = state.db.get_provider_by_id(id, app_type.as_str())? else { + return Ok(()); + }; + // Delete is intentionally keyed by provider ID. Once the user confirms + // deleting the provider itself, supported field edits do not change that + // intent; the latest native value is retained only for rollback. + let removed = crate::omp_config::remove_omp_provider_checked(id, &provider.settings_config)?; + + if let Err(error) = state.db.delete_provider(app_type.as_str(), id) { + if let Some(removed) = removed.as_ref() { + if let Err(rollback) = crate::omp_config::restore_omp_provider_if_missing(id, removed) { + return Err(AppError::Config(format!( + "failed to delete OMP provider: {error}; native rollback failed: {rollback}" + ))); + } + } + return Err(error); + } + Ok(()) +} + +pub(super) fn remove(state: &AppState, id: &str) -> Result<(), AppError> { + let app_type = AppType::Omp; + let _guard = + futures::executor::block_on(state.proxy_service.lock_switch_for_app(app_type.as_str())); + let provider = state + .db + .get_provider_by_id(id, app_type.as_str())? + .ok_or_else(|| AppError::InvalidInput(format!("OMP provider '{id}' not found")))?; + // Removing a provider from an additive live config intentionally captures + // the latest native node and stores it in the catalog for restoration. It + // therefore does not use the destructive delete CAS path: external edits + // are preserved rather than silently discarded. + let Some(removed) = crate::omp_config::remove_omp_provider(id)? else { + return Ok(()); + }; + let mut synced = provider; + merge_native_config(&mut synced, removed.clone()); + if let Err(error) = state.db.save_provider(app_type.as_str(), &synced) { + if let Err(rollback) = crate::omp_config::restore_omp_provider_if_missing(id, &removed) { + return Err(AppError::Config(format!( + "failed to preserve OMP provider before removal: {error}; native rollback failed: {rollback}" + ))); + } + return Err(error); + } + Ok(()) +} + +pub(super) fn enable(state: &AppState, id: &str) -> Result { + let app_type = AppType::Omp; + let _guard = + futures::executor::block_on(state.proxy_service.lock_switch_for_app(app_type.as_str())); + let provider = state + .db + .get_provider_by_id(id, app_type.as_str())? + .ok_or_else(|| AppError::InvalidInput(format!("OMP provider '{id}' not found")))?; + let was_disabled = crate::omp_config::read_omp_disabled_providers()?.contains(id); + + if let Some(native) = crate::omp_config::read_omp_native_provider(id)? { + // A native entry may have been edited externally since it was last + // imported. Validate it before treating the provider as enabled: the + // import-only validator is reserved for extension-owned opaque nodes, + // while ordinary providers must still satisfy OMP's semantic rules. + if is_opaque_extension_config(&native) { + crate::omp_config::validate_provider_node_for_import(id, &native)?; + } else { + crate::omp_config::validate_provider_node(id, &native)?; + } + // OMP applies disabledProviders before credential resolution. Restoring + // a models.yml entry alone would therefore still leave the provider + // unusable, so enabling also clears the effective disable entry. + crate::omp_config::set_omp_provider_disabled(id, false)?; + let mut synced = provider; + merge_native_config(&mut synced, native); + if let Err(error) = state.db.save_provider(app_type.as_str(), &synced) { + if was_disabled { + if let Err(rollback) = crate::omp_config::set_omp_provider_disabled(id, true) { + return Err(AppError::Config(format!( + "failed to save enabled OMP provider: {error}; disabledProviders rollback failed: {rollback}" + ))); + } + } + return Err(error); + } + return Ok(SwitchResult::default()); + } + + // Existing DB entries may have originated from an OMP-native node that is + // opaque to CC Switch (for example a built-in provider with only + // forward-compatible fields). Restore that exact node instead of applying + // the stricter custom-provider requirements used for new live entries. + if is_opaque_extension_config(&provider.settings_config) { + crate::omp_config::validate_provider_node_for_import( + &provider.id, + &provider.settings_config, + )?; + } else { + crate::omp_config::validate_provider_node(&provider.id, &provider.settings_config)?; + } + crate::omp_config::set_omp_provider_disabled(id, false)?; + if let Err(error) = + crate::omp_config::restore_omp_provider_if_missing(id, &provider.settings_config) + { + if was_disabled { + if let Err(rollback) = crate::omp_config::set_omp_provider_disabled(id, true) { + return Err(AppError::Config(format!( + "failed to restore OMP provider: {error}; disabledProviders rollback failed: {rollback}" + ))); + } + } + return Err(error); + } + Ok(SwitchResult::default()) +} + +fn sync_native_locked( + state: &AppState, + native: &IndexMap, +) -> Result { + // Import providers independently. Native OMP files may contain entries + // owned by extensions (or stale/malformed entries); one bad node should + // not hide every other provider from the CC Switch catalog. + let saved = state.db.get_all_providers(OMP_APP)?; + let mut changed = 0; + + for (id, config) in native { + if let Err(error) = crate::omp_config::validate_provider_node_for_import(id, config) { + log::warn!("Skipping invalid OMP provider '{id}' from models.yml: {error}"); + continue; + } + let mut provider = saved.get(id).cloned().unwrap_or_else(|| { + let name = native_provider_name(config).unwrap_or(id).to_string(); + let mut imported = Provider::with_id(id.clone(), name, config.clone(), None); + imported.category = Some("custom".to_string()); + imported.icon = Some("omp".to_string()); + imported + }); + let is_new = !saved.contains_key(id); + let previous_name = provider.name.clone(); + let previous_config = provider.settings_config.clone(); + merge_native_config(&mut provider, config.clone()); + if !is_new && provider.name == previous_name && provider.settings_config == previous_config + { + continue; + } + + state.db.save_provider(OMP_APP, &provider)?; + changed += 1; + } + + Ok(changed) +} + +fn merge_native_config(provider: &mut Provider, config: Value) { + if let Some(name) = native_provider_name(&config) { + provider.name = name.to_string(); + } + provider.settings_config = config; + // OMP's documented provider schema has no provider-level `name`; for + // normal custom entries the CC Switch catalog owns the display name and + // keeps it out of models.yml. Keep the field on opaque extension-owned + // nodes, though, so remove/enable round-trips do not silently lose data + // that CC Switch cannot interpret. + strip_managed_native_name(&mut provider.settings_config); +} + +fn native_provider_name(config: &Value) -> Option<&str> { + config + .get("name") + .and_then(Value::as_str) + .filter(|name| !name.trim().is_empty()) +} + +fn strip_native_name(config: &mut Value) { + if let Some(object) = config.as_object_mut() { + object.remove("name"); + } +} + +pub(crate) fn is_opaque_extension_config(config: &Value) -> bool { + config + .as_object() + .is_some_and(|object| object.contains_key("extension")) +} + +fn strip_managed_native_name(config: &mut Value) { + if !is_opaque_extension_config(config) { + strip_native_name(config); + } +} + +fn strip_unsupported_omp_metadata(provider: &mut Provider) { + strip_managed_native_name(&mut provider.settings_config); + provider.in_failover_queue = false; + let Some(meta) = provider.meta.take() else { + return; + }; + provider.meta = Some(ProviderMeta { + usage_script: meta.usage_script, + is_partner: meta.is_partner, + partner_promotion_key: meta.partner_promotion_key, + ..ProviderMeta::default() + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::Database; + use crate::omp_config::test_support::TestAgentDir; + use crate::provider::ProviderMeta; + use serde_json::json; + use serial_test::serial; + use std::fs; + use std::sync::Arc; + + fn state() -> AppState { + AppState::new(Arc::new( + Database::memory().expect("create in-memory database"), + )) + } + + fn input(model_id: &str) -> Provider { + Provider { + id: "cc-switch-test".to_string(), + name: "Test provider".to_string(), + settings_config: json!({ + "baseUrl": "https://api.example.com/v1", + "apiKey": "secret", + "api": "openai-completions", + "models": [{ "id": model_id }] + }), + website_url: None, + category: Some("custom".to_string()), + created_at: Some(1), + sort_index: None, + notes: None, + meta: Some(ProviderMeta { + endpoint_auto_select: Some(true), + live_config_managed: Some(false), + api_format: Some("openai_chat".to_string()), + custom_user_agent: Some("legacy-route-agent".to_string()), + is_partner: Some(true), + ..ProviderMeta::default() + }), + icon: None, + icon_color: None, + in_failover_queue: false, + } + } + + fn usage_script(code: &str) -> UsageScript { + UsageScript { + enabled: true, + language: "javascript".to_string(), + code: code.to_string(), + timeout: Some(5), + api_key: None, + base_url: None, + access_token: None, + user_id: None, + template_type: None, + auto_query_interval: Some(10), + coding_plan_provider: None, + } + } + + fn secure_test_dir(path: &std::path::Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .expect("restrict OMP test directory permissions"); + } + } + + #[test] + #[serial] + fn membership_is_derived_only_from_models_yml() { + let _agent = TestAgentDir::new(); + let state = state(); + + add(&state, input("model-a"), false).expect("save disabled provider"); + assert!(!crate::omp_config::omp_provider_exists("cc-switch-test").unwrap()); + + let saved = state + .db + .get_provider_by_id("cc-switch-test", "omp") + .unwrap() + .unwrap(); + let meta = saved.meta.unwrap_or_default(); + assert_eq!(meta.live_config_managed, None); + assert_eq!(meta.endpoint_auto_select, None); + assert_eq!(meta.api_format, None); + assert_eq!(meta.custom_user_agent, None); + assert_eq!(meta.is_partner, Some(true)); + + ProviderService::switch(&state, AppType::Omp, "cc-switch-test").expect("enable provider"); + assert!(crate::omp_config::omp_provider_exists("cc-switch-test").unwrap()); + + ProviderService::remove_from_live_config(&state, AppType::Omp, "cc-switch-test") + .expect("remove provider"); + assert!(!crate::omp_config::omp_provider_exists("cc-switch-test").unwrap()); + assert!(state + .db + .get_provider_by_id("cc-switch-test", "omp") + .unwrap() + .is_some()); + } + + #[test] + #[serial] + fn delete_rejects_external_native_provider_edits() { + let _agent = TestAgentDir::new(); + let state = state(); + add(&state, input("model-a"), true).expect("create native provider"); + + let path = crate::omp_config::get_omp_models_path().expect("models path"); + let changed = fs::read_to_string(&path) + .expect("read native models") + .replace("apiKey: secret", "apiKey: external-secret"); + fs::write(&path, changed).expect("edit native provider externally"); + + let error = delete(&state, "cc-switch-test") + .expect_err("delete must detect an external native edit"); + assert!(matches!(error, AppError::Conflict(_))); + assert!(crate::omp_config::omp_provider_exists("cc-switch-test").unwrap()); + assert!(state + .db + .get_provider_by_id("cc-switch-test", OMP_APP) + .unwrap() + .is_some()); + } + + #[test] + #[serial] + fn remove_preserves_latest_external_native_provider_edits() { + let _agent = TestAgentDir::new(); + let state = state(); + add(&state, input("model-a"), true).expect("create native provider"); + + let path = crate::omp_config::get_omp_models_path().expect("models path"); + let changed = fs::read_to_string(&path) + .expect("read native models") + .replace("apiKey: secret", "apiKey: external-secret"); + fs::write(&path, changed).expect("edit native provider externally"); + + remove(&state, "cc-switch-test").expect("remove should preserve the latest native value"); + assert!(!crate::omp_config::omp_provider_exists("cc-switch-test").unwrap()); + let saved = state + .db + .get_provider_by_id("cc-switch-test", OMP_APP) + .unwrap() + .unwrap(); + assert_eq!( + saved.settings_config.get("apiKey"), + Some(&json!("external-secret")) + ); + } + + #[test] + #[serial] + fn list_imports_opaque_extension_provider_without_models() { + let _agent = TestAgentDir::new(); + let state = state(); + let config = json!({"extension": {"type": "custom", "version": 1}}); + let native = IndexMap::from([("extension-provider".to_string(), config.clone())]); + + let changed = sync_native_locked(&state, &native).expect("sync extension provider"); + assert_eq!(changed, 1); + let provider = state + .db + .get_provider_by_id("extension-provider", OMP_APP) + .expect("query imported extension provider") + .expect("extension provider should be imported"); + assert_eq!(provider.settings_config, config); + } + + #[test] + #[serial] + fn malformed_non_extension_provider_cannot_bypass_strict_update_validation() { + let _agent = TestAgentDir::new(); + let state = state(); + let existing = Provider::with_id( + "malformed".to_string(), + "Malformed".to_string(), + json!({"futureField": true}), + None, + ); + state + .db + .save_provider(OMP_APP, &existing) + .expect("save malformed provider"); + + let replacement = Provider::with_id( + "malformed".to_string(), + "Edited".to_string(), + json!({"futureField": true, "anotherField": true}), + None, + ); + let error = update(&state, Some("malformed"), replacement) + .expect_err("ordinary malformed providers must remain strictly validated"); + assert!(matches!(error, AppError::InvalidInput(_))); + assert!(!crate::omp_config::omp_provider_exists("malformed").unwrap()); + } + + #[test] + #[serial] + fn malformed_non_extension_provider_cannot_be_enabled() { + let _agent = TestAgentDir::new(); + let state = state(); + let existing = Provider::with_id( + "malformed-enable".to_string(), + "Malformed".to_string(), + json!({"futureField": true}), + None, + ); + state + .db + .save_provider(OMP_APP, &existing) + .expect("save malformed provider"); + + let error = enable(&state, "malformed-enable") + .expect_err("ordinary malformed providers must fail strict enable validation"); + assert!(matches!(error, AppError::InvalidInput(_))); + assert!(!crate::omp_config::omp_provider_exists("malformed-enable").unwrap()); + } + + #[test] + #[serial] + fn malformed_native_non_extension_provider_cannot_be_enabled() { + let _agent = TestAgentDir::new(); + let state = state(); + let path = crate::omp_config::get_omp_models_path().expect("OMP models path"); + fs::create_dir_all(path.parent().expect("OMP models parent")).expect("create OMP dir"); + fs::write( + &path, + "providers:\n malformed-native-enable:\n futureField: true\n", + ) + .expect("write malformed native provider"); + + let existing = Provider::with_id( + "malformed-native-enable".to_string(), + "Malformed native".to_string(), + json!({"futureField": true}), + None, + ); + state + .db + .save_provider(OMP_APP, &existing) + .expect("save malformed provider"); + + let error = enable(&state, "malformed-native-enable") + .expect_err("ordinary malformed native providers must fail strict enable validation"); + assert!(matches!(error, AppError::InvalidInput(_))); + } + + #[test] + #[serial] + fn update_rejects_external_native_edits_before_overwrite() { + let _agent = TestAgentDir::new(); + let state = state(); + let baseline = input("model-a"); + add(&state, baseline.clone(), true).expect("add provider"); + + let mut external = baseline.settings_config.clone(); + external["apiKey"] = json!("rotated-outside"); + crate::omp_config::replace_omp_provider( + "cc-switch-test", + &baseline.settings_config, + &external, + ) + .expect("edit native provider externally"); + + let replacement = input("model-b"); + let error = update(&state, Some("cc-switch-test"), replacement) + .expect_err("external native edits must produce a conflict"); + assert!(matches!(error, AppError::Conflict(_))); + assert_eq!( + crate::omp_config::read_omp_native_provider("cc-switch-test") + .expect("read native provider") + .expect("native provider"), + external + ); + } + + #[test] + #[serial] + fn add_keeps_display_name_out_of_omp_native_schema() { + let _agent = TestAgentDir::new(); + let state = state(); + let mut provider = input("model-a"); + provider.settings_config["name"] = json!("Display only"); + add(&state, provider, true).expect("add provider"); + let native = crate::omp_config::read_omp_native_provider("cc-switch-test") + .unwrap() + .unwrap(); + assert!(native.get("name").is_none()); + assert_eq!( + state + .db + .get_provider_by_id("cc-switch-test", OMP_APP) + .unwrap() + .unwrap() + .settings_config + .get("name"), + None + ); + } + + #[test] + #[serial] + fn default_selection_does_not_block_membership_changes() { + let _agent = TestAgentDir::new(); + let state = state(); + let original = input("model-a"); + add(&state, original.clone(), true).expect("add provider"); + let settings_path = crate::omp_config::get_omp_settings_path().unwrap(); + fs::create_dir_all(settings_path.parent().unwrap()).unwrap(); + fs::write( + &settings_path, + r#"{"defaultProvider":"cc-switch-test","defaultModel":"model-a"}"#, + ) + .unwrap(); + + update(&state, Some("cc-switch-test"), input("model-b")) + .expect("global default must not block model edits"); + ProviderService::remove_from_live_config(&state, AppType::Omp, "cc-switch-test") + .expect("global default must not block removal"); + assert!(!crate::omp_config::omp_provider_exists("cc-switch-test").unwrap()); + + ProviderService::switch(&state, AppType::Omp, "cc-switch-test") + .expect("re-enable provider"); + ProviderService::delete(&state, AppType::Omp, "cc-switch-test") + .expect("global default must not block deletion"); + assert!(state + .db + .get_provider_by_id("cc-switch-test", "omp") + .unwrap() + .is_none()); + assert_eq!( + fs::read_to_string(settings_path).unwrap(), + r#"{"defaultProvider":"cc-switch-test","defaultModel":"model-a"}"# + ); + assert!(!crate::omp_config::omp_provider_exists("cc-switch-test").unwrap()); + } + + #[test] + #[serial] + fn set_default_model_writes_omp_model_roles_without_removing_other_providers() { + let _agent = TestAgentDir::new(); + let state = state(); + add(&state, input("model-a"), true).expect("add provider"); + crate::omp_config::insert_omp_provider( + "other", + &json!({ + "baseUrl": "https://other.example/v1", + "api": "openai-completions", + "apiKey": "secret", + "models": [{"id": "other-model"}] + }), + ) + .expect("add second native provider"); + + let selector = ProviderService::set_default_model( + &state, + AppType::Omp, + "cc-switch-test", + Some("model-a"), + ) + .expect("set OMP default model"); + assert_eq!(selector, "cc-switch-test/model-a"); + assert_eq!( + crate::omp_config::read_omp_model_roles() + .expect("read roles") + .get("default"), + Some(&"cc-switch-test/model-a".to_string()) + ); + assert!(crate::omp_config::omp_provider_exists("other").expect("other provider")); + } + + #[test] + #[serial] + fn provider_membership_never_changes_omp_auth_or_defaults() { + let _agent = TestAgentDir::new(); + let state = state(); + let agent_dir = crate::omp_config::get_omp_agent_dir().expect("agent directory"); + fs::create_dir_all(&agent_dir).expect("create agent directory"); + secure_test_dir(&agent_dir); + let auth_path = agent_dir.join("auth.json"); + let settings_path = agent_dir.join("settings.json"); + let auth_contents = br#"{ + "anthropic": {"type":"oauth","refresh":"native-secret"}, + "openai": {"type":"api_key","key":"native-api-key"} + }"#; + let settings_contents = + br#"{"defaultProvider":"anthropic","defaultModel":"claude-opus-4-6"}"#; + fs::write(&auth_path, auth_contents).expect("write auth"); + fs::write(&settings_path, settings_contents).expect("write settings"); + let models_path = agent_dir.join("models.yml"); + fs::write( + &models_path, + r#"{"providers":{"anthropic":{"baseUrl":"https://native.example/v1","futureField":{"keep":true}}}}"#, + ) + .expect("write explicit provider"); + + ProviderService::list(&state, AppType::Omp).expect("import explicit provider"); + ProviderService::remove_from_live_config(&state, AppType::Omp, "anthropic") + .expect("remove explicit provider"); + ProviderService::switch(&state, AppType::Omp, "anthropic") + .expect("enable explicit provider"); + let mut edited = state + .db + .get_provider_by_id("anthropic", OMP_APP) + .expect("read provider") + .expect("provider"); + edited.settings_config["anotherField"] = json!(true); + update(&state, Some("anthropic"), edited).expect("edit explicit provider"); + + assert_eq!(fs::read(auth_path).expect("read auth"), auth_contents); + assert_eq!( + fs::read(settings_path).expect("read settings"), + settings_contents + ); + } + + #[test] + #[serial] + fn failed_duplicate_create_rolls_back_native_insertion() { + let _agent = TestAgentDir::new(); + let state = state(); + add(&state, input("model-a"), false).expect("save DB-only provider"); + + assert!(add(&state, input("model-a"), true).is_err()); + assert!(!crate::omp_config::omp_provider_exists("cc-switch-test").unwrap()); + } + + #[test] + #[serial] + fn new_live_provider_requires_api_key_for_custom_models() { + let _agent = TestAgentDir::new(); + let state = state(); + let mut provider = input("model-a"); + provider + .settings_config + .as_object_mut() + .expect("provider object") + .remove("apiKey"); + + let error = add(&state, provider, true).expect_err("missing OMP API key"); + assert!(error.to_string().contains("requires apiKey")); + assert!(!crate::omp_config::omp_provider_exists("cc-switch-test").unwrap()); + assert!(state + .db + .get_provider_by_id("cc-switch-test", OMP_APP) + .expect("query provider") + .is_none()); + } + + #[test] + #[serial] + fn native_edits_sync_to_the_saved_provider_and_survive_removal() { + let _agent = TestAgentDir::new(); + let state = state(); + add(&state, input("model-a"), true).expect("add provider"); + let saved = state + .db + .get_provider_by_id("cc-switch-test", "omp") + .unwrap() + .unwrap(); + let mut external = saved.settings_config.clone(); + external["name"] = json!("External edit"); + external["models"][0]["contextWindow"] = json!(1_000_000.0); + crate::omp_config::replace_omp_provider( + "cc-switch-test", + &saved.settings_config, + &external, + ) + .expect("edit native provider"); + + let listed = ProviderService::list(&state, AppType::Omp).expect("sync native providers"); + assert_eq!(listed["cc-switch-test"].name, "External edit"); + let mut expected = external.clone(); + expected.as_object_mut().unwrap().remove("name"); + assert_eq!(listed["cc-switch-test"].settings_config, expected); + + ProviderService::remove_from_live_config(&state, AppType::Omp, "cc-switch-test") + .expect("remove externally edited provider"); + assert!(!crate::omp_config::omp_provider_exists("cc-switch-test").unwrap()); + let preserved = state + .db + .get_provider_by_id("cc-switch-test", "omp") + .unwrap() + .unwrap(); + assert_eq!(preserved.name, "External edit"); + assert_eq!(preserved.settings_config, expected); + } + + #[test] + #[serial] + fn refreshed_native_config_can_be_edited_without_snapshot_state() { + let _agent = TestAgentDir::new(); + let state = state(); + let baseline = input("model-a"); + add(&state, baseline.clone(), true).expect("add provider"); + + let mut external = baseline.settings_config.clone(); + external["apiKey"] = json!("rotated-outside"); + external["futureField"] = json!({ "preserve": true }); + crate::omp_config::replace_omp_provider( + "cc-switch-test", + &baseline.settings_config, + &external, + ) + .expect("edit native provider"); + + let listed = ProviderService::list(&state, AppType::Omp).expect("refresh native provider"); + let mut local = listed["cc-switch-test"].clone(); + local.name = "Local edit".to_string(); + local.settings_config["name"] = json!("Local edit"); + update(&state, Some("cc-switch-test"), local).expect("edit the refreshed native provider"); + assert_eq!( + crate::omp_config::read_omp_native_provider("cc-switch-test") + .expect("read native provider") + .expect("native provider")["futureField"], + json!({ "preserve": true }) + ); + } + + #[test] + #[serial] + fn enabled_provider_edit_needs_no_special_snapshot_parameter() { + let _agent = TestAgentDir::new(); + let state = state(); + add(&state, input("model-a"), true).expect("add provider"); + + let mut edited = input("model-b"); + edited.settings_config["unknownField"] = json!({ "keep": true }); + update(&state, Some("cc-switch-test"), edited.clone()).expect("edit enabled provider"); + + assert_eq!( + crate::omp_config::read_omp_native_provider("cc-switch-test") + .expect("read native provider") + .expect("native provider"), + edited.settings_config + ); + } + + #[test] + #[serial] + fn native_sync_imports_every_explicit_provider_node() { + let _agent = TestAgentDir::new(); + let state = state(); + let mut stale_oauth = input("stale-model"); + stale_oauth.id = "native-oauth".to_string(); + add(&state, stale_oauth, false).expect("save stale provider"); + let path = crate::omp_config::get_omp_models_path().unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + path, + r#"{ + "providers": { + "native-custom": { + "name": "Native custom", + "baseUrl": "https://api.example.com/v1", + "apiKey": "secret", + "api": "openai-completions", + "models": [{ "id": "model-a" }] + }, + "anthropic": { + "name": "Built in", + "baseUrl": "https://api.anthropic.com", + "api": "anthropic-messages", + "auth": "oauth", + "models": [{ "id": "claude" }] + }, + "openai": { + "baseUrl": "https://api.openai.com/v1" + }, + "deepseek": { + "futureField": { "preserve": true } + }, + "native-oauth": { + "name": "OAuth", + "oauth": "example", + "baseUrl": "https://api.example.com/v1", + "api": "openai-completions", + "models": [{ "id": "model-b" }] + } + } + }"#, + ) + .unwrap(); + + let providers = ProviderService::list(&state, AppType::Omp).expect("sync providers"); + assert_eq!(providers.len(), 5); + let imported = &providers["native-custom"]; + assert_eq!(imported.name, "Native custom"); + assert_eq!(imported.category.as_deref(), Some("custom")); + assert_eq!(imported.icon.as_deref(), Some("omp")); + assert_eq!(providers["anthropic"].name, "Built in"); + assert_eq!( + providers["openai"].settings_config, + json!({"baseUrl": "https://api.openai.com/v1"}) + ); + assert_eq!( + providers["deepseek"].settings_config["futureField"], + json!({ "preserve": true }) + ); + assert_eq!( + providers["native-oauth"].settings_config["oauth"], + json!("example") + ); + } + + #[test] + #[serial] + fn removal_preserves_and_can_restore_a_minimal_native_node() { + let _agent = TestAgentDir::new(); + let state = state(); + add(&state, input("model-a"), true).expect("add provider"); + let minimal = json!({ + "name": "Extension-owned provider", + "extension": { "type": "custom" } + }); + let path = crate::omp_config::get_omp_models_path().expect("models path"); + fs::write( + &path, + serde_json::to_vec(&json!({ + "providers": { + "cc-switch-test": minimal.clone() + } + })) + .expect("serialize models"), + ) + .expect("replace native provider"); + + ProviderService::remove_from_live_config(&state, AppType::Omp, "cc-switch-test") + .expect("remove exact native node"); + assert!(!crate::omp_config::omp_provider_exists("cc-switch-test").unwrap()); + let expected = minimal.clone(); + assert_eq!( + state + .db + .get_provider_by_id("cc-switch-test", OMP_APP) + .expect("read saved provider") + .expect("saved provider") + .settings_config, + expected + ); + + ProviderService::switch(&state, AppType::Omp, "cc-switch-test") + .expect("restore the complete native node"); + assert_eq!( + crate::omp_config::read_omp_native_provider("cc-switch-test") + .expect("read restored provider"), + Some(expected) + ); + } + + #[test] + #[serial] + fn usage_metadata_update_does_not_rewrite_native_provider_settings() { + let _agent = TestAgentDir::new(); + let state = state(); + let baseline = input("model-a"); + add(&state, baseline.clone(), true).expect("add provider"); + + let mut external = baseline.settings_config.clone(); + external["apiKey"] = json!("rotated-outside"); + external["futureField"] = json!({ "preserve": true }); + crate::omp_config::replace_omp_provider( + "cc-switch-test", + &baseline.settings_config, + &external, + ) + .expect("edit native provider"); + + update_usage_script(&state, "cc-switch-test", usage_script("return {}")) + .expect("save usage metadata"); + assert_eq!( + crate::omp_config::read_omp_native_provider("cc-switch-test") + .expect("read native provider") + .expect("native provider"), + external + ); + + let providers = ProviderService::list(&state, AppType::Omp).expect("sync provider"); + let saved = &providers["cc-switch-test"]; + assert_eq!(saved.settings_config, external); + assert_eq!( + saved + .meta + .as_ref() + .and_then(|meta| meta.usage_script.as_ref()) + .map(|script| script.code.as_str()), + Some("return {}") + ); + + clear_usage_script(&state, "cc-switch-test").expect("clear usage metadata"); + assert_eq!( + crate::omp_config::read_omp_native_provider("cc-switch-test") + .expect("read native provider"), + Some(external) + ); + assert!(state + .db + .get_provider_by_id("cc-switch-test", OMP_APP) + .expect("read saved provider") + .expect("saved provider") + .meta + .is_none_or(|meta| meta.usage_script.is_none())); + } + + #[test] + #[serial] + fn coomped_provider_keeps_its_display_name_after_enable_and_sync() { + let _agent = TestAgentDir::new(); + let state = state(); + let mut copy = input("model-a"); + copy.id = "cc-switch-test-copy".to_string(); + copy.name = "Test provider copy".to_string(); + + add(&state, copy, false).expect("save coomped provider"); + ProviderService::switch(&state, AppType::Omp, "cc-switch-test-copy") + .expect("enable coomped provider"); + let providers = ProviderService::list(&state, AppType::Omp).expect("sync providers"); + + assert_eq!(providers["cc-switch-test-copy"].name, "Test provider copy"); + assert!(providers["cc-switch-test-copy"] + .settings_config + .get("name") + .is_none()); + } + + #[test] + #[serial] + fn database_only_create_does_not_overwrite_an_unsynced_native_key() { + let _agent = TestAgentDir::new(); + let state = state(); + let path = crate::omp_config::get_omp_models_path().expect("models path"); + fs::create_dir_all(path.parent().expect("models directory")) + .expect("create models directory"); + fs::write( + &path, + r#"{ + "providers": { + "cc-switch-test-copy": { + "name": "Native OAuth", + "oauth": "example", + "baseUrl": "https://api.example.com/v1", + "api": "openai-completions", + "models": [{ "id": "model-a" }] + } + } + }"#, + ) + .expect("write native provider"); + + let mut copy = input("model-a"); + copy.id = "cc-switch-test-copy".to_string(); + let error = add(&state, copy, false) + .expect_err("an unsynced native provider key must stay reserved"); + + assert!(error.to_string().contains("already exists in models.yml")); + assert!(state + .db + .get_provider_by_id("cc-switch-test-copy", OMP_APP) + .expect("read saved provider") + .is_none()); + assert!( + crate::omp_config::omp_provider_exists("cc-switch-test-copy") + .expect("read native provider") + ); + + let providers = ProviderService::list(&state, AppType::Omp).expect("sync native provider"); + assert_eq!(providers["cc-switch-test-copy"].name, "Native OAuth"); + } + + #[test] + #[serial] + fn malformed_native_file_keeps_the_saved_catalog_visible() { + let _agent = TestAgentDir::new(); + let state = state(); + add(&state, input("model-a"), false).expect("save provider"); + let path = crate::omp_config::get_omp_models_path().expect("models path"); + fs::create_dir_all(path.parent().expect("models directory")) + .expect("create models directory"); + fs::write(path, "{not-json").expect("write malformed models"); + + let providers = ProviderService::list(&state, AppType::Omp).expect("read saved catalog"); + assert!(providers.contains_key("cc-switch-test")); + } + + #[test] + #[serial] + fn malformed_native_provider_nodes_are_skipped_during_import() { + let _agent = TestAgentDir::new(); + let state = state(); + let path = crate::omp_config::get_omp_models_path().expect("models path"); + fs::create_dir_all(path.parent().expect("models directory")) + .expect("create models directory"); + fs::write( + &path, + r#"{ + "providers": { + "valid": { + "baseUrl": "https://api.example.com/v1", + "apiKey": "secret", + "api": "openai-completions", + "models": [{"id": "model-a"}] + }, + "bad": "not-an-object", + "bad-model": { + "baseUrl": "https://api.example.com/v1", + "api": "openai-completions", + "models": [{"id": 42}] + } + } + }"#, + ) + .expect("write native providers"); + + let providers = ProviderService::list(&state, AppType::Omp).expect("sync providers"); + assert!(providers.contains_key("valid")); + assert!(!providers.contains_key("bad")); + assert!(!providers.contains_key("bad-model")); + assert!(state + .db + .get_provider_by_id("bad", OMP_APP) + .expect("query skipped provider") + .is_none()); + } + + #[test] + #[serial] + fn numeric_json_representation_does_not_block_removal() { + let _agent = TestAgentDir::new(); + let state = state(); + let mut saved = input("model-a"); + saved.settings_config["models"][0]["contextWindow"] = json!(1_000_000); + add(&state, saved, false).expect("save provider"); + + let path = crate::omp_config::get_omp_models_path().unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + secure_test_dir(path.parent().unwrap()); + fs::write( + path, + r#"{ + "providers": { + "cc-switch-test": { + "name": "Test provider", + "baseUrl": "https://api.example.com/v1", + "apiKey": "secret", + "api": "openai-completions", + "models": [{ "id": "model-a", "contextWindow": 1000000.0 }] + } + } + }"#, + ) + .unwrap(); + + ProviderService::remove_from_live_config(&state, AppType::Omp, "cc-switch-test") + .expect("remove provider with equivalent numeric representation"); + assert!(!crate::omp_config::omp_provider_exists("cc-switch-test").unwrap()); + assert_eq!( + state + .db + .get_provider_by_id("cc-switch-test", "omp") + .unwrap() + .unwrap() + .settings_config["models"][0]["contextWindow"] + .as_f64(), + Some(1_000_000.0) + ); + } + + #[test] + #[serial] + fn unreadable_selection_blocks_destructive_membership_changes() { + let _agent = TestAgentDir::new(); + let state = state(); + let settings_path = crate::omp_config::get_omp_settings_path().unwrap(); + fs::create_dir_all(settings_path.parent().unwrap()).unwrap(); + secure_test_dir(settings_path.parent().unwrap()); + fs::write(&settings_path, "{not-json").unwrap(); + + add(&state, input("model-a"), true).expect("selection is unrelated to adding a provider"); + assert!(state + .db + .get_provider_by_id("cc-switch-test", "omp") + .unwrap() + .is_some()); + assert!(crate::omp_config::omp_provider_exists("cc-switch-test").unwrap()); + + let original = input("model-a"); + update(&state, Some("cc-switch-test"), original.clone()) + .expect("an edit that keeps every model does not need the default selection"); + + let remove_error = + ProviderService::remove_from_live_config(&state, AppType::Omp, "cc-switch-test") + .expect_err("unreadable role config must block destructive removal"); + assert!(matches!(remove_error, AppError::Config(_))); + let enable_error = ProviderService::switch(&state, AppType::Omp, "cc-switch-test") + .expect_err("unreadable settings must block a misleading enable"); + assert!(matches!(enable_error, AppError::Config(_))); + let delete_error = ProviderService::delete(&state, AppType::Omp, "cc-switch-test") + .expect_err("unreadable role config must block destructive deletion"); + assert!(matches!(delete_error, AppError::Config(_))); + assert!(crate::omp_config::omp_provider_exists("cc-switch-test").unwrap()); + } +} diff --git a/src-tauri/src/services/provider/usage.rs b/src-tauri/src/services/provider/usage.rs index 9442eaf9d..35eecfeeb 100644 --- a/src-tauri/src/services/provider/usage.rs +++ b/src-tauri/src/services/provider/usage.rs @@ -482,6 +482,12 @@ impl ProviderService { .and_then(Value::as_str) .ok_or_else(|| AppError::InvalidInput("Pi provider API key is missing".to_string())) .map(str::to_string), + AppType::Omp => { + crate::omp_config::resolve_api_key(provider.settings_config.get("apiKey")) + .ok_or_else(|| { + AppError::InvalidInput("OMP provider API key is missing".to_string()) + }) + } } } @@ -563,6 +569,7 @@ impl ProviderService { .unwrap_or_default() .to_string()), AppType::Pi => crate::pi_config::provider_base_url(&provider.settings_config), + AppType::Omp => crate::omp_config::provider_base_url(&provider.settings_config), } } diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 700c4f00e..1df46bd9f 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -1755,7 +1755,8 @@ impl ProxyService { | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw - | AppType::Pi => {} + | AppType::Pi + | AppType::Omp => {} } if cached != original { self.save_failover_live_snapshot(app_type, &provider.id, &cached) @@ -2588,9 +2589,11 @@ impl ProxyService { (None, _) => Ok(incoming_snapshot), } } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { - Ok(backup_snapshot) - } + AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw + | AppType::Pi + | AppType::Omp => Ok(backup_snapshot), } } diff --git a/src-tauri/src/services/session_usage.rs b/src-tauri/src/services/session_usage.rs index c3493ad57..a582bff5a 100644 --- a/src-tauri/src/services/session_usage.rs +++ b/src-tauri/src/services/session_usage.rs @@ -394,6 +394,11 @@ pub(crate) fn sync_all_session_usage_unlocked( "Pi", crate::services::session_usage_pi::sync_pi_usage(db), ); + merge_sync_step( + &mut result, + "OMP", + crate::services::session_usage_omp::sync_omp_usage(db), + ); if result.imported > 0 { crate::usage_events::notify_log_recorded(); } @@ -1152,6 +1157,161 @@ pub(crate) fn metadata_modified_nanos(metadata: &fs::Metadata) -> i64 { .unwrap_or(0) } +/// Open a discovered session path without following symlinks in any path +/// component. The descriptor walk keeps each opened directory pinned while +/// resolving the next component, closing the discovery/read TOCTOU window. +pub(crate) fn open_session_file_no_follow(path: &Path) -> std::io::Result { + open_session_path_no_follow(path, false) +} + +/// Read a directory through a descriptor opened with `O_NOFOLLOW`, then +/// classify children with `fstatat(..., AT_SYMLINK_NOFOLLOW)`. This avoids the +/// pathname TOCTOU window between a symlink check and `read_dir`. +pub(crate) fn read_session_directory_entries_no_follow( + path: &Path, +) -> std::io::Result> { + #[cfg(unix)] + { + use std::ffi::{CStr, CString}; + use std::os::fd::AsRawFd; + use std::os::unix::ffi::OsStrExt; + let directory = open_session_path_no_follow(path, true)?; + let duplicate = unsafe { libc::dup(directory.as_raw_fd()) }; + if duplicate < 0 { + return Err(std::io::Error::last_os_error()); + } + let stream = unsafe { libc::fdopendir(duplicate) }; + if stream.is_null() { + unsafe { libc::close(duplicate) }; + return Err(std::io::Error::last_os_error()); + } + let mut entries = Vec::new(); + loop { + let entry = unsafe { libc::readdir(stream) }; + if entry.is_null() { + break; + } + let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }; + if name.to_bytes() == b"." || name.to_bytes() == b".." { + continue; + } + let name_c = CString::new(name.to_bytes()).map_err(|_| { + unsafe { libc::closedir(stream) }; + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "directory entry contains NUL", + ) + })?; + let mut stat = std::mem::MaybeUninit::::uninit(); + if unsafe { + libc::fstatat( + directory.as_raw_fd(), + name_c.as_ptr(), + stat.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + } != 0 + { + continue; + } + let mode = unsafe { stat.assume_init() }.st_mode & libc::S_IFMT; + entries.push(( + std::ffi::OsStr::from_bytes(name.to_bytes()).to_os_string(), + mode == libc::S_IFDIR, + mode == libc::S_IFREG, + mode == libc::S_IFLNK, + )); + } + unsafe { libc::closedir(stream) }; + Ok(entries) + } + #[cfg(not(unix))] + { + fs::read_dir(path)? + .map(|entry| { + let entry = entry?; + let kind = entry.file_type()?; + Ok(( + entry.file_name(), + kind.is_dir(), + kind.is_file(), + kind.is_symlink(), + )) + }) + .collect() + } +} + +fn open_session_path_no_follow(path: &Path, require_directory: bool) -> std::io::Result { + #[cfg(unix)] + { + use std::ffi::CString; + use std::os::fd::FromRawFd; + use std::os::unix::ffi::OsStrExt; + + if !path.is_absolute() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "session path must be absolute", + )); + } + + let mut current_fd = unsafe { + libc::open( + b"/\0".as_ptr().cast(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC, + ) + }; + if current_fd < 0 { + return Err(std::io::Error::last_os_error()); + } + let mut components = path.components().peekable(); + while let Some(component) = components.next() { + let name = match component { + std::path::Component::RootDir | std::path::Component::CurDir => continue, + std::path::Component::Normal(name) => name, + std::path::Component::ParentDir | std::path::Component::Prefix(_) => { + unsafe { libc::close(current_fd) }; + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "session path must be normalized and absolute", + )); + } + }; + let name = CString::new(name.as_bytes()).map_err(|_| { + unsafe { libc::close(current_fd) }; + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "session path contains a NUL byte", + ) + })?; + let is_last = components.peek().is_none(); + let mut flags = libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW; + if !is_last || require_directory { + flags |= libc::O_DIRECTORY; + } + let next_fd = unsafe { libc::openat(current_fd, name.as_ptr(), flags) }; + unsafe { libc::close(current_fd) }; + if next_fd < 0 { + return Err(std::io::Error::last_os_error()); + } + current_fd = next_fd; + if is_last { + return Ok(unsafe { File::from_raw_fd(current_fd) }); + } + } + unsafe { libc::close(current_fd) }; + Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "session path is empty", + )) + } + #[cfg(not(unix))] + { + File::open(path) + } +} + /// 更新 session_log_sync 表中某条目的同步进度(连接版本)。 /// /// 供批量事务复用:调用方已持有事务连接,直接在同一事务内写入同步状态。 @@ -1160,6 +1320,22 @@ pub(crate) fn update_sync_state_conn( file_path: &str, last_modified: i64, last_offset: i64, +) -> Result<(), AppError> { + update_line_sync_state_conn(conn, file_path, last_modified, last_offset, None, None) +} + +/// Update a line-oriented session cursor and, optionally, provider-specific +/// revision metadata. The revision columns were added for Claude's byte +/// cursor and are also suitable for Pi/OMP's append-proof state. Keeping the +/// wall-clock sync time in `last_synced_at` is important because it is shown +/// directly by the home page and must remain a real Unix timestamp. +pub(crate) fn update_line_sync_state_conn( + conn: &rusqlite::Connection, + file_path: &str, + last_modified: i64, + last_offset: i64, + last_byte_offset: Option, + last_tail_fingerprint: Option, ) -> Result<(), AppError> { let now = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) @@ -1171,16 +1347,29 @@ pub(crate) fn update_sync_state_conn( // 快照的 mtime 可能相等,因此按 (mtime, line_offset) 字典序判定: // mtime 更新才整体覆盖;mtime 相等时只允许 offset 不回退。 conn.execute( - "INSERT INTO session_log_sync (file_path, last_modified, last_line_offset, last_synced_at) - VALUES (?1, ?2, ?3, ?4) + "INSERT INTO session_log_sync + (file_path, last_modified, last_line_offset, last_synced_at, + last_byte_offset, last_tail_fingerprint) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(file_path) DO UPDATE SET last_modified = excluded.last_modified, last_line_offset = excluded.last_line_offset, - last_synced_at = excluded.last_synced_at + last_synced_at = excluded.last_synced_at, + last_byte_offset = COALESCE(excluded.last_byte_offset, + session_log_sync.last_byte_offset), + last_tail_fingerprint = COALESCE(excluded.last_tail_fingerprint, + session_log_sync.last_tail_fingerprint) WHERE excluded.last_modified > session_log_sync.last_modified OR (excluded.last_modified = session_log_sync.last_modified AND excluded.last_line_offset >= session_log_sync.last_line_offset)", - rusqlite::params![file_path, last_modified, last_offset, now], + rusqlite::params![ + file_path, + last_modified, + last_offset, + now, + last_byte_offset, + last_tail_fingerprint, + ], ) .map_err(|e| AppError::Database(format!("更新同步状态失败: {e}")))?; Ok(()) @@ -1401,7 +1590,7 @@ pub(crate) fn delete_session_logs_covered_by_proxy_log( conn.execute( "DELETE FROM proxy_request_logs - WHERE COALESCE(data_source, 'proxy') IN ('session_log', 'codex_session', 'gemini_session', 'opencode_session') + WHERE COALESCE(data_source, 'proxy') IN ('session_log', 'codex_session', 'gemini_session', 'opencode_session', 'omp_session') AND app_type = ?1 AND status_code >= 200 AND status_code < 300 @@ -1412,7 +1601,7 @@ pub(crate) fn delete_session_logs_covered_by_proxy_log( cache_creation_tokens = ?6 OR ( cache_creation_tokens = 0 - AND COALESCE(data_source, 'proxy') IN ('codex_session', 'gemini_session', 'opencode_session') + AND COALESCE(data_source, 'proxy') IN ('codex_session', 'gemini_session', 'opencode_session', 'omp_session') ) ) AND created_at BETWEEN ?7 - ?8 AND ?7 + ?8 @@ -1438,6 +1627,56 @@ pub(crate) fn delete_session_logs_covered_by_proxy_log( #[cfg(test)] mod tests { use super::*; + use std::io::Read; + + #[test] + fn open_session_file_no_follow_reads_normal_absolute_file() { + let temp = tempfile::tempdir().expect("isolated session root"); + let path = temp.path().join("sessions").join("one.jsonl"); + fs::create_dir_all(path.parent().expect("session parent")).expect("create session parent"); + fs::write(&path, b"fixture\n").expect("write session"); + + let mut file = open_session_file_no_follow(&path).expect("open regular session file"); + let mut content = String::new(); + file.read_to_string(&mut content) + .expect("read session file"); + assert_eq!(content, "fixture\n"); + } + + #[cfg(unix)] + #[test] + fn open_session_file_no_follow_rejects_relative_path() { + assert!(open_session_file_no_follow(Path::new("relative.jsonl")).is_err()); + } + + #[cfg(unix)] + #[test] + fn open_session_file_no_follow_rejects_file_symlink() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("isolated session root"); + let target = temp.path().join("target.jsonl"); + let link = temp.path().join("link.jsonl"); + fs::write(&target, b"outside\n").expect("write target"); + symlink(&target, &link).expect("create file symlink"); + + assert!(open_session_file_no_follow(&link).is_err()); + } + + #[cfg(unix)] + #[test] + fn open_session_file_no_follow_rejects_intermediate_directory_symlink() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("isolated session root"); + let real_dir = temp.path().join("real"); + let linked_dir = temp.path().join("linked"); + fs::create_dir_all(&real_dir).expect("create real directory"); + fs::write(real_dir.join("session.jsonl"), b"outside\n").expect("write target"); + symlink(&real_dir, &linked_dir).expect("create directory symlink"); + + assert!(open_session_file_no_follow(&linked_dir.join("session.jsonl")).is_err()); + } #[test] fn memory_database_session_guard_does_not_create_a_lock_file() -> Result<(), AppError> { diff --git a/src-tauri/src/services/session_usage_omp.rs b/src-tauri/src/services/session_usage_omp.rs new file mode 100644 index 000000000..061c69d7d --- /dev/null +++ b/src-tauri/src/services/session_usage_omp.rs @@ -0,0 +1,1379 @@ +//! OMP coding-agent session usage importer. +//! +//! OMP records normalized token and cost data in its session JSONL files. This +//! importer keeps direct (non-proxy) OMP usage visible in the shared dashboard. + +use crate::database::{lock_conn, Database}; +use crate::error::AppError; +use crate::proxy::usage::calculator::CostCalculator; +use crate::proxy::usage::parser::TokenUsage; +use crate::services::session_usage::{ + metadata_modified_nanos, open_session_file_no_follow, update_line_sync_state_conn, + SessionSyncResult, +}; +use crate::services::sql_helpers::INPUT_TOKEN_SEMANTICS_FRESH; +use crate::services::usage_stats::find_model_pricing_match; +use rusqlite::OptionalExtension; +use rust_decimal::Decimal; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::fs; +use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +const APP_TYPE: &str = "omp"; +const DATA_SOURCE: &str = "omp_session"; +const PROVIDER_PLACEHOLDER: &str = "_omp_session"; +const UNKNOWN_MODEL: &str = "unknown"; +const MAX_USAGE_LABEL_BYTES: usize = 512; +const MIN_SQLITE_UNIX_MILLIS: i64 = -62_167_219_200_000; +const MAX_SQLITE_UNIX_MILLIS: i64 = 253_402_300_799_999; +const REVISION_TAIL_BYTES: u64 = 4096; +const REVISION_MARKER_SHIFT: u32 = 61; +const REVISION_COMPLETE_SHIFT: u32 = 60; +const REVISION_SIZE_SHIFT: u32 = 32; +const REVISION_MARKER: u64 = 0b101; +const REVISION_SIZE_MASK: u64 = (1 << 28) - 1; +const OMP_REQUEST_DEDUP_SQL: &str = "SELECT EXISTS( + SELECT 1 FROM session_usage_dedup + WHERE data_source = ?1 AND request_id = ?2 + )"; +const OMP_SEMANTIC_DEDUP_SQL: &str = "SELECT EXISTS( + SELECT 1 FROM session_usage_dedup + WHERE data_source = ?1 AND semantic_id = ?2 + )"; +const OMP_LEGACY_SEMANTIC_DEDUP_SQL: &str = "SELECT EXISTS( + SELECT 1 FROM session_usage_dedup + WHERE data_source = ?1 AND semantic_id = ?2 AND has_entry_id = 0 + )"; + +#[derive(Debug, Clone, Copy, Default)] +struct OMPCosts { + input: Decimal, + output: Decimal, + cache_read: Decimal, + cache_write: Decimal, + total: Decimal, +} + +impl OMPCosts { + fn reported(self) -> Option<(Decimal, Decimal, Decimal, Decimal, Decimal)> { + let component_total = self.input + self.output + self.cache_read + self.cache_write; + let total = if self.total > Decimal::ZERO { + self.total + } else { + component_total + }; + (total > Decimal::ZERO).then_some(( + self.input, + self.output, + self.cache_read, + self.cache_write, + total, + )) + } +} + +#[derive(Debug)] +struct OMPUsageRecord { + request_id: String, + semantic_id: String, + has_entry_id: bool, + provider_id: String, + model: String, + request_model: String, + input_tokens: u32, + output_tokens: u32, + cache_read_tokens: u32, + cache_write_tokens: u32, + costs: OMPCosts, + status_code: i64, + error_message: Option, + created_at: i64, + session_id: String, +} + +#[derive(Debug)] +struct ParsedOMPFile { + records: Vec, + last_complete_line: i64, + incomplete_tail: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct OMPFileRevision { + modified_nanos: i64, + file_size: u64, + tail_fingerprint: u32, + complete: bool, +} + +impl OMPFileRevision { + fn encoded_tail(self) -> i64 { + (u64::from(self.tail_fingerprint) | (u64::from(self.complete) << 32)) as i64 + } +} + +#[derive(Debug, Clone, Copy)] +struct OMPSyncState { + revision: OMPFileRevision, + last_line_offset: i64, + legacy_revision: bool, +} + +#[derive(Debug)] +struct OMPRequestIdentity { + request_id: String, + semantic_id: String, + has_entry_id: bool, +} + +/// Import usage from every OMP session file discoverable by the session +/// browser's current root and layout rules. +pub fn sync_omp_usage(db: &Database) -> Result { + let files = crate::session_manager::providers::omp::session_files() + .map_err(|error| AppError::Config(format!("无法发现 OMP 会话: {error}")))?; + Ok(sync_omp_files(db, &files)) +} + +fn sync_omp_files(db: &Database, files: &[PathBuf]) -> SessionSyncResult { + let mut result = SessionSyncResult { + files_scanned: files.len().min(u32::MAX as usize) as u32, + ..Default::default() + }; + + for file_path in files { + match sync_single_omp_file(db, file_path) { + Ok(file_result) => result.merge(file_result), + Err(error) => { + let message = format!("{}: {error}", file_path.display()); + log::warn!("[OMP-SYNC] 会话文件解析失败: {message}"); + result.errors.push(message); + } + } + } + + if result.imported > 0 { + log::info!( + "[OMP-SYNC] 同步完成: 导入 {} 条, 跳过 {} 条, 扫描 {} 个文件", + result.imported, + result.skipped, + result.files_scanned + ); + } + result +} + +fn sync_single_omp_file(db: &Database, file_path: &Path) -> Result { + let metadata = fs::symlink_metadata(file_path) + .map_err(|error| AppError::Config(format!("无法读取 OMP 会话文件元数据: {error}")))?; + if !metadata.file_type().is_file() + || file_path.extension().and_then(|value| value.to_str()) != Some("jsonl") + { + return Err(AppError::Config( + "OMP 会话路径不是普通 JSONL 文件".to_string(), + )); + } + if metadata.len() > crate::session_manager::providers::omp::MAX_SESSION_BYTES { + return Err(AppError::Config(format!( + "OMP 会话文件超过 {} 字节安全上限", + crate::session_manager::providers::omp::MAX_SESSION_BYTES + ))); + } + + let file_path_string = file_path.to_string_lossy().to_string(); + let modified = metadata_modified_nanos(&metadata); + let revision = omp_file_revision(file_path, &metadata, modified)?; + let previous = get_omp_sync_state(db, &file_path_string)?; + if let Some(state) = previous { + if state.revision == revision { + if state.legacy_revision { + let conn = lock_conn!(db.conn); + update_omp_sync_state_on_conn( + &conn, + &file_path_string, + revision, + state.last_line_offset, + )?; + } + return Ok(SessionSyncResult::default()); + } + } + + // A matching tail at the old EOF identifies OMP's normal append path, so + // active sessions can seek straight to appended JSONL. Any mismatch is a + // rewrite and must rescan from the header; the durable request ledger + // makes that safe. + let (start_after_line, start_at_byte) = match previous { + Some(state) + if state.revision.complete + && revision.file_size > state.revision.file_size + && omp_prefix_tail_matches(file_path, state.revision)? => + { + (state.last_line_offset, Some(state.revision.file_size)) + } + Some(_) | None => (0, None), + }; + let parsed = parse_omp_file( + file_path, + start_after_line, + start_at_byte, + revision.file_size, + modified, + )?; + let conn = lock_conn!(db.conn); + let tx = conn + .unchecked_transaction() + .map_err(|error| AppError::Database(format!("启动 OMP 用量导入事务失败: {error}")))?; + let mut result = SessionSyncResult::default(); + for record in &parsed.records { + if insert_omp_record(&tx, record)? { + result.imported = result.imported.saturating_add(1); + } else { + result.skipped = result.skipped.saturating_add(1); + } + } + + update_omp_sync_state_on_conn(&tx, &file_path_string, revision, parsed.last_complete_line)?; + tx.commit() + .map_err(|error| AppError::Database(format!("提交 OMP 用量导入事务失败: {error}")))?; + if parsed.incomplete_tail { + result.deferred_files = 1; + } + Ok(result) +} + +fn get_omp_sync_state(db: &Database, file_path: &str) -> Result, AppError> { + let conn = lock_conn!(db.conn); + let row = conn + .query_row( + "SELECT last_modified, last_line_offset, last_synced_at, + last_byte_offset, last_tail_fingerprint + FROM session_log_sync WHERE file_path = ?1", + rusqlite::params![file_path], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + )) + }, + ) + .optional() + .map_err(|error| AppError::Database(format!("读取 OMP 会话同步状态失败: {error}")))?; + let Some(( + modified_nanos, + last_line_offset, + legacy_encoded_revision, + stored_file_size, + stored_tail_fingerprint, + )) = row + else { + return Ok(None); + }; + let (file_size, tail_fingerprint, complete, legacy_revision) = if let ( + Some(file_size), + Some(encoded_tail), + ) = + (stored_file_size, stored_tail_fingerprint) + { + if file_size < 0 || encoded_tail < 0 { + return Ok(None); + } + let encoded_tail = encoded_tail as u64; + ( + file_size as u64, + encoded_tail as u32, + ((encoded_tail >> 32) & 1) == 1, + false, + ) + } else { + // Databases written by the first OMP importer stored the + // append-proof revision in last_synced_at. Accept those rows once + // and migrate them to the dedicated cursor columns on the next + // successful sync. + let encoded_revision = legacy_encoded_revision as u64; + if encoded_revision >> REVISION_MARKER_SHIFT != REVISION_MARKER { + return Ok(None); + } + ( + (encoded_revision >> REVISION_SIZE_SHIFT) & REVISION_SIZE_MASK, + encoded_revision as u32, + ((encoded_revision >> REVISION_COMPLETE_SHIFT) & 1) == 1, + true, + ) + }; + if file_size > crate::session_manager::providers::omp::MAX_SESSION_BYTES + || last_line_offset < 0 + || last_line_offset > crate::session_manager::providers::omp::MAX_TREE_ENTRIES as i64 + 1 + { + return Ok(None); + } + Ok(Some(OMPSyncState { + revision: OMPFileRevision { + modified_nanos, + file_size, + tail_fingerprint, + complete, + }, + last_line_offset, + legacy_revision, + })) +} + +fn update_omp_sync_state_on_conn( + conn: &rusqlite::Connection, + file_path: &str, + revision: OMPFileRevision, + last_line_offset: i64, +) -> Result<(), AppError> { + update_line_sync_state_conn( + conn, + file_path, + revision.modified_nanos, + last_line_offset, + Some( + i64::try_from(revision.file_size) + .map_err(|_| AppError::Config("OMP 会话文件大小超出同步游标范围".to_string()))?, + ), + Some(revision.encoded_tail()), + ) +} + +fn omp_file_revision( + file_path: &Path, + metadata: &fs::Metadata, + modified_nanos: i64, +) -> Result { + let tail_len = metadata.len().min(REVISION_TAIL_BYTES); + let mut tail = vec![0; tail_len as usize]; + if tail_len > 0 { + let mut file = open_session_file_no_follow(file_path) + .map_err(|error| AppError::Config(format!("无法打开 OMP 会话文件: {error}")))?; + file.seek(SeekFrom::Start(metadata.len() - tail_len)) + .and_then(|_| file.read_exact(&mut tail)) + .map_err(|error| AppError::Config(format!("无法读取 OMP 会话文件尾部: {error}")))?; + } + + let complete = tail.last() == Some(&b'\n'); + let tail_fingerprint = omp_tail_fingerprint(&tail); + Ok(OMPFileRevision { + modified_nanos, + file_size: metadata.len(), + tail_fingerprint, + complete, + }) +} + +fn omp_prefix_tail_matches(file_path: &Path, previous: OMPFileRevision) -> Result { + let tail_len = previous.file_size.min(REVISION_TAIL_BYTES); + let mut tail = vec![0; tail_len as usize]; + if tail_len > 0 { + let mut file = open_session_file_no_follow(file_path) + .map_err(|error| AppError::Config(format!("无法打开 OMP 会话文件: {error}")))?; + file.seek(SeekFrom::Start(previous.file_size - tail_len)) + .and_then(|_| file.read_exact(&mut tail)) + .map_err(|error| AppError::Config(format!("无法校验 OMP 会话追加边界: {error}")))?; + } + Ok(omp_tail_fingerprint(&tail) == previous.tail_fingerprint) +} + +fn omp_tail_fingerprint(tail: &[u8]) -> u32 { + let mut hasher = Sha256::new(); + hash_field(&mut hasher, b"pi-session-tail-v1"); + hash_field(&mut hasher, tail); + let digest = hasher.finalize(); + u32::from_be_bytes(digest[..4].try_into().unwrap_or_default()) +} + +fn parse_omp_file( + file_path: &Path, + start_after_line: i64, + start_at_byte: Option, + snapshot_size: u64, + file_modified_nanos: i64, +) -> Result { + let file = open_session_file_no_follow(file_path) + .map_err(|error| AppError::Config(format!("无法打开 OMP 会话文件: {error}")))?; + let mut reader = BufReader::new(file); + let mut buffer = String::new(); + let mut line_number = 0i64; + let mut bytes_read = 0u64; + let mut session_id = None; + let mut session_timestamp = None; + let mut records = Vec::new(); + let mut incomplete_tail = false; + + // An append cursor points at the old complete EOF, while the session + // header lives near the beginning of the file. Establish the session + // identity once, then seek directly to that cursor; otherwise an active + // OMP session would be reparsed from byte zero on every sync cycle. + if let Some(byte_offset) = + start_at_byte.filter(|offset| *offset > 0 && *offset <= snapshot_size) + { + while session_id.is_none() && bytes_read < byte_offset { + buffer.clear(); + let remaining = byte_offset.saturating_sub(bytes_read); + let read = Read::by_ref(&mut reader) + .take(remaining) + .read_line(&mut buffer) + .map_err(|error| AppError::Config(format!("无法读取 OMP 会话 header: {error}")))?; + if read == 0 { + break; + } + bytes_read = bytes_read.saturating_add(read as u64); + line_number = line_number.saturating_add(1); + let line = buffer.trim(); + let Ok(value) = serde_json::from_str::(line) else { + if !buffer.ends_with('\n') { + return Err(AppError::Config("OMP 会话 header 不完整".to_string())); + } + continue; + }; + if value.get("type").and_then(Value::as_str) == Some("session") { + session_id = value + .get("id") + .and_then(Value::as_str) + .filter(|id| crate::session_manager::providers::omp::is_valid_tree_id(id)) + .map(str::to_string); + if session_id.is_none() { + return Err(AppError::Config("OMP 会话 header 缺少 id".to_string())); + } + session_timestamp = value + .get("timestamp") + .and_then(parse_timestamp_millis) + .map(|timestamp| timestamp / 1000); + } + } + if session_id.is_none() { + return Err(AppError::Config("OMP 会话没有有效 header".to_string())); + } + reader + .seek(SeekFrom::Start(byte_offset)) + .map_err(|error| AppError::Config(format!("无法定位 OMP 会话增量边界: {error}")))?; + bytes_read = byte_offset; + line_number = start_after_line; + } + + loop { + buffer.clear(); + let remaining = snapshot_size.saturating_sub(bytes_read); + if remaining == 0 { + break; + } + let read = Read::by_ref(&mut reader) + .take(remaining) + .read_line(&mut buffer) + .map_err(|error| AppError::Config(format!("无法读取 OMP 会话文件: {error}")))?; + if read == 0 { + return Err(AppError::Config("OMP 会话文件在读取期间被截断".to_string())); + } + bytes_read = bytes_read.saturating_add(read as u64); + if bytes_read > crate::session_manager::providers::omp::MAX_SESSION_BYTES { + return Err(AppError::Config( + "OMP 会话文件读取时超过安全上限".to_string(), + )); + } + let has_newline = buffer.ends_with('\n'); + let line = buffer.trim(); + let value = if line.is_empty() { + None + } else { + match serde_json::from_str::(line) { + Ok(value) => Some(value), + Err(_) if !has_newline => { + incomplete_tail = true; + break; + } + Err(_) => None, + } + }; + line_number = line_number.saturating_add(1); + if line_number > crate::session_manager::providers::omp::MAX_TREE_ENTRIES as i64 + 1 { + return Err(AppError::Config(format!( + "OMP 会话超过 {} 条 entry 安全上限", + crate::session_manager::providers::omp::MAX_TREE_ENTRIES + ))); + } + if session_id.is_some() && line_number <= start_after_line { + continue; + } + let Some(value) = value else { + if !has_newline { + incomplete_tail = true; + break; + } + continue; + }; + + if session_id.is_none() { + // OMP may prepend a title and model/thinking-level metadata before + // the session header. Those records carry no usage and are safe to + // ignore while locating the authoritative session id. + if value.get("type").and_then(Value::as_str) != Some("session") { + continue; + } + session_id = value + .get("id") + .and_then(Value::as_str) + .filter(|id| crate::session_manager::providers::omp::is_valid_tree_id(id)) + .map(str::to_string); + if session_id.is_none() { + return Err(AppError::Config("OMP 会话 header 缺少 id".to_string())); + } + let header_timestamp_millis = value.get("timestamp").and_then(parse_timestamp_millis); + session_timestamp = header_timestamp_millis.map(|timestamp| timestamp / 1000); + continue; + } + if let Some(record) = parse_usage_record( + &value, + session_id.as_deref().unwrap_or_default(), + session_timestamp, + file_modified_nanos / 1_000_000_000, + ) { + records.push(record); + } + } + + if session_id.is_none() && !incomplete_tail { + return Err(AppError::Config("OMP 会话没有有效 header".to_string())); + } + Ok(ParsedOMPFile { + records, + last_complete_line: line_number, + incomplete_tail, + }) +} + +fn parse_usage_record( + entry: &Value, + session_id: &str, + session_timestamp: Option, + file_timestamp: i64, +) -> Option { + let entry_type = entry.get("type").and_then(Value::as_str)?; + let (kind, usage_value, message) = match entry_type { + "message" => { + let message = entry.get("message")?; + match message.get("role").and_then(Value::as_str) { + Some("assistant") => ("assistant", message.get("usage")?, Some(message)), + Some("toolResult") => ("tool_result", message.get("usage")?, Some(message)), + _ => return None, + } + } + // OMP persists usage for non-transcript model-control requests (for + // example automatic thinking-level changes) as a dedicated entry. + // These requests must be included in usage totals just like assistant + // transcript messages. + "model_usage" => ("model_usage", entry.get("usage")?, None), + "compaction" => ("compaction", entry.get("usage")?, None), + "branch_summary" => ("branch_summary", entry.get("usage")?, None), + _ => return None, + }; + + let event_timestamp_millis = entry + .get("timestamp") + .and_then(parse_timestamp_millis) + .or_else(|| { + message + .and_then(|value| value.get("timestamp")) + .and_then(parse_timestamp_millis) + }); + let input_tokens = token_count(usage_value, "input"); + let output_tokens = token_count(usage_value, "output"); + let cache_read_tokens = token_count(usage_value, "cacheRead"); + let cache_write_tokens = token_count(usage_value, "cacheWrite"); + let costs = parse_costs(usage_value.get("cost")); + let stop_reason = message + .and_then(|value| nonempty_string(value.get("stopReason"))) + .or_else(|| nonempty_string(entry.get("stopReason"))); + let failed = matches!(stop_reason, Some("error" | "aborted")); + if input_tokens == 0 + && output_tokens == 0 + && cache_read_tokens == 0 + && cache_write_tokens == 0 + && costs.reported().is_none() + && !failed + { + return None; + } + + let (provider_id, model, request_model) = match kind { + "assistant" => { + let message = message?; + // OMP distinguishes the configured provider/model from the + // upstream route selected by a proxy or fallback adapter. + let provider = bounded_label( + message + .get("upstreamProvider") + .or_else(|| message.get("provider")), + PROVIDER_PLACEHOLDER, + ); + let requested = bounded_label(message.get("model"), UNKNOWN_MODEL); + let actual = message + .get("upstreamModel") + .and_then(|value| nonempty_string(Some(value))) + .or_else(|| nonempty_string(message.get("responseModel"))) + .map(truncate_usage_label) + .unwrap_or(&requested) + .to_string(); + (provider, actual, requested) + } + "model_usage" => { + let provider = bounded_label( + entry + .get("upstreamProvider") + .or_else(|| entry.get("provider")), + PROVIDER_PLACEHOLDER, + ); + let requested = bounded_label(entry.get("model"), UNKNOWN_MODEL); + let actual = entry + .get("upstreamModel") + .and_then(|value| nonempty_string(Some(value))) + .or_else(|| nonempty_string(entry.get("responseModel"))) + .map(truncate_usage_label) + .unwrap_or(&requested) + .to_string(); + (provider, actual, requested) + } + _ => ( + PROVIDER_PLACEHOLDER.to_string(), + UNKNOWN_MODEL.to_string(), + UNKNOWN_MODEL.to_string(), + ), + }; + + let created_at = event_timestamp_millis + .map(|timestamp| timestamp / 1000) + .or(session_timestamp) + .unwrap_or(file_timestamp) + .clamp(MIN_SQLITE_UNIX_MILLIS / 1000, MAX_SQLITE_UNIX_MILLIS / 1000); + + let (status_code, error_message) = if matches!(kind, "assistant" | "model_usage") { + match stop_reason { + Some("error") | Some("aborted") => { + let fallback = if stop_reason == Some("aborted") { + "OMP request aborted" + } else { + "OMP request failed" + }; + let error = message + .and_then(|value| nonempty_string(value.get("errorMessage"))) + .or_else(|| nonempty_string(entry.get("errorMessage"))) + .unwrap_or(fallback) + .chars() + .take(4096) + .collect(); + ( + if stop_reason == Some("aborted") { + 499 + } else { + 500 + }, + Some(error), + ) + } + _ => (200, None), + } + } else { + (200, None) + }; + + let identity = omp_request_identity(entry, kind, usage_value, message, session_id); + + Some(OMPUsageRecord { + request_id: identity.request_id, + semantic_id: identity.semantic_id, + has_entry_id: identity.has_entry_id, + provider_id, + model, + request_model, + input_tokens, + output_tokens, + cache_read_tokens, + cache_write_tokens, + costs, + status_code, + error_message, + created_at, + session_id: session_id.to_string(), + }) +} + +fn nonempty_string(value: Option<&Value>) -> Option<&str> { + value + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn bounded_label(value: Option<&Value>, fallback: &str) -> String { + truncate_usage_label(nonempty_string(value).unwrap_or(fallback)).to_string() +} + +fn truncate_usage_label(value: &str) -> &str { + if value.len() <= MAX_USAGE_LABEL_BYTES { + return value; + } + let mut end = MAX_USAGE_LABEL_BYTES; + while !value.is_char_boundary(end) { + end -= 1; + } + &value[..end] +} + +fn token_count(usage: &Value, key: &str) -> u32 { + usage + .get(key) + .and_then(Value::as_u64) + .unwrap_or(0) + .min(u32::MAX as u64) as u32 +} + +fn parse_costs(value: Option<&Value>) -> OMPCosts { + let decimal = |key| { + value + .and_then(|cost| cost.get(key)) + .and_then(parse_decimal) + .unwrap_or(Decimal::ZERO) + .max(Decimal::ZERO) + }; + OMPCosts { + input: decimal("input"), + output: decimal("output"), + cache_read: decimal("cacheRead"), + cache_write: decimal("cacheWrite"), + total: decimal("total"), + } +} + +fn parse_decimal(value: &Value) -> Option { + let raw = match value { + Value::Number(number) => number.to_string(), + Value::String(value) => value.clone(), + _ => return None, + }; + Decimal::from_str(&raw) + .or_else(|_| Decimal::from_scientific(&raw)) + .ok() +} + +fn parse_timestamp_millis(value: &Value) -> Option { + let timestamp = if let Some(timestamp) = value.as_i64() { + if !(-100_000_000_000..=100_000_000_000).contains(×tamp) { + timestamp + } else { + timestamp.saturating_mul(1000) + } + } else { + value + .as_str() + .and_then(|timestamp| chrono::DateTime::parse_from_rfc3339(timestamp).ok())? + .timestamp_millis() + }; + (MIN_SQLITE_UNIX_MILLIS..=MAX_SQLITE_UNIX_MILLIS) + .contains(×tamp) + .then_some(timestamp) +} + +fn omp_request_identity( + entry: &Value, + kind: &str, + usage: &Value, + message: Option<&Value>, + session_id: &str, +) -> OMPRequestIdentity { + let mut hasher = Sha256::new(); + hash_field(&mut hasher, b"pi-session-semantic-v1"); + hash_field(&mut hasher, kind.as_bytes()); + + for (label, value) in [ + (b"entry_timestamp".as_slice(), entry.get("timestamp")), + ( + b"message_timestamp".as_slice(), + message.and_then(|value| value.get("timestamp")), + ), + ] { + if let Some(value) = value { + hash_field(&mut hasher, label); + hash_json(&mut hasher, value); + } + } + if let Some(message) = message { + for key in [ + "provider", + "upstreamProvider", + "model", + "upstreamModel", + "responseModel", + "responseId", + "api", + "toolCallId", + "toolName", + "stopReason", + "errorMessage", + ] { + if let Some(value) = message.get(key) { + hash_field(&mut hasher, key.as_bytes()); + hash_json(&mut hasher, value); + } + } + if let Some(content) = message.get("content") { + hash_field(&mut hasher, b"content"); + hash_json(&mut hasher, content); + } + } else { + for key in [ + "provider", + "upstreamProvider", + "model", + "upstreamModel", + "responseModel", + "stopReason", + "errorMessage", + ] { + if let Some(value) = entry.get(key) { + hash_field(&mut hasher, key.as_bytes()); + hash_json(&mut hasher, value); + } + } + if let Some(summary) = entry.get("summary") { + hash_field(&mut hasher, b"summary"); + hash_json(&mut hasher, summary); + } + } + hash_field(&mut hasher, b"usage"); + hash_json(&mut hasher, usage); + // Entries without a stable OMP `id` fall back to semantic identity. Keep + // that identity scoped to its session so identical usage events in two + // different sessions cannot be mistaken for the same request. + hash_field(&mut hasher, b"session_id"); + hash_field(&mut hasher, session_id.as_bytes()); + let semantic_id = format!("omp_session_semantic:{:x}", hasher.finalize()); + let entry_id = nonempty_string(entry.get("id")); + let request_id = if let Some(entry_id) = entry_id { + let mut request_hasher = Sha256::new(); + hash_field(&mut request_hasher, b"pi-session-request-v3"); + hash_field(&mut request_hasher, kind.as_bytes()); + hash_field(&mut request_hasher, entry_id.as_bytes()); + if let Some(timestamp) = entry.get("timestamp") { + hash_json(&mut request_hasher, timestamp); + } + format!("omp_session:{:x}", request_hasher.finalize()) + } else { + semantic_id.clone() + }; + OMPRequestIdentity { + request_id, + semantic_id, + has_entry_id: entry_id.is_some(), + } +} + +fn hash_json(hasher: &mut Sha256, value: &Value) { + match value { + Value::Null => hash_field(hasher, b"null"), + Value::Bool(value) => { + hash_field(hasher, b"bool"); + hash_field(hasher, if *value { b"true" } else { b"false" }); + } + Value::Number(value) => { + hash_field(hasher, b"number"); + hash_field(hasher, value.to_string().as_bytes()); + } + Value::String(value) => { + hash_field(hasher, b"string"); + hash_field(hasher, value.as_bytes()); + } + Value::Array(values) => { + hash_field(hasher, b"array"); + hash_field(hasher, &(values.len() as u64).to_be_bytes()); + for value in values { + hash_json(hasher, value); + } + } + Value::Object(values) => { + hash_field(hasher, b"object"); + hash_field(hasher, &(values.len() as u64).to_be_bytes()); + let mut keys: Vec<_> = values.keys().collect(); + keys.sort_unstable(); + for key in keys { + hash_field(hasher, key.as_bytes()); + hash_json(hasher, &values[key]); + } + } + } +} + +fn hash_field(hasher: &mut Sha256, value: &[u8]) { + hasher.update((value.len() as u64).to_be_bytes()); + hasher.update(value); +} + +fn insert_omp_record( + conn: &rusqlite::Connection, + record: &OMPUsageRecord, +) -> Result { + let request_seen: bool = conn + .query_row( + OMP_REQUEST_DEDUP_SQL, + rusqlite::params![DATA_SOURCE, record.request_id], + |row| row.get(0), + ) + .map_err(|error| AppError::Database(format!("查询 OMP 用量去重账本失败: {error}")))?; + let already_seen = request_seen + || conn + .query_row( + if record.has_entry_id { + OMP_LEGACY_SEMANTIC_DEDUP_SQL + } else { + OMP_SEMANTIC_DEDUP_SQL + }, + rusqlite::params![DATA_SOURCE, record.semantic_id], + |row| row.get(0), + ) + .map_err(|error| AppError::Database(format!("查询 OMP 用量去重账本失败: {error}")))?; + if already_seen { + return Ok(false); + } + conn.execute( + "INSERT OR IGNORE INTO session_usage_dedup + (data_source, request_id, semantic_id, has_entry_id) + VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![ + DATA_SOURCE, + record.request_id, + record.semantic_id, + i64::from(record.has_entry_id), + ], + ) + .map_err(|error| AppError::Database(format!("写入 OMP 用量去重账本失败: {error}")))?; + + let usage = TokenUsage { + input_tokens: record.input_tokens, + output_tokens: record.output_tokens, + cache_read_tokens: record.cache_read_tokens, + cache_creation_tokens: record.cache_write_tokens, + model: Some(record.model.clone()), + message_id: None, + }; + let (costs, pricing_model) = if let Some(costs) = record.costs.reported() { + (Some(costs), record.model.clone()) + } else if let Some((matched_model, pricing)) = find_model_pricing_match(conn, &record.model) + .ok() + .flatten() + .map(|matched| (matched.model_id, matched.pricing)) + .or_else(|| { + find_model_pricing_match(conn, &record.request_model) + .ok() + .flatten() + .map(|matched| (matched.model_id, matched.pricing)) + }) + { + let calculated = + CostCalculator::calculate_for_app(APP_TYPE, &usage, &pricing, Decimal::ONE); + ( + Some(( + calculated.input_cost, + calculated.output_cost, + calculated.cache_read_cost, + calculated.cache_creation_cost, + calculated.total_cost, + )), + matched_model, + ) + } else { + (None, String::new()) + }; + let (input_cost, output_cost, cache_read_cost, cache_write_cost, total_cost) = + costs.unwrap_or(( + Decimal::ZERO, + Decimal::ZERO, + Decimal::ZERO, + Decimal::ZERO, + Decimal::ZERO, + )); + + conn.execute( + "INSERT OR IGNORE INTO proxy_request_logs ( + request_id, provider_id, app_type, model, request_model, pricing_model, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + input_token_semantics, + input_cost_usd, output_cost_usd, cache_read_cost_usd, + cache_creation_cost_usd, total_cost_usd, + latency_ms, first_token_ms, status_code, error_message, session_id, + provider_type, is_streaming, cost_multiplier, created_at, data_source + ) VALUES ( + ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, + ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26 + )", + rusqlite::params![ + record.request_id, + record.provider_id, + APP_TYPE, + record.model, + record.request_model, + pricing_model, + record.input_tokens, + record.output_tokens, + record.cache_read_tokens, + record.cache_write_tokens, + INPUT_TOKEN_SEMANTICS_FRESH, + input_cost.to_string(), + output_cost.to_string(), + cache_read_cost.to_string(), + cache_write_cost.to_string(), + total_cost.to_string(), + 0i64, + Option::::None, + record.status_code, + record.error_message, + record.session_id, + Some(DATA_SOURCE), + 1i64, + "1.0", + record.created_at, + DATA_SOURCE, + ], + ) + .map(|changed| changed > 0) + .map_err(|error| AppError::Database(format!("插入 OMP 会话用量失败: {error}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::fs; + use std::io::Write; + + #[test] + fn parses_omp_responses_usage() { + let entry: Value = serde_json::json!({ + "type": "message", + "id": "entry-1", + "timestamp": "2026-09-04T03:14:22.500Z", + "message": { + "role": "assistant", + "provider": "company-1", + "model": "gpt-5.6-sol", + "responseModel": "gpt-5.6-sol", + "usage": { + "input": 3689, + "output": 14, + "cacheRead": 14848, + "cacheWrite": 0, + "cost": {"total": 0.0209752} + }, + "stopReason": "stop" + } + }); + let record = parse_usage_record(&entry, "session-1", None, 0).expect("usage"); + assert_eq!(record.provider_id, "company-1"); + assert_eq!(record.model, "gpt-5.6-sol"); + assert_eq!(record.input_tokens, 3689); + assert_eq!(record.output_tokens, 14); + assert_eq!(record.cache_read_tokens, 14848); + assert_eq!(record.status_code, 200); + assert_eq!(record.costs.total, Decimal::from_str("0.0209752").unwrap()); + } + + #[test] + fn prefers_omp_upstream_provider_and_model() { + let entry = serde_json::json!({ + "type": "message", + "id": "entry-upstream", + "message": { + "role": "assistant", + "provider": "company-1", + "model": "alias-model", + "upstreamProvider": "upstream-company", + "upstreamModel": "gpt-5.6-sol-2026-09-04", + "usage": {"input": 1, "output": 2, "cost": {"total": 0.1}}, + "stopReason": "stop" + } + }); + let record = parse_usage_record(&entry, "session-1", None, 0).expect("usage"); + assert_eq!(record.provider_id, "upstream-company"); + assert_eq!(record.model, "gpt-5.6-sol-2026-09-04"); + assert_eq!(record.request_model, "alias-model"); + } + + #[test] + fn imports_model_usage_entries() { + let entry = serde_json::json!({ + "type": "model_usage", + "id": "model-usage-1", + "timestamp": "2026-09-04T03:14:23Z", + "provider": "company-1", + "model": "gpt-5.6-sol", + "usage": { + "input": 7, + "output": 3, + "cacheRead": 2, + "cacheWrite": 1, + "cost": {"total": "0.0125"} + }, + "stopReason": "stop" + }); + let record = parse_usage_record(&entry, "session-1", None, 0).expect("usage"); + assert_eq!(record.provider_id, "company-1"); + assert_eq!(record.model, "gpt-5.6-sol"); + assert_eq!(record.input_tokens, 7); + assert_eq!(record.output_tokens, 3); + assert_eq!(record.cache_read_tokens, 2); + assert_eq!(record.cache_write_tokens, 1); + assert_eq!(record.status_code, 200); + } + + #[test] + fn ignores_non_usage_omp_entries() { + let entry = serde_json::json!({ + "type": "model_change", + "model": "company-1/gpt-5.6-sol" + }); + assert!(parse_usage_record(&entry, "session-1", None, 0).is_none()); + } + + #[test] + fn imports_real_omp_jsonl_with_metadata_prefix_and_deduplicates() -> Result<(), AppError> { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("session.jsonl"); + let lines = [ + serde_json::json!({ + "type": "title", + "title": "OMP fixture" + }), + serde_json::json!({ + "type": "session", + "version": 3, + "id": "session-omp-fixture", + "timestamp": "2026-09-04T03:14:22.500Z", + "cwd": "/tmp" + }), + serde_json::json!({ + "type": "model_change", + "id": "model-change-1", + "model": "company-1/gpt-5.6-sol" + }), + serde_json::json!({ + "type": "message", + "id": "assistant-1", + "timestamp": "2026-09-04T03:14:23.000Z", + "message": { + "role": "assistant", + "api": "openai-responses", + "provider": "company-1", + "model": "gpt-5.6-sol", + "responseModel": "gpt-5.6-sol", + "usage": { + "input": 3689, + "output": 14, + "cacheRead": 14848, + "cacheWrite": 0, + "cost": { + "input": 0.014756, + "output": 0.00028, + "cacheRead": 0.0059392, + "cacheWrite": 0, + "total": 0.0209752 + } + }, + "stopReason": "stop" + } + }), + ]; + let content = lines + .iter() + .map(|line| serde_json::to_string(line).expect("serialize fixture") + "\n") + .collect::(); + fs::write(&path, content.as_bytes()).expect("write fixture"); + + let db = Database::memory()?; + let first = sync_omp_files(&db, std::slice::from_ref(&path)); + assert_eq!(first.imported, 1); + assert!(first.errors.is_empty()); + let second = sync_omp_files(&db, std::slice::from_ref(&path)); + assert_eq!(second.imported, 0); + assert_eq!(second.skipped, 0); + + let conn = lock_conn!(db.conn); + let row: (String, String, String, i64, i64, i64, String) = conn.query_row( + "SELECT provider_id, app_type, data_source, input_tokens, + output_tokens, cache_read_tokens, total_cost_usd + FROM proxy_request_logs WHERE request_id LIKE 'omp_session:%'", + [], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + )) + }, + )?; + assert_eq!(row.0, "company-1"); + assert_eq!(row.1, APP_TYPE); + assert_eq!(row.2, DATA_SOURCE); + assert_eq!(row.3, 3689); + assert_eq!(row.4, 14); + assert_eq!(row.5, 14848); + assert_eq!(row.6, "0.0209752"); + let sync_state: (i64, Option, Option) = conn.query_row( + "SELECT last_synced_at, last_byte_offset, last_tail_fingerprint + FROM session_log_sync WHERE file_path = ?1", + rusqlite::params![path.to_string_lossy().as_ref()], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + assert!(sync_state.0 > 0); + assert_eq!(sync_state.1, Some(content.len() as i64)); + assert!(sync_state.2.is_some()); + Ok(()) + } + + #[test] + fn appends_and_completes_partial_omp_tail_incrementally() -> Result<(), AppError> { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("active.jsonl"); + let header = serde_json::json!({ + "type": "session", "version": 3, "id": "session-active", + "timestamp": "2026-09-04T03:14:22Z" + }); + let first = serde_json::json!({ + "type": "message", "id": "assistant-1", "timestamp": "2026-09-04T03:14:23Z", + "message": {"role": "assistant", "provider": "company-1", "model": "m1", + "usage": {"input": 1, "output": 1}, "stopReason": "stop"} + }); + let second = serde_json::to_string(&serde_json::json!({ + "type": "message", "id": "assistant-2", "timestamp": "2026-09-04T03:14:24Z", + "message": {"role": "assistant", "provider": "company-1", "model": "m1", + "usage": {"input": 2, "output": 2}, "stopReason": "stop"} + })) + .expect("serialize second record"); + let header_line = serde_json::to_string(&header).expect("serialize header") + "\n"; + let first_line = serde_json::to_string(&first).expect("serialize first record") + "\n"; + fs::write(&path, format!("{header_line}{first_line}")).expect("write initial session"); + let db = Database::memory()?; + let initial = sync_omp_files(&db, std::slice::from_ref(&path)); + assert_eq!(initial.imported, 1); + + let split = second.len() / 2; + let mut file = fs::OpenOptions::new() + .append(true) + .open(&path) + .expect("open partial session"); + file.write_all(&second.as_bytes()[..split]) + .expect("write partial record"); + file.flush().expect("flush partial record"); + drop(file); + let partial = sync_omp_files(&db, std::slice::from_ref(&path)); + assert_eq!((partial.imported, partial.deferred_files), (0, 1)); + + let mut file = fs::OpenOptions::new() + .append(true) + .open(&path) + .expect("open completed session"); + file.write_all(&second.as_bytes()[split..]) + .expect("write completed record"); + file.write_all(b"\n").expect("write record newline"); + file.flush().expect("flush completed record"); + drop(file); + // The partial record is rescanned from the saved complete boundary. + let completed = sync_omp_files(&db, std::slice::from_ref(&path)); + assert_eq!(completed.imported, 1); + let total: i64 = lock_conn!(db.conn).query_row( + "SELECT COUNT(*) FROM proxy_request_logs WHERE data_source = ?1", + rusqlite::params![DATA_SOURCE], + |row| row.get(0), + )?; + assert_eq!(total, 2); + Ok(()) + } + + #[test] + fn imports_error_aborted_and_model_usage_statuses() -> Result<(), AppError> { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("statuses.jsonl"); + let lines = [ + serde_json::json!({"type":"session","id":"session-statuses","version":3}), + serde_json::json!({"type":"model_usage","id":"failed","provider":"company-1","model":"m","usage":{"input":1,"output":0},"stopReason":"error","errorMessage":"bad gateway"}), + serde_json::json!({"type":"message","id":"aborted","message":{"role":"assistant","provider":"company-1","model":"m","usage":{"input":0,"output":0},"stopReason":"aborted"}}), + ]; + let content = lines + .iter() + .map(|line| serde_json::to_string(line).expect("serialize status record") + "\n") + .collect::(); + fs::write(&path, content).expect("write status session"); + let db = Database::memory()?; + assert_eq!(sync_omp_files(&db, std::slice::from_ref(&path)).imported, 2); + let statuses: Vec<(i64, String)> = lock_conn!(db.conn) + .prepare("SELECT status_code, error_message FROM proxy_request_logs WHERE data_source = ?1 ORDER BY status_code")? + .query_map(rusqlite::params![DATA_SOURCE], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::>()?; + assert_eq!( + statuses, + vec![ + (499, "OMP request aborted".into()), + (500, "bad gateway".into()) + ] + ); + Ok(()) + } + + #[test] + fn leaves_pricing_model_empty_when_cost_is_unavailable() -> Result<(), AppError> { + let temp = tempfile::tempdir().expect("create isolated session root"); + let path = temp.path().join("unpriced.jsonl"); + fs::write( + &path, + concat!( + "{\"type\":\"session\",\"version\":3,\"id\":\"unpriced-session\"}\n", + "{\"type\":\"message\",\"id\":\"unpriced-entry\",\"message\":{\"role\":\"assistant\",\"provider\":\"company-1\",\"model\":\"unpriced-omp-model\",\"usage\":{\"input\":3,\"output\":2},\"stopReason\":\"stop\"}}\n" + ), + ) + .expect("write unpriced session"); + let db = Database::memory()?; + assert_eq!(sync_omp_files(&db, std::slice::from_ref(&path)).imported, 1); + let pricing_model: String = lock_conn!(db.conn).query_row( + "SELECT pricing_model FROM proxy_request_logs WHERE data_source = ?1", + rusqlite::params![DATA_SOURCE], + |row| row.get(0), + )?; + assert!(pricing_model.is_empty()); + Ok(()) + } + + #[test] + #[serial] + fn sync_usage_reads_migrated_xdg_data_home_sessions() -> Result<(), AppError> { + let home = tempfile::tempdir().expect("create isolated home"); + let _env = crate::test_support::TestEnvGuard::isolated(home.path()); + let xdg_data_home = home.path().join("xdg-data"); + let sessions_dir = xdg_data_home.join("omp/sessions"); + fs::create_dir_all(&sessions_dir).expect("create migrated OMP sessions directory"); + std::env::set_var("XDG_DATA_HOME", &xdg_data_home); + let path = sessions_dir.join("migrated.jsonl"); + fs::write( + &path, + concat!( + "{\"type\":\"session\",\"version\":3,\"id\":\"migrated-session\"}\n", + "{\"type\":\"message\",\"id\":\"migrated-entry\",\"message\":{\"role\":\"assistant\",\"provider\":\"company-1\",\"model\":\"gpt-5.6\",\"usage\":{\"input\":3,\"output\":2},\"stopReason\":\"stop\"}}\n" + ), + ) + .expect("write migrated session"); + + let db = Database::memory()?; + let result = sync_omp_usage(&db)?; + assert_eq!(result.imported, 1); + assert_eq!(result.files_scanned, 1); + assert!(result.errors.is_empty()); + + Ok(()) + } +} diff --git a/src-tauri/src/services/session_usage_pi.rs b/src-tauri/src/services/session_usage_pi.rs index bb0ec9149..8e85d4561 100644 --- a/src-tauri/src/services/session_usage_pi.rs +++ b/src-tauri/src/services/session_usage_pi.rs @@ -8,7 +8,8 @@ use crate::error::AppError; use crate::proxy::usage::calculator::CostCalculator; use crate::proxy::usage::parser::TokenUsage; use crate::services::session_usage::{ - metadata_modified_nanos, update_sync_state_conn, SessionSyncResult, + metadata_modified_nanos, open_session_file_no_follow, update_line_sync_state_conn, + SessionSyncResult, }; use crate::services::sql_helpers::INPUT_TOKEN_SEMANTICS_FRESH; use crate::services::usage_stats::find_model_pricing; @@ -16,7 +17,7 @@ use rusqlite::OptionalExtension; use rust_decimal::Decimal; use serde_json::Value; use sha2::{Digest, Sha256}; -use std::fs::{self, File}; +use std::fs; use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use std::str::FromStr; @@ -109,11 +110,8 @@ struct PiFileRevision { } impl PiFileRevision { - fn encoded(self) -> i64 { - ((REVISION_MARKER << REVISION_MARKER_SHIFT) - | (u64::from(self.complete) << REVISION_COMPLETE_SHIFT) - | (self.file_size << REVISION_SIZE_SHIFT) - | u64::from(self.tail_fingerprint)) as i64 + fn encoded_tail(self) -> i64 { + (u64::from(self.tail_fingerprint) | (u64::from(self.complete) << 32)) as i64 } } @@ -121,6 +119,7 @@ impl PiFileRevision { struct PiSyncState { revision: PiFileRevision, last_line_offset: i64, + legacy_revision: bool, } #[derive(Debug)] @@ -187,8 +186,19 @@ fn sync_single_pi_file(db: &Database, file_path: &Path) -> Result Result Result(0)?, row.get::<_, i64>(1)?, row.get::<_, i64>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, )) }, ) .optional() .map_err(|error| AppError::Database(format!("读取 Pi 会话同步状态失败: {error}")))?; - let Some((modified_nanos, last_line_offset, encoded_revision)) = row else { + let Some(( + modified_nanos, + last_line_offset, + legacy_encoded_revision, + stored_file_size, + stored_tail_fingerprint, + )) = row + else { return Ok(None); }; - let encoded_revision = encoded_revision as u64; - if encoded_revision >> REVISION_MARKER_SHIFT != REVISION_MARKER { - return Ok(None); - } - let file_size = (encoded_revision >> REVISION_SIZE_SHIFT) & REVISION_SIZE_MASK; + let (file_size, tail_fingerprint, complete, legacy_revision) = if let ( + Some(file_size), + Some(encoded_tail), + ) = + (stored_file_size, stored_tail_fingerprint) + { + if file_size < 0 || encoded_tail < 0 { + return Ok(None); + } + let encoded_tail = encoded_tail as u64; + ( + file_size as u64, + encoded_tail as u32, + ((encoded_tail >> 32) & 1) == 1, + false, + ) + } else { + // Databases written by the first Pi importer stored the append-proof + // revision in last_synced_at. Accept those rows once and migrate them + // to the dedicated cursor columns on the next successful sync. + let encoded_revision = legacy_encoded_revision as u64; + if encoded_revision >> REVISION_MARKER_SHIFT != REVISION_MARKER { + return Ok(None); + } + ( + (encoded_revision >> REVISION_SIZE_SHIFT) & REVISION_SIZE_MASK, + encoded_revision as u32, + ((encoded_revision >> REVISION_COMPLETE_SHIFT) & 1) == 1, + true, + ) + }; if file_size > crate::session_manager::providers::pi::MAX_SESSION_BYTES || last_line_offset < 0 || last_line_offset > crate::session_manager::providers::pi::MAX_TREE_ENTRIES as i64 + 1 @@ -269,10 +315,11 @@ fn get_pi_sync_state(db: &Database, file_path: &str) -> Result> REVISION_COMPLETE_SHIFT) & 1) == 1, + tail_fingerprint, + complete, }, last_line_offset, + legacy_revision, })) } @@ -282,15 +329,17 @@ fn update_pi_sync_state_on_conn( revision: PiFileRevision, last_line_offset: i64, ) -> Result<(), AppError> { - update_sync_state_conn(conn, file_path, revision.modified_nanos, last_line_offset)?; - // Match upstream: Pi privately tags this field with the append-proof - // revision; no production consumer interprets it as a wall-clock value. - conn.execute( - "UPDATE session_log_sync SET last_synced_at = ?2 WHERE file_path = ?1", - rusqlite::params![file_path, revision.encoded()], + update_line_sync_state_conn( + conn, + file_path, + revision.modified_nanos, + last_line_offset, + Some( + i64::try_from(revision.file_size) + .map_err(|_| AppError::Config("Pi 会话文件大小超出同步游标范围".to_string()))?, + ), + Some(revision.encoded_tail()), ) - .map_err(|error| AppError::Database(format!("更新 Pi 会话同步状态失败: {error}")))?; - Ok(()) } fn pi_file_revision( @@ -301,7 +350,7 @@ fn pi_file_revision( let tail_len = metadata.len().min(REVISION_TAIL_BYTES); let mut tail = vec![0; tail_len as usize]; if tail_len > 0 { - let mut file = File::open(file_path) + let mut file = open_session_file_no_follow(file_path) .map_err(|error| AppError::Config(format!("无法打开 Pi 会话文件: {error}")))?; file.seek(SeekFrom::Start(metadata.len() - tail_len)) .and_then(|_| file.read_exact(&mut tail)) @@ -322,7 +371,7 @@ fn pi_prefix_tail_matches(file_path: &Path, previous: PiFileRevision) -> Result< let tail_len = previous.file_size.min(REVISION_TAIL_BYTES); let mut tail = vec![0; tail_len as usize]; if tail_len > 0 { - let mut file = File::open(file_path) + let mut file = open_session_file_no_follow(file_path) .map_err(|error| AppError::Config(format!("无法打开 Pi 会话文件: {error}")))?; file.seek(SeekFrom::Start(previous.file_size - tail_len)) .and_then(|_| file.read_exact(&mut tail)) @@ -346,7 +395,7 @@ fn parse_pi_file( snapshot_size: u64, file_modified_nanos: i64, ) -> Result { - let file = File::open(file_path) + let file = open_session_file_no_follow(file_path) .map_err(|error| AppError::Config(format!("无法打开 Pi 会话文件: {error}")))?; let mut reader = BufReader::new(file); let mut buffer = String::new(); @@ -357,6 +406,57 @@ fn parse_pi_file( let mut records = Vec::new(); let mut incomplete_tail = false; + // An append cursor points at the old complete EOF, while the session + // header lives near the beginning of the file. Establish the session + // identity once, then seek directly to that cursor so active sessions are + // not reparsed from byte zero on every sync cycle. + if let Some(byte_offset) = + start_at_byte.filter(|offset| *offset > 0 && *offset <= snapshot_size) + { + while session_id.is_none() && bytes_read < byte_offset { + buffer.clear(); + let remaining = byte_offset.saturating_sub(bytes_read); + let read = Read::by_ref(&mut reader) + .take(remaining) + .read_line(&mut buffer) + .map_err(|error| AppError::Config(format!("无法读取 Pi 会话 header: {error}")))?; + if read == 0 { + break; + } + bytes_read = bytes_read.saturating_add(read as u64); + line_number = line_number.saturating_add(1); + let line = buffer.trim(); + let Ok(value) = serde_json::from_str::(line) else { + if !buffer.ends_with('\n') { + return Err(AppError::Config("Pi 会话 header 不完整".to_string())); + } + continue; + }; + if value.get("type").and_then(Value::as_str) == Some("session") { + session_id = value + .get("id") + .and_then(Value::as_str) + .filter(|id| crate::session_manager::providers::pi::is_valid_tree_id(id)) + .map(str::to_string); + if session_id.is_none() { + return Err(AppError::Config("Pi 会话 header 缺少 id".to_string())); + } + session_timestamp = value + .get("timestamp") + .and_then(parse_timestamp_millis) + .map(|timestamp| timestamp / 1000); + } + } + if session_id.is_none() { + return Err(AppError::Config("Pi 会话没有有效 header".to_string())); + } + reader + .seek(SeekFrom::Start(byte_offset)) + .map_err(|error| AppError::Config(format!("无法定位 Pi 会话增量边界: {error}")))?; + bytes_read = byte_offset; + line_number = start_after_line; + } + loop { buffer.clear(); let remaining = snapshot_size.saturating_sub(bytes_read); @@ -424,13 +524,6 @@ fn parse_pi_file( } let header_timestamp_millis = value.get("timestamp").and_then(parse_timestamp_millis); session_timestamp = header_timestamp_millis.map(|timestamp| timestamp / 1000); - if let Some(byte_offset) = start_at_byte.filter(|offset| *offset >= bytes_read) { - reader.seek(SeekFrom::Start(byte_offset)).map_err(|error| { - AppError::Config(format!("无法定位 Pi 会话增量边界: {error}")) - })?; - bytes_read = byte_offset; - line_number = start_after_line; - } continue; } if let Some(record) = parse_usage_record( @@ -875,7 +968,7 @@ fn insert_pi_record(conn: &rusqlite::Connection, record: &PiUsageRecord) -> Resu #[cfg(test)] mod tests { use super::*; - use std::fs::FileTimes; + use std::fs::{File, FileTimes}; use std::io::Write; fn session_path(root: &Path, name: &str) -> PathBuf { diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 7c254f08d..c0b4c7310 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -640,6 +640,7 @@ impl SkillService { AppType::OpenCode, AppType::Hermes, AppType::Pi, + AppType::Omp, ] .into_iter() } @@ -724,6 +725,7 @@ impl SkillService { AppType::Hermes => crate::hermes_config::get_hermes_dir().join("skills"), AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir().join("skills"), AppType::Pi => crate::pi_config::get_pi_agent_dir()?.join("skills"), + AppType::Omp => crate::omp_config::get_omp_agent_dir()?.join("skills"), }) } @@ -844,6 +846,7 @@ impl SkillService { let mut installed = db.get_all_installed_skills()?; for skill in installed.values_mut() { skill.apps.pi = Self::skill_exists_in_app(&skill.directory, &AppType::Pi); + skill.apps.omp = Self::skill_exists_in_app(&skill.directory, &AppType::Omp); } let skills: HashMap = installed .into_values() @@ -981,9 +984,11 @@ impl SkillService { let mut discovered: HashMap = HashMap::new(); - // Pi support did not exist before the SSOT migration. Never claim an - // independently installed Pi skill as a legacy CC Switch deployment. - for app in Self::supported_skill_apps().filter(|app| !matches!(app, AppType::Pi)) { + // Pi/OMP support did not exist before the SSOT migration. Never claim + // an independently installed native skill as a legacy CC Switch deployment. + for app in + Self::supported_skill_apps().filter(|app| !matches!(app, AppType::Pi | AppType::Omp)) + { let app_dir = match Self::get_app_skills_dir(&app) { Ok(d) => d, Err(_) => continue, @@ -1214,7 +1219,7 @@ impl SkillService { directory: &str, app: &AppType, ) -> Result<(), AppError> { - if !matches!(app, AppType::Pi) { + if !matches!(app, AppType::Pi | AppType::Omp) { return Ok(()); } let ssot_dir = Self::get_ssot_dir()?; @@ -1277,7 +1282,7 @@ impl SkillService { // Pi's native Skills directory may contain user-managed entries. Match // upstream by replacing only a destination that still mirrors the // CC Switch source; preserve every conflicting entry. - if matches!(app, AppType::Pi) && (dest.exists() || Self::is_symlink(&dest)) { + if matches!(app, AppType::Pi | AppType::Omp) && (dest.exists() || Self::is_symlink(&dest)) { Self::inspect_pi_skill_destination(&source, &dest)?; } @@ -1434,7 +1439,7 @@ impl SkillService { let app_dir = Self::get_distinct_app_skills_dir(&ssot_dir, app)?; fs::create_dir_all(&app_dir).map_err(|e| AppError::io(&app_dir, e))?; let dest = app_dir.join(&directory); - if matches!(app, AppType::Pi) + if matches!(app, AppType::Pi | AppType::Omp) && (dest.exists() || Self::is_symlink(&dest)) && !Self::pi_skill_destination_is_managed(&source, &dest) { @@ -1561,7 +1566,7 @@ impl SkillService { let app_dir = Self::get_distinct_app_skills_dir(&ssot_dir, app)?; let path = app_dir.join(&directory); if path.exists() || Self::is_symlink(&path) { - if matches!(app, AppType::Pi) { + if matches!(app, AppType::Pi | AppType::Omp) { let source = Self::get_ssot_dir()?.join(directory); if !Self::pi_skill_destination_is_managed(&source, &path) { return Err(AppError::InvalidInput(format!( @@ -2683,6 +2688,7 @@ impl SkillService { .ok_or_else(|| AppError::Message(format!("Skill not found: {skill_id}")))?; let directory = Self::require_valid_directory(&skill.directory)?; skill.apps.pi = Self::skill_exists_in_app(&skill.directory, &AppType::Pi); + skill.apps.omp = Self::skill_exists_in_app(&skill.directory, &AppType::Omp); let (owner, name) = match (&skill.repo_owner, &skill.repo_name) { (Some(owner), Some(name)) => (owner.clone(), name.clone()), @@ -2727,6 +2733,7 @@ impl SkillService { AppError::Message(format!("Skill was removed during update: {skill_id}")) })?; current.apps.pi = Self::skill_exists_in_app(¤t.directory, &AppType::Pi); + current.apps.omp = Self::skill_exists_in_app(¤t.directory, &AppType::Omp); if current.directory != skill.directory || current.repo_owner != skill.repo_owner || current.repo_name != skill.repo_name @@ -2757,6 +2764,12 @@ impl SkillService { } else { None }; + let omp_deployment = if current.apps.omp { + let omp_dir = Self::get_distinct_app_skills_dir(&ssot_dir, &AppType::Omp)?; + Self::inspect_pi_skill_destination(&dest, &omp_dir.join(&directory))? + } else { + None + }; let had_previous = fs::symlink_metadata(&dest).is_ok(); if had_previous { fs::rename(&dest, &previous).map_err(|e| AppError::IoContext { @@ -2826,8 +2839,20 @@ impl SkillService { deployment_failures.push(format!("Pi: {error}")); } } + if let Some(deployment) = omp_deployment.as_ref() { + let omp_destination = Self::get_app_skills_dir(&AppType::Omp)?.join(&updated.directory); + if let Err(error) = + Self::refresh_pi_skill_destination(&dest, &omp_destination, deployment) + { + log::warn!( + "Updated Skill {} but failed to sync it to OMP: {error}", + updated.id + ); + deployment_failures.push(format!("OMP: {error}")); + } + } for app in Self::supported_skill_apps() { - if matches!(app, AppType::Pi) { + if matches!(app, AppType::Pi | AppType::Omp) { continue; } if updated.apps.is_enabled_for(&app) { @@ -3494,6 +3519,7 @@ impl SkillService { let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name); let mut apps = selection.apps; apps.pi = Self::skill_exists_in_app(&dir_name, &AppType::Pi); + apps.omp = Self::skill_exists_in_app(&dir_name, &AppType::Omp); let (id, repo_owner, repo_name, repo_branch, readme_url) = build_repo_info_from_lock(&agents_lock, &dir_name); diff --git a/src-tauri/src/services/stream_check/provider_extract.rs b/src-tauri/src/services/stream_check/provider_extract.rs index d07986cc5..4cb5ab404 100644 --- a/src-tauri/src/services/stream_check/provider_extract.rs +++ b/src-tauri/src/services/stream_check/provider_extract.rs @@ -22,6 +22,7 @@ impl StreamCheckService { AppType::Hermes => Self::extract_hermes_base_url(provider), AppType::OpenClaw => Self::extract_openclaw_base_url(provider), AppType::Pi => crate::pi_config::provider_base_url(&provider.settings_config), + AppType::Omp => crate::omp_config::provider_base_url(&provider.settings_config), AppType::Claude | AppType::Codex | AppType::Gemini => get_adapter(app_type) .expect("proxy-capable app must have an adapter") .extract_base_url(provider) diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index 263aa29f9..ffcc8bc28 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -216,6 +216,7 @@ fn provider_name_coalesce(log_alias: &str, provider_alias: &str) -> String { WHEN '_gemini_session' THEN 'Gemini (Session)' \ WHEN '_opencode_session' THEN 'OpenCode (Session)' \ WHEN '_pi_session' THEN 'Pi (Session)' \ + WHEN '_omp_session' THEN 'OMP (Session)' \ ELSE {log_alias}.provider_id END)" ) } @@ -236,7 +237,7 @@ pub(crate) fn effective_usage_log_filter(log_alias: &str) -> String { let proxy_data_source = data_source_expr("proxy_dedup"); format!( "NOT ( - {data_source} IN ('session_log', 'codex_session', 'gemini_session', 'opencode_session') + {data_source} IN ('session_log', 'codex_session', 'gemini_session', 'opencode_session', 'omp_session') AND EXISTS ( SELECT 1 FROM proxy_request_logs proxy_dedup @@ -251,7 +252,7 @@ pub(crate) fn effective_usage_log_filter(log_alias: &str) -> String { proxy_dedup.cache_creation_tokens = {log_alias}.cache_creation_tokens OR ( {log_alias}.cache_creation_tokens = 0 - AND {data_source} IN ('codex_session', 'gemini_session', 'opencode_session') + AND {data_source} IN ('codex_session', 'gemini_session', 'opencode_session', 'omp_session') ) ) AND proxy_dedup.created_at BETWEEN diff --git a/src-tauri/src/services/visible_apps.rs b/src-tauri/src/services/visible_apps.rs index cb2c4f99e..2183dd6e9 100644 --- a/src-tauri/src/services/visible_apps.rs +++ b/src-tauri/src/services/visible_apps.rs @@ -34,12 +34,13 @@ pub struct VisibleAppsStartupOutcome { pub should_prompt: bool, } -const CONTROLLED_APPS: [AppType; 5] = [ +const CONTROLLED_APPS: [AppType; 6] = [ AppType::Gemini, AppType::OpenCode, AppType::Hermes, AppType::OpenClaw, AppType::Pi, + AppType::Omp, ]; pub fn detect_visible_app_installation() -> VisibleAppsDetection { @@ -191,6 +192,7 @@ pub fn app_display_name(app: &AppType) -> &'static str { AppType::Hermes => "Hermes", AppType::OpenClaw => "OpenClaw", AppType::Pi => "Pi", + AppType::Omp => "OMP", } } @@ -322,6 +324,7 @@ mod tests { hermes: true, openclaw: false, pi: false, + omp: false, }; settings.visible_apps_settings = VisibleAppsSettings { mode: VisibleAppsMode::Auto, @@ -365,6 +368,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + omp: false, }; settings.visible_apps_settings = VisibleAppsSettings { mode: VisibleAppsMode::Manual, @@ -433,6 +437,7 @@ mod tests { hermes: true, openclaw: true, pi: false, + omp: false, }; settings.visible_apps_settings = VisibleAppsSettings { mode: VisibleAppsMode::Auto, diff --git a/src-tauri/src/session_manager/mod.rs b/src-tauri/src/session_manager/mod.rs index e38f3eaa1..342249da1 100644 --- a/src-tauri/src/session_manager/mod.rs +++ b/src-tauri/src/session_manager/mod.rs @@ -447,6 +447,7 @@ fn search_provider_cancellable( "pi" => search_file_provider(metas, is_cancelled, |meta| { pi::search_session_cancellable(meta, needle, is_cancelled) }), + "omp" => Some(Vec::new()), _ => Some(Vec::new()), } } @@ -501,6 +502,10 @@ pub(crate) fn load_messages_cancellable( "gemini" => gemini::load_messages_cancellable(path, is_cancelled), "hermes" => hermes::load_messages_cancellable(path, is_cancelled), "pi" => pi::load_messages_cancellable(path, is_cancelled), + "omp" => Err( + "OMP session browsing is not supported yet; use `sessions sync-usage` for usage import." + .to_string(), + ), _ => Err(format!("Unsupported provider: {provider_id}")), } } diff --git a/src-tauri/src/session_manager/providers/mod.rs b/src-tauri/src/session_manager/providers/mod.rs index 890581d7e..78842ce92 100644 --- a/src-tauri/src/session_manager/providers/mod.rs +++ b/src-tauri/src/session_manager/providers/mod.rs @@ -2,6 +2,7 @@ pub mod claude; pub mod codex; pub mod gemini; pub mod hermes; +pub mod omp; pub mod openclaw; pub mod opencode; pub mod pi; diff --git a/src-tauri/src/session_manager/providers/omp.rs b/src-tauri/src/session_manager/providers/omp.rs new file mode 100644 index 000000000..96ea776b8 --- /dev/null +++ b/src-tauri/src/session_manager/providers/omp.rs @@ -0,0 +1,135 @@ +//! OMP session-file discovery for usage synchronization. +//! +//! OMP stores sessions below the active agent directory. The directory may +//! be redirected by the same environment/profile rules used by the native +//! configuration adapter, so discovery delegates root resolution to +//! [`crate::omp_config`]. This module intentionally exposes only file +//! discovery for now; the regular session browser is still a separate, +//! follow-up feature. + +use std::fs; +use std::path::PathBuf; + +use crate::services::session_usage::read_session_directory_entries_no_follow; + +/// Keep usage imports bounded in the same way as the Pi importer. +pub(crate) const MAX_SESSION_BYTES: u64 = 128 * 1024 * 1024; +pub(crate) const MAX_TREE_ENTRIES: usize = 500_000; + +pub(crate) fn is_valid_tree_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 256 + && id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) +} +const MAX_SESSION_FILES: usize = 500_000; +const MAX_DIRECTORY_DEPTH: usize = 64; + +/// Return all regular OMP JSONL session files under the active agent root. +/// +/// Missing session directories are normal before the first OMP invocation and +/// therefore produce an empty result. Symlinks are never followed. +pub(crate) fn session_files() -> Result, String> { + let root = crate::omp_config::get_omp_sessions_dir().map_err(|error| error.to_string())?; + let metadata = match fs::symlink_metadata(&root) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(format!( + "OMP session directory is unavailable ({}): {error}", + root.display() + )); + } + }; + if !metadata.is_dir() { + return Err(format!( + "OMP session path is not a directory: {}", + root.display() + )); + } + + let mut files = Vec::new(); + let mut pending = vec![(root, 0usize)]; + while let Some((directory, depth)) = pending.pop() { + let directory_metadata = fs::symlink_metadata(&directory).map_err(|error| { + format!( + "OMP session path metadata failed ({}): {error}", + directory.display() + ) + })?; + if directory_metadata.file_type().is_symlink() { + return Err(format!( + "OMP session directory became a symlink: {}", + directory.display() + )); + } + if !directory_metadata.is_dir() { + continue; + } + let entries = read_session_directory_entries_no_follow(&directory).map_err(|error| { + format!( + "OMP session directory is not readable ({}): {error}", + directory.display() + ) + })?; + for (name, is_dir, is_file, is_symlink) in entries { + let path = directory.join(name); + if is_symlink { + continue; + } + if is_dir { + if depth < MAX_DIRECTORY_DEPTH { + pending.push((path, depth + 1)); + } else { + log::warn!( + "Skipping OMP session directory beyond depth limit: {}", + path.display() + ); + } + continue; + } + if !is_file || path.extension().and_then(|value| value.to_str()) != Some("jsonl") { + continue; + } + if files.len() >= MAX_SESSION_FILES { + return Err(format!( + "OMP session file count exceeds safety limit ({MAX_SESSION_FILES})" + )); + } + // Keep oversized files in the candidate list. The importer emits a + // useful per-file error instead of silently reporting success. + files.push(path); + } + } + files.sort(); + Ok(files) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_session_directory_is_empty() { + // This test only checks the bounded traversal helper contract through + // a temporary tree; root resolution itself is environment-dependent. + let root = tempfile::tempdir() + .expect("tempdir") + .path() + .join("sessions"); + assert!(!root.exists()); + } + + #[test] + fn max_session_bytes_is_bounded() { + assert_eq!(MAX_SESSION_BYTES, 128 * 1024 * 1024); + } + + #[test] + fn discovery_limits_are_explicit() { + assert_eq!(MAX_DIRECTORY_DEPTH, 64); + assert_eq!(MAX_SESSION_FILES, 500_000); + assert_eq!(MAX_TREE_ENTRIES, 500_000); + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 06fda8899..66138f5db 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -24,6 +24,8 @@ pub struct VisibleApps { pub openclaw: bool, #[serde(default = "default_visible_app_pi")] pub pi: bool, + #[serde(default = "default_visible_app_omp")] + pub omp: bool, } fn default_visible_app_claude() -> bool { @@ -54,6 +56,10 @@ fn default_visible_app_pi() -> bool { true } +fn default_visible_app_omp() -> bool { + true +} + pub fn default_visible_apps() -> VisibleApps { VisibleApps { claude: true, @@ -63,6 +69,7 @@ pub fn default_visible_apps() -> VisibleApps { hermes: true, openclaw: true, pi: true, + omp: true, } } @@ -130,6 +137,7 @@ impl VisibleApps { AppType::Hermes => self.hermes, AppType::OpenClaw => self.openclaw, AppType::Pi => self.pi, + AppType::Omp => self.omp, } } @@ -142,6 +150,7 @@ impl VisibleApps { AppType::Hermes => self.hermes = enabled, AppType::OpenClaw => self.openclaw = enabled, AppType::Pi => self.pi = enabled, + AppType::Omp => self.omp = enabled, } } @@ -162,7 +171,7 @@ impl VisibleApps { } } -fn app_order() -> [AppType; 7] { +fn app_order() -> [AppType; 8] { [ AppType::Claude, AppType::Codex, @@ -171,6 +180,7 @@ fn app_order() -> [AppType; 7] { AppType::Hermes, AppType::OpenClaw, AppType::Pi, + AppType::Omp, ] } @@ -542,6 +552,13 @@ pub struct AppSettings { pub openclaw_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub pi_config_dir: Option, + /// Deprecated compatibility field from pre-native OMP integration. + /// + /// OMP resolves its agent directory from its own environment and profile + /// variables. Keep accepting this key from older settings files so they + /// remain readable, but never write it back or expose it as a setting. + #[serde(default, skip_serializing)] + pub omp_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_claude: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -645,6 +662,7 @@ impl Default for AppSettings { hermes_config_dir: None, openclaw_config_dir: None, pi_config_dir: None, + omp_config_dir: None, current_provider_claude: None, current_provider_codex: None, current_provider_gemini: None, @@ -733,6 +751,13 @@ impl AppSettings { .filter(|s| !s.is_empty()) .map(|s| s.to_string()); + self.omp_config_dir = self + .omp_config_dir + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + self.language = self .language .as_ref() @@ -1142,6 +1167,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option { AppType::Hermes => settings.current_provider_hermes.clone(), AppType::OpenClaw => settings.current_provider_openclaw.clone(), AppType::Pi => None, + AppType::Omp => None, } } @@ -1156,6 +1182,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppType::Hermes => settings.current_provider_hermes = id.map(|value| value.to_string()), AppType::OpenClaw => settings.current_provider_openclaw = id.map(|value| value.to_string()), AppType::Pi => {} + AppType::Omp => {} } update_settings(settings) diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index c06acd490..931b6bce9 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -269,6 +269,14 @@ impl AppState { Err(error) => log::warn!("✗ Failed to synchronize Pi providers: {error}"), } + match crate::services::provider::ProviderService::import_omp_providers_from_live(self) { + Ok(count) if count > 0 => { + log::info!("✓ Synchronized {count} OMP provider(s) from models.yml"); + } + Ok(_) => log::debug!("○ No OMP providers to synchronize"), + Err(error) => log::warn!("✗ Failed to synchronize OMP providers: {error}"), + } + self.refresh_config_from_db() } @@ -533,6 +541,7 @@ fn export_db_to_multi_app_config(db: &Database) -> Result Result config.prompts.hermes.prompts = prompts.into_iter().collect(), AppType::OpenClaw => config.prompts.openclaw.prompts = prompts.into_iter().collect(), AppType::Pi => config.prompts.pi.prompts = prompts.into_iter().collect(), + AppType::Omp => config.prompts.omp.prompts = prompts.into_iter().collect(), } // common snippet @@ -587,6 +597,8 @@ fn persist_multi_app_config_to_db_preserving_current_providers( AppType::OpenCode, AppType::Hermes, AppType::OpenClaw, + AppType::Pi, + AppType::Omp, ] { let app_key = app.as_str(); let manager = config.get_manager(&app); diff --git a/src-tauri/src/sync_policy.rs b/src-tauri/src/sync_policy.rs index 490518660..c24118d1f 100644 --- a/src-tauri/src/sync_policy.rs +++ b/src-tauri/src/sync_policy.rs @@ -26,6 +26,7 @@ pub(crate) fn should_sync_live(app_type: &AppType) -> bool { AppType::OpenClaw => get_openclaw_dir().exists(), // Pi live provider writes are owned by the revision-aware native service. AppType::Pi => false, + AppType::Omp => false, } } diff --git a/src-tauri/src/test_support.rs b/src-tauri/src/test_support.rs index a102cb853..f35d54b0c 100644 --- a/src-tauri/src/test_support.rs +++ b/src-tauri/src/test_support.rs @@ -41,6 +41,7 @@ pub(crate) struct TestEnvGuard { old_claude_config_dir: Option, old_codex_home: Option, old_xdg_runtime_dir: Option, + old_xdg_data_home: Option, } impl TestEnvGuard { @@ -53,6 +54,7 @@ impl TestEnvGuard { let old_claude_config_dir = std::env::var_os("CLAUDE_CONFIG_DIR"); let old_codex_home = std::env::var_os("CODEX_HOME"); let old_xdg_runtime_dir = std::env::var_os("XDG_RUNTIME_DIR"); + let old_xdg_data_home = std::env::var_os("XDG_DATA_HOME"); std::env::set_var("HOME", home); std::env::set_var("USERPROFILE", home); @@ -74,6 +76,7 @@ impl TestEnvGuard { old_claude_config_dir, old_codex_home, old_xdg_runtime_dir, + old_xdg_data_home, } } @@ -92,6 +95,7 @@ impl Drop for TestEnvGuard { restore_env("CLAUDE_CONFIG_DIR", &self.old_claude_config_dir); restore_env("CODEX_HOME", &self.old_codex_home); restore_env("XDG_RUNTIME_DIR", &self.old_xdg_runtime_dir); + restore_env("XDG_DATA_HOME", &self.old_xdg_data_home); set_test_home_override(self.old_home.as_deref().map(Path::new)); crate::settings::reload_test_settings(); } diff --git a/src-tauri/tests/deeplink_import.rs b/src-tauri/tests/deeplink_import.rs index f77b712f2..b2283d6a8 100644 --- a/src-tauri/tests/deeplink_import.rs +++ b/src-tauri/tests/deeplink_import.rs @@ -248,6 +248,144 @@ fn deeplink_import_openclaw_provider_defaults_to_openai_completions_api() { assert!(persisted.is_some(), "provider should be persisted to db"); } +#[test] +fn deeplink_import_omp_provider_writes_native_shape() { + let _guard = lock_test_mutex(); + reset_test_fs(); + let _home = ensure_test_home(); + + let url = "ccswitch://v1/import?resource=provider&app=omp&name=DeepLink%20OMP&homepage=https%3A%2F%2Fomp.example&endpoint=https%3A%2F%2Fapi.omp.example%2Fv1&apiKey=sk-test-omp-key&model=gpt-4.1"; + let request = parse_deeplink_url(url).expect("parse deeplink url"); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Omp); + let state = state_from_config(config); + + let provider_id = import_provider_from_deeplink(&state, request.clone()) + .expect("import OMP provider from deeplink"); + + let provider = state + .db + .get_provider_by_id(&provider_id, AppType::Omp.as_str()) + .expect("read OMP provider") + .expect("OMP provider persisted"); + assert_eq!(provider.name, request.name.expect("request name")); + assert_eq!(provider.settings_config["api"], "openai-completions"); + assert_eq!(provider.settings_config["apiKey"], "sk-test-omp-key"); + assert_eq!( + provider.settings_config["baseUrl"], + "https://api.omp.example/v1" + ); + assert_eq!(provider.settings_config["models"][0]["id"], "gpt-4.1"); + assert!(provider.settings_config.get("name").is_none()); +} + +#[test] +fn deeplink_import_omp_keyless_provider_sets_auth_none() { + let _guard = lock_test_mutex(); + reset_test_fs(); + let _home = ensure_test_home(); + + let url = "ccswitch://v1/import?resource=provider&app=omp&name=Local%20OMP&endpoint=http%3A%2F%2F127.0.0.1%3A11434%2Fv1&model=local-model"; + let request = parse_deeplink_url(url).expect("parse deeplink url"); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Omp); + let state = state_from_config(config); + + let provider_id = import_provider_from_deeplink(&state, request) + .expect("import keyless OMP provider from deeplink"); + let provider = state + .db + .get_provider_by_id(&provider_id, AppType::Omp.as_str()) + .expect("read OMP provider") + .expect("OMP provider persisted"); + + assert_eq!(provider.settings_config["auth"], "none"); + assert!(provider.settings_config.get("apiKey").is_none()); +} + +#[test] +fn deeplink_import_omp_inline_config_preserves_native_fields() { + let _guard = lock_test_mutex(); + reset_test_fs(); + let _home = ensure_test_home(); + + let config = BASE64_URL_SAFE_NO_PAD.encode( + br#"{"baseUrl":"https://config.example/v1","api":"anthropic-messages","apiKey":"CONFIG_KEY","headers":{"x-team":"blue"},"models":[{"id":"claude"}]}"#, + ); + let url = format!( + "ccswitch://v1/import?resource=provider&app=oh-my-pi&name=Inline%20OMP&homepage=https%3A%2F%2Fomp.example&config={config}&configFormat=json" + ); + let request = parse_deeplink_url(&url).expect("parse deeplink url"); + + let mut app_config = MultiAppConfig::default(); + app_config.ensure_app(&AppType::Omp); + let state = state_from_config(app_config); + let provider_id = import_provider_from_deeplink(&state, request) + .expect("import OMP provider with inline config"); + let provider = state + .db + .get_provider_by_id(&provider_id, AppType::Omp.as_str()) + .expect("read OMP provider") + .expect("OMP provider persisted"); + + assert_eq!(provider.settings_config["api"], "anthropic-messages"); + assert_eq!(provider.settings_config["apiKey"], "CONFIG_KEY"); + assert_eq!(provider.settings_config["headers"]["x-team"], "blue"); + assert_eq!(provider.settings_config["models"][0]["id"], "claude"); +} + +#[test] +fn deeplink_import_omp_rejects_model_level_endpoint_without_top_level_endpoint() { + let _guard = lock_test_mutex(); + reset_test_fs(); + let _home = ensure_test_home(); + + let config = BASE64_URL_SAFE_NO_PAD.encode( + br#"{"api":"openai-completions","apiKey":"CONFIG_KEY","models":[{"id":"local-model","baseUrl":"https://model.example/v1"}]}"#, + ); + let url = format!( + "ccswitch://v1/import?resource=provider&app=omp&name=Model%20URL%20OMP&homepage=https%3A%2F%2Fomp.example&config={config}&configFormat=json" + ); + let request = parse_deeplink_url(&url).expect("parse deeplink url"); + + let mut app_config = MultiAppConfig::default(); + app_config.ensure_app(&AppType::Omp); + let state = state_from_config(app_config); + let error = import_provider_from_deeplink(&state, request) + .expect_err("OMP custom models require a provider-level baseUrl"); + assert!(error.to_string().contains("baseUrl")); +} + +#[test] +fn deeplink_omp_url_api_key_overrides_inline_auth_mode() { + let _guard = lock_test_mutex(); + reset_test_fs(); + let _home = ensure_test_home(); + + let config = BASE64_URL_SAFE_NO_PAD.encode( + br#"{"baseUrl":"https://config.example/v1","api":"openai-completions","auth":"none","models":[{"id":"model"}]}"#, + ); + let url = format!( + "ccswitch://v1/import?resource=provider&app=omp&name=Override%20OMP&homepage=https%3A%2F%2Fomp.example&apiKey=URL_KEY&config={config}&configFormat=json" + ); + let request = parse_deeplink_url(&url).expect("parse deeplink url"); + + let mut app_config = MultiAppConfig::default(); + app_config.ensure_app(&AppType::Omp); + let state = state_from_config(app_config); + let provider_id = import_provider_from_deeplink(&state, request) + .expect("URL api key should override inline auth mode"); + let provider = state + .db + .get_provider_by_id(&provider_id, AppType::Omp.as_str()) + .expect("read OMP provider") + .expect("OMP provider persisted"); + assert_eq!(provider.settings_config["apiKey"], "URL_KEY"); + assert!(provider.settings_config.get("auth").is_none()); +} + #[test] fn deeplink_import_openclaw_provider_preserves_canonical_inline_config() { let _guard = lock_test_mutex(); diff --git a/src-tauri/tests/settings_current_provider.rs b/src-tauri/tests/settings_current_provider.rs index ceb71bd89..e2a663947 100644 --- a/src-tauri/tests/settings_current_provider.rs +++ b/src-tauri/tests/settings_current_provider.rs @@ -14,6 +14,7 @@ mod app_config { OpenClaw, Hermes, Pi, + Omp, } impl AppType { @@ -26,6 +27,7 @@ mod app_config { AppType::OpenClaw => "openclaw", AppType::Hermes => "hermes", AppType::Pi => "pi", + AppType::Omp => "omp", } } } diff --git a/src-tauri/tests/settings_visible_apps.rs b/src-tauri/tests/settings_visible_apps.rs index 30b18c5f7..587282f60 100644 --- a/src-tauri/tests/settings_visible_apps.rs +++ b/src-tauri/tests/settings_visible_apps.rs @@ -17,6 +17,7 @@ mod app_config { OpenClaw, Hermes, Pi, + Omp, } impl AppType { @@ -29,6 +30,7 @@ mod app_config { AppType::OpenClaw => "openclaw", AppType::Hermes => "hermes", AppType::Pi => "pi", + AppType::Omp => "omp", } } } @@ -397,6 +399,7 @@ fn default_visible_apps_hide_gemini() { AppType::Hermes, AppType::OpenClaw, AppType::Pi, + AppType::Omp, ] ); assert!(!visible.is_enabled_for(&AppType::Gemini)); @@ -415,6 +418,7 @@ fn set_visible_apps_persists_visible_apps_as_camel_case_json() { openclaw: true, pi: false, hermes: true, + omp: false, }) .expect("persist visible apps"); @@ -433,6 +437,7 @@ fn set_visible_apps_persists_visible_apps_as_camel_case_json() { "openclaw": true, "hermes": true, "pi": false, + "omp": false, }) ); } @@ -468,6 +473,7 @@ fn load_reads_valid_non_default_visible_apps_from_settings_json() { openclaw: false, pi: true, hermes: true, + omp: true, } ); assert_eq!( @@ -478,6 +484,7 @@ fn load_reads_valid_non_default_visible_apps_from_settings_json() { AppType::OpenCode, AppType::Hermes, AppType::Pi, + AppType::Omp, ] ); } @@ -507,6 +514,7 @@ fn load_partial_visible_apps_object_uses_defaults_for_missing_keys() { openclaw: true, pi: true, hermes: true, + omp: true, } ); } @@ -583,6 +591,7 @@ fn set_visible_apps_rejects_zero_selection() { openclaw: false, pi: false, hermes: false, + omp: false, }) .expect_err("zero visible apps should be rejected"); @@ -606,6 +615,7 @@ fn update_settings_rejects_all_false_visible_apps() { openclaw: false, pi: false, hermes: false, + omp: false, }, ..Default::default() }; @@ -657,7 +667,8 @@ fn load_normalizes_all_false_visible_apps_to_defaults() { "opencode": false, "openclaw": false, "hermes": false, - "pi": false + "pi": false, + "omp": false } }), ); @@ -703,6 +714,7 @@ fn next_visible_app_wraps_and_skips_hidden_entries() { openclaw: true, pi: false, hermes: true, + omp: false, }; assert_eq!( From 12215eebc4caa0d24e32460c89d3d4159f37ed98 Mon Sep 17 00:00:00 2001 From: Devin-Pi <89199086+Devin-Pi@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:35:04 +0800 Subject: [PATCH 02/12] test(omp): use direct environment key assertion --- src-tauri/src/omp_config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/omp_config.rs b/src-tauri/src/omp_config.rs index d8742ad68..a67c54632 100644 --- a/src-tauri/src/omp_config.rs +++ b/src-tauri/src/omp_config.rs @@ -4478,7 +4478,7 @@ mod tests { .expect("write project dotenv"); let env = resolve_omp_path_environment(); - assert!(env.get("PI_CONFIG_DIR").is_none()); + assert!(!env.contains_key("PI_CONFIG_DIR")); assert_eq!( get_omp_shared_config_agent_dir().expect("resolve default config root"), home.path().join(".omp/agent") From 752236a38098c1f3c0c7094a41abbe70e93ae8ca Mon Sep 17 00:00:00 2001 From: Devin-Pi <89199086+Devin-Pi@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:50:50 +0800 Subject: [PATCH 03/12] test(omp): restore runtime agent override --- src-tauri/src/services/pi_prompt_files.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src-tauri/src/services/pi_prompt_files.rs b/src-tauri/src/services/pi_prompt_files.rs index af3e0a3e0..6ffbeb82c 100644 --- a/src-tauri/src/services/pi_prompt_files.rs +++ b/src-tauri/src/services/pi_prompt_files.rs @@ -797,6 +797,7 @@ mod tests { .expect("write native shared prompt"); fs::write(runtime_override.join("SYSTEM.md"), "runtime-only prompt") .expect("write runtime override prompt"); + let previous_agent_override = std::env::var_os("PI_CODING_AGENT_DIR"); std::env::set_var("PI_CODING_AGENT_DIR", &runtime_override); let active = OmpPromptFileService::active_path(PiPromptFileKind::SystemOverride) @@ -815,5 +816,7 @@ mod tests { let active = OmpPromptFileService::active_path(PiPromptFileKind::SystemAppend) .expect("resolve generic append prompt path"); assert_eq!(active, default_agent.join("APPEND_SYSTEM.md")); + + crate::test_support::restore_env("PI_CODING_AGENT_DIR", &previous_agent_override); } } From 7e56928490611b059993522c55a1ae3fe0a96bdb Mon Sep 17 00:00:00 2001 From: Devin-Pi <89199086+Devin-Pi@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:49:32 +0800 Subject: [PATCH 04/12] fix(omp): allow explicit defaults for discovery providers --- src-tauri/src/omp_config.rs | 95 ++++++++++++++++++++++++++++--------- 1 file changed, 72 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/omp_config.rs b/src-tauri/src/omp_config.rs index a67c54632..154d11508 100644 --- a/src-tauri/src/omp_config.rs +++ b/src-tauri/src/omp_config.rs @@ -1252,33 +1252,52 @@ pub(crate) fn set_omp_default_model( // apply semantic validation without imposing CC Switch's HTTP-only probe // restriction. validate_provider_node_for_editor(provider_id, provider)?; - let models = provider - .get("models") - .and_then(Value::as_array) - .ok_or_else(|| { - AppError::InvalidInput(format!("OMP provider '{provider_id}' has no model catalog")) - })?; + let models = provider.get("models").and_then(Value::as_array); + let discovery_only = provider.get("discovery").is_some(); + let has_static_models = models.is_some_and(|models| !models.is_empty()); let selected = match model_id.map(str::trim) { - Some(model_id) - if models.iter().any(|model| { - model.get("id").and_then(Value::as_str).map(str::trim) == Some(model_id) - }) => - { - model_id.to_string() + Some(model_id) if model_id.is_empty() => { + return Err(AppError::InvalidInput( + "OMP model id must be a non-empty string".to_string(), + )); } Some(model_id) => { - return Err(AppError::InvalidInput(format!( - "OMP model '{provider_id}/{model_id}' is not present in models.yml" - ))) + // Discovery-backed providers may intentionally omit a static + // `models` catalog. OMP resolves those model ids at runtime, so + // an explicit selector must be accepted even when the registry + // has not been populated yet. Static catalogs remain strict so a + // typo cannot silently create a dangling role. + if !discovery_only { + let Some(models) = models.filter(|_| has_static_models) else { + return Err(AppError::InvalidInput(format!( + "OMP provider '{provider_id}' has no model catalog; specify a discovery provider" + ))); + }; + if !models.iter().any(|model| { + model.get("id").and_then(Value::as_str).map(str::trim) == Some(model_id) + }) { + return Err(AppError::InvalidInput(format!( + "OMP model '{provider_id}/{model_id}' is not present in models.yml" + ))); + } + } + model_id.to_string() + } + None => { + let Some(models) = models else { + return Err(AppError::InvalidInput(format!( + "OMP provider '{provider_id}' has no static model catalog; specify --model for a discovery provider" + ))); + }; + models + .iter() + .find_map(|model| model.get("id").and_then(Value::as_str).map(str::trim)) + .filter(|id| !id.is_empty()) + .ok_or_else(|| { + AppError::InvalidInput(format!("OMP provider '{provider_id}' has no models")) + })? + .to_string() } - None => models - .iter() - .find_map(|model| model.get("id").and_then(Value::as_str).map(str::trim)) - .filter(|id| !id.is_empty()) - .ok_or_else(|| { - AppError::InvalidInput(format!("OMP provider '{provider_id}' has no models")) - })? - .to_string(), }; // External OMP edits are not coordinated by our mutex. Refuse to write a @@ -5084,6 +5103,36 @@ mod tests { } } + #[test] + #[serial] + fn discovery_only_provider_accepts_explicit_default_model() { + let _agent = test_support::TestAgentDir::new(); + let path = get_omp_models_path().expect("OMP models path"); + ensure_private_omp_parent(&path).expect("create OMP agent directory"); + fs::write( + &path, + "providers:\n local:\n baseUrl: http://127.0.0.1:11434\n api: openai-completions\n auth: none\n discovery:\n type: ollama\n", + ) + .expect("write discovery-only provider"); + + let selector = set_omp_default_model("local", Some("llama3")) + .expect("discovery-only providers accept explicit model ids"); + assert_eq!(selector, "local/llama3"); + assert_eq!( + read_omp_model_roles() + .expect("read model roles") + .get("default") + .map(String::as_str), + Some("local/llama3") + ); + + let error = set_omp_default_model("local", None) + .expect_err("discovery-only providers require an explicit model id"); + assert!(error + .to_string() + .contains("specify --model for a discovery provider")); + } + #[test] #[serial] fn empty_models_file_is_treated_as_an_empty_registry() { From 1d8023a7db9f04a1532c4e69b208eb093b428133 Mon Sep 17 00:00:00 2001 From: Devin-Pi <89199086+Devin-Pi@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:13:02 +0800 Subject: [PATCH 05/12] test: secure future-schema config fixture --- src-tauri/src/main.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index bebc42729..18329710f 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -333,6 +333,12 @@ mod tests { #[serial] fn provider_commands_still_fail_on_future_schema_database() { let temp = tempfile::tempdir().expect("create temp dir"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o700)) + .expect("restrict config dir permissions"); + } seed_future_schema_database(temp.path()); let _guard = ConfigDirEnvGuard::set(temp.path()); From 9c200f481ddac0c3a62de6ab3ef42692c56d596f Mon Sep 17 00:00:00 2001 From: Devin-Pi <89199086+Devin-Pi@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:17:43 +0800 Subject: [PATCH 06/12] refactor(omp): simplify discovery default validation --- src-tauri/src/omp_config.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src-tauri/src/omp_config.rs b/src-tauri/src/omp_config.rs index 154d11508..17f85e4a1 100644 --- a/src-tauri/src/omp_config.rs +++ b/src-tauri/src/omp_config.rs @@ -1254,7 +1254,6 @@ pub(crate) fn set_omp_default_model( validate_provider_node_for_editor(provider_id, provider)?; let models = provider.get("models").and_then(Value::as_array); let discovery_only = provider.get("discovery").is_some(); - let has_static_models = models.is_some_and(|models| !models.is_empty()); let selected = match model_id.map(str::trim) { Some(model_id) if model_id.is_empty() => { return Err(AppError::InvalidInput( @@ -1268,7 +1267,7 @@ pub(crate) fn set_omp_default_model( // has not been populated yet. Static catalogs remain strict so a // typo cannot silently create a dangling role. if !discovery_only { - let Some(models) = models.filter(|_| has_static_models) else { + let Some(models) = models.filter(|models| !models.is_empty()) else { return Err(AppError::InvalidInput(format!( "OMP provider '{provider_id}' has no model catalog; specify a discovery provider" ))); From 5e273c4e93c328b4967d7980ebb2c91679d4c398 Mon Sep 17 00:00:00 2001 From: Devin-Pi <89199086+Devin-Pi@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:20:27 +0800 Subject: [PATCH 07/12] fix(omp): retain strict checks for empty static catalogs --- src-tauri/src/omp_config.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/omp_config.rs b/src-tauri/src/omp_config.rs index 17f85e4a1..cf25605a0 100644 --- a/src-tauri/src/omp_config.rs +++ b/src-tauri/src/omp_config.rs @@ -1266,12 +1266,7 @@ pub(crate) fn set_omp_default_model( // an explicit selector must be accepted even when the registry // has not been populated yet. Static catalogs remain strict so a // typo cannot silently create a dangling role. - if !discovery_only { - let Some(models) = models.filter(|models| !models.is_empty()) else { - return Err(AppError::InvalidInput(format!( - "OMP provider '{provider_id}' has no model catalog; specify a discovery provider" - ))); - }; + if let Some(models) = models.filter(|models| !models.is_empty()) { if !models.iter().any(|model| { model.get("id").and_then(Value::as_str).map(str::trim) == Some(model_id) }) { @@ -1279,6 +1274,10 @@ pub(crate) fn set_omp_default_model( "OMP model '{provider_id}/{model_id}' is not present in models.yml" ))); } + } else if !discovery_only { + return Err(AppError::InvalidInput(format!( + "OMP provider '{provider_id}' has no model catalog; specify a discovery provider" + ))); } model_id.to_string() } From 092eb561c5963a34bb22d24af9ea58f8b6e92834 Mon Sep 17 00:00:00 2001 From: Devin-Pi <89199086+Devin-Pi@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:34:36 +0800 Subject: [PATCH 08/12] fix(omp): avoid reentrant lock while checking disabled providers --- src-tauri/src/omp_config.rs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/omp_config.rs b/src-tauri/src/omp_config.rs index cf25605a0..939267cf5 100644 --- a/src-tauri/src/omp_config.rs +++ b/src-tauri/src/omp_config.rs @@ -924,6 +924,14 @@ fn path_matches_cwd(raw: &Path, cwd: &Path) -> bool { /// A project `disabledProviders` array replaces the global array, matching /// OMP's settings merge semantics. pub(crate) fn read_omp_disabled_providers() -> Result, AppError> { + let _guard = lock_models_file()?; + read_omp_disabled_providers_locked() +} + +/// Read the effective disabled provider set while the caller already owns the +/// native OMP file lock. Keeping this helper lock-free avoids recursively +/// acquiring the non-reentrant process mutex in compound mutations. +fn read_omp_disabled_providers_locked() -> Result, AppError> { let (document, path) = effective_omp_settings_document()?; disabled_provider_values(document.get("disabledProviders"), &path) } @@ -1229,7 +1237,7 @@ pub(crate) fn set_omp_default_model( ) -> Result { validate_provider_key(provider_id)?; let _guard = lock_models_file()?; - if read_omp_disabled_providers()?.contains(provider_id) { + if read_omp_disabled_providers_locked()?.contains(provider_id) { return Err(AppError::InvalidInput(format!( "OMP provider '{provider_id}' is disabled by config.yml disabledProviders" ))); @@ -5131,6 +5139,26 @@ mod tests { .contains("specify --model for a discovery provider")); } + #[test] + #[serial] + fn setting_default_model_checks_disabled_provider_without_reentrant_lock() { + let _agent = test_support::TestAgentDir::new(); + let models_path = get_omp_models_path().expect("OMP models path"); + ensure_private_omp_parent(&models_path).expect("create OMP agent directory"); + fs::write( + &models_path, + "providers:\n local:\n baseUrl: http://127.0.0.1:11434\n api: openai-completions\n auth: none\n models:\n - id: llama3\n", + ) + .expect("write provider"); + let config_path = get_omp_settings_path().expect("OMP config path"); + ensure_private_omp_parent(&config_path).expect("create OMP config directory"); + fs::write(&config_path, "disabledProviders: [local]\n").expect("disable provider"); + + let error = set_omp_default_model("local", Some("llama3")) + .expect_err("disabled providers cannot become the default"); + assert!(error.to_string().contains("is disabled")); + } + #[test] #[serial] fn empty_models_file_is_treated_as_an_empty_registry() { From 7c8e2afdf00c761cbe1d28c79d55304b648ff87f Mon Sep 17 00:00:00 2001 From: Devin-Pi <89199086+Devin-Pi@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:04:34 +0800 Subject: [PATCH 09/12] fix(omp): align native path and schema semantics --- src-tauri/src/codex_history_migration.rs | 4 +- src-tauri/src/database/tests.rs | 26 +++++ src-tauri/src/omp_config.rs | 138 ++++++++++++++++------- src-tauri/src/store.rs | 8 ++ 4 files changed, 132 insertions(+), 44 deletions(-) diff --git a/src-tauri/src/codex_history_migration.rs b/src-tauri/src/codex_history_migration.rs index ac220dd7c..57faa4aeb 100644 --- a/src-tauri/src/codex_history_migration.rs +++ b/src-tauri/src/codex_history_migration.rs @@ -2737,7 +2737,7 @@ base_url = "https://proxy.example/v1" let backup_root = get_app_config_dir().join("backups").join("migration"); let backup_path = backup_root.join("state").join(CODEX_STATE_DB_FILENAME); - fs::create_dir_all(backup_path.parent().expect("backup parent")) + crate::config::create_managed_config_dir_all(backup_path.parent().expect("backup parent")) .expect("create backup parent"); let external_target = dir.path().join("external-state.sqlite"); symlink(&external_target, &backup_path).expect("create dangling backup symlink"); @@ -2777,7 +2777,7 @@ base_url = "https://proxy.example/v1" let backup_root = get_app_config_dir().join("backups").join("migration"); let backup_path = backup_root.join("state").join(CODEX_STATE_DB_FILENAME); - fs::create_dir_all(backup_path.parent().expect("backup parent")) + crate::config::create_managed_config_dir_all(backup_path.parent().expect("backup parent")) .expect("create backup parent"); fs::write(&backup_path, b"existing").expect("write existing backup"); #[cfg(unix)] diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index f81db093c..27227ae77 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -82,6 +82,26 @@ impl ConfigDirEnvGuard { std::env::set_var("XDG_RUNTIME_DIR", runtime.path()); std::env::set_var("XDG_STATE_HOME", runtime.path()); } + // `tempfile::TempDir` normally uses 0700, but the test runner may + // apply a permissive umask to directories created by callers. The + // production config guard intentionally rejects group/other-writable + // managed roots, so normalize an existing test root here. Tests that + // exercise rejection of insecure permissions set their explicit mode + // after this helper returns; system paths such as /tmp are excluded. + #[cfg(unix)] + if path != Path::new("/tmp") + && !path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = std::fs::symlink_metadata(path) { + if meta.is_dir() { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .expect("restrict isolated config dir"); + } + } + } Self { original, original_xdg_runtime_dir, @@ -420,6 +440,12 @@ fn daemon_owned_pidfile_can_initialize_and_run_v16_migration_without_self_deadlo let _env = crate::test_support::TestEnvGuard::isolated(home.path()); let config_dir = home.path().join(".cc-switch"); std::fs::create_dir_all(&config_dir).expect("create config dir"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&config_dir, std::fs::Permissions::from_mode(0o700)) + .expect("restrict config dir"); + } let db_path = config_dir.join("cc-switch.db"); let conn = Connection::open(&db_path).expect("seed database"); Database::create_tables_on_conn(&conn).expect("create current tables"); diff --git a/src-tauri/src/omp_config.rs b/src-tauri/src/omp_config.rs index 939267cf5..ab67961a2 100644 --- a/src-tauri/src/omp_config.rs +++ b/src-tauri/src/omp_config.rs @@ -305,7 +305,11 @@ pub(crate) fn get_omp_sessions_dir() -> Result { .map(|name| app_root.join("profiles").join(name)) .unwrap_or(app_root); let migrated_sessions = migrated_root.join("sessions"); - if migrated_sessions.is_dir() { + // OMP's directory resolver selects the XDG app root based on the + // migrated root itself, not on whether `sessions/` has already + // been created. Returning the path even when the directory is + // currently absent avoids falling back to stale legacy sessions. + if migrated_root.is_dir() { return Ok(migrated_sessions); } } @@ -558,22 +562,15 @@ fn get_omp_project_legacy_settings_path() -> Result { } /// OMP keeps project settings in the working directory rather than beneath -/// the user agent directory. `config.yml` is the canonical write target, -/// while an existing `config.yaml` is retained for compatibility. +/// the user agent directory. Unlike the global agent config, the upstream +/// loader only reads `.omp/config.yml`; a project-level `config.yaml` is not a +/// supported fallback and must therefore remain invisible to this adapter. pub(crate) fn get_omp_project_settings_path() -> Result { let cwd = std::env::current_dir().map_err(|error| { AppError::Config(format!("failed to resolve current directory: {error}")) })?; let root = cwd.join(".omp"); - let canonical = root.join("config.yml"); - if canonical.exists() { - return Ok(canonical); - } - let fallback = root.join("config.yaml"); - if fallback.exists() { - return Ok(fallback); - } - Ok(canonical) + Ok(root.join("config.yml")) } fn merge_omp_documents(base: &mut Value, overlay: &Value) { @@ -2515,18 +2512,12 @@ fn validate_provider_node_inner( .get("apiKey") .and_then(Value::as_str) .is_some_and(|value| !value.trim().is_empty()); - let has_nonempty_headers = object - .get("headers") - .and_then(Value::as_object) - .is_some_and(|headers| !headers.is_empty()); - let has_nonempty_compat = object - .get("compat") - .and_then(Value::as_object) - .is_some_and(|compat| !compat.is_empty()); - let has_nonempty_request_metadata = object - .get("requestMetadata") - .and_then(Value::as_object) - .is_some_and(|metadata| !metadata.is_empty()); + // OMP's validator uses JavaScript truthiness for these optional + // object fields. An explicitly supplied empty object is therefore + // still a valid override-only provider configuration. + let has_headers = object.get("headers").is_some(); + let has_compat = object.get("compat").is_some(); + let has_request_metadata = object.get("requestMetadata").is_some(); let auth_none = object.get("auth").and_then(Value::as_str) == Some("none"); let has_disable_strict_tools = object .get("disableStrictTools") @@ -2539,9 +2530,9 @@ fn validate_provider_node_inner( let has_known_override = has_nonempty_base_url || has_nonempty_api_key || auth_none - || has_nonempty_headers - || has_nonempty_compat - || has_nonempty_request_metadata + || has_headers + || has_compat + || has_request_metadata || has_disable_strict_tools || has_guardrail || object.get("remoteCompaction").is_some() @@ -3727,9 +3718,6 @@ fn providers<'a>(document: &'a Value, path: &Path) -> Result<&'a Map Ok(empty_json_object()), Some(Value::Object(providers)) => Ok(providers), @@ -3742,10 +3730,9 @@ fn providers<'a>(document: &'a Value, path: &Path) -> Result<&'a Map Result<(), AppError> { let root = document.as_object().ok_or_else(|| { AppError::InvalidInput(format!( @@ -3753,6 +3740,14 @@ fn validate_omp_models_root(document: &Value, path: &Path) -> Result<(), AppErro path.display() )) })?; + for key in root.keys() { + if key != "providers" { + return Err(AppError::InvalidInput(format!( + "OMP models contains unsupported root field '{key}': {}", + path.display() + ))); + } + } if let Some(value) = root.get("providers") { if !value.is_object() { return Err(AppError::InvalidInput(format!( @@ -4169,15 +4164,26 @@ mod tests { }) ) .is_err()); + + for (field, value) in [ + ("headers", json!({})), + ("compat", json!({})), + ("requestMetadata", json!({})), + ] { + let mut config = Map::new(); + config.insert(field.to_string(), value); + validate_provider_node(&format!("empty-{field}"), &Value::Object(config)) + .expect("OMP accepts explicitly supplied empty override objects"); + } } #[test] - fn models_root_preserves_unknown_fields_on_write() { + fn models_root_rejects_unknown_fields_like_omp_schema() { let path = PathBuf::from("/tmp/omp/models.yml"); assert!(validate_omp_models_root(&json!({"providers": {}}), &path).is_ok()); assert!( validate_omp_models_root(&json!({"providers": {}, "futureSetting": true}), &path) - .is_ok() + .is_err() ); assert!(validate_omp_models_root(&json!({"providers": []}), &path).is_err()); } @@ -4608,6 +4614,23 @@ mod tests { ); } + #[test] + #[serial] + fn migrated_xdg_data_root_wins_before_sessions_directory_exists() { + let home = tempfile::tempdir().expect("create isolated home"); + let _env = crate::test_support::TestEnvGuard::isolated(home.path()); + let xdg_data_home = home.path().join("xdg-data"); + std::fs::create_dir_all(xdg_data_home.join("omp")).expect("create migrated OMP data root"); + std::fs::create_dir_all(home.path().join(".omp/agent/sessions")) + .expect("create legacy sessions directory"); + std::env::set_var("XDG_DATA_HOME", &xdg_data_home); + + assert_eq!( + get_omp_sessions_dir().expect("resolve migrated OMP sessions directory"), + xdg_data_home.join("omp/sessions") + ); + } + #[test] #[serial] fn missing_models_yaml_uses_checked_writer_revision_sentinel() { @@ -5581,6 +5604,35 @@ mod tests { assert!(!disabled.contains("json-disabled")); } + #[test] + #[serial] + fn project_config_yaml_is_not_treated_as_an_omp_settings_source() { + let _agent = test_support::TestAgentDir::new(); + let temp = tempfile::tempdir().expect("create project directory"); + let _cwd = test_support::CurrentDirGuard::change_to(temp.path()); + + let project_root = temp.path().join(".omp"); + fs::create_dir_all(&project_root).expect("create project config directory"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&project_root, fs::Permissions::from_mode(0o700)) + .expect("make project config directory private"); + } + fs::write( + project_root.join("config.yaml"), + "modelRoleStorage: project\nmodelRoles:\n default: ignored/provider\n", + ) + .expect("write unsupported project YAML spelling"); + + assert_eq!( + get_omp_project_settings_path().expect("project settings path"), + project_root.join("config.yml") + ); + let layer = read_omp_project_settings_layer().expect("read project settings layer"); + assert!(layer.is_none(), "OMP does not load project config.yaml"); + } + #[test] #[serial] fn editing_json_disabled_provider_creates_yaml_override_instead_of_mutating_json() { @@ -5761,7 +5813,7 @@ mod tests { #[test] #[serial] - fn unknown_root_fields_survive_provider_crud_write() { + fn unknown_root_fields_are_rejected_before_provider_crud_write() { let _agent = test_support::TestAgentDir::new(); let path = get_omp_models_path().expect("models path"); ensure_private_omp_parent(&path).expect("create agent directory"); @@ -5771,10 +5823,12 @@ mod tests { ) .expect("write models with root metadata"); - insert_omp_provider("cc-switch-root-metadata", &provider()) - .expect("unknown root fields should be preserved"); - let written = fs::read_to_string(&path).expect("read updated models"); - assert!(written.contains("version: 2")); - assert!(written.contains("cc-switch-root-metadata:")); + let error = insert_omp_provider("cc-switch-root-metadata", &provider()) + .expect_err("unknown root fields should be rejected like OMP"); + assert!(error.to_string().contains("unsupported root field")); + assert_eq!( + fs::read_to_string(&path).expect("read unchanged models"), + "version: 2\nproviders:\n external:\n baseUrl: https://external.example/v1\n" + ); } } diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index 931b6bce9..6fc915096 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -1010,6 +1010,14 @@ wire_api = "responses" }), ); let _env = TestEnvGuard::isolated(temp_home.path()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let config_dir = temp_home.path().join(".cc-switch"); + std::fs::create_dir_all(&config_dir).expect("create config dir"); + std::fs::set_permissions(&config_dir, std::fs::Permissions::from_mode(0o700)) + .expect("restrict config dir"); + } write_json( crate::codex_config::get_codex_auth_path(), From 22a1b13d93f51ebc5e90178aee90f354252ed591 Mon Sep 17 00:00:00 2001 From: Devin-Pi <89199086+Devin-Pi@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:16:55 +0800 Subject: [PATCH 10/12] fix(omp): align paths, validation, and usage sync with upstream --- src-tauri/src/omp_config.rs | 128 +++++++++++++++----- src-tauri/src/services/session_usage_omp.rs | 102 ++-------------- src-tauri/tests/support.rs | 10 ++ 3 files changed, 117 insertions(+), 123 deletions(-) diff --git a/src-tauri/src/omp_config.rs b/src-tauri/src/omp_config.rs index ab67961a2..e452b9c78 100644 --- a/src-tauri/src/omp_config.rs +++ b/src-tauri/src/omp_config.rs @@ -229,7 +229,9 @@ pub(crate) fn get_omp_agent_dir() -> Result { .unwrap_or_else(|| PathBuf::from(".omp")); // OMP treats `PI_CONFIG_DIR` as a directory name relative to the user's // home (for example `.omp`), including when a caller supplies a leading - // separator. Keep that upstream behavior while preventing path escape. + // separator. Keep that upstream join/normalization behavior; the value is + // an explicit user-controlled path and is not implicitly constrained to + // remain below `home` when it contains `..` components. let config_root = omp_config_root(&home, config_dir); let profile = effective_profile(&path_env)?; let default_path = profile @@ -293,6 +295,10 @@ pub(crate) fn get_omp_sessions_dir() -> Result { .filter(|value| !value.is_empty()) .is_some_and(|value| !is_profile_derived_agent_dir_from_env(&config_root, value)); + // OMP only enables XDG data relocation on Unix platforms. In particular, + // a Windows process may still inherit XDG_DATA_HOME from a CI environment + // or shell profile, but native OMP continues using its agent directory. + #[cfg(any(target_os = "linux", target_os = "macos"))] if !explicit_agent_override && agent_dir == default_agent { if let Some(xdg_data) = path_env .get("XDG_DATA_HOME") @@ -363,8 +369,8 @@ fn is_profile_derived_agent_dir_from_env(config_root: &Path, value: &std::ffi::O fn omp_config_root(home: &Path, config_dir: PathBuf) -> PathBuf { // OMP treats PI_CONFIG_DIR as a directory *name* under the user's home // (its upstream implementation uses `path.join(os.homedir(), value)`). - // Strip a leading separator so an accidentally absolute-looking value - // cannot escape that root while retaining the same join semantics. + // Strip a leading separator so an accidentally absolute-looking value is + // still interpreted as home-relative, matching OMP's path.join call. let config_dir = config_dir.to_string_lossy(); normalize_omp_path(home.join(config_dir.trim_start_matches(['/', '\\']))) } @@ -1616,6 +1622,13 @@ fn upsert_omp_model_inner( } else { models.push(model); } + // Adding a model changes the provider's semantic requirements. In + // particular, OMP requires a provider-level `baseUrl` (and either + // credentials or an explicit `auth` mode) whenever custom models are + // present. Validate the merged provider before writing so an otherwise + // valid override-only provider cannot be left in a state that OMP rejects + // on its next load. + validate_provider_node_for_editor(provider_id, provider)?; write_models_document(&path, &document, &file_revision) } @@ -2310,9 +2323,12 @@ fn validate_provider_node_inner( "OMP provider transport", )?; if let Some(request_metadata) = object.get("requestMetadata") { - if !request_metadata.is_object() { + let request_metadata = request_metadata.as_object().ok_or_else(|| { + AppError::InvalidInput("OMP provider requestMetadata must be an object".to_string()) + })?; + if request_metadata.values().any(|value| !value.is_string()) { return Err(AppError::InvalidInput( - "OMP provider requestMetadata must be an object".to_string(), + "OMP provider requestMetadata values must be strings".to_string(), )); } } @@ -3730,9 +3746,10 @@ fn providers<'a>(document: &'a Value, path: &Path) -> Result<&'a Map Result<(), AppError> { let root = document.as_object().ok_or_else(|| { AppError::InvalidInput(format!( @@ -3740,14 +3757,6 @@ fn validate_omp_models_root(document: &Value, path: &Path) -> Result<(), AppErro path.display() )) })?; - for key in root.keys() { - if key != "providers" { - return Err(AppError::InvalidInput(format!( - "OMP models contains unsupported root field '{key}': {}", - path.display() - ))); - } - } if let Some(value) = root.get("providers") { if !value.is_object() { return Err(AppError::InvalidInput(format!( @@ -3997,15 +4006,14 @@ pub(crate) fn ensure_private_omp_parent(path: &Path) -> Result<(), AppError> { mode = 0o700; } let writable = mode & 0o022 != 0; - let world_writable = mode & 0o002 != 0; // A sticky world-writable ancestor such as /tmp is safe as a // shared container root; the managed directory itself must still - // remain private. Group-writable ancestors are retained for - // compatibility with user-managed workspace roots, while a - // non-sticky world-writable ancestor can redirect the path. + // remain private. Any other group/other-writable ancestor can let + // another user redirect a missing managed component before the + // write, so reject it even when only the group write bit is set. let shared_sticky_root = mode & 0o1000 != 0; if (ancestor == parent && writable) - || (ancestor != parent && world_writable && !shared_sticky_root) + || (ancestor != parent && writable && !shared_sticky_root) { return Err(AppError::InvalidInput(format!( "OMP managed directory cannot be group/other writable: {} ({mode:04o}); run chmod 700 {}", @@ -4042,6 +4050,10 @@ pub(crate) mod test_support { let dir = tempfile::tempdir().expect("create OMP test directory"); let agent_dir = dir.path().join("agent"); std::fs::create_dir_all(&agent_dir).expect("create OMP test agent directory"); + // The production writer rejects group/other-writable ancestors; + // tempfile directories inherit a permissive mode on some hosts, + // so make the isolated test root match a private user config root. + restrict_test_directory(dir.path()); restrict_test_directory(&agent_dir); Self::set(agent_dir, Some(dir)) } @@ -4050,6 +4062,9 @@ pub(crate) mod test_support { if let Err(error) = std::fs::create_dir_all(agent_dir) { panic!("create OMP test agent directory: {error}"); } + if let Some(parent) = agent_dir.parent() { + restrict_test_directory(parent); + } restrict_test_directory(agent_dir); Self::set(agent_dir.to_path_buf(), None) } @@ -4100,6 +4115,10 @@ pub(crate) mod test_support { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); let previous = std::env::current_dir().expect("read current directory"); + // Project-layer tests often create managed `.omp` files below a + // `tempfile` root. Match a real private project root before the + // production permission checks walk that ancestor. + restrict_test_directory(path); std::env::set_current_dir(path).expect("switch current directory"); Self { _lock: lock, @@ -4164,6 +4183,13 @@ mod tests { }) ) .is_err()); + assert!(validate_provider_node( + "invalid-metadata-value", + &json!({ + "requestMetadata": {"attempt": 1} + }) + ) + .is_err()); for (field, value) in [ ("headers", json!({})), @@ -4178,12 +4204,12 @@ mod tests { } #[test] - fn models_root_rejects_unknown_fields_like_omp_schema() { + fn models_root_preserves_unknown_fields_like_omp_schema() { let path = PathBuf::from("/tmp/omp/models.yml"); assert!(validate_omp_models_root(&json!({"providers": {}}), &path).is_ok()); assert!( validate_omp_models_root(&json!({"providers": {}, "futureSetting": true}), &path) - .is_err() + .is_ok() ); assert!(validate_omp_models_root(&json!({"providers": []}), &path).is_err()); } @@ -4657,6 +4683,9 @@ mod tests { &legacy_path, r#"// OMP legacy JSONC supports comments and trailing commas. { + // Older OMP releases used a top-level models array. The native + // schema keeps this unknown field during JSON -> YAML migration. + models: [], providers: { legacy: { baseUrl: 'https://legacy.example/v1', @@ -4674,6 +4703,7 @@ mod tests { assert!(path.exists()); let migrated = fs::read_to_string(&path).expect("read migrated YAML"); assert!(!migrated.contains("// OMP legacy")); + assert!(migrated.contains("models: []")); assert!(migrated.contains("providers:")); assert!(read_omp_native_providers() .expect("read migrated providers") @@ -5053,6 +5083,28 @@ mod tests { .expect("zero premium multiplier is valid OMP metadata"); } + #[test] + #[serial] + fn adding_model_to_override_only_provider_is_rejected_without_mutation() { + let _agent = test_support::TestAgentDir::new(); + let override_only = json!({"headers": {}}); + insert_omp_provider("override-only", &override_only).expect("seed override provider"); + let (_, revision) = read_omp_models_yaml().expect("read models revision"); + + let error = upsert_omp_model_checked( + "override-only", + "model-a", + json!({"id": "model-a"}), + &revision, + ) + .expect_err("custom models require provider-level connection settings"); + assert!(error.to_string().contains("baseUrl is required")); + assert_eq!( + read_omp_native_provider("override-only").expect("read provider"), + Some(override_only) + ); + } + #[test] #[serial] fn discovery_provider_base_url_uses_native_defaults_and_env_overrides() { @@ -5231,6 +5283,22 @@ mod tests { assert_eq!(directory_mode, 0o700); } + #[cfg(unix)] + #[test] + #[serial] + fn group_writable_omp_ancestor_is_rejected() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("create temporary root"); + fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o770)) + .expect("make temporary root group-writable"); + let path = temp.path().join("agent").join("models.yml"); + + let error = ensure_private_omp_parent(&path) + .expect_err("group-writable ancestors must not protect OMP credentials"); + assert!(error.to_string().contains("group/other writable")); + } + #[test] #[serial] fn stale_models_revision_does_not_overwrite_an_external_edit() { @@ -5813,7 +5881,7 @@ mod tests { #[test] #[serial] - fn unknown_root_fields_are_rejected_before_provider_crud_write() { + fn unknown_root_fields_survive_provider_crud_write() { let _agent = test_support::TestAgentDir::new(); let path = get_omp_models_path().expect("models path"); ensure_private_omp_parent(&path).expect("create agent directory"); @@ -5823,12 +5891,10 @@ mod tests { ) .expect("write models with root metadata"); - let error = insert_omp_provider("cc-switch-root-metadata", &provider()) - .expect_err("unknown root fields should be rejected like OMP"); - assert!(error.to_string().contains("unsupported root field")); - assert_eq!( - fs::read_to_string(&path).expect("read unchanged models"), - "version: 2\nproviders:\n external:\n baseUrl: https://external.example/v1\n" - ); + insert_omp_provider("cc-switch-root-metadata", &provider()) + .expect("unknown root fields should be preserved"); + let written = fs::read_to_string(&path).expect("read updated models"); + assert!(written.contains("version: 2")); + assert!(written.contains("cc-switch-root-metadata:")); } } diff --git a/src-tauri/src/services/session_usage_omp.rs b/src-tauri/src/services/session_usage_omp.rs index 061c69d7d..d2fbeefc0 100644 --- a/src-tauri/src/services/session_usage_omp.rs +++ b/src-tauri/src/services/session_usage_omp.rs @@ -201,27 +201,13 @@ fn sync_single_omp_file(db: &Database, file_path: &Path) -> Result state.revision.file_size - && omp_prefix_tail_matches(file_path, state.revision)? => - { - (state.last_line_offset, Some(state.revision.file_size)) - } - Some(_) | None => (0, None), - }; - let parsed = parse_omp_file( - file_path, - start_after_line, - start_at_byte, - revision.file_size, - modified, - )?; + // Always parse from the beginning. OMP normally appends JSONL, but a + // session can also be compacted or rewritten in place; an old EOF/tail + // fingerprint cannot prove that the bytes in the middle are unchanged. + // Full rescans are safe because the durable request/semantic ledgers make + // already-imported records idempotent, and they prevent silent usage loss + // after a rewrite followed by an append. + let parsed = parse_omp_file(file_path, revision.file_size, modified)?; let conn = lock_conn!(db.conn); let tx = conn .unchecked_transaction() @@ -368,19 +354,6 @@ fn omp_file_revision( }) } -fn omp_prefix_tail_matches(file_path: &Path, previous: OMPFileRevision) -> Result { - let tail_len = previous.file_size.min(REVISION_TAIL_BYTES); - let mut tail = vec![0; tail_len as usize]; - if tail_len > 0 { - let mut file = open_session_file_no_follow(file_path) - .map_err(|error| AppError::Config(format!("无法打开 OMP 会话文件: {error}")))?; - file.seek(SeekFrom::Start(previous.file_size - tail_len)) - .and_then(|_| file.read_exact(&mut tail)) - .map_err(|error| AppError::Config(format!("无法校验 OMP 会话追加边界: {error}")))?; - } - Ok(omp_tail_fingerprint(&tail) == previous.tail_fingerprint) -} - fn omp_tail_fingerprint(tail: &[u8]) -> u32 { let mut hasher = Sha256::new(); hash_field(&mut hasher, b"pi-session-tail-v1"); @@ -391,8 +364,6 @@ fn omp_tail_fingerprint(tail: &[u8]) -> u32 { fn parse_omp_file( file_path: &Path, - start_after_line: i64, - start_at_byte: Option, snapshot_size: u64, file_modified_nanos: i64, ) -> Result { @@ -407,57 +378,6 @@ fn parse_omp_file( let mut records = Vec::new(); let mut incomplete_tail = false; - // An append cursor points at the old complete EOF, while the session - // header lives near the beginning of the file. Establish the session - // identity once, then seek directly to that cursor; otherwise an active - // OMP session would be reparsed from byte zero on every sync cycle. - if let Some(byte_offset) = - start_at_byte.filter(|offset| *offset > 0 && *offset <= snapshot_size) - { - while session_id.is_none() && bytes_read < byte_offset { - buffer.clear(); - let remaining = byte_offset.saturating_sub(bytes_read); - let read = Read::by_ref(&mut reader) - .take(remaining) - .read_line(&mut buffer) - .map_err(|error| AppError::Config(format!("无法读取 OMP 会话 header: {error}")))?; - if read == 0 { - break; - } - bytes_read = bytes_read.saturating_add(read as u64); - line_number = line_number.saturating_add(1); - let line = buffer.trim(); - let Ok(value) = serde_json::from_str::(line) else { - if !buffer.ends_with('\n') { - return Err(AppError::Config("OMP 会话 header 不完整".to_string())); - } - continue; - }; - if value.get("type").and_then(Value::as_str) == Some("session") { - session_id = value - .get("id") - .and_then(Value::as_str) - .filter(|id| crate::session_manager::providers::omp::is_valid_tree_id(id)) - .map(str::to_string); - if session_id.is_none() { - return Err(AppError::Config("OMP 会话 header 缺少 id".to_string())); - } - session_timestamp = value - .get("timestamp") - .and_then(parse_timestamp_millis) - .map(|timestamp| timestamp / 1000); - } - } - if session_id.is_none() { - return Err(AppError::Config("OMP 会话没有有效 header".to_string())); - } - reader - .seek(SeekFrom::Start(byte_offset)) - .map_err(|error| AppError::Config(format!("无法定位 OMP 会话增量边界: {error}")))?; - bytes_read = byte_offset; - line_number = start_after_line; - } - loop { buffer.clear(); let remaining = snapshot_size.saturating_sub(bytes_read); @@ -498,9 +418,6 @@ fn parse_omp_file( crate::session_manager::providers::omp::MAX_TREE_ENTRIES ))); } - if session_id.is_some() && line_number <= start_after_line { - continue; - } let Some(value) = value else { if !has_newline { incomplete_tail = true; @@ -1238,7 +1155,7 @@ mod tests { } #[test] - fn appends_and_completes_partial_omp_tail_incrementally() -> Result<(), AppError> { + fn appends_and_completes_partial_omp_tail_without_duplicate_imports() -> Result<(), AppError> { let temp = tempfile::tempdir().expect("tempdir"); let path = temp.path().join("active.jsonl"); let header = serde_json::json!({ @@ -1284,7 +1201,8 @@ mod tests { file.write_all(b"\n").expect("write record newline"); file.flush().expect("flush completed record"); drop(file); - // The partial record is rescanned from the saved complete boundary. + // The file is safely rescanned from the beginning; request/semantic + // ledgers suppress the already-imported first record. let completed = sync_omp_files(&db, std::slice::from_ref(&path)); assert_eq!(completed.imported, 1); let total: i64 = lock_conn!(db.conn).query_row( diff --git a/src-tauri/tests/support.rs b/src-tauri/tests/support.rs index c30de9859..f207aeaeb 100644 --- a/src-tauri/tests/support.rs +++ b/src-tauri/tests/support.rs @@ -18,6 +18,16 @@ pub fn ensure_test_home() -> &'static Path { let _ = std::fs::remove_dir_all(&base); } std::fs::create_dir_all(&base).expect("create test home"); + // OMP's managed-file writer rejects group/other-writable ancestors. + // Keep the isolated test home equivalent to a real private config + // root so deeplink and integration tests exercise the writer rather + // than failing on tempfile's inherited 0775 mode. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&base, std::fs::Permissions::from_mode(0o700)) + .expect("restrict test home permissions"); + } base }); std::env::set_var("HOME", home); From 5deb0a60276b823b156f974afa7ca9e2a3971d53 Mon Sep 17 00:00:00 2001 From: Devin-Pi <89199086+Devin-Pi@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:23:48 +0800 Subject: [PATCH 11/12] fix(omp): update provider tests for upstream metadata argument --- src-tauri/src/cli/commands/provider.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src-tauri/src/cli/commands/provider.rs b/src-tauri/src/cli/commands/provider.rs index 036250e4b..39feba377 100644 --- a/src-tauri/src/cli/commands/provider.rs +++ b/src-tauri/src/cli/commands/provider.rs @@ -2333,6 +2333,7 @@ mod tests { }, None, None, + None, "Local OMP", &mut None, ) @@ -2353,6 +2354,7 @@ mod tests { }, None, None, + None, "OMP override", &mut None, ) From 40202ec96a044a7c239073a7f7bf17aef3b17018 Mon Sep 17 00:00:00 2001 From: Devin-Pi <89199086+Devin-Pi@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:51:27 +0800 Subject: [PATCH 12/12] fix(omp): derive provider IDs from names --- docs/CHANGELOG.md | 4 + src-tauri/src/cli/commands/provider.rs | 5 +- src-tauri/src/cli/commands/provider_input.rs | 4 +- src-tauri/src/cli/i18n.rs | 16 ++ .../src/cli/tui/app/form_handlers/provider.rs | 55 +++++- src-tauri/src/cli/tui/app/tests.rs | 152 +++++++++++++++++ src-tauri/src/cli/tui/form/provider_state.rs | 12 +- src-tauri/src/cli/tui/form/tests.rs | 23 +++ .../src/cli/tui/runtime_actions/editor.rs | 159 +++++++++++++++++- src-tauri/src/services/provider/mod.rs | 23 ++- src-tauri/src/services/provider/omp.rs | 24 +++ src-tauri/src/services/provider/tests.rs | 20 +++ .../tests/provider_add_noninteractive.rs | 51 ++++++ 13 files changed, 535 insertions(+), 13 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8f3bcaef0..14d487789 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **OMP / Provider Form**: Derive provider IDs from the visible Name field and remove direct ID editing from add and edit forms. + ## [5.10.4] - 2026-08-30 ### Added diff --git a/src-tauri/src/cli/commands/provider.rs b/src-tauri/src/cli/commands/provider.rs index 39feba377..e85ca0cc7 100644 --- a/src-tauri/src/cli/commands/provider.rs +++ b/src-tauri/src/cli/commands/provider.rs @@ -1624,9 +1624,12 @@ fn add_provider(app_type: AppType, args: AddProviderArgs) -> Result<(), AppError let manager = config .get_manager(&app_type) .ok_or_else(|| AppError::Message(texts::app_config_not_found(app_type.as_str())))?; - let existing_ids: Vec = manager.providers.keys().cloned().collect(); + let mut existing_ids: Vec = manager.providers.keys().cloned().collect(); let common_snippet = config.common_config_snippets.get(&app_type).cloned(); drop(config); + if matches!(app_type, AppType::Omp) { + existing_ids.extend(crate::omp_config::read_omp_native_providers()?.into_keys()); + } let template = args.template.unwrap_or(ProviderAddTemplate::Custom); validate_provider_add_template(&app_type, template)?; diff --git a/src-tauri/src/cli/commands/provider_input.rs b/src-tauri/src/cli/commands/provider_input.rs index de3d65157..2b6f91dd7 100644 --- a/src-tauri/src/cli/commands/provider_input.rs +++ b/src-tauri/src/cli/commands/provider_input.rs @@ -3703,7 +3703,9 @@ pub fn generate_provider_id_for_app( name: &str, existing_ids: &[String], ) -> String { - if ProviderService::is_provider_key_app(app_type) { + if matches!(app_type, AppType::Omp) { + ProviderService::generate_omp_provider_key(name, existing_ids) + } else if ProviderService::is_provider_key_app(app_type) { ProviderService::generate_provider_key(name, existing_ids) } else { generate_provider_id(name, existing_ids) diff --git a/src-tauri/src/cli/i18n.rs b/src-tauri/src/cli/i18n.rs index c0bdb5b24..41826b4a6 100644 --- a/src-tauri/src/cli/i18n.rs +++ b/src-tauri/src/cli/i18n.rs @@ -2786,6 +2786,22 @@ pub mod texts { } } + pub fn tui_omp_provider_key_invalid() -> &'static str { + if is_chinese() { + "根据名称生成的供应商标识不能超过 128 字节" + } else { + "Provider ID derived from Name cannot exceed 128 bytes" + } + } + + pub fn tui_omp_provider_name_required() -> &'static str { + if is_chinese() { + "请填写 name;现有 id 保持不变。" + } else { + "Please fill in Name; the existing ID remains unchanged." + } + } + pub fn tui_hermes_base_url_required() -> &'static str { if is_chinese() { "API 端点不能为空" diff --git a/src-tauri/src/cli/tui/app/form_handlers/provider.rs b/src-tauri/src/cli/tui/app/form_handlers/provider.rs index 21a140ad7..24b1f6a2c 100644 --- a/src-tauri/src/cli/tui/app/form_handlers/provider.rs +++ b/src-tauri/src/cli/tui/app/form_handlers/provider.rs @@ -53,6 +53,13 @@ impl App { provider.refresh_usage_query_provider_kind(); provider.field_errors.clear(); provider.usage_query_field_errors.clear(); + if matches!(provider.app_type, crate::app_config::AppType::Omp) + && !provider.mode.is_edit() + { + provider.id.set(""); + provider.id_is_manual = false; + provider.ensure_generated_id(&data.existing_provider_ids()); + } if let Some(message) = provider.usage_query_script_validation_error() { provider.set_usage_query_field_error(form::UsageQueryField::Script, message); } @@ -60,10 +67,35 @@ impl App { matches!(provider.app_type, crate::app_config::AppType::Hermes) .then(|| validate_hermes_base_url(&provider.hermes_base_url.value)) .flatten(); + let provider_key_field = if matches!(provider.app_type, crate::app_config::AppType::Omp) + { + ProviderAddField::Name + } else { + ProviderAddField::Id + }; + let provider_key_error = if matches!(provider.app_type, crate::app_config::AppType::Omp) + { + texts::tui_omp_provider_key_invalid() + } else { + texts::tui_hermes_provider_key_invalid() + }; - if ProviderService::is_provider_key_app(&provider.app_type) && provider.id.is_blank() { + if matches!(provider.app_type, crate::app_config::AppType::Omp) + && provider.name.is_blank() + { + Some(( + ProviderValidationTarget::Main(ProviderAddField::Name), + if provider.mode.is_edit() { + texts::tui_omp_provider_name_required().to_string() + } else { + texts::tui_toast_provider_add_missing_fields().to_string() + }, + )) + } else if ProviderService::is_provider_key_app(&provider.app_type) + && provider.id.is_blank() + { Some(( - ProviderValidationTarget::Main(ProviderAddField::Id), + ProviderValidationTarget::Main(provider_key_field), texts::tui_toast_provider_add_missing_fields().to_string(), )) } else if ProviderService::validate_provider_key_for_add( @@ -73,8 +105,8 @@ impl App { .is_err() { Some(( - ProviderValidationTarget::Main(ProviderAddField::Id), - texts::tui_hermes_provider_key_invalid().to_string(), + ProviderValidationTarget::Main(provider_key_field), + provider_key_error.to_string(), )) } else if provider.name.is_blank() { Some(( @@ -2101,13 +2133,26 @@ pub(super) fn validate_provider_inline_field( ) -> Option { match field { ProviderAddField::Name if provider.name.is_blank() => Some( - if provider.mode.is_edit() { + if provider.mode.is_edit() && matches!(provider.app_type, AppType::Omp) { + texts::tui_omp_provider_name_required() + } else if provider.mode.is_edit() { texts::tui_toast_provider_missing_name() } else { texts::tui_toast_provider_add_missing_fields() } .to_string(), ), + ProviderAddField::Name + if !provider.mode.is_edit() + && matches!(provider.app_type, AppType::Omp) + && ProviderService::validate_provider_key_for_add( + &provider.app_type, + provider.id.value.as_str(), + ) + .is_err() => + { + Some(texts::tui_omp_provider_key_invalid().to_string()) + } ProviderAddField::Id if ProviderService::is_provider_key_app(&provider.app_type) && (provider.id.is_blank() diff --git a/src-tauri/src/cli/tui/app/tests.rs b/src-tauri/src/cli/tui/app/tests.rs index 69d797cde..2f35d37bd 100644 --- a/src-tauri/src/cli/tui/app/tests.rs +++ b/src-tauri/src/cli/tui/app/tests.rs @@ -12967,6 +12967,53 @@ mod tests { assert!(content.contains("\"name\": \"Provider One\"")); } + #[test] + fn provider_omp_add_form_rederives_id_from_final_name() { + let mut app = App::new(Some(AppType::Omp)); + app.route = Route::Providers; + app.focus = Focus::Content; + + let mut form = ProviderAddFormState::new(AppType::Omp); + form.name.set("First Name"); + assert!(form.ensure_generated_id(&[])); + let value = form.to_provider_json_value(); + form.apply_provider_json_value_to_fields(value) + .expect("rehydrate OMP form"); + assert!(!form.id_is_manual); + + form.name.set("Final Name"); + form.id.set("stale-hidden-id"); + form.id_is_manual = true; + app.form = Some(FormState::ProviderAdd(form)); + + let submit = app.on_key(ctrl(KeyCode::Char('s')), &UiData::default()); + let Action::EditorSubmit { content, .. } = submit else { + panic!("Ctrl+S should submit the OMP add form"); + }; + assert!(content.contains("\"id\": \"final-name\"")); + } + + #[test] + fn provider_omp_long_derived_id_reports_name_length_error() { + let mut app = App::new(Some(AppType::Omp)); + app.route = Route::Providers; + app.focus = Focus::Content; + let mut form = ProviderAddFormState::new(AppType::Omp); + form.name.set("a".repeat(129)); + app.form = Some(FormState::ProviderAdd(form)); + + let action = app.on_key(ctrl(KeyCode::Char('s')), &UiData::default()); + + assert!(matches!(action, Action::None)); + let Some(FormState::ProviderAdd(form)) = app.form.as_ref() else { + panic!("expected provider form"); + }; + assert_eq!(form.fields()[form.field_idx], ProviderAddField::Name); + assert!(form + .main_field_error(ProviderAddField::Name) + .is_some_and(|message| message.contains("128"))); + } + #[test] fn provider_add_form_missing_fields_toast_mentions_name_only() { let mut app = App::new(Some(AppType::Claude)); @@ -13012,6 +13059,111 @@ mod tests { assert!(form.main_field_error(ProviderAddField::Name).is_none()); } + #[test] + fn provider_omp_save_focuses_name_when_derived_identity_is_missing() { + let mut app = App::new(Some(AppType::Omp)); + app.route = Route::Providers; + app.focus = Focus::Content; + app.form = Some(FormState::ProviderAdd(ProviderAddFormState::new( + AppType::Omp, + ))); + + let action = app.on_key(ctrl(KeyCode::Char('s')), &UiData::default()); + + assert!(matches!(action, Action::None)); + let Some(FormState::ProviderAdd(form)) = app.form.as_ref() else { + panic!("expected provider form"); + }; + assert_eq!(form.fields()[form.field_idx], ProviderAddField::Name); + assert!(form.main_field_error(ProviderAddField::Name).is_some()); + assert!(form.main_field_error(ProviderAddField::Id).is_none()); + } + + #[test] + fn provider_omp_edit_missing_name_describes_preserved_id() { + let provider = Provider::with_id( + "native-key".to_string(), + "Native key".to_string(), + serde_json::json!({"extension": "native"}), + None, + ); + let mut form = ProviderAddFormState::from_provider(AppType::Omp, &provider); + form.name.set(""); + + let mut app = App::new(Some(AppType::Omp)); + app.route = Route::Providers; + app.focus = Focus::Content; + app.form = Some(FormState::ProviderAdd(form)); + + let action = app.on_key(ctrl(KeyCode::Char('s')), &UiData::default()); + + assert!(matches!(action, Action::None)); + let Some(FormState::ProviderAdd(form)) = app.form.as_ref() else { + panic!("expected provider form"); + }; + assert_eq!( + form.main_field_error(ProviderAddField::Name), + Some(texts::tui_omp_provider_name_required()) + ); + } + + #[test] + fn provider_omp_inline_blank_name_describes_preserved_id() { + let provider = Provider::with_id( + "native-key".to_string(), + "Native key".to_string(), + serde_json::json!({"extension": "native"}), + None, + ); + let mut app = App::new(Some(AppType::Omp)); + app.route = Route::Providers; + app.focus = Focus::Content; + app.form = Some(FormState::ProviderAdd(ProviderAddFormState::from_provider( + AppType::Omp, + &provider, + ))); + select_provider_field(&mut app, ProviderAddField::Name); + + app.on_key(key(KeyCode::Enter), &UiData::default()); + let Some(FormState::ProviderAdd(form)) = app.form.as_mut() else { + panic!("expected provider form"); + }; + form.name.set(""); + app.on_key(key(KeyCode::Enter), &UiData::default()); + + let Some(FormState::ProviderAdd(form)) = app.form.as_ref() else { + panic!("expected provider form"); + }; + assert_eq!( + form.main_field_error(ProviderAddField::Name), + Some(texts::tui_omp_provider_name_required()) + ); + } + #[test] + fn provider_omp_inline_overlong_name_reports_derived_id_limit() { + let mut app = App::new(Some(AppType::Omp)); + app.route = Route::Providers; + app.focus = Focus::Content; + app.form = Some(FormState::ProviderAdd(ProviderAddFormState::new( + AppType::Omp, + ))); + let data = UiData::default(); + select_provider_field(&mut app, ProviderAddField::Name); + app.on_key(key(KeyCode::Enter), &data); + for _ in 0..129 { + app.on_key(key(KeyCode::Char('a')), &data); + } + app.on_key(key(KeyCode::Enter), &data); + + let Some(FormState::ProviderAdd(form)) = app.form.as_ref() else { + panic!("expected provider form"); + }; + assert_eq!( + form.main_field_error(ProviderAddField::Name), + Some(texts::tui_omp_provider_key_invalid()) + ); + } + #[test] fn provider_add_form_codex_requires_base_url_before_submit() { let mut app = App::new(Some(AppType::Codex)); diff --git a/src-tauri/src/cli/tui/form/provider_state.rs b/src-tauri/src/cli/tui/form/provider_state.rs index 0e6ae7af3..818c65b43 100644 --- a/src-tauri/src/cli/tui/form/provider_state.rs +++ b/src-tauri/src/cli/tui/form/provider_state.rs @@ -475,7 +475,9 @@ impl ProviderAddFormState { } pub fn is_id_editable(&self) -> bool { - !self.mode.is_edit() && self.copy_source_id.is_none() + !matches!(self.app_type, AppType::Omp) + && !self.mode.is_edit() + && self.copy_source_id.is_none() } pub fn ensure_generated_id(&mut self, existing_ids: &[String]) -> bool { @@ -504,7 +506,7 @@ impl ProviderAddFormState { if matches!( self.app_type, - AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Omp + AppType::Hermes | AppType::OpenClaw | AppType::Pi ) && self.copy_source_id.is_none() { fields.insert(0, ProviderAddField::Id); @@ -2703,6 +2705,9 @@ impl ProviderAddFormState { next.mode = previous_mode.clone(); next.copy_source_id = previous_copy_source_id; + if matches!(next.app_type, AppType::Omp) && !next.mode.is_edit() { + next.id_is_manual = false; + } next.focus = previous_focus; next.page = previous_page; next.template_idx = previous_template_idx; @@ -2812,6 +2817,9 @@ impl ProviderAddFormState { next.mode = previous_mode.clone(); next.copy_source_id = previous_copy_source_id; + if matches!(next.app_type, AppType::Omp) && !next.mode.is_edit() { + next.id_is_manual = false; + } next.focus = previous_focus; next.page = previous_page; next.template_idx = previous_template_idx; diff --git a/src-tauri/src/cli/tui/form/tests.rs b/src-tauri/src/cli/tui/form/tests.rs index 1d63f3719..a33b8ab66 100644 --- a/src-tauri/src/cli/tui/form/tests.rs +++ b/src-tauri/src/cli/tui/form/tests.rs @@ -7990,6 +7990,29 @@ fn provider_add_form_pi_uses_native_api_default() { ); } +#[test] +fn provider_form_omp_derives_hidden_id_from_name() { + let mut form = ProviderAddFormState::new(AppType::Omp); + + assert!(form.fields().contains(&ProviderAddField::Name)); + assert!(!form.fields().contains(&ProviderAddField::Id)); + assert!(!form.is_id_editable()); + + form.name.set("Custom OMP"); + assert!(form.ensure_generated_id(&[])); + assert_eq!(form.id.value, "custom-omp"); + + let provider = Provider::with_id( + "native-key".to_string(), + "Native key".to_string(), + json!({"extension": "native"}), + None, + ); + let edit_form = ProviderAddFormState::from_provider(AppType::Omp, &provider); + assert!(!edit_form.fields().contains(&ProviderAddField::Id)); + assert!(!edit_form.is_id_editable()); +} + #[test] fn provider_add_form_omp_preserves_omp_api_default_by_omission() { let mut form = ProviderAddFormState::new(AppType::Omp); diff --git a/src-tauri/src/cli/tui/runtime_actions/editor.rs b/src-tauri/src/cli/tui/runtime_actions/editor.rs index 195096cbe..b5a96e087 100644 --- a/src-tauri/src/cli/tui/runtime_actions/editor.rs +++ b/src-tauri/src/cli/tui/runtime_actions/editor.rs @@ -30,7 +30,9 @@ fn validate_provider_submit( expected_pi_settings: Option<&Value>, ) -> Option<&'static str> { if provider.name.trim().is_empty() { - return Some(if is_edit { + return Some(if is_edit && matches!(app_type, AppType::Omp) { + texts::tui_omp_provider_name_required() + } else if is_edit { texts::tui_toast_provider_missing_name() } else { texts::tui_toast_provider_add_missing_fields() @@ -1086,6 +1088,16 @@ fn submit_provider_form_apply_codex_config_toml( Ok(()) } +fn show_omp_name_validation_error(ctx: &mut RuntimeActionContext<'_>, message: &'static str) { + if let Some(FormState::ProviderAdd(form)) = ctx.app.form.as_mut() { + form.page = super::super::form::ProviderFormPage::Main; + form.focus = super::super::form::FormFocus::Fields; + form.sync_main_field_index(super::super::form::ProviderAddField::Name); + form.set_main_field_error(super::super::form::ProviderAddField::Name, message); + } + ctx.app.push_toast(message, ToastKind::Warning); +} + fn submit_provider_add( ctx: &mut RuntimeActionContext<'_>, content: String, @@ -1105,8 +1117,18 @@ fn submit_provider_add( _ => None, }; + // OMP's form never exposes an ID. Treat Name as the sole identity input + // even if a stale or hand-edited submit payload contains a nonblank ID. + if matches!(ctx.app.app_type, AppType::Omp) { + provider.id.clear(); + } + if let Some(message) = validate_provider_submit(&ctx.app.app_type, &provider, false, None) { - ctx.app.push_toast(message, ToastKind::Warning); + if matches!(ctx.app.app_type, AppType::Omp) && provider.name.trim().is_empty() { + show_omp_name_validation_error(ctx, message); + } else { + ctx.app.push_toast(message, ToastKind::Warning); + } return Ok(()); } @@ -1140,6 +1162,12 @@ fn submit_provider_add( return Ok(()); }; provider.id = provider_id; + if matches!(ctx.app.app_type, AppType::Omp) + && ProviderService::validate_provider_key_for_add(&AppType::Omp, &provider.id).is_err() + { + show_omp_name_validation_error(ctx, texts::tui_omp_provider_key_invalid()); + return Ok(()); + } let result = if let Some(copy_source_id) = copy_source_id { ProviderService::duplicate( @@ -1197,7 +1225,11 @@ fn submit_provider_edit( true, expected_pi_settings_config.as_ref(), ) { - ctx.app.push_toast(message, ToastKind::Warning); + if matches!(ctx.app.app_type, AppType::Omp) && provider.name.trim().is_empty() { + show_omp_name_validation_error(ctx, message); + } else { + ctx.app.push_toast(message, ToastKind::Warning); + } return Ok(()); } @@ -2616,6 +2648,127 @@ mod tests { ); } + #[test] + #[serial(home_settings)] + fn submit_provider_add_omp_ignores_hidden_payload_id() { + let _agent = crate::omp_config::test_support::TestAgentDir::new(); + let mut fixture = runtime_ctx(AppType::Omp); + let mut ctx = runtime_action_ctx(&mut fixture); + + submit_provider_add( + &mut ctx, + r#"{ + "id": "hand-edited-id", + "name": "Final Name", + "settingsConfig": { + "baseUrl": "https://api.example.com/v1", + "apiKey": "secret", + "api": "openai-completions", + "models": [{"id": "model-a"}] + } + }"# + .to_string(), + ) + .expect("submit OMP provider"); + + let row_ids = ctx + .data + .providers + .rows + .iter() + .map(|row| row.id.as_str()) + .collect::>(); + let toast = ctx.app.toast.as_ref().map(|toast| toast.message.as_str()); + assert!( + crate::omp_config::omp_provider_exists("final-name").expect("read OMP registry"), + "rows={row_ids:?}, toast={toast:?}" + ); + + assert!(ctx + .data + .providers + .rows + .iter() + .any(|row| row.id == "final-name")); + assert!(!ctx + .data + .providers + .rows + .iter() + .any(|row| row.id == "hand-edited-id")); + } + + #[test] + #[serial(home_settings)] + fn submit_provider_add_omp_targets_name_for_overlong_derived_id() { + let _agent = crate::omp_config::test_support::TestAgentDir::new(); + let mut fixture = runtime_ctx(AppType::Omp); + let long_name = "a".repeat(129); + let mut form = ProviderAddFormState::new(AppType::Omp); + form.name.set(&long_name); + fixture.app.form = Some(FormState::ProviderAdd(form)); + let mut ctx = runtime_action_ctx(&mut fixture); + + let content = json!({ + "id": "hand-edited-id", + "name": long_name, + "settingsConfig": { + "baseUrl": "https://api.example.com/v1", + "apiKey": "secret", + "api": "openai-completions", + "models": [{"id": "model-a"}] + } + }) + .to_string(); + submit_provider_add(&mut ctx, content).expect("reject overlong OMP identity"); + + let Some(FormState::ProviderAdd(form)) = ctx.app.form.as_ref() else { + panic!("expected provider form"); + }; + assert_eq!( + form.fields()[form.field_idx], + crate::cli::tui::form::ProviderAddField::Name + ); + assert_eq!( + form.main_field_error(crate::cli::tui::form::ProviderAddField::Name), + Some(texts::tui_omp_provider_key_invalid()) + ); + } + + #[test] + #[serial(home_settings)] + fn submit_provider_edit_omp_uses_preserved_id_name_error() { + let _agent = crate::omp_config::test_support::TestAgentDir::new(); + let mut fixture = runtime_ctx(AppType::Omp); + let provider = Provider::with_id( + "native-key".to_string(), + "Native key".to_string(), + json!({"extension": "native"}), + None, + ); + fixture.app.form = Some(FormState::ProviderAdd(ProviderAddFormState::from_provider( + AppType::Omp, + &provider, + ))); + let mut ctx = runtime_action_ctx(&mut fixture); + + submit_provider_edit( + &mut ctx, + "native-key".to_string(), + Some(json!({"extension": "native"})), + r#"{"id":"changed","name":"","settingsConfig":{"extension":"native"}}"#.to_string(), + ) + .expect("reject missing OMP edit name"); + + let Some(FormState::ProviderAdd(form)) = ctx.app.form.as_ref() else { + panic!("expected provider form"); + }; + assert_eq!( + form.main_field_error(crate::cli::tui::form::ProviderAddField::Name), + Some(texts::tui_omp_provider_name_required()) + ); + } + #[test] #[serial(home_settings)] fn submit_provider_add_rejects_name_that_cannot_generate_id() { diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 77616ef6c..ae9fff7a9 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -392,6 +392,25 @@ impl ProviderService { } } + pub fn generate_omp_provider_key(name: &str, existing_ids: &[String]) -> String { + let base_id = Self::generate_provider_key(name, &[]); + if !existing_ids.iter().any(|existing| existing == &base_id) { + return base_id; + } + + const KEY_LIMIT: usize = 128; + let mut counter = 1; + loop { + let suffix = format!("-{counter}"); + let base_len = KEY_LIMIT.saturating_sub(suffix.len()).min(base_id.len()); + let candidate = format!("{}{}", &base_id[..base_len], suffix); + if !existing_ids.iter().any(|existing| existing == &candidate) { + return candidate; + } + counter += 1; + } + } + pub fn validate_provider_key_for_add( app_type: &AppType, provider_id: &str, @@ -534,8 +553,10 @@ impl ProviderService { })?; let mut existing_ids = providers.keys().cloned().collect::>(); existing_ids.extend(Self::live_provider_ids(&app_type)?); - let duplicate = + let mut duplicate = Self::duplicate_provider_with_overrides(source, provider_override, &existing_ids); + let existing_ids = existing_ids.into_iter().collect::>(); + duplicate.id = Self::generate_omp_provider_key(&duplicate.name, &existing_ids); omp::add(state, duplicate.clone(), false)?; return Ok(duplicate); } diff --git a/src-tauri/src/services/provider/omp.rs b/src-tauri/src/services/provider/omp.rs index dd02a150b..a218d2995 100644 --- a/src-tauri/src/services/provider/omp.rs +++ b/src-tauri/src/services/provider/omp.rs @@ -861,6 +861,30 @@ mod tests { assert!(!crate::omp_config::omp_provider_exists("cc-switch-test").unwrap()); } + #[test] + #[serial] + fn duplicate_derives_omp_key_from_visible_copy_name() { + let _agent = TestAgentDir::new(); + let state = state(); + let mut source = input("model-a"); + source.name = "Friendly Name".to_string(); + add(&state, source.clone(), false).expect("save source provider"); + + let mut edited_copy = source; + edited_copy.id = "cc-switch-test-copy".to_string(); + edited_copy.name = "Friendly Name copy".to_string(); + let duplicate = + ProviderService::duplicate(&state, AppType::Omp, "cc-switch-test", Some(edited_copy)) + .expect("duplicate OMP provider"); + + assert_eq!(duplicate.id, "friendly-name-copy"); + assert!(state + .db + .get_provider_by_id("friendly-name-copy", OMP_APP) + .expect("query duplicate") + .is_some()); + } + #[test] #[serial] fn new_live_provider_requires_api_key_for_custom_models() { diff --git a/src-tauri/src/services/provider/tests.rs b/src-tauri/src/services/provider/tests.rs index a6f3fbd7d..6113ec9bc 100644 --- a/src-tauri/src/services/provider/tests.rs +++ b/src-tauri/src/services/provider/tests.rs @@ -7660,3 +7660,23 @@ fn delete_rejects_last_failover_queue_provider_while_active() { .expect("read queued provider") .is_some()); } + +#[test] +fn omp_provider_key_collision_suffix_stays_within_native_limit() { + let base = "a".repeat(128); + let first = ProviderService::generate_omp_provider_key(&base, std::slice::from_ref(&base)); + + assert_eq!(first.len(), 128); + assert!(first.ends_with("-1")); + + let existing = vec![base.clone(), first]; + let second = ProviderService::generate_omp_provider_key(&base, &existing); + assert_eq!(second.len(), 128); + assert!(second.ends_with("-2")); + + assert_eq!( + ProviderService::generate_provider_key(&base, std::slice::from_ref(&base)), + format!("{base}-1"), + "non-OMP provider key generation must remain unchanged" + ); +} diff --git a/src-tauri/tests/provider_add_noninteractive.rs b/src-tauri/tests/provider_add_noninteractive.rs index bf6043a8d..e7dfeba64 100644 --- a/src-tauri/tests/provider_add_noninteractive.rs +++ b/src-tauri/tests/provider_add_noninteractive.rs @@ -919,3 +919,54 @@ fn add_official_template_rejects_field_overrides() { "error should reject overrides on official templates: {err}" ); } + +#[test] +#[serial] +fn omp_add_name_deconflicts_native_only_provider_key() { + let _guard = lock_test_mutex(); + prepare_empty_state(); + let agent_dir = ensure_test_home().join(".omp/agent"); + std::fs::create_dir_all(&agent_dir).expect("create OMP agent directory"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for path in [ + agent_dir.parent().expect("OMP config root"), + agent_dir.as_path(), + ] { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .expect("restrict OMP config directory"); + } + } + std::fs::write( + agent_dir.join("models.yml"), + r#"providers: + native: + baseUrl: https://native.example.com/v1 + apiKey: secret + api: openai-completions + models: + - id: model-a +"#, + ) + .expect("seed native-only OMP provider"); + + run_add( + Some("Native"), + AppType::Omp, + AddOpts { + base_url: Some("https://copy.example.com/v1".to_string()), + api_key: Some("copy-secret".to_string()), + model: Some("model-b".to_string()), + ..Default::default() + }, + ) + .expect("add deconflicted OMP provider"); + + let state = cc_switch_lib::AppState::try_new().expect("reload app state"); + assert!(state + .db + .get_provider_by_id("native-1", AppType::Omp.as_str()) + .expect("query generated OMP provider") + .is_some()); +}