initial relay scaffold
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { configFile } from '../file-models/config.json'
|
||||
|
||||
const { InputSpec, Value } = 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.
|
||||
const inputSpec = InputSpec.of({
|
||||
// Core tier knobs.
|
||||
core_lifetime: Value.number({
|
||||
name: 'Core — Lifetime Credits',
|
||||
description:
|
||||
'Total credits a Core (unlicensed) install can ever spend. Default 5.',
|
||||
required: true,
|
||||
default: 5,
|
||||
min: 0,
|
||||
max: 1_000_000,
|
||||
integer: true,
|
||||
step: 1,
|
||||
units: 'credits',
|
||||
placeholder: null,
|
||||
}),
|
||||
// Pro tier knobs.
|
||||
pro_monthly: Value.number({
|
||||
name: 'Pro — Monthly Credits',
|
||||
description:
|
||||
'Total credits a Pro user gets each calendar month. Resets on the 1st. Default 50.',
|
||||
required: true,
|
||||
default: 50,
|
||||
min: 0,
|
||||
max: 1_000_000,
|
||||
integer: true,
|
||||
step: 1,
|
||||
units: 'credits',
|
||||
placeholder: null,
|
||||
}),
|
||||
pro_gemini_cap: Value.number({
|
||||
name: 'Pro — Gemini Cap (monthly)',
|
||||
description:
|
||||
'Within the Pro monthly allowance, how many credits may be served via Gemini (the rest spill to the operator-hardware fallback). Default 25.',
|
||||
required: true,
|
||||
default: 25,
|
||||
min: 0,
|
||||
max: 1_000_000,
|
||||
integer: true,
|
||||
step: 1,
|
||||
units: 'credits',
|
||||
placeholder: null,
|
||||
}),
|
||||
// Max tier knobs.
|
||||
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.',
|
||||
required: true,
|
||||
default: 50,
|
||||
min: 0,
|
||||
max: 1_000_000,
|
||||
integer: true,
|
||||
step: 1,
|
||||
units: 'credits',
|
||||
placeholder: null,
|
||||
}),
|
||||
})
|
||||
|
||||
export const adjustTierQuotas = sdk.Action.withInput(
|
||||
'adjust-tier-quotas',
|
||||
|
||||
async ({ effects }) => ({
|
||||
name: 'Adjust Tier Quotas',
|
||||
description:
|
||||
'Tune the per-tier monthly credit caps and Gemini exposure without redeploying. Changes apply to the next request — no restart needed.',
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
|
||||
inputSpec,
|
||||
|
||||
async ({ effects }) => {
|
||||
const config = await configFile.read().once()
|
||||
let parsed: any = {}
|
||||
try {
|
||||
parsed = JSON.parse(config?.relay_tier_quotas_json || '{}')
|
||||
} catch {
|
||||
parsed = {}
|
||||
}
|
||||
return {
|
||||
core_lifetime: parsed?.core?.lifetime ?? 5,
|
||||
pro_monthly: parsed?.pro?.monthly ?? 50,
|
||||
pro_gemini_cap: parsed?.pro?.geminiCapMonthly ?? 25,
|
||||
max_gemini_cap: parsed?.max?.geminiCapMonthly ?? 50,
|
||||
}
|
||||
},
|
||||
|
||||
async ({ effects, input }) => {
|
||||
const quotas = {
|
||||
core: {
|
||||
lifetime: input.core_lifetime ?? 5,
|
||||
monthly: null,
|
||||
geminiCapMonthly: null,
|
||||
},
|
||||
pro: {
|
||||
lifetime: null,
|
||||
monthly: input.pro_monthly ?? 50,
|
||||
geminiCapMonthly: input.pro_gemini_cap ?? 25,
|
||||
},
|
||||
max: {
|
||||
lifetime: null,
|
||||
monthly: null,
|
||||
geminiCapMonthly: input.max_gemini_cap ?? 50,
|
||||
},
|
||||
}
|
||||
await configFile.merge(effects, {
|
||||
relay_tier_quotas_json: JSON.stringify(quotas),
|
||||
})
|
||||
return null
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { setGeminiKey } from './setGeminiKey'
|
||||
import { setKeysatBaseUrl } from './setKeysatBaseUrl'
|
||||
import { setParakeetUrl } from './setParakeetUrl'
|
||||
import { setGemmaUrl } from './setGemmaUrl'
|
||||
import { setAdminPassword } from './setAdminPassword'
|
||||
import { adjustTierQuotas } from './adjustTierQuotas'
|
||||
|
||||
export const actions = sdk.Actions.of()
|
||||
.addAction(setGeminiKey)
|
||||
.addAction(setKeysatBaseUrl)
|
||||
.addAction(setParakeetUrl)
|
||||
.addAction(setGemmaUrl)
|
||||
.addAction(setAdminPassword)
|
||||
.addAction(adjustTierQuotas)
|
||||
@@ -0,0 +1,107 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { configFile } from '../file-models/config.json'
|
||||
import { randomBytes, scryptSync } from 'crypto'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const SCRYPT_KEYLEN = 64
|
||||
|
||||
// Mirror of Recap's setAdminPassword — same shape so server-side
|
||||
// admin-auth code can be lifted with minimal change.
|
||||
const inputSpec = InputSpec.of({
|
||||
relay_admin_username: Value.text({
|
||||
name: 'Admin Username',
|
||||
description: 'Username for the relay admin dashboard. Defaults to "admin".',
|
||||
required: true,
|
||||
default: 'admin',
|
||||
minLength: 1,
|
||||
maxLength: 64,
|
||||
}),
|
||||
relay_admin_password: Value.text({
|
||||
name: 'Admin Password',
|
||||
description:
|
||||
'Password for the relay admin dashboard. Must be at least 8 characters. Leave blank to disable /admin entirely (useful while testing /relay/* endpoints).',
|
||||
required: false,
|
||||
default: null,
|
||||
masked: true,
|
||||
minLength: 0,
|
||||
maxLength: 256,
|
||||
}),
|
||||
relay_admin_password_confirm: Value.text({
|
||||
name: 'Confirm Password',
|
||||
description: 'Re-enter the password to confirm.',
|
||||
required: false,
|
||||
default: null,
|
||||
masked: true,
|
||||
minLength: 0,
|
||||
maxLength: 256,
|
||||
}),
|
||||
})
|
||||
|
||||
export const setAdminPassword = sdk.Action.withInput(
|
||||
'set-admin-password',
|
||||
|
||||
async ({ effects }) => ({
|
||||
name: 'Set Admin Password',
|
||||
description:
|
||||
"Gate the relay's /admin dashboard. The public /relay/* endpoints are unaffected — they're per-call authenticated via X-Recap-Install-Id + Authorization headers.",
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
|
||||
inputSpec,
|
||||
|
||||
async ({ effects }) => {
|
||||
const config = await configFile.read().once()
|
||||
return {
|
||||
relay_admin_username: config?.relay_admin_username || 'admin',
|
||||
relay_admin_password: undefined,
|
||||
relay_admin_password_confirm: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
async ({ effects, input }) => {
|
||||
const username = (input.relay_admin_username || '').trim()
|
||||
const password = input.relay_admin_password || ''
|
||||
const confirm = input.relay_admin_password_confirm || ''
|
||||
|
||||
if (!username) throw new Error('Username is required.')
|
||||
|
||||
if (password === '' && confirm === '') {
|
||||
await configFile.merge(effects, {
|
||||
relay_admin_username: username,
|
||||
relay_admin_password_hash: '',
|
||||
relay_admin_password_salt: '',
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
if (password !== confirm) {
|
||||
throw new Error('Password and confirmation do not match.')
|
||||
}
|
||||
if (password.length < 8) {
|
||||
throw new Error('Password must be at least 8 characters.')
|
||||
}
|
||||
|
||||
const salt = randomBytes(16).toString('hex')
|
||||
const hash = scryptSync(password, salt, SCRYPT_KEYLEN).toString('hex')
|
||||
|
||||
const existing = await configFile.read().once()
|
||||
const sessionSecret =
|
||||
existing?.relay_admin_session_secret &&
|
||||
existing.relay_admin_session_secret.length > 0
|
||||
? existing.relay_admin_session_secret
|
||||
: randomBytes(32).toString('hex')
|
||||
|
||||
await configFile.merge(effects, {
|
||||
relay_admin_username: username,
|
||||
relay_admin_password_hash: hash,
|
||||
relay_admin_password_salt: salt,
|
||||
relay_admin_session_secret: sessionSecret,
|
||||
})
|
||||
|
||||
return null
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { configFile } from '../file-models/config.json'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
// The operator's Gemini API key. This is the relay's primary backend
|
||||
// — Recap requests for both transcribe and analyze go to Gemini first,
|
||||
// and only spill to the optional Parakeet/Gemma backends once a user
|
||||
// exceeds their tier's monthly Gemini cap.
|
||||
//
|
||||
// Free key from https://aistudio.google.com/apikey. Track usage in
|
||||
// the Google AI Studio dashboard to know what tier pricing should be.
|
||||
const inputSpec = InputSpec.of({
|
||||
relay_gemini_api_key: Value.text({
|
||||
name: 'Gemini API Key',
|
||||
description:
|
||||
'The relay\'s Google Gemini API key. Used for transcribe + analyze forwarding. Get one at https://aistudio.google.com/apikey',
|
||||
required: true,
|
||||
default: null,
|
||||
masked: true,
|
||||
minLength: 1,
|
||||
maxLength: 256,
|
||||
}),
|
||||
})
|
||||
|
||||
export const setGeminiKey = sdk.Action.withInput(
|
||||
'set-gemini-key',
|
||||
|
||||
async ({ effects }) => ({
|
||||
name: 'Set Gemini API Key',
|
||||
description:
|
||||
"The operator's Gemini key. Required — the relay will refuse to serve traffic until this is set.",
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
|
||||
inputSpec,
|
||||
|
||||
async ({ effects }) => {
|
||||
const config = await configFile.read().once()
|
||||
return {
|
||||
relay_gemini_api_key: config?.relay_gemini_api_key || undefined,
|
||||
}
|
||||
},
|
||||
|
||||
async ({ effects, input }) => {
|
||||
await configFile.merge(effects, {
|
||||
relay_gemini_api_key: input.relay_gemini_api_key,
|
||||
})
|
||||
return null
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { configFile } from '../file-models/config.json'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
// Optional Gemma/Ollama endpoint for the operator-hardware analysis
|
||||
// fallback. Counterpart to setParakeetUrl — Parakeet handles transcribe
|
||||
// overflow, this handles analyze overflow.
|
||||
const inputSpec = InputSpec.of({
|
||||
relay_gemma_base_url: Value.text({
|
||||
name: 'Gemma Base URL',
|
||||
description:
|
||||
"URL of the operator's Gemma / Ollama / OpenAI-compatible analysis endpoint. Used as the overflow path once a user exceeds their monthly Gemini cap. Leave empty to hard-cap at the Gemini limit. Example: http://192.168.1.87:11434",
|
||||
required: false,
|
||||
default: '',
|
||||
minLength: 0,
|
||||
maxLength: 256,
|
||||
patterns: [
|
||||
{
|
||||
regex: '^(https?://.+)?$',
|
||||
description: 'Must be empty or start with http:// or https://',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
export const setGemmaUrl = sdk.Action.withInput(
|
||||
'set-gemma-url',
|
||||
|
||||
async ({ effects }) => ({
|
||||
name: 'Set Gemma URL',
|
||||
description:
|
||||
'Optional. Where the relay forwards analysis requests once a user exceeds their monthly Gemini cap. Leave empty to disable the fallback.',
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
|
||||
inputSpec,
|
||||
|
||||
async ({ effects }) => {
|
||||
const config = await configFile.read().once()
|
||||
return {
|
||||
relay_gemma_base_url: config?.relay_gemma_base_url || '',
|
||||
}
|
||||
},
|
||||
|
||||
async ({ effects, input }) => {
|
||||
await configFile.merge(effects, {
|
||||
relay_gemma_base_url: (input.relay_gemma_base_url || '').trim(),
|
||||
})
|
||||
return null
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { configFile } from '../file-models/config.json'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
// Where the relay calls to validate licenses. Defaults to the public
|
||||
// Keysat endpoint. Operators running Keysat on the same Start9 server
|
||||
// can override to the internal hostname (e.g. http://keysat.startos:3000)
|
||||
// for a lower-latency hot path — every relay request hits this for the
|
||||
// cached online check.
|
||||
const inputSpec = InputSpec.of({
|
||||
relay_keysat_base_url: Value.text({
|
||||
name: 'Keysat Base URL',
|
||||
description:
|
||||
"URL of the Keysat license server. Defaults to https://keysat.xyz. If you're running Keysat as a co-located StartOS package, override to the internal hostname (http://keysat.startos:<port>) to skip the public-internet roundtrip.",
|
||||
required: true,
|
||||
default: 'https://keysat.xyz',
|
||||
minLength: 8,
|
||||
maxLength: 256,
|
||||
patterns: [
|
||||
{
|
||||
regex: '^https?://.+$',
|
||||
description: 'Must start with http:// or https://',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
export const setKeysatBaseUrl = sdk.Action.withInput(
|
||||
'set-keysat-base-url',
|
||||
|
||||
async ({ effects }) => ({
|
||||
name: 'Set Keysat URL',
|
||||
description:
|
||||
"Where the relay validates Recap user licenses. Defaults to https://keysat.xyz — override to a co-located internal hostname if Keysat is on the same Start9 server.",
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
|
||||
inputSpec,
|
||||
|
||||
async ({ effects }) => {
|
||||
const config = await configFile.read().once()
|
||||
return {
|
||||
relay_keysat_base_url: config?.relay_keysat_base_url || 'https://keysat.xyz',
|
||||
}
|
||||
},
|
||||
|
||||
async ({ effects, input }) => {
|
||||
await configFile.merge(effects, {
|
||||
relay_keysat_base_url: (input.relay_keysat_base_url || '').trim(),
|
||||
})
|
||||
return null
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { configFile } from '../file-models/config.json'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
// Optional Parakeet endpoint for the operator-hardware fallback path.
|
||||
// When a Pro/Max user exceeds their Gemini monthly cap, the relay
|
||||
// routes transcribe requests here instead. Empty disables the fallback
|
||||
// — over-cap users get 402.
|
||||
//
|
||||
// In a typical setup this points at the operator's NVIDIA Spark or
|
||||
// similar local GPU box running the NeMo / Parakeet HTTP wrapper.
|
||||
const inputSpec = InputSpec.of({
|
||||
relay_parakeet_base_url: Value.text({
|
||||
name: 'Parakeet Base URL',
|
||||
description:
|
||||
'URL of the operator\'s Parakeet (or any Whisper-API-compatible) transcription endpoint. Used as the overflow path once a user exceeds their monthly Gemini cap. Leave empty to hard-cap at the Gemini limit. Example: http://192.168.1.87:8000',
|
||||
required: false,
|
||||
default: '',
|
||||
minLength: 0,
|
||||
maxLength: 256,
|
||||
patterns: [
|
||||
{
|
||||
regex: '^(https?://.+)?$',
|
||||
description: 'Must be empty or start with http:// or https://',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
export const setParakeetUrl = sdk.Action.withInput(
|
||||
'set-parakeet-url',
|
||||
|
||||
async ({ effects }) => ({
|
||||
name: 'Set Parakeet URL',
|
||||
description:
|
||||
"Optional. Where the relay forwards transcription requests once a user exceeds their monthly Gemini cap. Leave empty to disable the operator-hardware fallback.",
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
|
||||
inputSpec,
|
||||
|
||||
async ({ effects }) => {
|
||||
const config = await configFile.read().once()
|
||||
return {
|
||||
relay_parakeet_base_url: config?.relay_parakeet_base_url || '',
|
||||
}
|
||||
},
|
||||
|
||||
async ({ effects, input }) => {
|
||||
await configFile.merge(effects, {
|
||||
relay_parakeet_base_url: (input.relay_parakeet_base_url || '').trim(),
|
||||
})
|
||||
return null
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user