0ae59f3550
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
211 lines
8.5 KiB
JavaScript
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 };
|
|
}
|