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
2 changes: 1 addition & 1 deletion bun.lock

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

4 changes: 4 additions & 0 deletions env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ DEEPSEEK_API_KEY=your-deepseek-api-key
# Ollama (Local LLM)
OLLAMA_BASE_URL=http://127.0.0.1:11434

# LM Studio (Local OpenAI-compatible LLM)
LM_STUDIO_BASE_URL=http://127.0.0.1:1234/v1
LM_STUDIO_MODEL=your-lm-studio-model-id

# Persistent memory embeddings reuse existing model API keys.
# Priority: OpenAI (OPENAI_API_KEY) -> Gemini (GOOGLE_API_KEY) -> Ollama (OLLAMA_BASE_URL)
# No additional memory-specific API keys required.
Expand Down
3 changes: 1 addition & 2 deletions src/agent/agent.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AIMessage, AIMessageChunk, SystemMessage, HumanMessage, ToolMessage, type BaseMessage } from '@langchain/core/messages';
import { StructuredToolInterface } from '@langchain/core/tools';
import { callLlmWithMessages, streamLlmWithMessages } from '../model/llm.js';
import { callLlmWithMessages, streamLlmWithMessages, DEFAULT_MODEL } from '../model/llm.js';
import { getTools, getToolConcurrencyMap } from '../tools/registry.js';
import { buildSystemPrompt, loadSoulDocument, loadRulesDocument } from './prompts.js';
import { extractTextContent, hasToolCalls } from '../utils/ai-message.js';
Expand All @@ -20,7 +20,6 @@ import { runMemoryFlush, shouldRunMemoryFlush } from '../memory/flush.js';
import { resolveProvider } from '../providers.js';


const DEFAULT_MODEL = 'gpt-5.5';
const DEFAULT_MAX_ITERATIONS = 10;
const MAX_OVERFLOW_RETRIES = 2;
const OVERFLOW_KEEP_ROUNDS = 3;
Expand Down
8 changes: 8 additions & 0 deletions src/components/select-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ class EmptyModelSelector extends Container {
new Text(theme.muted('Make sure Ollama is running and you have models downloaded.'), 0, 0),
);
}
if (providerId === 'lmstudio') {
this.addChild(
new Text(theme.muted('Make sure LM Studio is running and exposing its OpenAI-compatible API.'), 0, 0),
);
this.addChild(
new Text(theme.muted('You can also preconfigure a default model via LM_STUDIO_MODEL.'), 0, 0),
);
}
this.addChild(new Text(theme.muted('esc to go back'), 0, 0));
}

Expand Down
13 changes: 11 additions & 2 deletions src/controllers/model-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type Model,
} from '../utils/model.js';
import { getOllamaModels } from '../utils/ollama.js';
import { getLmStudioModels } from '../utils/lm-studio.js';
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from '../model/llm.js';
import { InMemoryChatHistory } from '../utils/in-memory-chat-history.js';

Expand Down Expand Up @@ -109,6 +110,14 @@ export class ModelSelectionController {
return;
}

if (providerId === 'lmstudio') {
const lmStudioModelIds = await getLmStudioModels();
this.pendingModelsValue = lmStudioModelIds.map((id) => ({ id, displayName: id }));
this.appStateValue = 'model_select';
this.emitChange();
return;
}

this.pendingModelsValue = getModelsForProvider(providerId);
this.appStateValue = 'model_select';
this.emitChange();
Expand All @@ -124,8 +133,8 @@ export class ModelSelectionController {
return;
}

if (this.pendingProviderValue === 'ollama') {
this.completeModelSwitch(this.pendingProviderValue, `ollama:${modelId}`);
if (this.pendingProviderValue === 'ollama' || this.pendingProviderValue === 'lmstudio') {
this.completeModelSwitch(this.pendingProviderValue, `${this.pendingProviderValue}:${modelId}`);
return;
}

Expand Down
5 changes: 3 additions & 2 deletions src/cron/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { resolveSessionStorePath, loadSessionStore, type SessionEntry } from '..
import { cleanMarkdownForWhatsApp } from '../gateway/utils.js';
import { getSetting } from '../utils/config.js';
import { dexterPath } from '../utils/paths.js';
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from '../model/llm.js';
import { saveCronStore } from './store.js';
import { computeNextRunAtMs } from './schedule.js';
import type { ActiveHours, CronJob, CronStore } from './types.js';
Expand Down Expand Up @@ -125,8 +126,8 @@ export async function executeCronJob(
}

// 3. Resolve model
const model = job.payload.model ?? (getSetting('modelId', 'gpt-5.5') as string);
const modelProvider = job.payload.modelProvider ?? (getSetting('provider', 'openai') as string);
const model = job.payload.model ?? (getSetting('modelId', DEFAULT_MODEL) as string);
const modelProvider = job.payload.modelProvider ?? (getSetting('provider', DEFAULT_PROVIDER) as string);

// 4. Build query
let query = `[CRON JOB: ${job.name}]\n\n${job.payload.message}`;
Expand Down
6 changes: 3 additions & 3 deletions src/gateway/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type { GroupContext } from '../agent/prompts.js';
import { appendFileSync } from 'node:fs';
import { dexterPath } from '../utils/paths.js';
import { getSetting } from '../utils/config.js';
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from '../model/llm.js';

const LOG_PATH = dexterPath('gateway-debug.log');
function debugLog(msg: string) {
Expand Down Expand Up @@ -157,8 +158,8 @@ async function handleInbound(cfg: GatewayConfig, inbound: WhatsAppInboundMessage
}

console.log(`Processing message with agent...`);
const model = getSetting('modelId', 'gpt-5.5') as string;
const modelProvider = getSetting('provider', 'openai') as string;
const model = getSetting('modelId', DEFAULT_MODEL) as string;
const modelProvider = getSetting('provider', DEFAULT_PROVIDER) as string;

// If agent is already running for this session, enqueue for mid-run injection
if (isSessionRunning(route.sessionKey)) {
Expand Down Expand Up @@ -238,4 +239,3 @@ export async function startGateway(params: { configPath?: string } = {}): Promis
snapshot: () => manager.getSnapshot(),
};
}

33 changes: 31 additions & 2 deletions src/model/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,28 @@ import { logger } from '@/utils';
import { classifyError, isNonRetryableError } from '@/utils/errors';
import { resolveProvider, getProviderById } from '@/providers';

export const DEFAULT_PROVIDER = 'openai';
export const DEFAULT_MODEL = 'gpt-5.5';
/**
* 解析 LM Studio 的基础地址,默认指向本地 OpenAI-compatible 端点。
*/
function getLmStudioBaseUrl(): string {
return process.env.LM_STUDIO_BASE_URL || 'http://127.0.0.1:1234/v1';
}

/**
* 读取 LM Studio 配置的默认模型,并补齐内部使用的 provider 前缀。
*/
function getDefaultLmStudioModelId(): string | null {
const model = process.env.LM_STUDIO_MODEL?.trim();
if (!model) {
return null;
}
return `lmstudio:${model}`;
}

const DEFAULT_LM_STUDIO_MODEL = getDefaultLmStudioModelId();

export const DEFAULT_PROVIDER = DEFAULT_LM_STUDIO_MODEL ? 'lmstudio' : 'openai';
export const DEFAULT_MODEL = DEFAULT_LM_STUDIO_MODEL ?? 'gpt-5.5';

/**
* Gets the fast model variant for the given provider.
Expand Down Expand Up @@ -132,6 +152,15 @@ const MODEL_FACTORIES: Record<string, ModelFactory> = {
...opts,
...(process.env.OLLAMA_BASE_URL ? { baseUrl: process.env.OLLAMA_BASE_URL } : {}),
}),
lmstudio: (name, opts) =>
new ChatOpenAI({
model: name.replace(/^lmstudio:/, ''),
...opts,
apiKey: process.env.LM_STUDIO_API_KEY || 'lm-studio',
configuration: {
baseURL: getLmStudioBaseUrl(),
},
}),
};

const DEFAULT_FACTORY: ModelFactory = (name, opts) =>
Expand Down
6 changes: 6 additions & 0 deletions src/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ export const PROVIDERS: ProviderDef[] = [
modelPrefix: 'ollama:',
contextWindow: 128_000,
},
{
id: 'lmstudio',
displayName: 'LM Studio',
modelPrefix: 'lmstudio:',
contextWindow: 128_000,
},
];

const defaultProvider = PROVIDERS.find((p) => p.id === 'openai')!;
Expand Down
82 changes: 82 additions & 0 deletions src/utils/lm-studio.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import { resolveProvider } from '../providers.js';
import { getDefaultModelForProvider, getModelDisplayName, getModelsForProvider } from './model.js';
import { getLmStudioModels } from './lm-studio.js';

const originalFetch = globalThis.fetch;
const originalBaseUrl = process.env.LM_STUDIO_BASE_URL;
const originalModel = process.env.LM_STUDIO_MODEL;

/**
* 统一恢复 LM Studio 相关环境变量,避免测试之间相互污染。
*/
function restoreLmStudioEnv() {
if (originalBaseUrl === undefined) {
delete process.env.LM_STUDIO_BASE_URL;
} else {
process.env.LM_STUDIO_BASE_URL = originalBaseUrl;
}

if (originalModel === undefined) {
delete process.env.LM_STUDIO_MODEL;
} else {
process.env.LM_STUDIO_MODEL = originalModel;
}
}

describe('LM Studio utilities', () => {
beforeEach(() => {
process.env.LM_STUDIO_BASE_URL = 'http://127.0.0.1:1234/v1';
delete process.env.LM_STUDIO_MODEL;
});

afterEach(() => {
globalThis.fetch = originalFetch;
restoreLmStudioEnv();
mock.restore();
});

test('resolves the lmstudio provider from its model prefix', () => {
expect(resolveProvider('lmstudio:qwen/qwen3-8b').id).toBe('lmstudio');
expect(getModelDisplayName('lmstudio:qwen/qwen3-8b')).toBe('qwen/qwen3-8b');
});

test('exposes the configured LM Studio model in provider model lists', () => {
process.env.LM_STUDIO_MODEL = 'qwen/qwen3-8b';

expect(getModelsForProvider('lmstudio')).toEqual([
{ id: 'qwen/qwen3-8b', displayName: 'qwen/qwen3-8b' },
]);
});

test('qualifies the default LM Studio model with its provider prefix', () => {
process.env.LM_STUDIO_MODEL = 'qwen/qwen3-8b';

expect(getDefaultModelForProvider('lmstudio')).toBe('lmstudio:qwen/qwen3-8b');
});

test('returns models from the LM Studio API when available', async () => {
globalThis.fetch = mock(async () =>
new Response(
JSON.stringify({
data: [{ id: 'qwen/qwen3-8b' }, { id: 'deepseek/deepseek-r1-0528-qwen3-8b' }],
}),
{ status: 200 },
),
) as unknown as typeof fetch;

await expect(getLmStudioModels()).resolves.toEqual([
'qwen/qwen3-8b',
'deepseek/deepseek-r1-0528-qwen3-8b',
]);
});

test('falls back to LM_STUDIO_MODEL when the API is unreachable', async () => {
process.env.LM_STUDIO_MODEL = 'qwen/qwen3-8b';
globalThis.fetch = mock(async () => {
throw new Error('connection refused');
}) as unknown as typeof fetch;

await expect(getLmStudioModels()).resolves.toEqual(['qwen/qwen3-8b']);
});
});
54 changes: 54 additions & 0 deletions src/utils/lm-studio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* LM Studio OpenAI-compatible API utilities.
*/

interface LmStudioModel {
id?: string;
}

interface LmStudioModelsResponse {
data?: LmStudioModel[];
}

/**
* 解析 LM Studio 的模型列表接口地址。
*/
function getLmStudioBaseUrl(): string {
return process.env.LM_STUDIO_BASE_URL || 'http://127.0.0.1:1234/v1';
}

/**
* 返回环境变量中配置的 LM Studio 默认模型。
*/
function getConfiguredLmStudioModel(): string | null {
const model = process.env.LM_STUDIO_MODEL?.trim();
return model || null;
}

/**
* 从 LM Studio 拉取可用模型;当服务不可达时回退到环境变量中的默认模型。
*/
export async function getLmStudioModels(): Promise<string[]> {
const configuredModel = getConfiguredLmStudioModel();

try {
const response = await fetch(`${getLmStudioBaseUrl()}/models`);

if (!response.ok) {
return configuredModel ? [configuredModel] : [];
}

const data = (await response.json()) as LmStudioModelsResponse;
const models = (data.data ?? [])
.map((model) => model.id?.trim())
.filter((model): model is string => Boolean(model));

if (models.length > 0) {
return models;
}

return configuredModel ? [configuredModel] : [];
} catch {
return configuredModel ? [configuredModel] : [];
}
}
35 changes: 33 additions & 2 deletions src/utils/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,33 @@ interface Provider {
models: Model[];
}

/**
* 为需要 provider 前缀的模型补齐内部模型 ID,确保底层路由可以识别。
*/
function qualifyModelId(providerId: string, modelId: string): string {
if (providerId === 'lmstudio' && !modelId.startsWith('lmstudio:')) {
return `lmstudio:${modelId}`;
}
if (providerId === 'ollama' && !modelId.startsWith('ollama:')) {
return `ollama:${modelId}`;
}
if (providerId === 'openrouter' && !modelId.startsWith('openrouter:')) {
return `openrouter:${modelId}`;
}
return modelId;
}

/**
* 读取 LM Studio 在环境变量中配置的默认模型,供本地 provider 直接复用。
*/
function getLmStudioConfiguredModel(): Model[] {
const modelId = process.env.LM_STUDIO_MODEL?.trim();
if (!modelId) {
return [];
}
return [{ id: modelId, displayName: modelId }];
}

const PROVIDER_MODELS: Record<string, Model[]> = {
openai: [
{ id: 'gpt-5.5', displayName: 'GPT 5.5' },
Expand Down Expand Up @@ -42,6 +69,9 @@ export const PROVIDERS: Provider[] = PROVIDER_DEFS.map((provider) => ({
}));

export function getModelsForProvider(providerId: string): Model[] {
if (providerId === 'lmstudio') {
return getLmStudioConfiguredModel();
}
const provider = PROVIDERS.find((entry) => entry.providerId === providerId);
return provider?.models ?? [];
}
Expand All @@ -52,11 +82,12 @@ export function getModelIdsForProvider(providerId: string): string[] {

export function getDefaultModelForProvider(providerId: string): string | undefined {
const models = getModelsForProvider(providerId);
return models[0]?.id;
const modelId = models[0]?.id;
return modelId ? qualifyModelId(providerId, modelId) : undefined;
}

export function getModelDisplayName(modelId: string): string {
const normalizedId = modelId.replace(/^(ollama|openrouter):/, '');
const normalizedId = modelId.replace(/^(ollama|openrouter|lmstudio):/, '');

for (const provider of PROVIDERS) {
const model = provider.models.find((entry) => entry.id === normalizedId || entry.id === modelId);
Expand Down
Loading