Skip to content
Merged
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: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ LINEAR_TEAM_ID=your-linear-team-id
### CLI Adapter (Provider)

```yaml
adapter: claude # "claude" | "codex" | "gpt" | "local"
adapter: claude # "claude" | "codex" | "gpt" | "local" | "lmstudio"
```

Switch at runtime via Discord: `!provider codex` / `!provider claude`
Expand All @@ -135,8 +135,9 @@ Switch at runtime via Discord: `!provider codex` / `!provider claude`
| `codex` | OpenAI Codex CLI | o3, o4-mini | CLI auth |
| `gpt` | OpenAI API | gpt-4o, o3, gpt-4.1 | OAuth PKCE |
| `local` | Ollama / LMStudio / llama.cpp | gemma4, llama3, mistral, qwen, etc. | None |
| `lmstudio` | LM Studio OpenAI-compatible API | loaded LM Studio model (`LMSTUDIO_MODEL`) | Optional API key |

Local models are auto-detected on standard ports (Ollama `:11434`, LMStudio `:1234`, llama.cpp `:8080`).
Local models are auto-detected on standard ports (Ollama `:11434`, LMStudio `:1234`, llama.cpp `:8080`). Use `lmstudio` for a dedicated LM Studio endpoint (`LMSTUDIO_BASE_URL`, default `http://localhost:1234`).

Per-role adapter overrides:

Expand Down
5 changes: 4 additions & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
# Copy this file to config.yaml to use

# Default CLI adapter for worker/reviewer stages
# Options: claude, codex, gpt, local
# Options: claude, codex, gpt, local, lmstudio
# For GPT: run `openswarm auth login --provider gpt` first
# For local: start Ollama, LMStudio, or llama.cpp server
# For lmstudio: start LM Studio Local Server (default http://localhost:1234)
# Optional env: LMSTUDIO_BASE_URL, LMSTUDIO_MODEL, LMSTUDIO_API_KEY
# If LMSTUDIO_MODEL is unset, the adapter auto-selects the first loaded model.
adapter: claude

discord:
Expand Down
3 changes: 3 additions & 0 deletions src/adapters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,22 @@ export { ClaudeCliAdapter } from './claude.js';
export { CodexCliAdapter } from './codex.js';
export { GptCliAdapter } from './gpt.js';
export { LocalModelAdapter } from './local.js';
export { LmStudioAdapter } from './lmstudio.js';
export { registerProcess, getProcess, getAllProcesses, killProcess, startHealthChecker, stopHealthChecker } from './processRegistry.js';

import { ClaudeCliAdapter } from './claude.js';
import { CodexCliAdapter } from './codex.js';
import { GptCliAdapter } from './gpt.js';
import { LocalModelAdapter } from './local.js';
import { LmStudioAdapter } from './lmstudio.js';
import type { AdapterName, CliAdapter } from './types.js';

const adapters: Record<string, CliAdapter> = {
claude: new ClaudeCliAdapter(),
codex: new CodexCliAdapter(),
gpt: new GptCliAdapter(),
local: new LocalModelAdapter(),
lmstudio: new LmStudioAdapter(),
};

let defaultAdapter: AdapterName = 'claude';
Expand Down
92 changes: 92 additions & 0 deletions src/adapters/lmstudio.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// ============================================
// OpenSwarm - LM Studio Adapter Tests
// Created: 2026-05-13
// Purpose: Verify dedicated LM Studio adapter wiring and OpenAI-compatible probing
// Dependencies: vitest
// Test Status: npm run test -- src/adapters/lmstudio.test.ts
// ============================================

import { afterEach, describe, expect, it, vi } from 'vitest';
import { LmStudioAdapter } from './lmstudio.js';
import { getAdapter } from './index.js';

describe('LmStudioAdapter', () => {
const originalEnv = { ...process.env };

afterEach(() => {
vi.restoreAllMocks();
process.env = { ...originalEnv };
});

it('registers as a named adapter', () => {
const adapter = getAdapter('lmstudio');

expect(adapter.name).toBe('lmstudio');
expect(adapter.capabilities.supportsModelSelection).toBe(true);
});

it('checks LM Studio default /v1/models endpoint', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ data: [] }), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);

const adapter = new LmStudioAdapter();
await expect(adapter.isAvailable()).resolves.toBe(true);

expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:1234/v1/models',
expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }),
);
expect(adapter.getActiveUrl()).toBe('http://localhost:1234');
});

it('uses LMSTUDIO_BASE_URL and optional bearer API key', async () => {
process.env.LMSTUDIO_BASE_URL = 'http://127.0.0.1:4321/';
process.env.LMSTUDIO_API_KEY = 'test-key';
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ data: [] }), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);

const adapter = new LmStudioAdapter();
await expect(adapter.isAvailable()).resolves.toBe(true);

expect(fetchMock).toHaveBeenCalledWith(
'http://127.0.0.1:4321/v1/models',
expect.objectContaining({
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer test-key',
},
}),
);
});

it('auto-selects the loaded LM Studio model when no override is provided', async () => {
delete process.env.LMSTUDIO_MODEL;
delete process.env.LMSTUDIO_API_KEY;
process.env.LMSTUDIO_BASE_URL = 'http://127.0.0.1:4321/';

const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ data: [{ id: 'gemma-4-26b-a4b-it-mlx' }] }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ data: [{ id: 'gemma-4-26b-a4b-it-mlx' }] }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'LM Studio is ready.' }, finish_reason: 'stop' }] }), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);

const adapter = new LmStudioAdapter();
const result = await adapter.run({
prompt: 'Say hello',
cwd: process.cwd(),
});

expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('LM Studio is ready.');
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'http://127.0.0.1:4321/v1/models',
expect.any(Object),
);

const chatCall = fetchMock.mock.calls.at(-1);
expect(chatCall?.[0]).toBe('http://127.0.0.1:4321/v1/chat/completions');
const body = JSON.parse(chatCall?.[1]?.body as string) as { model: string };
expect(body.model).toBe('gemma-4-26b-a4b-it-mlx');
});
});
48 changes: 48 additions & 0 deletions src/adapters/lmstudio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// ============================================
// OpenSwarm - LM Studio Adapter
// Created: 2026-05-13
// Purpose: Dedicated OpenAI-compatible adapter for LM Studio local server
// Dependencies: LocalModelAdapter
// Test Status: Covered by src/adapters/lmstudio.test.ts
// ============================================

import { LocalModelAdapter } from './local.js';
import type { CliRunOptions, CliRunResult } from './types.js';

const DEFAULT_LMSTUDIO_BASE_URL = 'http://localhost:1234';
const DEFAULT_LMSTUDIO_MODEL = 'local-model';

function normalizeBaseUrl(url: string): string {
return url.replace(/\/+$/, '');
}

export class LmStudioAdapter extends LocalModelAdapter {
constructor() {
super({
name: 'lmstudio',
endpoints: [normalizeBaseUrl(process.env.LMSTUDIO_BASE_URL ?? DEFAULT_LMSTUDIO_BASE_URL)],
defaultModel: normalizeValue(process.env.LMSTUDIO_MODEL) ?? DEFAULT_LMSTUDIO_MODEL,
apiKey: process.env.LMSTUDIO_API_KEY,
logPrefix: 'LMStudio',
noServerMessage: 'No LM Studio server found. Start LM Studio Local Server first, or set LMSTUDIO_BASE_URL.',
});
}

async run(options: CliRunOptions): Promise<CliRunResult> {
const model = await this.resolveModel(options.model);
return super.run({ ...options, model });
}

private async resolveModel(requestedModel?: string): Promise<string> {
const explicitModel = normalizeValue(requestedModel) ?? normalizeValue(process.env.LMSTUDIO_MODEL);
if (explicitModel) return explicitModel;

const loadedModels = await this.listModels();
return loadedModels[0] ?? DEFAULT_LMSTUDIO_MODEL;
}
}

function normalizeValue(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
51 changes: 42 additions & 9 deletions src/adapters/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,17 @@ const DEFAULT_ENDPOINTS = [
const DEFAULT_MODEL = 'gemma3:4b';
const HEALTH_CHECK_TIMEOUT_MS = 2000;

export interface LocalModelAdapterOptions {
name?: string;
endpoints?: string[];
defaultModel?: string;
apiKey?: string;
logPrefix?: string;
noServerMessage?: string;
}

export class LocalModelAdapter implements CliAdapter {
readonly name = 'local';
readonly name: string;

readonly capabilities: AdapterCapabilities = {
supportsStreaming: false,
Expand All @@ -40,6 +49,20 @@ export class LocalModelAdapter implements CliAdapter {
// 활성 서버 URL (isAvailable에서 감지, run에서 사용)
private activeUrl: string | null = null;
private configuredUrl: string | null = null;
private readonly endpoints: string[];
private readonly defaultModel: string;
private readonly apiKey?: string;
private readonly logPrefix: string;
private readonly noServerMessage: string;

constructor(options: LocalModelAdapterOptions = {}) {
this.name = options.name ?? 'local';
this.endpoints = options.endpoints ?? DEFAULT_ENDPOINTS;
this.defaultModel = options.defaultModel ?? DEFAULT_MODEL;
this.apiKey = options.apiKey;
this.logPrefix = options.logPrefix ?? 'Local';
this.noServerMessage = options.noServerMessage ?? 'No local model server found. Start Ollama, LMStudio, or llama.cpp server first.';
}

/** config.yaml에서 baseUrl을 주입받을 때 사용 */
setBaseUrl(url: string): void {
Expand All @@ -48,12 +71,13 @@ export class LocalModelAdapter implements CliAdapter {

async isAvailable(): Promise<boolean> {
const candidates = this.configuredUrl
? [this.configuredUrl, ...DEFAULT_ENDPOINTS]
: DEFAULT_ENDPOINTS;
? [this.configuredUrl, ...this.endpoints]
: this.endpoints;

for (const url of candidates) {
try {
const res = await fetch(`${url}/v1/models`, {
headers: this.buildHeaders(),
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
});
if (res.ok) {
Expand Down Expand Up @@ -81,6 +105,7 @@ export class LocalModelAdapter implements CliAdapter {

try {
const res = await fetch(`${this.activeUrl}/v1/models`, {
headers: this.buildHeaders(),
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
});
if (!res.ok) return [];
Expand All @@ -106,14 +131,14 @@ export class LocalModelAdapter implements CliAdapter {
return {
exitCode: 1,
stdout: '',
stderr: 'No local model server found. Start Ollama, LMStudio, or llama.cpp server first.\n' +
`Checked: ${(this.configuredUrl ? [this.configuredUrl, ...DEFAULT_ENDPOINTS] : DEFAULT_ENDPOINTS).join(', ')}`,
stderr: `${this.noServerMessage}\n` +
`Checked: ${(this.configuredUrl ? [this.configuredUrl, ...this.endpoints] : this.endpoints).join(', ')}`,
durationMs: Date.now() - startTime,
};
}
}

const model = options.model ?? DEFAULT_MODEL;
const model = options.model ?? this.defaultModel;
const baseUrl = this.activeUrl!;

// 도구 지원 여부 감지 (모델에 따라 다를 수 있음)
Expand All @@ -138,7 +163,7 @@ export class LocalModelAdapter implements CliAdapter {
const result = await runAgenticLoop(loopOptions);
if (options.onLog) {
const toolInfo = supportsTools ? `${result.toolCallCount} tool uses` : 'no tools';
options.onLog(`[Local] ${result.apiCallCount} API calls, ${toolInfo}, ${result.totalTokens} tokens`);
options.onLog(`[${this.logPrefix}] ${result.apiCallCount} API calls, ${toolInfo}, ${result.totalTokens} tokens`);
}
return loopResultToCliResult(result);
} catch (err) {
Expand Down Expand Up @@ -181,7 +206,7 @@ export class LocalModelAdapter implements CliAdapter {
try {
const res = await fetch(`${baseUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: this.buildHeaders(),
body: JSON.stringify({
model,
messages: [{ role: 'user', content: 'hi' }],
Expand Down Expand Up @@ -214,7 +239,7 @@ export class LocalModelAdapter implements CliAdapter {

const res = await fetch(`${baseUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: this.buildHeaders(),
body: JSON.stringify(body),
});

Expand All @@ -236,6 +261,14 @@ export class LocalModelAdapter implements CliAdapter {
};
}

private buildHeaders(): Record<string, string> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.apiKey) {
headers.Authorization = `Bearer ${this.apiKey}`;
}
return headers;
}

parseWorkerOutput(raw: CliRunResult): WorkerResult {
const text = raw.stdout;
return extractWorkerResultJson(text) ?? extractWorkerFromText(text);
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { WorkerResult, ReviewResult } from '../agents/agentPair.js';
// Re-export for convenience
export type { WorkerResult, ReviewResult };

export type AdapterName = 'claude' | 'codex' | 'gpt' | 'local';
export type AdapterName = 'claude' | 'codex' | 'gpt' | 'local' | 'lmstudio';

/**
* Raw result from a CLI process execution
Expand Down
2 changes: 1 addition & 1 deletion src/automation/runnerTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { ExecutorResult } from '../orchestration/workflow.js';
import type { DefaultRolesConfig, ProjectAgentConfig, JobProfile } from '../core/types.js';

export interface AutonomousConfig {
defaultAdapter?: 'claude' | 'codex' | 'gpt' | 'local';
defaultAdapter?: 'claude' | 'codex' | 'gpt' | 'local' | 'lmstudio';
linearTeamId: string;
allowedProjects: string[];
heartbeatSchedule: string;
Expand Down
6 changes: 4 additions & 2 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ function getConfigSearchPaths(): string[] {

const DEFAULT_HEARTBEAT_INTERVAL = 30 * 60 * 1000; // 30 minutes
const DEFAULT_GITHUB_CHECK_INTERVAL = 5 * 60 * 1000; // 5 minutes
const AdapterNameSchema = z.enum(['claude', 'codex', 'gpt']);
const AdapterNameSchema = z.enum(['claude', 'codex', 'gpt', 'local', 'lmstudio']);

// Zod Schemas

Expand Down Expand Up @@ -591,8 +591,10 @@ export function generateSampleConfig(): string {
# Environment variables use \${VAR_NAME} or \${VAR_NAME:-default} format

# Default CLI adapter for worker/reviewer stages
# Options: claude, codex, gpt
# Options: claude, codex, gpt, local, lmstudio
# For GPT: run \`openswarm auth login --provider gpt\` first
# For LM Studio: start Local Server and set LMSTUDIO_BASE_URL/LMSTUDIO_MODEL if needed
# If LMSTUDIO_MODEL is unset, the lmstudio adapter auto-selects the first loaded model.
adapter: claude

discord:
Expand Down
4 changes: 2 additions & 2 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export type SwarmEvent = {
*/
export type SwarmConfig = {
/** Default CLI adapter */
adapter?: 'claude' | 'codex' | 'gpt' | 'local';
adapter?: 'claude' | 'codex' | 'gpt' | 'local' | 'lmstudio';
/** UI language: 'en' | 'ko' (default: 'en') */
language: 'en' | 'ko';
/** Discord bot token */
Expand Down Expand Up @@ -256,7 +256,7 @@ export type RoleConfig = {
/** Whether role is enabled */
enabled: boolean;
/** CLI adapter name */
adapter?: 'claude' | 'codex' | 'gpt' | 'local';
adapter?: 'claude' | 'codex' | 'gpt' | 'local' | 'lmstudio';
/** Model ID */
model: string;
/** Timeout (ms), 0 = unlimited */
Expand Down
5 changes: 5 additions & 0 deletions src/support/chatBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ export const CHAT_MODEL_ALIASES: Record<AdapterName, Record<string, string>> = {
'phi': 'phi4:latest',
'starcoder': 'starcoder2:7b',
},
lmstudio: {
local: process.env.LMSTUDIO_MODEL ?? 'local-model',
lmstudio: process.env.LMSTUDIO_MODEL ?? 'local-model',
},
};

export function inferProviderFromModel(model?: string): AdapterName {
Expand All @@ -71,6 +75,7 @@ export function getDefaultChatModel(provider: AdapterName): string {
if (provider === 'codex') return 'gpt-5-codex';
if (provider === 'gpt') return 'gpt-4o';
if (provider === 'local') return 'gemma3:4b';
if (provider === 'lmstudio') return process.env.LMSTUDIO_MODEL ?? 'local-model';
return 'claude-sonnet-4-5-20250929';
}

Expand Down
Loading