From 6797aae40488f58c75514fd74fb96f2e2b01f4e0 Mon Sep 17 00:00:00 2001 From: local Date: Mon, 11 May 2026 21:53:50 -0500 Subject: [PATCH] v0.2.3 Core tier 10/5/5 split + dynamic health version --- server/config.js | 21 +++++++++++--- server/credits.js | 29 +++++++++++++++++-- server/package.json | 2 +- server/routes/health.js | 21 +++++++++++++- startos/actions/adjustTierQuotas.ts | 21 ++++++++++++-- startos/file-models/config.json.ts | 11 +++++++- startos/versions/index.ts | 5 ++-- startos/versions/v0.2.3.ts | 43 +++++++++++++++++++++++++++++ 8 files changed, 138 insertions(+), 15 deletions(-) create mode 100644 startos/versions/v0.2.3.ts diff --git a/server/config.js b/server/config.js index 365b0d5..d62a6cb 100644 --- a/server/config.js +++ b/server/config.js @@ -24,7 +24,12 @@ function defaultConfig() { relay_admin_password_salt: "", relay_admin_session_secret: "", relay_tier_quotas_json: JSON.stringify({ - core: { lifetime: 5, monthly: null, geminiCapMonthly: null }, + core: { + lifetime: 10, + geminiCapLifetime: 5, + monthly: null, + geminiCapMonthly: null, + }, pro: { lifetime: null, monthly: 50, geminiCapMonthly: 25 }, max: { lifetime: null, monthly: null, geminiCapMonthly: 50 }, }), @@ -68,14 +73,17 @@ export async function getConfigSnapshot() { } // Parsed view of relay_tier_quotas_json, with safe fallbacks if the -// blob is missing or malformed. +// blob is missing or malformed. geminiCapLifetime is the new field +// added in relay 0.2.3 — splits a Core install's lifetime budget into +// Gemini-served vs hardware-served credits. export async function getTierQuotas() { const cfg = await getConfigSnapshot(); try { const parsed = JSON.parse(cfg.relay_tier_quotas_json); return { core: { - lifetime: parsed?.core?.lifetime ?? 5, + lifetime: parsed?.core?.lifetime ?? 10, + geminiCapLifetime: parsed?.core?.geminiCapLifetime ?? 5, monthly: parsed?.core?.monthly ?? null, geminiCapMonthly: parsed?.core?.geminiCapMonthly ?? null, }, @@ -92,7 +100,12 @@ export async function getTierQuotas() { }; } catch { return { - core: { lifetime: 5, monthly: null, geminiCapMonthly: null }, + core: { + lifetime: 10, + geminiCapLifetime: 5, + monthly: null, + geminiCapMonthly: null, + }, pro: { lifetime: null, monthly: 50, geminiCapMonthly: 25 }, max: { lifetime: null, monthly: null, geminiCapMonthly: 50 }, }; diff --git a/server/credits.js b/server/credits.js index a547885..994fab6 100644 --- a/server/credits.js +++ b/server/credits.js @@ -8,9 +8,10 @@ // { // install_id: "uuid", // tier_snapshot: "core" | "pro" | "max", // last-seen tier -// lifetime_consumed: number, // for Core lifetime cap +// lifetime_consumed: number, // total Core credits ever used +// lifetime_gemini_consumed: number, // Core credits served by Gemini // month: "YYYY-MM", // calendar-month key -// monthly_consumed: number, // total this month +// monthly_consumed: number, // total this month (paid tiers) // monthly_gemini_consumed: number, // Gemini-only this month // last_active_at: ISO-8601 string, // } @@ -63,6 +64,7 @@ function blankRow(installId) { install_id: installId, tier_snapshot: "core", lifetime_consumed: 0, + lifetime_gemini_consumed: 0, month: currentMonthKey(), monthly_consumed: 0, monthly_gemini_consumed: 0, @@ -103,16 +105,31 @@ export async function getOrCreateRow(installId) { // Returns: // { remaining: number | null, capped: "lifetime" | "monthly" | "none", gemini_remaining: number | null } // `null` for remaining means "unlimited" (Max tier total). +// `null` for gemini_remaining means "no Gemini cap on this tier" — the +// router can always pick Gemini. export function computeRemaining(row, quota) { const tier = row.tier_snapshot; const tierQuota = quota[tier] || quota.core; if (tierQuota.lifetime != null) { const remaining = Math.max(0, tierQuota.lifetime - (row.lifetime_consumed || 0)); + // Core tier may carve out a portion of the lifetime budget for + // Gemini specifically (geminiCapLifetime). When set, remaining + // Gemini credits = cap - lifetime_gemini_consumed; the rest of + // the lifetime budget falls through to operator hardware. When + // null, lifetime tier ignores the Gemini/hardware split and uses + // whichever backend is available. + const geminiRemaining = + tierQuota.geminiCapLifetime == null + ? null + : Math.max( + 0, + tierQuota.geminiCapLifetime - (row.lifetime_gemini_consumed || 0) + ); return { remaining, capped: "lifetime", - gemini_remaining: null, // lifetime tier doesn't split Gemini/hardware + gemini_remaining: geminiRemaining, }; } @@ -158,11 +175,17 @@ export function planBackend(row, quota, { hasHardware }) { } // Debit one credit on a successful call. Persists immediately. +// Tracks Gemini-vs-hardware separately for Core (lifetime_gemini_consumed) +// and paid tiers (monthly_gemini_consumed) so the planner can enforce +// the per-tier Gemini cap. export async function commitCredit(installId, { backend, tier }) { const row = await getOrCreateRow(installId); row.tier_snapshot = tier; if (tier === "core") { row.lifetime_consumed = (row.lifetime_consumed || 0) + 1; + if (backend === "gemini") { + row.lifetime_gemini_consumed = (row.lifetime_gemini_consumed || 0) + 1; + } } else { row.monthly_consumed = (row.monthly_consumed || 0) + 1; if (backend === "gemini") { diff --git a/server/package.json b/server/package.json index 64a1f87..b4a729d 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "recap-relay-server", - "version": "0.1.0", + "version": "0.2.3", "type": "module", "private": true, "dependencies": { diff --git a/server/routes/health.js b/server/routes/health.js index 3e1ef21..e5c8da4 100644 --- a/server/routes/health.js +++ b/server/routes/health.js @@ -3,8 +3,27 @@ // /api/relay/status can verify the relay is reachable. import express from "express"; +import { readFileSync } from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; import { getConfigSnapshot } from "../config.js"; +// Pull the version off server/package.json once at module load — the +// build pipeline bumps that file in lockstep with each StartOS version +// bump, so the health endpoint always reports a meaningful number. +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +let VERSION = "unknown"; +try { + const pkg = JSON.parse( + readFileSync(path.join(__dirname, "..", "package.json"), "utf8") + ); + VERSION = pkg.version || "unknown"; +} catch { + // Read failure is non-fatal — health still works, just reports + // "unknown". Build pipeline shouldn't ever ship a missing + // package.json so this branch is defensive only. +} + export function healthRouter() { const router = express.Router(); @@ -13,7 +32,7 @@ export function healthRouter() { res.json({ ok: true, service: "recap-relay", - version: "0.1.0", + version: VERSION, backends: { gemini: !!cfg.relay_gemini_api_key, parakeet: !!cfg.relay_parakeet_base_url, diff --git a/startos/actions/adjustTierQuotas.ts b/startos/actions/adjustTierQuotas.ts index e56b48a..040048e 100644 --- a/startos/actions/adjustTierQuotas.ts +++ b/startos/actions/adjustTierQuotas.ts @@ -14,7 +14,20 @@ const inputSpec = InputSpec.of({ core_lifetime: Value.number({ name: 'Core — Lifetime Credits', description: - 'Total credits a Core (unlicensed) install can ever spend. Default 5.', + 'Total credits a Core (unlicensed) install can ever spend across its lifetime. Default 10 (5 served via Gemini + 5 via operator hardware).', + required: true, + default: 10, + min: 0, + max: 1_000_000, + integer: true, + step: 1, + units: 'credits', + placeholder: null, + }), + core_gemini_cap: Value.number({ + name: 'Core — Gemini Cap (lifetime)', + description: + 'Within the Core lifetime allowance, how many credits may be served via Gemini (the rest spill to the operator-hardware fallback). Default 5.', required: true, default: 5, min: 0, @@ -91,7 +104,8 @@ export const adjustTierQuotas = sdk.Action.withInput( parsed = {} } return { - core_lifetime: parsed?.core?.lifetime ?? 5, + core_lifetime: parsed?.core?.lifetime ?? 10, + core_gemini_cap: parsed?.core?.geminiCapLifetime ?? 5, pro_monthly: parsed?.pro?.monthly ?? 50, pro_gemini_cap: parsed?.pro?.geminiCapMonthly ?? 25, max_gemini_cap: parsed?.max?.geminiCapMonthly ?? 50, @@ -101,7 +115,8 @@ export const adjustTierQuotas = sdk.Action.withInput( async ({ effects, input }) => { const quotas = { core: { - lifetime: input.core_lifetime ?? 5, + lifetime: input.core_lifetime ?? 10, + geminiCapLifetime: input.core_gemini_cap ?? 5, monthly: null, geminiCapMonthly: null, }, diff --git a/startos/file-models/config.json.ts b/startos/file-models/config.json.ts index ec697c8..39bc986 100644 --- a/startos/file-models/config.json.ts +++ b/startos/file-models/config.json.ts @@ -62,7 +62,16 @@ export const configFile = FileHelper.json( // "Adjust Tier Quotas" action without a code change or restart. relay_tier_quotas_json: z.string().default( JSON.stringify({ - core: { lifetime: 5, monthly: null, geminiCapMonthly: null }, + // Core: 10 lifetime credits total — first 5 served via Gemini + // (operator's cloud spend), final 5 fall through to operator + // hardware so the user can keep going on free tier without + // costing the operator more cloud $. + core: { + lifetime: 10, + geminiCapLifetime: 5, + monthly: null, + geminiCapMonthly: null, + }, pro: { lifetime: null, monthly: 50, geminiCapMonthly: 25 }, max: { lifetime: null, monthly: null, geminiCapMonthly: 50 }, }), diff --git a/startos/versions/index.ts b/startos/versions/index.ts index 37e62aa..69e4f00 100644 --- a/startos/versions/index.ts +++ b/startos/versions/index.ts @@ -3,8 +3,9 @@ import { v_0_1_0 } from './v0.1.0' import { v_0_2_0 } from './v0.2.0' import { v_0_2_1 } from './v0.2.1' import { v_0_2_2 } from './v0.2.2' +import { v_0_2_3 } from './v0.2.3' export const versionGraph = VersionGraph.of({ - current: v_0_2_2, - other: [v_0_2_1, v_0_2_0, v_0_1_0], + current: v_0_2_3, + other: [v_0_2_2, v_0_2_1, v_0_2_0, v_0_1_0], }) diff --git a/startos/versions/v0.2.3.ts b/startos/versions/v0.2.3.ts new file mode 100644 index 0000000..c4d5c2d --- /dev/null +++ b/startos/versions/v0.2.3.ts @@ -0,0 +1,43 @@ +import { VersionInfo } from '@start9labs/start-sdk' +import { configFile } from '../file-models/config.json' + +export const v_0_2_3 = VersionInfo.of({ + version: '0.2.3:0', + releaseNotes: { + en_US: + 'Core tier reworked: 10 lifetime credits (was 5), with the first 5 served via Gemini and the next 5 via operator hardware. /relay/health now reports the actual package version. Existing installs migrate their saved tier-quota blob automatically on upgrade.', + }, + migrations: { + // Update the saved tier-quota JSON for installs that came up under + // v0.2.0–v0.2.2 (Core: 5 lifetime, all Gemini). Idempotent — if + // the operator has already moved away from the old defaults via + // Adjust Tier Quotas, we leave their values alone. + up: async ({ effects }) => { + const current = await configFile.read().once() + if (!current?.relay_tier_quotas_json) return + let parsed: any = {} + try { + parsed = JSON.parse(current.relay_tier_quotas_json) + } catch { + return + } + // Migrate only the legacy default shape — operator-edited blobs + // are left alone. The legacy shape was exactly: core.lifetime=5 + // AND no geminiCapLifetime field present. + const isLegacyCore = + parsed?.core?.lifetime === 5 && + parsed?.core?.geminiCapLifetime === undefined + if (!isLegacyCore) return + parsed.core = { + lifetime: 10, + geminiCapLifetime: 5, + monthly: null, + geminiCapMonthly: null, + } + await configFile.merge(effects, { + relay_tier_quotas_json: JSON.stringify(parsed), + }) + }, + down: async ({ effects }) => {}, + }, +})