feat(connectors): add Ollama and LM Studio as a local auth kind
Both servers expose OpenAI-compatible /v1/chat/completions and /v1/models, so we route them through pi-ai's `openai-completions` API the same way Fireworks does — but with a per-installation base URL the user enters during onboarding instead of a hardcoded one. The shared schemas grow a `local` credential payload carrying just the baseUrl; the credential resolver surfaces it on `ResolvedCredential.baseUrl`, the runtime bridge forwards it through `prepareRun`, and the worker plumbs it into `resolveModel` so the streamed Model<openai-completions> targets the user's server. The local-cost path short-circuits to zero in cost-writer since users bill their own hardware, and the live model list is fetched from the saved baseUrl by `fetchLocalConnectorModels` (Ollama and LM Studio both speak the standard OpenAI catalog shape). Onboarding ships a new LocalProviderCard and the Settings panel a LocalForm; both default to the catalog's per-connector localhost URL and accept any custom URL.
This commit is contained in:
1 parent
07d9c00fab
commit
a532120649
26 files changed
+504
-18
No files matched your search
@@ -4,6 +4,7 @@ import type { TranslationFn } from "../../../i18n";
|
||||
import { OAuthProviderCard } from "./oauth-card";
|
||||
import { ApiKeyProviderCard } from "./api-key-card";
|
||||
import { CloudProviderCard } from "./cloud-card";
|
||||
import { LocalProviderCard } from "./local-card";
|
||||
|
||||
export interface ProviderCardProps {
|
||||
entry: ConnectorCatalogEntry;
|
||||
@@ -15,8 +16,8 @@ export interface ProviderCardProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch based on the catalog auth descriptor. Five auth kinds live behind
|
||||
* three dedicated cards — cloud variants share a card because their onboarding
|
||||
* Dispatch based on the catalog auth descriptor. Six auth kinds live behind
|
||||
* four dedicated cards — cloud variants share a card because their onboarding
|
||||
* affordance is identical (deep link to Settings). A universal form generator
|
||||
* is intentionally avoided so each auth flow stays legible end-to-end.
|
||||
*/
|
||||
@@ -31,5 +32,7 @@ export function OnboardingProviderDispatch(props: ProviderCardProps) {
|
||||
case "azure":
|
||||
case "gcp_adc":
|
||||
return <CloudProviderCard {...props} />;
|
||||
case "local":
|
||||
return <LocalProviderCard {...props} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { FloppyDisk } from "../../ui/icons";
|
||||
import type { ConnectorCatalogEntry } from "@shared/connector-catalog";
|
||||
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
|
||||
import type { TranslationFn } from "../../../i18n";
|
||||
import {
|
||||
InlineFeedback,
|
||||
OnboardingProviderCard,
|
||||
OnboardingStatusBadge,
|
||||
type OnboardingCardStatus,
|
||||
} from "./shared";
|
||||
import { Input } from "../../ui";
|
||||
|
||||
interface LocalProviderCardProps {
|
||||
entry: ConnectorCatalogEntry;
|
||||
credential: CredentialSummary | null;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
t: TranslationFn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding card for local OpenAI-compatible servers (Ollama, LM Studio).
|
||||
*
|
||||
* The catalog supplies a sensible default URL per entry; the user can
|
||||
* override it to point at a non-default port, a remote machine, or a
|
||||
* reverse proxy. The card never asks for an API key — local servers
|
||||
* accept any bearer value, so the credential resolver supplies the
|
||||
* connector id as a non-empty placeholder for the OpenAI SDK.
|
||||
*/
|
||||
export function LocalProviderCard({
|
||||
entry,
|
||||
credential,
|
||||
expanded,
|
||||
onToggle,
|
||||
t,
|
||||
}: LocalProviderCardProps) {
|
||||
if (entry.auth.type !== "local") {
|
||||
throw new Error(`LocalProviderCard received non-local connector: ${entry.id}`);
|
||||
}
|
||||
const defaultBaseUrl = entry.auth.defaultBaseUrl;
|
||||
const initialBaseUrl = credential?.displayHint?.startsWith("local:")
|
||||
? credential.displayHint.slice("local:".length)
|
||||
: defaultBaseUrl;
|
||||
const [baseUrl, setBaseUrl] = useState(initialBaseUrl);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const next = credential?.displayHint?.startsWith("local:")
|
||||
? credential.displayHint.slice("local:".length)
|
||||
: defaultBaseUrl;
|
||||
setBaseUrl(next);
|
||||
}, [credential?.displayHint, defaultBaseUrl]);
|
||||
|
||||
const status: OnboardingCardStatus = busy
|
||||
? "checking"
|
||||
: credential?.status ?? "not_configured";
|
||||
const connected = credential?.status === "active";
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!window.agentCompany) return;
|
||||
const trimmed = baseUrl.trim();
|
||||
if (trimmed.length === 0) {
|
||||
setFeedback(t("onboarding.providers.baseUrlRequired"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
new URL(trimmed);
|
||||
} catch {
|
||||
setFeedback(t("onboarding.providers.baseUrlInvalid"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setFeedback(null);
|
||||
const result = await window.agentCompany.saveCredential({
|
||||
connectorId: entry.id,
|
||||
payload: {
|
||||
kind: "local",
|
||||
value: { baseUrl: trimmed },
|
||||
},
|
||||
});
|
||||
setBusy(false);
|
||||
if (!result.ok) {
|
||||
setFeedback(result.error.message);
|
||||
return;
|
||||
}
|
||||
setFeedback(t("onboarding.providers.saved"));
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (!window.agentCompany) return;
|
||||
setBusy(true);
|
||||
setFeedback(null);
|
||||
const result = await window.agentCompany.removeCredential({ connectorId: entry.id });
|
||||
setBusy(false);
|
||||
if (!result.ok) setFeedback(result.error.message);
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingProviderCard
|
||||
label={entry.label}
|
||||
description={entry.description}
|
||||
badge={<OnboardingStatusBadge status={status} t={t} />}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
{entry.notes ? (
|
||||
<div className="text-[12px] leading-relaxed text-muted-foreground">{entry.notes}</div>
|
||||
) : null}
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleSave();
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{t("onboarding.providers.baseUrlLabel")}{" "}
|
||||
<span className="font-mono text-[11px] text-muted-foreground/70">({defaultBaseUrl})</span>
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={baseUrl}
|
||||
onChange={setBaseUrl}
|
||||
placeholder={defaultBaseUrl}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || baseUrl.trim().length === 0}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
|
||||
>
|
||||
<FloppyDisk size={14} />
|
||||
{busy ? t("onboarding.providers.saving") : t("onboarding.providers.save")}
|
||||
</button>
|
||||
{connected ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRemove()}
|
||||
disabled={busy}
|
||||
className="text-[12px] text-muted-foreground transition hover:text-destructive disabled:opacity-40"
|
||||
>
|
||||
{t("onboarding.providers.disconnect")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
<InlineFeedback message={feedback} />
|
||||
</OnboardingProviderCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState } from "react";
|
||||
import { ActionButton, Input } from "../../ui";
|
||||
import { useT } from "../../../i18n";
|
||||
|
||||
const desktop = (typeof window !== "undefined" ? window.agentCompany : null) as
|
||||
| (Window["agentCompany"] | null);
|
||||
|
||||
interface Props {
|
||||
connectorId: string;
|
||||
defaultBaseUrl: string;
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
export function LocalForm({ connectorId, defaultBaseUrl, onSaved }: Props) {
|
||||
const t = useT();
|
||||
const [baseUrl, setBaseUrl] = useState(defaultBaseUrl);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const trimmed = baseUrl.trim();
|
||||
const canSave = trimmed.length > 0 && !busy && Boolean(desktop);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!desktop || !canSave) return;
|
||||
try {
|
||||
new URL(trimmed);
|
||||
} catch {
|
||||
setError(t("onboarding.providers.baseUrlInvalid"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const result = await desktop.saveCredential({
|
||||
connectorId,
|
||||
payload: { kind: "local", value: { baseUrl: trimmed } },
|
||||
});
|
||||
setBusy(false);
|
||||
if (!result.ok) {
|
||||
setError(result.error.message);
|
||||
return;
|
||||
}
|
||||
onSaved?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleSubmit();
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={baseUrl}
|
||||
onChange={setBaseUrl}
|
||||
placeholder={defaultBaseUrl}
|
||||
disabled={busy}
|
||||
ariaLabel={t("onboarding.providers.baseUrlLabel")}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<div role="alert" className="text-[11px] text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<ActionButton
|
||||
type="submit"
|
||||
label={busy ? t("form.saving") : t("form.save")}
|
||||
tone="accent"
|
||||
disabled={!canSave}
|
||||
loading={busy}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { ApiKeyForm } from "../forms/ApiKeyForm";
|
||||
import { AzureForm } from "../forms/AzureForm";
|
||||
import { AwsForm } from "../forms/AwsForm";
|
||||
import { VertexForm } from "../forms/VertexForm";
|
||||
import { LocalForm } from "../forms/LocalForm";
|
||||
import { ActionButton } from "../../ui";
|
||||
import { useT, type TranslationFn } from "../../../i18n";
|
||||
|
||||
@@ -28,6 +29,8 @@ function kindLabel(
|
||||
return t("providers.kindAws");
|
||||
case "gcp_adc":
|
||||
return t("providers.kindGcp");
|
||||
case "local":
|
||||
return t("providers.kindLocal");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +121,8 @@ export function ProviderRow({ entry, credential }: Props) {
|
||||
return <AwsForm connectorId={entry.id} onSaved={onSaved} />;
|
||||
if (entry.auth.type === "gcp_adc")
|
||||
return <VertexForm connectorId={entry.id} onSaved={onSaved} />;
|
||||
if (entry.auth.type === "local")
|
||||
return <LocalForm connectorId={entry.id} defaultBaseUrl={entry.auth.defaultBaseUrl} onSaved={onSaved} />;
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user