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;
}