dsh-packycode-api 1.4.3 → 1.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.5
4
+
5
+ - **Diagnostics now say how a request to PackyCode failed on the network.** An Ubuntu user running `dsh --profile web` saw model requests retried over and over: one session had 39 failed attempts in 12 steps, logged as `Request timed out.`, a later one `Connection error.`, and the balance read `fetch failed (ETIMEDOUT)` from every status host. Each attempt failed about 0.5 s after it started. `cf.api.fan` and `www.packyapi.ai` resolve to two IPv4 and two IPv6 addresses. Node tries them in turn and gives every address but the last 250 ms (`net.getDefaultAutoSelectFamilyAttemptTimeout()`). On that machine the lookup returned the IPv6 addresses but they could not be reached, so the last address failed at once, and the connection failed whenever both IPv4 handshakes took longer than 250 ms. Node's own undici reports that as an `AggregateError`, which the OpenAI SDK calls `Connection error.`. The undici 8 that DSH installs with a proxy policy rewrites it as a `ConnectTimeoutError` that still says `timeout: 10000ms`, which the SDK calls `Request timed out.`. The report kept neither the cause nor the addresses, so the two texts looked like two problems.
6
+ - **The report has a `network` section** (`src/network.js`). From the moment the plugin loads, it listens on undici's `diagnostics_channel` for requests to the plugin's own hosts: `cf.api.fan`, `www.packyapi.ai`, `www.packyapi.com` and any host override. For each host it records requests, answered requests by status, failures by stage (connect, request or mid-response), cancellations, time to response headers, and connections that needed a later address or went over IPv6. Each of the 12 latest failures keeps the path without its query, its stage and duration, the error chain's names and codes, each address tried with its error, and the message, redacted and shortened. The section also gives the Node version and platform, `autoSelectFamily`, the per-address attempt timeout, the DNS result order and the scheme each proxy variable names. It looks each host up the way `net.connect` does, waiting at most 3 s. Headers, bodies, queries, proxy addresses and other hosts are never read. The section covers the whole process rather than one session, and the card's privacy note and both READMEs say so.
7
+ - Validation, 2026-10-30:
8
+ - **Offline:** 544 tests (new: requests to a loopback server counted by status and timed, and another host name for the same server ignored; a connection refused on both addresses of a host recorded with each address; a cancellation counted apart from failures, a timeout recorded at the request stage and a cut body mid-response; undici 8's rewritten connect timeout and a connection that needed a later address read from published events, with stored keys redacted and only the newest failures kept; proxy schemes without proxy hosts or credentials; host lookups bounded at 3 s; the plugin's own wiring through the RPC), both rebuilt bundles and the versioned tarball.
9
+ - **Under DSH's runtime (the desktop app's Node 24.21 with its undici 8.11.2):** one host resolved to two IPv4 addresses that never answered and two IPv6 addresses that refused at once. The connection failed after 510–525 ms. Through Node's undici the log recorded `AggregateError ETIMEDOUT`; through DSH's undici 8 it recorded `ConnectTimeoutError UND_ERR_CONNECT_TIMEOUT` over `AggregateError ETIMEDOUT`. Both records listed the four addresses with their codes. On real traffic to `cf.api.fan` (its root path, no key), direct and through a local HTTP proxy with DSH's undici 8, the log counted three requests answered 404, their time to headers and their connections. Going direct, one of the two connections reached the second address after the first took longer than 250 ms. The built `lib/index.js` loads there. Not checked in the desktop app or on Linux.
10
+ - **Gemini 4 Argon is back in the catalog.** PackyCode serves `gemini-4-argon` again: on 2026-10-30 `/api/pricing` lists it in `gemini-slb` and `gemini-officially`, and the gateway answers it. It takes back its earlier entry: `google-generative-ai` at `https://cf.api.fan/v1beta`, 1,048,576 context and 65,536 output per Google's model page, and `low`, `medium` and `high`. The pricing page now lists an `openai` endpoint for it too; it stays on the native protocol with Flash, with `thinkingLevel` and the skip signature, and the one resend stays Flash's alone. The picker offers twelve models, the settings panel counts Argon under Gemini, `scripts/gemini-tool-check.mjs` takes it again, and the READMEs' FAQ entry about the refusal is gone. A session left on Argon works again without being changed.
11
+ - Validation, 2026-10-30:
12
+ - **Offline:** 548 tests (the per-model native Gemini tests run for Argon again, its catalog entry, efforts and resolved metadata are pinned again, the replay guard's non-Flash case is Argon again, and a session on a model that left the catalog is still refused as `UNKNOWN_MODEL` before any credential is read), both rebuilt bundles and the versioned tarball.
13
+ - **Live, through the built route, with the test key (`gemini-slb` among its groups):** Argon answered in `scripts/live-check.mjs`, named a red and a blue image, and read as reached by the key's groups. Native tool calls continued to `ok` in 6 of 10 rounds (4 at `low`, 1 at `medium`, 1 at `high`), each continuation accepted on its first send, with no signature refusal. Of the other four, three passed the script's 90 s limit without finishing, and one was refused at 08:24Z with `分组 gemini-slb 下模型 gemini-4-argon 无可用渠道(distributor)`. Flash continued a tool call to `ok` once at `low`. The other models answered, except `grok-4.7`: `/api/pricing` now lists it only in `grok-sale`, which the test key lacks, and the gateway refused it with `分组 grok-officially 下模型 grok-4.7 无可用渠道(distributor)`. That is upstream, and nothing in the plugin changed for it. Not checked in the desktop app.
14
+ - **Live, direct to the native endpoint:** the stalls are not Argon's alone. Sampled every 20 s from 08:25Z to 08:31Z, Argon answered 15 of 16 requests in 3.8–64 s and Flash 12 of 16 in 2.0–11.6 s; the rest of each passed 90 s. In a run of 12 streaming requests per model, all 24 answered, with headers after 2.8–51 s on Argon and 2.0–10.6 s on Flash.
15
+
16
+ ## 1.4.4
17
+
18
+ - **DeepSeek V4 Pro joins the catalog** ([#4](https://github.com/BaronCyrus/dsh-packycode-api/issues/4)). `deepseek-v4-pro` takes DeepSeek Flash's route: `openai-responses` at `https://cf.api.fan/v1`, 1,000,000 context and 384,000 output, with Flash's efforts (`off` as `reasoning.effort: "none"`, `low`, `high`, `max`), per DeepSeek's [models and pricing page](https://api-docs.deepseek.com/quick_start/pricing/) and [API reference](https://api-docs.deepseek.com/api/create-chat-completion). PackyCode's `/api/pricing` lists it in `deepseek-officially` and `deepseek-sale` with `openai-response` among its endpoints, at 4.5 times Flash's input rate. It sits after Flash in the picker, and the settings panel counts it under DeepSeek. pi-ai's own DeepSeek catalog pins `low` unsupported for V4 Pro; DeepSeek documents `low` for both models, and through PackyCode V4 Pro answered it as its own level.
19
+ - **V4 Pro takes text only.** DeepSeek documents image input for Flash alone, and PackyCode's gateway drops an image sent to V4 Pro without an error: the model then spent 2,807 reasoning tokens on a picture it never got and answered "Unknown". The catalog declares `['text']`, so DSH's composer does not attach images to it, and DSH's LLM runtime sends an image already in the session (after a switch from Flash, say) as its one-line "image omitted" note. Its session titles go to Flash, which every group serving V4 Pro serves too.
20
+ - Validation, 2026-10-28:
21
+ - **Offline:** 536 tests (new: V4 Pro's catalog entry, efforts and resolved metadata; each effort, and `off` as `none`, sent through the route without the GPT cache key or `strict`; through DSH's `LlmRuntime`, an image in a V4 Pro session reaches the gateway as DSH's note and is never read; V4 Pro titles on Flash), both rebuilt bundles and the versioned tarball. `scripts/live-check.mjs --with-vision` now refuses a text-only `--model` before any request.
22
+ - **Live, direct to `/v1/responses` with the test key (`deepseek-officially` among its groups):** `none`, `low`, `high` and `max` were each echoed back, `none` with 0 reasoning tokens; `low` and `high` differed in input tokens (36 and 115 for the same prompt), so `low` is not answered as `high`. `max_output_tokens` up to 393,216 was accepted and 393,217 refused with 400, as for Flash. Without `strict`, V4 Pro left the optional sandbox fields of DSH's bash tool out in 4 of 4 calls, as Flash does.
23
+ - **Live, through the built route:** `off`, `low`, `high` and `max` each sent its effort (`off` as `none`) and answered `ok`; `minimal`, `medium` and `xhigh` were refused before any request. An `echo` tool call continued to `ok` at `high`, `off` and `max`, with the turn's reasoning replayed and 256–384 prefix tokens cached without a cache key. A DSH title request ran on Flash ("十月发货单整理与缺货标注"). A red image in a V4 Pro session, through DSH's `LlmRuntime`, was not read or sent, and the model said it could not see an image. `scripts/live-check.mjs` passed: all eleven models answered, and the key's groups read as reaching each of them. Key rotation, the rejected-key classification, both auto reviewers and the RPC projections were unchanged. Not checked in the desktop app.
24
+ - **Gemini 4 Argon leaves the catalog.** PackyCode stopped serving `gemini-4-argon` on 2026-10-27. It answered at about 06:00Z, and before 07:00Z the gateway refused it, first with `分组 gemini-slb 下模型 gemini-4-argon 无可用渠道(distributor)`, then with `当前令牌未覆盖供应商`. The public `/api/pricing` no longer lists it, while `gemini-3.8-flash` stays in `gemini-slb`. The picker now offers ten models, the settings panel's Gemini line counts Flash only, and `scripts/gemini-tool-check.mjs` takes only Flash.
25
+ - **A session still set to Argon** now gets DSH's `UNKNOWN_MODEL` (`pi-ai provider "packycode" has no configured model "gemini-4-argon"`) instead of the gateway's refusal, and needs another model; both READMEs say so in their FAQ. A stored model selection that names Argon is ignored like any unknown id. Flash keeps its native handling unchanged: `thinkingLevel`, the skip signature and its one resend.
26
+ - Validation, 2026-10-27:
27
+ - **Offline:** 534 tests (new: a session still set to Argon is refused as `UNKNOWN_MODEL` before any credential is read; the per-model native Gemini tests now run for Flash only, and the replay guard's non-Flash case uses a placeholder Gemini id), both rebuilt bundles and the versioned tarball.
28
+ - **Live, with the test key (`gemini-slb` among its groups):** at 07:17Z `/api/pricing` listed `gemini-3.8-flash`, `gemini-3.5-flash`, `gemini-3-flash-preview`, `gemini-3.1-pro-preview` and `gemini-3-pro-preview` in `gemini-slb`, and no `gemini-4-argon`, and the gateway still refused Argon. `scripts/live-check.mjs` passed: all ten models answered, and the key's groups read as reaching each of them. Key rotation, the rejected-key classification, both auto reviewers and the RPC projections were unchanged. Gemini 3.8 Flash continued a native tool call to `ok` 2 of 2 times at `low` and once at `high`, each continuation accepted on its first send, and it named a red and a blue image. Both live scripts now refuse `--model gemini-4-argon` before any request. Not checked in the desktop app.
29
+
3
30
  ## 1.4.3
4
31
 
5
32
  - **GPT tool calls no longer fill DSH's sandbox escalation fields.** On GPT-6.1 Terra users saw tool calls fail with `Error: invalid justification: expected a non-empty sentence`. The plugin sent DSH's tools to GPT without `strict`, and PackyCode's GPT route treats such a tool as strict, so the model filled every optional parameter. DSH's `sandbox_permissions` came back as `workspace-write`, the first value of its enum, with an empty or stock `justification`, next to invented values such as `timeoutMs: 1000`. DSH's edit and write refuse `sandbox_permissions` with an empty justification, and bash does too when the requested mode is not the session's own. All four GPT models did this, also before 1.4.2. DeepSeek Flash and Grok 4.7 leave optional parameters out with or without `strict`.
package/README.en.md CHANGED
@@ -48,10 +48,10 @@ You need **DeepSeek Harness** and a **PackyCode (PackyAPI) API key**. For DSH se
48
48
  Open the desktop application's **Plugins** page, click **Add plugin**, and enter the package name **with a version** as the **package name or address**:
49
49
 
50
50
  ```text
51
- dsh-packycode-api@1.4.3
51
+ dsh-packycode-api@1.4.5
52
52
  ```
53
53
 
54
- Choose the **official** install source, then click **Install**. `1.4.3` was the latest release when this was written; the [npm page](https://www.npmjs.com/package/dsh-packycode-api) shows the current one.
54
+ Choose the **official** install source, then click **Install**. `1.4.5` was the latest release when this was written; the [npm page](https://www.npmjs.com/package/dsh-packycode-api) shows the current one.
55
55
 
56
56
  > **Always include the version.** With a bare `dsh-packycode-api`, the install prompt shows the newest version on npm, but DSH's bundled pnpm by default installs only versions published at least 24 hours ago, so you can end up several releases behind with no sign of it in the prompt. With an exact version, pnpm installs that version and adds it to the profile's `minimumReleaseAgeExclude` itself. Afterwards, check **Current version** under **Settings → PackyCode → Plugin version**.
57
57
 
@@ -65,14 +65,14 @@ Open **Settings → PackyCode**. Paste the API key under **Add a key**. A label
65
65
 
66
66
  ### 3. Select a model
67
67
 
68
- Choose a model from the `PackyCode` group. Of the eleven models, Claude runs through this device's Claude Code CLI on `cc` keys; everything else uses the plugin’s HTTP route. Your key’s groups must cover the selected model.
68
+ Choose a model from the `PackyCode` group. Of the twelve models, Claude runs through this device's Claude Code CLI on `cc` keys; everything else uses the plugin’s HTTP route. Your key’s groups must cover the selected model.
69
69
 
70
70
  <details>
71
71
  <summary>Verify the install / other profiles / a local checkout</summary>
72
72
 
73
73
  The **Plugins** page shows the installed state and version, and is also where you enable, disable, or uninstall.
74
74
 
75
- **For non-desktop profiles only:** `dsh plugin --profile <name> add dsh-packycode-api@1.4.3`. Do not use it for `desktop` — the CLI refuses it (`profile "desktop" is managed exclusively by the Electron application`).
75
+ **For non-desktop profiles only:** `dsh plugin --profile <name> add dsh-packycode-api@1.4.5`. Do not use it for `desktop` — the CLI refuses it (`profile "desktop" is managed exclusively by the Electron application`).
76
76
 
77
77
  For a local checkout, keep the `link:` prefix: `dsh plugin --profile <name> add link:/path/to/dsh-packycode-api`. **Do not pass a bare path or `file:`** — that install copies the whole checkout, `node_modules` included, into the profile, where it then shadows the packages the DSH installation itself provides and the plugin looks uninstalled; see the FAQ. If you already installed that way, uninstall first and reinstall as above.
78
78
 
@@ -221,6 +221,7 @@ The card shows whether DSH's plugin is on, how many reviews this plugin answered
221
221
  | Model | How it runs | Effort | Token group |
222
222
  | --- | --- | --- | --- |
223
223
  | `deepseek-flash` | HTTP, Responses, images allowed | Can be off; `low` / `high` / `max` | `deepseek-officially`, `deepseek-sale` |
224
+ | `deepseek-v4-pro` (DeepSeek V4 Pro) | HTTP, Responses, text only; 1M context, 384K output | Same | Same |
224
225
  | `gpt-6.1-sol` | HTTP, Responses, images allowed | Cannot be off; `low` / `medium` / `high` / `xhigh` / `max` | `codex`, `codex-sale` |
225
226
  | `gpt-6.1-terra` (GPT-6.1 Terra) | Same | Can be off; `low` / `medium` / `high` / `xhigh` / `max` | Same |
226
227
  | `gpt-6-astra` | Same | Cannot be off; `low` / `medium` / `high` / `xhigh` / `max` | Same |
@@ -236,7 +237,7 @@ A model in the list does not mean the current token group allows it. When changi
236
237
 
237
238
  `jev-latest` (TypeSafe Jev) is not in the model picker: it is a decision model that answers classification questions and cannot hold a conversation, so it only serves [Auto review](#auto-review). The plugin calls `/v1/systemone` host-side; it needs a key in the `typesafe-officially` group, and each key's Jev entry under Accounts and keys shows whether it can.
238
239
 
239
- All eleven models in the current catalog declare text and image input. DeepSeek Flash follows the [official vision guide](https://api-docs.deepseek.com/guides/vision/); images are read through DSH's attachment service and sent with the Responses request.
240
+ Of the twelve models in the current catalog, all but DeepSeek V4 Pro declare text and image input. DeepSeek Flash follows the [official vision guide](https://api-docs.deepseek.com/guides/vision/); images are read through DSH's attachment service and sent with the Responses request. DeepSeek V4 Pro takes text only, per DeepSeek's [models and pricing page](https://api-docs.deepseek.com/quick_start/pricing/): DSH does not let you attach an image to it, and an image already in the session (say, one switched over from Flash) is sent as a one-line "image omitted" note. PackyCode's gateway does not refuse an image sent to V4 Pro; it drops it.
240
241
 
241
242
  Gemini capabilities and limits follow Google's model pages: [Gemini 4 Argon](https://ai.google.dev/gemini-api/docs/models/gemini-4-argon), [Gemini 3.8 Flash](https://ai.google.dev/gemini-api/docs/models/gemini-3.8-flash). Both models use the native Gemini protocol through `/v1beta/models/{model}:streamGenerateContent`, streaming text, thinking and tool calls; images are read through DSH's attachment service. Thinking levels are sent with `thinkingLevel`. Both routes use the single-stream output limit without automatic continuation beyond 65,536 tokens. Available endpoints and groups follow the [PackyAPI live model catalog](https://www.packyapi.com/api/pricing).
242
243
 
@@ -261,7 +262,7 @@ DeepSeek Flash uses the gateway's Responses endpoint (`/api/pricing` lists `open
261
262
  - Claude streams finish at `message_stop` without waiting for the gateway to close the HTTP body, and CRLF delimiters remain intact across network chunks. Stream errors and cancellation still surface; waiting for response headers is not counted as success.
262
263
  - Offered DSH bash/read/write/edit/glob/grep tools use canonical CLI names such as `Bash` and `Read` over HTTP; other tools use MCP names. Through the CLI, DSH's tools are not sent to Claude Code. Only aliases declared for the current request regain their original names before DSH dispatch; unknown names never grant execution. Arguments, call IDs, results and signed history remain intact. Thinking display follows the CLI defaults: `omitted` for Haiku and `updates` with its required beta for Sonnet and Opus. Reasoning effort and output limits remain unchanged, signatures on empty thinking blocks survive replay, and explicit display options take precedence.
263
264
  - When the legacy working-directory sentence agrees with the initiating session metadata, Claude receives the exact path as a separate JSON string. Sentence punctuation stays outside the path; real trailing dots, Windows drive/UNC paths, spaces and backslashes remain intact. Commands are never trimmed or rewritten. Shell guidance prefers the current directory or `workdir`, preserving PowerShell syntax for Windows tools.
264
- - Claude turns on both routes retain the full DSH tool loop, permissions and plan mode, using host search and image tools. Claude titles use Haiku on the same route as the conversation; GPT-6 Astra and GPT-5.6 Sol titles use GPT-6.1 Sol.
265
+ - Claude turns on both routes retain the full DSH tool loop, permissions and plan mode, using host search and image tools. Claude titles use Haiku on the same route as the conversation; GPT-6 Astra and GPT-5.6 Sol titles use GPT-6.1 Sol, and DeepSeek V4 Pro titles use DeepSeek Flash.
265
266
  - The 1.2 `claudeCliMode` / `claudeCliCwd` settings do not return: Claude Code's permissions follow the session's DSH permission preset, and its working directory is the session's. The tool named `claude_code` is now the mirror tool that shows Claude Code's tool calls in cc sessions; only the plugin uses it, and it is not the 1.2 delegation tool. `claudeCliPath` applies again and names the CLI (empty means the usual install places, then PATH). 1.3.4's `claudeTransport` is removed, and a value left in a profile is ignored. Separately installed Claude Code is only modified when you click **Update Claude Code CLI** in the CLI card.
266
267
  - Codex Responses requests still need `prompt_cache_key`; the plugin supplies a missing session identity. Other Responses families omit it.
267
268
  - After building, `node scripts/claude-check.mjs` checks all three Claude models on order totals, support triage, replenishment, read-only inventory tool roundtrips, product color labels, Chinese titles and plan mode (read-only lookup → plan submitted with `exit_plan_mode` → approval), validating the business answers; `--transport cli` runs the cc route's checks instead (`scripts/claude-agent-check.mjs`): in a scratch directory Claude Code reads a file and computes, edits one (approved), deletes one (refused), plans and then implements after review, asks a question, reads an image and names a session, and the files and answers are checked, with the gateway's client-anomaly and anti-abuse refusals counted. `node scripts/claude-stability.mjs --start --hours 6 --key-label <label>` starts a deadline- and request-budget-limited window. Invoke the same script on a schedule to sample again; `--status` reads the record. Each round records its build and scenario version. A test-message policy refusal fails that check while the other scheduled checks continue; real credential or quota failures still stop the window. Checks never change stored credentials or start DSH.
@@ -271,9 +272,9 @@ DeepSeek Flash uses the gateway's Responses endpoint (`/api/pricing` lists `open
271
272
 
272
273
  ## Update and uninstall
273
274
 
274
- > **Upgrading from 1.3.2 or earlier:** install `dsh-packycode-api@1.4.3` on the Plugins page instead of clicking **Update plugin** in the old settings card. The old button bypasses pnpm's release-age policy: if the version it installs is less than 24 hours old, the profile refuses every other plugin install until that version is 24 hours old. After the install and a restart, the button is the fixed one, and later updates can use it.
275
+ > **Upgrading from 1.3.2 or earlier:** install `dsh-packycode-api@1.4.5` on the Plugins page instead of clicking **Update plugin** in the old settings card. The old button bypasses pnpm's release-age policy: if the version it installs is less than 24 hours old, the profile refuses every other plugin install until that version is 24 hours old. After the install and a restart, the button is the fixed one, and later updates can use it.
275
276
 
276
- **Update:** open **Settings → PackyCode** and, when a newer version is listed, click **Update plugin**. It installs that exact version into the owning profile with DSH's bundled pnpm and reads `package.json` back before reporting success. The install keeps pnpm's release-age policy instead of overriding it: by default pnpm installs the version at once and records it in the profile's `minimumReleaseAgeExclude`; under a stricter pnpm policy the button says when the update can run. The newer version is read from npm, while pnpm downloads it from the profile's own install source: right after a release that source (a regional mirror in particular) may not have it yet, and the button asks you to update again in a few minutes; when the source cannot be reached, it asks you to check the network or proxy. A local `link:` checkout does not get the button. Then quit and reopen the desktop application. A page refresh does not reload the Host. Reinstalling from the **Plugins** page works too, again with the version in the package name (for example `dsh-packycode-api@1.4.3`); without it you may get a version that is merely the newest one published at least 24 hours ago.
277
+ **Update:** open **Settings → PackyCode** and, when a newer version is listed, click **Update plugin**. It installs that exact version into the owning profile with DSH's bundled pnpm and reads `package.json` back before reporting success. The install keeps pnpm's release-age policy instead of overriding it: by default pnpm installs the version at once and records it in the profile's `minimumReleaseAgeExclude`; under a stricter pnpm policy the button says when the update can run. The newer version is read from npm, while pnpm downloads it from the profile's own install source: right after a release that source (a regional mirror in particular) may not have it yet, and the button asks you to update again in a few minutes; when the source cannot be reached, it asks you to check the network or proxy. A local `link:` checkout does not get the button. Then quit and reopen the desktop application. A page refresh does not reload the Host. Reinstalling from the **Plugins** page works too, again with the version in the package name (for example `dsh-packycode-api@1.4.5`); without it you may get a version that is merely the newest one published at least 24 hours ago.
277
278
 
278
279
  **Coming back from a local-path install, uninstall first.** An update is a `pnpm add`: it rewrites the files inside the package directory and leaves a `node_modules` that an earlier local-path install left there. That older tree keeps shadowing the packages the DSH installation provides, so the plugin row fails to load with no visible reason. **Uninstall** runs `pnpm remove`, which deletes the package directory outright — only a reinstall after that is clean. `node scripts/install-check.mjs` reports whether a profile is in that state.
279
280
 
@@ -289,7 +290,7 @@ ls ~/.dsh/profiles/desktop/node_modules/dsh-packycode-api/node_modules
289
290
 
290
291
  Output means a leftover tree: a registry install ships no `node_modules`, so this one usually comes from an earlier local-path install (`file:`, or a bare path instead of `link:`). It sits inside the profile directory, and DSH resolves a plugin's imports from inside the profile first, so the plugin gets those old copies — for example `@deepseek-ai/schemastery` 3.18.2, which has no `Schema.volatile()`, so the module throws while it is imported. When an import fails the Loader creates no fiber for that row and no client bundle is composed either, so the model route, the RPC channel, and the settings panel all stay absent.
291
292
 
292
- Recovery: **uninstall** the plugin on the **Plugins** page, **install** it again with the version in the package name (for example `dsh-packycode-api@1.4.3`), then quit and reopen the desktop application. Uninstall runs `pnpm remove` and deletes the whole package directory; reinstalling or clicking **Update** only rewrites files and keeps the leftover tree. Deleting that `node_modules` by hand and restarting works too.
293
+ Recovery: **uninstall** the plugin on the **Plugins** page, **install** it again with the version in the package name (for example `dsh-packycode-api@1.4.5`), then quit and reopen the desktop application. Uninstall runs `pnpm remove` and deletes the whole package directory; reinstalling or clicking **Update** only rewrites files and keeps the leftover tree. Deleting that `node_modules` by hand and restarting works too.
293
294
 
294
295
  **Right after a first install, every card in Settings shows `transport failure for /packycode-api/…: HTTP 405`?** The installed version is 1.1.6 or earlier, usually because only the bare package name was entered: the install prompt shows the newest version, but pnpm installs the newest one published at least 24 hours ago (see **Install the plugin**). Those older versions can register their settings channel only after DSH restarts and rebuilds its `connection` service. So right after a first install `/packycode-api` does not exist yet, requests fall through to the static page, and it answers 405; the model route and the settings panel itself load normally. From 1.1.7 on, a first install needs no restart. Check which version was installed:
295
296
 
@@ -323,7 +324,7 @@ Recovery: quit and reopen the desktop application, and the old version works. Th
323
324
 
324
325
  **Do I need Claude Code installed?** Only for Claude models on a `cc` key. The plugin runs it with its own config home and never touches your Claude Code login or settings; it updates the installation only when you click **Update Claude Code CLI**. Other groups and models do not need it.
325
326
 
326
- **Replies stop at the output token limit (已达到输出 token 上限), or requests keep failing — how do I report it?** Open **Settings → PackyCode → Diagnostics**, pick the session (the 20 most recent, newest first, labelled with their folder name), click **Generate diagnostics** and then **Copy**, and paste the text into an [Issue](https://github.com/BaronCyrus/dsh-packycode-api/issues) or send it to the plugin author. It holds settings and numbers only: the plugin version, the session id and start time, each key's position and groups (no labels, no keys), each request's model, reasoning effort and output limit, the latest steps' token usage and finish reasons, and retry and failure codes (messages redacted and shortened). It contains no conversation text, file paths or keys. Where copying is unavailable, the text is selected instead; press ⌘C or Ctrl+C.
327
+ **Replies stop at the output token limit (已达到输出 token 上限), or requests keep failing — how do I report it?** Open **Settings → PackyCode → Diagnostics**, pick the session (the 20 most recent, newest first, labelled with their folder name), click **Generate diagnostics** and then **Copy**, and paste the text into an [Issue](https://github.com/BaronCyrus/dsh-packycode-api/issues) or send it to the plugin author. It holds settings and numbers only: the plugin version, the session id and start time, each key's position and groups (no labels, no keys), each request's model, reasoning effort and output limit, the latest steps' token usage and finish reasons, and retry and failure codes (messages redacted and shortened). A `network` section covers this plugin's connections to PackyCode's servers since it loaded, across sessions: the Node version, its connection settings (address family autoselection and how long each address gets) and which scheme each proxy variable names (never the proxy itself), then for each server the addresses it resolves to now, its requests, statuses, failures, cancellations and time to response headers, and for the 12 latest failures the stage (connect, request or mid-response), the time taken, the underlying error codes and how each tried address failed. It contains no conversation text, file paths or keys. Where copying is unavailable, the text is selected instead; press ⌘C or Ctrl+C.
327
328
 
328
329
  ## Security and scope
329
330
 
package/README.md CHANGED
@@ -48,10 +48,10 @@
48
48
  打开桌面应用的**插件**页,点击**添加插件**,在**包名或地址**中填入**带版本号**的包名:
49
49
 
50
50
  ```text
51
- dsh-packycode-api@1.4.3
51
+ dsh-packycode-api@1.4.5
52
52
  ```
53
53
 
54
- 在**安装源**中选择**官方源**,然后点击**安装**。`1.4.3` 是写这份说明时的最新版,当前最新版本号以 [npm 页面](https://www.npmjs.com/package/dsh-packycode-api) 为准。
54
+ 在**安装源**中选择**官方源**,然后点击**安装**。`1.4.5` 是写这份说明时的最新版,当前最新版本号以 [npm 页面](https://www.npmjs.com/package/dsh-packycode-api) 为准。
55
55
 
56
56
  > **一定要带版本号。** 只填 `dsh-packycode-api` 时,安装前的提示显示的是 npm 上的最新版,但 DSH 自带的 pnpm 默认只安装发布满 24 小时的版本,实际装上的可能是好几个版本之前的旧版,而安装提示里看不出这一点。填写精确版本时,pnpm 会直接安装这个版本,并自动把它加入 profile 的 `minimumReleaseAgeExclude`。装好后可以在 **设置 → PackyCode → 插件版本** 核对「当前版本」。
57
57
 
@@ -65,14 +65,14 @@ dsh-packycode-api@1.4.3
65
65
 
66
66
  ### 3. 选择模型
67
67
 
68
- 回到对话,在模型选择器里选 `PackyCode` 分组下的模型。十一个模型里,Claude 在 `cc` 分组的 Key 上由本机 Claude Code CLI 执行,其余都通过插件内 HTTP 调用;Key 的分组必须覆盖所选模型。
68
+ 回到对话,在模型选择器里选 `PackyCode` 分组下的模型。十二个模型里,Claude 在 `cc` 分组的 Key 上由本机 Claude Code CLI 执行,其余都通过插件内 HTTP 调用;Key 的分组必须覆盖所选模型。
69
69
 
70
70
  <details>
71
71
  <summary>检查安装 / 其它 profile / 本地目录</summary>
72
72
 
73
73
  桌面应用的**插件**页可以看到已安装状态与版本,也可以在那里启用、停用或卸载。
74
74
 
75
- **仅用于非 desktop profile:** `dsh plugin --profile <name> add dsh-packycode-api@1.4.3`。不要对 `desktop` 使用它 —— CLI 会直接拒绝(`profile "desktop" is managed exclusively by the Electron application`)。
75
+ **仅用于非 desktop profile:** `dsh plugin --profile <name> add dsh-packycode-api@1.4.5`。不要对 `desktop` 使用它 —— CLI 会直接拒绝(`profile "desktop" is managed exclusively by the Electron application`)。
76
76
 
77
77
  本地开发要加 `link:` 前缀:`dsh plugin --profile <name> add link:/path/to/dsh-packycode-api`。**不要直接写路径(或 `file:`)** —— 那样的安装会把整个仓库目录(连同 `node_modules`)复制进 profile,之后它会在 profile 内部挡住 DSH 自带的包,插件看起来像没装,见「常见问题」。已经那样装过的话,先卸载再按上面的方式重装。
78
78
 
@@ -221,6 +221,7 @@ DSH 自带一个实验性插件「自动授权审查」,默认关闭。开启
221
221
  | 模型 | 怎么调用 | 推理档位 | 适合的令牌分组 |
222
222
  | --- | --- | --- | --- |
223
223
  | `deepseek-flash` | HTTP,Responses,可带图 | 可关;`low` / `high` / `max` | `deepseek-officially`、`deepseek-sale` |
224
+ | `deepseek-v4-pro`(DeepSeek V4 Pro) | HTTP,Responses,只收文字;1M 上下文,384K 输出 | 同上 | 同上 |
224
225
  | `gpt-6.1-sol` | HTTP,Responses,可带图 | 不能关;`low` / `medium` / `high` / `xhigh` / `max` | `codex`、`codex-sale` |
225
226
  | `gpt-6.1-terra`(GPT-6.1 Terra) | 同上 | 可关;`low` / `medium` / `high` / `xhigh` / `max` | 同上 |
226
227
  | `gpt-6-astra` | 同上 | 不能关;`low` / `medium` / `high` / `xhigh` / `max` | 同上 |
@@ -236,7 +237,7 @@ DSH 自带一个实验性插件「自动授权审查」,默认关闭。开启
236
237
 
237
238
  `jev-latest`(TypeSafe Jev)不在模型选择器里:它是只回答分类问题的决策模型,不能对话,只用于[自动审查](#自动审查)。插件在宿主侧直接调用 `/v1/systemone`,需要 Key 在 `typesafe-officially` 分组;「账户与 Key」里每个 Key 的「Jev」一项会显示能否使用。
238
239
 
239
- 当前目录中的 11 个模型都声明了文字和图像输入。DeepSeek Flash 的识图能力按 [官方图像理解文档](https://api-docs.deepseek.com/zh-cn/guides/vision/) 配置;图片通过 DSH 附件服务读取,再随 Responses 请求发送。
240
+ 当前目录的 12 个模型中,除 DeepSeek V4 Pro 外都声明了文字和图像输入。DeepSeek Flash 的识图能力按 [官方图像理解文档](https://api-docs.deepseek.com/zh-cn/guides/vision/) 配置;图片通过 DSH 附件服务读取,再随 Responses 请求发送。DeepSeek V4 Pro 按 [官方模型与价格页](https://api-docs.deepseek.com/zh-cn/quick_start/pricing/) 只收文字:DSH 不让给它附图,会话里已有的图片(比如从 Flash 切过来的会话)会换成一行「图片已省略」的说明再发送。PackyCode 网关收到发给 V4 Pro 的图片不报错,而是直接丢掉。
240
241
 
241
242
  Gemini 的能力与限制来自 Google 官方模型页:[Gemini 4 Argon](https://ai.google.dev/gemini-api/docs/models/gemini-4-argon)、[Gemini 3.8 Flash](https://ai.google.dev/gemini-api/docs/models/gemini-3.8-flash)。两款模型统一使用 Gemini 原生协议,通过 `/v1beta/models/{model}:streamGenerateContent` 流式返回文字、思考和工具调用,图片仍由 DSH 附件服务读取。思考档位通过 `thinkingLevel` 传入。两者均使用单次流式输出上限,不执行超过 65,536 token 的自动续写。可用端点与分组以 [PackyAPI 实时模型目录](https://www.packyapi.com/api/pricing) 为准。
242
243
 
@@ -261,7 +262,7 @@ DeepSeek Flash 走网关的 Responses 接口(`/api/pricing` 列出 `openai-res
261
262
  - Claude 流收到 `message_stop` 后即可完成,不再等网关关闭 HTTP 连接;跨分片的 CRLF 换行保持完整。流内错误与取消仍会返回,未收到响应头的等待也不会被当作成功。
262
263
  - DSH 的 bash/read/write/edit/glob/grep 在 HTTP 请求中使用已声明的 CLI 标准名称(如 `Bash`、`Read`),其它工具使用 MCP 命名;经 CLI 时 DSH 的工具不发给 Claude Code。回到 DSH 时仅还原本次实际提供的工具名称,未知名称不会获得执行权限。工具参数、调用 id、结果和历史签名不变。思考展示采用 CLI 默认:Haiku 为 `omitted`,Sonnet / Opus 为 `updates`(附带相应 beta)。所选推理强度和输出上限不变,空思考块的签名仍完整回传;明确指定的显示模式优先。
263
264
  - Claude 请求中的旧工作目录句式会在与当前会话元数据一致时转换为单独的 JSON 路径,避免把提示语的句号复制进路径。真实目录名中的点、Windows 盘符、UNC、空格和反斜杠保持不变;命令参数不会被自动修剪。命令工具优先使用当前目录或 `workdir`,Windows 的 PowerShell 工具保持其原有语法。
264
- - 两条通道的 Claude 对话都保留完整 DSH 工具循环、权限及计划模式;网页搜索和生图使用宿主工具。会话标题由 `claude-haiku-5-5` 生成(与对话走同一条通道);GPT-6 Astra 与 GPT-5.6 Sol 的标题交给 GPT-6.1 Sol。
265
+ - 两条通道的 Claude 对话都保留完整 DSH 工具循环、权限及计划模式;网页搜索和生图使用宿主工具。会话标题由 `claude-haiku-5-5` 生成(与对话走同一条通道);GPT-6 Astra 与 GPT-5.6 Sol 的标题交给 GPT-6.1 Sol,DeepSeek V4 Pro 的标题交给 DeepSeek Flash。
265
266
  - 1.2.x 的 `claudeCliMode`、`claudeCliCwd` 设置不再恢复:Claude Code 的权限由会话的 DSH 权限预设决定,工作目录就是会话目录。名为 `claude_code` 的工具现在是 cc 会话里显示 Claude Code 工具调用的镜像工具,只由插件使用,不是 1.2.x 的委派工具。`claudeCliPath` 重新生效,用于指定 CLI 位置(留空则按常见安装位置和 PATH 查找)。1.3.4 的 `claudeTransport` 已移除,profile 里残留的值会被忽略。除非你在 CLI 卡片里点「更新 Claude Code CLI」,用户独立安装的 Claude Code 不会被改动。
266
267
  - Codex 的 Responses 请求仍需要 `prompt_cache_key`;插件补上缺失的会话标识,其它 Responses 模型省略该字段。
267
268
  - 构建后可用 `node scripts/claude-check.mjs` 验证三个 Claude 模型的订单核算、工单归类、库存补货、只读库存查询工具回传、商品色卡标注、中文标题和计划模式(只读查询 → `exit_plan_mode` 提交计划 → 审批),并核对业务答案;加 `--transport cli` 则改跑 cc 通道的检查(`scripts/claude-agent-check.mjs`):在临时目录里让 Claude Code 自己读文件计算、改文件(授权批准)、删文件(授权拒绝)、计划审阅后实施、提问、看图和起标题,核对文件和答案,并统计网关的「客户端异常」和「反滥用」拒绝次数。`node scripts/claude-stability.mjs --start --hours 6 --key-label <备注名>` 开始有截止时间和请求上限的采样;定时执行同一脚本继续采样,用 `--status` 查看记录。业务场景版本和每轮构建分别记录;测试消息拒绝将对应检查记为失败,其余预定检查继续,真实凭据或额度错误仍会停止整个窗口。测试不改变已保存的凭据,也不启动 DSH。
@@ -271,9 +272,9 @@ DeepSeek Flash 走网关的 Responses 接口(`/api/pricing` 列出 `openai-res
271
272
 
272
273
  ## 更新与卸载
273
274
 
274
- > **从 1.3.2 或更早版本升级:** 请在插件页填写 `dsh-packycode-api@1.4.3` 安装,不要点旧版设置页里的「更新插件」。旧按钮安装时会绕过 pnpm 的发布冷却期:装到的版本如果发布还不满 24 小时,这个 profile 在它满 24 小时之前就装不了其他插件。装好并重启后,按钮就换成了修好的版本,以后直接点即可。
275
+ > **从 1.3.2 或更早版本升级:** 请在插件页填写 `dsh-packycode-api@1.4.5` 安装,不要点旧版设置页里的「更新插件」。旧按钮安装时会绕过 pnpm 的发布冷却期:装到的版本如果发布还不满 24 小时,这个 profile 在它满 24 小时之前就装不了其他插件。装好并重启后,按钮就换成了修好的版本,以后直接点即可。
275
276
 
276
- **更新:** 打开 **设置 → PackyCode**,有新版本时点「更新插件」。它会在当前 profile 目录里用 DSH 自带的 pnpm 安装精确版本,并读回 `package.json` 确认版本落地。安装遵守 pnpm 的发布冷却策略,不会绕过它:默认情况下 pnpm 会直接装上这个版本,并把它记进 profile 的 `minimumReleaseAgeExclude`;如果你给 pnpm 配了更严格的策略,按钮会提示什么时候可以更新。新版本号是从 npm 读到的,pnpm 却从 profile 配置的安装源下载:刚发布的版本,安装源(尤其是国内镜像)可能还没同步到,这时按钮会提示过几分钟再更新;连不上安装源时,会提示检查网络或代理。本地 `link:` 开发安装不会出现这个按钮。完成后完全退出并重新打开桌面应用。只刷新页面不会重载 Host。也可以在**插件**页重新安装,同样要填写带版本号的包名(例如 `dsh-packycode-api@1.4.3`),否则装上的可能是发布满 24 小时的旧版。
277
+ **更新:** 打开 **设置 → PackyCode**,有新版本时点「更新插件」。它会在当前 profile 目录里用 DSH 自带的 pnpm 安装精确版本,并读回 `package.json` 确认版本落地。安装遵守 pnpm 的发布冷却策略,不会绕过它:默认情况下 pnpm 会直接装上这个版本,并把它记进 profile 的 `minimumReleaseAgeExclude`;如果你给 pnpm 配了更严格的策略,按钮会提示什么时候可以更新。新版本号是从 npm 读到的,pnpm 却从 profile 配置的安装源下载:刚发布的版本,安装源(尤其是国内镜像)可能还没同步到,这时按钮会提示过几分钟再更新;连不上安装源时,会提示检查网络或代理。本地 `link:` 开发安装不会出现这个按钮。完成后完全退出并重新打开桌面应用。只刷新页面不会重载 Host。也可以在**插件**页重新安装,同样要填写带版本号的包名(例如 `dsh-packycode-api@1.4.5`),否则装上的可能是发布满 24 小时的旧版。
277
278
 
278
279
  **从本地路径安装切回官方源时,必须先卸载。** 「更新」是 `pnpm add`,它只重写包目录里的文件,不会删除更早一次本地路径安装留在包目录内的 `node_modules`;那份旧依赖会继续挡住 DSH 自带的包,插件行加载失败且看不出原因。「卸载」执行的是 `pnpm remove`,会删掉整个包目录,重装才是干净的。用 `node scripts/install-check.mjs` 可以确认当前 profile 是否处于这个状态。
279
280
 
@@ -289,7 +290,7 @@ ls ~/.dsh/profiles/desktop/node_modules/dsh-packycode-api/node_modules
289
290
 
290
291
  有输出就是残留:从 npm 安装的包不会自带 `node_modules`,这一份通常来自更早一次「本地路径」安装(`file:` 或直接写路径,而不是 `link:`)。它位于 profile 目录内部,而 DSH 解析插件导入时优先使用 profile 内的副本,所以插件拿到的仍是那份旧依赖 —— 例如 `@deepseek-ai/schemastery` 3.18.2 没有 `Schema.volatile()`,模块在导入阶段就抛错。导入失败时 Loader 不会为这一行创建 fiber,客户端 bundle 也不会被组合,于是模型路由、RPC 通道和设置页全都不会出现。
291
292
 
292
- 修复:到**插件**页**卸载**本插件,再用带版本号的包名重新**安装**(例如 `dsh-packycode-api@1.4.3`),然后完全退出并重新打开桌面应用。卸载执行的是 `pnpm remove`,会删掉整个包目录;直接重装或点「更新」只重写文件,不会清掉这份残留。也可以手动删掉上面那个 `node_modules` 再重启。
293
+ 修复:到**插件**页**卸载**本插件,再用带版本号的包名重新**安装**(例如 `dsh-packycode-api@1.4.5`),然后完全退出并重新打开桌面应用。卸载执行的是 `pnpm remove`,会删掉整个包目录;直接重装或点「更新」只重写文件,不会清掉这份残留。也可以手动删掉上面那个 `node_modules` 再重启。
293
294
 
294
295
  **首次安装后,设置页每张卡片都显示 `transport failure for /packycode-api/…: HTTP 405`?** 实际装上的是 1.1.6 或更早的版本,通常是因为安装时只填了包名:安装提示显示的是最新版,pnpm 实际装的却是发布满 24 小时的旧版(见「安装插件」)。这些旧版要等 DSH 重启、重建它的 `connection` 服务之后,才能注册设置通道。所以首次安装后 `/packycode-api` 通道还不存在,请求落到静态页面,返回 405;模型路由和设置页本身照常加载。1.1.7 起首次安装不需要重启。先确认装上的版本:
295
296
 
@@ -323,7 +324,7 @@ grep '"version"' ~/.dsh/profiles/desktop/node_modules/dsh-packycode-api/package.
323
324
 
324
325
  **需要安装 Claude Code 吗?** 只有 Key 在 `cc` 分组、又要用 Claude 模型时需要。插件用独立配置目录运行它,不改动你自己的 Claude Code 登录和设置;只有你点「更新 Claude Code CLI」时才会更新这份安装。其它分组和其它模型都不需要。
325
326
 
326
- **回答被截断(「已达到输出 token 上限」)、请求反复失败,怎么反馈?** 打开 **设置 → PackyCode → 诊断信息**,选出问题的会话(最近 20 个,按时间倒序,标着所在文件夹名),点「生成诊断信息」再点「复制」,把内容贴到 [Issue](https://github.com/BaronCyrus/dsh-packycode-api/issues) 或发给插件作者。内容只有设置和数字:插件版本、会话 id 与创建时间、每个 Key 的序号与分组(不含备注名和 Key 本身)、每次请求用的模型、推理档位和输出上限、最近若干步的 token 用量与结束原因,以及重试和失败的错误码(错误信息已脱敏并截短)。不含对话内容、文件路径和 Key。复制不可用时,文本会被全选,按 ⌘C 或 Ctrl+C 即可。
327
+ **回答被截断(「已达到输出 token 上限」)、请求反复失败,怎么反馈?** 打开 **设置 → PackyCode → 诊断信息**,选出问题的会话(最近 20 个,按时间倒序,标着所在文件夹名),点「生成诊断信息」再点「复制」,把内容贴到 [Issue](https://github.com/BaronCyrus/dsh-packycode-api/issues) 或发给插件作者。内容只有设置和数字:插件版本、会话 id 与创建时间、每个 Key 的序号与分组(不含备注名和 Key 本身)、每次请求用的模型、推理档位和输出上限、最近若干步的 token 用量与结束原因,以及重试和失败的错误码(错误信息已脱敏并截短)。另有一段 `network`,记录插件这次运行以来连接 PackyCode 服务器的情况(不分会话):Node 版本、连接设置(地址族自动选择及每个地址的等待时间)和代理变量的协议类型(不含代理地址),每个服务器当前解析到的地址、请求数、各状态码次数、失败与中止次数、响应头耗时,以及最近 12 次失败各自的阶段(连接、请求或响应中途)、耗时、底层错误码和每个尝试地址的结果。不含对话内容、文件路径和 Key。复制不可用时,文本会被全选,按 ⌘C 或 Ctrl+C 即可。
327
328
 
328
329
  ## 安全边界
329
330
 
package/lib/client.js CHANGED
@@ -216,7 +216,7 @@ window.__ModuleLoader__.load({
216
216
  diagCopy: "复制",
217
217
  diagCopied: "已复制",
218
218
  diagSelected: "已选中全部内容,请按 ⌘C 或 Ctrl+C 复制。",
219
- diagPrivacy: "只包含模型、推理档位、输出上限、每一步的 token 用量和错误码,不含对话内容、文件路径和 Key。",
219
+ diagPrivacy: "只包含模型、推理档位、输出上限、每一步的 token 用量和错误码,以及本次运行中插件连接 PackyCode 服务器的情况(服务器地址、状态码、错误码、耗时和 Node 的连接设置),不含对话内容、文件路径和 Key。",
220
220
  diagNoSessions: "还没有会话。",
221
221
  diagLive: "进行中",
222
222
  diagNoFolder: "无目录",
@@ -449,7 +449,7 @@ window.__ModuleLoader__.load({
449
449
  diagCopy: "Copy",
450
450
  diagCopied: "Copied",
451
451
  diagSelected: "All text is selected; press ⌘C or Ctrl+C to copy.",
452
- diagPrivacy: "Contains only models, reasoning effort, output limits, per-step token usage and error codes — no conversation text, file paths or keys.",
452
+ diagPrivacy: "Contains only models, reasoning effort, output limits, per-step token usage and error codes, plus how this run's connections to PackyCode's servers went (server addresses, statuses, error codes, timings and Node's connection settings) — no conversation text, file paths or keys.",
453
453
  diagNoSessions: "No sessions yet.",
454
454
  diagLive: "open",
455
455
  diagNoFolder: "no folder",
@@ -1951,7 +1951,7 @@ window.__ModuleLoader__.load({
1951
1951
  };
1952
1952
  /** The catalog's models by family, for the "reaches" line under each key. */
1953
1953
  const MODEL_FAMILIES = [
1954
- ["DeepSeek", ["deepseek-flash"]],
1954
+ ["DeepSeek", ["deepseek-flash", "deepseek-v4-pro"]],
1955
1955
  ["GPT", [
1956
1956
  "gpt-6.1-sol",
1957
1957
  "gpt-6.1-terra",
package/lib/index.js CHANGED
@@ -12,6 +12,9 @@ import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messag
12
12
  import { AsyncLocalStorage } from "node:async_hooks";
13
13
  import { HarnessError, LlmError, QUOTA_EXCEEDED_CODE, createUserMessage, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
14
14
  import { credentialKey } from "@deepseek-ai/dsh-credentials";
15
+ import { subscribe, unsubscribe } from "node:diagnostics_channel";
16
+ import * as dns from "node:dns";
17
+ import * as net from "node:net";
15
18
  import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
16
19
  import { lazyStream } from "@earendil-works/pi-ai/api/lazy";
17
20
  import { clampThinkingLevel, createProvider } from "@earendil-works/pi-ai";
@@ -1078,9 +1081,10 @@ const DEFAULT_REASONING_LEVEL = "high";
1078
1081
  * model does not support is refused before the request is sent: Claude Opus
1079
1082
  * 5.5 (platform.claude.com effort docs), GPT-6.1 Sol, GPT-6.1 Terra, GPT-6
1080
1083
  * Astra and GPT-5.6 Sol `reasoning.effort` (developers.openai.com model
1081
- * pages), DeepSeek thinking mode (`low|high|max` effort, or off), Grok 4.7
1082
- * (docs.x.ai/developers/grok-4-7), and Gemini 4 Argon
1083
- * (ai.google.dev/gemini-api/docs/models/gemini-4-argon and docs/openai).
1084
+ * pages), DeepSeek thinking mode (`low|high|max` effort, or off, for Flash
1085
+ * and V4 Pro alike), Grok 4.7
1086
+ * (docs.x.ai/developers/grok-4-7), and Gemini 4 Argon and 3.8 Flash
1087
+ * (ai.google.dev/gemini-api/docs/models/gemini-4-argon and gemini-3.8-flash).
1084
1088
  *
1085
1089
  * @module dsh-packycode-api/catalog
1086
1090
  */
@@ -1171,6 +1175,19 @@ const PACKYCODE_MODELS = Object.freeze([
1171
1175
  maxTokens: 384e3,
1172
1176
  thinkingLevelMap: DEEPSEEK_LEVELS
1173
1177
  }),
1178
+ Object.freeze({
1179
+ id: "deepseek-v4-pro",
1180
+ name: "DeepSeek V4 Pro",
1181
+ api: "openai-responses",
1182
+ provider: PROVIDER,
1183
+ baseUrl: RESPONSES_BASE,
1184
+ reasoning: true,
1185
+ input: Object.freeze(["text"]),
1186
+ cost: NO_COST,
1187
+ contextWindow: 1e6,
1188
+ maxTokens: 384e3,
1189
+ thinkingLevelMap: DEEPSEEK_LEVELS
1190
+ }),
1174
1191
  Object.freeze({
1175
1192
  id: "gpt-6.1-sol",
1176
1193
  name: "GPT-6.1 Sol",
@@ -6254,6 +6271,10 @@ function claudeTransportFor({ groups } = {}) {
6254
6271
  * each request used, how every model step ended and what it consumed, and
6255
6272
  * which retries and failures happened.
6256
6273
  *
6274
+ * The report also carries how this process reached PackyCode's servers
6275
+ * (`network.js`): it is process-wide, not per session, and starts when the
6276
+ * plugin loads.
6277
+ *
6257
6278
  * The summary carries numbers and identifiers only. Message text, reasoning,
6258
6279
  * tool arguments and results, titles, paths and key material never enter it;
6259
6280
  * error messages are redacted and shortened. A user can paste it into a bug
@@ -6276,12 +6297,12 @@ const DIAGNOSTICS_LIMITS = Object.freeze({
6276
6297
  });
6277
6298
  const finite = (value) => typeof value === "number" && Number.isFinite(value) ? value : void 0;
6278
6299
  /** Identifier-like strings only (routes, models, efforts, reason kinds, codes). */
6279
- const word = (value) => typeof value === "string" && /^[\w.:/@[\]-]{1,80}$/u.test(value) ? value : void 0;
6300
+ const word$1 = (value) => typeof value === "string" && /^[\w.:/@[\]-]{1,80}$/u.test(value) ? value : void 0;
6280
6301
  const iso = (value) => finite(value) === void 0 ? void 0 : new Date(value).toISOString();
6281
6302
  const last = (list, count) => list.slice(Math.max(0, list.length - count));
6282
- const compact = (object) => Object.fromEntries(Object.entries(object).filter(([, value]) => value !== void 0));
6303
+ const compact$1 = (object) => Object.fromEntries(Object.entries(object).filter(([, value]) => value !== void 0));
6283
6304
  const chunksOf = (stream) => Array.isArray(stream) ? stream.map((item) => item?.chunk).filter((chunk) => chunk !== null && typeof chunk === "object") : [];
6284
- /** A provider or harness error message, redacted and shortened. */
6305
+ /** A provider, harness or transport error message, redacted and shortened. */
6285
6306
  function safeMessage$1(text, secrets) {
6286
6307
  if (typeof text !== "string" || text === "") return void 0;
6287
6308
  const safe = redactSecrets(text, secrets).replace(/sk-[A-Za-z0-9_-]{8,}/gu, "sk-[redacted]").replace(/Bearer\s+[A-Za-z0-9._~+/=-]{8,}/giu, "Bearer [redacted]").replace(/\s+/gu, " ").trim();
@@ -6290,7 +6311,7 @@ function safeMessage$1(text, secrets) {
6290
6311
  function failureOf(reason, secrets) {
6291
6312
  const failure = reason?.failure ?? reason?.error;
6292
6313
  return failure === void 0 ? {} : {
6293
- code: word(failure.code),
6314
+ code: word$1(failure.code),
6294
6315
  message: safeMessage$1(failure.message, secrets)
6295
6316
  };
6296
6317
  }
@@ -6316,12 +6337,12 @@ function summarizeSessionEvents(events, { secrets = [] } = {}) {
6316
6337
  if (type === "turn/start") turns += 1;
6317
6338
  else if (type === "request/header") {
6318
6339
  const config = data.header?.config ?? {};
6319
- requests.push(compact({
6340
+ requests.push(compact$1({
6320
6341
  time: iso(event.time),
6321
- reason: word(data.reason),
6322
- provider: word(config.provider),
6323
- model: word(config.model),
6324
- reasoningEffort: word(config.reasoningEffort),
6342
+ reason: word$1(data.reason),
6343
+ provider: word$1(config.provider),
6344
+ model: word$1(config.model),
6345
+ reasoningEffort: word$1(config.reasoningEffort),
6325
6346
  maxTokens: finite(config.maxTokens),
6326
6347
  maxTokensFromAdapter: data.header?.adapterDefaults?.maxTokens === true ? true : void 0
6327
6348
  }));
@@ -6330,11 +6351,11 @@ function summarizeSessionEvents(events, { secrets = [] } = {}) {
6330
6351
  const usage = data.usage ?? chunks.find((chunk) => chunk.type === "usage")?.usage ?? {};
6331
6352
  const input = finite(usage.inputTokens);
6332
6353
  const cacheRead = finite(usage.cacheReadTokens);
6333
- steps.push(compact({
6354
+ steps.push(compact$1({
6334
6355
  turn: finite(data.turn),
6335
6356
  step: finite(data.step),
6336
6357
  time: iso(event.time),
6337
- finish: word(chunks.find((chunk) => chunk.type === "finish")?.reason?.kind) ?? "none",
6358
+ finish: word$1(chunks.find((chunk) => chunk.type === "finish")?.reason?.kind) ?? "none",
6338
6359
  input,
6339
6360
  cacheRead,
6340
6361
  cacheWrite: finite(usage.cacheWriteTokens),
@@ -6343,26 +6364,26 @@ function summarizeSessionEvents(events, { secrets = [] } = {}) {
6343
6364
  }));
6344
6365
  } else if (type === "assistant/attempt") {
6345
6366
  const reason = chunksOf(data.stream).find((chunk) => chunk.type === "finish")?.reason;
6346
- attempts.push(compact({
6367
+ attempts.push(compact$1({
6347
6368
  turn: finite(data.turn),
6348
6369
  step: finite(data.step),
6349
6370
  time: iso(event.time),
6350
- finish: word(reason?.kind),
6371
+ finish: word$1(reason?.kind),
6351
6372
  ...failureOf(reason, secrets)
6352
6373
  }));
6353
- } else if (type === "llm/retry") retries.push(compact({
6374
+ } else if (type === "llm/retry") retries.push(compact$1({
6354
6375
  turn: finite(data.turn),
6355
6376
  step: finite(data.step),
6356
6377
  time: iso(event.time),
6357
- provider: word(data.provider),
6378
+ provider: word$1(data.provider),
6358
6379
  retry: finite(data.retry),
6359
6380
  maxRetries: finite(data.maxRetries),
6360
6381
  ...failureOf({ failure: data.failure }, secrets)
6361
6382
  }));
6362
- else if (type === "turn/end") turnEnds.push(compact({
6383
+ else if (type === "turn/end") turnEnds.push(compact$1({
6363
6384
  turn: finite(data.turn),
6364
6385
  time: iso(event.time),
6365
- kind: word(data.reason?.kind),
6386
+ kind: word$1(data.reason?.kind),
6366
6387
  ...failureOf(data.reason, secrets)
6367
6388
  }));
6368
6389
  }
@@ -6388,9 +6409,10 @@ function summarizeSessionEvents(events, { secrets = [] } = {}) {
6388
6409
  * The diagnostics service behind the settings card.
6389
6410
  * @param options - DSH's session query service (read lazily), the session
6390
6411
  * event reader the image originals already use, the key ring and group
6391
- * reader, the stored secrets to redact, and this package's manifest URL.
6412
+ * reader, the stored secrets to redact, this package's manifest URL, and
6413
+ * the network log.
6392
6414
  */
6393
- function createDiagnostics({ sessionQuery, readEvents, ring, groups, secrets = async () => [], ownPackageJsonUrl }) {
6415
+ function createDiagnostics({ sessionQuery, readEvents, ring, groups, secrets = async () => [], ownPackageJsonUrl, network }) {
6394
6416
  const query = () => {
6395
6417
  const service = sessionQuery?.();
6396
6418
  if (typeof service?.listSessions !== "function") throw new Error("PackyCode diagnostics need DSH session history, which this runtime does not provide");
@@ -6398,7 +6420,7 @@ function createDiagnostics({ sessionQuery, readEvents, ring, groups, secrets = a
6398
6420
  };
6399
6421
  const version = async () => {
6400
6422
  try {
6401
- return word(JSON.parse(await readFile(fileURLToPath(ownPackageJsonUrl), "utf8")).version);
6423
+ return word$1(JSON.parse(await readFile(fileURLToPath(ownPackageJsonUrl), "utf8")).version);
6402
6424
  } catch {
6403
6425
  return;
6404
6426
  }
@@ -6414,19 +6436,27 @@ function createDiagnostics({ sessionQuery, readEvents, ring, groups, secrets = a
6414
6436
  }
6415
6437
  return listed.map((entry, index) => {
6416
6438
  const group = reading?.keys?.find((item) => item.id === entry.id);
6417
- return compact({
6439
+ return compact$1({
6418
6440
  key: index + 1,
6419
6441
  active: entry.active === true,
6420
6442
  disabled: entry.disabled === true ? true : void 0,
6421
- groups: Array.isArray(group?.groups) ? group.groups.map(word).filter(Boolean) : void 0,
6443
+ groups: Array.isArray(group?.groups) ? group.groups.map(word$1).filter(Boolean) : void 0,
6422
6444
  reaches: group?.coverage === void 0 ? void 0 : Object.keys(group.coverage).filter((model) => group.coverage[model] === true)
6423
6445
  });
6424
6446
  });
6425
6447
  };
6448
+ /** How this process reached PackyCode; a report never fails for want of it. */
6449
+ const networkOf = async (secretList) => {
6450
+ try {
6451
+ return await network?.snapshot({ secrets: secretList });
6452
+ } catch {
6453
+ return;
6454
+ }
6455
+ };
6426
6456
  return Object.freeze({
6427
6457
  /** Recent top-level sessions, newest first, for the picker. */
6428
6458
  async list(signal) {
6429
- return { sessions: (await query().listSessions(signal)).filter((record) => typeof record?.header?.id === "string" && !(record.header.delegationDepth > 0)).slice(0, SESSION_LIST_LIMIT).map((record) => compact({
6459
+ return { sessions: (await query().listSessions(signal)).filter((record) => typeof record?.header?.id === "string" && !(record.header.delegationDepth > 0)).slice(0, SESSION_LIST_LIMIT).map((record) => compact$1({
6430
6460
  id: record.header.id,
6431
6461
  createdAt: iso(record.header.createdAt),
6432
6462
  live: record.live === true,
@@ -6445,18 +6475,20 @@ function createDiagnostics({ sessionQuery, readEvents, ring, groups, secrets = a
6445
6475
  throw new Error("PackyCode could not read that session");
6446
6476
  }
6447
6477
  const record = (await query().listSessions(signal)).find((item) => item?.header?.id === sessionId);
6478
+ const secretList = await secrets();
6448
6479
  return {
6449
6480
  format: DIAGNOSTICS_FORMAT,
6450
6481
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
6451
- plugin: compact({ version: await version() }),
6452
- session: compact({
6482
+ plugin: compact$1({ version: await version() }),
6483
+ session: compact$1({
6453
6484
  id: sessionId,
6454
6485
  createdAt: iso(record?.header?.createdAt),
6455
6486
  live: record === void 0 ? void 0 : record.live === true,
6456
6487
  delegationDepth: finite(record?.header?.delegationDepth)
6457
6488
  }),
6458
6489
  keys: await keys(),
6459
- ...summarizeSessionEvents(events, { secrets: await secrets() })
6490
+ ...summarizeSessionEvents(events, { secrets: secretList }),
6491
+ network: await networkOf(secretList)
6460
6492
  };
6461
6493
  }
6462
6494
  });
@@ -7557,6 +7589,304 @@ function createModelSelectionController({ readPreference, writePreference, isWri
7557
7589
  });
7558
7590
  }
7559
7591
  //#endregion
7592
+ //#region src/network.js
7593
+ /**
7594
+ * How this process reached PackyCode: how each request to the plugin's own
7595
+ * hosts ended at the transport level, which addresses a failed connection
7596
+ * tried and how each of them failed, and the runtime settings and name
7597
+ * resolution that decide those connections.
7598
+ *
7599
+ * Node's fetch reports every transport fault as a bare `fetch failed`, and
7600
+ * the OpenAI SDK turns that into "Connection error." or "Request timed out."
7601
+ * depending on which undici runs under fetch: the same connection, refused
7602
+ * after 0.5 s on every address, reads differently on two machines. undici
7603
+ * publishes the real cause on `diagnostics_channel`. This module listens
7604
+ * there for the plugin's hosts only and keeps identifiers and numbers: host
7605
+ * names, server addresses, paths without their query, error names and codes,
7606
+ * statuses and durations. It never reads headers or bodies.
7607
+ *
7608
+ * @module dsh-packycode-api/network
7609
+ */
7610
+ /** How much the log keeps, and how long one host lookup may take. */
7611
+ const NETWORK_LIMITS = Object.freeze({
7612
+ failures: 12,
7613
+ samples: 50,
7614
+ lookupMs: 3e3
7615
+ });
7616
+ const CHANNELS = Object.freeze({
7617
+ create: "undici:request:create",
7618
+ headers: "undici:request:headers",
7619
+ error: "undici:request:error",
7620
+ connected: "undici:client:connected",
7621
+ connectError: "undici:client:connectError"
7622
+ });
7623
+ const NAME = /^[\w.:-]{1,60}$/u;
7624
+ const PATH = /^\/[\w.:/@%~+-]{0,79}$/u;
7625
+ const word = (value) => typeof value === "string" && NAME.test(value) ? value : void 0;
7626
+ const compact = (object) => Object.fromEntries(Object.entries(object).filter(([, value]) => value !== void 0));
7627
+ function hostnameOf(origin) {
7628
+ try {
7629
+ return new URL(String(origin)).hostname.toLowerCase();
7630
+ } catch {
7631
+ return;
7632
+ }
7633
+ }
7634
+ /** The request path with its query dropped: a query may carry a credential. */
7635
+ function pathOf(path) {
7636
+ const bare = typeof path === "string" ? path.split(/[?#]/u)[0] : void 0;
7637
+ return bare !== void 0 && PATH.test(bare) ? bare : void 0;
7638
+ }
7639
+ /** A caller's cancellation, as opposed to a transport fault. */
7640
+ const isAbort$1 = (error) => error?.name === "AbortError" && typeof error?.code !== "string" || error?.code === "ABORT_ERR";
7641
+ /** `Name CODE` for the error and each cause under it. */
7642
+ function chainOf(error) {
7643
+ const chain = [];
7644
+ for (let current = error, depth = 0; current !== null && typeof current === "object" && depth < 3; current = current.cause, depth++) {
7645
+ const label = [word(current.name) ?? word(current.constructor?.name), word(current.code)].filter(Boolean).join(" ");
7646
+ if (label !== "") chain.push(label);
7647
+ }
7648
+ return chain.length === 0 ? void 0 : chain;
7649
+ }
7650
+ /** Each address a connection tried and how it failed, from the error or a cause under it. */
7651
+ function attemptsOf(error) {
7652
+ const entry = (item) => net.isIP(item?.address ?? "") === 0 ? void 0 : [item.address, word(item.code)].filter(Boolean).join(" ");
7653
+ for (let current = error, depth = 0; current !== null && typeof current === "object" && depth < 3; current = current.cause, depth++) {
7654
+ if (Array.isArray(current.errors)) {
7655
+ const list = current.errors.map(entry).filter(Boolean);
7656
+ return list.length === 0 ? void 0 : list;
7657
+ }
7658
+ const single = entry(current);
7659
+ if (single !== void 0) return [single];
7660
+ }
7661
+ }
7662
+ function summary(samples) {
7663
+ if (samples.length === 0) return void 0;
7664
+ const sorted = [...samples].sort((a, b) => a - b);
7665
+ const at = (share) => sorted[Math.min(sorted.length - 1, Math.floor(share * sorted.length))];
7666
+ return {
7667
+ median: at(.5),
7668
+ p90: at(.9),
7669
+ max: sorted.at(-1)
7670
+ };
7671
+ }
7672
+ /** Which scheme each proxy variable names: lower case first, as DSH reads them. Never the proxy itself. */
7673
+ function proxySchemes(env) {
7674
+ const read = (name) => {
7675
+ const value = [env?.[name], env?.[name.toUpperCase()]].find((item) => typeof item === "string" && item.trim() !== "");
7676
+ if (value === void 0) return void 0;
7677
+ try {
7678
+ return word(new URL(value.trim()).protocol.replace(/:$/u, "")) ?? "invalid";
7679
+ } catch {
7680
+ return "invalid";
7681
+ }
7682
+ };
7683
+ const schemes = compact({
7684
+ https: read("https_proxy"),
7685
+ http: read("http_proxy"),
7686
+ all: read("all_proxy"),
7687
+ noProxy: read("no_proxy") === void 0 ? void 0 : true
7688
+ });
7689
+ return Object.keys(schemes).length === 0 ? "none" : schemes;
7690
+ }
7691
+ /** The settings that decide how Node opens a connection. */
7692
+ function runtimeOf(env) {
7693
+ const call = (fn) => typeof fn === "function" ? fn() : void 0;
7694
+ return compact({
7695
+ node: process.version,
7696
+ platform: process.platform,
7697
+ autoSelectFamily: call(net.getDefaultAutoSelectFamily),
7698
+ attemptTimeoutMs: call(net.getDefaultAutoSelectFamilyAttemptTimeout),
7699
+ dnsOrder: call(dns.getDefaultResultOrder),
7700
+ proxy: proxySchemes(env)
7701
+ });
7702
+ }
7703
+ /** The addresses a connection to `hostname` would try, asked the way `net.connect` asks. */
7704
+ const defaultLookup = (hostname) => dns.promises.lookup(hostname, process.platform === "win32" ? { all: true } : {
7705
+ all: true,
7706
+ hints: dns.ADDRCONFIG
7707
+ });
7708
+ /**
7709
+ * The network log behind the diagnostics card.
7710
+ * @param options - the plugin's host origins (read on every request), the
7711
+ * environment the proxy variables come from, and test seams for the clock,
7712
+ * the lookup and its bound.
7713
+ */
7714
+ function createNetworkLog({ hosts, env = process.env, now = Date.now, clock = () => performance.now(), lookupAll = defaultLookup, lookupMs = NETWORK_LIMITS.lookupMs } = {}) {
7715
+ const watched = () => {
7716
+ const names = [];
7717
+ for (const origin of hosts?.() ?? []) {
7718
+ const name = hostnameOf(origin);
7719
+ if (name !== void 0 && !names.includes(name)) names.push(name);
7720
+ }
7721
+ return names;
7722
+ };
7723
+ const stats = /* @__PURE__ */ new Map();
7724
+ const statsFor = (host) => {
7725
+ let entry = stats.get(host);
7726
+ if (entry === void 0) {
7727
+ entry = {
7728
+ requests: 0,
7729
+ answered: 0,
7730
+ statuses: {},
7731
+ failed: {
7732
+ connect: 0,
7733
+ request: 0,
7734
+ response: 0
7735
+ },
7736
+ aborted: 0,
7737
+ headersMs: [],
7738
+ connects: {
7739
+ ok: 0,
7740
+ afterFallback: 0,
7741
+ ipv6: 0
7742
+ }
7743
+ };
7744
+ stats.set(host, entry);
7745
+ }
7746
+ return entry;
7747
+ };
7748
+ const requests = /* @__PURE__ */ new WeakMap();
7749
+ const connectErrors = /* @__PURE__ */ new WeakSet();
7750
+ const failures = [];
7751
+ let since;
7752
+ let listening;
7753
+ const guard = (handler) => (message) => {
7754
+ try {
7755
+ handler(message);
7756
+ } catch {}
7757
+ };
7758
+ const handlers = {
7759
+ [CHANNELS.create]: ({ request }) => {
7760
+ const host = hostnameOf(request?.origin);
7761
+ if (host === void 0 || !watched().includes(host)) return;
7762
+ requests.set(request, {
7763
+ host,
7764
+ path: pathOf(request.path),
7765
+ startedAt: clock(),
7766
+ answered: false
7767
+ });
7768
+ statsFor(host).requests += 1;
7769
+ },
7770
+ [CHANNELS.headers]: ({ request, response }) => {
7771
+ const entry = requests.get(request);
7772
+ if (entry === void 0) return;
7773
+ entry.answered = true;
7774
+ const stat = statsFor(entry.host);
7775
+ stat.answered += 1;
7776
+ const status = response?.statusCode;
7777
+ if (Number.isInteger(status)) stat.statuses[status] = (stat.statuses[status] ?? 0) + 1;
7778
+ stat.headersMs.push(Math.round(clock() - entry.startedAt));
7779
+ if (stat.headersMs.length > NETWORK_LIMITS.samples) stat.headersMs.shift();
7780
+ },
7781
+ [CHANNELS.connectError]: ({ error }) => {
7782
+ if (error !== null && typeof error === "object") connectErrors.add(error);
7783
+ },
7784
+ [CHANNELS.connected]: ({ connectParams, socket }) => {
7785
+ const host = typeof connectParams?.hostname === "string" ? connectParams.hostname.toLowerCase() : void 0;
7786
+ if (host === void 0 || !watched().includes(host)) return;
7787
+ const connects = statsFor(host).connects;
7788
+ connects.ok += 1;
7789
+ if (Array.isArray(socket?.autoSelectFamilyAttemptedAddresses) && socket.autoSelectFamilyAttemptedAddresses.length > 1) connects.afterFallback += 1;
7790
+ if (socket?.remoteFamily === "IPv6") connects.ipv6 += 1;
7791
+ },
7792
+ [CHANNELS.error]: ({ request, error }) => {
7793
+ const entry = requests.get(request);
7794
+ if (entry === void 0) return;
7795
+ requests.delete(request);
7796
+ const stat = statsFor(entry.host);
7797
+ if (isAbort$1(error)) {
7798
+ stat.aborted += 1;
7799
+ return;
7800
+ }
7801
+ const stage = connectErrors.has(error) ? "connect" : entry.answered ? "response" : "request";
7802
+ stat.failed[stage] += 1;
7803
+ failures.push(compact({
7804
+ time: new Date(now()).toISOString(),
7805
+ host: entry.host,
7806
+ path: entry.path,
7807
+ stage,
7808
+ afterMs: Math.round(clock() - entry.startedAt),
7809
+ error: chainOf(error),
7810
+ attempts: attemptsOf(error),
7811
+ message: typeof error?.message === "string" ? error.message.slice(0, 1e3) : void 0
7812
+ }));
7813
+ if (failures.length > NETWORK_LIMITS.failures) failures.shift();
7814
+ }
7815
+ };
7816
+ const subscribed = Object.entries(handlers).map(([name, handler]) => [name, guard(handler)]);
7817
+ const stop = () => {
7818
+ if (!listening) return;
7819
+ listening = false;
7820
+ for (const [name, handler] of subscribed) unsubscribe(name, handler);
7821
+ };
7822
+ /** One host's addresses, or why there are none; never longer than `lookupMs`. */
7823
+ const resolve = async (hostname) => {
7824
+ let timer;
7825
+ try {
7826
+ const list = await Promise.race([lookupAll(hostname), new Promise((_, reject) => {
7827
+ timer = setTimeout(() => reject(Object.assign(/* @__PURE__ */ new Error("lookup timed out"), { code: "LOOKUP_TIMEOUT" })), lookupMs);
7828
+ timer.unref?.();
7829
+ })]);
7830
+ return { resolved: (Array.isArray(list) ? list : []).map((item) => item?.address).filter((address) => net.isIP(address ?? "") !== 0) };
7831
+ } catch (error) {
7832
+ return { lookupError: word(error?.code) ?? "LOOKUP_FAILED" };
7833
+ } finally {
7834
+ clearTimeout(timer);
7835
+ }
7836
+ };
7837
+ const countsOf = (stat) => {
7838
+ if (stat === void 0) return {};
7839
+ const nonZero = (counts) => Object.fromEntries(Object.entries(counts).filter(([, count]) => count > 0));
7840
+ const failed = nonZero(stat.failed);
7841
+ return compact({
7842
+ requests: stat.requests,
7843
+ answered: stat.answered,
7844
+ statuses: Object.keys(stat.statuses).length === 0 ? void 0 : { ...stat.statuses },
7845
+ failed: Object.keys(failed).length === 0 ? void 0 : failed,
7846
+ aborted: stat.aborted === 0 ? void 0 : stat.aborted,
7847
+ headersMs: summary(stat.headersMs),
7848
+ connects: stat.connects.ok === 0 ? void 0 : nonZero(stat.connects)
7849
+ });
7850
+ };
7851
+ return Object.freeze({
7852
+ /**
7853
+ * Start listening; returns the function that stops it.
7854
+ * @returns the disposer the plugin's effect runs on unload.
7855
+ */
7856
+ start() {
7857
+ if (!listening) {
7858
+ listening = true;
7859
+ since = now();
7860
+ for (const [name, handler] of subscribed) subscribe(name, handler);
7861
+ }
7862
+ return stop;
7863
+ },
7864
+ stop,
7865
+ /**
7866
+ * What the log saw since it started, the runtime settings, and how each
7867
+ * host resolves now.
7868
+ * @param options - stored secrets to redact from error messages.
7869
+ */
7870
+ async snapshot({ secrets = [] } = {}) {
7871
+ const names = watched();
7872
+ const lookups = await Promise.all(names.map(resolve));
7873
+ return compact({
7874
+ since: since === void 0 ? void 0 : new Date(since).toISOString(),
7875
+ runtime: runtimeOf(env),
7876
+ hosts: names.map((host, index) => ({
7877
+ host,
7878
+ ...lookups[index],
7879
+ ...countsOf(stats.get(host))
7880
+ })),
7881
+ recentFailures: failures.map((failure) => compact({
7882
+ ...failure,
7883
+ message: safeMessage$1(failure.message, secrets)
7884
+ }))
7885
+ });
7886
+ }
7887
+ });
7888
+ }
7889
+ //#endregion
7560
7890
  //#region src/claude-user-text.js
7561
7891
  /**
7562
7892
  * Which of DSH's messages Claude Code receives on the cc route.
@@ -7997,6 +8327,7 @@ const REQUEST_LIMITS = Object.freeze({
7997
8327
  });
7998
8328
  /** Short titles use a smaller model in the same supplier/key group. */
7999
8329
  const TITLE_MODELS = Object.freeze({
8330
+ "deepseek-v4-pro": "deepseek-flash",
8000
8331
  "gpt-6-astra": "gpt-6.1-sol",
8001
8332
  "gpt-5.6-sol": "gpt-6.1-sol",
8002
8333
  "claude-opus-5-5": "claude-haiku-5-5",
@@ -9458,6 +9789,17 @@ function apply(ctx, config) {
9458
9789
  statusHost: normalizeHost(current.statusHost, DEFAULT_STATUS_HOST)
9459
9790
  };
9460
9791
  };
9792
+ /**
9793
+ * How requests to PackyCode's own hosts end at the transport level, for
9794
+ * the diagnostics card. It listens from load, so the first group reading
9795
+ * is in it too.
9796
+ */
9797
+ const network = createNetworkLog({ hosts: () => [
9798
+ hosts().apiHost,
9799
+ DEFAULT_API_HOST,
9800
+ ...statusHostOrder(hosts())
9801
+ ] });
9802
+ ctx.effect(() => network.start(), "packycode-api: network diagnostics");
9461
9803
  const balance = createBalanceReader({
9462
9804
  listKeys: () => ring.secrets(),
9463
9805
  getHosts: hosts
@@ -9837,7 +10179,8 @@ function apply(ctx, config) {
9837
10179
  ring,
9838
10180
  groups,
9839
10181
  secrets: async () => (await Promise.all([ring.secrets(), imageRing.secrets()])).flat().map((entry) => entry.key),
9840
- ownPackageJsonUrl: new URL("../package.json", import.meta.url)
10182
+ ownPackageJsonUrl: new URL("../package.json", import.meta.url),
10183
+ network
9841
10184
  })
9842
10185
  });
9843
10186
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-packycode-api",
3
- "version": "1.4.3",
3
+ "version": "1.4.5",
4
4
  "description": "Use PackyCode API keys in DeepSeek Harness with multi-protocol models, image generation and editing, separate key rings, and balance display",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",