v0.2.4 max-monthly union + /relay/policy
This commit is contained in:
@@ -22,6 +22,7 @@ import { transcribeRouter } from "./routes/transcribe.js";
|
|||||||
import { analyzeRouter } from "./routes/analyze.js";
|
import { analyzeRouter } from "./routes/analyze.js";
|
||||||
import { healthRouter } from "./routes/health.js";
|
import { healthRouter } from "./routes/health.js";
|
||||||
import { balanceRouter } from "./routes/balance.js";
|
import { balanceRouter } from "./routes/balance.js";
|
||||||
|
import { policyRouter } from "./routes/policy.js";
|
||||||
import { adminRouter } from "./routes/admin.js";
|
import { adminRouter } from "./routes/admin.js";
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -47,6 +48,7 @@ setupAdminAuthRoutes(app);
|
|||||||
// authenticates per-call via headers (X-Recap-Install-Id required,
|
// authenticates per-call via headers (X-Recap-Install-Id required,
|
||||||
// Authorization optional).
|
// Authorization optional).
|
||||||
app.use("/relay", healthRouter());
|
app.use("/relay", healthRouter());
|
||||||
|
app.use("/relay", policyRouter());
|
||||||
app.use("/relay", balanceRouter());
|
app.use("/relay", balanceRouter());
|
||||||
app.use("/relay", transcribeRouter());
|
app.use("/relay", transcribeRouter());
|
||||||
app.use("/relay", analyzeRouter());
|
app.use("/relay", analyzeRouter());
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "recap-relay-server",
|
"name": "recap-relay-server",
|
||||||
"version": "0.2.3",
|
"version": "0.2.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,14 +1,43 @@
|
|||||||
import { sdk } from '../sdk'
|
import { sdk } from '../sdk'
|
||||||
import { configFile } from '../file-models/config.json'
|
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
|
// Operator-facing knob for tier-quota tuning without a code change or
|
||||||
// redeploy. The schema is { core: TierConfig, pro: TierConfig,
|
// redeploy. The schema is { core: TierConfig, pro: TierConfig,
|
||||||
// max: TierConfig } where TierConfig is
|
// max: TierConfig } where TierConfig fields can be number or null.
|
||||||
// { lifetime: number|null, monthly: number|null, geminiCapMonthly: number|null }
|
// `null` means "no cap on this dimension." The relay reads this on
|
||||||
// null means "no cap on this dimension." The relay reads this on every
|
// every request via configFile's live-reload.
|
||||||
// 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({
|
const inputSpec = InputSpec.of({
|
||||||
// Core tier knobs.
|
// Core tier knobs.
|
||||||
core_lifetime: Value.number({
|
core_lifetime: Value.number({
|
||||||
@@ -65,10 +94,19 @@ const inputSpec = InputSpec.of({
|
|||||||
placeholder: null,
|
placeholder: null,
|
||||||
}),
|
}),
|
||||||
// Max tier knobs.
|
// 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({
|
max_gemini_cap: Value.number({
|
||||||
name: 'Max — Gemini Cap (monthly)',
|
name: 'Max — Gemini Cap (monthly)',
|
||||||
description:
|
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,
|
required: true,
|
||||||
default: 50,
|
default: 50,
|
||||||
min: 0,
|
min: 0,
|
||||||
@@ -103,16 +141,37 @@ export const adjustTierQuotas = sdk.Action.withInput(
|
|||||||
} catch {
|
} catch {
|
||||||
parsed = {}
|
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 {
|
return {
|
||||||
core_lifetime: parsed?.core?.lifetime ?? 10,
|
core_lifetime: parsed?.core?.lifetime ?? 10,
|
||||||
core_gemini_cap: parsed?.core?.geminiCapLifetime ?? 5,
|
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_monthly: maxMonthlyUnion,
|
||||||
max_gemini_cap: parsed?.max?.geminiCapMonthly ?? 50,
|
max_gemini_cap: parsed?.max?.geminiCapMonthly ?? 50,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async ({ effects, input }) => {
|
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 = {
|
const quotas = {
|
||||||
core: {
|
core: {
|
||||||
lifetime: input.core_lifetime ?? 10,
|
lifetime: input.core_lifetime ?? 10,
|
||||||
@@ -127,7 +186,7 @@ export const adjustTierQuotas = sdk.Action.withInput(
|
|||||||
},
|
},
|
||||||
max: {
|
max: {
|
||||||
lifetime: null,
|
lifetime: null,
|
||||||
monthly: null,
|
monthly: maxMonthly,
|
||||||
geminiCapMonthly: input.max_gemini_cap ?? 50,
|
geminiCapMonthly: input.max_gemini_cap ?? 50,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_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'
|
import { v_0_2_3 } from './v0.2.3'
|
||||||
|
import { v_0_2_4 } from './v0.2.4'
|
||||||
|
|
||||||
export const versionGraph = VersionGraph.of({
|
export const versionGraph = VersionGraph.of({
|
||||||
current: v_0_2_3,
|
current: v_0_2_4,
|
||||||
other: [v_0_2_2, v_0_2_1, v_0_2_0, v_0_1_0],
|
other: [v_0_2_3, v_0_2_2, v_0_2_1, v_0_2_0, v_0_1_0],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user