Repository navigation
Expand file tree
/
Copy pathmodel_sync.py
More file actions
210 lines (183 loc) · 9.46 KB
/
Copy pathmodel_sync.py
File metadata and controls
210 lines (183 loc) · 9.46 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
"""Синхронизация списков моделей с провайдерами.
Провайдеры добавляют и убирают модели, а `key_models` заполнялась только в
момент валидации ключа — список во вкладке «Модели» и в `/v1/models` прокси
замирал на снапшоте. Здесь список перечитывается через GET /models и
сливается с БД (`Database.sync_key_models`), поэтому:
- новые модели появляются в списке без перевалидации ключа;
- модели, которых провайдер больше не отдаёт, снимаются с подтверждения и
удаляются после второго пропуска подряд (защита от одиночного сбоя);
- один запрос на эндпоинт: список моделей у всех ключей одного base_url
одинаковый, ключи перебираются только чтобы обойти 401/403.
Запуск: `main_optimized.py --refresh-models` или фоновой задачей прокси
(POST /admin/refresh-models дергает то же самое принудительно).
"""
from __future__ import annotations
import asyncio
import ssl
from typing import List, Optional, Tuple
import aiohttp
from loguru import logger
from database import Database
TIMEOUT = aiohttp.ClientTimeout(total=25, connect=8)
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36")
MAX_KEYS_PER_ENDPOINT = 3
def model_urls(base_url: str) -> List[str]:
"""Кандидаты URL для списка моделей (порядок = приоритет)."""
root = (base_url or "").strip().strip("/")
for suffix in ("/chat/completions", "/completions", "/models"):
if root.endswith(suffix):
root = root[: -len(suffix)]
break
if not root:
return []
if not root.startswith("http"):
root = "https://" + root
if root.endswith("/v1") or root.endswith("/v1beta") or root.endswith("/api/v1"):
return [f"{root}/models"]
return [f"{root}/v1/models", f"{root}/models"]
def auth_headers(api_key: str, base_url: str, platform: str = "") -> dict:
plat = (platform or "").lower()
if plat == "gemini" or "googleapis.com" in (base_url or ""):
return {"x-goog-api-key": api_key, "Accept": "application/json",
"User-Agent": UA}
if plat == "anthropic" or "anthropic.com" in (base_url or ""):
return {"x-api-key": api_key, "anthropic-version": "2023-06-01",
"Accept": "application/json", "User-Agent": UA}
return {"Authorization": f"Bearer {api_key}",
"Accept": "application/json", "User-Agent": UA}
def parse_models(payload) -> List[str]:
"""Идентификаторы моделей из ответа /models (OpenAI, Gemini, OpenRouter)."""
items = []
if isinstance(payload, dict):
raw = payload.get("data")
if isinstance(raw, dict): # некоторые шлюзы: {"data": {"models": [...]}}
raw = raw.get("models")
if not isinstance(raw, list):
raw = payload.get("models")
else:
raw = payload
if not isinstance(raw, list):
return []
for item in raw:
mid = ""
if isinstance(item, str):
mid = item
elif isinstance(item, dict):
mid = item.get("id") or item.get("name") or item.get("model") or ""
mid = str(mid).strip()
if mid.startswith("models/"): # Gemini: models/gemini-2.5-flash
mid = mid[len("models/"):]
if mid:
items.append(mid)
seen = set()
out = []
for m in items:
if m.lower() not in seen:
seen.add(m.lower())
out.append(m)
return out
async def _fetch_endpoint_models(session: aiohttp.ClientSession,
base_url: str, keys: List[dict]) -> Tuple[List[str], int, str]:
"""Список моделей эндпоинта: перебор ключей и URL-вариантов.
Возвращает (models, status, error). models пуст при неудаче.
"""
last_status = 0
last_error = ""
for key in keys[:MAX_KEYS_PER_ENDPOINT]:
headers = auth_headers(key["api_key"], base_url, key.get("platform", ""))
for url in model_urls(base_url):
try:
async with session.get(url, headers=headers) as resp:
last_status = resp.status
if resp.status == 200:
try:
payload = await resp.json(content_type=None)
except Exception as exc:
last_error = f"json: {type(exc).__name__}"
continue
models = parse_models(payload)
if models:
return models, 200, ""
last_error = "empty model list"
continue
if resp.status in (401, 403):
last_error = f"HTTP {resp.status}"
break # ключ отвергнут — следующий ключ
if resp.status == 404:
last_error = "HTTP 404"
continue # другой URL-вариант
last_error = f"HTTP {resp.status}"
except Exception as exc:
last_error = f"{type(exc).__name__}: {str(exc)[:80]}"
continue
return [], last_status, last_error
async def refresh_models(db: Database, *, stale_hours: float = 6.0,
limit: int = 0, force: bool = False,
only_base_url: str = "", concurrency: int = 6,
log=logger) -> dict:
"""Обновить списки моделей у провайдеров.
stale_hours: не трогать эндпоинты, синхронизированные раньше, чем N часов
назад (force=True игнорирует). limit: сколько эндпоинтов за прогон (0 = все).
"""
# limit применяется ПОСЛЕ фильтра по base_url: иначе
# --models-base-url при limit=1 получал чужой первый эндпоинт и пустой список.
targets = db.get_model_sync_targets(
stale_hours=0 if force else stale_hours,
limit=0 if only_base_url else limit)
if only_base_url:
targets = [t for t in targets if t["base_url"] == only_base_url]
if limit:
targets = targets[:limit]
stats = {"endpoints": len(targets), "ok": 0, "failed": 0,
"added": 0, "removed": 0, "demoted": 0, "keys": 0,
"errors": []}
if not targets:
return stats
ssl_ctx = ssl.create_default_context()
connector = aiohttp.TCPConnector(
limit=concurrency * 2, limit_per_host=2, ssl=ssl_ctx,
resolver=aiohttp.resolver.ThreadedResolver(), force_close=False,
use_dns_cache=True, ttl_dns_cache=300, enable_cleanup_closed=True)
semaphore = asyncio.Semaphore(concurrency)
async with aiohttp.ClientSession(connector=connector, timeout=TIMEOUT,
trust_env=True) as session:
async def one(target: dict) -> None:
base_url = target["base_url"]
keys = db.get_live_keys_for_endpoint(base_url)
if not keys:
return
async with semaphore:
models, status, error = await _fetch_endpoint_models(
session, base_url, keys)
if not models:
db.record_model_sync(base_url, target.get("platform", ""), 0,
"error" if status != 200 else "empty",
error)
stats["failed"] += 1
if len(stats["errors"]) < 10:
stats["errors"].append(
f"{base_url}: HTTP {status or '-'} {error}")
return
synced = 0
for key in keys:
res = db.sync_key_models(key["api_key"], models, authoritative=True)
stats["added"] += res["added"]
stats["removed"] += res["removed"]
stats["demoted"] += res["demoted"]
synced += 1
stats["keys"] += synced
stats["ok"] += 1
db.record_model_sync(base_url, target.get("platform", ""),
len(models), "ok")
log.debug(f"models sync {base_url}: {len(models)} моделей, "
f"ключей {synced}, +{stats['added']}")
await asyncio.gather(*[one(t) for t in targets], return_exceptions=True)
log.info(f"Модели: эндпоинтов {stats['endpoints']}, ок {stats['ok']}, "
f"ошибок {stats['failed']}, ключей {stats['keys']}; "
f"+{stats['added']} новых, -{stats['removed']} убранных, "
f"{stats['demoted']} снято с подтверждения")
return stats
def refresh_models_sync(db: Database, **kwargs) -> dict:
"""Синхронная обёртка для CLI/потоков."""
return asyncio.run(refresh_models(db, **kwargs))