diff --git a/src/main/agent-runtime/cost-writer.ts b/src/main/agent-runtime/cost-writer.ts index 21771062..45c6f1eb 100644 --- a/src/main/agent-runtime/cost-writer.ts +++ b/src/main/agent-runtime/cost-writer.ts @@ -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 = 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}`); diff --git a/src/main/agent-runtime/runtime-bridge.ts b/src/main/agent-runtime/runtime-bridge.ts index 812a6023..0b4b4e3f 100644 --- a/src/main/agent-runtime/runtime-bridge.ts +++ b/src/main/agent-runtime/runtime-bridge.ts @@ -440,6 +440,7 @@ export class RuntimeBridge { provider: definition.provider, apiKey: resolved.apiKey, extraEnv: resolved.extraEnv ?? {}, + baseUrl: resolved.baseUrl ?? null, priorMessages, }, }; diff --git a/src/main/connector-definitions.ts b/src/main/connector-definitions.ts index f906672f..cdd29e85 100644 --- a/src/main/connector-definitions.ts +++ b/src/main/connector-definitions.ts @@ -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, ]; diff --git a/src/main/connector-definitions/index.ts b/src/main/connector-definitions/index.ts index 51e9f64e..380227e0 100644 --- a/src/main/connector-definitions/index.ts +++ b/src/main/connector-definitions/index.ts @@ -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"; diff --git a/src/main/connector-definitions/local.ts b/src/main/connector-definitions/local.ts new file mode 100644 index 00000000..c538647a --- /dev/null +++ b/src/main/connector-definitions/local.ts @@ -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.", + }, +]; diff --git a/src/main/connector-health-service.ts b/src/main/connector-health-service.ts index 26228fcf..bfe4a085 100644 --- a/src/main/connector-health-service.ts +++ b/src/main/connector-health-service.ts @@ -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 }; + } } } diff --git a/src/main/connectors.ts b/src/main/connectors.ts index 20a1bc4e..34858856 100644 --- a/src/main/connectors.ts +++ b/src/main/connectors.ts @@ -21,6 +21,8 @@ const RESERVED_ENV_BINDING_KEYS = new Set([ const LOCAL_MODEL_RESOLVER_PROVIDERS: ReadonlySet = new Set([ "fireworks", "fireworks-firepass", + "ollama", + "lm-studio", ]); const PI_KNOWN_PROVIDER_IDS: ReadonlySet = 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.`, + ); } } diff --git a/src/main/credentials/credential-resolver.ts b/src/main/credentials/credential-resolver.ts index 2c719c64..5fa617da 100644 --- a/src/main/credentials/credential-resolver.ts +++ b/src/main/credentials/credential-resolver.ts @@ -34,6 +34,8 @@ export class CredentialError extends UpstreamError { export interface ResolvedCredential { apiKey: string; extraEnv?: Record; + /** 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)}`); diff --git a/src/main/credentials/credential-store.ts b/src/main/credentials/credential-store.ts index 92f0f0b2..9a88c1ea 100644 --- a/src/main/credentials/credential-store.ts +++ b/src/main/credentials/credential-store.ts @@ -39,6 +39,8 @@ export function deriveDisplayHint(payload: StoredCredentialPayload): string { : "no-expiry"; return `oauth:expires=${expiresLabel}`; } + case "local": + return `local:${payload.value.baseUrl}`; } } diff --git a/src/main/credentials/types.ts b/src/main/credentials/types.ts index aaa6e7b9..a247bab3 100644 --- a/src/main/credentials/types.ts +++ b/src/main/credentials/types.ts @@ -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"]; diff --git a/src/main/database/credentials.ts b/src/main/database/credentials.ts index 6b4f52cf..1c8b8692 100644 --- a/src/main/database/credentials.ts +++ b/src/main/database/credentials.ts @@ -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"; diff --git a/src/main/ipc-credentials-handlers.ts b/src/main/ipc-credentials-handlers.ts index 0ec38799..051e241e 100644 --- a/src/main/ipc-credentials-handlers.ts +++ b/src/main/ipc-credentials-handlers.ts @@ -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 = { diff --git a/src/main/main-runtime-support.ts b/src/main/main-runtime-support.ts index fabda301..f1862dab 100644 --- a/src/main/main-runtime-support.ts +++ b/src/main/main-runtime-support.ts @@ -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, diff --git a/src/main/model-resolver.ts b/src/main/model-resolver.ts index 8374a77b..5895330d 100644 --- a/src/main/model-resolver.ts +++ b/src/main/model-resolver.ts @@ -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 | 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 | 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 | 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> { + 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. diff --git a/src/main/worker/runtime/index.ts b/src/main/worker/runtime/index.ts index 6c1da392..e9b6888b 100644 --- a/src/main/worker/runtime/index.ts +++ b/src/main/worker/runtime/index.ts @@ -19,6 +19,8 @@ interface PrepareRunResult { provider: string; apiKey: string; extraEnv: Record; + /** 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}.`); } diff --git a/src/renderer/components/company-onboarding/provider-cards/ProviderCard.tsx b/src/renderer/components/company-onboarding/provider-cards/ProviderCard.tsx index ad49075f..c1a30dc3 100644 --- a/src/renderer/components/company-onboarding/provider-cards/ProviderCard.tsx +++ b/src/renderer/components/company-onboarding/provider-cards/ProviderCard.tsx @@ -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 ; + case "local": + return ; } } diff --git a/src/renderer/components/company-onboarding/provider-cards/local-card.tsx b/src/renderer/components/company-onboarding/provider-cards/local-card.tsx new file mode 100644 index 00000000..fbab8f41 --- /dev/null +++ b/src/renderer/components/company-onboarding/provider-cards/local-card.tsx @@ -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(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 ( + } + expanded={expanded} + onToggle={onToggle} + > + {entry.notes ? ( +
{entry.notes}
+ ) : null} +
{ + event.preventDefault(); + void handleSave(); + }} + className="space-y-3" + > +
+ + +
+
+ + {connected ? ( + + ) : null} +
+
+ +
+ ); +} diff --git a/src/renderer/components/settings/forms/LocalForm.tsx b/src/renderer/components/settings/forms/LocalForm.tsx new file mode 100644 index 00000000..caf28f90 --- /dev/null +++ b/src/renderer/components/settings/forms/LocalForm.tsx @@ -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(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 ( +
{ + event.preventDefault(); + void handleSubmit(); + }} + className="space-y-3" + > + + + {error ? ( +
+ {error} +
+ ) : null} + +
+ +
+
+ ); +} diff --git a/src/renderer/components/settings/providers/ProviderRow.tsx b/src/renderer/components/settings/providers/ProviderRow.tsx index fa8cd697..a716626b 100644 --- a/src/renderer/components/settings/providers/ProviderRow.tsx +++ b/src/renderer/components/settings/providers/ProviderRow.tsx @@ -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 ; if (entry.auth.type === "gcp_adc") return ; + if (entry.auth.type === "local") + return ; return null; }; diff --git a/src/renderer/i18n/en.ts b/src/renderer/i18n/en.ts index 5b14d459..43578420 100644 --- a/src/renderer/i18n/en.ts +++ b/src/renderer/i18n/en.ts @@ -189,6 +189,9 @@ export const en: Record = { "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 = { "providers.kindAzure": "Azure", "providers.kindAws": "AWS", "providers.kindGcp": "Vertex (ADC)", + "providers.kindLocal": "Local", "providers.statusConnected": "Connected", "providers.statusExpired": "Expired", "providers.statusError": "Error", diff --git a/src/renderer/i18n/zh.ts b/src/renderer/i18n/zh.ts index 97f25c7a..692b9272 100644 --- a/src/renderer/i18n/zh.ts +++ b/src/renderer/i18n/zh.ts @@ -189,6 +189,9 @@ export const zh: Record = { "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 = { "providers.kindAzure": "Azure", "providers.kindAws": "AWS", "providers.kindGcp": "Vertex (ADC)", + "providers.kindLocal": "本地", "providers.statusConnected": "已连接", "providers.statusExpired": "已过期", "providers.statusError": "出错", diff --git a/src/shared/connector-catalog.ts b/src/shared/connector-catalog.ts index dc9dc5c0..cb7b68ae 100644 --- a/src/shared/connector-catalog.ts +++ b/src/shared/connector-catalog.ts @@ -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 { diff --git a/src/shared/contracts/schemas-company.ts b/src/shared/contracts/schemas-company.ts index 7d3a15ee..75fc2aaa 100644 --- a/src/shared/contracts/schemas-company.ts +++ b/src/shared/contracts/schemas-company.ts @@ -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; diff --git a/src/shared/contracts/schemas-credentials.ts b/src/shared/contracts/schemas-credentials.ts index eb91e49f..4d58c5b4 100644 --- a/src/shared/contracts/schemas-credentials.ts +++ b/src/shared/contracts/schemas-credentials.ts @@ -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; export type AwsCredential = z.infer; export type AzureCredential = z.infer; export type GcpAdcCredential = z.infer; +export type LocalCredential = z.infer; export type ApiKeyValue = z.infer; diff --git a/tests/integration/pi-mono-migrations.spec.ts b/tests/integration/pi-mono-migrations.spec.ts index 4498dd70..c6f3b530 100644 --- a/tests/integration/pi-mono-migrations.spec.ts +++ b/tests/integration/pi-mono-migrations.spec.ts @@ -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(); diff --git a/tests/unit/connector-catalog.test.ts b/tests/unit/connector-catalog.test.ts index 1734c049..b53f4ffd 100644 --- a/tests/unit/connector-catalog.test.ts +++ b/tests/unit/connector-catalog.test.ts @@ -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);