ifieryarrows commited on
Commit
527abf4
·
verified ·
1 Parent(s): 89770cf

Sync from GitHub (tests passed)

Browse files
app/ai_engine.py CHANGED
@@ -47,7 +47,12 @@ from app.settings import get_settings
47
  from app.features import build_feature_matrix, get_feature_descriptions
48
  from app.lock import pipeline_lock
49
  from app.async_bridge import run_async_from_sync
50
- from app.openrouter_client import OpenRouterError, OpenRouterRateLimitError, create_chat_completion
 
 
 
 
 
51
  from pipelines.market_calendar import assign_market_date, is_after_close_news
52
 
53
  logging.basicConfig(
@@ -649,7 +654,7 @@ async def score_batch_with_llm(
649
  """
650
  settings = get_settings()
651
 
652
- if not settings.openrouter_api_key:
653
  raise RuntimeError("OpenRouter API key not configured")
654
 
655
  normalized_articles = [
@@ -670,7 +675,7 @@ async def score_batch_with_llm(
670
 
671
  async def _request_scoring(*, max_tokens: int) -> dict[str, Any]:
672
  request_kwargs: dict[str, Any] = {
673
- "api_key": settings.openrouter_api_key,
674
  "model": model_name,
675
  "messages": [
676
  {"role": "system", "content": LLM_SENTIMENT_SYSTEM_PROMPT},
@@ -697,7 +702,7 @@ async def score_batch_with_llm(
697
  f"MALFORMED:\n{malformed_content}"
698
  )
699
  repair_data = await create_chat_completion(
700
- api_key=settings.openrouter_api_key,
701
  model=model_name,
702
  messages=[
703
  {
@@ -1018,7 +1023,7 @@ async def _repair_json_response_v2(
1018
  f"MALFORMED:\n{malformed_content}"
1019
  )
1020
  repair_data = await create_chat_completion(
1021
- api_key=settings.openrouter_api_key,
1022
  model=model_name,
1023
  messages=[
1024
  {
@@ -1086,7 +1091,7 @@ async def _score_subset_with_model_v2(
1086
 
1087
  async def _request(*, max_tokens: int) -> dict[str, Any]:
1088
  request_kwargs: dict[str, Any] = {
1089
- "api_key": settings.openrouter_api_key,
1090
  "model": model_name,
1091
  "messages": [
1092
  {"role": "system", "content": LLM_V2_SYSTEM_PROMPT},
@@ -1197,7 +1202,7 @@ async def _score_batch_with_llm_v2_unbounded(
1197
  Commodity-aware sentiment scoring with fast+reliable model escalation.
1198
  """
1199
  settings = get_settings()
1200
- if not settings.openrouter_api_key:
1201
  raise RuntimeError("OpenRouter API key not configured")
1202
 
1203
  fast_model = settings.resolved_scoring_fast_model
@@ -1574,7 +1579,7 @@ def score_unscored_processed_articles(
1574
  reliable_exhausted = rate_limited_by_model.get(reliable_model) == today_utc
1575
  both_exhausted = fast_exhausted and reliable_exhausted
1576
 
1577
- if settings.openrouter_api_key and llm_budget_remaining > 0 and not both_exhausted:
1578
  eligible_items = [item for item in chunk_items if item["id"] in llm_eligible_ids]
1579
  skipped = len(chunk_items) - len(eligible_items)
1580
  if skipped > 0:
@@ -1587,7 +1592,7 @@ def score_unscored_processed_articles(
1587
  llm_budget_remaining -= llm_take
1588
  for item in eligible_items[llm_take:]:
1589
  fallback_reason_by_id[item["id"]] = "budget_policy"
1590
- elif not settings.openrouter_api_key:
1591
  for article_id in llm_eligible_ids:
1592
  fallback_reason_by_id[article_id] = "auth"
1593
  elif both_exhausted:
@@ -2038,13 +2043,13 @@ def score_unscored_articles(
2038
  llm_candidates: list[Any] = []
2039
  non_llm_candidates: list[Any] = []
2040
 
2041
- if settings.openrouter_api_key and llm_budget_remaining > 0:
2042
  llm_take = min(len(chunk), llm_budget_remaining)
2043
  llm_candidates = chunk[:llm_take]
2044
  non_llm_candidates = chunk[llm_take:]
2045
  else:
2046
  non_llm_candidates = chunk
2047
- if settings.openrouter_api_key and llm_budget_remaining <= 0 and not budget_exhausted_logged:
2048
  logger.info(
2049
  "LLM budget exhausted (%s articles). Remaining chunks use soft-neutral FinBERT fallback.",
2050
  settings.max_llm_articles_per_run,
@@ -2096,7 +2101,7 @@ def score_unscored_articles(
2096
  for article in non_llm_candidates:
2097
  reason = (
2098
  "llm_skipped_budget"
2099
- if settings.openrouter_api_key and llm_budget_remaining <= 0
2100
  else "llm_unavailable_no_api_key"
2101
  )
2102
  llm_results_by_id[article.id] = _neutral_llm_result(
 
47
  from app.features import build_feature_matrix, get_feature_descriptions
48
  from app.lock import pipeline_lock
49
  from app.async_bridge import run_async_from_sync
50
+ from app.openrouter_client import (
51
+ OpenRouterError,
52
+ OpenRouterRateLimitError,
53
+ create_chat_completion,
54
+ get_configured_api_keys,
55
+ )
56
  from pipelines.market_calendar import assign_market_date, is_after_close_news
57
 
58
  logging.basicConfig(
 
654
  """
655
  settings = get_settings()
656
 
657
+ if not get_configured_api_keys(settings):
658
  raise RuntimeError("OpenRouter API key not configured")
659
 
660
  normalized_articles = [
 
675
 
676
  async def _request_scoring(*, max_tokens: int) -> dict[str, Any]:
677
  request_kwargs: dict[str, Any] = {
678
+ "api_keys": get_configured_api_keys(settings),
679
  "model": model_name,
680
  "messages": [
681
  {"role": "system", "content": LLM_SENTIMENT_SYSTEM_PROMPT},
 
702
  f"MALFORMED:\n{malformed_content}"
703
  )
704
  repair_data = await create_chat_completion(
705
+ api_keys=get_configured_api_keys(settings),
706
  model=model_name,
707
  messages=[
708
  {
 
1023
  f"MALFORMED:\n{malformed_content}"
1024
  )
1025
  repair_data = await create_chat_completion(
1026
+ api_keys=get_configured_api_keys(settings),
1027
  model=model_name,
1028
  messages=[
1029
  {
 
1091
 
1092
  async def _request(*, max_tokens: int) -> dict[str, Any]:
1093
  request_kwargs: dict[str, Any] = {
1094
+ "api_keys": get_configured_api_keys(settings),
1095
  "model": model_name,
1096
  "messages": [
1097
  {"role": "system", "content": LLM_V2_SYSTEM_PROMPT},
 
1202
  Commodity-aware sentiment scoring with fast+reliable model escalation.
1203
  """
1204
  settings = get_settings()
1205
+ if not get_configured_api_keys(settings):
1206
  raise RuntimeError("OpenRouter API key not configured")
1207
 
1208
  fast_model = settings.resolved_scoring_fast_model
 
1579
  reliable_exhausted = rate_limited_by_model.get(reliable_model) == today_utc
1580
  both_exhausted = fast_exhausted and reliable_exhausted
1581
 
1582
+ if get_configured_api_keys(settings) and llm_budget_remaining > 0 and not both_exhausted:
1583
  eligible_items = [item for item in chunk_items if item["id"] in llm_eligible_ids]
1584
  skipped = len(chunk_items) - len(eligible_items)
1585
  if skipped > 0:
 
1592
  llm_budget_remaining -= llm_take
1593
  for item in eligible_items[llm_take:]:
1594
  fallback_reason_by_id[item["id"]] = "budget_policy"
1595
+ elif not get_configured_api_keys(settings):
1596
  for article_id in llm_eligible_ids:
1597
  fallback_reason_by_id[article_id] = "auth"
1598
  elif both_exhausted:
 
2043
  llm_candidates: list[Any] = []
2044
  non_llm_candidates: list[Any] = []
2045
 
2046
+ if get_configured_api_keys(settings) and llm_budget_remaining > 0:
2047
  llm_take = min(len(chunk), llm_budget_remaining)
2048
  llm_candidates = chunk[:llm_take]
2049
  non_llm_candidates = chunk[llm_take:]
2050
  else:
2051
  non_llm_candidates = chunk
2052
+ if get_configured_api_keys(settings) and llm_budget_remaining <= 0 and not budget_exhausted_logged:
2053
  logger.info(
2054
  "LLM budget exhausted (%s articles). Remaining chunks use soft-neutral FinBERT fallback.",
2055
  settings.max_llm_articles_per_run,
 
2101
  for article in non_llm_candidates:
2102
  reason = (
2103
  "llm_skipped_budget"
2104
+ if get_configured_api_keys(settings) and llm_budget_remaining <= 0
2105
  else "llm_unavailable_no_api_key"
2106
  )
2107
  llm_results_by_id[article.id] = _neutral_llm_result(
app/commentary.py CHANGED
@@ -13,7 +13,7 @@ from dataclasses import dataclass
13
  from datetime import datetime, timezone
14
  from typing import Any, Optional
15
 
16
- from .openrouter_client import OpenRouterError, create_chat_completion
17
  from .sentiment import (
18
  BEARISH,
19
  BULLISH,
@@ -802,7 +802,7 @@ async def _generate_commentary_and_stance(
802
  tft_summary=tft_summary,
803
  )
804
 
805
- if not settings.openrouter_api_key:
806
  logger.warning("OpenRouter API key not configured, using template commentary fallback")
807
  return CommentaryGenerationResult(
808
  fallback_commentary, deterministic_stance, "deterministic_fallback", None, "auth", []
@@ -908,7 +908,7 @@ Write as a seasoned commodities strategist would for institutional clients—pre
908
  """
909
 
910
  base_request_kwargs = {
911
- "api_key": settings.openrouter_api_key,
912
  "model": settings.resolved_commentary_model,
913
  "messages": [
914
  {
@@ -976,7 +976,7 @@ Write as a seasoned commodities strategist would for institutional clients—pre
976
  f"<malformed_output>{malformed_content}</malformed_output>"
977
  )
978
  repair_data = await create_chat_completion(
979
- api_key=settings.openrouter_api_key,
980
  model=next(
981
  (m for m in settings.openrouter_fallback_models_list if m != settings.resolved_commentary_model),
982
  settings.resolved_scoring_fast_model if hasattr(settings, "resolved_scoring_fast_model") else settings.resolved_commentary_model,
 
13
  from datetime import datetime, timezone
14
  from typing import Any, Optional
15
 
16
+ from .openrouter_client import OpenRouterError, create_chat_completion, get_configured_api_keys
17
  from .sentiment import (
18
  BEARISH,
19
  BULLISH,
 
802
  tft_summary=tft_summary,
803
  )
804
 
805
+ if not get_configured_api_keys(settings):
806
  logger.warning("OpenRouter API key not configured, using template commentary fallback")
807
  return CommentaryGenerationResult(
808
  fallback_commentary, deterministic_stance, "deterministic_fallback", None, "auth", []
 
908
  """
909
 
910
  base_request_kwargs = {
911
+ "api_keys": get_configured_api_keys(settings),
912
  "model": settings.resolved_commentary_model,
913
  "messages": [
914
  {
 
976
  f"<malformed_output>{malformed_content}</malformed_output>"
977
  )
978
  repair_data = await create_chat_completion(
979
+ api_keys=get_configured_api_keys(settings),
980
  model=next(
981
  (m for m in settings.openrouter_fallback_models_list if m != settings.resolved_commentary_model),
982
  settings.resolved_scoring_fast_model if hasattr(settings, "resolved_scoring_fast_model") else settings.resolved_commentary_model,
app/openrouter_client.py CHANGED
@@ -54,6 +54,8 @@ def _classify_status(status: int, body: str) -> str:
54
  lowered = body.lower()
55
  if status in (401, 403):
56
  return "auth"
 
 
57
  if status == 404:
58
  return "model_unavailable"
59
  if status == 408:
@@ -106,6 +108,27 @@ def _attempt(model: str, ordinal: int, category: str, status: Optional[int], ret
106
  return {"model": model, "attempt": ordinal, "category": category, "status_code": status, "retried": retried}
107
 
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  def _extract_message_text(data: dict[str, Any]) -> str:
110
  choices = data.get("choices")
111
  if not isinstance(choices, list) or not choices:
@@ -141,7 +164,8 @@ def _candidate_payload(base_payload: dict[str, Any], candidate: str) -> dict[str
141
 
142
  async def create_chat_completion(
143
  *,
144
- api_key: str,
 
145
  model: str,
146
  messages: list[dict[str, Any]],
147
  max_tokens: Optional[int] = None,
@@ -157,18 +181,18 @@ async def create_chat_completion(
157
  extra_payload: Optional[dict[str, Any]] = None,
158
  chain_deadline_seconds: float = 120.0,
159
  ) -> dict[str, Any]:
160
- """Call each model explicitly so failure category and actual model are known."""
161
- if not api_key:
 
 
 
 
 
162
  raise OpenRouterError("OpenRouter API key not configured", category="auth")
163
  models = _ordered_models(model, fallback_models)
164
  if not models:
165
  raise OpenRouterError("No OpenRouter model configured", category="model_unavailable")
166
 
167
- headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
168
- if referer:
169
- headers["HTTP-Referer"] = referer
170
- if title:
171
- headers["X-Title"] = title
172
  base_payload: dict[str, Any] = {"messages": messages}
173
  if max_tokens is not None:
174
  base_payload["max_tokens"] = max_tokens
@@ -185,94 +209,147 @@ async def create_chat_completion(
185
  deadline = time.monotonic() + max(float(chain_deadline_seconds), 1.0)
186
  last_error: Optional[OpenRouterError] = None
187
  first_rate_limit_error: Optional[OpenRouterRateLimitError] = None
188
- free_tier_daily_exhausted = False
189
  retryable_categories = {"rate_limit", "timeout", "network", "provider_5xx"}
 
190
 
191
  async with httpx.AsyncClient(timeout=timeout_seconds) as client:
192
  for candidate in models:
193
- if free_tier_daily_exhausted and candidate.lower().endswith(":free"):
 
 
194
  continue
195
- for attempt_index in range(max(0, int(max_retries)) + 1):
196
- if time.monotonic() >= deadline:
197
- raise OpenRouterError("OpenRouter model chain deadline exceeded", category="timeout", model=candidate, attempts=attempts)
198
- await _throttle_request(rpm)
199
- remaining_before_call = deadline - time.monotonic()
200
- if remaining_before_call <= 0:
201
- raise OpenRouterError("OpenRouter model chain deadline exceeded", category="timeout", model=candidate, attempts=attempts)
202
- attempt_timeout = min(float(timeout_seconds), max(remaining_before_call, 0.1))
203
- payload = _candidate_payload(base_payload, candidate)
204
- response: Optional[httpx.Response] = None
205
- try:
206
- response = await asyncio.wait_for(
207
- client.post("https://openrouter.ai/api/v1/chat/completions", headers=headers, json=payload),
208
- timeout=attempt_timeout,
209
- )
210
- except (httpx.TimeoutException, asyncio.TimeoutError, TimeoutError) as exc:
211
- last_error = OpenRouterError(str(exc) or "OpenRouter request timed out", category="timeout", model=candidate)
212
- except httpx.RequestError as exc:
213
- last_error = OpenRouterError(str(exc), category="network", model=candidate)
214
- else:
215
- if response.status_code == 200:
216
- _log_rate_limit_headers(response, candidate)
217
- try:
218
- data = response.json()
219
- except ValueError:
220
- last_error = OpenRouterError("OpenRouter returned a non-JSON response", status_code=200, category="empty_response", model=candidate)
221
- else:
222
- if not data.get("choices"):
223
- last_error = OpenRouterError("OpenRouter returned no completion choices", status_code=200, category="empty_response", model=candidate)
224
- elif not _extract_message_text(data):
225
- last_error = OpenRouterError("OpenRouter returned empty message content", status_code=200, category="empty_response", model=candidate)
226
- else:
227
- attempts.append(_attempt(candidate, attempt_index + 1, "success", 200, attempt_index > 0))
228
- data["_coppermind"] = {"requested_model": candidate, "actual_model": data.get("model") or candidate, "attempts": attempts}
229
- return data
230
  else:
231
- body = response.text[:1000]
232
- category = _classify_status(response.status_code, body)
233
- error_cls = OpenRouterRateLimitError if category == "rate_limit" else OpenRouterError
234
- last_error = error_cls(f"OpenRouter {category}: HTTP {response.status_code} - {body[:500]}", status_code=response.status_code, category=category, model=candidate)
235
- if isinstance(last_error, OpenRouterRateLimitError) and first_rate_limit_error is None:
236
- first_rate_limit_error = last_error
237
- if _is_free_tier_daily_quota_exhausted(response.status_code, body, candidate):
238
- free_tier_daily_exhausted = True
239
-
240
- status = response.status_code if response is not None else None
241
- retryable = last_error is not None and last_error.category in retryable_categories
242
- # When fallback models exist, advance immediately on a timeout instead of
243
- # burning a second 45s window on the same unresponsive upstream model.
244
- skip_same_model_timeout_retry = (
245
- last_error is not None
246
- and last_error.category == "timeout"
247
- and len(models) > 1
248
- )
249
- will_retry = (
250
- retryable
251
- and not skip_same_model_timeout_retry
252
- and not free_tier_daily_exhausted
253
- and attempt_index < max(0, int(max_retries))
254
- )
255
- attempts.append(_attempt(candidate, attempt_index + 1, last_error.category if last_error else "network", status, will_retry))
256
- if last_error and last_error.category == "auth":
257
- last_error.attempts = attempts
258
- raise last_error
259
- if will_retry:
260
- retry_after = _parse_retry_after_seconds(response) if response is not None else None
261
- delay = min(retry_after if retry_after is not None else (2 ** attempt_index) + random.uniform(0.0, 0.5), 30.0)
262
- remaining = max(deadline - time.monotonic(), 0.0)
263
- if delay >= remaining:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  break
265
- await asyncio.sleep(delay)
 
 
 
 
 
 
 
 
 
266
  continue
267
- break
268
- if free_tier_daily_exhausted:
269
- logger.warning("OpenRouter daily free-tier quota exhausted on model=%s; skipping remaining :free candidates", candidate)
270
  else:
271
  logger.warning("OpenRouter model failed; advancing chain model=%s category=%s", candidate, last_error.category if last_error else "unknown")
272
 
273
  if first_rate_limit_error is not None and not isinstance(last_error, OpenRouterRateLimitError):
274
  last_error = first_rate_limit_error
275
  if last_error is None:
276
- last_error = OpenRouterError("OpenRouter request unexpectedly terminated", category="network")
 
 
 
 
277
  last_error.attempts = attempts
278
  raise last_error
 
54
  lowered = body.lower()
55
  if status in (401, 403):
56
  return "auth"
57
+ if status == 402:
58
+ return "spend_limit"
59
  if status == 404:
60
  return "model_unavailable"
61
  if status == 408:
 
108
  return {"model": model, "attempt": ordinal, "category": category, "status_code": status, "retried": retried}
109
 
110
 
111
+ def get_configured_api_keys(settings: Any) -> list[str]:
112
+ """Return configured OpenRouter keys without exposing them to logs."""
113
+ configured = getattr(settings, "openrouter_api_keys", None)
114
+ candidates = configured if configured is not None else [
115
+ getattr(settings, "openrouter_api_key", None),
116
+ getattr(settings, "openrouter_api_key_backup", None),
117
+ ]
118
+ if isinstance(candidates, str):
119
+ candidates = [candidates]
120
+ keys: list[str] = []
121
+ for candidate in candidates or []:
122
+ normalized = str(candidate or "").strip()
123
+ if normalized and normalized not in keys:
124
+ keys.append(normalized)
125
+ return keys
126
+
127
+
128
+ def _api_key_slot(index: int) -> str:
129
+ return "primary" if index == 0 else f"backup-{index}"
130
+
131
+
132
  def _extract_message_text(data: dict[str, Any]) -> str:
133
  choices = data.get("choices")
134
  if not isinstance(choices, list) or not choices:
 
164
 
165
  async def create_chat_completion(
166
  *,
167
+ api_key: Optional[str] = None,
168
+ api_keys: Optional[list[str]] = None,
169
  model: str,
170
  messages: list[dict[str, Any]],
171
  max_tokens: Optional[int] = None,
 
181
  extra_payload: Optional[dict[str, Any]] = None,
182
  chain_deadline_seconds: float = 120.0,
183
  ) -> dict[str, Any]:
184
+ """Try model fallbacks and rotate keys on quota or spend-limit responses."""
185
+ configured_keys: list[str] = []
186
+ for candidate in [api_key, *(api_keys or [])]:
187
+ normalized = str(candidate or "").strip()
188
+ if normalized and normalized not in configured_keys:
189
+ configured_keys.append(normalized)
190
+ if not configured_keys:
191
  raise OpenRouterError("OpenRouter API key not configured", category="auth")
192
  models = _ordered_models(model, fallback_models)
193
  if not models:
194
  raise OpenRouterError("No OpenRouter model configured", category="model_unavailable")
195
 
 
 
 
 
 
196
  base_payload: dict[str, Any] = {"messages": messages}
197
  if max_tokens is not None:
198
  base_payload["max_tokens"] = max_tokens
 
209
  deadline = time.monotonic() + max(float(chain_deadline_seconds), 1.0)
210
  last_error: Optional[OpenRouterError] = None
211
  first_rate_limit_error: Optional[OpenRouterRateLimitError] = None
212
+ free_tier_daily_exhausted_keys: set[int] = set()
213
  retryable_categories = {"rate_limit", "timeout", "network", "provider_5xx"}
214
+ key_rotation_categories = {"rate_limit", "spend_limit"}
215
 
216
  async with httpx.AsyncClient(timeout=timeout_seconds) as client:
217
  for candidate in models:
218
+ candidate_is_free = candidate.lower().endswith(":free")
219
+ if candidate_is_free and len(free_tier_daily_exhausted_keys) == len(configured_keys):
220
+ logger.warning("OpenRouter free-tier daily quota exhausted for all configured keys; skipping model=%s", candidate)
221
  continue
222
+ attempted_for_model = False
223
+ for key_index, active_key in enumerate(configured_keys):
224
+ if candidate_is_free and key_index in free_tier_daily_exhausted_keys:
225
+ continue
226
+ attempted_for_model = True
227
+ key_slot = _api_key_slot(key_index)
228
+ headers = {"Authorization": f"Bearer {active_key}", "Content-Type": "application/json"}
229
+ if referer:
230
+ headers["HTTP-Referer"] = referer
231
+ if title:
232
+ headers["X-Title"] = title
233
+
234
+ rotate_key = False
235
+ daily_quota_exhausted = False
236
+ for attempt_index in range(max(0, int(max_retries)) + 1):
237
+ if time.monotonic() >= deadline:
238
+ raise OpenRouterError("OpenRouter model chain deadline exceeded", category="timeout", model=candidate, attempts=attempts)
239
+ await _throttle_request(rpm)
240
+ remaining_before_call = deadline - time.monotonic()
241
+ if remaining_before_call <= 0:
242
+ raise OpenRouterError("OpenRouter model chain deadline exceeded", category="timeout", model=candidate, attempts=attempts)
243
+ attempt_timeout = min(float(timeout_seconds), max(remaining_before_call, 0.1))
244
+ payload = _candidate_payload(base_payload, candidate)
245
+ response: Optional[httpx.Response] = None
246
+ try:
247
+ response = await asyncio.wait_for(
248
+ client.post("https://openrouter.ai/api/v1/chat/completions", headers=headers, json=payload),
249
+ timeout=attempt_timeout,
250
+ )
251
+ except (httpx.TimeoutException, asyncio.TimeoutError, TimeoutError) as exc:
252
+ last_error = OpenRouterError(str(exc) or "OpenRouter request timed out", category="timeout", model=candidate)
253
+ except httpx.RequestError as exc:
254
+ last_error = OpenRouterError(str(exc), category="network", model=candidate)
 
 
255
  else:
256
+ if response.status_code == 200:
257
+ _log_rate_limit_headers(response, candidate)
258
+ try:
259
+ data = response.json()
260
+ except ValueError:
261
+ last_error = OpenRouterError("OpenRouter returned a non-JSON response", status_code=200, category="empty_response", model=candidate)
262
+ else:
263
+ if not data.get("choices"):
264
+ last_error = OpenRouterError("OpenRouter returned no completion choices", status_code=200, category="empty_response", model=candidate)
265
+ elif not _extract_message_text(data):
266
+ last_error = OpenRouterError("OpenRouter returned empty message content", status_code=200, category="empty_response", model=candidate)
267
+ else:
268
+ attempts.append(_attempt(candidate, attempt_index + 1, "success", 200, attempt_index > 0) | {"key_slot": key_slot})
269
+ data["_coppermind"] = {
270
+ "requested_model": candidate,
271
+ "actual_model": data.get("model") or candidate,
272
+ "key_slot": key_slot,
273
+ "attempts": attempts,
274
+ }
275
+ return data
276
+ else:
277
+ body = response.text[:1000]
278
+ category = _classify_status(response.status_code, body)
279
+ error_cls = OpenRouterRateLimitError if category == "rate_limit" else OpenRouterError
280
+ last_error = error_cls(f"OpenRouter {category}: HTTP {response.status_code} - {body[:500]}", status_code=response.status_code, category=category, model=candidate)
281
+ if isinstance(last_error, OpenRouterRateLimitError) and first_rate_limit_error is None:
282
+ first_rate_limit_error = last_error
283
+ if _is_free_tier_daily_quota_exhausted(response.status_code, body, candidate):
284
+ daily_quota_exhausted = True
285
+ free_tier_daily_exhausted_keys.add(key_index)
286
+ logger.warning(
287
+ "OpenRouter free-tier daily quota exhausted; skipping this key for remaining free models in this request key_slot=%s",
288
+ key_slot,
289
+ )
290
+
291
+ status = response.status_code if response is not None else None
292
+ retryable = last_error is not None and last_error.category in retryable_categories
293
+ # When fallback models exist, advance immediately on a timeout instead of
294
+ # burning a second 45s window on the same unresponsive upstream model.
295
+ skip_same_model_timeout_retry = (
296
+ last_error is not None
297
+ and last_error.category == "timeout"
298
+ and len(models) > 1
299
+ )
300
+ will_retry = (
301
+ retryable
302
+ and not skip_same_model_timeout_retry
303
+ and not daily_quota_exhausted
304
+ and not (
305
+ last_error
306
+ and last_error.category == "rate_limit"
307
+ and key_index < len(configured_keys) - 1
308
+ )
309
+ and attempt_index < max(0, int(max_retries))
310
+ )
311
+ attempts.append(
312
+ _attempt(candidate, attempt_index + 1, last_error.category if last_error else "network", status, will_retry)
313
+ | {"key_slot": key_slot}
314
+ )
315
+ if last_error and last_error.category == "auth":
316
+ last_error.attempts = attempts
317
+ raise last_error
318
+ if daily_quota_exhausted:
319
+ break
320
+ if last_error and last_error.category in key_rotation_categories and key_index < len(configured_keys) - 1:
321
+ logger.warning(
322
+ "OpenRouter key quota/spend limit reached; advancing to backup key model=%s category=%s key_slot=%s",
323
+ candidate,
324
+ last_error.category,
325
+ key_slot,
326
+ )
327
+ rotate_key = True
328
  break
329
+ if will_retry:
330
+ retry_after = _parse_retry_after_seconds(response) if response is not None else None
331
+ delay = min(retry_after if retry_after is not None else (2 ** attempt_index) + random.uniform(0.0, 0.5), 30.0)
332
+ remaining = max(deadline - time.monotonic(), 0.0)
333
+ if delay >= remaining:
334
+ break
335
+ await asyncio.sleep(delay)
336
+ continue
337
+ break
338
+ if rotate_key:
339
  continue
340
+
341
+ if not attempted_for_model and candidate_is_free:
342
+ logger.warning("OpenRouter daily free-tier quota exhausted for all keys; skipping model=%s", candidate)
343
  else:
344
  logger.warning("OpenRouter model failed; advancing chain model=%s category=%s", candidate, last_error.category if last_error else "unknown")
345
 
346
  if first_rate_limit_error is not None and not isinstance(last_error, OpenRouterRateLimitError):
347
  last_error = first_rate_limit_error
348
  if last_error is None:
349
+ last_error = OpenRouterRateLimitError(
350
+ "OpenRouter free-tier daily quota exhausted for all configured API keys",
351
+ status_code=429,
352
+ category="rate_limit",
353
+ )
354
  last_error.attempts = attempts
355
  raise last_error
app/settings.py CHANGED
@@ -89,36 +89,45 @@ class Settings(BaseSettings):
89
 
90
  # OpenRouter AI Commentary
91
  openrouter_api_key: Optional[str] = None
92
- # Deprecated - kept for backward compatibility. Keep this on a currently
93
- # listed free model so installations without an explicit env override do
94
- # not silently start with a retired OpenRouter slug.
95
- openrouter_model: str = "nvidia/nemotron-3-super-120b-a12b:free"
96
- # Keep fast and reliable roles independent so one provider/model removal
97
- # cannot collapse the entire LLM path. NVIDIA and Liquid passed the exact
98
- # credentialed scoring/commentary probe on 2026-09-27; the Gemma free
99
- # models were temporarily rate-limited by their shared upstream pool.
100
- openrouter_model_scoring: str = "nvidia/nemotron-3-super-120b-a12b:free"
101
- openrouter_model_scoring_fast: Optional[str] = "nvidia/nemotron-3-super-120b-a12b:free"
102
- openrouter_model_scoring_reliable: Optional[str] = "liquid/lfm-2.5-2.6b:free"
103
- openrouter_model_commentary: str = "nvidia/nemotron-3-super-120b-a12b:free"
104
- # Stay below the shared free-provider request ceiling. The worker still
105
- # uses bounded retries and a per-chain deadline for transient 429s.
106
  openrouter_rpm: int = 12
107
  openrouter_max_retries: int = 1
108
  openrouter_timeout_seconds: float = 45.0
109
  openrouter_chain_deadline_seconds: float = 120.0
110
- # Free tier: ~50 req/day per model. At chunk_size=12 a run of 60 articles
111
- # costs ~5 chunks (=5–10 requests incl. escalation) which leaves headroom
112
- # for multiple runs per day before hitting the ceiling. Raise cautiously.
113
  max_llm_articles_per_run: int = 100
114
  # Comma-separated list of additional OpenRouter model slugs used by the
115
  # client as transport-level fallbacks when the primary model 429s/5xx's.
116
  # Example: "google/gemini-flash-1.5:free,meta-llama/llama-3.1-8b-instruct:free"
117
  openrouter_fallback_models: Optional[str] = (
118
- "liquid/lfm-2.5-2.6b:free,nvidia/nemotron-3-super-120b-a12b:free,"
119
- "nvidia/nemotron-3.5-super-vl:free,"
120
- "google/gemma-4-31b-it:free,google/gemma-4-26b-a4b-it:free"
121
  )
 
 
 
 
 
 
 
 
 
 
 
122
  tokenizers_parallelism: str = "false"
123
 
124
  # Twelve Data (Live Price)
@@ -135,7 +144,7 @@ class Settings(BaseSettings):
135
 
136
  # LLM Sentiment Analysis
137
  # Deprecated - kept for backward compatibility
138
- llm_sentiment_model: str = "nvidia/nemotron-3-super-120b-a12b:free"
139
 
140
  # Pipeline trigger authentication
141
  pipeline_trigger_secret: Optional[str] = None
@@ -256,7 +265,7 @@ class Settings(BaseSettings):
256
  self.llm_sentiment_model,
257
  self.openrouter_model,
258
  )
259
- or "nvidia/nemotron-3-super-120b-a12b:free"
260
  )
261
 
262
  @property
@@ -274,7 +283,7 @@ class Settings(BaseSettings):
274
  self.llm_sentiment_model,
275
  self.openrouter_model_scoring,
276
  )
277
- or "nvidia/nemotron-3-super-120b-a12b:free"
278
  )
279
 
280
  @property
@@ -322,6 +331,7 @@ def mask_api_key(text: str, settings: Settings = None) -> str:
322
  keys_to_mask = [
323
  settings.twelvedata_api_key,
324
  settings.openrouter_api_key,
 
325
  settings.newsapi_key,
326
  settings.pipeline_trigger_secret,
327
  ]
 
89
 
90
  # OpenRouter AI Commentary
91
  openrouter_api_key: Optional[str] = None
92
+ # Rotated to when the primary key receives a rate-limit response.
93
+ openrouter_api_key_backup: Optional[str] = None
94
+ # Deprecated generic fallback. Keep it on a currently listed model that
95
+ # supports the project's structured-output contract.
96
+ openrouter_model: str = "google/gemma-4-31b-it:free"
97
+ # Use a high-usage free model for fast scoring and a separate provider for
98
+ # reliable scoring/commentary. All selected routes support response_format
99
+ # and reasoning in the current OpenRouter catalog.
100
+ openrouter_model_scoring: str = "nvidia/nemotron-3.5-super-vl:free"
101
+ openrouter_model_scoring_fast: Optional[str] = "nvidia/nemotron-3.5-super-vl:free"
102
+ openrouter_model_scoring_reliable: Optional[str] = "google/gemma-4-31b-it:free"
103
+ openrouter_model_commentary: str = "google/gemma-4-31b-it:free"
104
+ # Stay below OpenRouter's free-model per-minute ceiling. The account-wide
105
+ # daily allowance is separate, and bounded fallbacks can still consume it.
106
  openrouter_rpm: int = 12
107
  openrouter_max_retries: int = 1
108
  openrouter_timeout_seconds: float = 45.0
109
  openrouter_chain_deadline_seconds: float = 120.0
110
+ # OpenRouter's free-model daily allowance is account-wide, not per model.
111
+ # Failed attempts and model fallbacks can also consume that allowance.
 
112
  max_llm_articles_per_run: int = 100
113
  # Comma-separated list of additional OpenRouter model slugs used by the
114
  # client as transport-level fallbacks when the primary model 429s/5xx's.
115
  # Example: "google/gemini-flash-1.5:free,meta-llama/llama-3.1-8b-instruct:free"
116
  openrouter_fallback_models: Optional[str] = (
117
+ "google/gemma-4-31b-it:free,xingchen-agi/xing4.0-29b-a4b:free,"
118
+ "google/gemma-4-26b-a4b-it:free"
 
119
  )
120
+
121
+ @property
122
+ def openrouter_api_keys(self) -> list[str]:
123
+ """Configured OpenRouter keys in priority order, with duplicates removed."""
124
+ keys: list[str] = []
125
+ for candidate in (self.openrouter_api_key, self.openrouter_api_key_backup):
126
+ normalized = str(candidate or "").strip()
127
+ if normalized and normalized not in keys:
128
+ keys.append(normalized)
129
+ return keys
130
+
131
  tokenizers_parallelism: str = "false"
132
 
133
  # Twelve Data (Live Price)
 
144
 
145
  # LLM Sentiment Analysis
146
  # Deprecated - kept for backward compatibility
147
+ llm_sentiment_model: str = "google/gemma-4-31b-it:free"
148
 
149
  # Pipeline trigger authentication
150
  pipeline_trigger_secret: Optional[str] = None
 
265
  self.llm_sentiment_model,
266
  self.openrouter_model,
267
  )
268
+ or "nvidia/nemotron-3.5-super-vl:free"
269
  )
270
 
271
  @property
 
283
  self.llm_sentiment_model,
284
  self.openrouter_model_scoring,
285
  )
286
+ or "google/gemma-4-31b-it:free"
287
  )
288
 
289
  @property
 
331
  keys_to_mask = [
332
  settings.twelvedata_api_key,
333
  settings.openrouter_api_key,
334
+ settings.openrouter_api_key_backup,
335
  settings.newsapi_key,
336
  settings.pipeline_trigger_secret,
337
  ]
app/stepfun_probe.py CHANGED
@@ -25,7 +25,7 @@ from app.ai_engine import (
25
  )
26
  from app.db import SessionLocal, init_db
27
  from app.models import NewsProcessed, NewsRaw
28
- from app.openrouter_client import OpenRouterError, create_chat_completion
29
  from app.settings import get_settings
30
 
31
 
@@ -99,7 +99,7 @@ async def _run_strict_probe(
99
  started = time.perf_counter()
100
  try:
101
  data = await create_chat_completion(
102
- api_key=settings.openrouter_api_key or "",
103
  model=model,
104
  messages=[
105
  {
 
25
  )
26
  from app.db import SessionLocal, init_db
27
  from app.models import NewsProcessed, NewsRaw
28
+ from app.openrouter_client import OpenRouterError, create_chat_completion, get_configured_api_keys
29
  from app.settings import get_settings
30
 
31
 
 
99
  started = time.perf_counter()
100
  try:
101
  data = await create_chat_completion(
102
+ api_keys=get_configured_api_keys(settings),
103
  model=model,
104
  messages=[
105
  {
scripts/probe_openrouter_contracts.py CHANGED
@@ -19,11 +19,14 @@ from app.settings import get_settings
19
 
20
  async def run_probe(*, fast: str, reliable: str, commentary_model: str, batch_size: int = 12) -> dict:
21
  runtime = get_settings()
22
- if not runtime.openrouter_api_key:
 
23
  return {"ok": False, "error": "OpenRouter credential is not configured"}
24
 
25
  probe_settings = SimpleNamespace(
26
  openrouter_api_key=runtime.openrouter_api_key,
 
 
27
  openrouter_max_retries=1,
28
  openrouter_rpm=runtime.openrouter_rpm,
29
  openrouter_timeout_seconds=runtime.openrouter_timeout_seconds,
@@ -106,9 +109,9 @@ async def run_probe(*, fast: str, reliable: str, commentary_model: str, batch_si
106
 
107
  def main() -> int:
108
  parser = argparse.ArgumentParser(description="Probe exact OpenRouter production contracts")
109
- parser.add_argument("--fast", default="nvidia/nemotron-3-super-120b-a12b:free")
110
- parser.add_argument("--reliable", default="liquid/lfm-2.5-2.6b:free")
111
- parser.add_argument("--commentary", default="nvidia/nemotron-3-super-120b-a12b:free")
112
  parser.add_argument("--batch-size", type=int, default=12, help="Synthetic scoring items, up to the worker's 12-item chunk")
113
  args = parser.parse_args()
114
  result = asyncio.run(run_probe(fast=args.fast, reliable=args.reliable, commentary_model=args.commentary, batch_size=args.batch_size))
 
19
 
20
  async def run_probe(*, fast: str, reliable: str, commentary_model: str, batch_size: int = 12) -> dict:
21
  runtime = get_settings()
22
+ api_keys = runtime.openrouter_api_keys
23
+ if not api_keys:
24
  return {"ok": False, "error": "OpenRouter credential is not configured"}
25
 
26
  probe_settings = SimpleNamespace(
27
  openrouter_api_key=runtime.openrouter_api_key,
28
+ openrouter_api_key_backup=runtime.openrouter_api_key_backup,
29
+ openrouter_api_keys=api_keys,
30
  openrouter_max_retries=1,
31
  openrouter_rpm=runtime.openrouter_rpm,
32
  openrouter_timeout_seconds=runtime.openrouter_timeout_seconds,
 
109
 
110
  def main() -> int:
111
  parser = argparse.ArgumentParser(description="Probe exact OpenRouter production contracts")
112
+ parser.add_argument("--fast", default="nvidia/nemotron-3.5-super-vl:free")
113
+ parser.add_argument("--reliable", default="google/gemma-4-31b-it:free")
114
+ parser.add_argument("--commentary", default="google/gemma-4-31b-it:free")
115
  parser.add_argument("--batch-size", type=int, default=12, help="Synthetic scoring items, up to the worker's 12-item chunk")
116
  args = parser.parse_args()
117
  result = asyncio.run(run_probe(fast=args.fast, reliable=args.reliable, commentary_model=args.commentary, batch_size=args.batch_size))