Files
Keysat 0ae59f3550 Add multi-tenant cloud mode: self-serve purchase, credit metering, core-decoupling
Introduces RECAP_MODE=multi alongside single-mode self-host:
- Tenant auth + accounts (magic-link via System SMTP), per-tenant credit pool,
  anonymous trial minting with per-IP/-64 caps
- Self-serve Pro/Max purchase: inline Lightning (BTCPay) + card (Zaprite),
  prepaid 30-day periods, expiry-reminder emails
- Core-decoupling: relay owns cloud tier/expiry keyed by Recaps user-id
- SQLite (better-sqlite3) schema for multi-mode; filesystem unchanged for single
- StartOS actions/versions through 0.2.155
2026-06-13 14:25:05 -05:00

211 lines
8.5 KiB
JavaScript

// 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 ./<name>.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 <Provider> 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 };
}