feat(launch): support launch deepseek harness (#3)

* feat(launch): support launch deepseek harness

* fix(launch): node version gate, cloud key clearing, and dsh docs
This commit is contained in:
Xiaoze Fan
2026-08-14 23:50:44 -07:00
committed by GitHub
parent 4f510f1ba6
commit a2572083e9
3 changed files with 106 additions and 2 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ See [docs/install.md](docs/install.md) for requirements and installation.
```bash
ft serve --model ~/models/Qwen3.6-35B-A3B # API server on http://127.0.0.1:1919
ft launch claude # point an agent at it (codex / opencode / openclaw)
ft launch claude # point an agent at it (codex / dsh / hermes / opencode / openclaw)
ft shell # or chat in the terminal
```
+1 -1
View File
@@ -108,7 +108,7 @@ ft ctl [--base-url http://127.0.0.1:1919] [--timeout 10] [--json] <subcommand>
## ft launch
```bash
ft launch {claude,codex,hermes,openclaw,opencode} [options] [-- <agent args>]
ft launch {claude,codex,dsh,hermes,openclaw,opencode} [options] [-- <agent args>]
```
Discovers the served model via `/v1/models`, writes the agent's provider
+104
View File
@@ -29,6 +29,10 @@ OPENCLAW_PROVIDER = "freetoken"
OPENCLAW_PROVIDER_NAME = "FreeToken"
HERMES_API_KEY = "freetoken-local"
HERMES_MIN_CONTEXT_LENGTH = 64_000 # Hermes refuses to start below this
DSH_API_KEY = "freetoken"
DSH_MIN_NODE = (22, 19) # dsh's floor; its npm package declares no engines gate
DSH_LAUNCH_SETTINGS_NAME = "freetoken-launch.settings.yaml"
DSH_LAUNCH_PATCH_NAME = "freetoken-launch.cordis.patch.yml"
CLOUD_PROVIDER_API_KEY_ENV = (
"ANTHROPIC_API_KEY",
"ANTHROPIC_OAUTH_TOKEN",
@@ -113,6 +117,11 @@ AGENT_INSTALLERS = {
),
dependencies=("bash", "curl", "git"),
),
"dsh": AgentInstaller(
display_name="DeepSeek Harness",
argv=("npm", "install", "--global", "@deepseek-ai/dsh"),
dependencies=("npm", "node"),
),
}
@@ -684,12 +693,105 @@ def prepare_hermes(ctx: LaunchContext) -> CommandSpec:
return CommandSpec(argv=argv, env={})
def _dsh_home() -> Path:
home = os.environ.get("DSH_HOME")
return Path(home).expanduser() if home else Path.home() / ".dsh"
def _warn_dsh_node_version() -> None:
node = shutil.which("node")
if node is None:
return
try:
raw = subprocess.run(
[node, "--version"], capture_output=True, text=True, timeout=5
).stdout.strip()
parts = raw.lstrip("v").split(".")
version = (int(parts[0]), int(parts[1]) if len(parts) > 1 else 0)
except (OSError, ValueError, subprocess.SubprocessError):
return
if version < DSH_MIN_NODE:
floor = ".".join(str(part) for part in DSH_MIN_NODE)
print(
f"warning: dsh requires Node.js >={floor} and node reports {raw}.",
file=sys.stderr,
)
def prepare_dsh(ctx: LaunchContext) -> CommandSpec:
"""DeepSeek Harness (dsh). Like the codex profile, the wiring lives in its
own settings document: a ``--patch`` overlay repoints dsh's settings row at
``freetoken-launch.settings.yaml`` for this invocation only, so the user's
``settings.yaml`` is never read or written. The llm-deepseek adapter (route
``deepseek-official``) is used rather than a custom llm-pi-ai provider: it
replays tool-call arguments and assistant content verbatim, which prefix
caching depends on. The dummy DEEPSEEK_API_KEY satisfies dsh's non-empty
key requirement; FreeToken itself is unauthenticated."""
settings_path = _dsh_home() / DSH_LAUNCH_SETTINGS_NAME
patch_path = _dsh_home() / DSH_LAUNCH_PATCH_NAME
if not ctx.dry_run:
import yaml # ships with transformers (a core dependency); only needed on this path
_warn_dsh_node_version()
# Preserve state dsh itself writes back into the active settings doc
# (model re-picks, onboarding acks); only our two sections are re-pinned.
config: dict[str, object] = {}
if settings_path.exists():
try:
loaded = yaml.safe_load(settings_path.read_text())
except yaml.YAMLError:
loaded = None
if isinstance(loaded, dict):
config = loaded
window, output = _context_window(ctx), _max_output_tokens(ctx)
config["llm-deepseek"] = {
"baseURL": ctx.server.openai_base_url,
"models": [
{
"id": model_id,
"name": f"{model_id} (FreeToken)",
"contextWindow": window,
"maxTokens": output,
}
for model_id in _ordered_model_ids(ctx)
],
}
config["agent-default-model"] = {
"provider": "deepseek-official",
"model": ctx.model.model_id,
}
_write_text_with_backup(settings_path, yaml.safe_dump(config, sort_keys=False))
patch = [{"id": "settings", "config": {"path": str(settings_path)}}]
_write_text_with_backup(patch_path, yaml.safe_dump(patch, sort_keys=False))
# The `dsh web` alias rejects launcher flags like --patch; use the explicit
# --profile form and normalize a leading alias or --profile from extra args.
profile, app_args = "web", list(ctx.extra_args)
if app_args and app_args[0] == "web":
app_args = app_args[1:]
elif len(app_args) >= 2 and app_args[0] == "--profile":
profile, app_args = app_args[1], app_args[2:]
argv = ["dsh", "--profile", profile, "--patch", str(patch_path), *app_args]
return CommandSpec(
argv=argv,
env={
"DEEPSEEK_BASE_URL": ctx.server.openai_base_url,
"DEEPSEEK_API_KEY": DSH_API_KEY,
"DSH_TELEMETRY_DISABLED": "1",
},
unset_env=CLOUD_PROVIDER_API_KEY_ENV,
)
PREPARERS = {
"codex": prepare_codex,
"claude": prepare_claude,
"opencode": prepare_opencode,
"openclaw": prepare_openclaw,
"hermes": prepare_hermes,
"dsh": prepare_dsh,
}
@@ -779,6 +881,8 @@ def _agent_binary_fallbacks(agent: str, binary: str) -> list[Path]:
return [home / ".npm-global" / "bin" / filename]
if agent == "hermes":
return [home / ".local" / "bin" / filename]
if agent == "dsh":
return [home / ".npm-global" / "bin" / filename]
return []