v0.2.3 Core tier 10/5/5 split + dynamic health version
This commit is contained in:
+17
-4
@@ -24,7 +24,12 @@ function defaultConfig() {
|
|||||||
relay_admin_password_salt: "",
|
relay_admin_password_salt: "",
|
||||||
relay_admin_session_secret: "",
|
relay_admin_session_secret: "",
|
||||||
relay_tier_quotas_json: JSON.stringify({
|
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 },
|
pro: { lifetime: null, monthly: 50, geminiCapMonthly: 25 },
|
||||||
max: { lifetime: null, monthly: null, geminiCapMonthly: 50 },
|
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
|
// 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() {
|
export async function getTierQuotas() {
|
||||||
const cfg = await getConfigSnapshot();
|
const cfg = await getConfigSnapshot();
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(cfg.relay_tier_quotas_json);
|
const parsed = JSON.parse(cfg.relay_tier_quotas_json);
|
||||||
return {
|
return {
|
||||||
core: {
|
core: {
|
||||||
lifetime: parsed?.core?.lifetime ?? 5,
|
lifetime: parsed?.core?.lifetime ?? 10,
|
||||||
|
geminiCapLifetime: parsed?.core?.geminiCapLifetime ?? 5,
|
||||||
monthly: parsed?.core?.monthly ?? null,
|
monthly: parsed?.core?.monthly ?? null,
|
||||||
geminiCapMonthly: parsed?.core?.geminiCapMonthly ?? null,
|
geminiCapMonthly: parsed?.core?.geminiCapMonthly ?? null,
|
||||||
},
|
},
|
||||||
@@ -92,7 +100,12 @@ export async function getTierQuotas() {
|
|||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
core: { lifetime: 5, monthly: null, geminiCapMonthly: null },
|
core: {
|
||||||
|
lifetime: 10,
|
||||||
|
geminiCapLifetime: 5,
|
||||||
|
monthly: null,
|
||||||
|
geminiCapMonthly: null,
|
||||||
|
},
|
||||||
pro: { lifetime: null, monthly: 50, geminiCapMonthly: 25 },
|
pro: { lifetime: null, monthly: 50, geminiCapMonthly: 25 },
|
||||||
max: { lifetime: null, monthly: null, geminiCapMonthly: 50 },
|
max: { lifetime: null, monthly: null, geminiCapMonthly: 50 },
|
||||||
};
|
};
|
||||||
|
|||||||
+26
-3
@@ -8,9 +8,10 @@
|
|||||||
// {
|
// {
|
||||||
// install_id: "uuid",
|
// install_id: "uuid",
|
||||||
// tier_snapshot: "core" | "pro" | "max", // last-seen tier
|
// 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
|
// 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
|
// monthly_gemini_consumed: number, // Gemini-only this month
|
||||||
// last_active_at: ISO-8601 string,
|
// last_active_at: ISO-8601 string,
|
||||||
// }
|
// }
|
||||||
@@ -63,6 +64,7 @@ function blankRow(installId) {
|
|||||||
install_id: installId,
|
install_id: installId,
|
||||||
tier_snapshot: "core",
|
tier_snapshot: "core",
|
||||||
lifetime_consumed: 0,
|
lifetime_consumed: 0,
|
||||||
|
lifetime_gemini_consumed: 0,
|
||||||
month: currentMonthKey(),
|
month: currentMonthKey(),
|
||||||
monthly_consumed: 0,
|
monthly_consumed: 0,
|
||||||
monthly_gemini_consumed: 0,
|
monthly_gemini_consumed: 0,
|
||||||
@@ -103,16 +105,31 @@ export async function getOrCreateRow(installId) {
|
|||||||
// Returns:
|
// Returns:
|
||||||
// { remaining: number | null, capped: "lifetime" | "monthly" | "none", gemini_remaining: number | null }
|
// { remaining: number | null, capped: "lifetime" | "monthly" | "none", gemini_remaining: number | null }
|
||||||
// `null` for remaining means "unlimited" (Max tier total).
|
// `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) {
|
export function computeRemaining(row, quota) {
|
||||||
const tier = row.tier_snapshot;
|
const tier = row.tier_snapshot;
|
||||||
const tierQuota = quota[tier] || quota.core;
|
const tierQuota = quota[tier] || quota.core;
|
||||||
|
|
||||||
if (tierQuota.lifetime != null) {
|
if (tierQuota.lifetime != null) {
|
||||||
const remaining = Math.max(0, tierQuota.lifetime - (row.lifetime_consumed || 0));
|
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 {
|
return {
|
||||||
remaining,
|
remaining,
|
||||||
capped: "lifetime",
|
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.
|
// 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 }) {
|
export async function commitCredit(installId, { backend, tier }) {
|
||||||
const row = await getOrCreateRow(installId);
|
const row = await getOrCreateRow(installId);
|
||||||
row.tier_snapshot = tier;
|
row.tier_snapshot = tier;
|
||||||
if (tier === "core") {
|
if (tier === "core") {
|
||||||
row.lifetime_consumed = (row.lifetime_consumed || 0) + 1;
|
row.lifetime_consumed = (row.lifetime_consumed || 0) + 1;
|
||||||
|
if (backend === "gemini") {
|
||||||
|
row.lifetime_gemini_consumed = (row.lifetime_gemini_consumed || 0) + 1;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
row.monthly_consumed = (row.monthly_consumed || 0) + 1;
|
row.monthly_consumed = (row.monthly_consumed || 0) + 1;
|
||||||
if (backend === "gemini") {
|
if (backend === "gemini") {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "recap-relay-server",
|
"name": "recap-relay-server",
|
||||||
"version": "0.1.0",
|
"version": "0.2.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+20
-1
@@ -3,8 +3,27 @@
|
|||||||
// /api/relay/status can verify the relay is reachable.
|
// /api/relay/status can verify the relay is reachable.
|
||||||
|
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
import { getConfigSnapshot } from "../config.js";
|
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() {
|
export function healthRouter() {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -13,7 +32,7 @@ export function healthRouter() {
|
|||||||
res.json({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
service: "recap-relay",
|
service: "recap-relay",
|
||||||
version: "0.1.0",
|
version: VERSION,
|
||||||
backends: {
|
backends: {
|
||||||
gemini: !!cfg.relay_gemini_api_key,
|
gemini: !!cfg.relay_gemini_api_key,
|
||||||
parakeet: !!cfg.relay_parakeet_base_url,
|
parakeet: !!cfg.relay_parakeet_base_url,
|
||||||
|
|||||||
@@ -14,7 +14,20 @@ const inputSpec = InputSpec.of({
|
|||||||
core_lifetime: Value.number({
|
core_lifetime: Value.number({
|
||||||
name: 'Core — Lifetime Credits',
|
name: 'Core — Lifetime Credits',
|
||||||
description:
|
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,
|
required: true,
|
||||||
default: 5,
|
default: 5,
|
||||||
min: 0,
|
min: 0,
|
||||||
@@ -91,7 +104,8 @@ export const adjustTierQuotas = sdk.Action.withInput(
|
|||||||
parsed = {}
|
parsed = {}
|
||||||
}
|
}
|
||||||
return {
|
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_monthly: parsed?.pro?.monthly ?? 50,
|
||||||
pro_gemini_cap: parsed?.pro?.geminiCapMonthly ?? 25,
|
pro_gemini_cap: parsed?.pro?.geminiCapMonthly ?? 25,
|
||||||
max_gemini_cap: parsed?.max?.geminiCapMonthly ?? 50,
|
max_gemini_cap: parsed?.max?.geminiCapMonthly ?? 50,
|
||||||
@@ -101,7 +115,8 @@ export const adjustTierQuotas = sdk.Action.withInput(
|
|||||||
async ({ effects, input }) => {
|
async ({ effects, input }) => {
|
||||||
const quotas = {
|
const quotas = {
|
||||||
core: {
|
core: {
|
||||||
lifetime: input.core_lifetime ?? 5,
|
lifetime: input.core_lifetime ?? 10,
|
||||||
|
geminiCapLifetime: input.core_gemini_cap ?? 5,
|
||||||
monthly: null,
|
monthly: null,
|
||||||
geminiCapMonthly: null,
|
geminiCapMonthly: null,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -62,7 +62,16 @@ export const configFile = FileHelper.json(
|
|||||||
// "Adjust Tier Quotas" action without a code change or restart.
|
// "Adjust Tier Quotas" action without a code change or restart.
|
||||||
relay_tier_quotas_json: z.string().default(
|
relay_tier_quotas_json: z.string().default(
|
||||||
JSON.stringify({
|
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 },
|
pro: { lifetime: null, monthly: 50, geminiCapMonthly: 25 },
|
||||||
max: { lifetime: null, monthly: null, geminiCapMonthly: 50 },
|
max: { lifetime: null, monthly: null, geminiCapMonthly: 50 },
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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_0 } from './v0.2.0'
|
||||||
import { v_0_2_1 } from './v0.2.1'
|
import { v_0_2_1 } from './v0.2.1'
|
||||||
import { v_0_2_2 } from './v0.2.2'
|
import { v_0_2_2 } from './v0.2.2'
|
||||||
|
import { v_0_2_3 } from './v0.2.3'
|
||||||
|
|
||||||
export const versionGraph = VersionGraph.of({
|
export const versionGraph = VersionGraph.of({
|
||||||
current: v_0_2_2,
|
current: v_0_2_3,
|
||||||
other: [v_0_2_1, v_0_2_0, v_0_1_0],
|
other: [v_0_2_2, v_0_2_1, v_0_2_0, v_0_1_0],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user