Merge branch 'master' into worktree/web-skill-tool-row
This commit is contained in:
@@ -372,9 +372,6 @@ export function ChatView({
|
||||
const [atBottom, setAtBottom] = useState(true)
|
||||
/** Last position delivered or written on the main thread. */
|
||||
const observedTopRef = useRef(0)
|
||||
/** Pre-input position for the current wheel gesture. */
|
||||
const wheelStartRef = useRef<number | null>(null)
|
||||
const wheelEpochRef = useRef(0)
|
||||
/** Paging anchor: semantic row/position at click, updated by reader scrolls
|
||||
* while the request is pending and restored after the prepend lands. */
|
||||
const anchorRef = useRef<PagingAnchor | null>(null)
|
||||
@@ -394,8 +391,6 @@ export function ChatView({
|
||||
const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}:${lastSteeringId ?? ''}`
|
||||
|
||||
const toBottom = (el: HTMLElement): void => {
|
||||
wheelStartRef.current = null
|
||||
wheelEpochRef.current += 1
|
||||
anchorRef.current = null
|
||||
el.scrollTop = el.scrollHeight
|
||||
observedTopRef.current = el.scrollTop
|
||||
@@ -472,17 +467,19 @@ export function ChatView({
|
||||
/* v8 ignore next -- ref-null guard: the handler only fires while mounted. */
|
||||
if (local === null) return
|
||||
const el = scrollerOf(local)
|
||||
// Only wheel input may make raw scroll geometry change follow ownership.
|
||||
// Browser clamping and delayed programmatic scroll events otherwise have
|
||||
// the same event shape and must preserve the current ownership state.
|
||||
// Only reader input may make raw scroll geometry change follow ownership:
|
||||
// a delivered position that deviates from the observed-top ledger (every
|
||||
// programmatic write records itself there synchronously). This covers
|
||||
// wheel, touch, scrollbar, and keyboard alike without naming devices.
|
||||
// Browser shrink-clamps land exactly on the floor min and delayed
|
||||
// programmatic deliveries land on the ledger itself, so both preserve
|
||||
// the current ownership state.
|
||||
const floor = Math.max(0, el.scrollHeight - el.clientHeight)
|
||||
const wheelStart = wheelStartRef.current
|
||||
const movedByWheel = wheelStart !== null
|
||||
&& Math.abs(el.scrollTop - Math.min(wheelStart, floor)) > 0.5
|
||||
const isAtBottom = movedByWheel
|
||||
const movedByReader = Math.abs(el.scrollTop - Math.min(observedTopRef.current, floor)) > 0.5
|
||||
const isAtBottom = movedByReader
|
||||
? floor - el.scrollTop <= FOLLOW_THRESHOLD + 1
|
||||
: atBottomRef.current
|
||||
if (!movedByWheel && isAtBottom) {
|
||||
if (!movedByReader && isAtBottom) {
|
||||
toBottom(el)
|
||||
return
|
||||
}
|
||||
@@ -501,34 +498,18 @@ export function ChatView({
|
||||
observedTopRef.current = el.scrollTop
|
||||
}
|
||||
|
||||
// Bind scroll and the wheel provenance needed to distinguish reader input
|
||||
// from layout-driven scrolls on the resolved scrollport once per mount.
|
||||
// Bind the scroll listener on the resolved scrollport once per mount;
|
||||
// reader-input attribution rides the observed-top ledger, not per-device
|
||||
// input listeners.
|
||||
useEffect(() => {
|
||||
const local = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: effect runs after the list node commits. */
|
||||
if (local === null) return
|
||||
const el = scrollerOf(local)
|
||||
const onScroll = (): void => { onScrollRef.current() }
|
||||
const onWheel = (event: WheelEvent): void => {
|
||||
if (event.ctrlKey || event.deltaY === 0) return
|
||||
const startTop = observedTopRef.current
|
||||
const floor = Math.max(0, el.scrollHeight - el.clientHeight)
|
||||
const canMove = event.deltaY < 0 ? startTop > 1 : startTop < floor - 1
|
||||
if (!canMove) return
|
||||
wheelStartRef.current = startTop
|
||||
const epoch = ++wheelEpochRef.current
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (wheelEpochRef.current === epoch) wheelStartRef.current = null
|
||||
})
|
||||
})
|
||||
}
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
el.addEventListener('wheel', onWheel, { capture: true, passive: true })
|
||||
return () => {
|
||||
wheelStartRef.current = null
|
||||
el.removeEventListener('scroll', onScroll)
|
||||
el.removeEventListener('wheel', onWheel, true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -158,9 +158,9 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection }
|
||||
}
|
||||
|
||||
/** Simulate reader input before the browser delivers the host scroll event. */
|
||||
/** Simulate reader input (any device): a delivered position that deviates
|
||||
* from the observed-top ledger of programmatic writes. */
|
||||
function readerScroll(element: HTMLElement, top: number): void {
|
||||
fireEvent.wheel(element, { deltaY: top < element.scrollTop ? -120 : 120 })
|
||||
element.scrollTop = top
|
||||
fireEvent.scroll(element)
|
||||
}
|
||||
@@ -939,7 +939,7 @@ describe('ChatView', () => {
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps following when a delayed clamp scroll arrives after layout regrows', () => {
|
||||
it('keeps following when a stream-finalization shrink clamp delivers its scroll', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
@@ -947,12 +947,12 @@ describe('ChatView', () => {
|
||||
scroller.scrollTop = 700
|
||||
fireEvent.scroll(scroller)
|
||||
|
||||
// The wheel cannot move farther down. A stream-finalization shrink clamps
|
||||
// the old position, then reflow grows the layout before scroll delivery.
|
||||
fireEvent.wheel(scroller, { deltaY: 120 })
|
||||
metrics.setLayout(1_040, 500)
|
||||
// Stream finalization shrinks the column: the browser clamps the pinned
|
||||
// position onto the new floor and delivers a scroll event. The clamp
|
||||
// lands exactly on the ledger's floor min, so it is not reader input.
|
||||
metrics.setLayout(800, 700)
|
||||
fireEvent.scroll(scroller)
|
||||
expect(scroller.scrollTop).toBe(740)
|
||||
expect(scroller.scrollTop).toBe(500)
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
expect(h.chatScroll.read()).toBeNull()
|
||||
|
||||
@@ -961,7 +961,7 @@ describe('ChatView', () => {
|
||||
expect(scroller.scrollTop).toBe(900)
|
||||
})
|
||||
|
||||
it('uses the last delivered top when compositor scrolling precedes passive wheel delivery', () => {
|
||||
it('uses the last delivered top when compositor scrolling precedes scroll delivery', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
@@ -969,8 +969,10 @@ describe('ChatView', () => {
|
||||
scroller.scrollTop = 700
|
||||
fireEvent.scroll(scroller)
|
||||
|
||||
// Chromium advances compositor geometry before delivering the event:
|
||||
// attribution must compare against the observed-top ledger, never a
|
||||
// baseline sampled from already-moved raw geometry.
|
||||
scroller.scrollTop = 500
|
||||
fireEvent.wheel(scroller, { deltaY: -200 })
|
||||
fireEvent.scroll(scroller)
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -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-models/README.md
|
||||
README.md: b55914197e472edec8a8b6d4d3e02036d1697728
|
||||
README.zh.md: ca93c3d5a2a85fffb22707f8389f1e979468e2ec
|
||||
README.md: 80ae642ec9d6f91c78af041dda0b201959309577
|
||||
README.zh.md: 4236c8fec4f6d5e51363095d790944af9c08092a
|
||||
|
||||
@@ -4,11 +4,11 @@ English | [中文](README.zh.md)
|
||||
|
||||
Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
|
||||
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and 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. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset.
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and 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. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a literal key or referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and each adapter's model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action.
|
||||
|
||||
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
|
||||
|
||||
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
|
||||
## Model list and endpoint interrogation
|
||||
|
||||
@@ -29,7 +29,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **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 ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). DeepSeek exposes `baseURL`, `reasoningEffort`, and model `id`/`name`/`contextWindow`/`maxTokens`; pi-ai exposes `baseURL` and `reasoning`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name.
|
||||
- **Deleting a row leaves its stored key in `.env`** — removal unsets the settings profile but deliberately does not unset the derived credential; re-adding the provider finds the key already configured. An explicit key-removal control is deferred.
|
||||
- **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 listing shape, so a gateway speaking another 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.
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
|
||||
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。只有确认字面密钥或引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及各适配器自己的模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。
|
||||
|
||||
前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。
|
||||
|
||||
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值,会以同一条格式失败被拒绝;该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
|
||||
## 模型列表与端点询问
|
||||
|
||||
@@ -29,7 +29,7 @@ pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型,
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md))。DeepSeek 公开 `baseURL`、`reasoningEffort` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`;pi-ai 公开 `baseURL` 与 `reasoning`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。
|
||||
- **删除一行会把它已存储的密钥留在 `.env` 里**:删除取消设置的是 settings profile,却刻意不清除那条派生凭据;重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓。
|
||||
- **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `<ROUTE>_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据和无法识别的目标会保留,因为该行无法证明自己拥有它们。
|
||||
- **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。
|
||||
- **询问只覆盖 OpenAI 兼容端点**:适配器只读这一种列表形状,因此讲其他协议的网关会报告自己无法被询问,其模型需手工填写。
|
||||
- **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { apiKeyFailure } from './apiKey.ts'
|
||||
import { EditorFooter } from './EditorFooter.tsx'
|
||||
import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx'
|
||||
import { ModelListEditor } from './ModelListEditor.tsx'
|
||||
@@ -80,12 +81,22 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
// bad row is named by its position here too. Capacities have route-level
|
||||
// fallbacks; what a route cannot default is at least one model.
|
||||
const modelFailure = validateDeepSeekModels(models)
|
||||
const keyFailure = apiKeyFailure(keyDraft)
|
||||
// The typed key with paste whitespace removed. A blank field yields an empty
|
||||
// string, which the create path reads as "no key supplied" — a route may
|
||||
// legitimately authenticate through the provider's own ambient discovery.
|
||||
const keyValue = keyDraft.trim()
|
||||
const ready = route.length > 0 && !routeInvalid && !routeTaken
|
||||
&& baseURL.length > 0 && models.length > 0 && modelFailure === undefined
|
||||
&& keyFailure === undefined
|
||||
// The one blocked gate worth a line under the form. The route id is omitted
|
||||
// because its own field already explains itself, and a satisfied card says
|
||||
// nothing at all rather than printing an empty paragraph.
|
||||
const hint = failure !== undefined || ready
|
||||
// The key field prints its own failure directly beneath itself, so a card
|
||||
// blocked only by the key stays silent here rather than answering with the
|
||||
// next unmet gate — which is satisfied, and reads as a second, false fault.
|
||||
|| keyFailure !== undefined
|
||||
? undefined
|
||||
: baseURL.length === 0
|
||||
? t('customNeedsBaseUrl')
|
||||
@@ -112,8 +123,8 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
expectedRevision: openedAt,
|
||||
})
|
||||
if (!response.result.ok) return response.result.error.message
|
||||
if (keyDraft.length > 0) {
|
||||
const stored = await api.credentials.set({ ref: keyRef, value: keyDraft })
|
||||
if (keyValue.length > 0) {
|
||||
const stored = await api.credentials.set({ ref: keyRef, value: keyValue })
|
||||
// The profile landed; saying the key did not is the only honest report,
|
||||
// and the row is now editable so the key can be entered again there.
|
||||
if (!stored.result.ok) return stored.result.error.message
|
||||
@@ -208,6 +219,12 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
disabled={disabled}
|
||||
onChange={(event) => { setKeyDraft(event.target.value) }}
|
||||
/>
|
||||
{/* A create card has no stored key to keep, so the blank case says
|
||||
what a blank field means here instead: this route may authenticate
|
||||
through the provider's own ambient discovery or OAuth. */}
|
||||
{keyFailure === undefined
|
||||
? null
|
||||
: <p className={styles['error']}>{t(keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure)}</p>}
|
||||
</div>
|
||||
<ModelListEditor
|
||||
models={models}
|
||||
@@ -216,8 +233,9 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
settingsNs: NS,
|
||||
baseURL,
|
||||
api: protocol,
|
||||
...keyDraft.length === 0 ? {} : { apiKey: keyDraft },
|
||||
...keyValue.length === 0 ? {} : { apiKey: keyValue },
|
||||
}}
|
||||
probeBlocked={keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure}
|
||||
api={api}
|
||||
t={t}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -74,6 +74,13 @@ export interface ModelListEditorProps {
|
||||
onReset?: () => void
|
||||
/** Endpoint facts for the fetch action. */
|
||||
probe: ProbeTarget
|
||||
/**
|
||||
* Copy key naming why the fetch action is unavailable, or `undefined` when
|
||||
* it is. The card owns this because the key it would send is judged there:
|
||||
* asking with a key the form has already refused spends a round trip to be
|
||||
* told what the field already says.
|
||||
*/
|
||||
probeBlocked?: keyof typeof en | undefined
|
||||
/** Wire face the fetch action calls. */
|
||||
api: Pick<IApiClient, 'llm'>
|
||||
/** Section copy. */
|
||||
@@ -314,8 +321,10 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
|
||||
<button
|
||||
type="button"
|
||||
className={styles['linkButton']}
|
||||
disabled={disabled || busy || !askable}
|
||||
title={askable ? undefined : t('fetchNeedsBaseUrl')}
|
||||
disabled={disabled || busy || !askable || props.probeBlocked !== undefined}
|
||||
title={props.probeBlocked !== undefined
|
||||
? t(props.probeBlocked)
|
||||
: askable ? undefined : t('fetchNeedsBaseUrl')}
|
||||
onClick={() => { void fetchModels() }}
|
||||
>
|
||||
{busy ? t('fetching') : t('fetchModels')}
|
||||
|
||||
@@ -38,6 +38,13 @@
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
}
|
||||
|
||||
.savedNotice {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.rows {
|
||||
list-style: none;
|
||||
/* Extra air between the title/intro block and the first provider card. */
|
||||
@@ -65,6 +72,13 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rowIdentity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rowName {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
@@ -72,6 +86,23 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.credentialDot {
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.credentialDotConfigured {
|
||||
background: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.credentialDotMissing {
|
||||
background: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Models settings section: the provider rows joined from the configurable
|
||||
* directory, settings namespaces, and credential states, with one editor
|
||||
* card at a time. A whole-section provider without a configured key (the
|
||||
* unconfigured DeepSeek posture) renders as its open setup card instead of a
|
||||
* row; the add flow is a card carrying the dormant-provider select. Every
|
||||
* mutation writes through the wire, while a provider removal first requires
|
||||
* card at a time. Rows expose only confirmed API-key state through accessible
|
||||
* solid configured or missing dots. A whole-section provider without a
|
||||
* configured key (the unconfigured DeepSeek posture) renders as its open setup
|
||||
* card instead of a row; the add flow is a card carrying the dormant-provider
|
||||
* select. Every mutation writes through the wire, while a provider removal first requires
|
||||
* confirmation; the page re-renders from pushed invalidations or the
|
||||
* post-apply reload.
|
||||
*/
|
||||
@@ -15,9 +16,9 @@ import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { CustomProviderCard } from './CustomProviderCard.tsx'
|
||||
import { messageOf, protocolChoices } from './store.ts'
|
||||
import { deriveKeyRef, messageOf, protocolChoices } from './store.ts'
|
||||
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
|
||||
import { ProviderEditor } from './ProviderEditor.tsx'
|
||||
import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
@@ -39,42 +40,73 @@ export interface ModelsSectionInjected {
|
||||
*/
|
||||
export type ModelsSectionProps = Partial<ModelsSectionInjected>
|
||||
|
||||
/** The editor target: an existing row or a dormant directory entry. */
|
||||
interface EditorTarget {
|
||||
/** Provider identity shared by row actions and confirmation copy. */
|
||||
export interface ProviderIdentity {
|
||||
/** Stable provider route id. */
|
||||
provider: string
|
||||
/** Human-facing provider name. */
|
||||
displayName: string
|
||||
}
|
||||
|
||||
/** One existing row or dormant directory entry addressed by an editor action. */
|
||||
interface EditorTarget extends ProviderIdentity {
|
||||
settingsNs: string
|
||||
settingsPath: readonly string[]
|
||||
/** Writable credential identified under this page's conventional reference. */
|
||||
credentialRef?: string
|
||||
}
|
||||
|
||||
/** Values that vary around the shared provider-editor rendering. */
|
||||
interface ProviderEditorRenderProps extends Pick<
|
||||
ProviderEditorProps,
|
||||
'namespace' | 'api' | 't' | 'readOnly' | 'onClose'
|
||||
> {
|
||||
target: EditorTarget
|
||||
}
|
||||
|
||||
/** Render an editor for either the setup posture or an expanded provider row. */
|
||||
function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps): ReactNode {
|
||||
return (
|
||||
<ProviderEditor
|
||||
provider={target.provider}
|
||||
displayName={target.displayName}
|
||||
settingsPath={target.settingsPath}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one user-added provider profile by unsetting its path in the stored
|
||||
* user section, then reload. The removal names the profile rather than
|
||||
* rebuilding the section: this page only ever holds the redacted descriptor,
|
||||
* so a rebuilt section would drop every literal secret stored elsewhere in
|
||||
* the namespace along with the profile being removed.
|
||||
* @param api - settings wire face.
|
||||
* Remove one user-added provider and its page-managed credential. Credential
|
||||
* removal comes first so a second-step failure leaves the provider row visible
|
||||
* and the whole operation safely retryable; both unsets are idempotent.
|
||||
* The settings removal names the profile rather than rebuilding its redacted
|
||||
* namespace, which would drop literal secrets stored elsewhere.
|
||||
* @param api - settings and credential wire faces.
|
||||
* @param controller - the page store to refresh.
|
||||
* @param target - the provider's settings address.
|
||||
* @param target - the provider's settings address and optional managed credential.
|
||||
* @returns the failure message, or undefined once the write and reload landed.
|
||||
*/
|
||||
export async function removeProviderProfile(
|
||||
api: Pick<IApiClient, 'settings'>,
|
||||
api: Pick<IApiClient, 'settings' | 'credentials'>,
|
||||
controller: ModelsSettingsStore,
|
||||
target: { settingsNs: string; settingsPath: readonly string[] },
|
||||
target: { settingsNs: string; settingsPath: readonly string[]; credentialRef?: string },
|
||||
): Promise<string | undefined> {
|
||||
let response
|
||||
try {
|
||||
response = await api.settings.mutate({
|
||||
if (target.credentialRef !== undefined) {
|
||||
const credential = await api.credentials.unset({ ref: target.credentialRef })
|
||||
if (!credential.result.ok) return credential.result.error.message
|
||||
}
|
||||
const response = await api.settings.mutate({
|
||||
ns: target.settingsNs,
|
||||
ops: [{ op: 'unset', path: [...target.settingsPath] }],
|
||||
})
|
||||
if (!response.result.ok) return response.result.error.message
|
||||
} catch (error) {
|
||||
// The transport rejected rather than answering; the caller must be able
|
||||
// to say so instead of the row silently staying put.
|
||||
// to retry the idempotent operation instead of the row silently staying.
|
||||
return messageOf(error)
|
||||
}
|
||||
if (!response.result.ok) return response.result.error.message
|
||||
await controller.load()
|
||||
return undefined
|
||||
}
|
||||
@@ -93,14 +125,33 @@ export function needsSetup(row: ProviderRow): boolean {
|
||||
}
|
||||
|
||||
function targetOf(row: ProviderRow): EditorTarget {
|
||||
const managedRef = deriveKeyRef(row.entry.provider)
|
||||
const credentialRef = row.apiKeyEnv === managedRef
|
||||
&& row.credential?.configured === true
|
||||
&& row.credential.writable
|
||||
? managedRef
|
||||
: undefined
|
||||
return {
|
||||
provider: row.entry.provider,
|
||||
displayName: row.entry.displayName,
|
||||
settingsNs: row.entry.settingsNs,
|
||||
settingsPath: row.entry.settingsPath,
|
||||
...credentialRef === undefined ? {} : { credentialRef },
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable visible and accessible identity for one provider target. */
|
||||
export function providerTargetLabel(target: ProviderIdentity): string {
|
||||
return target.provider === target.displayName
|
||||
? target.provider
|
||||
: `${target.displayName} (${target.provider})`
|
||||
}
|
||||
|
||||
/** Replace the one provider placeholder in localized destructive-action copy. */
|
||||
export function providerCopy(template: string, target: ProviderIdentity): string {
|
||||
return template.replace('{provider}', () => providerTargetLabel(target))
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Models section content column.
|
||||
* @param props - slot-delivered injected dependencies.
|
||||
@@ -119,28 +170,35 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<EditorTarget | undefined>(undefined)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [deleteFailure, setDeleteFailure] = useState<string | undefined>(undefined)
|
||||
const [savedTarget, setSavedTarget] = useState<ProviderIdentity | undefined>(undefined)
|
||||
const [declaring, setDeclaring] = useState(false)
|
||||
|
||||
const closeEditor = (changed: boolean): void => {
|
||||
const closeEditor = (changed: boolean, target: ProviderIdentity): void => {
|
||||
setEditing(undefined)
|
||||
setAdding(false)
|
||||
setDeclaring(false)
|
||||
if (changed) void controller.load()
|
||||
if (changed) {
|
||||
setSavedTarget(target)
|
||||
void controller.load()
|
||||
}
|
||||
}
|
||||
|
||||
const closeDelete = (): void => {
|
||||
if (deleting) return
|
||||
setDeleteTarget(undefined)
|
||||
setDeleteFailure(undefined)
|
||||
}
|
||||
|
||||
const confirmDelete = (): void => {
|
||||
/* v8 ignore next -- the action only renders with a target and is disabled while a deletion is pending */
|
||||
if (deleteTarget === undefined || deleting) return
|
||||
setDeleting(true)
|
||||
setDeleteFailure(undefined)
|
||||
void removeProviderProfile(api, controller, deleteTarget)
|
||||
.then((failure) => {
|
||||
if (failure !== undefined) {
|
||||
controller.fail(failure)
|
||||
setDeleteFailure(failure)
|
||||
return
|
||||
}
|
||||
setDeleteTarget(undefined)
|
||||
@@ -176,6 +234,13 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
<h2 className={styles['title']}>{t('title')}</h2>
|
||||
<p className={styles['intro']}>{t('intro')}</p>
|
||||
{!state.writable && state.status === 'ready' ? <p className={styles['notice']}>{t('readOnly')}</p> : null}
|
||||
{savedTarget === undefined
|
||||
? null
|
||||
: (
|
||||
<p className={styles['savedNotice']} role="status" aria-live="polite">
|
||||
{providerCopy(t('savedProvider'), savedTarget)}
|
||||
</p>
|
||||
)}
|
||||
<ul className={styles['rows']}>
|
||||
{configured.map((row) => {
|
||||
const target = targetOf(row)
|
||||
@@ -187,29 +252,54 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
// setup card IS its presence on the page.
|
||||
return (
|
||||
<li key={row.entry.provider} className={styles['setupCard']}>
|
||||
<ProviderEditor
|
||||
provider={target.provider}
|
||||
displayName={target.displayName}
|
||||
namespace={namespace}
|
||||
settingsPath={target.settingsPath}
|
||||
api={api}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
{renderProviderEditor({
|
||||
target,
|
||||
namespace,
|
||||
api,
|
||||
t,
|
||||
readOnly: !state.writable,
|
||||
onClose: (changed) => { closeEditor(changed, target) },
|
||||
})}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
const open = !adding && editing?.provider === row.entry.provider
|
||||
const credentialConfigured = row.literalApiKeyConfigured || row.credential?.configured === true
|
||||
const credentialMissing = !credentialConfigured
|
||||
&& row.apiKeyEnv !== undefined
|
||||
&& row.credential?.configured === false
|
||||
return (
|
||||
<li key={row.entry.provider} className={styles['rowCard']}>
|
||||
<div className={styles['rowHead']}>
|
||||
<span className={styles['rowName']}>{row.entry.displayName}</span>
|
||||
<span className={styles['rowIdentity']}>
|
||||
<span className={styles['rowName']}>{row.entry.displayName}</span>
|
||||
{credentialConfigured
|
||||
? (
|
||||
<span
|
||||
className={`${styles['credentialDot']} ${styles['credentialDotConfigured']}`}
|
||||
role="img"
|
||||
aria-label={t('credentialConfigured')}
|
||||
title={t('credentialConfigured')}
|
||||
/>
|
||||
)
|
||||
: credentialMissing
|
||||
? (
|
||||
<span
|
||||
className={`${styles['credentialDot']} ${styles['credentialDotMissing']}`}
|
||||
role="img"
|
||||
aria-label={t('credentialMissing')}
|
||||
title={t('credentialMissing')}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
</span>
|
||||
<span className={styles['rowActions']}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['secondaryButton']}
|
||||
aria-label={providerCopy(t('editProvider'), target)}
|
||||
onClick={() => {
|
||||
setSavedTarget(undefined)
|
||||
// One card at a time: leaving `declaring` set would show
|
||||
// the create card beside this editor, and closing either
|
||||
// one discards the other's draft.
|
||||
@@ -225,8 +315,13 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
<button
|
||||
type="button"
|
||||
className={styles['dangerButton']}
|
||||
aria-label={providerCopy(t('removeProvider'), target)}
|
||||
disabled={!state.writable}
|
||||
onClick={() => { setDeleteTarget(target) }}
|
||||
onClick={() => {
|
||||
setSavedTarget(undefined)
|
||||
setDeleteFailure(undefined)
|
||||
setDeleteTarget(target)
|
||||
}}
|
||||
>
|
||||
{t('remove')}
|
||||
</button>
|
||||
@@ -235,18 +330,14 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
</span>
|
||||
</div>
|
||||
{open
|
||||
? (
|
||||
<ProviderEditor
|
||||
provider={target.provider}
|
||||
displayName={target.displayName}
|
||||
namespace={namespace}
|
||||
settingsPath={target.settingsPath}
|
||||
api={api}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
)
|
||||
? renderProviderEditor({
|
||||
target,
|
||||
namespace,
|
||||
api,
|
||||
t,
|
||||
readOnly: !state.writable,
|
||||
onClose: (changed) => { closeEditor(changed, target) },
|
||||
})
|
||||
: null}
|
||||
</li>
|
||||
)
|
||||
@@ -284,7 +375,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
api={api}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={closeEditor}
|
||||
onClose={(changed) => { closeEditor(changed, addTarget) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -299,7 +390,10 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
api={api}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={closeEditor}
|
||||
onClose={(changed) => {
|
||||
setDeclaring(false)
|
||||
if (changed) void controller.load()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -317,6 +411,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
const first = addable[0]
|
||||
/* v8 ignore next -- the button is disabled while nothing is addable */
|
||||
if (first === undefined) return
|
||||
setSavedTarget(undefined)
|
||||
setDeclaring(false)
|
||||
setAdding(true)
|
||||
setEditing(targetOf(first))
|
||||
@@ -330,7 +425,12 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
type="button"
|
||||
className={styles['addButton']}
|
||||
disabled={protocols.length === 0 || !state.writable}
|
||||
onClick={() => { setAdding(false); setEditing(undefined); setDeclaring(true) }}
|
||||
onClick={() => {
|
||||
setSavedTarget(undefined)
|
||||
setAdding(false)
|
||||
setEditing(undefined)
|
||||
setDeclaring(true)
|
||||
}}
|
||||
>
|
||||
<IconPlusOutline16 size={14} />
|
||||
{t('customAdd')}
|
||||
@@ -341,9 +441,16 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
<Modal
|
||||
open={deleteTarget !== undefined}
|
||||
onClose={closeDelete}
|
||||
title={t('deleteTitle')}
|
||||
title={deleteTarget === undefined ? '' : providerCopy(t('deleteTitle'), deleteTarget)}
|
||||
closeLabel={t('close')}
|
||||
description={t('deleteDescription')}
|
||||
description={deleteTarget === undefined
|
||||
? ''
|
||||
: providerCopy(
|
||||
deleteTarget.credentialRef === undefined
|
||||
? t('deleteDescription')
|
||||
: t('deleteDescriptionWithCredential'),
|
||||
deleteTarget,
|
||||
)}
|
||||
className={styles['deleteDialog'] as string}
|
||||
footer={(
|
||||
<>
|
||||
@@ -356,11 +463,15 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
disabled={deleting}
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
{deleting ? t('deleting') : t('deleteConfirm')}
|
||||
{deleteTarget === undefined
|
||||
? ''
|
||||
: providerCopy(deleting ? t('deleting') : t('deleteConfirm'), deleteTarget)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
>
|
||||
{deleteFailure === undefined ? null : <p className={styles['error']}>{deleteFailure}</p>}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* field is a single write-only **API key** input (the page never asks for an
|
||||
* environment-variable name — a typed key stores through `credentials.set`
|
||||
* under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile
|
||||
* has none, and the pi-ai profile records that derivation as `apiKeyEnv`);
|
||||
* has none. The pi-ai profile records that derivation as `apiKeyEnv` only when
|
||||
* a key is entered; a blank key materializes a reference-free profile for
|
||||
* provider-native authentication);
|
||||
* the collapsed 自定义设置 area carries the per-family extras (`baseURL` for
|
||||
* both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, and
|
||||
* DeepSeek's id/name/context-window model catalog). Everything else stays
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
import {
|
||||
DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels,
|
||||
} from './DeepSeekModelsEditor.tsx'
|
||||
import { apiKeyFailure } from './apiKey.ts'
|
||||
import { EditorFooter } from './EditorFooter.tsx'
|
||||
import { ModelListEditor } from './ModelListEditor.tsx'
|
||||
import { deriveKeyRef, messageOf } from './store.ts'
|
||||
@@ -133,10 +136,13 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
const [keyState, setKeyState] = useState<CredentialView | undefined>(undefined)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failure, setFailure] = useState<string | undefined>(undefined)
|
||||
// The revision this card opened at. A write carrying it is refused if
|
||||
// anything else — another tab, an external edit of settings.yaml — moved the
|
||||
// namespace meanwhile, instead of silently overwriting that change.
|
||||
const [openedAt] = useState(() => namespace.revision)
|
||||
// A settings success advances both retry baselines immediately. Keeping the
|
||||
// derived fields in the draft prevents a pushed namespace refresh from
|
||||
// turning them into deletions when the following credential write is retried.
|
||||
const [committedOriginal, setCommittedOriginal] = useState<unknown>(
|
||||
() => getPath(namespace.user, settingsPath),
|
||||
)
|
||||
const [expectedRevision, setExpectedRevision] = useState(() => namespace.revision)
|
||||
const root = useMemo(() => rehydrateSchema(namespace.schema), [namespace.schema])
|
||||
const node = useMemo(() => nodeAtPath(root, settingsPath), [root, settingsPath])
|
||||
const fallback = getPath(namespace.value, settingsPath)
|
||||
@@ -163,15 +169,26 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
|
||||
const stringAt = (source: unknown, key: string): string | undefined => {
|
||||
const value = getPath(source, [key])
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : undefined
|
||||
}
|
||||
const setField = (key: string, next: string | undefined): void => {
|
||||
setDraft(current => next === undefined ? deletePath(current, [key]) : setPath(current, [key], next))
|
||||
// A value of nothing but whitespace is cleared, not stored: `stringAt`
|
||||
// already reports it as absent, so the field would otherwise render empty
|
||||
// while the draft still carried the spaces into `settings.yaml`, where
|
||||
// both adapters would accept that non-empty string as a real value.
|
||||
const value = next === undefined || next.trim().length === 0 ? undefined : next
|
||||
setDraft(current => value === undefined ? deletePath(current, [key]) : setPath(current, [key], value))
|
||||
}
|
||||
|
||||
// The model list is validated by the same per-row checker for both families,
|
||||
// so a bad row is named by its position rather than by a blanket message.
|
||||
const modelFailure = validateDeepSeekModels(getPath(draft, ['models']))
|
||||
const keyFailure = apiKeyFailure(keyDraft)
|
||||
// What a probe or a write must carry: the typed key with paste whitespace
|
||||
// removed. A blank field yields an empty string, which both call sites read
|
||||
// as "no key supplied" rather than as a key — that is how a card whose
|
||||
// provider already has a stored key is edited without re-entering it.
|
||||
const keyValue = keyDraft.trim()
|
||||
// What the form currently shows, which is what an interrogation must ask:
|
||||
// an edited-but-unsaved endpoint, and a key typed but not yet stored.
|
||||
const probeApi = stringAt(draft, 'api') ?? stringAt(fallback, 'api')
|
||||
@@ -183,7 +200,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
provider: props.provider,
|
||||
...probeBaseURL === undefined ? {} : { baseURL: probeBaseURL },
|
||||
...probeApi === undefined ? {} : { api: probeApi },
|
||||
...keyDraft.length === 0 ? {} : { apiKey: keyDraft },
|
||||
...keyValue.length === 0 ? {} : { apiKey: keyValue },
|
||||
}
|
||||
/**
|
||||
* The write for this card, or a failure message. Every edit travels as
|
||||
@@ -194,11 +211,10 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
*/
|
||||
const applyOnce = async (): Promise<string | undefined> => {
|
||||
const ns = namespace.ns
|
||||
const original = getPath(namespace.user, settingsPath)
|
||||
// The pi-ai profile must name the reference the key stores under, so a
|
||||
// dormant add (or a legacy profile without one) records the derivation.
|
||||
// A pi-ai profile names the conventional reference only when this page is
|
||||
// about to store a key. Otherwise the provider keeps its native auth path.
|
||||
const next = layout === 'pi-ai' && stringAt(draft, 'apiKeyEnv') === undefined
|
||||
&& stringAt(fallback, 'apiKeyEnv') === undefined
|
||||
&& stringAt(fallback, 'apiKeyEnv') === undefined && keyValue.length > 0
|
||||
? setPath(draft, ['apiKeyEnv'], keyRef)
|
||||
: draft
|
||||
{
|
||||
@@ -217,17 +233,26 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
const sectionError = validateDraft(node, next)
|
||||
if (sectionError !== undefined) return sectionError
|
||||
}
|
||||
const ops = pathOps(settingsPath, original, next)
|
||||
const materializesNativeProfile = layout === 'pi-ai'
|
||||
&& fallback === undefined
|
||||
&& committedOriginal === undefined
|
||||
&& Object.keys(next).length === 0
|
||||
const ops: SettingsPathOpView[] = materializesNativeProfile
|
||||
? [{ op: 'set', path: [...settingsPath], value: {} }]
|
||||
: pathOps(settingsPath, committedOriginal, next)
|
||||
if (ops.length > 0) {
|
||||
const response = await api.settings.mutate({ ns, ops, expectedRevision: openedAt })
|
||||
const response = await api.settings.mutate({ ns, ops, expectedRevision })
|
||||
if (!response.result.ok) {
|
||||
return response.result.error.code === 'settings-conflict'
|
||||
? t('conflict')
|
||||
: response.result.error.message
|
||||
}
|
||||
setCommittedOriginal(getPath(response.result.value.user, settingsPath))
|
||||
setExpectedRevision(response.result.value.revision)
|
||||
setDraft(next)
|
||||
}
|
||||
if (keyDraft.length > 0) {
|
||||
const stored = await api.credentials.set({ ref: keyRef, value: keyDraft })
|
||||
if (keyValue.length > 0) {
|
||||
const stored = await api.credentials.set({ ref: keyRef, value: keyValue })
|
||||
if (!stored.result.ok) return stored.result.error.message
|
||||
}
|
||||
setKeyDraft('')
|
||||
@@ -286,6 +311,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
const models = modelDrafts(modelsOverridden ? customModels : inheritedModels())
|
||||
const defaultContextWindow = getPath(fallback, ['defaultContextWindow'])
|
||||
const defaultMaxTokens = getPath(fallback, ['maxTokens'])
|
||||
const keyPlaceholder = keyLocked
|
||||
? t('keyEnvLocked')
|
||||
: keyState?.configured === true
|
||||
? t('keyStored')
|
||||
: family === 'pi-ai' ? t('keyPlaceholderNative') : t('keyPlaceholder')
|
||||
/** What both family editors take: the rows, whose layer owns them, and the two writes. */
|
||||
const catalogProps = {
|
||||
models,
|
||||
@@ -306,13 +336,12 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={keyDraft}
|
||||
placeholder={keyLocked
|
||||
? t('keyEnvLocked')
|
||||
: keyState?.configured === true ? t('keyStored') : t('keyPlaceholder')}
|
||||
placeholder={keyPlaceholder}
|
||||
aria-label={t('keyInput')}
|
||||
disabled={disabled || keyLocked}
|
||||
onChange={(event) => { setKeyDraft(event.target.value) }}
|
||||
/>
|
||||
{keyFailure === undefined ? null : <p className={styles['error']}>{t(keyFailure)}</p>}
|
||||
</div>
|
||||
<details className={styles['customized']}>
|
||||
<summary className={styles['customizedSummary']}>{t('customized')}</summary>
|
||||
@@ -363,7 +392,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined}
|
||||
/>
|
||||
)
|
||||
: <ModelListEditor {...catalogProps} probe={probe} api={api} />}
|
||||
: <ModelListEditor {...catalogProps} probe={probe} probeBlocked={keyFailure} api={api} />}
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
@@ -396,7 +425,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
<EditorFooter
|
||||
t={t}
|
||||
busy={busy}
|
||||
submitDisabled={disabled || layout === 'unknown' || modelFailure !== undefined}
|
||||
submitDisabled={disabled || layout === 'unknown' || modelFailure !== undefined
|
||||
|| keyFailure !== undefined}
|
||||
submitLabel="apply"
|
||||
submitBusyLabel="applying"
|
||||
onCancel={() => { props.onClose(false) }}
|
||||
|
||||
58
packages/client/ui-models/src/client/apiKey.ts
Normal file
58
packages/client/ui-models/src/client/apiKey.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Browser-side judgement of a typed API key.
|
||||
* @module @deepseek-ai/dsh-client-ui-models/apiKey
|
||||
*/
|
||||
|
||||
/**
|
||||
* Twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`: printable ASCII, space
|
||||
* excluded. Client packages reference only client packages, so the charset
|
||||
* rule is mirrored here rather than imported; keep the two in step, as
|
||||
* `validateDeepSeekModels` is kept in step with the host's `catalogModel`.
|
||||
*/
|
||||
const LEGAL_API_KEY = /^[\x21-\x7E]+$/
|
||||
|
||||
/**
|
||||
* A pasted `NAME=value` environment line. Two narrowings keep real keys clear
|
||||
* of it: the name must be upper-case, so `sk-` forms break at the hyphen, and
|
||||
* the `=` must be followed by something other than another `=`, so base64
|
||||
* padding on an all-upper-case key (`ABCD==`) is not mistaken for an
|
||||
* assignment. This heuristic runs only here — a resolver applying it could
|
||||
* lock a user out of a gateway whose key legitimately takes this shape, with
|
||||
* the environment refusing it too and no way through.
|
||||
*/
|
||||
const ENV_LINE = /^[A-Z][A-Z0-9_]*=[^=]/
|
||||
|
||||
/**
|
||||
* Copy key naming why a typed key cannot be saved. A wrapped paste reports the
|
||||
* same format failure as an illegal character: the reader's next move is the
|
||||
* same either way — look at the key and paste it again — so naming the two
|
||||
* causes apart would spend the field's one line on a distinction that changes
|
||||
* nothing about what to do.
|
||||
*/
|
||||
export type ApiKeyFailureKey = 'keyBlank' | 'keyIllegalCharacters'
|
||||
|
||||
/** Whether a value is wrapped in one matching pair of quotes. */
|
||||
function isQuoted(value: string): boolean {
|
||||
const first = value[0]
|
||||
if (first !== '"' && first !== '\'' && first !== '`') return false
|
||||
return value.length > 1 && value.endsWith(first)
|
||||
}
|
||||
|
||||
/**
|
||||
* Judge the key input's current value.
|
||||
*
|
||||
* An empty field is not a failure: every card opens with it empty even when a
|
||||
* key is already stored, where it means keep that one. A field holding only
|
||||
* whitespace is a failure rather than an empty field, so typed input is never
|
||||
* silently discarded.
|
||||
* @param draft - the key input's current value, untrimmed.
|
||||
* @returns the copy key for a field-level failure, or `undefined` to allow submit.
|
||||
*/
|
||||
export function apiKeyFailure(draft: string): ApiKeyFailureKey | undefined {
|
||||
if (draft.length === 0) return undefined
|
||||
const value = draft.trim()
|
||||
if (value.length === 0) return 'keyBlank'
|
||||
if (ENV_LINE.test(value) || isQuoted(value)) return 'keyIllegalCharacters'
|
||||
if (!LEGAL_API_KEY.test(value)) return 'keyIllegalCharacters'
|
||||
return undefined
|
||||
}
|
||||
@@ -6,23 +6,30 @@ export const en = {
|
||||
title: 'Models',
|
||||
intro: 'Enter your API keys to use models from the following providers.',
|
||||
edit: 'Edit',
|
||||
editProvider: 'Edit {provider}',
|
||||
remove: 'Delete',
|
||||
deleteTitle: 'Delete model provider?',
|
||||
deleteDescription: 'Deleting this model provider removes its configuration. You will not be able to use its models until you add the provider again.',
|
||||
deleteConfirm: 'Delete provider',
|
||||
deleting: 'Deleting provider…',
|
||||
removeProvider: 'Delete {provider}',
|
||||
deleteTitle: 'Delete {provider}?',
|
||||
deleteDescription: 'Deleting {provider} removes its configuration. Any credential it uses is managed elsewhere and will be kept.',
|
||||
deleteDescriptionWithCredential: 'Deleting {provider} removes its configuration and stored API key.',
|
||||
deleteConfirm: 'Delete {provider}',
|
||||
deleting: 'Deleting {provider}…',
|
||||
add: 'Add provider',
|
||||
provider: 'Provider',
|
||||
close: 'Close',
|
||||
cancel: 'Cancel',
|
||||
apply: 'Apply',
|
||||
applying: 'Applying…',
|
||||
savedProvider: 'Saved {provider}.',
|
||||
credentialConfigured: 'API key configured',
|
||||
credentialMissing: 'API key missing',
|
||||
readOnly: 'The settings document is read-only in this deployment.',
|
||||
loadFailed: 'Loading the provider directory failed',
|
||||
conflict: 'Someone else changed these settings while this card was open. Close it and reopen to edit the current values.',
|
||||
retry: 'Retry',
|
||||
keyInput: 'API key',
|
||||
keyPlaceholder: 'Enter your API key',
|
||||
keyPlaceholderNative: 'Enter an API key, or leave blank to use environment authentication',
|
||||
keyStored: 'Configured — enter a new value to replace',
|
||||
keyEnvLocked: 'Provided by the launch environment (read-only)',
|
||||
customized: 'Customized settings',
|
||||
@@ -46,6 +53,9 @@ export const en = {
|
||||
addModel: 'Add model',
|
||||
removeModel: 'Delete model',
|
||||
modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.',
|
||||
keyBlank: 'Enter the API key, or leave the field empty to keep the stored one.',
|
||||
keyBlankNew: 'Enter the API key, or leave the field empty if this provider authenticates another way.',
|
||||
keyIllegalCharacters: 'This API key is not in a valid format. Please check it.',
|
||||
modelIdRequired: 'Model ID is required.',
|
||||
modelIdDuplicate: 'Model ID must be unique.',
|
||||
modelNameInvalid: 'Display name cannot be empty.',
|
||||
@@ -90,23 +100,30 @@ export const zh: typeof en = {
|
||||
title: '模型',
|
||||
intro: '填入各提供方的 API 密钥即可使用其模型。',
|
||||
edit: '编辑',
|
||||
editProvider: '编辑 {provider}',
|
||||
remove: '删除',
|
||||
deleteTitle: '删除模型提供方?',
|
||||
deleteDescription: '删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。',
|
||||
deleteConfirm: '删除提供方',
|
||||
deleting: '正在删除提供方…',
|
||||
removeProvider: '删除 {provider}',
|
||||
deleteTitle: '删除 {provider}?',
|
||||
deleteDescription: '删除 {provider} 会移除其配置;其使用的凭证(如有)由其他位置管理,将会保留。',
|
||||
deleteDescriptionWithCredential: '删除 {provider} 会移除其配置和存储的 API 密钥。',
|
||||
deleteConfirm: '删除 {provider}',
|
||||
deleting: '正在删除 {provider}…',
|
||||
add: '添加提供方',
|
||||
provider: '提供方',
|
||||
close: '关闭',
|
||||
cancel: '取消',
|
||||
apply: '保存',
|
||||
applying: '保存中…',
|
||||
savedProvider: '已保存 {provider}。',
|
||||
credentialConfigured: 'API 密钥已配置',
|
||||
credentialMissing: 'API 密钥缺失',
|
||||
readOnly: '当前部署的设置文档为只读。',
|
||||
loadFailed: '加载提供方目录失败',
|
||||
conflict: '这张卡片打开期间,这些设置已被其他地方改动。请关闭后重新打开,在当前值上编辑。',
|
||||
retry: '重试',
|
||||
keyInput: 'API 密钥',
|
||||
keyPlaceholder: '输入 API 密钥',
|
||||
keyPlaceholderNative: '输入 API 密钥,或留空使用环境认证',
|
||||
keyStored: '已配置——输入新值可替换',
|
||||
keyEnvLocked: '由启动环境提供(只读)',
|
||||
customized: '自定义设置',
|
||||
@@ -130,6 +147,9 @@ export const zh: typeof en = {
|
||||
addModel: '添加模型',
|
||||
removeModel: '删除模型',
|
||||
modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。',
|
||||
keyBlank: '请输入 API 密钥;留空则保持已存储的密钥。',
|
||||
keyBlankNew: '请输入 API 密钥;若该提供方以其他方式鉴权,可以留空。',
|
||||
keyIllegalCharacters: '该 API 密钥格式错误,请检查。',
|
||||
modelIdRequired: '模型 ID 不能为空。',
|
||||
modelIdDuplicate: '模型 ID 不能重复。',
|
||||
modelNameInvalid: '显示名称不能为空。',
|
||||
|
||||
@@ -125,18 +125,6 @@ export class ModelsSettingsStore {
|
||||
*/
|
||||
constructor(private readonly api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>) {}
|
||||
|
||||
/**
|
||||
* Surface a failure from an operation the page ran outside {@link load} —
|
||||
* a row removal — on the same banner a load failure uses.
|
||||
* @param message - the failure text to show.
|
||||
*/
|
||||
fail(message: string): void {
|
||||
this.store.update((s) => {
|
||||
s.status = 'error'
|
||||
s.error = message
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the whole page snapshot: directory and namespaces in parallel,
|
||||
* then one batched credential describe over every referenced ref. A
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('ui-models apply', () => {
|
||||
expect(resolveSlotLabel(entry.options.label)).toBe('模型')
|
||||
const injected = (entry.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected)()
|
||||
expect(injected.t('nav')).toBe('模型')
|
||||
expect(injected.t('deleteTitle')).toBe('删除模型提供方?')
|
||||
expect(injected.t('deleteTitle')).toBe('删除 {provider}?')
|
||||
expect(typeof injected.controller.load).toBe('function')
|
||||
expect(typeof injected.useSnapshot).toBe('function')
|
||||
expect(injected.api).toBeDefined()
|
||||
@@ -80,10 +80,10 @@ describe('ui-models apply', () => {
|
||||
b.locale.setLocale('en')
|
||||
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Models')
|
||||
const injected = b.slots.entries('settings.section')[0]!.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected
|
||||
expect(injected().t('deleteTitle')).toBe('Delete model provider?')
|
||||
expect(injected().t('deleteTitle')).toBe('Delete {provider}?')
|
||||
b.locale.setLocale('zh')
|
||||
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('模型')
|
||||
expect(injected().t('deleteTitle')).toBe('删除模型提供方?')
|
||||
expect(injected().t('deleteTitle')).toBe('删除 {provider}?')
|
||||
})
|
||||
|
||||
it('locale change while the slot is undeclared stays a no-op', async () => {
|
||||
|
||||
@@ -5,12 +5,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import Schema from 'schemastery'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { ModelsSection, needsSetup, removeProviderProfile } from '../src/client/ModelsSection.tsx'
|
||||
import {
|
||||
ModelsSection, needsSetup, providerCopy, providerTargetLabel, removeProviderProfile,
|
||||
} from '../src/client/ModelsSection.tsx'
|
||||
import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx'
|
||||
import { pathOps } from '../src/client/ProviderEditor.tsx'
|
||||
import {
|
||||
DeepSeekModelsEditor, formatCapacity, modelDrafts, parseCapacity, validateDeepSeekModels,
|
||||
} from '../src/client/DeepSeekModelsEditor.tsx'
|
||||
import { apiKeyFailure } from '../src/client/apiKey.ts'
|
||||
import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts'
|
||||
import type { ProviderRow } from '../src/client/store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
@@ -18,6 +21,8 @@ import { en } from '../src/client/locales.ts'
|
||||
afterEach(cleanup)
|
||||
|
||||
const t: ModelsSectionInjected['t'] = key => en[key]
|
||||
const OPENAI_TARGET = { provider: 'openai', displayName: 'openai' }
|
||||
const openaiCopy = (template: string): string => providerCopy(template, OPENAI_TARGET)
|
||||
|
||||
/** Open one row's capacity disclosure (1-based, as the labels read). */
|
||||
function expandRow(position: number): void {
|
||||
@@ -136,11 +141,13 @@ function scriptedFace(overrides: {
|
||||
replace?: ReturnType<typeof vi.fn>
|
||||
mutate?: ReturnType<typeof vi.fn>
|
||||
set?: ReturnType<typeof vi.fn>
|
||||
unset?: ReturnType<typeof vi.fn>
|
||||
} = {}) {
|
||||
const update = overrides.update ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
|
||||
const replace = overrides.replace ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
|
||||
const mutate = overrides.mutate ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
|
||||
const set = overrides.set ?? vi.fn(() => Promise.resolve(ok({})))
|
||||
const unset = overrides.unset ?? vi.fn(() => Promise.resolve(ok({})))
|
||||
const face = {
|
||||
llm: {
|
||||
providers: vi.fn(() => Promise.resolve(ok({
|
||||
@@ -170,16 +177,16 @@ function scriptedFace(overrides: {
|
||||
}])),
|
||||
}))),
|
||||
set,
|
||||
unset: vi.fn(() => Promise.resolve(ok({}))),
|
||||
unset,
|
||||
},
|
||||
}
|
||||
return { face, update, replace, mutate, set }
|
||||
return { face, update, replace, mutate, set, unset }
|
||||
}
|
||||
|
||||
type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
|
||||
|
||||
async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
|
||||
const { face, update, replace, mutate, set } = scriptedFace(overrides)
|
||||
const { face, update, replace, mutate, set, unset } = scriptedFace(overrides)
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace)
|
||||
await controller.load()
|
||||
const injected: ModelsSectionInjected = {
|
||||
@@ -189,7 +196,7 @@ async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {})
|
||||
t,
|
||||
}
|
||||
const view = render(<ModelsSection {...injected} />)
|
||||
return { view, face, update, replace, mutate, set, controller }
|
||||
return { view, face, update, replace, mutate, set, unset, controller }
|
||||
}
|
||||
|
||||
describe('ModelsSection', () => {
|
||||
@@ -207,9 +214,36 @@ describe('ModelsSection', () => {
|
||||
expect(screen.getByText('openai')).toBeTruthy()
|
||||
expect(screen.queryByText('Active')).toBeNull()
|
||||
expect(screen.queryByText('Inactive')).toBeNull()
|
||||
const configured = screen.getByRole('img', { name: en.credentialConfigured })
|
||||
expect(configured.getAttribute('title')).toBe(en.credentialConfigured)
|
||||
expect(configured.className).toContain('credentialDotConfigured')
|
||||
expect(configured.closest('li')?.textContent).toContain('openai')
|
||||
expect(screen.queryByRole('img', { name: en.credentialMissing })).toBeNull()
|
||||
expect(screen.getByText(en.add)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('marks only a confirmed missing reference and leaves native or unavailable state unmarked', async () => {
|
||||
const { face } = scriptedFace()
|
||||
face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
|
||||
credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])),
|
||||
})))
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace)
|
||||
await controller.load()
|
||||
render(<ModelsSection
|
||||
controller={controller}
|
||||
useSnapshot={bindSnapshotSelector(controller.store)}
|
||||
api={face as never}
|
||||
t={t}
|
||||
/>)
|
||||
|
||||
const missing = screen.getByRole('img', { name: en.credentialMissing })
|
||||
expect(missing.getAttribute('title')).toBe(en.credentialMissing)
|
||||
expect(missing.className).toContain('credentialDotMissing')
|
||||
expect(missing.closest('li')?.textContent).toContain('openai')
|
||||
expect(screen.queryByRole('img', { name: en.credentialConfigured })).toBeNull()
|
||||
expect(screen.getByText('zombie').closest('li')?.querySelector('[role="img"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('turns the setup card into a row once the credential reports configured', async () => {
|
||||
const { face } = await mountSection()
|
||||
face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
|
||||
@@ -254,6 +288,13 @@ describe('ModelsSection', () => {
|
||||
expect(deriveKeyRef('minimax-cn')).toBe('MINIMAX_CN_API_KEY')
|
||||
})
|
||||
|
||||
it('uses one stable provider identity in action copy', () => {
|
||||
const target = { provider: 'deepseek-official', displayName: 'DeepSeek' }
|
||||
expect(providerTargetLabel(target)).toBe('DeepSeek (deepseek-official)')
|
||||
expect(providerCopy(en.deleteTitle, target)).toBe('Delete DeepSeek (deepseek-official)?')
|
||||
expect(providerTargetLabel(OPENAI_TARGET)).toBe('openai')
|
||||
})
|
||||
|
||||
it('names only the fields the card can see, so an unseen secret survives', () => {
|
||||
// `before` is the REDACTED subtree: a stored literal apiKey is in neither
|
||||
// side, so no op mentions it and the seam leaves it alone.
|
||||
@@ -268,11 +309,16 @@ describe('ModelsSection', () => {
|
||||
it('stores a typed key write-only from the setup card without touching settings', async () => {
|
||||
const { set, update, face } = await mountSection()
|
||||
const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
|
||||
fireEvent.change(key, { target: { value: 'sk-live' } })
|
||||
fireEvent.change(key, { target: { value: ' sk-live ' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-live' }) })
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
await waitFor(() => { expect(face.settings.describe.mock.calls.length).toBeGreaterThan(1) })
|
||||
expect((await screen.findByRole('status')).textContent).toBe(
|
||||
providerCopy(en.savedProvider, { provider: 'deepseek-official', displayName: 'DeepSeek' }),
|
||||
)
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('applies customized deepseek fields as path ops', async () => {
|
||||
@@ -777,6 +823,7 @@ describe('ModelsSection', () => {
|
||||
expect((urls[1] as HTMLInputElement).placeholder).toBe(en.baseUrlDefault)
|
||||
const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
const addKey = keys[keys.length - 1] as HTMLInputElement
|
||||
expect(addKey.placeholder).toBe(en.keyPlaceholderNative)
|
||||
fireEvent.change(addKey, { target: { value: 'sk-ant' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
@@ -788,6 +835,59 @@ describe('ModelsSection', () => {
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' }) })
|
||||
})
|
||||
|
||||
it('keeps pi-ai provider-native authentication when no key is entered', async () => {
|
||||
const { mutate, set } = await mountSection()
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
await screen.findByLabelText(en.provider)
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
ops: [{ op: 'set', path: ['providers', 'anthropic'], value: {} }],
|
||||
expectedRevision: 0,
|
||||
})
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retries only the credential after refreshed settings already committed', async () => {
|
||||
const committed = wireNamespaces()[2]!
|
||||
const afterSettings: SettingsNamespaceView = {
|
||||
...committed,
|
||||
value: { providers: {
|
||||
...(committed.value as { providers: object }).providers,
|
||||
anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' },
|
||||
} },
|
||||
user: { providers: {
|
||||
...(committed.user as { providers: object }).providers,
|
||||
anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' },
|
||||
} },
|
||||
revision: 1,
|
||||
}
|
||||
const mutate = vi.fn(() => Promise.resolve(ok(afterSettings)))
|
||||
const set = vi.fn()
|
||||
.mockResolvedValueOnce(fail('credential store unavailable', 'credential-rejected'))
|
||||
.mockResolvedValueOnce(ok({}))
|
||||
const { face, controller } = await mountSection({ mutate, set })
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
await screen.findByLabelText(en.provider)
|
||||
const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-ant' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
await screen.findByText('credential store unavailable')
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
face.settings.describe.mockResolvedValue(ok({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: wireNamespaces().map(namespace => namespace.ns === 'llm-pi-ai' ? afterSettings : namespace),
|
||||
}))
|
||||
await act(async () => { await controller.load() })
|
||||
expect(controller.store.getSnapshot().namespaces.get('llm-pi-ai')?.revision).toBe(1)
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledTimes(2) })
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
expect(set).toHaveBeenLastCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' })
|
||||
})
|
||||
|
||||
it('switches the add card target and degrades unknown or broken targets loudly', async () => {
|
||||
await mountSection()
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
@@ -876,6 +976,7 @@ describe('ModelsSection', () => {
|
||||
fireEvent.change(key, { target: { value: 'sk-live' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await screen.findByText(/shadowed by the read-only environment/)
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('locks the key input when the launch environment provides the credential', async () => {
|
||||
@@ -898,34 +999,37 @@ describe('ModelsSection', () => {
|
||||
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
|
||||
const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
const editorKey = keys[keys.length - 1] as HTMLInputElement
|
||||
expect(editorKey.placeholder).toBe(en.keyPlaceholder)
|
||||
expect(editorKey.placeholder).toBe(en.keyPlaceholderNative)
|
||||
fireEvent.change(editorKey, { target: { value: 'sk-live' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
|
||||
})
|
||||
|
||||
it('requires confirmation before removing a user-added provider', async () => {
|
||||
const { replace, mutate } = await mountSection()
|
||||
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
||||
const dialog = screen.getByRole('dialog', { name: en.deleteTitle })
|
||||
expect(dialog.textContent).toContain(en.deleteDescription)
|
||||
const { replace, mutate, unset } = await mountSection()
|
||||
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
|
||||
const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })
|
||||
expect(dialog.textContent).toContain(openaiCopy(en.deleteDescriptionWithCredential))
|
||||
expect(document.activeElement).toBe(within(dialog).getByRole('button', { name: en.cancel }))
|
||||
expect(unset).not.toHaveBeenCalled()
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: en.cancel }))
|
||||
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
|
||||
expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
||||
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
|
||||
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
|
||||
fireEvent.click(within(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) }))
|
||||
.getByRole('button', { name: en.close }))
|
||||
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
|
||||
expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
||||
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
|
||||
.getByRole('button', { name: en.deleteConfirm }))
|
||||
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
|
||||
fireEvent.click(within(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) }))
|
||||
.getByRole('button', { name: openaiCopy(en.deleteConfirm) }))
|
||||
await waitFor(() => { expect(unset).toHaveBeenCalledWith({ ref: 'OPENAI_API_KEY' }) })
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
|
||||
expect(unset.mock.invocationCallOrder[0]).toBeLessThan(mutate.mock.invocationCallOrder[0] as number)
|
||||
expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
@@ -939,20 +1043,22 @@ describe('ModelsSection', () => {
|
||||
resolveRemoval = resolve
|
||||
}))
|
||||
await mountSection({ mutate })
|
||||
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
||||
const dialog = screen.getByRole('dialog', { name: en.deleteTitle })
|
||||
const confirm = within(dialog).getByRole<HTMLButtonElement>('button', { name: en.deleteConfirm })
|
||||
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
|
||||
const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })
|
||||
const confirm = within(dialog).getByRole<HTMLButtonElement>('button', { name: openaiCopy(en.deleteConfirm) })
|
||||
fireEvent.click(confirm)
|
||||
fireEvent.click(confirm)
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
expect(confirm.disabled).toBe(true)
|
||||
expect(within(dialog).getByRole<HTMLButtonElement>('button', { name: en.cancel }).disabled).toBe(true)
|
||||
expect(within(dialog).getByRole('button', { name: en.deleting })).toBe(confirm)
|
||||
expect(within(dialog).getByRole('button', { name: openaiCopy(en.deleting) })).toBe(confirm)
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: en.close }))
|
||||
expect(screen.getByRole('dialog', { name: en.deleteTitle })).toBe(dialog)
|
||||
expect(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBe(dialog)
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
await act(async () => { resolveRemoval(ok(wireNamespaces()[2]!)) })
|
||||
await waitFor(() => { expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the load failure with a retry control', async () => {
|
||||
@@ -1057,15 +1163,58 @@ describe('ModelsSection', () => {
|
||||
expect(controller.store.getSnapshot().rows).toBe(before)
|
||||
})
|
||||
|
||||
it('shows a failed removal on the page banner, including a non-Error rejection', async () => {
|
||||
// The whole click path: the row's Remove button, the transport rejecting
|
||||
// with a non-Error value, and the store surfacing it where a load failure
|
||||
// would appear — rather than the row silently staying put.
|
||||
await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('the host refused'))) })
|
||||
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
||||
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
|
||||
.getByRole('button', { name: en.deleteConfirm }))
|
||||
await screen.findByText(`${en.loadFailed}: the host refused`)
|
||||
it('keeps a failed identified deletion recoverable in its confirmation dialog', async () => {
|
||||
const mutate = vi.fn()
|
||||
.mockResolvedValueOnce(fail('the host refused'))
|
||||
.mockResolvedValueOnce(ok(wireNamespaces()[2]!))
|
||||
const { unset } = await mountSection({ mutate })
|
||||
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
|
||||
const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })
|
||||
const confirm = within(dialog).getByRole('button', { name: openaiCopy(en.deleteConfirm) })
|
||||
fireEvent.click(confirm)
|
||||
await within(dialog).findByText('the host refused')
|
||||
expect(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBe(dialog)
|
||||
expect(unset).toHaveBeenCalledOnce()
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
|
||||
fireEvent.click(confirm)
|
||||
await waitFor(() => { expect(unset).toHaveBeenCalledTimes(2) })
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(2) })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('retains credentials that are not identified as page-managed', async () => {
|
||||
const { unset, mutate } = await mountSection()
|
||||
const target = { provider: 'zombie', displayName: 'zombie' }
|
||||
fireEvent.click(screen.getByRole('button', { name: providerCopy(en.removeProvider, target) }))
|
||||
const dialog = screen.getByRole('dialog', { name: providerCopy(en.deleteTitle, target) })
|
||||
expect(dialog.textContent).toContain(providerCopy(en.deleteDescription, target))
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: providerCopy(en.deleteConfirm, target) }))
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
expect(unset).not.toHaveBeenCalled()
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
ops: [{ op: 'unset', path: ['providers', 'zombie'] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('does not remove provider settings when its managed credential removal is refused', async () => {
|
||||
const { face, controller, mutate } = await mountSection({
|
||||
unset: vi.fn(() => Promise.resolve(fail('credential is read-only', 'credential-rejected'))),
|
||||
})
|
||||
const failure = await removeProviderProfile(
|
||||
face as unknown as Parameters<typeof removeProviderProfile>[0],
|
||||
controller,
|
||||
{
|
||||
settingsNs: 'llm-pi-ai',
|
||||
settingsPath: ['providers', 'openai'],
|
||||
credentialRef: 'OPENAI_API_KEY',
|
||||
},
|
||||
)
|
||||
expect(failure).toBe('credential is read-only')
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a transport rejection instead of failing the removal silently', async () => {
|
||||
@@ -1080,3 +1229,54 @@ describe('ModelsSection', () => {
|
||||
expect(failure).toBe('connection lost')
|
||||
})
|
||||
})
|
||||
|
||||
describe('apiKeyFailure', () => {
|
||||
it('treats a blank field as no failure — it means keep the stored key', () => {
|
||||
expect(apiKeyFailure('')).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a printable-ASCII key', 'sk-0123456789'],
|
||||
['a padded key, which the caller trims', ' sk-abc '],
|
||||
['the printable-ASCII boundary characters', '!~'],
|
||||
['a hyphenated key carrying an equals sign', 'sk-ABC=xyz'],
|
||||
['an all-upper-case key ending in base64 padding', 'ABCD=='],
|
||||
['an all-upper-case key ending in one padding character', 'MNOPQRST='],
|
||||
])('accepts %s', (_label, draft) => {
|
||||
expect(apiKeyFailure(draft)).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['spaces', ' '],
|
||||
['a tab', '\t'],
|
||||
])('fails a field holding only %s instead of silently dropping it', (_label, draft) => {
|
||||
expect(apiKeyFailure(draft)).toBe('keyBlank')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an emoji', 'sk-\u{1F600}'],
|
||||
['CJK text', 'sk-你好'],
|
||||
['full-width punctuation', 'sk-abc,'],
|
||||
['an interior space', 'sk-abc def'],
|
||||
['a C0 control character', 'sk-abc\x01'],
|
||||
['a latin-1 character', 'sk-café'],
|
||||
])('fails %s as illegal characters', (_label, draft) => {
|
||||
expect(apiKeyFailure(draft)).toBe('keyIllegalCharacters')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a pasted environment line', 'DEEPSEEK_API_KEY=sk-abc'],
|
||||
['double quotes', '"sk-abc"'],
|
||||
['single quotes', '\'sk-abc\''],
|
||||
['backticks', '`sk-abc`'],
|
||||
])('fails %s as a format failure', (_label, draft) => {
|
||||
expect(apiKeyFailure(draft)).toBe('keyIllegalCharacters')
|
||||
})
|
||||
|
||||
it('needs a matching closing quote before it calls a value wrapped', () => {
|
||||
// A lone quote and an unbalanced one are legal printable ASCII, so the
|
||||
// heuristic leaves them alone rather than guessing at a paste error.
|
||||
expect(apiKeyFailure('"')).toBeUndefined()
|
||||
expect(apiKeyFailure('"a')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -142,7 +142,7 @@ async function mountSection(options: Parameters<typeof scriptedFace>[0] = {}) {
|
||||
t,
|
||||
}
|
||||
render(<ModelsSection {...injected} />)
|
||||
return scripted
|
||||
return { ...scripted, controller }
|
||||
}
|
||||
|
||||
/** Open the editor of one configured row and expand its customized fold. */
|
||||
@@ -862,4 +862,184 @@ describe('hand-declared providers', () => {
|
||||
await waitFor(() => { expect(screen.queryByText(en.customTitle)).toBeNull() })
|
||||
expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('refuses an unusable key on the field and blocks creation', () => {
|
||||
const { mutate, set } = mountCard()
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } })
|
||||
|
||||
// A hand-declared route reaches the same judgement as an edited one, so a
|
||||
// key that no header can carry never becomes a profile plus a bad secret.
|
||||
expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy()
|
||||
expect(buttonNamed(en.create).disabled).toBe(true)
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stays silent about the other gates when only the key is refused', () => {
|
||||
mountCard()
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } })
|
||||
|
||||
// Route, endpoint, and models are all satisfied, so answering with the
|
||||
// next unmet gate would print a second, false fault beside the real one.
|
||||
expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy()
|
||||
expect(screen.queryByText(en.customNeedsModels)).toBeNull()
|
||||
expect(screen.queryByText(en.customNeedsBaseUrl)).toBeNull()
|
||||
})
|
||||
|
||||
it('tells a whitespace-only key what a blank field means on a create card', () => {
|
||||
const { mutate } = mountCard()
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' ' } })
|
||||
|
||||
// There is no stored key to keep here, so the blank case says the thing
|
||||
// that is true of a route being declared: it may authenticate elsewhere.
|
||||
expect(screen.getByText(en.keyBlankNew)).toBeTruthy()
|
||||
expect(screen.queryByText(en.keyBlank)).toBeNull()
|
||||
expect(buttonNamed(en.fetchModels).title).toBe(en.keyBlankNew)
|
||||
expect(buttonNamed(en.create).disabled).toBe(true)
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates without a key when the route authenticates some other way', async () => {
|
||||
const { set, onClose } = mountCard()
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'ambient-gateway' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
|
||||
fireEvent.click(screen.getByText(en.create))
|
||||
|
||||
await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('API key field', () => {
|
||||
it('submits with a blank key field without writing a credential', async () => {
|
||||
const { mutate, set } = await mountSection()
|
||||
openEditor('openai')
|
||||
|
||||
// The field opens empty even for a provider whose key is stored, where it
|
||||
// means "keep that one" — so editing anything else must not require it.
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://moved.example/v1' } })
|
||||
expect(buttonNamed(en.apply).disabled).toBe(false)
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears a whitespace-only base URL instead of writing the spaces', async () => {
|
||||
const { mutate } = await mountSection()
|
||||
openEditor('openai')
|
||||
|
||||
// The field renders this as empty, so the draft must agree: storing the
|
||||
// spaces would hand both adapters a non-empty string they accept as a URL.
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: ' ' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
|
||||
const ops = firstMutate(mutate).ops
|
||||
expect(ops.some(op => op.op === 'set' && op.path.includes('baseURL'))).toBe(false)
|
||||
expect(ops.some(op => op.op === 'unset' && op.path.includes('baseURL'))).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks submit and names the field when the key holds only whitespace', async () => {
|
||||
const { mutate, set } = await mountSection()
|
||||
openEditor('openai')
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' ' } })
|
||||
|
||||
expect(screen.getByText(en.keyBlank)).toBeTruthy()
|
||||
expect(buttonNamed(en.apply).disabled).toBe(true)
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks submit when the key contains characters no header can carry', async () => {
|
||||
const { set } = await mountSection()
|
||||
openEditor('openai')
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } })
|
||||
|
||||
expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy()
|
||||
expect(buttonNamed(en.apply).disabled).toBe(true)
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks submit when a whole NAME=value line was pasted', async () => {
|
||||
await mountSection()
|
||||
openEditor('openai')
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'OPENAI_API_KEY=sk-abc' } })
|
||||
|
||||
expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy()
|
||||
expect(buttonNamed(en.apply).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('trims a padded key before storing it', async () => {
|
||||
const { set } = await mountSection()
|
||||
openEditor('openai')
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' sk-abc ' } })
|
||||
expect(buttonNamed(en.apply).disabled).toBe(false)
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
await waitFor(() => { expect(set).toHaveBeenCalled() })
|
||||
expect((set.mock.calls[0]?.[0] as { value: string }).value).toBe('sk-abc')
|
||||
})
|
||||
|
||||
it('blocks the interrogation too, rather than spending a round trip on a refused key', async () => {
|
||||
const { discover } = await mountSection()
|
||||
openEditor('openai')
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } })
|
||||
|
||||
// The host would refuse this before building the header anyway; asking is
|
||||
// a round trip to be told what the field already says.
|
||||
expect(buttonNamed(en.fetchModels).disabled).toBe(true)
|
||||
expect(buttonNamed(en.fetchModels).title).toBe(en.keyIllegalCharacters)
|
||||
expect(discover).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('carries the trimmed key into an interrogation, not the padded draft', async () => {
|
||||
const { discover } = await mountSection()
|
||||
openEditor('openai')
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' sk-abc ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.fetchModels }))
|
||||
|
||||
await waitFor(() => { expect(discover).toHaveBeenCalled() })
|
||||
expect(firstProbe(discover)).toMatchObject({ apiKey: 'sk-abc' })
|
||||
})
|
||||
|
||||
it('reloads the section after creating a hand-declared provider', async () => {
|
||||
const { controller, mutate } = await mountSection()
|
||||
const load = vi.spyOn(controller, 'load')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: en.customAdd }))
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
||||
fireEvent.click(screen.getByText(en.create))
|
||||
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
await waitFor(() => { expect(load).toHaveBeenCalledOnce() })
|
||||
expect(screen.queryByText(en.customTitle)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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-general/README.md
|
||||
README.md: 29e48d193d24644f37d219b4df44a8fedf062e53
|
||||
README.zh.md: 17ebc9e8ab273aae0e7ea4c764da569da6d9f49f
|
||||
README.md: ab27e073dc76335efc619f56365d1705007f7ef2
|
||||
README.zh.md: 18bbecf67f51ae63bfacd4ba78437bea95b50bee
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
|
||||
|
||||
A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read.
|
||||
A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read.
|
||||
|
||||
`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容、本地配置文件操作,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
|
||||
|
||||
回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权 settings 读取。
|
||||
回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权 settings 读取。
|
||||
|
||||
`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在 user-settings seam 中注册 `ui-onboarding`。loopback 浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非 loopback 浏览器不能访问受保护的 settings API:它仍会显示通知,但「继续」只推进当前浏览器进程,重新加载后会再次显示通知。版本不同时,系统也会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。
|
||||
|
||||
|
||||
@@ -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/code-runtime/README.md
|
||||
README.md: 2d32a05071efdfa05c336211196bb769ed5a5fc7
|
||||
README.zh.md: 62c9a395ac3cf2b5cd55455ab5a273a1e276f6b7
|
||||
README.md: f20a287419b94b1a9dc1d8da7303fc4d3032cfd3
|
||||
README.zh.md: f5cd4c9949f2bd7a7d6d7cd078144910712a3819
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The code-execution capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract runtime interface for executing one model-written program against host-provided async bindings, capturing what it printed and returned. The consumer is the tool registry's [Code Mode](../core/tools/README.md) (`tools: { mode: code }` — the `run_code` tool and the generated TypeScript SDK); design in the [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md). **Product** packages.
|
||||
The code-execution capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract runtime interface for executing one model-written program against host-provided async bindings, capturing what it printed and returned. The consumer is the tool registry's [Code Mode](../core/tools/README.md) (`tools: { mode: code }` — the `run_code` tool and the SDK generated in the loaded runtime's `language`); design in the [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md). **Product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
代码执行能力 seam(参见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):一个抽象运行时接口,用于对宿主提供的异步绑定执行模型编写的程序,并捕获它打印和返回的内容。消费方是工具注册表的 [Code Mode](../core/tools/README.md)(`tools: { mode: code }`,即 `run_code` 工具和生成的 TypeScript SDK);设计见 [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。这些全是**产品**包。
|
||||
代码执行能力 seam(参见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):一个抽象运行时接口,用于对宿主提供的异步绑定执行模型编写的程序,并捕获它打印和返回的内容。消费方是工具注册表的 [Code Mode](../core/tools/README.md)(`tools: { mode: code }`,即 `run_code` 工具和按所加载运行时 `language` 生成的 SDK);设计见 [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。这些全是**产品**包。
|
||||
|
||||
| 包 | 职责 | ctx key |
|
||||
|---|---|---|
|
||||
|
||||
@@ -13,7 +13,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import { CodeRuntime, DUNDER_MEMBER, PORTABLE_RESERVED_WORDS, RESERVED_BINDING_GLOBALS, RESERVED_ERROR_MEMBERS } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { CodeBindingNamespace, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
|
||||
@@ -65,20 +65,12 @@ const ELU_POLL_INTERVAL_MS = 25
|
||||
/** Smallest cap that can represent the counted payloads: an empty logs array plus an empty JSON failure message. */
|
||||
const MIN_OUTPUT_BYTES = 4
|
||||
|
||||
/** ECMAScript reserved words that cannot be async-function parameter names — rejected as binding globals. */
|
||||
const RESERVED_WORDS = new Set([
|
||||
'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do',
|
||||
'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in',
|
||||
'instanceof', 'new', 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof',
|
||||
'var', 'void', 'while', 'with', 'yield', 'let', 'static', 'implements', 'interface', 'package',
|
||||
'private', 'protected', 'public', 'arguments', 'eval',
|
||||
])
|
||||
|
||||
/** Valid async-function parameter name (the binding global becomes one). */
|
||||
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
||||
|
||||
/** Error properties whose binding-member replacement would destroy the promised Error contract. */
|
||||
const RESERVED_ERROR_PROPERTIES = new Set(['name', 'message', 'stack'])
|
||||
/**
|
||||
* The seam's language-portable identifier subset (see
|
||||
* `CodeBindingNamespace.global`): no `$`, which is JS-only spelling — the same
|
||||
* namespace list must be usable against every backend regardless of language.
|
||||
*/
|
||||
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
|
||||
/**
|
||||
* The shell a program is wrapped in for the type-strip, matching the
|
||||
@@ -328,10 +320,19 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
private validateBindings(request: CodeRunRequest): Map<string, CodeBindingNamespace> {
|
||||
const bindings = new Map<string, CodeBindingNamespace>()
|
||||
for (const namespace of request.bindings) {
|
||||
if (!IDENTIFIER.test(namespace.global) || RESERVED_WORDS.has(namespace.global)) {
|
||||
if (!IDENTIFIER.test(namespace.global) || PORTABLE_RESERVED_WORDS.has(namespace.global)) {
|
||||
throw new Error(`dsh-code-runtime-worker: binding global ${JSON.stringify(namespace.global)} is not a usable identifier`)
|
||||
}
|
||||
if (namespace.global === 'console' || bindings.has(namespace.global)) {
|
||||
// RESERVED_BINDING_GLOBALS is the seam's shared backend-owned set:
|
||||
// `console` is THIS backend's log-capture slot; the dunder entries exist
|
||||
// for the Python side — its seeded/wrapped slots plus the `__debug__`
|
||||
// compile-time constant — refused here too so the namespace list stays
|
||||
// portable across backends. The seam declaration is the single home for
|
||||
// why each entry is reserved.
|
||||
if (RESERVED_BINDING_GLOBALS.has(namespace.global)) {
|
||||
throw new Error(`dsh-code-runtime-worker: reserved binding global ${JSON.stringify(namespace.global)}`)
|
||||
}
|
||||
if (bindings.has(namespace.global)) {
|
||||
throw new Error(`dsh-code-runtime-worker: duplicate binding global ${JSON.stringify(namespace.global)}`)
|
||||
}
|
||||
bindings.set(namespace.global, namespace)
|
||||
@@ -341,13 +342,17 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
for (const namespace of request.bindings) {
|
||||
const descriptor = namespace.errorClass
|
||||
if (!descriptor) continue
|
||||
if (!IDENTIFIER.test(descriptor.name) || RESERVED_WORDS.has(descriptor.name)) {
|
||||
if (!IDENTIFIER.test(descriptor.name) || PORTABLE_RESERVED_WORDS.has(descriptor.name)) {
|
||||
throw new Error(`dsh-code-runtime-worker: binding error class ${JSON.stringify(descriptor.name)} is not a usable identifier`)
|
||||
}
|
||||
if (descriptor.name === 'console' || bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) {
|
||||
if (RESERVED_BINDING_GLOBALS.has(descriptor.name)) {
|
||||
throw new Error(`dsh-code-runtime-worker: reserved binding global ${JSON.stringify(descriptor.name)}`)
|
||||
}
|
||||
if (bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) {
|
||||
throw new Error(`dsh-code-runtime-worker: duplicate injected global ${JSON.stringify(descriptor.name)}`)
|
||||
}
|
||||
if (descriptor.memberNameProperty.length === 0 || RESERVED_ERROR_PROPERTIES.has(descriptor.memberNameProperty)) {
|
||||
const member = descriptor.memberNameProperty
|
||||
if (member.length === 0 || RESERVED_ERROR_MEMBERS.has(member) || DUNDER_MEMBER.test(member)) {
|
||||
throw new Error(`dsh-code-runtime-worker: binding error member property ${JSON.stringify(descriptor.memberNameProperty)} is not usable`)
|
||||
}
|
||||
errorClassNames.add(descriptor.name)
|
||||
|
||||
@@ -787,7 +787,17 @@ describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
|
||||
const cases: [string, RegExp][] = [
|
||||
['not valid!', /not a usable identifier/],
|
||||
['await', /not a usable identifier/],
|
||||
['console', /duplicate binding global/],
|
||||
// `$tools` is legal JS but outside the seam's language-portable subset:
|
||||
// the same namespace list must work against every backend's language.
|
||||
['$tools', /not a usable identifier/],
|
||||
// `a$b` pins the second character class too: the old identifier regex
|
||||
// `[A-Za-z0-9_$]*` would have accepted a `$` after the first character.
|
||||
['a$b', /not a usable identifier/],
|
||||
// `lambda` is a Python keyword, refused here directly (not just
|
||||
// transitively) so the worker's adoption of PORTABLE_RESERVED_WORDS is
|
||||
// its own regression, symmetric with the `$tools` case.
|
||||
['lambda', /not a usable identifier/],
|
||||
['console', /reserved binding global/],
|
||||
]
|
||||
for (const [global, message] of cases) {
|
||||
await expect(runtime.run({ program: 'return 1', bindings: [{ global, functions: {} }] })).rejects.toThrow(message)
|
||||
@@ -814,7 +824,7 @@ describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
|
||||
|
||||
await expect(run([namespace('tools', 'not valid!')])).rejects.toThrow(/error class.*not a usable identifier/)
|
||||
await expect(run([namespace('tools', 'await')])).rejects.toThrow(/error class.*not a usable identifier/)
|
||||
await expect(run([namespace('tools', 'console')])).rejects.toThrow(/duplicate injected global/)
|
||||
await expect(run([namespace('tools', 'console')])).rejects.toThrow(/reserved binding global/)
|
||||
await expect(run([namespace('tools', 'tools')])).rejects.toThrow(/duplicate injected global/)
|
||||
await expect(run([
|
||||
namespace('tools', 'CallError'),
|
||||
@@ -822,6 +832,14 @@ describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
|
||||
])).rejects.toThrow(/duplicate injected global/)
|
||||
await expect(run([namespace('tools', 'CallError', '')])).rejects.toThrow(/member property.*not usable/)
|
||||
await expect(run([namespace('tools', 'CallError', 'message')])).rejects.toThrow(/member property.*not usable/)
|
||||
// The shared exclusion set covers Python's exception-protocol members and
|
||||
// dunders too, so the same errorClass is valid (or not) on every backend.
|
||||
await expect(run([namespace('tools', 'CallError', 'args')])).rejects.toThrow(/member property.*not usable/)
|
||||
await expect(run([namespace('tools', 'CallError', '__dict__')])).rejects.toThrow(/member property.*not usable/)
|
||||
// The Python backend's owned globals are refused here too (shared
|
||||
// RESERVED_BINDING_GLOBALS), keeping namespace lists backend-portable.
|
||||
await expect(runtime.run({ program: 'return 1', bindings: [{ global: '__dsh_main__', functions: {} }] }))
|
||||
.rejects.toThrow(/reserved binding global/)
|
||||
})
|
||||
|
||||
it('rejects config values that are not positive numbers', async () => {
|
||||
|
||||
@@ -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/code-runtime/code-runtime/README.md
|
||||
README.md: c7a2d519e47d160f5ab123bfc887e7e9f24ec602
|
||||
README.zh.md: 22d0b120d7cea50b578a184b3e40d77707ebc489
|
||||
README.md: bb1c20d00a260f643f601c42c6e48722437d5aab
|
||||
README.zh.md: 15fbcecf77b2318acf3b09101802cd032ae426d2
|
||||
|
||||
@@ -11,7 +11,7 @@ This package is the interface third of the capability (the bash trio is the temp
|
||||
| Member | Semantics |
|
||||
|---|---|
|
||||
| `run(request)` | Execute one program against the request's bindings. **Resolves with an error FIELD for every program outcome** — parse/transform failure, thrown exception, invalid completion, output overflow, budget expiry, abort, or substrate death (`CodeRunFailure`'s orthogonal `kind` taxonomy); it rejects only for caller misuse of the seam itself (e.g. a run submitted after disposal). The program runs as the body of an async function: top-level `await`/`return` work, and a lossless JSON completion becomes `result.value`. |
|
||||
| `language` | Readonly descriptor: the source language `run` expects (`'typescript'` is the well-known value). Informational, not gating — a consumer that generates language-specific presentation switches on it and fails loud on a language it cannot present. |
|
||||
| `language` | Readonly descriptor: the source language `run` expects. `'typescript'` and `'python'` are the well-known values — those `dsh-tools` presents; only `'typescript'` has a published backend. Informational, not gating — a consumer that generates language-specific presentation switches on it and fails loud on a language it cannot present. |
|
||||
| `isolation` | Readonly descriptor: the execution substrate (`'worker-thread'`, `'process'`, `'container'`). A label for deployments and diagnostics, **not a security claim**. |
|
||||
|
||||
Semantics every implementation must honor (contract details in the class JSDoc): binding calls bridge complete lossless-JSON arguments and resolutions with no seam-level byte cap; the program is treated as a hostile peer (arbitrary binding names are own properties, malformed traffic never crashes the host); no state survives between runs; disposal terminates in-flight runs AND awaits their exit before completing.
|
||||
@@ -20,6 +20,8 @@ Semantics every implementation must honor (contract details in the class JSDoc):
|
||||
|
||||
`CodeRunRequest` (`program`, `bindings`, `signal?`) carries everything the runtime acts on — defaulting (time budgets and outer-output cap) is the implementation's validated config, never a hidden `??` inside `run()`. `bindings` is a list of `CodeBindingNamespace`s (`global` + `functions` + optional `errorClass`), each exposed to the program as one global object of async callables returning `CodeJsonValue`, the seam-local structural equivalent of canonical `JsonValue` that keeps this interface package independent of sessions. An `errorClass` descriptor names a real program-global constructor and the own property that receives the rejected member name; runtimes remain independent of consumer terms such as `ToolCallError`. `CodeRunResult` reports the lossless JSON completion `value?`, ordered `logs: string[]`, and the `error?` (`CodeRunFailure`: `kind` + model-feedable `message`). See `src/types.ts` for the full contracts.
|
||||
|
||||
Binding-global and error-class names are **language-portable**: they must match the identifier subset `[A-Za-z_][A-Za-z0-9_]*` (no JS-only `$`) and clear the seam-exported exclusion sets, so one `bindings` list is valid against every backend regardless of its `language`. The package exports the contract every backend enforces — `PORTABLE_RESERVED_WORDS` (ECMAScript ∪ Python reserved words), `RESERVED_BINDING_GLOBALS` (backend-owned globals such as `console`), `RESERVED_ERROR_MEMBERS` and `DUNDER_MEMBER` (error-member exclusions) — so a name like `$tools`, `lambda`, or `__dsh_main__` makes `run()` reject as seam misuse on any backend, not just some. See `src/index.ts` for the exact sets and rationale.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through Code Mode in `dsh-tools`, which exposes `run_code` and returns program logs, values, or failures as retained tool-result tokens.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
| 成员 | 语义 |
|
||||
|---|---|
|
||||
| `run(request)` | 针对请求的绑定执行一段程序。**所有程序失败结果都通过 resolve 结果中的 error 字段报告**:包括解析/转换失败、抛出异常、无效完成值、输出溢出、预算到期、中止或执行基底终止(由 `CodeRunFailure` 的正交 `kind` 分类表示);只有调用方误用 seam 本身时才 reject(例如 dispose(资源释放)后仍提交运行)。程序作为异步函数的函数体运行,因此顶层 `await`/`return` 可用,无损 JSON 完成值会成为 `result.value`。 |
|
||||
| `language` | 只读描述符:`run` 期望的源语言(已知值为 `'typescript'`)。仅供参考,不作门禁;生成语言专用呈现的消费方会根据该值选择分支,遇到无法呈现的语言时明确失败。 |
|
||||
| `language` | 只读描述符:`run` 期望的源语言。已知值为 `'typescript'` 与 `'python'`——`dsh-tools` 能呈现的那些;其中只有 `'typescript'` 有已发布的后端。仅供参考,不作门禁;生成语言专用呈现的消费方会根据该值选择分支,遇到无法呈现的语言时明确失败。 |
|
||||
| `isolation` | 只读描述符:执行基底(`'worker-thread'`、`'process'`、`'container'`)。供部署与诊断使用,**不构成安全声明**。 |
|
||||
|
||||
每个实现都必须遵守以下语义(完整契约见类 JSDoc):绑定调用会桥接完整的无损 JSON 参数与 resolve 值,seam 层不设字节上限;程序被视为敌对对等方(任意绑定名称都会成为自有属性,格式错误的通信绝不能使宿主崩溃);不同运行之间不保留任何状态;dispose 会终止进行中的运行,并且在完成前等待其退出。
|
||||
@@ -20,6 +20,8 @@
|
||||
|
||||
`CodeRunRequest`(`program`、`bindings`、`signal?`)携带运行时操作所需的全部内容;默认值解析(时间预算与外层输出上限)属于实现的已验证配置,绝不能是隐藏的 `??`,更不能藏在 `run()` 内部。`bindings` 是 `CodeBindingNamespace` 列表(`global` + `functions` + 可选 `errorClass`);每个命名空间会作为一个由异步可调用函数组成的全局对象公开给程序,这些函数返回 `CodeJsonValue`。后者是 seam 本地、与规范 `JsonValue` 结构等价的类型,使接口包保持独立于会话。`errorClass` 描述符点名真实的程序全局构造器,以及用于接收被拒绝成员名称的自有属性;运行时不依赖 `ToolCallError` 等消费方术语。`CodeRunResult` 报告无损 JSON 完成值 `value?`、有序的 `logs: string[]` 和 `error?`(`CodeRunFailure`:`kind` + 可反馈给模型的 `message`)。完整契约见 `src/types.ts`。
|
||||
|
||||
binding-global 与 error-class 名称是**语言可移植**的:必须匹配标识符子集 `[A-Za-z_][A-Za-z0-9_]*`(不含 JS 专有的 `$`)并通过 seam 导出的排除集,因此同一份 `bindings` 列表对每个后端都有效,无论其 `language` 为何。本包导出每个后端都执行的契约——`PORTABLE_RESERVED_WORDS`(ECMAScript ∪ Python 保留字)、`RESERVED_BINDING_GLOBALS`(如 `console` 等后端拥有的 global)、`RESERVED_ERROR_MEMBERS` 与 `DUNDER_MEMBER`(error-member 排除)——因此 `$tools`、`lambda`、`__dsh_main__` 之类的名称会让 `run()` 在任何后端上作为 seam 误用而 reject,而非只在某些后端。确切集合与理由见 `src/index.ts`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过 `dsh-tools` 中的 Code Mode 间接提供;后者公开 `run_code`,并将程序日志、值或失败作为保留的工具结果 token 返回。
|
||||
|
||||
@@ -17,6 +17,77 @@ export type {
|
||||
CodeRunResult,
|
||||
} from './types.ts'
|
||||
|
||||
/**
|
||||
* Binding globals EVERY backend refuses because SOME backend owns the slot in
|
||||
* the program's namespace: `console` (the worker's log capture), and
|
||||
* `__dsh_main__`/`__builtins__`/`__name__` (the Python backend's bootstrap
|
||||
* wrapper and seeded module globals — that backend is a later PR in this
|
||||
* stack, see the [portable-identifier Agent
|
||||
* Note](../../../../.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md)),
|
||||
* and `__debug__`. One shared set — rather than each backend refusing only its
|
||||
* own slots — keeps the portability promise real: a namespace list valid on
|
||||
* one backend is valid on all, so a caller cannot pick a name that works on
|
||||
* the worker and collides on Python (or vice versa). `__name__` et al. ARE
|
||||
* valid portable identifiers, so the identifier rule on
|
||||
* `CodeBindingNamespace.global` never rejects them — hence this explicit set.
|
||||
* (Error members differ: {@link DUNDER_MEMBER} refuses every dunder form
|
||||
* wholesale; binding globals refuse only the names listed here.) `__debug__`
|
||||
* is listed for a different reason than a collision: CPython compiles a bare
|
||||
* `__debug__` reference to the constant `True` and rejects any assignment to
|
||||
* the name at COMPILE time, so an injected global under that name is
|
||||
* unreachable from the program — accepted by validation, unusable on the
|
||||
* Python backend, which is exactly the split the shared set exists to prevent.
|
||||
*/
|
||||
export const RESERVED_BINDING_GLOBALS: ReadonlySet<string> = new Set([
|
||||
'console',
|
||||
'__dsh_main__', '__builtins__', '__name__', '__debug__',
|
||||
])
|
||||
|
||||
/**
|
||||
* `CodeBindingErrorClass.memberNameProperty` names EVERY backend refuses, as
|
||||
* one shared contract so a request valid on one backend is valid on all. The
|
||||
* JS `Error` exclusions (`name`, `message`, `stack`) and Python's
|
||||
* exception-protocol members (`args`, `with_traceback`, `add_note`) are
|
||||
* listed by name; dunder-form names (`__x__`, non-empty middle) are refused
|
||||
* wholesale — several are constrained CPython descriptors whose `setattr`
|
||||
* raises while constructing the rejection, and the exact set is an interpreter
|
||||
* version detail. Any other non-empty own property name is accepted everywhere.
|
||||
*/
|
||||
export const RESERVED_ERROR_MEMBERS: ReadonlySet<string> = new Set([
|
||||
'name', 'message', 'stack',
|
||||
'args', 'with_traceback', 'add_note',
|
||||
])
|
||||
|
||||
/**
|
||||
* Dunder form (`__x__`, non-empty middle): object-protocol slots in Python,
|
||||
* refused as {@link RESERVED_ERROR_MEMBERS | error members} on every backend.
|
||||
*/
|
||||
export const DUNDER_MEMBER = /^__.+__$/
|
||||
|
||||
/**
|
||||
* Reserved words of every portable target language (ECMAScript ∪ Python),
|
||||
* refused as {@link CodeBindingNamespace.global} / error-class names by all
|
||||
* backends. Python is a portability target here even though only the
|
||||
* TypeScript worker ships in this PR (the CPython backend is a later PR in the
|
||||
* stack). The portable-identifier contract promises a namespace list valid
|
||||
* on one backend is valid on every backend; a per-language check would let
|
||||
* `lambda` pass the TypeScript backend and fail the Python one. Extending the
|
||||
* seam with a new language means widening this union (a breaking review of
|
||||
* existing binding names, by design).
|
||||
*/
|
||||
export const PORTABLE_RESERVED_WORDS: ReadonlySet<string> = new Set([
|
||||
// ECMAScript reserved words and reserved-in-strict-mode names.
|
||||
'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do',
|
||||
'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in',
|
||||
'instanceof', 'new', 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof',
|
||||
'var', 'void', 'while', 'with', 'yield', 'let', 'static', 'implements', 'interface', 'package',
|
||||
'private', 'protected', 'public', 'arguments', 'eval',
|
||||
// Python 3.x keywords and soft keywords not already above ('type' and '_'
|
||||
// are soft keywords: legal names in practice, reserved here for safety).
|
||||
'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'def', 'del', 'elif', 'except', 'from',
|
||||
'global', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'match', 'type', '_',
|
||||
])
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
codeRuntime: CodeRuntime
|
||||
@@ -36,7 +107,8 @@ export abstract class CodeRuntime extends Service {
|
||||
* lowercase identifier. Informational, not gating — a consumer that
|
||||
* generates language-specific presentation (typed SDK stubs, usage
|
||||
* instructions) switches on it and fails loud on a language it cannot
|
||||
* present. Well-known value: `'typescript'`.
|
||||
* present. Well-known values: `'typescript'` and `'python'`, those
|
||||
* `dsh-tools` presents; only `'typescript'` has a published backend.
|
||||
*/
|
||||
abstract readonly language: string
|
||||
|
||||
|
||||
@@ -28,9 +28,14 @@ export type CodeJsonValue = null | boolean | number | string | CodeJsonValue[] |
|
||||
* of a particular consumer such as Code Mode.
|
||||
*/
|
||||
export interface CodeBindingErrorClass {
|
||||
/** Constructor global and resulting `Error.name` (must be a usable JS identifier). */
|
||||
/** Constructor global and resulting `Error.name`; same portable identifier rule as {@link CodeBindingNamespace.global}. */
|
||||
name: string
|
||||
/** Non-empty own property for the member name; cannot replace `name`, `message`, or `stack`. */
|
||||
/**
|
||||
* Non-empty own property for the member name. The portable exclusion set is
|
||||
* `RESERVED_ERROR_MEMBERS` plus dunder-form names (`__x__`, non-empty
|
||||
* middle), enforced identically by every backend; any other name —
|
||||
* identifiers or not — is accepted everywhere.
|
||||
*/
|
||||
memberNameProperty: string
|
||||
}
|
||||
|
||||
@@ -42,7 +47,16 @@ export interface CodeBindingErrorClass {
|
||||
* collisions.
|
||||
*/
|
||||
export interface CodeBindingNamespace {
|
||||
/** The global identifier the program sees (must be a valid JS identifier). */
|
||||
/**
|
||||
* The global identifier the program sees. Must match the LANGUAGE-PORTABLE
|
||||
* identifier subset `[A-Za-z_][A-Za-z0-9_]*` and no language's reserved
|
||||
* words, so the same namespace list works against every backend regardless
|
||||
* of `language` — a JS-only spelling like `$tools` is rejected by design,
|
||||
* not just by the Python backend. Names that satisfy the identifier rule but
|
||||
* name a backend-owned slot (`RESERVED_BINDING_GLOBALS`, e.g. `console`,
|
||||
* `__dsh_main__`) are also refused everywhere; see its declaration for the
|
||||
* exact set and why each entry is reserved.
|
||||
*/
|
||||
global: string
|
||||
/** The callable members, keyed by the exact name the program calls. */
|
||||
functions: Record<string, CodeBindingFunction>
|
||||
|
||||
57
packages/code-runtime/code-runtime/tests/reserved.spec.ts
Normal file
57
packages/code-runtime/code-runtime/tests/reserved.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DUNDER_MEMBER,
|
||||
PORTABLE_RESERVED_WORDS,
|
||||
RESERVED_BINDING_GLOBALS,
|
||||
RESERVED_ERROR_MEMBERS,
|
||||
} from '@deepseek-ai/dsh-code-runtime'
|
||||
|
||||
/**
|
||||
* The seam owns the portable-identifier exclusion sets so every backend
|
||||
* enforces one contract: a namespace list valid on one backend is valid on
|
||||
* all. These assertions pin the shared membership backends import rather than
|
||||
* re-declare.
|
||||
*/
|
||||
describe('seam-owned portable identifier exclusions', () => {
|
||||
it('RESERVED_BINDING_GLOBALS covers each backend-owned slot', () => {
|
||||
expect(RESERVED_BINDING_GLOBALS.has('console')).toBe(true)
|
||||
expect(RESERVED_BINDING_GLOBALS.has('__dsh_main__')).toBe(true)
|
||||
expect(RESERVED_BINDING_GLOBALS.has('__builtins__')).toBe(true)
|
||||
expect(RESERVED_BINDING_GLOBALS.has('__name__')).toBe(true)
|
||||
expect(RESERVED_BINDING_GLOBALS.has('__debug__')).toBe(true)
|
||||
expect(RESERVED_BINDING_GLOBALS.has('tools')).toBe(false)
|
||||
})
|
||||
|
||||
it('RESERVED_ERROR_MEMBERS covers the JS Error and Python exception-protocol members', () => {
|
||||
for (const name of ['name', 'message', 'stack', 'args', 'with_traceback', 'add_note']) {
|
||||
expect(RESERVED_ERROR_MEMBERS.has(name)).toBe(true)
|
||||
}
|
||||
expect(RESERVED_ERROR_MEMBERS.has('code')).toBe(false)
|
||||
})
|
||||
|
||||
it('DUNDER_MEMBER matches dunder-form names only', () => {
|
||||
expect(DUNDER_MEMBER.test('__dict__')).toBe(true)
|
||||
expect(DUNDER_MEMBER.test('__init__')).toBe(true)
|
||||
expect(DUNDER_MEMBER.test('_private')).toBe(false)
|
||||
expect(DUNDER_MEMBER.test('name')).toBe(false)
|
||||
expect(DUNDER_MEMBER.test('__mid')).toBe(false)
|
||||
// `__` has an empty middle — not a real CPython dunder, so not matched.
|
||||
expect(DUNDER_MEMBER.test('__')).toBe(false)
|
||||
// `____` also has an empty middle between the two `__` pairs — not matched.
|
||||
expect(DUNDER_MEMBER.test('____')).toBe(false)
|
||||
// A single character between the pairs is the shortest real dunder form.
|
||||
expect(DUNDER_MEMBER.test('__x__')).toBe(true)
|
||||
})
|
||||
|
||||
it('PORTABLE_RESERVED_WORDS is the union of ECMAScript and Python reserved words', () => {
|
||||
// ECMAScript-only keyword.
|
||||
expect(PORTABLE_RESERVED_WORDS.has('function')).toBe(true)
|
||||
// Python-only keyword — refused here so the list stays portable.
|
||||
expect(PORTABLE_RESERVED_WORDS.has('lambda')).toBe(true)
|
||||
expect(PORTABLE_RESERVED_WORDS.has('nonlocal')).toBe(true)
|
||||
// Shared keyword.
|
||||
expect(PORTABLE_RESERVED_WORDS.has('class')).toBe(true)
|
||||
// Ordinary identifier is not reserved.
|
||||
expect(PORTABLE_RESERVED_WORDS.has('tools')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -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/core/tools/README.md
|
||||
README.md: 80ea3cc93437d48a7ea0ffba0ff4d2ef2407755f
|
||||
README.zh.md: 691d2f2fcccdaa1bcab5343b2fce661d9c99e8ad
|
||||
README.md: 81cc57983d83fd19468017b217d4db9978f4e228
|
||||
README.zh.md: 9f875bd80a03d1d0f78625ee98eeaad9d118f871
|
||||
|
||||
@@ -13,7 +13,7 @@ tools:
|
||||
mode: native # native (default) | code | both
|
||||
```
|
||||
|
||||
`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. The reserved transport cannot be registered, shadowed, restricted, or removed. Non-native modes require a TypeScript `ctx.codeRuntime`, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol.
|
||||
`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. The reserved transport cannot be registered, shadowed, restricted, or removed. Non-native modes require a `ctx.codeRuntime` whose `language` has a registered SDK renderer — TypeScript ships via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md); a Python renderer is built in and drives any runtime that reports `language: 'python'` (a first-party `dsh-code-runtime-python` backend is delivered separately). A runtime language with no renderer fails prompt assembly loudly, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol.
|
||||
|
||||
### Public API
|
||||
|
||||
@@ -114,9 +114,9 @@ Returning `undefined` selects generic fallback. Presenters depend only on their
|
||||
|
||||
### Code Mode
|
||||
|
||||
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only the program's outer logs and return value re-enter model context. The SDK declares exact `ToolArgsMap` and `ToolOutputMap` entries for every visible tool, and each binding resolves to the tool's canonical JSON value. Each lossless-JSON binding call re-enters the complete tool pipeline under the native scheduling contract (concurrency-safe calls may overlap up to `maxParallelSubCalls`; exclusive calls run alone as ordering barriers) with logged correlation to the outer call. Denials and other failed results reject with the real program-visible `ToolCallError` carrying only `toolName` and `message`; Native content and internal error codes stay outside the Code contract. Ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; runtime failures surface as `CodeRunFailedError`. See the [Code Mode foundation](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), [typed-return contract](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md), and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`.
|
||||
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic SDK for the current scope, generated in the loaded runtime's language — the registry selects the renderer by `ctx.codeRuntime.language` (`typescript` → the TypeScript SDK below, `python` → the Python SDK). Only the program's outer logs and return value re-enter model context. The SDK declares exact per-tool argument and canonical-output types for every visible tool (`ToolArgsMap`/`ToolOutputMap` in TypeScript, named `TypedDict`s in Python), and each binding resolves to the tool's canonical JSON value. Each lossless-JSON binding call re-enters the complete tool pipeline under the native scheduling contract (concurrency-safe calls may overlap up to `maxParallelSubCalls`; exclusive calls run alone as ordering barriers) with logged correlation to the outer call. Denials and other failed results reject with the real program-visible `ToolCallError` carrying only `toolName` and `message`; Native content and internal error codes stay outside the Code contract. Ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; runtime failures surface as `CodeRunFailedError`. See the [Code Mode foundation](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), [typed-return contract](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md), and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`.
|
||||
|
||||
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, `JsonValue`, exact `ToolArgsMap` / `ToolOutputMap`, `ToolName`, the `ToolCallError` declaration, and a mapped `tools` namespace for the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) handles every unified schema construct and degrades unsupported raw constructs to `unknown`, never throwing during prompt assembly.
|
||||
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating the language-appropriate SDK text at each assembly. In the TypeScript flavor it emits `JsonValue`, exact `ToolArgsMap` / `ToolOutputMap`, `ToolName`, the `ToolCallError` declaration, and a mapped `tools` namespace for the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions; the Python flavor (`ctx.codeRuntime.language === 'python'`) emits the equivalent named `TypedDict`s and a `tools` object with matching usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). Both codegens are exported and never throw during prompt assembly: `jsonSchemaToTs` handles every unified schema construct and degrades unsupported raw constructs to `unknown`; `jsonSchemaToPy` does the same, degrading to `Any` (and a whole object to `dict[str, Any]` when a field name is not a legal `TypedDict` attribute, or whenever it is called outside the SDK render, which supplies the naming context a `TypedDict` declaration needs).
|
||||
- **The dispatch bridge** (`run_code`'s execute): every binding call is snapshotted as lossless JSON before dispatch (`undefined`, `BigInt`, cycles, sparse arrays, `-0`, and exotic objects reject that one call), scheduled through a per-run pool that reuses the native concurrency contract — calls start strictly in submission order, consecutive `isConcurrencySafe` calls overlap up to the validated `maxParallelSubCalls` config (default 10; `1` restores serial dispatch), and an exclusive-classified call drains the pool, runs alone, and bars later calls — given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A success returns the final canonical value after policy; a failure reaches the worker as one message and becomes `ToolCallError(toolName, message)`. Each started sub-call logs a `tool/code-dispatch-start` event (deterministic id `<parent>:code:<n>`, numbered by submission) at pipeline entry and settles with one `tool/code-dispatch` event carrying the complete model-facing `content`/`isError` outcome (the `tool/result` vocabulary, so UIs render sub-calls through the native path — the pair's `time` fields carry per-sub-call timing); a queued call abandoned by run settlement logs neither. `deriveMessages()` surfaces neither event nor persists the canonical value. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails.
|
||||
- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from.
|
||||
- **Result boundary**: intermediate binding values cross the worker boundary whole and have no per-binding byte cap. `run_code` returns canonical `{ logs: string[], result?: JsonValue }`; strings render raw, every other present JSON root renders through a stack-safe pretty JSON traversal whose total indentation is capped at ten characters (deeper subtrees stay compact), `null` remains explicit, and absent `result` means the program returned `undefined`. The worker's configurable `maxOutputBytes` (default 64 MiB) applies only to the combined serialized outer log-array, completion-value, or failure-message payloads; fixed result-envelope syntax and presentation whitespace are outside that ledger. Invalid and over-limit completions fail explicitly, and only this outer result is eligible for ordinary spill.
|
||||
@@ -145,7 +145,7 @@ Prefix-stable while visible definitions and their order are unchanged. Registrat
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact `declare const tools` block. `both` exposes normal schemas and this Code Mode surface.
|
||||
Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language (the TypeScript `declare const tools` block, or the Python `tools` declaration). `both` exposes normal schemas and this Code Mode surface. The instructions and SDK block match the loaded runtime's language; the TypeScript flavor (via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)) is shown below, and the Python flavor (for any runtime reporting `language: 'python'`) is the same shape with Python syntax (`await tools.name(args)`, subscript access for exotic names, `print(...)` and top-level `return`).
|
||||
|
||||
##### Code Mode SDK instructions
|
||||
|
||||
@@ -190,6 +190,6 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
- **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md).
|
||||
- **Caller-defined subagent and workflow structured outputs remain object-rooted** — this is a consumer-level guard; the shared schema vocabulary and tool outputs support every JSON root.
|
||||
- **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper.
|
||||
- **Code Mode is TypeScript-only and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language === 'typescript'`; scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only.
|
||||
- **Code Mode's SDK language follows the one loaded runtime and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language` has a registered SDK renderer (`typescript` via the worker backend, `python` for any runtime reporting that language); scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only, and a single runtime fixes the language service-wide (the [language-dispatch Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md) owns the lookup, and why the registry reads the loaded runtime instead of carrying a language field of its own).
|
||||
- **Code Mode intermediate values are execution-local and unbounded by bytes** — the canonical typed values cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. The durable log copy of each sub-call IS bounded: the `tools/code-dispatch-log` waterfall lets the spill policy replace an oversized `tool/code-dispatch` content with a preview + locator ([rationale](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md)).
|
||||
- **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP (cross-call state would be invisible to the log); see [the Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md).
|
||||
|
||||
@@ -13,7 +13,7 @@ tools:
|
||||
mode: native # native (default) | code | both
|
||||
```
|
||||
|
||||
`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输和生成的 `tools:sdk` 段;`both` 同时贡献两种形式。不能注册、遮蔽、限制或移除该保留传输。非原生模式要求存在 TypeScript `ctx.codeRuntime`;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。
|
||||
`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输和生成的 `tools:sdk` 段;`both` 同时贡献两种形式。不能注册、遮蔽、限制或移除该保留传输。非原生模式要求所加载 `ctx.codeRuntime` 的 `language` 有已注册的 SDK 渲染器——TypeScript 经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md) 交付;Python 渲染器内置,驱动任何报告 `language: 'python'` 的运行时(第一方 `dsh-code-runtime-python` 后端另行交付)。没有渲染器的运行时语言会让提示词组装响亮失败;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。
|
||||
|
||||
### 公开 API
|
||||
|
||||
@@ -114,9 +114,9 @@ ctx.tools.register(defineTool({
|
||||
|
||||
### Code Mode
|
||||
|
||||
在 `code` 或 `both` 模式下,注册表为当前作用域公开保留的 `run_code` 传输和确定性的 TypeScript SDK;只有程序的外层日志与返回值会重新进入模型上下文。SDK 为每个可见工具声明精确的 `ToolArgsMap` 和 `ToolOutputMap` 条目,每个绑定都会解析为该工具的规范 JSON 值。每个无损 JSON 绑定调用都会在原生调度契约下重新进入完整工具流水线(并发安全的调用最多可重叠 `maxParallelSubCalls` 个;独占调用单独运行并构成排序屏障),并在日志中与外层调用建立关联。拒绝及其他失败结果会以程序实际可见的 `ToolCallError` 形式拒绝,且只携带 `toolName` 和 `message`;Native 内容和内部错误码留在 Code 契约之外。普通副作用不会回滚,子调用的 `additionalContexts` 会通过父结果延迟,以保持调用/结果相邻。运行结算会中止并排空尚未完成的绑定;运行时失败以 `CodeRunFailedError` 形式出现。参见 [Code Mode 基础](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)、[类型化返回契约](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md)和[代码运行时 seam](../../code-runtime/README.md)。可以运行 `pnpm run demo:code-mode` 试用。
|
||||
在 `code` 或 `both` 模式下,注册表为当前作用域公开保留的 `run_code` 传输和按所加载运行时语言生成的确定性 SDK——注册表按 `ctx.codeRuntime.language` 选择渲染器(`typescript` → 下方的 TypeScript SDK,`python` → Python SDK)。只有程序的外层日志与返回值会重新进入模型上下文。SDK 为每个可见工具声明精确的参数与规范输出类型(TypeScript 为 `ToolArgsMap`/`ToolOutputMap`,Python 为具名 `TypedDict`),每个绑定都会解析为该工具的规范 JSON 值。每个无损 JSON 绑定调用都会在原生调度契约下重新进入完整工具流水线(并发安全的调用最多可重叠 `maxParallelSubCalls` 个;独占调用单独运行并构成排序屏障),并在日志中与外层调用建立关联。拒绝及其他失败结果会以程序实际可见的 `ToolCallError` 形式拒绝,且只携带 `toolName` 和 `message`;Native 内容和内部错误码留在 Code 契约之外。普通副作用不会回滚,子调用的 `additionalContexts` 会通过父结果延迟,以保持调用/结果相邻。运行结算会中止并排空尚未完成的绑定;运行时失败以 `CodeRunFailedError` 形式出现。参见 [Code Mode 基础](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)、[类型化返回契约](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md)和[代码运行时 seam](../../code-runtime/README.md)。可以运行 `pnpm run demo:code-mode` 试用。
|
||||
|
||||
- **SDK 段**(`tools:sdk`,顺序 150):一个惰性提示词段,每次组装时都会重新生成 `JsonValue`、精确的 `ToolArgsMap` / `ToolOutputMap`、`ToolName`、`ToolCallError` 声明、面向调用作用域可见最终能力的映射 `tools` 命名空间(特殊名称使用带引号的键),以及固定用法说明。其输出具有确定性:工具按字典序排列;工具集合不变时,文本逐字节相同(有利于前缀 cache)。导出的代码生成器 `jsonSchemaToTs` 会处理统一 schema 的每种构造,并将不受支持的原始构造降级为 `unknown`,绝不会在提示词组装期间抛出。
|
||||
- **SDK 段**(`tools:sdk`,顺序 150):一个惰性提示词段,每次组装时都会重新生成与所加载运行时语言相符的 SDK 文本。TypeScript 形态发出 `JsonValue`、精确的 `ToolArgsMap` / `ToolOutputMap`、`ToolName`、`ToolCallError` 声明、面向调用作用域可见最终能力的映射 `tools` 命名空间(特殊名称使用带引号的键),以及固定用法说明;Python 形态(`ctx.codeRuntime.language === 'python'`)发出等价的具名 `TypedDict` 与一个带相同用法说明的 `tools` 对象。其输出具有确定性:工具按字典序排列;工具集合不变时,文本逐字节相同(有利于前缀 cache)。两个代码生成器都已导出,且绝不会在提示词组装期间抛出:`jsonSchemaToTs` 处理统一 schema 的每种构造并将不受支持的原始构造降级为 `unknown`;`jsonSchemaToPy` 同理,降级为 `Any`(当某字段名不是合法的 `TypedDict` 属性时,或在 SDK 渲染之外被调用时——`TypedDict` 声明所需的命名上下文由该渲染提供——整个对象降级为 `dict[str, Any]`)。
|
||||
- **分发桥接层**(`run_code` 的 execute):每个绑定调用都会在分发前快照为无损 JSON(`undefined`、`BigInt`、循环、稀疏数组、`-0` 和特殊对象会使该次调用被拒绝),经由每次运行独有、复用原生并发契约的池调度——调用严格按提交顺序启动,连续的 `isConcurrencySafe` 调用最多可重叠经校验的 `maxParallelSubCalls` 配置个(默认 10;设为 `1` 即恢复串行分发),被分类为独占的调用先排空池、单独运行并阻挡其后的调用——以外层执行的不透明 token 作为 `parent`,并经过完整的 pre-execute → guards → execute → post-execute → result 流水线。成功会返回策略处理后的最终规范值;失败以一条消息到达 worker,并成为 `ToolCallError(toolName, message)`。每个已启动的子调用在进入流水线时记录一条 `tool/code-dispatch-start` 事件(确定性 id `<parent>:code:<n>`,按提交顺序编号),并以一条携带完整模型可见 `content`/`isError` 结果的 `tool/code-dispatch` 事件完结(采用 `tool/result` 词汇,因此 UI 会沿原生路径呈现子调用——这对事件的 `time` 字段承载每个子调用的计时);因 run 结算而被放弃的排队调用两者都不记录。`deriveMessages()` 既不公开这两个事件,也不持久化规范值。token 关联让以提交为语义的观察器能够把内部成功延迟到最终 `run_code` 结果,而无需公开实时外层执行;普通工具副作用不会回滚。每个子调用的 `additionalContexts` 条目都会按分发顺序通过外层 `ToolRunContext` 延迟;循环只在父级 `run_code` 结果之后追加这些上下文,从而保持相邻关系,并且即使程序后来失败,也会保留各自的来源/元数据。
|
||||
- **结算纪律**:桥接层拥有一个运行作用域的中止机制;该中止会跟随传入的外层信号,并在运行因任何原因结算时触发,因此预算耗尽会中止正在运行的子工具,而不会将其遗留。桥接层随后会在返回之前排空队列,使每个 `tool/code-dispatch` 都落在仍打开的轮次内。失败的运行会抛出 `CodeRunFailedError`(`code: 'CODE_RUN_FAILED'`,message = 失败类型 + 已捕获日志),流水线会将其转换为模型可据以自我修正的结构化 `isError`。
|
||||
- **结果边界**:中间绑定值会完整跨越 worker 边界,且没有逐绑定字节上限。`run_code` 返回规范的 `{ logs: string[], result?: JsonValue }`;字符串原样呈现,其他所有存在的 JSON 根都通过栈安全的美化 JSON 遍历呈现,总缩进最多为 10 个字符(更深的子树保持紧凑),`null` 保持显式,而缺少 `result` 表示程序返回 `undefined`。worker 可配置的 `maxOutputBytes`(默认 64 MiB)只应用于组合序列化后的外层日志数组、完成值或失败消息载荷;固定的结果 envelope 语法和呈现空白不计入该账本。无效和超限的完成会明确失败,只有此外层结果可以使用普通 spill。
|
||||
@@ -145,7 +145,7 @@ agent loop 将连续的 `parallel` 调用归入有界滚动池,并把每个 `e
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及生成的精确 `declare const tools` 块。`both` 会同时公开普通 schema 与此 Code Mode 接口。
|
||||
Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块(TypeScript 的 `declare const tools` 块,或 Python 的 `tools` 声明)。`both` 会同时公开普通 schema 与此 Code Mode 接口。说明与 SDK 块随所加载运行时的语言切换;下方展示 TypeScript 风格(经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)),Python 风格(用于任何报告 `language: 'python'` 的运行时)形状相同,只是换成 Python 语法(`await tools.name(args)`、特殊名称用下标访问、`print(...)` 与顶层 `return`)。
|
||||
|
||||
##### Code Mode SDK 说明
|
||||
|
||||
@@ -190,6 +190,6 @@ The available tools:
|
||||
- **`tools/pre-execute` 有意不允许改写 `exec.arguments`**:否则日志记录和呈现的参数会与实际运行内容失去同步;改写设计记录在[拟议的 Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)中。
|
||||
- **调用方定义的 subagent 与工作流结构化输出仍要求对象根**:这是消费方层面的守卫;共享 schema 词汇和工具输出支持任意 JSON 根。
|
||||
- **定义上的 `timeoutMs` 仅为声明**:注册表绝不会强制执行截止时间;要强制执行,必须使用 `@deepseek-ai/dsh-timeout-policy` 包装层。
|
||||
- **Code Mode 只支持 TypeScript,且呈现模式在服务内统一**:`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language === 'typescript'`;作用域限制/遮蔽仍会选择每个 agent 的可见绑定,但不能让一个工具仅使用 Native,而另一个仅使用 Code。
|
||||
- **Code Mode 的 SDK 语言跟随唯一加载的运行时,且呈现模式在服务内统一**:`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language` 有已注册的 SDK 渲染器(`typescript` 经 worker 后端,`python` 用于任何报告该语言的运行时);作用域限制/遮蔽仍会选择每个 agent 的可见绑定,但不能让一个工具仅使用 Native、另一个仅使用 Code,且单个运行时把语言固定为服务级([语言分发 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md) 负责这次查表,以及注册表为何读取所加载的运行时而不自带 language 字段)。
|
||||
- **Code Mode 中间值只存在于执行局部,且没有字节上限**:这些规范的类型化值无法从会话回放重建,并可能耗尽进程或 worker 内存;只有外层 `run_code` 输出受 worker 可配置的硬上限约束。每个子调用的持久日志副本则确实有上限:`tools/code-dispatch-log` waterfall 允许 spill 策略把过大的 `tool/code-dispatch` 内容替换为预览加定位符([原理](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md))。
|
||||
- **每次运行都会获得全新的 `run_code` 状态**:MVP 不采用持久 REPL 风格内核(跨调用状态不会出现在日志中);参见 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { CodeBindingFunction, CodeRunResult, CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import { defineTool } from './schema.ts'
|
||||
import { defineTool, parameterSchemaSpecToJsonSchema } from './schema.ts'
|
||||
import { TOOL_REGISTRY_SCHEDULER } from './index.ts'
|
||||
import type { CodeDispatchLog, ToolDefinition, ToolExecutionResult, ToolRegistry, ToolRunContext } from './index.ts'
|
||||
|
||||
@@ -56,6 +56,111 @@ export const RUN_CODE_NAME = 'run_code'
|
||||
/** The `tools:sdk` section order: inside the 100–199 tool-guidance band, after per-tool guidance sections. */
|
||||
export const SDK_SECTION_ORDER = 150
|
||||
|
||||
/**
|
||||
* The language-specific `run_code` schema text: the tool `description` and its
|
||||
* `code` parameter description, kept together so a language's two model-facing
|
||||
* strings share one source of truth. Keyed by `CodeRuntime.language`, mirroring
|
||||
* `SDK_RENDERERS` in {@link ./index.ts}. The emitted flavor MUST match the
|
||||
* semantics the same language's SDK instructions promise, so the model never
|
||||
* receives a TypeScript-shaped schema beside a Python SDK (or vice versa).
|
||||
*/
|
||||
interface RunCodeFlavor {
|
||||
/** The tool `description` the model sees for this language. */
|
||||
readonly description: string
|
||||
/** The `code` parameter's description for this language. */
|
||||
readonly codeDescription: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The TypeScript flavor: the historical default, and the fallback for a schema
|
||||
* read with no runtime mounted ({@link resolveFlavor} owns which readers reach
|
||||
* that). A real assembly always resolves a runtime first, so the model never
|
||||
* sees this fallback outside its own language.
|
||||
*/
|
||||
const TYPESCRIPT_FLAVOR: RunCodeFlavor = {
|
||||
description:
|
||||
'Execute a TypeScript program against the available tools. Write the BODY of an '
|
||||
+ 'async function (erasable syntax only; top-level `await` and `return` work) and '
|
||||
+ 'call tools as `await tools.name(args)` per the declarations in the system prompt. '
|
||||
+ 'Only what you print or return comes back — curate it.',
|
||||
codeDescription: 'The program: the body of an async TypeScript function.',
|
||||
}
|
||||
|
||||
/**
|
||||
* The Python flavor: the body of an async function, top-level `await` and
|
||||
* `return`, answer via `print` and/or the returned value, matching
|
||||
* {@link ./py-types.ts}'s SDK instructions.
|
||||
*/
|
||||
const PYTHON_FLAVOR: RunCodeFlavor = {
|
||||
description:
|
||||
'Execute a Python program against the available tools. Write the BODY of an '
|
||||
+ 'async function (top-level `await` and `return` work) and call tools as '
|
||||
+ '`await tools.name(args)` per the declarations in the system prompt. Answer '
|
||||
+ 'with `print(...)` and/or `return <value>` — only that comes back, so curate it.',
|
||||
codeDescription: 'The program: the body of an async Python function.',
|
||||
}
|
||||
|
||||
/**
|
||||
* The languages Code Mode ships a presentation for. Both per-language tables —
|
||||
* {@link RUN_CODE_FLAVORS} here and `SDK_RENDERERS` in {@link ./index.ts} — are
|
||||
* checked against this union with `satisfies`, so a language added to one and
|
||||
* not the other fails `typecheck` instead of waiting for a runtime that reports
|
||||
* it. The tables stay declared `Record<string, …>` because `CodeRuntime.language`
|
||||
* is an unconstrained `string`: this union pins what the harness ships, while the
|
||||
* `Object.hasOwn` guards reject what a mounted runtime may report.
|
||||
*/
|
||||
export type CodeSdkLanguage = 'typescript' | 'python'
|
||||
|
||||
/** Per-language `run_code` schema flavors (see {@link RunCodeFlavor}); one entry per {@link CodeSdkLanguage}. */
|
||||
const RUN_CODE_FLAVORS: Record<string, RunCodeFlavor> = {
|
||||
typescript: TYPESCRIPT_FLAVOR,
|
||||
python: PYTHON_FLAVOR,
|
||||
} satisfies Record<CodeSdkLanguage, RunCodeFlavor>
|
||||
|
||||
/**
|
||||
* The `description` parameter's model-facing description: language-independent
|
||||
* (the UI label contract is the same for every runtime), shared between the
|
||||
* static spec and the language-aware `parameters` getter so the two emissions
|
||||
* can never drift.
|
||||
*/
|
||||
const RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION
|
||||
= 'Clear, concise description of what this program does in active voice, '
|
||||
+ '5-10 words (shown in the UI). Examples: "Count TODO markers across packages"; '
|
||||
+ '"Read failing test and its fixture"; "Rename config key in every cordis.yml".'
|
||||
|
||||
/**
|
||||
* Resolve the {@link RunCodeFlavor} for the loaded runtime's language, read at
|
||||
* schema-emission time so the model-visible `run_code` schema always matches
|
||||
* the SDK section's language. `peekRuntime` returns `undefined` only when no
|
||||
* runtime is mounted, which reaches this function through definition readers
|
||||
* and `schemas()` — the doc-catalog harvest is the only shipped one, and none
|
||||
* of them feeds a model, because `wireSchemas` calls `requireCodeRuntime`
|
||||
* before projecting — so that path degrades to {@link TYPESCRIPT_FLAVOR}. A
|
||||
* mounted runtime whose language has no flavor entry fails loud, exactly as
|
||||
* `requireCodeRuntime` rejects it at assembly. Keeping this table in step with
|
||||
* `SDK_RENDERERS` is the compiler's job ({@link CodeSdkLanguage}); what this
|
||||
* guard owns is the runtime-supplied language neither table knows, which never
|
||||
* yields a wrong-language schema for a real runtime.
|
||||
*/
|
||||
function resolveFlavor(peekRuntime: () => CodeRuntime | undefined): RunCodeFlavor {
|
||||
const runtime = peekRuntime()
|
||||
if (runtime === undefined) {
|
||||
// No runtime mounted: reached by definition readers and `schemas()`, of
|
||||
// which the doc-catalog harvest is the only shipped one. None feeds a
|
||||
// model — `wireSchemas` calls `requireCodeRuntime` before projecting, so
|
||||
// the assembly path never arrives here. Degrade to the TS default.
|
||||
return TYPESCRIPT_FLAVOR
|
||||
}
|
||||
// Own-property read: a language like `toString`/`constructor` would otherwise
|
||||
// resolve an inherited Object.prototype member as a flavor.
|
||||
const flavor = RUN_CODE_FLAVORS[runtime.language]
|
||||
if (!Object.hasOwn(RUN_CODE_FLAVORS, runtime.language) || flavor === undefined) {
|
||||
const known = Object.keys(RUN_CODE_FLAVORS).map(name => JSON.stringify(name)).join(', ')
|
||||
throw new Error(`dsh-tools: no run_code schema flavor registered for runtime language ${JSON.stringify(runtime.language)} (known: ${known})`)
|
||||
}
|
||||
return flavor
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by `run_code` when the program run itself failed — a program
|
||||
* exception, a budget expiry, an abort, or substrate death. Extends
|
||||
@@ -194,6 +299,13 @@ type RunCodeOutput = { logs: string[]; result?: JsonValue }
|
||||
export interface RunCodeBridgeOptions {
|
||||
/** Resolves `ctx.codeRuntime` or throws the loud misconfiguration error (shared with the registry's assembly-time checks). */
|
||||
requireRuntime: () => CodeRuntime
|
||||
/**
|
||||
* Reads `ctx.codeRuntime` without throwing: `undefined` when none is mounted.
|
||||
* Lets schema emission tell "no runtime" (degrade to TS; the readers that
|
||||
* reach it are {@link resolveFlavor}'s) apart from "unknown language" (fail
|
||||
* loud).
|
||||
*/
|
||||
peekRuntime: () => CodeRuntime | undefined
|
||||
/** The run's overlap cap for parallel-classified sub-calls (the registry passes its validated `maxParallelSubCalls`). */
|
||||
maxParallel: number
|
||||
/** Runs the contained `tools/code-dispatch-log` waterfall over one settled sub-dispatch (the registry's private invoker). */
|
||||
@@ -212,22 +324,22 @@ export interface RunCodeBridgeOptions {
|
||||
* @returns the registry-ready definition.
|
||||
*/
|
||||
export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridgeOptions): ToolDefinition {
|
||||
const { requireRuntime, maxParallel, shapeDispatchLog } = options
|
||||
return defineTool({
|
||||
const { requireRuntime, peekRuntime, maxParallel, shapeDispatchLog } = options
|
||||
const definition = defineTool({
|
||||
name: RUN_CODE_NAME,
|
||||
description:
|
||||
'Execute a TypeScript program against the available tools. Write the BODY of an '
|
||||
+ 'async function (erasable syntax only; top-level `await` and `return` work) and '
|
||||
+ 'call tools as `await tools.name(args)` per the declarations in the system prompt. '
|
||||
+ 'Only what you print or return comes back — curate it.',
|
||||
// The description and `code` parameter description are placeholders here:
|
||||
// the language-aware getters installed below replace both, resolving the
|
||||
// loaded runtime's flavor at schema-emission time so the schema the MODEL
|
||||
// sees matches the SDK section's language. Argument VALIDATION still keys
|
||||
// off this static spec (defineTool closes over it), which is language-
|
||||
// independent (one required string `code`).
|
||||
description: TYPESCRIPT_FLAVOR.description,
|
||||
parameters: {
|
||||
code: { type: 'string', required: true, description: 'The program: the body of an async TypeScript function.' },
|
||||
code: { type: 'string', required: true, description: TYPESCRIPT_FLAVOR.codeDescription },
|
||||
description: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Clear, concise description of what this program does in active voice, '
|
||||
+ '5-10 words (shown in the UI). Examples: "Count TODO markers across packages"; '
|
||||
+ '"Read failing test and its fixture"; "Rename config key in every cordis.yml".',
|
||||
description: RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION,
|
||||
},
|
||||
},
|
||||
output: {
|
||||
@@ -569,4 +681,22 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge
|
||||
// title and reads durable result content without duplicating a large raw
|
||||
// result into the host view payload.
|
||||
})
|
||||
// Resolve the language flavor lazily, at the moment the registry projects the
|
||||
// schema (`schemaOf` destructures `description`/`parameters`). The definition
|
||||
// is minted once at registration, before a runtime is known; deferring here
|
||||
// is the least invasive point that still emits the loaded runtime's language.
|
||||
Object.defineProperty(definition, 'description', {
|
||||
enumerable: true,
|
||||
get: () => resolveFlavor(peekRuntime).description,
|
||||
})
|
||||
Object.defineProperty(definition, 'parameters', {
|
||||
enumerable: true,
|
||||
// Recompile through the same spec→schema projection defineTool used, so
|
||||
// the emitted shape can never drift from the validated one.
|
||||
get: () => parameterSchemaSpecToJsonSchema({
|
||||
code: { type: 'string', required: true, description: resolveFlavor(peekRuntime).codeDescription },
|
||||
description: { type: 'string', required: true, description: RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION },
|
||||
}) as unknown as Record<string, unknown>,
|
||||
})
|
||||
return definition
|
||||
}
|
||||
|
||||
@@ -22,8 +22,31 @@ import type { ToolCallView, ToolResultView } from './presentation.ts'
|
||||
import { assertSupportedJsonSchema, validateJsonSchemaValue } from './json-schema.ts'
|
||||
import type { JsonSchemaNode } from './json-schema.ts'
|
||||
import { createRunCodeTool, RUN_CODE_NAME, SDK_SECTION_ORDER } from './code-mode.ts'
|
||||
import type { CodeSdkLanguage } from './code-mode.ts'
|
||||
import { renderToolsSdk } from './ts-types.ts'
|
||||
import type { ToolSdkSchema } from './ts-types.ts'
|
||||
import { renderToolsSdkPy } from './py-types.ts'
|
||||
|
||||
/**
|
||||
* Language → SDK-section renderer. The registry looks up the loaded
|
||||
* `ctx.codeRuntime.language` in this table when assembling the `tools:sdk`
|
||||
* section under a non-native mode; a runtime whose language is not a key
|
||||
* fails the assembly loudly (same idiom as `toolOrder` violations). Adding a
|
||||
* new backend language is three parallel edits — a {@link CodeSdkLanguage}
|
||||
* member, an entry here, and a `RUN_CODE_FLAVORS` entry in `code-mode.ts` for
|
||||
* its `run_code` schema strings — plus the renderer function this table points
|
||||
* at. The `satisfies` clause pins this table's key set to that union, which
|
||||
* the flavor table is checked against too, so any of the three left out is a
|
||||
* typecheck failure. What no check reaches is the prose that names the values
|
||||
* instead of deriving them: the seam's `dsh-code-runtime` README pair, its
|
||||
* `CodeRuntime.language` JSDoc, and `docs/core-data-structures/code-runtime.md`
|
||||
* with its zh pair, plus this package's own README pair and the
|
||||
* {@link Config.mode} JSDoc.
|
||||
*/
|
||||
const SDK_RENDERERS: Record<string, (schemas: ToolSdkSchema[]) => string> = {
|
||||
typescript: renderToolsSdk,
|
||||
python: renderToolsSdkPy,
|
||||
} satisfies Record<CodeSdkLanguage, (schemas: ToolSdkSchema[]) => string>
|
||||
|
||||
export {
|
||||
defineTool,
|
||||
@@ -65,6 +88,7 @@ export type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
|
||||
export { CodeRunFailedError, RUN_CODE_NAME } from './code-mode.ts'
|
||||
export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts'
|
||||
export { jsonSchemaToPy, renderToolsSdkPy } from './py-types.ts'
|
||||
export { defineContentToolFixture, type ContentToolFixtureOptions } from './testing.ts'
|
||||
|
||||
// The render-intent vocabulary a tool declares via `presentCall`/`presentResult`
|
||||
@@ -593,8 +617,9 @@ export interface Config {
|
||||
/**
|
||||
* Model presentation. `native` (default) sends every visible schema; `code`
|
||||
* sends only `run_code` plus a generated SDK prompt; `both` sends both forms.
|
||||
* Code modes require a TypeScript runtime and fail prompt assembly when it is
|
||||
* absent or mismatched. Under `code`, native names in `toolOrder` are invalid.
|
||||
* Code modes require a `ctx.codeRuntime` whose `language` has a registered
|
||||
* SDK renderer (TypeScript or Python) and fail prompt assembly when it is
|
||||
* absent or has no renderer. Under `code`, native names in `toolOrder` are invalid.
|
||||
*/
|
||||
mode?: ToolPresentationMode
|
||||
/**
|
||||
@@ -757,6 +782,7 @@ export class ToolRegistry extends Service {
|
||||
? undefined
|
||||
: createRunCodeTool(this, {
|
||||
requireRuntime: () => this.requireCodeRuntime(),
|
||||
peekRuntime: () => this.ctx.get('codeRuntime'),
|
||||
maxParallel: resolveMaxParallelSubCalls(config.maxParallelSubCalls),
|
||||
shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch),
|
||||
})
|
||||
@@ -765,10 +791,21 @@ export class ToolRegistry extends Service {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tools:sdk',
|
||||
order: SDK_SECTION_ORDER,
|
||||
// Regenerate from the calling scope's visible tools in stable order.
|
||||
// Regenerate from the calling scope's visible tools in stable order,
|
||||
// picking the renderer that matches the loaded runtime's language.
|
||||
// `requireCodeRuntime` already validated the language is in the table,
|
||||
// so the guard below is defense-in-depth against a caller that bypassed
|
||||
// it (impossible under normal composition).
|
||||
text: (context) => {
|
||||
this.requireCodeRuntime()
|
||||
return renderToolsSdk(this.sdkSchemas(context.scope))
|
||||
const runtime = this.requireCodeRuntime()
|
||||
// Own-property read: a language like `toString`/`constructor` would
|
||||
// otherwise resolve an inherited Object.prototype member as a renderer.
|
||||
const render = SDK_RENDERERS[runtime.language]
|
||||
/* v8 ignore next 3 -- requireCodeRuntime rejects an unknown language before this ever runs. */
|
||||
if (!Object.hasOwn(SDK_RENDERERS, runtime.language) || render === undefined) {
|
||||
throw new Error(`dsh-tools: no SDK renderer registered for runtime language ${JSON.stringify(runtime.language)} (known: ${Object.keys(SDK_RENDERERS).map(name => JSON.stringify(name)).join(', ')})`)
|
||||
}
|
||||
return render(this.sdkSchemas(context.scope))
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -780,11 +817,17 @@ export class ToolRegistry extends Service {
|
||||
*/
|
||||
private wireSchemas(scope?: ScopeKey): ToolProviderResult {
|
||||
const view = this.view(scope)
|
||||
const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
|
||||
if (this.mode === 'native') {
|
||||
const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
|
||||
return { schemas, knownNames: [...view.knownNames] }
|
||||
}
|
||||
// Validate the runtime language BEFORE projecting schemas: schemaOf reads
|
||||
// run_code's language-aware description/parameters getters, whose own
|
||||
// flavor-table guard would otherwise surface first. This keeps the
|
||||
// renderer-table rejection the canonical assembly-time error for a
|
||||
// language with no SDK renderer.
|
||||
this.requireCodeRuntime()
|
||||
const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
|
||||
if (this.mode === 'code') {
|
||||
return {
|
||||
schemas: schemas.filter(schema => schema.name === RUN_CODE_NAME),
|
||||
@@ -801,14 +844,23 @@ export class ToolRegistry extends Service {
|
||||
* behind it — hostage to a code runtime existing even under `mode:
|
||||
* 'native'` (the loop's optional-backend idiom, same as
|
||||
* `sessionPersistence`).
|
||||
*
|
||||
* Assembly and `run_code` execution read separately, so the language is not
|
||||
* bound to a request. Harmless while one published backend exists — both
|
||||
* reads return the same flavor — but a reload that swapped in a second
|
||||
* language between them would hand a program written against one SDK to the
|
||||
* other. Binding it belongs to the PR that publishes that backend, which is
|
||||
* also the first point it can be tested; recorded in the
|
||||
* [language-dispatch note](../../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md).
|
||||
*/
|
||||
private requireCodeRuntime(): CodeRuntime {
|
||||
const runtime = this.ctx.get('codeRuntime')
|
||||
if (!runtime) {
|
||||
throw new Error(`dsh-tools: mode "${this.mode}" requires a code runtime — load a ctx.codeRuntime implementation (e.g. @deepseek-ai/dsh-code-runtime-worker) or set tools mode to "native"`)
|
||||
}
|
||||
if (runtime.language !== 'typescript') {
|
||||
throw new Error(`dsh-tools: mode "${this.mode}" generates a TypeScript SDK, but the loaded code runtime's language is "${runtime.language}"`)
|
||||
if (!Object.hasOwn(SDK_RENDERERS, runtime.language)) {
|
||||
const known = Object.keys(SDK_RENDERERS).map(name => JSON.stringify(name)).join(', ')
|
||||
throw new Error(`dsh-tools: no SDK renderer registered for runtime language ${JSON.stringify(runtime.language)} (known: ${known})`)
|
||||
}
|
||||
return runtime
|
||||
}
|
||||
|
||||
818
packages/core/tools/src/py-types.ts
Normal file
818
packages/core/tools/src/py-types.ts
Normal file
@@ -0,0 +1,818 @@
|
||||
/**
|
||||
* Code Mode codegen — Python flavor. The pure projection from registered tool schemas to the
|
||||
* Python SDK text the model programs against under `runtime.language === 'python'`. Sibling of
|
||||
* {@link ./ts-types.ts | ts-types.ts}; the two files are two projections of the same registry
|
||||
* store, keyed by the loaded {@link @deepseek-ai/dsh-code-runtime#CodeRuntime.language | code
|
||||
* runtime's language}.
|
||||
*
|
||||
* Under `mode: 'code'` the native tool schemas are omitted from the request, so this generated
|
||||
* SDK is the model's ONLY source for each tool's argument names, required fields, types,
|
||||
* descriptions, and canonical output shapes; under `mode: 'both'` the native schemas ship
|
||||
* alongside it and it is one of two. Object-shaped arguments and outputs therefore render as one
|
||||
* named `TypedDict` per tool (and per nested object), not an opaque `dict[str, Any]`, so the
|
||||
* shape survives into the program under the mode that has nothing else to carry it.
|
||||
* @module @deepseek-ai/dsh-tools/src/py-types
|
||||
*/
|
||||
|
||||
import { assertSupportedJsonSchema } from './json-schema.ts'
|
||||
import type { JsonSchemaNode, JsonSchemaScalar } from './json-schema.ts'
|
||||
import type { ToolSdkSchema } from './ts-types.ts'
|
||||
|
||||
/**
|
||||
* The reference grammar's `xid_start xid_continue*` — the set
|
||||
* `str.isidentifier()` accepts on a CPython whose Unicode tables match the
|
||||
* engine's. See {@link isBareIdentifier} for what a version skew does.
|
||||
*/
|
||||
const IDENTIFIER = /^[\p{XID_Start}_]\p{XID_Continue}*$/u
|
||||
|
||||
/**
|
||||
* Whether a name can be emitted as a bare Python identifier rather than
|
||||
* routed to the subscript/`dict[str, Any]` path.
|
||||
*
|
||||
* Python identifiers are not ASCII: `路径` is as legal a field name as `path`,
|
||||
* and rejecting it would degrade the whole enclosing object, dropping every
|
||||
* field's name, requiredness, and type — information whose only source under
|
||||
* `mode: 'code'` is this generated text.
|
||||
*
|
||||
* NFKC stability is a second and separate condition, because CPython
|
||||
* normalizes identifiers at compile time while JSON keys are compared as
|
||||
* written: `field` would be declared and reachable as `field`, so the SDK would
|
||||
* advertise a key under a spelling the harness never accepts, and two keys
|
||||
* that normalize together would collapse into one declaration. Those names
|
||||
* take the subscript path, which carries their exact bytes.
|
||||
*
|
||||
* `IDENTIFIER`'s equivalence to `str.isidentifier()` was measured across 21
|
||||
* samples with zero divergence, on Node 22.23.1 against CPython 3.9.6 — every
|
||||
* sample sits inside the two versions' shared tables, and the skew characters
|
||||
* below are exactly where that pair diverges. The predicate as a whole is
|
||||
* deliberately stricter than `isidentifier()`, which does not test NFKC
|
||||
* stability: `'field'.isidentifier()` is True and this returns false.
|
||||
*
|
||||
* Both conditions are evaluated against the ENGINE's Unicode tables, and the
|
||||
* two sides are versioned independently — `\p{XID_Start}`/`\p{XID_Continue}`
|
||||
* follow the running engine (Node 22.23.1 reports Unicode 17.0) while CPython
|
||||
* follows its own (3.9.6 reports 13.0.0). The skew is not symmetric. A CPython
|
||||
* older than the engine is the dangerous direction: a character added to either
|
||||
* property since its tables (U+10570 Vithkuqi and U+1E290 Toto, 14.0; U+1E4D0
|
||||
* Nag Mundari, 15.0; U+1C89 Cyrillic TJE, 16.0 — ages per `DerivedAge.txt`; all
|
||||
* four are NFKC-stable and accepted here, and all four are `Cn` on that 3.9.6,
|
||||
* which rejects them) is emitted bare and its tokenizer refuses the character,
|
||||
* taking the whole SDK block down — the same parseability invariant
|
||||
* {@link UNPRINTABLE}, {@link LONE_SURROGATE} and {@link MAX_LIST_NESTING}
|
||||
* exist for. Both properties carry it: a character added only to `XID_Continue`
|
||||
* passes the trailing `\p{XID_Continue}*` in a tail position and fails the same
|
||||
* way — U+200C ZWNJ and U+200D ZWJ are that case, gaining `XID_Continue` in UCD
|
||||
* 15.1 and absent from it in 13.0.0, 14.0.0 and 15.0.0, so `a\u{200C}b` is
|
||||
* emitted bare here while `isidentifier()` is False on 3.9.6 and on 3.12.13
|
||||
* (15.0.0). A CPython newer than the engine only routes a legal name to the
|
||||
* subscript/`dict[str, Any]` path: less readable, still correct. The NFKC
|
||||
* condition reduces to the same skew, since normalization stability guarantees
|
||||
* an assigned character's normalization never changes afterwards.
|
||||
*
|
||||
* This predicate is not the only reader of engine tables. {@link camelCase}
|
||||
* reads them at three further points — its split set, its head test, and its
|
||||
* `toUpperCase()` case mapping — and this predicate's verdict gates none of
|
||||
* them: a class name derived there reaches emitted text whenever any object
|
||||
* shape in the tool's schema declares a `TypedDict`, including for a tool this
|
||||
* predicate rejected. A tool named `zz-\u{1E4D0}x` with such parameters never
|
||||
* reaches the skew here (the `-` rejects it outright) yet emits `class
|
||||
* Zz\u{1E4D0}xArgs`, which that same 3.9.6 refuses — Nag Mundari arrived two
|
||||
* releases after its tables. The case mapping is a separate table rather than
|
||||
* an XID membership test, and it fails on names both conditions above accept:
|
||||
* `\u{019B}` is XID_Start and NFKC-stable, so this predicate accepts it and
|
||||
* `async def \u{019B}` compiles on 3.9.6, but Node uppercases it to
|
||||
* `\u{A7DC}` — unassigned in that CPython, whose own `.upper()` is the identity
|
||||
* here — and the declared `class \u{A7DC}Args` fails with `invalid
|
||||
* non-printable character U+A7DC`. Closing the exposure therefore covers all
|
||||
* four read points, not this predicate alone; it needs the target interpreter's
|
||||
* version, which the backend reporting `language: 'python'` owns and which is
|
||||
* unpublished on this base, so the note records it as that PR's decision.
|
||||
*
|
||||
* The `ts-types` sibling keeps its own ASCII rule rather than sharing this
|
||||
* one: ECMAScript identifiers are a different set (`$`) and are never
|
||||
* normalized, so one predicate cannot be correct for both. ZWJ/ZWNJ are not
|
||||
* part of that difference — both sets carry them on the engine's tables; what
|
||||
* separates the two there is the CPython table version above.
|
||||
* @param name - the raw schema field or tool name.
|
||||
* @returns whether the name can be emitted bare.
|
||||
*/
|
||||
function isBareIdentifier(name: string): boolean {
|
||||
return IDENTIFIER.test(name) && name.normalize('NFKC') === name
|
||||
}
|
||||
|
||||
/**
|
||||
* Python hard keywords: reserved everywhere, so a tool or field named
|
||||
* ``class`` or ``lambda`` is legal on the wire but not as an attribute
|
||||
* (``tools.class`` would be a SyntaxError in the model program) and not as a
|
||||
* class-syntax `TypedDict` field. Such a tool renders under subscript access
|
||||
* and such an object degrades to ``dict[str, Any]`` — the model still reaches
|
||||
* every tool and field without collisions.
|
||||
* Soft keywords (``match``, ``case``, ``type``, ``_`` — the language
|
||||
* reference's whole set) are deliberately ABSENT: each is special in exactly
|
||||
* one syntactic position — a statement head (``match``, ``type``), a ``match``
|
||||
* statement's clause head (``case``), or a pattern (``_``) — so ``match: str``
|
||||
* as a field and ``async def match(...)`` as a method are both legal, and
|
||||
* including them would needlessly degrade common search/regex tool fields to
|
||||
* ``dict[str, Any]``. Underscore-leading names are handled separately, not
|
||||
* here: a non-dunder ``__token`` name-mangles, a dunder present on
|
||||
* ``object``/``type`` resolves before the proxy hook, and implicit
|
||||
* special-method lookup bypasses the hook.
|
||||
*/
|
||||
const RESERVED = new Set([
|
||||
'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await', 'break', 'class',
|
||||
'continue', 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global',
|
||||
'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise',
|
||||
'return', 'try', 'while', 'with', 'yield',
|
||||
// Not a keyword, but CPython refuses to ASSIGN it at compile time
|
||||
// (`SyntaxError: cannot assign to __debug__`), which is what a TypedDict
|
||||
// field, a parameter name, and a keyword argument all are.
|
||||
'__debug__',
|
||||
])
|
||||
|
||||
/** `typing` symbols this module may emit, in the deterministic import order. */
|
||||
const TYPING_ORDER = ['Any', 'Literal', 'NotRequired', 'Protocol', 'TypedDict'] as const
|
||||
|
||||
/** `indent`-deep line prefix (four spaces per level to match PEP 8 output). */
|
||||
function pad(indent: number): string {
|
||||
return ' '.repeat(indent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collector threaded through {@link renderType}: the emitted `TypedDict` class
|
||||
* declarations (nested classes precede the parent that references them), the
|
||||
* class names already taken (for collision suffixing), a per-base collision
|
||||
* counter, and the `typing` symbols the render actually used.
|
||||
*/
|
||||
interface RenderState {
|
||||
readonly classes: string[]
|
||||
readonly usedClassNames: Set<string>
|
||||
/** Next collision counter per capped base, so allocation is amortized O(1) instead of rescanning from `2`. */
|
||||
readonly nextClassCounter: Map<string, number>
|
||||
readonly typing: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Cc` code points that survive the whitespace collapse in {@link describe}
|
||||
* and have no printable form: the C0 controls, DEL, and the C1 controls. Only
|
||||
* U+0009 to U+000D are absent, because ECMAScript `\s` already collapsed them —
|
||||
* `\s` is TAB/VT/FF/SP/NBSP/ZWNBSP/Zs plus LF/CR/LS/PS, so no C1 code point is
|
||||
* in it and the whole U+0080 to U+009F block reaches this rule intact. Those
|
||||
* are not hypothetical input: they are what Windows-1252 bytes 0x80 to 0x9F
|
||||
* (smart quotes, em dash) become when decoded as Latin-1.
|
||||
* CPython rejects source containing a NUL outright
|
||||
* (`SyntaxError: source code string cannot contain null bytes`), whether it
|
||||
* sits in a docstring or in a comment, so one such byte anywhere in a schema
|
||||
* description would make the whole generated SDK unparseable — under
|
||||
* `mode: 'code'`, the model's only declaration of the tools. The rest are
|
||||
* legal but invisible; escaping them with the same rule keeps the emitted text
|
||||
* readable and the treatment uniform.
|
||||
*
|
||||
* The boundary is the category, not per-code-point addressability: `\xNN`
|
||||
* addresses U+0000 to U+00FF, so one escape form covers `Cc` exactly. The
|
||||
* invisible `Cf` formatting characters pass through by design — of them only
|
||||
* U+00AD soft hyphen would fit `\xNN` at all, and escaping that one while
|
||||
* U+200B ZWSP, U+200E/U+200F bidi marks, and U+2060 word joiner passed through
|
||||
* would leave a rule that is neither category- nor addressability-shaped. The
|
||||
* whole family is legal in both consumers, since only LF and CR terminate a
|
||||
* Python string literal or a `#` comment. That set is the tokenizer's, not
|
||||
* `str.splitlines()`': NEL (U+0085), LS (U+2028), and PS (U+2029) split a
|
||||
* string at run time but do not end a physical line in source — measured on
|
||||
* CPython 3.9.6 and 3.12.13, each accepted in both positions with the value
|
||||
* round-tripping — so they are safe raw wherever they reach emitted text
|
||||
* unescaped, which for all three is `JSON.stringify`, at two call sites:
|
||||
* {@link pyScalar}'s literal path, and the subscript tool-name comment's own
|
||||
* call, which a name carrying any of them always reaches, none being
|
||||
* `XID_Continue`. The `description` path escapes NEL under the class above and
|
||||
* folds LS and PS in {@link describe}'s `\s+` collapse, both being `\s`.
|
||||
*/
|
||||
const UNPRINTABLE = /[\u0000-\u0008\u000e-\u001f\u007f-\u009f]/g
|
||||
|
||||
/**
|
||||
* Unpaired surrogate code points, escaped by {@link describe} as `\uNNNN` —
|
||||
* its own form, since `\xNN` stops at U+00FF. The `u` flag is what makes this
|
||||
* the LONE ones: in Unicode mode a well-formed pair is a single astral code
|
||||
* point outside D800 to DFFF, so an emoji in a description survives untouched.
|
||||
*
|
||||
* This is the NUL case from {@link UNPRINTABLE}, not the invisible-character
|
||||
* case. Python source must be UTF-8-encodable and a lone surrogate is not, so
|
||||
* `compile()` raises `UnicodeEncodeError: surrogates not allowed` for one
|
||||
* anywhere in the text — measured on 3.9 for a string literal and for a `#`
|
||||
* comment alike. A raw or MCP tool description reaches this: `JSON.parse` on a
|
||||
* wire `"\ud800"` escape yields exactly such a code point.
|
||||
*/
|
||||
const LONE_SURROGATE = /[\ud800-\udfff]/gu
|
||||
|
||||
/**
|
||||
* The collapsed one-line `description` of a schema node (byte-stable across
|
||||
* formatting churn), or `undefined` when the node carries none. Every caller
|
||||
* passes an object — a validated property node, the `ToolSdkSchema` itself, or
|
||||
* the `{ description }` wrapper {@link docLines} synthesizes — so only the
|
||||
* description field needs guarding. A description that collapses
|
||||
* to nothing (empty, or whitespace only) is `undefined` too: it documents the
|
||||
* node no better than an absent one, and emitting it would leave an empty
|
||||
* `"""` docstring or a bare `# ` line in the SDK. Only ECMAScript whitespace
|
||||
* folds, so a description of whitespace plus one surviving control character is
|
||||
* NOT absent: it collapses to that character's visible escape.
|
||||
*
|
||||
* Control characters left over after the whitespace collapse are rendered as
|
||||
* their `\xNN` escapes (see {@link UNPRINTABLE}) and unpaired surrogates as
|
||||
* their `\uNNNN` escapes (see {@link LONE_SURROGATE}); the escape's own backslash is
|
||||
* emitted literally by both consumers, since {@link docLines} doubles it into a
|
||||
* Python source escape and a `#` comment carries it verbatim.
|
||||
*/
|
||||
function describe(schema: object): string | undefined {
|
||||
const description = (schema as Record<string, unknown>).description
|
||||
if (typeof description !== 'string') return undefined
|
||||
const collapsed = description
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(UNPRINTABLE, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
||||
.replace(LONE_SURROGATE, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`)
|
||||
.trim()
|
||||
return collapsed.length === 0 ? undefined : collapsed
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line docstring for a tool `description`, or no lines when there is none.
|
||||
* Backslashes are doubled first, every quote is escaped, and a trailing
|
||||
* backslash cannot survive: a description ending in `"` or an odd backslash
|
||||
* would otherwise merge with (or escape) the closing triple quote and make
|
||||
* the generated block — Code Mode's only SDK — syntactically invalid Python.
|
||||
*/
|
||||
function docLines(description: unknown, indent: number): string[] {
|
||||
const collapsed = describe({ description })
|
||||
if (collapsed === undefined) return []
|
||||
const escaped = collapsed.replaceAll('\\', '\\\\').replaceAll('"', '\\"')
|
||||
return [`${pad(indent)}"""${escaped}"""`]
|
||||
}
|
||||
|
||||
/**
|
||||
* CamelCase a name into a Python type identifier: non-identifier characters
|
||||
* split words, `_` splits too (it is `XID_Continue`, so the split set names it
|
||||
* explicitly), and a head that cannot start an identifier takes a `Tool`
|
||||
* prefix. Unicode survives, so a `路径` field yields `路径`-based class names
|
||||
* instead of collapsing to the bare prefix. A character that is not
|
||||
* `XID_Continue` splits even when it is a letter, so a name whose NFKC folding
|
||||
* would leave the identifier set is not carried through — the split set is the
|
||||
* grammar's, not an ASCII approximation of it.
|
||||
*
|
||||
* The result is NFKC-normalized: these names are generated, never matched
|
||||
* against a JSON key, so normalizing is free here and keeps what CPython
|
||||
* compiles identical to what is emitted — unlike {@link isBareIdentifier},
|
||||
* which must reject unstable names outright. Normalizing AFTER the prefix
|
||||
* decision is what makes that hold at the seam the prefix creates: `Tool` +
|
||||
* a combining-mark head composes there (`U+0301` gives `Tooĺ`, U+013A), so
|
||||
* normalizing only the un-prefixed part would emit a name CPython compiles to
|
||||
* a different symbol. The second call is idempotent on the un-prefixed arm.
|
||||
*
|
||||
* The split set, the head test, and `toUpperCase()` all read the engine's
|
||||
* Unicode tables, so this function carries the same version skew
|
||||
* {@link isBareIdentifier} documents, by paths independent of it: a class name
|
||||
* derived here reaches emitted text whenever any object shape in the tool's
|
||||
* schema declares a `TypedDict`, and the predicate's verdict on the tool name
|
||||
* does not gate that. The case mapping is the one that can fail on a name the
|
||||
* predicate accepted; the worked example is there.
|
||||
* @param raw - the schema field or tool name to derive from.
|
||||
* @returns a class-name segment safe to emit.
|
||||
*/
|
||||
function camelCase(raw: string): string {
|
||||
const joined = raw
|
||||
.split(/[^\p{XID_Continue}]+|_+/u)
|
||||
.filter(part => part.length > 0)
|
||||
.map(part => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
|
||||
.join('')
|
||||
.normalize('NFKC')
|
||||
return (/^\p{XID_Start}/u.test(joined) ? joined : `Tool${joined}`).normalize('NFKC')
|
||||
}
|
||||
|
||||
/** Class-name base cap keeping each emitted name — and total text — linear in schema depth. */
|
||||
const MAX_CLASS_NAME_BASE = 120
|
||||
|
||||
/**
|
||||
* Deepest `list[…]` nesting emitted into one annotation before the item type
|
||||
* degrades to `Any`. CPython's tokenizer rejects a logical line holding more
|
||||
* than 200 simultaneously-open brackets (`MAXLEVEL`, `SyntaxError: too many
|
||||
* nested parentheses`), so an array chain deeper than that would render an SDK
|
||||
* block that is not valid Python at all — the same failure the docstring
|
||||
* escaping in {@link docLines} exists to prevent. 180 leaves headroom for the
|
||||
* few brackets an annotation can add around the chain, all of which count
|
||||
* toward the same limit. Per emission site, counting brackets open at the
|
||||
* chain's innermost point:
|
||||
*
|
||||
* - Return annotation, `async def f(self, args: X) -> chain:` — 180 `list[`
|
||||
* plus an innermost `Literal[`. The parameter list's `(` closed at the `)`
|
||||
* before the `->`, so it is NOT open here: 181.
|
||||
* - TypedDict field, `field: NotRequired[chain]` — a class-body line with no
|
||||
* other open bracket, and its children start at `listDepth: 1` to reserve
|
||||
* the `NotRequired[`, so 179 `list[` plus `Literal[`: 181. Required fields
|
||||
* share that start for uniformity, spending one level of representable depth
|
||||
* on a bracket they never emit.
|
||||
* - Argument annotation, `async def f(self, args: chain) -> Y:` — the `(` IS
|
||||
* still open around it: 180 `list[` plus `Literal[` plus the paren, 182, the
|
||||
* worst case. Reachable only through a raw `register()` whose `parameters`
|
||||
* is an array reached from the root through `oneOf` arms alone — the root
|
||||
* array itself, or one nested under any depth of unions, since an arm
|
||||
* inherits the enclosing depth unchanged (`A | B` opens no bracket). An
|
||||
* object ancestor takes it out of this case: its fields restart the chain at
|
||||
* the 181 site. `defineTool` compiles an object root, so the annotation is a
|
||||
* bare TypedDict class name or a one-bracket `dict[str, Any]` when that
|
||||
* object degrades — never a chain.
|
||||
*
|
||||
* A CPython grammar limit, not a deployment choice, so it is fixed rather than
|
||||
* configurable. The sibling `ts-types` renderer needs no counterpart: nothing
|
||||
* in the TypeScript grammar bounds nesting, and its SDK block is never type-
|
||||
* checked. Only bracket nesting counts — a `oneOf` renders as a flat `A | B`
|
||||
* chain and nested objects render as separate `class` statements, so neither
|
||||
* accumulates open brackets at any depth. The invariant this cap serves is
|
||||
* grammatical validity; see the `oneOf` arm in {@link renderType} for the one
|
||||
* interpreter limit deliberately left uncapped.
|
||||
*/
|
||||
const MAX_LIST_NESTING = 180
|
||||
|
||||
/**
|
||||
* Cap a class-name base at {@link MAX_CLASS_NAME_BASE} (see the callers for
|
||||
* why capping keeps the render linear). `slice` counts UTF-16 code units, so
|
||||
* an astral character straddling the boundary would be cut in half and leave a
|
||||
* lone surrogate — not an identifier character, and not even well-formed text;
|
||||
* drop it rather than emit it.
|
||||
*/
|
||||
function capClassNameBase(base: string): string {
|
||||
if (base.length <= MAX_CLASS_NAME_BASE) return base
|
||||
const capped = base.slice(0, MAX_CLASS_NAME_BASE)
|
||||
return /[\uD800-\uDBFF]$/.test(capped) ? capped.slice(0, -1) : capped
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve a unique class name from a base, suffixing `2`, `3`, … on collision.
|
||||
* The base is capped at {@link MAX_CLASS_NAME_BASE} first: child class names
|
||||
* derive from their parent's allocated name (`ParentChild`), so an unbounded
|
||||
* schema of single-field objects would otherwise grow each name by one field
|
||||
* per level and the sum of all names to Θ(depth²). Capping the base keeps each
|
||||
* name — and the total emitted text — linear in depth. Collisions resume from
|
||||
* the per-base counter in `state.nextClassCounter` rather than rescanning from
|
||||
* `2`, so a deep chain sharing one capped base stays O(1) per allocation
|
||||
* (amortized) instead of Θ(depth²) in time.
|
||||
*/
|
||||
function allocateClassName(base: string, state: RenderState): string {
|
||||
const capped = capClassNameBase(base)
|
||||
let name = capped
|
||||
if (state.usedClassNames.has(name)) {
|
||||
let n = state.nextClassCounter.get(capped) ?? 2
|
||||
while (state.usedClassNames.has(`${capped}${n}`)) n++
|
||||
name = `${capped}${n}`
|
||||
state.nextClassCounter.set(capped, n + 1)
|
||||
}
|
||||
state.usedClassNames.add(name)
|
||||
return name
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a child-name segment to a parent class-name base, capping the result
|
||||
* at {@link MAX_CLASS_NAME_BASE}. Capping AT PROPAGATION (not only inside
|
||||
* {@link allocateClassName}) keeps each level O(1): a deep `oneOf`- or
|
||||
* object-chain would otherwise carry an ever-growing ConsString down the tree
|
||||
* and re-materialize it (via `.length`/`.slice`) at every level — Θ(depth²).
|
||||
* The bounded base plus the collision counter still yields unique names.
|
||||
*
|
||||
* The join is NFKC-normalized because both sides are separately normalized yet
|
||||
* their concatenation need not be: a base ending in a Hangul L jamo or LV
|
||||
* syllable composes with a following V or T jamo head (`가` + `ᆨ` gives `각`),
|
||||
* so the emitted class name would differ from the symbol CPython compiles, and
|
||||
* two byte-distinct names could fold onto one — `usedClassNames` dedupes by the
|
||||
* raw bytes, so the collision counter would not see it. Normalizing costs
|
||||
* O(cap + segment) per level, the same order as the `slice` it feeds. The other
|
||||
* two join points need no counterpart: `Args`/`Output` start with `A`/`O` and
|
||||
* {@link allocateClassName}'s suffix is digits, none of which compose backwards.
|
||||
*/
|
||||
function childClassName(base: string, segment: string): string {
|
||||
return capClassNameBase(`${base}${segment}`.normalize('NFKC'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one validated scalar as Python literal text (`True`/`False`,
|
||||
* JSON-quoted strings, bare numbers). `null` cannot reach here: the `null`
|
||||
* type renders directly as `None`, and the unified validator rejects a null
|
||||
* `const`/`enum` entry on every other scalar type.
|
||||
*
|
||||
* A beyond-safe-range integral number takes `BigInt` digits rather than
|
||||
* `String`: Python integers are arbitrary-precision, so the emitted digits ARE
|
||||
* the value the model programs against, and `String` can give a different
|
||||
* integer than the double holds (`2 ** 60` prints the rounded `...847000`, not
|
||||
* the exact `...846976`) or no integer literal at all (`1e21` prints `1e+21`).
|
||||
* `String`'s rounding is not a bug in it: `Number::toString` emits the shortest
|
||||
* decimal string that re-reads to the same double, then pads to the exponent
|
||||
* with zeros (1 significant digit for `1e20`, 16 for `2 ** 60`) — and when the
|
||||
* shortest string is shorter than the double's exact value, those padded digits
|
||||
* name an integer no double holds. Passing one back would have to cross the
|
||||
* argument boundary as a JSON number — a double again — so the SDK would
|
||||
* document a value no program can pass. `BigInt` needs no case split: where
|
||||
* `String` is already exact (`2 ** 53`, `1e20`) the two agree byte for byte,
|
||||
* and where it is not, `BigInt` is the exact one. The TS flavor needs no
|
||||
* counterpart at all: its literal is re-read by a JS parser back into the same
|
||||
* double.
|
||||
*
|
||||
* `JSON.stringify` is also what keeps this path's output parseable, and it is
|
||||
* the only thing that does. It covers both classes of hazard: the two kinds of
|
||||
* code point CPython refuses anywhere in source — NUL among the C0 controls,
|
||||
* and the whole D800–DFFF unpaired-surrogate block, escaped under ES2019
|
||||
* well-formed stringification, which the engines range guarantees — and the
|
||||
* ones that break this line in particular, a bare `"` closing the literal
|
||||
* early, a trailing odd backslash eating the closing quote, and a bare LF/CR
|
||||
* ending it before its terminator. The `description` path carries
|
||||
* {@link UNPRINTABLE} and {@link LONE_SURROGATE} because nothing quotes it,
|
||||
* and folds newlines in {@link describe}.
|
||||
*
|
||||
* That leans on a coincidence worth naming: every escape `JSON.stringify` can
|
||||
* emit (`\"`, `\\`, `\b`, `\f`, `\n`, `\r`, `\t`, `\uXXXX`) is also a Python
|
||||
* escape denoting the same character, so the emitted `Literal[...]` both
|
||||
* parses and decodes back to the value the schema declared. DEL, the C1
|
||||
* controls (NEL among them), and LS/PS (U+2028/U+2029) do reach it raw —
|
||||
* legal but invisible, byte-for-byte as in the TS flavor; escaping them is a
|
||||
* both-flavors change. Those last three are legal here for the reason
|
||||
* {@link UNPRINTABLE} records: they are `str.splitlines()` boundaries, not
|
||||
* tokenizer line terminators. The subscript tool-name comment quotes its name
|
||||
* through its own call to the same `JSON.stringify`, never through this
|
||||
* function, and inherits both halves — escapes and pass-throughs alike.
|
||||
*/
|
||||
function pyScalar(value: JsonSchemaScalar): string {
|
||||
if (value === true) return 'True'
|
||||
if (value === false) return 'False'
|
||||
if (typeof value === 'string') return JSON.stringify(value)
|
||||
if (typeof value === 'number' && Number.isInteger(value) && !Number.isSafeInteger(value)) {
|
||||
return BigInt(value).toString()
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a validated scalar `const`/`enum` as `Literal[...]`, falling back to
|
||||
* the broad type. Deliberately deviates from PEP 586, which restricts `Literal`
|
||||
* parameters to int/bool/str/bytes/enum/None: a non-integral number
|
||||
* `const`/`enum` emits a float literal (`Literal[1.5]`) a strict checker would
|
||||
* reject. An integral one does not deviate — {@link pyScalar} emits int digits,
|
||||
* including for the beyond-safe-range values it widens through `BigInt`, and
|
||||
* PEP 586 admits int parameters. Harmless either way — the stub is advisory
|
||||
* prompt text, only required to parse — and keeping the exact value
|
||||
* communicates the constraint to the model.
|
||||
*/
|
||||
function renderConstrainedScalar(node: JsonSchemaNode, broad: string, state: RenderState): string {
|
||||
if (node.const !== undefined) {
|
||||
state.typing.add('Literal')
|
||||
return `Literal[${pyScalar(node.const)}]`
|
||||
}
|
||||
if (node.enum !== undefined) {
|
||||
state.typing.add('Literal')
|
||||
return `Literal[${node.enum.map(pyScalar).join(', ')}]`
|
||||
}
|
||||
return broad
|
||||
}
|
||||
|
||||
/**
|
||||
* Map one JSON-Schema node to a Python type expression, threading `state` to
|
||||
* collect the `TypedDict` declarations and `typing` symbols a full render
|
||||
* needs. `className` is the name to give an object node with properties (and
|
||||
* the prefix for its nested objects). Handles every unified schema construct —
|
||||
* `oneOf` (→ `X | Y`), `const`/`enum` (→ `Literal[...]`), `integer` (→ `int`),
|
||||
* `null` (→ `None`) — and degrades an unsupported or malformed schema to `Any`
|
||||
* without throwing, the same trusted-after-validation stance as the sibling
|
||||
* {@link ./ts-types.ts | ts-types} renderer. {@link jsonSchemaToPy} is the
|
||||
* context-free entry point; this is the collecting core.
|
||||
*/
|
||||
function renderType(schema: unknown, className: string, state: RenderState): string {
|
||||
interface Frame {
|
||||
// A validated JSON-schema node past the root `assertSupportedJsonSchema`
|
||||
// (the root frame's schema is asserted before any frame is built), so the
|
||||
// walk reads its fields without casts — the same typed-frame shape as the
|
||||
// sibling ts-types renderer.
|
||||
schema: JsonSchemaNode
|
||||
className: string
|
||||
phase: 'start' | 'children'
|
||||
kind?: 'oneOf' | 'array' | 'typeddict'
|
||||
node?: JsonSchemaNode
|
||||
/** Open `list[` brackets enclosing this node in the annotation being built ({@link MAX_LIST_NESTING}). */
|
||||
listDepth: number
|
||||
children: { schema: JsonSchemaNode; className: string; listDepth: number }[]
|
||||
childIndex: number
|
||||
childTypes: string[]
|
||||
entries: [string, JsonSchemaNode][]
|
||||
allocated?: string
|
||||
}
|
||||
const newFrame = (schema: JsonSchemaNode, className: string, listDepth: number): Frame =>
|
||||
({ schema, className, phase: 'start', listDepth, children: [], childIndex: 0, childTypes: [], entries: [] })
|
||||
try {
|
||||
// Validate the WHOLE tree once, then trust it — the same contract the
|
||||
// sibling ts-types renderer follows at a typed same-process seam. Every
|
||||
// node past this point is a validated JSON-schema node, so the walk reads
|
||||
// its fields without re-checking. An unsupported or malformed schema throws
|
||||
// here (before anything is emitted) and degrades to `Any`, the Python
|
||||
// counterpart of the TS flavor's `unknown`.
|
||||
assertSupportedJsonSchema(schema)
|
||||
const frames: Frame[] = [newFrame(schema, className, 0)]
|
||||
let result: string | undefined
|
||||
/* jscpd:ignore-start -- the explicit-stack walk skeleton deliberately parallels
|
||||
ts-types.ts's renderSupportedSchema; the two sibling renderers keep symmetric shapes. */
|
||||
const finish = (type: string): void => {
|
||||
frames.pop()
|
||||
const parent = frames.at(-1)
|
||||
if (parent === undefined) result = type
|
||||
else parent.childTypes.push(type)
|
||||
}
|
||||
|
||||
while (frames.length > 0) {
|
||||
const frame = frames.at(-1)
|
||||
/* v8 ignore next -- the loop condition guarantees a current frame. */
|
||||
if (frame === undefined) break
|
||||
|
||||
if (frame.phase === 'children') {
|
||||
if (frame.childIndex < frame.children.length) {
|
||||
const child = frame.children[frame.childIndex]
|
||||
/* v8 ignore next -- childIndex is bounded by children.length. */
|
||||
if (child === undefined) throw new Error('missing python render child')
|
||||
frame.childIndex++
|
||||
frames.push(newFrame(child.schema, child.className, child.listDepth))
|
||||
continue
|
||||
}
|
||||
if (frame.kind === 'oneOf') {
|
||||
// Concatenate incrementally (template literal, not `Array.join`): V8
|
||||
// builds a lazy ConsString, so a deep oneOf chain materializes once
|
||||
// at the root instead of re-materializing the accumulated string at
|
||||
// every level (which `join` would, making it Θ(depth²)). This matches
|
||||
// the array arm's template-literal laziness and ts-types' composable-
|
||||
// document approach — the whole walk stays linear in schema depth.
|
||||
let union = ''
|
||||
for (const [index, childType] of frame.childTypes.entries()) {
|
||||
union = index === 0 ? childType : `${union} | ${childType}`
|
||||
}
|
||||
finish(union)
|
||||
continue
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
if (frame.kind === 'array') {
|
||||
// `list[A | B]` needs no parentheses in Python. Array frames always
|
||||
// schedule exactly one child, so its type is present.
|
||||
/* v8 ignore next -- the ?? arm needs a childless array frame, which start never builds. */
|
||||
finish(`list[${frame.childTypes[0] ?? 'Any'}]`)
|
||||
continue
|
||||
}
|
||||
// typeddict: assemble AFTER the children so any nested class this one
|
||||
// references is already declared (declaration order = reference order).
|
||||
const node = frame.node
|
||||
const name = frame.allocated
|
||||
/* v8 ignore next -- typeddict frames always set node and allocated at start. */
|
||||
if (node === undefined || name === undefined) throw new Error('missing typeddict frame state')
|
||||
const required = new Set(node.required)
|
||||
const lines = [`class ${name}(TypedDict):`]
|
||||
for (let index = 0; index < frame.entries.length; index++) {
|
||||
const entry = frame.entries[index]
|
||||
const fieldType = frame.childTypes[index]
|
||||
/* v8 ignore next -- entries and childTypes correspond one-to-one. */
|
||||
if (entry === undefined || fieldType === undefined) throw new Error('missing typeddict field type')
|
||||
const [field, fieldSchema] = entry
|
||||
// The parent node passed assertSupportedJsonSchema, so every property
|
||||
// value is a validated schema node.
|
||||
const description = describe(fieldSchema)
|
||||
if (description !== undefined) lines.push(`${pad(1)}# ${description}`)
|
||||
if (required.has(field)) {
|
||||
lines.push(`${pad(1)}${field}: ${fieldType}`)
|
||||
} else {
|
||||
state.typing.add('NotRequired')
|
||||
lines.push(`${pad(1)}${field}: NotRequired[${fieldType}]`)
|
||||
}
|
||||
}
|
||||
// TypedDict syntax cannot express openness, so an open object states it
|
||||
// in-band: the annotation is advisory either way, and `mode: 'code'`
|
||||
// omits the native schemas, making this line the model's only signal
|
||||
// that extra keys are accepted.
|
||||
if (node.additionalProperties !== false) {
|
||||
lines.push(`${pad(1)}# Additional keys beyond those declared are allowed.`)
|
||||
}
|
||||
// A closed empty object still needs a class body (`pass`) to be valid
|
||||
// Python; the declared emptiness is the information.
|
||||
if (lines.length === 1) lines.push(`${pad(1)}pass`)
|
||||
state.classes.push(lines.join('\n'))
|
||||
finish(name)
|
||||
continue
|
||||
}
|
||||
|
||||
frame.phase = 'children'
|
||||
const node = frame.schema
|
||||
if (node.oneOf !== undefined) {
|
||||
frame.kind = 'oneOf'
|
||||
// A union renders as `A | B` — no brackets of its own, so the branches
|
||||
// inherit the enclosing depth unchanged.
|
||||
//
|
||||
// Union LENGTH is deliberately uncapped, unlike list nesting. The two
|
||||
// limits are different in kind: >200 open brackets is a SyntaxError
|
||||
// from the tokenizer, so the text is not Python; a long `A | B | …`
|
||||
// chain is grammatically valid at any length and only defeats CPython's
|
||||
// C-recursion when `compile()` walks the left-nested BinOp spine
|
||||
// (measured: 1,000 branches compile, 5,000 raise RecursionError). This
|
||||
// block is prompt text — nothing compiles it — so that limit costs
|
||||
// nothing here, while capping would retire the deep-chain tests that
|
||||
// pin the walk's linear time and the class-name propagation cap. The
|
||||
// standard this renderer holds is grammatical validity, not
|
||||
// compilability under one interpreter's stack.
|
||||
frame.children = node.oneOf.map((branch, index) => ({ schema: branch, className: childClassName(frame.className, `${index + 1}`), listDepth: frame.listDepth }))
|
||||
continue
|
||||
}
|
||||
if (node.type === undefined) {
|
||||
state.typing.add('Any')
|
||||
finish('Any')
|
||||
continue
|
||||
}
|
||||
switch (node.type) {
|
||||
case 'string': finish(renderConstrainedScalar(node, 'str', state)); break
|
||||
case 'number': finish(renderConstrainedScalar(node, 'float', state)); break
|
||||
case 'integer': finish(renderConstrainedScalar(node, 'int', state)); break
|
||||
case 'boolean': finish(renderConstrainedScalar(node, 'bool', state)); break
|
||||
case 'null': finish('None'); break
|
||||
case 'array': {
|
||||
if (node.items === undefined) {
|
||||
state.typing.add('Any')
|
||||
finish('list[Any]')
|
||||
break
|
||||
}
|
||||
// Past MAX_LIST_NESTING another `list[` would push the annotation
|
||||
// beyond CPython's open-bracket limit and make the whole SDK block
|
||||
// unparseable, so the chain degrades here instead — an unusable
|
||||
// annotation either way, and this one is valid Python.
|
||||
if (frame.listDepth >= MAX_LIST_NESTING) {
|
||||
state.typing.add('Any')
|
||||
finish('Any')
|
||||
break
|
||||
}
|
||||
// An array of objects names its item type after the array field.
|
||||
frame.kind = 'array'
|
||||
frame.children = [{ schema: node.items, className: frame.className, listDepth: frame.listDepth + 1 }]
|
||||
break
|
||||
}
|
||||
case 'object': {
|
||||
// A missing `properties` is an empty property map, exactly as the
|
||||
// unified validator and the TS renderer read it — NOT an unknown
|
||||
// shape. The openness of the resulting empty object is decided below,
|
||||
// so a closed empty object still declares an empty TypedDict rather
|
||||
// than a permissive `dict[str, Any]`.
|
||||
const entries = Object.entries(node.properties ?? {})
|
||||
// An empty `className` marks the context-free `jsonSchemaToPy` entry:
|
||||
// there is no naming context to declare into, so degrade. This reads
|
||||
// the CALL's className, not `frame.className`: the marker belongs to
|
||||
// the whole walk, and frames propagate a derived name (a `oneOf`
|
||||
// branch of the context-free root gets the index-derived name `1` —
|
||||
// `childClassName` concatenates and caps, it does not go through
|
||||
// `camelCase`), so a per-frame read would declare classes the caller
|
||||
// has no way to receive, under a name that is not even a legal
|
||||
// identifier: `class 1(TypedDict):`. A field
|
||||
// name that is not a legal Python attribute is inexpressible as a
|
||||
// class-syntax `TypedDict` field, so such an object degrades whole.
|
||||
// A leading-double-underscore non-dunder field (`__token`) would be
|
||||
// NAME-MANGLED inside class syntax (`_ClassName__token`), describing a
|
||||
// different JSON key than the registered schema — degrade like any
|
||||
// other inexpressible field name.
|
||||
if (className === '' || !entries.every(([name]) => isBareIdentifier(name) && !RESERVED.has(name) && !(name.startsWith('__') && !name.endsWith('__')))) {
|
||||
state.typing.add('Any')
|
||||
finish('dict[str, Any]')
|
||||
break
|
||||
}
|
||||
// An OPEN empty object is any dict; a CLOSED empty object declares an
|
||||
// empty TypedDict so "no keys accepted" survives into the SDK.
|
||||
if (entries.length === 0 && node.additionalProperties !== false) {
|
||||
state.typing.add('Any')
|
||||
finish('dict[str, Any]')
|
||||
break
|
||||
}
|
||||
frame.kind = 'typeddict'
|
||||
frame.node = node
|
||||
frame.allocated = allocateClassName(frame.className, state)
|
||||
state.typing.add('TypedDict')
|
||||
frame.entries = entries
|
||||
// A field annotation is its own logical line, so nesting restarts —
|
||||
// at 1, reserving the bracket an optional field's `NotRequired[…]`
|
||||
// wraps around it. frame.allocated was assigned three statements up;
|
||||
// the ?? arm is for the type system only.
|
||||
/* v8 ignore next -- allocated is always set before children are built. */
|
||||
frame.children = entries.map(([field, child]) => ({ schema: child, className: childClassName(frame.allocated ?? '', camelCase(field)), listDepth: 1 }))
|
||||
break
|
||||
}
|
||||
/* v8 ignore next 4 -- assertSupportedJsonSchema narrowed this closed type union. */
|
||||
default: {
|
||||
state.typing.add('Any')
|
||||
finish('Any')
|
||||
}
|
||||
}
|
||||
}
|
||||
/* v8 ignore next -- every root frame produces one expression. */
|
||||
return result ?? 'Any'
|
||||
} catch {
|
||||
// An unsupported or malformed schema failed validation (before any
|
||||
// emission), or an unreachable internal invariant tripped. Either degrades
|
||||
// the node to `Any` rather than crashing prompt assembly — the Python
|
||||
// counterpart of the TS flavor's `unknown` fallback.
|
||||
state.typing.add('Any')
|
||||
return 'Any'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map one JSON-Schema node to a context-free Python type expression from the
|
||||
* `typing` module. Handles every unified schema construct — `object` (degraded
|
||||
* to `dict[str, Any]`: naming a `TypedDict` requires the render context that
|
||||
* {@link renderToolsSdkPy} supplies), `const`/`enum` (→ `Literal[...]`),
|
||||
* `oneOf` (→ union), `string`/`number`/`integer`/`boolean`/`null`, `array`
|
||||
* (`items` → `list[T]`) — and returns `Any` for an unsupported or malformed
|
||||
* schema, matching the TS flavor's `unknown` fallback. Type annotations in the
|
||||
* emitted SDK are advisory: Python does not enforce them at runtime.
|
||||
* @param schema - the JSON-Schema node.
|
||||
* @returns the Python type text.
|
||||
*/
|
||||
export function jsonSchemaToPy(schema: unknown): string {
|
||||
// A throwaway state whose class collector never escapes: an object with
|
||||
// properties has nowhere to declare its TypedDict and degrades to
|
||||
// dict[str, Any]. renderToolsSdkPy drives the named-TypedDict path.
|
||||
return renderType(schema, '', { classes: [], usedClassNames: new Set(), nextClassCounter: new Map(), typing: new Set() })
|
||||
}
|
||||
|
||||
/** The fixed model-facing usage contract rendered above the declarations. */
|
||||
const SDK_INSTRUCTIONS = `## Writing code for run_code
|
||||
|
||||
Pass \`run_code\` the body of an async Python function (top-level \`await\` and \`return\` both work). At run time exactly two of the names declared below are bound: \`tools\` and \`ToolCallError\`. Everything else is a STATIC STUB describing shapes — in particular the \`TypedDict\` classes do NOT exist at run time, so build arguments as plain \`dict\`/\`list\` JSON values: \`await tools.name({"field": 1})\`, never \`FooArgs(field=1)\`, which raises \`NameError\`. Inside the program:
|
||||
|
||||
- Call tools as \`await tools.name(args)\` — subscript access for exotic, reserved, or underscore-leading names: \`await tools["my-tool"](args)\`. Every call resolves to the tool's typed canonical JSON value (each method's return type below). Tool arguments must be lossless JSON.
|
||||
- A FAILED tool call raises \`ToolCallError\`, whose \`toolName\` identifies the failed tool and whose message is human-readable — wrap in \`try/except\` to handle and continue.
|
||||
- Independent read-only calls MAY overlap under \`asyncio.gather\` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with \`await\`.
|
||||
- Emit the run's answer with \`print(...)\` and/or a top-level \`return <value>\`; the returned value must be lossless JSON. ONLY what you print and the returned value come back — intermediate tool results never enter the conversation, so extract just what you need.
|
||||
|
||||
The available tools:`
|
||||
|
||||
/**
|
||||
* Render the full `tools:sdk` prompt section under `runtime.language ===
|
||||
* 'python'`: the Python-flavored usage instructions plus one named `TypedDict`
|
||||
* per tool argument or output object (and per nested object) and one awaitable
|
||||
* method per visible tool on a `Tools` protocol — typed args in, the tool's
|
||||
* canonical output value out — with a `tools: Tools` singleton the model calls
|
||||
* into. The `typing` import line lists exactly the symbols the render used.
|
||||
* Deterministic — tools are emitted in lexicographic name order, and class
|
||||
* declarations precede the protocol in that same order (nested classes before
|
||||
* the parent that references them), so an unchanged tool set produces
|
||||
* byte-identical text across assemblies. The sort is not a total order on
|
||||
* byte-equal names, so two schemas sharing a name would render in argument
|
||||
* order; the caller's visible-capability map is keyed by name, so the input
|
||||
* never carries a duplicate.
|
||||
* @param schemas - the tool schemas plus canonical output schemas to declare
|
||||
* (the caller excludes `run_code` itself).
|
||||
* @returns the complete section text.
|
||||
*/
|
||||
export function renderToolsSdkPy(schemas: ToolSdkSchema[]): string {
|
||||
const sorted = [...schemas].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
|
||||
const state: RenderState = { classes: [], usedClassNames: new Set(), nextClassCounter: new Map(), typing: new Set(['Protocol']) }
|
||||
// ONE ordered member stream, matching the documented lexicographic contract
|
||||
// and the TypeScript flavor (which quotes exotic keys in place rather than
|
||||
// partitioning them out). Interleaving is free here: a comment line between
|
||||
// two `async def` lines is not a statement, so it changes nothing about how
|
||||
// the class body parses.
|
||||
const members: string[] = []
|
||||
let statements = 0
|
||||
for (const schema of sorted) {
|
||||
const argType = renderType(schema.parameters, `${camelCase(schema.name)}Args`, state)
|
||||
const outputType = renderType(schema.output, `${camelCase(schema.name)}Output`, state)
|
||||
if (isBareIdentifier(schema.name) && !RESERVED.has(schema.name) && !schema.name.startsWith('_')) {
|
||||
// A docstring only documents its method when it is the FIRST statement
|
||||
// of that method's body. Emitted before the `async def` it would instead
|
||||
// become the `Tools` class docstring (for the first tool) or a dead
|
||||
// expression (for every later one), leaving every method undocumented —
|
||||
// and under `mode: 'code'` this SDK is the model's only description of
|
||||
// what a tool does. A docstring is a complete body, so the `...` stub is
|
||||
// only for the description-less case.
|
||||
const doc = docLines(schema.description, 2)
|
||||
members.push(doc.length > 0
|
||||
? `${pad(1)}async def ${schema.name}(self, args: ${argType}) -> ${outputType}:`
|
||||
: `${pad(1)}async def ${schema.name}(self, args: ${argType}) -> ${outputType}: ...`)
|
||||
members.push(...doc)
|
||||
statements += 1
|
||||
} else {
|
||||
// Not reachable as ``tools.name`` — the model reaches it via
|
||||
// ``tools[name]``. Exotic names and hard keywords are not legal
|
||||
// attributes at all; an underscore-leading name (``_foo``) IS a legal
|
||||
// attribute and is routed here anyway, because the forms that break
|
||||
// split three ways — a non-dunder ``__token`` name-mangles at the CALL
|
||||
// site, a dunder that exists on ``object``/``type`` (``__class__``,
|
||||
// ``__doc__``) resolves before ``__getattr__`` ever runs, and implicit
|
||||
// special-method lookup skips the hook entirely — and one rule over the
|
||||
// whole family costs nothing while a per-form rule would have to
|
||||
// enumerate them (see {@link RESERVED}). The stub lists it as a subscript comment
|
||||
// (referencing the named TypedDicts too) so a reader sees what is
|
||||
// accessible; runtime resolution goes through the proxy's __getitem__.
|
||||
members.push(`${pad(1)}# tools[${JSON.stringify(schema.name)}](args: ${argType}) -> ${outputType}`)
|
||||
const description = describe(schema)
|
||||
if (description !== undefined) members.push(`${pad(1)}# ${description}`)
|
||||
}
|
||||
}
|
||||
// Subscript entries are COMMENTS, not statements: a class body of only
|
||||
// comments fails to parse, so `pass` is required whenever no method was
|
||||
// emitted — including the subscript-only tool set.
|
||||
const bodyLines = statements > 0 ? members : [`${pad(1)}pass`, ...members]
|
||||
const body = bodyLines.join('\n')
|
||||
const imports = TYPING_ORDER.filter(symbol => state.typing.has(symbol))
|
||||
const classBlock = state.classes.length > 0 ? `${state.classes.join('\n\n')}\n\n` : ''
|
||||
const errorDeclaration = 'class ToolCallError(Exception):\n toolName: str'
|
||||
const declaration = `from typing import ${imports.join(', ')}\n\n${errorDeclaration}\n\n${classBlock}class Tools(Protocol):\n${body}\n\ntools: Tools`
|
||||
return `${SDK_INSTRUCTIONS}\n\n\`\`\`python\n${declaration}\n\`\`\``
|
||||
}
|
||||
@@ -262,7 +262,10 @@ The available tools:`
|
||||
* Render the full `tools:sdk` prompt section: the fixed usage instructions
|
||||
* plus one `declare const tools` interface covering every given tool.
|
||||
* Deterministic — tools are emitted in lexicographic name order, so an
|
||||
* unchanged tool set produces byte-identical text across assemblies.
|
||||
* unchanged tool set produces byte-identical text across assemblies. The sort
|
||||
* is not a total order on byte-equal names, so two schemas sharing a name
|
||||
* would render in argument order; the caller's visible-capability map is keyed
|
||||
* by name, so the input never carries a duplicate.
|
||||
* @param schemas - the tool schemas to declare (the caller excludes
|
||||
* `run_code` itself).
|
||||
* @returns the complete section text.
|
||||
|
||||
@@ -335,9 +335,89 @@ describe('mode-aware wire contribution', () => {
|
||||
await expect(systemPrompt.assemble()).rejects.toThrow(/requires a code runtime/)
|
||||
})
|
||||
|
||||
it("rejects every assembly when the runtime's language is not typescript", async () => {
|
||||
const { systemPrompt } = await setup({ mode: 'code', runtime: { language: 'python' } })
|
||||
await expect(systemPrompt.assemble()).rejects.toThrow(/language is "python"/)
|
||||
it('rejects every assembly when the runtime language has no registered SDK renderer', async () => {
|
||||
const { systemPrompt } = await setup({ mode: 'code', runtime: { language: 'ruby' } })
|
||||
await expect(systemPrompt.assemble()).rejects.toThrow(/no SDK renderer registered for runtime language "ruby"/)
|
||||
})
|
||||
|
||||
it('assembles under a python runtime by picking the Python SDK renderer', async () => {
|
||||
const { ctx, systemPrompt } = await setup({ mode: 'code', runtime: { language: 'python' } })
|
||||
registerEcho(ctx)
|
||||
const assembly = await systemPrompt.assemble()
|
||||
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')
|
||||
expect(sdk?.text).toContain('class Tools(Protocol):')
|
||||
expect(sdk?.text).toContain('async def echo(self, args:')
|
||||
expect(sdk?.text).toContain('top-level `await`')
|
||||
})
|
||||
|
||||
it("assembles under a python runtime in mode 'both' as well, SDK and schema together", async () => {
|
||||
// `both` reaches the same wireSchemas/requireCodeRuntime/SDK-section code
|
||||
// as `code`, so this pins the mode-by-language matrix rather than a
|
||||
// separate path — including that the `wireSchemas` projection behind
|
||||
// `assembly.tools` picks the Python flavor under `both` instead of hitting
|
||||
// the flavor-table guard.
|
||||
const { ctx, systemPrompt } = await setup({ mode: 'both', runtime: { language: 'python' } })
|
||||
registerEcho(ctx)
|
||||
const assembly = await systemPrompt.assemble()
|
||||
expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('class Tools(Protocol):')
|
||||
const runCodeSchema = assembly.tools.find(tool => tool.name === RUN_CODE_NAME)
|
||||
expect(runCodeSchema?.description).toContain('Execute a Python program')
|
||||
// `both` keeps the native tools alongside run_code; `code` does not.
|
||||
expect(assembly.tools.map(tool => tool.name)).toContain('echo')
|
||||
})
|
||||
|
||||
it('emits a TypeScript-flavored run_code schema under a typescript runtime', async () => {
|
||||
const { ctx, systemPrompt } = await setup({ mode: 'code', runtime: { language: 'typescript' } })
|
||||
registerEcho(ctx)
|
||||
const assembly = await systemPrompt.assemble()
|
||||
const runCodeSchema = assembly.tools.find(tool => tool.name === RUN_CODE_NAME)
|
||||
expect(runCodeSchema?.description).toContain('Execute a TypeScript program')
|
||||
expect(runCodeSchema?.description).toContain('BODY of an')
|
||||
const codeParam = (runCodeSchema?.parameters as { properties: { code: { description: string } } }).properties.code
|
||||
expect(codeParam.description).toBe('The program: the body of an async TypeScript function.')
|
||||
})
|
||||
|
||||
it('emits a Python-flavored run_code schema under a python runtime (matches the SDK language)', async () => {
|
||||
const { ctx, systemPrompt } = await setup({ mode: 'code', runtime: { language: 'python' } })
|
||||
registerEcho(ctx)
|
||||
const assembly = await systemPrompt.assemble()
|
||||
const runCodeSchema = assembly.tools.find(tool => tool.name === RUN_CODE_NAME)
|
||||
expect(runCodeSchema?.description).toContain('Execute a Python program')
|
||||
expect(runCodeSchema?.description).toContain('`return <value>`')
|
||||
expect(runCodeSchema?.description).not.toContain('TypeScript')
|
||||
const codeParam = (runCodeSchema?.parameters as { properties: { code: { description: string } } }).properties.code
|
||||
expect(codeParam.description).toBe('The program: the body of an async Python function.')
|
||||
})
|
||||
|
||||
it('resolves the run_code schema flavor lazily and fails loud on a language absent from the flavor table', async () => {
|
||||
// The flavor getter reads the runtime directly (peekRuntime), so it — not
|
||||
// requireCodeRuntime — owns the flavor-table guard. Keeping
|
||||
// RUN_CODE_FLAVORS in step with SDK_RENDERERS is the compiler's job (both
|
||||
// are `satisfies`-checked against CodeSdkLanguage), so what the guard
|
||||
// covers is a mounted runtime naming a language absent from both tables,
|
||||
// which throws when the schema is projected. Assembly's
|
||||
// requireCodeRuntime rejects such a language earlier; this reaches the
|
||||
// guard on its own.
|
||||
const { ctx } = await setup({ mode: 'code', runtime: { language: 'ruby' } })
|
||||
const definition = ctx.tools.get(RUN_CODE_NAME)
|
||||
// Names the known languages, symmetric with the SDK_RENDERERS guard: this
|
||||
// is the reachable rejection, so it must be at least as diagnosable.
|
||||
expect(() => definition?.description)
|
||||
.toThrow(/no run_code schema flavor registered for runtime language "ruby" \(known: "typescript", "python"\)/)
|
||||
})
|
||||
|
||||
it('degrades the run_code flavor to TypeScript when no runtime is mounted', async () => {
|
||||
// Any reader of the definition without a mounted runtime lands here; the
|
||||
// shipped one is the tool-catalog generator, which boots the registry under
|
||||
// `mode: code` and reads run_code's schema WITHOUT a runtime. peekRuntime
|
||||
// returns undefined there, so the flavor getter degrades to the TS default
|
||||
// rather than throwing. None of those readers feeds a model: assembly goes
|
||||
// through wireSchemas, which requires a runtime first.
|
||||
const { ctx } = await setup({ mode: 'code', runtime: false })
|
||||
const definition = ctx.tools.get(RUN_CODE_NAME)
|
||||
expect(definition?.description).toContain('Execute a TypeScript program')
|
||||
const params = definition?.parameters as { properties: { code: { description: string } } }
|
||||
expect(params.properties.code.description).toBe('The program: the body of an async TypeScript function.')
|
||||
})
|
||||
|
||||
it("rejects the assembly when toolOrder names a native tool that mode 'code' no longer contributes", async () => {
|
||||
|
||||
1163
packages/core/tools/tests/py-types.spec.ts
Normal file
1163
packages/core/tools/tests/py-types.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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/fs/fs-policy/README.md
|
||||
README.md: dc4e9377793570c80b8d71ec84196bebe7fe583a
|
||||
README.zh.md: 499192d2623765f214af7556d23beabbe2129ea1
|
||||
README.md: f6b3292bdc6e5565df0393a59c50d4e594921401
|
||||
README.zh.md: 2ebd2f054ece0472c7147f6f9e740987b11c6031
|
||||
|
||||
@@ -55,7 +55,7 @@ Because the plugin influences the world only through events, removing it does no
|
||||
|
||||
#### What the model sees
|
||||
|
||||
This plugin adds no prompt or schema. It rejects an edit without a prior read with code `FS_NOT_OBSERVED` and exact message `edit requires reading "<path>" first`. Guarded mutations whose observed version is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper; observation state is never shown.
|
||||
This plugin adds no prompt or schema. It rejects an edit without a prior read with code `FS_NOT_OBSERVED` and exact message `edit requires reading "<path>" first`. Guarded mutations whose observed version is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper, which appends the recovery instruction to `FS_STALE_VERSION` (`— re-read the file, then retry`) and `FS_NOT_OBSERVED` (`— read the file, then retry`) messages while preserving the code; observation state is never shown.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ await ctx.plugin(FsPolicy)
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
该插件不添加提示词或 schema。编辑前未读取时,它会以代码 `FS_NOT_OBSERVED` 和精确消息 `edit requires reading "<path>" first` 拒绝。观察版本陈旧的防护变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.md)拥有面向模型的错误包装;观察状态绝不会显示。
|
||||
该插件不添加提示词或 schema。编辑前未读取时,它会以代码 `FS_NOT_OBSERVED` 和精确消息 `edit requires reading "<path>" first` 拒绝。观察版本陈旧的防护变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.md)拥有面向模型的错误包装,会为 `FS_STALE_VERSION` 消息追加恢复指令(`— re-read the file, then retry`)、为 `FS_NOT_OBSERVED` 消息追加恢复指令(`— read the file, then retry`),同时保留错误码;观察状态绝不会显示。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -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/fs/tool-fs/README.md
|
||||
README.md: 1ecfa0d013e1208b7d9058b4a254990f16f118e0
|
||||
README.zh.md: 9d4de4c219e7818c83c5ac326d2f2d385a30ece7
|
||||
README.md: 28880860dc6c89745eb21fbf732d04f596f9b07f
|
||||
README.zh.md: 8af0aec51e71681211bbd5a4f582be8b8b0271b8
|
||||
|
||||
@@ -136,7 +136,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Failures are normalized as `Error: <message>`. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to <max>`, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "<path>": not found`, `cannot read "<path>": not a regular file`, and `offset <offset> is out of range for "<path>" (<total> lines)`; provider and policy templates are quoted in their package READMEs.
|
||||
Failures are normalized as `Error: <message>`. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to <max>`, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "<path>": not found`, `cannot read "<path>": not a regular file`, and `offset <offset> is out of range for "<path>" (<total> lines)`; provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` (including a missing edit target) gets `— re-read the file, then retry`, `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
失败会规范化为 `Error: <message>`。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to <max>`、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "<path>": not found`、`cannot read "<path>": not a regular file` 和 `offset <offset> is out of range for "<path>" (<total> lines)`;提供方和策略模板在各自包的 README 中逐字列出。
|
||||
失败会规范化为 `Error: <message>`。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to <max>`、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "<path>": not found`、`cannot read "<path>": not a regular file` 和 `offset <offset> is out of range for "<path>" (<total> lines)`;提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION`(包括编辑目标缺失)追加 `— re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `— read the file, then retry`;结构化错误码保持不变。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { computeHunkDiffs, diffsFromMeta } from './diff.ts'
|
||||
import { remediateFsError } from './error.ts'
|
||||
import { sessionResolveOptions } from './session-cwd.ts'
|
||||
import type { FsSandboxSurface } from './sandbox.ts'
|
||||
|
||||
@@ -116,10 +117,13 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
|
||||
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath, sandboxPolicy?.workspaceRoot))
|
||||
// Single-slot decision: the policy plugin returns { version: vObserved } or
|
||||
// throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit).
|
||||
// No stat — the bare default never manufactures a version basis.
|
||||
const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
|
||||
// No stat — the bare default never manufactures a version basis. The intent
|
||||
// slot itself can throw FS_NOT_OBSERVED for an unread target, so it sits
|
||||
// inside the try: both that refusal and the provider's guarded-mutation
|
||||
// failure get the model-facing remedy below.
|
||||
let outcome
|
||||
try {
|
||||
const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
|
||||
outcome = await ctx.fs.editText(
|
||||
target,
|
||||
{ oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll },
|
||||
@@ -128,8 +132,10 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
|
||||
sandboxPolicy,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
// A sandbox denial becomes the shared [sandbox: …] marker; any other error passes through.
|
||||
throw sandbox.mapError(error, sandboxPolicy)
|
||||
// A sandbox denial becomes the shared [sandbox: …] marker (the model
|
||||
// recognizes it from bash); stale/not-observed failures gain their
|
||||
// model-facing remedy; anything else passes through.
|
||||
throw remediateFsError(sandbox.mapError(error, sandboxPolicy))
|
||||
}
|
||||
// Record the observed version (a no-op when no policy plugin listens).
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
|
||||
34
packages/fs/tool-fs/src/error.ts
Normal file
34
packages/fs/tool-fs/src/error.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Model-facing remediation for guarded-mutation failures. The provider's
|
||||
* `FS_STALE_VERSION` and `FS_NOT_OBSERVED` messages state the condition but
|
||||
* not the only correct recovery (re-read / read the file), so this package
|
||||
* appends the remedy at the model boundary; provider messages stay
|
||||
* machine-oriented and unchanged.
|
||||
* @module @deepseek-ai/dsh-tool-fs/src/error
|
||||
*/
|
||||
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsErrorCode } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
/** The remedy appended to each remediable failure code's message. */
|
||||
const REMEDIES: Partial<Record<FsErrorCode, string>> = {
|
||||
FS_STALE_VERSION: 're-read the file, then retry',
|
||||
FS_NOT_OBSERVED: 'read the file, then retry',
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the correct recovery instruction to a guarded-mutation failure's
|
||||
* message. `FS_STALE_VERSION` (the file changed since this session's last
|
||||
* observation, including a missing target) recovers only by re-reading;
|
||||
* `FS_NOT_OBSERVED` (no prior read by this session) by reading. The `FsError`
|
||||
* code is preserved so retry/permission/UI layers keep routing on it, and the
|
||||
* original error chains as `cause`. Anything else passes through untouched.
|
||||
* @param error - the caught value from a write/edit execution.
|
||||
* @returns a remediated `FsError` for the two guarded-mutation codes, else the original value.
|
||||
*/
|
||||
export function remediateFsError(error: unknown): unknown {
|
||||
if (!(error instanceof FsError)) return error
|
||||
const remedy = REMEDIES[error.code]
|
||||
if (!remedy) return error
|
||||
return new FsError(`${error.message} — ${remedy}`, error.code, { cause: error })
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { computeHunkDiffs, diffsFromMeta } from './diff.ts'
|
||||
import { remediateFsError } from './error.ts'
|
||||
import { sessionResolveOptions } from './session-cwd.ts'
|
||||
import type { FsSandboxSurface } from './sandbox.ts'
|
||||
|
||||
@@ -113,8 +114,9 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
|
||||
outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxPolicy)
|
||||
} catch (error: unknown) {
|
||||
// A sandbox denial becomes the shared [sandbox: …] marker (the model
|
||||
// recognizes it from bash); any other error passes through.
|
||||
throw sandbox.mapError(error, sandboxPolicy)
|
||||
// recognizes it from bash); stale/not-observed failures gain their
|
||||
// model-facing remedy; anything else passes through.
|
||||
throw remediateFsError(sandbox.mapError(error, sandboxPolicy))
|
||||
}
|
||||
// Record the observed version (a no-op when no policy plugin listens).
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
|
||||
35
packages/fs/tool-fs/tests/error.spec.ts
Normal file
35
packages/fs/tool-fs/tests/error.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Unit tests for the model-facing error remediation: the remedy appended to
|
||||
* guarded-mutation failures, code preservation, and passthrough behavior.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import { remediateFsError } from '../src/error.ts'
|
||||
|
||||
describe('remediateFsError', () => {
|
||||
it('appends the re-read remedy to FS_STALE_VERSION, preserving the code and chaining the cause', () => {
|
||||
const original = new FsError('cannot edit "x": file changed since it was read', 'FS_STALE_VERSION')
|
||||
const remedied = remediateFsError(original) as FsError
|
||||
expect(remedied).toBeInstanceOf(FsError)
|
||||
expect(remedied.message).toBe('cannot edit "x": file changed since it was read — re-read the file, then retry')
|
||||
expect(remedied.code).toBe('FS_STALE_VERSION')
|
||||
expect(remedied.cause).toBe(original)
|
||||
})
|
||||
|
||||
it('appends the read remedy to FS_NOT_OBSERVED', () => {
|
||||
const remedied = remediateFsError(new FsError('edit requires reading "x" first', 'FS_NOT_OBSERVED')) as FsError
|
||||
expect(remedied.message).toBe('edit requires reading "x" first — read the file, then retry')
|
||||
expect(remedied.code).toBe('FS_NOT_OBSERVED')
|
||||
})
|
||||
|
||||
it('leaves other FsError codes untouched', () => {
|
||||
const original = new FsError('no match anywhere', 'FS_EDIT_NOT_FOUND')
|
||||
expect(remediateFsError(original)).toBe(original)
|
||||
})
|
||||
|
||||
it('leaves non-FsError values untouched', () => {
|
||||
const original = new Error('boom')
|
||||
expect(remediateFsError(original)).toBe(original)
|
||||
})
|
||||
})
|
||||
@@ -71,6 +71,9 @@ describe('default deployment (with dsh-fs-policy)', () => {
|
||||
const result = await call('write', { file_path: 'a.txt', content: 'clobber' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
|
||||
// The model-facing text names the remedy, not just the condition.
|
||||
expect(text(result)).toContain('without reading it first')
|
||||
expect(text(result)).toContain('read the file, then retry')
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original')
|
||||
})
|
||||
|
||||
@@ -89,6 +92,23 @@ describe('default deployment (with dsh-fs-policy)', () => {
|
||||
const result = await call('write', { file_path: 'a.txt', content: 'replaced' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
|
||||
// The model-facing text names the remedy, not just the condition.
|
||||
expect(text(result)).toContain('file changed since it was read')
|
||||
expect(text(result)).toContain('re-read the file, then retry')
|
||||
})
|
||||
|
||||
it('the stale remedy is actionable: re-reading the changed file unblocks the retried write', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'original')
|
||||
await call('read', { file_path: 'a.txt' })
|
||||
await writeFile(join(dir, 'a.txt'), 'changed-externally') // out-of-band change
|
||||
const stale = await call('write', { file_path: 'a.txt', content: 'replaced' })
|
||||
expect(stale.isError).toBe(true)
|
||||
expect(stale.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
|
||||
// Follow the remedy: re-read (refreshes the observed version), then retry.
|
||||
expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false)
|
||||
const retried = await call('write', { file_path: 'a.txt', content: 'replaced' })
|
||||
expect(retried.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('replaced')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -131,6 +151,9 @@ describe('default deployment (with dsh-fs-policy)', () => {
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
|
||||
// The policy's refusal reaches the model with the read remedy appended.
|
||||
expect(text(result)).toContain('edit requires reading')
|
||||
expect(text(result)).toContain('read the file, then retry')
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world')
|
||||
})
|
||||
|
||||
@@ -155,6 +178,23 @@ describe('default deployment (with dsh-fs-policy)', () => {
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
|
||||
// The model-facing text names the remedy, not just the condition.
|
||||
expect(text(result)).toContain('file changed since it was read')
|
||||
expect(text(result)).toContain('re-read the file, then retry')
|
||||
})
|
||||
|
||||
it('the stale remedy is actionable: re-reading the changed file unblocks the retried edit', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
await call('read', { file_path: 'a.txt' })
|
||||
await writeFile(join(dir, 'a.txt'), 'hello brave world') // out-of-band change
|
||||
const stale = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(stale.isError).toBe(true)
|
||||
expect(stale.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
|
||||
// Follow the remedy: re-read (refreshes the observed version), then retry.
|
||||
expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false)
|
||||
const retried = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(retried.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello brave there')
|
||||
})
|
||||
|
||||
it('rejects an ambiguous match without replace_all', async () => {
|
||||
@@ -194,6 +234,43 @@ describe('default deployment (with dsh-fs-policy)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleted observed target (fail-closed corner)', () => {
|
||||
it('a deleted observed file stays un-writable and un-editable in-session: the remedy cannot unblock it', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'original')
|
||||
await call('read', { file_path: 'a.txt' })
|
||||
await rm(join(dir, 'a.txt')) // out-of-band deletion
|
||||
|
||||
// Edit of the missing target: stale (the missing-target path shares the
|
||||
// stale code and the re-read remedy).
|
||||
const edit = await call('edit', { file_path: 'a.txt', old_string: 'original', new_string: 'x' })
|
||||
expect(edit.isError).toBe(true)
|
||||
expect(edit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
|
||||
|
||||
// Re-reading the missing file FAILS with FS_NOT_FOUND and records no
|
||||
// observation, so the retried edit fails identically: the observed entry
|
||||
// is never cleared for a deleted target.
|
||||
const reread = await call('read', { file_path: 'a.txt' })
|
||||
expect(reread.isError).toBe(true)
|
||||
expect(reread.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
|
||||
const retriedEdit = await call('edit', { file_path: 'a.txt', old_string: 'original', new_string: 'x' })
|
||||
expect(retriedEdit.isError).toBe(true)
|
||||
expect(retriedEdit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
|
||||
|
||||
// Write cannot recreate it either: the stale observation still forces
|
||||
// replaceIfVersion, which rejects a missing target ("file no longer exists").
|
||||
const write = await call('write', { file_path: 'a.txt', content: 'fresh' })
|
||||
expect(write.isError).toBe(true)
|
||||
expect(write.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
|
||||
|
||||
// The dead end lifts once the file exists again and is freshly observed.
|
||||
await writeFile(join(dir, 'a.txt'), 'restored')
|
||||
expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false)
|
||||
const recovered = await call('write', { file_path: 'a.txt', content: 'fresh' })
|
||||
expect(recovered.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('fresh')
|
||||
})
|
||||
})
|
||||
|
||||
describe('stat budget', () => {
|
||||
it('read stats once; write and edit never stat in the tool (the gate stats zero too)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
@@ -264,6 +341,9 @@ describe('bare provider (no dsh-fs-policy)', () => {
|
||||
const result = await call('edit', { file_path: 'missing.txt', old_string: 'a', new_string: 'b' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
|
||||
// Even without policy, the stale text carries the re-read remedy.
|
||||
expect(text(result)).toContain('file changed since it was read')
|
||||
expect(text(result)).toContain('re-read the file, then retry')
|
||||
})
|
||||
|
||||
it('edit still enforces literal-match codes (FS_EDIT_NOT_FOUND), unrelated to freshness', async () => {
|
||||
|
||||
@@ -397,12 +397,13 @@ describe('write tool', () => {
|
||||
expect(text(result)).toContain('file_path must be a non-empty string')
|
||||
})
|
||||
|
||||
it('propagates a backend FsError as an isError result carrying its code', async () => {
|
||||
it('propagates a backend FsError as an isError result carrying its code and remedy', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.rejectWith = new FsError('blocked', 'FS_STALE_VERSION')
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ info: { name: 'FsError', code: 'FS_STALE_VERSION' } })
|
||||
expect(text(result)).toContain('re-read the file, then retry')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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/host/apiproxy/README.md
|
||||
README.md: 0963476a767801b465a6ead24feb0ecc9988b5f5
|
||||
README.zh.md: e3634c5f92f3a3723eb3c14e39223d9d9550c6f9
|
||||
README.md: 395e0d5085878e230fdf7de49a0ca47745bdc270
|
||||
README.zh.md: 2ef34f7d6e7ae031dd5f847dfa13827fe4550839
|
||||
|
||||
@@ -32,7 +32,7 @@ A stale continuation discards every partial result, deduplication entry, and cur
|
||||
|
||||
Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method does not use the default 30-second unary timeout, while caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request.
|
||||
|
||||
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
|
||||
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). WSL translates the Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item` instead of assuming a Linux desktop association. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
|
||||
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,不使用默认的 30 秒一元调用超时,而调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。
|
||||
|
||||
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
|
||||
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。WSL 会通过 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,而非假定存在 Linux 桌面文件关联。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Cross-platform native path and text-document openers used by the local GUI carrier. */
|
||||
|
||||
import { release as osRelease } from 'node:os'
|
||||
import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
|
||||
|
||||
/** Testable command boundary; native implementations never invoke a shell. */
|
||||
@@ -8,6 +9,10 @@ export type PathOpenerRunner = NativeCommandRunner
|
||||
/** Injectable platform facts for deterministic adapter tests. */
|
||||
export interface PathOpenerInternals {
|
||||
platform?: NodeJS.Platform
|
||||
/** Kernel release override used to distinguish WSL from desktop Linux. */
|
||||
osRelease?: string
|
||||
/** WSL environment marker override used with the kernel release. */
|
||||
env?: Readonly<Partial<Record<'WSL_DISTRO_NAME' | 'WSL_INTEROP', string>>>
|
||||
run?: PathOpenerRunner
|
||||
}
|
||||
|
||||
@@ -19,6 +24,36 @@ function powershellLiteral(path: string): string {
|
||||
return `'${path.replace(/'/g, "''")}'`
|
||||
}
|
||||
|
||||
/** Whether one environment marker is set to a non-empty value. */
|
||||
function present(value: string | undefined): boolean {
|
||||
return value !== undefined && value !== ''
|
||||
}
|
||||
|
||||
/** Distinguish WSL from desktop Linux using its process and kernel markers. */
|
||||
function isWsl(internals: PathOpenerInternals): boolean {
|
||||
const env = internals.env ?? process.env
|
||||
if (present(env.WSL_DISTRO_NAME) || present(env.WSL_INTEROP)) return true
|
||||
return (internals.osRelease ?? osRelease()).toLowerCase().includes('microsoft')
|
||||
}
|
||||
|
||||
/** Open one Windows-resolvable path through its registered desktop application. */
|
||||
async function openWindowsPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise<void> {
|
||||
await run('powershell.exe', [
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
`Invoke-Item -LiteralPath ${powershellLiteral(path)}`,
|
||||
], signal)
|
||||
}
|
||||
|
||||
/** Translate a WSL path before handing it to the Windows desktop. */
|
||||
async function openWslPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise<void> {
|
||||
const translated = await run('wslpath', ['-w', path], signal)
|
||||
signal.throwIfAborted()
|
||||
const windowsPath = translated.stdout.replace(/[\r\n]+$/, '')
|
||||
if (windowsPath === '') throw new Error('wslpath returned no Windows path')
|
||||
await openWindowsPath(windowsPath, signal, run)
|
||||
}
|
||||
|
||||
/** Dispatch one shell-free platform command for the requested open intent. */
|
||||
async function openNativePathWithIntent(
|
||||
path: string,
|
||||
@@ -35,15 +70,15 @@ async function openNativePathWithIntent(
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
await run('powershell.exe', [
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
`Invoke-Item -LiteralPath ${powershellLiteral(path)}`,
|
||||
], signal)
|
||||
await openWindowsPath(path, signal, run)
|
||||
return
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
if (isWsl(internals)) {
|
||||
await openWslPath(path, signal, run)
|
||||
return
|
||||
}
|
||||
await run('xdg-open', [path], signal)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn<ExecFileMock>()
|
||||
|
||||
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
|
||||
|
||||
import { release as osRelease } from 'node:os'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/native-path-opener.ts'
|
||||
|
||||
@@ -34,10 +35,58 @@ describe('native path opener', () => {
|
||||
|
||||
it('uses the Linux desktop association for text documents', async () => {
|
||||
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
|
||||
await openNativeTextFile('/tmp/settings.yaml', signal(), { platform: 'linux', run })
|
||||
await openNativeTextFile('/tmp/settings.yaml', signal(), {
|
||||
platform: 'linux', osRelease: '6.8.0-generic', env: {}, run,
|
||||
})
|
||||
expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/settings.yaml'], expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['distribution marker', { WSL_DISTRO_NAME: 'Ubuntu' }, '6.8.0-generic'],
|
||||
['interop marker', { WSL_INTEROP: '/run/WSL/123_interop' }, '6.8.0-generic'],
|
||||
['kernel release', {}, '5.15.153.1-microsoft-standard-WSL2'],
|
||||
])('hands WSL text documents to the Windows desktop from the %s', async (_label, env, osRelease) => {
|
||||
const requestSignal = signal()
|
||||
const run = vi.fn<PathOpenerRunner>(async command => command === 'wslpath'
|
||||
? { stdout: '\\\\wsl.localhost\\Ubuntu\\home\\test user\\settings.yaml\r\n', stderr: '' }
|
||||
: { stdout: '', stderr: '' })
|
||||
await openNativeTextFile('/home/test user/settings.yaml', requestSignal, {
|
||||
platform: 'linux', osRelease, env, run,
|
||||
})
|
||||
expect(run.mock.calls).toEqual([
|
||||
['wslpath', ['-w', '/home/test user/settings.yaml'], requestSignal],
|
||||
[
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
"Invoke-Item -LiteralPath '\\\\wsl.localhost\\Ubuntu\\home\\test user\\settings.yaml'",
|
||||
],
|
||||
requestSignal,
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects an empty WSL path translation before invoking Windows', async () => {
|
||||
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '\r\n', stderr: '' }))
|
||||
await expect(openNativeTextFile('/home/test/settings.yaml', signal(), {
|
||||
platform: 'linux', osRelease: '6.8.0-generic', env: { WSL_DISTRO_NAME: 'Ubuntu' }, run,
|
||||
})).rejects.toThrow('wslpath returned no Windows path')
|
||||
expect(run).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not invoke Windows when the request aborts during WSL path translation', async () => {
|
||||
const abort = new AbortController()
|
||||
const run = vi.fn<PathOpenerRunner>(async () => {
|
||||
abort.abort(new Error('closed'))
|
||||
return { stdout: '\\\\wsl.localhost\\Ubuntu\\home\\test\\settings.yaml\n', stderr: '' }
|
||||
})
|
||||
await expect(openNativeTextFile('/home/test/settings.yaml', abort.signal, {
|
||||
platform: 'linux', osRelease: '6.8.0-generic', env: { WSL_DISTRO_NAME: 'Ubuntu' }, run,
|
||||
})).rejects.toThrow('closed')
|
||||
expect(run).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('opens with Windows Invoke-Item and escapes single quotes', async () => {
|
||||
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
|
||||
await openNativePath("C:\\work\\o'reilly.txt", signal(), { platform: 'win32', run })
|
||||
@@ -60,7 +109,10 @@ describe('native path opener', () => {
|
||||
|
||||
it('opens with Linux xdg-open', async () => {
|
||||
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
|
||||
await openNativePath('/tmp/a.txt', signal(), { platform: 'linux', run })
|
||||
await openNativePath('/tmp/a.txt', signal(), {
|
||||
platform: 'linux', osRelease: '6.8.0-generic',
|
||||
env: { WSL_DISTRO_NAME: '', WSL_INTEROP: '' }, run,
|
||||
})
|
||||
expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/a.txt'], expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
@@ -71,7 +123,9 @@ describe('native path opener', () => {
|
||||
|
||||
it('uses the current process platform when no platform override is supplied', async () => {
|
||||
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
|
||||
await openNativePath('/tmp/platform-default.txt', signal(), { run })
|
||||
await openNativePath('/tmp/platform-default.txt', signal(), {
|
||||
osRelease: '6.8.0-generic', env: {}, run,
|
||||
})
|
||||
const expected = process.platform === 'win32'
|
||||
? 'powershell.exe'
|
||||
: process.platform === 'linux'
|
||||
@@ -80,6 +134,17 @@ describe('native path opener', () => {
|
||||
expect(run.mock.calls[0]?.[0]).toBe(expected)
|
||||
})
|
||||
|
||||
it('samples ambient WSL markers and kernel release when no fact overrides are supplied', async () => {
|
||||
const ambientWsl = [process.env.WSL_DISTRO_NAME, process.env.WSL_INTEROP]
|
||||
.some(value => value !== undefined && value !== '')
|
||||
|| osRelease().toLowerCase().includes('microsoft')
|
||||
const run = vi.fn<PathOpenerRunner>(async command => command === 'wslpath'
|
||||
? { stdout: 'C:\\settings.yaml\n', stderr: '' }
|
||||
: { stdout: '', stderr: '' })
|
||||
await openNativePath('/tmp/ambient-facts.yaml', signal(), { platform: 'linux', run })
|
||||
expect(run.mock.calls[0]?.[0]).toBe(ambientWsl ? 'wslpath' : 'xdg-open')
|
||||
})
|
||||
|
||||
it('runs the default command adapter without a shell and preserves command failures', async () => {
|
||||
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
|
||||
callback(null, '', '')
|
||||
|
||||
@@ -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/llm/llm-deepseek/README.md
|
||||
README.md: 0cd265cadb2b2a619613761062ab2cef209bec83
|
||||
README.zh.md: 1883b054277adfd6c3d02b2a76ead9b3f8b0138f
|
||||
README.md: c6435d0bdfbb9758b6f86ef38e94159a9ccdc36d
|
||||
README.zh.md: f37286ade023ceecf6bfc87eb08fd4d80a5a3912
|
||||
|
||||
@@ -53,7 +53,7 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und
|
||||
Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk:
|
||||
|
||||
- **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load.
|
||||
- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between.
|
||||
- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a trimmed, non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Whitespace-only literals are absent rather than Authorization values. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. Every key is format-checked before use — a literal at connection-facts resolution (plugin load, or the next settings snapshot), a stored or ambient value at request time — so a value no HTTP header can carry is refused there instead of surfacing as an opaque `fetch` `TypeError`; the request-time check throws `LlmError('INVALID_CREDENTIAL')` naming the failing entry point but never any part of the key. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between.
|
||||
|
||||
The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek-official')` always reports the current policy.
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
|
||||
连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk:
|
||||
|
||||
- **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。
|
||||
- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。
|
||||
- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:去除首尾空白后非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。纯空白字面值会被视为缺失,而不会成为 Authorization 值。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。每个密钥在使用前都会被校验格式——字面量在连接事实解析时(插件加载或下一次 settings 快照)校验,已存储的值或环境变量值则在请求时校验——因此 HTTP 标头无法承载的值会在这一步被拒绝,而不是以语义不明的 `fetch` `TypeError` 形式浮现;请求时校验会抛出 `LlmError('INVALID_CREDENTIAL')`,点名失败的入口,但绝不透露密钥的任何部分。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。
|
||||
|
||||
唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek-official')` 始终报告当前策略。
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
|
||||
import { assertUsableApiKey, LlmError, normalizeApiKey, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
@@ -58,7 +58,13 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
|
||||
* reasoning effort resolves to `high`.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */
|
||||
/**
|
||||
* Trimmed literal API key; whitespace-only is absent, so it resolves through
|
||||
* {@link apiKeyEnv} like an omitted one. Prefer {@link apiKeyEnv} to keep
|
||||
* secrets out of configuration files. {@link resolveAdapterOptions} also
|
||||
* format-checks what remains: a value no HTTP header can carry fails there
|
||||
* rather than inside `fetch`.
|
||||
*/
|
||||
apiKey?: string
|
||||
/** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */
|
||||
apiKeyEnv?: string
|
||||
@@ -174,8 +180,25 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions {
|
||||
`llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
|
||||
)
|
||||
}
|
||||
// An absent apiKey is not a failure: it falls through to apiKeyEnv below.
|
||||
// A supplied one must be usable, so a malformed literal fails here beside
|
||||
// the other beyond-schema bounds instead of inside `fetch`.
|
||||
// Absence is not a failure, and a blank literal is absence: both resolve
|
||||
// through apiKeyEnv below, which is this adapter's defined fallback. (The
|
||||
// pi-ai adapter refuses a blank one instead, because there absence selects a
|
||||
// different authentication mode rather than a different source for the same
|
||||
// key.) What a literal cannot be is unusable: a value no HTTP header can
|
||||
// carry fails here beside the other beyond-schema bounds, not inside `fetch`.
|
||||
let apiKey: string | undefined
|
||||
if (config.apiKey !== undefined) {
|
||||
const checked = normalizeApiKey(config.apiKey)
|
||||
if (!checked.ok && checked.reason === 'illegalCharacters') {
|
||||
throw new Error('llm-deepseek: apiKey contains characters no HTTP header can carry; paste the raw key only')
|
||||
}
|
||||
apiKey = checked.ok ? checked.value : undefined
|
||||
}
|
||||
return {
|
||||
...config.apiKey !== undefined && config.apiKey.length > 0 ? { apiKey: config.apiKey } : {},
|
||||
...apiKey === undefined ? {} : { apiKey },
|
||||
apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
|
||||
baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL,
|
||||
defaults: {
|
||||
@@ -223,12 +246,12 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const credentials = ctx.get('credentials')
|
||||
if (credentials !== undefined) {
|
||||
const hit = await credentials.resolve(ref)
|
||||
if (hit !== undefined) return hit.value
|
||||
if (hit !== undefined) return assertUsableApiKey(hit.value, 'llm-deepseek', ref)
|
||||
} else {
|
||||
// Without the seam, keep the historical ambient fallback so a plain
|
||||
// cordis.yml composition works from the environment alone.
|
||||
const ambient = process.env[ref]
|
||||
if (ambient !== undefined && ambient.length > 0) return ambient
|
||||
if (ambient !== undefined && ambient.length > 0) return assertUsableApiKey(ambient, 'llm-deepseek', ref)
|
||||
}
|
||||
throw new LlmError(
|
||||
`llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials`
|
||||
|
||||
@@ -699,6 +699,13 @@ describe('plugin registration and config', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes a literal API key and treats whitespace as absent', () => {
|
||||
expect(resolveAdapterOptions({ apiKey: ' key ' }).apiKey).toBe('key')
|
||||
const whitespace = resolveAdapterOptions({ apiKey: ' \t ', apiKeyEnv: 'CUSTOM_API_KEY' })
|
||||
expect(whitespace.apiKey).toBeUndefined()
|
||||
expect(whitespace.apiKeyEnv).toBe('CUSTOM_API_KEY')
|
||||
})
|
||||
|
||||
it('uses the default model catalog when apply is called directly', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
@@ -991,3 +998,38 @@ describe('plugin registration and config', () => {
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('API key format', () => {
|
||||
it('trims a padded literal apiKey', () => {
|
||||
expect(resolveAdapterOptions({ apiKey: ' sk-abc ' }).apiKey).toBe('sk-abc')
|
||||
})
|
||||
|
||||
it('leaves an omitted apiKey absent so apiKeyEnv still resolves it', () => {
|
||||
expect(resolveAdapterOptions({}).apiKey).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats a whitespace-only literal apiKey as absent, not as a failure', () => {
|
||||
// This adapter's absence has a defined fallback, so a blank literal
|
||||
// resolves through apiKeyEnv like an omitted one. (llm-pi-ai refuses a
|
||||
// blank one instead: there, absence selects provider-native or OAuth
|
||||
// authentication rather than a different source for the same key.)
|
||||
const resolved = resolveAdapterOptions({ apiKey: ' ', apiKeyEnv: 'CUSTOM_API_KEY' })
|
||||
expect(resolved.apiKey).toBeUndefined()
|
||||
expect(resolved.apiKeyEnv).toBe('CUSTOM_API_KEY')
|
||||
})
|
||||
|
||||
it('rejects a literal apiKey no header can carry', () => {
|
||||
expect(() => resolveAdapterOptions({ apiKey: 'sk-\u{1F600}' }))
|
||||
.toThrow(/no HTTP header can carry/)
|
||||
})
|
||||
|
||||
it('never echoes the key in the rejection', () => {
|
||||
const secret = 'sk-\u{1F600}supersecret'
|
||||
expect(() => resolveAdapterOptions({ apiKey: secret })).toThrow()
|
||||
try {
|
||||
resolveAdapterOptions({ apiKey: secret })
|
||||
} catch (error) {
|
||||
expect((error as Error).message).not.toContain('supersecret')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Context } from 'cordis'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import LlmService, { INVALID_CREDENTIAL_CODE } from '@deepseek-ai/dsh-llm'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
@@ -103,6 +103,25 @@ describe('request-level dynamic configuration', () => {
|
||||
expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived')
|
||||
})
|
||||
|
||||
it('rejects a stored credential no header can carry, never echoing it in the failure', async () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
||||
const dir = await home()
|
||||
const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' })
|
||||
const secret = 'sk-\u{1F600}supersecret'
|
||||
|
||||
// The real credentials seam (the path the web Models page writes through),
|
||||
// not a hand-built stub: this package's own dynamic-config harness already
|
||||
// boots one, and round-tripping the value through its actual store/read
|
||||
// path is stronger evidence than a canned in-memory return would be.
|
||||
await ctx.credentials.set(KEY_REF, secret)
|
||||
const result = await prompt(ctx)
|
||||
expect(result.finish).toMatchObject({ kind: 'error', failure: { code: INVALID_CREDENTIAL_CODE } })
|
||||
if (result.finish.kind !== 'error') throw new Error('expected an error finish')
|
||||
expect(result.finish.failure.message).not.toContain(secret)
|
||||
expect(result.finish.failure.message).not.toContain('supersecret')
|
||||
expect(result.finish.failure.message).not.toContain('ByteString')
|
||||
})
|
||||
|
||||
it('advertises a live settings catalog without re-registration', async () => {
|
||||
const dir = await home()
|
||||
const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
|
||||
@@ -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/llm/llm-pi-ai/README.md
|
||||
README.md: af0e952dd8dbd9767b98229ee6b87262007d6738
|
||||
README.zh.md: f8a19999f08aa8a6963874d57bf74370797b951c
|
||||
README.md: 0dcf15d6caf365a1f8e75088cb363eaa6560a6ec
|
||||
README.zh.md: 79be5d320c0f4411f7cf8a0bd72c887048929dcb
|
||||
|
||||
@@ -67,7 +67,7 @@ Resolution still fails loud, naming the offending route and model, when a route
|
||||
|
||||
The adapter reads its profiles through a thunk **once per operation** instead of freezing them at construction. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`, and because `providers` is a dict, the base and the user's `llm-pi-ai:` settings section merge **per provider**: a user can add a route, override one field of a composition route, or point a route at another proxy, all effective on the next request with no restart. Without a mounted settings service the entry config alone drives the adapter, unchanged.
|
||||
|
||||
Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A section this adapter could not serve is refused where it is written — the registered `validate` resolves the whole profile set, so `ctx.settings.mutate` rejects with the resolver's own error (the wire surface reports it as `settings-rejected`) and nothing is stored. A stored section that becomes unserviceable some other way — an external edit of `settings.yaml` — keeps the namespace's last good value at the settings seam and warns. The entry config itself still fails plugin load, and a route the llm registry refuses (one another adapter family already owns) is logged while the previously registered routes keep serving.
|
||||
Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. Every key is trimmed and format-checked before use — a literal `apiKey` when profiles resolve (plugin load, or the next settings snapshot), a value `apiKeyEnv` resolves at request time — so a value no HTTP header can carry is refused there instead of surfacing as an opaque `fetch` `TypeError`; the request-time refusal throws `LlmError('INVALID_CREDENTIAL')` naming the failing route and credential reference but never any part of the key. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A section this adapter could not serve is refused where it is written — the registered `validate` resolves the whole profile set, so `ctx.settings.mutate` rejects with the resolver's own error (the wire surface reports it as `settings-rejected`) and nothing is stored. A stored section that becomes unserviceable some other way — an external edit of `settings.yaml` — keeps the namespace's last good value at the settings seam and warns. The entry config itself still fails plugin load, and a route the llm registry refuses (one another adapter family already owns) is logged while the previously registered routes keep serving.
|
||||
|
||||
The adapter exposes each configured route's models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata read from the same pi-ai `Models` collection the request path uses, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, configured output cap, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. A model's **configured** `maxTokens` becomes the seam's `defaultMaxTokens`, so a request that names no output cap carries the one the deployment chose; a value inherited from the installed catalog is the model's output *capability* and never becomes a request default on its own.
|
||||
|
||||
@@ -85,7 +85,7 @@ The plugin offers `ctx.llm.registerModelDiscovery('llm-pi-ai', …)`, which answ
|
||||
|
||||
A request naming a route the **installed catalog ships is answered from that catalog**, with no network call: pi-ai's registry is the authoritative list for its own providers, and it carries the context windows and output caps a listing endpoint would not disclose. Such a route needs no `baseURL` at all. Only a route the catalog does not describe — a gateway, a self-hosted server — is interrogated over the wire, and one that names no endpoint is told to set one or enter its models by hand.
|
||||
|
||||
A draft carries the credential the user typed, if any; a route that already stored one shows a configuration surface only a redacted descriptor, so the interrogation supplies that route's own credential — resolved exactly as a request to it would, `apiKey` then `apiKeyEnv` — rather than going out unauthenticated and reporting the endpoint's 401 as a wrong key. A typed key wins, being the one under test. Resolution happens only on the path that reaches the network, so a catalog route answers without touching credentials at all.
|
||||
A draft carries the credential the user typed, if any; a route that already stored one shows a configuration surface only a redacted descriptor, so the interrogation supplies that route's own credential — resolved exactly as a request to it would, `apiKey` then `apiKeyEnv` — rather than going out unauthenticated and reporting the endpoint's 401 as a wrong key. A typed key wins, being the one under test. Resolution happens only on the path that reaches the network, so a catalog route answers without touching credentials at all. A supplied or stored probe key is trimmed and format-checked the same way, so a value no HTTP header can carry is refused immediately as `LlmError('INVALID_CREDENTIAL')` instead of reaching `fetch`, where it would surface as an opaque `ByteString` failure indistinguishable from an unreachable endpoint.
|
||||
|
||||
Interrogation reads `openai-completions` and `openai-responses`, whose `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; every other protocol answers `DISCOVERY_UNSUPPORTED` so the surface falls back to hand-entry instead of an authentication failure being reported as a provider with no models. The `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.
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩
|
||||
|
||||
适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`;由于 `providers` 是字典,base 与用户的 `llm-pi-ai:` settings 分节**按提供方**合并:用户可以新增路由、覆盖组合路由的单个字段,或把路由指向另一个 proxy,全部在下一次请求生效,无需重启。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。
|
||||
|
||||
凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。本适配器无法服务的分节会在写入处被拒——注册的 `validate` 会解析整份 profile 集合,因此 `ctx.settings.mutate` 以 resolver 自身的错误拒绝(协议面将其报为 `settings-rejected`),什么都不会存储。已存储分节若因其他途径变得不可服务——比如外部编辑了 `settings.yaml`——则由 settings seam 保留该 namespace 最后可用的值并告警。entry 配置本身仍会使插件加载失败;而 llm 注册表拒绝的路由(已被另一适配器族占有的那种)会被记录下来,先前注册的路由继续服务。
|
||||
凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。每个密钥在使用前都会被去除首尾空白并校验格式——字面 `apiKey` 在 profile 解析时(插件加载,或下一次 settings 快照)校验,`apiKeyEnv` 解析出的值则在请求时校验——因此 HTTP 标头无法承载的值会在这一步被拒绝,而不是以语义不明的 `fetch` `TypeError` 形式浮现;请求时的拒绝会抛出 `LlmError('INVALID_CREDENTIAL')`,点名失败的路由与凭据引用,但绝不透露密钥的任何部分。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。本适配器无法服务的分节会在写入处被拒——注册的 `validate` 会解析整份 profile 集合,因此 `ctx.settings.mutate` 以 resolver 自身的错误拒绝(协议面将其报为 `settings-rejected`),什么都不会存储。已存储分节若因其他途径变得不可服务——比如外部编辑了 `settings.yaml`——则由 settings seam 保留该 namespace 最后可用的值并告警。entry 配置本身仍会使插件加载失败;而 llm 注册表拒绝的路由(已被另一适配器族占有的那种)会被记录下来,先前注册的路由继续服务。
|
||||
|
||||
适配器通过 `ctx.llm.listModels(provider)` 公开每条已配置路由的模型。这是从请求路径所用的同一个 pi-ai `Models` 集合读取的提供方无关 selector 元数据,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口、已配置输出上限和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。模型**已配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`,因此未点名输出上限的请求会携带部署选定的那一个;而从已安装 catalog 继承来的值是模型的输出**能力**,绝不会自行变成请求默认值。
|
||||
|
||||
@@ -85,7 +85,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩
|
||||
|
||||
点名了**已安装 catalog 所提供路由**的请求,直接由该 catalog 作答,完全不联网:pi-ai 的注册表才是它自家提供方的权威列表,且携带列表端点不会公布的上下文窗口与输出上限。这类路由根本不需要 `baseURL`。只有 catalog 未描述的路由——网关、自建服务——才会经协议层询问;若它也没给端点,则会被告知去设置一个或手工填写模型。
|
||||
|
||||
草稿携带的是用户当下键入的凭据(如果有);已经存好凭据的路由,在配置界面上只呈现一个脱敏描述符,因此询问会自行取用该路由的凭据——解析方式与向它发请求时完全一致,先 `apiKey` 后 `apiKeyEnv`——而不是不带认证发出去、再把端点的 401 报成密钥不对。键入的密钥优先,因为那正是被测试的那一把。解析只发生在真正要联网的路径上,因此 catalog 路由作答时完全不会触碰凭据。
|
||||
草稿携带的是用户当下键入的凭据(如果有);已经存好凭据的路由,在配置界面上只呈现一个脱敏描述符,因此询问会自行取用该路由的凭据——解析方式与向它发请求时完全一致,先 `apiKey` 后 `apiKeyEnv`——而不是不带认证发出去、再把端点的 401 报成密钥不对。键入的密钥优先,因为那正是被测试的那一把。解析只发生在真正要联网的路径上,因此 catalog 路由作答时完全不会触碰凭据。用户提供或已存储的探测密钥也会经过同样的去除空白与格式校验:HTTP 标头无法承载的值会被立即以 `LlmError('INVALID_CREDENTIAL')` 拒绝,而不会传到 `fetch`——否则会呈现为一个和端点不可达难以区分的、语义不明的 `ByteString` 失败。
|
||||
|
||||
询问只读 `openai-completions` 与 `openai-responses`,它们「`GET /models` + bearer 认证」的形状是网关、自建服务与官方端点三方一致认可的那一种。Azure 尽管出身 OpenAI 也被排除——它用 `api-key` 标头认证并要求 `api-version` 查询参数——Codex 则走 OAuth;其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把认证失败报成一个没有模型的提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import z from 'schemastery'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
|
||||
import { normalizeApiKey, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
|
||||
import { resolveRouteModels } from './catalog.ts'
|
||||
import type { PiAiModelProfile } from './catalog.ts'
|
||||
@@ -38,7 +38,11 @@ export type { PiAiModelProfile } from './catalog.ts'
|
||||
|
||||
/** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */
|
||||
export interface PiAiProviderProfile {
|
||||
/** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */
|
||||
/**
|
||||
* Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its
|
||||
* provider-native ambient discovery. Trimmed and format-checked by {@link resolveProfiles}; a
|
||||
* value no HTTP header can carry fails there rather than inside `fetch`.
|
||||
*/
|
||||
apiKey?: string
|
||||
/** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */
|
||||
apiKeyEnv?: string
|
||||
@@ -220,8 +224,18 @@ export function resolveProfiles(
|
||||
for (const [provider, source] of entries) {
|
||||
rejectRemovedFields(provider, source)
|
||||
if (provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty')
|
||||
if (source.apiKey !== undefined && source.apiKey.trim().length === 0) {
|
||||
throw new Error(`llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication`)
|
||||
// Omission selects the installed provider's own auth — ambient discovery
|
||||
// or OAuth — so only a supplied key is judged.
|
||||
let apiKey: string | undefined
|
||||
if (source.apiKey !== undefined) {
|
||||
const checked = normalizeApiKey(source.apiKey)
|
||||
if (!checked.ok) {
|
||||
throw new Error(checked.reason === 'empty'
|
||||
? `llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication`
|
||||
: `llm-pi-ai: provider "${provider}" has an apiKey containing characters no HTTP header can carry;`
|
||||
+ ' paste the raw key only')
|
||||
}
|
||||
apiKey = checked.value
|
||||
}
|
||||
if (source.baseURL !== undefined && source.baseURL.length === 0) {
|
||||
throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`)
|
||||
@@ -252,6 +266,7 @@ export function resolveProfiles(
|
||||
const { apiKeyEnv, retryPolicy, models: _models, displayName: _displayName, ...rest } = source
|
||||
resolved.set(provider, {
|
||||
...rest,
|
||||
...apiKey === undefined ? {} : { apiKey },
|
||||
provider,
|
||||
displayName,
|
||||
...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) },
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* @module dsh-llm-pi-ai/discovery
|
||||
*/
|
||||
|
||||
import { LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import { INVALID_CREDENTIAL_CODE, LlmError, normalizeApiKey } from '@deepseek-ai/dsh-llm'
|
||||
import type { LlmDiscoveredModel, LlmModelDiscoveryRequest } from '@deepseek-ai/dsh-llm'
|
||||
import { attributionHeaders } from '@deepseek-ai/dsh-llm'
|
||||
import { catalogModels } from './catalog.ts'
|
||||
@@ -161,6 +161,25 @@ function readListing(body: unknown): LlmDiscoveredModel[] {
|
||||
return models
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept one probe key, or refuse it before the header is built. Without this
|
||||
* the `fetch` below would throw a ByteString `TypeError` that this function's
|
||||
* catch reports as `could not reach <url>` — blaming the network for a local,
|
||||
* deterministic fault.
|
||||
* @param raw - the key typed into the form or read from storage.
|
||||
* @returns the trimmed, usable key.
|
||||
*/
|
||||
function usableProbeKey(raw: string): string {
|
||||
const checked = normalizeApiKey(raw)
|
||||
if (checked.ok) return checked.value
|
||||
throw new LlmError(
|
||||
checked.reason === 'empty'
|
||||
? 'this provider\'s API key is blank; enter it on the Models page, or clear it to probe unauthenticated'
|
||||
: 'this provider\'s API key contains characters no HTTP header can carry; paste the raw key only',
|
||||
INVALID_CREDENTIAL_CODE,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Interrogate one draft provider endpoint for the models it advertises.
|
||||
* @param request - the endpoint, protocol, and one-shot credential to use.
|
||||
@@ -216,7 +235,10 @@ export async function discoverModels(
|
||||
// stored one is only asked for here, past the catalog short-circuit and the
|
||||
// protocol check, so a route answered from the registry costs no credential
|
||||
// lookup — and no diagnostic about a credential it never needed.
|
||||
const apiKey = request.apiKey ?? await storedApiKey?.()
|
||||
// A probe carrying no key stays unauthenticated, which is how a route that
|
||||
// relies on the provider's own ambient discovery is meant to be asked.
|
||||
const supplied = request.apiKey ?? await storedApiKey?.()
|
||||
const apiKey = supplied === undefined ? undefined : usableProbeKey(supplied)
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm'
|
||||
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { PiAiAdapter } from './adapter.ts'
|
||||
@@ -145,7 +145,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// Without the seam, read exactly the named variable so a plain
|
||||
// cordis.yml composition works from the environment alone.
|
||||
: process.env[ref]
|
||||
if (hit !== undefined && hit.length > 0) return hit
|
||||
if (hit !== undefined && hit.length > 0) return assertUsableApiKey(hit, 'llm-pi-ai', ref)
|
||||
throw new LlmError(
|
||||
`llm-pi-ai: no credential for provider route "${provider}"; its profile resolves ${ref}, which is not`
|
||||
+ ` set — store ${ref} through the credentials service (the web Models page writes it) or export it,`
|
||||
|
||||
24
packages/llm/llm-pi-ai/tests/config.spec.ts
Normal file
24
packages/llm/llm-pi-ai/tests/config.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveProfiles } from '../src/config.ts'
|
||||
|
||||
describe('API key format', () => {
|
||||
it('trims a padded literal apiKey into the resolved profile', () => {
|
||||
const resolved = resolveProfiles({ openai: { apiKey: ' sk-abc ', baseURL: 'https://acme.test' } })
|
||||
expect(resolved.get('openai')?.apiKey).toBe('sk-abc')
|
||||
})
|
||||
|
||||
it('keeps an omitted apiKey absent so ambient authentication still applies', () => {
|
||||
const resolved = resolveProfiles({ openai: { baseURL: 'https://acme.test' } })
|
||||
expect(resolved.get('openai')?.apiKey).toBeUndefined()
|
||||
})
|
||||
|
||||
it('still tells an empty apiKey to omit itself', () => {
|
||||
expect(() => resolveProfiles({ openai: { apiKey: ' ', baseURL: 'https://acme.test' } }))
|
||||
.toThrow(/omit it to use ambient authentication/)
|
||||
})
|
||||
|
||||
it('rejects an apiKey no header can carry', () => {
|
||||
expect(() => resolveProfiles({ openai: { apiKey: 'sk-\u{1F600}', baseURL: 'https://acme.test' } }))
|
||||
.toThrow(/no HTTP header can carry/)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createServer } from 'node:http'
|
||||
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { userAgent } from '@deepseek-ai/dsh-llm'
|
||||
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
@@ -12,6 +12,9 @@ const servers: Server[] = []
|
||||
const touchedEnv: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
// A no-op when the test never stubbed `fetch`; only 'probe key format'
|
||||
// below installs one.
|
||||
vi.unstubAllGlobals()
|
||||
for (const name of touchedEnv.splice(0)) Reflect.deleteProperty(process.env, name)
|
||||
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
|
||||
})
|
||||
@@ -311,3 +314,45 @@ describe('draft-provider model discovery', () => {
|
||||
.rejects.toMatchObject({ code: 'NO_DISCOVERY' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('probe key format', () => {
|
||||
it('reports an illegal probe key as a credential fault, not an unreachable endpoint', async () => {
|
||||
await expect(discoverModels({
|
||||
baseURL: 'https://acme.test',
|
||||
api: 'openai-completions',
|
||||
apiKey: 'sk-\u{1F600}',
|
||||
})).rejects.toMatchObject({ code: 'INVALID_CREDENTIAL' })
|
||||
})
|
||||
|
||||
it('reports a blank probe key as a credential fault too', async () => {
|
||||
// The Models page omits `apiKey` entirely for a cleared field rather than
|
||||
// sending '', so this pins the contract for every other caller: a supplied
|
||||
// key is judged, and only an absent one probes unauthenticated. '' means
|
||||
// "I have a key" and is answered as the empty key it is.
|
||||
await expect(discoverModels({
|
||||
baseURL: 'https://acme.test',
|
||||
api: 'openai-completions',
|
||||
apiKey: '',
|
||||
})).rejects.toMatchObject({ code: 'INVALID_CREDENTIAL' })
|
||||
})
|
||||
|
||||
it('leaves a probe with no key unauthenticated', async () => {
|
||||
// The file's other cases capture headers through a real local HTTP server
|
||||
// (`listingServer`); this one has no route or stored key to resolve, so
|
||||
// the smallest real double is a `fetch` stub, scoped to this test and
|
||||
// unstubbed by the shared `afterEach` above.
|
||||
const requests: RequestInit[] = []
|
||||
vi.stubGlobal('fetch', async (_url: string | URL, init?: RequestInit) => {
|
||||
requests.push(init ?? {})
|
||||
return new Response(JSON.stringify({ data: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
})
|
||||
|
||||
await discoverModels({ baseURL: 'https://acme.test', api: 'openai-completions' })
|
||||
|
||||
const headers = new Headers(requests[0]?.headers)
|
||||
expect(headers.has('authorization')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/llm/llm/README.md
|
||||
README.md: ca34ffdeaafdbe061e030c80997b7234ce36a1bd
|
||||
README.zh.md: 1f95d3cd641126e129f94fe31269454a1bcce972
|
||||
README.md: 618d5f9f7c69c3ff2b420ae3fec96604802bf1be
|
||||
README.zh.md: 4b99c477eae694d1315d90920e835fcfde3b571a
|
||||
|
||||
@@ -63,6 +63,10 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta
|
||||
|
||||
Every product adapter sends application identity on provider HTTP requests. `attributionHeaders(identity?)` builds the standard `User-Agent`, defaulting to public `APP_IDENTITY`; white-label deployments may replace but not suppress it. Adapters verify the wire header directly or through their library hook. See [the attribution Agent Note](../../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md).
|
||||
|
||||
### API key validation (`api-key.ts`)
|
||||
|
||||
Every adapter that puts a credential in an HTTP header judges it the same way before use. `normalizeApiKey(raw)` trims surrounding whitespace, then accepts any non-empty printable-ASCII value (`/^[\x21-\x7E]+$/`, space excluded) or reports why not as an `ApiKeyRejection` (`'empty'` | `'illegalCharacters'`), both carried in the `ApiKeyCheck` result. Absence is never judged: a caller decides whether a value was supplied before asking, since a profile naming no credential authenticates through the provider's own ambient discovery or OAuth.
|
||||
|
||||
### Classes
|
||||
|
||||
- `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`.
|
||||
@@ -73,6 +77,7 @@ Every product adapter sends application identity on provider HTTP requests. `att
|
||||
- `CONTEXT_WINDOW_EXCEEDED_CODE` — the provider-neutral code both DeepSeek adapters use when a request exceeds the model context window, regardless of thrown-HTTP versus in-band finish delivery. `isContextWindowExceededError(detail)` is their shared conservative classifier for OpenAI-compatible provider detail.
|
||||
- `QUOTA_EXCEEDED_CODE` — the non-transient provider-neutral code for exhausted account quota, balance, credits, budget, or usage limits. `isQuotaExceededError(detail)` keeps those failures distinct from request-rate limits.
|
||||
- `EMPTY_RESPONSE_CODE` — the provider-neutral code both adapters use for a degenerate provider completion: a terminal `stop` that carried no content blocks at all. Classified as an error finish (not a successful empty message) because the attempt produced nothing durable; `dsh-llm-retry` retries it by default.
|
||||
- `INVALID_CREDENTIAL_CODE` — the provider-neutral code for a credential that was supplied but cannot be used: malformed rather than absent, so the fix is to correct the stored value rather than supply one — the distinction from `MISSING_CREDENTIAL`. Deliberately excluded from the default retryable set, since a malformed credential fails identically on every attempt. `assertUsableApiKey(raw, pkg, ref)` throws `LlmError` with this code, the one shared diagnosis every adapter uses for an unusable stored credential.
|
||||
|
||||
### Real adapters
|
||||
|
||||
|
||||
@@ -63,6 +63,10 @@
|
||||
|
||||
每个产品适配器都会在提供方 HTTP 请求上发送应用身份。`attributionHeaders(identity?)` 构建标准 `User-Agent`,默认为公开 `APP_IDENTITY`;白标部署可以替换它,但不能抑制它。适配器会直接验证 wire 标头,或通过自身库 hook 验证。详见 [归因 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md)。
|
||||
|
||||
### API 密钥校验(`api-key.ts`)
|
||||
|
||||
每个要把凭据放进 HTTP 标头的适配器,使用前都以同一套规则校验它。`normalizeApiKey(raw)` 先去除首尾空白,再接受任意非空的可打印 ASCII 值(`/^[\x21-\x7E]+$/`,不含空格),否则以 `ApiKeyRejection`(`'empty'` | `'illegalCharacters'`)说明拒绝原因,二者一并包含在 `ApiKeyCheck` 结果中。缺失从不参与校验:调用方会在询问之前自行判断是否提供了值——未点名凭据的 profile 会转由提供方自身的环境发现或 OAuth 完成认证。
|
||||
|
||||
### 类
|
||||
|
||||
- `LlmAdapter`:提供方适配器的抽象基类。唯一必需方法是 `stream()`。
|
||||
@@ -73,6 +77,7 @@
|
||||
- `CONTEXT_WINDOW_EXCEEDED_CODE`:当请求超过模型上下文窗口时,无论通过 HTTP 异常抛出还是带内 finish 交付,两个 DeepSeek 适配器都使用的提供方无关 code。`isContextWindowExceededError(detail)` 是它们针对 OpenAI 兼容提供方详细信息的共享保守分类器。
|
||||
- `QUOTA_EXCEEDED_CODE`:帐户配额、余额、点数、预算或用量限制耗尽时使用的非短暂提供方无关 code。`isQuotaExceededError(detail)` 使这些失败与请求速率限制保持区分。
|
||||
- `EMPTY_RESPONSE_CODE`:两个适配器都使用的提供方无关 code,用于表示退化的提供方生成结果:一个未携带任何内容块的终止 `stop`。它会被分类为错误 finish(而非成功空消息),因为尝试未产生持久内容;`dsh-llm-retry` 默认重试它。
|
||||
- `INVALID_CREDENTIAL_CODE`:已提供但无法使用的凭据所用的提供方无关 code——格式错误而非缺失,修复方式是改正已存储的值而非补供一个,这正是它与 `MISSING_CREDENTIAL` 的区别。它被刻意排除在默认可重试集合之外:格式错误的凭据每次尝试都会以同样方式失败。`assertUsableApiKey(raw, pkg, ref)` 会以该 code 抛出 `LlmError`,是每个适配器判定已存储凭据不可用时共用的诊断。
|
||||
|
||||
### 真实适配器
|
||||
|
||||
|
||||
41
packages/llm/llm/src/api-key.ts
Normal file
41
packages/llm/llm/src/api-key.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* The one definition of a well-formed provider API key, shared by every
|
||||
* adapter that puts one in an HTTP header.
|
||||
* @module @deepseek-ai/dsh-llm/api-key
|
||||
*/
|
||||
|
||||
/**
|
||||
* Characters an HTTP header value carries verbatim and every known provider
|
||||
* key uses: printable ASCII, space excluded. A key outside this set cannot
|
||||
* reach any provider — `fetch` refuses to build the header — so this is a
|
||||
* transport invariant rather than one provider's policy. Latin-1 is excluded
|
||||
* deliberately: a header could carry it, but no provider issues it, and
|
||||
* admitting it trades a local explained refusal for an opaque 401.
|
||||
*/
|
||||
const LEGAL_API_KEY = /^[\x21-\x7E]+$/
|
||||
|
||||
/** Why a supplied API key cannot be used. */
|
||||
export type ApiKeyRejection = 'empty' | 'illegalCharacters'
|
||||
|
||||
/** The verdict on one supplied API key. */
|
||||
export type ApiKeyCheck =
|
||||
| { readonly ok: true; readonly value: string }
|
||||
| { readonly ok: false; readonly reason: ApiKeyRejection }
|
||||
|
||||
/**
|
||||
* Judge one *supplied* API key, trimming surrounding whitespace first.
|
||||
*
|
||||
* Trimming is silent because a padded key has one unambiguous reading; every
|
||||
* other defect is reported. Absence is a configuration state this function
|
||||
* never sees — a profile naming no credential authenticates through the
|
||||
* provider's own ambient discovery or OAuth — so callers decide whether a
|
||||
* value was supplied before asking.
|
||||
* @param raw - the key exactly as configured, stored, or typed.
|
||||
* @returns the trimmed key, or why it cannot be used.
|
||||
*/
|
||||
export function normalizeApiKey(raw: string): ApiKeyCheck {
|
||||
const value = raw.trim()
|
||||
if (value.length === 0) return { ok: false, reason: 'empty' }
|
||||
if (!LEGAL_API_KEY.test(value)) return { ok: false, reason: 'illegalCharacters' }
|
||||
return { ok: true, value }
|
||||
}
|
||||
@@ -38,6 +38,15 @@ export const QUOTA_EXCEEDED_CODE = 'QUOTA'
|
||||
*/
|
||||
export const EMPTY_RESPONSE_CODE = 'EMPTY_RESPONSE'
|
||||
|
||||
/**
|
||||
* Canonical provider-neutral code for a credential that was supplied but
|
||||
* cannot be used — malformed rather than absent. Distinct from
|
||||
* `MISSING_CREDENTIAL` because the fix differs: correct the stored value
|
||||
* rather than supply one. Deliberately outside the default retryable set —
|
||||
* a malformed credential fails identically on every attempt.
|
||||
*/
|
||||
export const INVALID_CREDENTIAL_CODE = 'INVALID_CREDENTIAL'
|
||||
|
||||
/** Structured codes and plain phrases that explicitly name a context bound being exceeded. */
|
||||
const STRUCTURED_CONTEXT_OVERFLOW = new RegExp(
|
||||
String.raw`(?:^|[^a-z0-9])context[\s_-](?:length|window)[\s_-]`
|
||||
|
||||
@@ -25,13 +25,15 @@ import type { ResolvedRetryPolicy } from './retry-policy.ts'
|
||||
import type { ProviderRequestId } from './brand.ts'
|
||||
import { callConfigEquals, deepFreeze } from './call-config.ts'
|
||||
import type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config.ts'
|
||||
import { HarnessError } from './error.ts'
|
||||
import { HarnessError, INVALID_CREDENTIAL_CODE } from './error.ts'
|
||||
import { normalizeLlmFailure } from './adapter-failure.ts'
|
||||
import { normalizeApiKey } from './api-key.ts'
|
||||
|
||||
export * from './attribution.ts'
|
||||
export * from './brand.ts'
|
||||
export * from './never.ts'
|
||||
export * from './error.ts'
|
||||
export * from './api-key.ts'
|
||||
export * from './types.ts'
|
||||
export * from './message.ts'
|
||||
export * from './retry-policy.ts'
|
||||
@@ -122,6 +124,41 @@ export class LlmError extends HarnessError {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept one supplied credential, or refuse it as unusable.
|
||||
*
|
||||
* A stored key arrives from the credentials seam, a `.env` line, or a shell
|
||||
* export, all of which pick up surrounding whitespace, so trimming is silent.
|
||||
* Anything else fails here rather than inside `fetch`, whose ByteString
|
||||
* refusal names a UTF-16 code point instead of the setting to change. The key
|
||||
* never enters the message: `ref` names where to fix it, and echoing any part
|
||||
* of a secret into a log or a UI is the failure this diagnosis avoids.
|
||||
*
|
||||
* Lives beside {@link LlmError} rather than in `./api-key.ts` so the predicate
|
||||
* module stays dependency-free; both adapters share this one diagnosis instead
|
||||
* of keeping near-identical local copies.
|
||||
* @param raw - the credential exactly as supplied.
|
||||
* @param pkg - the refusing package name, prefixed to the diagnostic.
|
||||
* @param ref - the credential reference the value resolved through.
|
||||
* @returns the trimmed, usable key.
|
||||
*/
|
||||
export function assertUsableApiKey(raw: string, pkg: string, ref: string): string {
|
||||
const checked = normalizeApiKey(raw)
|
||||
if (checked.ok) return checked.value
|
||||
// The Models page is named as the writer it usually is, not as the only one:
|
||||
// the same value can arrive from a hand-edited .env or a shell export in a
|
||||
// composition that mounts no credentials seam at all, where directing the
|
||||
// user to a page that deployment does not serve would be a dead end.
|
||||
throw new LlmError(
|
||||
checked.reason === 'empty'
|
||||
? `${pkg}: the API key resolved from ${ref} is blank; set ${ref} to the raw key`
|
||||
+ ' (the web Models page writes it) or export it in the launching environment'
|
||||
: `${pkg}: the API key resolved from ${ref} contains characters no HTTP header can carry;`
|
||||
+ ` set ${ref} to the raw key alone (the web Models page writes it)`,
|
||||
INVALID_CREDENTIAL_CODE,
|
||||
)
|
||||
}
|
||||
|
||||
/** One model call whose config and adapter registration were resolved together. */
|
||||
export interface PreparedLlmCall {
|
||||
/** Detached, deep-frozen config with any adapter-owned default materialized. */
|
||||
|
||||
70
packages/llm/llm/tests/api-key.spec.ts
Normal file
70
packages/llm/llm/tests/api-key.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { assertUsableApiKey, INVALID_CREDENTIAL_CODE, normalizeApiKey } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
describe('normalizeApiKey', () => {
|
||||
it('accepts a printable-ASCII key unchanged', () => {
|
||||
expect(normalizeApiKey('sk-0123456789abcdef')).toEqual({ ok: true, value: 'sk-0123456789abcdef' })
|
||||
})
|
||||
|
||||
it('trims surrounding whitespace before judging', () => {
|
||||
expect(normalizeApiKey(' sk-abc\t\n')).toEqual({ ok: true, value: 'sk-abc' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an empty string', ''],
|
||||
['spaces only', ' '],
|
||||
['a tab only', '\t'],
|
||||
])('rejects %s as empty', (_label, raw) => {
|
||||
expect(normalizeApiKey(raw)).toEqual({ ok: false, reason: 'empty' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an emoji', 'sk-\u{1F600}abc'],
|
||||
['CJK text', 'sk-你好'],
|
||||
['full-width punctuation', 'sk-abc,'],
|
||||
['an interior space', 'sk-abc def'],
|
||||
['a C0 control character', 'sk-abc\x01'],
|
||||
['a latin-1 character', 'sk-café'],
|
||||
])('rejects %s as illegal characters', (_label, raw) => {
|
||||
expect(normalizeApiKey(raw)).toEqual({ ok: false, reason: 'illegalCharacters' })
|
||||
})
|
||||
|
||||
it('accepts the printable-ASCII boundary characters', () => {
|
||||
expect(normalizeApiKey('!~')).toEqual({ ok: true, value: '!~' })
|
||||
})
|
||||
|
||||
it('publishes a code distinct from a missing credential', () => {
|
||||
expect(INVALID_CREDENTIAL_CODE).toBe('INVALID_CREDENTIAL')
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertUsableApiKey', () => {
|
||||
it('returns the trimmed key when it is usable', () => {
|
||||
expect(assertUsableApiKey(' sk-abc ', 'llm-deepseek', 'DEEPSEEK_API_KEY')).toBe('sk-abc')
|
||||
})
|
||||
|
||||
it('refuses a blank stored credential, naming the reference', () => {
|
||||
expect(() => assertUsableApiKey(' ', 'llm-deepseek', 'DEEPSEEK_API_KEY'))
|
||||
.toThrow(/llm-deepseek: the API key resolved from DEEPSEEK_API_KEY is blank/)
|
||||
})
|
||||
|
||||
it('refuses an unusable stored credential with the invalid-credential code', () => {
|
||||
try {
|
||||
assertUsableApiKey('sk-\u{1F600}', 'llm-pi-ai', 'ACME_API_KEY')
|
||||
expect.fail('an illegal key must throw')
|
||||
} catch (error) {
|
||||
expect((error as { code: string }).code).toBe(INVALID_CREDENTIAL_CODE)
|
||||
expect((error as Error).message).toContain('llm-pi-ai')
|
||||
expect((error as Error).message).toContain('ACME_API_KEY')
|
||||
}
|
||||
})
|
||||
|
||||
it('never echoes the key it refuses', () => {
|
||||
try {
|
||||
assertUsableApiKey('sk-\u{1F600}supersecret', 'llm-deepseek', 'DEEPSEEK_API_KEY')
|
||||
expect.fail('an illegal key must throw')
|
||||
} catch (error) {
|
||||
expect((error as Error).message).not.toContain('supersecret')
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user