Skip to content
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

Add LM Studio support #34

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,22 @@ Groq is an ultrafast model provider, and for now (end of May 2024) is fully free

Ollama is a tool for running models locally, and yet another solution to problems with latency and pricing. With ollama and a small model - such as phi-3 - you can freely lower the delay as much as you'd want, and the frequent requests will not cost you a fortune. To use ollama with this plugin, make sure the ollama service is running and switch the provider to ollama - that's it!

## LM Studio

LM Studio is a powerful desktop application that allows you to run local LLMs with a user-friendly interface. To use LM Studio with this plugin:

1. Download and install [LM Studio](https://lmstudio.ai/)
2. In LM Studio, go to the "Developer" tab
3. Start the local server (it runs on port 3245 by default)
4. In Obsidian's Companion settings, select "LM Studio" as your provider
5. The default endpoint (`http://localhost:3245`) should work if you haven't changed the port

LM Studio is particularly useful because:
- It provides a nice GUI for downloading and managing models
- It supports both CPU and GPU inference
- It works on Windows, macOS, and Linux
- It's completely free and runs everything locally

# Presets

Companion's "Presets" feature allows you to save your current settings as a preset, enable a "command" switch for that preset, and then use the preset as a global editor command in the command palette. This can be useful if you have certain settings that you use frequently and want to access them quickly and easily.
Expand Down
1 change: 1 addition & 0 deletions src/complete/completers.sass
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@import "completers/chatgpt/chatgpt.sass"
@import "completers/ai21/ai21.sass"
@import "completers/ollama/ollama.sass"
@import "completers/lmstudio/lmstudio.sass"
@import "completers/groq/groq.sass"
4 changes: 4 additions & 0 deletions src/complete/completers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { GooseAIComplete } from "./completers/gooseai/gooseai";
import { OobaboogaComplete } from "./completers/oobabooga/oobabooga";
import { OllamaComplete } from "./completers/ollama/ollama";
import { GroqComplete } from "./completers/groq/groq";
import { LMStudioComplete } from "./completers/lmstudio/lmstudio";

export const available: Completer[] = [
new ChatGPTComplete(),
Expand All @@ -15,4 +16,7 @@ export const available: Completer[] = [
new OobaboogaComplete(),
new OllamaComplete(),
new GroqComplete(),
new LMStudioComplete(),
];

console.log("Available completers:", available.map(c => c.id));
4 changes: 4 additions & 0 deletions src/complete/completers/lmstudio/lmstudio.sass
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.ai-complete-lmstudio-full-width
width: 100%
min-height: 120px
resize: none
191 changes: 191 additions & 0 deletions src/complete/completers/lmstudio/lmstudio.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { Completer, Model, Prompt } from "../../complete";
import {
SettingsUI as ProviderSettingsUI,
Settings as ProviderSettings,
parse_settings as parse_provider_settings,
} from "./provider_settings";
import {
SettingsUI as ModelSettingsUI,
parse_settings as parse_model_settings,
Settings as ModelSettings,
} from "./model_settings";
import OpenAI from "openai";
import { Notice } from "obsidian";
import Mustache from "mustache";

export default class LMStudioModel implements Model {
id: string;
name: string;
description: string;
rate_limit_notice: Notice | null = null;
rate_limit_notice_timeout: number | null = null;
Settings = ModelSettingsUI;

provider_settings: ProviderSettings;

constructor(
provider_settings: string,
id: string,
name: string,
description: string
) {
this.id = id;
this.name = name;
this.description = description;
this.provider_settings = parse_provider_settings(provider_settings);
}

get_api() {
return new OpenAI({
baseURL: this.provider_settings.endpoint + "/v1",
apiKey: "lm-studio", // LM Studio doesn't require a real API key
dangerouslyAllowBrowser: true,
});
}

async prepare(
prompt: Prompt,
settings: ModelSettings
): Promise<{
prefix: string;
suffix: string;
last_line: string;
context: string;
}> {
const cropped = {
prefix: prompt.prefix.slice(-(settings.prompt_length || 6000)),
suffix: prompt.suffix.slice(0, settings.prompt_length || 6000),
};
const last_line = cropped.prefix
.split("\n")
.filter((x) => x.length > 0)
.pop();
return {
...cropped,
last_line: last_line || "",
context: cropped.prefix
.split("\n")
.filter((x) => x !== last_line)
.join("\n"),
};
}

async complete(prompt: Prompt, settings: string): Promise<string> {
const model_settings = parse_model_settings(settings);

try {
const response = await this.get_api().chat.completions.create({
model: this.id,
messages: [
{
role: "system",
content: model_settings.system_prompt,
},
{
role: "user",
content: Mustache.render(
model_settings.user_prompt,
await this.prepare(prompt, model_settings)
),
},
],
temperature: model_settings.temperature,
max_tokens: model_settings.max_tokens,
});

return this.interpret(
prompt,
response.choices[0]?.message?.content || ""
);
} catch (e) {
throw new Error(`LM Studio API error: ${e.message}`);
}
}

async *iterate(prompt: Prompt, settings: string): AsyncGenerator<string> {
const model_settings = parse_model_settings(settings);

try {
const completion = await this.get_api().chat.completions.create({
model: this.id,
messages: [
{
role: "system",
content: model_settings.system_prompt,
},
{
role: "user",
content: Mustache.render(
model_settings.user_prompt,
await this.prepare(prompt, model_settings)
),
},
],
temperature: model_settings.temperature,
max_tokens: model_settings.max_tokens,
stream: true,
});

let initialized = false;
for await (const chunk of completion) {
const token = chunk.choices[0]?.delta?.content || "";
if (!initialized) {
yield this.interpret(prompt, token);
initialized = true;
} else {
yield token;
}
}
} catch (e) {
throw new Error(`LM Studio API error: ${e.message}`);
}
}

interpret(prompt: Prompt, completion: string) {
const response_punctuation = " \n.,?!:;";
const prompt_punctuation = " \n";

if (
prompt.prefix.length !== 0 &&
!prompt_punctuation.includes(
prompt.prefix[prompt.prefix.length - 1]
) &&
!response_punctuation.includes(completion[0])
) {
completion = " " + completion;
}

return completion;
}
}

export class LMStudioComplete implements Completer {
id: string = "lmstudio";
name: string = "LM Studio";
description: string = "Local LM Studio server for running local models";

async get_models(settings: string) {
const provider_settings = parse_provider_settings(settings);
const api = new OpenAI({
baseURL: provider_settings.endpoint + "/v1",
apiKey: "lm-studio",
dangerouslyAllowBrowser: true,
});

try {
const models = await api.models.list();
return models.data.map((model: any) => {
return new LMStudioModel(
settings,
model.id,
model.id,
`LM Studio model: ${model.id}`
);
});
} catch (e) {
throw new Error(`Failed to fetch LM Studio models: ${e.message}`);
}
}

Settings = ProviderSettingsUI;
}
126 changes: 126 additions & 0 deletions src/complete/completers/lmstudio/model_settings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import * as React from "react";
import SettingsItem from "../../../components/SettingsItem";
import { z } from "zod";

export const settings_schema = z.object({
system_prompt: z.string(),
user_prompt: z.string(),
temperature: z.number().optional(),
max_tokens: z.number().optional(),
prompt_length: z.number().optional(),
});

export type Settings = z.infer<typeof settings_schema>;

const default_settings: Settings = {
system_prompt: "",
user_prompt: '{{#context}}Context:\n\n{{context}}\n\n=================================\n{{/context}}Do not start with "...". Continue the following paragraph:\n\n{{last_line}}',
max_tokens: 100,
};

export const parse_settings = (data: string | null): Settings => {
if (data == null) {
return default_settings;
}
try {
const settings: unknown = JSON.parse(data);
return settings_schema.parse(settings);
} catch (e) {
return default_settings;
}
};

export function SettingsUI({
settings,
saveSettings,
}: {
settings: string | null;
saveSettings: (settings: string) => void;
}) {
const parsed_settings = parse_settings(settings);

return (
<>
<SettingsItem name="System prompt" />
<textarea
className="ai-complete-lmstudio-full-width"
value={parsed_settings.system_prompt}
onChange={(e) =>
saveSettings(
JSON.stringify({
...parsed_settings,
system_prompt: e.target.value,
})
)
}
/>
<SettingsItem name="User prompt" />
<textarea
className="ai-complete-lmstudio-full-width"
value={parsed_settings.user_prompt}
onChange={(e) =>
saveSettings(
JSON.stringify({
...parsed_settings,
user_prompt: e.target.value,
})
)
}
/>
<SettingsItem name="Temperature">
<input
type="number"
value={
parsed_settings.temperature === undefined
? ""
: parsed_settings.temperature
}
onChange={(e) =>
saveSettings(
JSON.stringify({
...parsed_settings,
temperature: parseFloat(e.target.value),
})
)
}
/>
</SettingsItem>
<SettingsItem name="Max tokens">
<input
type="number"
value={
parsed_settings.max_tokens === undefined
? ""
: parsed_settings.max_tokens
}
onChange={(e) =>
saveSettings(
JSON.stringify({
...parsed_settings,
max_tokens: parseInt(e.target.value),
})
)
}
/>
</SettingsItem>
<SettingsItem name="Prompt length">
<input
type="number"
value={
parsed_settings.prompt_length === undefined
? ""
: parsed_settings.prompt_length
}
onChange={(e) =>
saveSettings(
JSON.stringify({
...parsed_settings,
prompt_length: parseInt(e.target.value),
})
)
}
/>
</SettingsItem>
</>
);
}
Loading