-
Notifications
You must be signed in to change notification settings - Fork 450
Add lms launch XYZ
#594
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Add lms launch XYZ
#594
Changes from 1 commit
ce23c8c
bb0dbf5
f37ad72
b20ba92
092b576
ab4a4e4
f1ba673
4ca0c41
bbb4880
a38dc3c
a8bdb36
4d33fd1
7b5d417
d37632c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 }; | ||
| }, | ||
| }; |
| 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, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If the caller already has Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in Confirmed against code.claude.com/docs/en/model-config — Change ( |
||
| }; | ||
| 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 }; | ||
| }, | ||
| }; | ||
| 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`, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With the current Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in You're right — this would have broken Since LM Studio serves the Responses API at Change (
|
||
| "-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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the LM Studio endpoint enforces a bearer token, Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in Right — the adapter set Change ( |
||
| }; | ||
| 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 }; | ||
| }, | ||
| }; | ||
| 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 }; | ||
| }, | ||
| }; |
| 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in Confirmed against the Factory BYOK docs: a Change (
|
||
| } | ||
|
|
||
| if (!ctx.yes && process.stdin.isTTY === true) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in Right — Change ( |
||
| console.info(); | ||
| console.info(chalk.dim(`! "droid" reads its model list from ${filePath}.`)); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For interactive Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in Confirmed — the two Change ( |
||
| 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 }); | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a non- Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in Right — Change ( |
||
| }; | ||
|
|
||
| return { | ||
| command: COMMAND, | ||
| args: [], | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the forwarded Droid invocation is non-interactive, such as Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Factory's So injecting |
||
| env: {}, | ||
| notes: [ | ||
| `Wrote a temporary "${DISPLAY_NAME}" entry to ${filePath}; it will be reverted on exit.`, | ||
| ], | ||
| cleanup, | ||
| }; | ||
| }, | ||
| }; | ||
| 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]; |
| 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 }; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in Confirmed against https://opencode.ai/config.json — a model 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 |
||
| } | ||
| const config = { | ||
| $schema: "https://opencode.ai/config.json", | ||
| model: `lmstudio/${ctx.model}`, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OpenCode merges config sources rather than replacing them, and Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in Confirmed on opencode.ai/docs/config — Change ( |
||
| provider: { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in Confirmed on opencode.ai/docs/config — Change ( |
||
| 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 }; | ||
| }, | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Registering
launchas 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 aslms launch codex -- --versionorlms launch aider -- -v, the process exits beforelaunchcan parse the--separator, so the advertised verbatim forwarding path cannot invoke the tool.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
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, solms launch codex -- --version(orlms launch aider -- -v) matched and printed the lms version + exited beforelaunchever parsed--, defeating the verbatim forwarding.Change (
src/index.ts): the shortcut now only fires for a-v/--versionthat appears before the first subcommand token (commandArguments.slice(0, firstSubcommandIndex)).lms -v/lms --versionstill work; after a subcommand the flag belongs to it, andlaunchforwards everything past--to the wrapped tool. As a bonus this also stopslms <anysub> --versionfrom masquerading as the lms version in general.