mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 08:03:39 +00:00
Custom provider WIP
This commit is contained in:
parent
389c80d7a8
commit
1f9a3a00cc
17 changed files with 1185 additions and 107 deletions
42
package-lock.json
generated
42
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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') }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)..."
|
||||
|
|
|
|||
|
|
@ -5,15 +5,16 @@ import {
|
|||
Agent,
|
||||
type AgentState,
|
||||
ApiKeyPromptDialog,
|
||||
ApiKeysTab,
|
||||
type AppMessage,
|
||||
AppStorage,
|
||||
ChatPanel,
|
||||
createJavaScriptReplTool,
|
||||
CustomProvidersStore,
|
||||
IndexedDBStorageBackend,
|
||||
// PersistentStorageDialog, // TODO: Fix - currently broken
|
||||
ProviderKeysStore,
|
||||
ProviderTransport,
|
||||
ProvidersModelsTab,
|
||||
ProxyTab,
|
||||
SessionListDialog,
|
||||
SessionsStore,
|
||||
|
|
@ -33,24 +34,32 @@ registerCustomMessageRenderers();
|
|||
const settings = new SettingsStore();
|
||||
const providerKeys = new ProviderKeysStore();
|
||||
const sessions = new SessionsStore();
|
||||
const customProviders = new CustomProvidersStore();
|
||||
|
||||
// Gather configs
|
||||
const configs = [settings.getConfig(), SessionsStore.getMetadataConfig(), providerKeys.getConfig(), sessions.getConfig()];
|
||||
const configs = [
|
||||
settings.getConfig(),
|
||||
SessionsStore.getMetadataConfig(),
|
||||
providerKeys.getConfig(),
|
||||
customProviders.getConfig(),
|
||||
sessions.getConfig(),
|
||||
];
|
||||
|
||||
// Create backend
|
||||
const backend = new IndexedDBStorageBackend({
|
||||
dbName: "pi-web-ui-example",
|
||||
version: 1,
|
||||
version: 2, // Incremented for custom-providers store
|
||||
stores: configs,
|
||||
});
|
||||
|
||||
// Wire backend to stores
|
||||
settings.setBackend(backend);
|
||||
providerKeys.setBackend(backend);
|
||||
customProviders.setBackend(backend);
|
||||
sessions.setBackend(backend);
|
||||
|
||||
// Create and set app storage
|
||||
const storage = new AppStorage(settings, providerKeys, sessions, backend);
|
||||
const storage = new AppStorage(settings, providerKeys, sessions, customProviders, backend);
|
||||
setAppStorage(storage);
|
||||
|
||||
let currentSessionId: string | undefined;
|
||||
|
|
@ -349,7 +358,7 @@ const renderApp = () => {
|
|||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: icon(Settings, "sm"),
|
||||
onClick: () => SettingsDialog.open([new ApiKeysTab(), new ProxyTab()]),
|
||||
onClick: () => SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]),
|
||||
title: "Settings",
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
|||
99
packages/web-ui/src/components/CustomProviderCard.ts
Normal file
99
packages/web-ui/src/components/CustomProviderCard.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { Button, html, i18n, type TemplateResult } from "@mariozechner/mini-lit";
|
||||
import { LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import type { CustomProvider } from "../storage/stores/custom-providers-store.js";
|
||||
|
||||
@customElement("custom-provider-card")
|
||||
export class CustomProviderCard extends LitElement {
|
||||
@property({ type: Object }) provider!: CustomProvider;
|
||||
@property({ type: Boolean }) isAutoDiscovery = false;
|
||||
@property({ type: Object }) status?: { modelCount: number; status: "connected" | "disconnected" | "checking" };
|
||||
@property() onRefresh?: (provider: CustomProvider) => void;
|
||||
@property() onEdit?: (provider: CustomProvider) => void;
|
||||
@property() onDelete?: (provider: CustomProvider) => void;
|
||||
|
||||
protected createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private renderStatus(): TemplateResult {
|
||||
if (!this.isAutoDiscovery) {
|
||||
return html`
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
${i18n("Models")}: ${this.provider.models?.length || 0}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (!this.status) return html``;
|
||||
|
||||
const statusIcon =
|
||||
this.status.status === "connected"
|
||||
? html`<span class="text-green-500">●</span>`
|
||||
: this.status.status === "checking"
|
||||
? html`<span class="text-yellow-500">●</span>`
|
||||
: html`<span class="text-red-500">●</span>`;
|
||||
|
||||
const statusText =
|
||||
this.status.status === "connected"
|
||||
? `${this.status.modelCount} ${i18n("models")}`
|
||||
: this.status.status === "checking"
|
||||
? i18n("Checking...")
|
||||
: i18n("Disconnected");
|
||||
|
||||
return html`
|
||||
<div class="text-xs text-muted-foreground mt-1 flex items-center gap-1">
|
||||
${statusIcon} ${statusText}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`
|
||||
<div class="border border-border rounded-lg p-4 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-sm text-foreground">${this.provider.name}</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
<span class="capitalize">${this.provider.type}</span>
|
||||
${this.provider.baseUrl ? html` • ${this.provider.baseUrl}` : ""}
|
||||
</div>
|
||||
${this.renderStatus()}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
${
|
||||
this.isAutoDiscovery && this.onRefresh
|
||||
? Button({
|
||||
onClick: () => this.onRefresh?.(this.provider),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: i18n("Refresh"),
|
||||
})
|
||||
: ""
|
||||
}
|
||||
${
|
||||
this.onEdit
|
||||
? Button({
|
||||
onClick: () => this.onEdit?.(this.provider),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: i18n("Edit"),
|
||||
})
|
||||
: ""
|
||||
}
|
||||
${
|
||||
this.onDelete
|
||||
? Button({
|
||||
onClick: () => this.onDelete?.(this.provider),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: i18n("Delete"),
|
||||
})
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -63,7 +63,7 @@ export class UserMessage extends LitElement {
|
|||
: this.message.content.find((c) => c.type === "text")?.text || "";
|
||||
|
||||
return html`
|
||||
<div class="flex justify-start ml-4">
|
||||
<div class="flex justify-start mx-4">
|
||||
<div class="user-message-container py-2 px-4 rounded-xl">
|
||||
<markdown-block .content=${content}></markdown-block>
|
||||
${
|
||||
|
|
|
|||
268
packages/web-ui/src/dialogs/CustomProviderDialog.ts
Normal file
268
packages/web-ui/src/dialogs/CustomProviderDialog.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import { Button, DialogBase, html, Input, i18n, Label, Select, type TemplateResult } from "@mariozechner/mini-lit";
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { state } from "lit/decorators.js";
|
||||
import { getAppStorage } from "../storage/app-storage.js";
|
||||
import type { CustomProvider, CustomProviderType } from "../storage/stores/custom-providers-store.js";
|
||||
import { discoverModels } from "../utils/model-discovery.js";
|
||||
|
||||
export class CustomProviderDialog extends DialogBase {
|
||||
private provider?: CustomProvider;
|
||||
private initialType?: CustomProviderType;
|
||||
private onSaveCallback?: () => void;
|
||||
|
||||
@state() private name = "";
|
||||
@state() private type: CustomProviderType = "openai-completions";
|
||||
@state() private baseUrl = "";
|
||||
@state() private apiKey = "";
|
||||
@state() private testing = false;
|
||||
@state() private testError = "";
|
||||
@state() private discoveredModels: Model<any>[] = [];
|
||||
|
||||
protected modalWidth = "min(800px, 90vw)";
|
||||
protected modalHeight = "min(700px, 90vh)";
|
||||
|
||||
static async open(
|
||||
provider: CustomProvider | undefined,
|
||||
initialType: CustomProviderType | undefined,
|
||||
onSave?: () => void,
|
||||
) {
|
||||
const dialog = new CustomProviderDialog();
|
||||
dialog.provider = provider;
|
||||
dialog.initialType = initialType;
|
||||
dialog.onSaveCallback = onSave;
|
||||
document.body.appendChild(dialog);
|
||||
dialog.initializeFromProvider();
|
||||
dialog.open();
|
||||
dialog.requestUpdate();
|
||||
}
|
||||
|
||||
private initializeFromProvider() {
|
||||
if (this.provider) {
|
||||
this.name = this.provider.name;
|
||||
this.type = this.provider.type;
|
||||
this.baseUrl = this.provider.baseUrl;
|
||||
this.apiKey = this.provider.apiKey || "";
|
||||
this.discoveredModels = this.provider.models || [];
|
||||
} else {
|
||||
this.name = "";
|
||||
this.type = this.initialType || "openai-completions";
|
||||
this.baseUrl = "";
|
||||
this.updateDefaultBaseUrl();
|
||||
this.apiKey = "";
|
||||
this.discoveredModels = [];
|
||||
}
|
||||
this.testError = "";
|
||||
this.testing = false;
|
||||
}
|
||||
|
||||
private updateDefaultBaseUrl() {
|
||||
if (this.baseUrl) return;
|
||||
|
||||
const defaults: Record<string, string> = {
|
||||
ollama: "http://localhost:11434",
|
||||
"llama.cpp": "http://localhost:8080",
|
||||
vllm: "http://localhost:8000",
|
||||
lmstudio: "http://localhost:1234",
|
||||
"openai-completions": "",
|
||||
"openai-responses": "",
|
||||
"anthropic-messages": "",
|
||||
};
|
||||
|
||||
this.baseUrl = defaults[this.type] || "";
|
||||
}
|
||||
|
||||
private isAutoDiscoveryType(): boolean {
|
||||
return this.type === "ollama" || this.type === "llama.cpp" || this.type === "vllm" || this.type === "lmstudio";
|
||||
}
|
||||
|
||||
private async testConnection() {
|
||||
if (!this.isAutoDiscoveryType()) return;
|
||||
|
||||
this.testing = true;
|
||||
this.testError = "";
|
||||
this.discoveredModels = [];
|
||||
|
||||
try {
|
||||
const models = await discoverModels(
|
||||
this.type as "ollama" | "llama.cpp" | "vllm" | "lmstudio",
|
||||
this.baseUrl,
|
||||
this.apiKey || undefined,
|
||||
);
|
||||
|
||||
this.discoveredModels = models.map((model) => ({
|
||||
...model,
|
||||
provider: this.name || this.type,
|
||||
}));
|
||||
|
||||
this.testError = "";
|
||||
} catch (error) {
|
||||
this.testError = error instanceof Error ? error.message : String(error);
|
||||
this.discoveredModels = [];
|
||||
} finally {
|
||||
this.testing = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private async save() {
|
||||
if (!this.name || !this.baseUrl) {
|
||||
alert(i18n("Please fill in all required fields"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const storage = getAppStorage();
|
||||
|
||||
const provider: CustomProvider = {
|
||||
id: this.provider?.id || crypto.randomUUID(),
|
||||
name: this.name,
|
||||
type: this.type,
|
||||
baseUrl: this.baseUrl,
|
||||
apiKey: this.apiKey || undefined,
|
||||
models: this.isAutoDiscoveryType() ? undefined : this.provider?.models || [],
|
||||
};
|
||||
|
||||
await storage.customProviders.set(provider);
|
||||
|
||||
if (this.onSaveCallback) {
|
||||
this.onSaveCallback();
|
||||
}
|
||||
this.close();
|
||||
} catch (error) {
|
||||
console.error("Failed to save provider:", error);
|
||||
alert(i18n("Failed to save provider"));
|
||||
}
|
||||
}
|
||||
|
||||
protected override renderContent(): TemplateResult {
|
||||
const providerTypes = [
|
||||
{ value: "ollama", label: "Ollama (auto-discovery)" },
|
||||
{ value: "llama.cpp", label: "llama.cpp (auto-discovery)" },
|
||||
{ value: "vllm", label: "vLLM (auto-discovery)" },
|
||||
{ value: "lmstudio", label: "LM Studio (auto-discovery)" },
|
||||
{ value: "openai-completions", label: "OpenAI Completions Compatible" },
|
||||
{ value: "openai-responses", label: "OpenAI Responses Compatible" },
|
||||
{ value: "anthropic-messages", label: "Anthropic Messages Compatible" },
|
||||
];
|
||||
|
||||
return html`
|
||||
<div class="flex flex-col h-full overflow-hidden">
|
||||
<div class="p-6 flex-shrink-0 border-b border-border">
|
||||
<h2 class="text-lg font-semibold text-foreground">
|
||||
${this.provider ? i18n("Edit Provider") : i18n("Add Provider")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
${Label({ htmlFor: "provider-name", children: i18n("Provider Name") })}
|
||||
${Input({
|
||||
value: this.name,
|
||||
placeholder: i18n("e.g., My Ollama Server"),
|
||||
onInput: (e: Event) => {
|
||||
this.name = (e.target as HTMLInputElement).value;
|
||||
this.requestUpdate();
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
${Label({ htmlFor: "provider-type", children: i18n("Provider Type") })}
|
||||
${Select({
|
||||
value: this.type,
|
||||
options: providerTypes.map((pt) => ({
|
||||
value: pt.value,
|
||||
label: pt.label,
|
||||
})),
|
||||
onChange: (value: string) => {
|
||||
this.type = value as CustomProviderType;
|
||||
this.baseUrl = "";
|
||||
this.updateDefaultBaseUrl();
|
||||
this.requestUpdate();
|
||||
},
|
||||
width: "100%",
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
${Label({ htmlFor: "base-url", children: i18n("Base URL") })}
|
||||
${Input({
|
||||
value: this.baseUrl,
|
||||
placeholder: i18n("e.g., http://localhost:11434"),
|
||||
onInput: (e: Event) => {
|
||||
this.baseUrl = (e.target as HTMLInputElement).value;
|
||||
this.requestUpdate();
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
${Label({ htmlFor: "api-key", children: i18n("API Key (Optional)") })}
|
||||
${Input({
|
||||
type: "password",
|
||||
value: this.apiKey,
|
||||
placeholder: i18n("Leave empty if not required"),
|
||||
onInput: (e: Event) => {
|
||||
this.apiKey = (e.target as HTMLInputElement).value;
|
||||
this.requestUpdate();
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
|
||||
${
|
||||
this.isAutoDiscoveryType()
|
||||
? html`
|
||||
<div class="flex flex-col gap-2">
|
||||
${Button({
|
||||
onClick: () => this.testConnection(),
|
||||
variant: "outline",
|
||||
disabled: this.testing || !this.baseUrl,
|
||||
children: this.testing ? i18n("Testing...") : i18n("Test Connection"),
|
||||
})}
|
||||
${this.testError ? html` <div class="text-sm text-destructive">${this.testError}</div> ` : ""}
|
||||
${
|
||||
this.discoveredModels.length > 0
|
||||
? html`
|
||||
<div class="text-sm text-muted-foreground">
|
||||
${i18n("Discovered")} ${this.discoveredModels.length} ${i18n("models")}:
|
||||
<ul class="list-disc list-inside mt-2">
|
||||
${this.discoveredModels.slice(0, 5).map((model) => html`<li>${model.name}</li>`)}
|
||||
${
|
||||
this.discoveredModels.length > 5
|
||||
? html`<li>...${i18n("and")} ${this.discoveredModels.length - 5} ${i18n("more")}</li>`
|
||||
: ""
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
`
|
||||
: html` <div class="text-sm text-muted-foreground">
|
||||
${i18n("For manual provider types, add models after saving the provider.")}
|
||||
</div>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 flex-shrink-0 border-t border-border flex justify-end gap-2">
|
||||
${Button({
|
||||
onClick: () => this.close(),
|
||||
variant: "ghost",
|
||||
children: i18n("Cancel"),
|
||||
})}
|
||||
${Button({
|
||||
onClick: () => this.save(),
|
||||
variant: "default",
|
||||
disabled: !this.name || !this.baseUrl,
|
||||
children: i18n("Save"),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("custom-provider-dialog", CustomProviderDialog);
|
||||
|
|
@ -1,14 +1,15 @@
|
|||
import { Badge, Button, DialogBase, DialogHeader, html, icon, type TemplateResult } from "@mariozechner/mini-lit";
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { MODELS } from "@mariozechner/pi-ai/dist/models.generated.js";
|
||||
import { getModels, getProviders, type Model } from "@mariozechner/pi-ai";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
import { Brain, Image as ImageIcon } from "lucide";
|
||||
import { Ollama } from "ollama/dist/browser.mjs";
|
||||
import { Input } from "../components/Input.js";
|
||||
import { getAppStorage } from "../storage/app-storage.js";
|
||||
import type { AutoDiscoveryProviderType } from "../storage/stores/custom-providers-store.js";
|
||||
import { formatModelCost } from "../utils/format.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
import { discoverModels } from "../utils/model-discovery.js";
|
||||
|
||||
@customElement("agent-model-selector")
|
||||
export class ModelSelector extends DialogBase {
|
||||
|
|
@ -16,10 +17,10 @@ export class ModelSelector extends DialogBase {
|
|||
@state() searchQuery = "";
|
||||
@state() filterThinking = false;
|
||||
@state() filterVision = false;
|
||||
@state() ollamaModels: Model<any>[] = [];
|
||||
@state() ollamaError: string | null = null;
|
||||
@state() customProvidersLoading = false;
|
||||
@state() selectedIndex = 0;
|
||||
@state() private navigationMode: "mouse" | "keyboard" = "mouse";
|
||||
@state() private customProviderModels: Model<any>[] = [];
|
||||
|
||||
private onSelectCallback?: (model: Model<any>) => void;
|
||||
private scrollContainerRef = createRef<HTMLDivElement>();
|
||||
|
|
@ -33,7 +34,7 @@ export class ModelSelector extends DialogBase {
|
|||
selector.currentModel = currentModel;
|
||||
selector.onSelectCallback = onSelect;
|
||||
selector.open();
|
||||
selector.fetchOllamaModels();
|
||||
selector.loadCustomProviders();
|
||||
}
|
||||
|
||||
override async firstUpdated(changedProperties: PropertyValues): Promise<void> {
|
||||
|
|
@ -91,67 +92,50 @@ export class ModelSelector extends DialogBase {
|
|||
});
|
||||
}
|
||||
|
||||
private async fetchOllamaModels() {
|
||||
private async loadCustomProviders() {
|
||||
this.customProvidersLoading = true;
|
||||
const allCustomModels: Model<any>[] = [];
|
||||
|
||||
try {
|
||||
// Create Ollama client
|
||||
const ollama = new Ollama({ host: "http://localhost:11434" });
|
||||
const storage = getAppStorage();
|
||||
const customProviders = await storage.customProviders.getAll();
|
||||
|
||||
// Get list of available models
|
||||
const { models } = await ollama.list();
|
||||
// Load models from custom providers
|
||||
for (const provider of customProviders) {
|
||||
const isAutoDiscovery: boolean =
|
||||
provider.type === "ollama" ||
|
||||
provider.type === "llama.cpp" ||
|
||||
provider.type === "vllm" ||
|
||||
provider.type === "lmstudio";
|
||||
|
||||
// Fetch details for each model and convert to Model format
|
||||
const ollamaModelPromises: Promise<Model<any> | null>[] = models
|
||||
.map(async (model: any) => {
|
||||
if (isAutoDiscovery) {
|
||||
try {
|
||||
// Get model details
|
||||
const details = await ollama.show({
|
||||
model: model.name,
|
||||
});
|
||||
const models = await discoverModels(
|
||||
provider.type as AutoDiscoveryProviderType,
|
||||
provider.baseUrl,
|
||||
provider.apiKey,
|
||||
);
|
||||
|
||||
// Some Ollama servers don't report capabilities; don't filter on them
|
||||
const modelsWithProvider = models.map((model) => ({
|
||||
...model,
|
||||
provider: provider.name,
|
||||
}));
|
||||
|
||||
// Extract model info
|
||||
const modelInfo: any = details.model_info || {};
|
||||
|
||||
// Get context window size - look for architecture-specific keys
|
||||
const architecture = modelInfo["general.architecture"] || "";
|
||||
const contextKey = `${architecture}.context_length`;
|
||||
const contextWindow = parseInt(modelInfo[contextKey] || "8192", 10);
|
||||
const maxTokens = 4096; // Default max output tokens
|
||||
|
||||
// Create Model object manually since ollama models aren't in MODELS constant
|
||||
const ollamaModel: Model<any> = {
|
||||
id: model.name,
|
||||
name: model.name,
|
||||
api: "openai-completions" as any,
|
||||
provider: "ollama",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: contextWindow,
|
||||
maxTokens: maxTokens,
|
||||
};
|
||||
|
||||
return ollamaModel;
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch details for model ${model.name}:`, err);
|
||||
return null;
|
||||
allCustomModels.push(...modelsWithProvider);
|
||||
} catch (error) {
|
||||
console.debug(`Failed to load models from ${provider.name}:`, error);
|
||||
}
|
||||
})
|
||||
.filter((m: any) => m !== null);
|
||||
|
||||
const results = await Promise.all(ollamaModelPromises);
|
||||
this.ollamaModels = results.filter((m): m is Model<any> => m !== null);
|
||||
} catch (err) {
|
||||
// Ollama not available or other error - silently ignore
|
||||
console.debug("Ollama not available:", err);
|
||||
this.ollamaError = err instanceof Error ? err.message : String(err);
|
||||
} else if (provider.models) {
|
||||
// Manual provider - models already defined
|
||||
allCustomModels.push(...provider.models);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load custom providers:", error);
|
||||
} finally {
|
||||
this.customProviderModels = allCustomModels;
|
||||
this.customProvidersLoading = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -169,21 +153,20 @@ export class ModelSelector extends DialogBase {
|
|||
}
|
||||
|
||||
private getFilteredModels(): Array<{ provider: string; id: string; model: any }> {
|
||||
// Collect all models from all providers
|
||||
// Collect all models from known providers
|
||||
const allModels: Array<{ provider: string; id: string; model: any }> = [];
|
||||
for (const [provider, providerData] of Object.entries(MODELS)) {
|
||||
for (const [modelId, model] of Object.entries(providerData)) {
|
||||
allModels.push({ provider, id: modelId, model });
|
||||
const knownProviders = getProviders();
|
||||
|
||||
for (const provider of knownProviders) {
|
||||
const models = getModels(provider as any);
|
||||
for (const model of models) {
|
||||
allModels.push({ provider, id: model.id, model });
|
||||
}
|
||||
}
|
||||
|
||||
// Add Ollama models
|
||||
for (const ollamaModel of this.ollamaModels) {
|
||||
allModels.push({
|
||||
id: ollamaModel.id,
|
||||
provider: "ollama",
|
||||
model: ollamaModel,
|
||||
});
|
||||
// Add custom provider models
|
||||
for (const model of this.customProviderModels) {
|
||||
allModels.push({ provider: model.provider, id: model.id, model });
|
||||
}
|
||||
|
||||
// Filter models based on search and capability filters
|
||||
|
|
@ -283,8 +266,7 @@ export class ModelSelector extends DialogBase {
|
|||
<!-- Scrollable model list -->
|
||||
<div class="flex-1 overflow-y-auto" ${ref(this.scrollContainerRef)}>
|
||||
${filteredModels.map(({ provider, id, model }, index) => {
|
||||
// Check if this is the current model by comparing IDs
|
||||
const isCurrent = this.currentModel?.id === model.id;
|
||||
const isCurrent = this.currentModel?.id === model.id && this.currentModel?.provider === model.provider;
|
||||
const isSelected = index === this.selectedIndex;
|
||||
return html`
|
||||
<div
|
||||
|
|
|
|||
211
packages/web-ui/src/dialogs/ProvidersModelsTab.ts
Normal file
211
packages/web-ui/src/dialogs/ProvidersModelsTab.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { Button, html, i18n, Select, type TemplateResult } from "@mariozechner/mini-lit";
|
||||
import { getProviders } from "@mariozechner/pi-ai";
|
||||
import { LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import "../components/CustomProviderCard.js";
|
||||
import "../components/ProviderKeyInput.js";
|
||||
import { getAppStorage } from "../storage/app-storage.js";
|
||||
import type {
|
||||
AutoDiscoveryProviderType,
|
||||
CustomProvider,
|
||||
CustomProviderType,
|
||||
} from "../storage/stores/custom-providers-store.js";
|
||||
import { discoverModels } from "../utils/model-discovery.js";
|
||||
import { CustomProviderDialog } from "./CustomProviderDialog.js";
|
||||
import { SettingsTab } from "./SettingsDialog.js";
|
||||
|
||||
@customElement("providers-models-tab")
|
||||
export class ProvidersModelsTab extends SettingsTab {
|
||||
@state() private customProviders: CustomProvider[] = [];
|
||||
@state() private providerStatus: Map<
|
||||
string,
|
||||
{ modelCount: number; status: "connected" | "disconnected" | "checking" }
|
||||
> = new Map();
|
||||
|
||||
override async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await this.loadCustomProviders();
|
||||
}
|
||||
|
||||
private async loadCustomProviders() {
|
||||
try {
|
||||
const storage = getAppStorage();
|
||||
this.customProviders = await storage.customProviders.getAll();
|
||||
|
||||
// Check status for auto-discovery providers
|
||||
for (const provider of this.customProviders) {
|
||||
const isAutoDiscovery =
|
||||
provider.type === "ollama" ||
|
||||
provider.type === "llama.cpp" ||
|
||||
provider.type === "vllm" ||
|
||||
provider.type === "lmstudio";
|
||||
if (isAutoDiscovery) {
|
||||
this.checkProviderStatus(provider);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load custom providers:", error);
|
||||
}
|
||||
}
|
||||
|
||||
getTabName(): string {
|
||||
return "Providers & Models";
|
||||
}
|
||||
|
||||
private async checkProviderStatus(provider: CustomProvider) {
|
||||
this.providerStatus.set(provider.id, { modelCount: 0, status: "checking" });
|
||||
this.requestUpdate();
|
||||
|
||||
try {
|
||||
const models = await discoverModels(
|
||||
provider.type as AutoDiscoveryProviderType,
|
||||
provider.baseUrl,
|
||||
provider.apiKey,
|
||||
);
|
||||
|
||||
this.providerStatus.set(provider.id, { modelCount: models.length, status: "connected" });
|
||||
} catch (error) {
|
||||
this.providerStatus.set(provider.id, { modelCount: 0, status: "disconnected" });
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private renderKnownProviders(): TemplateResult {
|
||||
const providers = getProviders();
|
||||
|
||||
return html`
|
||||
<div class="flex flex-col gap-6">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-foreground mb-2">Cloud Providers</h3>
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
Cloud LLM providers with predefined models. API keys are stored locally in your browser.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-6">
|
||||
${providers.map((provider) => html` <provider-key-input .provider=${provider}></provider-key-input> `)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCustomProviders(): TemplateResult {
|
||||
const isAutoDiscovery = (type: string) =>
|
||||
type === "ollama" || type === "llama.cpp" || type === "vllm" || type === "lmstudio";
|
||||
|
||||
return html`
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-foreground mb-2">Custom Providers</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
User-configured servers with auto-discovered or manually defined models.
|
||||
</p>
|
||||
</div>
|
||||
${Select({
|
||||
placeholder: i18n("Add Provider"),
|
||||
options: [
|
||||
{ value: "ollama", label: "Ollama" },
|
||||
{ value: "llama.cpp", label: "llama.cpp" },
|
||||
{ value: "vllm", label: "vLLM" },
|
||||
{ value: "lmstudio", label: "LM Studio" },
|
||||
{ value: "openai-completions", label: i18n("OpenAI Completions Compatible") },
|
||||
{ value: "openai-responses", label: i18n("OpenAI Responses Compatible") },
|
||||
{ value: "anthropic-messages", label: i18n("Anthropic Messages Compatible") },
|
||||
],
|
||||
onChange: (value: string) => this.addCustomProvider(value as CustomProviderType),
|
||||
variant: "outline",
|
||||
size: "sm",
|
||||
})}
|
||||
</div>
|
||||
|
||||
${
|
||||
this.customProviders.length === 0
|
||||
? html`
|
||||
<div class="text-sm text-muted-foreground text-center py-8">
|
||||
No custom providers configured. Click 'Add Provider' to get started.
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="flex flex-col gap-4">
|
||||
${this.customProviders.map(
|
||||
(provider) => html`
|
||||
<custom-provider-card
|
||||
.provider=${provider}
|
||||
.isAutoDiscovery=${isAutoDiscovery(provider.type)}
|
||||
.status=${this.providerStatus.get(provider.id)}
|
||||
.onRefresh=${(p: CustomProvider) => this.refreshProvider(p)}
|
||||
.onEdit=${(p: CustomProvider) => this.editProvider(p)}
|
||||
.onDelete=${(p: CustomProvider) => this.deleteProvider(p)}
|
||||
></custom-provider-card>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async addCustomProvider(type: CustomProviderType) {
|
||||
await CustomProviderDialog.open(undefined, type, async () => {
|
||||
await this.loadCustomProviders();
|
||||
this.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
private async editProvider(provider: CustomProvider) {
|
||||
await CustomProviderDialog.open(provider, undefined, async () => {
|
||||
await this.loadCustomProviders();
|
||||
this.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshProvider(provider: CustomProvider) {
|
||||
this.providerStatus.set(provider.id, { modelCount: 0, status: "checking" });
|
||||
this.requestUpdate();
|
||||
|
||||
try {
|
||||
const models = await discoverModels(
|
||||
provider.type as AutoDiscoveryProviderType,
|
||||
provider.baseUrl,
|
||||
provider.apiKey,
|
||||
);
|
||||
|
||||
this.providerStatus.set(provider.id, { modelCount: models.length, status: "connected" });
|
||||
this.requestUpdate();
|
||||
|
||||
console.log(`Refreshed ${models.length} models from ${provider.name}`);
|
||||
} catch (error) {
|
||||
this.providerStatus.set(provider.id, { modelCount: 0, status: "disconnected" });
|
||||
this.requestUpdate();
|
||||
|
||||
console.error(`Failed to refresh provider ${provider.name}:`, error);
|
||||
alert(`Failed to refresh provider: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteProvider(provider: CustomProvider) {
|
||||
if (!confirm("Are you sure you want to delete this provider?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const storage = getAppStorage();
|
||||
await storage.customProviders.delete(provider.id);
|
||||
await this.loadCustomProviders();
|
||||
this.requestUpdate();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete provider:", error);
|
||||
}
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`
|
||||
<div class="flex flex-col gap-8">
|
||||
${this.renderKnownProviders()}
|
||||
<div class="border-t border-border"></div>
|
||||
${this.renderCustomProviders()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
62
packages/web-ui/src/storage/stores/custom-providers-store.ts
Normal file
62
packages/web-ui/src/storage/stores/custom-providers-store.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { Store } from "../store.js";
|
||||
import type { StoreConfig } from "../types.js";
|
||||
|
||||
export type AutoDiscoveryProviderType = "ollama" | "llama.cpp" | "vllm" | "lmstudio";
|
||||
|
||||
export type CustomProviderType =
|
||||
| AutoDiscoveryProviderType // Auto-discovery - models fetched on-demand
|
||||
| "openai-completions" // Manual models - stored in provider.models
|
||||
| "openai-responses" // Manual models - stored in provider.models
|
||||
| "anthropic-messages"; // Manual models - stored in provider.models
|
||||
|
||||
export interface CustomProvider {
|
||||
id: string; // UUID
|
||||
name: string; // Display name, also used as Model.provider
|
||||
type: CustomProviderType;
|
||||
baseUrl: string;
|
||||
apiKey?: string; // Optional, applies to all models
|
||||
|
||||
// For manual types ONLY - models stored directly on provider
|
||||
// Auto-discovery types: models fetched on-demand, never stored
|
||||
models?: Model<any>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Store for custom LLM providers (auto-discovery servers + manual providers).
|
||||
*/
|
||||
export class CustomProvidersStore extends Store {
|
||||
getConfig(): StoreConfig {
|
||||
return {
|
||||
name: "custom-providers",
|
||||
};
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CustomProvider | null> {
|
||||
return this.getBackend().get("custom-providers", id);
|
||||
}
|
||||
|
||||
async set(provider: CustomProvider): Promise<void> {
|
||||
await this.getBackend().set("custom-providers", provider.id, provider);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.getBackend().delete("custom-providers", id);
|
||||
}
|
||||
|
||||
async getAll(): Promise<CustomProvider[]> {
|
||||
const keys = await this.getBackend().keys("custom-providers");
|
||||
const providers: CustomProvider[] = [];
|
||||
for (const key of keys) {
|
||||
const provider = await this.get(key);
|
||||
if (provider) {
|
||||
providers.push(provider);
|
||||
}
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
async has(id: string): Promise<boolean> {
|
||||
return this.getBackend().has("custom-providers", id);
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
277
packages/web-ui/src/utils/model-discovery.ts
Normal file
277
packages/web-ui/src/utils/model-discovery.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
import { LMStudioClient } from "@lmstudio/sdk";
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { Ollama } from "ollama/browser";
|
||||
|
||||
/**
|
||||
* Discover models from an Ollama server.
|
||||
* @param baseUrl - Base URL of the Ollama server (e.g., "http://localhost:11434")
|
||||
* @param apiKey - Optional API key (currently unused by Ollama)
|
||||
* @returns Array of discovered models
|
||||
*/
|
||||
export async function discoverOllamaModels(baseUrl: string, apiKey?: string): Promise<Model<any>[]> {
|
||||
try {
|
||||
// Create Ollama client
|
||||
const ollama = new Ollama({ host: baseUrl });
|
||||
|
||||
// Get list of available models
|
||||
const { models } = await ollama.list();
|
||||
|
||||
// Fetch details for each model and convert to Model format
|
||||
const ollamaModelPromises: Promise<Model<any> | null>[] = models.map(async (model: any) => {
|
||||
try {
|
||||
// Get model details
|
||||
const details = await ollama.show({
|
||||
model: model.name,
|
||||
});
|
||||
|
||||
// Check capabilities - filter out models that don't support tools
|
||||
const capabilities: string[] = (details as any).capabilities || [];
|
||||
if (!capabilities.includes("tools")) {
|
||||
console.debug(`Skipping model ${model.name}: does not support tools`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract model info
|
||||
const modelInfo: any = details.model_info || {};
|
||||
|
||||
// Get context window size - look for architecture-specific keys
|
||||
const architecture = modelInfo["general.architecture"] || "";
|
||||
const contextKey = `${architecture}.context_length`;
|
||||
const contextWindow = parseInt(modelInfo[contextKey] || "8192", 10);
|
||||
|
||||
// Ollama caps max tokens at 10x context length
|
||||
const maxTokens = contextWindow * 10;
|
||||
|
||||
// Ollama only supports completions API
|
||||
const ollamaModel: Model<any> = {
|
||||
id: model.name,
|
||||
name: model.name,
|
||||
api: "openai-completions" as any,
|
||||
provider: "", // Will be set by caller
|
||||
baseUrl: `${baseUrl}/v1`,
|
||||
reasoning: capabilities.includes("thinking"),
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: contextWindow,
|
||||
maxTokens: maxTokens,
|
||||
};
|
||||
|
||||
return ollamaModel;
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch details for model ${model.name}:`, err);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(ollamaModelPromises);
|
||||
return results.filter((m): m is Model<any> => m !== null);
|
||||
} catch (err) {
|
||||
console.error("Failed to discover Ollama models:", err);
|
||||
throw new Error(`Ollama discovery failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover models from a llama.cpp server via OpenAI-compatible /v1/models endpoint.
|
||||
* @param baseUrl - Base URL of the llama.cpp server (e.g., "http://localhost:8080")
|
||||
* @param apiKey - Optional API key
|
||||
* @returns Array of discovered models
|
||||
*/
|
||||
export async function discoverLlamaCppModels(baseUrl: string, apiKey?: string): Promise<Model<any>[]> {
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (apiKey) {
|
||||
headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/v1/models`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.data || !Array.isArray(data.data)) {
|
||||
throw new Error("Invalid response format from llama.cpp server");
|
||||
}
|
||||
|
||||
return data.data.map((model: any) => {
|
||||
// llama.cpp doesn't always provide context window info
|
||||
const contextWindow = model.context_length || 8192;
|
||||
const maxTokens = model.max_tokens || 4096;
|
||||
|
||||
const llamaModel: Model<any> = {
|
||||
id: model.id,
|
||||
name: model.id,
|
||||
api: "openai-completions" as any,
|
||||
provider: "", // Will be set by caller
|
||||
baseUrl: `${baseUrl}/v1`,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: contextWindow,
|
||||
maxTokens: maxTokens,
|
||||
};
|
||||
|
||||
return llamaModel;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to discover llama.cpp models:", err);
|
||||
throw new Error(`llama.cpp discovery failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover models from a vLLM server via OpenAI-compatible /v1/models endpoint.
|
||||
* @param baseUrl - Base URL of the vLLM server (e.g., "http://localhost:8000")
|
||||
* @param apiKey - Optional API key
|
||||
* @returns Array of discovered models
|
||||
*/
|
||||
export async function discoverVLLMModels(baseUrl: string, apiKey?: string): Promise<Model<any>[]> {
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (apiKey) {
|
||||
headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/v1/models`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.data || !Array.isArray(data.data)) {
|
||||
throw new Error("Invalid response format from vLLM server");
|
||||
}
|
||||
|
||||
return data.data.map((model: any) => {
|
||||
// vLLM provides max_model_len which is the context window
|
||||
const contextWindow = model.max_model_len || 8192;
|
||||
const maxTokens = Math.min(contextWindow, 4096); // Cap max tokens
|
||||
|
||||
const vllmModel: Model<any> = {
|
||||
id: model.id,
|
||||
name: model.id,
|
||||
api: "openai-completions" as any,
|
||||
provider: "", // Will be set by caller
|
||||
baseUrl: `${baseUrl}/v1`,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: contextWindow,
|
||||
maxTokens: maxTokens,
|
||||
};
|
||||
|
||||
return vllmModel;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to discover vLLM models:", err);
|
||||
throw new Error(`vLLM discovery failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover models from an LM Studio server using the LM Studio SDK.
|
||||
* @param baseUrl - Base URL of the LM Studio server (e.g., "http://localhost:1234")
|
||||
* @param apiKey - Optional API key (unused for LM Studio SDK)
|
||||
* @returns Array of discovered models
|
||||
*/
|
||||
export async function discoverLMStudioModels(baseUrl: string, apiKey?: string): Promise<Model<any>[]> {
|
||||
try {
|
||||
// Extract host and port from baseUrl
|
||||
const url = new URL(baseUrl);
|
||||
const port = url.port ? parseInt(url.port) : 1234;
|
||||
|
||||
// Create LM Studio client
|
||||
const client = new LMStudioClient({ baseUrl: `ws://${url.hostname}:${port}` });
|
||||
|
||||
// List all downloaded models
|
||||
const models = await client.system.listDownloadedModels();
|
||||
|
||||
// Filter to only LLM models and map to our Model format
|
||||
return models
|
||||
.filter((model) => model.type === "llm")
|
||||
.map((model) => {
|
||||
const contextWindow = model.maxContextLength;
|
||||
// Use 10x context length like Ollama does
|
||||
const maxTokens = contextWindow;
|
||||
|
||||
const lmStudioModel: Model<any> = {
|
||||
id: model.path,
|
||||
name: model.displayName || model.path,
|
||||
api: "openai-completions" as any,
|
||||
provider: "", // Will be set by caller
|
||||
baseUrl: `${baseUrl}/v1`,
|
||||
reasoning: model.trainedForToolUse || false,
|
||||
input: model.vision ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: contextWindow,
|
||||
maxTokens: maxTokens,
|
||||
};
|
||||
|
||||
return lmStudioModel;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to discover LM Studio models:", err);
|
||||
throw new Error(`LM Studio discovery failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to discover models based on provider type.
|
||||
* @param type - Provider type
|
||||
* @param baseUrl - Base URL of the server
|
||||
* @param apiKey - Optional API key
|
||||
* @returns Array of discovered models
|
||||
*/
|
||||
export async function discoverModels(
|
||||
type: "ollama" | "llama.cpp" | "vllm" | "lmstudio",
|
||||
baseUrl: string,
|
||||
apiKey?: string,
|
||||
): Promise<Model<any>[]> {
|
||||
switch (type) {
|
||||
case "ollama":
|
||||
return discoverOllamaModels(baseUrl, apiKey);
|
||||
case "llama.cpp":
|
||||
return discoverLlamaCppModels(baseUrl, apiKey);
|
||||
case "vllm":
|
||||
return discoverVLLMModels(baseUrl, apiKey);
|
||||
case "lmstudio":
|
||||
return discoverLMStudioModels(baseUrl, apiKey);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue