// 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, getRelayOperatorKey } 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]. // `req` is the Express request — only used for the relay provider in // multi-tenant mode, where the relay's install_id + license depend // on WHICH user is making the call. Pass it through whenever a // request is in scope. Single-mode + non-relay providers ignore it. // // Resolution priority for each field: client opt → config opt. // Returns { apiKey?, baseURL? } as appropriate for the provider. export function resolveProviderOpts(name, { config = {}, clientOpts = {}, req = null } = {}) { 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 + license key. None of these come from // clientOpts — relay identity + endpoint must not be spoofable from // a request body. // // Identity rules: // - single mode: always the operator install + operator license // - multi mode + signed-in user WITH their own keysat_license: // user's synthetic_install_id + user's license. The relay's // license-keyed credit ledger (Path 3) routes consumption to // the right user-pool. // - multi mode + free / trial / Core user OR signed-in user with // no license: operator's install + license. Their relay calls // are paid out of the operator's credit pool (tenant_credits // gates them locally to control fan-out). if (name === "relay") { opts.baseURL = getRelayBaseURL(); const ident = pickRelayIdentity(req); if (ident.cloud) { // Core-decoupling cloud identity: authenticate the server with the // operator key + name the user; no per-user Keysat license. opts.cloud = true; opts.userId = ident.userId; opts.operatorKey = ident.operatorKey; } else { opts.installId = ident.installId; if (ident.licenseKey) opts.licenseKey = ident.licenseKey; } } return opts; } // pickRelayIdentity(req) — single source of truth for "which (install_id, // license) do we present to the relay for THIS request". Centralized so // the rule doesn't drift across the four resolveProviderOpts callsites. function pickRelayIdentity(req) { // Single mode (or no request in scope, e.g. boot-time relay capability // probe): operator identity, period. if (!req || req.recapMode !== "multi") { return { installId: getInstallId(), licenseKey: getRawLicenseKey() || null }; } // Multi mode + PAID cloud user (core-decoupling): cloud identity — // authenticate the server with the operator key and name the user by // their Recaps account id. NO Keysat license; the relay owns the // tier, keyed by user-id. `req.user.tier` is the Recaps-side cache of // that relay tier (kept in sync by the operator grant flow). Falls // back to the operator pool when the operator key isn't configured. const tier = req.user?.tier; if ( req.user && req.user.id && !req.user.is_admin && (tier === "pro" || tier === "max") ) { const operatorKey = getRelayOperatorKey(); if (operatorKey) { return { cloud: true, userId: req.user.id, operatorKey }; } } // Multi mode + everyone else (admin, anon trial, signed-in free user, // family-share tenant on a self-hosted multi-tenant operator's box): // pay out of the operator's pool. return { installId: getInstallId(), licenseKey: getRawLicenseKey() || null }; }