Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ MIMO_MODEL=mimo-v2.5-pro
MIMO_TOKEN_PLAN_API_KEY=
MIMO_TOKEN_PLAN_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
MIMO_TOKEN_PLAN_MODEL=mimo-v2.5-pro

# LM Studio (local desktop app, no API key required)
LM_STUDIO_BASE_URL=http://127.0.0.1:1234/v1
LM_STUDIO_MODEL=
LM_STUDIO_ENABLED=false
MIMO_TOKEN_PLAN_ENABLED=false

# Default provider: deepseek | openai | anthropic | grok | ollamaCloud | ollamaLocal | openaiCompat | mimo | mimoTokenPlan
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

73 changes: 73 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ const PROVIDER_OPTIONS: Array<{ key: ProviderName; label: string }> = [
{ key: 'openaiCompat', label: 'OpenAI Compilations' },
{ key: 'mimo', label: 'MiMo (Xiaomi)' },
{ key: 'mimoTokenPlan', label: 'MiMo Token Plan (Xiaomi)' },
{ key: 'lmStudio', label: 'LM Studio' },
];

function getConfiguredProviderNames(config: MercuryConfig): ProviderName[] {
Expand Down Expand Up @@ -295,6 +296,10 @@ function validateApiKey(provider: ProviderName, value: string): string | null {
: 'Ollama Cloud keys must look like a real API token: long, no spaces, and not plain text.';
}

if (provider === 'lmStudio') {
return null;
}

if (provider === 'mimo') {
return /^sk-[A-Za-z0-9_-]{16,}$/i.test(value)
? null
Expand Down Expand Up @@ -500,6 +505,64 @@ async function promptOllamaLocalModelSelection(config: MercuryConfig, isReconfig
}
}

async function promptLmStudioModelSelection(config: MercuryConfig, isReconfig: boolean): Promise<{ baseUrl?: string; model?: string; skipped: boolean }> {
const existingConfig = config.providers.lmStudio;

const baseUrlPrompt = isReconfig && existingConfig.baseUrl
? chalk.white(` LM Studio base URL [${existingConfig.baseUrl}]: `)
: chalk.white(' LM Studio base URL [Enter for http://127.0.0.1:1234/v1]: ');
const baseUrlInput = await ask(baseUrlPrompt);
if (!baseUrlInput || baseUrlInput.toLowerCase() === 'none') {
if (isReconfig && existingConfig.baseUrl) {
return { baseUrl: existingConfig.baseUrl, model: existingConfig.model, skipped: true };
}
return { skipped: true };
}
const baseUrlError = validateBaseUrl(baseUrlInput);
if (baseUrlError) {
console.log(chalk.red(` ${baseUrlError}`));
if (isReconfig && existingConfig.baseUrl) {
return { baseUrl: existingConfig.baseUrl, model: existingConfig.model, skipped: true };
}
return { skipped: true };
}
const baseUrl = baseUrlInput;

console.log(chalk.dim(' Fetching LM Studio models...'));
try {
const catalog = await fetchProviderModelCatalog('lmStudio', {
...existingConfig,
baseUrl,
});
const model = await chooseProviderModel(
'LM Studio',
catalog.recommendedModel,
catalog.models,
);
return { baseUrl, model, skipped: false };
} catch (error) {
const message = error instanceof ProviderModelFetchError
? error.message
: 'Mercury could not fetch LM Studio models.';
console.log(chalk.yellow(` ${message}`));
console.log(chalk.dim(' Make sure LM Studio is running and the Developer API is enabled.'));
console.log(chalk.dim(' You can run `mercury doctor` later to configure LM Studio after starting it.'));

const manualModel = await ask(chalk.white(` LM Studio model name (Enter to skip LM Studio for now): `));
if (!manualModel) {
return { skipped: true };
}

const modelError = validateModelName(manualModel);
if (modelError) {
console.log(chalk.red(` ${modelError}`));
return { skipped: true };
}

return { baseUrl, model: manualModel, skipped: false };
}
}

async function promptOpenAICompatSetup(config: MercuryConfig, isReconfig: boolean): Promise<{ baseUrl?: string; apiKey?: string; model?: string; skipped: boolean }> {
const existingConfig = config.providers.openaiCompat;

Expand Down Expand Up @@ -1281,6 +1344,16 @@ async function configure(existingConfig?: MercuryConfig): Promise<void> {
continue;
}


if (provider === 'lmStudio') {
const result = await promptLmStudioModelSelection(config, isReconfig);
if (!result.skipped && result.baseUrl && result.model) {
config.providers.lmStudio.baseUrl = result.baseUrl;
config.providers.lmStudio.model = result.model;
config.providers.lmStudio.enabled = true;
}
continue;
}
if (provider === 'openaiCompat') {
const result = await promptOpenAICompatSetup(config, isReconfig);
if (!result.skipped && result.baseUrl && result.model) {
Expand Down
2 changes: 1 addition & 1 deletion src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ async function createProvider(pc: ProviderConfig): Promise<BaseProvider> {
// this entirely.
const { OpenAICompatProvider } = await import('./openai-compat.js');
return new OpenAICompatProvider(pc, { useChatApi: true });
} else if (pc.name === 'ollamaCloud' || pc.name === 'openaiCompat') {
} else if (pc.name === 'ollamaCloud' || pc.name === 'openaiCompat' || pc.name === 'lmStudio') {
const { OpenAICompatProvider } = await import('./openai-compat.js');
return new OpenAICompatProvider(pc, { useChatApi: true });
} else if (pc.name === 'mimo' || pc.name === 'mimoTokenPlan') {
Expand Down
12 changes: 12 additions & 0 deletions src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export type ProviderName =
| 'openaiCompat'
| 'mimo'
| 'mimoTokenPlan'
| 'lmStudio'
| 'chatgptWeb'
| 'githubCopilot';

Expand All @@ -77,6 +78,7 @@ export interface MercuryConfig {
openaiCompat: ProviderConfig;
mimo: ProviderConfig;
mimoTokenPlan: ProviderConfig;
lmStudio: ProviderConfig;
chatgptWeb: ProviderConfig;
githubCopilot: ProviderConfig;
};
Expand Down Expand Up @@ -267,6 +269,13 @@ export function getDefaultConfig(): MercuryConfig {
model: getEnv('MIMO_TOKEN_PLAN_MODEL', 'mimo-v2.5-pro'),
enabled: getEnvBool('MIMO_TOKEN_PLAN_ENABLED', false),
},
lmStudio: {
name: 'lmStudio',
apiKey: getEnv('LM_STUDIO_API_KEY', ''),
baseUrl: getEnv('LM_STUDIO_BASE_URL', 'http://127.0.0.1:1234/v1'),
model: getEnv('LM_STUDIO_MODEL', ''),
enabled: getEnvBool('LM_STUDIO_ENABLED', false),
},
chatgptWeb: {
name: 'chatgptWeb',
apiKey: '', // not used — auth is via OAuth
Expand Down Expand Up @@ -455,6 +464,9 @@ export function isProviderConfigured(provider: ProviderConfig): boolean {
if (provider.name === 'openaiCompat') {
return provider.baseUrl.length > 0 && provider.model.length > 0;
}
if (provider.name === 'lmStudio') {
return provider.baseUrl.length > 0 && provider.model.length > 0;
}
if (provider.name === 'chatgptWeb') {
// ChatGPT Web uses browser session auth, not API keys.
// Considered "configured" if enabled with a model selected.
Expand Down
20 changes: 20 additions & 0 deletions src/utils/provider-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,24 @@ describe('buildModelCatalog', () => {

expect(catalog.recommendedModel).toBe('model-a');
});

it('uses the first model as recommended for LM Studio when no preferred list exists', () => {
const catalog = buildModelCatalog(
'lmStudio',
['llama-3.2-3b-instruct', 'mistral-7b-instruct-v0.3'],
);

expect(catalog.recommendedModel).toBe('llama-3.2-3b-instruct');
expect(catalog.models).toContain('mistral-7b-instruct-v0.3');
});

it('falls back to the current model for LM Studio when no preferred list exists', () => {
const catalog = buildModelCatalog(
'lmStudio',
['qwen2.5-7b-instruct'],
'qwen2.5-7b-instruct',
);

expect(catalog.recommendedModel).toBe('qwen2.5-7b-instruct');
});
});
21 changes: 21 additions & 0 deletions src/utils/provider-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ const MIMO_PREFERRED_MODELS = [
const MIMO_TOKEN_PLAN_PREFERRED_MODELS = MIMO_PREFERRED_MODELS;

const OPENAI_COMPAT_PREFERRED_MODELS = [] as const;
const LM_STUDIO_PREFERRED_MODELS = [] as const;

const CHATGPT_WEB_PREFERRED_MODELS = [
'gpt-5.5',
Expand Down Expand Up @@ -199,6 +200,7 @@ function chooseRecommendedModel(
mimo: MIMO_PREFERRED_MODELS,
mimoTokenPlan: MIMO_TOKEN_PLAN_PREFERRED_MODELS,
chatgptWeb: CHATGPT_WEB_PREFERRED_MODELS,
lmStudio: LM_STUDIO_PREFERRED_MODELS,
githubCopilot: GITHUB_COPILOT_PREFERRED_MODELS,
};

Expand Down Expand Up @@ -238,6 +240,7 @@ export function buildModelCatalog(
mimoTokenPlan: MIMO_TOKEN_PLAN_PREFERRED_MODELS,
chatgptWeb: CHATGPT_WEB_PREFERRED_MODELS,
githubCopilot: GITHUB_COPILOT_PREFERRED_MODELS,
lmStudio: LM_STUDIO_PREFERRED_MODELS,
};

const withoutRecommended = filtered.filter((model) => model !== recommendedModel);
Expand Down Expand Up @@ -343,6 +346,20 @@ async function fetchOllamaCloudModels(config: ProviderConfig): Promise<ProviderM
return buildModelCatalog('ollamaCloud', ids, config.model);
}

async function fetchLmStudioModels(config: ProviderConfig): Promise<ProviderModelCatalog> {
const data = await fetchJson<OpenAIModelResponse>(
`${trimTrailingSlash(config.baseUrl)}/models`,
{},
'Mercury could not fetch models from LM Studio. Make sure LM Studio is running and the Developer API is enabled.',
);

const ids = (data.data ?? [])
.map((model) => model.id?.trim() ?? '')
.filter(Boolean);

return buildModelCatalog('lmStudio', ids, config.model);
}

async function fetchOllamaLocalModels(config: ProviderConfig): Promise<ProviderModelCatalog> {
const data = await fetchJson<OllamaTagsResponse>(
`${trimTrailingSlash(config.baseUrl)}/tags`,
Expand Down Expand Up @@ -423,6 +440,10 @@ export async function fetchProviderModelCatalog(
return fetchOpenAICompatModels(provider, config);
}

if (provider === 'lmStudio') {
return fetchLmStudioModels(config);
}

if (provider === 'mimo') {
return fetchMiMoModels(config);
}
Expand Down