diff --git a/apps/web/tests/expected/models-settings/declared-edit.expected.md b/apps/web/tests/expected/models-settings/declared-edit.expected.md index 85aa3923d0..c8f652fbf3 100644 --- a/apps/web/tests/expected/models-settings/declared-edit.expected.md +++ b/apps/web/tests/expected/models-settings/declared-edit.expected.md @@ -36,6 +36,7 @@ - option "OpenAI Chat Completions" [selected] - option "OpenAI Responses" - option "Anthropic Messages" + - option "Gemini API" - group "重试策略": - text: 重试策略 - combobox "重试策略": diff --git a/apps/web/tests/expected/models-settings/image-only.expected.md b/apps/web/tests/expected/models-settings/image-only.expected.md index 9645929155..051df42792 100644 --- a/apps/web/tests/expected/models-settings/image-only.expected.md +++ b/apps/web/tests/expected/models-settings/image-only.expected.md @@ -36,6 +36,7 @@ - option "OpenAI Chat Completions" - option "OpenAI Responses" - option "Anthropic Messages" [selected] + - option "Gemini API" - group "重试策略": - text: 重试策略 - combobox "重试策略": diff --git a/apps/web/tests/expected/models-settings/mobile-collapsed.expected.md b/apps/web/tests/expected/models-settings/mobile-collapsed.expected.md index 2f573f6e53..c052e8c61f 100644 --- a/apps/web/tests/expected/models-settings/mobile-collapsed.expected.md +++ b/apps/web/tests/expected/models-settings/mobile-collapsed.expected.md @@ -36,6 +36,7 @@ - option "OpenAI Chat Completions" - option "OpenAI Responses" - option "Anthropic Messages" [selected] + - option "Gemini API" - group "重试策略": - text: 重试策略 - combobox "重试策略": diff --git a/apps/web/tests/expected/models-settings/mobile-expanded.expected.md b/apps/web/tests/expected/models-settings/mobile-expanded.expected.md index 5e0855c653..69444fb808 100644 --- a/apps/web/tests/expected/models-settings/mobile-expanded.expected.md +++ b/apps/web/tests/expected/models-settings/mobile-expanded.expected.md @@ -36,6 +36,7 @@ - option "OpenAI Chat Completions" - option "OpenAI Responses" - option "Anthropic Messages" [selected] + - option "Gemini API" - group "重试策略": - text: 重试策略 - combobox "重试策略": diff --git a/apps/web/tests/forge-panel.e2e.ts b/apps/web/tests/forge-panel.e2e.ts index 6654187eb8..44f24a6a58 100644 --- a/apps/web/tests/forge-panel.e2e.ts +++ b/apps/web/tests/forge-panel.e2e.ts @@ -892,10 +892,13 @@ it.skipIf(MODE === 'record')('shows the local changes of the Session by scope an await view.getByRole('button', { name: /^(将文件树移到右侧|隐藏文件树|在左侧显示文件树)$/ }).click() expect(await resizeTree.getAttribute('aria-valuenow')).toBe(rightWidth) for (const side of ['old', 'new'] as const) { - const firstLine = await panelFile.locator(`[data-review-code][data-${side}-line="2"]`).boundingBox() + const firstCode = panelFile.locator(`[data-review-code][data-${side}-line="2"]`) + const firstLine = await firstCode.boundingBox() const lastLine = await panelFile.locator(`[data-review-code][data-${side}-line="4"]`).boundingBox() if (firstLine === null || lastLine === null) throw new Error('Review text must be visible') - await page.mouse.move(firstLine.x + 7, firstLine.y + firstLine.height / 2) + // Press on the first character, past the lane where the hovered row shows its comment plus. + const textStart = await firstCode.evaluate(element => Number.parseFloat(getComputedStyle(element).paddingLeft)) + await page.mouse.move(firstLine.x + textStart + 1, firstLine.y + firstLine.height / 2) await page.mouse.down() await page.mouse.move(lastLine.x + 120, lastLine.y + lastLine.height / 2, { steps: 5 }) await page.mouse.up() @@ -1170,6 +1173,109 @@ it.skipIf(MODE === 'record')('uses horizontal arrows to navigate files while pre expect(tripwire.warnings).toEqual([]) }) +it.skipIf(MODE === 'record')('opens a comment from the hover plus in one press without covering the line number or the code', async () => { + const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-hover-plus') + // notes.txt changes into context, a deletion followed by context, and an addition, so unified rows show both numbers. + await writeFile(join(project, 'notes.txt'), ['keep 1', 'keep 2', 'drop 3', 'keep 4', 'keep 5', 'keep 6'].map(line => `${line}\n`).join('')) + await git(project, 'add', 'notes.txt') + await git(project, 'commit', '--quiet', '-m', 'notes') + await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD') + await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main') + await writeFile(join(project, 'README.md'), `changed\n${Array.from({ length: 8 }, (_, line) => `line ${line + 2}\n`).join('')}`) + await writeFile(join(project, 'notes.txt'), ['keep 1', 'keep 2', 'keep 4', 'keep 5', 'added', 'keep 6'].map(line => `${line}\n`).join('')) + const { settled } = await sendPrompt(page, scaffold) + await settled + await page.locator('[data-forge-header]').click() + await page.locator('[data-forge-panel]').getByRole('button', { name: '刷新状态', exact: true }).click() + await page.locator('[data-forge-local]').getByRole('button', { name: /查看本会话的变更/ }).click() + const view = page.locator('[data-forge-changes="local"]') + const file = view.locator('[data-forge-diff-file="README.md"]') + const number = (name: string) => file.getByRole('button', { name: `选择第 ${name} 行`, exact: true }) + const plus = (name: string) => file.locator(`[data-review-add="${name}"]`) + const code = (line: number) => file.locator(`[data-review-code][data-new-line="${line}"]`) + const draggable = async (from: string, to: string) => { + const start = await plus(from).boundingBox(), end = await number(to).boundingBox() + if (start === null || end === null) throw new Error('The plus and the line number must be rendered') + const x = start.x + start.width / 2 + return { start: { x, y: start.y + start.height / 2 }, end: { x, y: end.y + end.height / 2 } } + } + await number('R3').waitFor() + + // The plus appears only on the hovered side of the hovered row, for the code as for the line number. + expect(await plus('R3').isVisible()).toBe(false) + await code(3).hover() + expect(await plus('R3').isVisible()).toBe(true) + expect(await plus('R4').isVisible()).toBe(false) + expect(await plus('L1').isVisible()).toBe(false) + await number('L1').hover() + expect(await plus('L1').isVisible()).toBe(true) + expect(await plus('R3').isVisible()).toBe(false) + + // Playwright refuses a click on a covered point, so these prove the hovered number and first character stay reachable. + await number('R3').hover() + await number('R3').click() + expect(await number('R3').getAttribute('aria-pressed')).toBe('true') + const first = await code(5).boundingBox() + const textStart = await code(5).evaluate(element => Number.parseFloat(getComputedStyle(element).paddingLeft)) + if (first === null) throw new Error('Review text must be visible') + await page.mouse.move(first.x + textStart + 1, first.y + first.height / 2) + await page.mouse.down() + await page.mouse.move(first.x + textStart + 30, first.y + first.height / 2, { steps: 3 }) + await page.mouse.up() + expect(await page.evaluate(() => document.getSelection()?.toString())).toMatch(/^li/) + await page.evaluate(() => { document.getSelection()?.removeAllRanges() }) + + // One press selects, a drag extends within the side, and the release opens the editor. + await code(6).hover() + const { start, end } = await draggable('R6', 'R8') + await page.mouse.move(start.x, start.y) + await page.mouse.down() + await page.mouse.move(end.x, end.y, { steps: 5 }) + expect(await number('R8').getAttribute('aria-pressed')).toBe('true') + expect(await file.getByRole('textbox').count()).toBe(0) + await page.mouse.up() + await file.getByRole('region', { name: '评论 R6–8', exact: true }).waitFor() + expect(await plus('R6').count()).toBe(0) + await file.getByRole('button', { name: '取消', exact: true }).click() + await file.getByRole('textbox').waitFor({ state: 'detached' }) + + // The single-column layout keeps the same gesture on the line's own side. + await view.getByRole('button', { name: '左右对比', exact: true }).click() + await file.locator('[data-diff-line]').first().waitFor() + await code(4).hover() + expect(await plus('R4').isVisible()).toBe(true) + await number('R4').click() + expect(await number('R4').getAttribute('aria-pressed')).toBe('true') + + // With both numbers shown, a deleted line's plus sits where a context line's does: in the sign column. + const notes = view.locator('[data-forge-diff-file="notes.txt"]') + const notesPlus = (name: string) => notes.locator(`[data-review-add="${name}"]`) + const notesCode = (side: 'old' | 'new', line: number) => notes.locator(`[data-review-code][data-${side}-line="${line}"]`) + await notesCode('new', 3).hover() + const contextPlus = await notesPlus('R3').boundingBox() + await notesCode('old', 3).hover() + const deletedPlus = await notesPlus('L3').boundingBox() + const nextRow = await notesCode('old', 4).boundingBox() + if (contextPlus === null || deletedPlus === null || nextRow === null) throw new Error('The pluses and the next row must be rendered') + expect(deletedPlus.x).toBe(contextPlus.x) + // A drag from the deleted line's plus extends through the context row below it on the old side. + const x = deletedPlus.x + deletedPlus.width / 2 + await page.mouse.move(x, deletedPlus.y + deletedPlus.height / 2) + await page.mouse.down() + await page.mouse.move(x, nextRow.y + nextRow.height / 2, { steps: 5 }) + await page.mouse.up() + await notes.getByRole('region', { name: '评论 L3–4', exact: true }).waitFor() + await notes.getByRole('button', { name: '取消', exact: true }).click() + await notes.getByRole('textbox').waitFor({ state: 'detached' }) + // The labeled plus of a left context line stays reachable; Playwright refuses a covered target. + await notes.getByRole('button', { name: '选择第 L2 行', exact: true }).click() + expect((await notes.getByRole('button', { name: '添加评论', exact: true }).boundingBox())?.x).toBe(contextPlus.x) + await notes.getByRole('button', { name: '添加评论', exact: true }).click() + await notes.getByRole('region', { name: '评论 L2', exact: true }).waitFor() + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) +}) + it.skipIf(MODE === 'record')('keeps tall branch and changed-file menus aligned with their triggers', async () => { const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-tall-menus') await Promise.all(Array.from({ length: 45 }, (_, index) => diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 58d76d04f1..950076a427 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -298,7 +298,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { // The custom-API mode is the second segment of the one add card; its // purpose line replaces the catalog one. await dialog.getByRole('tab', { name: '自定义模型 API' }).click() - await dialog.getByText('连接中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口,需填写 API 地址、协议和模型。').waitFor({ timeout: 10_000 }) + await dialog.getByText('连接中转站、自部署服务或其他兼容 OpenAI / Anthropic / Gemini 协议的接口,需填写 API 地址、协议和模型。').waitFor({ timeout: 10_000 }) // The third-party panel stays mounted, hidden, beside this one, so the // form is addressed through its own panel. const custom = dialog.getByRole('tabpanel', { name: '自定义模型 API' }) diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index b04aada43b..899572eab5 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -12,8 +12,8 @@ en: f6fb44a85dc5e4dd zh: 2b86ea4492c32ed7 /configure-models/add-a-custom-model-api: - en: 17867305a3e1b52a - zh: 876fedf1f6f563e4 + en: 1f55d7d94cbfbd80 + zh: 94e5a8bc7625d760 /configure-models/add-a-custom-model-api/discover-models: en: ec10ba53a00eddc6 zh: 857373a4e0fefee4 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index fe831bcaf9..90b70e0696 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -26,7 +26,7 @@ A nonempty key input has an eye button. Select **Show secret** to check what you ## Add a custom model API -Switch the card to **Custom model API** for a relay, a company gateway, a self-hosted server, or any provider absent from the installed catalog. Supply a lowercase Provider ID, base URL, API protocol, credential, and at least one model. The **API protocol** must be the one your gateway speaks, and the picker offers three: OpenAI Chat Completions, OpenAI Responses, and Anthropic Messages, stored in the active profile's `cordis.patch.yml` as `openai-completions`, `openai-responses`, and `anthropic-messages`. A provider speaks one protocol, so a gateway that serves two needs two providers. +Switch the card to **Custom model API** for a relay, a company gateway, a self-hosted server, or any provider absent from the installed catalog. Supply a lowercase Provider ID, base URL, API protocol, credential, and at least one model. The **API protocol** must be the one your gateway speaks, and the picker offers four: OpenAI Chat Completions, OpenAI Responses, Anthropic Messages, and Gemini API, stored in the active profile's `cordis.patch.yml` as `openai-completions`, `openai-responses`, `anthropic-messages`, and `google-generative-ai`. A Gemini API base URL includes the API version, such as `https://gateway.example/v1beta`. A provider speaks one protocol, so a gateway that serves two needs two providers. ![The custom model API form: Provider ID, display name, base URL, API protocol, and API key](providers-custom-form.png) diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index bb518c42b9..c580fd2ab0 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -26,7 +26,7 @@ ## 添加自定义模型 API -对于中转站、公司网关、自建服务器或已安装目录中不存在的提供商,把卡片切换到**自定义模型 API**。提供小写 Provider ID、基础 URL、API 协议、凭据和至少一个模型。**API 协议**必须选网关实际使用的那一种,选择框提供三种:OpenAI Chat Completions、OpenAI Responses 和 Anthropic Messages,在当前 profile 的 `cordis.patch.yml` 中分别存为 `openai-completions`、`openai-responses` 和 `anthropic-messages`。一个提供商只使用一种协议,网关同时提供两种时需要建两个提供商。 +对于中转站、公司网关、自建服务器或已安装目录中不存在的提供商,把卡片切换到**自定义模型 API**。提供小写 Provider ID、基础 URL、API 协议、凭据和至少一个模型。**API 协议**必须选网关实际使用的那一种,选择框提供四种:OpenAI Chat Completions、OpenAI Responses、Anthropic Messages 和 Gemini API,在当前 profile 的 `cordis.patch.yml` 中分别存为 `openai-completions`、`openai-responses`、`anthropic-messages` 和 `google-generative-ai`。Gemini API 的基础 URL 包含 API 版本,例如 `https://gateway.example/v1beta`。一个提供商只使用一种协议,网关同时提供两种时需要建两个提供商。 ![自定义模型 API 表单:Provider ID、显示名称、API 地址、API 协议、API 密钥](providers-custom-form.zh.png) diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index b498c4ba84..74ecbbb6d5 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -21,11 +21,11 @@ en: f4817eb2e3537ed9 zh: e589c193fa85473f /deepseek-ai-dsh-client-ui-settings-models/use-this-package/editing-a-provider: - en: 03a9953b8aa0c97a - zh: f3a5bcaa34193bcd + en: a0501172f15c3286 + zh: 6dfa112f215b2d3d /deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers: - en: c0b04b2b11af60d3 - zh: d195eec281e445cb + en: 59472e717c2f87be + zh: dc2a51a5a40138bc /deepseek-ai-dsh-client-ui-settings-models/use-this-package/first-run-dialogs: en: 589397a35f82244e zh: 342fbceee2c173a2 @@ -54,8 +54,8 @@ en: ca75c51c89c2c9b0 zh: 9779c98fff4c949a /deepseek-ai-dsh-client-ui-settings-models/known-limitations-and-deferred-work: - en: 480bf2ddd4b76779 - zh: dfda0c29be636f84 + en: 352101d81796eea8 + zh: dadae6c3032471ab /deepseek-ai-dsh-client-ui-settings-models/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 84c0e2d678..5039f7988d 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -63,13 +63,13 @@ Drag a model’s reorder handle to change its position within the provider, or f Expand **Customized settings → Model options** to edit each model. When the model row is narrow, the main row shows only the model ID; expand the row to view or edit its display name. Wider rows show both fields side by side. Focusing a display name expands its row and keeps it open until explicitly collapsed. Tab reaches the option and delete buttons before the display name at every width. Both provider families share the same row layout, labels, and icons: context window and max output tokens occupy two columns, and **Input types** occupies a separate row with **Text** and **Image** checkboxes. A row without an input declaration displays the installed model’s input types, then the provider default, then Text. Known pi-ai providers load their installed catalog without endpoint I/O; opening a row does not write an override. Explicit input selections take precedence, including text-only overrides of vision models. Checkbox edits save the selected types, with at least one type required. DeepSeek writes `inputModalities`; pi-ai writes `input`. Unchecking Image for DeepSeek also removes `imagePixelBudget` and `imageMaxBytes`, because the adapter rejects those limits without image input. Clearing the input field in `cordis.patch.yml` restores adapter inheritance; **Restore default models** resets the entire catalog override. Declare only input types the upstream model can actually process. -A model row on a hand-declared pi-ai route also edits its **Reasoning levels**, the `reasoningEfforts` the adapter already reads from `cordis.patch.yml`; the **?** beside the label explains the two columns. With the switch off the field is absent, so the model does not reason and the model picker offers no level; a stored `false` is kept until the switch is used. Turning it on always starts from rows for off, low, medium, and high; it does not restore levels removed earlier, and **Cancel** discards the edit instead. Each row pairs a level select — the keys `llm-pi-ai`'s configuration schema accepts in `reasoningEfforts`, read from the namespace schema like the protocol choices and today `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`, each usable once — with the **API value** sent for it, which starts as the level name; a field cleared while typing stores the level name and shows it once the field is left. Off only turns reasoning off, so its value field is disabled; a value already written for it in `cordis.patch.yml` is shown and kept. Changing a row's level resets a value still spelled as the old level; a custom value carries over only between two thinking levels, so changing a row to or from Off resets it. A stored key the schema does not list keeps its row and stays selectable there. Rows can be added and deleted and are saved in row order, while the model picker lists the levels in escalation order. The section refuses what the adapter refuses: no level besides Off — including a `reasoningEfforts:` left without a value, which shows the switch on with no rows — or a value that is missing on a level besides Off or empty on any level. A refused value marks its field invalid, and leaving that field fills in the level name. The rows keep no state of their own, so a model row reused after a deletion, a reset, or a fold shows only its own model. Routes served from the installed catalog and the DeepSeek card keep the adapter's levels and show no reasoning control. +A model row on a hand-declared pi-ai route also edits its **Reasoning levels**, the `reasoningEfforts` the adapter already reads from `cordis.patch.yml`; the **?** beside the label explains the two columns. With the switch off the field is absent, so the model does not reason and the model picker offers no level; a stored `false` is kept until the switch is used. Turning it on always starts from rows for off, low, medium, and high; it does not restore levels removed earlier, and **Cancel** discards the edit instead. Each row pairs a level select — the keys `llm-pi-ai`'s configuration schema accepts in `reasoningEfforts`, read from the namespace schema like the protocol choices and today `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`, each usable once — with the **API value** sent for it, which starts as the level name; a field cleared while typing stores the level name and shows it once the field is left. Off only turns reasoning off, so its value field is disabled; a value already written for it in `cordis.patch.yml` is shown and kept. Changing a row's level resets a value still spelled as the old level; a custom value carries over only between two thinking levels, so changing a row to or from Off resets it. A stored key the schema does not list keeps its row and stays selectable there. Rows can be added and deleted and are saved in row order, while the model picker lists the levels in escalation order. The section refuses what the adapter refuses: no level besides Off — including a `reasoningEfforts:` left without a value, which shows the switch on with no rows — or a value that is missing on a level besides Off or empty on any level. A refused value marks its field invalid, and leaving that field fills in the level name. The rows keep no state of their own, so a model row reused after a deletion, a reset, or a fold shows only its own model. On a Gemini API route the level select offers only `minimal`, `low`, `medium`, and `high`, turning the switch on starts from low, medium, and high, and each value field is disabled and holds the level name, which changing a row's level keeps; the Gemini implementation accepts only those four names as values. A stored level, or a value other than its level name, blocks saving with a message under the rows, in the create card and in the editor alike; the editor checks inherited models as well once the protocol is Gemini. Routes served from the installed catalog and the DeepSeek card keep the adapter's levels and show no reasoning control. Empty pi-ai capacity fields display `1M` for the context window and `128K` for maximum output tokens. These hints use decimal K/M units; leaving the fields empty preserves adapter inheritance. Provider-level defaults can differ from the hints. ### Adding and deleting providers -**Add model provider** is the one entry to the add card, offered while any editor-backed settings namespace is mounted and enabled while either mode below can proceed. The card opens on a segmented mode switch with a one-line purpose beneath it: **Third-party model provider** carries the dormant-directory provider select over the provider editor — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists — and **Custom model API** carries the create form for a route pi-ai does not ship: a relay, a self-hosted server, or any other OpenAI- or Anthropic-compatible endpoint. A mode is offered only while its namespace is mounted; with one mode the card shows that form alone under the mode's name, and a mode whose namespace has nothing left to adopt or no protocol to declare is disabled with the reason as its hover text. A panel mounts the first time its mode is shown and stays mounted, hidden, while the card is open and its mode stays offered, so switching modes discards neither draft; the switch locks while either panel has a write or an endpoint interrogation in flight, since a switch underneath one would orphan its answer. Each tab controls its panel through ids, and closing either panel forgets the catalog draft's target. The custom form asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The protocol picker names each protocol by its product name — OpenAI Chat Completions, OpenAI Responses, Anthropic Messages — while the stored value stays the schema identifier; a protocol the adapter adds before this page names it shows that identifier. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, while Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing changes until **Update list**. The picker opens with the configured models checked, or every candidate when the list is empty and the route serves its whole catalog; **Update list** appends newly checked candidates and removes unchecked configured models from the draft, while configured models the reply does not list stay. Rows without an id do not count as configured, a repeated candidate id is listed once, and **Update list** is disabled when it would empty a non-empty list, since an empty list serves the whole catalog. Each candidate displays its original model id in a monospace font to distinguish models with the same name. Rows stay on one line; hovering reveals the model name, falling back to the full id when no name is supplied. Selection and configuration retain the original model id. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** and **Deselect all** change only the visible results, so a hidden configured model is not removed unseen. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider. +**Add model provider** is the one entry to the add card, offered while any editor-backed settings namespace is mounted and enabled while either mode below can proceed. The card opens on a segmented mode switch with a one-line purpose beneath it: **Third-party model provider** carries the dormant-directory provider select over the provider editor — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists — and **Custom model API** carries the create form for a route pi-ai does not ship: a relay, a self-hosted server, or any other OpenAI-, Anthropic-, or Gemini-compatible endpoint. A mode is offered only while its namespace is mounted; with one mode the card shows that form alone under the mode's name, and a mode whose namespace has nothing left to adopt or no protocol to declare is disabled with the reason as its hover text. A panel mounts the first time its mode is shown and stays mounted, hidden, while the card is open and its mode stays offered, so switching modes discards neither draft; the switch locks while either panel has a write or an endpoint interrogation in flight, since a switch underneath one would orphan its answer. Each tab controls its panel through ids, and closing either panel forgets the catalog draft's target. The custom form asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. A Gemini API route also needs an API key, because pi-ai's Gemini implementation refuses a request without one: the create card, and the editor of such a route whose profile names no `apiKeyEnv`, mark the key field required, show that requirement as a hint under it, and block saving and **Fetch available models** until a key is typed. The protocol picker names each protocol by its product name — OpenAI Chat Completions, OpenAI Responses, Anthropic Messages, Gemini API — while the stored value stays the schema identifier; a protocol the adapter adds before this page names it shows that identifier. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`, and Gemini API shows `https://gateway.example/v1beta` because its base URL carries the API version. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing changes until **Update list**. The picker opens with the configured models checked, or every candidate when the list is empty and the route serves its whole catalog; **Update list** appends newly checked candidates and removes unchecked configured models from the draft, while configured models the reply does not list stay. Rows without an id do not count as configured, a repeated candidate id is listed once, and **Update list** is disabled when it would empty a non-empty list, since an empty list serves the whole catalog. Each candidate displays its original model id in a monospace font to distinguish models with the same name. Rows stay on one line; hovering reveals the model name, falling back to the full id when no name is supplied. Selection and configuration retain the original model id. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** and **Deselect all** change only the visible results, so a hidden configured model is not removed unseen. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider. ### First-run dialogs @@ -144,7 +144,7 @@ These limits define the editor's field coverage and the page's reach; they are c - **Credential cleanup is intentionally narrow** — deleting a row removes the configured, writable credential only when its reference is the exact `_API_KEY` target this page derives. Custom references, environment credentials, and unidentifiable targets are retained because the row cannot prove ownership of them. - **Only pi-ai routes can be hand-declared** — the custom-API form writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create. - **The catalog select lists route identifiers** — `moonshotai`, `zai`, and the other pi-ai catalog ids appear as they are, with no product names, aliases, or search. A searchable picker with the custom form as one pinned entry would let the mode switch go; it needs the directory to carry display names first. -- **Interrogation covers OpenAI-compatible and Anthropic Messages endpoints** — OpenAI protocols accept a standard `data` array or an enriched `models` map, while Anthropic uses its native model-listing route; every other protocol reports that it cannot be asked and its models are entered by hand. +- **Interrogation covers OpenAI-compatible, Anthropic Messages, and Gemini API endpoints** — OpenAI protocols accept a standard `data` array or an enriched `models` map, while Anthropic and Gemini use their native model-listing routes; every other protocol reports that it cannot be asked and its models are entered by hand. - **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 9fb8a65ef5..a6a1595bda 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -63,13 +63,13 @@ API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求 展开**自定义设置 → 模型选项**编辑模型。模型行较窄时,主行仅显示模型 ID,展开模型行后可查看或编辑显示名称;宽度充足时,两个输入框并排显示。显示名称获得焦点时会展开该行,并保持展开,直到显式收起。所有宽度下,Tab 都先经过模型选项和删除按钮,再到显示名称。两个提供商家族共用相同的模型行布局、标签和图标:上下文窗口与最大输出 token 数分为两列,**输入类型**独占下一行,提供**文本**和**图片**复选框。未声明输入类型的模型行优先显示已安装模型的输入类型,其次是提供商默认值,最后回退为文本。已知 pi-ai 提供商会加载已安装目录,不向端点发送请求;打开模型行不会写入覆盖值。显式输入选择具有优先权,包括为视觉模型设置的仅文本覆盖。修改复选框会保存所选类型,且至少保留一种。DeepSeek 写入 `inputModalities`,pi-ai 写入 `input`。DeepSeek 取消勾选图片时,还会移除 `imagePixelBudget` 和 `imageMaxBytes`,因为适配器在没有图片输入时拒绝这些限制。在 `cordis.patch.yml` 中清除输入字段可恢复适配器继承;**恢复默认模型**会重置整个模型目录覆盖。仅声明上游模型实际能够处理的输入类型。 -手工声明的 pi-ai 路由上,模型行还会编辑**推理等级**,即适配器本来就从 `cordis.patch.yml` 读取的 `reasoningEfforts`;标签旁的 **?** 说明两列的含义。开关关闭时该字段不存在,模型不推理,模型选择器不提供任何档位;已存储的 `false` 在开关被操作前保持不变。打开开关总是从 off、low、medium 和 high 四行开始,不会恢复之前删掉的档位;要放弃修改请点**取消**。每行把一个档位选择框——可选值是 `llm-pi-ai` 配置 schema 允许作为 `reasoningEfforts` 键的档位,与协议选项一样从命名空间 schema 读取,目前为 `off`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`,每个档位只能用一次——与发送给它的 **API 参数值**配成一对,参数值初始为档位名;输入时清空的字段会存为档位名,离开字段后显示出来。Off 只表示关闭思考,因此它的参数值输入框是禁用的;`cordis.patch.yml` 中已为它写好的值会显示并保留。修改某行的档位时,仍是旧档位名的参数值会随之重置;自定义值只在两个思考档位之间保留,切到或切离 Off 都会重置。schema 未列出的已存键会保留自己的行,并可在该行中继续选择。行可以增删,并按行的顺序保存,模型选择器则按升级顺序列出档位。本节拒绝适配器会拒绝的配置:没有 Off 以外的档位(包括未写值的 `reasoningEfforts:`,此时开关显示为打开且没有任何行),或者 Off 以外的档位缺少参数值、任意档位的参数值为空字符串。被拒绝的值会把对应输入框标为无效,离开该输入框时会填入档位名。各行不保留自己的状态,因此删除、恢复默认或折叠之后被复用的模型行只显示它自己的模型。走已安装目录的路由和 DeepSeek 卡片沿用适配器的档位,不显示推理等级控件。 +手工声明的 pi-ai 路由上,模型行还会编辑**推理等级**,即适配器本来就从 `cordis.patch.yml` 读取的 `reasoningEfforts`;标签旁的 **?** 说明两列的含义。开关关闭时该字段不存在,模型不推理,模型选择器不提供任何档位;已存储的 `false` 在开关被操作前保持不变。打开开关总是从 off、low、medium 和 high 四行开始,不会恢复之前删掉的档位;要放弃修改请点**取消**。每行把一个档位选择框——可选值是 `llm-pi-ai` 配置 schema 允许作为 `reasoningEfforts` 键的档位,与协议选项一样从命名空间 schema 读取,目前为 `off`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`,每个档位只能用一次——与发送给它的 **API 参数值**配成一对,参数值初始为档位名;输入时清空的字段会存为档位名,离开字段后显示出来。Off 只表示关闭思考,因此它的参数值输入框是禁用的;`cordis.patch.yml` 中已为它写好的值会显示并保留。修改某行的档位时,仍是旧档位名的参数值会随之重置;自定义值只在两个思考档位之间保留,切到或切离 Off 都会重置。schema 未列出的已存键会保留自己的行,并可在该行中继续选择。行可以增删,并按行的顺序保存,模型选择器则按升级顺序列出档位。本节拒绝适配器会拒绝的配置:没有 Off 以外的档位(包括未写值的 `reasoningEfforts:`,此时开关显示为打开且没有任何行),或者 Off 以外的档位缺少参数值、任意档位的参数值为空字符串。被拒绝的值会把对应输入框标为无效,离开该输入框时会填入档位名。各行不保留自己的状态,因此删除、恢复默认或折叠之后被复用的模型行只显示它自己的模型。在 Gemini API 路由上,档位选择框只提供 `minimal`、`low`、`medium` 和 `high`,打开开关从 low、medium 和 high 三行开始,参数值输入框全部禁用并保持为档位名,修改某行档位时也随之改为新档位名;Gemini 实现只接受这四个名称作为参数值。已存储的超出范围的档位,或与档位名不同的参数值,会在行下方显示提示并阻止保存,新建卡片与编辑器都是如此;协议为 Gemini 时,编辑器也会检查继承的模型。走已安装目录的路由和 DeepSeek 卡片沿用适配器的档位,不显示推理等级控件。 pi-ai 容量字段为空时,上下文窗口显示 `1M`,最大输出 token 数显示 `128K`。这些提示使用十进制 K/M 单位;保留空值会继续继承适配器配置。提供方级默认值可能与提示不同。 ### 新增与删除提供商 -**添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 namespace 已挂载就会出现,下述两种方式任一可继续时才可点击。卡片打开后顶部是分段式的方式切换,下方一行小字说明所选方式的用途:**第三方模型提供商**承载休眠目录提供商选择框与提供商编辑器——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog;**自定义模型 API**承载为 pi-ai 不提供的路由(中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口)准备的创建表单。某种方式只在其 namespace 已挂载时提供;只剩一种方式时卡片以该方式为标题直接显示表单;namespace 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用并把原因作为悬停提示。面板在其方式首次显示时挂载,之后在卡片打开且该方式仍被提供期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿;任一面板有写入或端点探测进行中时滑块锁定,因为此时切换会让结果落到看不见的面板上。每个 tab 通过 id 控制其面板,关闭任一面板都会忘记目录草稿的目标。自定义表单会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。协议选择框以产品名称显示各协议——OpenAI Chat Completions、OpenAI Responses、Anthropic Messages——存储的值仍是 schema 标识符;适配器新增而本页尚未命名的协议直接显示其标识符。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供商错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供商一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**更新列表**才会改变草稿。选择器打开时勾选已配置的模型;列表为空、路由提供完整 catalog 时勾选全部候选。**更新列表**会追加新勾选的候选,并从草稿中移除取消勾选的已配置模型;回复中未列出的已配置模型保持不变。没有 id 的行不算已配置,重复的候选 id 只列出一次;若会把非空列表清空,**更新列表**会被禁用,因为空列表表示提供完整 catalog。候选项使用等宽字体显示原始模型 id,以区分同名模型。每行保持单行,悬停可查看模型名称,未提供名称时回退到完整 id。勾选与配置仍使用原始模型 id。每个选中候选会在提供商公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**与**取消全选**只改变可见结果,以免在看不见时移除已配置的模型。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供商。 +**添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 namespace 已挂载就会出现,下述两种方式任一可继续时才可点击。卡片打开后顶部是分段式的方式切换,下方一行小字说明所选方式的用途:**第三方模型提供商**承载休眠目录提供商选择框与提供商编辑器——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog;**自定义模型 API**承载为 pi-ai 不提供的路由(中转站、自部署服务或其他兼容 OpenAI / Anthropic / Gemini 协议的接口)准备的创建表单。某种方式只在其 namespace 已挂载时提供;只剩一种方式时卡片以该方式为标题直接显示表单;namespace 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用并把原因作为悬停提示。面板在其方式首次显示时挂载,之后在卡片打开且该方式仍被提供期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿;任一面板有写入或端点探测进行中时滑块锁定,因为此时切换会让结果落到看不见的面板上。每个 tab 通过 id 控制其面板,关闭任一面板都会忘记目录草稿的目标。自定义表单会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。Gemini API 路由还需要 API 密钥,因为 pi-ai 的 Gemini 实现会拒绝没有密钥的请求:新建卡片,以及 profile 未声明 `apiKeyEnv` 的此类路由的编辑器,会把密钥字段标为必填,在其下方以提示说明这一要求,并在输入密钥前阻止保存和**获取可用模型**。协议选择框以产品名称显示各协议——OpenAI Chat Completions、OpenAI Responses、Anthropic Messages、Gemini API——存储的值仍是 schema 标识符;适配器新增而本页尚未命名的协议直接显示其标识符。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`;Gemini API 显示 `https://gateway.example/v1beta`,因为其基础 URL 包含 API 版本。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供商错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供商一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**更新列表**才会改变草稿。选择器打开时勾选已配置的模型;列表为空、路由提供完整 catalog 时勾选全部候选。**更新列表**会追加新勾选的候选,并从草稿中移除取消勾选的已配置模型;回复中未列出的已配置模型保持不变。没有 id 的行不算已配置,重复的候选 id 只列出一次;若会把非空列表清空,**更新列表**会被禁用,因为空列表表示提供完整 catalog。候选项使用等宽字体显示原始模型 id,以区分同名模型。每行保持单行,悬停可查看模型名称,未提供名称时回退到完整 id。勾选与配置仍使用原始模型 id。每个选中候选会在提供商公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**与**取消全选**只改变可见结果,以免在看不见时移除已配置的模型。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供商。 ### 首次运行弹窗 @@ -144,7 +144,7 @@ Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepS - **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据与无法识别的目标会保留,因为该行无法证明自己拥有它们。 - **只有 pi-ai 路由可以手工声明**:自定义模型 API 表单写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供商的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。 - **目录选择框列出的是路由标识符**:`moonshotai`、`zai` 等 pi-ai catalog id 原样显示,没有产品名、别名或搜索。一个把自定义表单作为置顶项的可搜索选择器可以取代方式切换;前提是目录先携带显示名称。 -- **询问覆盖 OpenAI 兼容与 Anthropic Messages 端点**:OpenAI 协议接受标准 `data` 数组或富信息 `models` 对象,Anthropic 则使用原生模型列表路由;其余协议会报告自己无法被询问,其模型需手工填写。 +- **询问覆盖 OpenAI 兼容、Anthropic Messages 与 Gemini API 端点**:OpenAI 协议接受标准 `data` 数组或富信息 `models` 对象,Anthropic 与 Gemini 则使用各自的原生模型列表路由;其余协议会报告自己无法被询问,其模型需手工填写。 - **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。 diff --git a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx index 1a16603664..efa93e28ab 100644 --- a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx @@ -1,7 +1,6 @@ /** - * The card that declares a provider pi-ai does not ship — an OpenAI-compatible - * gateway, a self-hosted server, or a provider newer than the installed - * catalog. + * The card that declares a provider pi-ai does not ship — a gateway, a + * self-hosted server, or a provider newer than the installed catalog. * * This is a create, not an edit, which is why it is its own form rather than * the provider editor with extra fields: the route id is being *chosen* here, @@ -37,8 +36,9 @@ import type { ModelDraft } from './ModelListEditor.tsx' import { validateReasoningEfforts } from './ModelReasoningEfforts.tsx' import { deriveKeyRef } from './store.ts' import { protocolLabel } from './protocol-label.ts' +import { protocolNeedsKey, protocolReasoningLevels } from './protocol-rules.ts' import type { ModelsOperations } from './operations.ts' -import type { en } from './locales.ts' +import type { en, ModelsKey } from './locales.ts' import styles from './ModelsSection.module.css' /** The settings namespace a hand-declared provider is written into. */ @@ -54,6 +54,16 @@ const NS = 'llm-pi-ai' */ const ROUTE_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/ +/** + * Endpoint examples for protocols whose SDK expects a different base than an + * OpenAI-compatible `/v1`: Anthropic's SDK appends `/v1/messages`, and the + * Gemini protocol expects the API version in the base URL. + */ +const BASE_URL_PLACEHOLDERS: Readonly> = { + 'anthropic-messages': 'customAnthropicBaseUrlPlaceholder', + 'google-generative-ai': 'customGeminiBaseUrlPlaceholder', +} + function isHttpUrl(value: string): boolean { try { const protocol = new URL(value).protocol @@ -129,8 +139,12 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { // bad row is named by its position here too. Capacities have route-level // fallbacks; what a route cannot default is at least one model. Every row of // this hand-declared route edits its reasoning levels, so they are checked too. - const modelFailure = validateDeepSeekModels(models) ?? validateReasoningEfforts(models) - const keyFailure = apiKeyFailure(keyDraft) + const reasoning = protocolReasoningLevels(protocol, reasoningLevels) + const modelFailure = validateDeepSeekModels(models) + ?? validateReasoningEfforts(models, reasoning.levelsOnly ? reasoning.levels : undefined) + // A blank key is a legal choice only where the protocol can authenticate without one. + const keyMissing = protocolNeedsKey(protocol) && keyDraft.trim().length === 0 + const keyFailure = keyMissing ? 'keyRequired' as const : apiKeyFailure(keyDraft) // The typed key with paste whitespace removed. A blank field yields an empty // string, which the create path reads as "no key supplied" — a route may // legitimately authenticate through the provider's own ambient discovery. @@ -250,9 +264,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { className={styles['input']} type="text" value={baseURL} - placeholder={t(protocol === 'anthropic-messages' - ? 'customAnthropicBaseUrlPlaceholder' - : 'customBaseUrlPlaceholder')} + placeholder={t(BASE_URL_PLACEHOLDERS[protocol] ?? 'customBaseUrlPlaceholder')} aria-label={t('baseUrl')} aria-invalid={baseUrlInvalid} disabled={profileDisabled} @@ -279,20 +291,23 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { value={keyDraft} placeholder={t('keyPlaceholder')} aria-label={t('keyInput')} + required={protocolNeedsKey(protocol)} disabled={disabled} onChange={(event) => { setKeyDraft(event.target.value) }} /> {/* A create card has no stored key to keep, so the blank case says what a blank field means here instead: this route may authenticate - through the provider's own ambient discovery or OAuth. */} + through the provider's own ambient discovery or OAuth. A key the + protocol requires is a requirement still to meet, not a fault. */} {keyFailure === undefined ? null - :

{t(keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure)}

} + :

{t(keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure)}

} outsideLevels(only, level, wire))) { + return { index, key: 'modelReasoningLevelsOnly' } + } if (entries.some(([level, wire]) => !acceptedValue(level, wire))) return { index, key: 'modelReasoningValueRequired' } if (!entries.some(([level]) => level !== 'off')) return { index, key: 'modelReasoningNeedsLevel' } } @@ -90,6 +107,8 @@ interface ModelReasoningEffortsProps { model: DeepSeekModelDraft /** Levels the adapter's schema accepts as `reasoningEfforts` keys, in escalation order. */ levels: readonly string[] + /** Whether the route's protocol accepts only {@link levels}, each sent under its own name. */ + levelsOnly: boolean /** One-based row position for accessible names. */ position: number /** Prevent changes while read-only or saving. */ @@ -107,7 +126,8 @@ interface ModelReasoningEffortsProps { * @param props - model declaration and row replacement action. * @returns the reasoning-level group. */ -export function ModelReasoningEfforts({ model, levels, position, disabled, t, onChange }: ModelReasoningEffortsProps): ReactNode { +export function ModelReasoningEfforts(props: ModelReasoningEffortsProps): ReactNode { + const { model, levels, levelsOnly, position, disabled, t, onChange } = props const entries = readEfforts(model) // Only the focused field keeps its own text, so a field cleared while typing // stays empty although its level's name is stored. Leaving the field drops @@ -124,7 +144,8 @@ export function ModelReasoningEfforts({ model, levels, position, disabled, t, on const changeLevel = (current: Entries, index: number, from: string, wire: unknown, to: string): void => { // Only a custom spelling between two thinking levels carries over; a value // still spelled as its old level, or one crossing to or from Off, resets. - const carried = typeof wire === 'string' && wire.length > 0 && wire !== from && from !== 'off' && to !== 'off' + // A protocol accepting only its offered levels sends each under its own name, so nothing carries. + const carried = !levelsOnly && typeof wire === 'string' && wire.length > 0 && wire !== from && from !== 'off' && to !== 'off' write(replaceAt(current, index, [to, carried ? wire : defaultValue(to)])) } @@ -158,6 +179,7 @@ export function ModelReasoningEfforts({ model, levels, position, disabled, t, on const refused = entries?.some(([level, wire]) => !acceptedValue(level, wire)) === true const missingLevel = entries !== undefined && !used.some(level => level !== 'off') + const outside = levelsOnly && entries?.some(([level, wire]) => outsideLevels(levels, level, wire)) === true return (
@@ -212,7 +234,8 @@ export function ModelReasoningEfforts({ model, levels, position, disabled, t, on aria-label={`${level} ${t('modelReasoningValue')}`} aria-invalid={!acceptedValue(level, wire)} // Off only turns reasoning off; a value stored in cordis.patch.yml is shown and kept. - disabled={disabled || level === 'off'} + // A protocol accepting only its offered levels sends each under its own name. + disabled={disabled || level === 'off' || levelsOnly} onChange={(event) => { editValue(entries, index, level, event.target.value) }} onBlur={() => { leaveValue(entries, index, level, wire) }} /> @@ -228,9 +251,11 @@ export function ModelReasoningEfforts({ model, levels, position, disabled, t, on
))} - {refused - ?

{t('modelReasoningValueRequired')}

- : missingLevel ?

{t('modelReasoningNeedsLevel')}

: null} + {outside + ?

{t('modelReasoningLevelsOnly')}

+ : refused + ?

{t('modelReasoningValueRequired')}

+ : missingLevel ?

{t('modelReasoningNeedsLevel')}

: null} - {!selecting && !editing && selection?.comment.side === side && selection.comment.endLine === line && ( + {plus && ( )} + {/* Keyboard and assistive technology reach the same editor through the line button and the labeled plus above. */} + {hover && !plus && !editing && ( + + )} } const cell = (value: SplitCell | null, spans: SideSpans, side: 'L' | 'R'): ReactNode => value === null ? : - {number(side, value.no)} + {number(side, value.no, true)} const rows: ReactNode[] = [] let inserted = false @@ -289,7 +319,8 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme ) } else if (columns) { for (const [index, pair] of splitRows(segment.rows).entries()) { - rows.push(
+ rows.push(
{ extendAdd(event.buttons, pair.left?.no ?? null, pair.right?.no ?? null) }}> {cell(pair.left, segment.old, 'L')}{cell(pair.right, segment.new, 'R')}
) if (!inserted && endsAt(pair.left?.no ?? null, pair.right?.no ?? null)) { @@ -300,8 +331,11 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme } } else { for (const [index, row] of segment.rows.entries()) { - rows.push(
- {singleSide !== 'R' && number('L', row.old)}{singleSide !== 'L' && number('R', row.new)}{SIGN[row.kind]} + // Both numbers place their plus in the sign column, so a row showing the labeled plus offers no hover plus. + const labeled = !selecting && !editing && endsAt(row.old, row.new) + rows.push(
{ extendAdd(event.buttons, row.old, row.new) }}> + {singleSide !== 'R' && number('L', row.old, row.kind === 'del' && !labeled)}{singleSide !== 'L' && number('R', row.new, row.kind !== 'del' && !labeled)}{SIGN[row.kind]}
) if (!inserted && endsAt(row.old, row.new)) { @@ -317,6 +351,7 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme } if (!inserted) rows.push(editor) return
{ if (event.button !== 0 || !(event.target instanceof Node)) return diff --git a/packages/experimental/client-ui-forge/src/client/ForgeTab.module.css b/packages/experimental/client-ui-forge/src/client/ForgeTab.module.css index b86332cfbf..168caafc17 100644 --- a/packages/experimental/client-ui-forge/src/client/ForgeTab.module.css +++ b/packages/experimental/client-ui-forge/src/client/ForgeTab.module.css @@ -314,6 +314,19 @@ color: var(--dsw-alias-state-error-primary); } +/* Checks outcome filling the glyph's head branch end. */ +.checksMark[data-checks='failure'] { + fill: var(--dsw-alias-state-error-primary); +} + +.checksMark[data-checks='pending'] { + fill: var(--dsw-alias-state-warn-primary); +} + +.checksMark[data-checks='success'] { + fill: var(--dsw-alias-state-success-primary); +} + .chevron { flex: none; color: var(--dsw-alias-label-tertiary); diff --git a/packages/experimental/client-ui-forge/src/client/ReviewComposer.module.css b/packages/experimental/client-ui-forge/src/client/ReviewComposer.module.css index 16b7d752fd..5ce683c482 100644 --- a/packages/experimental/client-ui-forge/src/client/ReviewComposer.module.css +++ b/packages/experimental/client-ui-forge/src/client/ReviewComposer.module.css @@ -26,4 +26,11 @@ @media (hover: hover) { .line:hover { color: var(--dsw-alias-state-business-primary); } } @media (pointer: coarse) { .input { font-size: 16px; } .line { min-height: 28px; } } -.add { position: absolute; inset-inline-start: 2px; top: 50%; transform: translateY(-50%); display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; padding: 0; border: 0; border-radius: var(--dsw-radius-xs); background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-foreground); cursor: pointer; user-select: none; } +/* The plus starts just inside the number's padding and covers the sign column, or the empty lane side-by-side cells keep instead, so the line number and the code stay clickable. */ +.add, .hoverAdd { position: absolute; z-index: 1; inset-inline-start: calc(100% - 4px); top: 50%; transform: translateY(-50%); display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; padding: 0; border: 0; border-radius: var(--dsw-radius-xs); background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-foreground); font: inherit; cursor: pointer; user-select: none; } +/* Unified rows that show both numbers move the left plus past the right number too; it paints over that number's padding. The plus inherits the code font because the number width is measured in `ch`. */ +[data-review]:not([data-split]):not([data-single-side]) [data-review-side="L"] .add, +[data-review]:not([data-split]):not([data-single-side]) [data-review-side="L"] .hoverAdd { inset-inline-start: calc(100% + var(--forge-number-width, 3.5em) - 4px); } +/* The mouse shortcut appears only for the hovered side of a row: a split cell or a unified line. */ +.hoverAdd { display: none; } +@media (hover: hover) { [data-diff-side]:hover .hoverAdd, [data-diff-line]:hover .hoverAdd { display: inline-flex; } } diff --git a/packages/experimental/client-ui-forge/src/client/SessionList.module.css b/packages/experimental/client-ui-forge/src/client/SessionList.module.css index 6619640116..c5c9b0b6d3 100644 --- a/packages/experimental/client-ui-forge/src/client/SessionList.module.css +++ b/packages/experimental/client-ui-forge/src/client/SessionList.module.css @@ -1,6 +1,5 @@ /* One icon in the Session row's resting seat, sized to the row's 20px line. */ .icon { - position: relative; display: inline-flex; flex: none; align-items: center; @@ -9,30 +8,6 @@ color: var(--dsw-alias-label-tertiary); } -/* Checks outcome at the glyph's lower right, ringed in the sidebar fill so it parts from the strokes. */ -.dot { - position: absolute; - right: -2px; - bottom: 3px; - width: 6px; - height: 6px; - border-radius: 50%; - corner-shape: round; - box-shadow: 0 0 0 1.5px var(--dsw-specific-sidebar-fill); -} - -.dot[data-checks='failure'] { - background: var(--dsw-alias-state-error-primary); -} - -.dot[data-checks='pending'] { - background: var(--dsw-alias-state-warn-primary); -} - -.dot[data-checks='success'] { - background: var(--dsw-alias-state-success-primary); -} - /* Hover card section: the card keeps one dark surface in both themes, so it * uses the hover card label and state tokens. */ .hover { diff --git a/packages/experimental/client-ui-forge/src/client/SessionRowForge.tsx b/packages/experimental/client-ui-forge/src/client/SessionRowForge.tsx index 1072148a89..744846afa3 100644 --- a/packages/experimental/client-ui-forge/src/client/SessionRowForge.tsx +++ b/packages/experimental/client-ui-forge/src/client/SessionRowForge.tsx @@ -2,7 +2,7 @@ * Session row icons after the time label: a branch glyph while the Session * works in the worktree the panel created for it, and the state glyph of its * most urgent change request. Glyphs of open change requests stay in the - * row's subdued color with a dot for their checks; merged and closed ones keep + * row's subdued color with their checks filling the head branch end; merged and closed ones keep * their state color. */ @@ -62,6 +62,7 @@ export type SessionRowForgeProps = export function SessionRowForge({ sessionId, useSessions, useStates, t }: SessionRowForgeProps): ReactNode { const facts = useSessionForgeFacts(useSessions, useStates, sessionId) const top = facts.changeRequests[0] + const checks = top === undefined ? 'none' : rowChecks(top) return ( <> {facts.worktree !== null && ( @@ -76,8 +77,8 @@ export function SessionRowForge({ sessionId, useSessions, useStates, t }: Sessio aria-label={t('change.stateAria', { state: stateLine(top, t) })} data-forge-row-change={top.tone} > - - {rowChecks(top) !== 'none' && } + )} diff --git a/packages/experimental/client-ui-forge/src/client/glyphs.tsx b/packages/experimental/client-ui-forge/src/client/glyphs.tsx index 439b1da1e6..fb9fefca87 100644 --- a/packages/experimental/client-ui-forge/src/client/glyphs.tsx +++ b/packages/experimental/client-ui-forge/src/client/glyphs.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from 'react' import clsx from 'clsx' import { IconCheckCircleFillRegular, IconCloseCircleFillRegular } from '@deepseek-ai/dsh-client-ui-primitives' -import type { CheckState } from '@deepseek-ai/dsh-experimental-forge/types' +import type { CheckState, ChecksRollup } from '@deepseek-ai/dsh-experimental-forge/types' import type { ChangeRequestTone } from './status.ts' import css from './ForgeTab.module.css' @@ -65,6 +65,16 @@ const SHAPES: Record = { ), } +/** Center of each shape's head branch end, which a checks mark covers. */ +const HEAD: Record = { + open: [12, 12.5], + unknown: [12, 12.5], + blocked: [12, 12.5], + draft: [12, 12.5], + merged: [11.5, 8], + closed: [12, 12.5], +} + /** * Draw a change request in its lifecycle shape: open, open with a cross for * one that needs its author, draft, merged, closed, or the open shape for an @@ -73,13 +83,15 @@ const SHAPES: Record = { * @param props.tinted - whether the state picks the color; otherwise the glyph uses the current text color. * @param props.size - width and height in pixels, 15 by default. * @param props.label - accessible name; omit it for a decorative glyph. + * @param props.checks - checks outcome drawn as a filled head branch end in its state color; omit it for the plain end. * @returns the SVG glyph. */ -export function ChangeRequestGlyph({ tone, tinted = true, size = 15, label }: { +export function ChangeRequestGlyph({ tone, tinted = true, size = 15, label, checks }: { readonly tone: ChangeRequestTone readonly tinted?: boolean | undefined readonly size?: number | undefined readonly label?: string | undefined + readonly checks?: Exclude | undefined }): ReactNode { return ( {SHAPES[tone]} + {/* Concentric with the stroked end and larger than its outer edge, so it covers that end completely. */} + {checks !== undefined && } ) } diff --git a/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx b/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx index 4f4f0e1efd..ba321c48fc 100644 --- a/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx @@ -133,9 +133,86 @@ it('keeps shift text selection and allows ordinary text selection when review ac m.rerender() m.select('R', 3, 4) expect(m.queryByRole('button', { name: 'Add comment' })).toBeNull() + expect(m.container.querySelector('[data-review-add]')).toBeNull() }) -it('refuses native and gutter ranges spanning collapsed context between hunks', () => { +it('opens the editor from a hover plus in one press, extending the range on its side while dragging', () => { + const m = mount() + const plus = (name: string): HTMLElement => { + const element = m.container.querySelector(`[data-review-add="${name}"]`) + if (element === null) throw new Error(`missing hover plus ${name}`) + return element + } + expect(plus('L3').getAttribute('aria-hidden')).toBe('true') + fireEvent.pointerDown(plus('R3'), { button: 2 }) + expect(m.line('R3').getAttribute('aria-pressed')).toBe('false') + fireEvent.pointerDown(plus('R3'), { button: 0 }) + // Crossing a row extends on the starting side, even over the other column. + fireEvent.pointerOver(m.line('L4'), { buttons: 1 }) + expect(m.line('L4').getAttribute('aria-pressed')).toBe('false') + expect(m.line('R4').getAttribute('aria-pressed')).toBe('true') + fireEvent.pointerOver(plus('R4'), { buttons: 0 }) + fireEvent.pointerOver(plus('R5'), { buttons: 1 }) + expect(m.line('R5').getAttribute('aria-pressed')).toBe('true') + fireEvent.pointerOver(plus('R4'), { buttons: 1 }) + expect(m.line('R5').getAttribute('aria-pressed')).toBe('false') + fireEvent.pointerUp(window) + expect(m.getByRole('region', { name: 'Comment on R3–4' })).toBeTruthy() + expect(m.container.querySelector('[data-review-add]')).toBeNull() + fireEvent.click(m.getByRole('button', { name: 'Cancel' })) + fireEvent.pointerDown(plus('L2'), { button: 0 }) + fireEvent.pointerCancel(window) + expect(m.queryByRole('textbox')).toBeNull() + expect(m.line('L2').getAttribute('aria-pressed')).toBe('true') + expect(m.container.querySelector('[data-review-add="L2"]')).toBeNull() + fireEvent.click(m.getByRole('button', { name: 'Add comment' })) + expect(m.getByRole('region', { name: 'Comment on L2' })).toBeTruthy() +}) + +it('extends a unified hover-plus drag through rows with a line on its side and keeps one plus per row', () => { + // Old 1–4: keep 1, drop 2, keep 3, keep 4; new 1–4: keep 1, keep 3, added, keep 4. + const mixed: ForgeDiffFile = { ...file, additions: 1, deletions: 1, + hunks: [{ oldStart: 1, oldLines: 4, newStart: 1, newLines: 4, lines: [' keep 1', '-drop 2', ' keep 3', '+added', ' keep 4'] }] } + const m = mount({ split: false, file: mixed }) + fireEvent.pointerDown(m.container.querySelector('[data-review-add="L2"]')!, { button: 0 }) + // Over the context row the pointer meets its new number, yet the drag extends on the old side. + fireEvent.pointerOver(m.line('R2'), { buttons: 1 }) + expect(m.line('L3').getAttribute('aria-pressed')).toBe('true') + // The added row has no old line, so the range stays. + fireEvent.pointerOver(m.line('R3'), { buttons: 1 }) + expect(m.line('L3').getAttribute('aria-pressed')).toBe('true') + fireEvent.pointerOver(m.line('R4'), { buttons: 1 }) + fireEvent.pointerUp(window) + expect(m.getByRole('region', { name: 'Comment on L2–4' })).toBeTruthy() + fireEvent.click(m.getByRole('button', { name: 'Cancel' })) + // A gutter drag still extends only over numbers of its own side. + fireEvent.pointerDown(m.line('L1'), { button: 0 }) + fireEvent.pointerOver(m.line('R2'), { buttons: 1 }) + expect(m.line('L3').getAttribute('aria-pressed')).toBe('false') + fireEvent.pointerUp(window) + // The row that shows the labeled plus offers no second hover plus at the same place. + fireEvent.click(m.line('L3')) + expect(m.getByRole('button', { name: 'Add comment' })).toBeTruthy() + expect(m.container.querySelector('[data-review-add="R2"]')).toBeNull() + expect(m.container.querySelector('[data-review-add="R4"]')).not.toBeNull() + // Side by side, the deleted line's row has no new line, so a drag on the new side passes over it. + m.rerender() + fireEvent.pointerDown(m.container.querySelector('[data-review-add="R1"]')!, { button: 0 }) + fireEvent.pointerOver(m.line('L2'), { buttons: 1 }) + fireEvent.pointerUp(window) + expect(m.getByRole('region', { name: 'Comment on R1' })).toBeTruthy() +}) + +it('offers one hover plus per unified row, on the side its line belongs to', () => { + const m = mount({ split: false }) + expect(Array.from(m.container.querySelectorAll('[data-review-add]'), element => element.getAttribute('data-review-add'))) + .toEqual(['R2', 'L3', 'L4', 'R3', 'R4', 'R5']) + fireEvent.pointerDown(m.container.querySelector('[data-review-add="L3"]')!, { button: 0 }) + fireEvent.pointerUp(window) + expect(m.getByRole('region', { name: 'Comment on L3' })).toBeTruthy() +}) + +it('refuses native, gutter, and hover-plus ranges spanning collapsed context between hunks', () => { const m = mount({ file: { ...file, hunks: [...file.hunks, { oldStart: 10, oldLines: 1, newStart: 10, newLines: 1, lines: ['-before', '+after'] }] } }) m.select('R', 4, 10) @@ -144,6 +221,12 @@ it('refuses native and gutter ranges spanning collapsed context between hunks', fireEvent.click(m.line('R10'), { shiftKey: true }) expect(m.line('R4').getAttribute('aria-pressed')).toBe('true') expect(m.line('R10').getAttribute('aria-pressed')).toBe('false') + // A refused press keeps the previous selection, and its release opens no editor for it. + fireEvent.pointerDown(m.container.querySelector('[data-review-add="R10"]')!, { button: 0, shiftKey: true }) + fireEvent.pointerUp(window) + expect(m.queryByRole('textbox')).toBeNull() + expect(m.line('R4').getAttribute('aria-pressed')).toBe('true') + expect(m.line('R10').getAttribute('aria-pressed')).toBe('false') }) it('places left and right records in unified rows and retains outdated records for hidden hunks', () => { diff --git a/packages/experimental/client-ui-forge/tests/session-list.client.spec.tsx b/packages/experimental/client-ui-forge/tests/session-list.client.spec.tsx index d03d64d264..4ca42dbb88 100644 --- a/packages/experimental/client-ui-forge/tests/session-list.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/session-list.client.spec.tsx @@ -108,19 +108,19 @@ describe('SessionRowForge', () => { expect(screen.getByRole('img', { name: en['list.worktree'] }).getAttribute('data-forge-row-worktree')).toBe('') const change = screen.getByRole('img', { name: 'Change request state: Open · Checks running' }) expect(change.getAttribute('data-forge-row-change')).toBe('open') - expect(change.querySelector(`.${css.dot}`)?.getAttribute('data-checks')).toBe('pending') + expect(change.querySelector('[data-checks]')?.getAttribute('data-checks')).toBe('pending') }) it('names only the state of a finished or unpolled change request, without a checks dot', () => { const merged = renderRow({ forgeChangeRequests: [binding(1)] }, statesOf(state(1, { state: 'merged', checksRollup: 'failure' }))) const glyph = screen.getByRole('img', { name: 'Change request state: Merged' }) expect(glyph.getAttribute('data-forge-row-change')).toBe('merged') - expect(glyph.querySelector(`.${css.dot}`)).toBeNull() + expect(glyph.querySelector('[data-checks]')).toBeNull() expect(screen.queryByRole('img', { name: en['list.worktree'] })).toBeNull() merged.view.unmount() renderRow({ forgeChangeRequests: [binding(2)] }) - expect(screen.getByRole('img', { name: 'Change request state: Unknown' }).querySelector(`.${css.dot}`)).toBeNull() + expect(screen.getByRole('img', { name: 'Change request state: Unknown' }).querySelector('[data-checks]')).toBeNull() }) it('follows the listed values and the polled states as they change', () => { diff --git a/packages/experimental/worktree/README.i18n.yaml b/packages/experimental/worktree/README.i18n.yaml index 52775cdaa9..c964c25be1 100644 --- a/packages/experimental/worktree/README.i18n.yaml +++ b/packages/experimental/worktree/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-worktree/use-this-package: - en: bddc0a7dd4dc21f6 - zh: 57005ed1aaf034aa + en: c788af94d52dd001 + zh: 200580d1a97048eb /deepseek-ai-dsh-experimental-worktree/understand-the-implementation: en: 6971a7ca9073bef1 zh: b977c28a75c7f963 diff --git a/packages/experimental/worktree/README.md b/packages/experimental/worktree/README.md index 5725f48843..756ea6803f 100644 --- a/packages/experimental/worktree/README.md +++ b/packages/experimental/worktree/README.md @@ -38,7 +38,7 @@ Mount the service alongside `workingDirectory`, `fs`, `subprocess`, `sandbox`, ` Every creation appends a `plugin:worktree/created` record to the Session log after the directory change, so the `worktree` projection lists the Session's checkouts with `owned` false for one inside a fork-inherited prefix. When the Workspace archives a Session, the service registers one `scheduled` cleanup for the first checkout that Session created itself and returns; the inspection runs in the background. While `retainOnArchive` is on, archiving registers nothing and every checkout stays; the field is volatile and read at each archive, and the [Forge panel](../client-ui-forge/README.md) edits it as a switch on the bundle's worktree row of the Plugins page. The checkout is retained while any unarchived Session refers to it, by a recorded creation or by a working directory inside it (a Session beneath an archived subagent ancestor counts as archived; cold Sessions are read from the projection cache or their log), and while the creator's sandbox policy is read-only. An eligible checkout is removed with a non-force `git worktree remove` under the creator's policy after a second use check inside the path lock, which also holds `ensure` and `set` into that path through `working-directory/access`; the branch and its commits remain. Tracked changes, untracked files, hidden index flags, submodules, locks, detached HEAD, a mismatched branch, and targets outside the configured pool retain the checkout; ignored files do not, as Git itself discards them. Processes are not consulted: a process still writing there makes Git refuse, and an idle one fails its next command. Unarchiving cancels an unadmitted removal and waits for an admitted one. `cleanupOf(session, revision)` reads the attempt, or waits for a named revision to settle, as `scheduled`, `retained`, `removed`, or `failed`; a failure leaves the Session archived and logs a warning. Checkouts created before this event existed, and Sessions archived earlier, are not swept. -The default checkout directory is `/.agents/worktrees/`. The returned `repositoryRoot` is the top-level directory of the caller’s current checkout; when it is a linked checkout, the pool is nested inside it. Omitted names join the `dsh-` prefix, the first eight hexadecimal digits of the UUID in the Session id, a hyphen, and four random hexadecimal digits, such as `dsh-6b1f02c4-9a3e`; a Session id without a UUID gets eight random hexadecimal digits after the prefix. `generatedWorktreeName` exports the same rule for callers that name a worktree before creating it. The service writes a `.gitignore` containing `*` and a trailing newline only when its allocation creates the pool; existing files and ignore rules are preserved. Relative `directory`, generated `namePrefix`, executable choices, process limits, and `retainOnArchive` are configurable in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-worktree). +The default checkout directory is `/.agents/worktrees/`. The returned `repositoryRoot` is the top-level directory of the caller’s current checkout; when it is a linked checkout, the pool is nested inside it. Omitted names join the `dsh-` prefix, the first eight hexadecimal digits of the UUID in the Session id, a hyphen, and four random hexadecimal digits, such as `dsh-6b1f02c4-9a3e`; a Session id without a UUID gets eight random hexadecimal digits after the prefix. `generatedWorktreeName` exports the same rule for callers that name a worktree before creating it. The service writes a `.gitignore` containing `*` and a trailing newline only when its allocation creates the pool; existing files and ignore rules are preserved. Each Git and Node command runs under its own `timeoutMs` deadline, 5 minutes by default, so checking out a large repository on a slow filesystem can finish; cancellation still stops the running command. Relative `directory`, generated `namePrefix`, executable choices, process limits, and `retainOnArchive` are configurable in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-worktree). ----- diff --git a/packages/experimental/worktree/README.zh.md b/packages/experimental/worktree/README.zh.md index 61b0e8cbdb..fa3624a49c 100644 --- a/packages/experimental/worktree/README.zh.md +++ b/packages/experimental/worktree/README.zh.md @@ -38,7 +38,7 @@ kind: "package-reference" 每次创建都会在目录切换之后向 Session 日志追加一条 `plugin:worktree/created` 事件,因此 `worktree` 投影列出该 Session 的检出目录,位于 fork 继承前缀内的条目 `owned` 为 false。Workspace 归档某个 Session 时,服务为该 Session 自己创建的第一个检出目录登记一条 `scheduled` 清理并立即返回;检查在后台进行。`retainOnArchive` 开启时,归档不登记任何清理,所有检出目录都保留;该字段是 volatile 字段,每次归档时读取,[Forge 面板](../client-ui-forge/README.zh.md)在插件页该套件的 worktree 行上以开关编辑它。只要任一未归档 Session 仍引用该目录——通过记录的创建,或通过位于其中的工作目录(祖先子代理已归档的 Session 视为已归档;冷 Session 从投影缓存或其日志读取)——或创建者的沙箱策略为只读,目录就保留。符合条件的目录在路径锁内再做一次使用检查后,用创建者的策略执行不带 force 的 `git worktree remove`;路径锁同时通过 `working-directory/access` 阻塞进入该路径的 `ensure` 与 `set`;分支及其提交保留。跟踪文件改动、未跟踪文件、隐藏的索引标记、子模块、锁、分离 HEAD、分支不匹配以及位于配置池之外的目标都会保留目录;被忽略的文件不会,因为 Git 自身会丢弃它们。不检查进程:仍在写入的进程会让 Git 拒绝删除,空闲进程的下一条命令会失败。取消归档会取消尚未获准的删除,并等待已获准的删除结束。`cleanupOf(session, revision)` 读取本次尝试,或等待指定修订号结束,状态为 `scheduled`、`retained`、`removed` 或 `failed`;失败时 Session 仍保持归档并记录警告。此事件存在之前创建的检出目录,以及更早归档的 Session,不会被扫描。 -默认检出目录是 `/.agents/worktrees/`。返回的 `repositoryRoot` 是调用方当前检出目录的顶层目录;如果它是链接检出目录,工作树父目录就嵌套在其中。省略名称时,名称由前缀 `dsh-`、Session id 中 UUID 的前 8 位十六进制数、一个连字符和 4 位随机十六进制数组成,例如 `dsh-6b1f02c4-9a3e`;Session id 不含 UUID 时,前缀后接 8 位随机十六进制数。`generatedWorktreeName` 导出同一规则,供在创建前确定名称的调用方使用。仅当服务的分配操作创建了父目录时,服务才会写入内容为 `*` 加换行符的 `.gitignore`;已有文件与忽略规则保持不变。相对路径 `directory`、生成名称使用的 `namePrefix`、可执行程序、进程限制与 `retainOnArchive` 可在[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-worktree)中查阅。 +默认检出目录是 `/.agents/worktrees/`。返回的 `repositoryRoot` 是调用方当前检出目录的顶层目录;如果它是链接检出目录,工作树父目录就嵌套在其中。省略名称时,名称由前缀 `dsh-`、Session id 中 UUID 的前 8 位十六进制数、一个连字符和 4 位随机十六进制数组成,例如 `dsh-6b1f02c4-9a3e`;Session id 不含 UUID 时,前缀后接 8 位随机十六进制数。`generatedWorktreeName` 导出同一规则,供在创建前确定名称的调用方使用。仅当服务的分配操作创建了父目录时,服务才会写入内容为 `*` 加换行符的 `.gitignore`;已有文件与忽略规则保持不变。每条 Git 和 Node 命令各自受 `timeoutMs` 时限约束,默认 5 分钟,使大仓库在较慢的文件系统上也能完成检出;取消仍会终止正在运行的命令。相对路径 `directory`、生成名称使用的 `namePrefix`、可执行程序、进程限制与 `retainOnArchive` 可在[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-worktree)中查阅。 ----- diff --git a/packages/experimental/worktree/src/index.ts b/packages/experimental/worktree/src/index.ts index 9006fb3f23..3c74e3f73c 100644 --- a/packages/experimental/worktree/src/index.ts +++ b/packages/experimental/worktree/src/index.ts @@ -87,7 +87,7 @@ export class WorktreeService extends Service { namePrefix: z.string().default('dsh-'), gitCommand: z.string().default('git'), nodeCommand: z.string().default('node'), - timeoutMs: z.number().default(60_000), + timeoutMs: z.number().default(300_000), graceMs: z.number().default(3_000), maxOutputBytes: z.number().default(65_536), }) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 1d5b274a5a..f7c103d726 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -36,8 +36,8 @@ en: 92f554e1718614ca zh: f17ed3ad3e5d28e2 /deepseek-ai-dsh-llm-pi-ai/use-this-package/discover-models-from-endpoints: - en: 6619e8cfe7555c73 - zh: b2f80c25acb81cf8 + en: ac930f29006456cf + zh: 8d37c47f02761452 /deepseek-ai-dsh-llm-pi-ai/use-this-package/failures-and-recovery: en: 3c1edbc63de24a1a zh: 01592718c3a2c79f @@ -93,8 +93,8 @@ en: b6bd570c0159ba08 zh: 7d2d91eea6a8d830 /deepseek-ai-dsh-llm-pi-ai/known-limitations-and-deferred-work: - en: 07e32eef563bb747 - zh: 225bde55120955f9 + en: 3a0cf0d369d25d37 + zh: 5974f328aea06aa8 /deepseek-ai-dsh-llm-pi-ai/known-limitations-and-deferred-work/dev-note: en: 3678dd0f6642c2a5 zh: 75cbdd064cc64282 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 5577269ec1..c063137301 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -116,7 +116,7 @@ Each operation captures the current `providers` Config reference. New or changed ### Discover models from endpoints -The plugin answers "which models can this provider serve?" for a route a configuration surface is editing or drafting. A route the installed catalog ships is answered from that catalog with no network call, preserving its `input` array as discovery `inputModalities`; only a route the catalog does not describe is interrogated over the wire. `openai-completions` and `openai-responses` use `GET {baseURL}/models` with bearer auth, while `anthropic-messages` uses native `GET /v1/models?limit=1000` semantics with `x-api-key` and `anthropic-version`; its listing URL accepts the API root with or without a trailing `/v1` because gateway documentation publishes both spellings, and only that listing URL normalizes the segment, so model requests receive the configured `baseURL` unchanged. A named configured route supplies its stored credential and profile `headers` inside the Host, so deployment headers configured through `cordis.patch.yml` or Cordis config reach model discovery without becoming discovery-request or Models-page fields; a key typed into the form still wins over the stored credential. The parser accepts either the standard `data` array or an enriched `models` map, normalizing each candidate's id, display name, context window, and output-token cap; Anthropic's `max_input_tokens` and `max_tokens` feed the same capacity fields, a map key remains the request id even when its entry names a different canonical id, primitive-valued map properties are ignored, and a missing display name falls back to that request id. The reply is candidate metadata a surface may offer for adoption — nothing is stored, and `cordis.patch.yml` remains the only thing that decides what a route serves. +The plugin answers "which models can this provider serve?" for a route a configuration surface is editing or drafting. A route the installed catalog ships is answered from that catalog with no network call, preserving its `input` array as discovery `inputModalities`; only a route the catalog does not describe is interrogated over the wire. `openai-completions` and `openai-responses` use `GET {baseURL}/models` with bearer auth, while `anthropic-messages` uses native `GET /v1/models?limit=1000` semantics with `x-api-key` and `anthropic-version`; its listing URL accepts the API root with or without a trailing `/v1` because gateway documentation publishes both spellings, and only that listing URL normalizes the segment, so model requests receive the configured `baseURL` unchanged. `google-generative-ai` uses `GET {baseURL}/models?pageSize=1000` with `x-goog-api-key`, because its `baseURL` already carries the API version, such as `/v1beta`. A named configured route supplies its stored credential and profile `headers` inside the Host, so deployment headers configured through `cordis.patch.yml` or Cordis config reach model discovery without becoming discovery-request or Models-page fields; a key typed into the form still wins over the stored credential. The parser accepts either the standard `data` array or an enriched `models` map, normalizing each candidate's id, display name, context window, and output-token cap; Anthropic's `max_input_tokens` and `max_tokens` feed the same capacity fields, a map key remains the request id even when its entry names a different canonical id, primitive-valued map properties are ignored, and a missing display name falls back to that request id. A Gemini reply is a `models` array instead: each `name` without its `models/` prefix becomes the request id, `displayName`, `inputTokenLimit`, and `outputTokenLimit` fill the same fields, and an entry whose `supportedGenerationMethods` omits `generateContent` is skipped. The reply is candidate metadata a surface may offer for adoption — nothing is stored, and `cordis.patch.yml` remains the only thing that decides what a route serves. ### Failures and recovery @@ -263,10 +263,11 @@ These limits define where the adapter stops and future work begins. They are cur - **Complete Config replacement can remove inherited dictionary entries** — a field reset instead restores its inherited value. - **`headers` can carry a credential the redactor never sees** — profile resolution rejects names and values Fetch cannot represent, but the dict remains plain strings; store credentials as `apiKeyEnv` references. - **Discovery does not change configured models** — adopt discovery results explicitly into the route configuration. -- **Anthropic discovery reads at most 1,000 models** — the request uses the API's maximum page size but does not traverse `has_more`; entries beyond the first page must be added by hand. +- **Anthropic and Gemini discovery read at most 1,000 models** — the request uses the API's maximum page size but does not traverse `has_more` or `nextPageToken`; entries beyond the first page must be added by hand. - **Uncataloged models need an unambiguous protocol** — catalog routes preserve each model's protocol, including both Azure APIs under `azure`. A model absent from a mixed-protocol catalog needs an explicit route-level `api`, which overrides every model on the route and must be one of the supported custom protocols. For Azure deployment names, keep the catalog model ID and set `AZURE_OPENAI_DEPLOYMENT_NAME_MAP`. - **A modality declaration is not verified** — a model declaring `image` its gateway does not serve is refused by the provider after prompt admission. The durable image remains in history and the same misdeclared model can fail again; switching to a text-only model remains possible because the shared LLM runtime projects image references into stable text for that request. -- **An unauthenticated route depends on its protocol** — a route naming no credential resolves as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder credential referenced by `apiKeyEnv` or an `Authorization` entry in `headers`. +- **An unauthenticated route depends on its protocol** — a route naming no credential resolves as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder credential referenced by `apiKeyEnv` or an `Authorization` entry in `headers`. pi-ai's Gemini implementation refuses a request without an API key, so a `google-generative-ai` route always needs `apiKeyEnv`, whose value it sends as `x-goog-api-key`. +- **Gemini accepts four reasoning levels** — on `google-generative-ai`, pi-ai's Gemini implementation sends only `minimal`, `low`, `medium`, or `high`, compared without case, and fails a request whose `reasoningEfforts` value names anything else; the [Models page](../../client/ui-settings-models/README.md) offers only these levels for such a route. A request without a level, including `off`, sends pi-ai's disabled-thinking configuration, which models that cannot disable thinking, such as Gemini 2.5 Pro, may reject. - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers. - **Conversation update support comes only from the installed pi-ai catalog** — native system and tool updates require the catalog model’s mid-conversation compat flags, which profiles cannot set. Hand-declared routes and unflagged models receive later system text as user-role reminders and the complete current tool list at the start of each request. - **Images are accepted only in user and tool-result messages** — images in system, developer, or assistant history fail with `UNSUPPORTED_CONTENT` on both conversion paths. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index ea9f18fae1..a8ee6f2eb6 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -116,7 +116,7 @@ profile 的 `models` 列表会替换而非扩展路由的已安装目录;每 ### 从端点发现模型 -插件会回答「该提供方可以提供哪些模型?」,供配置界面正在编辑或起草的路由使用。已安装目录提供的路由直接由目录回答,不发网络请求,并将其 `input` 数组保留为发现结果的 `inputModalities`;只有目录未描述的路由才会经网络询问。`openai-completions` 与 `openai-responses` 使用带 bearer 鉴权的 `GET {baseURL}/models`,`anthropic-messages` 则以 `x-api-key` 和 `anthropic-version` 使用原生 `GET /v1/models?limit=1000` 语义;其列表 URL 接受带或不带末尾 `/v1` 的 API 根地址,因为网关文档两种写法都会发布,且只有该列表 URL 会归一化这一段,模型请求收到的仍是配置原样的 `baseURL`。已配置且具名的路由会在 Host 内部提供已存凭据与 profile `headers`,因此通过 `cordis.patch.yml` 或 Cordis 配置设置的部署标头可以到达模型发现请求,但不会成为发现请求或 Models 页面的字段;表单中新键入的密钥仍优先于已存凭据。解析器接受标准 `data` 数组或富信息 `models` 对象,并归一化每个候选的 id、显示名、上下文窗口与最大输出 token 数;Anthropic 的 `max_input_tokens` 与 `max_tokens` 会进入相同容量字段,即使对象条目点名了另一个规范 id,对象键仍是请求 id,原始类型的对象属性会被忽略,缺失的显示名则回退到该请求 id。回答是界面可以提供给用户采纳的候选元数据——不存储任何内容,`cordis.patch.yml` 仍然是决定路由服务内容的唯一事实。 +插件会回答「该提供方可以提供哪些模型?」,供配置界面正在编辑或起草的路由使用。已安装目录提供的路由直接由目录回答,不发网络请求,并将其 `input` 数组保留为发现结果的 `inputModalities`;只有目录未描述的路由才会经网络询问。`openai-completions` 与 `openai-responses` 使用带 bearer 鉴权的 `GET {baseURL}/models`,`anthropic-messages` 则以 `x-api-key` 和 `anthropic-version` 使用原生 `GET /v1/models?limit=1000` 语义;其列表 URL 接受带或不带末尾 `/v1` 的 API 根地址,因为网关文档两种写法都会发布,且只有该列表 URL 会归一化这一段,模型请求收到的仍是配置原样的 `baseURL`。`google-generative-ai` 以 `x-goog-api-key` 使用 `GET {baseURL}/models?pageSize=1000`,因为其 `baseURL` 已包含 API 版本,例如 `/v1beta`。已配置且具名的路由会在 Host 内部提供已存凭据与 profile `headers`,因此通过 `cordis.patch.yml` 或 Cordis 配置设置的部署标头可以到达模型发现请求,但不会成为发现请求或 Models 页面的字段;表单中新键入的密钥仍优先于已存凭据。解析器接受标准 `data` 数组或富信息 `models` 对象,并归一化每个候选的 id、显示名、上下文窗口与最大输出 token 数;Anthropic 的 `max_input_tokens` 与 `max_tokens` 会进入相同容量字段,即使对象条目点名了另一个规范 id,对象键仍是请求 id,原始类型的对象属性会被忽略,缺失的显示名则回退到该请求 id。Gemini 的回答则是 `models` 数组:每个 `name` 去掉 `models/` 前缀后成为请求 id,`displayName`、`inputTokenLimit` 与 `outputTokenLimit` 填入相同字段,`supportedGenerationMethods` 不含 `generateContent` 的条目会被跳过。回答是界面可以提供给用户采纳的候选元数据——不存储任何内容,`cordis.patch.yml` 仍然是决定路由服务内容的唯一事实。 ### 失败与恢复 @@ -263,10 +263,11 @@ pi-ai 事件变成 harness 的推理、文本、工具调用、用量与 finish - **完整替换 Config 可以移除继承的字典条目**——字段重置则恢复其继承值。 - **`headers` 可以携带 redactor 永远看不到的凭据**——profile 解析会拒绝 Fetch 无法表示的名称与值,但该字典仍是纯字符串;以 `apiKeyEnv` 引用存储凭据。 - **发现操作不更改已配置模型**——需显式将发现结果采纳到路由配置中。 -- **Anthropic 模型发现最多读取 1,000 个模型**——请求使用 API 的最大页大小,但不会遍历 `has_more`;第一页之外的条目需要手工添加。 +- **Anthropic 与 Gemini 模型发现最多读取 1,000 个模型**——请求使用 API 的最大页大小,但不会遍历 `has_more` 或 `nextPageToken`;第一页之外的条目需要手工添加。 - **目录外模型需要明确的协议**——目录路由保留每个模型的协议,包括 `azure` 下的两种 Azure API。混合协议目录中不存在的模型需要显式设置路由级 `api`;该设置覆盖路由上的所有模型,且必须属于受支持的自定义协议。Azure 部署名称应通过 `AZURE_OPENAI_DEPLOYMENT_NAME_MAP` 设置,模型 ID 保持为目录中的 ID。 - **模态声明不受校验**——声明 `image` 而其网关不支持的模型会在提示词准入后被提供方拒绝。持久图片仍留在历史中,同一误声明模型可能再次失败;切换到纯文本模型仍然可行,因为共享 LLM 运行时会针对该请求把图片引用投影为稳定文本。 -- **未认证路由取决于其协议**——不点名凭据的路由解析为已配置但无密钥,但 pi-ai 的 OpenAI 兼容实现仍要求 API 密钥或 `Authorization` 标头,因此无密钥本地服务器需要由 `apiKeyEnv` 引用或 `headers` 中的 `Authorization` 条目提供的占位凭据。 +- **未认证路由取决于其协议**——不点名凭据的路由解析为已配置但无密钥,但 pi-ai 的 OpenAI 兼容实现仍要求 API 密钥或 `Authorization` 标头,因此无密钥本地服务器需要由 `apiKeyEnv` 引用或 `headers` 中的 `Authorization` 条目提供的占位凭据。pi-ai 的 Gemini 实现会拒绝没有 API 密钥的请求,因此 `google-generative-ai` 路由始终需要 `apiKeyEnv`,其值以 `x-goog-api-key` 发送。 +- **Gemini 只接受四个推理等级**——在 `google-generative-ai` 上,pi-ai 的 Gemini 实现只发送 `minimal`、`low`、`medium` 或 `high`(不区分大小写),`reasoningEfforts` 的值为其他名称时请求失败;[模型页](../../client/ui-settings-models/README.zh.md)只为此类路由提供这些档位。不带等级的请求(包括 `off`)会发送 pi-ai 的关闭思考配置,无法关闭思考的模型(例如 Gemini 2.5 Pro)可能拒绝该请求。 - **不支持 `GenerateOptions.stop`**——pi-ai 的通用流式选项无法跨提供方保证停止序列行为。 - **对话更新支持只来自已安装的 pi-ai 目录**——原生系统与工具更新需要目录模型的对话中 compat 标志,profile 不能设置这些标志。手工声明的路由与未标记的模型会以用户角色提醒接收后续系统文本,并在每次请求开头接收完整的当前工具列表。 - **图片只在 user 与工具结果消息中被接受**——system、developer 或 assistant 历史中的图片在两条转换路径上都会以 `UNSUPPORTED_CONTENT` 失败。 diff --git a/packages/llm/llm-pi-ai/src/discovery.ts b/packages/llm/llm-pi-ai/src/discovery.ts index d171b30235..020861bc94 100644 --- a/packages/llm/llm-pi-ai/src/discovery.ts +++ b/packages/llm/llm-pi-ai/src/discovery.ts @@ -13,9 +13,10 @@ * metadata the surface offers for adoption. `cordis.patch.yml` remains the only * thing that decides what a route serves. * - * OpenAI-compatible and Anthropic Messages protocols are interrogated through - * their native model-listing endpoints. The parser accepts the standard - * `data` array and the enriched `models` map some compatible gateways expose. + * OpenAI-compatible, Anthropic Messages, and Gemini protocols are interrogated + * through their native model-listing endpoints. The OpenAI and Anthropic parser + * accepts the standard `data` array and the enriched `models` map some + * compatible gateways expose; Gemini replies with its own `models` array. * Every other protocol reports that it cannot be interrogated so the surface * falls back to hand-entry rather than guessing its response fields. * @@ -30,14 +31,18 @@ import { catalogModels } from './catalog.ts' /** * Protocols whose model listing this module can read. OpenAI protocols use * bearer auth at `GET {baseURL}/models`; Anthropic Messages uses `x-api-key` - * and `anthropic-version` at its native `GET /v1/models`. Azure is absent + * and `anthropic-version` at its native `GET /v1/models`; Gemini uses + * `x-goog-api-key` at `GET {baseURL}/models`. Azure is absent * despite its OpenAI lineage — it authenticates with an `api-key` header and * requires an `api-version` query — and Codex authenticates through OAuth; * guessing at either would report an authentication failure as a provider * with no models. pi-ai's remaining protocols are absent for the same reason. + * The route protocol table in `provider.ts` may name a protocol absent here; + * discovery reports it as unsupported. */ const LISTABLE_PROTOCOLS: ReadonlySet = new Set([ 'anthropic-messages', + 'google-generative-ai', 'openai-completions', 'openai-responses', ]) @@ -48,6 +53,9 @@ const ANTHROPIC_VERSION = '2023-06-01' /** Largest model-list page accepted by Anthropic's public endpoint; discovery reads one page and does not follow `has_more`. */ const ANTHROPIC_MODEL_LIMIT = 1000 +/** Largest model-list page accepted by the Gemini API; discovery reads one page and does not follow `nextPageToken`. */ +const GEMINI_MODEL_LIMIT = 1000 + /** * Endpoint replies larger than this are refused. The endpoint is whatever URL * the user typed, so the ceiling holds on the bytes actually read rather than @@ -108,14 +116,16 @@ function label(...candidates: readonly unknown[]): string | undefined { * treated as a prefix rather than a URL to resolve against, so a deployment * path such as `https://gateway.example/openai/v1` keeps its segments instead * of losing them to `URL` resolution. OpenAI protocols list at - * `{baseURL}/models`. Anthropic lists at `{root}/v1/models`, where the root is - * the base without trailing slashes and without one trailing `/v1` segment: - * gateway documentation publishes both spellings of the same root. Only this - * listing URL normalizes that segment; model requests receive the configured - * `baseURL` unchanged. + * `{baseURL}/models`, and Gemini at `{baseURL}/models` with a page size, + * because its `baseURL` already carries the API version. Anthropic lists at + * `{root}/v1/models`, where the root is the base without trailing slashes and + * without one trailing `/v1` segment: gateway documentation publishes both + * spellings of the same root. Only this listing URL normalizes that segment; + * model requests receive the configured `baseURL` unchanged. */ function listingUrl(baseURL: string, api: string): string { const base = baseURL.replace(/\/+$/, '') + if (api === 'google-generative-ai') return `${base}/models?pageSize=${String(GEMINI_MODEL_LIMIT)}` if (api !== 'anthropic-messages') return `${base}/models` const root = base.endsWith('/v1') ? base.slice(0, -3) : base return `${root}/v1/models?limit=${String(ANTHROPIC_MODEL_LIMIT)}` @@ -229,6 +239,49 @@ function readListing(body: unknown): LlmDiscoveredModel[] { return models } +/** One entry of a Gemini `GET /models` reply. */ +interface GeminiListingEntry { + name?: unknown + displayName?: unknown + inputTokenLimit?: unknown + outputTokenLimit?: unknown + supportedGenerationMethods?: unknown +} + +/** + * Read a Gemini model-listing reply. Each entry's `name` is a resource name + * such as `models/gemini-2.5-pro`; the request id is that name without the + * `models/` prefix, because pi-ai's Gemini protocol sends the bare id. Entries + * that declare generation methods without `generateContent`, such as embedding + * models, cannot serve a conversation and are skipped. + */ +function readGeminiListing(body: unknown): LlmDiscoveredModel[] { + const listed = (body as { models?: unknown } | null)?.models + if (!Array.isArray(listed)) { + throw new LlmError( + 'the endpoint\'s model listing has no "models" array; enter this provider\'s models by hand', + 'DISCOVERY_FAILED', + ) + } + const models: LlmDiscoveredModel[] = [] + for (const raw of listed as readonly unknown[]) { + const entry = raw as GeminiListingEntry | null + const methods = entry?.supportedGenerationMethods + if (Array.isArray(methods) && !methods.includes('generateContent')) continue + const id = label(entry?.name)?.replace(/^models\//, '') + if (id === undefined || id.length === 0) continue + const contextWindow = capacity(entry?.inputTokenLimit) + const maxTokens = capacity(entry?.outputTokenLimit) + models.push({ + id, + name: label(entry?.displayName) ?? id, + ...contextWindow === undefined ? {} : { contextWindow }, + ...maxTokens === undefined ? {} : { maxTokens }, + }) + } + return models +} + /** * Accept one probe key, or refuse it before the header is built. Without this * the `fetch` below would throw a ByteString `TypeError` that this function's @@ -320,6 +373,8 @@ export async function discoverModels( if (api === 'anthropic-messages') { headers.set('anthropic-version', ANTHROPIC_VERSION) if (apiKey !== undefined) headers.set('x-api-key', apiKey) + } else if (api === 'google-generative-ai') { + if (apiKey !== undefined) headers.set('x-goog-api-key', apiKey) } else if (apiKey !== undefined) { headers.set('authorization', `Bearer ${apiKey}`) } @@ -359,5 +414,5 @@ export async function discoverModels( } catch (error: unknown) { throw new LlmError(`${url} did not answer with JSON`, 'DISCOVERY_FAILED', { cause: error }) } - return readListing(body) + return api === 'google-generative-ai' ? readGeminiListing(body) : readListing(body) } diff --git a/packages/llm/llm-pi-ai/src/provider.ts b/packages/llm/llm-pi-ai/src/provider.ts index 9a851f6ef8..ae0b04c901 100644 --- a/packages/llm/llm-pi-ai/src/provider.ts +++ b/packages/llm/llm-pi-ai/src/provider.ts @@ -21,6 +21,7 @@ import type { Api, ApiKeyAuth, Model, Provider, ProviderStreams } from '@earendil-works/pi-ai' import { anthropicMessagesApi } from '@earendil-works/pi-ai/api/anthropic-messages.lazy' +import { googleGenerativeAIApi } from '@earendil-works/pi-ai/api/google-generative-ai.lazy' import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy' import { openAIResponsesApi } from '@earendil-works/pi-ai/api/openai-responses.lazy' import { catalogProvider, PiAiCatalogError } from './catalog.ts' @@ -48,14 +49,14 @@ const PROTOCOLS: Readonly ProviderStreams>> = { 'openai-completions': openAICompletionsApi, 'openai-responses': openAIResponsesApi, 'anthropic-messages': anthropicMessagesApi, + 'google-generative-ai': googleGenerativeAIApi, } /** * Every wire protocol a configured route may name, most-reached first. The * order is the table's and therefore stable; a configuration surface offering * a choice presents the first as its default, which is why the protocol a - * hand-declared gateway most often speaks — and the one endpoint interrogation - * can read — leads. + * hand-declared gateway most often speaks leads. * @returns the supported protocol identifiers. */ export function supportedProtocols(): readonly string[] { diff --git a/packages/llm/llm-pi-ai/tests/discovery.spec.ts b/packages/llm/llm-pi-ai/tests/discovery.spec.ts index bb3862e78a..188f07f448 100644 --- a/packages/llm/llm-pi-ai/tests/discovery.spec.ts +++ b/packages/llm/llm-pi-ai/tests/discovery.spec.ts @@ -220,6 +220,52 @@ describe('draft-provider model discovery', () => { expect(server.headers.map(headers => headers['user-agent'])).toEqual([userAgent(), userAgent(), userAgent()]) }) + it('uses the Gemini model-listing path, header, and fields', async () => { + const server = await listingServer({ + body: JSON.stringify({ + models: [ + { + name: 'models/gemini-pro', + displayName: 'Gemini Pro', + inputTokenLimit: 1_048_576, + outputTokenLimit: 65_536, + supportedGenerationMethods: ['generateContent', 'countTokens'], + }, + { name: 'models/text-embedding', supportedGenerationMethods: ['embedContent'] }, + { name: 'gateway-alias' }, + { name: 'models/' }, + { displayName: 'nameless' }, + ], + nextPageToken: 'ignored', + }), + }) + const ctx = await harness() + + const models = await ctx.llm.discoverModels('llm-pi-ai', { + baseURL: `${server.url}/v1beta/`, + api: 'google-generative-ai', + apiKey: 'gemini-key', + }) + + expect(models).toEqual([ + { id: 'gemini-pro', name: 'Gemini Pro', contextWindow: 1_048_576, maxTokens: 65_536 }, + { id: 'gateway-alias', name: 'gateway-alias' }, + ]) + expect(server.paths).toEqual(['/v1beta/models?pageSize=1000']) + expect(server.headers[0]?.['x-goog-api-key']).toBe('gemini-key') + expect(server.headers[0]?.authorization).toBeUndefined() + }) + + it('leaves a Gemini probe with no key unauthenticated, and rejects a reply with no models array', async () => { + const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'm' }] }) }) + const ctx = await harness() + + const failure = ctx.llm.discoverModels('llm-pi-ai', { baseURL: server.url, api: 'google-generative-ai' }) + await expect(failure).rejects.toMatchObject({ code: 'DISCOVERY_FAILED' }) + await expect(failure).rejects.toThrow(/no "models" array/) + expect(server.headers[0]?.['x-goog-api-key']).toBeUndefined() + }) + it('prefers the standard data array when both supported formats are present', async () => { const server = await listingServer({ body: JSON.stringify({ @@ -379,7 +425,7 @@ describe('draft-provider model discovery', () => { .rejects.toMatchObject({ code: 'DISCOVERY_FAILED' }) }) - it.each(['azure-openai-responses', 'openai-codex-responses', 'google-generative-ai'])( + it.each(['azure-openai-responses', 'openai-codex-responses'])( 'says it cannot interrogate %s rather than guessing a shape', async (api) => { // Azure authenticates with an `api-key` header and an `api-version` diff --git a/packages/llm/llm-pi-ai/tests/gemini-protocol.spec.ts b/packages/llm/llm-pi-ai/tests/gemini-protocol.spec.ts new file mode 100644 index 0000000000..106b449c5c --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/gemini-protocol.spec.ts @@ -0,0 +1,83 @@ +/** A hand-declared route can speak the Gemini API protocol. */ + +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import LlmRuntime, { createMessage } from '@deepseek-ai/dsh-llm' +import * as PiAi from '../src/index.ts' +import { assemble } from './assemble.ts' +import { closeMockServers, mockServer } from './mock-server.ts' + +afterEach(async () => { + await closeMockServers() + vi.unstubAllEnvs() +}) + +/** One complete Gemini stream reply carrying `parts`. */ +function reply(parts: Record[]): { events: string[] } { + return { events: [JSON.stringify({ + candidates: [{ content: { role: 'model', parts }, finishReason: 'STOP', index: 0 }], + usageMetadata: { promptTokenCount: 3, candidatesTokenCount: 1, totalTokenCount: 4 }, + })] } +} + +async function gateway(url: string): Promise { + vi.stubEnv('GEMINI_GATEWAY_KEY', 'gemini-key') + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(LlmRuntime) + await ctx.plugin(PiAi, { providers: { 'gemini-gateway': { + apiKeyEnv: 'GEMINI_GATEWAY_KEY', + api: 'google-generative-ai', + baseURL: `${url}/v1beta`, + models: [{ id: 'gemini-test' }], + } } }) + return ctx +} + +it('streams a hand-declared Gemini route from its configured endpoint', async () => { + const server = await mockServer([reply([{ text: 'hello' }])]) + const ctx = await gateway(server.url) + + const result = await assemble(ctx, { + provider: 'gemini-gateway', + model: 'gemini-test', + messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + }) + + expect(result.finish).toEqual({ kind: 'stop' }) + expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) + expect(server.paths).toEqual(['/v1beta/models/gemini-test:streamGenerateContent?alt=sse']) + expect(server.headers[0]?.['x-goog-api-key']).toBe('gemini-key') +}) + +it('returns a Gemini function call and replays it with its result', async () => { + const server = await mockServer([ + reply([{ functionCall: { name: 'count', args: { value: 2 } }, thoughtSignature: 'c2ln' }]), + reply([{ text: 'two' }]), + ]) + const ctx = await gateway(server.url) + const tools = [{ name: 'count', description: 'Count', parameters: { type: 'object', properties: { value: { type: 'number' } } } }] + const user = { role: 'user' as const, content: [{ type: 'text' as const, text: 'count' }] } + + const call = await assemble(ctx, { provider: 'gemini-gateway', model: 'gemini-test', tools, messages: [user] }) + const [block] = call.message.content + if (block?.type !== 'tool-call') throw new Error('expected a tool call') + expect(call.finish).toEqual({ kind: 'tool-calls' }) + expect(block).toMatchObject({ name: 'count', arguments: '{"value":2}' }) + expect(server.requests[0]).toMatchObject({ tools: [{ functionDeclarations: [{ name: 'count' }] }] }) + + const answer = await assemble(ctx, { + provider: 'gemini-gateway', + model: 'gemini-test', + tools, + messages: [user, call.message, createMessage({ + role: 'tool', source: { kind: 'tool', callId: block.id }, toolCallId: block.id, content: [{ type: 'text', text: '2' }], + })], + }) + expect(answer.message.content).toEqual([{ type: 'text', text: 'two' }]) + expect(server.requests[1]).toMatchObject({ contents: [ + { role: 'user' }, + { role: 'model', parts: [{ functionCall: { name: 'count', args: { value: 2 } }, thoughtSignature: 'c2ln' }] }, + { role: 'user', parts: [{ functionResponse: { name: 'count' } }] }, + ] }) +})