Custom provider WIP

This commit is contained in:
Mario Zechner 2025-11-10 21:47:21 +01:00
parent 389c80d7a8
commit 1f9a3a00cc
17 changed files with 1185 additions and 107 deletions

42
package-lock.json generated
View file

@ -923,6 +923,28 @@
"@lit-labs/ssr-dom-shim": "^1.4.0"
}
},
"node_modules/@lmstudio/lms-isomorphic": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/@lmstudio/lms-isomorphic/-/lms-isomorphic-0.4.6.tgz",
"integrity": "sha512-v0LIjXKnDe3Ff3XZO5eQjlVxTjleUHXaom14MV7QU9bvwaoo3l5p71+xJ3mmSaqZq370CQ6pTKCn1Bb7Jf+VwQ==",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
}
},
"node_modules/@lmstudio/sdk": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@lmstudio/sdk/-/sdk-1.5.0.tgz",
"integrity": "sha512-fdY12x4hb14PEjYijh7YeCqT1ZDY5Ok6VR4l4+E/dI+F6NW8oB+P83Sxed5vqE4XgTzbgyPuSR2ZbMNxxF+6jA==",
"license": "Apache-2.0",
"dependencies": {
"@lmstudio/lms-isomorphic": "^0.4.6",
"chalk": "^4.1.2",
"jsonschema": "^1.5.0",
"zod": "^3.22.4",
"zod-to-json-schema": "^3.22.5"
}
},
"node_modules/@mariozechner/coding-agent": {
"resolved": "packages/coding-agent",
"link": true
@ -2510,7 +2532,6 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
@ -2527,7 +2548,6 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
@ -3106,7 +3126,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -3318,6 +3337,15 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/jsonschema": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/jsonschema/-/jsonschema-1.5.0.tgz",
"integrity": "sha512-K+A9hhqbn0f3pJX17Q/7H6yQfD/5OXgdrR5UE12gMXCiN9D5Xq2o5mddV2QEcX/bjla99ASsAAQUyMCCRWAEhw==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
@ -5139,7 +5167,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@ -5159,7 +5186,7 @@
"license": "MIT",
"dependencies": {
"@mariozechner/pi-ai": "^0.5.44",
"@mariozechner/pi-tui": "^0.5.47"
"@mariozechner/pi-tui": "^0.5.48"
},
"devDependencies": {
"@types/node": "^24.3.0",
@ -5371,7 +5398,7 @@
"version": "0.5.48",
"license": "MIT",
"dependencies": {
"@mariozechner/pi-agent": "^0.5.47",
"@mariozechner/pi-agent": "^0.5.48",
"chalk": "^5.5.0"
},
"bin": {
@ -5455,8 +5482,9 @@
"version": "0.5.48",
"license": "MIT",
"dependencies": {
"@lmstudio/sdk": "^1.5.0",
"@mariozechner/pi-ai": "^0.5.43",
"@mariozechner/pi-tui": "^0.5.47",
"@mariozechner/pi-tui": "^0.5.48",
"docx-preview": "^0.3.7",
"jszip": "^3.10.1",
"lit": "^3.3.1",

View file

@ -690,7 +690,7 @@ This continues until the assistant produces a response without tool calls.
Given a prompt asking to calculate two expressions and sum them:
```typescript
import { prompt, AgentContext, calculateTool } from '@mariozechner/pi-ai';
import { agentLoop, AgentContext, calculateTool } from '@mariozechner/pi-ai';
const context: AgentContext = {
systemPrompt: 'You are a helpful math assistant.',
@ -698,8 +698,8 @@ const context: AgentContext = {
tools: [calculateTool]
};
const stream = prompt(
{ role: 'user', content: 'Calculate 15 * 20 and 30 * 40, then sum the results' },
const stream = agentLoop(
{ role: 'user', content: 'Calculate 15 * 20 and 30 * 40, then sum the results', timestamp: Date.now() },
context,
{ model: getModel('openai', 'gpt-4o-mini') }
);

View file

@ -1356,6 +1356,23 @@ export const MODELS = {
contextWindow: 131000,
maxTokens: 32000,
} satisfies Model<"openai-completions">,
"zai-glm-4.6": {
id: "zai-glm-4.6",
name: "Z.AI GLM-4.6",
api: "openai-completions",
provider: "cerebras",
baseUrl: "https://api.cerebras.ai/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 40960,
} satisfies Model<"openai-completions">,
"qwen-3-coder-480b": {
id: "qwen-3-coder-480b",
name: "Qwen 3 Coder 480B",
@ -1821,6 +1838,23 @@ export const MODELS = {
} satisfies Model<"anthropic-messages">,
},
openrouter: {
"mistralai/voxtral-small-24b-2507": {
id: "mistralai/voxtral-small-24b-2507",
name: "Mistral: Voxtral Small 24B 2507",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.09999999999999999,
output: 0.3,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 32000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"openai/gpt-oss-safeguard-20b": {
id: "openai/gpt-oss-safeguard-20b",
name: "OpenAI: gpt-oss-safeguard-20b",
@ -1991,23 +2025,6 @@ export const MODELS = {
contextWindow: 400000,
maxTokens: 128000,
} satisfies Model<"openai-completions">,
"mistralai/voxtral-small-24b-2507": {
id: "mistralai/voxtral-small-24b-2507",
name: "Mistral: Voxtral Small 24B 2507",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.09999999999999999,
output: 0.3,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 32000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"inclusionai/ring-1t": {
id: "inclusionai/ring-1t",
name: "inclusionAI: Ring 1T",

View file

@ -216,7 +216,9 @@ esac
# --- Install additional packages ---------------------------------------------
echo "Installing additional packages..."
uv pip install huggingface-hub psutil tensorrt hf_transfer
# Note: tensorrt removed temporarily due to CUDA 13.0 compatibility issues
# TensorRT still depends on deprecated nvidia-cuda-runtime-cu13 package
uv pip install huggingface-hub psutil hf_transfer
# --- FlashInfer installation (optional, improves performance) ----------------
echo "Attempting FlashInfer installation (optional)..."

View file

@ -5,15 +5,16 @@ import {
Agent,
type AgentState,
ApiKeyPromptDialog,
ApiKeysTab,
type AppMessage,
AppStorage,
ChatPanel,
createJavaScriptReplTool,
CustomProvidersStore,
IndexedDBStorageBackend,
// PersistentStorageDialog, // TODO: Fix - currently broken
ProviderKeysStore,
ProviderTransport,
ProvidersModelsTab,
ProxyTab,
SessionListDialog,
SessionsStore,
@ -33,24 +34,32 @@ registerCustomMessageRenderers();
const settings = new SettingsStore();
const providerKeys = new ProviderKeysStore();
const sessions = new SessionsStore();
const customProviders = new CustomProvidersStore();
// Gather configs
const configs = [settings.getConfig(), SessionsStore.getMetadataConfig(), providerKeys.getConfig(), sessions.getConfig()];
const configs = [
settings.getConfig(),
SessionsStore.getMetadataConfig(),
providerKeys.getConfig(),
customProviders.getConfig(),
sessions.getConfig(),
];
// Create backend
const backend = new IndexedDBStorageBackend({
dbName: "pi-web-ui-example",
version: 1,
version: 2, // Incremented for custom-providers store
stores: configs,
});
// Wire backend to stores
settings.setBackend(backend);
providerKeys.setBackend(backend);
customProviders.setBackend(backend);
sessions.setBackend(backend);
// Create and set app storage
const storage = new AppStorage(settings, providerKeys, sessions, backend);
const storage = new AppStorage(settings, providerKeys, sessions, customProviders, backend);
setAppStorage(storage);
let currentSessionId: string | undefined;
@ -349,7 +358,7 @@ const renderApp = () => {
variant: "ghost",
size: "sm",
children: icon(Settings, "sm"),
onClick: () => SettingsDialog.open([new ApiKeysTab(), new ProxyTab()]),
onClick: () => SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]),
title: "Settings",
})}
</div>

View file

@ -18,6 +18,7 @@
"check": "npm run typecheck"
},
"dependencies": {
"@lmstudio/sdk": "^1.5.0",
"@mariozechner/pi-ai": "^0.5.43",
"@mariozechner/pi-tui": "^0.5.48",
"docx-preview": "^0.3.7",

View file

@ -64,4 +64,5 @@ body {
background: linear-gradient(135deg, rgba(217, 79, 0, 0.12), rgba(255, 107, 0, 0.12), rgba(212, 165, 0, 0.12));
border: 1px solid rgba(255, 107, 0, 0.25);
backdrop-filter: blur(10px);
max-width: 100%;
}

View file

@ -0,0 +1,99 @@
import { Button, html, i18n, type TemplateResult } from "@mariozechner/mini-lit";
import { LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { CustomProvider } from "../storage/stores/custom-providers-store.js";
@customElement("custom-provider-card")
export class CustomProviderCard extends LitElement {
@property({ type: Object }) provider!: CustomProvider;
@property({ type: Boolean }) isAutoDiscovery = false;
@property({ type: Object }) status?: { modelCount: number; status: "connected" | "disconnected" | "checking" };
@property() onRefresh?: (provider: CustomProvider) => void;
@property() onEdit?: (provider: CustomProvider) => void;
@property() onDelete?: (provider: CustomProvider) => void;
protected createRenderRoot() {
return this;
}
private renderStatus(): TemplateResult {
if (!this.isAutoDiscovery) {
return html`
<div class="text-xs text-muted-foreground mt-1">
${i18n("Models")}: ${this.provider.models?.length || 0}
</div>
`;
}
if (!this.status) return html``;
const statusIcon =
this.status.status === "connected"
? html`<span class="text-green-500">●</span>`
: this.status.status === "checking"
? html`<span class="text-yellow-500">●</span>`
: html`<span class="text-red-500">●</span>`;
const statusText =
this.status.status === "connected"
? `${this.status.modelCount} ${i18n("models")}`
: this.status.status === "checking"
? i18n("Checking...")
: i18n("Disconnected");
return html`
<div class="text-xs text-muted-foreground mt-1 flex items-center gap-1">
${statusIcon} ${statusText}
</div>
`;
}
render(): TemplateResult {
return html`
<div class="border border-border rounded-lg p-4 space-y-2">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="font-medium text-sm text-foreground">${this.provider.name}</div>
<div class="text-xs text-muted-foreground mt-1">
<span class="capitalize">${this.provider.type}</span>
${this.provider.baseUrl ? html`${this.provider.baseUrl}` : ""}
</div>
${this.renderStatus()}
</div>
<div class="flex gap-2">
${
this.isAutoDiscovery && this.onRefresh
? Button({
onClick: () => this.onRefresh?.(this.provider),
variant: "ghost",
size: "sm",
children: i18n("Refresh"),
})
: ""
}
${
this.onEdit
? Button({
onClick: () => this.onEdit?.(this.provider),
variant: "ghost",
size: "sm",
children: i18n("Edit"),
})
: ""
}
${
this.onDelete
? Button({
onClick: () => this.onDelete?.(this.provider),
variant: "ghost",
size: "sm",
children: i18n("Delete"),
})
: ""
}
</div>
</div>
</div>
`;
}
}

View file

@ -63,7 +63,7 @@ export class UserMessage extends LitElement {
: this.message.content.find((c) => c.type === "text")?.text || "";
return html`
<div class="flex justify-start ml-4">
<div class="flex justify-start mx-4">
<div class="user-message-container py-2 px-4 rounded-xl">
<markdown-block .content=${content}></markdown-block>
${

View file

@ -0,0 +1,268 @@
import { Button, DialogBase, html, Input, i18n, Label, Select, type TemplateResult } from "@mariozechner/mini-lit";
import type { Model } from "@mariozechner/pi-ai";
import { state } from "lit/decorators.js";
import { getAppStorage } from "../storage/app-storage.js";
import type { CustomProvider, CustomProviderType } from "../storage/stores/custom-providers-store.js";
import { discoverModels } from "../utils/model-discovery.js";
export class CustomProviderDialog extends DialogBase {
private provider?: CustomProvider;
private initialType?: CustomProviderType;
private onSaveCallback?: () => void;
@state() private name = "";
@state() private type: CustomProviderType = "openai-completions";
@state() private baseUrl = "";
@state() private apiKey = "";
@state() private testing = false;
@state() private testError = "";
@state() private discoveredModels: Model<any>[] = [];
protected modalWidth = "min(800px, 90vw)";
protected modalHeight = "min(700px, 90vh)";
static async open(
provider: CustomProvider | undefined,
initialType: CustomProviderType | undefined,
onSave?: () => void,
) {
const dialog = new CustomProviderDialog();
dialog.provider = provider;
dialog.initialType = initialType;
dialog.onSaveCallback = onSave;
document.body.appendChild(dialog);
dialog.initializeFromProvider();
dialog.open();
dialog.requestUpdate();
}
private initializeFromProvider() {
if (this.provider) {
this.name = this.provider.name;
this.type = this.provider.type;
this.baseUrl = this.provider.baseUrl;
this.apiKey = this.provider.apiKey || "";
this.discoveredModels = this.provider.models || [];
} else {
this.name = "";
this.type = this.initialType || "openai-completions";
this.baseUrl = "";
this.updateDefaultBaseUrl();
this.apiKey = "";
this.discoveredModels = [];
}
this.testError = "";
this.testing = false;
}
private updateDefaultBaseUrl() {
if (this.baseUrl) return;
const defaults: Record<string, string> = {
ollama: "http://localhost:11434",
"llama.cpp": "http://localhost:8080",
vllm: "http://localhost:8000",
lmstudio: "http://localhost:1234",
"openai-completions": "",
"openai-responses": "",
"anthropic-messages": "",
};
this.baseUrl = defaults[this.type] || "";
}
private isAutoDiscoveryType(): boolean {
return this.type === "ollama" || this.type === "llama.cpp" || this.type === "vllm" || this.type === "lmstudio";
}
private async testConnection() {
if (!this.isAutoDiscoveryType()) return;
this.testing = true;
this.testError = "";
this.discoveredModels = [];
try {
const models = await discoverModels(
this.type as "ollama" | "llama.cpp" | "vllm" | "lmstudio",
this.baseUrl,
this.apiKey || undefined,
);
this.discoveredModels = models.map((model) => ({
...model,
provider: this.name || this.type,
}));
this.testError = "";
} catch (error) {
this.testError = error instanceof Error ? error.message : String(error);
this.discoveredModels = [];
} finally {
this.testing = false;
this.requestUpdate();
}
}
private async save() {
if (!this.name || !this.baseUrl) {
alert(i18n("Please fill in all required fields"));
return;
}
try {
const storage = getAppStorage();
const provider: CustomProvider = {
id: this.provider?.id || crypto.randomUUID(),
name: this.name,
type: this.type,
baseUrl: this.baseUrl,
apiKey: this.apiKey || undefined,
models: this.isAutoDiscoveryType() ? undefined : this.provider?.models || [],
};
await storage.customProviders.set(provider);
if (this.onSaveCallback) {
this.onSaveCallback();
}
this.close();
} catch (error) {
console.error("Failed to save provider:", error);
alert(i18n("Failed to save provider"));
}
}
protected override renderContent(): TemplateResult {
const providerTypes = [
{ value: "ollama", label: "Ollama (auto-discovery)" },
{ value: "llama.cpp", label: "llama.cpp (auto-discovery)" },
{ value: "vllm", label: "vLLM (auto-discovery)" },
{ value: "lmstudio", label: "LM Studio (auto-discovery)" },
{ value: "openai-completions", label: "OpenAI Completions Compatible" },
{ value: "openai-responses", label: "OpenAI Responses Compatible" },
{ value: "anthropic-messages", label: "Anthropic Messages Compatible" },
];
return html`
<div class="flex flex-col h-full overflow-hidden">
<div class="p-6 flex-shrink-0 border-b border-border">
<h2 class="text-lg font-semibold text-foreground">
${this.provider ? i18n("Edit Provider") : i18n("Add Provider")}
</h2>
</div>
<div class="flex-1 overflow-y-auto p-6">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
${Label({ htmlFor: "provider-name", children: i18n("Provider Name") })}
${Input({
value: this.name,
placeholder: i18n("e.g., My Ollama Server"),
onInput: (e: Event) => {
this.name = (e.target as HTMLInputElement).value;
this.requestUpdate();
},
})}
</div>
<div class="flex flex-col gap-2">
${Label({ htmlFor: "provider-type", children: i18n("Provider Type") })}
${Select({
value: this.type,
options: providerTypes.map((pt) => ({
value: pt.value,
label: pt.label,
})),
onChange: (value: string) => {
this.type = value as CustomProviderType;
this.baseUrl = "";
this.updateDefaultBaseUrl();
this.requestUpdate();
},
width: "100%",
})}
</div>
<div class="flex flex-col gap-2">
${Label({ htmlFor: "base-url", children: i18n("Base URL") })}
${Input({
value: this.baseUrl,
placeholder: i18n("e.g., http://localhost:11434"),
onInput: (e: Event) => {
this.baseUrl = (e.target as HTMLInputElement).value;
this.requestUpdate();
},
})}
</div>
<div class="flex flex-col gap-2">
${Label({ htmlFor: "api-key", children: i18n("API Key (Optional)") })}
${Input({
type: "password",
value: this.apiKey,
placeholder: i18n("Leave empty if not required"),
onInput: (e: Event) => {
this.apiKey = (e.target as HTMLInputElement).value;
this.requestUpdate();
},
})}
</div>
${
this.isAutoDiscoveryType()
? html`
<div class="flex flex-col gap-2">
${Button({
onClick: () => this.testConnection(),
variant: "outline",
disabled: this.testing || !this.baseUrl,
children: this.testing ? i18n("Testing...") : i18n("Test Connection"),
})}
${this.testError ? html` <div class="text-sm text-destructive">${this.testError}</div> ` : ""}
${
this.discoveredModels.length > 0
? html`
<div class="text-sm text-muted-foreground">
${i18n("Discovered")} ${this.discoveredModels.length} ${i18n("models")}:
<ul class="list-disc list-inside mt-2">
${this.discoveredModels.slice(0, 5).map((model) => html`<li>${model.name}</li>`)}
${
this.discoveredModels.length > 5
? html`<li>...${i18n("and")} ${this.discoveredModels.length - 5} ${i18n("more")}</li>`
: ""
}
</ul>
</div>
`
: ""
}
</div>
`
: html` <div class="text-sm text-muted-foreground">
${i18n("For manual provider types, add models after saving the provider.")}
</div>`
}
</div>
</div>
<div class="p-6 flex-shrink-0 border-t border-border flex justify-end gap-2">
${Button({
onClick: () => this.close(),
variant: "ghost",
children: i18n("Cancel"),
})}
${Button({
onClick: () => this.save(),
variant: "default",
disabled: !this.name || !this.baseUrl,
children: i18n("Save"),
})}
</div>
</div>
`;
}
}
customElements.define("custom-provider-dialog", CustomProviderDialog);

View file

@ -1,14 +1,15 @@
import { Badge, Button, DialogBase, DialogHeader, html, icon, type TemplateResult } from "@mariozechner/mini-lit";
import type { Model } from "@mariozechner/pi-ai";
import { MODELS } from "@mariozechner/pi-ai/dist/models.generated.js";
import { getModels, getProviders, type Model } from "@mariozechner/pi-ai";
import type { PropertyValues } from "lit";
import { customElement, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
import { Brain, Image as ImageIcon } from "lucide";
import { Ollama } from "ollama/dist/browser.mjs";
import { Input } from "../components/Input.js";
import { getAppStorage } from "../storage/app-storage.js";
import type { AutoDiscoveryProviderType } from "../storage/stores/custom-providers-store.js";
import { formatModelCost } from "../utils/format.js";
import { i18n } from "../utils/i18n.js";
import { discoverModels } from "../utils/model-discovery.js";
@customElement("agent-model-selector")
export class ModelSelector extends DialogBase {
@ -16,10 +17,10 @@ export class ModelSelector extends DialogBase {
@state() searchQuery = "";
@state() filterThinking = false;
@state() filterVision = false;
@state() ollamaModels: Model<any>[] = [];
@state() ollamaError: string | null = null;
@state() customProvidersLoading = false;
@state() selectedIndex = 0;
@state() private navigationMode: "mouse" | "keyboard" = "mouse";
@state() private customProviderModels: Model<any>[] = [];
private onSelectCallback?: (model: Model<any>) => void;
private scrollContainerRef = createRef<HTMLDivElement>();
@ -33,7 +34,7 @@ export class ModelSelector extends DialogBase {
selector.currentModel = currentModel;
selector.onSelectCallback = onSelect;
selector.open();
selector.fetchOllamaModels();
selector.loadCustomProviders();
}
override async firstUpdated(changedProperties: PropertyValues): Promise<void> {
@ -91,67 +92,50 @@ export class ModelSelector extends DialogBase {
});
}
private async fetchOllamaModels() {
private async loadCustomProviders() {
this.customProvidersLoading = true;
const allCustomModels: Model<any>[] = [];
try {
// Create Ollama client
const ollama = new Ollama({ host: "http://localhost:11434" });
const storage = getAppStorage();
const customProviders = await storage.customProviders.getAll();
// Get list of available models
const { models } = await ollama.list();
// Load models from custom providers
for (const provider of customProviders) {
const isAutoDiscovery: boolean =
provider.type === "ollama" ||
provider.type === "llama.cpp" ||
provider.type === "vllm" ||
provider.type === "lmstudio";
// Fetch details for each model and convert to Model format
const ollamaModelPromises: Promise<Model<any> | null>[] = models
.map(async (model: any) => {
if (isAutoDiscovery) {
try {
// Get model details
const details = await ollama.show({
model: model.name,
});
const models = await discoverModels(
provider.type as AutoDiscoveryProviderType,
provider.baseUrl,
provider.apiKey,
);
// Some Ollama servers don't report capabilities; don't filter on them
const modelsWithProvider = models.map((model) => ({
...model,
provider: provider.name,
}));
// Extract model info
const modelInfo: any = details.model_info || {};
// Get context window size - look for architecture-specific keys
const architecture = modelInfo["general.architecture"] || "";
const contextKey = `${architecture}.context_length`;
const contextWindow = parseInt(modelInfo[contextKey] || "8192", 10);
const maxTokens = 4096; // Default max output tokens
// Create Model object manually since ollama models aren't in MODELS constant
const ollamaModel: Model<any> = {
id: model.name,
name: model.name,
api: "openai-completions" as any,
provider: "ollama",
baseUrl: "http://localhost:11434/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: contextWindow,
maxTokens: maxTokens,
};
return ollamaModel;
} catch (err) {
console.error(`Failed to fetch details for model ${model.name}:`, err);
return null;
allCustomModels.push(...modelsWithProvider);
} catch (error) {
console.debug(`Failed to load models from ${provider.name}:`, error);
}
})
.filter((m: any) => m !== null);
const results = await Promise.all(ollamaModelPromises);
this.ollamaModels = results.filter((m): m is Model<any> => m !== null);
} catch (err) {
// Ollama not available or other error - silently ignore
console.debug("Ollama not available:", err);
this.ollamaError = err instanceof Error ? err.message : String(err);
} else if (provider.models) {
// Manual provider - models already defined
allCustomModels.push(...provider.models);
}
}
} catch (error) {
console.error("Failed to load custom providers:", error);
} finally {
this.customProviderModels = allCustomModels;
this.customProvidersLoading = false;
this.requestUpdate();
}
}
@ -169,21 +153,20 @@ export class ModelSelector extends DialogBase {
}
private getFilteredModels(): Array<{ provider: string; id: string; model: any }> {
// Collect all models from all providers
// Collect all models from known providers
const allModels: Array<{ provider: string; id: string; model: any }> = [];
for (const [provider, providerData] of Object.entries(MODELS)) {
for (const [modelId, model] of Object.entries(providerData)) {
allModels.push({ provider, id: modelId, model });
const knownProviders = getProviders();
for (const provider of knownProviders) {
const models = getModels(provider as any);
for (const model of models) {
allModels.push({ provider, id: model.id, model });
}
}
// Add Ollama models
for (const ollamaModel of this.ollamaModels) {
allModels.push({
id: ollamaModel.id,
provider: "ollama",
model: ollamaModel,
});
// Add custom provider models
for (const model of this.customProviderModels) {
allModels.push({ provider: model.provider, id: model.id, model });
}
// Filter models based on search and capability filters
@ -283,8 +266,7 @@ export class ModelSelector extends DialogBase {
<!-- Scrollable model list -->
<div class="flex-1 overflow-y-auto" ${ref(this.scrollContainerRef)}>
${filteredModels.map(({ provider, id, model }, index) => {
// Check if this is the current model by comparing IDs
const isCurrent = this.currentModel?.id === model.id;
const isCurrent = this.currentModel?.id === model.id && this.currentModel?.provider === model.provider;
const isSelected = index === this.selectedIndex;
return html`
<div

View file

@ -0,0 +1,211 @@
import { Button, html, i18n, Select, type TemplateResult } from "@mariozechner/mini-lit";
import { getProviders } from "@mariozechner/pi-ai";
import { LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import "../components/CustomProviderCard.js";
import "../components/ProviderKeyInput.js";
import { getAppStorage } from "../storage/app-storage.js";
import type {
AutoDiscoveryProviderType,
CustomProvider,
CustomProviderType,
} from "../storage/stores/custom-providers-store.js";
import { discoverModels } from "../utils/model-discovery.js";
import { CustomProviderDialog } from "./CustomProviderDialog.js";
import { SettingsTab } from "./SettingsDialog.js";
@customElement("providers-models-tab")
export class ProvidersModelsTab extends SettingsTab {
@state() private customProviders: CustomProvider[] = [];
@state() private providerStatus: Map<
string,
{ modelCount: number; status: "connected" | "disconnected" | "checking" }
> = new Map();
override async connectedCallback() {
super.connectedCallback();
await this.loadCustomProviders();
}
private async loadCustomProviders() {
try {
const storage = getAppStorage();
this.customProviders = await storage.customProviders.getAll();
// Check status for auto-discovery providers
for (const provider of this.customProviders) {
const isAutoDiscovery =
provider.type === "ollama" ||
provider.type === "llama.cpp" ||
provider.type === "vllm" ||
provider.type === "lmstudio";
if (isAutoDiscovery) {
this.checkProviderStatus(provider);
}
}
} catch (error) {
console.error("Failed to load custom providers:", error);
}
}
getTabName(): string {
return "Providers & Models";
}
private async checkProviderStatus(provider: CustomProvider) {
this.providerStatus.set(provider.id, { modelCount: 0, status: "checking" });
this.requestUpdate();
try {
const models = await discoverModels(
provider.type as AutoDiscoveryProviderType,
provider.baseUrl,
provider.apiKey,
);
this.providerStatus.set(provider.id, { modelCount: models.length, status: "connected" });
} catch (error) {
this.providerStatus.set(provider.id, { modelCount: 0, status: "disconnected" });
}
this.requestUpdate();
}
private renderKnownProviders(): TemplateResult {
const providers = getProviders();
return html`
<div class="flex flex-col gap-6">
<div>
<h3 class="text-sm font-semibold text-foreground mb-2">Cloud Providers</h3>
<p class="text-sm text-muted-foreground mb-4">
Cloud LLM providers with predefined models. API keys are stored locally in your browser.
</p>
</div>
<div class="flex flex-col gap-6">
${providers.map((provider) => html` <provider-key-input .provider=${provider}></provider-key-input> `)}
</div>
</div>
`;
}
private renderCustomProviders(): TemplateResult {
const isAutoDiscovery = (type: string) =>
type === "ollama" || type === "llama.cpp" || type === "vllm" || type === "lmstudio";
return html`
<div class="flex flex-col gap-6">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-semibold text-foreground mb-2">Custom Providers</h3>
<p class="text-sm text-muted-foreground">
User-configured servers with auto-discovered or manually defined models.
</p>
</div>
${Select({
placeholder: i18n("Add Provider"),
options: [
{ value: "ollama", label: "Ollama" },
{ value: "llama.cpp", label: "llama.cpp" },
{ value: "vllm", label: "vLLM" },
{ value: "lmstudio", label: "LM Studio" },
{ value: "openai-completions", label: i18n("OpenAI Completions Compatible") },
{ value: "openai-responses", label: i18n("OpenAI Responses Compatible") },
{ value: "anthropic-messages", label: i18n("Anthropic Messages Compatible") },
],
onChange: (value: string) => this.addCustomProvider(value as CustomProviderType),
variant: "outline",
size: "sm",
})}
</div>
${
this.customProviders.length === 0
? html`
<div class="text-sm text-muted-foreground text-center py-8">
No custom providers configured. Click 'Add Provider' to get started.
</div>
`
: html`
<div class="flex flex-col gap-4">
${this.customProviders.map(
(provider) => html`
<custom-provider-card
.provider=${provider}
.isAutoDiscovery=${isAutoDiscovery(provider.type)}
.status=${this.providerStatus.get(provider.id)}
.onRefresh=${(p: CustomProvider) => this.refreshProvider(p)}
.onEdit=${(p: CustomProvider) => this.editProvider(p)}
.onDelete=${(p: CustomProvider) => this.deleteProvider(p)}
></custom-provider-card>
`,
)}
</div>
`
}
</div>
`;
}
private async addCustomProvider(type: CustomProviderType) {
await CustomProviderDialog.open(undefined, type, async () => {
await this.loadCustomProviders();
this.requestUpdate();
});
}
private async editProvider(provider: CustomProvider) {
await CustomProviderDialog.open(provider, undefined, async () => {
await this.loadCustomProviders();
this.requestUpdate();
});
}
private async refreshProvider(provider: CustomProvider) {
this.providerStatus.set(provider.id, { modelCount: 0, status: "checking" });
this.requestUpdate();
try {
const models = await discoverModels(
provider.type as AutoDiscoveryProviderType,
provider.baseUrl,
provider.apiKey,
);
this.providerStatus.set(provider.id, { modelCount: models.length, status: "connected" });
this.requestUpdate();
console.log(`Refreshed ${models.length} models from ${provider.name}`);
} catch (error) {
this.providerStatus.set(provider.id, { modelCount: 0, status: "disconnected" });
this.requestUpdate();
console.error(`Failed to refresh provider ${provider.name}:`, error);
alert(`Failed to refresh provider: ${error instanceof Error ? error.message : String(error)}`);
}
}
private async deleteProvider(provider: CustomProvider) {
if (!confirm("Are you sure you want to delete this provider?")) {
return;
}
try {
const storage = getAppStorage();
await storage.customProviders.delete(provider.id);
await this.loadCustomProviders();
this.requestUpdate();
} catch (error) {
console.error("Failed to delete provider:", error);
}
}
render(): TemplateResult {
return html`
<div class="flex flex-col gap-8">
${this.renderKnownProviders()}
<div class="border-t border-border"></div>
${this.renderCustomProviders()}
</div>
`;
}
}

View file

@ -52,6 +52,7 @@ export { AttachmentOverlay } from "./dialogs/AttachmentOverlay.js";
// Dialogs
export { ModelSelector } from "./dialogs/ModelSelector.js";
export { PersistentStorageDialog } from "./dialogs/PersistentStorageDialog.js";
export { ProvidersModelsTab } from "./dialogs/ProvidersModelsTab.js";
export { SessionListDialog } from "./dialogs/SessionListDialog.js";
export { ApiKeysTab, ProxyTab, SettingsDialog, SettingsTab } from "./dialogs/SettingsDialog.js";
// Prompts
@ -64,6 +65,12 @@ export {
export { AppStorage, getAppStorage, setAppStorage } from "./storage/app-storage.js";
export { IndexedDBStorageBackend } from "./storage/backends/indexeddb-storage-backend.js";
export { Store } from "./storage/store.js";
export type {
AutoDiscoveryProviderType,
CustomProvider,
CustomProviderType,
} from "./storage/stores/custom-providers-store.js";
export { CustomProvidersStore } from "./storage/stores/custom-providers-store.js";
export { ProviderKeysStore } from "./storage/stores/provider-keys-store.js";
export { SessionsStore } from "./storage/stores/sessions-store.js";
export { SettingsStore } from "./storage/stores/settings-store.js";

View file

@ -1,3 +1,4 @@
import type { CustomProvidersStore } from "./stores/custom-providers-store.js";
import type { ProviderKeysStore } from "./stores/provider-keys-store.js";
import type { SessionsStore } from "./stores/sessions-store.js";
import type { SettingsStore } from "./stores/settings-store.js";
@ -12,16 +13,19 @@ export class AppStorage {
readonly settings: SettingsStore;
readonly providerKeys: ProviderKeysStore;
readonly sessions: SessionsStore;
readonly customProviders: CustomProvidersStore;
constructor(
settings: SettingsStore,
providerKeys: ProviderKeysStore,
sessions: SessionsStore,
customProviders: CustomProvidersStore,
backend: StorageBackend,
) {
this.settings = settings;
this.providerKeys = providerKeys;
this.sessions = sessions;
this.customProviders = customProviders;
this.backend = backend;
}

View file

@ -0,0 +1,62 @@
import type { Model } from "@mariozechner/pi-ai";
import { Store } from "../store.js";
import type { StoreConfig } from "../types.js";
export type AutoDiscoveryProviderType = "ollama" | "llama.cpp" | "vllm" | "lmstudio";
export type CustomProviderType =
| AutoDiscoveryProviderType // Auto-discovery - models fetched on-demand
| "openai-completions" // Manual models - stored in provider.models
| "openai-responses" // Manual models - stored in provider.models
| "anthropic-messages"; // Manual models - stored in provider.models
export interface CustomProvider {
id: string; // UUID
name: string; // Display name, also used as Model.provider
type: CustomProviderType;
baseUrl: string;
apiKey?: string; // Optional, applies to all models
// For manual types ONLY - models stored directly on provider
// Auto-discovery types: models fetched on-demand, never stored
models?: Model<any>[];
}
/**
* Store for custom LLM providers (auto-discovery servers + manual providers).
*/
export class CustomProvidersStore extends Store {
getConfig(): StoreConfig {
return {
name: "custom-providers",
};
}
async get(id: string): Promise<CustomProvider | null> {
return this.getBackend().get("custom-providers", id);
}
async set(provider: CustomProvider): Promise<void> {
await this.getBackend().set("custom-providers", provider.id, provider);
}
async delete(id: string): Promise<void> {
await this.getBackend().delete("custom-providers", id);
}
async getAll(): Promise<CustomProvider[]> {
const keys = await this.getBackend().keys("custom-providers");
const providers: CustomProvider[] = [];
for (const key of keys) {
const provider = await this.get(key);
if (provider) {
providers.push(provider);
}
}
return providers;
}
async has(id: string): Promise<boolean> {
return this.getBackend().has("custom-providers", id);
}
}

View file

@ -170,6 +170,40 @@ declare module "@mariozechner/mini-lit" {
messages: string;
tokens: string;
"Drop files here": string;
// Providers & Models
"Providers & Models": string;
"Cloud Providers": string;
"Cloud LLM providers with predefined models. API keys are stored locally in your browser.": string;
"Custom Providers": string;
"User-configured servers with auto-discovered or manually defined models.": string;
"Add Provider": string;
"No custom providers configured. Click 'Add Provider' to get started.": string;
Models: string;
"auto-discovered": string;
Refresh: string;
Edit: string;
"Are you sure you want to delete this provider?": string;
"Edit Provider": string;
"Provider Name": string;
"e.g., My Ollama Server": string;
"Provider Type": string;
"Base URL": string;
"e.g., http://localhost:11434": string;
"API Key (Optional)": string;
"Leave empty if not required": string;
"Test Connection": string;
Discovered: string;
models: string;
and: string;
more: string;
"For manual provider types, add models after saving the provider.": string;
"Please fill in all required fields": string;
"Failed to save provider": string;
"OpenAI Completions Compatible": string;
"OpenAI Responses Compatible": string;
"Anthropic Messages Compatible": string;
"Checking...": string;
Disconnected: string;
}
}
@ -354,6 +388,44 @@ export const translations = {
Delete: "Delete",
"Drop files here": "Drop files here",
"Command failed:": "Command failed:",
// Providers & Models
"Providers & Models": "Providers & Models",
"Cloud Providers": "Cloud Providers",
"Cloud LLM providers with predefined models. API keys are stored locally in your browser.":
"Cloud LLM providers with predefined models. API keys are stored locally in your browser.",
"Custom Providers": "Custom Providers",
"User-configured servers with auto-discovered or manually defined models.":
"User-configured servers with auto-discovered or manually defined models.",
"Add Provider": "Add Provider",
"No custom providers configured. Click 'Add Provider' to get started.":
"No custom providers configured. Click 'Add Provider' to get started.",
"auto-discovered": "auto-discovered",
Refresh: "Refresh",
Edit: "Edit",
"Are you sure you want to delete this provider?": "Are you sure you want to delete this provider?",
"Edit Provider": "Edit Provider",
"Provider Name": "Provider Name",
"e.g., My Ollama Server": "e.g., My Ollama Server",
"Provider Type": "Provider Type",
"Base URL": "Base URL",
"e.g., http://localhost:11434": "e.g., http://localhost:11434",
"API Key (Optional)": "API Key (Optional)",
"Leave empty if not required": "Leave empty if not required",
"Test Connection": "Test Connection",
Discovered: "Discovered",
Models: "Models",
models: "models",
and: "and",
more: "more",
"For manual provider types, add models after saving the provider.":
"For manual provider types, add models after saving the provider.",
"Please fill in all required fields": "Please fill in all required fields",
"Failed to save provider": "Failed to save provider",
"OpenAI Completions Compatible": "OpenAI Completions Compatible",
"OpenAI Responses Compatible": "OpenAI Responses Compatible",
"Anthropic Messages Compatible": "Anthropic Messages Compatible",
"Checking...": "Checking...",
Disconnected: "Disconnected",
},
de: {
...defaultGerman,
@ -535,6 +607,44 @@ export const translations = {
Delete: "Löschen",
"Drop files here": "Dateien hier ablegen",
"Command failed:": "Befehl fehlgeschlagen:",
// Providers & Models
"Providers & Models": "Anbieter & Modelle",
"Cloud Providers": "Cloud-Anbieter",
"Cloud LLM providers with predefined models. API keys are stored locally in your browser.":
"Cloud-LLM-Anbieter mit vordefinierten Modellen. API-Schlüssel werden lokal in Ihrem Browser gespeichert.",
"Custom Providers": "Benutzerdefinierte Anbieter",
"User-configured servers with auto-discovered or manually defined models.":
"Benutzerkonfigurierte Server mit automatisch erkannten oder manuell definierten Modellen.",
"Add Provider": "Anbieter hinzufügen",
"No custom providers configured. Click 'Add Provider' to get started.":
"Keine benutzerdefinierten Anbieter konfiguriert. Klicken Sie auf 'Anbieter hinzufügen', um zu beginnen.",
"auto-discovered": "automatisch erkannt",
Refresh: "Aktualisieren",
Edit: "Bearbeiten",
"Are you sure you want to delete this provider?": "Sind Sie sicher, dass Sie diesen Anbieter löschen möchten?",
"Edit Provider": "Anbieter bearbeiten",
"Provider Name": "Anbietername",
"e.g., My Ollama Server": "z.B. Mein Ollama Server",
"Provider Type": "Anbietertyp",
"Base URL": "Basis-URL",
"e.g., http://localhost:11434": "z.B. http://localhost:11434",
"API Key (Optional)": "API-Schlüssel (Optional)",
"Leave empty if not required": "Leer lassen, falls nicht erforderlich",
"Test Connection": "Verbindung testen",
Discovered: "Erkannt",
Models: "Modelle",
models: "Modelle",
and: "und",
more: "mehr",
"For manual provider types, add models after saving the provider.":
"Für manuelle Anbietertypen fügen Sie Modelle nach dem Speichern des Anbieters hinzu.",
"Please fill in all required fields": "Bitte füllen Sie alle erforderlichen Felder aus",
"Failed to save provider": "Fehler beim Speichern des Anbieters",
"OpenAI Completions Compatible": "OpenAI Completions Kompatibel",
"OpenAI Responses Compatible": "OpenAI Responses Kompatibel",
"Anthropic Messages Compatible": "Anthropic Messages Kompatibel",
"Checking...": "Überprüfe...",
Disconnected: "Getrennt",
},
};

View file

@ -0,0 +1,277 @@
import { LMStudioClient } from "@lmstudio/sdk";
import type { Model } from "@mariozechner/pi-ai";
import { Ollama } from "ollama/browser";
/**
* Discover models from an Ollama server.
* @param baseUrl - Base URL of the Ollama server (e.g., "http://localhost:11434")
* @param apiKey - Optional API key (currently unused by Ollama)
* @returns Array of discovered models
*/
export async function discoverOllamaModels(baseUrl: string, apiKey?: string): Promise<Model<any>[]> {
try {
// Create Ollama client
const ollama = new Ollama({ host: baseUrl });
// Get list of available models
const { models } = await ollama.list();
// Fetch details for each model and convert to Model format
const ollamaModelPromises: Promise<Model<any> | null>[] = models.map(async (model: any) => {
try {
// Get model details
const details = await ollama.show({
model: model.name,
});
// Check capabilities - filter out models that don't support tools
const capabilities: string[] = (details as any).capabilities || [];
if (!capabilities.includes("tools")) {
console.debug(`Skipping model ${model.name}: does not support tools`);
return null;
}
// Extract model info
const modelInfo: any = details.model_info || {};
// Get context window size - look for architecture-specific keys
const architecture = modelInfo["general.architecture"] || "";
const contextKey = `${architecture}.context_length`;
const contextWindow = parseInt(modelInfo[contextKey] || "8192", 10);
// Ollama caps max tokens at 10x context length
const maxTokens = contextWindow * 10;
// Ollama only supports completions API
const ollamaModel: Model<any> = {
id: model.name,
name: model.name,
api: "openai-completions" as any,
provider: "", // Will be set by caller
baseUrl: `${baseUrl}/v1`,
reasoning: capabilities.includes("thinking"),
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: contextWindow,
maxTokens: maxTokens,
};
return ollamaModel;
} catch (err) {
console.error(`Failed to fetch details for model ${model.name}:`, err);
return null;
}
});
const results = await Promise.all(ollamaModelPromises);
return results.filter((m): m is Model<any> => m !== null);
} catch (err) {
console.error("Failed to discover Ollama models:", err);
throw new Error(`Ollama discovery failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
/**
* Discover models from a llama.cpp server via OpenAI-compatible /v1/models endpoint.
* @param baseUrl - Base URL of the llama.cpp server (e.g., "http://localhost:8080")
* @param apiKey - Optional API key
* @returns Array of discovered models
*/
export async function discoverLlamaCppModels(baseUrl: string, apiKey?: string): Promise<Model<any>[]> {
try {
const headers: HeadersInit = {
"Content-Type": "application/json",
};
if (apiKey) {
headers["Authorization"] = `Bearer ${apiKey}`;
}
const response = await fetch(`${baseUrl}/v1/models`, {
method: "GET",
headers,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (!data.data || !Array.isArray(data.data)) {
throw new Error("Invalid response format from llama.cpp server");
}
return data.data.map((model: any) => {
// llama.cpp doesn't always provide context window info
const contextWindow = model.context_length || 8192;
const maxTokens = model.max_tokens || 4096;
const llamaModel: Model<any> = {
id: model.id,
name: model.id,
api: "openai-completions" as any,
provider: "", // Will be set by caller
baseUrl: `${baseUrl}/v1`,
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: contextWindow,
maxTokens: maxTokens,
};
return llamaModel;
});
} catch (err) {
console.error("Failed to discover llama.cpp models:", err);
throw new Error(`llama.cpp discovery failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
/**
* Discover models from a vLLM server via OpenAI-compatible /v1/models endpoint.
* @param baseUrl - Base URL of the vLLM server (e.g., "http://localhost:8000")
* @param apiKey - Optional API key
* @returns Array of discovered models
*/
export async function discoverVLLMModels(baseUrl: string, apiKey?: string): Promise<Model<any>[]> {
try {
const headers: HeadersInit = {
"Content-Type": "application/json",
};
if (apiKey) {
headers["Authorization"] = `Bearer ${apiKey}`;
}
const response = await fetch(`${baseUrl}/v1/models`, {
method: "GET",
headers,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (!data.data || !Array.isArray(data.data)) {
throw new Error("Invalid response format from vLLM server");
}
return data.data.map((model: any) => {
// vLLM provides max_model_len which is the context window
const contextWindow = model.max_model_len || 8192;
const maxTokens = Math.min(contextWindow, 4096); // Cap max tokens
const vllmModel: Model<any> = {
id: model.id,
name: model.id,
api: "openai-completions" as any,
provider: "", // Will be set by caller
baseUrl: `${baseUrl}/v1`,
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: contextWindow,
maxTokens: maxTokens,
};
return vllmModel;
});
} catch (err) {
console.error("Failed to discover vLLM models:", err);
throw new Error(`vLLM discovery failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
/**
* Discover models from an LM Studio server using the LM Studio SDK.
* @param baseUrl - Base URL of the LM Studio server (e.g., "http://localhost:1234")
* @param apiKey - Optional API key (unused for LM Studio SDK)
* @returns Array of discovered models
*/
export async function discoverLMStudioModels(baseUrl: string, apiKey?: string): Promise<Model<any>[]> {
try {
// Extract host and port from baseUrl
const url = new URL(baseUrl);
const port = url.port ? parseInt(url.port) : 1234;
// Create LM Studio client
const client = new LMStudioClient({ baseUrl: `ws://${url.hostname}:${port}` });
// List all downloaded models
const models = await client.system.listDownloadedModels();
// Filter to only LLM models and map to our Model format
return models
.filter((model) => model.type === "llm")
.map((model) => {
const contextWindow = model.maxContextLength;
// Use 10x context length like Ollama does
const maxTokens = contextWindow;
const lmStudioModel: Model<any> = {
id: model.path,
name: model.displayName || model.path,
api: "openai-completions" as any,
provider: "", // Will be set by caller
baseUrl: `${baseUrl}/v1`,
reasoning: model.trainedForToolUse || false,
input: model.vision ? ["text", "image"] : ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: contextWindow,
maxTokens: maxTokens,
};
return lmStudioModel;
});
} catch (err) {
console.error("Failed to discover LM Studio models:", err);
throw new Error(`LM Studio discovery failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
/**
* Convenience function to discover models based on provider type.
* @param type - Provider type
* @param baseUrl - Base URL of the server
* @param apiKey - Optional API key
* @returns Array of discovered models
*/
export async function discoverModels(
type: "ollama" | "llama.cpp" | "vllm" | "lmstudio",
baseUrl: string,
apiKey?: string,
): Promise<Model<any>[]> {
switch (type) {
case "ollama":
return discoverOllamaModels(baseUrl, apiKey);
case "llama.cpp":
return discoverLlamaCppModels(baseUrl, apiKey);
case "vllm":
return discoverVLLMModels(baseUrl, apiKey);
case "lmstudio":
return discoverLMStudioModels(baseUrl, apiKey);
}
}