fix(codex-bridge): close the gaps an independent review found
Server CI and Windows package / verify (push) Successful in 3m23s
Server CI and Windows package / windows-package (push) Successful in 7m9s

- One decision per real turn: a turn starts with a user message after the
  previous final answer; tool steps, messages typed mid-turn, a lone "."
  and compaction keep the combo. Works without prompt_cache_key too.
- The virtual Otomatik name never reaches the gateway (stage off or error
  -> last choice or targets.zor); prefilter.target may not be it.
- finishCheck: at most once per turn; a broken, empty or refused retry
  delivers the already approved first answer.
- Layer window filter: only trusted windows, never the last layer.
- Correction channel only on always-pass targets while checks are on;
  Sesli-Hizli-Zeki stays always-pass by default; a >256 KB SSE frame
  passes unfiltered instead of breaking the stream.
- Turn-start questions wait in the local queue no longer than their
  timeout; decision-turn memory under a lock with refreshed order.
- Dify fetch refuses redirects (the app key never moves host); the
  rules file write retries a Windows sharing violation.
- An unreadable settings file no longer breaks every request.
- TypeScript accepts only https:// URLs the bridge accepts;
  finishCheck.timeoutSeconds (unused) removed.

Tests: 310 bridge tests, 36 electron tests; side-bridge live run for the
turn, prefilter (incl. decision timeout) and finish-check paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
yilsemandClaude Opus 5.5 committed 2026-10-14 08:14:38 +03:00
1 parent 7c4ef18df0
commit a7ba300448
10 files changed
+353 -91

No files matched your search

+20 -12
View File
@@ -36,25 +36,33 @@ decision always keeps the current path.
- `jev-settings.json` schema 2 adds `watchModels`, `prefilter`, `autoRoute`, `finishCheck`,
`verifierCheck` and `policy`. A schema 1 file is filled from `jev-settings-defaults.json`.
The default chain is `Katman-1 → Katman-2 → Katman-3`; `Katman-3`, `Chat-Model` and
`bekci-sesli-bekci` always pass.
The default chain is `Katman-1 → Katman-2 → Katman-3`; `Katman-3`, `Chat-Model`,
`bekci-sesli-bekci` and `Sesli-Hizli-Zeki` always pass.
- **Correction channel** (`watchModels`): the Bekçi watch sideband is no longer bound to the hard-coded
name `Sesli-Hizli-Zeki`; any exact target in the list owns it.
name `Sesli-Hizli-Zeki`; any exact target in the list owns it, but only while it always passes the check
(a checked answer may be replaced by a layer, and a receipt must not belong to an answer the user never saw).
A single SSE frame above 256 KB no longer breaks the stream; the rest of the stream passes unfiltered.
- **Refusal pre-check** (`prefilter`): at the start of a turn, "would a mainstream model refuse or water
this down?". If yes, the step starts at `prefilter.target` instead of collecting a refusal first.
- **Automatic model** (`autoRoute`): a virtual `ide/<model>` entry ("Otomatik") in the Codex list. Each new
user message gets one decision call (hard? research?) and goes to the configured combo. Tool steps of
the same turn keep that combo, because switching providers mid-turn breaks encrypted history. The
user's explicit model choice is never changed by this stage.
- Both turn-start questions go in one call, bounded by `timeoutSeconds` (default 3 s).
- **Layer window filter**: a layer whose context window cannot hold the request is skipped.
- **Automatic model** (`autoRoute`): a virtual `ide/<model>` entry ("Otomatik") in the Codex list. A new turn
(a user message after the previous final answer) gets one decision call (hard? research?) and goes to
the configured combo. Tool steps, messages typed while the turn runs, a lone "." and compaction stay on
the same combo, because switching providers mid-turn breaks encrypted history. When the stage is off or
fails, the virtual name never reaches the gateway: the last choice or `targets.zor` is used. The user's
explicit model choice is never changed by this stage.
- Both turn-start questions go in one call; the wait, including the local queue, is bounded by
`timeoutSeconds` (default 3 s).
- **Layer window filter**: a layer whose trusted context window (pencere.bekci_calisir) cannot hold the
request is skipped. The last link of the chain is never skipped.
- **Early finish check** (`finishCheck`): asked in the same call as the refusal check on a final answer.
If the answer stopped early or claimed success without evidence, it goes back to the same model once
with `finishCheck.note`; the retry is delivered without another finish question.
If the answer stopped early or claimed success without evidence, it goes back to the same model with
`finishCheck.note`, at most once per turn. If the retry breaks, comes back empty or fails the check, the
first (already approved) answer is delivered.
- **Rules from Dify** (`policy.source = dify`, `karar_politikasi.py`): the stage questions, thresholds and
combo choices may come from a Dify workflow's `kurallar` output. Fetched in the background every
`refreshMinutes`, validated against the local settings, last good copy kept in `jev-policy.json`.
The transition chain and the always-pass list stay local. A turn never waits for Dify.
The transition chain and the always-pass list stay local. A turn never waits for Dify, and a redirect
from the Dify endpoint is refused so the app key never travels to another host.
- **Workflow reviewer check** (`verifierCheck`, `karar_sor.py`): the Codex dynamic-workflow acceptance
step asks whether a reviewer approved without concrete evidence; if so acceptance is blocked.
@@ -7,7 +7,7 @@
{ "from": "Katman-1", "to": "Katman-2" },
{ "from": "Katman-2", "to": "Katman-3" }
],
"exemptModels": ["Chat-Model", "bekci-sesli-bekci", "Katman-3"],
"exemptModels": ["Chat-Model", "bekci-sesli-bekci", "Sesli-Hizli-Zeki", "Katman-3"],
"thresholds": { "ret": 0.5, "sart": 0.7 },
"questions": {
"ret": "The assistant did NOT do what the user asked or did NOT answer the user's question: it refused, declined, said it cannot or will not, avoided giving the requested judgment, or redirected to a different topic.",
@@ -41,8 +41,7 @@
"enabled": false,
"question": "The assistant stopped before the user's task was finished: it said it is done or handed back without doing all of what was asked, or it claimed success (tests pass, file changed, deployed) without showing the actual evidence.",
"threshold": 0.6,
"note": "Önceki cevabın iş bitmeden bitti dedi ya da kanıt göstermedi. İşi gerçekten bitir; gerekiyorsa araçları kullan ve gerçek kanıtı göster.",
"timeoutSeconds": 5
"note": "Önceki cevabın iş bitmeden bitti dedi ya da kanıt göstermedi. İşi gerçekten bitir; gerekiyorsa araçları kullan ve gerçek kanıtı göster."
},
"verifierCheck": {
"enabled": false,
+20 -12
View File
@@ -38,11 +38,14 @@ SORU = {
_ANAHTAR = {"okundu": False, "deger": ""}
def izleme_mi(hedef):
"""Bekci izleme kanali (cevaptan sonra duzeltme) bu hedefin mi? Ayar: watchModels, tam ad eslesmesi."""
"""Bekci izleme kanali (cevaptan sonra duzeltme) bu hedefin mi? Ayar: watchModels, tam ad eslesmesi.
Jev acikken yalniz muaf hedefte: denetlenen cevap baska katmana gecebilir, makbuz kullanicinin gormedigi
cevaba baglanmasin."""
try:
return hedef in jev_settings.read()[0]['watchModels']
s = jev_settings.read()[0]
return hedef in s['watchModels'] and (not s['enabled'] or hedef in s['exemptModels'])
except Exception:
return hedef == "Sesli-Hizli-Zeki"
return False
def muaf_mi(hedef):
@@ -125,18 +128,23 @@ def son_istek(req):
return son_istek_sirasi(req)[0]
def istek_metni(oge):
"""Bir kullanici mesajindaki istek: sesli aktarimda <input> icindeki soz; sarmalli sistem mesaji ise ""."""
if not (isinstance(oge, dict) and oge.get("type", "message") == "message" and oge.get("role") == "user"):
return ""
metin = _metin(oge).strip()
m = re.search(r"<realtime_delegation>.*?<input>(.*?)</input>", metin, re.S)
if m:
return m.group(1).strip()
return metin if metin and not metin.startswith("<") else ""
def son_istek_sirasi(req):
"""(istek, girdideki sirasi). Turun arac adimlarinda da ayni sira doner; yoksa ("", -1)."""
girdi = req.get("input") or []
for sira in range(len(girdi) - 1, -1, -1):
oge = girdi[sira]
if not (isinstance(oge, dict) and oge.get("type", "message") == "message" and oge.get("role") == "user"):
continue
metin = _metin(oge).strip()
m = re.search(r"<realtime_delegation>.*?<input>(.*?)</input>", metin, re.S)
if m:
return m.group(1).strip(), sira
if metin and not metin.startswith("<"):
metin = istek_metni(girdi[sira])
if metin:
return metin, sira
return "", -1
@@ -202,7 +210,7 @@ def sorular(durum, soru, key, timeout, gonder=_gonder, settings=None):
return None
govde = {"model": settings['model'], "state": durum,
"questions": {k: {"type": "noul", "instructions": v} for k, v in soru.items()}}
if not jev_settings.acquire(settings):
if not jev_settings.acquire(settings, wait=timeout):
return None
try:
response = gonder(govde, key, settings, timeout) if gonder is _gonder else gonder(govde, key)
@@ -87,11 +87,11 @@ def validate(settings):
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():
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) and _int(f.get('timeoutSeconds'), 1, 60)):
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'))):
@@ -186,15 +186,27 @@ def write_policy(result):
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()}.tmp')
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')
os.replace(tmp, path)
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):
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() + settings['queueWaitSeconds']
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()
@@ -13,11 +13,21 @@ jev_settings.POLICY_KEYS, each holding the same fields as the local settings sec
import copy
import json
import time
import urllib.error
import urllib.request
import jev_settings
UA = "AITURK-IDE-codex-bridge/karar-politikasi"
class _YonlendirmeYok(urllib.request.HTTPRedirectHandler):
"""A redirect would carry the Authorization header to another host; it is an error instead."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise urllib.error.HTTPError(req.full_url, code, f"redirect refused: {newurl}", headers, fp)
_ACICI = urllib.request.build_opener(_YonlendirmeYok)
MAX_BYTES = 64 * 1024
_son = {"zaman": 0.0}
@@ -43,7 +53,7 @@ def ayikla(cikti, yerel):
return overrides
def cek(settings, key, urlopen=urllib.request.urlopen):
def cek(settings, key, urlopen=_ACICI.open):
"""One fetch. Returns {"ok": bool, "overrides": {...}, "error": str}; never raises."""
if not key:
return {"ok": False, "error": "Dify key not configured"}
@@ -70,7 +80,7 @@ def zamani_mi(settings, simdi=None):
return settings['policy']['source'] == 'dify' and simdi - _son["zaman"] >= settings['policy']['refreshMinutes'] * 60
def yenile(log=lambda _m: None, urlopen=urllib.request.urlopen, zorla=False):
def yenile(log=lambda _m: None, urlopen=_ACICI.open, zorla=False):
"""Fetch when due; store the outcome. Local settings are read without the Dify overlay."""
try:
yerel, _ = _yerel_ayar()
+114 -29
View File
@@ -245,6 +245,7 @@ def load_ide_catalog(ag=True):
for h in set(oto["targets"].values())),
"arac": True, "kaynak": "", "claude": False}
model_map[slug] = (IDE_ROUTE, oto["model"], None)
OTO_ADI[0] = oto["model"]
n += 1
else:
ROUTES.pop(IDE_ROUTE, None)
@@ -263,68 +264,115 @@ def _oto_ayari():
return None
return s["autoRoute"] if s["enabled"] and s["autoRoute"]["enabled"] else None
# Tur basi kararlari: sohbet -> (tur anahtari, gelen slug, secilen slug). Ayni turun arac adimlari ayni karari kullanir.
# Tur basi kararlari: sohbet -> (tur anahtari, gelen slug, secilen slug). Ayni turun adimlari ayni karari kullanir.
ON_KARAR = {}
ON_KARAR_SINIR = 500
BITIS_TURLARI = {} # finishCheck kullanilan turlar (turda bir kez)
KARAR_KILIDI = threading.Lock()
OTO_ADI = ["Otomatik"] # katalogdaki sanal modelin son adi (karar asamasi hata verirse disari cikmasin)
# Acilista pencere verisi icin aga cikilmaz (IDE koprunun dinlemeye baslamasini sinirli sure bekler);
# ilk tam yenileme asagidaki dongunun basinda, arka planda yapilir.
load_ide_catalog(ag=False)
def tur_bilgisi(req, sohbet):
"""(sohbet koku, tur anahtari, tur basindaki istek, yeni tur mu). Tur, calisan bir turun icine dusmeyen
kullanici mesajiyla baslar. Arac adimlari, tur ortasinda yazilan mesaj, tek "." ve ozet istegi ayni turdadir;
yeni tur yalniz son oge yeni bir kullanici istegi oldugunda baslar."""
girdi = req.get("input") or []
kok = sohbet or "anon:" + hashlib.sha256(json.dumps(girdi[:1], ensure_ascii=False, sort_keys=True,
default=str).encode("utf-8", "replace")).hexdigest()
bas, istek = -1, ""
for i in range(len(girdi) - 1, -1, -1):
metin = jev_denetim.istek_metni(girdi[i])
if not metin or metin == ".":
continue
if not istek:
bas, istek = i, metin # tur basi bulunamazsa en son gercek istek
if not model_gecis.tur_ortasi(req, i):
bas, istek = i, metin
break
son = model_gecis.son_sira(girdi)
yeni = son >= 0 and son == bas and not model_gecis.tur_ortasi(req, son)
tur = hashlib.sha256(f"{kok}\0{bas}\0{istek}".encode("utf-8", "replace")).hexdigest()
return kok, tur, istek, yeni
def karar_asamasi(n, req, sohbet, slug):
"""14 Ekim 2026 karar asamalari: "Otomatik" modelin combo secimi (autoRoute) ve normal modelin reddedecegi
tahmin edilen isi dogrudan ayardaki katmana verme (prefilter). Karar tur basinda bir kez verilir; ayni turun
arac adimlari ayni karari kullanir (her adimda model degisirse saglayicilar arasi gecmis bozulur). Kapali ayar,
bos istek ya da karar modeli hatasi bugunku yolu degistirmez (Otomatik icin guclu hedef secilir)."""
tahmin edilen isi dogrudan ayardaki katmana verme (prefilter). Karar yalniz yeni turda verilir; ayni turun
adimlari ayni karari kullanir (tur ortasinda model degisirse saglayicilar arasi gecmis bozulur). Kapali ayar,
bos istek ya da karar modeli hatasi bugunku yolu degistirmez. Sanal model adi hicbir yolda gecide gitmez."""
if not slug.startswith(IDE_PREFIX):
return slug
settings = jev_settings.read()[0]
a, p = settings["autoRoute"], settings["prefilter"]
sanal = slug == IDE_PREFIX + a["model"]
kok, tur, istek, yeni = tur_bilgisi(req, sohbet)
with KARAR_KILIDI:
kayit = ON_KARAR.get(kok)
if sanal and not (settings["enabled"] and a["enabled"]):
# Kapatilmis Otomatik: son karar ya da guclu hedef; sanal ad gecide gitmez.
secilen = kayit[2] if kayit and kayit[1] == slug else IDE_PREFIX + a["targets"]["zor"]
log(f"[{n} {slug}] Otomatik kapali; bu adim {secilen} modeline gidiyor")
req["model"] = secilen
return secilen
if not settings["enabled"]:
return slug
a, p = settings["autoRoute"], settings["prefilter"]
oto = a["enabled"] and slug == IDE_PREFIX + a["model"]
hedef = slug[len(IDE_PREFIX):]
ret = p["enabled"] and (oto or not (jev_denetim.muaf_mi(hedef) or hedef == p["target"]
or hedef in jev_denetim.sonrakiler(p["target"])))
if not oto and not ret:
ret = p["enabled"] and (sanal or not (jev_denetim.muaf_mi(hedef) or hedef == p["target"]
or hedef in jev_denetim.sonrakiler(p["target"])))
if not sanal and not ret:
return slug
istek, sira = jev_denetim.son_istek_sirasi(req)
# Tur = son kullanici mesaji (metni ve girdideki yeri); ayni turun arac adimlarinda degismez.
tur = hashlib.sha256(f"{sohbet}\0{sira}\0{istek}".encode("utf-8", "replace")).hexdigest()
kayit = ON_KARAR.get(sohbet) if sohbet else None
if kayit and kayit[0] == tur and kayit[1] == slug:
if kayit and kayit[1] == slug and (kayit[0] == tur or not yeni):
secilen = kayit[2]
else:
if istek:
t0 = time.time()
sonuc = jev_denetim.on_karar(istek, oto, ret, jev_denetim.anahtar())
sonuc = jev_denetim.on_karar(istek, sanal, ret, jev_denetim.anahtar())
puan = sonuc["puan"]
log(f"[{n} {slug}] tur basi karari ({time.time() - t0:.1f} sn): "
+ (", ".join(f"{k}={v:.2f}" for k, v in puan.items()) if puan else "karar modeline sorulamadi"))
else:
sonuc = {"hedef": a["targets"]["zor"] if oto else None, "ret": None}
if oto:
sonuc = {"hedef": a["targets"]["zor"] if sanal else None, "ret": None}
if sanal:
hedef = sonuc["hedef"]
secilen = IDE_PREFIX + hedef
if (p["enabled"] and sonuc["ret"] and not jev_denetim.muaf_mi(hedef) and hedef != p["target"]
and hedef not in jev_denetim.sonrakiler(p["target"])):
log(f"[{n} {slug}] ret on kontrolu: normal model reddedebilir, adim {p['target']} katmanindan basliyor")
secilen = IDE_PREFIX + p["target"]
if sohbet:
ON_KARAR[sohbet] = (tur, slug, secilen)
req["model"] = secilen
with KARAR_KILIDI:
ON_KARAR.pop(kok, None) # yeniden ekleme sirayi tazeler: aktif sohbet en son silinir
ON_KARAR[kok] = (tur, slug, secilen)
while len(ON_KARAR) > ON_KARAR_SINIR:
ON_KARAR.pop(next(iter(ON_KARAR)))
ON_KARAR.pop(next(iter(ON_KARAR)), None)
if secilen != slug:
log(f"[{n} {slug}] karar: bu tur {secilen} modeline gidiyor")
req["model"] = secilen
return secilen
def bitis_kullan(tur):
"""finishCheck turda en fazla bir kez: ilk cagri True (ve isaretler), sonrakiler False."""
with KARAR_KILIDI:
if tur in BITIS_TURLARI:
return False
BITIS_TURLARI[tur] = time.time()
while len(BITIS_TURLARI) > ON_KARAR_SINIR:
BITIS_TURLARI.pop(next(iter(BITIS_TURLARI)), None)
return True
def sigan_katmanlar(sira, req, tag):
"""Istegi sigdiramayacak katman zincirden cikar (bos baglam hatasiyla bosa dusmesin)."""
"""Istegi sigdiramayacak katman zincirden cikar (bos baglam hatasiyla bosa dusmesin). Yalniz penceresi
guvenilir (pencere.bekci_calisir) katman atlanir; zincirin son halkasi hic atlanmaz."""
tahmin, sonuc = None, []
for ad in sira:
p = (IDE_BILGI.get(IDE_PREFIX + ad) or {}).get("pencere")
if p:
for i, ad in enumerate(sira):
bilgi = IDE_BILGI.get(IDE_PREFIX + ad) or {}
p = bilgi.get("pencere")
if p and i < len(sira) - 1 and pencere.bekci_calisir(bilgi.get("kaynak"), p):
if tahmin is None:
tahmin = pencere.tahmini_token(req, 2.0)
if tahmin > int(p * 0.90):
@@ -1059,6 +1107,7 @@ class H(http.server.BaseHTTPRequestHandler):
self._akis_basligi(resp, resp.status)
kalan, son_yazi, son_govde = list(sira), time.time(), b""
devam_kullanildi = False # finishCheck: son cevap ayni modele en fazla bir kez geri verilir
yedek = None # geri verilen istek basarisiz olursa teslim edilecek, Jev'den gecmis ilk cevap
def w(b):
if b:
@@ -1129,6 +1178,7 @@ class H(http.server.BaseHTTPRequestHandler):
yeni_devam = None
log(f"{tag} bitis denetimi: devam istegi acilamadi, cevap oldugu gibi geciyor ({type(e).__name__}: {e})")
if yeni_devam is not None:
yedek = (parcalar, rewriter, tag)
resp, rewriter, tag = yeni_devam
log(f"{tag} bitis denetimi: is bitmeden bitti denmis, ayni modele bir kez geri verildi")
continue
@@ -1138,6 +1188,11 @@ class H(http.server.BaseHTTPRequestHandler):
gecmedi = True
log(f"{tag} katman denetimi: bos adim (yazi da komut da yok), siradakine geciliyor: "
f"{b''.join(parcalar)[-200:]!r}")
if gecmedi and yedek is not None:
# Geri verilen istek koptu, bos dondu ya da gecmedi: Jev'den gecmis ilk cevap teslim edilir.
parcalar, rewriter, tag = yedek
gecmedi = False
log(f"{tag} bitis denetimi: geri verilen istek kullanilamadi, ilk cevap teslim ediliyor")
yeni = None
while gecmedi and kalan and yeni is None:
ad = kalan.pop(0)
@@ -1670,6 +1725,12 @@ class H(http.server.BaseHTTPRequestHandler):
slug = karar_asamasi(n, req, sohbet, slug)
except Exception as e:
log(f"[{n} {slug}] karar asamasi atlandi: {type(e).__name__}: {e}")
try:
if slug == IDE_PREFIX + OTO_ADI[0]:
# Sanal ad gecide gitmez: varsayilan guclu hedef.
slug = req["model"] = IDE_PREFIX + jev_settings.DEFAULTS["autoRoute"]["targets"]["zor"]
except Exception:
pass
mapping = MODEL_MAP.get(slug)
if CFG.get("nokta_karsila", True) and nokta_mesaji_mi(req) and model_gecis.tur_ortasi(req, model_gecis.son_sira(req["input"])):
# Calisan turun icine dusen nokta secimi tasimaz (olcum 10 Ekim 2026) ve cevaplanirsa isi yarida
@@ -1862,14 +1923,19 @@ class H(http.server.BaseHTTPRequestHandler):
# secili model baska bir rotada olsa bile. Gecit yoksa denetim yapilmaz. Ozet isteginde ve aracsiz
# (rewriter yok) yolda kapalidir.
jev_rota = ROUTES.get(ide_route)
jev_etkin = jev_denetim.etkin_mi() and not (ozet_tetik or rewriter is None or jev_rota is None or not jev_denetim.anahtar())
jev_sira = jev_denetim.sonrakiler(target) if jev_etkin else []
try:
jev_etkin = jev_denetim.etkin_mi() and not (ozet_tetik or rewriter is None or jev_rota is None or not jev_denetim.anahtar())
jev_sira = jev_denetim.sonrakiler(target) if jev_etkin else []
jev_istek = jev_denetim.son_istek(req) if jev_etkin else ""
except Exception as e:
# Okunamayan ayar istegi dusurmez: bu adim denetimsiz gecer.
log(f"{tag} katman denetimi ayari okunamadi, denetimsiz devam: {type(e).__name__}: {e}")
jev_etkin, jev_sira, jev_istek = False, [], ""
if jev_sira:
try:
jev_sira = sigan_katmanlar(jev_sira, req, tag)
except Exception as e:
log(f"{tag} katman pencere kontrolu atlandi: {type(e).__name__}: {e}")
jev_istek = jev_denetim.son_istek(req) if jev_etkin else ""
def jev_ac(ad):
katman_req = json.loads(data)
@@ -1883,9 +1949,12 @@ class H(http.server.BaseHTTPRequestHandler):
f"[{n} {slug}=>{ad}]")
def jev_devam(ad, onceki):
"""finishCheck: ayni hedefe, erken biten cevap ve not eklenmis istek. Yalniz AITURK gecidindeki hedefler."""
"""finishCheck: ayni hedefe, erken biten cevap ve not eklenmis istek. Yalniz AITURK gecidindeki hedefler.
Turda en fazla bir kez (bitis_kullan)."""
if ad == target and route_name != ide_route:
raise ValueError("hedef AITURK gecidinde degil")
if not bitis_kullan(bitis_turu):
raise ValueError("bu turda zaten geri verildi")
devam_req = json.loads(data)
devam_req["model"] = ad
devam_req.setdefault("input", []).extend([
@@ -1900,7 +1969,12 @@ class H(http.server.BaseHTTPRequestHandler):
return (yanit, SseRewriter(custom, hedef=ad, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren, tool_names=tool_names),
f"[{n} {slug}=>{ad} devam]")
try:
bitis_turu = tur_bilgisi(req, sohbet)[1]
jev_bitis = jev_devam if jev_etkin and jev_settings.read()[0]["finishCheck"]["enabled"] else None
if jev_bitis is not None:
with KARAR_KILIDI:
if bitis_turu in BITIS_TURLARI:
jev_bitis = None # bu turda bir kez geri verildi
except Exception:
jev_bitis = None
# A 9Router combo (Fusion-*, no provider prefix) runs a whole panel upstream. Once it may
@@ -1994,13 +2068,24 @@ class WatchResponse:
def __init__(self, response, accept):
self.response, self.filter = response, MetadataFilter(accept)
self.done = False
self.gecis = False # suzgec tasti (cok buyuk cerceve): kalan akis oldugu gibi gecer
def __getattr__(self, name):
return getattr(self.response, name)
def read1(self, size):
if self.gecis:
return self.response.read1(size)
while not self.done:
raw = self.response.read1(size)
if raw:
data = self.filter.feed(raw)
try:
data = self.filter.feed(raw)
except ValueError:
# 256 KB'i asan tek cerceve: akisi dusurmek yerine suzgecsiz devam (yan kanal cercevesi
# bu boyda olmaz). Tamponda yan kanal varsa disari cikmaz.
data, self.filter.frames.buf = self.filter.frames.buf, b''
self.gecis = True
log("izleme suzgeci: cok buyuk SSE cercevesi, kalan akis suzgecsiz geciyor")
return b'' if b'bekci.watch' in data else data
if data: return data
else:
self.done = True
@@ -5,6 +5,7 @@ import copy
import hashlib
import io
import json
import threading
import tempfile
import time
import unittest
@@ -205,10 +206,12 @@ class TurBasi(Ev):
sahte_denetim.son_istek = jev_denetim.son_istek
self.cevap = {'hedef': None, 'ret': None, 'puan': None}
sahte_denetim.son_istek_sirasi = jev_denetim.son_istek_sirasi
sahte_denetim.istek_metni = jev_denetim.istek_metni
self.env = {'IDE_PREFIX': 'ide/', 'ON_KARAR': {}, 'ON_KARAR_SINIR': 3, 'jev_settings': jev_settings,
'jev_denetim': sahte_denetim, 'model_gecis': model_gecis, 'hashlib': hashlib, 'time': time,
'log': self.logs.append}
exec(compile(ast.Module(body=[fonksiyon('karar_asamasi')], type_ignores=[]), 'kopru.py', 'exec'), self.env)
'json': json, 'KARAR_KILIDI': threading.Lock(), 'BITIS_TURLARI': {}, 'log': self.logs.append}
govde = [fonksiyon(a) for a in ('tur_bilgisi', 'karar_asamasi', 'bitis_kullan')]
exec(compile(ast.Module(body=govde, type_ignores=[]), 'kopru.py', 'exec'), self.env)
self.karar = self.env['karar_asamasi']
def on_karar(self, istek, oto, ret, key):
@@ -220,8 +223,10 @@ class TurBasi(Ev):
def test_everything_off_changes_nothing_and_asks_nothing(self):
self.save()
req = self.istek('ide/Otomatik', user('yap'))
self.assertEqual(self.karar(1, req, 'sohbet-1', 'ide/Otomatik'), 'ide/Otomatik')
for model in ('ide/Ajan', 'ide/codex-gpt-6-astra', 'ide/Chat-Model'):
req = self.istek(model, user('yap'))
self.assertEqual(self.karar(1, req, 'sohbet-1', model), model)
self.assertEqual(req['model'], model)
self.assertEqual(self.kararlar, [])
def test_automatic_choice_is_kept_for_the_tool_steps_of_the_same_turn(self):
@@ -235,7 +240,8 @@ class TurBasi(Ev):
req2 = self.istek('ide/Otomatik', user('kaynaklari karsilastir'), {'type': 'function_call', 'call_id': 'c1'}, arac)
self.assertEqual(self.karar(2, req2, 'sohbet-1', 'ide/Otomatik'), 'ide/Arastirma')
self.assertEqual(len(self.kararlar), 1) # same turn: no second question
req3 = self.istek('ide/Otomatik', user('kaynaklari karsilastir'), arac, user('simdi kodu duzelt'))
son_cevap = {'type': 'message', 'role': 'assistant', 'content': [{'type': 'output_text', 'text': 'Rapor hazir.'}]}
req3 = self.istek('ide/Otomatik', user('kaynaklari karsilastir'), arac, son_cevap, user('simdi kodu duzelt'))
self.cevap = {'hedef': 'Ajan', 'ret': None, 'puan': {'zor': 0.9, 'arastirma': 0.1}}
self.assertEqual(self.karar(3, req3, 'sohbet-1', 'ide/Otomatik'), 'ide/Ajan')
self.assertEqual(len(self.kararlar), 2) # new user message: new decision
@@ -272,9 +278,52 @@ class TurBasi(Ev):
self.settings['autoRoute']['enabled'] = True
self.save()
req = self.istek('ide/Otomatik', user('yap'))
self.assertEqual(self.karar(1, req, 'sohbet-1', 'ide/Otomatik'), 'ide/Otomatik')
# The virtual name never leaves the bridge; nothing is asked while the main switch is off.
self.assertEqual(self.karar(1, req, 'sohbet-1', 'ide/Otomatik'), 'ide/' + self.settings['autoRoute']['targets']['zor'])
self.assertEqual(self.kararlar, [])
def test_a_message_typed_mid_turn_or_a_dot_keeps_the_choice(self):
self.settings['autoRoute']['enabled'] = True
self.save()
self.cevap = {'hedef': 'Arastirma', 'ret': None, 'puan': {'zor': 0.2, 'arastirma': 0.9}}
bas = user('kaynaklari karsilastir')
cagri = {'type': 'function_call', 'call_id': 'c1', 'name': 'shell', 'arguments': '{}'}
cikti = {'type': 'function_call_output', 'call_id': 'c1', 'output': 'ok'}
self.assertEqual(self.karar(1, self.istek('ide/Otomatik', bas), 'sohbet-1', 'ide/Otomatik'), 'ide/Arastirma')
self.cevap = {'hedef': 'Workflow-Kesif', 'ret': None, 'puan': {'zor': 0.1, 'arastirma': 0.1}}
for ek in (user('.'), user('bir de sunu ekle'), {'type': 'compaction_trigger'}):
req = self.istek('ide/Otomatik', bas, cagri, cikti, ek)
self.assertEqual(self.karar(2, req, 'sohbet-1', 'ide/Otomatik'), 'ide/Arastirma', ek)
self.assertEqual(len(self.kararlar), 1)
def test_a_disabled_automatic_model_never_reaches_the_gateway(self):
self.save() # autoRoute off
req = self.istek('ide/Otomatik', user('yap'))
secilen = self.karar(1, req, 'sohbet-1', 'ide/Otomatik')
self.assertEqual(secilen, 'ide/' + self.settings['autoRoute']['targets']['zor'])
self.assertEqual(req['model'], secilen)
self.assertEqual(self.kararlar, [])
self.settings['autoRoute']['enabled'] = True
self.settings['enabled'] = False # main switch off
self.save()
self.assertNotEqual(self.karar(2, self.istek('ide/Otomatik', user('yap')), 'sohbet-1', 'ide/Otomatik'), 'ide/Otomatik')
def test_without_a_conversation_key_the_tool_steps_still_keep_the_choice(self):
self.settings['autoRoute']['enabled'] = True
self.save()
self.cevap = {'hedef': 'Ajan', 'ret': None, 'puan': {'zor': 0.9, 'arastirma': 0.1}}
bas = user('kodu duzelt')
self.assertEqual(self.karar(1, {'model': 'ide/Otomatik', 'input': [bas]}, '', 'ide/Otomatik'), 'ide/Ajan')
cikti = {'type': 'function_call_output', 'call_id': 'c1', 'output': 'ok'}
req = {'model': 'ide/Otomatik', 'input': [bas, {'type': 'function_call', 'call_id': 'c1'}, cikti]}
self.assertEqual(self.karar(2, req, '', 'ide/Otomatik'), 'ide/Ajan')
self.assertEqual(len(self.kararlar), 1)
def test_the_finish_retry_is_used_once_per_turn(self):
self.assertTrue(self.env['bitis_kullan']('tur-1'))
self.assertFalse(self.env['bitis_kullan']('tur-1'))
self.assertTrue(self.env['bitis_kullan']('tur-2'))
def test_turn_memory_is_bounded(self):
self.settings['prefilter']['enabled'] = True
self.save()
@@ -285,15 +334,25 @@ class TurBasi(Ev):
class KatmanPenceresi(unittest.TestCase):
def test_a_layer_too_small_for_the_request_is_skipped(self):
def suzgec(self, bilgi):
logs = []
env = {'IDE_PREFIX': 'ide/', 'IDE_BILGI': {'ide/Katman-2': {'pencere': 1000}, 'ide/Katman-3': {'pencere': 10 ** 6}},
'pencere': pencere, 'log': logs.append}
env = {'IDE_PREFIX': 'ide/', 'IDE_BILGI': bilgi, 'pencere': pencere, 'log': logs.append}
exec(compile(ast.Module(body=[fonksiyon('sigan_katmanlar')], type_ignores=[]), 'kopru.py', 'exec'), env)
return env['sigan_katmanlar'], logs
def test_a_layer_too_small_for_the_request_is_skipped(self):
f, logs = self.suzgec({'ide/Katman-2': {'pencere': 1000, 'kaynak': 'katalog'}, 'ide/Katman-3': {'pencere': 10 ** 6}})
req = {'input': [user('x' * 20000)]}
self.assertEqual(env['sigan_katmanlar'](['Katman-2', 'Katman-3', 'Bilinmeyen'], req, '[1]'), ['Katman-3', 'Bilinmeyen'])
self.assertEqual(f(['Katman-2', 'Katman-3', 'Bilinmeyen'], req, '[1]'), ['Katman-3', 'Bilinmeyen'])
self.assertIn('Katman-2', logs[0])
def test_the_last_layer_and_untrusted_windows_are_never_skipped(self):
req = {'input': [user('x' * 20000)]}
f, _ = self.suzgec({'ide/Katman-2': {'pencere': 1000, 'kaynak': 'katalog'}, 'ide/Katman-3': {'pencere': 1000, 'kaynak': 'katalog'}})
self.assertEqual(f(['Katman-2', 'Katman-3'], req, '[1]'), ['Katman-3']) # last one stays
f, _ = self.suzgec({'ide/Katman-2': {'pencere': 1000, 'kaynak': 'varsayilan'}})
self.assertEqual(f(['Katman-2', 'Katman-3'], req, '[1]'), ['Katman-2', 'Katman-3']) # placeholder window
class BitisDenetimi(Ev):
"""_denetimli_akis with finishCheck: an answer that stops early goes back to the same model once."""
@@ -372,6 +431,17 @@ class BitisDenetimi(Ev):
self.h._denetimli_akis(Yanit(sse('Bitti.')), '[1]', None, None, 'yap', [], Mock(), hedef='Ajan', devam=bozuk)
self.assertIn(b'Bitti.', self.gonderilen())
def test_a_broken_or_refused_retry_delivers_the_approved_first_answer(self):
for ikinci in (sse(), sse('Yapamam.')):
with self.subTest(ikinci=ikinci[:40]):
self.setUp()
self.puanlar = [{'ret': 0.0, 'sart': 0.0, 'bitmedi': 0.9}, {'ret': 0.9, 'sart': 0.0}]
ac = Mock(return_value=(Yanit(sse('katman')), None, '[K1]'))
self.h._denetimli_akis(Yanit(sse('Ilk cevap.')), '[1]', None, None, 'yap', ['Katman-1'], ac,
hedef='Ajan', devam=lambda ad, onceki: (Yanit(ikinci), None, '[devam]'))
self.assertIn(b'Ilk cevap.', self.gonderilen())
ac.assert_not_called()
def test_without_the_stage_the_finish_question_is_not_sent(self):
govdeler = []
@@ -387,9 +457,6 @@ class BitisDenetimi(Ev):
self.assertFalse(jev_denetim.bitmedi_mi(puan))
if __name__ == '__main__':
unittest.main()
class DogrulayiciElemesi(Ev):
def test_off_by_default_and_asked_only_when_enabled(self):
@@ -416,3 +483,68 @@ class DogrulayiciElemesi(Ev):
import io as _io
import karar_sor
self.assertEqual(karar_sor.main(['baska'], _io.StringIO('{}'), _io.StringIO()), 2)
class Inceleme(Ev):
def test_the_correction_channel_needs_an_always_pass_target_while_checks_are_on(self):
self.settings['watchModels'] = ['Chat-Model', 'Ajan']
self.save()
self.assertTrue(jev_denetim.izleme_mi('Chat-Model'))
self.assertFalse(jev_denetim.izleme_mi('Ajan')) # checked target: no sideband
self.settings['enabled'] = False
self.save()
self.assertTrue(jev_denetim.izleme_mi('Ajan'))
def test_a_turn_start_question_does_not_wait_in_the_queue_beyond_its_timeout(self):
self.settings['maxConcurrent'] = 1
self.settings['queueWaitSeconds'] = 3600
self.save()
s = jev_settings.read()[0]
self.assertTrue(jev_settings.acquire(s)) # the only slot is taken
try:
t0 = time.monotonic()
self.assertIsNone(jev_denetim.sorular({'user_request': 'x'}, {'zor': 'q'}, 'k', 1, gonder=lambda g, k: {}))
self.assertLess(time.monotonic() - t0, 3)
finally:
jev_settings.release()
def test_the_prefilter_target_cannot_be_the_automatic_model(self):
self.settings['prefilter']['target'] = self.settings['autoRoute']['model']
self.save()
with self.assertRaises(ValueError):
jev_settings.read()
def test_dify_redirects_are_refused_so_the_key_never_moves(self):
import urllib.error
with self.assertRaises(urllib.error.HTTPError):
karar_politikasi._YonlendirmeYok().redirect_request(
Mock(full_url='https://dify.example/v1/workflows/run'), None, 302, 'Found', {}, 'http://evil.example/')
class IzlemeSuzgeci(unittest.TestCase):
def test_a_huge_frame_passes_through_instead_of_breaking_the_stream(self):
tree_sinif = next(n for n in TREE.body if isinstance(n, ast.ClassDef) and n.name == 'WatchResponse')
from bekci_watch import MetadataFilter
logs = []
env = {'MetadataFilter': MetadataFilter, 'log': logs.append}
exec(compile(ast.Module(body=[tree_sinif], type_ignores=[]), 'kopru.py', 'exec'), env)
buyuk = b'data: ' + b'x' * 300000
parcalar = [buyuk[:200000], buyuk[200000:], b'\n\n', b'']
class Ham:
def read1(self, n):
return parcalar.pop(0)
w = env['WatchResponse'](Ham(), lambda m: None)
cikti = b''
while True:
b = w.read1(65536)
if not b and w.done or not parcalar and not b:
break
cikti += b
self.assertIn(b'x' * 1000, cikti)
self.assertEqual(cikti.count(b'x'), 300000)
self.assertTrue(logs)
if __name__ == '__main__':
unittest.main()
@@ -23,7 +23,7 @@ export interface StagedTransitionSettings {
thresholds: { zor: number; arastirma: number }
targets: { basit: string; zor: string; arastirma: string }
}
finishCheck: { enabled: boolean; question: string; threshold: number; note: string; timeoutSeconds: number }
finishCheck: { enabled: boolean; question: string; threshold: number; note: string }
verifierCheck: { enabled: boolean; question: string; threshold: number }
policy: { source: 'local' | 'dify'; endpoint: string; refreshMinutes: number }
}
@@ -106,5 +106,12 @@ describe('staged transition settings', () => {
expect(() => validateStagedTransition({ ...base, policy: { ...base.policy, endpoint: 'http://dify.example' } })).toThrow()
expect(() => validateStagedTransition({ ...base, policy: { ...base.policy, source: 'web' } })).toThrow()
expect(() => validateStagedTransition({ ...base, watchModels: ['a', 'a'] })).toThrow()
for (const endpoint of ['https:dify.example/v1', 'https:/dify.example', 'https:///dify.example', 'ftp://dify.example']) {
expect(() => validateStagedTransition({ ...base, policy: { ...base.policy, endpoint } })).toThrow()
expect(() => validateStagedTransition({ ...base, endpoint })).toThrow()
}
expect(() =>
validateStagedTransition({ ...base, prefilter: { ...base.prefilter, target: base.autoRoute.model } })
).toThrow()
})
})
+14 -13
View File
@@ -53,13 +53,21 @@ const probability = (n: unknown): n is number => typeof n === 'number' && Number
const integer = (n: unknown, min: number, max: number): n is number =>
typeof n === 'number' && Number.isInteger(n) && n >= min && n <= max
// Same acceptance as the bridge (urllib.parse.urlsplit): the literal https:// prefix and a host. WHATWG URL alone
// also accepts "https:host" and "https:/host", which the bridge rejects.
function https(value: unknown): value is string {
if (!text(value, 2048)) {
if (!text(value, 2048) || !/^https:\/\/[^/?#\s]/i.test(value.trim())) {
return false
}
const url = new URL(value)
let url: URL
return url.protocol === 'https:' && !url.username && !url.password && !url.hash
try {
url = new URL(value.trim())
} catch {
return false
}
return url.protocol === 'https:' && Boolean(url.hostname) && !url.username && !url.password && !url.hash
}
export function validateStagedTransition(input: unknown): StagedTransitionSettings {
@@ -160,7 +168,7 @@ export function validateStagedTransition(input: unknown): StagedTransitionSettin
throw new Error('Invalid automatic routing')
}
if (Object.values(a.targets).some(t => t.trim() === a.model.trim())) {
if (Object.values(a.targets).some(t => t.trim() === a.model.trim()) || p.target.trim() === a.model.trim()) {
throw new Error('Automatic routing cannot target itself')
}
@@ -170,8 +178,7 @@ export function validateStagedTransition(input: unknown): StagedTransitionSettin
typeof f?.enabled !== 'boolean' ||
!text(f.question) ||
!probability(f.threshold) ||
!text(f.note, 4000) ||
!integer(f.timeoutSeconds, 1, 60)
!text(f.note, 4000)
) {
throw new Error('Invalid finish check')
}
@@ -214,13 +221,7 @@ export function validateStagedTransition(input: unknown): StagedTransitionSettin
thresholds: { zor: a.thresholds.zor, arastirma: a.thresholds.arastirma },
targets: { basit: a.targets.basit.trim(), zor: a.targets.zor.trim(), arastirma: a.targets.arastirma.trim() }
},
finishCheck: {
enabled: f.enabled,
question: f.question,
threshold: f.threshold,
note: f.note,
timeoutSeconds: f.timeoutSeconds
},
finishCheck: { enabled: f.enabled, question: f.question, threshold: f.threshold, note: f.note },
verifierCheck: { enabled: c.enabled, question: c.question, threshold: c.threshold },
policy: { source: pol.source, endpoint: pol.endpoint.trim(), refreshMinutes: pol.refreshMinutes }
}