Module split: extract Gemini-specific helpers to server/gemini-helpers.js

• PRICING table             — per-1M-token rates by model
  • calcCost(model, usage)    — Gemini usage object → cost record
  • buildAnalysisPrompt(...)  — JSON-output topic-analysis prompt

These all share the Gemini contract — pricing schema, usage shape, and
prompt format. When we add other providers, each gets its own
provider-specific helpers file; this becomes the basis of the Gemini
provider implementation.

server/index.js: 2828 → 2758 lines.

Smoke tested: server boots; /api/license-status, /api/health, and /
(frontend) all respond. No behavior change.
This commit is contained in:
Keysat
2026-05-08 16:50:34 -05:00
parent ffc8c31130
commit 1c78e46ebd
2 changed files with 93 additions and 73 deletions
+90
View File
@@ -0,0 +1,90 @@
// Gemini-specific helpers: pricing table, cost calculation, prompt
// builder. Pure module — no state, no I/O. When we add other providers,
// each provider gets its own equivalent of this file.
import { formatTime } from "./util.js";
// ── Pricing (per 1M tokens) ─────────────────────────────────────────────────
// Only the models we actually use as analysis fallbacks. Keep flat — the
// numbers are operational data, not configuration. Update when Google
// changes published rates.
export const PRICING = {
"gemini-3-flash-preview": { input: 0.50, output: 3.00, thinking: 3.00 },
"gemini-3-pro-preview": { input: 2.00, output: 12.00, thinking: 12.00 },
"gemini-3.1-pro-preview": { input: 2.00, output: 12.00, thinking: 12.00 },
"gemini-2.5-flash": { input: 0.15, output: 0.60, thinking: 0.60 },
// Fallback for unknown / future models — better an estimate than nothing.
"default": { input: 1.00, output: 5.00, thinking: 5.00 },
};
// ── Cost calculation ────────────────────────────────────────────────────────
// Takes a Gemini SDK `usage` object (response.usageMetadata) and produces
// a structured cost record. Display strings are formatted at extraction
// time so callers don't reformat. Returns zeros for unknown models (uses
// the "default" rates).
export function calcCost(modelName, usage) {
const rates = PRICING[modelName] || PRICING["default"];
const inputTokens = usage.promptTokenCount || 0;
const outputTokens = usage.candidatesTokenCount || 0;
const thinkingTokens = usage.thoughtsTokenCount || 0;
const inputCost = (inputTokens / 1_000_000) * rates.input;
const outputCost = (outputTokens / 1_000_000) * rates.output;
const thinkingCost = (thinkingTokens / 1_000_000) * rates.thinking;
const totalCost = inputCost + outputCost + thinkingCost;
return {
inputTokens,
outputTokens,
thinkingTokens,
totalTokens: usage.totalTokenCount || (inputTokens + outputTokens + thinkingTokens),
inputCost: inputCost.toFixed(6),
outputCost: outputCost.toFixed(6),
thinkingCost: thinkingCost.toFixed(6),
totalCost: totalCost.toFixed(6),
totalCostDisplay: totalCost < 0.01 ? `$${(totalCost * 100).toFixed(3)}¢` : `$${totalCost.toFixed(4)}`,
};
}
// ── Topic-analysis prompt builder ───────────────────────────────────────────
// Takes the parsed transcript entries and builds the JSON-output prompt
// fed to the analysis model. Indices in the response are positional into
// the same `entries` array — the caller relies on that contract.
export function buildAnalysisPrompt(entries) {
const numbered = entries
.map((e, i) => `[${i}] (${formatTime(e.offset)}) ${e.text}`)
.join("\n");
return `You are analyzing a video transcript. Your job is to identify natural topic boundaries and group the transcript into discussion-based sections.
TRANSCRIPT (each line is numbered with a timestamp):
${numbered}
INSTRUCTIONS:
1. Read the entire transcript carefully.
2. Identify where the discussion naturally shifts from one topic to another.
3. Group consecutive transcript segments by topic. Some sections may be short (a quick aside) and some may be long (an extended deep-dive). Let the content dictate the length.
4. For each section, write:
- A short, specific topic title (3-8 words)
- A 1-3 sentence summary of what's discussed
- The start and end segment indices (inclusive)
IMPORTANT:
- Sections must be chronological and non-overlapping.
- Every segment index from 0 to ${entries.length - 1} must belong to exactly one section.
- startIndex of section N+1 must equal endIndex of section N plus 1.
- Create as many or as few sections as the content naturally requires.
- Titles should be descriptive and specific, not generic like "Introduction" unless it truly is one.
Respond with ONLY valid JSON in this exact format, no other text:
{
"sections": [
{
"title": "Brief Topic Title",
"summary": "1-3 sentence summary of this discussion section.",
"startIndex": 0,
"endIndex": 15
}
]
}`;
}
+3 -73
View File
@@ -18,6 +18,7 @@ import {
safeText,
retryGemini,
} from "./util.js";
import { calcCost, buildAnalysisPrompt } from "./gemini-helpers.js";
const execFileAsync = promisify(execFile);
const app = express();
@@ -427,41 +428,7 @@ async function autoUpdateYtdlp() {
}
}
// ── Pricing (per 1M tokens) ───────────────────────────────────────────────
const PRICING = {
"gemini-3-flash-preview": { input: 0.50, output: 3.00, thinking: 3.00 },
"gemini-3-pro-preview": { input: 2.00, output: 12.00, thinking: 12.00 },
"gemini-3.1-pro-preview": { input: 2.00, output: 12.00, thinking: 12.00 },
"gemini-2.5-flash": { input: 0.15, output: 0.60, thinking: 0.60 },
// Fallback for unknown models
"default": { input: 1.00, output: 5.00, thinking: 5.00 },
};
function calcCost(modelName, usage) {
const rates = PRICING[modelName] || PRICING["default"];
const inputTokens = usage.promptTokenCount || 0;
const outputTokens = usage.candidatesTokenCount || 0;
const thinkingTokens = usage.thoughtsTokenCount || 0;
const inputCost = (inputTokens / 1_000_000) * rates.input;
const outputCost = (outputTokens / 1_000_000) * rates.output;
const thinkingCost = (thinkingTokens / 1_000_000) * rates.thinking;
const totalCost = inputCost + outputCost + thinkingCost;
return {
inputTokens,
outputTokens,
thinkingTokens,
totalTokens: usage.totalTokenCount || (inputTokens + outputTokens + thinkingTokens),
inputCost: inputCost.toFixed(6),
outputCost: outputCost.toFixed(6),
thinkingCost: thinkingCost.toFixed(6),
totalCost: totalCost.toFixed(6),
totalCostDisplay: totalCost < 0.01 ? `$${(totalCost * 100).toFixed(3)}¢` : `$${totalCost.toFixed(4)}`,
};
}
// PRICING + calcCost + buildAnalysisPrompt moved to ./gemini-helpers.js
// safeText + retryGemini moved to ./util.js
// ── Health check ───────────────────────────────────────────────────────────
@@ -2714,44 +2681,7 @@ async function splitAudioFile(inputPath, outputDir, chunkSeconds = 2700) {
// sendEvent / extractVideoId / formatTime / parseTimestampedTranscript moved to ./util.js
function buildAnalysisPrompt(entries) {
const numbered = entries
.map((e, i) => `[${i}] (${formatTime(e.offset)}) ${e.text}`)
.join("\n");
return `You are analyzing a video transcript. Your job is to identify natural topic boundaries and group the transcript into discussion-based sections.
TRANSCRIPT (each line is numbered with a timestamp):
${numbered}
INSTRUCTIONS:
1. Read the entire transcript carefully.
2. Identify where the discussion naturally shifts from one topic to another.
3. Group consecutive transcript segments by topic. Some sections may be short (a quick aside) and some may be long (an extended deep-dive). Let the content dictate the length.
4. For each section, write:
- A short, specific topic title (3-8 words)
- A 1-3 sentence summary of what's discussed
- The start and end segment indices (inclusive)
IMPORTANT:
- Sections must be chronological and non-overlapping.
- Every segment index from 0 to ${entries.length - 1} must belong to exactly one section.
- startIndex of section N+1 must equal endIndex of section N plus 1.
- Create as many or as few sections as the content naturally requires.
- Titles should be descriptive and specific, not generic like "Introduction" unless it truly is one.
Respond with ONLY valid JSON in this exact format, no other text:
{
"sections": [
{
"title": "Brief Topic Title",
"summary": "1-3 sentence summary of this discussion section.",
"startIndex": 0,
"endIndex": 15
}
]
}`;
}
// buildAnalysisPrompt moved to ./gemini-helpers.js
// ── Network mode ──────────────────────────────────────────────────────────
// On StartOS (DATA_DIR=/data): always bind to 0.0.0.0 (container networking)