diff --git a/package-lock.json b/package-lock.json index 386cc7e5..e7b52e40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/ai/README.md b/packages/ai/README.md index 42809c02..51abfd6a 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -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') } ); diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 88235bf8..434b42a9 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -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", diff --git a/packages/pods/scripts/pod_setup.sh b/packages/pods/scripts/pod_setup.sh index 9b322c62..dcd483c0 100755 --- a/packages/pods/scripts/pod_setup.sh +++ b/packages/pods/scripts/pod_setup.sh @@ -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)..." diff --git a/packages/web-ui/example/src/main.ts b/packages/web-ui/example/src/main.ts index 6ecbcd5a..d8acac59 100644 --- a/packages/web-ui/example/src/main.ts +++ b/packages/web-ui/example/src/main.ts @@ -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", })} diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index e65909eb..ed073865 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -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", diff --git a/packages/web-ui/src/app.css b/packages/web-ui/src/app.css index 05adb5cf..c8ddc30d 100644 --- a/packages/web-ui/src/app.css +++ b/packages/web-ui/src/app.css @@ -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%; } diff --git a/packages/web-ui/src/components/CustomProviderCard.ts b/packages/web-ui/src/components/CustomProviderCard.ts new file mode 100644 index 00000000..28d73e88 --- /dev/null +++ b/packages/web-ui/src/components/CustomProviderCard.ts @@ -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` +
+ ${i18n("Models")}: ${this.provider.models?.length || 0} +
+ `; + } + + if (!this.status) return html``; + + const statusIcon = + this.status.status === "connected" + ? html`` + : this.status.status === "checking" + ? html`` + : html``; + + const statusText = + this.status.status === "connected" + ? `${this.status.modelCount} ${i18n("models")}` + : this.status.status === "checking" + ? i18n("Checking...") + : i18n("Disconnected"); + + return html` +
+ ${statusIcon} ${statusText} +
+ `; + } + + render(): TemplateResult { + return html` +
+
+
+
${this.provider.name}
+
+ ${this.provider.type} + ${this.provider.baseUrl ? html` • ${this.provider.baseUrl}` : ""} +
+ ${this.renderStatus()} +
+
+ ${ + 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"), + }) + : "" + } +
+
+
+ `; + } +} diff --git a/packages/web-ui/src/components/Messages.ts b/packages/web-ui/src/components/Messages.ts index e8dabb38..46304327 100644 --- a/packages/web-ui/src/components/Messages.ts +++ b/packages/web-ui/src/components/Messages.ts @@ -63,7 +63,7 @@ export class UserMessage extends LitElement { : this.message.content.find((c) => c.type === "text")?.text || ""; return html` -
+
${ diff --git a/packages/web-ui/src/dialogs/CustomProviderDialog.ts b/packages/web-ui/src/dialogs/CustomProviderDialog.ts new file mode 100644 index 00000000..674eb858 --- /dev/null +++ b/packages/web-ui/src/dialogs/CustomProviderDialog.ts @@ -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[] = []; + + 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 = { + 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` +
+
+

+ ${this.provider ? i18n("Edit Provider") : i18n("Add Provider")} +

+
+ +
+
+
+ ${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(); + }, + })} +
+ +
+ ${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%", + })} +
+ +
+ ${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(); + }, + })} +
+ +
+ ${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(); + }, + })} +
+ + ${ + this.isAutoDiscoveryType() + ? html` +
+ ${Button({ + onClick: () => this.testConnection(), + variant: "outline", + disabled: this.testing || !this.baseUrl, + children: this.testing ? i18n("Testing...") : i18n("Test Connection"), + })} + ${this.testError ? html`
${this.testError}
` : ""} + ${ + this.discoveredModels.length > 0 + ? html` +
+ ${i18n("Discovered")} ${this.discoveredModels.length} ${i18n("models")}: +
    + ${this.discoveredModels.slice(0, 5).map((model) => html`
  • ${model.name}
  • `)} + ${ + this.discoveredModels.length > 5 + ? html`
  • ...${i18n("and")} ${this.discoveredModels.length - 5} ${i18n("more")}
  • ` + : "" + } +
+
+ ` + : "" + } +
+ ` + : html`
+ ${i18n("For manual provider types, add models after saving the provider.")} +
` + } +
+
+ +
+ ${Button({ + onClick: () => this.close(), + variant: "ghost", + children: i18n("Cancel"), + })} + ${Button({ + onClick: () => this.save(), + variant: "default", + disabled: !this.name || !this.baseUrl, + children: i18n("Save"), + })} +
+
+ `; + } +} + +customElements.define("custom-provider-dialog", CustomProviderDialog); diff --git a/packages/web-ui/src/dialogs/ModelSelector.ts b/packages/web-ui/src/dialogs/ModelSelector.ts index 8ed06c70..2a218eec 100644 --- a/packages/web-ui/src/dialogs/ModelSelector.ts +++ b/packages/web-ui/src/dialogs/ModelSelector.ts @@ -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[] = []; - @state() ollamaError: string | null = null; + @state() customProvidersLoading = false; @state() selectedIndex = 0; @state() private navigationMode: "mouse" | "keyboard" = "mouse"; + @state() private customProviderModels: Model[] = []; private onSelectCallback?: (model: Model) => void; private scrollContainerRef = createRef(); @@ -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 { @@ -91,67 +92,50 @@ export class ModelSelector extends DialogBase { }); } - private async fetchOllamaModels() { + private async loadCustomProviders() { + this.customProvidersLoading = true; + const allCustomModels: Model[] = []; + 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 | 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 = { - 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 => 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 {
${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`
= 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` +
+
+

Cloud Providers

+

+ Cloud LLM providers with predefined models. API keys are stored locally in your browser. +

+
+
+ ${providers.map((provider) => html` `)} +
+
+ `; + } + + private renderCustomProviders(): TemplateResult { + const isAutoDiscovery = (type: string) => + type === "ollama" || type === "llama.cpp" || type === "vllm" || type === "lmstudio"; + + return html` +
+
+
+

Custom Providers

+

+ User-configured servers with auto-discovered or manually defined models. +

+
+ ${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", + })} +
+ + ${ + this.customProviders.length === 0 + ? html` +
+ No custom providers configured. Click 'Add Provider' to get started. +
+ ` + : html` +
+ ${this.customProviders.map( + (provider) => html` + this.refreshProvider(p)} + .onEdit=${(p: CustomProvider) => this.editProvider(p)} + .onDelete=${(p: CustomProvider) => this.deleteProvider(p)} + > + `, + )} +
+ ` + } +
+ `; + } + + 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` +
+ ${this.renderKnownProviders()} +
+ ${this.renderCustomProviders()} +
+ `; + } +} diff --git a/packages/web-ui/src/index.ts b/packages/web-ui/src/index.ts index d8b2e19a..547cb959 100644 --- a/packages/web-ui/src/index.ts +++ b/packages/web-ui/src/index.ts @@ -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"; diff --git a/packages/web-ui/src/storage/app-storage.ts b/packages/web-ui/src/storage/app-storage.ts index 56e57f83..420104d1 100644 --- a/packages/web-ui/src/storage/app-storage.ts +++ b/packages/web-ui/src/storage/app-storage.ts @@ -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; } diff --git a/packages/web-ui/src/storage/stores/custom-providers-store.ts b/packages/web-ui/src/storage/stores/custom-providers-store.ts new file mode 100644 index 00000000..38dfdd59 --- /dev/null +++ b/packages/web-ui/src/storage/stores/custom-providers-store.ts @@ -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[]; +} + +/** + * 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 { + return this.getBackend().get("custom-providers", id); + } + + async set(provider: CustomProvider): Promise { + await this.getBackend().set("custom-providers", provider.id, provider); + } + + async delete(id: string): Promise { + await this.getBackend().delete("custom-providers", id); + } + + async getAll(): Promise { + 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 { + return this.getBackend().has("custom-providers", id); + } +} diff --git a/packages/web-ui/src/utils/i18n.ts b/packages/web-ui/src/utils/i18n.ts index 1bef3a3c..1f50a7d0 100644 --- a/packages/web-ui/src/utils/i18n.ts +++ b/packages/web-ui/src/utils/i18n.ts @@ -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", }, }; diff --git a/packages/web-ui/src/utils/model-discovery.ts b/packages/web-ui/src/utils/model-discovery.ts new file mode 100644 index 00000000..805b9d5b --- /dev/null +++ b/packages/web-ui/src/utils/model-discovery.ts @@ -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[]> { + 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 | 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 = { + 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 => 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[]> { + 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 = { + 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[]> { + 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 = { + 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[]> { + 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 = { + 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[]> { + 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); + } +}