Custom provider WIP

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

View file

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

View file

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

View file

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