Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@lmstudio/sdk": "^1.5.0",
"chalk": "^4.1.2",
"columnify": "^1.6.0",
"cross-spawn": "^7.0.6",
"fast-glob": "^3.3.2",
"fuzzy": "^0.1.3",
"ink": "^6.5.1",
Expand All @@ -42,6 +43,7 @@
},
"devDependencies": {
"@types/columnify": "^1.5.4",
"@types/cross-spawn": "^6.0.6",
"@types/node": "^20.12.5",
"@types/react": "^19.2.0",
"@typescript-eslint/eslint-plugin": "^6.20.0",
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { dev } from "./subcommands/dev/index.js";
import { flags } from "./subcommands/flags.js";
import { get } from "./subcommands/get.js";
import { importCmd } from "./subcommands/importCmd.js";
import { launch } from "./subcommands/launch/index.js";
import { link } from "./subcommands/link/index.js";
import { ls, ps } from "./subcommands/list.js";
import { load } from "./subcommands/load.js";
Expand Down Expand Up @@ -167,6 +168,7 @@ Join our Discord: ${chalk.blue("https://discord.gg/lmstudio")}`,

addCommandsGroup("Local models", [chat, get, load, unload, ls, ps, importCmd], "#22D3EE");
addCommandsGroup("Serve", [server, log], "#34D399");
addCommandsGroup("Launch (Beta)", [launch], "#FB923C");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let launch forward version flags

Registering launch as a pass-through command here exposes it to the pre-Commander version check below (commandArguments.includes("-v") || commandArguments.includes("--version")). For any forwarded tool version flag, such as lms launch codex -- --version or lms launch aider -- -v, the process exits before launch can parse the -- separator, so the advertised verbatim forwarding path cannot invoke the tool.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d37632c.

Right — the pre-Commander shortcut did a blunt commandArguments.includes("-v"|"--version") over all args, so lms launch codex -- --version (or lms launch aider -- -v) matched and printed the lms version + exited before launch ever parsed --, defeating the verbatim forwarding.

Change (src/index.ts): the shortcut now only fires for a -v/--version that appears before the first subcommand token (commandArguments.slice(0, firstSubcommandIndex)). lms -v / lms --version still work; after a subcommand the flag belongs to it, and launch forwards everything past -- to the wrapped tool. As a bonus this also stops lms <anysub> --version from masquerading as the lms version in general.

addCommandsGroup("Remote Instances", [link], "#818CF8");
addCommandsGroup("Runtime", [runtime], "#C084FC");
addCommandsGroup("Develop & Publish (Beta)", [clone, push, dev, login, logout, whoami], "#F9A8D4");
Expand Down
45 changes: 45 additions & 0 deletions src/subcommands/launch/adapters/aider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { writeFile } from "fs/promises";
import { join } from "path";
import { type LaunchContext, type ToolAdapter } from "../types.js";

const COMMAND = "aider";

async function writeModelMetadataFile(ctx: LaunchContext): Promise<string> {
const metadataPath = join(ctx.workDir, ".aider.model.metadata.json");
const metadata = {
[`lm_studio/${ctx.model}`]: {
max_input_tokens: ctx.contextLength,
litellm_provider: "lm_studio",
mode: "chat",
},
};
await writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf-8");
return metadataPath;
}

/**
* Aider, via its LM Studio-native provider path. Env for the endpoint, CLI arg for model
* selection (no env equivalent exists). Verified against
* https://aider.chat/docs/llms/openai-compat.html
*/
export const aider: ToolAdapter = {
name: "aider",
displayName: "Aider",
command: COMMAND,
install: { pip: "aider-chat", url: "https://aider.chat/docs/llms/openai-compat.html" },
supportsContextHint: true,
injectsModelArg: true,
async prepare(ctx) {
const env: Record<string, string> = {
LM_STUDIO_API_BASE: ctx.openaiBaseUrl,
// Must be non-empty -- aider's OpenAI-compatible client rejects an empty bearer token.
LM_STUDIO_API_KEY: ctx.apiKey !== "" ? ctx.apiKey : "lmstudio",
};
const args = ["--model", `lm_studio/${ctx.model}`];
if (ctx.contextLength !== undefined) {
const metadataPath = await writeModelMetadataFile(ctx);
args.push("--model-metadata-file", metadataPath);
}
return { command: COMMAND, args, env };
},
};
45 changes: 45 additions & 0 deletions src/subcommands/launch/adapters/claude.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { type ToolAdapter } from "../types.js";

const COMMAND = "claude";
const CLAUDE_FIRST_PARTY_MODEL_ID_RE = /^claude-/i;

/**
* Claude Code. Env-only: base URL is the bare origin (Claude Code appends `/v1/messages` itself).
* Verified against https://code.claude.com/docs/en/env-vars and https://lmstudio.ai/blog/claudecode.
*/
export const claude: ToolAdapter = {
name: "claude",
aliases: ["claude-code"],
displayName: "Claude Code",
command: COMMAND,
install: { npm: "@anthropic-ai/claude-code", url: "https://lmstudio.ai/blog/claudecode" },
supportsContextHint: true,
async prepare(ctx) {
const env: Record<string, string> = {
ANTHROPIC_BASE_URL: ctx.origin, // NOT ctx.openaiBaseUrl -- no /v1 suffix here
ANTHROPIC_AUTH_TOKEN: ctx.apiKey,
ANTHROPIC_MODEL: ctx.model,
// Pin all four tiers so background/subagent calls don't target a nonexistent Anthropic
// model id.
ANTHROPIC_DEFAULT_OPUS_MODEL: ctx.model,
ANTHROPIC_DEFAULT_SONNET_MODEL: ctx.model,
ANTHROPIC_DEFAULT_HAIKU_MODEL: ctx.model,
ANTHROPIC_DEFAULT_FABLE_MODEL: ctx.model,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Override Claude subagent model too

If the caller already has CLAUDE_CODE_SUBAGENT_MODEL exported, spawnToolAndWait preserves it because only these keys are overlaid on process.env, and Claude Code documents that variable as the subagent model override. In that environment, subagents can still target a cloud/nonexistent Anthropic model even though the main and tier defaults are pinned here; set this variable to the local model/inherit value or explicitly clear it for the child.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a38dc3c.

Confirmed against code.claude.com/docs/en/model-configCLAUDE_CODE_SUBAGENT_MODEL overrides subagent/agent-team model resolution ahead of the tier defaults, and since spawnToolAndWait spawns with { ...process.env, ...extraEnv }, a value already exported in the caller's shell survived untouched and could point subagents at a cloud/nonexistent Anthropic model.

Change (adapters/claude.ts): added CLAUDE_CODE_SUBAGENT_MODEL: ctx.model alongside the four tier pins, so subagents resolve to the local model too. Test updated.

};
const notes: string[] = [];
if (ctx.contextLength !== undefined) {
env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(ctx.contextLength);
}
if (CLAUDE_FIRST_PARTY_MODEL_ID_RE.test(ctx.model)) {
notes.push(
`Model id "${ctx.model}" starts with "claude-"; Claude Code will assume a first-party ` +
`200K window and ignore CLAUDE_CODE_AUTO_COMPACT_WINDOW. Load with ` +
`"lms load --identifier <other-name>" to avoid this.`,
);
}
// args is empty on purpose: any passthrough "--model" the user typed after "claude" is
// forwarded untouched by index.ts, and Claude Code's own --model flag takes precedence over
// the env vars above, so nothing here needs to inject or dedupe a --model arg.
return { command: COMMAND, args: [], env, notes };
},
};
44 changes: 44 additions & 0 deletions src/subcommands/launch/adapters/codex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { type ToolAdapter } from "../types.js";

const COMMAND = "codex";
// A synthetic provider id, scoped to this invocation only (never written to any config file).
const PROVIDER_ID = "lmslaunch";

/**
* Codex CLI. Ships the explicit custom-provider `-c` override form (portable across versions)
* rather than assuming a built-in "lmstudio"/"oss" provider exists in the installed release.
* `wire_api=chat` is the broadest OpenAI-compatible mode; see the note below if it doesn't fit.
*/
export const codex: ToolAdapter = {
name: "codex",
displayName: "Codex CLI",
command: COMMAND,
install: { npm: "@openai/codex" },
supportsContextHint: true,
injectsModelArg: true,
async prepare(ctx) {
const args = [
"-c",
`model_providers.${PROVIDER_ID}.base_url=${ctx.openaiBaseUrl}`,
"-c",
`model_providers.${PROVIDER_ID}.wire_api=chat`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop forcing Codex onto the removed chat API

With the current @openai/codex CLI, this override makes lms launch codex fail while loading its config: OpenAI's Codex config reference says model_providers.<id>.wire_api only supports responses and defaults to it (https://developers.openai.com/codex/config-reference). Since this adapter always emits wire_api=chat before invoking Codex, users with current Codex installs cannot launch the advertised Codex integration; use responses or omit the override.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f37ad72.

You're right — this would have broken lms launch codex for anyone on a current Codex. Current Codex removed Chat Completions support (Feb 2026) and only speaks the Responses API, but the adapter pinned wire_api=chat.

Since LM Studio serves the Responses API at /v1/responses (v0.3.29+), switching to responses is a clean fix rather than a blocker: Codex posts to {base_url}/responseshttp://127.0.0.1:1234/v1/responses, which LM Studio implements.

Change (adapters/codex.ts):

  • model_providers.lmslaunch.wire_apiresponses.
  • Reworded the note so the -- -c …wire_api=chat override is the legacy fallback (pre-2026 Codex + older LM Studio), not the default.
  • Updated the unit test to assert wire_api=responses.

model_context_window is unaffected and still emitted.

"-c",
`model_provider=${PROVIDER_ID}`,
"-c",
`model=${ctx.model}`,
"-c",
"sandbox_mode=workspace-write",
];
if (ctx.contextLength !== undefined) {
args.push("-c", `model_context_window=${ctx.contextLength}`);
}
const env: Record<string, string> = {
OPENAI_API_KEY: ctx.apiKey, // some Codex builds require a non-empty key even though unused

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wire Codex API key into the custom provider

When the LM Studio endpoint enforces a bearer token, lms launch codex --api-key ... still won't authenticate: Codex custom providers read the key from the env var named by model_providers.<id>.env_key (see https://developers.openai.com/codex/config-advanced), but this adapter only sets OPENAI_API_KEY and never adds the matching model_providers.lmslaunch.env_key=OPENAI_API_KEY override. The custom provider therefore sends no bearer token for secured local endpoints.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b20ba92.

Right — the adapter set OPENAI_API_KEY in the child env but never told the custom provider to read it, so per the config-advanced docs Codex sent no Authorization header and --api-key couldn't authenticate a secured LM Studio endpoint.

Change (adapters/codex.ts): added -c model_providers.lmslaunch.env_key=OPENAI_API_KEY, naming the env var the adapter already populates, so Codex forwards it as the bearer token. Updated the codex unit test's expected arg list and the no-context-length length assertion (10 → 12).

};
const notes = [
`Using a temporary Codex provider ("${PROVIDER_ID}") with wire_api=chat. If Codex fails to ` +
`connect, try appending an override after "--": -c model_providers.${PROVIDER_ID}.wire_api=responses`,
];
return { command: COMMAND, args, env, notes };
},
};
26 changes: 26 additions & 0 deletions src/subcommands/launch/adapters/copilot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { type ToolAdapter } from "../types.js";

const COMMAND = "copilot";

/**
* GitHub Copilot CLI (the standalone `@github/copilot` package, NOT `gh copilot`). Env-only.
* Verified names against https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/use-byok-models
*/
export const copilot: ToolAdapter = {
name: "copilot",
displayName: "GitHub Copilot CLI",
command: COMMAND,
install: { npm: "@github/copilot" },
// No verified per-tool context knob: context lives entirely in the model load (layer 1).
supportsContextHint: false,
async prepare(ctx) {
const env: Record<string, string> = {
COPILOT_PROVIDER_BASE_URL: ctx.openaiBaseUrl,
COPILOT_MODEL: ctx.model,
COPILOT_PROVIDER_API_KEY: ctx.apiKey,
COPILOT_PROVIDER_TYPE: "openai",
COPILOT_OFFLINE: "true", // do not contact GitHub servers
};
return { command: COMMAND, args: [], env };
},
};
117 changes: 117 additions & 0 deletions src/subcommands/launch/adapters/droid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { confirm } from "@inquirer/prompts";
import chalk from "chalk";
import { mkdir, readFile, rm, writeFile } from "fs/promises";
import { homedir } from "os";
import { dirname, join } from "path";
import { exists } from "../../../exists.js";
import { runPromptWithExitHandling } from "../../../prompt.js";
import { UserInputError } from "../../../types/UserInputError.js";
import { type ToolAdapter } from "../types.js";

const COMMAND = "droid";
// Stable key so re-running "lms launch droid" updates the same entry instead of duplicating it.
const DISPLAY_NAME = "LM Studio (lms launch)";

export interface DroidCustomModel {
displayName: string;
model: string;
baseUrl: string;
apiKey: string;
provider: string;
maxOutputTokens?: number;
}

export interface DroidSettings {
customModels?: DroidCustomModel[];
[key: string]: unknown;
}

function settingsFilePath(): string {
return join(homedir(), ".factory", "settings.json");
}

/** Exported for unit testing; merges idempotently by `displayName` (last write wins). */
export function mergeDroidSettings(existing: DroidSettings, entry: DroidCustomModel): DroidSettings {
const customModels = (existing.customModels ?? []).filter(
model => model.displayName !== entry.displayName,
);
customModels.push(entry);
return { ...existing, customModels };
}

/**
* Factory's `droid` CLI. Model selection lives in `~/.factory/settings.json`, a real file the
* user's Factory installation also reads/writes, so we back up the original content, write our
* entry keyed by a stable displayName (idempotent across re-runs), confirm before touching it
* unless -y, and restore the original content on exit via `cleanup`.
*/
export const droid: ToolAdapter = {
name: "droid",
displayName: "Factory (droid)",
command: COMMAND,
install: { note: "Install the Factory CLI (droid) from your Factory account/dashboard." },
supportsContextHint: true,
async prepare(ctx) {
const filePath = settingsFilePath();
const fileExisted = await exists(filePath);
const originalRaw = fileExisted ? await readFile(filePath, "utf-8") : undefined;

let existingSettings: DroidSettings = {};
if (originalRaw !== undefined) {
try {
existingSettings = JSON.parse(originalRaw) as DroidSettings;
} catch {
throw new UserInputError(
`Could not parse ${filePath} as JSON. Please fix or remove the file, then try again.`,
);
}
}

const entry: DroidCustomModel = {
displayName: DISPLAY_NAME,
model: ctx.model,
baseUrl: ctx.openaiBaseUrl,
apiKey: ctx.apiKey,
provider: "generic-chat-completion-api",
};
if (ctx.contextLength !== undefined) {
entry.maxOutputTokens = ctx.contextLength;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't map Droid context to output tokens

ctx.contextLength is the model's input/context window, but Factory documents maxOutputTokens as the maximum output tokens for responses (https://docs.factory.ai/cli/byok/overview). Because resolveModelForLaunch normally supplies a context length even when the user did not pass --context-length, every lms launch droid can write very large output caps like 32K/128K while still not conveying a context-window hint, which can make Droid request or display an invalid response budget.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f37ad72.

Confirmed against the Factory BYOK docs: a customModels entry has no context-window field, and maxOutputTokens is the completion cap — so mapping the model's context length onto it advertised a bogus response budget while conveying no context hint.

Change (adapters/droid.ts):

  • Dropped the contextLength → maxOutputTokens mapping, and removed the now-unused maxOutputTokens field from DroidCustomModel.
  • Set supportsContextHint: false, since Factory genuinely exposes no context knob. lms launch droid -c N now takes the honest-degradation path: the model is still loaded at N tokens (the effective window) and lms warns that droid has no verified context-length knob, rather than inventing an output cap.
  • Added a unit test asserting droid.supportsContextHint === false.

}

if (!ctx.yes && process.stdin.isTTY === true) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require confirmation when stdin is non-interactive

When droid is launched without -y from a non-TTY context, this condition skips the confirmation instead of failing, then falls through to rewrite ~/.factory/settings.json. In particular, lms launch droid --print-env in a script permanently leaves the new custom model entry because print-env teardown is skipped, even though the user did not opt into the --yes auto-confirm behavior.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ab4a4e4.

Right — !ctx.yes && process.stdin.isTTY === true meant a non-TTY launch without -y fell straight through to rewriting ~/.factory/settings.json with no confirmation, and under --print-env (teardown skipped) that entry was left permanently — all without the user opting into --yes.

Change (adapters/droid.ts): the guard is now if (!ctx.yes), and inside it a non-TTY stdin throws a UserInputError asking the user to re-run with -y/--yes (or from an interactive terminal) instead of silently modifying the file. TTY without -y still prompts on stderr, and -y still auto-confirms. It applies to every non-interactive path (spawn, dry-run, print-env), since writing a real user file without consent is the issue in all of them.

console.info();
console.info(chalk.dim(`! "droid" reads its model list from ${filePath}.`));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Send Droid prompt text to stderr under print-env

For interactive lms launch droid --print-env without -y, these console.info calls write the prompt preface to stdout before formatEnvForShell prints the shell command. Because --print-env is meant for command substitution, eval "$(...)" will try to parse this human text as shell input even when the model is already loaded; write this prompt text to stderr/logger like the confirm prompt.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b20ba92.

Confirmed — the two console.info calls (the blank line and ! "droid" reads its model list from …) wrote to stdout, while the confirm prompt immediately below already used { output: process.stderr }. Under lms launch droid --print-env without -y, that preface polluted the command-substitution stdout.

Change (adapters/droid.ts): both are now console.error (stderr), consistent with the prompt. It's unconditional rather than print-env-gated — interactive prompt context belongs on stderr in every mode — so stdout stays clean whether or not --print-env is set.

const proceed = await runPromptWithExitHandling(() =>
confirm(
{ message: `Add/update the "${DISPLAY_NAME}" entry in ${filePath}?`, default: true },
{ output: process.stderr },
),
);
if (!proceed) {
throw new UserInputError(`Aborted: declined to modify ${filePath}.`);
}
}

const merged = mergeDroidSettings(existingSettings, entry);
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, JSON.stringify(merged, null, 2), "utf-8");

const cleanup = async () => {
if (originalRaw !== undefined) {
await writeFile(filePath, originalRaw, "utf-8");
} else {
await rm(filePath, { force: true });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve Factory settings updates during cleanup

When a non---print-env droid launch exits, this restores the entire original ~/.factory/settings.json snapshot (or deletes the file if it did not exist). If the launched droid process or the user changes Factory settings while that session is running, those later writes are lost on cleanup because this code writes the old snapshot back wholesale. Remove only the LM Studio (lms launch) entry from the current file instead, so unrelated settings changes survive.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4ca0c41.

Right — cleanup wrote the whole pre-launch snapshot back (or deleted the file), so anything droid or the user changed in ~/.factory/settings.json during the session — other custom models, unrelated settings — was clobbered on exit.

Change (adapters/droid.ts): cleanup now re-reads the current file and strips only our "LM Studio (lms launch)" entry via a new removeDroidSettingsEntry helper (symmetric to mergeDroidSettings), so concurrent changes survive. It drops the file only when we created it and nothing else remains, and falls back to the wholesale snapshot restore solely when the current file is missing/unparseable (surgical removal impossible). Added unit tests covering: removes only our entry while keeping session-added models, preserves changed top-level keys, drops customModels when it was only ours, and no-ops when our entry is absent.

};

return {
command: COMMAND,
args: [],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Select the Droid model when launching

When the forwarded Droid invocation is non-interactive, such as lms launch droid --model X -- exec ..., this adapter writes the custom model entry but returns no -m/--model argument, so Droid can keep using its current/default model instead of the LM Studio model that lms launch resolved. Factory's CLI reference documents model selection through -m, --model, so the launch wrapper should pass the injected model id/name (while still allowing user args to override it if needed).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — the underlying concern is right (the settings entry alone doesn't make droid use the model), but passing --model isn't safe here, so I've addressed it differently.

Factory's droid --model does not reliably accept custom BYOK models: it expects a generated id like custom:<DisplayName-dashified>-<index> (not the plain model id or the displayName), and there's an open bug — Factory-AI/factory#787 — where droid exec --model rejects every custom model id (all three tried forms: the custom:...-index id, the displayName, and an alternate) with "Invalid model". Per that issue only sessionDefaultSettings.model works via the flag, and the custom:...-index id is undocumented and index-dependent, so we can't reliably reproduce it.

So injecting --model <id> would risk breaking lms launch droid with an "Invalid model" error rather than fixing it. Instead (a38dc3c) I added a note directing the user to select the entry through droid's own /model picker — the documented selection path (per the Groq/Factory BYOK guides). If #787 lands and the id format is documented, I'm glad to inject it then; meanwhile user -- <args> still forward verbatim, so anyone with a known-working custom:... id can pass -- --model custom:... themselves.

env: {},
notes: [
`Wrote a temporary "${DISPLAY_NAME}" entry to ${filePath}; it will be reverted on exit.`,
],
cleanup,
};
},
};
9 changes: 9 additions & 0 deletions src/subcommands/launch/adapters/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { type ToolAdapter } from "../types.js";
import { aider } from "./aider.js";
import { claude } from "./claude.js";
import { codex } from "./codex.js";
import { copilot } from "./copilot.js";
import { droid } from "./droid.js";
import { opencode } from "./opencode.js";

export const adapters: ToolAdapter[] = [claude, codex, copilot, aider, opencode, droid];
37 changes: 37 additions & 0 deletions src/subcommands/launch/adapters/opencode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { type ToolAdapter } from "../types.js";

const COMMAND = "opencode";

/**
* opencode. Config is delivered inline via `OPENCODE_CONFIG_CONTENT`, which dodges the Windows
* `~/.config` vs `%APPDATA%` ambiguity a temp-file-based `OPENCODE_CONFIG` path would raise.
*/
export const opencode: ToolAdapter = {
name: "opencode",
displayName: "opencode",
command: COMMAND,
install: { url: "https://opencode.ai" },
supportsContextHint: true,
async prepare(ctx) {
const modelConfig: { limit?: { context: number } } = {};
if (ctx.contextLength !== undefined) {
modelConfig.limit = { context: ctx.contextLength };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Emit a complete OpenCode limit block

When ctx.contextLength is known, this emits limit: { context: ... } for the inline OpenCode config. I checked the current OpenCode schema at https://opencode.ai/config.json, and a model limit object requires both context and output; with only context, lms launch opencode produces an invalid model config whenever a context length is available, which can make OpenCode reject or ignore the injected local model limits. Either include a valid output limit or omit the limit block instead of marking this adapter as having a verified context hint.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f1ba673.

Confirmed against https://opencode.ai/config.json — a model limit object requires both context and output, so emitting limit: { context } alone was invalid and OpenCode would reject/ignore it.

We only know the model's loaded context window, not a real output cap, and inventing one would advertise a bogus response budget (the same trap the droid maxOutputTokens fix avoided). So rather than emit a partial/fabricated block, the adapter now omits limit entirely and sets supportsContextHint: false — the LM Studio server still enforces the context length it loaded the model at, and lms launch opencode -c N takes the honest-degradation path (loads at N, warns opencode has no verified context knob). Updated the opencode unit tests accordingly.

}
const config = {
$schema: "https://opencode.ai/config.json",
model: `lmstudio/${ctx.model}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pin OpenCode's small model

OpenCode merges config sources rather than replacing them, and small_model is a separate setting for lightweight/background tasks. Because the inline config only sets the main model, any existing global/project small_model remains in effect, so lms launch opencode can still send those tasks to a previously configured cloud/default provider instead of LM Studio; set small_model to the same lmstudio/... model as part of this injected config.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a38dc3c.

Confirmed on opencode.ai/docs/configsmall_model is a separate top-level setting for lightweight/background tasks (e.g. title generation), and OpenCode merges config sources, so a global/project small_model would keep routing those to a previously-configured provider even though the main model is set.

Change (adapters/opencode.ts): the injected config now also sets small_model: lmstudio/${ctx.model} (same provider/model form as model). Test updated.

provider: {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enable the injected OpenCode provider

When lms launch opencode runs with an existing OpenCode config that sets enabled_providers, this inline config adds provider.lmstudio but leaves that inherited allowlist untouched. OpenCode documents that config sources are merged and that enabled_providers allows only the listed providers (https://opencode.ai/docs/config/), so a user/project config such as enabled_providers: ["anthropic"] makes OpenCode ignore the injected LM Studio provider while model points at lmstudio/...; include lmstudio in the inline allowlist for this invocation (and clear an inherited disabled entry if needed).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4d33fd1.

Confirmed on opencode.ai/docs/configenabled_providers is an allowlist ("only the specified providers will be enabled and all others will be ignored") and disabled_providers takes priority over it. So a user/project enabled_providers: ["anthropic"] (or a disabled_providers containing lmstudio) would make OpenCode drop the injected lmstudio provider even though model/small_model point at lmstudio/....

Change (adapters/opencode.ts): the inline config now sets enabled_providers: ["lmstudio"] and disabled_providers: [], so this session resolves through the injected provider regardless of an inherited allowlist/blocklist. It scopes the launch to lmstudio, which is the intent — everything already points at lmstudio/.... Test updated.

lmstudio: {
npm: "@ai-sdk/openai-compatible",
name: "LM Studio (local)",
options: { baseURL: ctx.openaiBaseUrl, apiKey: ctx.apiKey },
models: { [ctx.model]: modelConfig },
},
},
};
const env: Record<string, string> = {
OPENCODE_CONFIG_CONTENT: JSON.stringify(config),
};
return { command: COMMAND, args: [], env };
},
};
Loading
Loading