From 38882f4f06eb27aff6d3769a02b83f9c9dbb02c4 Mon Sep 17 00:00:00 2001 From: isword Date: Sun, 18 Oct 2026 23:01:35 +0800 Subject: [PATCH 1/3] fix(importers): discover Pi auth-only model providers The explicit model-config scan only read ~/.pi/agent/models.json, so providers connected through `pi /login` (for example DeepSeek or a Z.AI coding plan) never appeared in the Pi group. Their keys live in auth.json and their endpoint/model metadata lives in Pi's cached models-store.json. Read both files and pair each API-key provider with its largest coherent endpoint group. OAuth/subscription grants stay in Pi, `!command` keys are never executed or stored, and models.json remains authoritative when both files describe a provider. A key in auth.json also fills a models.json provider whose `env:`/`$VAR` reference cannot resolve in Desktop. Tests cover the cached-catalog pairing, OAuth/command exclusion, endpoint selection, and the scanner merge. --- .../electron/main/importers/model-config.ts | 34 +++++- .../desktop/test/model-config-import.test.mjs | 81 +++++++++++++ docs/adr/0179-import-model-configuration.md | 28 ++++- .../03-runtime/11-provider-model-system.md | 13 ++- docs/spec/06-delivery/04-e2e-test-plan.md | 22 ++-- docs/spec/08-meta/decisions-log.md | 2 +- docs/zh-CN/spec/08-meta/decisions-log.md | 2 +- .../shared/src/model-config-import.test.ts | 108 ++++++++++++++++++ packages/shared/src/model-config-import.ts | 108 ++++++++++++++++++ 9 files changed, 374 insertions(+), 24 deletions(-) diff --git a/apps/desktop/electron/main/importers/model-config.ts b/apps/desktop/electron/main/importers/model-config.ts index 82fd217b94..e1d21283e7 100644 --- a/apps/desktop/electron/main/importers/model-config.ts +++ b/apps/desktop/electron/main/importers/model-config.ts @@ -9,6 +9,7 @@ import { parseCodexModelConfig, parseJsonDocument, parseOpenCodeModelConfig, + parsePiAuthModelConfig, parsePiModelConfig, type CcSwitchProviderRow, type ModelConfigImportDraft, @@ -85,10 +86,35 @@ async function scanPi( home: string, env: ModelConfigImportEnv, ): Promise { - const models = - (await readJson(path.join(home, ".pi", "agent", "models.json"))) ?? - (await readJson(path.join(home, ".pi", "models.json"))); - return parsePiModelConfig(models, env); + const agentDir = path.join(home, ".pi", "agent"); + const fallbackDir = path.join(home, ".pi"); + const [models, auth, modelsStore] = await Promise.all([ + readJson(path.join(agentDir, "models.json")) ?? + readJson(path.join(fallbackDir, "models.json")), + readJson(path.join(agentDir, "auth.json")) ?? + readJson(path.join(fallbackDir, "auth.json")), + // Pi caches the resolved catalog (including built-in providers) beside + // models.json. It is the only place an auth-only provider's base URL and + // model limits are available. + readJson(path.join(agentDir, "models-store.json")) ?? + readJson(path.join(fallbackDir, "models-store.json")), + ]); + const drafts = parsePiModelConfig(models, env); + const authConfig = parsePiAuthModelConfig(auth, modelsStore, env); + const byProvider = new Map(drafts.map((draft) => [draft.externalId, draft])); + for (const draft of authConfig.drafts) { + // models.json stays authoritative when it already describes the provider. + if (byProvider.has(draft.externalId)) continue; + drafts.push(draft); + byProvider.set(draft.externalId, draft); + } + return drafts.map((draft) => { + if (draft.hasSecret) return draft; + const secret = authConfig.apiKeys[draft.externalId]; + // A models.json `env:`/`$VAR` reference can resolve to nothing in the + // Desktop process; auth.json still holds the key Pi actually uses. + return secret ? { ...draft, secretValue: secret, hasSecret: true } : draft; + }); } async function scanCcSwitch( diff --git a/apps/desktop/test/model-config-import.test.mjs b/apps/desktop/test/model-config-import.test.mjs index defe5cacce..4cf34a48e8 100644 --- a/apps/desktop/test/model-config-import.test.mjs +++ b/apps/desktop/test/model-config-import.test.mjs @@ -10,6 +10,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); register(pathToFileURL(join(here, "helpers/ts-import-hooks.mjs"))); const { scanModelConfigs } = await import("../electron/main/importers/model-config.ts"); +const { publicModelConfigCandidate } = await import("@pi-desktop/shared"); test("scanModelConfigs reads Claude Code, OpenCode, Codex, and Pi stores", async () => { const home = await mkdtemp(join(tmpdir(), "pi-model-import-")); @@ -222,3 +223,83 @@ test("scanModelConfigs keeps the pi native config authoritative when a cc-switch assert.equal(piDrafts[0].modelIds.length, 10); assert.ok(piDrafts[0].modelIds.includes("m10")); }); + +// Regression: Settings → Models only read models.json, so providers connected +// through `pi /login` (auth.json only) never appeared in the Pi group. The scan +// now pairs auth.json API keys with Pi's cached catalog and fills keys that +// models.json references through an unresolved environment variable. +test("scanModelConfigs imports Pi auth-only providers and fills unresolved model keys", async () => { + const home = await mkdtemp(join(tmpdir(), "pi-agent-auth-import-")); + const agentDir = join(home, ".pi", "agent"); + await mkdir(agentDir, { recursive: true }); + + await writeFile( + join(agentDir, "models.json"), + JSON.stringify({ + providers: { + custom: { + baseUrl: "https://custom.example/v1", + api: "openai-completions", + apiKey: "env:CUSTOM_KEY", + models: [{ id: "custom-model" }], + }, + }, + }), + ); + await writeFile( + join(agentDir, "auth.json"), + JSON.stringify({ + custom: { type: "api_key", key: "sk-custom-auth" }, + deepseek: { type: "api_key", key: "sk-deepseek" }, + command: { type: "api_key", key: "!security find-generic-password" }, + codex: { type: "oauth", access: "oauth-access" }, + }), + ); + await writeFile( + join(agentDir, "models-store.json"), + JSON.stringify({ + deepseek: { + models: [ + { + id: "deepseek-flash", + api: "openai-completions", + baseUrl: "https://api.deepseek.com", + reasoning: true, + contextWindow: 1000000, + maxTokens: 384000, + }, + ], + }, + codex: { + models: [ + { + id: "gpt-6-sol", + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + }, + ], + }, + }), + ); + + const drafts = await scanModelConfigs({ homeDir: home, env: {} }); + const piDrafts = drafts.filter((draft) => draft.source === "pi"); + assert.deepEqual( + piDrafts.map((draft) => draft.externalId).sort(), + ["custom", "deepseek"], + ); + assert.equal(piDrafts.find((draft) => draft.externalId === "custom")?.secretValue, "sk-custom-auth"); + assert.equal(piDrafts.find((draft) => draft.externalId === "deepseek")?.secretValue, "sk-deepseek"); + assert.ok(!piDrafts.some((draft) => draft.externalId === "codex")); + assert.ok(!piDrafts.some((draft) => draft.externalId === "command")); + assert.ok( + !JSON.stringify(piDrafts.map((draft) => publicModelConfigCandidate(draft))).includes( + "sk-custom-auth", + ), + ); + assert.ok( + !JSON.stringify(piDrafts.map((draft) => publicModelConfigCandidate(draft))).includes( + "sk-deepseek", + ), + ); +}); diff --git a/docs/adr/0179-import-model-configuration.md b/docs/adr/0179-import-model-configuration.md index 241ec360bd..0bd05bbe97 100644 --- a/docs/adr/0179-import-model-configuration.md +++ b/docs/adr/0179-import-model-configuration.md @@ -1,7 +1,8 @@ # ADR 0179: Import model configuration from local agent stores - Status: Accepted for model import semantics; original Settings placement - superseded by ADR 0319 / D645 + superseded by ADR 0319 / D645 (amended 2026-10-18: Pi auth.json and cached + catalog) - Date: 2026-09-08 - Deciders: PI-Desktop core - Related: D007, D342, ADR 0012, @@ -41,7 +42,12 @@ credentials. Copying refresh tokens would be the wrong security boundary. - Codex: `~/.codex/config.toml` `[model_providers.]` - OpenCode: `~/.config/opencode/opencode.json` `provider` map plus `~/.local/share/opencode/auth.json` API keys - - Pi: `~/.pi/agent/models.json` (fallback `~/.pi/models.json`) + - Pi: `~/.pi/agent/models.json` (fallback `~/.pi/models.json`). + Providers connected through Pi's `~/.pi/agent/auth.json` API keys (fallback + `~/.pi/auth.json`) are paired with Pi's cached + `~/.pi/agent/models-store.json` (fallback `~/.pi/models-store.json`) so an + auth-only provider still gets its endpoint, model list, and limits. When + both files describe one provider, `models.json` stays authoritative. - CC Switch: `~/.cc-switch/cc-switch.db` `providers` table (legacy `config.json`). Each row's `settings_config` is converted by app type. Empty official seeds and OAuth-only rows are omitted. A live Claude / @@ -58,9 +64,11 @@ credentials. Copying refresh tokens would be the wrong security boundary. `Authorization: Bearer` header is copied into the host secret store. Placeholder values (`YOUR_API_KEY`, `${VAR}`) are treated as missing. A pi `apiKey` or auth header written as a `!` is never - executed or stored; that provider is imported without a secret. - OAuth auth.json entries and Codex `requires_openai_auth` tables without - a key are omitted or imported without a secret. + executed or stored; that provider is imported without a secret. Pi + `auth.json` entries of type `oauth` are skipped entirely; plain + `type: "api_key"` entries are copied. An unresolved `env:` / `$VAR` + reference in `models.json` is filled from the matching `auth.json` key + before import. 5. **Idempotence.** A candidate whose normalized base URL, API style, and credential match an existing provider is skipped. Different credentials @@ -87,3 +95,13 @@ credentials. Copying refresh tokens would be the wrong security boundary. row. Skip is safer; the user can edit the Models page. - Renderer-side `providers.create` with the key in the IPC payload: the scan cache already exists for sessions; keep secrets off the renderer. + +## Amendment — 2026-10-18: discover Pi auth-only providers + +Settings → Models only scanned `models.json`, so providers connected through +`pi /login` (for example DeepSeek or a Z.AI coding plan) never appeared in the +Pi group. The explicit scan now reads `auth.json` API keys and pairs them with +the catalog Pi already cached in `models-store.json`. OAuth/subscription grants +still stay in Pi, `!command` values are never executed or stored, and a +`models.json` provider remains authoritative when both files describe it. + diff --git a/docs/spec/03-runtime/11-provider-model-system.md b/docs/spec/03-runtime/11-provider-model-system.md index 30f0f76f88..43877f7985 100644 --- a/docs/spec/03-runtime/11-provider-model-system.md +++ b/docs/spec/03-runtime/11-provider-model-system.md @@ -577,11 +577,14 @@ type ModelDescriptor = { and keep dialog actions outside the independently scrollable content - do not expose raw catalog compatibility internals or provider secrets - Settings → Models → Providers can scan and copy provider/model rows from - Claude Code, Codex, OpenCode, Pi, and CC Switch. The inline scan is explicit. - Stored API keys are copied into the host secret store; OAuth/subscription - grants are not. An equivalent provider (normalized URL + API style + same - credential) is skipped on re-import. Different credentials at one endpoint - remain independent providers. No protocol or schema version bump + Claude Code, Codex, OpenCode, Pi, and CC Switch. The Pi scan also reads + `auth.json` API keys and Pi's cached `models-store.json` catalog, so + providers connected through `pi /login` are listed even when `models.json` + has no block for them. The inline scan is explicit. Stored API keys are + copied into the host secret store; OAuth/subscription grants are not. An + equivalent provider (normalized URL + API style + same credential) is + skipped on re-import. Different credentials at one endpoint remain + independent providers. No protocol or schema version bump (D342 / ADR 0179 / ADR 0188 / D645). ### Model selector diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index fc3b8c2607..7e1ee66e7a 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -13845,17 +13845,21 @@ are withdrawn with ADR 0165. - **Preconditions**: At least one supported local config exists among `~/.claude/settings.json`, `~/.codex/config.toml` `[model_providers.*]`, - `~/.config/opencode/opencode.json`, `~/.pi/agent/models.json`, or + `~/.config/opencode/opencode.json`, `~/.pi/agent/models.json`, + `~/.pi/agent/auth.json` plus `~/.pi/agent/models-store.json`, or `~/.cc-switch/cc-switch.db`, including two API-key profiles with the same - endpoint and different keys, and optionally one OAuth-only vendor. - PI-Desktop may already have an equivalent provider. + endpoint and different keys, one auth-only Pi API-key provider, and + optionally one OAuth-only vendor. PI-Desktop may already have an equivalent + provider. - **Steps**: 1. Open Settings → Models and expand **Import from other tools**. Confirm its scan explanation appears as inline text without a help icon, and the workbench remains idle until Scan is activated. 2. Scan model configuration. Confirm source groups start expanded, rows show name, model count, host, and an API key / No API key badge, and no - secret value appears in the UI or scan IPC payload. + secret value appears in the UI or scan IPC payload. Confirm the + auth-only Pi provider appears in the Pi group with its cached model + count and an API key badge. 3. Import the selected providers. Confirm both same-endpoint profiles appear as separate rows under Settings → Models and remain selectable in the Composer model menu. Re-import the same selection and confirm those @@ -13866,10 +13870,12 @@ are withdrawn with ADR 0165. 5. Confirm an OAuth-only source account is absent from the candidate list. - **Expected**: Explicit scan only (D007). Stored API keys land in the host - secret store. Only equivalent providers (normalized URL + API style + same - credential) skip; different credentials at one endpoint remain separate. - The expanded importer shows its scan explanation directly without a - question-mark control. No protocol or schema version bump. + secret store. Pi providers connected through `auth.json` are paired with + the cached `models-store.json` catalog; OAuth grants and `!command` keys + are not imported. Only equivalent providers (normalized URL + API style + + same credential) skip; different credentials at one endpoint remain + separate. The expanded importer shows its scan explanation directly + without a question-mark control. No protocol or schema version bump. - **Specs linked**: `04-ux/06-settings-ia.md`, `04-ux/08-component-spec.md` §18.5, `03-runtime/01-ipc-protocol.md`, `03-runtime/11-provider-model-system.md`, ADR 0179, D342 diff --git a/docs/spec/08-meta/decisions-log.md b/docs/spec/08-meta/decisions-log.md index 72603777f1..057e6fc9af 100644 --- a/docs/spec/08-meta/decisions-log.md +++ b/docs/spec/08-meta/decisions-log.md @@ -385,7 +385,7 @@ Gold source: local Codex electron captures; latest row wins where rows conflict. | D334 | Contained in-chat image display | **Amend the desktop `fs/read` workspace-only clause: `fs/read`, `fs/reveal`, and `fs/open` share `resolveOpenablePath` (workspace, `/scratch/`, `/attachments/`, plus `attachments/` blobs). Reads `realpath` the target. Add renderer-only `fs/readImageDataUrl` that returns a bounded image data URL or `missing` / `notImage` / `tooLarge` and never non-image bytes. A known image extension wins over client `mimeType`; extension-less blobs accept only the `IMAGE_MIME` allowlist. Chat thumbnails and local Markdown images load through that channel; click opens the host files viewer. Not a plugin API. See ADR 0172 and E2E-187.** | History attachments are extension-less data-root blobs the renderer origin cannot load; an unbounded absolute-path read would leak arbitrary files. | | D336 | Host-owned plugin completions and session context | **Amend D019: plugins may read the in-flight tool session's model-facing transcript through `pi.session.getLlmContext()` when `session.read` is granted, and may run a one-shot completion through `pi.agent.complete()` when `agent.complete` is granted. `pi.models.list()` lists ready provider/model rows (`models.list`). Credentials never leave Electron main. `includeSessionContext` requires both complete permissions and an in-flight tool session; plugins cannot pass a session id. Completions are rate-braked (8/60s) and size-capped. `session:modelChanged` is pushed after `session.configure`. The bundled first-party Advisor UX is temporarily not shipped; the public APIs remain available to explicitly installed plugins. No protocol/schema bump. See ADR 0174 and E2E-188 / E2E-189.** | Second-opinion tools need the user's models and the current LLM context without holding secrets or calling providers from plugin code. | | D340 | User-configurable outbound proxy | **Settings → General → Network exposes Proxy as System / Direct / Custom. Custom accepts http/https/socks5 URLs and a bypass list, persisted as optional `AppSettings.networkProxy`. Chromium `session.setProxy` covers the in-app browser and `net.fetch`; the agent sidecar applies an undici dispatcher (SOCKS5 CONNECT or HTTP ProxyAgent) via `sidecar.configure`; host-core marketplace curl uses `--proxy` from the stored blob and does not inherit proxy env (workspace Bash stays clean). OAuth still uses the system browser. No protocol or schema version bump. See ADR 0177 and E2E-190.** | Node fetch ignores the OS proxy, so Clash/V2Ray/SOCKS5 users could browse but not call models. One settings control should own app-owned HTTP. | -| D342 | Import model configuration from local agent stores | **Amend D007: Settings → Import still never auto-imports `~/.pi` (or any other tool). An explicit Model configuration card scans Claude Code, Codex, OpenCode, Pi, and CC Switch (`~/.cc-switch/cc-switch.db`, fallback `config.json`) provider rows, lists drafts without secrets, and `modelConfig/importRun` copies API keys through `providers.create`. A live tool file that matches a CC Switch endpoint and credential is not listed twice. OAuth/subscription tokens stay in the source app. Equivalent providers (normalized base URL + API style + same credential) are skipped; different credentials at one endpoint remain independent rows (ADR 0188). If no global default model exists, the first newly created provider becomes it. Electron IPC only; no host protocol or schema version bump. See ADR 0179, ADR 0188, and E2E-209. The Settings placement and session-import UI were superseded by D645 / ADR 0319: model import now lives on Models, and Settings has no session-import UI.** | Users already import sessions from those stores and otherwise retype the same endpoints and keys on the Models page. | +| D342 | Import model configuration from local agent stores | **Amend D007: Settings → Import still never auto-imports `~/.pi` (or any other tool). An explicit Model configuration card scans Claude Code, Codex, OpenCode, Pi, and CC Switch (`~/.cc-switch/cc-switch.db`, fallback `config.json`) provider rows, lists drafts without secrets, and `modelConfig/importRun` copies API keys through `providers.create`. The Pi scan also reads `~/.pi/agent/auth.json` API keys and pairs them with Pi's cached `models-store.json` catalog so auth-only providers are listed; `models.json` stays authoritative when both describe a provider. A live tool file that matches a CC Switch endpoint and credential is not listed twice. OAuth/subscription tokens stay in the source app. Equivalent providers (normalized base URL + API style + same credential) are skipped; different credentials at one endpoint remain independent rows (ADR 0188). If no global default model exists, the first newly created provider becomes it. Electron IPC only; no host protocol or schema version bump. See ADR 0179, ADR 0188, and E2E-209. The Settings placement and session-import UI were superseded by D645 / ADR 0319: model import now lives on Models, and Settings has no session-import UI.** | Users already import sessions from those stores and otherwise retype the same endpoints and keys on the Models page. | | D351 | Preserve distinct credentials during model configuration import | **Amend D342 / ADR 0179: an imported provider is equivalent only when normalized endpoint, API style, and credential all match. Same-endpoint profiles with different API keys create independent provider rows and remain selectable in Composer. Electron main resolves existing API keys through the host secret boundary; secrets never reach the renderer, logs, or provider metadata. The live-file-versus-CC-Switch scan uses the same credential-aware rule. No host protocol or storage schema version bump. See ADR 0188 and E2E-209.** | CC Switch stores multiple accounts at one gateway endpoint; endpoint-only idempotence silently dropped all but the first profile during import. | | D344 | Main-owned picker capabilities | **Amend D197 / D334: composer native file and image pickers keep selected absolute paths in Electron main behind a short-lived, sender-bound, one-shot token. `composer/importFiles` accepts only that token and a durable session id; the renderer never supplies source paths. The MVP file picker offers regular files only, so unsupported directory selection and its misleading UI copy are removed. Main retains realpath, regular-file, session-scratch, and size-limit validation. See ADR 0181 and E2E-102h.** | Renderer IPC is not a user-click gate: accepting arbitrary absolute paths in the import payload creates a local file exfiltration primitive. Folder import needs separate bounded traversal and symlink semantics. | | D314 | Shipped locale registry and language picker | **Amend D073: UI locales are listed in `@pi-desktop/i18n` (`en`, `zh-CN`, `zh-TW`, `tr`). Native names stay untranslated. Settings → General language is a searchable picker (Auto + registry), not three preview cards. Plugin labels keep their `en` + `zh-CN` contract with English fallback for shell locales without a plugin translation; the product changelog follows every shipped product locale. See ADR 0160, ADR 0182, and E2E-091.** | Preview cards cannot scale past two languages; a registry lets additional locales ship without rewriting the Appearance card. | diff --git a/docs/zh-CN/spec/08-meta/decisions-log.md b/docs/zh-CN/spec/08-meta/decisions-log.md index fbc570ab86..c5792d5581 100644 --- a/docs/zh-CN/spec/08-meta/decisions-log.md +++ b/docs/zh-CN/spec/08-meta/decisions-log.md @@ -386,7 +386,7 @@ | D334 | 受约束的聊天内图片显示 | **修订桌面 `fs/read` 仅工作区条款:`fs/read`、`fs/reveal` 和 `fs/open` 共用 `resolveOpenablePath`(工作区、`/scratch/`、`/attachments/`,以及 `attachments/` blob)。读取会 `realpath` 目标。** | 聊天需要内联图片,但不能把任意绝对路径交给渲染器。 | | D336 | 由宿主代发的插件补全与会话上下文 | **修订 D019:插件在授予 `session.read` 时可经 `pi.session.getLlmContext()` 读取进行中工具会话的面向模型转录,并在授予相应权限时经 `pi.agent.complete()` 跑一次性补全。见 ADR 0174。** | 插件需要第二意见补全,但不能自己拿提供商凭据。 | | D340 | 用户可配置的出站代理 | **设置 → 通用 → 网络把代理暴露为系统 / 直连 / 自定义。自定义接受 http/https/socks5 URL 和绕过列表,持久化为可选 `AppSettings.networkProxy`。Chromium `session.setProxy` 覆盖内置浏览器和 `net.fetch`;sidecar 经 undici dispatcher 应用。见 ADR 0177 与 E2E-190。** | Node fetch 忽略操作系统代理,Clash/V2Ray/SOCKS5 用户能浏览却不能调模型。 | -| D342 | 从本地智能体存储导入模型配置 | **修订 D007:设置 → 导入仍不自动导入 `~/.pi`。显式模型配置卡片扫描 Claude Code、Codex、OpenCode、Pi 和 CC Switch 提供商行,列出不含密钥的草稿,并由 `modelConfig/importRun` 经 `providers.create` 复制 API 密钥。等价提供商(规范化 base URL + API 风格 + 同一凭据)被跳过。见 ADR 0179、ADR 0188 与 E2E-209。设置入口和会话导入 UI 后由 D645 / ADR 0319 修订:模型导入现位于模型页,设置页不再提供会话导入。** | 用户已经从这些存储导入会话,否则要在模型页重打同样的端点和密钥。 | +| D342 | 从本地智能体存储导入模型配置 | **修订 D007:设置 → 导入仍不自动导入 `~/.pi`。显式模型配置卡片扫描 Claude Code、Codex、OpenCode、Pi 和 CC Switch 提供商行,列出不含密钥的草稿,并由 `modelConfig/importRun` 经 `providers.create` 复制 API 密钥。Pi 扫描还会读取 `~/.pi/agent/auth.json` 的 API 密钥,并与 Pi 缓存的 `models-store.json` 目录配对,使只存在于 auth.json 的提供商也能列出;两个文件都描述同一提供商时 `models.json` 仍为准。等价提供商(规范化 base URL + API 风格 + 同一凭据)被跳过。见 ADR 0179、ADR 0188 与 E2E-209。设置入口和会话导入 UI 后由 D645 / ADR 0319 修订:模型导入现位于模型页,设置页不再提供会话导入。** | 用户已经从这些存储导入会话,否则要在模型页重打同样的端点和密钥。 | | D351 | 模型配置导入保留不同凭据 | **修订 D342 / ADR 0179:导入提供商仅在规范化端点、API 风格和凭据都匹配时视为等价。同一端点不同 API 密钥创建独立提供商行,并仍可在 Composer 中选择。Electron main 经宿主密钥边界解析已有 API 密钥。见 ADR 0188 与 E2E-209。** | CC Switch 在同一网关端点存多个账户;仅按端点幂等会静默丢掉除第一个以外的配置。 | | D344 | 由主进程拥有的文件选择能力 | **修订 D197 / D334:Composer 原生文件和图片选择器把选中的绝对路径留在 Electron main,用短时、绑定发送者、一次性令牌保护。`composer/importFiles` 只接受该令牌和持久会话 id;渲染器从不提供源路径。MVP 文件选择器只提供常规文件。见 ADR 0181 与 E2E-102h。** | 渲染器 IPC 不是用户点击门禁:导入载荷接受任意绝对路径会变成本地文件外泄原语。 | | D314 | 已发布语言注册表与语言选择器 | **修订 D073:界面语言列在 `@pi-desktop/i18n`(`en`、`zh-CN`、`zh-TW`、`tr`)。本地名称永不翻译。设置 → 常规的语言是可搜索选择器(跟随系统 + 注册表),不再是三张预览卡。插件标签保持 `en` + `zh-CN` 契约;没有插件翻译的外壳语言使用英语回退;产品更新日志跟随每个已发布产品语言。见 ADR 0160、ADR 0182 与 E2E-091。** | 预览卡无法扩展到两种以上语言;注册表让添加语言无需重写外观卡即可发货。 | diff --git a/packages/shared/src/model-config-import.test.ts b/packages/shared/src/model-config-import.test.ts index a5260ad66b..d98efc47b4 100644 --- a/packages/shared/src/model-config-import.test.ts +++ b/packages/shared/src/model-config-import.test.ts @@ -9,6 +9,7 @@ import { parseCodexModelConfig, parseJsonDocument, parseOpenCodeModelConfig, + parsePiAuthModelConfig, parsePiModelConfig, providerCreateInputFromDraft, publicModelConfigCandidate, @@ -246,6 +247,113 @@ describe("parsePiModelConfig", () => { }); }); +describe("parsePiAuthModelConfig", () => { + it("pairs a stored API key with the provider's cached catalog", () => { + const { apiKeys, drafts } = parsePiAuthModelConfig( + { deepseek: { type: "api_key", key: "sk-deepseek" } }, + { + deepseek: { + models: [ + { + id: "deepseek-flash", + name: "DeepSeek V4.1 Flash", + api: "openai-completions", + baseUrl: "https://api.deepseek.com", + reasoning: true, + input: ["text", "image"], + contextWindow: 1000000, + maxTokens: 384000, + }, + { + id: "deepseek-v4-pro", + api: "openai-completions", + baseUrl: "https://api.deepseek.com", + }, + ], + }, + }, + ); + + expect(apiKeys).toEqual({ deepseek: "sk-deepseek" }); + expect(drafts).toHaveLength(1); + expect(drafts[0]).toMatchObject({ + source: "pi", + externalId: "deepseek", + baseUrl: "https://api.deepseek.com", + apiStyle: "chat_completions", + hasSecret: true, + }); + expect(drafts[0].modelIds).toEqual(["deepseek-flash", "deepseek-v4-pro"]); + expect(drafts[0].models[0]).toMatchObject({ + id: "deepseek-flash", + contextWindow: 1000000, + maxTokens: 384000, + supportsImages: true, + defaultThinkingLevel: "medium", + }); + }); + + it("skips OAuth grants and never stores a `!` command as a key", () => { + const { apiKeys, drafts } = parsePiAuthModelConfig( + { + codex: { type: "oauth", access: "oauth-access-token" }, + command: { type: "api_key", key: "!security find-generic-password -w" }, + }, + { + codex: { + models: [ + { id: "gpt-6-sol", api: "openai-codex-responses", baseUrl: "https://chatgpt.com/backend-api" }, + ], + }, + command: { + models: [ + { id: "cmd-model", api: "openai-completions", baseUrl: "https://api.example.com" }, + ], + }, + }, + ); + + expect(apiKeys).toEqual({}); + expect(drafts.map((draft) => draft.externalId)).toEqual(["command"]); + expect(drafts[0].hasSecret).toBe(false); + expect(drafts[0].secretValue).toBeUndefined(); + }); + + it("resolves an `env:` key and keeps one coherent endpoint group", () => { + const { apiKeys, drafts } = parsePiAuthModelConfig( + { mixed: { type: "api_key", key: "env:MIXED_KEY" } }, + { + mixed: { + models: [ + { id: "chat-a", api: "openai-completions", baseUrl: "https://api.example.com/v1" }, + { id: "chat-b", api: "openai-completions", baseUrl: "https://api.example.com/v1" }, + { id: "anthropic-a", api: "anthropic-messages", baseUrl: "https://api.example.com" }, + ], + }, + }, + { MIXED_KEY: "sk-mixed" }, + ); + + expect(apiKeys).toEqual({ mixed: "sk-mixed" }); + expect(drafts).toHaveLength(1); + expect(drafts[0]).toMatchObject({ + baseUrl: "https://api.example.com/v1", + apiStyle: "chat_completions", + }); + expect(drafts[0].modelIds).toEqual(["chat-a", "chat-b"]); + }); + + it("keeps the API key available when no cached catalog exists", () => { + const { apiKeys, drafts } = parsePiAuthModelConfig( + { custom: { type: "api_key", key: "sk-custom" } }, + {}, + ); + + expect(apiKeys).toEqual({ custom: "sk-custom" }); + expect(drafts).toEqual([]); + }); +}); + describe("matching and sanitizing", () => { it("matches only the same endpoint, api style, and credential", () => { const key = existingProviderMatchKey({ diff --git a/packages/shared/src/model-config-import.ts b/packages/shared/src/model-config-import.ts index 02c30e4456..df5cda73cc 100644 --- a/packages/shared/src/model-config-import.ts +++ b/packages/shared/src/model-config-import.ts @@ -462,6 +462,114 @@ export function parsePiModelConfig( return drafts; } +export type PiAuthModelConfig = { + /** + * Plain API keys from auth.json, keyed by provider id. OAuth grants and + * `!command` values are omitted; Pi keeps those credentials authoritative. + */ + apiKeys: Record; + /** + * Providers that exist in auth.json and the cached Pi model catalog but have + * no provider block in models.json. models.json remains authoritative when + * both files describe the same provider. + */ + drafts: ModelConfigImportDraft[]; +}; + +const PI_API_KEY_AUTH_TYPES = new Set(["api", "apikey", "api_key"]); + +/** + * Read the credentials Pi stores after `/login` and pair API-key providers with + * the model catalog Pi cached for them. + * + * Only plain API keys cross into the import drafts: OAuth/subscription grants + * stay with Pi, and `!command` keys are never executed or stored as literal + * secrets. A provider's cached models can span several base URLs / API styles; + * a Desktop provider row carries one protocol and one endpoint, so the largest + * coherent group wins and the remaining models stay available in Pi. + */ +export function parsePiAuthModelConfig( + authJson: unknown, + modelsStoreJson: unknown, + env: ModelConfigImportEnv = {}, +): PiAuthModelConfig { + const auth = asRecord(authJson) ?? {}; + const store = asRecord(modelsStoreJson) ?? {}; + const apiKeys: Record = {}; + const drafts: ModelConfigImportDraft[] = []; + + for (const [id, raw] of Object.entries(auth)) { + const record = asRecord(raw); + if (!record) continue; + const kind = firstString(record.type, record.kind)?.toLowerCase(); + // auth.json also holds OAuth/subscription grants, which stay in Pi. + if (kind && !PI_API_KEY_AUTH_TYPES.has(kind)) continue; + const rawKey = authApiKey(record); + if (rawKey && !isShellCommandSecret(rawKey)) { + const secret = resolveSecret(rawKey, env); + if (secret) apiKeys[id] = secret; + } + + const entry = asRecord(store[id]); + const models = Array.isArray(entry?.models) ? entry.models : []; + const selection = selectPiModelEntry(models); + if (!selection) continue; + const preset = matchNamedPreset({ + vendorKey: id, + baseUrl: selection.baseUrl, + apiStyle: selection.apiStyle, + }); + const draft = finishDraft({ + source: "pi", + externalId: id, + name: preset?.name ?? id, + baseUrl: selection.baseUrl, + apiStyle: selection.apiStyle, + vendorHint: id, + modelIds: selection.models + .map((model) => firstString(asRecord(model)?.id)) + .filter((modelId): modelId is string => !!modelId), + secretValue: apiKeys[id], + supportsReasoning: selection.models.some( + (model) => asRecord(model)?.reasoning === true, + ), + models: selection.models + .map((model) => bindingFromPiModel(model)) + .filter((binding): binding is ModelBinding => binding !== null), + }); + if (draft) drafts.push(draft); + } + + return { apiKeys, drafts }; +} + +type PiModelEntrySelection = { + baseUrl: string; + apiStyle: CatalogApiStyle; + models: unknown[]; +}; + +function selectPiModelEntry(models: unknown[]): PiModelEntrySelection | null { + const groups = new Map(); + for (const raw of models) { + const record = asRecord(raw); + const modelId = firstString(record?.id); + const baseUrl = firstString(record?.baseUrl, record?.baseURL); + const apiStyle = resolveApiStyle(firstString(record?.api, record?.apiStyle)); + if (!modelId || !baseUrl || !apiStyle) continue; + const key = `${baseUrl}\n${apiStyle}`; + const group = groups.get(key); + if (group) group.models.push(raw); + else groups.set(key, { baseUrl, apiStyle, models: [raw] }); + } + let selected: PiModelEntrySelection | null = null; + for (const group of groups.values()) { + // Ties keep the first catalog group so repeated scans stay deterministic. + if (!selected || group.models.length > selected.models.length) selected = group; + } + return selected; +} + const CC_SWITCH_APP_TYPES = [ "claude", "claude-desktop", From e3b150e180596cdc2bcb618eb3dc419a7a05f6f2 Mon Sep 17 00:00:00 2001 From: isword Date: Sun, 18 Oct 2026 23:01:38 +0800 Subject: [PATCH 2/3] fix(importers): scan Pi agent MCP configuration The explicit MCP scan covered Claude, Codex, Cursor, opencode, and the ChatGPT placeholder, but not Pi. A user with servers configured in ~/.pi/agent/mcp.json saw an empty import list even though Pi connected to those servers. Scan the user-level Pi mcp.json with the existing JSON mcpServers reader, report it as its own source, and label the group in every shipped locale. Project-level .pi/mcp.json stays out of scope because Pi only loads it after project trust. --- .../electron/main/importers/agent-mcp-scan.ts | 28 +++++--- .../electron/test/agent-mcp-scan.test.mjs | 68 +++++++++++++++++++ .../settings/imports/AgentMcpImportPanel.tsx | 1 + apps/desktop/src/lib/api.ts | 1 + docs/spec/03-runtime/01-ipc-protocol.md | 3 +- packages/i18n/src/locales/de/index.ts | 1 + packages/i18n/src/locales/en/index.ts | 1 + packages/i18n/src/locales/es/index.ts | 1 + packages/i18n/src/locales/fr/index.ts | 1 + packages/i18n/src/locales/ko/index.ts | 1 + packages/i18n/src/locales/pt-BR/index.ts | 1 + packages/i18n/src/locales/tr/index.ts | 1 + packages/i18n/src/locales/zh-CN/index.ts | 1 + packages/i18n/src/locales/zh-TW/index.ts | 1 + 14 files changed, 101 insertions(+), 9 deletions(-) diff --git a/apps/desktop/electron/main/importers/agent-mcp-scan.ts b/apps/desktop/electron/main/importers/agent-mcp-scan.ts index f79dd2afa3..30bee14624 100644 --- a/apps/desktop/electron/main/importers/agent-mcp-scan.ts +++ b/apps/desktop/electron/main/importers/agent-mcp-scan.ts @@ -18,6 +18,7 @@ export type McpSourceKind = | "cursor-project" | "codex" | "opencode" + | "pi" | "chatgpt-desktop"; export interface McpCandidate { @@ -536,19 +537,29 @@ async function scanChatgptDesktop(): Promise<{ }; } +async function scanPi( + home: string, +): Promise<{ report: McpSourceReport; candidates: McpCandidate[] }> { + return scanJsonMcpServers("pi", path.join(home, ".pi", "agent", "mcp.json"), (root) => + isRecord(root.mcpServers) ? root.mcpServers : null, + ); +} + export async function scanExternalMcp(opts: McpScanOptions = {}): Promise { const home = opts.homeDir ?? os.homedir(); const platform = opts.platform ?? process.platform; const env = opts.env ?? process.env; - const [claudeDesktop, claudeCode, cursorGlobal, opencode, codex, chatgpt] = await Promise.all([ - scanClaudeDesktop(home, platform, env), - scanClaudeCode(home), - scanCursorGlobal(home), - scanOpencode(home, env), - scanCodex(home), - scanChatgptDesktop(), - ]); + const [claudeDesktop, claudeCode, cursorGlobal, opencode, codex, pi, chatgpt] = + await Promise.all([ + scanClaudeDesktop(home, platform, env), + scanClaudeCode(home), + scanCursorGlobal(home), + scanOpencode(home, env), + scanCodex(home), + scanPi(home), + scanChatgptDesktop(), + ]); const parts: Array<{ reports: McpSourceReport[]; candidates: McpCandidate[] }> = [ { reports: [claudeDesktop.report], candidates: claudeDesktop.candidates }, @@ -556,6 +567,7 @@ export async function scanExternalMcp(opts: McpScanOptions = {}): Promise { + const { root, home } = await makeHome(); + try { + const cfg = join(home, ".pi", "agent", "mcp.json"); + await mkdir(dirname(cfg), { recursive: true }); + await writeFile( + cfg, + JSON.stringify({ + mcpServers: { + serena: { + command: "uvx", + args: ["--from", "git+https://github.com/oraios/serena", "serena"], + env: { SERENA_HOME: "/tmp/serena" }, + description: "Code intelligence", + }, + docs: { + url: "https://mcp.example.com/mcp", + headers: { Authorization: "Bearer literal" }, + }, + paused: { command: "node", enabled: false }, + }, + }), + ); + + const result = await scanExternalMcp({ homeDir: home, platform: "linux", env: {} }); + const found = result.candidates.filter((candidate) => candidate.source === "pi"); + assert.equal(found.length, 3); + + const serena = found.find((candidate) => candidate.id === "serena"); + assert.equal(serena.transport, "stdio"); + assert.equal(serena.command, "uvx"); + assert.deepEqual(serena.args, ["--from", "git+https://github.com/oraios/serena", "serena"]); + assert.deepEqual(serena.env, { SERENA_HOME: "/tmp/serena" }); + assert.equal(serena.description, "Code intelligence"); + + const docs = found.find((candidate) => candidate.id === "docs"); + assert.equal(docs.transport, "http"); + assert.equal(docs.url, "https://mcp.example.com/mcp"); + assert.deepEqual(docs.headers, { Authorization: "Bearer literal" }); + + const paused = found.find((candidate) => candidate.id === "paused"); + assert.equal(paused.disabled, true); + + const source = result.sources.find((item) => item.kind === "pi"); + assert.equal(source.exists, true); + assert.equal(source.count, 3); + assert.ok(source.path.endsWith("home/.pi/agent/mcp.json")); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("a broken pi agent mcp.json surfaces on its source row", async () => { + const { root, home } = await makeHome(); + try { + const cfg = join(home, ".pi", "agent", "mcp.json"); + await mkdir(dirname(cfg), { recursive: true }); + await writeFile(cfg, "{ not json"); + const result = await scanExternalMcp({ homeDir: home, platform: "linux", env: {} }); + const source = result.sources.find((item) => item.kind === "pi"); + assert.equal(source.exists, true); + assert.match(source.error, /invalid json/); + assert.equal(result.candidates.filter((candidate) => candidate.source === "pi").length, 0); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("parseCodexMcpToml ignores unrelated sections and comments", () => { const parsed = parseCodexMcpToml( [ diff --git a/apps/desktop/src/features/settings/imports/AgentMcpImportPanel.tsx b/apps/desktop/src/features/settings/imports/AgentMcpImportPanel.tsx index 09ab31c649..c152a2bdb5 100644 --- a/apps/desktop/src/features/settings/imports/AgentMcpImportPanel.tsx +++ b/apps/desktop/src/features/settings/imports/AgentMcpImportPanel.tsx @@ -28,6 +28,7 @@ const MCP_SOURCE_KEY: Record = { "cursor-project": "settings.importAgentScanSourceCursorProject", codex: "settings.importAgentScanSourceCodex", opencode: "settings.importAgentScanSourceOpenCode", + pi: "settings.importAgentScanSourcePi", "chatgpt-desktop": "settings.importAgentScanSourceChatgpt", }; diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 79ed9b38f2..7cdd8f0745 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -262,6 +262,7 @@ export type ExternalMcpSourceKind = | "cursor-project" | "codex" | "opencode" + | "pi" | "chatgpt-desktop"; export interface ExternalMcpCandidate { diff --git a/docs/spec/03-runtime/01-ipc-protocol.md b/docs/spec/03-runtime/01-ipc-protocol.md index 64c0fa9051..81266de7a2 100644 --- a/docs/spec/03-runtime/01-ipc-protocol.md +++ b/docs/spec/03-runtime/01-ipc-protocol.md @@ -1589,7 +1589,8 @@ Desktop-only channels scan configuration written by other agent tools on the same machine — Claude Desktop (`claude_desktop_config.json` on macOS, Windows and Linux), Claude Code (`~/.claude.json` and `~/.claude/settings.json` merged), Cursor global and per-project `mcp.json`, Codex (`~/.codex/config.toml` -`[mcp_servers.*]`), opencode (`~/.config/opencode/opencode.json` `mcp` map) — +`[mcp_servers.*]`), opencode (`~/.config/opencode/opencode.json` `mcp` map), +Pi (`~/.pi/agent/mcp.json` `mcpServers`) — so the user can review and batch-import into this app's MCP list. ChatGPT desktop is listed as a placeholder because it has no public configuration path yet. diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index 0fa00141cb..24772c9d1b 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -1223,6 +1223,7 @@ sklm: { importAgentScanSourceCursorProject: "Cursor project", importAgentScanSourceCodex: "Codex", importAgentScanSourceOpenCode: "opencode", + importAgentScanSourcePi: "PI agent", importAgentScanSourceChatgpt: "ChatGPT desktop", importAgentScanSourcePiUser: "PI global (already imported)", importAgentScanSourcePiProject: "PI project (already imported)", diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index 7b16fca985..5fc7defcd2 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -1075,6 +1075,7 @@ sklm: { importAgentScanSourceCursorProject: "Cursor project", importAgentScanSourceCodex: "Codex", importAgentScanSourceOpenCode: "opencode", + importAgentScanSourcePi: "PI agent", importAgentScanSourceChatgpt: "ChatGPT desktop", importAgentScanSourcePiUser: "PI global (already imported)", importAgentScanSourcePiProject: "PI project (already imported)", diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index 205a5595ad..21b2e8eaf8 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -1223,6 +1223,7 @@ sklm: { importAgentScanSourceCursorProject: "Cursor project", importAgentScanSourceCodex: "Codex", importAgentScanSourceOpenCode: "opencode", + importAgentScanSourcePi: "PI agent", importAgentScanSourceChatgpt: "ChatGPT desktop", importAgentScanSourcePiUser: "PI global (already imported)", importAgentScanSourcePiProject: "PI project (already imported)", diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index f015a56042..282fa5f5d1 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -1223,6 +1223,7 @@ sklm: { importAgentScanSourceCursorProject: "Cursor project", importAgentScanSourceCodex: "Codex", importAgentScanSourceOpenCode: "opencode", + importAgentScanSourcePi: "PI agent", importAgentScanSourceChatgpt: "ChatGPT desktop", importAgentScanSourcePiUser: "PI global (already imported)", importAgentScanSourcePiProject: "PI project (already imported)", diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index 5c5b1723f9..604734e41c 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -1231,6 +1231,7 @@ sklm: { importAgentScanSourceCursorProject: "Cursor project", importAgentScanSourceCodex: "Codex", importAgentScanSourceOpenCode: "opencode", + importAgentScanSourcePi: "PI agent", importAgentScanSourceChatgpt: "ChatGPT desktop", importAgentScanSourcePiUser: "PI global (already imported)", importAgentScanSourcePiProject: "PI project (already imported)", diff --git a/packages/i18n/src/locales/pt-BR/index.ts b/packages/i18n/src/locales/pt-BR/index.ts index 48036f4236..d6cc5d1768 100644 --- a/packages/i18n/src/locales/pt-BR/index.ts +++ b/packages/i18n/src/locales/pt-BR/index.ts @@ -1045,6 +1045,7 @@ export const ptBR = { importAgentScanSourceCursorProject: "Projeto Cursor", importAgentScanSourceCodex: "Codex", importAgentScanSourceOpenCode: "opencode", + importAgentScanSourcePi: "PI agent", importAgentScanSourceChatgpt: "ChatGPT desktop", importAgentScanSourcePiUser: "PI global (já importado)", importAgentScanSourcePiProject: "Projeto PI (já importado)", diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index 10d7331cff..861bd4896f 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -1225,6 +1225,7 @@ sklm: { importAgentScanSourceCursorProject: "Cursor project", importAgentScanSourceCodex: "Codex", importAgentScanSourceOpenCode: "opencode", + importAgentScanSourcePi: "PI agent", importAgentScanSourceChatgpt: "ChatGPT desktop", importAgentScanSourcePiUser: "PI global (already imported)", importAgentScanSourcePiProject: "PI project (already imported)", diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index b193bfda29..56706cac6f 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -1216,6 +1216,7 @@ sklm: { importAgentScanSourceCursorProject: "Cursor 项目", importAgentScanSourceCodex: "Codex", importAgentScanSourceOpenCode: "opencode", + importAgentScanSourcePi: "PI agent", importAgentScanSourceChatgpt: "ChatGPT 桌面版", importAgentScanSourcePiUser: "PI 全局(已导入)", importAgentScanSourcePiProject: "PI 项目(已导入)", diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index a954bec613..461af4dc3d 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -1216,6 +1216,7 @@ sklm: { importAgentScanSourceCursorProject: "Cursor project", importAgentScanSourceCodex: "Codex", importAgentScanSourceOpenCode: "opencode", + importAgentScanSourcePi: "PI agent", importAgentScanSourceChatgpt: "ChatGPT desktop", importAgentScanSourcePiUser: "PI global (already imported)", importAgentScanSourcePiProject: "PI project (already imported)", From 04fe4254c354c8e60283879ae034aa94396e29e8 Mon Sep 17 00:00:00 2001 From: isword Date: Sun, 18 Oct 2026 23:02:33 +0800 Subject: [PATCH 3/3] docs(adr): trim trailing blank line Keep the ADR ending consistent with the repository's whitespace checks. --- docs/adr/0179-import-model-configuration.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/adr/0179-import-model-configuration.md b/docs/adr/0179-import-model-configuration.md index 0bd05bbe97..cc4db40c15 100644 --- a/docs/adr/0179-import-model-configuration.md +++ b/docs/adr/0179-import-model-configuration.md @@ -104,4 +104,3 @@ Pi group. The explicit scan now reads `auth.json` API keys and pairs them with the catalog Pi already cached in `models-store.json`. OAuth/subscription grants still stay in Pi, `!command` values are never executed or stored, and a `models.json` provider remains authoritative when both files describe it. -