// Provider registry. Each provider wraps a single LLM/SDK behind a // uniform interface (see ./gemini.js for the reference shape). The rest // of the server talks to providers through getProvider() and never // imports SDKs directly. // // Adding a new provider: // 1. Create ./.js exporting createXxxProvider({ apiKey, ... }). // 2. Add it to PROVIDER_NAMES + the switch in getProvider(). // 3. Add the matching opts shape to PROVIDER_KEY_FIELDS so // resolveProviderOpts() can pull the right key/baseURL out of the // StartOS config. // 4. Wire its config field into startos/file-models/config.json.ts // and add a "Set Key" StartOS action. // // Capabilities (see provider.capabilities) signal what each one can do. // Some providers analyze but can't transcribe (Claude, OpenAI-compat, // Ollama); the orchestration layer in server/index.js can mix providers // across the transcription + analysis pipelines. import { createGeminiProvider } from "./gemini.js"; import { createAnthropicProvider } from "./anthropic.js"; import { createOpenAIProvider } from "./openai.js"; import { createOpenAICompatibleProvider } from "./openai-compatible.js"; import { createOllamaProvider } from "./ollama.js"; import { createWhisperProvider } from "./whisper.js"; import { createRelayProvider } from "./relay.js"; import { getInstallId } from "../install-id.js"; import { getRawLicenseKey } from "../license.js"; import { getRelayBaseURL } from "../relay-default.js"; export const PROVIDER_NAMES = [ "gemini", "anthropic", "openai", "openai-compatible", "ollama", "whisper", "relay", ]; // Map provider name → which fields to read from the StartOS config blob // when resolving its construction opts. Used by resolveProviderOpts(). export const PROVIDER_KEY_FIELDS = { gemini: { apiKey: "gemini_api_key" }, anthropic: { apiKey: "anthropic_api_key" }, openai: { apiKey: "openai_api_key" }, "openai-compatible": { apiKey: "openai_compatible_api_key", baseURL: "openai_compatible_base_url", }, ollama: { baseURL: "ollama_base_url" }, whisper: { apiKey: "whisper_api_key", baseURL: "whisper_base_url", }, // Relay is operator-only — base URL is HARDCODED in // server/relay-default.js, NOT read from StartOS config. The empty // object is intentional: resolveProviderOpts uses `name in // PROVIDER_KEY_FIELDS` to recognise the provider, then the // relay-specific block at the bottom of resolveProviderOpts // injects baseURL + installId + licenseKey server-side. Without // this entry the lookup throws "Unknown provider: relay" before // reaching the injection block. relay: {}, }; export function getProvider(name, opts = {}) { switch (name) { case "gemini": return createGeminiProvider(opts); case "anthropic": return createAnthropicProvider(opts); case "openai": return createOpenAIProvider(opts); case "openai-compatible": return createOpenAICompatibleProvider(opts); case "ollama": return createOllamaProvider(opts); case "whisper": return createWhisperProvider(opts); case "relay": return createRelayProvider(opts); default: throw new Error( `Unknown provider: ${name}. Available: ${PROVIDER_NAMES.join(", ")}` ); } } // Pull the construction opts for a provider out of the StartOS config // blob, optionally overridden per-provider by client-side opts the web // UI passed in the request body. // // `config` is the parsed startos-config.json snapshot. // `clientOpts` is { apiKey?, baseURL? } for THIS provider only — // typically a value out of req.body.providerOpts[name]. // // Resolution priority for each field: client opt → config opt. // Returns { apiKey?, baseURL? } as appropriate for the provider. export function resolveProviderOpts(name, { config = {}, clientOpts = {} } = {}) { const fields = PROVIDER_KEY_FIELDS[name]; if (!fields) { throw new Error(`Unknown provider: ${name}`); } const opts = {}; if (fields.apiKey) { const fromConfig = config[fields.apiKey] || ""; const fromClient = (clientOpts.apiKey || "").trim(); opts.apiKey = fromClient || fromConfig; } if (fields.baseURL) { const fromConfig = config[fields.baseURL] || ""; const fromClient = (clientOpts.baseURL || "").trim(); opts.baseURL = fromClient || fromConfig; // Last-resort fallback for Ollama: the canonical StartOS internal // hostname. Reachable when the optional Ollama dependency is // installed alongside Recap on the same StartOS server, even if // the user hasn't run the "Set Ollama Server URL" action. if (!opts.baseURL && name === "ollama") { opts.baseURL = "http://ollama.startos:11434"; } } // User-defined model list: providers with dynamic catalogs (ollama, // openai-compatible, whisper) accept a comma- or newline-separated // list of model names in clientOpts.models. Parse and pass through // as `defaultModels` so listTranscriptionModels / listAnalysisModels // return the right thing AND so the orchestration layer's fallback // chain knows what to walk through if the user's chosen model fails. if (typeof clientOpts.models === "string" && clientOpts.models.trim()) { const seen = new Set(); const models = clientOpts.models .split(/[,\n]/) .map((s) => s.trim()) .filter((s) => { if (!s || seen.has(s)) return false; seen.add(s); return true; }); if (models.length > 0) { opts.defaultModels = models; } } // Relay-specific injections: baseURL (hardcoded constant or env // override) + install-id (always) + license key (when present). // None of these come from clientOpts — relay identity + endpoint // must not be spoofable from a request body. if (name === "relay") { opts.baseURL = getRelayBaseURL(); opts.installId = getInstallId(); const rawKey = getRawLicenseKey(); if (rawKey) opts.licenseKey = rawKey; } return opts; }