mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
fix(llm): 生成提供方元数据并显示凭据检测状态 (#6106)
* fix(llm): generate provider metadata and clarify credential hints * fix(settings): shorten credential hints and mark detected sources in green * fix(llm): verify staged provider metadata and document regeneration * fix(provider-metadata): reconcile authentication hints and key controls * test(user-text): calibrate ratio headroom across unmatched workloads * test(gates): include provider metadata in hygiene expectations * fix(models): hide authentication hints for entered keys
This commit is contained in:
1 parent
a2486e8243
commit
7110b615eb
61 files changed
+918
-323
No files matched your search
+2
-2
@@ -21,8 +21,8 @@
|
||||
en: f07bb6fbbdd01fe4
|
||||
zh: 9313a0ef2a22af12
|
||||
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/performance-gate:
|
||||
en: 29b8f8c1ad73c77b
|
||||
zh: 8293b21ec08d3896
|
||||
en: bfa4b063696492a5
|
||||
zh: 3cee9b6087dbee91
|
||||
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/alternatives-considered:
|
||||
en: 7600c1671b5dc73c
|
||||
zh: b38e3ae76a5a32d1
|
||||
|
||||
@@ -48,7 +48,7 @@ English | [中文](2026-10-11-user-text-credential-matching.zh.md)
|
||||
| Assertions | Without credentials: absolute budgets and a cap on the with/without time ratio; with credentials: absolute budgets and the time ratio when the key count doubles; adversarial: absolute budgets and doubling ratios; allocation: a floor plus caps per credential, per line break inside masks, and per masked character |
|
||||
| Excluded | Cold code compilation, React rendering, browser paint and layout; allocation results are asserted and printed to the console, not added to the HTML report |
|
||||
|
||||
Expectations come from an x64 development machine. CI time budgets are converted with the shared scale and headroom; time ratios, doubling ratios, and allocation budgets do not scale with machine speed. Negative controls: the same benchmark fails when pointed at the regular-expression implementation or at an implementation that rebuilds its memo per query. Equivalence tests compare this implementation with the regular-expression one ([regex-oracle.client.ts](../../../../packages/client/ui-secret-mask/tests/regex-oracle.client.ts)) node by node through the production scheduler and from every query offset.
|
||||
Expectations come from an x64 development machine. CI time budgets are converted with the shared scale and headroom; ratios are not machine-scaled. Unmatched with/without ratios use a common 1.75× headroom because independently selected minima vary with V8 tiering and workload shape; unchanged-worker CI ratios reached 1.65× their reference expectations. Absolute time budgets, doubling ratios, and allocation budgets retain their existing limits. Negative controls: the same benchmark fails when pointed at the regular-expression implementation or at an implementation that rebuilds its memo per query. Equivalence tests compare this implementation with the regular-expression one ([regex-oracle.client.ts](../../../../packages/client/ui-secret-mask/tests/regex-oracle.client.ts)) node by node through the production scheduler and from every query offset.
|
||||
|
||||
The reference constants are calibrated for minima of five single evaluations. Minimum-of-five sample means use the existing absolute headroom without recalibration. Averaging can move the ratio of independently selected minima in either direction; a threefold matcher-work negative control verifies that the JSON ratio limit still rejects extra scanning.
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ Status: implemented
|
||||
| 断言 | 无凭据:绝对预算,以及"有/无凭据匹配器"的耗时比上限;含凭据:绝对预算,密钥数翻倍时的耗时比;对抗:绝对预算与翻倍耗时比;分配:底噪加每个凭据、每个遮挡内换行和每个遮挡字符的上限 |
|
||||
| 排除 | 冷代码编译、React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 |
|
||||
|
||||
预期值来自 x64 开发机。CI 时间预算按共享比例与余量换算;耗时比、翻倍比和分配预算不随机器缩放。负对照:同一 benchmark 指向正则实现或"每次查询新建记忆"的实现时都会失败。等价性测试经生产调度器逐节点比较本实现与正则实现([regex-oracle.client.ts](../../../../packages/client/ui-secret-mask/tests/regex-oracle.client.ts)),并对每个查询起点单独比较。
|
||||
预期值来自 x64 开发机。CI 时间预算按共享比例与余量换算;比值不随机器缩放。无凭据文本的有/无匹配器耗时比统一使用 1.75 倍余量,因为独立选取的最小值会随 V8 编译层级和文本形状变化;未改变的 worker 在 CI 中达到过参考预期的 1.65 倍。绝对耗时、翻倍比和分配预算保持原有上限。负对照:同一 benchmark 指向正则实现或"每次查询新建记忆"的实现时都会失败。等价性测试经生产调度器逐节点比较本实现与正则实现([regex-oracle.client.ts](../../../../packages/client/ui-secret-mask/tests/regex-oracle.client.ts)),并对每个查询起点单独比较。
|
||||
|
||||
参考常量按五次单独求值的最小值校准;五组样本均值的最小值使用既有绝对耗时余量,不重新校准。求平均值可能使两侧独立最小值的比值向任一方向变化;将匹配器工作量增至三倍的负对照验证 JSON 比值上限仍能拒绝额外扫描。
|
||||
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
- text: DeepSeek deepseek-official API 密钥
|
||||
- button "认证说明": "?"
|
||||
- textbox "API 密钥":
|
||||
- /placeholder: 留空时保留现有认证(DEEPSEEK_API_KEY)
|
||||
- text: API 密钥来源:已保存凭据
|
||||
- /placeholder: 留空使用已保存凭据
|
||||
- group:
|
||||
- text: 自定义设置 API 地址
|
||||
- textbox "API 地址":
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
- text: openai API 密钥
|
||||
- button "认证说明": "?"
|
||||
- textbox "API 密钥":
|
||||
- /placeholder: 留空时保留现有认证(OPENAI_API_KEY)
|
||||
- /placeholder: 留空使用环境认证(OPENAI_API_KEY)
|
||||
- paragraph: 已检测到环境变量(OPENAI_API_KEY)
|
||||
- group:
|
||||
- text: 自定义设置 API 地址
|
||||
- textbox "API 地址":
|
||||
|
||||
@@ -61,7 +61,8 @@
|
||||
- text: API 密钥
|
||||
- button "认证说明": "?"
|
||||
- textbox "API 密钥":
|
||||
- /placeholder: 留空时保留现有认证(MINIMAX_CN_API_KEY)
|
||||
- /placeholder: 留空使用环境认证(MINIMAX_CN_API_KEY)
|
||||
- paragraph: 未检测到环境变量。请填写 API 密钥,或设置环境变量 MINIMAX_CN_API_KEY。
|
||||
- group: 自定义设置
|
||||
- button "取消"
|
||||
- button "保存"
|
||||
@@ -17,8 +17,7 @@
|
||||
- text: DeepSeek deepseek-official API 密钥
|
||||
- button "认证说明": "?"
|
||||
- textbox "API 密钥":
|
||||
- /placeholder: 留空时保留现有认证(DEEPSEEK_API_KEY)
|
||||
- text: API 密钥来源:已保存凭据
|
||||
- /placeholder: 留空使用已保存凭据
|
||||
- group:
|
||||
- text: 自定义设置 API 地址
|
||||
- textbox "API 地址":
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
- text: API 密钥
|
||||
- button "认证说明": "?"
|
||||
- textbox "API 密钥":
|
||||
- /placeholder: 留空时保留现有认证(DEEPSEEK_API_KEY)
|
||||
- /placeholder: 输入 API 密钥
|
||||
- button "稍后配置"
|
||||
- button "保存并继续" [disabled]
|
||||
@@ -17,8 +17,7 @@
|
||||
- text: DeepSeek deepseek-official API 密钥
|
||||
- button "认证说明": "?"
|
||||
- textbox "API 密钥":
|
||||
- /placeholder: 留空时保留现有认证(DEEPSEEK_API_KEY)
|
||||
- text: API 密钥来源:已保存凭据
|
||||
- /placeholder: 留空使用已保存凭据
|
||||
- group:
|
||||
- text: 自定义设置 API 地址
|
||||
- textbox "API 地址":
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
- text: DeepSeek deepseek-official API 密钥
|
||||
- button "认证说明": "?"
|
||||
- textbox "API 密钥":
|
||||
- /placeholder: 留空时保留现有认证(DEEPSEEK_API_KEY)
|
||||
- /placeholder: 留空使用环境认证(DEEPSEEK_API_KEY)
|
||||
- paragraph: 未检测到环境变量。请填写 API 密钥,或设置环境变量 DEEPSEEK_API_KEY。
|
||||
- group: 自定义设置
|
||||
- button "取消"
|
||||
- button "保存"
|
||||
|
||||
@@ -65,7 +65,8 @@
|
||||
- text: API 密钥
|
||||
- button "认证说明": "?"
|
||||
- textbox "API 密钥":
|
||||
- /placeholder: 留空时保留现有认证(MINIMAX_CN_API_KEY)
|
||||
- /placeholder: 留空使用环境认证(MINIMAX_CN_API_KEY)
|
||||
- paragraph: 未检测到环境变量。请填写 API 密钥,或设置环境变量 MINIMAX_CN_API_KEY。
|
||||
- group: 自定义设置
|
||||
- button "取消"
|
||||
- button "保存"
|
||||
@@ -145,6 +145,11 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
// turn with a ByteString TypeError; the form names the offending field
|
||||
// instead.
|
||||
await key.fill('sk-\u{1F600}minimax')
|
||||
expect(await key.getAttribute('type')).toBe('password')
|
||||
await dialog.getByRole('button', { name: '显示密钥', exact: true }).click()
|
||||
expect(await key.getAttribute('type')).toBe('text')
|
||||
await dialog.getByRole('button', { name: '隐藏密钥', exact: true }).click()
|
||||
expect(await key.getAttribute('type')).toBe('password')
|
||||
await dialog.getByText('该 API 密钥格式错误,请检查。').waitFor({ timeout: 10_000 })
|
||||
await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(false)
|
||||
|
||||
@@ -215,7 +220,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-customized'))
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click()
|
||||
await dialog.getByText('API 密钥来源:已保存凭据', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
await dialog.getByPlaceholder('留空使用已保存凭据', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
expect(await dialog.textContent()).not.toContain('API 密钥来源:')
|
||||
expect(await dialog.textContent()).not.toMatch(/oauth/i)
|
||||
await dialog.getByText('自定义设置').click()
|
||||
const url = dialog.getByLabel('API 地址')
|
||||
|
||||
@@ -115,7 +115,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
await expect.poll(
|
||||
() => configuredInput.getAttribute('placeholder'),
|
||||
{ timeout: 10_000 },
|
||||
).toBe('留空时保留现有认证(DEEPSEEK_API_KEY)')
|
||||
).toBe('留空使用已保存凭据')
|
||||
|
||||
const secondReloadWarnings = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
@@ -256,7 +256,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
await expect.poll(
|
||||
() => settings.getByLabel('API 密钥', { exact: true }).getAttribute('placeholder'),
|
||||
{ timeout: 10_000 },
|
||||
).toBe('留空时保留现有认证(DEEPSEEK_API_KEY)')
|
||||
).toBe('留空使用已保存凭据')
|
||||
const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE)
|
||||
await settings.getByRole('button', { name: '保存', exact: true }).click()
|
||||
|
||||
@@ -11,6 +11,13 @@ import type {
|
||||
|
||||
/** Fresh registries and documents after warm-up; independent minima of adjacent pairs limit scheduler delay. */
|
||||
const ATTEMPTS = 5
|
||||
/**
|
||||
* Both independently selected minima vary with V8 tiering and workload shape.
|
||||
* Observed unchanged-worker CI ratios reached 1.65× the reference expectation;
|
||||
* use one 1.75× margin for all unmatched workloads, keeping absolute, scaling,
|
||||
* and allocation budgets unchanged.
|
||||
*/
|
||||
const UNMATCHED_RATIO_HEADROOM = 1.75
|
||||
/** A stuck worker is reaped before the outer benchmark deadline. */
|
||||
const WORKER_TIMEOUT_MS = 180_000
|
||||
/** Larger than every workload's allocation, so the allocation interval runs without a scavenge. */
|
||||
@@ -19,7 +26,7 @@ const WORKER = join(import.meta.dirname, '..', '.dsh-build', 'user-text', 'user-
|
||||
|
||||
/**
|
||||
* Documents without credentials: reference-machine milliseconds with the matcher, and the
|
||||
* ratio to the shipped rules without it. Ratios do not depend on machine speed. The matcher
|
||||
* ratio to the shipped rules without it. Ratios need separate variance headroom. The matcher
|
||||
* adds one native literal scan, about 2 ns per character, plus format checks where a literal
|
||||
* occurs (`://`, `sk-` in identifiers); 5,000 messages also pay the scheduler's cost for one
|
||||
* more matcher. Running sixteen format patterns from every query offset measures 1.6–4.9×.
|
||||
@@ -116,7 +123,7 @@ describe('sent user text with the credential matcher', () => {
|
||||
expect(measured.group).toBe('unmatched')
|
||||
expect(measured.secrets).toBe(0)
|
||||
expect(measured.withSecretMs).toBeLessThanOrEqual(budgetMs)
|
||||
expect(measured.ratio).toBeLessThanOrEqual(expected.ratio * PERFORMANCE_BUDGET_HEADROOM)
|
||||
expect(measured.ratio).toBeLessThanOrEqual(expected.ratio * UNMATCHED_RATIO_HEADROOM)
|
||||
})
|
||||
|
||||
it.each(Object.entries(MATCHED_EXPECTED_MS))('masks every credential of %s within its budget', (id, expectedMs) => {
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
en: dba2c72ee6ffa930
|
||||
zh: b65c27fbb9129ec8
|
||||
/development-guide/contributor-reference/git-integrations:
|
||||
en: 4e782ec2ca27dd1f
|
||||
zh: 7a60e8c0d60de3c8
|
||||
en: b2767984ba4ad45c
|
||||
zh: 781307679ed186b2
|
||||
/development-guide/contributor-reference/ci-gates:
|
||||
en: b64a914e41b1d285
|
||||
zh: 6c031b507c3c0e84
|
||||
|
||||
+4
-2
@@ -136,13 +136,15 @@ DEEPSEEK_BASE_URL=https://... # optional
|
||||
|
||||
lefthook is configured in `lefthook.yml` as a fast local checkpoint:
|
||||
|
||||
- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, checks the staged diff for whitespace errors, and runs the vendor manifest guard.
|
||||
- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, checks the staged diff for whitespace errors, checks pi-ai provider metadata when its inputs or generated file are staged, and runs the vendor manifest guard.
|
||||
- `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit.
|
||||
- `pre-push` runs `pnpm run typecheck`, which generates Typert contracts without bundling before the Client TypeScript check and checks both aggregates.
|
||||
|
||||
The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.
|
||||
|
||||
Apart from the scoped staged-record verification, the hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.
|
||||
The pi-ai check regenerates expected metadata in memory. It rejects stale data without rewriting or staging files. After a pi-ai dependency or generator change, follow the package's [provider metadata update steps](../packages/llm/llm-pi-ai/README.md#generated-provider-metadata).
|
||||
|
||||
Apart from the scoped pairing and provider metadata checks, the hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.
|
||||
|
||||
Contributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction.
|
||||
|
||||
|
||||
@@ -141,13 +141,15 @@ DEEPSEEK_BASE_URL=https://... # optional
|
||||
|
||||
lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:
|
||||
|
||||
- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;
|
||||
- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,然后检查暂存 diff 中的空白错误,在 pi-ai 提供方元数据的输入或生成文件已暂存时校验元数据,并运行 vendor manifest(元数据清单)守卫;
|
||||
- `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查;
|
||||
- `pre-push` 运行 `pnpm run typecheck`;该命令先不打包地生成 Typert 约定,再运行 Client TypeScript 检查,并检查两个 aggregate。
|
||||
|
||||
vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。
|
||||
|
||||
除限定范围的暂存记录校验外,这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。
|
||||
pi-ai 检查会在内存中重新生成预期元数据。数据过期时,检查拒绝提交,不改写或暂存文件。修改 pi-ai 依赖或生成器后,请按包内的[提供方元数据更新步骤](../packages/llm/llm-pi-ai/README.zh.md#generated-provider-metadata)操作。
|
||||
|
||||
除限定范围的配对和提供方元数据检查外,这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。
|
||||
|
||||
贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。
|
||||
|
||||
|
||||
@@ -549,8 +549,8 @@
|
||||
en: 028791baa24a6a0a
|
||||
zh: 028791baa24a6a0a
|
||||
/session-persistence-event-catalog/resolved-persistence-types/in-history:
|
||||
en: 8bda2ac68f50953b
|
||||
zh: 190a69ddd097c445
|
||||
en: 3e9695c61a9e30c8
|
||||
zh: cdc765b18de2d665
|
||||
/session-persistence-event-catalog/resolved-persistence-types/in-progress:
|
||||
en: 61de2b0c9de8c15b
|
||||
zh: 61de2b0c9de8c15b
|
||||
@@ -1173,8 +1173,8 @@
|
||||
en: 07e04c3e336f880f
|
||||
zh: 6f8986ede9994fa1
|
||||
/session-persistence-event-catalog/resolved-persistence-types/replayenvelope:
|
||||
en: 3460f0c910a247fd
|
||||
zh: 3990587ab01a1907
|
||||
en: 488050ba0b6438b4
|
||||
zh: 2bf607b5ad51e39d
|
||||
/session-persistence-event-catalog/resolved-persistence-types/requestcontext:
|
||||
en: b1ade5ed2c42b1ad
|
||||
zh: f1a6126bec25657b
|
||||
@@ -1221,8 +1221,8 @@
|
||||
en: ecc15ebadb77afdf
|
||||
zh: 97ac27d6a4175f6f
|
||||
/session-persistence-event-catalog/resolved-persistence-types/streamchunk:
|
||||
en: 12fe93a89508739b
|
||||
zh: 03306c0f7b124f0d
|
||||
en: 6a9cc39c2df1b476
|
||||
zh: 697ea4d14d77f59c
|
||||
/session-persistence-event-catalog/resolved-persistence-types/subagentcatalogevent:
|
||||
en: 725539dcd5c335ed
|
||||
zh: 5ca08d3667f84ff9
|
||||
@@ -1290,8 +1290,8 @@
|
||||
en: 2258683c3cfa4ce3
|
||||
zh: 5663aa76c911761b
|
||||
/session-persistence-event-catalog/resolved-persistence-types/toolschema:
|
||||
en: eb76c5bd050ffccd
|
||||
zh: cb24780d4b5f79ac
|
||||
en: 689c56f199c0035d
|
||||
zh: 48bcb9d508d1892c
|
||||
/session-persistence-event-catalog/resolved-persistence-types/toolschema~2:
|
||||
en: 12c14727bb7834e2
|
||||
zh: b0f3c877873c36db
|
||||
@@ -1938,11 +1938,11 @@
|
||||
en: bb507524bcc39c53
|
||||
zh: 8314898562437473
|
||||
/session-persistence-event-catalog/resolved-persistence-types/type-block-end:
|
||||
en: c5acb63761d0cbb8
|
||||
zh: 6caad01b80c6eb0d
|
||||
en: f23d45580ba6d17b
|
||||
zh: 4bdf4fc300a5b287
|
||||
/session-persistence-event-catalog/resolved-persistence-types/type-block-start:
|
||||
en: c52a22b7878bde2f
|
||||
zh: 42f3fae52cf09ebb
|
||||
en: ec1f0e5653a604db
|
||||
zh: f905b8ce4d3730be
|
||||
/session-persistence-event-catalog/resolved-persistence-types/type-chunk:
|
||||
en: 2186ede3e9917aea
|
||||
zh: 0cfef64045dce95b
|
||||
@@ -1980,8 +1980,8 @@
|
||||
en: 14e4b48d856bcf57
|
||||
zh: 72ba176535dc7f3f
|
||||
/session-persistence-event-catalog/resolved-persistence-types/type-finish:
|
||||
en: 359385a004565fda
|
||||
zh: 313f78d5906a3128
|
||||
en: 76f708f530d4fc9a
|
||||
zh: b6f1100c734a5991
|
||||
/session-persistence-event-catalog/resolved-persistence-types/type-goal-change:
|
||||
en: aad2516b62d3ee83
|
||||
zh: e7204c4dc4e8e408
|
||||
@@ -2019,8 +2019,8 @@
|
||||
en: 34e5591d75322649
|
||||
zh: a216839f305c9796
|
||||
/session-persistence-event-catalog/resolved-persistence-types/type-reasoning-delta:
|
||||
en: 8b5e884e0c0ab11b
|
||||
zh: 3b291d80028b6583
|
||||
en: d1bf7fbf222b717a
|
||||
zh: e88811c2e0d45fb6
|
||||
/session-persistence-event-catalog/resolved-persistence-types/type-request-context:
|
||||
en: f8addb1a01da9ca1
|
||||
zh: 4ef11ebddd302c05
|
||||
@@ -2079,8 +2079,8 @@
|
||||
en: 1484738718de5312
|
||||
zh: 3e6fbe9c64068225
|
||||
/session-persistence-event-catalog/resolved-persistence-types/type-text-delta:
|
||||
en: bac4775a1255bbbb
|
||||
zh: 834b81d203e21b7c
|
||||
en: 38a0279d2ba3a230
|
||||
zh: 42d7be8102d27995
|
||||
/session-persistence-event-catalog/resolved-persistence-types/type-todo-write:
|
||||
en: 2fa7ba77598a95b4
|
||||
zh: 50972b51d77e9155
|
||||
@@ -2088,8 +2088,8 @@
|
||||
en: 32f732b97739e4d2
|
||||
zh: f288e48e356969c0
|
||||
/session-persistence-event-catalog/resolved-persistence-types/type-tool-call-delta:
|
||||
en: a172400ce8d6dbe7
|
||||
zh: 1d6cba18564d0230
|
||||
en: 5822c230b4880a6e
|
||||
zh: e0b86bdee2834b24
|
||||
/session-persistence-event-catalog/resolved-persistence-types/type-tool-workflow-agent-end:
|
||||
en: 8eb5e1eb95722bc7
|
||||
zh: 8dd5980029600648
|
||||
@@ -2121,8 +2121,8 @@
|
||||
en: 1b045de96172be4b
|
||||
zh: 8a15c6f35b576b37
|
||||
/session-persistence-event-catalog/resolved-persistence-types/type-usage:
|
||||
en: 1eabcbd128f86602
|
||||
zh: c6a2067a05f4e692
|
||||
en: 8eec3bb6eee5085b
|
||||
zh: 04bb3281fd7bd95c
|
||||
/session-persistence-event-catalog/resolved-persistence-types/type-user-message:
|
||||
en: afab869c0592b46c
|
||||
zh: f15874a1e728ab0d
|
||||
|
||||
+11
-11
@@ -1989,7 +1989,7 @@ SHA-256: `ebb768d85ec87fb68e42fc14fd7d8535b5ac11791b14d04c6770180f0a719bf8`
|
||||
|
||||
SHA-256: `f311671e07bc7fa645a0b6597de9966b97147956565296ba76a36543801b66c5`
|
||||
|
||||
Sources: [`packages/llm/llm/src/types.ts:412`](../packages/llm/llm/src/types.ts)
|
||||
Sources: [`packages/llm/llm/src/types.ts:414`](../packages/llm/llm/src/types.ts)
|
||||
|
||||
`"in-history"`
|
||||
|
||||
@@ -4503,7 +4503,7 @@ Sources: [`packages/llm/llm/src/types.ts:68`](../packages/llm/llm/src/types.ts)
|
||||
|
||||
SHA-256: `0acd94fe8794574c9ae72c7855ee16d29c546096d4af760d16d1225385764555`
|
||||
|
||||
Sources: [`packages/llm/llm/src/types.ts:446`](../packages/llm/llm/src/types.ts)
|
||||
Sources: [`packages/llm/llm/src/types.ts:448`](../packages/llm/llm/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
@@ -4798,7 +4798,7 @@ Sources: [`packages/skill/skill/src/index.ts:146`](../packages/skill/skill/src/i
|
||||
|
||||
SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180`
|
||||
|
||||
Sources: [`packages/llm/llm/src/types.ts:468`](../packages/llm/llm/src/types.ts)
|
||||
Sources: [`packages/llm/llm/src/types.ts:470`](../packages/llm/llm/src/types.ts)
|
||||
|
||||
One of:
|
||||
|
||||
@@ -5223,7 +5223,7 @@ Sources: [`packages/llm/llm/src/message.ts:173`](../packages/llm/llm/src/message
|
||||
|
||||
SHA-256: `66ba70f6f068da443f969a0b947a013c3addd776b9b60a0a393759c19825e9a2`
|
||||
|
||||
Sources: [`packages/llm/llm/src/types.ts:489`](../packages/llm/llm/src/types.ts)
|
||||
Sources: [`packages/llm/llm/src/types.ts:491`](../packages/llm/llm/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
@@ -8037,7 +8037,7 @@ SHA-256: `1033093edd0db80ff410e00830b523405e00bb0c7684948e531ff65095799625`
|
||||
|
||||
SHA-256: `d4b739491689cc6bb13d07ffac1a32239875abda364c0d5edd8f74faf5e9c00d`
|
||||
|
||||
Sources: [`packages/llm/llm/src/types.ts:473`](../packages/llm/llm/src/types.ts)
|
||||
Sources: [`packages/llm/llm/src/types.ts:475`](../packages/llm/llm/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
@@ -8051,7 +8051,7 @@ Sources: [`packages/llm/llm/src/types.ts:473`](../packages/llm/llm/src/types.ts)
|
||||
|
||||
SHA-256: `3bec87f9e6ab3da03346f49923d3c51621debc0159df68605e543c3b2a026f90`
|
||||
|
||||
Sources: [`packages/llm/llm/src/types.ts:469`](../packages/llm/llm/src/types.ts)
|
||||
Sources: [`packages/llm/llm/src/types.ts:471`](../packages/llm/llm/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
@@ -8257,7 +8257,7 @@ SHA-256: `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8`
|
||||
|
||||
SHA-256: `a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1`
|
||||
|
||||
Sources: [`packages/llm/llm/src/types.ts:475`](../packages/llm/llm/src/types.ts)
|
||||
Sources: [`packages/llm/llm/src/types.ts:477`](../packages/llm/llm/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
@@ -8463,7 +8463,7 @@ Sources: [`packages/llm/llm/src/assistant-stream.ts:28`](../packages/llm/llm/src
|
||||
|
||||
SHA-256: `2abb711460e6238d851c30f3e4bdd87a2aa1887ee70575b3d3b7b25610673095`
|
||||
|
||||
Sources: [`packages/llm/llm/src/types.ts:471`](../packages/llm/llm/src/types.ts)
|
||||
Sources: [`packages/llm/llm/src/types.ts:473`](../packages/llm/llm/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
@@ -8783,7 +8783,7 @@ Sources: [`packages/llm/llm/src/assistant-stream.ts:21`](../packages/llm/llm/src
|
||||
|
||||
SHA-256: `95080295115fbd83544520ba247b68a03790131f6a2eac323d388758cb542f65`
|
||||
|
||||
Sources: [`packages/llm/llm/src/types.ts:470`](../packages/llm/llm/src/types.ts)
|
||||
Sources: [`packages/llm/llm/src/types.ts:472`](../packages/llm/llm/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
@@ -8831,7 +8831,7 @@ Sources: [`packages/llm/llm/src/assistant-stream.ts:35`](../packages/llm/llm/src
|
||||
|
||||
SHA-256: `88a610b831be6d92612a464be5c7d0b88ec24d97bd299837bf74bf7672183c9a`
|
||||
|
||||
Sources: [`packages/llm/llm/src/types.ts:472`](../packages/llm/llm/src/types.ts)
|
||||
Sources: [`packages/llm/llm/src/types.ts:474`](../packages/llm/llm/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
@@ -9009,7 +9009,7 @@ SHA-256: `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8`
|
||||
|
||||
SHA-256: `f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515`
|
||||
|
||||
Sources: [`packages/llm/llm/src/types.ts:474`](../packages/llm/llm/src/types.ts)
|
||||
Sources: [`packages/llm/llm/src/types.ts:476`](../packages/llm/llm/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
|
||||
@@ -1991,7 +1991,7 @@ SHA-256: `ebb768d85ec87fb68e42fc14fd7d8535b5ac11791b14d04c6770180f0a719bf8`
|
||||
|
||||
SHA-256: `f311671e07bc7fa645a0b6597de9966b97147956565296ba76a36543801b66c5`
|
||||
|
||||
来源:[`packages/llm/llm/src/types.ts:412`](../packages/llm/llm/src/types.ts)
|
||||
来源:[`packages/llm/llm/src/types.ts:414`](../packages/llm/llm/src/types.ts)
|
||||
|
||||
`"in-history"`
|
||||
|
||||
@@ -4505,7 +4505,7 @@ SHA-256: `814d9434e1ddf9078612b3b34322f26e9363b469213335007f7e9ba7fee9464e`
|
||||
|
||||
SHA-256: `0acd94fe8794574c9ae72c7855ee16d29c546096d4af760d16d1225385764555`
|
||||
|
||||
来源:[`packages/llm/llm/src/types.ts:446`](../packages/llm/llm/src/types.ts)
|
||||
来源:[`packages/llm/llm/src/types.ts:448`](../packages/llm/llm/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
@@ -4800,7 +4800,7 @@ SHA-256: `672c4aa2a9b736e3d6e5da6346d2e2ed5e3573f2d0baafa18e7407c9f3fee3db`
|
||||
|
||||
SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180`
|
||||
|
||||
来源:[`packages/llm/llm/src/types.ts:468`](../packages/llm/llm/src/types.ts)
|
||||
来源:[`packages/llm/llm/src/types.ts:470`](../packages/llm/llm/src/types.ts)
|
||||
|
||||
以下类型之一:
|
||||
|
||||
@@ -5225,7 +5225,7 @@ SHA-256: `4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731`
|
||||
|
||||
SHA-256: `66ba70f6f068da443f969a0b947a013c3addd776b9b60a0a393759c19825e9a2`
|
||||
|
||||
来源:[`packages/llm/llm/src/types.ts:489`](../packages/llm/llm/src/types.ts)
|
||||
来源:[`packages/llm/llm/src/types.ts:491`](../packages/llm/llm/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
@@ -8039,7 +8039,7 @@ SHA-256: `1033093edd0db80ff410e00830b523405e00bb0c7684948e531ff65095799625`
|
||||
|
||||
SHA-256: `d4b739491689cc6bb13d07ffac1a32239875abda364c0d5edd8f74faf5e9c00d`
|
||||
|
||||
来源:[`packages/llm/llm/src/types.ts:473`](../packages/llm/llm/src/types.ts)
|
||||
来源:[`packages/llm/llm/src/types.ts:475`](../packages/llm/llm/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
@@ -8053,7 +8053,7 @@ SHA-256: `d4b739491689cc6bb13d07ffac1a32239875abda364c0d5edd8f74faf5e9c00d`
|
||||
|
||||
SHA-256: `3bec87f9e6ab3da03346f49923d3c51621debc0159df68605e543c3b2a026f90`
|
||||
|
||||
来源:[`packages/llm/llm/src/types.ts:469`](../packages/llm/llm/src/types.ts)
|
||||
来源:[`packages/llm/llm/src/types.ts:471`](../packages/llm/llm/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
@@ -8259,7 +8259,7 @@ SHA-256: `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8`
|
||||
|
||||
SHA-256: `a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1`
|
||||
|
||||
来源:[`packages/llm/llm/src/types.ts:475`](../packages/llm/llm/src/types.ts)
|
||||
来源:[`packages/llm/llm/src/types.ts:477`](../packages/llm/llm/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
@@ -8465,7 +8465,7 @@ SHA-256: `71bb3254ec8f1e491ce63b762a7b5f18709de23aa4e8abcc8b886f4b6ce051dd`
|
||||
|
||||
SHA-256: `2abb711460e6238d851c30f3e4bdd87a2aa1887ee70575b3d3b7b25610673095`
|
||||
|
||||
来源:[`packages/llm/llm/src/types.ts:471`](../packages/llm/llm/src/types.ts)
|
||||
来源:[`packages/llm/llm/src/types.ts:473`](../packages/llm/llm/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
@@ -8785,7 +8785,7 @@ SHA-256: `e72269f931559e901e5e8fe1cfcbd0669fb795f43cc038ff39fba62f60403c33`
|
||||
|
||||
SHA-256: `95080295115fbd83544520ba247b68a03790131f6a2eac323d388758cb542f65`
|
||||
|
||||
来源:[`packages/llm/llm/src/types.ts:470`](../packages/llm/llm/src/types.ts)
|
||||
来源:[`packages/llm/llm/src/types.ts:472`](../packages/llm/llm/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
@@ -8833,7 +8833,7 @@ SHA-256: `3860b4757d512a2869be833acb0faf834335b1b056bf3f83df5474dce95f8fa6`
|
||||
|
||||
SHA-256: `88a610b831be6d92612a464be5c7d0b88ec24d97bd299837bf74bf7672183c9a`
|
||||
|
||||
来源:[`packages/llm/llm/src/types.ts:472`](../packages/llm/llm/src/types.ts)
|
||||
来源:[`packages/llm/llm/src/types.ts:474`](../packages/llm/llm/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
@@ -9011,7 +9011,7 @@ SHA-256: `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8`
|
||||
|
||||
SHA-256: `f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515`
|
||||
|
||||
来源:[`packages/llm/llm/src/types.ts:474`](../packages/llm/llm/src/types.ts)
|
||||
来源:[`packages/llm/llm/src/types.ts:476`](../packages/llm/llm/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
|
||||
Generated
+11
-11
@@ -22516,7 +22516,7 @@
|
||||
"packages/llm/llm/src/types.ts#ReplayEnvelope"
|
||||
],
|
||||
"sources": [
|
||||
"packages/llm/llm/src/types.ts:446"
|
||||
"packages/llm/llm/src/types.ts:448"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -23042,7 +23042,7 @@
|
||||
"digest": "2abb711460e6238d851c30f3e4bdd87a2aa1887ee70575b3d3b7b25610673095",
|
||||
"names": [],
|
||||
"sources": [
|
||||
"packages/llm/llm/src/types.ts:471"
|
||||
"packages/llm/llm/src/types.ts:473"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -23297,7 +23297,7 @@
|
||||
"digest": "3bec87f9e6ab3da03346f49923d3c51621debc0159df68605e543c3b2a026f90",
|
||||
"names": [],
|
||||
"sources": [
|
||||
"packages/llm/llm/src/types.ts:469"
|
||||
"packages/llm/llm/src/types.ts:471"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -23438,7 +23438,7 @@
|
||||
"packages/llm/llm/src/types.ts#StreamChunk"
|
||||
],
|
||||
"sources": [
|
||||
"packages/llm/llm/src/types.ts:468"
|
||||
"packages/llm/llm/src/types.ts:470"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -23951,7 +23951,7 @@
|
||||
"packages/llm/llm/src/types.ts#ToolSchema"
|
||||
],
|
||||
"sources": [
|
||||
"packages/llm/llm/src/types.ts:489"
|
||||
"packages/llm/llm/src/types.ts:491"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -24473,7 +24473,7 @@
|
||||
"digest": "88a610b831be6d92612a464be5c7d0b88ec24d97bd299837bf74bf7672183c9a",
|
||||
"names": [],
|
||||
"sources": [
|
||||
"packages/llm/llm/src/types.ts:472"
|
||||
"packages/llm/llm/src/types.ts:474"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -24631,7 +24631,7 @@
|
||||
"digest": "95080295115fbd83544520ba247b68a03790131f6a2eac323d388758cb542f65",
|
||||
"names": [],
|
||||
"sources": [
|
||||
"packages/llm/llm/src/types.ts:470"
|
||||
"packages/llm/llm/src/types.ts:472"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -24824,7 +24824,7 @@
|
||||
"digest": "a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1",
|
||||
"names": [],
|
||||
"sources": [
|
||||
"packages/llm/llm/src/types.ts:475"
|
||||
"packages/llm/llm/src/types.ts:477"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -25506,7 +25506,7 @@
|
||||
"digest": "d4b739491689cc6bb13d07ffac1a32239875abda364c0d5edd8f74faf5e9c00d",
|
||||
"names": [],
|
||||
"sources": [
|
||||
"packages/llm/llm/src/types.ts:473"
|
||||
"packages/llm/llm/src/types.ts:475"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -25914,7 +25914,7 @@
|
||||
"digest": "f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515",
|
||||
"names": [],
|
||||
"sources": [
|
||||
"packages/llm/llm/src/types.ts:474"
|
||||
"packages/llm/llm/src/types.ts:476"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -25942,7 +25942,7 @@
|
||||
"packages/llm/llm/src/types.ts#SystemPromptUpdate"
|
||||
],
|
||||
"sources": [
|
||||
"packages/llm/llm/src/types.ts:412"
|
||||
"packages/llm/llm/src/types.ts:414"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
en: 58dec2259b0b1c6f
|
||||
zh: fc99fe965acaabf3
|
||||
/llm-streaming/the-model-request:
|
||||
en: 60c661bdcfec52dd
|
||||
zh: c27a46f6c99efea1
|
||||
en: 2b0831aaae8e1cdf
|
||||
zh: f669d93d1b9d6025
|
||||
/llm-streaming/the-model-request/the-request-envelope-llmcallconfig-and-the-logged-header:
|
||||
en: f2e6932e211f179b
|
||||
zh: 75112586a6f3f4aa
|
||||
|
||||
@@ -504,6 +504,8 @@ interface LlmConfigurableProvider {
|
||||
* Absent when the route has no fixed endpoint.
|
||||
*/
|
||||
defaultBaseURL?: string
|
||||
/** Environment variables used to construct a provider endpoint; absent when none are reported. */
|
||||
baseURLEnv?: readonly string[]
|
||||
/** Authentication the adapter performs when the profile names no credential reference; absent when it reports none. */
|
||||
nativeAuth?: LlmNativeAuth
|
||||
/** Configuration diagnostic for repair; unaffected models may remain serviceable. */
|
||||
@@ -511,7 +513,7 @@ interface LlmConfigurableProvider {
|
||||
}
|
||||
```
|
||||
|
||||
A settings editor shows `defaultBaseURL` and `nativeAuth` for a profile that leaves the endpoint or the credential reference unset. `nativeAuth` lists environment variable names, never their values.
|
||||
A settings editor shows `defaultBaseURL`, endpoint configuration variables from `baseURLEnv`, and `nativeAuth` for a profile that leaves the endpoint or the credential reference unset. Both variable lists contain names, never values.
|
||||
|
||||
```ts type-equiv
|
||||
/** Authentication a provider route performs without a credential reference in its profile. */
|
||||
|
||||
@@ -510,6 +510,8 @@ interface LlmConfigurableProvider {
|
||||
* Absent when the route has no fixed endpoint.
|
||||
*/
|
||||
defaultBaseURL?: string
|
||||
/** Environment variables used to construct a provider endpoint; absent when none are reported. */
|
||||
baseURLEnv?: readonly string[]
|
||||
/** Authentication the adapter performs when the profile names no credential reference; absent when it reports none. */
|
||||
nativeAuth?: LlmNativeAuth
|
||||
/** Configuration diagnostic for repair; unaffected models may remain serviceable. */
|
||||
@@ -517,7 +519,7 @@ interface LlmConfigurableProvider {
|
||||
}
|
||||
```
|
||||
|
||||
设置编辑器在提供方配置未设置端点或凭据引用时显示 `defaultBaseURL` 和 `nativeAuth`。`nativeAuth` 只列出环境变量名,不包含变量值。
|
||||
设置编辑器在提供方配置未设置端点或凭据引用时显示 `defaultBaseURL`、`baseURLEnv` 中的端点配置变量,以及 `nativeAuth`。两个变量列表都只包含名称,不包含值。
|
||||
|
||||
```ts type-equiv
|
||||
/** Authentication a provider route performs without a credential reference in its profile. */
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: 2c6b665adc256b4e
|
||||
zh: 2240e4c1573ebe9c
|
||||
/configure-models/add-a-third-party-provider:
|
||||
en: 4b99a9b34d117eea
|
||||
zh: dddeb5a08c0d3cc6
|
||||
en: f6fb44a85dc5e4dd
|
||||
zh: 2b86ea4492c32ed7
|
||||
/configure-models/add-a-custom-model-api:
|
||||
en: 17867305a3e1b52a
|
||||
zh: 876fedf1f6f563e4
|
||||
|
||||
@@ -16,9 +16,11 @@ Keys are write-only. The page receives a redacted descriptor after saving, never
|
||||
|
||||
Choose **Add model provider**. The card opens on **Third-party model provider**: pick a provider dsh ships with — the list shows provider ids such as `anthropic`, `openai`, `moonshotai` for Kimi, or `zai` for GLM — enter its API key, and save. The installed catalog supplies the endpoint, protocol, and model list.
|
||||
|
||||
The authentication **?** explains saved-credential priority and the environment fallbacks displayed for a key field. Leaving the key blank preserves existing authentication. Providers without native key authentication, such as `openai-codex`, show no key field unless the profile explicitly names a credential reference.
|
||||
The authentication **?** explains saved-credential priority and environment fallbacks. Leave the key blank to use a saved credential or try the listed environment variables. A green hint identifies a detected environment variable. A yellow hint means no credential variable was detected; enter an API key or configure the listed variable. An unconfirmed hint means the status read failed. Detection does not validate the credential or test the connection. Provider-managed credential files, including AWS profiles and Google application-default credentials, are outside this check. Providers without native key authentication, such as `openai-codex`, show no key field unless the profile explicitly names a credential reference.
|
||||
|
||||
For an existing profile that names a credential reference, **API key source** shows the layer currently supplying that reference: saved credentials, launch environment, `$cwd/.env`, or `$DSH_HOME/.env`. Native authentication without a named reference has no source label.
|
||||
A saved credential uses the placeholder **Leave blank to use saved credentials**, with no status text below the input. Environment fallback uses **Leave blank to use env as auth** and one detection hint while the input is empty. A nonempty input shows no authentication status text. Address configuration variables appear under **Base URL** and do not count as credentials.
|
||||
|
||||
A nonempty key input has an eye button. Select **Show secret** to check what you typed, and **Hide secret** to mask it again. Changing the input masks it again. Stored credentials remain write-only.
|
||||
|
||||
## Add a custom model API
|
||||
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
|
||||
选择**添加模型提供商**。卡片默认打开在**第三方模型提供商**:选取 dsh 自带的提供商——列表显示的是提供商 id,例如 `anthropic`、`openai`、Kimi 对应的 `moonshotai`、GLM 对应的 `zai`——输入其 API 密钥并保存。已安装目录会提供端点、协议和模型列表。
|
||||
|
||||
认证 **?** 说明已保存凭据的优先级,以及密钥字段所显示的环境后备。密钥留空会保留现有认证。没有原生密钥认证的提供商(例如 `openai-codex`)不显示密钥字段,除非 profile 显式指定了凭据引用。
|
||||
认证 **?** 说明已保存凭据的优先级和环境后备。密钥留空时,使用已保存凭据,或尝试读取列出的环境变量。绿色提示标明已检测到环境变量。黄色提示表示未检测到凭据变量;请输入 API 密钥或配置列出的变量。状态未确认表示读取失败。检测不验证凭据,也不测试连接。AWS profile 和 Google 应用默认凭据等提供商管理的凭据文件不在检查范围内。没有原生密钥认证的提供商(例如 `openai-codex`)不显示密钥字段,除非 profile 显式指定了凭据引用。
|
||||
|
||||
已有 profile 指定凭据引用时,**API 密钥来源**显示当前为该引用供值的层:已保存凭据、启动环境、`$cwd/.env` 或 `$DSH_HOME/.env`。未指定引用的原生认证不显示来源标签。
|
||||
已保存凭据使用**留空使用已保存凭据**占位文字,输入框下方不显示状态文字。环境后备使用**留空使用环境认证**占位文字,输入留空时显示一条检测提示。输入非空时,不显示认证状态文字。地址配置变量显示在**基础 URL**下方,不计作凭据。
|
||||
|
||||
密钥输入非空时,输入框内显示眼睛按钮。选择**显示密钥**检查当前输入,选择**隐藏密钥**重新遮住输入。修改输入后会重新遮住密钥。已保存凭据仍保持只写。
|
||||
|
||||
## 添加自定义模型 API
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ pre-commit:
|
||||
- name: whitespace (staged)
|
||||
run: git diff --cached --check
|
||||
|
||||
- name: pi-ai provider metadata
|
||||
glob: '{pnpm-lock.yaml,packages/llm/llm-pi-ai/package.json,packages/llm/llm-pi-ai/scripts/gen-provider-data.ts,packages/llm/llm-pi-ai/src/provider-data.generated.ts}'
|
||||
run: node_modules/.bin/tsx packages/llm/llm-pi-ai/scripts/gen-provider-data.ts --check --cached
|
||||
|
||||
- name: vendor manifest guard
|
||||
run: scripts/check-vendor-manifest.sh
|
||||
|
||||
|
||||
@@ -213,6 +213,8 @@
|
||||
"postinstall": "node scripts/install-lefthook.mjs",
|
||||
"gen-official-bundle-catalog": "tsx scripts/gen-official-bundle-catalog.ts",
|
||||
"verify-official-bundle-catalog": "tsx scripts/gen-official-bundle-catalog.ts --check",
|
||||
"gen-provider-data": "tsx packages/llm/llm-pi-ai/scripts/gen-provider-data.ts",
|
||||
"verify-provider-data": "tsx packages/llm/llm-pi-ai/scripts/gen-provider-data.ts --check",
|
||||
"verify-product-use": "tsx scripts/verify-product-use.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
en: db5aaf7f702e1f0c
|
||||
zh: 409a78c14fb2b6e3
|
||||
/deepseek-ai-dsh-client-ui-settings-models/use-this-package/api-keys:
|
||||
en: b39c74c9a874f5d4
|
||||
zh: db0dfcc2de9cab53
|
||||
en: f4817eb2e3537ed9
|
||||
zh: e589c193fa85473f
|
||||
/deepseek-ai-dsh-client-ui-settings-models/use-this-package/editing-a-provider:
|
||||
en: 0786af5e4ed58c8a
|
||||
zh: af94474af0749101
|
||||
en: eac663a25c60adf9
|
||||
zh: 24450ef377a79e01
|
||||
/deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers:
|
||||
en: 9d533c65c82cd4a4
|
||||
zh: 0a5da334e127c035
|
||||
|
||||
@@ -41,14 +41,18 @@ Host configuration `credentialOnboarding` defaults to `true`. The Electron prelo
|
||||
|
||||
### API keys
|
||||
|
||||
API-key inputs start empty and use `autocomplete="new-password"` to ask browsers not to autofill saved login passwords.
|
||||
API-key inputs start empty and use `autocomplete="new-password"` to ask browsers not to autofill saved login passwords. A nonempty draft has the same eye control as masked conversation text: **Show secret** reveals the current input and **Hide secret** masks it. Changing or clearing the input masks it again. Stored credentials are never read into the input.
|
||||
|
||||
The primary field on an editor card 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 `cordis.patch.yml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and preserves provider-native authentication (for example the Bedrock credential chain or Vertex ADC). The field's placeholder and help tooltip use the profile's `apiKeyEnv` when it has one; otherwise they use the environment variables of the route's `nativeAuth`, in lookup order, as the configurable-provider directory reports them. These display hints omit references containing `oauth` (case-insensitively), and describe saved credentials followed by the displayed environment fallbacks. This presentation filter does not change the [credential resolution order](../../../docs/subsystems/credentials.md#resolution), the adapter's complete environment list, or an explicit reference. A filtered explicit reference still has a usable key input with the plain placeholder and no tooltip. A native route that has no key authentication omits the key field and authentication help entirely; an explicit reference still enables the field. A profile without a reference on a route without `nativeAuth`, such as a hand-declared route, shows the plain key placeholder and no tooltip. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing. A successful Apply emits a local accessible status message without echoing secret material.
|
||||
The primary field on an editor card 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 `cordis.patch.yml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and preserves provider-native authentication (for example the Bedrock credential chain or Vertex ADC). The field's placeholder and help tooltip use the profile's `apiKeyEnv` when it has one; otherwise they use the credential variables of the route's `nativeAuth`, excluding `baseURLEnv` address variables, in lookup order, as the configurable-provider directory reports them. These display hints omit references containing `oauth` (case-insensitively), and describe saved credentials followed by the displayed environment fallbacks. This presentation filter does not change the [credential resolution order](../../../docs/subsystems/credentials.md#resolution), the adapter's complete environment list, or an explicit reference. A filtered explicit reference still has a usable key input with the plain placeholder and no tooltip. A native route that has no key authentication omits the key field and authentication help entirely; an explicit reference still enables the field. A profile without a reference on a route without `nativeAuth`, such as a hand-declared route, shows the plain key placeholder and no tooltip. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing. A successful Apply emits a local accessible status message without echoing secret material.
|
||||
|
||||
For a profile with a named credential reference, the editor also shows **API key source** from the page's existing redacted `CredentialInfo` snapshot: saved credentials (`file`), launch environment (`env`), `$cwd/.env` (`project-env`), or `$DSH_HOME/.env` (`user-env`). Refreshing that snapshot updates an open editor without a separate editor probe. Unconfigured references, unknown source ids, and reference-free native authentication show no source label: a derived reference cannot identify the native provider's active credential. The label never receives a credential value or changes field writability.
|
||||
The editor uses redacted `CredentialInfo` to choose the saved-credential placeholder or environment detection, without displaying a credential-source label. Refreshing the snapshot updates an open editor without a separate probe. The hint never receives a credential value or changes field writability.
|
||||
|
||||
### Editing a provider
|
||||
|
||||
A blank key field offers a saved credential when one is detected, with no status text below the input; otherwise it offers environment authentication. Redacted `credentials.describe` checks distinguish a detected environment reference, no detected variables, and an unconfirmed read. A missing-variable hint uses the warning color and names the first variable to configure. A detected environment reference uses green. Address configuration variables do not count as credentials. Hidden authentication references still participate in detection; a generic detected hint keeps their names hidden. Detection does not validate credentials, test a connection, or inspect provider-managed files such as AWS profiles and Google application-default credentials. A nonempty input shows no authentication status text. The page reads native variable names as well as named references, in batches of at most 64, and never reads their values into the Client.
|
||||
|
||||
An endpoint without a fixed catalog default shows the provider's `baseURLEnv` configuration names below the input. A profile endpoint remains the first placeholder choice.
|
||||
|
||||
The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the placeholder shows the endpoint the profile resolves to, the public endpoint for deepseek, else the `defaultBaseURL` the configurable-provider directory reports for a pi-ai catalog route), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `cordis.patch.yml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately not among the editable fields: it is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each model row edits `id`, optional display `name`, optional `contextWindow`/`maxTokens`, and input types; unrelated model fields survive edits.
|
||||
|
||||
The DeepSeek card edits the `llm-deepseek` endpoint, credentials, and model catalog. It uses Messages, with the default endpoint placeholder `https://api.deepseek.com/anthropic`.
|
||||
|
||||
@@ -41,14 +41,18 @@ Host 配置 `credentialOnboarding` 默认为 `true`。Electron preload 标记会
|
||||
|
||||
### API 密钥
|
||||
|
||||
API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求浏览器不要自动填入已保存的登录密码。
|
||||
API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求浏览器不要自动填入已保存的登录密码。输入非空时,显示与会话密钥遮罩一致的眼睛按钮:**显示密钥**展示当前输入,**隐藏密钥**重新遮住输入。修改或清空输入后会重新遮住密钥。已保存凭据从不读入输入框。
|
||||
|
||||
编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名。键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `cordis.patch.yml` 从不携带密钥值。为新的 pi-ai 提供商留空密钥会保存一个不带引用的 profile,从而保留提供商原生认证(例如 Bedrock 凭据链或 Vertex ADC)。profile 有 `apiKeyEnv` 时,字段的占位文字与帮助提示采用该引用;否则按查找顺序采用可配置提供商目录为该路由报告的 `nativeAuth` 环境变量。这些显示提示会忽略包含 `oauth` 的引用(不区分大小写),并说明优先使用已保存凭据,其后为显示的环境后备。这项显示过滤不改变[凭据解析顺序](../../../docs/subsystems/credentials.zh.md#resolution)、适配器的完整环境变量列表或显式引用。被过滤的显式引用仍有可用的密钥输入框,使用普通占位文字且不显示帮助提示。没有密钥认证的原生路由不显示密钥字段及认证帮助;显式引用仍会启用该字段。路由没有 `nativeAuth`(例如手动声明的路由)且 profile 没有引用时,显示普通的密钥占位文字,且不显示帮助提示。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。
|
||||
编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名。键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `cordis.patch.yml` 从不携带密钥值。为新的 pi-ai 提供商留空密钥会保存一个不带引用的 profile,从而保留提供商原生认证(例如 Bedrock 凭据链或 Vertex ADC)。profile 有 `apiKeyEnv` 时,字段的占位文字与帮助提示采用该引用;否则按查找顺序采用可配置提供商目录为该路由报告的 `nativeAuth` 凭据变量,并排除 `baseURLEnv` 地址变量。这些显示提示会忽略包含 `oauth` 的引用(不区分大小写),并说明优先使用已保存凭据,其后为显示的环境后备。这项显示过滤不改变[凭据解析顺序](../../../docs/subsystems/credentials.zh.md#resolution)、适配器的完整环境变量列表或显式引用。被过滤的显式引用仍有可用的密钥输入框,使用普通占位文字且不显示帮助提示。没有密钥认证的原生路由不显示密钥字段及认证帮助;显式引用仍会启用该字段。路由没有 `nativeAuth`(例如手动声明的路由)且 profile 没有引用时,显示普通的密钥占位文字,且不显示帮助提示。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。
|
||||
|
||||
profile 有具名凭据引用时,编辑器还会根据页面已有的脱敏 `CredentialInfo` 快照显示 **API 密钥来源**:已保存凭据(`file`)、启动环境(`env`)、`$cwd/.env`(`project-env`)或 `$DSH_HOME/.env`(`user-env`)。快照刷新会更新打开的编辑器,无需单独探测。未配置的引用、未知来源 id 和无引用的原生认证不显示来源标签:派生引用不能说明原生提供商当前采用的凭据。标签从不接收凭据值,也不改变字段的可写性。
|
||||
编辑器使用脱敏 `CredentialInfo` 选择已保存凭据的占位文字或环境检测,不显示凭据来源标签。快照刷新会更新打开的编辑器,无需单独探测。提示从不接收凭据值,也不改变字段的可写性。
|
||||
|
||||
### 编辑提供商
|
||||
|
||||
密钥字段留空时,已检测到已保存凭据则提示使用该凭据,输入框下方不显示状态文字;否则提示使用环境认证。脱敏的 `credentials.describe` 检查区分已检测到环境引用、未检测到变量和读取状态未确认。缺少变量时使用黄色提示,并列出应配置的首个变量名。已检测到环境引用时使用绿色。地址配置变量不计作凭据。隐藏的认证引用仍参与检测;使用不含变量名的通用检测提示。检测不验证凭据、不测试连接,也不检查 AWS profile 或 Google 应用默认凭据等提供商管理的文件。输入非空时,不显示认证状态文字。页面读取原生变量名和具名引用,每批最多 64 项,不将变量值读入 Client。
|
||||
|
||||
目录未提供固定默认端点时,输入框下方显示提供方的 `baseURLEnv` 配置变量名。Profile 中的端点仍是占位符的首选。
|
||||
|
||||
收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(占位符显示 profile 解析出的端点,deepseek 即公共端点;没有时显示可配置提供商目录为 pi-ai 内置路由报告的 `defaultBaseURL`)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `cordis.patch.yml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供商级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens` 和输入类型;无关的模型字段在编辑后仍会保留。
|
||||
|
||||
`llm-deepseek` 的 DeepSeek 卡片编辑端点、凭据和模型目录。它使用 Messages,默认端点占位符为 `https://api.deepseek.com/anthropic`。
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/** Write-only credential input with the same reveal behavior as masked conversation text. */
|
||||
import { useState } from 'react'
|
||||
import type { InputHTMLAttributes, ReactNode } from 'react'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
const EYE_CONTOUR = 'M1.5 8C2.9 5.25 5.25 3.5 8 3.5C10.75 3.5 13.1 5.25 14.5 8C13.1 10.75 10.75 12.5 8 12.5C5.25 12.5 2.9 10.75 1.5 8Z'
|
||||
const EYE_PATH = `${EYE_CONTOUR}M10 8C10 9.1 9.1 10 8 10C6.9 10 6 9.1 6 8C6 6.9 6.9 6 8 6C9.1 6 10 6.9 10 8Z`
|
||||
const EYE_OFF_PATH = `${EYE_CONTOUR}M2.5 2.5L13.5 13.5`
|
||||
|
||||
/** Input attributes and section copy for the current credential draft. */
|
||||
interface ApiKeyInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'type' | 'className' | 'autoComplete'> {
|
||||
/** Unsaved input; stored credentials are never read into this field. */
|
||||
readonly value: string
|
||||
/** Section copy, including the same reveal labels as SecretText. */
|
||||
readonly t: (key: keyof typeof en) => string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal only a nonempty draft; a different value starts masked, as in SecretText.
|
||||
* @param props - current draft, input attributes, and translated control labels.
|
||||
* @returns a password input with an optional reveal button.
|
||||
*/
|
||||
export function ApiKeyInput({ value, t, ...props }: ApiKeyInputProps): ReactNode {
|
||||
const [revealedValue, setRevealedValue] = useState<string | undefined>()
|
||||
const revealed = value.length > 0 && revealedValue === value
|
||||
return (
|
||||
<div className={styles['keyInput']}>
|
||||
<input
|
||||
{...props}
|
||||
className={`${styles['input']} ${value.length > 0 ? styles['keyInputFilled'] : ''}`}
|
||||
type={revealed ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
setRevealedValue(undefined)
|
||||
props.onChange?.(event)
|
||||
}}
|
||||
/>
|
||||
{value.length === 0 ? null : <button
|
||||
type="button"
|
||||
className={styles['keyToggle']}
|
||||
aria-label={t(revealed ? 'keyHide' : 'keyReveal')}
|
||||
aria-pressed={revealed}
|
||||
disabled={props.disabled}
|
||||
onClick={() => { setRevealedValue(revealed ? undefined : value) }}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.3} strokeLinecap="round" aria-hidden="true">
|
||||
<path d={revealed ? EYE_OFF_PATH : EYE_PATH} />
|
||||
</svg>
|
||||
</button>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import { apiKeyFailure } from './apiKey.ts'
|
||||
import { ApiKeyInput } from './ApiKeyInput.tsx'
|
||||
import { EditorFooter } from './EditorFooter.tsx'
|
||||
import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx'
|
||||
import { ModelListEditor } from './ModelListEditor.tsx'
|
||||
@@ -268,10 +269,8 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
</div>
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('keyInput')}</span>
|
||||
<input
|
||||
className={styles['input']}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
<ApiKeyInput
|
||||
t={t}
|
||||
value={keyDraft}
|
||||
placeholder={t('keyPlaceholder')}
|
||||
aria-label={t('keyInput')}
|
||||
|
||||
@@ -639,6 +639,43 @@ select.input {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.keyInput {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.keyInputFilled {
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
.keyToggle {
|
||||
position: absolute;
|
||||
inset: 1px 1px 1px auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-md);
|
||||
background: none;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.keyToggle:hover:not(:disabled) {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.keyToggle:focus-visible {
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.keyToggle:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Select variant of .input: replaces the OS arrow (which sits flush against
|
||||
the right edge) with the shared 12px chevron inset like the composer's
|
||||
.select chips; the right pad reserves its cell. */
|
||||
|
||||
@@ -94,6 +94,8 @@ interface EditorTarget extends ProviderIdentity {
|
||||
declared?: boolean
|
||||
/** The route's catalog endpoint (see {@link ProviderEditorProps.defaultBaseURL}). */
|
||||
defaultBaseURL?: string
|
||||
/** Environment used to construct an endpoint (see {@link ProviderEditorProps.baseURLEnv}). */
|
||||
baseURLEnv?: readonly string[]
|
||||
/** The route's native authentication (see {@link ProviderEditorProps.nativeAuth}). */
|
||||
nativeAuth?: LlmNativeAuth
|
||||
}
|
||||
@@ -113,13 +115,14 @@ interface CatalogDraft {
|
||||
/** Values that vary around the shared provider-editor rendering. */
|
||||
interface ProviderEditorRenderProps extends Pick<
|
||||
ProviderEditorProps,
|
||||
'namespace' | 'schema' | 'operations' | 't' | 'readOnly' | 'onClose' | 'credential'
|
||||
'namespace' | 'schema' | 'operations' | 't' | 'readOnly' | 'onClose'
|
||||
> {
|
||||
target: EditorTarget
|
||||
row: ProviderRow
|
||||
}
|
||||
|
||||
/** Render an editor for either the setup posture or an expanded provider row. */
|
||||
function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps): ReactNode {
|
||||
function renderProviderEditor({ target, row, ...props }: ProviderEditorRenderProps): ReactNode {
|
||||
return (
|
||||
<ProviderEditor
|
||||
provider={target.provider}
|
||||
@@ -127,7 +130,10 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps):
|
||||
settingsPath={target.settingsPath}
|
||||
{...target.declared === true ? { declared: true } : {}}
|
||||
{...target.defaultBaseURL === undefined ? {} : { defaultBaseURL: target.defaultBaseURL }}
|
||||
{...target.baseURLEnv === undefined ? {} : { baseURLEnv: target.baseURLEnv }}
|
||||
{...target.nativeAuth === undefined ? {} : { nativeAuth: target.nativeAuth }}
|
||||
{...row.credential === undefined ? {} : { credential: row.credential }}
|
||||
{...row.credentialChecks === undefined ? {} : { credentialChecks: row.credentialChecks }}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -207,6 +213,7 @@ function targetOf(row: ProviderRow): EditorTarget {
|
||||
// Only declared routes may expose route-owned fields.
|
||||
...row.entry.declared === true ? { declared: true } : {},
|
||||
...row.entry.defaultBaseURL === undefined ? {} : { defaultBaseURL: row.entry.defaultBaseURL },
|
||||
...row.entry.baseURLEnv === undefined ? {} : { baseURLEnv: row.entry.baseURLEnv },
|
||||
...row.entry.nativeAuth === undefined ? {} : { nativeAuth: row.entry.nativeAuth },
|
||||
}
|
||||
}
|
||||
@@ -431,8 +438,8 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
|
||||
{error}
|
||||
{renderProviderEditor({
|
||||
target,
|
||||
row,
|
||||
namespace,
|
||||
...row.credential === undefined ? {} : { credential: row.credential },
|
||||
schema,
|
||||
operations,
|
||||
t,
|
||||
@@ -544,8 +551,8 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
|
||||
{open
|
||||
? renderProviderEditor({
|
||||
target,
|
||||
row,
|
||||
namespace,
|
||||
...row.credential === undefined ? {} : { credential: row.credential },
|
||||
schema,
|
||||
operations,
|
||||
t,
|
||||
@@ -637,7 +644,9 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
|
||||
settingsPath={draft.target.settingsPath}
|
||||
operations={operations}
|
||||
{...draft.target.defaultBaseURL === undefined ? {} : { defaultBaseURL: draft.target.defaultBaseURL }}
|
||||
{...draft.target.baseURLEnv === undefined ? {} : { baseURLEnv: draft.target.baseURLEnv }}
|
||||
{...draft.target.nativeAuth === undefined ? {} : { nativeAuth: draft.target.nativeAuth }}
|
||||
{...addRow?.credentialChecks === undefined ? {} : { credentialChecks: addRow.credentialChecks }}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={(changed) => { closeEditor(changed, draft.target) }}
|
||||
|
||||
@@ -37,6 +37,7 @@ import { protocolLabel } from './protocol-label.ts'
|
||||
import type { ModelsOperations } from './operations.ts'
|
||||
import type { SettingsSchemaOperations } from './schema-operations.ts'
|
||||
import type { en } from './locales.ts'
|
||||
import { ApiKeyInput } from './ApiKeyInput.tsx'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
/** Per-adapter-family curated field sets (unknown namespaces get the hint alone). */
|
||||
@@ -70,10 +71,14 @@ export interface ProviderEditorProps {
|
||||
operations: ModelsOperations
|
||||
/** Endpoint the route uses when its profile sets no `baseURL`; absent when the adapter reports none. */
|
||||
defaultBaseURL?: string
|
||||
/** Environment variables used by the provider to construct its endpoint. */
|
||||
baseURLEnv?: readonly string[]
|
||||
/** Authentication the route performs when its profile names no credential reference; absent when the adapter reports none. */
|
||||
nativeAuth?: LlmNativeAuth
|
||||
/** Redacted state of the profile's named credential reference, from the page snapshot. */
|
||||
credential?: CredentialInfo
|
||||
/** Redacted checks for the profile's named reference or the provider's native variables. */
|
||||
credentialChecks?: Readonly<Record<string, CredentialInfo>>
|
||||
/** Section copy. */
|
||||
t: (key: keyof typeof en) => string
|
||||
/** Disable writes (read-only settings provider). */
|
||||
@@ -344,24 +349,33 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
const defaultContextWindow = schema.getPath(fallback, ['defaultContextWindow'])
|
||||
const defaultMaxTokens = schema.getPath(fallback, ['maxTokens'])
|
||||
const defaultInput = schema.getPath(fallback, ['defaultInput'])
|
||||
const baseURL = stringAt(fallback, 'baseURL') ?? props.defaultBaseURL
|
||||
const endpointEnv = props.baseURLEnv ?? []
|
||||
const endpointHint = family === 'deepseek' ? t('deepSeekEndpointHint')
|
||||
: baseURL === undefined && endpointEnv.length > 0 ? t('baseUrlEnvironment').replace('{env}', () => endpointEnv.join(', ')) : undefined
|
||||
// A named reference is the only credential the adapter reads for this
|
||||
// route. Without one, a catalog route authenticates the way the directory
|
||||
// reports, and a declared route sends no key at all.
|
||||
const namedRef = stringAt(fallback, 'apiKeyEnv')
|
||||
const nativeAuth = namedRef === undefined ? props.nativeAuth : undefined
|
||||
// A derived reference cannot identify a native provider's active credential.
|
||||
const source = namedRef === undefined || props.credential?.configured !== true
|
||||
? undefined
|
||||
: props.credential.source
|
||||
const keySource = source === 'file' ? t('keySourceFile')
|
||||
: source === 'env' ? t('keySourceEnv')
|
||||
: source === 'project-env' ? t('keySourceProjectEnv')
|
||||
: source === 'user-env' ? t('keySourceUserEnv')
|
||||
: undefined
|
||||
// Display method-neutral references without changing the adapter's lookup.
|
||||
const visibleRefs = (namedRef === undefined ? nativeAuth?.env ?? [] : [namedRef])
|
||||
.filter(ref => !ref.toLowerCase().includes('oauth'))
|
||||
const authRefs = namedRef === undefined
|
||||
? (nativeAuth?.env ?? []).filter(ref => !endpointEnv.includes(ref)) : [namedRef]
|
||||
const visibleRefs = authRefs.filter(ref => !ref.toLowerCase().includes('oauth'))
|
||||
const keyHint = visibleRefs[0]
|
||||
const checkedRefs = authRefs.map(ref => [
|
||||
ref, props.credentialChecks?.[ref] ?? (ref === namedRef ? props.credential : undefined),
|
||||
] as const)
|
||||
const detected = checkedRefs.find(([, info]) => info?.configured === true)
|
||||
const detectedRef = detected?.[0]
|
||||
const detectedVisibleRef = detectedRef === undefined || !visibleRefs.includes(detectedRef) ? undefined : detectedRef
|
||||
const missingRefs = checkedRefs.length > 0 && checkedRefs.every(([, info]) => info?.configured === false)
|
||||
const savedCredential = detected?.[1]?.source === 'file'
|
||||
const credentialHint = keyDraft.length > 0 || savedCredential || keyHint === undefined || props.credentialRequired === true
|
||||
? undefined
|
||||
: t(detectedRef === undefined ? missingRefs ? 'keyFallbackMissing' : 'keyFallbackUnchecked'
|
||||
: detectedVisibleRef === undefined ? 'keyFallbackDetectedHidden' : 'keyFallbackDetected')
|
||||
.replace('{hint}', () => detectedVisibleRef ?? keyHint)
|
||||
const keyHelp = visibleRefs.length === 0
|
||||
? undefined
|
||||
: t('keyHelp').replace('{env}', () => visibleRefs.join(' / '))
|
||||
@@ -372,9 +386,9 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
)
|
||||
// A provider with no native key authentication has no key for this field to store.
|
||||
const keylessNativeAuth = nativeAuth?.env.length === 0 && nativeAuth.oauth
|
||||
const keyPlaceholder = keyHint === undefined
|
||||
const keyPlaceholder = keyHint === undefined || props.credentialRequired === true
|
||||
? t('keyPlaceholder')
|
||||
: t('keyPlaceholderFallback').replace('{hint}', () => keyHint)
|
||||
: t(savedCredential ? 'keyPlaceholderSaved' : 'keyPlaceholderFallback').replace('{hint}', () => keyHint)
|
||||
/** What both family editors take: the rows, whose layer owns them, and the two writes. */
|
||||
const catalogProps = {
|
||||
models,
|
||||
@@ -395,20 +409,19 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
{t('keyInput')}
|
||||
{authHelp}
|
||||
</span>
|
||||
<input
|
||||
className={styles['input']}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
<ApiKeyInput
|
||||
t={t}
|
||||
value={keyDraft}
|
||||
placeholder={keyPlaceholder}
|
||||
aria-label={t('keyInput')}
|
||||
aria-invalid={shownKeyFailure !== undefined}
|
||||
aria-describedby={credentialHint === undefined ? undefined : `${props.provider}-credential-hint`}
|
||||
required={props.credentialRequired === true}
|
||||
autoFocus={props.autoFocusCredential === true}
|
||||
disabled={disabled}
|
||||
onChange={(event) => { setKeyDraft(event.target.value) }}
|
||||
/>
|
||||
{keySource === undefined ? null : <span className={styles['advancedHint']}>{keySource}</span>}
|
||||
{credentialHint === undefined ? null : <p id={`${props.provider}-credential-hint`} className={styles[missingRefs ? 'notice' : detectedRef === undefined ? 'advancedHint' : 'savedNotice']}>{credentialHint}</p>}
|
||||
{shownKeyFailure === undefined ? null : <p className={styles['error']}>{t(shownKeyFailure)}</p>}
|
||||
</div>}
|
||||
{props.credentialOnly === true ? null : <details className={styles['customized']}>
|
||||
@@ -449,18 +462,16 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
value={stringAt(draft, 'baseURL') ?? ''}
|
||||
placeholder={family === 'deepseek'
|
||||
? t('deepSeekBaseUrl')
|
||||
: (() => {
|
||||
const baseURL = stringAt(fallback, 'baseURL') ?? props.defaultBaseURL
|
||||
return baseURL === undefined ? t('baseUrlDefault') : t('baseUrlPlaceholder').replace('{url}', () => baseURL)
|
||||
})()}
|
||||
aria-describedby={family === 'deepseek' ? `${props.provider}-endpoint-hint` : undefined}
|
||||
: baseURL === undefined ? t(endpointEnv.length > 0 ? 'baseUrlConfigured' : 'baseUrlDefault')
|
||||
: t('baseUrlPlaceholder').replace('{url}', () => baseURL)}
|
||||
aria-describedby={endpointHint === undefined ? undefined : `${props.provider}-endpoint-hint`}
|
||||
aria-label={t('baseUrl')}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
setField('baseURL', event.target.value === '' ? undefined : event.target.value)
|
||||
}}
|
||||
/>
|
||||
{family === 'deepseek' ? <span id={`${props.provider}-endpoint-hint`} className={styles['advancedHint']}>{t('deepSeekEndpointHint')}</span> : null}
|
||||
{endpointHint === undefined ? null : <span id={`${props.provider}-endpoint-hint`} className={styles['advancedHint']}>{endpointHint}</span>}
|
||||
</div>
|
||||
{/* The protocol sits beside the endpoint it describes, as it does
|
||||
on the create card. */}
|
||||
|
||||
@@ -37,17 +37,22 @@ export const en = {
|
||||
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',
|
||||
keyReveal: 'Show secret',
|
||||
keyHide: 'Hide secret',
|
||||
keyPlaceholder: 'Enter your API key',
|
||||
keyPlaceholderFallback: 'Leave blank to keep existing authentication ({hint})',
|
||||
keyPlaceholderFallback: 'Leave blank to use env as auth ({hint})',
|
||||
keyPlaceholderSaved: 'Leave blank to use saved credentials',
|
||||
keyFallbackMissing: 'Env var not detected. Enter an API key, or configure {hint} in your env.',
|
||||
keyFallbackDetected: 'Env var detected. ({hint})',
|
||||
keyFallbackDetectedHidden: 'Env var detected.',
|
||||
keyFallbackUnchecked: 'Environment credential status has not been confirmed.',
|
||||
keyHelp: 'Saved credentials are used first. Environment fallbacks shown here, in order: {env}. Sources are launch environment, $cwd/.env, then $DSH_HOME/.env.',
|
||||
authHelp: 'Authentication details',
|
||||
keySourceFile: 'API key source: saved credentials',
|
||||
keySourceEnv: 'API key source: launch environment',
|
||||
keySourceProjectEnv: 'API key source: $cwd/.env',
|
||||
keySourceUserEnv: 'API key source: $DSH_HOME/.env',
|
||||
customized: 'Customized settings',
|
||||
baseUrl: 'Base URL',
|
||||
baseUrlDefault: 'No default endpoint',
|
||||
baseUrlEnvironment: 'Provider endpoint configuration: {env}',
|
||||
baseUrlConfigured: 'Provider-configured endpoint',
|
||||
baseUrlPlaceholder: '{url}',
|
||||
deepSeekBaseUrl: 'https://api.deepseek.com/anthropic (Default to use DEEPSEEK_BASE_URL)',
|
||||
deepSeekEndpointHint: 'Use an API endpoint compatible with Anthropic Messages.',
|
||||
@@ -163,17 +168,22 @@ export const zh: { [Key in keyof typeof en]: string } = {
|
||||
conflict: '这张卡片打开期间,这些设置已被其他地方改动。请关闭后重新打开,在当前值上编辑。',
|
||||
retry: '重试',
|
||||
keyInput: 'API 密钥',
|
||||
keyReveal: '显示密钥',
|
||||
keyHide: '隐藏密钥',
|
||||
keyPlaceholder: '输入 API 密钥',
|
||||
keyPlaceholderFallback: '留空时保留现有认证({hint})',
|
||||
keyPlaceholderFallback: '留空使用环境认证({hint})',
|
||||
keyPlaceholderSaved: '留空使用已保存凭据',
|
||||
keyFallbackMissing: '未检测到环境变量。请填写 API 密钥,或设置环境变量 {hint}。',
|
||||
keyFallbackDetected: '已检测到环境变量({hint})',
|
||||
keyFallbackDetectedHidden: '已检测到环境变量',
|
||||
keyFallbackUnchecked: '环境凭据状态尚未确认',
|
||||
keyHelp: '优先使用已保存的凭据。此处显示的环境后备按以下顺序排列:{env}。来源依次为启动环境、$cwd/.env、$DSH_HOME/.env。',
|
||||
authHelp: '认证说明',
|
||||
keySourceFile: 'API 密钥来源:已保存凭据',
|
||||
keySourceEnv: 'API 密钥来源:启动环境',
|
||||
keySourceProjectEnv: 'API 密钥来源:$cwd/.env',
|
||||
keySourceUserEnv: 'API 密钥来源:$DSH_HOME/.env',
|
||||
customized: '自定义设置',
|
||||
baseUrl: 'API 地址',
|
||||
baseUrlDefault: '没有默认地址',
|
||||
baseUrlEnvironment: '提供方地址配置:{env}',
|
||||
baseUrlConfigured: '由提供方配置地址',
|
||||
baseUrlPlaceholder: '{url}',
|
||||
deepSeekBaseUrl: 'https://api.deepseek.com/anthropic(默认使用环境 DEEPSEEK_BASE_URL)',
|
||||
deepSeekEndpointHint: '请填写兼容 Anthropic Messages 协议的 API 地址。',
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface ProviderDirectoryEntry {
|
||||
readonly active: boolean
|
||||
readonly declared?: boolean
|
||||
readonly defaultBaseURL?: string
|
||||
readonly baseURLEnv?: readonly string[]
|
||||
readonly nativeAuth?: LlmNativeAuth
|
||||
readonly error?: string
|
||||
}
|
||||
@@ -55,6 +56,7 @@ export function joinProviderDirectory(
|
||||
active: active.has(entry.provider),
|
||||
...entry.declared === undefined ? {} : { declared: entry.declared },
|
||||
...entry.defaultBaseURL === undefined ? {} : { defaultBaseURL: entry.defaultBaseURL },
|
||||
...entry.baseURLEnv === undefined ? {} : { baseURLEnv: entry.baseURLEnv },
|
||||
...entry.nativeAuth === undefined ? {} : { nativeAuth: entry.nativeAuth },
|
||||
...entry.error === undefined ? {} : { error: entry.error },
|
||||
}))
|
||||
@@ -94,6 +96,8 @@ export interface ProviderRow {
|
||||
* own derivation rule.
|
||||
*/
|
||||
derivedCredential?: CredentialInfo
|
||||
/** Redacted checks for named or native references; absent when their read failed. */
|
||||
credentialChecks?: Readonly<Record<string, CredentialInfo>>
|
||||
}
|
||||
|
||||
/** Page snapshot. */
|
||||
@@ -245,16 +249,18 @@ export class ModelsSettingsStore {
|
||||
&& catalog.value.groups.some(group => group.id === 'deepseek-account' && group.models.length > 0)
|
||||
}
|
||||
}
|
||||
const refs = [...new Set(rows.filter(row => row.entry.provider !== 'deepseek-account').map(row => row.apiKeyEnv ?? deriveKeyRef(row.entry.provider)))]
|
||||
let credentials: Record<string, CredentialInfo> = {}
|
||||
const refs = [...new Set(rows.filter(row => row.entry.provider !== 'deepseek-account').flatMap(row =>
|
||||
row.apiKeyEnv === undefined ? [deriveKeyRef(row.entry.provider), ...row.entry.nativeAuth?.env ?? []] : [row.apiKeyEnv]))]
|
||||
const credentials: Record<string, CredentialInfo> = {}
|
||||
let credentialError: string | null = null
|
||||
if (refs.length > 0) {
|
||||
const response = await this.ctx.remote.credentials.describe(refs)
|
||||
// credentials.describe accepts at most 64 references per request.
|
||||
for (let index = 0; index < refs.length; index += 64) {
|
||||
const response = await this.ctx.remote.credentials.describe(refs.slice(index, index + 64))
|
||||
// Credential state is an enrichment for the Models page: a failure
|
||||
// degrades the badge instead of failing the load. The onboarding
|
||||
// projection below retains the failure distinction.
|
||||
if (response.ok) credentials = response.value
|
||||
else credentialError = response.error.message
|
||||
if (response.ok) Object.assign(credentials, response.value)
|
||||
else { credentialError = response.error.message; break }
|
||||
}
|
||||
if (generation !== this.generation) return
|
||||
this.store.update((s) => {
|
||||
@@ -270,6 +276,13 @@ export class ModelsSettingsStore {
|
||||
...row,
|
||||
...named === undefined ? {} : { credential: named },
|
||||
...derived === undefined ? {} : { derivedCredential: derived },
|
||||
...credentialError === null ? { credentialChecks: Object.fromEntries(
|
||||
(row.apiKeyEnv === undefined ? row.entry.nativeAuth?.env ?? [] : [row.apiKeyEnv])
|
||||
.flatMap((ref) => {
|
||||
const info = credentials[ref]
|
||||
return info === undefined ? [] : [[ref, info] as const]
|
||||
}),
|
||||
) } : {},
|
||||
}
|
||||
})
|
||||
s.namespaces = namespaces
|
||||
|
||||
@@ -196,11 +196,11 @@ function scriptedFace(overrides: {
|
||||
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
|
||||
{
|
||||
provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true,
|
||||
defaultBaseURL: 'https://api.openai.com/v1', nativeAuth: { env: ['OPENAI_API_KEY'], oauth: false },
|
||||
defaultBaseURL: 'https://api.openai.com/v1', baseURLEnv: ['FIXTURE_ENDPOINT'], nativeAuth: { env: ['OPENAI_API_KEY'], oauth: false },
|
||||
},
|
||||
{
|
||||
provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false,
|
||||
defaultBaseURL: 'https://api.anthropic.com',
|
||||
defaultBaseURL: 'https://api.anthropic.com', baseURLEnv: ['FIXTURE_ENDPOINT'],
|
||||
nativeAuth: { env: ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'], oauth: true },
|
||||
},
|
||||
{ provider: 'zombie', displayName: 'zombie', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'zombie'], active: false },
|
||||
@@ -275,7 +275,7 @@ function piAiNamespaceWith(provider: string, profile: { [key: string]: JsonValue
|
||||
function renderPiAiEditor(
|
||||
provider: string,
|
||||
namespace: SettingsNamespaceView,
|
||||
facts: Pick<ProviderEditorProps, 'defaultBaseURL' | 'nativeAuth' | 'credential'> = {},
|
||||
facts: Pick<ProviderEditorProps, 'defaultBaseURL' | 'baseURLEnv' | 'nativeAuth' | 'credential' | 'credentialChecks'> = {},
|
||||
): void {
|
||||
render(<ProviderEditor provider={provider} displayName={provider}
|
||||
namespace={namespace} settingsPath={['providers', provider]} schema={settingsSchema}
|
||||
@@ -360,6 +360,17 @@ async function mountDeepSeekCard(overrides: Parameters<typeof scriptedFace>[0] =
|
||||
}
|
||||
|
||||
describe('ModelsSection', () => {
|
||||
it('keeps the credential state unconfirmed when its redacted read fails', async () => {
|
||||
const scripted = scriptedFace()
|
||||
scripted.face.credentials.describe.mockResolvedValue(remoteFail('credentials unavailable', 'gateway/internal'))
|
||||
await mountFace(scripted)
|
||||
const edit = screen.getByRole('button', { name: openaiCopy(en.editProvider) })
|
||||
fireEvent.click(edit)
|
||||
const row = within(edit.closest('li')!)
|
||||
expect(row.getByText(en.keyFallbackUnchecked)).toBeTruthy()
|
||||
expect(row.queryByText(en.keyFallbackMissing.replace('{hint}', 'OPENAI_API_KEY'))).toBeNull()
|
||||
})
|
||||
|
||||
it('waits for the current snapshot when a setup surface remounts during refresh', async () => {
|
||||
const { view, controller, face, mirror, mutate } = await mountFirstRun()
|
||||
view.unmount()
|
||||
@@ -829,7 +840,7 @@ describe('ModelsSection', () => {
|
||||
providerCopy(en.savedProvider, { provider: 'deepseek-official', displayName: 'DeepSeek' }),
|
||||
)
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
expect(screen.queryByText(providerCopy(en.savedProvider, { provider: 'deepseek-official', displayName: 'DeepSeek' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('reuses the provider editor as a required credential-only onboarding form', async () => {
|
||||
@@ -1401,6 +1412,30 @@ describe('ModelsSection', () => {
|
||||
expect(initialize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reveals only the current key draft and masks it again on change', async () => {
|
||||
const { set } = await mountDeepSeekCard()
|
||||
const input = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
|
||||
expect(input.type).toBe('password')
|
||||
expect(input.value).toBe('')
|
||||
expect(screen.queryByRole('button', { name: en.keyReveal })).toBeNull()
|
||||
fireEvent.change(input, { target: { value: 'test-key' } })
|
||||
const reveal = screen.getByRole('button', { name: en.keyReveal })
|
||||
expect(reveal.getAttribute('aria-pressed')).toBe('false')
|
||||
fireEvent.click(reveal)
|
||||
expect(input.type).toBe('text')
|
||||
const hide = screen.getByRole('button', { name: en.keyHide })
|
||||
expect(hide.getAttribute('aria-pressed')).toBe('true')
|
||||
fireEvent.click(hide)
|
||||
expect(input.type).toBe('password')
|
||||
fireEvent.click(screen.getByRole('button', { name: en.keyReveal }))
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
expect(input.type).toBe('password')
|
||||
expect(screen.queryByRole('button', { name: en.keyReveal })).toBeNull()
|
||||
fireEvent.change(input, { target: { value: 'test-key' } })
|
||||
expect(input.type).toBe('password')
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an invalid draft before writing', async () => {
|
||||
const { mutate } = await mountDeepSeekCard()
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
@@ -1415,7 +1450,7 @@ describe('ModelsSection', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
|
||||
// The field names the profile's reference; a stored value stays hidden.
|
||||
const editorKey = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
|
||||
expect(editorKey.placeholder).toBe(en.keyPlaceholderFallback.replace('{hint}', 'OPENAI_API_KEY'))
|
||||
expect(editorKey.placeholder).toBe(en.keyPlaceholderSaved)
|
||||
// pi-ai carries Base URL too: the stored override shows as the value and
|
||||
// the effective profile endpoint, ahead of the catalog's, as its placeholder.
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
@@ -1601,7 +1636,7 @@ describe('ModelsSection', () => {
|
||||
face.credentials.describe.mockImplementation(() => Promise.resolve(remoteFail('down', 'gateway/internal')))
|
||||
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
|
||||
const editorKey = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
|
||||
expect(editorKey.placeholder).toBe(en.keyPlaceholderFallback.replace('{hint}', 'OPENAI_API_KEY'))
|
||||
expect(editorKey.placeholder).toBe(en.keyPlaceholderSaved)
|
||||
fireEvent.change(editorKey, { target: { value: 'sk-live' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
|
||||
@@ -1751,6 +1786,20 @@ describe('ModelsSection', () => {
|
||||
expect(screen.getByRole<HTMLInputElement>('textbox', { name: en.customRoute }).value).toBe('acme')
|
||||
})
|
||||
|
||||
it('offers the same key reveal control on a custom provider', async () => {
|
||||
await mountSection()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.add }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: en.addCustom }))
|
||||
const panel = within(screen.getByRole('tabpanel', { name: en.addCustom }))
|
||||
const input = panel.getByLabelText<HTMLInputElement>(en.keyInput)
|
||||
expect(panel.queryByRole('button', { name: en.keyReveal })).toBeNull()
|
||||
fireEvent.change(input, { target: { value: 'custom-test-key' } })
|
||||
fireEvent.click(panel.getByRole('button', { name: en.keyReveal }))
|
||||
expect(input.type).toBe('text')
|
||||
fireEvent.click(panel.getByRole('button', { name: en.keyHide }))
|
||||
expect(input.type).toBe('password')
|
||||
})
|
||||
|
||||
it('opens on the custom mode when every catalog provider is already configured', async () => {
|
||||
const scripted = scriptedFace()
|
||||
scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([
|
||||
@@ -2150,15 +2199,25 @@ describe('apiKeyFailure', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['file', 'keySourceFile'], ['env', 'keySourceEnv'],
|
||||
['project-env', 'keySourceProjectEnv'], ['user-env', 'keySourceUserEnv'],
|
||||
] as const)('shows the named credential source %s without reading its value', (source, key) => {
|
||||
it.each(['file', 'env', 'project-env', 'user-env'] as const)('shows only the blank-input environment detection for source %s', (source) => {
|
||||
renderPiAiEditor('openai', piAiNamespaceWith('openai', { apiKeyEnv: 'OPENAI_API_KEY' }), {
|
||||
credential: { configured: true, source, writable: true },
|
||||
})
|
||||
expect(screen.getByText(en[key])).toBeTruthy()
|
||||
expect(screen.getByLabelText<HTMLInputElement>(en.keyInput).value).toBe('')
|
||||
const hint = en.keyFallbackDetected.replace('{hint}', 'OPENAI_API_KEY')
|
||||
const input = screen.getByLabelText<HTMLInputElement>(en.keyInput)
|
||||
expect(screen.queryByText(/^API key source:/)).toBeNull()
|
||||
if (source === 'file') expect(screen.queryByText(hint)).toBeNull()
|
||||
else expect(screen.getAllByText(hint)).toHaveLength(1)
|
||||
expect(input.value).toBe('')
|
||||
expect(input.placeholder)
|
||||
.toBe(source === 'file' ? en.keyPlaceholderSaved : en.keyPlaceholderFallback.replace('{hint}', 'OPENAI_API_KEY'))
|
||||
fireEvent.change(input, { target: { value: 'new-key' } })
|
||||
expect(screen.queryByText(hint)).toBeNull()
|
||||
expect(screen.queryByText(/^API key source:/)).toBeNull()
|
||||
expect(input.getAttribute('aria-describedby')).toBeNull()
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
if (source === 'file') expect(screen.queryByText(hint)).toBeNull()
|
||||
else expect(screen.getAllByText(hint)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -2171,18 +2230,20 @@ it.each([
|
||||
expect(document.body.textContent).not.toMatch(/oauth/i)
|
||||
})
|
||||
|
||||
it('refreshes the open editor source from the page snapshot without an editor probe', async () => {
|
||||
it('refreshes a blank editor from saved credentials to environment detection without an editor probe', async () => {
|
||||
const { face, controller } = await mountSection()
|
||||
const reads = face.credentials.describe.mock.calls.length
|
||||
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
|
||||
expect(screen.getByText(en.keySourceFile)).toBeTruthy()
|
||||
expect(screen.getByLabelText<HTMLInputElement>(en.keyInput).placeholder).toBe(en.keyPlaceholderSaved)
|
||||
expect(screen.queryByText(/^API key source:/)).toBeNull()
|
||||
expect(screen.queryByText(/Env var detected/)).toBeNull()
|
||||
expect(face.credentials.describe).toHaveBeenCalledTimes(reads)
|
||||
face.credentials.describe.mockImplementation(refs => Promise.resolve(remoteOk(
|
||||
Object.fromEntries(refs.map(ref => [ref, { configured: true, source: 'env', writable: true }])),
|
||||
)))
|
||||
await act(() => controller.load())
|
||||
expect(screen.getByText(en.keySourceEnv)).toBeTruthy()
|
||||
expect(screen.queryByText(en.keySourceFile)).toBeNull()
|
||||
expect(screen.getByText(en.keyFallbackDetected.replace('{hint}', 'OPENAI_API_KEY'))).toBeTruthy()
|
||||
expect(screen.queryByText(/^API key source:/)).toBeNull()
|
||||
expect(face.credentials.describe).toHaveBeenCalledTimes(reads + 1)
|
||||
})
|
||||
|
||||
@@ -2250,6 +2311,36 @@ it('shows the next method-neutral fallback without modifying native authenticati
|
||||
expect(document.body.textContent).not.toMatch(/oauth/i)
|
||||
})
|
||||
|
||||
it('detects a hidden native credential without displaying its reference', () => {
|
||||
renderPiAiEditor('anthropic', piAiNamespaceWith('anthropic', {}), {
|
||||
nativeAuth: { env: ['ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'], oauth: true },
|
||||
credentialChecks: {
|
||||
ANTHROPIC_OAUTH_TOKEN: { configured: true, source: 'env', writable: true },
|
||||
ANTHROPIC_API_KEY: { configured: false, writable: true },
|
||||
},
|
||||
})
|
||||
expect(screen.queryByText(en.keyFallbackMissing.replace('{hint}', 'ANTHROPIC_API_KEY'))).toBeNull()
|
||||
expect(document.body.textContent).toContain('Env var detected.')
|
||||
expect(document.body.textContent).not.toMatch(/oauth/i)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['cloudflare-ai-gateway', ['CLOUDFLARE_API_KEY', 'CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_GATEWAY_ID'], ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_GATEWAY_ID']],
|
||||
['cloudflare-workers-ai', ['CLOUDFLARE_API_KEY', 'CLOUDFLARE_ACCOUNT_ID'], ['CLOUDFLARE_ACCOUNT_ID']],
|
||||
['google-vertex', ['GOOGLE_CLOUD_API_KEY', 'GOOGLE_CLOUD_PROJECT', 'GCLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION'], ['GOOGLE_CLOUD_PROJECT', 'GCLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION']],
|
||||
] as const)('does not report endpoint variables as detected credentials for %s', (provider, env, baseURLEnv) => {
|
||||
renderPiAiEditor(provider, piAiNamespaceWith(provider, {}), {
|
||||
nativeAuth: { env, oauth: false },
|
||||
baseURLEnv,
|
||||
credentialChecks: Object.fromEntries(env.map(ref => [ref, {
|
||||
configured: baseURLEnv.some(name => name === ref), source: 'env', writable: true,
|
||||
}])),
|
||||
})
|
||||
expect(screen.getByText(en.keyFallbackMissing.replace('{hint}', env[0]))).toBeTruthy()
|
||||
expect(authHelpText()).toBe(en.keyHelp.replace('{env}', env[0]))
|
||||
expect(screen.queryByText(/Env var detected/)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a method-specific profile reference usable without displaying its name', () => {
|
||||
renderPiAiEditor('anthropic', piAiNamespaceWith('anthropic', { apiKeyEnv: 'TEAM_OAUTH_TOKEN' }), {
|
||||
nativeAuth: { env: ['ANTHROPIC_API_KEY'], oauth: true },
|
||||
@@ -2267,6 +2358,60 @@ it('offers a plain key field to a route the adapter reports no authentication or
|
||||
expect(screen.getByLabelText<HTMLInputElement>(en.baseUrl).placeholder).toBe(en.baseUrlDefault)
|
||||
})
|
||||
|
||||
it('shows endpoint configuration variables without presenting them as credential keys', () => {
|
||||
const baseURLEnv = ['AZURE_OPENAI_BASE_URL', 'AZURE_OPENAI_RESOURCE_NAME']
|
||||
renderPiAiEditor('azure', wireNamespaces().find(view => view.ns === 'llm-pi-ai')!, {
|
||||
baseURLEnv, nativeAuth: { env: ['AZURE_OPENAI_API_KEY'], oauth: false },
|
||||
})
|
||||
expect(authHelpText()).toContain('AZURE_OPENAI_API_KEY')
|
||||
expect(authHelpText()).not.toContain('AZURE_OPENAI_BASE_URL')
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
const input = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
|
||||
expect(input.placeholder).toBe(en.baseUrlConfigured)
|
||||
expect(document.getElementById(input.getAttribute('aria-describedby')!)?.textContent)
|
||||
.toBe(en.baseUrlEnvironment.replace('{env}', baseURLEnv.join(', ')))
|
||||
})
|
||||
|
||||
it.each([true, false, undefined])('shows the confirmed fallback state without assuming a connection works: %s', (configured) => {
|
||||
renderPiAiEditor('deepseek', wireNamespaces().find(view => view.ns === 'llm-pi-ai')!, {
|
||||
nativeAuth: { env: ['DEEPSEEK_API_KEY'], oauth: false },
|
||||
...configured === undefined ? {} : { credentialChecks: { DEEPSEEK_API_KEY: { configured, writable: true } } },
|
||||
})
|
||||
const input = screen.getByLabelText<HTMLInputElement>(en.keyInput)
|
||||
expect(input.placeholder).toBe(en.keyPlaceholderFallback.replace('{hint}', 'DEEPSEEK_API_KEY'))
|
||||
const hint = configured === true ? en.keyFallbackDetected.replace('{hint}', 'DEEPSEEK_API_KEY')
|
||||
: configured === false ? en.keyFallbackMissing.replace('{hint}', 'DEEPSEEK_API_KEY') : en.keyFallbackUnchecked
|
||||
expect(screen.getByText(hint).getAttribute('role')).toBeNull()
|
||||
expect(document.getElementById(input.getAttribute('aria-describedby')!)?.textContent).toBe(hint)
|
||||
fireEvent.change(input, { target: { value: 'new-key' } })
|
||||
expect(screen.queryByText(hint)).toBeNull()
|
||||
expect(input.getAttribute('aria-describedby')).toBeNull()
|
||||
})
|
||||
|
||||
it('checks all native fallback names and displays a later configured source', () => {
|
||||
renderPiAiEditor('anthropic', wireNamespaces().find(view => view.ns === 'llm-pi-ai')!, {
|
||||
nativeAuth: { env: ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'], oauth: true },
|
||||
credentialChecks: {
|
||||
ANTHROPIC_AUTH_TOKEN: { configured: false, writable: true },
|
||||
ANTHROPIC_OAUTH_TOKEN: { configured: true, writable: true },
|
||||
ANTHROPIC_API_KEY: { configured: true, source: 'env', writable: true },
|
||||
},
|
||||
})
|
||||
expect(screen.getByText(en.keyFallbackDetectedHidden)).toBeTruthy()
|
||||
expect(document.body.textContent).not.toMatch(/oauth/i)
|
||||
})
|
||||
|
||||
it('keeps an inherited profile endpoint ahead of dynamic provider configuration', () => {
|
||||
const namespace = wireNamespaces().find(view => view.ns === 'llm-pi-ai')!
|
||||
renderPiAiEditor('azure', { ...namespace, value: { providers: { azure: { baseURL: 'https://team.example/openai/v1' } } } }, {
|
||||
baseURLEnv: ['AZURE_OPENAI_BASE_URL'],
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
const input = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
|
||||
expect(input.placeholder).toBe('https://team.example/openai/v1')
|
||||
expect(input.getAttribute('aria-describedby')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the localized account row as signed in without an edit action', async () => {
|
||||
const scripted = scriptedFace({})
|
||||
const { controller } = await mountFace(scripted)
|
||||
|
||||
@@ -20,10 +20,10 @@ it('carries the endpoint and native authentication the adapter reports onto the
|
||||
const nativeAuth = { env: ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'], oauth: true }
|
||||
expect(joinProviderDirectory([], [{
|
||||
provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'],
|
||||
defaultBaseURL: 'https://api.anthropic.com', nativeAuth,
|
||||
defaultBaseURL: 'https://api.anthropic.com', baseURLEnv: ['ENDPOINT'], nativeAuth,
|
||||
}])).toEqual([{
|
||||
provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'],
|
||||
active: false, defaultBaseURL: 'https://api.anthropic.com', nativeAuth,
|
||||
active: false, defaultBaseURL: 'https://api.anthropic.com', baseURLEnv: ['ENDPOINT'], nativeAuth,
|
||||
}])
|
||||
})
|
||||
|
||||
@@ -162,6 +162,20 @@ function api(overrides: {
|
||||
}
|
||||
|
||||
describe('ModelsSettingsStore', () => {
|
||||
it('describes native variables without exposing secret values and respects the 64-reference limit', async () => {
|
||||
const providers = Array.from({ length: 65 }, (_, index) => ({
|
||||
provider: `fixture-${index}`, displayName: 'fixture', settingsNs: 'llm-pi-ai', settingsPath: ['providers', `fixture-${index}`], active: false,
|
||||
nativeAuth: { env: [`NATIVE_${index}`], oauth: false },
|
||||
}))
|
||||
const { ctx, mirror, seenRefs } = api({ providers: () => Promise.resolve(ok({ providers })) })
|
||||
const store = new ModelsSettingsStore(ctx, settingsSchema, mirror)
|
||||
await store.load()
|
||||
expect(seenRefs.map(refs => refs.length)).toEqual([64, 64, 2])
|
||||
expect(seenRefs.flat()).toContain('NATIVE_64')
|
||||
expect(store.store.getSnapshot().rows[64]!.credentialChecks)
|
||||
.toEqual({ NATIVE_64: { configured: false, writable: true } })
|
||||
})
|
||||
|
||||
it('joins rows with configured, removable, and credential state', async () => {
|
||||
const { ctx, mirror, seenRefs } = api()
|
||||
const store = new ModelsSettingsStore(ctx, settingsSchema, mirror)
|
||||
|
||||
@@ -5862,7 +5862,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'LlmConfigurableProvider',
|
||||
declaration: 'export interface LlmConfigurableProvider {\n provider: string;\n displayName: string;\n settingsNs: string;\n settingsPath: readonly string[];\n declared?: boolean;\n defaultBaseURL?: string;\n nativeAuth?: LlmNativeAuth;\n error?: string;\n}',
|
||||
declaration: 'export interface LlmConfigurableProvider {\n provider: string;\n displayName: string;\n settingsNs: string;\n settingsPath: readonly string[];\n declared?: boolean;\n defaultBaseURL?: string;\n baseURLEnv?: readonly string[];\n nativeAuth?: LlmNativeAuth;\n error?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'LlmDiscoveredModel',
|
||||
|
||||
@@ -48,11 +48,14 @@
|
||||
en: 8aba1113937b368a
|
||||
zh: 804a4ca672a776df
|
||||
/deepseek-ai-dsh-llm-pi-ai/understand-the-implementation/source-map:
|
||||
en: cb23b02b49ac8c9a
|
||||
zh: 304f998a2cd33893
|
||||
en: 64912b8a3a336419
|
||||
zh: 33a245969b4194f6
|
||||
/deepseek-ai-dsh-llm-pi-ai/understand-the-implementation/registration-and-directory:
|
||||
en: e9aa65cd3417fc05
|
||||
zh: 4568471e712b055e
|
||||
en: 0c6df3e8b6a75372
|
||||
zh: 476666a6f209542c
|
||||
/deepseek-ai-dsh-llm-pi-ai/understand-the-implementation/generated-provider-metadata:
|
||||
en: 477d277e9ac35c8f
|
||||
zh: 96870ca333fb6510
|
||||
/deepseek-ai-dsh-llm-pi-ai/understand-the-implementation/replay-and-vocabulary:
|
||||
en: 73242ca9b8a98c76
|
||||
zh: 4ab98526ae70bd23
|
||||
|
||||
@@ -149,7 +149,7 @@ Initial prompt text and active tools use pi-ai's `Context.systemPrompt` and `Con
|
||||
| [`src/login.ts`](src/login.ts) | Authorization flows for the installed providers that ship a login |
|
||||
| [`src/config.ts`](src/config.ts) | Profile schema, resolution, and serviceability checks |
|
||||
| [`src/catalog.ts`](src/catalog.ts) | Installed-catalog integration and drift gates |
|
||||
| [`src/native-auth.ts`](src/native-auth.ts) | Environment variables each installed provider's native authentication reads |
|
||||
| [`src/provider-data.ts`](src/provider-data.ts) | Generated authentication and endpoint metadata for configuration surfaces |
|
||||
| [`src/models.ts`](src/models.ts) | Model collections, static providers, and reasoning levels over narrow pi-ai entry points |
|
||||
| [`src/provider.ts`](src/provider.ts) | The supported-protocol table and provider construction |
|
||||
| [`src/context.ts`](src/context.ts) | Harness-to-pi-ai context conversion, image handling, replay restore |
|
||||
@@ -159,7 +159,26 @@ Initial prompt text and active tools use pi-ai's `Context.systemPrompt` and `Con
|
||||
|
||||
### Registration and directory
|
||||
|
||||
The plugin declares every installed catalog provider it can authenticate in the configurable-provider directory, joined with every route the current profiles declare, so configuration surfaces can offer the full catalog before any route exists. A catalog provider whose catalog lists models but no chat model, such as one of classifier models alone, is left out and gets no sign-in flow, because this adapter dispatches chat requests only. Each entry carries `declared` — whether pi-ai ships nothing under that key — because only the adapter can distinguish a hand-declared route from a narrowed catalog route. A catalog entry also carries `defaultBaseURL` when pi-ai ships a fixed endpoint for the provider, and `nativeAuth`: the environment variables the provider's own authentication reads, in lookup order, and whether it offers an OAuth sign-in. pi-ai exports no list of those names, so `src/native-auth.ts` keeps one, and `tests/native-auth.spec.ts` runs every installed provider's resolver to fail on a changed name or order. Route registration is atomic: a candidate set that collides with another adapter leaves the previous routes serving. A bare mount with zero routes is the dormant posture: nothing registers until the Config supplies profiles, and routes drop when it empties.
|
||||
The plugin declares every installed catalog provider it can authenticate in the configurable-provider directory, joined with every route the current profiles declare, so configuration surfaces can offer the full catalog before any route exists. A catalog provider whose catalog lists models but no chat model, such as one of classifier models alone, is left out and gets no sign-in flow, because this adapter dispatches chat requests only. Each entry carries `declared` — whether pi-ai ships nothing under that key — because only the adapter can distinguish a hand-declared route from a narrowed catalog route. A catalog entry carries `defaultBaseURL` for a fixed provider endpoint or a fixed endpoint shared by all its models, `baseURLEnv` for endpoint configuration variables, and `nativeAuth` for authentication variables and OAuth availability. Different model endpoints and unresolved URL templates are not presented as one fixed default. Route registration is atomic: a candidate set that collides with another adapter leaves the previous routes serving. A bare mount with zero routes is the dormant posture: nothing registers until the Config supplies profiles, and routes drop when it empties.
|
||||
|
||||
<a id="generated-provider-metadata"></a>
|
||||
### Generated provider metadata
|
||||
|
||||
[`scripts/gen-provider-data.ts`](scripts/gen-provider-data.ts) owns [`src/provider-data.generated.ts`](src/provider-data.generated.ts). It uses public resolvers with synthetic `AuthContext` answers to discover environment reads across empty, individual and suffix configurations. Resolver failures use `findEnvKeys` through the temporary compatibility entry. All current authentication variables come from resolvers; no authentication supplement is needed. The generator reports fallback use and fails if fallback metadata is missing. It does not read real credentials, start a login flow, or send a model request. Probing covers the installed resolvers; it does not prove all possible value-dependent branches.
|
||||
|
||||
Endpoint data comes from the public provider/model catalog and uppercase URL placeholders. Azure, Vertex and Bedrock need small supplements for variables read outside the auth context. Runtime directory reads execute no probes. The dependency range remains unchanged. A provider added by a newer consumer installation has no generated authentication hints until regeneration. For an existing provider, changed environment names and endpoints also remain at the generated snapshot until regeneration. Environment hints check only the listed credential references; they do not inspect provider-managed files such as AWS profiles or Google application-default credentials.
|
||||
|
||||
After updating pi-ai or changing the generator, install the updated locked dependencies, then run these commands from the repository root:
|
||||
|
||||
```sh
|
||||
pnpm run gen-provider-data
|
||||
pnpm run verify-provider-data
|
||||
git add packages/llm/llm-pi-ai/src/provider-data.generated.ts
|
||||
```
|
||||
|
||||
Review the generated diff and stage the dependency or generator changes with it. Do not edit the generated file by hand. Generation errors must be resolved before committing.
|
||||
|
||||
The `pre-commit` hook runs `node_modules/.bin/tsx packages/llm/llm-pi-ai/scripts/gen-provider-data.ts --check --cached` when `pnpm-lock.yaml`, this package's `package.json`, the generator, or the generated file is staged. It compares the staged generated file with expected data computed from the installed dependency and current generator. It does not rewrite files or stage them. Updating only the working file is insufficient; stage the generated file too. If the check reports stale metadata, rerun the commands above before retrying the commit. CI runs `pnpm run verify-provider-data` against the checkout and installed lockfile dependencies even when the local hook was not triggered.
|
||||
|
||||
### Replay and vocabulary
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ Config 更新严格验证发生变化的 provider。初始加载将已存储的
|
||||
| [`src/login.ts`](src/login.ts) | 面向提供登录的已安装提供方的授权流程 |
|
||||
| [`src/config.ts`](src/config.ts) | Profile schema、解析与可服务性校验 |
|
||||
| [`src/catalog.ts`](src/catalog.ts) | 已安装目录集成与漂移门禁 |
|
||||
| [`src/native-auth.ts`](src/native-auth.ts) | 每个已安装提供方原生认证读取的环境变量 |
|
||||
| [`src/provider-data.ts`](src/provider-data.ts) | 配置界面使用的认证与端点生成数据 |
|
||||
| [`src/models.ts`](src/models.ts) | 基于 pi-ai 窄入口的 model collection、静态 provider 与 reasoning level |
|
||||
| [`src/provider.ts`](src/provider.ts) | 受支持协议表与提供方构建 |
|
||||
| [`src/context.ts`](src/context.ts) | Harness 到 pi-ai 的上下文转换、图片处理、回放恢复 |
|
||||
@@ -159,7 +159,26 @@ Config 更新严格验证发生变化的 provider。初始加载将已存储的
|
||||
|
||||
### 注册与目录
|
||||
|
||||
插件会在可配置提供方目录中声明它能认证的每个已安装目录提供方,并加入当前 profile 声明的每条路由,因此配置界面可以在任何路由存在之前提供完整目录。目录列出了模型却没有聊天模型的提供方(例如只有分类器模型)不进入目录,也不注册登录流程,因为本适配器只分派聊天请求。每个条目都携带 `declared`——pi-ai 是否在该键下不提供任何内容——因为只有适配器能区分手工声明路由与收窄目录路由。目录条目在 pi-ai 为该提供方提供固定端点时还携带 `defaultBaseURL`,并携带 `nativeAuth`:提供方自身认证按查找顺序读取的环境变量,以及它是否提供 OAuth 登录。pi-ai 不导出这些变量名的列表,因此由 `src/native-auth.ts` 维护一份,`tests/native-auth.spec.ts` 运行每个已安装提供方的解析器,变量名或顺序变化时测试失败。路由注册具有原子性:与其他适配器冲突的候选集合会让此前路由继续服务。零路由的裸挂载即休眠姿态:settings 分节提供 profile 前不注册任何内容,分节清空时路由随之消失。
|
||||
插件会在可配置提供方目录中声明它能认证的每个已安装目录提供方,并加入当前 profile 声明的每条路由,因此配置界面可以在任何路由存在之前提供完整目录。目录列出了模型却没有聊天模型的提供方(例如只有分类器模型)不进入目录,也不注册登录流程,因为本适配器只分派聊天请求。每个条目都携带 `declared`——pi-ai 是否在该键下不提供任何内容——因为只有适配器能区分手工声明路由与收窄目录路由。目录条目以 `defaultBaseURL` 表示提供方的固定端点或全部模型共用的固定端点,以 `baseURLEnv` 表示端点配置变量,以 `nativeAuth` 表示认证变量和 OAuth 可用性。不同模型的端点和未展开的 URL 模板不会显示为单一固定默认值。路由注册具有原子性:与其他适配器冲突的候选集合会让此前路由继续服务。零路由的裸挂载即休眠姿态:settings 分节提供 profile 前不注册任何内容,分节清空时路由随之消失。
|
||||
|
||||
<a id="generated-provider-metadata"></a>
|
||||
### 生成的提供方元数据
|
||||
|
||||
[`scripts/gen-provider-data.ts`](scripts/gen-provider-data.ts) 负责生成 [`src/provider-data.generated.ts`](src/provider-data.generated.ts)。它通过公开 resolver 和模拟的 `AuthContext` 回答,探测空配置、单个变量及变量后缀组合下的环境读取。resolver 失败时使用临时兼容入口的 `findEnvKeys`。当前所有认证变量都来自 resolver,无需认证变量补充。生成器会报告回退;缺少回退数据时生成失败。它不读取真实凭据,不启动登录流程,也不发送模型请求。探测覆盖已安装的 resolver,不能证明所有依赖具体变量值的分支。
|
||||
|
||||
端点数据来自公开的提供方及模型目录,以及 URL 中的大写变量占位符。Azure、Vertex 和 Bedrock 需补充认证上下文之外读取的变量。运行时读取目录不执行探测。依赖范围保持不变。用户安装的新版本新增提供方时,重新生成之前不显示其认证变量提示。已有提供方的环境变量名和端点发生变化时,也会继续使用生成快照,直到重新生成。环境提示只检查列出的凭据引用,不检查 AWS profile 或 Google 应用默认凭据等提供方管理的文件。
|
||||
|
||||
更新 pi-ai 或修改生成器后,先按更新的锁文件安装依赖,再从仓库根目录运行以下命令:
|
||||
|
||||
```sh
|
||||
pnpm run gen-provider-data
|
||||
pnpm run verify-provider-data
|
||||
git add packages/llm/llm-pi-ai/src/provider-data.generated.ts
|
||||
```
|
||||
|
||||
检查生成的 diff,并一同暂存依赖或生成器的改动。不要手工编辑生成文件。生成失败时,先解决错误再提交。
|
||||
|
||||
暂存 `pnpm-lock.yaml`、本包的 `package.json`、生成器或生成文件时,`pre-commit` 钩子会直接运行 `node_modules/.bin/tsx packages/llm/llm-pi-ai/scripts/gen-provider-data.ts --check --cached`,无需 Git 客户端的 PATH 中有 `pnpm`。它将暂存的生成文件与基于已安装依赖和当前生成器计算的预期数据比较,不改写或暂存文件。只更新工作区文件还不够;生成文件也必须暂存。检查报告元数据过期时,请重新运行上述命令,再尝试提交。即使本地钩子未触发,CI 仍会基于检出文件和按锁文件安装的依赖运行 `pnpm run verify-provider-data`。
|
||||
|
||||
### 回放与词汇
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/** Generate display metadata through pi-ai's public catalog and auth context. */
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import ts from 'typescript'
|
||||
import type { Provider } from '@earendil-works/pi-ai'
|
||||
import { builtinProviders } from '@earendil-works/pi-ai/providers/all'
|
||||
|
||||
interface ProviderData {
|
||||
env: readonly string[]
|
||||
defaultBaseURL?: string
|
||||
baseURLEnv?: readonly string[]
|
||||
}
|
||||
|
||||
const printer = ts.createPrinter()
|
||||
const sourceFile = ts.createSourceFile('provider-data.ts', '', ts.ScriptTarget.Latest)
|
||||
const quote = (value: string): string => printer.printNode(ts.EmitHint.Expression, ts.factory.createStringLiteral(value, true), sourceFile)
|
||||
|
||||
// Endpoint variables read by API implementations, outside AuthContext and
|
||||
// uppercase catalog URL placeholders. Keep exceptions here, not in the UI.
|
||||
const SUPPLEMENTS: Readonly<Record<string, { baseURLEnv: readonly string[] }>> = {
|
||||
azure: { baseURLEnv: ['AZURE_OPENAI_BASE_URL', 'AZURE_OPENAI_RESOURCE_NAME'] },
|
||||
'amazon-bedrock': { baseURLEnv: ['AWS_REGION', 'AWS_DEFAULT_REGION'] },
|
||||
'google-vertex': { baseURLEnv: ['GOOGLE_CLOUD_PROJECT', 'GCLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION'] },
|
||||
}
|
||||
|
||||
/** Insert newly discovered names after their predecessor in the resolver. */
|
||||
function merge(into: string[], sequence: readonly string[]): void {
|
||||
for (const [index, name] of sequence.entries()) {
|
||||
if (into.includes(name)) continue
|
||||
into.splice(index === 0 ? 0 : into.indexOf(sequence[index - 1]!) + 1, 0, name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover resolver reads using only synthetic environment and file answers.
|
||||
* @param provider - Installed provider; no credential or login flow is used.
|
||||
* @returns Names observed across empty, individual and suffix configurations.
|
||||
*/
|
||||
export async function resolverEnv(provider: Provider): Promise<string[]> {
|
||||
const names: string[] = []
|
||||
const controller = new AbortController()
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
const timedOut = new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
const error = new Error(`${provider.id}: auth metadata probe timed out`)
|
||||
controller.abort(error)
|
||||
reject(error)
|
||||
}, 2000)
|
||||
})
|
||||
const reads = async (set: ReadonlySet<string>): Promise<string[]> => {
|
||||
const read: string[] = []
|
||||
await Promise.race([provider.auth.apiKey?.resolve({
|
||||
ctx: {
|
||||
env: (name) => {
|
||||
controller.signal.throwIfAborted()
|
||||
if (read.length >= 128) throw new Error(`${provider.id}: auth metadata probe exceeded its read limit`)
|
||||
read.push(name)
|
||||
return Promise.resolve(set.has(name) ? 'probe-value' : undefined)
|
||||
},
|
||||
fileExists: () => Promise.resolve(false),
|
||||
},
|
||||
signal: controller.signal,
|
||||
}), timedOut])
|
||||
controller.signal.throwIfAborted()
|
||||
return read
|
||||
}
|
||||
try {
|
||||
merge(names, await reads(new Set()))
|
||||
let previousLength: number
|
||||
do {
|
||||
previousLength = names.length
|
||||
if (names.length > 64) throw new Error(`${provider.id}: auth metadata probe exceeded its name limit`)
|
||||
for (let index = 0; index < previousLength; index++) {
|
||||
merge(names, await reads(new Set([names[index]!])))
|
||||
merge(names, await reads(new Set(names.slice(index))))
|
||||
}
|
||||
} while (names.length !== previousLength)
|
||||
return names
|
||||
} finally {
|
||||
clearTimeout(timeout!)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate metadata without resolving real credentials or streaming a request.
|
||||
* @param providers - Public installed catalog, or isolated providers for verification.
|
||||
* @returns Display metadata keyed by provider id.
|
||||
*/
|
||||
export async function generateProviderData(providers: readonly Provider[] = builtinProviders()): Promise<Record<string, ProviderData>> {
|
||||
const data: Record<string, ProviderData> = {}
|
||||
for (const provider of providers) {
|
||||
let env: readonly string[]
|
||||
try { env = await resolverEnv(provider) }
|
||||
catch (error) {
|
||||
// compat is a temporary upstream surface. Import it only as a last
|
||||
// resort, with a synthetic dictionary that never reads process.env.
|
||||
const fallback = (await import('@earendil-works/pi-ai/compat')).findEnvKeys(
|
||||
provider.id, new Proxy<Record<string, string>>({}, { get: () => 'probe-value' }),
|
||||
)
|
||||
if (fallback === undefined || fallback.length === 0) throw new Error(`${provider.id}: no auth metadata fallback`, { cause: error })
|
||||
console.warn(`${provider.id}: using findEnvKeys auth metadata fallback`, error)
|
||||
env = fallback
|
||||
}
|
||||
const urls = provider.getModels().map(model => model.baseUrl)
|
||||
const fixed = provider.baseUrl || (urls.length > 0 && urls.every(url => url === urls[0]) ? urls[0] : undefined)
|
||||
const baseURLEnv = [...new Set([
|
||||
...(SUPPLEMENTS[provider.id]?.baseURLEnv ?? []),
|
||||
...[provider.baseUrl ?? '', ...urls].flatMap(url => [...url.matchAll(/\{([A-Z][A-Z0-9_]+)\}/g)].map(match => match[1]!)),
|
||||
])]
|
||||
data[provider.id] = {
|
||||
env,
|
||||
...fixed && !fixed.includes('{') ? { defaultBaseURL: fixed } : {},
|
||||
...baseURLEnv.length === 0 ? {} : { baseURLEnv },
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the deterministic catalog, or reject stale data without changing it.
|
||||
* @param check - Validate the working file instead of writing it.
|
||||
* @param cached - Validate the staged file instead; never write or stage files.
|
||||
*/
|
||||
export async function syncProviderData(check: boolean, cached = false): Promise<void> {
|
||||
const source = '/** Generated from the installed pi-ai catalog. Run pnpm gen-provider-data; do not edit. */\n\n'
|
||||
+ "import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all'\n"
|
||||
+ "import type { LlmConfigurableProvider } from '@deepseek-ai/dsh-llm'\n\n"
|
||||
+ "type ProviderData = Pick<LlmConfigurableProvider, 'defaultBaseURL' | 'baseURLEnv'> & { env: readonly string[] }\n\n"
|
||||
+ '/** Catalog endpoints and environment names; no credentials are evaluated at runtime. */\n'
|
||||
+ 'export const PROVIDER_DATA: Record<BuiltinProvider, ProviderData> & Partial<Record<string, ProviderData>> = {\n'
|
||||
+ Object.entries(await generateProviderData()).map(([id, data]) => {
|
||||
const fields = [`env: [${data.env.map(quote).join(', ')}]`,
|
||||
...data.defaultBaseURL === undefined ? [] : [`defaultBaseURL: ${quote(data.defaultBaseURL)}`],
|
||||
...data.baseURLEnv === undefined ? [] : [`baseURLEnv: [${data.baseURLEnv.map(quote).join(', ')}]`],
|
||||
]
|
||||
return ` ${quote(id)}: { ${fields.join(', ')} },\n`
|
||||
}).join('') + '}\n'
|
||||
const path = new URL('../src/provider-data.generated.ts', import.meta.url)
|
||||
let current: string | undefined
|
||||
try {
|
||||
current = cached ? execFileSync('git', ['show', ':packages/llm/llm-pi-ai/src/provider-data.generated.ts'], {
|
||||
cwd: fileURLToPath(new URL('../../../../', import.meta.url)), encoding: 'utf8', stdio: 'pipe',
|
||||
}) : readFileSync(path, 'utf8')
|
||||
}
|
||||
catch (error) {
|
||||
if (cached) throw new Error('Cannot read staged provider metadata; run pnpm gen-provider-data and stage provider-data.generated.ts', { cause: error })
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
if (current === source) return
|
||||
if (check || cached) throw new Error(`Provider metadata is stale; run pnpm gen-provider-data${cached ? ' and stage provider-data.generated.ts' : ''}`)
|
||||
writeFileSync(path, source)
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
await syncProviderData(process.argv.includes('--check'), process.argv.includes('--cached'))
|
||||
console.log('Provider metadata matches the installed pi-ai catalog.')
|
||||
}
|
||||
@@ -72,7 +72,7 @@ import type { ResolvedPiAiProviderProfile } from './config.ts'
|
||||
import { discoverModels } from './discovery.ts'
|
||||
import type { StoredModelDiscoveryProfile } from './discovery.ts'
|
||||
import { registerPiAiFlows } from './login.ts'
|
||||
import { nativeAuthOf } from './native-auth.ts'
|
||||
import { providerDefaultsOf } from './provider-data.ts'
|
||||
|
||||
export { PiAiAdapter } from './adapter.ts'
|
||||
export type { PiAiAdapterOptions } from './adapter.ts'
|
||||
@@ -141,8 +141,7 @@ function directoryEntries(
|
||||
// narrowing a shipped provider's models stores a profile too, and that
|
||||
// route is still one pi-ai knows.
|
||||
declared: installed === undefined,
|
||||
...installed?.baseUrl === undefined ? {} : { defaultBaseURL: installed.baseUrl },
|
||||
...installed === undefined ? {} : { nativeAuth: nativeAuthOf(installed) },
|
||||
...installed === undefined ? {} : providerDefaultsOf(installed),
|
||||
...error === undefined ? {} : { error },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* The authentication each installed pi-ai provider performs when a profile
|
||||
* names no credential reference. A provider's API-key resolver reads
|
||||
* environment variables through `AuthContext.env`, but pi-ai exports no list
|
||||
* of the names it reads, so this module lists them. The `Record` key type
|
||||
* makes a pi-ai upgrade that adds or removes a provider fail compilation here,
|
||||
* and `tests/native-auth.spec.ts` runs every installed resolver to fail on a
|
||||
* changed name or lookup order.
|
||||
*
|
||||
* @module dsh-llm-pi-ai/native-auth
|
||||
*/
|
||||
|
||||
import type { Provider } from '@earendil-works/pi-ai'
|
||||
import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all'
|
||||
import type { LlmNativeAuth } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Environment variables each installed provider's API-key resolver reads, in lookup order. */
|
||||
const NATIVE_AUTH_ENV: Record<BuiltinProvider, readonly string[]> = {
|
||||
'amazon-bedrock': [
|
||||
'AWS_BEARER_TOKEN_BEDROCK',
|
||||
'AWS_PROFILE',
|
||||
'AWS_ACCESS_KEY_ID',
|
||||
'AWS_SECRET_ACCESS_KEY',
|
||||
'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI',
|
||||
'AWS_CONTAINER_CREDENTIALS_FULL_URI',
|
||||
'AWS_WEB_IDENTITY_TOKEN_FILE',
|
||||
],
|
||||
'ant-ling': ['ANT_LING_API_KEY'],
|
||||
anthropic: [
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_OAUTH_TOKEN',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_FEDERATION_RULE_ID',
|
||||
'ANTHROPIC_ORGANIZATION_ID',
|
||||
'ANTHROPIC_IDENTITY_TOKEN_FILE',
|
||||
'ANTHROPIC_SERVICE_ACCOUNT_ID',
|
||||
'ANTHROPIC_WORKSPACE_ID',
|
||||
],
|
||||
azure: ['AZURE_OPENAI_API_KEY'],
|
||||
baseten: ['BASETEN_API_KEY'],
|
||||
cerebras: ['CEREBRAS_API_KEY'],
|
||||
'cloudflare-ai-gateway': ['CLOUDFLARE_API_KEY', 'CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_GATEWAY_ID'],
|
||||
'cloudflare-workers-ai': ['CLOUDFLARE_API_KEY', 'CLOUDFLARE_ACCOUNT_ID'],
|
||||
deepseek: ['DEEPSEEK_API_KEY'],
|
||||
fireworks: ['FIREWORKS_API_KEY'],
|
||||
'github-copilot': ['COPILOT_GITHUB_TOKEN'],
|
||||
google: ['GEMINI_API_KEY'],
|
||||
'google-vertex': [
|
||||
'GOOGLE_CLOUD_API_KEY',
|
||||
'GOOGLE_APPLICATION_CREDENTIALS',
|
||||
'GOOGLE_CLOUD_PROJECT',
|
||||
'GCLOUD_PROJECT',
|
||||
'GOOGLE_CLOUD_LOCATION',
|
||||
],
|
||||
groq: ['GROQ_API_KEY'],
|
||||
huggingface: ['HF_TOKEN'],
|
||||
'kimi-coding': ['KIMI_API_KEY'],
|
||||
meta: ['META_API_KEY'],
|
||||
minimax: ['MINIMAX_API_KEY'],
|
||||
'minimax-cn': ['MINIMAX_CN_API_KEY'],
|
||||
mistral: ['MISTRAL_API_KEY'],
|
||||
moonshotai: ['MOONSHOT_API_KEY'],
|
||||
'moonshotai-cn': ['MOONSHOT_API_KEY'],
|
||||
nvidia: ['NVIDIA_API_KEY'],
|
||||
openai: ['OPENAI_API_KEY'],
|
||||
// Only its OAuth sign-in authenticates this provider.
|
||||
'openai-codex': [],
|
||||
opencode: ['OPENCODE_API_KEY'],
|
||||
'opencode-go': ['OPENCODE_API_KEY'],
|
||||
openrouter: ['OPENROUTER_API_KEY'],
|
||||
'qwen-token-plan': ['QWEN_TOKEN_PLAN_API_KEY'],
|
||||
'qwen-token-plan-cn': ['QWEN_TOKEN_PLAN_CN_API_KEY'],
|
||||
'qwen-token-plan-individual': ['QWEN_TOKEN_PLAN_API_KEY'],
|
||||
radius: ['RADIUS_API_KEY'],
|
||||
together: ['TOGETHER_API_KEY'],
|
||||
typesafe: ['TYPESAFE_API_KEY'],
|
||||
'vercel-ai-gateway': ['AI_GATEWAY_API_KEY'],
|
||||
xai: ['XAI_API_KEY'],
|
||||
xiaomi: ['XIAOMI_API_KEY'],
|
||||
'xiaomi-token-plan-ams': ['XIAOMI_TOKEN_PLAN_AMS_API_KEY'],
|
||||
'xiaomi-token-plan-cn': ['XIAOMI_TOKEN_PLAN_CN_API_KEY'],
|
||||
'xiaomi-token-plan-sgp': ['XIAOMI_TOKEN_PLAN_SGP_API_KEY'],
|
||||
zai: ['ZAI_API_KEY'],
|
||||
'zai-coding-cn': ['ZAI_CODING_CN_API_KEY'],
|
||||
}
|
||||
|
||||
/**
|
||||
* The native authentication of one installed catalog provider.
|
||||
* @param provider - a provider the installed pi-ai catalog ships.
|
||||
* @returns the environment variables its API-key resolver reads, and whether it ships an OAuth sign-in.
|
||||
*/
|
||||
export function nativeAuthOf(provider: Provider): LlmNativeAuth {
|
||||
return { env: NATIVE_AUTH_ENV[provider.id as BuiltinProvider], oauth: provider.auth.oauth !== undefined }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/** Generated from the installed pi-ai catalog. Run pnpm gen-provider-data; do not edit. */
|
||||
|
||||
import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all'
|
||||
import type { LlmConfigurableProvider } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
type ProviderData = Pick<LlmConfigurableProvider, 'defaultBaseURL' | 'baseURLEnv'> & { env: readonly string[] }
|
||||
|
||||
/** Catalog endpoints and environment names; no credentials are evaluated at runtime. */
|
||||
export const PROVIDER_DATA: Record<BuiltinProvider, ProviderData> & Partial<Record<string, ProviderData>> = {
|
||||
'amazon-bedrock': { env: ['AWS_BEARER_TOKEN_BEDROCK', 'AWS_PROFILE', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI', 'AWS_CONTAINER_CREDENTIALS_FULL_URI', 'AWS_WEB_IDENTITY_TOKEN_FILE'], baseURLEnv: ['AWS_REGION', 'AWS_DEFAULT_REGION'] },
|
||||
'ant-ling': { env: ['ANT_LING_API_KEY'], defaultBaseURL: 'https://api.ant-ling.com/v1' },
|
||||
'anthropic': { env: ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY', 'ANTHROPIC_FEDERATION_RULE_ID', 'ANTHROPIC_ORGANIZATION_ID', 'ANTHROPIC_IDENTITY_TOKEN_FILE', 'ANTHROPIC_SERVICE_ACCOUNT_ID', 'ANTHROPIC_WORKSPACE_ID'], defaultBaseURL: 'https://api.anthropic.com' },
|
||||
'azure': { env: ['AZURE_OPENAI_API_KEY'], baseURLEnv: ['AZURE_OPENAI_BASE_URL', 'AZURE_OPENAI_RESOURCE_NAME'] },
|
||||
'baseten': { env: ['BASETEN_API_KEY'], defaultBaseURL: 'https://inference.baseten.co/v1' },
|
||||
'cerebras': { env: ['CEREBRAS_API_KEY'], defaultBaseURL: 'https://api.cerebras.ai/v1' },
|
||||
'cloudflare-ai-gateway': { env: ['CLOUDFLARE_API_KEY', 'CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_GATEWAY_ID'], baseURLEnv: ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_GATEWAY_ID'] },
|
||||
'cloudflare-workers-ai': { env: ['CLOUDFLARE_API_KEY', 'CLOUDFLARE_ACCOUNT_ID'], baseURLEnv: ['CLOUDFLARE_ACCOUNT_ID'] },
|
||||
'deepseek': { env: ['DEEPSEEK_API_KEY'], defaultBaseURL: 'https://api.deepseek.com' },
|
||||
'fireworks': { env: ['FIREWORKS_API_KEY'], defaultBaseURL: 'https://api.fireworks.ai/inference' },
|
||||
'github-copilot': { env: ['COPILOT_GITHUB_TOKEN'], defaultBaseURL: 'https://api.individual.githubcopilot.com' },
|
||||
'google': { env: ['GEMINI_API_KEY'], defaultBaseURL: 'https://generativelanguage.googleapis.com/v1beta' },
|
||||
'google-vertex': { env: ['GOOGLE_CLOUD_API_KEY', 'GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_PROJECT', 'GCLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION'], baseURLEnv: ['GOOGLE_CLOUD_PROJECT', 'GCLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION'] },
|
||||
'groq': { env: ['GROQ_API_KEY'], defaultBaseURL: 'https://api.groq.com/openai/v1' },
|
||||
'huggingface': { env: ['HF_TOKEN'], defaultBaseURL: 'https://router.huggingface.co/v1' },
|
||||
'kimi-coding': { env: ['KIMI_API_KEY'], defaultBaseURL: 'https://api.kimi.com/coding' },
|
||||
'meta': { env: ['META_API_KEY'], defaultBaseURL: 'https://api.meta.ai/v1' },
|
||||
'minimax': { env: ['MINIMAX_API_KEY'], defaultBaseURL: 'https://api.minimax.io/anthropic' },
|
||||
'minimax-cn': { env: ['MINIMAX_CN_API_KEY'], defaultBaseURL: 'https://api.minimaxi.com/anthropic' },
|
||||
'mistral': { env: ['MISTRAL_API_KEY'], defaultBaseURL: 'https://api.mistral.ai' },
|
||||
'moonshotai': { env: ['MOONSHOT_API_KEY'], defaultBaseURL: 'https://api.moonshot.ai/v1' },
|
||||
'moonshotai-cn': { env: ['MOONSHOT_API_KEY'], defaultBaseURL: 'https://api.moonshot.cn/v1' },
|
||||
'nvidia': { env: ['NVIDIA_API_KEY'], defaultBaseURL: 'https://integrate.api.nvidia.com/v1' },
|
||||
'openai': { env: ['OPENAI_API_KEY'], defaultBaseURL: 'https://api.openai.com/v1' },
|
||||
'openai-codex': { env: [], defaultBaseURL: 'https://chatgpt.com/backend-api' },
|
||||
'opencode': { env: ['OPENCODE_API_KEY'] },
|
||||
'opencode-go': { env: ['OPENCODE_API_KEY'] },
|
||||
'openrouter': { env: ['OPENROUTER_API_KEY'], defaultBaseURL: 'https://openrouter.ai/api/v1' },
|
||||
'qwen-token-plan': { env: ['QWEN_TOKEN_PLAN_API_KEY'], defaultBaseURL: 'https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1' },
|
||||
'qwen-token-plan-cn': { env: ['QWEN_TOKEN_PLAN_CN_API_KEY'], defaultBaseURL: 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1' },
|
||||
'qwen-token-plan-individual': { env: ['QWEN_TOKEN_PLAN_API_KEY'], defaultBaseURL: 'https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1' },
|
||||
'radius': { env: ['RADIUS_API_KEY'], defaultBaseURL: 'https://radius.pi.dev/v1' },
|
||||
'together': { env: ['TOGETHER_API_KEY'], defaultBaseURL: 'https://api.together.ai/v1' },
|
||||
'typesafe': { env: ['TYPESAFE_API_KEY'] },
|
||||
'vercel-ai-gateway': { env: ['AI_GATEWAY_API_KEY'], defaultBaseURL: 'https://ai-gateway.vercel.sh' },
|
||||
'xai': { env: ['XAI_API_KEY'], defaultBaseURL: 'https://api.x.ai/v1' },
|
||||
'xiaomi': { env: ['XIAOMI_API_KEY'], defaultBaseURL: 'https://api.xiaomimimo.com/v1' },
|
||||
'xiaomi-token-plan-ams': { env: ['XIAOMI_TOKEN_PLAN_AMS_API_KEY'], defaultBaseURL: 'https://token-plan-ams.xiaomimimo.com/v1' },
|
||||
'xiaomi-token-plan-cn': { env: ['XIAOMI_TOKEN_PLAN_CN_API_KEY'], defaultBaseURL: 'https://token-plan-cn.xiaomimimo.com/v1' },
|
||||
'xiaomi-token-plan-sgp': { env: ['XIAOMI_TOKEN_PLAN_SGP_API_KEY'], defaultBaseURL: 'https://token-plan-sgp.xiaomimimo.com/v1' },
|
||||
'zai': { env: ['ZAI_API_KEY'], defaultBaseURL: 'https://api.z.ai/api/coding/paas/v4' },
|
||||
'zai-coding-cn': { env: ['ZAI_CODING_CN_API_KEY'], defaultBaseURL: 'https://open.bigmodel.cn/api/coding/paas/v4' },
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Installed provider defaults for configuration surfaces. */
|
||||
|
||||
import type { Provider } from '@earendil-works/pi-ai'
|
||||
import type { LlmConfigurableProvider } from '@deepseek-ai/dsh-llm'
|
||||
import { PROVIDER_DATA } from './provider-data.generated.ts'
|
||||
|
||||
/**
|
||||
* Return generated display metadata without executing authentication.
|
||||
* @param provider - Installed catalog provider, possibly from a newer consumer dependency.
|
||||
* @returns Known metadata, or only a fixed public endpoint for an unrecorded provider.
|
||||
*/
|
||||
export function providerDefaultsOf(provider: Provider): Pick<LlmConfigurableProvider, 'nativeAuth' | 'defaultBaseURL' | 'baseURLEnv'> {
|
||||
const data = PROVIDER_DATA[provider.id]
|
||||
// A consumer can install a newer pi-ai within the dependency range. An
|
||||
// unrecorded provider remains configurable without guessed auth metadata.
|
||||
if (data === undefined) return provider.baseUrl && !provider.baseUrl.includes('{') ? { defaultBaseURL: provider.baseUrl } : {}
|
||||
const { env, ...endpoint } = data
|
||||
return { ...endpoint, nativeAuth: { env, oauth: provider.auth.oauth !== undefined } }
|
||||
}
|
||||
@@ -94,6 +94,13 @@ describe('llm-pi-ai real dormant composition', () => {
|
||||
|
||||
// The shipped posture: the adapter exists, no route does.
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
const directory = ctx.llm.listConfigurableProviders()
|
||||
expect(directory.find(entry => entry.provider === 'azure')).toMatchObject({
|
||||
nativeAuth: { env: ['AZURE_OPENAI_API_KEY'], oauth: false },
|
||||
baseURLEnv: ['AZURE_OPENAI_BASE_URL', 'AZURE_OPENAI_RESOURCE_NAME'],
|
||||
})
|
||||
expect(directory.find(entry => entry.provider === 'radius')?.defaultBaseURL).toBe('https://radius.pi.dev/v1')
|
||||
expect(directory.find(entry => entry.provider === 'opencode')?.defaultBaseURL).toBeUndefined()
|
||||
|
||||
// Exactly what the web Models page leaves on disk.
|
||||
await writeFile(settingsPath, [
|
||||
|
||||
@@ -1,48 +1,117 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AuthContext, Provider } from '@earendil-works/pi-ai'
|
||||
import { catalogProvider, catalogProviderIds } from '../src/catalog.ts'
|
||||
import { nativeAuthOf } from '../src/native-auth.ts'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Provider } from '@earendil-works/pi-ai'
|
||||
import { builtinProviders } from '@earendil-works/pi-ai/providers/all'
|
||||
import { generateProviderData, resolverEnv } from '../scripts/gen-provider-data.ts'
|
||||
import { catalogProvider } from '../src/catalog.ts'
|
||||
import { PROVIDER_DATA } from '../src/provider-data.generated.ts'
|
||||
import { providerDefaultsOf } from '../src/provider-data.ts'
|
||||
|
||||
/** The names one resolver reads, in order, when only the names in `set` hold a value. */
|
||||
async function reads(provider: Provider, set: ReadonlySet<string>): Promise<string[]> {
|
||||
const read: string[] = []
|
||||
const ctx: AuthContext = {
|
||||
env: (name) => {
|
||||
read.push(name)
|
||||
return Promise.resolve(set.has(name) ? 'probe-value' : undefined)
|
||||
},
|
||||
fileExists: () => Promise.resolve(false),
|
||||
}
|
||||
await provider.auth.apiKey?.resolve({ ctx, signal: new AbortController().signal })
|
||||
return read
|
||||
}
|
||||
describe('provider metadata', () => {
|
||||
it('matches every installed public resolver and catalog, including non-chat providers', async () => {
|
||||
expect(await generateProviderData()).toEqual(PROVIDER_DATA)
|
||||
expect(Object.keys(PROVIDER_DATA)).toEqual(builtinProviders().map(provider => provider.id))
|
||||
})
|
||||
|
||||
/** Insert each name `into` lacks right after the name the resolver read before it. */
|
||||
function merge(into: string[], sequence: readonly string[]): void {
|
||||
for (const [index, name] of sequence.entries()) {
|
||||
if (into.includes(name)) continue
|
||||
into.splice(index === 0 ? 0 : into.indexOf(sequence[index - 1]!) + 1, 0, name)
|
||||
}
|
||||
}
|
||||
it('checks the staged snapshot even when the working file is fresh, without changing either', () => {
|
||||
const fixture = mkdtempSync(join(tmpdir(), 'dsh-provider-data-'))
|
||||
const relative = 'packages/llm/llm-pi-ai/src/provider-data.generated.ts'
|
||||
const snapshot = readFileSync(new URL('../src/provider-data.generated.ts', import.meta.url), 'utf8')
|
||||
const git = (args: readonly string[]): string => execFileSync('git', [...args], { cwd: fixture, encoding: 'utf8' })
|
||||
const check = (): string => execFileSync(process.execPath, [
|
||||
fileURLToPath(new URL('../scripts/gen-provider-data.ts', import.meta.url)), '--check', '--cached',
|
||||
], {
|
||||
cwd: fixture, encoding: 'utf8', stdio: 'pipe',
|
||||
env: { ...process.env, GIT_DIR: join(fixture, '.git'), GIT_WORK_TREE: fixture },
|
||||
})
|
||||
try {
|
||||
git(['init', '-q'])
|
||||
mkdirSync(join(fixture, 'packages/llm/llm-pi-ai/src'), { recursive: true })
|
||||
writeFileSync(join(fixture, relative), `${snapshot}// stale staged snapshot\n`)
|
||||
git(['add', relative])
|
||||
writeFileSync(join(fixture, relative), snapshot)
|
||||
const staged = git(['show', `:${relative}`])
|
||||
expect(check).toThrow('Provider metadata is stale')
|
||||
expect(git(['show', `:${relative}`])).toBe(staged)
|
||||
expect(readFileSync(join(fixture, relative), 'utf8')).toBe(snapshot)
|
||||
git(['add', relative])
|
||||
expect(check()).toContain('Provider metadata matches')
|
||||
git(['rm', '--cached', relative])
|
||||
expect(check).toThrow('Cannot read staged provider metadata; run pnpm gen-provider-data and stage provider-data.generated.ts')
|
||||
expect(readFileSync(join(fixture, relative), 'utf8')).toBe(snapshot)
|
||||
} finally { rmSync(fixture, { recursive: true, force: true }) }
|
||||
})
|
||||
|
||||
describe('native authentication', () => {
|
||||
it('lists every environment variable each installed resolver reads, in lookup order', async () => {
|
||||
for (const id of catalogProviderIds()) {
|
||||
const provider = catalogProvider(id)!
|
||||
// Probe individual values and suffixes until no new names appear. A
|
||||
// suffix keeps earlier short-circuit keys unset while satisfying several
|
||||
// required values, such as Anthropic's workload identity federation.
|
||||
const names: string[] = []
|
||||
merge(names, await reads(provider, new Set()))
|
||||
let previousLength: number
|
||||
do {
|
||||
previousLength = names.length
|
||||
for (let index = 0; index < names.length; index++) {
|
||||
merge(names, await reads(provider, new Set([names[index]!])))
|
||||
merge(names, await reads(provider, new Set(names.slice(index))))
|
||||
}
|
||||
} while (names.length !== previousLength)
|
||||
expect(nativeAuthOf(provider).env, id).toEqual(names)
|
||||
}
|
||||
it('finds short-circuit credential pairs and federation fields missed by findEnvKeys', async () => {
|
||||
const data = await generateProviderData()
|
||||
expect(data['amazon-bedrock']!.env).toContain('AWS_SECRET_ACCESS_KEY')
|
||||
expect(data['anthropic']!.env).toEqual([
|
||||
'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_FEDERATION_RULE_ID', 'ANTHROPIC_ORGANIZATION_ID',
|
||||
'ANTHROPIC_IDENTITY_TOKEN_FILE', 'ANTHROPIC_SERVICE_ACCOUNT_ID', 'ANTHROPIC_WORKSPACE_ID',
|
||||
])
|
||||
expect(data['google-vertex']!.env).toContain('GOOGLE_APPLICATION_CREDENTIALS')
|
||||
expect(data['cloudflare-ai-gateway']!.env).toEqual(['CLOUDFLARE_API_KEY', 'CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_GATEWAY_ID'])
|
||||
expect(data['cloudflare-workers-ai']!.env).toEqual(['CLOUDFLARE_API_KEY', 'CLOUDFLARE_ACCOUNT_ID'])
|
||||
})
|
||||
|
||||
it('uses fixed catalog endpoints and keeps dynamic or model-specific endpoints distinct', () => {
|
||||
expect(PROVIDER_DATA.openai.defaultBaseURL).toBe('https://api.openai.com/v1')
|
||||
expect(PROVIDER_DATA.radius.defaultBaseURL).toBe('https://radius.pi.dev/v1')
|
||||
expect(PROVIDER_DATA.azure.defaultBaseURL).toBeUndefined()
|
||||
expect(PROVIDER_DATA.azure.baseURLEnv).toEqual(['AZURE_OPENAI_BASE_URL', 'AZURE_OPENAI_RESOURCE_NAME'])
|
||||
expect(PROVIDER_DATA['cloudflare-ai-gateway'].defaultBaseURL).toBeUndefined()
|
||||
expect(PROVIDER_DATA['cloudflare-ai-gateway'].baseURLEnv).toEqual(['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_GATEWAY_ID'])
|
||||
expect(PROVIDER_DATA['amazon-bedrock'].defaultBaseURL).toBeUndefined()
|
||||
expect(PROVIDER_DATA.opencode.defaultBaseURL).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not execute authentication when returning metadata at runtime', () => {
|
||||
const provider = catalogProvider('openai')!
|
||||
const resolve = vi.fn(() => { throw new Error('must not execute') })
|
||||
expect(providerDefaultsOf({ ...provider, auth: { apiKey: { name: 'fixture', resolve } } })).toEqual({
|
||||
defaultBaseURL: 'https://api.openai.com/v1', nativeAuth: { env: ['OPENAI_API_KEY'], oauth: false },
|
||||
})
|
||||
expect(resolve).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an empty resolver result without consulting compatibility fallbacks', async () => {
|
||||
const provider = catalogProvider('openai-codex')!
|
||||
expect((await generateProviderData([provider]))[provider.id]!.env).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a newly installed provider configurable without guessed metadata', () => {
|
||||
const provider = { ...catalogProvider('openai')!, id: 'new-provider' }
|
||||
expect(providerDefaultsOf(provider)).toEqual({ defaultBaseURL: 'https://api.openai.com/v1' })
|
||||
expect(providerDefaultsOf({ ...catalogProvider('azure')!, id: 'new-provider' })).toEqual({})
|
||||
expect(providerDefaultsOf({ ...provider, baseUrl: 'https://{ACCOUNT}.example' })).toEqual({})
|
||||
})
|
||||
|
||||
it('reports a resolver failure before using compatibility metadata, and refuses unknown metadata', async () => {
|
||||
const provider: Provider = { ...catalogProvider('deepseek')!, auth: { apiKey: {
|
||||
name: 'fixture', resolve: () => Promise.reject(new Error('unsupported probe')),
|
||||
} } }
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
expect((await generateProviderData([provider])).deepseek!.env).toEqual(['DEEPSEEK_API_KEY'])
|
||||
expect(warn).toHaveBeenCalledWith('deepseek: using findEnvKeys auth metadata fallback', expect.any(Error))
|
||||
await expect(generateProviderData([{ ...provider, id: 'unknown-provider' }])).rejects.toThrow('no auth metadata fallback')
|
||||
} finally { warn.mockRestore() }
|
||||
})
|
||||
|
||||
it('cancels a probe even when a resolver does not observe the signal', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const provider: Provider = { ...catalogProvider('deepseek')!, auth: { apiKey: {
|
||||
name: 'fixture', resolve: () => new Promise(() => {}),
|
||||
} } }
|
||||
const assertion = expect(resolverEnv(provider)).rejects.toThrow('probe timed out')
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
await assertion
|
||||
} finally { vi.useRealTimers() }
|
||||
})
|
||||
})
|
||||
@@ -24,8 +24,8 @@
|
||||
en: 9d481d19dc52e404
|
||||
zh: aa09cf4462e93ba6
|
||||
/deepseek-ai-dsh-llm/use-this-package/what-you-can-do:
|
||||
en: f6beb04bb628a8d5
|
||||
zh: 9f8dfcee3464b4b8
|
||||
en: dc61eb86585f6e0d
|
||||
zh: 9c0d6b240dfd2cda
|
||||
/deepseek-ai-dsh-llm/use-this-package/failures-and-recovery:
|
||||
en: 311d8c92447b7094
|
||||
zh: 68cf5cb1c3853cc1
|
||||
|
||||
@@ -83,7 +83,7 @@ const configure = (maxTokens: number): ConfigureCall =>
|
||||
|
||||
- **Stream one model call** — `ctx.llm.stream(options)` yields raw chunks (token-level deltas) for any registered provider and model; consumers assemble them with `BlockAssembler`.
|
||||
- **Register provider adapters** — an adapter owns one or more provider routes, and its registration captures that route's retry policy; registering the same route twice fails with `DUPLICATE_ADAPTER`.
|
||||
- **Expose and activate providers through configuration** — adapters declare configurable-provider routes plus a settings namespace, so configuration surfaces can activate dormant providers and edit connection facts without a restart. `LlmConfigurableProvider.error` reports a configuration diagnostic for repair; unaffected models can remain serviceable. An entry can also carry `defaultBaseURL`, the endpoint the adapter uses when the profile sets none, and `nativeAuth`, the environment variables and OAuth sign-in that the adapter's authentication reads when the profile names no credential reference; configuration UIs read both from the directory without calling the adapter.
|
||||
- **Expose and activate providers through configuration** — adapters declare configurable-provider routes plus a settings namespace, so configuration surfaces can activate dormant providers and edit connection facts without a restart. `LlmConfigurableProvider.error` reports a configuration diagnostic for repair; unaffected models can remain serviceable. An entry can also carry `defaultBaseURL`, the endpoint the adapter uses when the profile sets none, and `nativeAuth`, the environment variables and OAuth sign-in that the adapter's authentication reads when the profile names no credential reference; `baseURLEnv` lists environment variables used to configure the endpoint. Configuration UIs read these fields from the directory without calling the adapter.
|
||||
- **Discover and resolve models** — List the models an adapter advertises, interrogate an endpoint for the models it serves (candidates carry optional `inputModalities`; absence means unknown), and resolve one exact model's context window, output default, reasoning efforts, input modalities, and system prompt update mode: `LlmResolvedModelInfo.systemPromptUpdate` is `'in-history'` when the model reads the latest `system` message at any position as the effective system prompt and absent when only a leading system message is read; `normalizeModelInfo` rejects any other value with `INVALID_MODEL_INFO`.
|
||||
- **Validate call config** — an explicit or configured reasoning effort is checked against the exact model before any provider I/O, and an adapter-configured output cap is materialized when the request omits one.
|
||||
- **Read an embedded Assistant stream without expanding it** — `assistantStreamFirstTokenTime` (first token), `assistantStreamHasVisibleContent` (any visible content), and `assistantStreamHasVisibleText` (any visible text) answer their questions from the compact records with early exit; `lastAssistantStreamChunk` scans backward to the last raw chunk of one type, `assistantStreamChunks` and `joinAssistantStreamText` scan the whole stream, and `assembleAssistantStream` feeds a `BlockAssembler` one joined delta per run with the same blocks, usage, and replay state as the per-member expansion. `runFirstTokenTime` and `runFirstVisibleTime` do the early-exit scan for one packed run, and `isTokenDelta`, `isVisibleChunk`, and `chunkHasVisibleText` define the token and visibility rules for a single chunk. `expandAssistantStream` remains the validating path for records read at a durable boundary; it is not memoized, because a retained expansion costs roughly ten times the compact stream for as long as the event lives.
|
||||
|
||||
@@ -83,7 +83,7 @@ const configure = (maxTokens: number): ConfigureCall =>
|
||||
|
||||
- **流式发起一次模型调用**——`ctx.llm.stream(options)` 为任何已注册提供方与模型产出原始分片(token 级增量);消费方用 `BlockAssembler` 组装。
|
||||
- **注册提供方适配器**——一个适配器拥有一个或多个提供方路由,其注册会捕获该路由的重试策略;重复注册同一路由会以 `DUPLICATE_ADAPTER` 失败。
|
||||
- **通过配置暴露并激活提供方**——适配器声明可配置提供方路由与 settings namespace,配置界面因此可以激活休眠提供方并编辑连接信息,无需重启。`LlmConfigurableProvider.error` 报告供修复的配置诊断;未受影响的模型仍可提供服务。条目还可携带 `defaultBaseURL`,即配置未设置端点时适配器使用的端点;以及 `nativeAuth`,即配置未指定凭据引用时适配器认证读取的环境变量与 OAuth 登录;配置界面直接从目录读取二者,无需调用适配器。
|
||||
- **通过配置暴露并激活提供方**——适配器声明可配置提供方路由与 settings namespace,配置界面因此可以激活休眠提供方并编辑连接信息,无需重启。`LlmConfigurableProvider.error` 报告供修复的配置诊断;未受影响的模型仍可提供服务。条目还可携带 `defaultBaseURL`,即配置未设置端点时适配器使用的端点;以及 `nativeAuth`,即配置未指定凭据引用时适配器认证读取的环境变量与 OAuth 登录;`baseURLEnv` 列出配置端点所用的环境变量。配置界面直接从目录读取这些字段,无需调用适配器。
|
||||
- **发现与解析模型**——列出适配器公布的模型、询问端点它提供哪些模型(候选携带可选的 `inputModalities`;缺省表示未知),并解析某个精确模型的上下文窗口、输出默认值、推理(reasoning)强度、输入模态与系统提示词更新模式:当模型把任意位置最新的 `system` 消息读作有效系统提示词时,`LlmResolvedModelInfo.systemPromptUpdate` 为 `'in-history'`;只读取开头 system 消息时该字段缺失;`normalizeModelInfo` 以 `INVALID_MODEL_INFO` 拒绝任何其他值。
|
||||
- **校验调用配置**——显式或配置的推理强度会在任何提供方 I/O 之前对照精确模型校验;请求省略输出上限时,会填入适配器配置的输出上限。
|
||||
- **不展开即读取内嵌 Assistant 流**——`assistantStreamFirstTokenTime`(首 token)、`assistantStreamHasVisibleContent`(任一可见内容)与 `assistantStreamHasVisibleText`(任一可见文本)通过可提前退出的扫描直接从紧凑记录得出结果;`lastAssistantStreamChunk` 反向扫描到某一类型的最后一个原始 chunk,`assistantStreamChunks` 与 `joinAssistantStreamText` 扫描整个流,`assembleAssistantStream` 向 `BlockAssembler` 每个 run 喂一段拼接 delta,blocks/usage/replayState 与逐成员展开相同。`runFirstTokenTime` 与 `runFirstVisibleTime` 对单个打包 run 做提前退出扫描,`isTokenDelta`、`isVisibleChunk` 与 `chunkHasVisibleText` 定义单个 chunk 的 token 与可见性规则。`expandAssistantStream` 仍是持久边界读取记录的校验路径;它不被记忆化,因为保留的展开在事件生命周期内约花费紧凑流的十倍内存。
|
||||
|
||||
@@ -1174,6 +1174,7 @@ function detachDirectoryEntry(entry: LlmConfigurableProvider): LlmConfigurablePr
|
||||
return {
|
||||
...entry,
|
||||
settingsPath: [...entry.settingsPath],
|
||||
...entry.baseURLEnv === undefined ? {} : { baseURLEnv: [...entry.baseURLEnv] },
|
||||
...(nativeAuth === undefined ? {} : { nativeAuth: { ...nativeAuth, env: [...nativeAuth.env] } }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +269,8 @@ export interface LlmConfigurableProvider {
|
||||
* Absent when the route has no fixed endpoint.
|
||||
*/
|
||||
defaultBaseURL?: string
|
||||
/** Environment variables used to construct a provider endpoint; absent when none are reported. */
|
||||
baseURLEnv?: readonly string[]
|
||||
/** Authentication the adapter performs when the profile names no credential reference; absent when it reports none. */
|
||||
nativeAuth?: LlmNativeAuth
|
||||
/** Configuration diagnostic for repair; unaffected models may remain serviceable. */
|
||||
|
||||
@@ -115,16 +115,20 @@ describe('configurable-provider directory', () => {
|
||||
it('detaches stored entries from caller-owned objects', async () => {
|
||||
const ctx = await setup()
|
||||
const env = ['OPENAI_API_KEY']
|
||||
const source = entry({ defaultBaseURL: 'https://api.openai.com/v1', nativeAuth: { env, oauth: false } })
|
||||
const baseURLEnv = ['ENDPOINT']
|
||||
const source = entry({ defaultBaseURL: 'https://api.openai.com/v1', baseURLEnv, nativeAuth: { env, oauth: false } })
|
||||
ctx.llm.registerConfigurableProviders([source])
|
||||
source.displayName = 'mutated'
|
||||
env.push('mutated')
|
||||
baseURLEnv.push('mutated')
|
||||
const listed = ctx.llm.listConfigurableProviders()
|
||||
expect(listed).toEqual([
|
||||
entry({ defaultBaseURL: 'https://api.openai.com/v1', nativeAuth: { env: ['OPENAI_API_KEY'], oauth: false } }),
|
||||
entry({ defaultBaseURL: 'https://api.openai.com/v1', baseURLEnv: ['ENDPOINT'], nativeAuth: { env: ['OPENAI_API_KEY'], oauth: false } }),
|
||||
])
|
||||
;(listed[0]!.nativeAuth!.env as string[]).push('mutated')
|
||||
;(listed[0]!.baseURLEnv as string[]).push('mutated')
|
||||
expect(ctx.llm.listConfigurableProviders()[0]!.nativeAuth!.env).toEqual(['OPENAI_API_KEY'])
|
||||
expect(ctx.llm.listConfigurableProviders()[0]!.baseURLEnv).toEqual(['ENDPOINT'])
|
||||
})
|
||||
|
||||
it('withdraws every entry when the registration disposes', async () => {
|
||||
|
||||
@@ -368,7 +368,7 @@ describe('gate graph validation', () => {
|
||||
const ids = withPnpmEntrypoint(() => gatesForMode('hygiene').map(subject => subject.id))
|
||||
|
||||
expect(ids).toEqual([
|
||||
'rescope-vendor', 'publint', 'constraints', 'default-product-isolation', 'product-use', 'official-bundle-catalog', 'package-dependencies', 'application-entrypoints',
|
||||
'rescope-vendor', 'publint', 'constraints', 'default-product-isolation', 'product-use', 'official-bundle-catalog', 'provider-data', 'package-dependencies', 'application-entrypoints',
|
||||
'dsh-package-licenses', 'node-next-types',
|
||||
'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'client-route-resolution', 'no-bare-dispatcher',
|
||||
'plugin-record-callers', 'no-unknown-casts',
|
||||
|
||||
@@ -369,6 +369,7 @@ function ciSharedStaticGates(): Gate[] {
|
||||
pnpmScript('default-product-isolation', 'verify-default-product-isolation', { label: 'default product isolation' }),
|
||||
pnpmScript('product-use', 'verify-product-use', { label: 'product package classification' }),
|
||||
pnpmScript('official-bundle-catalog', 'verify-official-bundle-catalog', { label: 'Official bundle catalog' }),
|
||||
pnpmScript('provider-data', 'verify-provider-data', { label: 'pi-ai provider metadata' }),
|
||||
pnpmScript('application-entrypoints', 'verify-application-entrypoints', { label: 'application entrypoints' }),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('package-dependencies', 'verify-package-dependencies', { label: 'package dependencies' }),
|
||||
@@ -788,6 +789,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
|
||||
pnpmScript('default-product-isolation', 'verify-default-product-isolation', { label: 'default product isolation' }),
|
||||
pnpmScript('product-use', 'verify-product-use', { label: 'product package classification' }),
|
||||
pnpmScript('official-bundle-catalog', 'verify-official-bundle-catalog', { label: 'Official bundle catalog' }),
|
||||
pnpmScript('provider-data', 'verify-provider-data', { label: 'pi-ai provider metadata' }),
|
||||
pnpmScript('package-dependencies', 'verify-package-dependencies', { label: 'package dependencies' }),
|
||||
pnpmScript('application-entrypoints', 'verify-application-entrypoints', { label: 'application entrypoints' }),
|
||||
pnpmScript('dsh-package-licenses', 'verify-dsh-package-licenses', { label: 'DSH package licenses' }),
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
"packages/*/*/tests/**/*.ts",
|
||||
"packages/experimental/claude-code-mods/examples/*/index.ts",
|
||||
"packages/experimental/inspector/scripts/**/*.ts",
|
||||
"packages/llm/llm-pi-ai/scripts/**/*.ts",
|
||||
"scripts/**/*.ts",
|
||||
"website/**/*.ts",
|
||||
"website/.vitepress/**/*.ts"
|
||||
|
||||
Reference in new issue
Block a user