gitcast 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ai/__init__.py +0 -0
- ai/formatter.py +59 -0
- ai/generator.py +604 -0
- ai/prompts.py +197 -0
- ai/viral_patterns.py +75 -0
- api/__init__.py +0 -0
- api/analytics.py +48 -0
- api/auth.py +49 -0
- api/auth_middleware.py +129 -0
- api/auth_routes.py +117 -0
- api/monitoring.py +56 -0
- api/payload.py +253 -0
- api/ratelimit.py +9 -0
- api/routes.py +1565 -0
- api/server.py +162 -0
- api/validators.py +101 -0
- assets/__init__.py +1 -0
- assets/favicon-16x16.png +0 -0
- assets/favicon-32x32.png +0 -0
- assets/favicon-64x64.png +0 -0
- assets/favicon.ico +0 -0
- assets/icon.png +0 -0
- cli/.env.example +26 -0
- cli/__init__.py +1 -0
- cli/gitcast.py +79 -0
- config/__init__.py +0 -0
- config/settings.py +213 -0
- core/__init__.py +0 -0
- core/capture.py +258 -0
- core/codebase_reader.py +90 -0
- core/framing.py +86 -0
- core/hotkey.py +21 -0
- core/log_stream.py +50 -0
- core/ocr.py +173 -0
- core/screenshot_session.py +274 -0
- core/security.py +126 -0
- core/tray.py +54 -0
- gitcast-1.0.0.dist-info/LICENSE +21 -0
- gitcast-1.0.0.dist-info/METADATA +67 -0
- gitcast-1.0.0.dist-info/RECORD +61 -0
- gitcast-1.0.0.dist-info/WHEEL +5 -0
- gitcast-1.0.0.dist-info/entry_points.txt +2 -0
- gitcast-1.0.0.dist-info/top_level.txt +10 -0
- publisher/__init__.py +0 -0
- publisher/clipboard.py +44 -0
- publisher/twitter.py +100 -0
- storage/__init__.py +0 -0
- storage/cleanup.py +60 -0
- storage/engagement.py +114 -0
- storage/insights.py +203 -0
- storage/key_manager.py +45 -0
- storage/logger.py +208 -0
- storage/metrics.py +119 -0
- storage/sprint.py +40 -0
- storage/streak.py +0 -0
- storage/supabase_client.py +25 -0
- storage/tone_memory.py +139 -0
- ui/__init__.py +0 -0
- web/__init__.py +1 -0
- web/index.html +4994 -0
- web/landing.html +925 -0
ai/__init__.py
ADDED
|
File without changes
|
ai/formatter.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
# [Formatter] module for post cleaning and thread splitting
|
|
4
|
+
|
|
5
|
+
def split_into_thread(post_text: str, max_chars: int = 270) -> list[str]:
|
|
6
|
+
"""
|
|
7
|
+
Splits text into multiple tweets at sentence boundaries.
|
|
8
|
+
Numbers each tweet: 1/ 2/ 3/
|
|
9
|
+
"""
|
|
10
|
+
if len(post_text) <= 280:
|
|
11
|
+
return [post_text]
|
|
12
|
+
|
|
13
|
+
# Split into sentences (basic regex split)
|
|
14
|
+
sentences = re.split(r'(?<=[.!?])\s+', post_text.strip())
|
|
15
|
+
|
|
16
|
+
tweets = []
|
|
17
|
+
current_tweet = ""
|
|
18
|
+
tweet_number = 1
|
|
19
|
+
|
|
20
|
+
for sentence in sentences:
|
|
21
|
+
# Check if adding this sentence exceeds limit (considering "N/ " prefix)
|
|
22
|
+
prefix = f"{tweet_number}/ "
|
|
23
|
+
potential_content = current_tweet + (" " if current_tweet else "") + sentence
|
|
24
|
+
|
|
25
|
+
if len(prefix + potential_content) <= max_chars:
|
|
26
|
+
current_tweet = potential_content
|
|
27
|
+
else:
|
|
28
|
+
if current_tweet:
|
|
29
|
+
tweets.append(f"{tweet_number}/ {current_tweet}")
|
|
30
|
+
tweet_number += 1
|
|
31
|
+
|
|
32
|
+
# If a single sentence is too long, we must split it by words
|
|
33
|
+
if len(f"{tweet_number}/ {sentence}") > max_chars:
|
|
34
|
+
words = sentence.split()
|
|
35
|
+
sub_tweet = ""
|
|
36
|
+
for word in words:
|
|
37
|
+
if len(f"{tweet_number}/ {sub_tweet} {word}") <= max_chars:
|
|
38
|
+
sub_tweet = f"{sub_tweet} {word}".strip()
|
|
39
|
+
else:
|
|
40
|
+
tweets.append(f"{tweet_number}/ {sub_tweet}...")
|
|
41
|
+
tweet_number += 1
|
|
42
|
+
sub_tweet = word
|
|
43
|
+
current_tweet = sub_tweet
|
|
44
|
+
else:
|
|
45
|
+
current_tweet = sentence
|
|
46
|
+
|
|
47
|
+
if current_tweet:
|
|
48
|
+
tweets.append(f"{tweet_number}/ {current_tweet}")
|
|
49
|
+
|
|
50
|
+
return tweets
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
print("=== THREAD SPLITTER TEST ===")
|
|
54
|
+
long_post = "This is a very long post that should be split into multiple tweets. It contains many sentences. Each sentence is relatively short but together they exceed the limit of a single tweet. We want to see how the formatter handles this situation by splitting at sentence boundaries and numbering the resulting tweets correctly."
|
|
55
|
+
|
|
56
|
+
# Force small limit for testing
|
|
57
|
+
result = split_into_thread(long_post, max_chars=100)
|
|
58
|
+
for t in result:
|
|
59
|
+
print(f"[{len(t)} chars] {t}")
|
ai/generator.py
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import httpx
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from ai.prompts import get_all_prompts, sprint_summary_prompt
|
|
6
|
+
from core.log_stream import stream_log
|
|
7
|
+
from api.analytics import track
|
|
8
|
+
from config.settings import (
|
|
9
|
+
GROQ_API_KEY,
|
|
10
|
+
GEMINI_API_KEY,
|
|
11
|
+
MOONSHOT_API_KEY,
|
|
12
|
+
CEREBRAS_API_KEY,
|
|
13
|
+
OPENROUTER_API_KEY,
|
|
14
|
+
GROQ_MODEL,
|
|
15
|
+
MOONSHOT_MODEL,
|
|
16
|
+
GEMINI_MODEL,
|
|
17
|
+
CEREBRAS_MODEL,
|
|
18
|
+
OPENROUTER_MODEL,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# ── Constants ─────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
PROVIDERS = {
|
|
24
|
+
"groq": {
|
|
25
|
+
"base_url": "https://api.groq.com/openai/v1",
|
|
26
|
+
"api_key": GROQ_API_KEY,
|
|
27
|
+
"model": GROQ_MODEL,
|
|
28
|
+
"tasks": ["quick_win", "struggle", "linkedin", "deep_tech", "pr_generator"]
|
|
29
|
+
},
|
|
30
|
+
"kimi": {
|
|
31
|
+
"base_url": "https://api.moonshot.cn/v1",
|
|
32
|
+
"api_key": MOONSHOT_API_KEY,
|
|
33
|
+
"model": MOONSHOT_MODEL,
|
|
34
|
+
"tasks": ["article", "sprint_summary"]
|
|
35
|
+
},
|
|
36
|
+
"cerebras": {
|
|
37
|
+
"base_url": "https://api.cerebras.ai/v1",
|
|
38
|
+
"api_key": CEREBRAS_API_KEY,
|
|
39
|
+
"model": CEREBRAS_MODEL,
|
|
40
|
+
"tasks": [] # fallback only
|
|
41
|
+
},
|
|
42
|
+
"openrouter": {
|
|
43
|
+
"base_url": "https://openrouter.ai/api/v1",
|
|
44
|
+
"api_key": OPENROUTER_API_KEY,
|
|
45
|
+
"model": OPENROUTER_MODEL,
|
|
46
|
+
"tasks": [] # fallback only
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
GEMINI_URL = f"https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_MODEL}:generateContent"
|
|
51
|
+
MAX_TOKENS = 4096
|
|
52
|
+
TIMEOUT = 15
|
|
53
|
+
|
|
54
|
+
_last_call_meta = {"provider_used": "", "used_fallback": False}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ProviderUnavailable(Exception):
|
|
58
|
+
def __init__(self, provider: str, message: str, status_code: int | None = None):
|
|
59
|
+
super().__init__(message)
|
|
60
|
+
self.provider = provider
|
|
61
|
+
self.status_code = status_code
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_uuid(value: str) -> bool:
|
|
65
|
+
try:
|
|
66
|
+
uuid.UUID(str(value))
|
|
67
|
+
return True
|
|
68
|
+
except (TypeError, ValueError):
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _load_user_provider_keys(user_id: str) -> dict:
|
|
73
|
+
from storage.key_manager import decrypt_key
|
|
74
|
+
from storage.supabase_client import get_client
|
|
75
|
+
|
|
76
|
+
if not user_id or not _is_uuid(user_id):
|
|
77
|
+
return {}
|
|
78
|
+
|
|
79
|
+
response = (
|
|
80
|
+
get_client()
|
|
81
|
+
.table("api_keys")
|
|
82
|
+
.select("provider,encrypted_key")
|
|
83
|
+
.eq("user_id", user_id)
|
|
84
|
+
.execute()
|
|
85
|
+
)
|
|
86
|
+
keys = {}
|
|
87
|
+
for row in response.data or []:
|
|
88
|
+
provider = row.get("provider")
|
|
89
|
+
encrypted_key = row.get("encrypted_key")
|
|
90
|
+
if not provider or not encrypted_key:
|
|
91
|
+
continue
|
|
92
|
+
try:
|
|
93
|
+
keys[provider] = decrypt_key(encrypted_key)
|
|
94
|
+
(
|
|
95
|
+
get_client()
|
|
96
|
+
.table("api_keys")
|
|
97
|
+
.update({"last_used_at": datetime.now().isoformat()})
|
|
98
|
+
.eq("user_id", user_id)
|
|
99
|
+
.eq("provider", provider)
|
|
100
|
+
.execute()
|
|
101
|
+
)
|
|
102
|
+
except Exception as e:
|
|
103
|
+
stream_log("Keys", "WARN", f"{provider} key decrypt failed: {e}")
|
|
104
|
+
return keys
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def refresh_provider_keys(user_id: str = "") -> None:
|
|
108
|
+
from config import settings
|
|
109
|
+
|
|
110
|
+
settings.reload_api_keys()
|
|
111
|
+
user_keys = _load_user_provider_keys(user_id) if user_id else {}
|
|
112
|
+
PROVIDERS["groq"]["api_key"] = settings.GROQ_API_KEY
|
|
113
|
+
PROVIDERS["kimi"]["api_key"] = settings.MOONSHOT_API_KEY
|
|
114
|
+
PROVIDERS["cerebras"]["api_key"] = settings.CEREBRAS_API_KEY
|
|
115
|
+
PROVIDERS["openrouter"]["api_key"] = settings.OPENROUTER_API_KEY
|
|
116
|
+
PROVIDERS["groq"]["model"] = settings.GROQ_MODEL
|
|
117
|
+
PROVIDERS["kimi"]["model"] = settings.MOONSHOT_MODEL
|
|
118
|
+
PROVIDERS["cerebras"]["model"] = settings.CEREBRAS_MODEL
|
|
119
|
+
PROVIDERS["openrouter"]["model"] = settings.OPENROUTER_MODEL
|
|
120
|
+
|
|
121
|
+
global GEMINI_API_KEY
|
|
122
|
+
global GEMINI_URL
|
|
123
|
+
GEMINI_API_KEY = settings.GEMINI_API_KEY
|
|
124
|
+
GEMINI_URL = f"https://generativelanguage.googleapis.com/v1beta/models/{settings.GEMINI_MODEL}:generateContent"
|
|
125
|
+
for provider, api_key in user_keys.items():
|
|
126
|
+
if provider == "gemini":
|
|
127
|
+
GEMINI_API_KEY = api_key
|
|
128
|
+
elif provider in PROVIDERS:
|
|
129
|
+
PROVIDERS[provider]["api_key"] = api_key
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── Main generator ────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
async def generate_posts(payload: dict, user_id: str = "") -> dict:
|
|
135
|
+
async def _generate_all():
|
|
136
|
+
refresh_provider_keys(user_id)
|
|
137
|
+
prompts = get_all_prompts()
|
|
138
|
+
format_keys = payload.get("format_keys", list(prompts.keys()))
|
|
139
|
+
user_message = payload.get("user_message", "")
|
|
140
|
+
use_vision = payload.get("use_vision_fallback", False)
|
|
141
|
+
|
|
142
|
+
screenshots = payload.get("screenshots", [])
|
|
143
|
+
if not screenshots and payload.get("screenshot_path"):
|
|
144
|
+
screenshots = [{"path": payload["screenshot_path"]}]
|
|
145
|
+
|
|
146
|
+
selected_prompts = {k: v for k, v in prompts.items() if k in format_keys}
|
|
147
|
+
|
|
148
|
+
output = {}
|
|
149
|
+
unavailable = {}
|
|
150
|
+
|
|
151
|
+
async def run_one(format_key, system_prompt, client):
|
|
152
|
+
started = asyncio.get_event_loop().time()
|
|
153
|
+
active_user_message = user_message
|
|
154
|
+
if format_key == "pr_generator":
|
|
155
|
+
if "## Screen context" in active_user_message:
|
|
156
|
+
parts = active_user_message.split("## Screen context")
|
|
157
|
+
prefix = parts[0].strip()
|
|
158
|
+
suffix = parts[1].strip()
|
|
159
|
+
if "## " in suffix:
|
|
160
|
+
next_idx = suffix.find("## ")
|
|
161
|
+
active_user_message = prefix + "\n\n" + suffix[next_idx:].strip()
|
|
162
|
+
else:
|
|
163
|
+
active_user_message = prefix
|
|
164
|
+
|
|
165
|
+
local_meta = {"provider_used": "", "used_fallback": False}
|
|
166
|
+
try:
|
|
167
|
+
if use_vision and screenshots and format_key != "pr_generator":
|
|
168
|
+
stream_log("ROUTER", "ROUTER", f"{format_key} -> gemini vision fallback")
|
|
169
|
+
stream_log("GENERATOR", "INFO", f"generating {format_key} via gemini...")
|
|
170
|
+
t0 = asyncio.get_event_loop().time()
|
|
171
|
+
try:
|
|
172
|
+
result = await _gemini_vision_call(
|
|
173
|
+
system_prompt=system_prompt,
|
|
174
|
+
user_message=active_user_message,
|
|
175
|
+
screenshots=screenshots,
|
|
176
|
+
client=client,
|
|
177
|
+
)
|
|
178
|
+
elapsed = asyncio.get_event_loop().time() - t0
|
|
179
|
+
stream_log("GENERATOR", "OK", f"{format_key} complete ({elapsed:.1f}s)")
|
|
180
|
+
local_meta.update({"provider_used": "gemini", "used_fallback": True})
|
|
181
|
+
except Exception as e:
|
|
182
|
+
print(f"[Generator] {format_key} failed on gemini: {e}")
|
|
183
|
+
stream_log("GENERATOR", "WARN", f"{format_key} failed on gemini — trying fallback")
|
|
184
|
+
raise e
|
|
185
|
+
else:
|
|
186
|
+
result = await _ai_call(
|
|
187
|
+
format_key,
|
|
188
|
+
system_prompt,
|
|
189
|
+
active_user_message,
|
|
190
|
+
user_id=user_id,
|
|
191
|
+
unavailable=unavailable,
|
|
192
|
+
client=client,
|
|
193
|
+
meta=local_meta,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
output[format_key] = result
|
|
197
|
+
elapsed = asyncio.get_event_loop().time() - started
|
|
198
|
+
track("post_generated", {
|
|
199
|
+
"format_keys": [format_key],
|
|
200
|
+
"provider_used": local_meta.get("provider_used", ""),
|
|
201
|
+
"latency_seconds": round(elapsed, 1),
|
|
202
|
+
"used_fallback": bool(local_meta.get("used_fallback", False)),
|
|
203
|
+
})
|
|
204
|
+
except Exception as e:
|
|
205
|
+
stream_log("Generator", "ERROR", f"{format_key} failed: {e}")
|
|
206
|
+
output[format_key] = f"[Error] {str(e)}"
|
|
207
|
+
|
|
208
|
+
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
|
209
|
+
tasks = [
|
|
210
|
+
run_one(format_key, system_prompt, client)
|
|
211
|
+
for format_key, system_prompt in selected_prompts.items()
|
|
212
|
+
]
|
|
213
|
+
await asyncio.gather(*tasks)
|
|
214
|
+
|
|
215
|
+
# Check if all format generation failed
|
|
216
|
+
all_failed = True
|
|
217
|
+
for format_key in format_keys:
|
|
218
|
+
val = output.get(format_key)
|
|
219
|
+
if val and not val.startswith("[Error]"):
|
|
220
|
+
all_failed = False
|
|
221
|
+
break
|
|
222
|
+
|
|
223
|
+
if all_failed:
|
|
224
|
+
return {"error": "all providers failed — check your API keys"}
|
|
225
|
+
|
|
226
|
+
return output
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
# Add a 30 second total timeout across all calls
|
|
230
|
+
return await asyncio.wait_for(_generate_all(), timeout=30.0)
|
|
231
|
+
except asyncio.TimeoutError:
|
|
232
|
+
stream_log("Generator", "ERROR", "Generation timed out after 30 seconds")
|
|
233
|
+
return {"error": "all providers failed — check your API keys"}
|
|
234
|
+
except Exception as e:
|
|
235
|
+
stream_log("Generator", "ERROR", f"Generation failed: {e}")
|
|
236
|
+
return {"error": "all providers failed — check your API keys"}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ── Sprint generator ──────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
async def generate_sprint_summary(entries: list, narrative: str = "", user_id: str = "") -> str:
|
|
242
|
+
"""
|
|
243
|
+
Synthesizes multiple sprint captures into a single cohesive thread.
|
|
244
|
+
"""
|
|
245
|
+
from api.payload import build_sprint_payload
|
|
246
|
+
|
|
247
|
+
stream_log("Generator", "AI", f"synthesizing {len(entries)} captures into sprint thread")
|
|
248
|
+
|
|
249
|
+
refresh_provider_keys(user_id)
|
|
250
|
+
payload = build_sprint_payload(entries)
|
|
251
|
+
system_prompt = sprint_summary_prompt(len(entries))
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
|
255
|
+
return await _ai_call("sprint_summary", system_prompt, payload["user_message"], user_id=user_id, client=client)
|
|
256
|
+
except Exception as e:
|
|
257
|
+
stream_log("Generator", "ERROR", f"sprint synthesis failed: {e}")
|
|
258
|
+
return f"[Error] {str(e)}"
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ── Provider Routing & Fallback ────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
async def _ai_call(
|
|
264
|
+
format_key: str,
|
|
265
|
+
system_prompt: str,
|
|
266
|
+
user_message: str,
|
|
267
|
+
user_id: str = "",
|
|
268
|
+
unavailable: dict[str, str] | None = None,
|
|
269
|
+
client: httpx.AsyncClient = None,
|
|
270
|
+
meta: dict | None = None,
|
|
271
|
+
) -> str:
|
|
272
|
+
"""
|
|
273
|
+
Identifies primary provider for a task and falls back through available providers.
|
|
274
|
+
"""
|
|
275
|
+
unavailable = unavailable if unavailable is not None else {}
|
|
276
|
+
|
|
277
|
+
# 1. Identify primary provider
|
|
278
|
+
primary = "groq" # default
|
|
279
|
+
for name, config in PROVIDERS.items():
|
|
280
|
+
if format_key in config["tasks"]:
|
|
281
|
+
primary = name
|
|
282
|
+
break
|
|
283
|
+
|
|
284
|
+
# 2. Build the fallback chain
|
|
285
|
+
# Standard order: Primary -> Groq -> Cerebras -> Kimi -> OpenRouter -> Gemini
|
|
286
|
+
chain = [primary]
|
|
287
|
+
for fallback in ["groq", "cerebras", "kimi", "openrouter"]:
|
|
288
|
+
if fallback not in chain:
|
|
289
|
+
chain.append(fallback)
|
|
290
|
+
|
|
291
|
+
# 3. Walk the chain
|
|
292
|
+
last_error = None
|
|
293
|
+
stream_log("ROUTER", "ROUTER", f"{format_key} -> {primary}")
|
|
294
|
+
|
|
295
|
+
async def run_chain(active_client: httpx.AsyncClient):
|
|
296
|
+
nonlocal last_error
|
|
297
|
+
for provider_name in chain:
|
|
298
|
+
if provider_name in unavailable:
|
|
299
|
+
stream_log("ROUTER", "WARN", f"{provider_name} skipped: {unavailable[provider_name]}")
|
|
300
|
+
last_error = unavailable[provider_name]
|
|
301
|
+
continue
|
|
302
|
+
|
|
303
|
+
stream_log("GENERATOR", "INFO", f"generating {format_key} via {provider_name}...")
|
|
304
|
+
t0 = asyncio.get_event_loop().time()
|
|
305
|
+
try:
|
|
306
|
+
if provider_name != primary:
|
|
307
|
+
stream_log("ROUTER", "ROUTER", f"{format_key} -> {provider_name} fallback")
|
|
308
|
+
result = await _call_provider(provider_name, system_prompt, user_message, client=active_client)
|
|
309
|
+
_last_call_meta.update({
|
|
310
|
+
"provider_used": provider_name,
|
|
311
|
+
"used_fallback": provider_name != primary,
|
|
312
|
+
})
|
|
313
|
+
if meta is not None:
|
|
314
|
+
meta.update({
|
|
315
|
+
"provider_used": provider_name,
|
|
316
|
+
"used_fallback": provider_name != primary,
|
|
317
|
+
})
|
|
318
|
+
elapsed = asyncio.get_event_loop().time() - t0
|
|
319
|
+
stream_log("GENERATOR", "OK", f"{format_key} complete ({elapsed:.1f}s)")
|
|
320
|
+
return result
|
|
321
|
+
except ProviderUnavailable as e:
|
|
322
|
+
last_error = str(e)
|
|
323
|
+
unavailable[e.provider] = str(e)
|
|
324
|
+
print(f"[Generator] {format_key} failed on {provider_name}: {e}")
|
|
325
|
+
stream_log("GENERATOR", "WARN", f"{format_key} failed on {provider_name} — trying fallback")
|
|
326
|
+
continue
|
|
327
|
+
except Exception as e:
|
|
328
|
+
last_error = str(e)
|
|
329
|
+
unavailable[provider_name] = str(e)
|
|
330
|
+
print(f"[Generator] {format_key} failed on {provider_name}: {e}")
|
|
331
|
+
stream_log("GENERATOR", "WARN", f"{format_key} failed on {provider_name} — trying fallback")
|
|
332
|
+
continue
|
|
333
|
+
|
|
334
|
+
# 4. Final attempt with Gemini
|
|
335
|
+
if GEMINI_API_KEY and "gemini" not in unavailable:
|
|
336
|
+
stream_log("GENERATOR", "INFO", f"generating {format_key} via gemini...")
|
|
337
|
+
t0 = asyncio.get_event_loop().time()
|
|
338
|
+
try:
|
|
339
|
+
if "gemini" != primary:
|
|
340
|
+
stream_log("ROUTER", "ROUTER", f"{format_key} -> gemini fallback")
|
|
341
|
+
result = await _gemini_text_call(system_prompt, user_message, client=active_client)
|
|
342
|
+
_last_call_meta.update({"provider_used": "gemini", "used_fallback": True})
|
|
343
|
+
if meta is not None:
|
|
344
|
+
meta.update({"provider_used": "gemini", "used_fallback": True})
|
|
345
|
+
elapsed = asyncio.get_event_loop().time() - t0
|
|
346
|
+
stream_log("GENERATOR", "OK", f"{format_key} complete ({elapsed:.1f}s)")
|
|
347
|
+
return result
|
|
348
|
+
except Exception as e:
|
|
349
|
+
last_error = f"Gemini also failed: {e}"
|
|
350
|
+
unavailable["gemini"] = last_error
|
|
351
|
+
print(f"[Generator] {format_key} failed on gemini: {e}")
|
|
352
|
+
stream_log("GENERATOR", "WARN", f"{format_key} failed on gemini — trying fallback")
|
|
353
|
+
elif "gemini" in unavailable:
|
|
354
|
+
last_error = unavailable["gemini"]
|
|
355
|
+
|
|
356
|
+
raise Exception(f"AI chain exhausted. Last error: {last_error}")
|
|
357
|
+
|
|
358
|
+
if client:
|
|
359
|
+
return await run_chain(client)
|
|
360
|
+
else:
|
|
361
|
+
async with httpx.AsyncClient(timeout=TIMEOUT) as local_client:
|
|
362
|
+
return await run_chain(local_client)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
async def _call_provider(
|
|
366
|
+
provider_name: str,
|
|
367
|
+
system_prompt: str,
|
|
368
|
+
user_message: str,
|
|
369
|
+
retries: int = 1,
|
|
370
|
+
client: httpx.AsyncClient = None,
|
|
371
|
+
) -> str:
|
|
372
|
+
"""
|
|
373
|
+
Single unified function for calling any OpenAI-compatible provider.
|
|
374
|
+
"""
|
|
375
|
+
config = PROVIDERS.get(provider_name)
|
|
376
|
+
if not config or not config["api_key"]:
|
|
377
|
+
raise ValueError(f"Provider {provider_name} not configured or missing API key.")
|
|
378
|
+
|
|
379
|
+
url = f"{config['base_url'].rstrip('/')}/chat/completions"
|
|
380
|
+
headers = {
|
|
381
|
+
"Authorization": f"Bearer {config['api_key']}",
|
|
382
|
+
"Content-Type": "application/json",
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
body = {
|
|
386
|
+
"model": config["model"],
|
|
387
|
+
"max_tokens": MAX_TOKENS,
|
|
388
|
+
"temperature": 0.8,
|
|
389
|
+
"messages": [
|
|
390
|
+
{"role": "system", "content": system_prompt},
|
|
391
|
+
{"role": "user", "content": user_message},
|
|
392
|
+
],
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
is_local_client = client is None
|
|
396
|
+
active_client = client if client is not None else httpx.AsyncClient(timeout=TIMEOUT)
|
|
397
|
+
try:
|
|
398
|
+
for attempt in range(retries + 1):
|
|
399
|
+
try:
|
|
400
|
+
stream_log(provider_name, "AI", f"Calling {config['model']} (timeout={TIMEOUT}s, attempt {attempt + 1}/{retries + 1})")
|
|
401
|
+
response = await active_client.post(url, headers=headers, json=body, timeout=TIMEOUT)
|
|
402
|
+
|
|
403
|
+
if response.status_code == 429:
|
|
404
|
+
if attempt < retries:
|
|
405
|
+
wait = 2 + (attempt * 2)
|
|
406
|
+
stream_log(provider_name, "WARN", f"Rate limited (429). Retrying in {wait}s...")
|
|
407
|
+
await asyncio.sleep(wait)
|
|
408
|
+
continue
|
|
409
|
+
raise ProviderUnavailable(provider_name, "Rate limit exceeded (429)", 429)
|
|
410
|
+
|
|
411
|
+
if response.status_code in {402, 404}:
|
|
412
|
+
raise ProviderUnavailable(
|
|
413
|
+
provider_name,
|
|
414
|
+
f"API Error {response.status_code}: {response.text[:180]}",
|
|
415
|
+
response.status_code,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
if response.status_code != 200:
|
|
419
|
+
raise Exception(f"API Error {response.status_code}: {response.text[:180]}")
|
|
420
|
+
|
|
421
|
+
data = response.json()
|
|
422
|
+
content = data["choices"][0]["message"]["content"].strip()
|
|
423
|
+
stream_log(provider_name, "OK", "Provider call complete successfully.")
|
|
424
|
+
return content
|
|
425
|
+
|
|
426
|
+
except httpx.TimeoutException as te:
|
|
427
|
+
stream_log(provider_name, "ERROR", f"Request to {provider_name} timed out after {TIMEOUT}s: {te}")
|
|
428
|
+
if attempt == retries:
|
|
429
|
+
raise ProviderUnavailable(provider_name, f"Request to {provider_name} timed out after {TIMEOUT}s", 408)
|
|
430
|
+
await asyncio.sleep(1)
|
|
431
|
+
except Exception as e:
|
|
432
|
+
stream_log(provider_name, "ERROR", f"Request to {provider_name} failed: {e}")
|
|
433
|
+
if attempt == retries:
|
|
434
|
+
raise e
|
|
435
|
+
await asyncio.sleep(1)
|
|
436
|
+
finally:
|
|
437
|
+
if is_local_client:
|
|
438
|
+
await active_client.aclose()
|
|
439
|
+
|
|
440
|
+
raise Exception(f"Provider {provider_name} failed after retries.")
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# Legacy aliases for backward compatibility with routes
|
|
444
|
+
async def _groq_call(system_prompt: str, user_message: str, retries: int = 2) -> str:
|
|
445
|
+
return await _call_provider("groq", system_prompt, user_message, retries=retries)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
# ── Gemini text call ──────────────────────────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
async def _gemini_text_call(system_prompt: str, user_message: str, client: httpx.AsyncClient = None) -> str:
|
|
451
|
+
"""
|
|
452
|
+
Fallback text generation using Gemini 1.5 Flash.
|
|
453
|
+
"""
|
|
454
|
+
if not GEMINI_API_KEY:
|
|
455
|
+
raise ValueError("GEMINI_API_KEY is not set in .env")
|
|
456
|
+
|
|
457
|
+
url = GEMINI_URL
|
|
458
|
+
headers = {
|
|
459
|
+
"Content-Type": "application/json",
|
|
460
|
+
"x-goog-api-key": GEMINI_API_KEY
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
body = {
|
|
464
|
+
"contents": [
|
|
465
|
+
{
|
|
466
|
+
"parts": [
|
|
467
|
+
{"text": f"System Instruction: {system_prompt}\n\nUser Message: {user_message}"}
|
|
468
|
+
]
|
|
469
|
+
}
|
|
470
|
+
],
|
|
471
|
+
"generationConfig": {
|
|
472
|
+
"maxOutputTokens": MAX_TOKENS,
|
|
473
|
+
"temperature": 0.7,
|
|
474
|
+
},
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
is_local_client = client is None
|
|
478
|
+
active_client = client if client is not None else httpx.AsyncClient(timeout=TIMEOUT)
|
|
479
|
+
try:
|
|
480
|
+
stream_log("Gemini", "AI", f"Calling Gemini text fallback API (timeout={TIMEOUT}s)")
|
|
481
|
+
response = await active_client.post(url, headers=headers, json=body, timeout=TIMEOUT)
|
|
482
|
+
if response.status_code != 200:
|
|
483
|
+
detail = response.text[:240]
|
|
484
|
+
stream_log("Gemini", "ERROR", f"Text call failed with {response.status_code}: {detail}")
|
|
485
|
+
if response.status_code in {400, 401, 403, 404, 429}:
|
|
486
|
+
raise ProviderUnavailable("gemini", f"Gemini API Error {response.status_code}: {detail}", response.status_code)
|
|
487
|
+
raise Exception(f"Gemini API Error {response.status_code}: {detail}")
|
|
488
|
+
|
|
489
|
+
data = response.json()
|
|
490
|
+
stream_log("Gemini", "OK", "Gemini text fallback complete successfully.")
|
|
491
|
+
return data["candidates"][0]["content"]["parts"][0]["text"].strip()
|
|
492
|
+
except httpx.TimeoutException as te:
|
|
493
|
+
stream_log("Gemini", "ERROR", f"Gemini text fallback call timed out after {TIMEOUT}s: {te}")
|
|
494
|
+
raise te
|
|
495
|
+
except Exception as e:
|
|
496
|
+
stream_log("Gemini", "ERROR", f"Gemini text fallback failed: {e}")
|
|
497
|
+
raise e
|
|
498
|
+
finally:
|
|
499
|
+
if is_local_client:
|
|
500
|
+
await active_client.aclose()
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
# ── Gemini vision call ────────────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
async def _gemini_vision_call(
|
|
506
|
+
system_prompt: str,
|
|
507
|
+
user_message: str,
|
|
508
|
+
screenshots: list,
|
|
509
|
+
client: httpx.AsyncClient = None,
|
|
510
|
+
) -> str:
|
|
511
|
+
"""
|
|
512
|
+
Calls Gemini 1.5 Flash with multiple screenshots as vision inputs.
|
|
513
|
+
"""
|
|
514
|
+
if not GEMINI_API_KEY:
|
|
515
|
+
raise ValueError("GEMINI_API_KEY is not set in .env")
|
|
516
|
+
|
|
517
|
+
url = f"{GEMINI_URL}?key={GEMINI_API_KEY}"
|
|
518
|
+
headers = {"Content-Type": "application/json"}
|
|
519
|
+
|
|
520
|
+
parts = [{"text": f"{system_prompt}\n\n{user_message}"}]
|
|
521
|
+
|
|
522
|
+
# Add each screenshot
|
|
523
|
+
for s in screenshots:
|
|
524
|
+
b64 = _encode_image(s["path"])
|
|
525
|
+
if b64:
|
|
526
|
+
parts.append({
|
|
527
|
+
"inline_data": {
|
|
528
|
+
"mime_type": "image/png",
|
|
529
|
+
"data": b64,
|
|
530
|
+
}
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
body = {
|
|
534
|
+
"contents": [{"parts": parts}],
|
|
535
|
+
"generationConfig": {
|
|
536
|
+
"maxOutputTokens": MAX_TOKENS,
|
|
537
|
+
"temperature": 0.85,
|
|
538
|
+
},
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
is_local_client = client is None
|
|
542
|
+
active_client = client if client is not None else httpx.AsyncClient(timeout=TIMEOUT)
|
|
543
|
+
try:
|
|
544
|
+
stream_log("Gemini", "AI", f"Calling Gemini vision with {len(parts) - 1} screenshot(s) (timeout={TIMEOUT}s)")
|
|
545
|
+
response = await active_client.post(url, headers=headers, json=body, timeout=TIMEOUT)
|
|
546
|
+
if response.status_code != 200:
|
|
547
|
+
raise Exception(f"Gemini Vision Error {response.status_code}: {response.text}")
|
|
548
|
+
|
|
549
|
+
data = response.json()
|
|
550
|
+
stream_log("Gemini", "OK", "Gemini vision call complete successfully.")
|
|
551
|
+
return data["candidates"][0]["content"]["parts"][0]["text"].strip()
|
|
552
|
+
except httpx.TimeoutException as te:
|
|
553
|
+
stream_log("Gemini", "ERROR", f"Gemini vision call timed out after {TIMEOUT}s: {te}")
|
|
554
|
+
raise te
|
|
555
|
+
except (KeyError, IndexError):
|
|
556
|
+
raise Exception("Gemini response format unexpected")
|
|
557
|
+
except Exception as e:
|
|
558
|
+
stream_log("Gemini", "ERROR", f"Gemini vision call failed: {e}")
|
|
559
|
+
raise e
|
|
560
|
+
finally:
|
|
561
|
+
if is_local_client:
|
|
562
|
+
await active_client.aclose()
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _encode_image(image_path: str) -> str:
|
|
566
|
+
import base64
|
|
567
|
+
try:
|
|
568
|
+
with open(image_path, "rb") as f:
|
|
569
|
+
return base64.b64encode(f.read()).decode("utf-8")
|
|
570
|
+
except Exception:
|
|
571
|
+
return ""
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
# ── Test ──────────────────────────────────────────────────────────────────────
|
|
575
|
+
|
|
576
|
+
if __name__ == "__main__":
|
|
577
|
+
import asyncio
|
|
578
|
+
from core.capture import run_capture
|
|
579
|
+
from core.ocr import run_ocr
|
|
580
|
+
from api.payload import build_payload
|
|
581
|
+
from config.settings import set_project_narrative
|
|
582
|
+
|
|
583
|
+
set_project_narrative("an AI-powered build-in-public automation tool for developers")
|
|
584
|
+
|
|
585
|
+
async def test():
|
|
586
|
+
print("[Generator] Running multi-provider routing test...")
|
|
587
|
+
capture = run_capture()
|
|
588
|
+
ocr = run_ocr(capture["screenshot"]["path"])
|
|
589
|
+
payload = build_payload(
|
|
590
|
+
raw_thought="testing the new multi-provider fallback chain with Cerebras and OpenRouter",
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
payload["use_vision_fallback"] = False
|
|
594
|
+
payload["screenshot_b64"] = None
|
|
595
|
+
|
|
596
|
+
print("[Generator] Firing routed AI calls...")
|
|
597
|
+
results = await generate_posts(payload)
|
|
598
|
+
|
|
599
|
+
print("\n=== GENERATED POSTS ===")
|
|
600
|
+
for format_key, content in results.items():
|
|
601
|
+
print(f"\n--- {format_key.upper()} ---")
|
|
602
|
+
print(content)
|
|
603
|
+
|
|
604
|
+
asyncio.run(test())
|