Skip to content

feat(custom-model): DeepSeek Harness injection through a per-session patch - #755

Merged
Ark0N merged 4 commits into
Ark0N:masterfrom
opticon454:feature/custom-endpoint-deepseek
Nov 4, 2026
Merged

Ark0N merged 4 commits into
Ark0N:masterfrom
opticon454:feature/custom-endpoint-deepseek

Conversation

@opticon454

Copy link
Copy Markdown
Contributor

Context

DeepSeek Harness (dsh) was injected through DEEPSEEK_BASE_URL and DEEPSEEK_API_KEY only, which cannot select an exact model: dsh picks its model from a profile entry, not an env var.

Split out of the custom-endpoint PR (#752) at the maintainer's request.

What changed

  • DeepSeek moves from the env recipe to a configDir recipe with a new deepseek-patch-json template. A per-session patch (cordis.patch.yml under the session's DSH_HOME) selects the exact model on the saved endpoint, and carries its context window when one is known.
  • The old appendV1Suffix DeepSeek workaround goes away: the patch writes the endpoint's base URL itself.
  • The per-session DSH_HOME would otherwise hide the user's installed profile and session logs, and a clear or tab close deletes that directory. So the recipe links the real home's profiles, sessions and settings.yaml back (linkFromRealHome and realHomeFallback fields on the configDir recipe, consumed generically, with no CLI id in the code). The links use lstat and are best effort, so a re-apply over a dangling link does not throw. The real home is the session's own DSH_HOME first, then the server's, then ~/.dsh.
  • Tests: test/deepseek-custom-endpoint.test.ts (the patch, all three links, a dangling-link re-apply), test/custom-model-recovery.test.ts (the session-first home lookup), the contract test, and MockSession.envOverrideValue for the route tests.
  • Docs: the DeepSeek sections of docs/architecture-invariants.md and docs/custom-model-endpoints.md.

Notes

  • This branch stands alone on master. Where the core PR adds a context length, this one omits contextWindow when none is known; the two branches will conflict slightly in stock.ts, types.ts and i18n.js contexts, so whichever merges second needs a rebase.

Verification

  • typecheck, lint and format:check are clean.
  • Full npm test: 667 files passed, 13,506 tests passed, 12 skipped, 0 failed.
  • Not tried against a real dsh install.

Replace the deepseek env redirect with a deepseek-patch-json recipe: a per-session DSH_HOME holding a generated cordis.patch.yml that declares a codeman-custom openai-completions provider over the installed profile. Ported from PR Ark0N#752 commits 941df57 and 4bb1bfe, minus the core context-limit changes.
- linkFromRealHome/realHomeFallback on the configDir recipe; the apply consumer is generic and names no CLI
- lstat guard so boot-recovery re-applies survive a dangling link; symlink is best-effort
- resolve the real home from the session's own dirEnvVar override before the server's, threaded through both routes and boot recovery (Session.envOverrideValue)
- deepseek links profiles/sessions/settings.yaml; boot-recovery test covers the session lookup
@Ark0N

Ark0N commented Nov 2, 2026

Copy link
Copy Markdown
Owner

Thanks for splitting this out of #752. This PR moves DeepSeek Harness onto a per-session DSH_HOME with a generated cordis.patch.yml, so a custom endpoint selects an exact model instead of only redirecting the base URL.

I ran it against a real dsh install (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1) with the patch your code generates. --dump-config shows the three rows patched, the TUI boots on the chosen model, and a prompt reaches the endpoint as POST /v1/chat/completions with the right model, the bearer key and max_tokens: 8192. The reply renders and the context window shows in the footer. So the real dsh reply the docs call outstanding now exists, and the docs can say so.

Two things need fixing before merge:

  1. A missing sessions directory breaks every turn (src/custom-model-injection-apply.ts:89). linkRealHomeEntry links the entry even when the target does not exist. dsh writes transcripts under $DSH_HOME/sessions, and a recursive mkdir through a dangling symlink fails with ENOENT. So on a dsh home that has never run a turn (a fresh install, or a relocated DSH_HOME), the TUI boots and then every prompt shows Turn error · ENOENT: no such file or directory, mkdir '<config dir>/sessions/...', with no request sent. It never recovers, because the lstat guard keeps the dangling link on every re-apply. Please let the recipe mark which entries are directories, and create a missing directory target before linking (mkdirSync(target, { recursive: true, mode: 0o700 }), which dsh does itself on a first turn). Skip a missing file target such as settings.yaml. Then replace the dangling-link assertion at test/deepseek-custom-endpoint.test.ts:56-59 with a test for that.

  2. A selection saved by the old env recipe cannot be cleared after upgrading (src/web/server.ts:3613 and :3915). Recovery rebuilds the env through the new recipe but restores the saved bookkeeping unchanged, because _rebuildCustomModelEnv (src/web/server.ts:1454) returns only envOverrides. A session persisted by 1.31.0 to 1.47.0 comes back with envKeys: ["DEEPSEEK_BASE_URL","DEEPSEEK_API_KEY"] and no configDir. Clearing it then leaves DSH_HOME on the generated config dir and never deletes the dir. That recovery code predates this PR, but this is the first time a shipped recipe changes kind. Please return the full AppliedCustomModel from _rebuildCustomModelEnv, and build the restored bookkeeping from it: configDir/launchModel from the apply, and envKeys as the union of saved and applied. Add a recovery test with an env-shaped __customModel.

Smaller ones to fold in while you are there:

  1. src/custom-model-injection.ts:225: the custom provider reads DEEPSEEK_API_KEY, which dsh's own llm-deepseek and web-search-deepseek rows also read, and the new recipe no longer redirects DEEPSEEK_BASE_URL. An in-session /model switch back to deepseek-official (or a profile with web search on) sends the custom endpoint's key to api.deepseek.com. A dedicated variable keeps the two apart, as codex's recipe does with CODEMAN_CUSTOM_MODEL_API_KEY in its privilegedEnvKeys.
  2. src/web/server.ts:1473-1479 and test/custom-model-recovery.test.ts:61: getEnvOverridesForPersist() keeps only CLAUDE_CODE_* and CLAUDE_CONFIG_DIR. So DSH_HOME is never there after a real restart, and the session lookup only takes effect on the first apply; the test builds a state that restore cannot produce. Either drop the recovery argument and the test, or record the resolved real home in the bookkeeping.
  3. docs/custom-model-endpoints-plan.md:115 (and line 12) still describe the env recipe with appendV1Suffix, and the invariants paragraph points readers at that table.
  4. src/config/cli-registry/schema.ts:598: realHomeFallback takes any string, so ../x leaves the home directory. "Required with linkFromRealHome" and "never include fileName" live only in comments. A pattern plus a superRefine covers all three.

One design question is open and is the maintainer's call: should a custom-model dsh session also share storages/ (dsh-TUI's persisted /model choice and workspace state), .agent-presets (the user's own presets do not show up today) and .env with the real home, or stay isolated as now? Linking the dotted ones needs homeSegmentName to allow a leading dot. Either way, docs/custom-model-endpoints.md needs a short note on what such a session shares and what it does not. That includes that the home-level row replaces the dsh-tui row's whole config, so a profile-level effort/preset does not apply.

Once 1 and 2 are in, this is ready for another look.

…ld restore bookkeeping, dedicated custom key var, schema guards for the real-home recipe

- linkFromRealHomeDirs marks directory entries: missing dirs are created (0700) instead of leaving a dangling link that failed every prompt with ENOENT; missing file targets are skipped
- _restoreCustomModel rebuilds the bookkeeping from the CURRENT recipe (configDir + env-key union) so a selection saved under the old env recipe can still be cleared after upgrade; drop the dead session-home recovery lookup
- deepseek's patch now references CODEMAN_CUSTOM_MODEL_API_KEY, so an in-session /model switch back to the official route cannot carry the custom key to api.deepseek.com (privilegedEnvKeys updated)
- schema: realHomeFallback must be one relative segment; linkFromRealHome requires it and must not collide with fileName
- docs: maintainer-verified real-dsh run replaces the outstanding-verification wording; shares-vs-isolates note; plan table row refreshed
@opticon454

Copy link
Copy Markdown
Contributor Author

Pushed 1464332, thanks for running it against a real dsh.

  1. A directory entry (sessions, profiles) is now marked as one on the recipe, and a missing directory target is created (mkdirSync recursive, mode 0700, as dsh does on a first turn) before it is linked. A missing file target such as settings.yaml is skipped. The dangling-link assertion is replaced by a test for a home with no sessions yet, a skipped missing file and a clean re-apply.
  2. _rebuildCustomModelEnv returns the full applied result, and both restore sites build the bookkeeping from it: configDir and launchModel from the apply, envKeys as the union of saved and applied. New recovery test with an env-shaped saved __customModel.
  3. The patch now reads a dedicated CODEMAN_CUSTOM_MODEL_API_KEY, listed in deepseek's privilegedEnvKeys as codex's is, so DEEPSEEK_API_KEY is never the custom endpoint's key.
  4. I dropped the session-home recovery argument and its test, with a comment that recovery falls back to the server's DSH_HOME or ~/.dsh.
  5. docs/custom-model-endpoints-plan.md now describes the patch recipe.
  6. realHomeFallback is a single relative segment (no .., slash or leading /), required with linkFromRealHome, and no linked entry may equal fileName, with schema tests.

Docs: a short note on what such a session shares with the real home (profiles, sessions, settings.yaml) and what it does not (storages, .agent-presets, .env; the home-level row replaces the dsh-tui row's whole config), and the real dsh reply you verified. I left the question about sharing storages, .agent-presets and .env alone, as the maintainer's call.

Full npm test: 13,512 passed, 0 failed.

@Ark0N

Ark0N commented Nov 2, 2026

Copy link
Copy Markdown
Owner

Thanks for the quick round on this, opticon454. This PR moves DeepSeek Harness onto a per-session DSH_HOME with a generated cordis.patch.yml, so a custom endpoint selects an exact model, and it links the installed profiles, transcripts and settings.yaml back from the real dsh home.

All six points from the last review are in and check out: missing directories are created before linking, the upgrade restore rebuilds its bookkeeping from the current recipe, the patch reads its own key variable, and the schema guards and the plan table are done. I also checked that removing the per-session directory unlinks the symlinks and leaves the real sessions and profiles untouched.

What is left:

  1. The session DSH_HOME lookup has no test (src/custom-model-injection-apply.ts:79, callers src/web/routes/session-routes.ts:1622 and :4519). Both routes pass a lookup and linkRealHomeEntries prefers it, but no test passes a value: MockSession.envOverrideValue returns undefined, and test/deepseek-custom-endpoint.test.ts never passes the seventh argument to applyCustomModelInjection. Please add a case that stubs DSH_HOME to one sandbox, passes (k) => (k === 'DSH_HOME' ? otherSandbox : undefined) and asserts the three links resolve into otherSandbox, plus the case where the lookup returns the session's own config dir and resolution falls through to the server's home.
  2. Two doc lines contradict the rest of the PR. docs/architecture-invariants.md:637 still ends with "a real hello world reply through an actual dsh binary is still outstanding"; please replace that clause with the verified run (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1). docs/wiki/Custom-Model-Endpoints.md:189, the user manual, still describes the /v1 env fix as not yet run against dsh; please update that row too.

Smaller ones, take them or leave them to me at merge time:

  1. src/custom-model-injection-apply.ts:99 and :106: existsSync and mkdirSync run synchronously on a DSH_HOME a session can supply, inside request handlers (CLAUDE.md, the fix(cases): bound path probes for linked workspaces and session creation, so an unreachable mount cannot freeze the server #516 rule on user-chosen paths). Separately, a relative DSH_HOME such as work creates work/sessions under the server's working directory and leaves relative links that dangle. Skipping a non-absolute home (isAbsolute, as effectiveDshHome() in src/deepseek-route-config.ts does) covers the second; probing a session-supplied home with probePathKind(..., { pastCap: true }) in the two async routes before the apply covers the first.
  2. src/custom-model-injection-apply.ts:33: the JSDoc still says the function never touches anything outside baseDir, but it now creates directories in the real dsh home.
  3. docs/custom-model-endpoints.md:566: please state the shared and isolated split plainly instead of calling it an open question for the maintainer, and add that the user's own home-level cordis.patch.yml does not apply in such a session and that the patch replaces the llm-pi-ai row's whole config.
  4. src/custom-model-injection.ts:246: one comment line on why maxTokens is a quarter of the context, capped at 8192.
  5. src/config/cli-registry/schema.ts:609 accepts . as realHomeFallback (the home directory itself), and :600 does not check that linkFromRealHomeDirs is a subset of linkFromRealHome, as types.ts says it must be. Both fit in the existing superRefine.

Not for this PR: clearing a selection ({"clear": true}) relaunches dsh without DSH_HOME, because respawnPane() forwards the server's DSH_HOME and the clear's setenv -u then removes it (src/tmux-manager.ts:2855-2865). The old DeepSeek keys and codex's CODEX_HOME already collide the same way, so the fix belongs in tmux-manager.ts on its own.

The question about also sharing storages/, .agent-presets and .env with the real home is still open, and it does not block this PR.

Once 1 and 2 are in, this is ready to merge; whatever is still open from 3 to 7 at that point I will fold in on top.

…home

Second review round on PR Ark0N#755: skip non-absolute DSH_HOME candidates so a relative home can never mkdir under the server cwd; probe a session-supplied home with probePathKind in both async routes before the synchronous apply; test the session env-override lookup (seventh arg) incl. the self-link fall-through and the relative-home skip; reject '.' and '..' as realHomeFallback and enforce linkFromRealHomeDirs subset in the schema; fix the stale apply JSDoc and the two doc lines lagging the verified dsh run; state the shared/isolated split plainly.
@opticon454

Copy link
Copy Markdown
Contributor Author

Pushed fa2b814, thanks.

  1. Tests for the session DSH_HOME lookup: with the process DSH_HOME stubbed to one sandbox and a lookup returning another, the three links resolve into the lookup's home; and when the lookup returns the session's own config dir, resolution falls through to the server's home.
  2. docs/architecture-invariants.md and docs/wiki/Custom-Model-Endpoints.md now record the verified run (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1) instead of saying it is outstanding.
  3. A non-absolute home is skipped (isAbsolute), and the two async routes probe a session-supplied home with probePathKind(..., { pastCap: true }) before the apply, passing no lookup for an unresponsive one. Tests cover the relative-home skip.
  4. The JSDoc now says it creates directories in the real dsh home.
  5. docs/custom-model-endpoints.md states the shared and isolated split plainly, and notes that the user's own home-level cordis.patch.yml does not apply in such a session and that the patch replaces the llm-pi-ai row's whole config.
  6. One comment on why maxTokens is a quarter of the context, capped at 8192.
  7. realHomeFallback rejects . and .., and the superRefine checks linkFromRealHomeDirs is a subset of linkFromRealHome, with schema tests.

I left the {"clear": true} relaunch for its own fix in tmux-manager.ts, and the question about sharing storages, .agent-presets and .env open. Full npm test: 13,516 passed, 0 failed.

@Ark0N

Ark0N commented Nov 2, 2026

Copy link
Copy Markdown
Owner

Thanks for the third round, opticon454. This PR moves DeepSeek Harness onto a per-session DSH_HOME with a generated cordis.patch.yml that selects the exact model, and links the installed profiles, transcripts and settings.yaml back from the real dsh home.

Everything from the last review is in and checks out: the session DSH_HOME lookup is tested both ways, the doc lines record the verified run, a relative home is skipped and a session-supplied one is probed before the apply, the JSDoc and the maxTokens comment are in, and the schema refuses . and checks that the directory list is a subset of the linked list.

A few small things are left. I will fold them in at merge time, so there is nothing more you need to push:

  1. settings.yaml is shared only until the session writes it (docs/custom-model-endpoints.md:551, src/config/cli-registry/stock.ts:1561). dsh saves settings through writeFileAtomic in @deepseek-ai/dsh-atomic-write, which writes a temp file and renames it over the target, replacing a symlink instead of writing through it. So the first /settings change inside a custom-model session turns the link into a local file: the change never reaches ~/.dsh/settings.yaml and is deleted with the session's directory on clear or close. The docs and the two code comments will say that the session reads the real file until its first in-session settings write.
  2. A test for guardSessionConfigDirHome (src/web/routes/session-routes.ts:248): unknown hides DSH_HOME while other keys pass through, directory passes it through, and a relative value is returned without a probe.
  3. A warning log on the fallback (src/web/routes/session-routes.ts:257): when the session's own DSH_HOME does not answer, the apply links the server's dsh home instead, and nothing says so today.
  4. Two doc sentences: one clause in CLAUDE.md on the linkFromRealHome exception to the isolated configDir rule, and one in docs/wiki/Custom-Model-Endpoints.md on what a custom-model dsh session does not carry over (the home-level cordis.patch.yml, the user's own llm-pi-ai providers, a profile-level effort/preset).

Not for this PR: applying a custom model overwrites a session's own DSH_HOME and a clear deletes it, so the session-home lookup only serves the first apply. That shares its root with the {"clear": true} relaunch issue from the last review and belongs with that separate tmux-manager.ts fix.

The question about also sharing storages/, .agent-presets and .env with the real home is still open and does not block this PR.

Thanks again for the careful work and the quick turnarounds.

@Ark0N
Ark0N merged commit 4c29aed into Ark0N:master Nov 4, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Nov 4, 2026
Say that settings.yaml is shared only until the session's first settings write
(dsh saves by rename, which replaces the link), warn when a session's own DSH_HOME
does not answer and the server's home is linked instead, test
guardSessionConfigDirHome, and document the linkFromRealHome exception and what a
custom-model dsh session does not carry over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@opticon454
opticon454 deleted the feature/custom-endpoint-deepseek branch November 4, 2026 23:16
@Ark0N

Ark0N commented Nov 4, 2026

Copy link
Copy Markdown
Owner

Thanks opticon454, this is in the 1.48.0 release with the four items from the last review on top. Clearing a selection still drops a session's own DSH_HOME (the tmux-manager.ts issue from that review), which stays a separate fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants