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:
ZacharyZhang-NY committed 2026-05-14 14:55:38 -04:00
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;
};