v0.2 hardware backend
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
# Recap Relay
|
||||||
|
|
||||||
|
Operator-side credit-metered proxy for Recap clients. Fronts Google Gemini (and optionally a local Parakeet+Gemma setup) so Recap users on Core/Pro/Max tiers can summarize videos without bringing their own API keys.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- Receives `POST /relay/transcribe` and `POST /relay/analyze` from Recap installs
|
||||||
|
- Validates `X-Recap-Install-Id` and optional `Authorization: Bearer LIC1-...` against a Keysat license server (cached online check)
|
||||||
|
- Tracks per-install credit balances in `/data/credits.json` with calendar-month rollover for paid tiers
|
||||||
|
- Routes to Gemini first; falls back to the operator's Parakeet+Gemma above the per-user-per-month Gemini cap
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
After installing, set:
|
||||||
|
1. **Gemini API Key** — required, the relay's primary backend
|
||||||
|
2. **Keysat URL** — defaults to `https://keysat.xyz`; override to the internal hostname if Keysat is co-located on this Start9 server
|
||||||
|
3. **Admin Password** — gates the `/admin/*` dashboard
|
||||||
|
4. (Optional) **Parakeet URL** + **Gemma URL** — operator-hardware fallback for Pro/Max overflow
|
||||||
|
|
||||||
|
Then forward a public hostname (e.g. `relay.yourdomain.com`) to this service via StartTunnel, and point your Recap install's "Set Relay URL" action at that hostname.
|
||||||
|
|
||||||
|
## Tier defaults
|
||||||
|
|
||||||
|
- **Core** (unlicensed): 5 lifetime credits
|
||||||
|
- **Pro** (`relay_pro` entitlement): 50 monthly credits, max 25 via Gemini
|
||||||
|
- **Max** (`relay_max` entitlement): unlimited monthly, max 50 via Gemini
|
||||||
|
|
||||||
|
Adjust via the "Adjust Tier Quotas" action without redeploying.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEAgsromMy4osMJplX1rY0fd4ouS6wfkm/vfeY2gXEQHkA=
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
Generated
+359
@@ -0,0 +1,359 @@
|
|||||||
|
{
|
||||||
|
"name": "recap-relay-startos",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "recap-relay-startos",
|
||||||
|
"dependencies": {
|
||||||
|
"@start9labs/start-sdk": "1.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.19.0",
|
||||||
|
"@vercel/ncc": "^0.38.4",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@iarna/toml": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-td6ZUkz2oS3VeleBcN+m//Q6HlCFCPrnI0FZhrt/h4XqLEdOyYp2u21nd8MdsR+WJy5r9PTDaHTDDfhf4H4l6Q==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/@noble/curves": {
|
||||||
|
"version": "1.9.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
|
||||||
|
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/hashes": "1.8.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.21.3 || >=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@noble/hashes": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.21.3 || >=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@nodable/entities": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@start9labs/start-sdk": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-rtAfumVbMy90iw2WRbWH7fGcuwAvvuFfR4YwgSsh5R2Bz9MXtcEfmznwhnrp+ntQ6BOUSQ0wLzePbfsS6kUagg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@iarna/toml": "^3.0.0",
|
||||||
|
"@noble/curves": "^1.8.2",
|
||||||
|
"@noble/hashes": "^1.7.2",
|
||||||
|
"@types/ini": "^4.1.1",
|
||||||
|
"deep-equality-data-structures": "^2.0.0",
|
||||||
|
"fast-xml-parser": "^5.5.6",
|
||||||
|
"ini": "^5.0.0",
|
||||||
|
"isomorphic-fetch": "^3.0.0",
|
||||||
|
"mime": "^4.0.7",
|
||||||
|
"yaml": "^2.7.1",
|
||||||
|
"zod": "^4.3.6",
|
||||||
|
"zod-deep-partial": "^1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/ini": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "22.19.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
|
||||||
|
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vercel/ncc": {
|
||||||
|
"version": "0.38.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.4.tgz",
|
||||||
|
"integrity": "sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"ncc": "dist/ncc/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/deep-equality-data-structures": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/deep-equality-data-structures/-/deep-equality-data-structures-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-qgrUr7MKXq7VRN+WUpQ48QlXVGL0KdibAoTX8KRg18lgOgqbEKMAW1WZsVCtakY4+XX42pbAJzTz/DlXEFM2Fg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"object-hash": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fast-xml-builder": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"path-expression-matcher": "^1.5.0",
|
||||||
|
"xml-naming": "^0.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fast-xml-parser": {
|
||||||
|
"version": "5.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz",
|
||||||
|
"integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@nodable/entities": "^2.1.0",
|
||||||
|
"fast-xml-builder": "^1.1.7",
|
||||||
|
"path-expression-matcher": "^1.5.0",
|
||||||
|
"strnum": "^2.2.3"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"fxparser": "src/cli/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ini": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || >=20.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/isomorphic-fetch": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-fetch": "^2.6.1",
|
||||||
|
"whatwg-fetch": "^3.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"mime": "bin/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/object-hash": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-expression-matcher": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prettier": {
|
||||||
|
"version": "3.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
|
||||||
|
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"prettier": "bin/prettier.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strnum": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-fetch": {
|
||||||
|
"version": "3.6.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
|
||||||
|
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xml-naming": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod-deep-partial": {
|
||||||
|
"version": "1.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod-deep-partial/-/zod-deep-partial-1.4.4.tgz",
|
||||||
|
"integrity": "sha512-aWkPl7hVStgE01WzbbSxCgX4O+sSpgt8JOjvFUtMTF75VgL6MhWQbiZi+AWGN85SfSTtI9gsOtL1vInoqfDVaA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^4.1.13"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+194
-30
@@ -1,55 +1,219 @@
|
|||||||
// Operator-hardware fallback backend. Forwards transcribe requests to
|
// Operator-hardware fallback backend. Forwards transcribe requests to
|
||||||
// the operator's Parakeet (or any Whisper-API-compatible) endpoint and
|
// a Parakeet endpoint (or any Whisper-API-compatible server — same wire
|
||||||
// analyze requests to their Gemma (or any OpenAI-API-compatible) endpoint.
|
// format) and analyze requests to a Gemma endpoint (or any
|
||||||
|
// OpenAI-compatible chat-completions server).
|
||||||
//
|
//
|
||||||
// v0.1 is a stub — the endpoints are wired up, but no operator has
|
// Used when a Pro/Max user has exceeded their monthly Gemini cap.
|
||||||
// pointed a real Parakeet/Gemma at the relay yet. Returns a 503
|
// Returns the same shape gemini.js produces so route handlers don't
|
||||||
// "hardware fallback not yet wired" so the credits.js routing logic
|
// need a backend-specific branch downstream:
|
||||||
// still applies but users get a clear message instead of a silent
|
// transcribeAudio → { text, segments, duration_seconds }
|
||||||
// failure.
|
// analyzeText → { text }
|
||||||
|
//
|
||||||
|
// Both endpoints are reached via plain fetch — no SDK dependency keeps
|
||||||
|
// the relay container slim and the upstream wire format is dead-simple
|
||||||
|
// for these two well-known shapes.
|
||||||
|
|
||||||
|
const ANALYZE_MAX_TOKENS = 16000;
|
||||||
|
// Gemma served locally tends to live on the host's LAN, not the public
|
||||||
|
// internet, so generous timeouts. Same scale as Recap's defaults.
|
||||||
|
const DEFAULT_TIMEOUT_MS = 900_000;
|
||||||
|
|
||||||
|
// Pull the model identifier out of the prompt if the operator wants a
|
||||||
|
// specific Gemma SKU. We default to "gemma3:27b" which is the typical
|
||||||
|
// Ollama tag for the analysis-capable Gemma model. Operators with a
|
||||||
|
// different deployment can update this via a future StartOS action;
|
||||||
|
// for v0.2 it's hardcoded.
|
||||||
|
const HARDWARE_ANALYZE_MODEL = process.env.RELAY_GEMMA_MODEL || "gemma3:27b";
|
||||||
|
|
||||||
|
// Parakeet's typical model identifier. Mirrors what Recap's whisper.js
|
||||||
|
// sends when the operator points the relay at a NeMo Parakeet HTTP
|
||||||
|
// wrapper. Configurable via env var for non-default deployments.
|
||||||
|
const HARDWARE_TRANSCRIBE_MODEL =
|
||||||
|
process.env.RELAY_PARAKEET_MODEL || "parakeet-tdt-0.6b-v3";
|
||||||
|
|
||||||
export function createHardwareBackend({
|
export function createHardwareBackend({
|
||||||
parakeetBaseURL = "",
|
parakeetBaseURL = "",
|
||||||
gemmaBaseURL = "",
|
gemmaBaseURL = "",
|
||||||
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const hasParakeet = !!parakeetBaseURL;
|
const parakeet = parakeetBaseURL ? parakeetBaseURL.replace(/\/$/, "") : "";
|
||||||
const hasGemma = !!gemmaBaseURL;
|
const gemma = gemmaBaseURL ? gemmaBaseURL.replace(/\/$/, "") : "";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasTranscribe: hasParakeet,
|
hasTranscribe: !!parakeet,
|
||||||
hasAnalyze: hasGemma,
|
hasAnalyze: !!gemma,
|
||||||
|
|
||||||
async transcribeAudio() {
|
// POST <parakeet>/v1/audio/transcriptions with the OpenAI Whisper
|
||||||
if (!hasParakeet) {
|
// multipart shape. Parakeet wrappers (NeMo + the patched one Recap
|
||||||
|
// already talks to) honor this format and return segments with
|
||||||
|
// per-segment timestamps when timestamp_granularities=segment is
|
||||||
|
// requested. Falls back to a bare request if the rich shape 4xx/5xxs.
|
||||||
|
async transcribeAudio({
|
||||||
|
audio,
|
||||||
|
mimeType = "application/octet-stream",
|
||||||
|
offsetSeconds = 0,
|
||||||
|
}) {
|
||||||
|
if (!parakeet) {
|
||||||
const err = new Error(
|
const err = new Error(
|
||||||
"operator-hardware transcribe path is not configured (relay_parakeet_base_url is empty)"
|
"operator-hardware transcribe is not configured (relay_parakeet_base_url is empty)"
|
||||||
);
|
);
|
||||||
err.status = 503;
|
err.status = 503;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
// TODO v0.2: POST audio to parakeetBaseURL using the OpenAI
|
|
||||||
// audio-transcriptions wire format Recap already speaks. Return
|
// Try the rich request first (verbose_json + segment timestamps).
|
||||||
// { text, segments, duration_seconds } in the same shape as
|
// FormData/Blob globals are available in Node 20+. Wrap the
|
||||||
// gemini.js's transcribeAudio.
|
// received Buffer in a Blob so the multipart body is properly
|
||||||
const err = new Error("operator-hardware transcribe path not yet implemented in relay v0.1");
|
// chunked instead of falling back to base64.
|
||||||
err.status = 503;
|
const buildForm = (richMode) => {
|
||||||
throw err;
|
const form = new FormData();
|
||||||
|
const blob = new Blob([audio], { type: mimeType });
|
||||||
|
form.append("file", blob, "audio.bin");
|
||||||
|
form.append("model", HARDWARE_TRANSCRIBE_MODEL);
|
||||||
|
if (richMode) {
|
||||||
|
form.append("response_format", "verbose_json");
|
||||||
|
form.append("timestamp_granularities[]", "segment");
|
||||||
|
}
|
||||||
|
return form;
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = `${parakeet}/v1/audio/transcriptions`;
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
body: buildForm(true),
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const e = new Error(
|
||||||
|
`Parakeet transcribe network error: ${err?.message || err}`
|
||||||
|
);
|
||||||
|
e.status = 502;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the wrapper rejects the rich params, retry with bare-bones.
|
||||||
|
if (!res.ok && res.status >= 400 && res.status < 600) {
|
||||||
|
const richBody = await safeBody(res);
|
||||||
|
console.warn(
|
||||||
|
`[hardware] rich Parakeet request returned ${res.status}: ${richBody.slice(0, 200)} — retrying bare`
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
body: buildForm(false),
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const e = new Error(
|
||||||
|
`Parakeet transcribe network error (fallback): ${err?.message || err}`
|
||||||
|
);
|
||||||
|
e.status = 502;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await safeBody(res);
|
||||||
|
const e = new Error(
|
||||||
|
`Parakeet transcribe ${res.status}: ${body.slice(0, 300)}`
|
||||||
|
);
|
||||||
|
e.status = res.status;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const segments = Array.isArray(data.segments) ? data.segments : [];
|
||||||
|
|
||||||
|
// Offset support: when the relay caller is processing a chunked
|
||||||
|
// audio file, it asks for transcripts at a non-zero base time.
|
||||||
|
// Parakeet returns timestamps relative to the chunk; shift them
|
||||||
|
// up by offsetSeconds so the combined transcript downstream
|
||||||
|
// lines up with the real video timeline.
|
||||||
|
const shifted = segments.map((s) => ({
|
||||||
|
start: (s.start || 0) + offsetSeconds,
|
||||||
|
end: (s.end || 0) + offsetSeconds,
|
||||||
|
text: (s.text || "").trim(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Build the [MM:SS] text format Recap's parseTimestampedTranscript
|
||||||
|
// already speaks. The route handler will pass this straight back
|
||||||
|
// to Recap, which parses it on the client side.
|
||||||
|
const lines = shifted.length
|
||||||
|
? shifted.map((s) => `[${formatMmSs(s.start)}] ${s.text}`)
|
||||||
|
: [`[0:00] ${(data.text || "").trim()}`];
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: lines.join("\n"),
|
||||||
|
segments: shifted,
|
||||||
|
duration_seconds: data.duration || 0,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async analyzeText() {
|
// POST <gemma>/v1/chat/completions with the OpenAI shape. Ollama's
|
||||||
if (!hasGemma) {
|
// server, vLLM, llama.cpp's HTTP server, and most other OSS LLM
|
||||||
|
// runners support this wire format — so we don't lock the relay
|
||||||
|
// to one specific Gemma deployment.
|
||||||
|
async analyzeText({ prompt }) {
|
||||||
|
if (!gemma) {
|
||||||
const err = new Error(
|
const err = new Error(
|
||||||
"operator-hardware analyze path is not configured (relay_gemma_base_url is empty)"
|
"operator-hardware analyze is not configured (relay_gemma_base_url is empty)"
|
||||||
);
|
);
|
||||||
err.status = 503;
|
err.status = 503;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
// TODO v0.2: POST prompt to gemmaBaseURL using either /api/generate
|
|
||||||
// (Ollama native) or /v1/chat/completions (OpenAI-compatible).
|
const url = `${gemma}/v1/chat/completions`;
|
||||||
// Return { text } matching gemini.js's analyzeText.
|
let res;
|
||||||
const err = new Error("operator-hardware analyze path not yet implemented in relay v0.1");
|
try {
|
||||||
err.status = 503;
|
res = await fetch(url, {
|
||||||
throw err;
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: HARDWARE_ANALYZE_MODEL,
|
||||||
|
max_tokens: ANALYZE_MAX_TOKENS,
|
||||||
|
messages: [{ role: "user", content: prompt }],
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const e = new Error(
|
||||||
|
`Gemma analyze network error: ${err?.message || err}`
|
||||||
|
);
|
||||||
|
e.status = 502;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await safeBody(res);
|
||||||
|
const e = new Error(`Gemma analyze ${res.status}: ${body.slice(0, 300)}`);
|
||||||
|
e.status = res.status;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const text = data?.choices?.[0]?.message?.content || "";
|
||||||
|
return { text };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatMmSs(seconds) {
|
||||||
|
const s = Math.max(0, Math.floor(seconds));
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
if (h > 0)
|
||||||
|
return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
||||||
|
return `${m}:${String(sec).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeBody(res) {
|
||||||
|
try {
|
||||||
|
return await res.text();
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ export const DEFAULT_LANG = 'en_US'
|
|||||||
|
|
||||||
const dict = {
|
const dict = {
|
||||||
// main.ts
|
// main.ts
|
||||||
'Starting Recap...': 0,
|
'Starting Recap Relay...': 0,
|
||||||
'Web Interface': 1,
|
'Relay Endpoint': 1,
|
||||||
'Recap is ready': 2,
|
'Relay is accepting connections': 2,
|
||||||
'Recap is not responding': 3,
|
'Relay is not responding': 3,
|
||||||
|
|
||||||
// interfaces.ts
|
// interfaces.ts
|
||||||
'Web UI': 4,
|
'HTTP endpoint for Recap clients to relay transcribe + analyze calls. Also serves the operator admin dashboard at /admin/.':
|
||||||
'The web interface for Recap — browse, search, and manage your transcript library': 5,
|
4,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
|
|||||||
name: i18n('Relay Endpoint'),
|
name: i18n('Relay Endpoint'),
|
||||||
id: 'api',
|
id: 'api',
|
||||||
description: i18n(
|
description: i18n(
|
||||||
'HTTP endpoint for Recap clients to relay transcribe + analyze ' +
|
'HTTP endpoint for Recap clients to relay transcribe + analyze calls. Also serves the operator admin dashboard at /admin/.',
|
||||||
'calls. Also serves the operator admin dashboard at /admin/.',
|
|
||||||
),
|
),
|
||||||
type: 'ui',
|
type: 'ui',
|
||||||
masked: false,
|
masked: false,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { VersionGraph } from '@start9labs/start-sdk'
|
import { VersionGraph } from '@start9labs/start-sdk'
|
||||||
import { v_0_1_0 } from './v0.1.0'
|
import { v_0_1_0 } from './v0.1.0'
|
||||||
|
import { v_0_2_0 } from './v0.2.0'
|
||||||
|
|
||||||
export const versionGraph = VersionGraph.of({
|
export const versionGraph = VersionGraph.of({
|
||||||
current: v_0_1_0,
|
current: v_0_2_0,
|
||||||
other: [],
|
other: [v_0_1_0],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_0 = VersionInfo.of({
|
||||||
|
version: '0.2.0:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Implements the operator-hardware fallback path. Parakeet transcribe forwarding speaks the OpenAI Whisper API wire format (with verbose_json + segment timestamps, and a bare-shape fallback for wrappers that reject the rich params); Gemma analyze forwarding uses /v1/chat/completions for broad compatibility with Ollama / vLLM / llama.cpp servers.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user