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`, - `jev-settings.json` schema 2 adds `watchModels`, `prefilter`, `autoRoute`, `finishCheck`,
`verifierCheck` and `policy`. A schema 1 file is filled from `jev-settings-defaults.json`. `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 The default chain is `Katman-1 → Katman-2 → Katman-3`; `Katman-3`, `Chat-Model`,
`bekci-sesli-bekci` always pass. `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 - **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 - **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. 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 - **Automatic model** (`autoRoute`): a virtual `ide/<model>` entry ("Otomatik") in the Codex list. A new turn
user message gets one decision call (hard? research?) and goes to the configured combo. Tool steps of (a user message after the previous final answer) gets one decision call (hard? research?) and goes to
the same turn keep that combo, because switching providers mid-turn breaks encrypted history. The the configured combo. Tool steps, messages typed while the turn runs, a lone "." and compaction stay on
user's explicit model choice is never changed by this stage. the same combo, because switching providers mid-turn breaks encrypted history. When the stage is off or
- Both turn-start questions go in one call, bounded by `timeoutSeconds` (default 3 s). fails, the virtual name never reaches the gateway: the last choice or `targets.zor` is used. The user's
- **Layer window filter**: a layer whose context window cannot hold the request is skipped. 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. - **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 If the answer stopped early or claimed success without evidence, it goes back to the same model with
with `finishCheck.note`; the retry is delivered without another finish question. `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 - **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 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`. `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 - **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. 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-1", "to": "Katman-2" },
{ "from": "Katman-2", "to": "Katman-3" } { "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 }, "thresholds": { "ret": 0.5, "sart": 0.7 },
"questions": { "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.", "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, "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.", "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, "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.", "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
}, },
"verifierCheck": { "verifierCheck": {
"enabled": false, "enabled": false,
+20 -12
View File
@@ -38,11 +38,14 @@ SORU = {
_ANAHTAR = {"okundu": False, "deger": ""} _ANAHTAR = {"okundu": False, "deger": ""}
def izleme_mi(hedef): 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: 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: except Exception:
return hedef == "Sesli-Hizli-Zeki" return False
def muaf_mi(hedef): def muaf_mi(hedef):
@@ -125,18 +128,23 @@ def son_istek(req):
return son_istek_sirasi(req)[0] 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): def son_istek_sirasi(req):
"""(istek, girdideki sirasi). Turun arac adimlarinda da ayni sira doner; yoksa ("", -1).""" """(istek, girdideki sirasi). Turun arac adimlarinda da ayni sira doner; yoksa ("", -1)."""
girdi = req.get("input") or [] girdi = req.get("input") or []
for sira in range(len(girdi) - 1, -1, -1): for sira in range(len(girdi) - 1, -1, -1):
oge = girdi[sira] metin = istek_metni(girdi[sira])
if not (isinstance(oge, dict) and oge.get("type", "message") == "message" and oge.get("role") == "user"): if metin:
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("<"):
return metin, sira return metin, sira
return "", -1 return "", -1
@@ -202,7 +210,7 @@ def sorular(durum, soru, key, timeout, gonder=_gonder, settings=None):
return None return None
govde = {"model": settings['model'], "state": durum, govde = {"model": settings['model'], "state": durum,
"questions": {k: {"type": "noul", "instructions": v} for k, v in soru.items()}} "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 return None
try: try:
response = gonder(govde, key, settings, timeout) if gonder is _gonder else gonder(govde, key) 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('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'))): and all(_text((a.get('targets') or {}).get(k), 256) for k in ('basit', 'zor', 'arastirma'))):
raise ValueError('Invalid automatic routing') 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') raise ValueError('Automatic routing cannot target itself')
f = settings['finishCheck'] f = settings['finishCheck']
if not (isinstance(f.get('enabled'), bool) and _text(f.get('question')) and _prob(f.get('threshold')) 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') raise ValueError('Invalid finish check')
v = settings['verifierCheck'] v = settings['verifierCheck']
if not (isinstance(v.get('enabled'), bool) and _text(v.get('question')) and _prob(v.get('threshold'))): 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') record['lastGoodAt'] = previous.get('lastGoodAt') or previous.get('fetchedAt')
elif result.get('ok'): elif result.get('ok'):
record['lastGoodAt'] = record['fetchedAt'] 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') 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 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 global _active
deadline = time.monotonic() + settings['queueWaitSeconds'] deadline = time.monotonic() + (min(settings['queueWaitSeconds'], wait) if wait else settings['queueWaitSeconds'])
with _condition: with _condition:
while settings['maxConcurrent'] and _active >= settings['maxConcurrent']: while settings['maxConcurrent'] and _active >= settings['maxConcurrent']:
remaining = deadline - time.monotonic() 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 copy
import json import json
import time import time
import urllib.error
import urllib.request import urllib.request
import jev_settings import jev_settings
UA = "AITURK-IDE-codex-bridge/karar-politikasi" 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 MAX_BYTES = 64 * 1024
_son = {"zaman": 0.0} _son = {"zaman": 0.0}
@@ -43,7 +53,7 @@ def ayikla(cikti, yerel):
return overrides 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.""" """One fetch. Returns {"ok": bool, "overrides": {...}, "error": str}; never raises."""
if not key: if not key:
return {"ok": False, "error": "Dify key not configured"} 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 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.""" """Fetch when due; store the outcome. Local settings are read without the Dify overlay."""
try: try:
yerel, _ = _yerel_ayar() yerel, _ = _yerel_ayar()
+114 -29
View File
@@ -245,6 +245,7 @@ def load_ide_catalog(ag=True):
for h in set(oto["targets"].values())), for h in set(oto["targets"].values())),
"arac": True, "kaynak": "", "claude": False} "arac": True, "kaynak": "", "claude": False}
model_map[slug] = (IDE_ROUTE, oto["model"], None) model_map[slug] = (IDE_ROUTE, oto["model"], None)
OTO_ADI[0] = oto["model"]
n += 1 n += 1
else: else:
ROUTES.pop(IDE_ROUTE, None) ROUTES.pop(IDE_ROUTE, None)
@@ -263,68 +264,115 @@ def _oto_ayari():
return None return None
return s["autoRoute"] if s["enabled"] and s["autoRoute"]["enabled"] else 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 = {}
ON_KARAR_SINIR = 500 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); # Acilista pencere verisi icin aga cikilmaz (IDE koprunun dinlemeye baslamasini sinirli sure bekler);
# ilk tam yenileme asagidaki dongunun basinda, arka planda yapilir. # ilk tam yenileme asagidaki dongunun basinda, arka planda yapilir.
load_ide_catalog(ag=False) 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): def karar_asamasi(n, req, sohbet, slug):
"""14 Ekim 2026 karar asamalari: "Otomatik" modelin combo secimi (autoRoute) ve normal modelin reddedecegi """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 tahmin edilen isi dogrudan ayardaki katmana verme (prefilter). Karar yalniz yeni turda verilir; ayni turun
arac adimlari ayni karari kullanir (her adimda model degisirse saglayicilar arasi gecmis bozulur). Kapali ayar, adimlari ayni karari kullanir (tur ortasinda model degisirse saglayicilar arasi gecmis bozulur). Kapali ayar,
bos istek ya da karar modeli hatasi bugunku yolu degistirmez (Otomatik icin guclu hedef secilir).""" bos istek ya da karar modeli hatasi bugunku yolu degistirmez. Sanal model adi hicbir yolda gecide gitmez."""
if not slug.startswith(IDE_PREFIX): if not slug.startswith(IDE_PREFIX):
return slug return slug
settings = jev_settings.read()[0] 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"]: if not settings["enabled"]:
return slug return slug
a, p = settings["autoRoute"], settings["prefilter"]
oto = a["enabled"] and slug == IDE_PREFIX + a["model"]
hedef = slug[len(IDE_PREFIX):] hedef = slug[len(IDE_PREFIX):]
ret = p["enabled"] and (oto or not (jev_denetim.muaf_mi(hedef) or hedef == p["target"] ret = p["enabled"] and (sanal or not (jev_denetim.muaf_mi(hedef) or hedef == p["target"]
or hedef in jev_denetim.sonrakiler(p["target"]))) or hedef in jev_denetim.sonrakiler(p["target"])))
if not oto and not ret: if not sanal and not ret:
return slug return slug
istek, sira = jev_denetim.son_istek_sirasi(req) if kayit and kayit[1] == slug and (kayit[0] == tur or not yeni):
# 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:
secilen = kayit[2] secilen = kayit[2]
else: else:
if istek: if istek:
t0 = time.time() 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"] puan = sonuc["puan"]
log(f"[{n} {slug}] tur basi karari ({time.time() - t0:.1f} sn): " 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")) + (", ".join(f"{k}={v:.2f}" for k, v in puan.items()) if puan else "karar modeline sorulamadi"))
else: else:
sonuc = {"hedef": a["targets"]["zor"] if oto else None, "ret": None} sonuc = {"hedef": a["targets"]["zor"] if sanal else None, "ret": None}
if oto: if sanal:
hedef = sonuc["hedef"] hedef = sonuc["hedef"]
secilen = IDE_PREFIX + hedef secilen = IDE_PREFIX + hedef
if (p["enabled"] and sonuc["ret"] and not jev_denetim.muaf_mi(hedef) and hedef != p["target"] 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"])): and hedef not in jev_denetim.sonrakiler(p["target"])):
log(f"[{n} {slug}] ret on kontrolu: normal model reddedebilir, adim {p['target']} katmanindan basliyor") log(f"[{n} {slug}] ret on kontrolu: normal model reddedebilir, adim {p['target']} katmanindan basliyor")
secilen = IDE_PREFIX + p["target"] secilen = IDE_PREFIX + p["target"]
if sohbet: req["model"] = secilen
ON_KARAR[sohbet] = (tur, slug, 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: 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: if secilen != slug:
log(f"[{n} {slug}] karar: bu tur {secilen} modeline gidiyor") log(f"[{n} {slug}] karar: bu tur {secilen} modeline gidiyor")
req["model"] = secilen req["model"] = secilen
return 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): 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, [] tahmin, sonuc = None, []
for ad in sira: for i, ad in enumerate(sira):
p = (IDE_BILGI.get(IDE_PREFIX + ad) or {}).get("pencere") bilgi = IDE_BILGI.get(IDE_PREFIX + ad) or {}
if p: p = bilgi.get("pencere")
if p and i < len(sira) - 1 and pencere.bekci_calisir(bilgi.get("kaynak"), p):
if tahmin is None: if tahmin is None:
tahmin = pencere.tahmini_token(req, 2.0) tahmin = pencere.tahmini_token(req, 2.0)
if tahmin > int(p * 0.90): if tahmin > int(p * 0.90):
@@ -1059,6 +1107,7 @@ class H(http.server.BaseHTTPRequestHandler):
self._akis_basligi(resp, resp.status) self._akis_basligi(resp, resp.status)
kalan, son_yazi, son_govde = list(sira), time.time(), b"" kalan, son_yazi, son_govde = list(sira), time.time(), b""
devam_kullanildi = False # finishCheck: son cevap ayni modele en fazla bir kez geri verilir 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): def w(b):
if b: if b:
@@ -1129,6 +1178,7 @@ class H(http.server.BaseHTTPRequestHandler):
yeni_devam = None yeni_devam = None
log(f"{tag} bitis denetimi: devam istegi acilamadi, cevap oldugu gibi geciyor ({type(e).__name__}: {e})") log(f"{tag} bitis denetimi: devam istegi acilamadi, cevap oldugu gibi geciyor ({type(e).__name__}: {e})")
if yeni_devam is not None: if yeni_devam is not None:
yedek = (parcalar, rewriter, tag)
resp, rewriter, tag = yeni_devam resp, rewriter, tag = yeni_devam
log(f"{tag} bitis denetimi: is bitmeden bitti denmis, ayni modele bir kez geri verildi") log(f"{tag} bitis denetimi: is bitmeden bitti denmis, ayni modele bir kez geri verildi")
continue continue
@@ -1138,6 +1188,11 @@ class H(http.server.BaseHTTPRequestHandler):
gecmedi = True gecmedi = True
log(f"{tag} katman denetimi: bos adim (yazi da komut da yok), siradakine geciliyor: " log(f"{tag} katman denetimi: bos adim (yazi da komut da yok), siradakine geciliyor: "
f"{b''.join(parcalar)[-200:]!r}") 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 yeni = None
while gecmedi and kalan and yeni is None: while gecmedi and kalan and yeni is None:
ad = kalan.pop(0) ad = kalan.pop(0)
@@ -1670,6 +1725,12 @@ class H(http.server.BaseHTTPRequestHandler):
slug = karar_asamasi(n, req, sohbet, slug) slug = karar_asamasi(n, req, sohbet, slug)
except Exception as e: except Exception as e:
log(f"[{n} {slug}] karar asamasi atlandi: {type(e).__name__}: {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) 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"])): 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 # 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 # secili model baska bir rotada olsa bile. Gecit yoksa denetim yapilmaz. Ozet isteginde ve aracsiz
# (rewriter yok) yolda kapalidir. # (rewriter yok) yolda kapalidir.
jev_rota = ROUTES.get(ide_route) 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()) try:
jev_sira = jev_denetim.sonrakiler(target) if jev_etkin else [] 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: if jev_sira:
try: try:
jev_sira = sigan_katmanlar(jev_sira, req, tag) jev_sira = sigan_katmanlar(jev_sira, req, tag)
except Exception as e: except Exception as e:
log(f"{tag} katman pencere kontrolu atlandi: {type(e).__name__}: {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): def jev_ac(ad):
katman_req = json.loads(data) katman_req = json.loads(data)
@@ -1883,9 +1949,12 @@ class H(http.server.BaseHTTPRequestHandler):
f"[{n} {slug}=>{ad}]") f"[{n} {slug}=>{ad}]")
def jev_devam(ad, onceki): 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: if ad == target and route_name != ide_route:
raise ValueError("hedef AITURK gecidinde degil") 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 = json.loads(data)
devam_req["model"] = ad devam_req["model"] = ad
devam_req.setdefault("input", []).extend([ 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), return (yanit, SseRewriter(custom, hedef=ad, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren, tool_names=tool_names),
f"[{n} {slug}=>{ad} devam]") f"[{n} {slug}=>{ad} devam]")
try: try:
bitis_turu = tur_bilgisi(req, sohbet)[1]
jev_bitis = jev_devam if jev_etkin and jev_settings.read()[0]["finishCheck"]["enabled"] else None 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: except Exception:
jev_bitis = None jev_bitis = None
# A 9Router combo (Fusion-*, no provider prefix) runs a whole panel upstream. Once it may # 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): def __init__(self, response, accept):
self.response, self.filter = response, MetadataFilter(accept) self.response, self.filter = response, MetadataFilter(accept)
self.done = False self.done = False
self.gecis = False # suzgec tasti (cok buyuk cerceve): kalan akis oldugu gibi gecer
def __getattr__(self, name): def __getattr__(self, name):
return getattr(self.response, name) return getattr(self.response, name)
def read1(self, size): def read1(self, size):
if self.gecis:
return self.response.read1(size)
while not self.done: while not self.done:
raw = self.response.read1(size) raw = self.response.read1(size)
if raw: 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 if data: return data
else: else:
self.done = True self.done = True
@@ -5,6 +5,7 @@ import copy
import hashlib import hashlib
import io import io
import json import json
import threading
import tempfile import tempfile
import time import time
import unittest import unittest
@@ -205,10 +206,12 @@ class TurBasi(Ev):
sahte_denetim.son_istek = jev_denetim.son_istek sahte_denetim.son_istek = jev_denetim.son_istek
self.cevap = {'hedef': None, 'ret': None, 'puan': None} self.cevap = {'hedef': None, 'ret': None, 'puan': None}
sahte_denetim.son_istek_sirasi = jev_denetim.son_istek_sirasi 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, 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, 'jev_denetim': sahte_denetim, 'model_gecis': model_gecis, 'hashlib': hashlib, 'time': time,
'log': self.logs.append} 'json': json, 'KARAR_KILIDI': threading.Lock(), 'BITIS_TURLARI': {}, 'log': self.logs.append}
exec(compile(ast.Module(body=[fonksiyon('karar_asamasi')], type_ignores=[]), 'kopru.py', 'exec'), self.env) 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'] self.karar = self.env['karar_asamasi']
def on_karar(self, istek, oto, ret, key): 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): def test_everything_off_changes_nothing_and_asks_nothing(self):
self.save() self.save()
req = self.istek('ide/Otomatik', user('yap')) for model in ('ide/Ajan', 'ide/codex-gpt-6-astra', 'ide/Chat-Model'):
self.assertEqual(self.karar(1, req, 'sohbet-1', 'ide/Otomatik'), 'ide/Otomatik') 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, []) self.assertEqual(self.kararlar, [])
def test_automatic_choice_is_kept_for_the_tool_steps_of_the_same_turn(self): 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) 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(self.karar(2, req2, 'sohbet-1', 'ide/Otomatik'), 'ide/Arastirma')
self.assertEqual(len(self.kararlar), 1) # same turn: no second question 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.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(self.karar(3, req3, 'sohbet-1', 'ide/Otomatik'), 'ide/Ajan')
self.assertEqual(len(self.kararlar), 2) # new user message: new decision self.assertEqual(len(self.kararlar), 2) # new user message: new decision
@@ -272,9 +278,52 @@ class TurBasi(Ev):
self.settings['autoRoute']['enabled'] = True self.settings['autoRoute']['enabled'] = True
self.save() self.save()
req = self.istek('ide/Otomatik', user('yap')) 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, []) 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): def test_turn_memory_is_bounded(self):
self.settings['prefilter']['enabled'] = True self.settings['prefilter']['enabled'] = True
self.save() self.save()
@@ -285,15 +334,25 @@ class TurBasi(Ev):
class KatmanPenceresi(unittest.TestCase): class KatmanPenceresi(unittest.TestCase):
def test_a_layer_too_small_for_the_request_is_skipped(self): def suzgec(self, bilgi):
logs = [] logs = []
env = {'IDE_PREFIX': 'ide/', 'IDE_BILGI': {'ide/Katman-2': {'pencere': 1000}, 'ide/Katman-3': {'pencere': 10 ** 6}}, env = {'IDE_PREFIX': 'ide/', 'IDE_BILGI': bilgi, 'pencere': pencere, 'log': logs.append}
'pencere': pencere, 'log': logs.append}
exec(compile(ast.Module(body=[fonksiyon('sigan_katmanlar')], type_ignores=[]), 'kopru.py', 'exec'), env) 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)]} 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]) 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): class BitisDenetimi(Ev):
"""_denetimli_akis with finishCheck: an answer that stops early goes back to the same model once.""" """_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.h._denetimli_akis(Yanit(sse('Bitti.')), '[1]', None, None, 'yap', [], Mock(), hedef='Ajan', devam=bozuk)
self.assertIn(b'Bitti.', self.gonderilen()) 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): def test_without_the_stage_the_finish_question_is_not_sent(self):
govdeler = [] govdeler = []
@@ -387,9 +457,6 @@ class BitisDenetimi(Ev):
self.assertFalse(jev_denetim.bitmedi_mi(puan)) self.assertFalse(jev_denetim.bitmedi_mi(puan))
if __name__ == '__main__':
unittest.main()
class DogrulayiciElemesi(Ev): class DogrulayiciElemesi(Ev):
def test_off_by_default_and_asked_only_when_enabled(self): def test_off_by_default_and_asked_only_when_enabled(self):
@@ -416,3 +483,68 @@ class DogrulayiciElemesi(Ev):
import io as _io import io as _io
import karar_sor import karar_sor
self.assertEqual(karar_sor.main(['baska'], _io.StringIO('{}'), _io.StringIO()), 2) 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 } thresholds: { zor: number; arastirma: number }
targets: { basit: string; zor: string; arastirma: string } 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 } verifierCheck: { enabled: boolean; question: string; threshold: number }
policy: { source: 'local' | 'dify'; endpoint: string; refreshMinutes: 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, endpoint: 'http://dify.example' } })).toThrow()
expect(() => validateStagedTransition({ ...base, policy: { ...base.policy, source: 'web' } })).toThrow() expect(() => validateStagedTransition({ ...base, policy: { ...base.policy, source: 'web' } })).toThrow()
expect(() => validateStagedTransition({ ...base, watchModels: ['a', 'a'] })).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 => const integer = (n: unknown, min: number, max: number): n is number =>
typeof n === 'number' && Number.isInteger(n) && n >= min && n <= max 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 { function https(value: unknown): value is string {
if (!text(value, 2048)) { if (!text(value, 2048) || !/^https:\/\/[^/?#\s]/i.test(value.trim())) {
return false 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 { export function validateStagedTransition(input: unknown): StagedTransitionSettings {
@@ -160,7 +168,7 @@ export function validateStagedTransition(input: unknown): StagedTransitionSettin
throw new Error('Invalid automatic routing') 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') throw new Error('Automatic routing cannot target itself')
} }
@@ -170,8 +178,7 @@ export function validateStagedTransition(input: unknown): StagedTransitionSettin
typeof f?.enabled !== 'boolean' || typeof f?.enabled !== 'boolean' ||
!text(f.question) || !text(f.question) ||
!probability(f.threshold) || !probability(f.threshold) ||
!text(f.note, 4000) || !text(f.note, 4000)
!integer(f.timeoutSeconds, 1, 60)
) { ) {
throw new Error('Invalid finish check') 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 }, 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() } targets: { basit: a.targets.basit.trim(), zor: a.targets.zor.trim(), arastirma: a.targets.arastirma.trim() }
}, },
finishCheck: { finishCheck: { enabled: f.enabled, question: f.question, threshold: f.threshold, note: f.note },
enabled: f.enabled,
question: f.question,
threshold: f.threshold,
note: f.note,
timeoutSeconds: f.timeoutSeconds
},
verifierCheck: { enabled: c.enabled, question: c.question, threshold: c.threshold }, verifierCheck: { enabled: c.enabled, question: c.question, threshold: c.threshold },
policy: { source: pol.source, endpoint: pol.endpoint.trim(), refreshMinutes: pol.refreshMinutes } policy: { source: pol.source, endpoint: pol.endpoint.trim(), refreshMinutes: pol.refreshMinutes }
} }