Files
truf-server/app/keycheckers/openrouter/OpenrouterKeycheck.py
T
2026-09-30 20:30:56 +03:00

499 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import sys
sys.dont_write_bytecode = True
import requests
import json
import os
import argparse
from itertools import cycle
from datetime import datetime, timezone
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
except Exception:
pass
from keycheck_common import (
acquire_file_lock,
append_checked,
append_jsonl,
commit_status_transaction,
default_input_file,
default_proxy_file,
env_int,
ensure_output_files as ensure_private_output_files,
finding_detector_secret_hash,
iter_findings,
iter_bounded_text_lines,
keycheck_input_mode,
load_known_statuses,
load_checked_statuses,
mask_secret,
now_iso,
physical_jsonl_segments,
private_append_writer,
private_atomic_writer,
reconcile_keycheck_jsonl_segments,
record_validation_result,
recover_status_transaction,
release_file_lock,
repair_keycheck_jsonl_tail,
require_provider_authority,
rotate_jsonl_if_needed,
service_output_dir,
should_skip_key,
sha256_text,
write_keycheck_event,
)
from runtime_security import reject_reparse_components, require_private_file
# --- Конфигурация ---
SERVICE = "openrouter"
OUTPUT_DIR = os.getenv("KEYCHECK_OUTPUT_DIR") or service_output_dir(SERVICE)
INPUT_FILE = os.getenv("KEYCHECK_INPUT_FILE") or default_input_file()
ALIVE_FILE = os.path.join(OUTPUT_DIR, "openrouterAlive.txt")
DEAD_FILE = os.path.join(OUTPUT_DIR, "openrouterDead.txt")
LIMITED_FILE = os.path.join(OUTPUT_DIR, "openrouterLimited.txt")
NO_BALANCE_FILE = os.path.join(OUTPUT_DIR, "openrouterNoBalance.txt")
NETWORK_FILE = os.path.join(OUTPUT_DIR, "openrouterNetwork.txt")
UNKNOWN_FILE = os.path.join(OUTPUT_DIR, "openrouterUnknown.txt")
CHECKED_FILE = os.path.join(OUTPUT_DIR, "openrouterChecked.txt")
RESULTS_FILE = os.path.join(OUTPUT_DIR, "openrouterResults.jsonl")
PROXY_FILE = os.getenv("KEYCHECK_PROXY_FILE") or default_proxy_file()
STATUS_FILES = {
"VALID": ALIVE_FILE,
"NO_BALANCE": NO_BALANCE_FILE,
"DEAD": DEAD_FILE,
"LIMITED": LIMITED_FILE,
"NETWORK": NETWORK_FILE,
"UNKNOWN": UNKNOWN_FILE,
}
CREDITS_URL = "https://openrouter.ai/api/v1/credits"
# --- Вспомогательные функции ---
def load_set_from_file(filepath):
"""Загружает ключи из файла в set для быстрой проверки."""
if not os.path.exists(filepath):
return set()
return {line.strip().split(':')[0] for line in iter_bounded_text_lines(filepath) if line.strip()}
def ensure_output_files():
ensure_private_output_files((CHECKED_FILE, RESULTS_FILE, *STATUS_FILES.values()))
recover_status_transaction(CHECKED_FILE, STATUS_FILES)
def legacy_status_key(line):
value = line.strip()
if not value:
return None
if '\t' in value:
return value.split('\t', 1)[0].strip()
return value.split(':', 1)[0].strip()
def load_openrouter_keys(path):
if keycheck_input_mode() == 'postgres':
return set()
if not os.path.exists(path):
return set()
return {key for key in (legacy_status_key(line) for line in iter_bounded_text_lines(path)) if key}
def iter_plain_openrouter_keys(paths):
if keycheck_input_mode() == 'postgres':
return
seen = set()
items = []
for path in paths or []:
if not path or not os.path.exists(path):
continue
for line in iter_bounded_text_lines(path):
key = legacy_status_key(line)
if not key or key in seen:
continue
seen.add(key)
items.append({'raw': key, 'source': path, 'finding': {}})
for item in items:
yield item
def retry_plain_files(args):
if keycheck_input_mode() == 'postgres':
return []
files = list(args.plain or [])
if args.recheck_all:
files.extend(STATUS_FILES.values())
else:
if args.retry_valid:
files.append(ALIVE_FILE)
if args.retry_no_balance:
files.append(NO_BALANCE_FILE)
if args.retry_limited:
files.append(LIMITED_FILE)
if args.retry_network:
files.append(NETWORK_FILE)
if args.retry_unknown:
files.append(UNKNOWN_FILE)
out = []
seen = set()
for path in files:
if path and path not in seen:
seen.add(path)
out.append(path)
return out
def migrate_legacy_checked():
if keycheck_input_mode() == 'postgres':
return
checked = load_checked_statuses(CHECKED_FILE)
for key in sorted(load_openrouter_keys(ALIVE_FILE)):
if key not in checked:
write_keycheck_event(SERVICE, RESULTS_FILE, key, {'status': 'VALID', 'message': 'legacy alive status migration'}, 'legacy:openrouterAlive.txt', {}, 'OpenRouter', 'legacy_status')
append_checked(CHECKED_FILE, key, 'VALID')
checked[key] = 'VALID'
for key in sorted(load_openrouter_keys(DEAD_FILE)):
if key not in checked:
write_keycheck_event(SERVICE, RESULTS_FILE, key, {'status': 'DEAD', 'message': 'legacy dead status migration'}, 'legacy:openrouterDead.txt', {}, 'OpenRouter', 'legacy_status')
append_checked(CHECKED_FILE, key, 'DEAD')
checked[key] = 'DEAD'
def _legacy_alive_checked_at(path):
details = os.stat(path, follow_symlinks=False)
return datetime.fromtimestamp(details.st_mtime, timezone.utc).isoformat(timespec='seconds')
def _legacy_balance_event_payload(key, balance, checked_at):
balance_text = f'{balance:.6f}'
key_hash = sha256_text(key)
event_id = sha256_text('|'.join([
SERVICE,
'legacy_status',
'openrouterAlive.txt',
key_hash,
'NO_BALANCE',
balance_text,
]))
return {
'key_masked': mask_secret(key),
'key_hash': key_hash,
'secret_hash': key_hash,
'detector_secret_hash': finding_detector_secret_hash({}),
'finding_uid': '',
'detector': 'OpenRouter',
'source': 'legacy:openrouterAlive.txt',
'finding': {},
'checked_at': checked_at,
'result_source': 'legacy_status',
'status': 'NO_BALANCE',
'message': f'credits={balance_text}',
'event_id': event_id,
}
def _publish_legacy_no_balance(moved):
lock_path = f'{NO_BALANCE_FILE}.lock'
lock = acquire_file_lock(lock_path, timeout_sec=30)
try:
require_private_file(NO_BALANCE_FILE)
existing = load_openrouter_keys(NO_BALANCE_FILE)
with private_append_writer(NO_BALANCE_FILE) as handle:
for key, balance in moved:
if key in existing:
continue
balance_text = f'{balance:.6f}'
handle.write(f'{key}\tNO_BALANCE\tcredits={balance_text}\tmigrated_from_alive\n')
existing.add(key)
finally:
release_file_lock(lock, lock_path)
def _existing_legacy_event_ids(expected):
found = set()
max_line_bytes = max(1024, env_int('KEYCHECK_INPUT_MAX_LINE_BYTES', 16 * 1024 * 1024))
max_file_bytes = max(
max_line_bytes,
max(1, env_int('KEYCHECK_INPUT_LIST_MAX_BYTES', 32 * 1024 * 1024)),
max(0, env_int('KEYCHECK_RESULTS_MAX_MB', 32)) * 1024 * 1024 + max_line_bytes,
)
paths = [path for _, path in physical_jsonl_segments(RESULTS_FILE)]
if os.path.isfile(RESULTS_FILE):
paths.append(os.path.abspath(RESULTS_FILE))
for path in paths:
reject_reparse_components(path)
if os.path.getsize(path) > max_file_bytes:
raise RuntimeError(f'OpenRouter result file exceeds the bounded migration scan size: {path}')
with open(path, 'rb') as handle:
while True:
raw_line = handle.readline(max_line_bytes + 1)
if not raw_line:
break
if len(raw_line) > max_line_bytes:
raise RuntimeError(f'OpenRouter result line exceeds the bounded migration scan size: {path}')
if not raw_line.endswith(b'\n'):
raise RuntimeError(f'torn OpenRouter result line during legacy migration: {path}')
try:
payload = json.loads(raw_line.decode('utf-8', errors='replace'))
except (TypeError, ValueError):
continue
event_id = str(payload.get('event_id') or '') if isinstance(payload, dict) else ''
if event_id not in expected:
continue
wanted = expected[event_id]
for field in ('key_hash', 'status', 'source', 'result_source'):
if str(payload.get(field) or '') != str(wanted.get(field) or ''):
raise RuntimeError(f'conflicting OpenRouter legacy migration event: {event_id}')
found.add(event_id)
return found
def _publish_legacy_balance_events(moved, checked_at):
payloads = [_legacy_balance_event_payload(key, balance, checked_at) for key, balance in moved]
expected = {payload['event_id']: payload for payload in payloads}
lock_path = f'{RESULTS_FILE}.lock'
lock = acquire_file_lock(lock_path, timeout_sec=30)
try:
require_private_file(RESULTS_FILE)
repair_keycheck_jsonl_tail(RESULTS_FILE)
reconcile_keycheck_jsonl_segments(RESULTS_FILE)
existing = _existing_legacy_event_ids(expected)
max_bytes = max(0, env_int('KEYCHECK_RESULTS_MAX_MB', 32)) * 1024 * 1024
for payload in payloads:
event_id = payload['event_id']
if event_id in existing:
continue
rotate_jsonl_if_needed(RESULTS_FILE, max_bytes)
with private_append_writer(RESULTS_FILE) as handle:
handle.write(json.dumps(payload, ensure_ascii=False, default=str) + '\n')
existing.add(event_id)
finally:
release_file_lock(lock, lock_path)
def _publish_legacy_checked(moved, checked_at):
lock_path = f'{CHECKED_FILE}.lock'
lock = acquire_file_lock(lock_path, timeout_sec=30)
try:
require_private_file(CHECKED_FILE)
existing = set()
for line in iter_bounded_text_lines(CHECKED_FILE):
parts = line.rstrip('\r\n').split('\t')
if len(parts) >= 2:
existing.add((parts[0], parts[1]))
with private_append_writer(CHECKED_FILE) as handle:
for key, _ in moved:
identity = (key, 'NO_BALANCE')
if identity in existing:
continue
handle.write(f'{key}\tNO_BALANCE\t{checked_at}\n')
existing.add(identity)
finally:
release_file_lock(lock, lock_path)
def _rewrite_legacy_alive(keep):
with private_atomic_writer(ALIVE_FILE, binary=True, suffix='.legacy.tmp') as handle:
for line in keep:
handle.write(line.encode('utf-8'))
def migrate_legacy_alive_balances():
if keycheck_input_mode() == 'postgres':
return
lock_path = f'{ALIVE_FILE}.lock'
lock = acquire_file_lock(lock_path, timeout_sec=30)
try:
if not os.path.exists(ALIVE_FILE):
return
require_private_file(ALIVE_FILE)
checked_at = _legacy_alive_checked_at(ALIVE_FILE)
keep = []
moved = []
seen = set()
for line in iter_bounded_text_lines(ALIVE_FILE):
text = line.strip()
if not text:
keep.append(line)
continue
key = legacy_status_key(text)
balance = None
if ':' in text and '\t' not in text:
try:
balance = float(text.rsplit(':', 1)[1])
except ValueError:
balance = None
if key and balance is not None and balance <= 0:
if key not in seen:
moved.append((key, balance))
seen.add(key)
else:
keep.append(line)
if not moved:
return
_publish_legacy_no_balance(moved)
_publish_legacy_balance_events(moved, checked_at)
_publish_legacy_checked(moved, checked_at)
_rewrite_legacy_alive(keep)
finally:
release_file_lock(lock, lock_path)
def load_proxies(proxy_file=None):
"""Загружает и подготавливает прокси."""
proxy_file = proxy_file or PROXY_FILE
if not os.path.exists(proxy_file):
print("ℹ️ Файл proxy.txt не найден, запросы будут идти напрямую.")
return None
proxies = []
with open(proxy_file, 'r') as f:
for line in f:
line = line.strip()
if not line: continue
try:
ip, port, login, password = line.split(':')
proxy_url = f"http://{login}:{password}@{ip}:{port}"
proxies.append({"http": proxy_url, "https": proxy_url})
except ValueError:
print(f"⚠️ Неверный формат прокси: '{line}'. Пропускаем.")
if not proxies:
print("⚠️ Файл proxy.txt пуст. Запросы будут идти напрямую.")
return None
print(f"✅ Загружено {len(proxies)} прокси.")
return cycle(proxies)
def write_result(key, result, source_line, finding=None, previous_status=None):
status = result.get('status') or 'UNKNOWN'
credits = result.get('remaining_credits')
extra = f"credits={credits:.6f}" if isinstance(credits, (int, float)) else source_line
checked_at = now_iso()
result = {**result, 'checked_at': result.get('checked_at') or checked_at}
write_keycheck_event(SERVICE, RESULTS_FILE, key, result, source_line, finding, 'OpenRouter')
commit_status_transaction(
CHECKED_FILE, STATUS_FILES, key, status, result.get('message', ''), extra,
)
record_validation_result(SERVICE, key, result, source_line, finding, 'OpenRouter')
# --- Функция проверки ---
def check_openrouter_key(key, proxy):
"""Проверяет один ключ OpenRouter и возвращает normalized result."""
headers = {"Authorization": f"Bearer {key}"}
try:
resp = requests.get(CREDITS_URL, headers=headers, proxies=proxy, timeout=15)
except requests.exceptions.RequestException as e:
return {'status': 'NETWORK', 'message': str(e)}
if resp.status_code == 200:
try:
data = resp.json().get("data", {})
total = float(data.get("total_credits", 0.0) or 0.0)
used = float(data.get("total_usage", 0.0) or 0.0)
except (TypeError, ValueError, json.JSONDecodeError) as exc:
return {'status': 'UNKNOWN', 'http_status': resp.status_code, 'message': f'invalid credits response: {exc}'}
remaining = total - used
if remaining > 0:
return {'status': 'VALID', 'remaining_credits': remaining, 'message': f'credits={remaining:.6f}'}
return {'status': 'NO_BALANCE', 'remaining_credits': remaining, 'message': f'credits={remaining:.6f}'}
if resp.status_code in (401, 403):
return {'status': 'DEAD', 'http_status': resp.status_code, 'message': resp.text[:1000]}
if resp.status_code == 429:
return {'status': 'LIMITED', 'http_status': resp.status_code, 'message': resp.text[:1000]}
if 500 <= resp.status_code <= 599:
return {'status': 'NETWORK', 'http_status': resp.status_code, 'message': resp.text[:1000]}
return {'status': 'UNKNOWN', 'http_status': resp.status_code, 'message': resp.text[:1000]}
def parse_args():
parser = argparse.ArgumentParser(description='OpenRouter key checker')
parser.add_argument('--input', default=INPUT_FILE)
parser.add_argument('--plain', action='append', default=[])
parser.add_argument('--proxy-file', default=PROXY_FILE)
parser.add_argument('--max-keys', type=int, default=0)
parser.add_argument('--retry-network', action='store_true')
parser.add_argument('--retry-limited', action='store_true')
parser.add_argument('--retry-unknown', action='store_true')
parser.add_argument('--retry-no-balance', action='store_true')
parser.add_argument('--retry-valid', action='store_true')
parser.add_argument('--recheck-all', action='store_true')
return parser.parse_args()
# --- Основной процесс ---
def main():
require_provider_authority(SERVICE)
args = parse_args()
ensure_output_files()
migrate_legacy_alive_balances()
migrate_legacy_checked()
print("--- 🚀 Запуск чекера ключей OpenRouter 🚀 ---")
proxy_cycler = load_proxies(args.proxy_file)
checked_statuses = load_checked_statuses(CHECKED_FILE)
known_statuses = load_known_statuses(CHECKED_FILE, STATUS_FILES)
known_keys = set(known_statuses)
for path in STATUS_FILES.values():
known_keys.update(load_openrouter_keys(path))
retry_statuses = set()
if args.retry_network:
retry_statuses.add('NETWORK')
if args.retry_limited:
retry_statuses.add('LIMITED')
if args.retry_unknown:
retry_statuses.add('UNKNOWN')
if args.retry_no_balance:
retry_statuses.add('NO_BALANCE')
if args.retry_valid:
retry_statuses.add('VALID')
print(f"📖 Загружено: {len(load_openrouter_keys(ALIVE_FILE))} живых ключей, {len(known_keys)} классифицированных ключей.")
if keycheck_input_mode() == 'jsonl' and not os.path.exists(args.input):
print(f"❌ Файл с секретами {args.input} не найден. Завершение.")
return
processed = 0
def candidates():
for item in iter_findings(args.input, ["OpenRouter"]):
key = item.get("raw") or ""
if key:
yield item
seen = set()
for item in iter_plain_openrouter_keys(retry_plain_files(args)):
key = item.get("raw") or ""
if key and key not in seen:
seen.add(key)
yield item
for item in candidates():
key = item.get("raw") or ""
if not key:
continue
source = item.get("source") or args.input
finding = item.get("finding") or {}
if should_skip_key(
key, checked_statuses, known_keys, args, retry_statuses,
service=SERVICE, source=source, finding=finding, detector='OpenRouter', known_statuses=known_statuses,
):
continue
if args.max_keys and processed >= args.max_keys:
break
processed += 1
print(f"\n[{processed}] 🎯 Новый кандидат: {key[:8]}...{key[-4:]} from {source}")
current_proxy = next(proxy_cycler) if proxy_cycler else None
result = check_openrouter_key(key, current_proxy)
print(f" STATUS: {result.get('status')} | {str(result.get('message', ''))[:200]}")
previous_status = known_statuses.get(key) or checked_statuses.get(key)
write_result(key, result, source, finding, previous_status)
known_keys.add(key)
checked_statuses[key] = result.get('status')
print("\n--- ✅ Проверка завершена. ---")
if __name__ == "__main__":
main()