mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 03:04:28 +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"
|
"@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": {
|
"node_modules/@mariozechner/coding-agent": {
|
||||||
"resolved": "packages/coding-agent",
|
"resolved": "packages/coding-agent",
|
||||||
"link": true
|
"link": true
|
||||||
|
|
@ -2510,7 +2532,6 @@
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-styles": "^4.1.0",
|
"ansi-styles": "^4.1.0",
|
||||||
|
|
@ -2527,7 +2548,6 @@
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"has-flag": "^4.0.0"
|
"has-flag": "^4.0.0"
|
||||||
|
|
@ -3106,7 +3126,6 @@
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
|
|
@ -3318,6 +3337,15 @@
|
||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/jszip": {
|
||||||
"version": "3.10.1",
|
"version": "3.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|
@ -5159,7 +5186,7 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mariozechner/pi-ai": "^0.5.44",
|
"@mariozechner/pi-ai": "^0.5.44",
|
||||||
"@mariozechner/pi-tui": "^0.5.47"
|
"@mariozechner/pi-tui": "^0.5.48"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.3.0",
|
"@types/node": "^24.3.0",
|
||||||
|
|
@ -5371,7 +5398,7 @@
|
||||||
"version": "0.5.48",
|
"version": "0.5.48",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mariozechner/pi-agent": "^0.5.47",
|
"@mariozechner/pi-agent": "^0.5.48",
|
||||||
"chalk": "^5.5.0"
|
"chalk": "^5.5.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -5455,8 +5482,9 @@
|
||||||
"version": "0.5.48",
|
"version": "0.5.48",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@lmstudio/sdk": "^1.5.0",
|
||||||
"@mariozechner/pi-ai": "^0.5.43",
|
"@mariozechner/pi-ai": "^0.5.43",
|
||||||
"@mariozechner/pi-tui": "^0.5.47",
|
"@mariozechner/pi-tui": "^0.5.48",
|
||||||
"docx-preview": "^0.3.7",
|
"docx-preview": "^0.3.7",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lit": "^3.3.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:
|
Given a prompt asking to calculate two expressions and sum them:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { prompt, AgentContext, calculateTool } from '@mariozechner/pi-ai';
|
import { agentLoop, AgentContext, calculateTool } from '@mariozechner/pi-ai';
|
||||||
|
|
||||||
const context: AgentContext = {
|
const context: AgentContext = {
|
||||||
systemPrompt: 'You are a helpful math assistant.',
|
systemPrompt: 'You are a helpful math assistant.',
|
||||||
|
|
@ -698,8 +698,8 @@ const context: AgentContext = {
|
||||||
tools: [calculateTool]
|
tools: [calculateTool]
|
||||||
};
|
};
|
||||||
|
|
||||||
const stream = prompt(
|
const stream = agentLoop(
|
||||||
{ role: 'user', content: 'Calculate 15 * 20 and 30 * 40, then sum the results' },
|
{ role: 'user', content: 'Calculate 15 * 20 and 30 * 40, then sum the results', timestamp: Date.now() },
|
||||||
context,
|
context,
|
||||||
{ model: getModel('openai', 'gpt-4o-mini') }
|
{ model: getModel('openai', 'gpt-4o-mini') }
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1356,6 +1356,23 @@ export const MODELS = {
|
||||||
contextWindow: 131000,
|
contextWindow: 131000,
|
||||||
maxTokens: 32000,
|
maxTokens: 32000,
|
||||||
} satisfies Model<"openai-completions">,
|
} 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": {
|
"qwen-3-coder-480b": {
|
||||||
id: "qwen-3-coder-480b",
|
id: "qwen-3-coder-480b",
|
||||||
name: "Qwen 3 Coder 480B",
|
name: "Qwen 3 Coder 480B",
|
||||||
|
|
@ -1821,6 +1838,23 @@ export const MODELS = {
|
||||||
} satisfies Model<"anthropic-messages">,
|
} satisfies Model<"anthropic-messages">,
|
||||||
},
|
},
|
||||||
openrouter: {
|
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": {
|
"openai/gpt-oss-safeguard-20b": {
|
||||||
id: "openai/gpt-oss-safeguard-20b",
|
id: "openai/gpt-oss-safeguard-20b",
|
||||||
name: "OpenAI: gpt-oss-safeguard-20b",
|
name: "OpenAI: gpt-oss-safeguard-20b",
|
||||||
|
|
@ -1991,23 +2025,6 @@ export const MODELS = {
|
||||||
contextWindow: 400000,
|
contextWindow: 400000,
|
||||||
maxTokens: 128000,
|
maxTokens: 128000,
|
||||||
} satisfies Model<"openai-completions">,
|
} 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": {
|
"inclusionai/ring-1t": {
|
||||||
id: "inclusionai/ring-1t",
|
id: "inclusionai/ring-1t",
|
||||||
name: "inclusionAI: Ring 1T",
|
name: "inclusionAI: Ring 1T",
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,9 @@ esac
|
||||||
|
|
||||||
# --- Install additional packages ---------------------------------------------
|
# --- Install additional packages ---------------------------------------------
|
||||||
echo "Installing 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) ----------------
|
# --- FlashInfer installation (optional, improves performance) ----------------
|
||||||
echo "Attempting FlashInfer installation (optional)..."
|
echo "Attempting FlashInfer installation (optional)..."
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,16 @@ import {
|
||||||
Agent,
|
Agent,
|
||||||
type AgentState,
|
type AgentState,
|
||||||
ApiKeyPromptDialog,
|
ApiKeyPromptDialog,
|
||||||
ApiKeysTab,
|
|
||||||
type AppMessage,
|
type AppMessage,
|
||||||
AppStorage,
|
AppStorage,
|
||||||
ChatPanel,
|
ChatPanel,
|
||||||
createJavaScriptReplTool,
|
createJavaScriptReplTool,
|
||||||
|
CustomProvidersStore,
|
||||||
IndexedDBStorageBackend,
|
IndexedDBStorageBackend,
|
||||||
// PersistentStorageDialog, // TODO: Fix - currently broken
|
// PersistentStorageDialog, // TODO: Fix - currently broken
|
||||||
ProviderKeysStore,
|
ProviderKeysStore,
|
||||||
ProviderTransport,
|
ProviderTransport,
|
||||||
|
ProvidersModelsTab,
|
||||||
ProxyTab,
|
ProxyTab,
|
||||||
SessionListDialog,
|
SessionListDialog,
|
||||||
SessionsStore,
|
SessionsStore,
|
||||||
|
|
@ -33,24 +34,32 @@ registerCustomMessageRenderers();
|
||||||
const settings = new SettingsStore();
|
const settings = new SettingsStore();
|
||||||
const providerKeys = new ProviderKeysStore();
|
const providerKeys = new ProviderKeysStore();
|
||||||
const sessions = new SessionsStore();
|
const sessions = new SessionsStore();
|
||||||
|
const customProviders = new CustomProvidersStore();
|
||||||
|
|
||||||
// Gather configs
|
// 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
|
// Create backend
|
||||||
const backend = new IndexedDBStorageBackend({
|
const backend = new IndexedDBStorageBackend({
|
||||||
dbName: "pi-web-ui-example",
|
dbName: "pi-web-ui-example",
|
||||||
version: 1,
|
version: 2, // Incremented for custom-providers store
|
||||||
stores: configs,
|
stores: configs,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wire backend to stores
|
// Wire backend to stores
|
||||||
settings.setBackend(backend);
|
settings.setBackend(backend);
|
||||||
providerKeys.setBackend(backend);
|
providerKeys.setBackend(backend);
|
||||||
|
customProviders.setBackend(backend);
|
||||||
sessions.setBackend(backend);
|
sessions.setBackend(backend);
|
||||||
|
|
||||||
// Create and set app storage
|
// Create and set app storage
|
||||||
const storage = new AppStorage(settings, providerKeys, sessions, backend);
|
const storage = new AppStorage(settings, providerKeys, sessions, customProviders, backend);
|
||||||
setAppStorage(storage);
|
setAppStorage(storage);
|
||||||
|
|
||||||
let currentSessionId: string | undefined;
|
let currentSessionId: string | undefined;
|
||||||
|
|
@ -349,7 +358,7 @@ const renderApp = () => {
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
size: "sm",
|
size: "sm",
|
||||||
children: icon(Settings, "sm"),
|
children: icon(Settings, "sm"),
|
||||||
onClick: () => SettingsDialog.open([new ApiKeysTab(), new ProxyTab()]),
|
onClick: () => SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]),
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"check": "npm run typecheck"
|
"check": "npm run typecheck"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@lmstudio/sdk": "^1.5.0",
|
||||||
"@mariozechner/pi-ai": "^0.5.43",
|
"@mariozechner/pi-ai": "^0.5.43",
|
||||||
"@mariozechner/pi-tui": "^0.5.48",
|
"@mariozechner/pi-tui": "^0.5.48",
|
||||||
"docx-preview": "^0.3.7",
|
"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));
|
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);
|
border: 1px solid rgba(255, 107, 0, 0.25);
|
||||||
backdrop-filter: blur(10px);
|
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 || "";
|
: this.message.content.find((c) => c.type === "text")?.text || "";
|
||||||
|
|
||||||
return html`
|
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">
|
<div class="user-message-container py-2 px-4 rounded-xl">
|
||||||
<markdown-block .content=${content}></markdown-block>
|
<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 { Badge, Button, DialogBase, DialogHeader, html, icon, type TemplateResult } from "@mariozechner/mini-lit";
|
||||||
import type { Model } from "@mariozechner/pi-ai";
|
import { getModels, getProviders, type Model } from "@mariozechner/pi-ai";
|
||||||
import { MODELS } from "@mariozechner/pi-ai/dist/models.generated.js";
|
|
||||||
import type { PropertyValues } from "lit";
|
import type { PropertyValues } from "lit";
|
||||||
import { customElement, state } from "lit/decorators.js";
|
import { customElement, state } from "lit/decorators.js";
|
||||||
import { createRef, ref } from "lit/directives/ref.js";
|
import { createRef, ref } from "lit/directives/ref.js";
|
||||||
import { Brain, Image as ImageIcon } from "lucide";
|
import { Brain, Image as ImageIcon } from "lucide";
|
||||||
import { Ollama } from "ollama/dist/browser.mjs";
|
|
||||||
import { Input } from "../components/Input.js";
|
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 { formatModelCost } from "../utils/format.js";
|
||||||
import { i18n } from "../utils/i18n.js";
|
import { i18n } from "../utils/i18n.js";
|
||||||
|
import { discoverModels } from "../utils/model-discovery.js";
|
||||||
|
|
||||||
@customElement("agent-model-selector")
|
@customElement("agent-model-selector")
|
||||||
export class ModelSelector extends DialogBase {
|
export class ModelSelector extends DialogBase {
|
||||||
|
|
@ -16,10 +17,10 @@ export class ModelSelector extends DialogBase {
|
||||||
@state() searchQuery = "";
|
@state() searchQuery = "";
|
||||||
@state() filterThinking = false;
|
@state() filterThinking = false;
|
||||||
@state() filterVision = false;
|
@state() filterVision = false;
|
||||||
@state() ollamaModels: Model<any>[] = [];
|
@state() customProvidersLoading = false;
|
||||||
@state() ollamaError: string | null = null;
|
|
||||||
@state() selectedIndex = 0;
|
@state() selectedIndex = 0;
|
||||||
@state() private navigationMode: "mouse" | "keyboard" = "mouse";
|
@state() private navigationMode: "mouse" | "keyboard" = "mouse";
|
||||||
|
@state() private customProviderModels: Model<any>[] = [];
|
||||||
|
|
||||||
private onSelectCallback?: (model: Model<any>) => void;
|
private onSelectCallback?: (model: Model<any>) => void;
|
||||||
private scrollContainerRef = createRef<HTMLDivElement>();
|
private scrollContainerRef = createRef<HTMLDivElement>();
|
||||||
|
|
@ -33,7 +34,7 @@ export class ModelSelector extends DialogBase {
|
||||||
selector.currentModel = currentModel;
|
selector.currentModel = currentModel;
|
||||||
selector.onSelectCallback = onSelect;
|
selector.onSelectCallback = onSelect;
|
||||||
selector.open();
|
selector.open();
|
||||||
selector.fetchOllamaModels();
|
selector.loadCustomProviders();
|
||||||
}
|
}
|
||||||
|
|
||||||
override async firstUpdated(changedProperties: PropertyValues): Promise<void> {
|
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 {
|
try {
|
||||||
// Create Ollama client
|
const storage = getAppStorage();
|
||||||
const ollama = new Ollama({ host: "http://localhost:11434" });
|
const customProviders = await storage.customProviders.getAll();
|
||||||
|
|
||||||
// Get list of available models
|
// Load models from custom providers
|
||||||
const { models } = await ollama.list();
|
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
|
if (isAutoDiscovery) {
|
||||||
const ollamaModelPromises: Promise<Model<any> | null>[] = models
|
|
||||||
.map(async (model: any) => {
|
|
||||||
try {
|
try {
|
||||||
// Get model details
|
const models = await discoverModels(
|
||||||
const details = await ollama.show({
|
provider.type as AutoDiscoveryProviderType,
|
||||||
model: model.name,
|
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
|
allCustomModels.push(...modelsWithProvider);
|
||||||
const modelInfo: any = details.model_info || {};
|
} catch (error) {
|
||||||
|
console.debug(`Failed to load models from ${provider.name}:`, error);
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
})
|
} else if (provider.models) {
|
||||||
.filter((m: any) => m !== null);
|
// Manual provider - models already defined
|
||||||
|
allCustomModels.push(...provider.models);
|
||||||
const results = await Promise.all(ollamaModelPromises);
|
}
|
||||||
this.ollamaModels = results.filter((m): m is Model<any> => m !== null);
|
}
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
// Ollama not available or other error - silently ignore
|
console.error("Failed to load custom providers:", error);
|
||||||
console.debug("Ollama not available:", err);
|
} finally {
|
||||||
this.ollamaError = err instanceof Error ? err.message : String(err);
|
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 }> {
|
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 }> = [];
|
const allModels: Array<{ provider: string; id: string; model: any }> = [];
|
||||||
for (const [provider, providerData] of Object.entries(MODELS)) {
|
const knownProviders = getProviders();
|
||||||
for (const [modelId, model] of Object.entries(providerData)) {
|
|
||||||
allModels.push({ provider, id: modelId, model });
|
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
|
// Add custom provider models
|
||||||
for (const ollamaModel of this.ollamaModels) {
|
for (const model of this.customProviderModels) {
|
||||||
allModels.push({
|
allModels.push({ provider: model.provider, id: model.id, model });
|
||||||
id: ollamaModel.id,
|
|
||||||
provider: "ollama",
|
|
||||||
model: ollamaModel,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter models based on search and capability filters
|
// Filter models based on search and capability filters
|
||||||
|
|
@ -283,8 +266,7 @@ export class ModelSelector extends DialogBase {
|
||||||
<!-- Scrollable model list -->
|
<!-- Scrollable model list -->
|
||||||
<div class="flex-1 overflow-y-auto" ${ref(this.scrollContainerRef)}>
|
<div class="flex-1 overflow-y-auto" ${ref(this.scrollContainerRef)}>
|
||||||
${filteredModels.map(({ provider, id, model }, index) => {
|
${filteredModels.map(({ provider, id, model }, index) => {
|
||||||
// Check if this is the current model by comparing IDs
|
const isCurrent = this.currentModel?.id === model.id && this.currentModel?.provider === model.provider;
|
||||||
const isCurrent = this.currentModel?.id === model.id;
|
|
||||||
const isSelected = index === this.selectedIndex;
|
const isSelected = index === this.selectedIndex;
|
||||||
return html`
|
return html`
|
||||||
<div
|
<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
|
// Dialogs
|
||||||
export { ModelSelector } from "./dialogs/ModelSelector.js";
|
export { ModelSelector } from "./dialogs/ModelSelector.js";
|
||||||
export { PersistentStorageDialog } from "./dialogs/PersistentStorageDialog.js";
|
export { PersistentStorageDialog } from "./dialogs/PersistentStorageDialog.js";
|
||||||
|
export { ProvidersModelsTab } from "./dialogs/ProvidersModelsTab.js";
|
||||||
export { SessionListDialog } from "./dialogs/SessionListDialog.js";
|
export { SessionListDialog } from "./dialogs/SessionListDialog.js";
|
||||||
export { ApiKeysTab, ProxyTab, SettingsDialog, SettingsTab } from "./dialogs/SettingsDialog.js";
|
export { ApiKeysTab, ProxyTab, SettingsDialog, SettingsTab } from "./dialogs/SettingsDialog.js";
|
||||||
// Prompts
|
// Prompts
|
||||||
|
|
@ -64,6 +65,12 @@ export {
|
||||||
export { AppStorage, getAppStorage, setAppStorage } from "./storage/app-storage.js";
|
export { AppStorage, getAppStorage, setAppStorage } from "./storage/app-storage.js";
|
||||||
export { IndexedDBStorageBackend } from "./storage/backends/indexeddb-storage-backend.js";
|
export { IndexedDBStorageBackend } from "./storage/backends/indexeddb-storage-backend.js";
|
||||||
export { Store } from "./storage/store.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 { ProviderKeysStore } from "./storage/stores/provider-keys-store.js";
|
||||||
export { SessionsStore } from "./storage/stores/sessions-store.js";
|
export { SessionsStore } from "./storage/stores/sessions-store.js";
|
||||||
export { SettingsStore } from "./storage/stores/settings-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 { ProviderKeysStore } from "./stores/provider-keys-store.js";
|
||||||
import type { SessionsStore } from "./stores/sessions-store.js";
|
import type { SessionsStore } from "./stores/sessions-store.js";
|
||||||
import type { SettingsStore } from "./stores/settings-store.js";
|
import type { SettingsStore } from "./stores/settings-store.js";
|
||||||
|
|
@ -12,16 +13,19 @@ export class AppStorage {
|
||||||
readonly settings: SettingsStore;
|
readonly settings: SettingsStore;
|
||||||
readonly providerKeys: ProviderKeysStore;
|
readonly providerKeys: ProviderKeysStore;
|
||||||
readonly sessions: SessionsStore;
|
readonly sessions: SessionsStore;
|
||||||
|
readonly customProviders: CustomProvidersStore;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
settings: SettingsStore,
|
settings: SettingsStore,
|
||||||
providerKeys: ProviderKeysStore,
|
providerKeys: ProviderKeysStore,
|
||||||
sessions: SessionsStore,
|
sessions: SessionsStore,
|
||||||
|
customProviders: CustomProvidersStore,
|
||||||
backend: StorageBackend,
|
backend: StorageBackend,
|
||||||
) {
|
) {
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
this.providerKeys = providerKeys;
|
this.providerKeys = providerKeys;
|
||||||
this.sessions = sessions;
|
this.sessions = sessions;
|
||||||
|
this.customProviders = customProviders;
|
||||||
this.backend = backend;
|
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;
|
messages: string;
|
||||||
tokens: string;
|
tokens: string;
|
||||||
"Drop files here": 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",
|
Delete: "Delete",
|
||||||
"Drop files here": "Drop files here",
|
"Drop files here": "Drop files here",
|
||||||
"Command failed:": "Command failed:",
|
"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: {
|
de: {
|
||||||
...defaultGerman,
|
...defaultGerman,
|
||||||
|
|
@ -535,6 +607,44 @@ export const translations = {
|
||||||
Delete: "Löschen",
|
Delete: "Löschen",
|
||||||
"Drop files here": "Dateien hier ablegen",
|
"Drop files here": "Dateien hier ablegen",
|
||||||
"Command failed:": "Befehl fehlgeschlagen:",
|
"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