Merge remote-tracking branch 'origin/master' into worktree/process-plugin-streaming-72f9b3

# Conflicts:
#	packages/client/ui-jobs/package.json
This commit is contained in:
Yichen Jiang committed 2026-09-03 11:30:41 +08:00
commit 6d65c12ef2
299 files changed
+2181 -401

No files matched your search

+3
View File
@@ -391,6 +391,9 @@
"process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml": "sha256:dde0041399b253e3758045f0858488db8178ffc563ce889c8b396c87af6c3730",
"process/2026-08-12-documentation-site-navigation-and-chrome.md": "sha256:56cb836ed862378afd33eb5c1a9dc159958b35a0aed3bf4336fcf26ab0b84b8b",
"process/2026-08-12-documentation-site-navigation-and-chrome.zh.md": "sha256:f2dd4adde38a09fe312866a1e6dad0f465684d809287862f40f1a488acd4fe18",
"process/2026-08-31-pr-opened-issue-start-dates.i18n.yaml": "sha256:c522daca5e126bf64227d6259f447648a589831125113dcf49814817d4b28f17",
"process/2026-08-31-pr-opened-issue-start-dates.md": "sha256:749f343576006b0d4950b9c67f434cea0bb9ae21a5fd8d74fefe77b59289ffbf",
"process/2026-08-31-pr-opened-issue-start-dates.zh.md": "sha256:a227dc6cba6e62d08a15b1c6b69b5941297c2d10035d257d6f3bc95d6ea54591",
"simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml": "sha256:ad3d1263cb0051b885173bf064de62065e2c646ccaae2d7250723da3b4eab90c",
"simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md": "sha256:8fb061d51c8c23b47d2367814bab3623c6d5b972f38d207a273caa9030b579bd",
"simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md": "sha256:2ffeaca91f82844a5616d6dcce6b4af514bb8a7c46f78e47f668b204ac6edc04",
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.md
2026-08-31-pr-opened-issue-start-dates.md: 49756d9960a7616993e4513c20c990e5cfba167e
2026-08-31-pr-opened-issue-start-dates.zh.md: 752533251be559cb4fe82f619553872bbcd748d1
2026-08-31-pr-opened-issue-start-dates.md: f17e1bf0dcfdc541952312d1b504bdaa2e2817cc
2026-08-31-pr-opened-issue-start-dates.zh.md: 3aab8f5528ad125210fbb3839fd334d99ad49bb0
@@ -1,6 +1,7 @@
# Agent Note: PR-opened Issue start dates
Status: implemented
Archived: 2026-09-02
English | [中文](2026-08-31-pr-opened-issue-start-dates.zh.md)
@@ -1,6 +1,7 @@
# Agent Note: 在 PR 创建时设置 Issue 开始日期
Status: implemented
Archived: 2026-09-02
[English](2026-08-31-pr-opened-issue-start-dates.md) | 中文
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
2026-07-10-single-file-executable-sdk-runtime-distribution.md: c152345772826ec4e2dbfd238726c429418c7897
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: ea5e457afd761cb5071f8b584ef10fa7ffaa8210
2026-07-10-single-file-executable-sdk-runtime-distribution.md: ab09691b48684b8ebae269df4d585b499423e671
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: d986105b087357bd8301251dca14736a5b427bbb
@@ -21,6 +21,8 @@ The exe is packaged with the **`--sea` (enhanced SEA) mode** of [@yao-pkg/pkg](h
`--sea` requires target ≥ node22; the exe uniformly targets node24. One pkg invocation packages exactly one target; multi-platform builds invoke it once per platform.
`@yao-pkg/pkg` is an exact-pinned root `devDependency` invoked as `pnpm exec pkg`, with [`patches/@[email protected]`](../../../../patches/@[email protected]) removing the SEA bootstrap's `patchChildProcess` call. Unpatched, pkg rewrites spawned commands named `node` — including the string after a `-c`/`/c` flag, exactly the Bash tool's `bash -c` form — to the executable itself and stamps `PKG_EXECPATH` into every child environment, so a model-issued `node --version` silently boots the dsh CLI; Node's own SEA layer performs no such rewrite, and a SEA binary cannot impersonate plain Node because it always boots its embedded app. With the call removed, children resolve `node` through PATH like any other process (a machine without Node reports command-not-found honestly), no `PKG_EXECPATH` reaches children, absolute `process.execPath` spawns still re-enter the app, worker threads never applied the hook, and `process.pkg` sidecar selection is untouched.
Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's testing-system "snapshot" (ACP replay expected outputs, `$DSH_SNAPSHOT`); this document says "VFS" for the former.
### The serving interface is a plugin inside the dsh application
@@ -64,7 +66,7 @@ The Python client launches the packaged `dsh` command with the selected profile
## Testing
The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build installs both wheels into a clean venv outside the checkout, proves matching versions and installed module/executable locations, then completes turns against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The filesystem-search scenario requires the model to call both `glob` and `grep` through the target-native `-rg` sidecar. The MCP scenario starts a temporary external stdio server, deliberately delays its initial `tools/list` response, then immediately starts the first SDK prompt; the prompt must see and call the discovered tool, proving that `initialize` is a real Loader-settlement readiness boundary rather than a timing sleep. The same installed run compares a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. Trusted pull requests add a real-provider two-turn file write/read whose external bytes, tool calls, completed reasons, and persisted log must agree. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ.
The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build installs both wheels into a clean venv outside the checkout, proves matching versions and installed module/executable locations, then completes turns against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The filesystem-search scenario requires the model to call both `glob` and `grep` through the target-native `-rg` sidecar. The spawn-node scenario drives the platform shell tool through a command starting with `node` and requires the machine's own Node version in the tool result with no `PKG_EXECPATH` in the child environment, pinning the packaged runtime against a pkg upgrade that re-records the child-process patch. The MCP scenario starts a temporary external stdio server, deliberately delays its initial `tools/list` response, then immediately starts the first SDK prompt; the prompt must see and call the discovered tool, proving that `initialize` is a real Loader-settlement readiness boundary rather than a timing sleep. The same installed run compares a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. Trusted pull requests add a real-provider two-turn file write/read whose external bytes, tool calls, completed reasons, and persisted log must agree. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ.
Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends.
@@ -84,4 +86,4 @@ Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disp
**Bought**: zero-dependency single-file distribution on target platforms; plugin semantics strictly identical to running from source (the same real package tree, no transpilation, no registry); the serving interface, the plugin set, and the configuration all converge on two sources of truth — `cordis.yml` plus one dependency manifest; the exe and node carriers share one tree and one semantics, so development verification never waits for packaging; official Node binaries remove the patched-binary supply-chain concern.
**Paid**: artifacts on the order of 174MB with source entering the blob as-is (no bytecode obfuscation; a closed-source distribution requirement needs a separate evaluation); pkg's VFS/module-hook layer remains community-maintained (the build script pins `@yao-pkg/[email protected]`; upgrading is an explicit change); `--sea` is one invocation per target (matching CI's one leg per platform; local multi-platform builds are serial).
**Paid**: artifacts on the order of 174MB with source entering the blob as-is (no bytecode obfuscation; a closed-source distribution requirement needs a separate evaluation); pkg's VFS/module-hook layer remains community-maintained (`@yao-pkg/pkg` is an exact-pinned, pnpm-patched root devDependency; upgrading re-records the patch and is an explicit change); `--sea` is one invocation per target (matching CI's one leg per platform; local multi-platform builds are serial).
@@ -21,6 +21,8 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后
`--sea` 要求构建目标 ≥ node22,exe 统一以 node24 为构建目标;每次 pkg 调用只打包一个构建目标,多平台各调用一次。
`@yao-pkg/pkg` 是精确钉版的根 `devDependency`,经 `pnpm exec pkg` 调用,并以 [`patches/@[email protected]`](../../../../patches/@[email protected]) 移除 SEA bootstrap 中的 `patchChildProcess` 调用。未打补丁时,pkg 会把 spawn 的 `node` 命令——包括 `-c`/`/c` 标志后的命令串,恰是 Bash 工具的 `bash -c` 形态——改写为 exe 自身,并向每个子进程环境注入 `PKG_EXECPATH`,模型下发的 `node --version` 会静默启动 dsh CLI;Node 自身的 SEA 层没有这种改写,且 SEA 二进制永远启动内嵌应用、无法充当纯 Node。移除该调用后,子进程像普通进程一样经 PATH 解析 `node`(无 Node 的机器如实报 command not found),子进程环境不再出现 `PKG_EXECPATH`,以 `process.execPath` 绝对路径 spawn 的重入不受影响,worker 线程本来就未应用该钩子,`process.pkg` 侧车选择也不受影响。
术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的「快照」(ACP(Agent Client Protocol)回放预期输出、`$DSH_SNAPSHOT`)无关,本文用「VFS」指前者。
### 对外服务接口是 dsh 应用中的插件
@@ -64,7 +66,7 @@ exe 内支持 `dsh-workflow-worker-thread` 与 `dsh-code-runtime-worker-thread`
## 测试
验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都会把两个 wheel 包安装进 checkout 外的干净 venv,证明版本相同以及已安装模块/可执行文件的位置,再通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。文件系统搜索场景要求模型通过目标平台的 `-rg` 伴随文件调用 `glob` 与 `grep`。MCP 场景会启动临时外部 stdio server,刻意延迟首次 `tools/list` 响应,随后立即启动第一个 SDK 提示词;该提示词必须看到并调用已发现的工具,从而证明 `initialize` 是真正以 Loader 插件树完全稳定为准的就绪边界,而不是依赖定时 sleep。同一项安装后运行还会经 Python SDK 比较一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。可信拉取请求会增加真实提供方双轮文件写入/读取,并要求外部字节、工具调用、已完成原因与持久化日志一致。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。
验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都会把两个 wheel 包安装进 checkout 外的干净 venv,证明版本相同以及已安装模块/可执行文件的位置,再通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。文件系统搜索场景要求模型通过目标平台的 `-rg` 伴随文件调用 `glob` 与 `grep`。spawn-node 场景驱动平台 shell 工具执行以 `node` 开头的命令,要求工具结果给出机器自身的 Node 版本且子进程环境中无 `PKG_EXECPATH`,把打包运行时钉死在「pkg 升级重录 child_process 补丁也不得回归」的行为上。MCP 场景会启动临时外部 stdio server,刻意延迟首次 `tools/list` 响应,随后立即启动第一个 SDK 提示词;该提示词必须看到并调用已发现的工具,从而证明 `initialize` 是真正以 Loader 插件树完全稳定为准的就绪边界,而不是依赖定时 sleep。同一项安装后运行还会经 Python SDK 比较一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。可信拉取请求会增加真实提供方双轮文件写入/读取,并要求外部字节、工具调用、已完成原因与持久化日志一致。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。
手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,生命周期较短的管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。
@@ -85,4 +87,4 @@ exe 内支持 `dsh-workflow-worker-thread` 与 `dsh-code-runtime-worker-thread`
**买到的**:目标平台零依赖的单文件分发;插件语义与源码运行严格一致(同一棵真实包树,无转译、无注册表);对外服务接口、插件集与配置全部收敛到 `cordis.yml` 和一份依赖 manifest 这两个真源;exe 与 `node` 双载体使用同一棵树和相同语义,开发验证无需等待打包;官方 Node 二进制消除了补丁版二进制的供应链顾虑。
**付出的**:产物约 174MB,且源码原样进入 blob(没有字节码混淆;闭源分发诉求需要另行评估);pkg 的 VFS/模块钩子层仍由社区维护(构建脚本钉死 `@yao-pkg/pkg@6.21.0`,升级需要显式改动);`--sea` 每个构建目标调用一次(与 CI 每个平台一个任务相匹配,本地多平台构建串行执行)。
**付出的**:产物约 174MB,且源码原样进入 blob(没有字节码混淆;闭源分发诉求需要另行评估);pkg 的 VFS/模块钩子层仍由社区维护(`@yao-pkg/pkg` 为精确钉版、带 pnpm 补丁的根 devDependency,升级需重录补丁,属显式改动);`--sea` 每个构建目标调用一次(与 CI 每个平台一个任务相匹配,本地多平台构建串行执行)。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md
2026-08-04-draft-provider-endpoint-interrogation.md: d4112d813ad4f5781b74639209d13952e459f7dd
2026-08-04-draft-provider-endpoint-interrogation.zh.md: 1626a34cb3163949d70688cefeec77d328c62caa
2026-08-04-draft-provider-endpoint-interrogation.md: a6d8d32f12711744cb1380d2ab2767dcd3dd63f6
2026-08-04-draft-provider-endpoint-interrogation.zh.md: e162ddb30706e871a2a42b5bbe7e09a451296289
@@ -6,7 +6,7 @@ English | [中文](2026-08-04-draft-provider-endpoint-interrogation.zh.md)
## Problem
Once a pi-ai route became [a declaration rather than a catalog lookup](2026-08-03-pi-ai-declared-provider-catalog.md), a person adding an OpenAI-compatible gateway had to know its model ids before they could configure it. The adapter no longer constrains them to an installed catalog, which is the point, but it also means nothing tells the user what the endpoint actually serves — and most of these endpoints do publish that list at `GET /models`.
Once a pi-ai route became [a declaration rather than a catalog lookup](2026-08-03-pi-ai-declared-provider-catalog.md), a person adding a compatible gateway had to know its model ids before they could configure it. The adapter no longer constrains them to an installed catalog, which is the point, but it also means nothing tells the user what the endpoint actually serves — and OpenAI- and Anthropic-compatible endpoints publish that list through protocol-specific model-listing routes.
The obvious answer, a dynamic runtime catalog refreshed in the background, was rejected with the layer below it: it makes a route's model list external mutable state needing a cache, an invalidation story, and an offline path, while the product need is narrower. What is needed is a *question asked once*, whose answer the user adopts into `settings.yaml` — so `settings.yaml` remains the only thing deciding what a route serves.
@@ -21,7 +21,7 @@ Interrogation is keyed by **settings namespace**, not by provider route:
- `LlmDiscoveredModel` makes every field but `id` optional, because most listings disclose an id and nothing else. The reply is candidates, not a catalog: a surface adopting one still owes the capacities the adapter requires.
- `llm.discoverModels` carries the same draft over the wire. Its `apiKey` is the third and last payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`, and it is never stored or echoed back. It does ride the client's outgoing envelope like every other secret-bearing payload, where a `subscribeEnvelopes()` observer can see it; redacting that tap is a configuration-plane-wide change, not this method's to make alone. Connection authenticates the method with the complete Host API: it makes the host issue a GET to a caller-chosen URL and reports the outcome, which an anonymous caller must not receive. Every refusal folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered.
`dsh-llm-pi-ai` implements the wire path as a plain `GET {baseURL}/models`, reading `openai-completions` and `openai-responses`: their `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Profile resolution rejects names and values Fetch cannot represent, so a malformed deployment header is reported as a configuration error before interrogation. Configured profile headers are installed first; the fixed JSON accept header, a typed-or-stored bearer credential, and Harness attribution then win case-insensitive collisions in that order. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; both would have reported an authentication failure as a provider with no models. Every other protocol answers `DISCOVERY_UNSUPPORTED`, so the surface falls back to hand-entry rather than reporting a guessed response shape as an empty provider. `baseURL` is 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. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared `content-length` is checked first as a courtesy but never trusted as the bound, matching `dsh-web-fetch`'s two-stage shape for its own caller-supplied URLs.
`dsh-llm-pi-ai` applies the protocol-specific listing routes, authentication, URL normalization, response formats, and metadata rules recorded by [protocol-specific model listing discovery](2026-09-02-protocol-specific-model-listing-discovery.md). Profile resolution rejects names and values Fetch cannot represent, so a malformed deployment header is reported as a configuration error before interrogation. Configured profile headers are installed first; fixed protocol headers, a typed-or-stored protocol credential, and Harness attribution then win their case-insensitive collisions. A protocol without a documented listing contract answers `DISCOVERY_UNSUPPORTED`, so the surface falls back to hand-entry rather than reporting guessed response fields as an empty provider. `baseURL` is treated as a prefix rather than a URL to resolve against, so deployment path segments remain intact. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared `content-length` is checked first as a courtesy but never trusted as the bound, matching `dsh-web-fetch`'s two-stage pattern for its own caller-supplied URLs.
### Why not pi-ai's own refresh machinery
@@ -35,16 +35,16 @@ pi-ai supplies `createProvider({ fetchModels })` plus `Models.refresh()` and a `
**Have the host read the entire stored profile instead of accepting a draft.** No secret would cross the wire for an already-configured provider. But adding a provider would then require saving an unusable configuration first, and a form whose endpoint was edited but not yet saved would silently interrogate the old one. The draft remains authoritative for the endpoint and protocol. The narrow Host-side exceptions are the stored credential, which is write-only, and profile headers, which remain deployment configuration rather than Models-page fields.
**Interrogate every pi-ai protocol.** Anthropic's listing happens to share OpenAI's envelope, and Google's does not. Supporting the ones that are easy would make coverage arbitrary and, worse, make a wrong guess at a response shape indistinguishable from a provider with no models. A protocol that says it cannot be interrogated sends the user to hand-entry, which is the documented fallback.
**Interrogate every pi-ai protocol.** Coverage based on convenient response similarities would be arbitrary and would make a wrong guess indistinguishable from a provider with no models. Anthropic is included only through its documented native listing contract, as the [protocol-specific extension](2026-09-02-protocol-specific-model-listing-discovery.md) records; Google's field set and Azure's request contract differ, while Codex uses OAuth. An unsupported protocol sends the user to hand-entry, which remains the documented fallback.
**Buffer the reply with `response.text()` and check its length.** Simpler, but the bound would arrive after the bytes did, and the endpoint is whatever URL the user typed.
## Consequences
A person adding a gateway can ask it what it serves instead of hunting through its documentation, and the answer arrives as candidates they choose from rather than as configuration written behind their back. An already-configured enterprise gateway uses the same deployment headers for interrogation and model requests without adding a header injection field to the browser protocol. The seam gained a registry that is deliberately small: one offer per namespace, no storage, no lifecycle beyond the fiber.
A person adding a gateway can ask it what it serves instead of hunting through its documentation, and the answer arrives as candidates they choose from rather than as configuration written behind their back. When an endpoint discloses richer metadata, adopting a candidate fills its id, name, context window, and output-token cap into the editable Web row. Search preserves hidden selections, selecting all adds the visible results, and deselecting all clears every result so a filtered picker cannot submit hidden models accidentally. An already-configured enterprise gateway uses the same deployment headers and Harness `User-Agent` for interrogation and model requests without adding a header injection field to the browser protocol. The seam gained a registry that is deliberately small: one offer per namespace, no storage, no lifecycle beyond the fiber.
What it costs: the wire gained a third secret-carrying payload, so the configuration plane's write-only surface is now three methods rather than two. Discovery coverage is protocol-shaped rather than provider-shaped — an Anthropic-compatible gateway must be filled in by hand even though its listing would parse. And because nothing re-runs the question, a model list is still only as current as its last edit; that is the same trade the layer below made deliberately.
What it costs: the wire gained a third secret-carrying payload, so the configuration plane's write-only surface is now three methods rather than two. Discovery coverage remains protocol-shaped rather than provider-shaped, and an endpoint using an unsupported request contract must be filled in by hand. Because nothing re-runs the question, a model list is still only as current as its last edit; that is the same trade the layer below made deliberately.
## Testing
`packages/llm/llm/tests/topology.spec.ts` covers the registry: one offer per namespace, disposal with the fiber, normalization that drops duplicate and unusable ids without inventing capacities, the `NO_DISCOVERY`/`INVALID_DISCOVERY` refusals, and the `model-discovery-failed` Remote mapping. `packages/llm/llm-pi-ai/tests/discovery.spec.ts` drives the probe against local HTTP servers — a listing with and without disclosed capacities, a preserved deployment path, an absent credential, a configured route supplying its stored credential and headers while a typed key wins without resolving the stored one, a catalog route answering without resolving one at all, dropped rows, 401/403 versus a server fault, a non-listing and a non-JSON body, an unreachable endpoint, caller cancellation, an unsupported protocol, and the size ceiling in both its declared-length and streamed forms. `packages/llm/llm-pi-ai/tests/loader-composition.spec.ts` boots settings and credentials through the Loader and proves settings-only headers reach `GET /models` with request-owned headers winning collisions. `packages/llm/llm-pi-ai/tests/adapter.spec.ts` rejects profile headers Fetch cannot represent, and `packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts` proves a settings write reports that configuration error while its last good routes keep serving. `packages/client/connection/tests/node-half.host.spec.ts` pins the `llm/discoverModels` `/api` carrier registration, while `packages/client/ui-settings-models/tests/provider-form.client.spec.tsx` verifies that the draft reaches the Remote whole, absent fields stay absent, and no settings namespace or credential is written before selection.
`packages/llm/llm/tests/topology.spec.ts` covers the registry: one offer per namespace, disposal with the fiber, normalization that drops duplicate and unusable ids without inventing capacities, the `NO_DISCOVERY`/`INVALID_DISCOVERY` refusals, and the `model-discovery-failed` Remote mapping. `packages/llm/llm-pi-ai/tests/discovery.spec.ts` drives the probe against local HTTP servers — standard arrays and enriched objects with every accepted metadata spelling, Anthropic's native path, headers, and capacity fields, route keys that differ from nested canonical ids, name fallback, a preserved deployment path, an absent credential, a configured route supplying its stored credential and headers while a typed key wins without resolving the stored one, a catalog route answering without resolving one at all, dropped rows, 401/403 versus a server fault, a non-listing and a non-JSON body, an unreachable endpoint, caller cancellation, an unsupported protocol, and the size ceiling in both its declared-length and streamed forms. `packages/llm/llm-pi-ai/tests/loader-composition.spec.ts` boots settings and credentials through the Loader and proves settings-only headers reach `GET /models` with request-owned headers winning collisions. `packages/llm/llm-pi-ai/tests/adapter.spec.ts` rejects profile headers Fetch cannot represent, and `packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts` proves a settings write reports that configuration error while its last good routes keep serving. `packages/client/connection/tests/node-half.host.spec.ts` pins the `llm/discoverModels` `/api` carrier registration, while the component and built-Web settings tests verify that the complete draft reaches the Remote, absent fields stay absent, selected metadata fills all four editable model fields, tuned rows win over rediscovery, filtered deselection clears hidden candidates, and no settings namespace or credential is written before selection.
@@ -6,7 +6,7 @@ Status: implemented
## Problem
当 pi-ai 路由变成[一份声明而非 catalog 查表](2026-08-03-pi-ai-declared-provider-catalog.zh.md)之后,要接入一个 OpenAI 兼容网关的人,必须先知道它的模型 id 才能完成配置。适配器不再把人限制在已安装 catalog 里——这正是那次改动的目的——但也意味着没有任何东西告诉用户该端点究竟服务什么,而这类端点大多在 `GET /models` 上公布了这份列表。
当 pi-ai 路由变成[一份声明而非 catalog 查表](2026-08-03-pi-ai-declared-provider-catalog.zh.md)之后,要接入一个兼容网关的人,必须先知道它的模型 id 才能完成配置。适配器不再把人限制在已安装 catalog 里——这正是那次改动的目的——但也意味着没有任何东西告诉用户该端点究竟服务什么,而 OpenAI 与 Anthropic 兼容端点会通过各自协议的模型列表路由公布这份信息。
显而易见的答案——后台刷新的运行时动态 catalog——已随下层一并被拒绝:它会把路由的模型列表变成需要缓存、失效语义与离线路径的外部可变状态,而产品需求要窄得多。真正需要的是*一次性询问*,其答案由用户采纳进 `settings.yaml`,从而让 `settings.yaml` 始终是决定路由服务内容的唯一真源。
@@ -21,7 +21,7 @@ Status: implemented
- `LlmDiscoveredModel` 除 `id` 外每个字段都可选,因为大多数列表只公布 id。回复是候选而非 catalog:采纳其中一条的界面仍要补上适配器所需的容量。
- `llm.discoverModels` 把同一份草稿送过协议层。它的 `apiKey` 是可承载机密的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。它确实会像其他承载机密的载荷一样随客户端外发信封同行,`subscribeEnvelopes()` 观察者看得到;把那个抽头脱敏是整个配置面的改动,不该由这一个方法独自决定。Connection 用与完整 Host API 相同的会话认证该方法:它让宿主向调用方选定的 URL 发起 GET 并回报结果,匿名调用者绝不能获得这类探测能力。每一种拒绝都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。
`dsh-llm-pi-ai` 的实现只是一次朴素的 `GET {baseURL}/models`,且仅限 OpenAI 兼容协议。它们的列表形状是网关、自建服务与官方端点三方一致认可的那一种,而这正是该动作存在的场景。Profile 解析会拒绝 Fetch 无法表示的名称与值,因此格式错误的部署 header 会在询问前以配置错误报告。已配置的 profile headers 最先装入;固定的 JSON accept header、键入或已存的 bearer 凭据以及 Harness attribution 随后依次以大小写不敏感方式赢得冲突。其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把猜错的响应形状报成一个空提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明的 `content-length` 作为善意提示,但绝不把它当作边界;这与 `dsh-web-fetch` 面对自己的调用方提供 URL 时所用的两段式形状一致。
`dsh-llm-pi-ai` 会应用[协议特定模型列表发现](2026-09-02-protocol-specific-model-listing-discovery.zh.md)记录的列表路由、认证、URL 归一化、响应格式与元数据规则。Profile 解析会拒绝 Fetch 无法表示的名称与值,因此格式错误的部署 header 会在询问前以配置错误报告。已配置的 profile headers 最先装入;固定协议 headers、键入或已存的协议凭据以及 Harness attribution 随后分别以大小写不敏感方式赢得相应冲突。没有文档化列表约定的协议会以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把猜错的响应字段报成一个空提供方。`baseURL` 按前缀而非待解析 URL 处理,因此部署路径段会保持不变。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明的 `content-length` 作为善意提示,但绝不把它当作边界;这与 `dsh-web-fetch` 面对自己的调用方提供 URL 时所用的两段式模式一致。
### 为什么不用 pi-ai 自己的 refresh 机制
@@ -35,16 +35,16 @@ pi-ai 提供了 `createProvider({ fetchModels })` 加上 `Models.refresh()` 与
**让 Host 读取整个已存 profile,而不是接受草稿。** 对已配置好的提供方来说,不会有机密跨越协议层。但这样一来新增提供方就必须先保存一份不可用的配置,而端点已改却尚未保存的表单会静默地去询问旧地址。草稿仍是端点和协议的权威来源。Host 侧的狭窄例外是只写的已存凭据,以及仍属部署配置、而非 Models 页面字段的 profile headers。
**询问 pi-ai 的每一种协议。** Anthropic 的列表恰好与 OpenAI 共用同一层信封,而 Google 的不是。只支持容易的那几种会让覆盖范围变得任意;更糟的是,猜错的响应形状会与「该提供方没有模型」无法区分。一个明说自己无法被询问的协议,会把用户送去手工填写——那正是既定的回退路径。
**询问每一种 pi-ai 协议。** 根据便利的响应相似性选择覆盖范围会显得武断,也会让猜错的响应与「提供方没有模型」无法区分。Anthropic 仅通过其文档化原生列表约定纳入支持,具体由[协议特定扩展](2026-09-02-protocol-specific-model-listing-discovery.zh.md)记录;Google 的字段集合与 Azure 的请求约定不同,Codex 则使用 OAuth。不支持的协议会把用户送去手工填写,这仍是既定回退路径。
**用 `response.text()` 缓冲整个回复再判断长度。** 更简单,但上限会在字节已经到达之后才生效,而端点是用户随手填的任意 URL。
## Consequences
接入网关的人可以直接问它服务什么,而不必去翻它的文档;答案以候选形式抵达,由用户自己挑选,而不是被背着写进配置。已配置的企业网关会为询问与模型请求使用同一组部署 headers,而无需给浏览器协议增加 header 注入字段。seam 因此多了一个刻意保持很小的注册表:每个 namespace 一份、不存储、生命周期不超出 fiber。
接入网关的人可以直接问它服务什么,而不必去翻它的文档;答案以候选形式抵达,由用户自己挑选,而不是被背着写进配置。当端点公布了更丰富的元数据时,采纳候选会把 id、名称、上下文窗口与最大输出 token 数填进 Web 的可编辑行。搜索会保留隐藏项的勾选状态,全选会加入可见结果,而取消全选会清空全部结果,因此筛选后的选择器不会意外提交隐藏模型。已配置的企业网关会为询问与模型请求使用同一组部署 headers 和 Harness `User-Agent`,而无需给浏览器协议增加 header 注入字段。seam 因此多了一个刻意保持很小的注册表:每个 namespace 一份、不存储、生命周期不超出 fiber。
代价是:协议层多了第三个承载机密的载荷,配置面的只写接口从两个方法变成三个。发现覆盖范围按协议而非按提供方划分——一个 Anthropic 兼容网关即便其列表能被解析,也仍须手工填写。而且由于没有任何环节会重跑该询问,模型列表的新鲜度依旧只到最近一次编辑为止;这与下层刻意做出的取舍是同一个。
代价是:协议层多了第三个承载机密的载荷,配置面的只写接口从两个方法变成三个。发现覆盖范围仍按协议而非按提供方划分,使用不受支持请求约定的端点仍须手工填写。由于没有任何环节会重跑该询问,模型列表的新鲜度依旧只到最近一次编辑为止;这与下层刻意做出的取舍是同一个。
## Testing
`packages/llm/llm/tests/topology.spec.ts` 覆盖注册表:每个 namespace 一份、随 fiber dispose(资源释放)、丢弃重复与不可用 id 且不凭空补容量的归一化、`NO_DISCOVERY`/`INVALID_DISCOVERY` 两种拒绝,以及 `model-discovery-failed` Remote 映射。`packages/llm/llm-pi-ai/tests/discovery.spec.ts` 针对本地 HTTP 服务器驱动探测——含与不含公布容量的列表、被保留的部署路径、无凭据、已配置路由提供自己的已存凭据与 headers 且键入的密钥无需解析已存凭据便可压过它、catalog 路由完全不解析凭据即作答、被丢弃的行、401/403 与服务器故障之别、非列表与非 JSON 响应、不可达端点、调用方取消、不支持的协议,以及尺寸上限的「声明长度」与「流式」两种形态。`packages/llm/llm-pi-ai/tests/loader-composition.spec.ts` 通过 Loader 启动 settings 与 credentials,并证明仅配置在 settings 中的 headers 会抵达 `GET /models`,且请求所持有的 headers 赢得冲突。`packages/llm/llm-pi-ai/tests/adapter.spec.ts` 拒绝 Fetch 无法表示的 profile headers,`packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts` 证明 settings 写入会报告该配置错误,同时上一组可用路由仍继续服务。`packages/client/connection/tests/node-half.host.spec.ts` 固定 `llm/discoverModels` 的 `/api` 承载注册,`packages/client/ui-settings-models/tests/provider-form.client.spec.tsx` 则验证草稿完整抵达 Remote、缺席字段保持缺席,以及选择前没有 settings namespace 或凭据被写入。
`packages/llm/llm/tests/topology.spec.ts` 覆盖注册表:每个 namespace 一份、随 fiber dispose(资源释放)、丢弃重复与不可用 id 且不凭空补容量的归一化、`NO_DISCOVERY`/`INVALID_DISCOVERY` 两种拒绝,以及 `model-discovery-failed` Remote 映射。`packages/llm/llm-pi-ai/tests/discovery.spec.ts` 针对本地 HTTP 服务器驱动探测——包括采用每种受支持元数据拼写的标准数组与富信息对象、Anthropic 原生路径、headers 与容量字段、不同于嵌套规范 id 的路由键、名称回退、被保留的部署路径、无凭据、已配置路由提供自己的已存凭据与 headers 且键入的密钥无需解析已存凭据便可压过它、catalog 路由完全不解析凭据即作答、被丢弃的行、401/403 与服务器故障之别、非列表与非 JSON 响应、不可达端点、调用方取消、不支持的协议,以及尺寸上限的「声明长度」与「流式」两种形态。`packages/llm/llm-pi-ai/tests/loader-composition.spec.ts` 通过 Loader 启动 settings 与 credentials,并证明仅配置在 settings 中的 headers 会抵达 `GET /models`,且请求所持有的 headers 赢得冲突。`packages/llm/llm-pi-ai/tests/adapter.spec.ts` 拒绝 Fetch 无法表示的 profile headers,`packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts` 证明 settings 写入会报告该配置错误,同时上一组可用路由仍继续服务。`packages/client/connection/tests/node-half.host.spec.ts` 固定 `llm/discoverModels` 的 `/api` 承载注册,而设置页的组件测试和构建后 Web 测试则验证完整草稿抵达 Remote、缺席字段保持缺席、所选元数据填满四个可编辑模型字段、用户调整过的行优先于重新发现结果、筛选后的取消选择会清除隐藏候选项,以及选择前没有 settings namespace 或凭据被写入。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.md
2026-09-02-protocol-specific-model-listing-discovery.md: 69e684c284ff57da7ed61fd5dc417fd110d3e5b7
2026-09-02-protocol-specific-model-listing-discovery.zh.md: 682806fb72c2cc24572e6e351d4d388cead84dc0
@@ -0,0 +1,43 @@
# Agent Note: Reading protocol-specific model listings
Status: implemented
English | [中文](2026-09-02-protocol-specific-model-listing-discovery.zh.md)
## Problem
The [draft provider interrogation](2026-08-04-draft-provider-endpoint-interrogation.md) originally read the OpenAI-compatible `data` array only. Some compatible gateways instead publish an enriched `models` object, while Anthropic publishes a native model-listing route with different authentication and URL rules. Treating either case as unsupported forced a user to copy model ids and capacities by hand even though the endpoint disclosed them.
One gateway could be made to return an OpenAI-style array by sending an OpenAI SDK `User-Agent`. That behavior was undocumented, changed request attribution, and made the reply depend on a client identity rather than on a supported response parser.
## Decision
`dsh-llm-pi-ai` reads model listings according to the selected protocol. `openai-completions` and `openai-responses` use `GET {baseURL}/models` with bearer authentication. `anthropic-messages` uses `GET /v1/models?limit=1000` with `x-api-key` and `anthropic-version: 2023-06-01`. The Anthropic page size is the documented maximum; discovery does not follow `has_more`, so an endpoint advertising more than 1,000 models exposes only its first page.
Anthropic SDK resource methods append `/v1` themselves, and gateway documentation publishes the API root both with and without that suffix. The listing URL therefore treats a drafted Anthropic `baseURL` ending in `/v1` as the same API root as the address without it, and it is the only place that segment is normalized: model requests receive the configured `baseURL` unchanged, exactly as pi-ai handles it. Deployment path prefixes remain intact: `https://gateway.example/tenant/v1` and `https://gateway.example/tenant` both list at `/tenant/v1/models`.
The parser accepts a `data` array or an enriched `models` object, with a present array taking precedence. Array entries use their `id`; object entries use the property key because a nested `id` may name a canonical model instead of the route alias accepted on requests. Only object-valued map entries are considered models, so primitive directory metadata cannot become a candidate accidentally. A nested `id` is the fallback for an empty property key.
The parser normalizes the supported name and capacity spellings into `LlmDiscoveredModel`. A missing display name becomes the request id so adoption fills a complete editable row. The request keeps the Harness attribution headers; response parsing, not client impersonation, provides gateway compatibility.
## Alternatives considered
**Follow every Anthropic page.** Cursor traversal would return listings larger than 1,000 entries, but it adds multi-request failure, cancellation, cursor-progress, and aggregate-size behavior to a configuration action. The implementation requests Anthropic's maximum page and documents the remaining truncation.
**Normalize the inference base as well.** Stripping the same `/v1` segment before model routing would let a `/v1` address both list and serve, but it moves request URL rules out of pi-ai and into this package for one protocol. Model requests keep pi-ai's own handling of `baseURL`; the listing request is the only URL this package builds.
**Refuse a trailing `/v1`.** A load-time or discovery-time rejection would name the mistake early, but gateway documentation publishes the `/v1` spelling, so a user pasting a documented address would be turned away from a listing that works.
**Send an OpenAI SDK `User-Agent` for discovery.** This made one gateway return `data`, but it misattributed Harness traffic and relied on an undocumented client-name branch. Reading both known reply formats keeps attribution accurate.
**Adopt every property of a `models` object.** A primitive-valued property does not prove that its key is a model id and may be directory metadata such as a count or status. Restricting entries to records avoids inventing model candidates.
## Consequences
The Models page can interrogate OpenAI-compatible gateways and Anthropic Messages endpoints without changing request identity. Discovered candidates carry route ids, names, context windows, and output-token caps when the endpoint provides them, and name-only listings still receive an editable label through the id fallback. Anthropic addresses list in either root or `/v1` form; model requests use the configured address as pi-ai receives it.
The supported formats remain an explicit compatibility set rather than arbitrary JSON inference. Anthropic accounts with more than 1,000 visible models require hand-entry for entries outside the first page, and primitive-valued `models` properties are ignored.
## Testing
Local HTTP-server tests pin both accepted response formats, field normalization, name fallback, ignored malformed entries, Anthropic headers, the maximum-page query, and both spellings of the Anthropic root. Replies recorded from OpenRouter, models.dev, and DeepSeek on 2026-09-02, together with the example reply in Anthropic's List Models reference, live under `packages/llm/llm-pi-ai/tests/fixtures/model-listings/` and replay through the parser, so the accepted field spellings are pinned to real endpoints and the published reference rather than to hand-written samples.
@@ -0,0 +1,43 @@
# Agent Note: 读取协议特定的模型列表
Status: implemented
[English](2026-09-02-protocol-specific-model-listing-discovery.md) | 中文
## 问题
[提供方草稿询问决策](2026-08-04-draft-provider-endpoint-interrogation.zh.md)最初只读取 OpenAI 兼容的 `data` 数组。一些兼容网关改为公布富信息 `models` 对象,而 Anthropic 公布了具有不同认证与 URL 规则的原生模型列表路由。把任一情况视为不受支持,都会迫使用户手工复制模型 id 和容量,即使端点已经公布这些信息。
向一个网关发送 OpenAI SDK `User-Agent` 可以使其返回 OpenAI 风格数组。该行为没有文档,改变了请求归属,并使回答取决于客户端身份而非受支持的响应解析器。
## 决策
`dsh-llm-pi-ai` 按所选协议格式读取模型列表。`openai-completions` 与 `openai-responses` 以 bearer 认证使用 `GET {baseURL}/models`。`anthropic-messages` 以 `x-api-key` 和 `anthropic-version: 2023-06-01` 使用 `GET /v1/models?limit=1000`。Anthropic 页大小采用文档规定的最大值;模型发现不会继续跟随 `has_more`,因此公布超过 1,000 个模型的端点只会暴露第一页。
Anthropic SDK 资源方法会自行追加 `/v1`,而网关文档会同时发布带与不带该后缀的 API 根地址。因此列表 URL 会把末尾为 `/v1` 的 Anthropic `baseURL` 草稿视为与不带该后缀的地址相同的 API 根地址,并且只有它会归一化这一段:模型请求收到的仍是配置原样的 `baseURL`,与 pi-ai 的处理完全一致。部署路径前缀会保留:`https://gateway.example/tenant/v1` 与 `https://gateway.example/tenant` 都在 `/tenant/v1/models` 列表。
解析器接受 `data` 数组或富信息 `models` 对象,并在数组存在时优先使用它。数组条目使用自身的 `id`;对象条目使用属性键,因为嵌套 `id` 可能指向规范模型,而不是请求所接受的路由别名。只有值为对象的映射条目才视为模型,因此原始类型的目录元数据不会意外成为候选。空属性键才会回退到嵌套 `id`。
解析器会把受支持的名称与容量拼写归一化为 `LlmDiscoveredModel`。缺失的显示名会变成请求 id,使采纳操作填入完整的可编辑行。请求保留 Harness 归属标头;网关兼容性由响应解析提供,而非冒充客户端身份。
## 考虑过的替代方案
**跟随 Anthropic 的所有页面。** 游标遍历可以返回超过 1,000 个条目的列表,但会给配置操作增加多请求失败、取消、游标推进与总大小处理。实现请求 Anthropic 的最大页面,并记录剩余截断限制。
**同时归一化推理地址。** 在模型路由前截掉同一段 `/v1` 可以让 `/v1` 地址既能列表也能服务,但这会把请求 URL 规则从 pi-ai 挪进本包,且只为一种协议。模型请求保持 pi-ai 自身对 `baseURL` 的处理;列表请求是本包构造的唯一 URL。
**拒绝末尾的 `/v1`。** 在加载或探测时拒绝可以尽早点出错误,但网关文档发布的就是 `/v1` 写法,照文档粘贴地址的用户会被一个本能工作的列表拒之门外。
**为模型发现发送 OpenAI SDK `User-Agent`。** 这会让一个网关返回 `data`,但会错误标记 Harness 流量,并依赖未记录的客户端名称分支。读取两种已知响应格式可以保持归属准确。
**采纳 `models` 对象的每个属性。** 原始类型属性不能证明其键是模型 id,也可能是数量或状态等目录元数据。把条目限制为记录可避免虚构模型候选。
## 后果
Models 页面无需改变请求身份,即可询问 OpenAI 兼容网关与 Anthropic Messages 端点。发现的候选会在端点提供时携带路由 id、名称、上下文窗口与最大输出 token 数,只有 id 的列表也会通过 id 回退获得可编辑标签。Anthropic 地址以根地址或 `/v1` 形式都能列表;模型请求使用 pi-ai 收到的配置地址。
受支持格式仍是显式兼容集合,而不是任意 JSON 推断。可见模型超过 1,000 个的 Anthropic 账户需要手工录入第一页之外的条目,原始类型的 `models` 属性会被忽略。
## 测试
本地 HTTP 服务器测试钉住两种受支持响应格式、字段归一化、名称回退、忽略畸形条目、Anthropic 标头、最大页查询,以及 Anthropic 根地址的两种写法。2026-09-02 从 OpenRouter、models.dev 与 DeepSeek 录得的回复,连同 Anthropic List Models 参考文档给出的示例回复,存放在 `packages/llm/llm-pi-ai/tests/fixtures/model-listings/` 下并经解析器回放,因此受支持的字段拼写钉在真实端点与公开参考文档上,而不是手写样例上。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md
2026-08-10-event-directed-pr-review-status.md: 47f6f1731b037ae55a994c3373c0f99917da98dd
2026-08-10-event-directed-pr-review-status.zh.md: 8062ab5b1f2efdfcba92f0675af700e59a358d25
2026-08-10-event-directed-pr-review-status.md: de4dc0700f2083772321fdf5f26c05fdf39928be
2026-08-10-event-directed-pr-review-status.zh.md: b8a8fbaa25673a376542965700e864f82ab0d739
@@ -16,7 +16,7 @@ The Issue lifecycle workflow treats review webhooks as commands. `pull_request.r
Ordinary subscribed pull-request events remain forward-only implementation signals: they can move `Inbox`, `Backlog`, or `Ready` to `In progress`, but they cannot move `In review` backward. Review-request commands can move any earlier active status to `In review`. Changes-requested commands can move earlier active statuses forward to `In progress` and can move `In review` back only when the latest status event for the target Project was written by the configured lifecycle actor. A human or unknown latest actor preserves the current status.
The status projection resolves only exact same-repository `Fixes`, `Closes`, or `Resolves` references. It does not alter terminal statuses, add an Issue with no Project status, depend on PR metadata validity, query `reviewDecision`, reconstruct review rounds, look up pull requests from Issues, or run a scheduled reconciler. [PR-opened Issue start dates](2026-08-31-pr-opened-issue-start-dates.md) own the separate date initialization for every same-repository Issue reference.
The status projection resolves only exact same-repository `Fixes`, `Closes`, or `Resolves` references. It does not alter terminal statuses, add an Issue with no Project status, depend on PR metadata validity, query `reviewDecision`, reconstruct review rounds, look up pull requests from Issues, or run a scheduled reconciler. [Project-local Issue planning fields](2026-09-02-project-local-issue-planning-fields.md) own the separate date initialization for every same-repository Issue reference.
[Issue lifecycle](../../../../.github/workflows/issue-lifecycle.yml) remains unsubscribed from `pull_request.ready_for_review`; neither event command depends on that action. [Issue policy](../../../../.github/workflows/issue-policy.yml) retains `ready_for_review` because it owns required-check enforcement when a human pull request enters review.
@@ -16,7 +16,7 @@ Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review
工作流订阅的普通 PR 事件仍是只向前推进的实现信号:它们可以将 `Inbox`、`Backlog` 或 `Ready` 推进至 `In progress`,但不能让 `In review` 倒退。请求评审命令可将任意较早的活跃状态推进至 `In review`。请求修改命令可将较早的活跃状态推进至 `In progress`;它也可以让 `In review` 状态回退,但仅在目标 Project 的最新状态事件由配置的生命周期执行主体写入时进行。若最新状态事件的执行主体是人工用户或未知主体,则保留当前状态。
状态投影仅解析同一仓库内严格匹配的 `Fixes`、`Closes` 或 `Resolves` 引用。它不会更改终态、将没有 Project 状态的 Issue 添加到 Project、依赖 PR 元数据是否有效、查询 `reviewDecision`、重建评审轮次、从 Issue 反向查找 PR,或运行定时协调器。独立的日期初始化由[在 PR 创建时设置 Issue 开始日期](2026-08-31-pr-opened-issue-start-dates.zh.md)负责,并处理每个同仓库 Issue 引用。
状态投影仅解析同一仓库内严格匹配的 `Fixes`、`Closes` 或 `Resolves` 引用。它不会更改终态、将没有 Project 状态的 Issue 添加到 Project、依赖 PR 元数据是否有效、查询 `reviewDecision`、重建评审轮次、从 Issue 反向查找 PR,或运行定时协调器。独立的日期初始化由[Project 局部 Issue 规划字段](2026-09-02-project-local-issue-planning-fields.zh.md)负责,并处理每个同仓库 Issue 引用。
[Issue 生命周期](../../../../.github/workflows/issue-lifecycle.yml)仍不订阅 `pull_request.ready_for_review`;两条事件命令均不依赖该动作。[Issue 策略](../../../../.github/workflows/issue-policy.yml)保留 `ready_for_review`,因为人工提交的 PR 进入评审时,该工作流负责执行必需检查门禁。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.md
2026-09-02-project-local-issue-planning-fields.md: e44fe0f002c40309beaeb4af3ccdd975714777e6
2026-09-02-project-local-issue-planning-fields.zh.md: 28308d48994dbb144f7e7934a1058cef35942c20
@@ -0,0 +1,43 @@
# Agent Note: Project-local Issue planning fields
Status: implemented
English | [中文](2026-09-02-project-local-issue-planning-fields.zh.md)
## Problem
The Issue lifecycle workflow needs structured planning metadata, but organization Issue fields require a separate GitHub App permission from organization Projects. A workflow token with Project write access can read and update Project custom fields while GitHub rejects Issue-field reads, so using both storage systems makes one policy depend on two independently administered permission sets.
Priority, impact, cost, and dates are used to plan work in `DSH Issue Management`. Keeping those values on the Issue also exposes them outside that Project, but the repository has no workflow that needs cross-Project values.
## Decision
The `DSH Issue Management` Project owns `Priority`, `Severity`, `Cost`, `Start Date`, and `Target Date` as Project custom fields. `Severity` uses the option meanings from the organization `影响面` field, and `Cost` uses the option meanings from `解决代价`.
Repository policy resolves `Priority` and `Start Date` from the configured Project. It rejects an Issue-backed field or the wrong data type, reads Priority from the Project item, and writes Start Date through `updateProjectV2ItemFieldValue`. Organization Issue fields are retained only as `Legacy ...` migration sources and are not read by repository workflows.
The pull-request policy workflow uses the repository `GITHUB_TOKEN` for REST Issue and pull-request reads, and a GitHub App token restricted to repository Issues and organization Projects read access for ProjectV2 queries. Lifecycle mutations continue to use the write-capable App token.
The Issue lifecycle workflow initializes `Start Date` only for `pull_request.opened`. It reads the pull request's live body, retains every same-repository reference that resolves to an Issue, converts `created_at` to a calendar date in the configured Project time zone, ensures the Issue is a Project item, and writes the date only when the current Project value is empty.
The [organization-field implementation](../../archived/process/2026-08-31-pr-opened-issue-start-dates.md) records the superseded cross-Project ownership decision and its event-timing rationale. Event-directed Status transitions remain owned by [the lifecycle decision](2026-08-10-event-directed-pr-review-status.md).
## Verification
[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) require Project custom fields for Priority and Start Date, prove repository and Project reads use separate credentials, cover the Shanghai date boundary, opened-only dispatch, empty-value writes, existing-value preservation, and missing Project items, and pin `updateProjectV2ItemFieldValue`. Workflow tests pin the Project token's read-only permission. Removing an organization field requires comparing every legacy value with its Project value, including archived Project items.
## Alternatives considered
**Keep organization Issue fields.** They make one value visible across Projects, but the workflow does not need that scope and the GitHub App would require separate organization Issue Fields access.
**Dual-write Issue and Project fields.** Mirrored fields retain cross-Project visibility, but every writer and manual edit can create drift and requires a reconciliation policy.
**Process every subscribed pull-request event or overwrite Start Date.** Later events could repair missing dates, but they would assign dates after work starts or replace a manual plan. The initializer therefore retains opened-only, empty-only behavior.
## Consequences
Planning metadata is scoped to one Project membership. The same Issue can have different values in another Project, and an Issue outside `DSH Issue Management` has no Project-local planning values.
The GitHub App needs Project access rather than organization Issue Fields access for policy metadata. Field renames or type changes fail the workflow instead of falling back to legacy fields.
The empty-value read makes ordinary retries idempotent. Project field updates have no compare-and-set precondition, so simultaneous pull requests can both observe an empty Start Date and the last mutation can win.
@@ -0,0 +1,43 @@
# Agent Note: Project 局部 Issue 规划字段
Status: implemented
[English](2026-09-02-project-local-issue-planning-fields.md) | 中文
## 问题
Issue 生命周期工作流需要结构化规划元数据,但组织 Issue 字段使用的 GitHub App 权限独立于组织 Project 权限。具有 Project 写权限的工作流 token 可以读取和更新 Project custom field,而 GitHub 会拒绝读取 Issue 字段,因此同时使用两套存储会让同一策略依赖两组独立管理的权限。
Priority、影响面、解决代价和日期用于在 `DSH Issue Management` 中规划工作。把这些值保存在 Issue 上还会让它们在该 Project 之外可见,但仓库没有需要跨 Project 值的工作流。
## 决策
`DSH Issue Management` Project 使用 Project custom field 存储 `Priority`、`Severity`、`Cost`、`Start Date` 和 `Target Date`。`Severity` 沿用组织字段 `影响面` 的选项含义,`Cost` 沿用 `解决代价` 的选项含义。
仓库策略从配置的 Project 解析 `Priority` 和 `Start Date`。策略拒绝 Issue 字段投影或错误的数据类型,从 Project item 读取 Priority,并通过 `updateProjectV2ItemFieldValue` 写入 Start Date。组织 Issue 字段仅作为带有 `Legacy ...` 前缀的迁移源保留,仓库工作流不会读取它们。
PR 策略工作流使用仓库 `GITHUB_TOKEN` 执行 REST Issue 和 PR 读取,并使用仅有仓库 Issues 与组织 Projects 读取权限的 GitHub App token 执行 ProjectV2 查询。生命周期 mutation 继续使用有写权限的 App token。
Issue 生命周期工作流仅在 `pull_request.opened` 时初始化 `Start Date`。工作流读取 PR 的实时正文,保留每个能解析为 Issue 的同仓库引用,把 `created_at` 按配置的 Project 时区转换为日历日期,确保 Issue 是 Project item,并仅在当前 Project 值为空时写入日期。
[组织字段实现](../../archived/process/2026-08-31-pr-opened-issue-start-dates.md)记录了已被取代的跨 Project 所有权决策及其事件时机依据。由事件直接指定的 Status 转换仍由[生命周期决策](2026-08-10-event-directed-pr-review-status.zh.md)负责。
## 验证
[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)要求 Priority 和 Start Date 使用 Project custom field,证明仓库读取与 Project 读取使用不同凭据,覆盖上海时区日期边界、仅 opened 分派、空值写入、已有值保留和 Project item 缺失,并固定 `updateProjectV2ItemFieldValue`。工作流测试固定 Project token 的只读权限。删除组织字段前必须逐项比较所有旧字段值与 Project 值,包括已归档的 Project item。
## 考虑过的替代方案
**保留组织 Issue 字段。** 它们可以让一个值在多个 Project 中可见,但工作流不需要该范围,并且 GitHub App 还需要单独的组织 Issue Fields 权限。
**同时写入 Issue 和 Project 字段。** 镜像字段保留跨 Project 可见性,但每个写入方和人工编辑都可能产生偏差,并且还需要协调策略。
**处理每个已订阅 PR 事件或覆盖 Start Date。** 后续事件可以修复缺失日期,但会在工作开始后才赋值或替换人工计划。因此初始化器保留仅 opened、仅空值的行为。
## 后果
规划元数据限定在一个 Project 归属中。同一个 Issue 可以在另一个 Project 中使用不同的值,`DSH Issue Management` 之外的 Issue 没有 Project 局部规划值。
GitHub App 通过 Project 权限而不是组织 Issue Fields 权限访问策略元数据。字段改名或类型变化会让工作流失败,而不会回退到旧字段。
空值读取使通常的重试保持幂等。Project 字段更新没有比较并设置前提,因此同时引用同一个 Issue 的 PR 可能都会观察到空的 Start Date,最后一次 mutation 可能胜出。
+49 -16
View File
@@ -52,6 +52,9 @@ for (const status of ['In progress', 'In review']) {
if (typeof config.lifecycleActor !== 'string' || !config.lifecycleActor) {
throw new Error('config.lifecycleActor 未设置')
}
if (typeof config.priorityField !== 'string' || !config.priorityField) {
throw new Error('config.priorityField 未设置')
}
if (typeof config.startDateField !== 'string' || !config.startDateField) {
throw new Error('config.startDateField 未设置')
}
@@ -415,6 +418,10 @@ function token() {
return value
}
function projectToken() {
return process.env.PROJECT_TOKEN || token()
}
async function api(path, options = {}) {
const response = await fetch(`${process.env.GITHUB_API_URL ?? 'https://api.github.com'}${path}`, {
...options,
@@ -439,19 +446,25 @@ async function graphql(query, variables) {
const result = await api('/graphql', {
method: 'POST',
body: JSON.stringify({ query, variables }),
headers: { 'Content-Type': 'application/json' },
headers: {
Authorization: `Bearer ${projectToken()}`,
'Content-Type': 'application/json',
},
})
if (result.errors?.length) throw new Error(result.errors.map((error) => error.message).join('; '))
return result.data
}
async function issueSnapshot(number, status = undefined) {
/**
* Read one Issue together with its Project planning values.
* @param {number} number Same-repository Issue number.
* @param {string|null|undefined} status Optional known Project status.
* @returns {Promise<object|null>} Issue snapshot, or null when the number identifies a pull request.
*/
export async function issueSnapshot(number, status = undefined) {
const issue = await api(`/repos/${config.organization}/${config.repository}/issues/${number}`)
if (issue.pull_request) return null
const values = await api(
`/repos/${config.organization}/${config.repository}/issues/${number}/issue-field-values?per_page=100`,
)
const field = (name) => values.find((value) => value.issue_field_name === name)
const context = await projectContext(number)
return {
number,
nodeId: issue.node_id,
@@ -460,8 +473,8 @@ async function issueSnapshot(number, status = undefined) {
assignees: issue.assignees.map((assignee) => assignee.login),
labels: issue.labels.map((label) => label.name),
type: issue.type?.name ?? null,
priority: field(config.priorityField)?.single_select_option?.name ?? null,
status: status === undefined ? await projectStatus(number) : status,
priority: context.item?.priorityValue?.name ?? null,
status: status === undefined ? (context.item?.fieldValueByName?.name ?? null) : status,
state: issue.state,
stateReason: issue.state_reason ?? null,
}
@@ -476,6 +489,7 @@ async function projectContext(number, includeStatusActor = false, includeStartDa
$project: Int!
$includeStatusActor: Boolean!
$includeStartDate: Boolean!
$priorityField: String!
$startDateField: String!
) {
organization(login: $organization) {
@@ -484,8 +498,19 @@ async function projectContext(number, includeStatusActor = false, includeStartDa
title
fields(first: 50) {
nodes {
... on ProjectV2Field { id name dataType isIssueField }
... on ProjectV2SingleSelectField { id name dataType options { id name } }
... on ProjectV2Field {
id
name
dataType
isIssueField
}
... on ProjectV2SingleSelectField {
id
name
dataType
isIssueField
options { id name }
}
}
}
}
@@ -510,6 +535,9 @@ async function projectContext(number, includeStatusActor = false, includeStartDa
fieldValueByName(name: "Status") {
... on ProjectV2ItemFieldSingleSelectValue { name optionId }
}
priorityValue: fieldValueByName(name: $priorityField) {
... on ProjectV2ItemFieldSingleSelectValue { name optionId }
}
startDateValue: fieldValueByName(name: $startDateField)
@include(if: $includeStartDate) {
... on ProjectV2ItemFieldDateValue { date }
@@ -526,6 +554,7 @@ async function projectContext(number, includeStatusActor = false, includeStartDa
project: config.projectNumber,
includeStatusActor,
includeStartDate,
priorityField: config.priorityField,
startDateField: config.startDateField,
},
)
@@ -535,6 +564,14 @@ async function projectContext(number, includeStatusActor = false, includeStartDa
if (!issue) throw new Error(`#${number} 不存在`)
const statusField = project.fields.nodes.find((field) => field?.name === 'Status')
if (!statusField) throw new Error('Project 缺少 Status 字段')
const priorityField = project.fields.nodes.find((field) => field?.name === config.priorityField)
if (!priorityField) throw new Error(`Project 缺少 ${config.priorityField} 字段`)
if (priorityField.dataType !== 'SINGLE_SELECT') {
throw new Error(`Project ${config.priorityField} 字段必须为 Single Select`)
}
if (priorityField.isIssueField) {
throw new Error(`Project ${config.priorityField} 字段必须为 Project custom field`)
}
const startDateField = includeStartDate
? project.fields.nodes.find((field) => field?.name === config.startDateField)
: null
@@ -555,12 +592,7 @@ async function projectContext(number, includeStatusActor = false, includeStartDa
latestStatusEvent && latestStatusEvent.status === item?.fieldValueByName?.name
? (latestStatusEvent.actor?.login ?? null)
: null
return { project, issue, statusField, startDateField, item, statusActor }
}
async function projectStatus(number) {
const context = await projectContext(number)
return context.item?.fieldValueByName?.name ?? null
return { project, issue, statusField, priorityField, startDateField, item, statusActor }
}
async function ensureProjectItem(number, includeStartDate = false) {
@@ -579,6 +611,7 @@ async function ensureProjectItem(number, includeStartDate = false) {
item: {
id: data.addProjectV2ItemById.item.id,
fieldValueByName: null,
priorityValue: null,
startDateValue: null,
},
}
+91 -1
View File
@@ -5,6 +5,7 @@ import {
countVisibleUnits,
initializeIssueStartDate,
initializePullRequestStartDates,
issueSnapshot,
nextResolvingIssueStatus,
parseReferences,
projectDate,
@@ -18,6 +19,10 @@ import {
const projectGraphqlData = ({
projectItem = true,
priority = null,
priorityField = true,
priorityType = 'SINGLE_SELECT',
priorityIsIssueField = false,
startDate = null,
startDateField = true,
startDateType = 'DATE',
@@ -29,7 +34,24 @@ const projectGraphqlData = ({
title: 'DSH Issue Management',
fields: {
nodes: [
{ id: 'status-field-id', name: 'Status', dataType: 'SINGLE_SELECT', options: [] },
{
id: 'status-field-id',
name: 'Status',
dataType: 'SINGLE_SELECT',
isIssueField: false,
options: [],
},
...(priorityField
? [
{
id: 'priority-project-field-id',
name: 'Priority',
dataType: priorityType,
isIssueField: priorityIsIssueField,
options: [],
},
]
: []),
...(startDateField
? [
{
@@ -54,6 +76,8 @@ const projectGraphqlData = ({
id: 'item-id',
project: { id: 'project-id' },
fieldValueByName: { name: 'Inbox', optionId: 'inbox-option-id' },
priorityValue:
priority === null ? null : { name: priority, optionId: `${priority}-option-id` },
startDateValue: startDate === null ? null : { date: startDate },
},
]
@@ -266,6 +290,52 @@ test('initializes every referenced Issue only for a PR opened event', async () =
assert.equal(writes.length, 3)
})
test('reads Priority and Status from Project custom fields', async (t) => {
const previousGhToken = process.env.GH_TOKEN
const previousGithubToken = process.env.GITHUB_TOKEN
const previousProjectToken = process.env.PROJECT_TOKEN
delete process.env.GH_TOKEN
process.env.GITHUB_TOKEN = 'repository-token'
process.env.PROJECT_TOKEN = 'project-token'
t.after(() => {
if (previousGhToken === undefined) delete process.env.GH_TOKEN
else process.env.GH_TOKEN = previousGhToken
if (previousGithubToken === undefined) delete process.env.GITHUB_TOKEN
else process.env.GITHUB_TOKEN = previousGithubToken
if (previousProjectToken === undefined) delete process.env.PROJECT_TOKEN
else process.env.PROJECT_TOKEN = previousProjectToken
})
const urls = []
t.mock.method(globalThis, 'fetch', async (url, options) => {
urls.push(url)
if (url.endsWith('/issues/42')) {
assert.equal(options.headers.Authorization, 'Bearer repository-token')
return Response.json({
node_id: 'issue-id',
title: 'Project metadata',
body: null,
assignees: [],
labels: [],
type: { name: 'Task' },
state: 'open',
state_reason: null,
})
}
assert.equal(url, 'https://api.github.com/graphql')
assert.equal(options.headers.Authorization, 'Bearer project-token')
return Response.json({ data: projectGraphqlData({ priority: 'P1' }) })
})
const issue = await issueSnapshot(42)
assert.equal(issue.priority, 'P1')
assert.equal(issue.status, 'Inbox')
assert.deepEqual(urls, [
'https://api.github.com/repos/deepseek-harness/deepseek-harness/issues/42',
'https://api.github.com/graphql',
])
})
test('writes an empty Project Start Date with the configured field', async (t) => {
const requests = mockGraphql(t, (request) => {
if (request.query.includes('query(')) return projectGraphqlData()
@@ -277,6 +347,8 @@ test('writes an empty Project Start Date with the configured field', async (t) =
assert.equal(requests.length, 2)
assert.match(requests[0].query, /isIssueField/)
assert.doesNotMatch(requests[0].query, /issueField\s*\{/)
assert.match(requests[0].query, /priorityValue: fieldValueByName/)
assert.equal(requests[0].variables.priorityField, 'Priority')
assert.match(requests[0].query, /ProjectV2ItemFieldDateValue/)
assert.match(requests[1].query, /updateProjectV2ItemFieldValue/)
assert.match(requests[1].query, /value: \{date: \$date\}/)
@@ -332,6 +404,24 @@ test('rejects a missing, non-Date, or Issue-level Start Date field', async (t) =
assert.equal(requests.length, 3)
})
test('rejects a missing, non-select, or Issue-level Priority field', async (t) => {
let response = projectGraphqlData({ priorityField: false })
const requests = mockGraphql(t, () => response)
await assert.rejects(initializeIssueStartDate(42, '2026-08-28'), /Project 缺少 Priority 字段/)
response = projectGraphqlData({ priorityType: 'TEXT' })
await assert.rejects(
initializeIssueStartDate(42, '2026-08-28'),
/Priority 字段必须为 Single Select/,
)
response = projectGraphqlData({ priorityIsIssueField: true })
await assert.rejects(
initializeIssueStartDate(42, '2026-08-28'),
/Priority 字段必须为 Project custom field/,
)
assert.equal(requests.length, 3)
})
test('does not treat pull request references as Issue associations', () => {
const references = {
all: [123, 1180, 1181],
+13
View File
@@ -21,7 +21,20 @@ jobs:
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Create Project read token
id: app-token
if: ${{ github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.type != 'App' }}
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.DSH_ISSUE_APP_CLIENT_ID }}
private-key: ${{ secrets.DSH_ISSUE_APP_PRIVATE_KEY }}
owner: deepseek-harness
repositories: deepseek-harness
permission-issues: read
permission-organization-projects: read
- name: Validate pull request
if: ${{ github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.type != 'App' }}
env:
GITHUB_TOKEN: ${{ github.token }}
PROJECT_TOKEN: ${{ steps.app-token.outputs.token }}
run: node .github/issue-management/policy.mjs pr
+2 -6
View File
@@ -112,6 +112,7 @@ External packages that a workspace package resolves at runtime. The tier covers
pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
- `@yao-pkg/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
- `[email protected]` — [`patches/[email protected]`](patches/[email protected])
## Official Claude Code platform payloads
@@ -161,6 +162,7 @@ External packages **directly declared** only by repository tooling, test infrast
| [`@types/ws`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@vitejs/plugin-react`](https://github.com/vitejs/vite-plugin-react) | MIT |
| [`@vitest/coverage-v8`](https://github.com/vitest-dev/vitest) | MIT |
| [`@yao-pkg/pkg`](https://github.com/yao-pkg/pkg) | MIT |
| [`@yarnpkg/cli-dist`](https://github.com/yarnpkg/berry) | BSD-2-Clause |
| [`cytoscape`](https://github.com/cytoscape/cytoscape.js) | MIT |
| [`cytoscape-cose-bilkent`](https://github.com/cytoscape/cytoscape.js-cose-bilkent) | MIT |
@@ -204,12 +206,6 @@ Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the developm
| [`pytest`](https://github.com/pytest-dev/pytest) | MIT | test-only |
| [`uv`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool |
## Fetched at build time
| Package | License | Role |
| --- | --- | --- |
| [`@yao-pkg/pkg`](https://github.com/yao-pkg/pkg) | MIT | invoked by `scripts/build-exe-for-python-sdk.ts` to assemble the single-file SDK runtime executable |
## First-party native packages
`@deepseek-ai/node-addon-landlock-run` (and its platform packages) is built and released from this repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh",
"description": "dsh CLI: profile boot, plugin management, and the browser UI alias",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-web-frontend",
"description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+2 -3
View File
@@ -179,7 +179,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('filters the discovered model catalog and preserves hidden selections', async () => {
it('filters the discovered model catalog and clears hidden selections', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-picker'))
const settingsDialog = page.getByRole('dialog', { name: '设置' })
await settingsDialog.getByRole('button', { name: '编辑 minimax-cn' }).click()
@@ -206,8 +206,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
await search.fill('')
await expect.poll(async () => boxes.count()).toBe(count)
const restored = await boxes.evaluateAll(nodes => nodes.map(node => (node as HTMLInputElement).checked))
expect(restored.filter(Boolean)).toHaveLength(count - 1)
expect(restored.filter(checked => !checked)).toHaveLength(1)
expect(restored).toEqual(Array.from({ length: count }, () => false))
await picker.getByRole('button', { name: '全选' }).waitFor()
await picker.getByRole('button', { name: '全选' }).click()
const snapshot = await captureStableAria(
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-root",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"license": "MIT",
"private": true,
"type": "module",
@@ -169,6 +169,7 @@
"@types/node": "^22.20.0",
"@types/spdx-expression-parse": "^4.0.0",
"@vitest/coverage-v8": "^4.1.8",
"@yao-pkg/pkg": "6.21.0",
"@yarnpkg/cli-dist": "4.17.1",
"eslint-plugin-sonarjs": "^4.1.0",
"execa": "^10.0.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-acp",
"description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-api-gateway",
"description": "Typert Remote Host dispatcher and Client API endpoint",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-api-remotes",
"description": "Remote BFF assembly for application-selected Host capabilities",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-api-session-controller",
"description": "Session Remote commands, cold reads, and live control transport",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-api-settings-controller",
"description": "Remote owner for the configuration surfaces over the settings-domain seams",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-api-workspace-controller",
"description": "Workspace Remote commands and reconnect-safe state transport",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-attachment-local",
"description": "Private content-addressed DSH_HOME attachment storage",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-attachment",
"description": "Durable immutable attachment storage seam for the DeepSeek Harness",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-app-boot",
"description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-cmdline",
"description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-acp-app",
"description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-base",
"description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-headless",
"description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-sdk-app",
"description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-sdk-minimal",
"description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, editor, and JSONL sessions",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-web-app",
"description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-connection",
"description": "Authenticated RPC transport, generation lifecycle, and browser fixture",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-hmr",
"description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-locale",
"description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-modules",
"description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-store",
"description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-agent-preset",
"description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-approval",
"description": "Approval composer takeover over the scoped Remote Event waterfall",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-attachment",
"description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-brand-official",
"description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-chat",
"description": "Chat Conversation target, node definitions, renderers, and details surface",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-commands",
"description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-conversation",
"description": "Target-neutral Conversation assembly, shell, composer, queue, and view navigation",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-deliverables",
"description": "Produced-files turn tail and clickable final-response file references for Web",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-directory-picker-browse",
"description": "In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-directory-picker-native",
"description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the host's OS chooser",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-goal",
"description": "Session goal surface: GoalBar docked above the composer, read from the goal session projection",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-input-trigger",
"description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-jobs",
"description": "Session-header background-job list with on-demand streaming record panels",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-layout",
"description": "Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels)",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-message-feedback",
"description": "Per-message feedback controls contributed to the assistant-message action strip, backed by the messageFeedback Host Remote",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-model-selection",
"description": "Model selection over the shared model catalog, Session projection, and session.selectModel",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-permission-presets",
"description": "Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-plan",
"description": "Plan-mode composer control: the conversation.input.plan seat over the plan projection and the /plan command channel",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-primitives",
"description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-reference",
"description": "Unified Web @file and @session reference source",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-renderer",
"description": "Browser UI renderer: React slot bindings, ctx.uiRenderer, and the assembled application root",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-schedule",
"description": "Read-only active Schedule catalog in the Web Session header",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-session",
"description": "Session Controller adapter for React and session-scoped slots",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-settings-general",
"description": "Settings ownerless-copy and product onboarding plugin: the General section, shell trigger/header chrome content, settings dictionaries, and the versioned welcome notice",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md
README.md: b3f07e1a788b223f0f49e37b9a4a54662acf6445
README.zh.md: c23dcf95f6b9e9d47b390c3243bf3d8bb0ef26f1
README.md: cac2785886346a0c1e81009404fdb0da238bbe40
README.zh.md: 7df96a9a1b9474b5e88d8f9814f353f92eb1a31d
+2 -2
View File
@@ -37,7 +37,7 @@ The collapsed 自定义设置 fold carries the curated extras: `baseURL` for bot
### Adding and deleting providers
The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. **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 is written until **Add selected**. Search matches model ids and optional display names without clearing hidden selections, while **Select all** and **Deselect all** affect only the visible results. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. **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 is written until **Add selected**. Each selected candidate copies its id, display name, context window, and output-token cap 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** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. 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
@@ -105,7 +105,7 @@ These limits define the editor's field coverage and the page's reach; they are c
- **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved.
- **Credential cleanup is intentionally narrow** — deleting a row removes the configured, writable credential only when its reference is the exact `<ROUTE>_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-provider card 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.
- **Interrogation covers OpenAI-compatible endpoints** — the adapter reads only that model-list response format, so a gateway speaking another protocol reports that it cannot be asked and its models are entered by hand.
- **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.
- **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.
<a id="dev-note"></a>
@@ -37,7 +37,7 @@ kind: "package-reference"
### 新增与删除提供方
「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态;**全选**与**取消全选**只影响可见结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。
「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。每个选中候选会在提供方公布相应信息时,把 id、显示名、上下文窗口与最大输出 token 数复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。
### 首次运行弹窗
@@ -105,7 +105,7 @@ kind: "package-reference"
- **卡片上只有 API 密钥与精选折叠字段可编辑**:手写编辑器以 schema 通用字段覆盖换取了 mockup 布局。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。
- **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `<ROUTE>_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据与无法识别的目标会保留,因为该行无法证明自己拥有它们。
- **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。
- **询问只覆盖 OpenAI 兼容端点**:适配器只读这种模型列表响应格式,因此讲其他协议的网关会报告自己无法被询问,其模型需手工填写。
- **询问覆盖 OpenAI 兼容与 Anthropic Messages 端点**:OpenAI 协议接受标准 `data` 数组或富信息 `models` 对象,Anthropic 则使用原生模型列表路由;其余协议会报告自己无法被询问,其模型需手工填写。
- **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。
<a id="dev-note"></a>
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-settings-models",
"description": "Models settings and shared product-onboarding dialogs over existing settings and credential joins",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
@@ -297,12 +297,11 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
const toggleVisibleCandidates = (): void => {
setPicked((current) => {
const next = new Set(current)
if (visibleCandidates.every(candidate => current.has(candidate.id))) {
for (const candidate of visibleCandidates) next.delete(candidate.id)
} else {
for (const candidate of visibleCandidates) next.add(candidate.id)
return new Set()
}
const next = new Set(current)
for (const candidate of visibleCandidates) next.add(candidate.id)
return next
})
}
@@ -529,7 +529,8 @@ describe('endpoint interrogation', () => {
it('adopts only the picked candidates, keeping a row the user already tuned', async () => {
const discover = vi.fn(() => Promise.resolve(ok([
{ id: 'kept', contextWindow: 999 }, { id: 'fresh', contextWindow: 4096, name: 'Fresh' },
{ id: 'kept', contextWindow: 999 },
{ id: 'fresh', contextWindow: 4096, maxTokens: 2048, name: 'Fresh' },
])))
const { mutate } = await mountSection({
discover,
@@ -544,11 +545,17 @@ describe('endpoint interrogation', () => {
expect(boxes.map(box => box.checked)).toEqual([false, true])
fireEvent.click(screen.getByText(en.fetchAdopt))
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 2`).value).toBe('fresh')
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelName} 2`).value).toBe('Fresh')
expandModel(2)
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelContextWindow} 2`).value).toBe('4096')
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelMaxTokens} 2`).value).toBe('2048')
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
expect(firstMutate(mutate).ops[0]?.value).toEqual([
{ id: 'kept', contextWindow: 111 },
{ id: 'fresh', contextWindow: 4096, name: 'Fresh' },
{ id: 'fresh', contextWindow: 4096, maxTokens: 2048, name: 'Fresh' },
])
})
@@ -661,7 +668,7 @@ describe('endpoint interrogation', () => {
expect(firstMutate(mutate).ops[0]?.value).toEqual([{ id: 'a' }, { id: 'b', maxTokens: 2048 }])
})
it('filters by model id or name and scopes bulk selection to visible candidates', async () => {
it('filters by model id or name, selects visible candidates, and clears every selection', async () => {
const discover = vi.fn(() => Promise.resolve(ok([
{ id: 'alpha' }, { id: 'opaque-id', name: 'Beta Display' }, { id: 'gamma' },
])))
@@ -687,14 +694,17 @@ describe('endpoint interrogation', () => {
expect([...dialog.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')]
.map(box => box.checked)).toEqual([false])
// Clearing the filter restores every row and preserves hidden selections.
// Deselecting a filtered result must also clear hidden selections so they
// cannot be adopted accidentally.
fireEvent.change(search, { target: { value: '' } })
const boxes = [...dialog.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')]
expect(boxes.map(box => box.checked)).toEqual([true, false, true])
expect(boxes.map(box => box.checked)).toEqual([false, false, false])
// Selecting while filtered adds only visible candidates.
fireEvent.change(search, { target: { value: 'alpha' } })
fireEvent.click(within_(dialog, en.fetchSelectAll))
expect(boxes.map(box => box.checked)).toEqual([true, true, true])
expect(within_(dialog, en.fetchDeselectAll)).toBeTruthy()
fireEvent.change(search, { target: { value: '' } })
expect(boxes.map(box => box.checked)).toEqual([true, false, false])
fireEvent.change(search, { target: { value: 'missing' } })
expect(screen.getByText(en.fetchNoMatches)).toBeTruthy()
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-settings-plugin-inventory",
"description": "Read-only Cordis Loader inventory tab in Web Plugins settings",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-settings-plugins",
"description": "Plugins settings section with feature-owned tabs and configurable host-plane plugin cards",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-settings",
"description": "Settings domain base plugin: the settings-namespace scope service and the canonical settings slot-type contract",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-sidebar",
"description": "Sidebar plugin: session multi-level tree, search, grouping, state dots",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-skill",
"description": "Web skill references and the dedicated skill tool row",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-slots",
"description": "Slot registry pure core: SlotMap declaration merging, single register composition API, four-share props types, store-seat types, renderer install seam",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-subagent",
"description": "Subagent conversation catalog, continuation routing UI, and '@' reference source",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-theme",
"description": "Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeRuntime for light/dark/system state; --dsw-* token styles and Appearance settings row",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-tool",
"description": "Client Tool call-tree renderer and keyed per-tool presentation slot",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-trajectory",
"description": "Trajectory event ledger with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service)",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-user-questions",
"description": "Web ask_user_question composer takeover and plan-review presentation UI",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-workflow-run",
"description": "Durable workflow-run Conversation Node and nested member disclosure for dsh web",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-workspace",
"description": "Workspace picker plugin: one WorkspacePicker registered into the sidebar and empty-state workspace slots",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-web",
"description": "Web boot kernel: static module table, Cordis loader, framework-free boot page, and UI-renderer handoff",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-code-runtime-worker-thread",
"description": "Worker-thread implementation of the DeepSeek Harness code-execution seam",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-code-runtime",
"description": "Abstract code-execution seam (ctx.codeRuntime) for the DeepSeek Harness",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-command-compact",
"description": "Human-facing slash command for explicit session compaction",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-compaction-basic",
"description": "Token-meter-driven compaction policy and LLM summarization backend for the DeepSeek Harness",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-compaction-tool-result-pruner",
"description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-compaction",
"description": "Abstract compaction service seam (ctx.compaction) for the DeepSeek Harness",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-agent-instructions",
"description": "Workspace context loader for AGENTS.md/CLAUDE.md instruction files",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-file-reference-local",
"description": "Local-filesystem ctx.fileReferences provider with bounded fuzzy indexes",
"version": "0.1.2-alpha.5",
"version": "0.1.2-rc.1",
"publishConfig": {
"access": "public"
},
Loaded 100 of 299 files, more files were not shown because too many files have changed in this diff. Show more