0ae59f3550
Introduces RECAP_MODE=multi alongside single-mode self-host: - Tenant auth + accounts (magic-link via System SMTP), per-tenant credit pool, anonymous trial minting with per-IP/-64 caps - Self-serve Pro/Max purchase: inline Lightning (BTCPay) + card (Zaprite), prepaid 30-day periods, expiry-reminder emails - Core-decoupling: relay owns cloud tier/expiry keyed by Recaps user-id - SQLite (better-sqlite3) schema for multi-mode; filesystem unchanged for single - StartOS actions/versions through 0.2.155
133 lines
4.4 KiB
JavaScript
133 lines
4.4 KiB
JavaScript
import { test, describe } from "node:test";
|
||
import { strict as assert } from "node:assert";
|
||
import { PRICING, calcCost, buildAnalysisPrompt } from "../gemini-helpers.js";
|
||
|
||
describe("PRICING table", () => {
|
||
test("includes all current production model slugs", () => {
|
||
assert.ok(PRICING["gemini-3.1-pro-preview"]);
|
||
assert.ok(PRICING["gemini-2.5-pro"]);
|
||
assert.ok(PRICING["gemini-3-flash-preview"]);
|
||
assert.ok(PRICING["gemini-2.5-flash"]);
|
||
assert.ok(PRICING["gemini-3.1-flash-lite"]);
|
||
});
|
||
|
||
test("has a 'default' fallback row", () => {
|
||
assert.ok(PRICING["default"]);
|
||
assert.equal(typeof PRICING["default"].input, "number");
|
||
});
|
||
|
||
test("each row has input/output/thinking rates", () => {
|
||
for (const [model, rates] of Object.entries(PRICING)) {
|
||
assert.equal(typeof rates.input, "number", `${model}.input`);
|
||
assert.equal(typeof rates.output, "number", `${model}.output`);
|
||
assert.equal(typeof rates.thinking, "number", `${model}.thinking`);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("calcCost", () => {
|
||
test("uses model-specific rates when known", () => {
|
||
const cost = calcCost("gemini-3-flash-preview", {
|
||
promptTokenCount: 1_000_000,
|
||
candidatesTokenCount: 1_000_000,
|
||
thoughtsTokenCount: 0,
|
||
});
|
||
assert.equal(cost.inputTokens, 1_000_000);
|
||
assert.equal(cost.outputTokens, 1_000_000);
|
||
// 1M tokens × $0.50 input + 1M × $3.00 output = $3.50
|
||
assert.equal(parseFloat(cost.totalCost).toFixed(2), "3.50");
|
||
});
|
||
|
||
test("falls back to 'default' rates for unknown model", () => {
|
||
const cost = calcCost("invented-model-x", {
|
||
promptTokenCount: 1_000_000,
|
||
candidatesTokenCount: 0,
|
||
thoughtsTokenCount: 0,
|
||
});
|
||
// default input rate = $1.00 per 1M
|
||
assert.equal(parseFloat(cost.totalCost).toFixed(2), "1.00");
|
||
});
|
||
|
||
test("treats missing usage fields as zero", () => {
|
||
const cost = calcCost("gemini-3-flash-preview", {});
|
||
assert.equal(cost.inputTokens, 0);
|
||
assert.equal(cost.outputTokens, 0);
|
||
assert.equal(cost.thinkingTokens, 0);
|
||
assert.equal(parseFloat(cost.totalCost), 0);
|
||
});
|
||
|
||
test("formats sub-cent totals as ¢", () => {
|
||
const cost = calcCost("gemini-2.5-flash", {
|
||
promptTokenCount: 1000,
|
||
candidatesTokenCount: 1000,
|
||
thoughtsTokenCount: 0,
|
||
});
|
||
// tiny — display in ¢
|
||
assert.match(cost.totalCostDisplay, /¢$/);
|
||
});
|
||
|
||
test("formats >$0.01 totals as $X.XXXX", () => {
|
||
const cost = calcCost("gemini-3.1-pro-preview", {
|
||
promptTokenCount: 1_000_000,
|
||
candidatesTokenCount: 0,
|
||
thoughtsTokenCount: 0,
|
||
});
|
||
// $2.00
|
||
assert.match(cost.totalCostDisplay, /^\$\d+\.\d{4}$/);
|
||
});
|
||
|
||
test("totalTokens prefers usage.totalTokenCount when provided", () => {
|
||
const cost = calcCost("gemini-3-flash-preview", {
|
||
promptTokenCount: 100,
|
||
candidatesTokenCount: 50,
|
||
thoughtsTokenCount: 25,
|
||
totalTokenCount: 200, // server-reported total may include hidden tokens
|
||
});
|
||
assert.equal(cost.totalTokens, 200);
|
||
});
|
||
|
||
test("totalTokens falls back to sum when totalTokenCount missing", () => {
|
||
const cost = calcCost("gemini-3-flash-preview", {
|
||
promptTokenCount: 100,
|
||
candidatesTokenCount: 50,
|
||
thoughtsTokenCount: 25,
|
||
});
|
||
assert.equal(cost.totalTokens, 175);
|
||
});
|
||
});
|
||
|
||
describe("buildAnalysisPrompt", () => {
|
||
test("numbers entries with timestamp + offset", () => {
|
||
const entries = [
|
||
{ offset: 0, text: "Welcome", duration: 5 },
|
||
{ offset: 30, text: "Topic two", duration: 60 },
|
||
];
|
||
const prompt = buildAnalysisPrompt(entries);
|
||
assert.match(prompt, /\[0\] \(0:00\) Welcome/);
|
||
assert.match(prompt, /\[1\] \(0:30\) Topic two/);
|
||
});
|
||
|
||
test("substitutes the last segment index in the constraint", () => {
|
||
const entries = [{ offset: 0, text: "x", duration: 1 }];
|
||
const prompt = buildAnalysisPrompt(entries);
|
||
assert.match(prompt, /from 0 to 0 must belong/);
|
||
});
|
||
|
||
test("handles long-running content (hours)", () => {
|
||
const entries = [
|
||
{ offset: 0, text: "start", duration: 1 },
|
||
{ offset: 3661, text: "later", duration: 1 },
|
||
];
|
||
const prompt = buildAnalysisPrompt(entries);
|
||
assert.match(prompt, /\(1:01:01\) later/);
|
||
});
|
||
|
||
test("emits a JSON-output instruction", () => {
|
||
const prompt = buildAnalysisPrompt([{ offset: 0, text: "x", duration: 1 }]);
|
||
assert.match(prompt, /Respond with ONLY valid JSON/);
|
||
assert.match(prompt, /"sections":/);
|
||
assert.match(prompt, /"startIndex":/);
|
||
assert.match(prompt, /"endIndex":/);
|
||
});
|
||
});
|