Spaces:
Running
Running
Sync from GitHub (tests passed)
Browse files- app/ai_engine.py +17 -12
- app/commentary.py +4 -4
- app/openrouter_client.py +160 -83
- app/settings.py +33 -23
- app/stepfun_probe.py +2 -2
- scripts/probe_openrouter_contracts.py +7 -4
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
"
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
"
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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 |
-
"
|
| 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 |
-
|
| 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 |
-
"""
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 194 |
continue
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
data["_coppermind"] = {"requested_model": candidate, "actual_model": data.get("model") or candidate, "attempts": attempts}
|
| 229 |
-
return data
|
| 230 |
else:
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
break
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
continue
|
| 267 |
-
|
| 268 |
-
if
|
| 269 |
-
logger.warning("OpenRouter daily free-tier quota exhausted
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 93 |
-
|
| 94 |
-
#
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
#
|
| 98 |
-
#
|
| 99 |
-
#
|
| 100 |
-
openrouter_model_scoring: str = "nvidia/nemotron-3-super-
|
| 101 |
-
openrouter_model_scoring_fast: Optional[str] = "nvidia/nemotron-3-super-
|
| 102 |
-
openrouter_model_scoring_reliable: Optional[str] = "
|
| 103 |
-
openrouter_model_commentary: str = "
|
| 104 |
-
# Stay below
|
| 105 |
-
#
|
| 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 |
-
#
|
| 111 |
-
#
|
| 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 |
-
"
|
| 119 |
-
"
|
| 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 = "
|
| 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-
|
| 260 |
)
|
| 261 |
|
| 262 |
@property
|
|
@@ -274,7 +283,7 @@ class Settings(BaseSettings):
|
|
| 274 |
self.llm_sentiment_model,
|
| 275 |
self.openrouter_model_scoring,
|
| 276 |
)
|
| 277 |
-
or "
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 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-
|
| 110 |
-
parser.add_argument("--reliable", default="
|
| 111 |
-
parser.add_argument("--commentary", default="
|
| 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))
|