Files
aiturk-hermes-ide/apps/desktop/assets/codex-bridge/jev_settings.py
T
yilsemandClaude Opus 4.8 65ce4be58b
Server CI and Windows package / verify (push) Successful in 4m30s
Server CI and Windows package / windows-package (push) Successful in 16m59s
fix(codex-bridge): strip a stray ide/ prefix from stored model names
A hand- or agent-edited jev-settings.json could store a model name that
already carries the gateway's own "ide/" prefix. The bridge adds "ide/"
again when it routes, producing "ide/ide/<model>" — an unknown model whose
context window the bridge reads as the 32768 default and then silently
drops the request, answering "." with a wrong "model seçildi" instead of
the chosen model. jev_settings.read()/_policy() now normalize every stored
model name (fallbackModel, watchModels, exemptModels, prefilter.target,
autoRoute.model/targets) by stripping a leading "ide/". No real
provider/model id begins with "ide/", so valid settings are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 02:09:30 +03:00

263 lines
11 KiB
Python

"""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
IDE_PREFIX = 'ide/'
def _strip_ide(value):
"""A stored model name must never carry the gateway's own 'ide/' prefix; the bridge adds it when
it routes. A doubled 'ide/ide/...' makes the bridge treat the name as an unknown model, read its
context window as the 32768 default and silently drop the request. No real provider/model id
begins with 'ide/', so stripping a leading 'ide/' here can only undo an accidental prefix."""
if isinstance(value, str):
while value.startswith(IDE_PREFIX):
value = value[len(IDE_PREFIX):]
return value
def _normalize_models(settings):
"""Strip a stray 'ide/' prefix from every stored model name so routing can never double it."""
if 'fallbackModel' in settings:
settings['fallbackModel'] = _strip_ide(settings['fallbackModel'])
for key in ('watchModels', 'exemptModels'):
values = settings.get(key)
if isinstance(values, list):
settings[key] = [_strip_ide(x) for x in values]
prefilter = settings.get('prefilter')
if isinstance(prefilter, dict) and 'target' in prefilter:
prefilter['target'] = _strip_ide(prefilter['target'])
auto = settings.get('autoRoute')
if isinstance(auto, dict):
if 'model' in auto:
auto['model'] = _strip_ide(auto['model'])
targets = auto.get('targets')
if isinstance(targets, dict):
for key in list(targets):
targets[key] = _strip_ide(targets[key])
return settings
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])
_normalize_models(merged)
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])
_normalize_models(settings)
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()