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
@@ -1,4 +1,4 @@
|
||||
import { calculateCost, type Usage, type Api, type Model } from "@mariozechner/pi-ai";
|
||||
import { calculateCost, type Usage } from "@mariozechner/pi-ai";
|
||||
import type { AppDatabase } from "@main/database";
|
||||
import { getConnectorDefinition } from "@main/connectors";
|
||||
import { resolveModel, resolveModelCostFallback } from "@main/model-resolver";
|
||||
@@ -17,6 +17,12 @@ interface ObserveInput {
|
||||
usage: Usage;
|
||||
}
|
||||
|
||||
// Local OpenAI-compatible servers run on the user's own machine, so the
|
||||
// LLM call has no third-party billing line. Record a zero cost entry
|
||||
// without invoking pi-ai's calculator (which would require the model's
|
||||
// baseUrl from the credential — unnecessary work for a flat-zero cost).
|
||||
const ZERO_COST_PROVIDERS: ReadonlySet<string> = new Set(["ollama", "lm-studio"]);
|
||||
|
||||
export class CostWriter {
|
||||
constructor(private readonly deps: CostWriterDeps) {}
|
||||
|
||||
@@ -25,6 +31,18 @@ export class CostWriter {
|
||||
if (!definition.provider) {
|
||||
throw new Error(`[cost-writer] connector ${input.connectorId} has no provider mapping`);
|
||||
}
|
||||
if (ZERO_COST_PROVIDERS.has(definition.provider)) {
|
||||
this.deps.db.core.runs.recordCostEntry({
|
||||
runId: input.runId,
|
||||
companyId: input.companyId,
|
||||
agentId: input.agentId,
|
||||
taskId: input.taskId,
|
||||
connectorId: input.connectorId,
|
||||
amountUsd: 0,
|
||||
unattributed: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const model = resolveModel(definition.provider, input.modelId);
|
||||
if (!model) {
|
||||
throw new Error(`[cost-writer] model ${input.modelId} not found for provider ${definition.provider}`);
|
||||
|
||||
@@ -440,6 +440,7 @@ export class RuntimeBridge {
|
||||
provider: definition.provider,
|
||||
apiKey: resolved.apiKey,
|
||||
extraEnv: resolved.extraEnv ?? {},
|
||||
baseUrl: resolved.baseUrl ?? null,
|
||||
priorMessages,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
import type { KnownProvider } from "@mariozechner/pi-ai";
|
||||
import type { ConnectorId } from "@shared/types";
|
||||
import { OAUTH_CONNECTORS, API_KEY_CONNECTORS, CLOUD_CONNECTORS } from "./connector-definitions/index";
|
||||
import { OAUTH_CONNECTORS, API_KEY_CONNECTORS, CLOUD_CONNECTORS, LOCAL_CONNECTORS } from "./connector-definitions/index";
|
||||
|
||||
/**
|
||||
* OAuth provider id recognised by the AgentCompany credential layer. These
|
||||
@@ -61,14 +61,21 @@ export interface ConnectorAuthGcpAdc {
|
||||
type: "gcp_adc";
|
||||
}
|
||||
|
||||
export interface ConnectorAuthLocal {
|
||||
type: "local";
|
||||
/** Pre-filled base URL the onboarding card shows on first save. */
|
||||
defaultBaseUrl: string;
|
||||
}
|
||||
|
||||
export type ConnectorAuth =
|
||||
| ConnectorAuthOAuth
|
||||
| ConnectorAuthApiKey
|
||||
| ConnectorAuthAws
|
||||
| ConnectorAuthAzure
|
||||
| ConnectorAuthGcpAdc;
|
||||
| ConnectorAuthGcpAdc
|
||||
| ConnectorAuthLocal;
|
||||
|
||||
export type LocalModelResolverProvider = "fireworks" | "fireworks-firepass";
|
||||
export type LocalModelResolverProvider = "fireworks" | "fireworks-firepass" | "ollama" | "lm-studio";
|
||||
export type ConnectorProvider = KnownProvider | LocalModelResolverProvider;
|
||||
|
||||
export interface ConnectorDefinition {
|
||||
@@ -94,4 +101,5 @@ export const CONNECTORS: ConnectorDefinition[] = [
|
||||
...OAUTH_CONNECTORS,
|
||||
...API_KEY_CONNECTORS,
|
||||
...CLOUD_CONNECTORS,
|
||||
...LOCAL_CONNECTORS,
|
||||
];
|
||||
@@ -9,3 +9,4 @@
|
||||
export { OAUTH_CONNECTORS } from "./oauth";
|
||||
export { API_KEY_CONNECTORS } from "./apikey";
|
||||
export { CLOUD_CONNECTORS } from "./cloud";
|
||||
export { LOCAL_CONNECTORS } from "./local";
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Local pi-mono connectors.
|
||||
*
|
||||
* Ollama and LM Studio both expose an OpenAI-compatible
|
||||
* `/v1/chat/completions` endpoint, so they are routed through pi-ai's
|
||||
* `openai-completions` API path with a per-installation `baseUrl` that
|
||||
* the user enters during onboarding. The provider ids are deliberately
|
||||
* absent from `@mariozechner/pi-ai`'s `KnownProvider` set — model
|
||||
* dispatch resolves them locally via `src/main/model-resolver.ts` the
|
||||
* same way `fireworks` and `fireworks-firepass` do.
|
||||
*
|
||||
* No environment variable name accompanies the auth descriptor: a local
|
||||
* server's address is a user choice, never read from `process.env`.
|
||||
*/
|
||||
|
||||
import type { ConnectorDefinition } from "../connector-definitions";
|
||||
|
||||
export const LOCAL_CONNECTORS: ConnectorDefinition[] = [
|
||||
{
|
||||
id: "ollama",
|
||||
provider: "ollama",
|
||||
label: "Ollama",
|
||||
description:
|
||||
"Local Ollama server (https://ollama.com) — runs open-weights models on your own machine through its OpenAI-compatible endpoint.",
|
||||
defaultModel: "llama3.2",
|
||||
auth: { type: "local", defaultBaseUrl: "http://localhost:11434/v1" },
|
||||
notes:
|
||||
"Ollama exposes /v1/chat/completions and /v1/models. The default port is 11434; override if you launched `ollama serve` on a different address.",
|
||||
},
|
||||
{
|
||||
id: "lm-studio",
|
||||
provider: "lm-studio",
|
||||
label: "LM Studio",
|
||||
description:
|
||||
"Local LM Studio server (https://lmstudio.ai) — host any loaded model through its OpenAI-compatible endpoint.",
|
||||
defaultModel: "local-model",
|
||||
auth: { type: "local", defaultBaseUrl: "http://localhost:1234/v1" },
|
||||
notes:
|
||||
"Enable LM Studio's local server from the Developer tab; the default port is 1234. Model ids match whatever you have loaded.",
|
||||
},
|
||||
];
|
||||
@@ -109,6 +109,15 @@ export function createConnectorHealthService({
|
||||
}
|
||||
return { status: "ready", authState: "ok", lastError: null };
|
||||
}
|
||||
case "local": {
|
||||
if (stored?.kind !== "local") {
|
||||
return { status: "auth_required", authState: "required", lastError: "Local server URL not configured." };
|
||||
}
|
||||
if (!stored.value.baseUrl) {
|
||||
return { status: "error", authState: "error", lastError: "Local server URL missing." };
|
||||
}
|
||||
return { status: "ready", authState: "ok", lastError: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ const RESERVED_ENV_BINDING_KEYS = new Set([
|
||||
const LOCAL_MODEL_RESOLVER_PROVIDERS: ReadonlySet<LocalModelResolverProvider> = new Set([
|
||||
"fireworks",
|
||||
"fireworks-firepass",
|
||||
"ollama",
|
||||
"lm-studio",
|
||||
]);
|
||||
const PI_KNOWN_PROVIDER_IDS: ReadonlySet<string> = new Set(getProviders());
|
||||
|
||||
@@ -110,6 +112,11 @@ function renderConfigurationDoc(definition: ConnectorDefinition): string {
|
||||
definition.label,
|
||||
"Uses Google Cloud Application Default Credentials. Run `gcloud auth application-default login` or set GOOGLE_APPLICATION_CREDENTIALS.",
|
||||
);
|
||||
case "local":
|
||||
return buildCloudDoc(
|
||||
definition.label,
|
||||
`OpenAI-compatible local server. Default base URL: ${definition.auth.defaultBaseUrl}. Configure under Onboarding → Providers (Advanced) and pick a model id that your server actually serves.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ export class CredentialError extends UpstreamError {
|
||||
export interface ResolvedCredential {
|
||||
apiKey: string;
|
||||
extraEnv?: Record<string, string>;
|
||||
/** Base URL for OpenAI-compatible local servers (Ollama, LM Studio). */
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export class CredentialResolver {
|
||||
@@ -144,6 +146,16 @@ export class CredentialResolver {
|
||||
if (cfg.credentialsPath) env.GOOGLE_APPLICATION_CREDENTIALS = cfg.credentialsPath;
|
||||
return { apiKey: "", extraEnv: env };
|
||||
}
|
||||
case "local": {
|
||||
if (stored?.kind !== "local") {
|
||||
throw new CredentialError("CREDENTIAL_MISSING", connectorId);
|
||||
}
|
||||
// Ollama and LM Studio ignore the bearer value but the OpenAI SDK
|
||||
// refuses an empty string, so we hand it the connector id as a
|
||||
// stable non-empty placeholder. The actual transport is keyed off
|
||||
// `baseUrl`, surfaced to the worker through the stream model.
|
||||
return { apiKey: connectorId, baseUrl: stored.value.baseUrl };
|
||||
}
|
||||
default: {
|
||||
const exhaustive: never = auth;
|
||||
throw new CredentialError("CREDENTIAL_INVALID", connectorId, `Unsupported auth descriptor: ${JSON.stringify(exhaustive)}`);
|
||||
|
||||
@@ -39,6 +39,8 @@ export function deriveDisplayHint(payload: StoredCredentialPayload): string {
|
||||
: "no-expiry";
|
||||
return `oauth:expires=${expiresLabel}`;
|
||||
}
|
||||
case "local":
|
||||
return `local:${payload.value.baseUrl}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
AwsCredential,
|
||||
AzureCredential,
|
||||
GcpAdcCredential,
|
||||
LocalCredential,
|
||||
} from "@shared/contracts/schemas-credentials";
|
||||
|
||||
/**
|
||||
@@ -25,6 +26,7 @@ export type StoredCredentialPayload =
|
||||
| { kind: "api_key"; value: ApiKeyValue }
|
||||
| { kind: "azure"; value: AzureCredential }
|
||||
| { kind: "aws"; value: AwsCredential }
|
||||
| { kind: "gcp_adc"; value: GcpAdcCredential };
|
||||
| { kind: "gcp_adc"; value: GcpAdcCredential }
|
||||
| { kind: "local"; value: LocalCredential };
|
||||
|
||||
export type CredentialKind = StoredCredentialPayload["kind"];
|
||||
@@ -2,7 +2,7 @@ import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { nowIso } from "./helpers";
|
||||
|
||||
export type CredentialKind = "oauth" | "api_key" | "aws" | "azure" | "gcp_adc";
|
||||
export type CredentialKind = "oauth" | "api_key" | "aws" | "azure" | "gcp_adc" | "local";
|
||||
export type CredentialStatus = "active" | "expired" | "revoked" | "error";
|
||||
export type CredentialBackend = "safe_storage" | "vault";
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ export function registerCredentialIpcHandlers(deps: CredentialHandlerDeps): () =
|
||||
}
|
||||
const provider = definition.provider;
|
||||
const defaultModel = definition.defaultModel;
|
||||
const model = resolveModel(provider, defaultModel);
|
||||
const model = resolveModel(provider, defaultModel, { baseUrl: resolved.baseUrl });
|
||||
if (!model) return ok({ ok: false, message: `Unknown model ${defaultModel} for provider ${provider}` });
|
||||
const extraHeaders = resolveExtraStreamHeaders(provider, resolved.apiKey);
|
||||
const options: SimpleStreamOptions = {
|
||||
|
||||
@@ -184,8 +184,18 @@ export function createMainRuntimeSupport(state: MainRuntimeState) {
|
||||
// user has selected a company; the onboarding path uses this
|
||||
// catalog directly because no company exists yet.
|
||||
const { getConnectorDefinition } = await import("./connectors");
|
||||
const { resolveModels } = await import("./model-resolver");
|
||||
const { resolveModels, fetchLocalConnectorModels } = await import("./model-resolver");
|
||||
const definition = getConnectorDefinition(connectorId as ConnectorId);
|
||||
if (definition.auth.type === "local") {
|
||||
// Local servers' catalogs depend on the user's installed/loaded
|
||||
// models. Read the saved baseUrl and ask the server directly.
|
||||
const piRuntime = requireServiceGraph().getPiRuntime();
|
||||
const credentials = piRuntime?.credentialStore.read(connectorId);
|
||||
const baseUrl = credentials?.kind === "local" ? credentials.value.baseUrl : null;
|
||||
if (!baseUrl) return [];
|
||||
const models = await fetchLocalConnectorModels(baseUrl);
|
||||
return models.map((m) => ({ id: m.id, label: m.name }));
|
||||
}
|
||||
return resolveModels(definition.provider).map((m) => ({ id: m.id, label: m.name }));
|
||||
},
|
||||
logger: log,
|
||||
|
||||
@@ -53,6 +53,21 @@ const FIREWORKS_CURATED_MODELS: Array<{
|
||||
const FIREWORKS_FIREPASS_COST = { input: 0, output: 0 };
|
||||
const FIREWORKS_FALLBACK_COST = { input: 0.50, output: 0.50 };
|
||||
|
||||
// Local OpenAI-compatible servers (Ollama, LM Studio) bill the user's own
|
||||
// hardware, so the surfaced cost is zero. Context window varies wildly
|
||||
// across self-hosted models; the configured window is conservative
|
||||
// enough to keep pi's compactor honest without rejecting reasonable
|
||||
// requests on smaller models.
|
||||
const LOCAL_DEFAULT_COST = { input: 0, output: 0 };
|
||||
const LOCAL_DEFAULT_CONTEXT = 32_768;
|
||||
const LOCAL_DEFAULT_MAX_TOKENS = 8192;
|
||||
|
||||
type LocalOpenAiCompatProvider = "ollama" | "lm-studio";
|
||||
|
||||
function isLocalOpenAiCompatProvider(provider: string): provider is LocalOpenAiCompatProvider {
|
||||
return provider === "ollama" || provider === "lm-studio";
|
||||
}
|
||||
|
||||
function buildFireworksModel(
|
||||
modelId: string,
|
||||
name?: string,
|
||||
@@ -74,7 +89,30 @@ function buildFireworksModel(
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveModel(provider: string, modelId: string): Model<Api> | null {
|
||||
function buildLocalModel(
|
||||
provider: LocalOpenAiCompatProvider,
|
||||
modelId: string,
|
||||
baseUrl: string,
|
||||
): Model<"openai-completions"> {
|
||||
return {
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
api: "openai-completions",
|
||||
provider,
|
||||
baseUrl,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: LOCAL_DEFAULT_COST.input, output: LOCAL_DEFAULT_COST.output, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: LOCAL_DEFAULT_CONTEXT,
|
||||
maxTokens: LOCAL_DEFAULT_MAX_TOKENS,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveModel(
|
||||
provider: string,
|
||||
modelId: string,
|
||||
options?: { baseUrl?: string },
|
||||
): Model<Api> | null {
|
||||
if (provider === "fireworks-firepass") {
|
||||
return buildFireworksModel(FIREWORKS_FIREPASS_MODEL_ID, FIREWORKS_FIREPASS_MODEL_NAME, 262_144, FIREWORKS_FIREPASS_COST);
|
||||
}
|
||||
@@ -82,6 +120,13 @@ export function resolveModel(provider: string, modelId: string): Model<Api> | nu
|
||||
const curated = FIREWORKS_CURATED_MODELS.find((entry) => entry.id === modelId);
|
||||
return buildFireworksModel(modelId, curated?.name, curated?.contextWindow, curated?.cost);
|
||||
}
|
||||
if (isLocalOpenAiCompatProvider(provider)) {
|
||||
if (!options?.baseUrl) {
|
||||
log.warn(`[model-resolver] ${provider} requires a baseUrl from credentials; none provided.`);
|
||||
return null;
|
||||
}
|
||||
return buildLocalModel(provider, modelId, options.baseUrl);
|
||||
}
|
||||
if (!isPiKnownProvider(provider)) {
|
||||
log.warn(`[model-resolver] Unknown pi provider: ${provider}`);
|
||||
return null;
|
||||
@@ -144,6 +189,13 @@ export function resolveModels(provider: string): Array<{ id: string; name: strin
|
||||
if (provider === "fireworks") {
|
||||
return FIREWORKS_CURATED_MODELS.map(({ id, name }) => ({ id, name }));
|
||||
}
|
||||
if (isLocalOpenAiCompatProvider(provider)) {
|
||||
// Local-server catalogs depend on what the user has loaded and must be
|
||||
// fetched against the saved baseUrl. The sync resolver returns an
|
||||
// empty list; the renderer reaches the live catalog through
|
||||
// `listLocalConnectorModels` in `main-runtime-support.ts`.
|
||||
return [];
|
||||
}
|
||||
if (!isPiKnownProvider(provider)) {
|
||||
log.warn(`[model-resolver] Unknown pi provider: ${provider}`);
|
||||
return [];
|
||||
@@ -157,6 +209,37 @@ export function resolveModels(provider: string): Array<{ id: string; name: strin
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the live model list from a local OpenAI-compatible server.
|
||||
*
|
||||
* Both Ollama (`/v1/models`) and LM Studio (`/v1/models`) expose the
|
||||
* standard OpenAI catalog shape — `{ data: [{ id, ...}] }` — so a single
|
||||
* fetch path handles both. Returns an empty list when the server is
|
||||
* unreachable so the renderer can surface a "not running" message
|
||||
* instead of throwing.
|
||||
*/
|
||||
export async function fetchLocalConnectorModels(
|
||||
baseUrl: string,
|
||||
): Promise<Array<{ id: string; name: string }>> {
|
||||
const url = `${baseUrl.replace(/\/$/, "")}/models`;
|
||||
try {
|
||||
const response = await fetch(url, { method: "GET" });
|
||||
if (!response.ok) {
|
||||
log.warn(`[model-resolver] local model list ${url} returned HTTP ${response.status}`);
|
||||
return [];
|
||||
}
|
||||
const body = (await response.json()) as { data?: Array<{ id?: unknown }> };
|
||||
if (!Array.isArray(body.data)) return [];
|
||||
return body.data
|
||||
.map((row) => (typeof row?.id === "string" ? row.id : null))
|
||||
.filter((id): id is string => id !== null)
|
||||
.map((id) => ({ id, name: id }));
|
||||
} catch (err) {
|
||||
log.warn(`[model-resolver] fetch ${url} failed:`, err instanceof Error ? err.message : String(err));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra HTTP headers that must be merged into the streamSimple call for the
|
||||
* given provider. Returns undefined when no extra headers are required.
|
||||
|
||||
@@ -19,6 +19,8 @@ interface PrepareRunResult {
|
||||
provider: string;
|
||||
apiKey: string;
|
||||
extraEnv: Record<string, string>;
|
||||
/** OpenAI-compatible server base URL for local connectors (Ollama, LM Studio). */
|
||||
baseUrl: string | null;
|
||||
priorMessages: AgentMessage[];
|
||||
}
|
||||
|
||||
@@ -133,7 +135,9 @@ export class WorkerRunner {
|
||||
const runEnv = mergeRunEnvs(job.env, prepared.extraEnv);
|
||||
redact = buildRedactor(runEnv);
|
||||
|
||||
const model = resolveModel(prepared.provider, job.model);
|
||||
const model = resolveModel(prepared.provider, job.model, {
|
||||
baseUrl: prepared.baseUrl ?? undefined,
|
||||
});
|
||||
if (!model) {
|
||||
throw new Error(`Unknown model ${job.model} for provider ${prepared.provider}.`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -189,6 +189,9 @@ export const en: Record<string, string> = {
|
||||
"onboarding.providers.apiKeyLabel": "API key",
|
||||
"onboarding.providers.apiKeyRequired": "Enter an API key before saving.",
|
||||
"onboarding.providers.apiKeyReplacePlaceholder": "Leave empty to keep the current key",
|
||||
"onboarding.providers.baseUrlLabel": "Base URL",
|
||||
"onboarding.providers.baseUrlRequired": "Enter the local server URL before saving.",
|
||||
"onboarding.providers.baseUrlInvalid": "Enter a valid HTTP or HTTPS URL.",
|
||||
"onboarding.providers.save": "Save",
|
||||
"onboarding.providers.saving": "Saving…",
|
||||
"onboarding.providers.saved": "Saved.",
|
||||
@@ -1130,6 +1133,7 @@ export const en: Record<string, string> = {
|
||||
"providers.kindAzure": "Azure",
|
||||
"providers.kindAws": "AWS",
|
||||
"providers.kindGcp": "Vertex (ADC)",
|
||||
"providers.kindLocal": "Local",
|
||||
"providers.statusConnected": "Connected",
|
||||
"providers.statusExpired": "Expired",
|
||||
"providers.statusError": "Error",
|
||||
|
||||
@@ -189,6 +189,9 @@ export const zh: Record<string, string> = {
|
||||
"onboarding.providers.apiKeyLabel": "API Key",
|
||||
"onboarding.providers.apiKeyRequired": "请输入 API Key 后再保存。",
|
||||
"onboarding.providers.apiKeyReplacePlaceholder": "保持现有 Key 请留空",
|
||||
"onboarding.providers.baseUrlLabel": "服务地址",
|
||||
"onboarding.providers.baseUrlRequired": "请填入本地服务地址后再保存。",
|
||||
"onboarding.providers.baseUrlInvalid": "请输入合法的 HTTP 或 HTTPS 地址。",
|
||||
"onboarding.providers.save": "保存",
|
||||
"onboarding.providers.saving": "保存中…",
|
||||
"onboarding.providers.saved": "已保存。",
|
||||
@@ -1130,6 +1133,7 @@ export const zh: Record<string, string> = {
|
||||
"providers.kindAzure": "Azure",
|
||||
"providers.kindAws": "AWS",
|
||||
"providers.kindGcp": "Vertex (ADC)",
|
||||
"providers.kindLocal": "本地",
|
||||
"providers.statusConnected": "已连接",
|
||||
"providers.statusExpired": "已过期",
|
||||
"providers.statusError": "出错",
|
||||
|
||||
@@ -16,20 +16,22 @@ export type PiOAuthProviderId =
|
||||
| "google-gemini-cli"
|
||||
| "google-antigravity";
|
||||
|
||||
export type ConnectorAuthKind = "oauth" | "api_key" | "aws" | "azure" | "gcp_adc";
|
||||
export type ConnectorAuthKind = "oauth" | "api_key" | "aws" | "azure" | "gcp_adc" | "local";
|
||||
|
||||
export interface ConnectorAuthOAuth { type: "oauth"; oauthId: PiOAuthProviderId }
|
||||
export interface ConnectorAuthApiKey { type: "api_key"; envKey: string }
|
||||
export interface ConnectorAuthAws { type: "aws" }
|
||||
export interface ConnectorAuthAzure { type: "azure" }
|
||||
export interface ConnectorAuthGcpAdc { type: "gcp_adc" }
|
||||
export interface ConnectorAuthLocal { type: "local"; defaultBaseUrl: string }
|
||||
|
||||
export type ConnectorAuth =
|
||||
| ConnectorAuthOAuth
|
||||
| ConnectorAuthApiKey
|
||||
| ConnectorAuthAws
|
||||
| ConnectorAuthAzure
|
||||
| ConnectorAuthGcpAdc;
|
||||
| ConnectorAuthGcpAdc
|
||||
| ConnectorAuthLocal;
|
||||
|
||||
export interface ConnectorCatalogEntry {
|
||||
id: ConnectorId;
|
||||
@@ -242,6 +244,27 @@ const API_KEY_ENTRIES: ConnectorCatalogEntry[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const LOCAL_ENTRIES: ConnectorCatalogEntry[] = [
|
||||
{
|
||||
id: "ollama",
|
||||
provider: "ollama",
|
||||
label: "Ollama",
|
||||
description: "Local Ollama server — open-weights models on your own machine via the OpenAI-compatible endpoint.",
|
||||
defaultModel: "llama3.2",
|
||||
auth: { type: "local", defaultBaseUrl: "http://localhost:11434/v1" },
|
||||
notes: "Default port 11434. Override if you launched `ollama serve` elsewhere.",
|
||||
},
|
||||
{
|
||||
id: "lm-studio",
|
||||
provider: "lm-studio",
|
||||
label: "LM Studio",
|
||||
description: "Local LM Studio server — host any loaded model via the OpenAI-compatible endpoint.",
|
||||
defaultModel: "local-model",
|
||||
auth: { type: "local", defaultBaseUrl: "http://localhost:1234/v1" },
|
||||
notes: "Enable LM Studio's local server from the Developer tab. Default port 1234.",
|
||||
},
|
||||
];
|
||||
|
||||
const CLOUD_ENTRIES: ConnectorCatalogEntry[] = [
|
||||
{
|
||||
id: "amazon-bedrock",
|
||||
@@ -277,6 +300,7 @@ export const CONNECTOR_CATALOG: ConnectorCatalogEntry[] = [
|
||||
...OAUTH_ENTRIES,
|
||||
...API_KEY_ENTRIES,
|
||||
...CLOUD_ENTRIES,
|
||||
...LOCAL_ENTRIES,
|
||||
];
|
||||
|
||||
export function findConnectorCatalogEntry(id: string): ConnectorCatalogEntry | undefined {
|
||||
|
||||
@@ -49,6 +49,9 @@ export const connectorIdEnum = z.enum([
|
||||
"amazon-bedrock",
|
||||
"azure-openai",
|
||||
"google-vertex",
|
||||
// Local (2)
|
||||
"ollama",
|
||||
"lm-studio",
|
||||
]);
|
||||
|
||||
export type ConnectorId = z.infer<typeof connectorIdEnum>;
|
||||
|
||||
@@ -6,6 +6,7 @@ export const credentialKindSchema = z.enum([
|
||||
"aws",
|
||||
"azure",
|
||||
"gcp_adc",
|
||||
"local",
|
||||
]);
|
||||
|
||||
export const credentialStatusSchema = z.enum([
|
||||
@@ -49,11 +50,16 @@ export const gcpAdcCredentialSchema = z.object({
|
||||
credentialsPath: z.string().optional(),
|
||||
});
|
||||
|
||||
export const localCredentialSchema = z.object({
|
||||
baseUrl: z.string().url(),
|
||||
});
|
||||
|
||||
export const credentialPayloadSchema = z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("api_key"), value: apiKeyValueSchema }),
|
||||
z.object({ kind: z.literal("azure"), value: azureCredentialSchema }),
|
||||
z.object({ kind: z.literal("aws"), value: awsCredentialSchema }),
|
||||
z.object({ kind: z.literal("gcp_adc"), value: gcpAdcCredentialSchema }),
|
||||
z.object({ kind: z.literal("local"), value: localCredentialSchema }),
|
||||
]);
|
||||
|
||||
export const saveCredentialInputSchema = z.object({
|
||||
@@ -85,4 +91,5 @@ export type SaveCredentialInput = z.infer<typeof saveCredentialInputSchema>;
|
||||
export type AwsCredential = z.infer<typeof awsCredentialSchema>;
|
||||
export type AzureCredential = z.infer<typeof azureCredentialSchema>;
|
||||
export type GcpAdcCredential = z.infer<typeof gcpAdcCredentialSchema>;
|
||||
export type LocalCredential = z.infer<typeof localCredentialSchema>;
|
||||
export type ApiKeyValue = z.infer<typeof apiKeyValueSchema>;
|
||||
@@ -107,12 +107,14 @@ describe("pi-mono migrations 27 + 28", () => {
|
||||
db.init();
|
||||
|
||||
const rows = db.projection.snapshot.listSnapshot().connectors.map((entry) => entry.id);
|
||||
expect(rows.length).toBe(26);
|
||||
expect(rows.length).toBe(28);
|
||||
expect(rows).toContain("claude-pro");
|
||||
expect(rows).toContain("amazon-bedrock");
|
||||
expect(rows).toContain("google-vertex");
|
||||
expect(rows).toContain("fireworks");
|
||||
expect(rows).toContain("fireworks-firepass");
|
||||
expect(rows).toContain("ollama");
|
||||
expect(rows).toContain("lm-studio");
|
||||
expect(rows.find((id) => (id as string) === "claude_local")).toBeUndefined();
|
||||
expect(rows.find((id) => (id as string) === "codex_local")).toBeUndefined();
|
||||
expect(rows.find((id) => (id as string) === "claude_compatible")).toBeUndefined();
|
||||
|
||||
@@ -7,14 +7,16 @@ import { findConnectorDefinition, listFeaturedOnboardingConnectors } from "../..
|
||||
* Settings panel) depends on. */
|
||||
|
||||
describe("CONNECTORS catalog", () => {
|
||||
it("contains exactly 26 connectors (5 OAuth + 18 API key + 3 Cloud)", () => {
|
||||
expect(CONNECTORS.length).toBe(26);
|
||||
it("contains exactly 28 connectors (5 OAuth + 18 API key + 3 Cloud + 2 Local)", () => {
|
||||
expect(CONNECTORS.length).toBe(28);
|
||||
const oauth = CONNECTORS.filter((c) => c.auth?.type === "oauth");
|
||||
const apiKey = CONNECTORS.filter((c) => c.auth?.type === "api_key");
|
||||
const cloud = CONNECTORS.filter((c) => c.auth ? ["aws", "azure", "gcp_adc"].includes(c.auth.type) : false);
|
||||
const local = CONNECTORS.filter((c) => c.auth?.type === "local");
|
||||
expect(oauth.length).toBe(5);
|
||||
expect(apiKey.length).toBe(18);
|
||||
expect(cloud.length).toBe(3);
|
||||
expect(local.length).toBe(2);
|
||||
});
|
||||
|
||||
it("uses unique connector ids", () => {
|
||||
@@ -39,7 +41,7 @@ describe("CONNECTORS catalog", () => {
|
||||
"minimax", "minimax-cn", "huggingface", "opencode", "opencode-go", "kimi-coding",
|
||||
]);
|
||||
// Providers wired through src/main/model-resolver.ts instead of pi's catalog.
|
||||
const localProviders = new Set(["fireworks", "fireworks-firepass"]);
|
||||
const localProviders = new Set(["fireworks", "fireworks-firepass", "ollama", "lm-studio"]);
|
||||
for (const connector of CONNECTORS) {
|
||||
const provider = connector.provider ?? "";
|
||||
const recognised = knownPiProviders.has(provider) || localProviders.has(provider);
|
||||
|
||||
Reference in new issue
Block a user