"""Per-user settings, re-read for new evaluations without restarting active chats. Schema 2 (14 Oct 2026) adds the decision stages: watchModels, prefilter, autoRoute, finishCheck, verifierCheck and policy. A schema 1 file stays valid: every missing section is filled from the defaults, and every new stage starts disabled, so an upgrade changes no behaviour by itself. With policy.source "dify" the stage settings may be overridden by the last good rules fetched from Dify (jev-policy.json); invalid or missing rules leave the local settings in force. """ import base64 import copy import ctypes import json import os import sys import threading import time from pathlib import Path from urllib.parse import urlsplit DEFAULTS = json.loads(Path(__file__).with_name('jev-settings-defaults.json').read_text(encoding='utf-8')) POLICY_FILE = 'jev-policy.json' # Sections Dify rules may change. The transition chain and the always-pass list stay local. POLICY_KEYS = ('prefilter', 'autoRoute', 'finishCheck', 'verifierCheck', 'thresholds', 'questions') SECTIONS = ('watchModels', 'prefilter', 'autoRoute', 'finishCheck', 'verifierCheck', 'policy') _condition = threading.Condition() _active = 0 def home(): if '--home' in sys.argv: return Path(sys.argv[sys.argv.index('--home') + 1]) return Path(os.environ.get('AITURK_CODEX_BRIDGE_HOME') or Path(__file__).parent) def _fill(value, default): """Missing keys of a schema 1 file come from the defaults; present values are kept as written.""" if isinstance(default, dict) and isinstance(value, dict): return {k: _fill(value[k], d) if k in value else copy.deepcopy(d) for k, d in default.items()} | \ {k: v for k, v in value.items() if k not in default} return value def _text(value, limit=10000): return isinstance(value, str) and bool(value.strip()) and len(value) <= limit def _prob(value): return isinstance(value, (int, float)) and not isinstance(value, bool) and 0 <= value <= 1 def _int(value, low, high): return isinstance(value, int) and not isinstance(value, bool) and low <= value <= high def _https(value): if not _text(value, 2048): return False url = urlsplit(value) return url.scheme == 'https' and bool(url.netloc) and not url.username and not url.password def validate(settings): """Raise ValueError when a value could route to the wrong place or wait without bound.""" # A corrupted file must not silently change routing or contact another host. if not _https(settings['endpoint']): raise ValueError('Invalid Jev endpoint') for name in ('ret', 'sart'): n = settings['thresholds'][name] if not isinstance(n, (int, float)) or not 0 <= n <= 1 or not settings['questions'][name]: raise ValueError('Invalid Jev rule') for name in ('timeoutSeconds', 'queueWaitSeconds'): if not isinstance(settings[name], int) or not 1 <= settings[name] <= 3600: raise ValueError('Invalid Jev timeout') if not isinstance(settings['maxConcurrent'], int) or not 0 <= settings['maxConcurrent'] <= 512: raise ValueError('Invalid Jev concurrency') if not settings['fallbackModel'] or not isinstance(settings['rejectionRules'], list) or settings['failureAction'] not in ('pass', 'next'): raise ValueError('Invalid Jev routing') watch = settings['watchModels'] if not isinstance(watch, list) or len(watch) > 64 or not all(_text(x, 256) for x in watch) or len(set(watch)) != len(watch): raise ValueError('Invalid watch models') p = settings['prefilter'] if not (isinstance(p.get('enabled'), bool) and _text(p.get('question')) and _prob(p.get('threshold')) and _text(p.get('target'), 256) and _int(p.get('timeoutSeconds'), 1, 60)): raise ValueError('Invalid prefilter') a = settings['autoRoute'] if not (isinstance(a.get('enabled'), bool) and _text(a.get('model'), 256) and _int(a.get('timeoutSeconds'), 1, 60) and all(_text((a.get('questions') or {}).get(k)) and _prob((a.get('thresholds') or {}).get(k)) for k in ('zor', 'arastirma')) and all(_text((a.get('targets') or {}).get(k), 256) for k in ('basit', 'zor', 'arastirma'))): raise ValueError('Invalid automatic routing') if a['model'] in a['targets'].values() or p['target'] == a['model']: raise ValueError('Automatic routing cannot target itself') f = settings['finishCheck'] if not (isinstance(f.get('enabled'), bool) and _text(f.get('question')) and _prob(f.get('threshold')) and _text(f.get('note'), 4000)): raise ValueError('Invalid finish check') v = settings['verifierCheck'] if not (isinstance(v.get('enabled'), bool) and _text(v.get('question')) and _prob(v.get('threshold'))): raise ValueError('Invalid verifier check') pol = settings['policy'] if not (pol.get('source') in ('local', 'dify') and _https(pol.get('endpoint')) and _int(pol.get('refreshMinutes'), 1, 1440)): raise ValueError('Invalid policy source') return settings def _policy(settings): """Last good Dify rules on top of the local settings; anything invalid keeps the local settings.""" if settings['policy']['source'] != 'dify': return settings try: cache = json.loads((home() / POLICY_FILE).read_text(encoding='utf-8')) overrides = cache.get('overrides') if not cache.get('ok') or not isinstance(overrides, dict): return settings merged = copy.deepcopy(settings) for key in POLICY_KEYS: if isinstance(overrides.get(key), dict): merged[key] = _fill(overrides[key], settings[key]) return validate(merged) except (OSError, ValueError, KeyError, TypeError, AttributeError): return settings def read(overlay=True): """(settings, protectedKey). overlay=False: the local settings without the Dify rules.""" try: data = json.loads((home() / 'jev-settings.json').read_text(encoding='utf-8')) except FileNotFoundError: return copy.deepcopy(DEFAULTS), None settings = data['settings'] for section in SECTIONS: settings[section] = _fill(settings[section], DEFAULTS[section]) if section in settings else copy.deepcopy(DEFAULTS[section]) validate(settings) return (_policy(settings) if overlay else settings), data.get('protectedKey') class _Blob(ctypes.Structure): _fields_ = [('cbData', ctypes.c_uint32), ('pbData', ctypes.POINTER(ctypes.c_ubyte))] def _unprotect(encrypted): if not encrypted: return '' if os.name != 'nt': raise RuntimeError('Windows credential protection required') raw = base64.b64decode(encrypted, validate=True) buf = (ctypes.c_ubyte * len(raw)).from_buffer_copy(raw) source, result = _Blob(len(raw), buf), _Blob() decrypt = ctypes.windll.crypt32.CryptUnprotectData decrypt.argtypes = [ctypes.POINTER(_Blob), ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(_Blob)] decrypt.restype = ctypes.c_int if not decrypt(ctypes.byref(source), None, None, None, None, 0, ctypes.byref(result)): raise ctypes.WinError() try: return ctypes.string_at(result.pbData, result.cbData).decode('utf-8') finally: ctypes.windll.kernel32.LocalFree.argtypes = [ctypes.c_void_p] ctypes.windll.kernel32.LocalFree(result.pbData) def key(): _, encrypted = read() return _unprotect(encrypted) def policy_key(): """Dify app key for the rules source (protectedPolicyKey, Windows DPAPI). Empty when not set.""" try: data = json.loads((home() / 'jev-settings.json').read_text(encoding='utf-8')) except (OSError, ValueError): return '' return _unprotect(data.get('protectedPolicyKey')) def write_policy(result): """Atomically store the outcome of a Dify fetch. A failed fetch keeps the last good overrides.""" path = home() / POLICY_FILE previous = {} try: previous = json.loads(path.read_text(encoding='utf-8')) except (OSError, ValueError): pass record = {'fetchedAt': time.strftime('%Y-%m-%dT%H:%M:%S%z'), 'ok': bool(result.get('ok')), 'error': str(result.get('error') or '')[:500]} record['overrides'] = result.get('overrides') if result.get('ok') else previous.get('overrides') if not result.get('ok') and previous.get('ok'): record['ok'] = True # the last good rules stay in force; the error is reported beside them record['lastGoodAt'] = previous.get('lastGoodAt') or previous.get('fetchedAt') elif result.get('ok'): record['lastGoodAt'] = record['fetchedAt'] tmp = path.with_name(path.name + f'.{os.getpid()}.{threading.get_ident()}.tmp') tmp.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding='utf-8') try: for deneme in range(5): try: os.replace(tmp, path) # Windows: a reader holding the file makes this fail briefly break except PermissionError: if deneme == 4: raise time.sleep(0.05 * (deneme + 1)) finally: if tmp.exists(): tmp.unlink() return record def acquire(settings, wait=None): """wait: at most this many seconds in the queue (turn-start questions must not hold a request).""" global _active deadline = time.monotonic() + (min(settings['queueWaitSeconds'], wait) if wait else settings['queueWaitSeconds']) with _condition: while settings['maxConcurrent'] and _active >= settings['maxConcurrent']: remaining = deadline - time.monotonic() if remaining <= 0: return False _condition.wait(remaining) _active += 1 return True def release(): global _active with _condition: _active -= 1 _condition.notify_all()