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);
+ }
+}