File size: 5,178 Bytes
ef4c36f 20176e3 ef4c36f f7933f3 ef4c36f 20176e3 ef4c36f f7933f3 ef4c36f 20176e3 f7933f3 20176e3 f7933f3 20176e3 f7933f3 20176e3 f7933f3 ef4c36f 20176e3 ef4c36f 9928810 ef4c36f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 | import {
enforceScriptLimit,
MAX_SCRIPT_CHARS,
MIN_SCRIPT_CHARS,
TARGET_SECONDS,
} from "@/lib/script-limits";
import type { GitHubRepoData } from "@/types";
// Free-tier text models, best first (Gemini API pricing + models docs, 2026-11).
// Quotas are per model, so a 429 on one usually means the next still has room.
// Skipped: 3.1-pro-preview (paid-only), 2.5-pro/2.5-flash (deprecated, reroute 2026-12-01).
const GEMINI_MODELS = [
"gemini-3.8-flash",
"gemini-3.6-flash",
"gemini-3.5-flash-lite",
"gemini-3.1-flash-lite",
];
const NVIDIA_MODEL = "minimaxai/minimax-m3.1-flash";
interface GeminiResponse {
candidates?: Array<{
content?: { parts?: Array<{ text?: string }> };
}>;
}
export async function callLlm(
prompt: string,
{ maxTokens = 300, temperature = 0.8, timeoutMs = 45_000 } = {},
): Promise<string> {
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) throw new Error("GEMINI_API_KEY is not set");
// ponytail: one try per model, any failure (429/503/timeout/404/empty) falls to the next model —
// switching models replaces backoff. Healthy replies take ~10s; worst case 4 × timeoutMs.
const errors: string[] = [];
for (const model of GEMINI_MODELS) {
try {
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
{
method: "POST",
headers: { "Content-Type": "application/json", "x-goog-api-key": apiKey },
body: JSON.stringify({
contents: [{ role: "user", parts: [{ text: prompt }] }],
generationConfig: {
temperature,
maxOutputTokens: maxTokens,
// ponytail: Flash thinks by default and burns maxOutputTokens on it; off for short copy
thinkingConfig: { thinkingBudget: 0 },
},
}),
signal: AbortSignal.timeout(timeoutMs),
},
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`);
}
const data = (await response.json()) as GeminiResponse;
const raw = data.candidates?.[0]?.content?.parts
?.map((p) => p.text ?? "")
.join("")
.trim();
if (!raw) throw new Error("empty response");
return raw;
} catch (err) {
errors.push(`${model}: ${(err as Error).message}`);
console.warn(`Gemini ${model} failed, trying next model:`, (err as Error).message);
}
}
// Last resort: NVIDIA's OpenAI-compatible endpoint. Non-streaming — we need the whole text anyway.
const nvidiaKey = process.env.NVIDIA_API_KEY;
if (nvidiaKey) {
try {
const response = await fetch("https://integrate.api.nvidia.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${nvidiaKey}`,
},
body: JSON.stringify({
model: NVIDIA_MODEL,
messages: [{ role: "user", content: prompt }],
max_tokens: maxTokens,
temperature,
top_p: 0.95,
stream: false,
}),
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`);
}
const data = (await response.json()) as {
choices?: Array<{ message?: { content?: string | null } }>;
};
// ponytail: some models inline their reasoning as <think>…</think>; it must not reach the script
const raw = data.choices?.[0]?.message?.content
?.replace(/<think>[\s\S]*?<\/think>/g, "")
.trim();
if (!raw) throw new Error("empty response");
return raw;
} catch (err) {
errors.push(`nvidia ${NVIDIA_MODEL}: ${(err as Error).message}`);
}
}
throw new Error(`All LLM providers failed — ${errors.join(" | ")}`);
}
export async function generateIntroScript(repo: GitHubRepoData): Promise<string> {
const prompt = `You write viral ${TARGET_SECONDS} second TikTok/Reels intro scripts for GitHub repos.
Repo: ${repo.fullName}
Description: ${repo.description || "No description"}
Language: ${repo.language}
Stars: ${repo.stars}
Topics: ${repo.topics.join(", ") || "none"}
README excerpt:
${repo.readme.slice(0, 2500)}
Write a punchy spoken intro script that:
- Takes ${TARGET_SECONDS} seconds to read aloud: 65-80 words, between ${MIN_SCRIPT_CHARS} and ${MAX_SCRIPT_CHARS} characters
- Never shorter than ${MIN_SCRIPT_CHARS} characters — add a concrete detail from the README rather than padding
- Opens with a hook about what the project does
- Highlights 2-3 key features or use cases
- Ends by asking viewers to comment below to get the repo link directly in their DM
- Uses short sentences, no markdown, no emojis
- Sounds natural when read aloud
Return ONLY the script text, nothing else.`;
const raw = await callLlm(prompt, { maxTokens: 300 });
const script = enforceScriptLimit(raw.replace(/^["']|["']$/g, ""));
if (!script) throw new Error("LLM returned empty script");
return script;
}
|