From e612e8b8e8cecbec5ed159fbe39d465c7ecb0279 Mon Sep 17 00:00:00 2001 From: local Date: Mon, 11 May 2026 22:02:38 -0500 Subject: [PATCH] v0.2.4 max-monthly union + /relay/policy --- server/index.js | 2 + server/package.json | 2 +- server/routes/policy.js | 28 +++++++++++ startos/actions/adjustTierQuotas.ts | 73 ++++++++++++++++++++++++++--- startos/versions/index.ts | 5 +- startos/versions/v0.2.4.ts | 13 +++++ 6 files changed, 113 insertions(+), 10 deletions(-) create mode 100644 server/routes/policy.js create mode 100644 startos/versions/v0.2.4.ts diff --git a/server/index.js b/server/index.js index c09c1c7..740c0c7 100644 --- a/server/index.js +++ b/server/index.js @@ -22,6 +22,7 @@ import { transcribeRouter } from "./routes/transcribe.js"; import { analyzeRouter } from "./routes/analyze.js"; import { healthRouter } from "./routes/health.js"; import { balanceRouter } from "./routes/balance.js"; +import { policyRouter } from "./routes/policy.js"; import { adminRouter } from "./routes/admin.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -47,6 +48,7 @@ setupAdminAuthRoutes(app); // authenticates per-call via headers (X-Recap-Install-Id required, // Authorization optional). app.use("/relay", healthRouter()); +app.use("/relay", policyRouter()); app.use("/relay", balanceRouter()); app.use("/relay", transcribeRouter()); app.use("/relay", analyzeRouter()); diff --git a/server/package.json b/server/package.json index b4a729d..d07ab00 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "recap-relay-server", - "version": "0.2.3", + "version": "0.2.4", "type": "module", "private": true, "dependencies": { diff --git a/server/routes/policy.js b/server/routes/policy.js new file mode 100644 index 0000000..40f93da --- /dev/null +++ b/server/routes/policy.js @@ -0,0 +1,28 @@ +// GET /relay/policy — public, no auth. Returns the relay's current +// tier-quota config so Recap installs can show dynamic copy (e.g. the +// activation screen's "N relay credits" line stays in sync when the +// operator tunes the Core lifetime cap via the Adjust Tier Quotas +// StartOS action, with no Recap update required). +// +// Read-only, safe to expose publicly — the response is the same +// information the relay enforces against every request anyway. + +import express from "express"; +import { getTierQuotas } from "../config.js"; + +export function policyRouter() { + const router = express.Router(); + + router.get("/policy", async (_req, res) => { + const tiers = await getTierQuotas(); + res.json({ + tiers, + // Convenience field for Recap clients that just want "N credits + // for a fresh Core install" without re-deriving from tiers.core. + core_total_credits: tiers.core?.lifetime ?? null, + core_gemini_credits: tiers.core?.geminiCapLifetime ?? null, + }); + }); + + return router; +} diff --git a/startos/actions/adjustTierQuotas.ts b/startos/actions/adjustTierQuotas.ts index 040048e..660c7c5 100644 --- a/startos/actions/adjustTierQuotas.ts +++ b/startos/actions/adjustTierQuotas.ts @@ -1,14 +1,43 @@ import { sdk } from '../sdk' import { configFile } from '../file-models/config.json' -const { InputSpec, Value } = sdk +const { InputSpec, Value, Variants } = sdk // Operator-facing knob for tier-quota tuning without a code change or // redeploy. The schema is { core: TierConfig, pro: TierConfig, -// max: TierConfig } where TierConfig is -// { lifetime: number|null, monthly: number|null, geminiCapMonthly: number|null } -// null means "no cap on this dimension." The relay reads this on every -// request via configFile's live-reload. +// max: TierConfig } where TierConfig fields can be number or null. +// `null` means "no cap on this dimension." The relay reads this on +// every request via configFile's live-reload. + +// Max-tier monthly credits is rendered as a union — operator can pick +// "unlimited" (the default) or specify a hard monthly cap. Surfaced +// as a Value.union so the StartOS UI gives a clean radio-style choice +// rather than relying on a sentinel like 0 = unlimited. +const maxMonthlyVariants = Variants.of({ + unlimited: { + name: 'Unlimited', + spec: InputSpec.of({}), + }, + limited: { + name: 'Limit to a specific amount', + spec: InputSpec.of({ + amount: Value.number({ + name: 'Monthly Limit', + description: + 'Total credits each Max-tier install gets per calendar month.', + required: true, + default: 1000, + min: 0, + max: 1_000_000, + integer: true, + step: 1, + units: 'credits', + placeholder: null, + }), + }), + }, +}) + const inputSpec = InputSpec.of({ // Core tier knobs. core_lifetime: Value.number({ @@ -65,10 +94,19 @@ const inputSpec = InputSpec.of({ placeholder: null, }), // Max tier knobs. + max_monthly: Value.union( + { + name: 'Max — Monthly Credits', + description: + 'Max-tier users default to unlimited monthly credits. Switch to "Limit to a specific amount" to cap how many credits each Max install can spend per month.', + default: 'unlimited', + }, + maxMonthlyVariants, + ), max_gemini_cap: Value.number({ name: 'Max — Gemini Cap (monthly)', description: - 'Max-tier users get unlimited total credits but a capped slice goes via Gemini. Default 50.', + 'Within the Max monthly allowance, how many credits may be served via Gemini (the rest spill to the operator-hardware fallback). Default 50.', required: true, default: 50, min: 0, @@ -103,16 +141,37 @@ export const adjustTierQuotas = sdk.Action.withInput( } catch { parsed = {} } + // Translate the saved `max.monthly` value back into the union shape. + // null → unlimited; number → limited with that amount. + const savedMaxMonthly = parsed?.max?.monthly + const maxMonthlyUnion = + savedMaxMonthly == null + ? { selection: 'unlimited' as const, value: {} } + : { + selection: 'limited' as const, + value: { amount: savedMaxMonthly }, + } return { 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_monthly: maxMonthlyUnion, max_gemini_cap: parsed?.max?.geminiCapMonthly ?? 50, } }, async ({ effects, input }) => { + // Resolve the Max monthly union back to a primitive value for + // storage. The union arrives as { selection, value }; "unlimited" + // → null, "limited" → the inner amount. + const mmSel = (input.max_monthly as any)?.selection + const mmVal = (input.max_monthly as any)?.value + const maxMonthly = + mmSel === 'limited' + ? Number(mmVal?.amount ?? 1000) + : null + const quotas = { core: { lifetime: input.core_lifetime ?? 10, @@ -127,7 +186,7 @@ export const adjustTierQuotas = sdk.Action.withInput( }, max: { lifetime: null, - monthly: null, + monthly: maxMonthly, geminiCapMonthly: input.max_gemini_cap ?? 50, }, } diff --git a/startos/versions/index.ts b/startos/versions/index.ts index 69e4f00..048c388 100644 --- a/startos/versions/index.ts +++ b/startos/versions/index.ts @@ -4,8 +4,9 @@ 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' +import { v_0_2_4 } from './v0.2.4' export const versionGraph = VersionGraph.of({ - current: v_0_2_3, - other: [v_0_2_2, v_0_2_1, v_0_2_0, v_0_1_0], + current: v_0_2_4, + other: [v_0_2_3, v_0_2_2, v_0_2_1, v_0_2_0, v_0_1_0], }) diff --git a/startos/versions/v0.2.4.ts b/startos/versions/v0.2.4.ts new file mode 100644 index 0000000..e265ba9 --- /dev/null +++ b/startos/versions/v0.2.4.ts @@ -0,0 +1,13 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +export const v_0_2_4 = VersionInfo.of({ + version: '0.2.4:0', + releaseNotes: { + en_US: + 'Max tier monthly credits is now operator-configurable via the Adjust Tier Quotas action — radio choice between "Unlimited" (default) and a specific monthly cap. New GET /relay/policy endpoint exposes the current tier-quota config so Recap installs can render dynamic copy (e.g. "10 relay credits" updates automatically when you change the Core lifetime cap).', + }, + migrations: { + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, + }, +})