fix(codex-bridge): remaining audit findings (routing, trimming, retries, model list fallback, logging)

Second batch from the ten read-only audits; 126 bridge tests. Among them: a spoken number
("opus beş beş") resolves, a used route no longer expires after 7 days, the local reply says how to
go back, an unrouted 9r/ name gets a local 503 instead of going to chatgpt.com, a combo request is not
resent after the upstream closed it, the last model list is served when chatgpt.com is unreachable,
trimming keeps the real last user message, Claude-member combos use the Claude estimate, CRLF SSE is
handled, log lines carry the date and the status endpoint reports a code fingerprint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
yilsemandClaude Opus 5.5 committed 2026-10-11 01:50:08 +03:00
1 parent e2793fa38f
commit 7c0dc467d5
10 files changed
+731 -101

No files matched your search

+21 -5
View File
@@ -13,10 +13,10 @@ Codex depends on it while `openai_base_url` points here.
The public `/models` catalog supplies every OmniRoute chat model; a member does
not need a private local model allowlist. `/router/catalog` supplies only the
`kind` metadata used for sorting, never additional model IDs. Direct 9Router
entries and internal 9Router combos are excluded. Combos sort A–Z first, then
individual models by provider and name, including OpenAI subscription models.
Codex labels the first visible row `isDefault`; a saved model selection remains
unchanged. A failed or empty refresh keeps the last successful catalog.
entries and internal 9Router combos are excluded. Codex's own subscription models
stay first, in their own order and under their own names; added entries follow
them: combos A–Z, then individual models by provider and name. A saved model
selection remains unchanged. A failed or empty refresh keeps the last successful catalog.
- `ozet_kopru.py`: conversation summaries are produced as plain text, so a summary made
by one provider stays readable after the user switches to another.
- `gemini_context.py`, `resim_context.py`: request fixes for routed models.
@@ -45,6 +45,22 @@ for the chat, i.e. until the user picks another one in the list.
Setting: `sesle_model` (default on).
Audit follow-up (11 Oct 2026, same version number; `GET .../status` now also returns `kod`, the first 8 hex digits
of `bridge.py`'s SHA-256, to tell builds apart):
- Numbers said as words ("opus beş beş", "üç nokta sekiz") and a trailing "ve devam et" no longer drop the command.
- Picking the same model again in the list does not clear a spoken route (Codex sends the same name), so the
bridge's answer says how to go back. A route that is in use no longer expires after 7 days.
- A command is turned into a "model was changed" note only if it took effect; a mid-turn note also reaches a
subscription model when hook messages follow it.
- Combos: a Claude member selects the stricter token estimate; one unknown member no longer switches the overflow
guard off for a known small member; a combo dropped by the peer after the request was sent is not resent.
- An old `9r/…` name without a route gets a local 503 instead of going to chatgpt.com; upstream error bodies
reach Codex whole, with `Retry-After`.
- The last model list is also served when chatgpt.com cannot be reached at all, and is cached when the answer
was gzip-compressed. The old `display_map` setting is no longer read (subscription models keep their names).
- Log lines carry the date; the access-token tail is no longer logged.
### Work handoff instruction (prepared, off by default)
`is_hatti.py`: when `aiturk_hat` is enabled and has a `komut`, the bridge prepends a developer message ("small work
@@ -72,4 +88,4 @@ Not changed: subscription models, their order and names, and everything on the v
Settings in `bridge.json` (all optional): `dis_arac_modu`, `tasma_bekcisi`, `yarim_cevap`, `ozet_kopru.dongu_siniri`, `ozet_kopru.dongu_art_arda`.
A lone `.` sent as the user's message (used to apply a model change during a voice session) is answered by the bridge itself with "<model> hazır." and never reaches a model (`nokta_karsila`, default on). Targets that reject tool calls are remembered (`aracsiz-modeller.json`) and marked "araçsız" in the picker.
A lone `.` sent as the user's message (used to apply a model change during a voice session) is answered by the bridge itself with "<model> seçildi." (the target is not probed, so it does not say "ready") and never reaches a model (`nokta_karsila`, default on). Targets that reject tool calls are remembered (`aracsiz-modeller.json`) and marked "araçsız" in the picker.
+140 -76
View File
@@ -32,6 +32,11 @@ import is_hatti
SURUM = "8"
HERE = os.path.dirname(os.path.abspath(__file__))
try:
with open(os.path.abspath(__file__), "rb") as _f:
KOD = hashlib.sha256(_f.read()).hexdigest()[:8] # ayni surum numarasi altindaki farkli kodu ayirt eder
except OSError:
KOD = ""
# Codex sends request bodies zstd-compressed. Python gained zstd in 3.14; for older
# interpreters the matching `zstandard` build ships next to this file.
@@ -97,8 +102,13 @@ def is_timeout(error):
"""True for a socket/read timeout, also when urllib wraps it in URLError."""
reason = getattr(error, "reason", None)
return isinstance(error, TimeoutError) or isinstance(reason, TimeoutError)
def kopma_mi(error):
"""Baglanti kurulduktan sonra uzak uc kapatti ya da sifirladi (yanit basliklari gelmeden): istek ulasmis ve
is baslamis olabilir. Baglanti hic kurulamadiysa (reddedildi, DNS) False."""
reason = getattr(error, "reason", None)
turler = (ConnectionResetError, ConnectionAbortedError, http.client.IncompleteRead) # RemoteDisconnected dahil
return isinstance(error, turler) or isinstance(reason, turler)
DEBUG = bool(CFG.get("debug", False))
DISPLAY_MAP = CFG.get("display_map", {})
UA = CFG.get("user_agent", "AstraBridge/1.0 (codex)")
# Bu metinleri iceren hatalar kalicidir; yeniden denemek yerine hemen Codex'e iletilir.
NO_RETRY_PATTERNS = CFG.get("no_retry_patterns", ["All fusion panel models failed", "model not found", "does not exist", "insufficient_balance", "allowance exhausted"])
@@ -108,7 +118,7 @@ lock = threading.Lock()
seq = [0]
def log(msg):
line = time.strftime("%H:%M:%S ") + msg
line = time.strftime("%Y-%m-%d %H:%M:%S ") + msg
print(line, flush=True)
with lock:
with open(os.path.join(LOGDIR, "bridge.log"), "a", encoding="utf-8") as f:
@@ -202,7 +212,7 @@ def load_ide_catalog(ag=True):
"clone_from": clone, "priority": 300 + n, "reasoning_levels": levels,
"kind": g["kind"], "context_window": g.get("pencere")})
IDE_BILGI[slug] = {"pencere": g.get("pencere"), "gorsel": bool(g.get("gorsel")), "arac": g.get("arac"),
"kaynak": g.get("pencere_kaynak") or ""}
"kaynak": g.get("pencere_kaynak") or "", "claude": bool(g.get("claude"))}
model_map[slug] = (IDE_ROUTE, g["id"], None)
n += 1
else:
@@ -251,7 +261,7 @@ def save_last_models(acct, hdrs, body):
yol = _cache_path(acct)
veri = {"acct": acct, "headers": hdrs, "at": time.strftime("%Y-%m-%d %H:%M:%S"),
"body": body.decode("utf-8")}
gecici = yol + ".tmp"
gecici = "%s.%d.tmp" % (yol, threading.get_ident()) # ayni hesabin iki es zamanli kaydi carpismasin
with open(gecici, "w", encoding="utf-8") as f:
json.dump(veri, f, ensure_ascii=False)
os.replace(gecici, yol) # yarim dosya birakmamak icin
@@ -529,34 +539,47 @@ class SseRewriter:
d = {"type": "error", "error": {"message": str(d.get("message")), "type": str(d.get("code") or ""),
"status_code": d.get("status_code")}}
if t == "error" and isinstance(d.get("error"), dict):
# Gecit kalici hatayi (kredi/yetki) "event: error" ile bildirip akisi kapatiyor; Codex bunu tanimaz,
# 5 kez yeniden baglanir ve "stream closed" der (10 Ekim 2026: "upstream 402"). Kalici olanlar Codex'in
# tanidigi bicime cevrilir; gecici olanlar oldugu gibi birakilir ki Codex yeniden denesin.
metin = str(d["error"].get("message") or d["error"].get("type") or "")
if pencere.aracsiz_mi(metin):
if self.aracsiz is not None:
self.aracsiz(self.hedef)
return self._gorunur_hata("Bu model araç çağrısını desteklemiyor; Codex'te çalışamaz. Başka bir model seçin.")
if self.dolu and pencere.kapali_hata_mi(metin):
# Pencereye yakin istekte gecidin nedensiz 400'u: buyuk olasilikla sigmadi. Codex'e sessiz kopma
# yerine anlasilir hata gider (ogrenme yapilmaz; neden kesin degil).
return [{"type": "response.failed", "response": {
"id": "resp_astrabridge_hata", "object": "response", "status": "failed", "output": [],
"error": {"code": "context_length_exceeded", "message":
"Sohbet seçili modelin bağlam penceresine sığmadı. Sohbeti sıkıştırın ya da daha büyük pencereli bir model seçin."}}}]
if pencere.tasma_mi(metin):
if self.tasma is not None:
self.tasma(self.hedef, metin)
return [{"type": "response.failed", "response": {
"id": "resp_astrabridge_hata", "object": "response", "status": "failed", "output": [],
"error": {"code": "context_length_exceeded", "message": metin[:300]}}}]
if re.search(r"(?<![0-9])(401|402|403)(?![0-9])|credit|quota|billing|payment|unauthori[sz]ed|forbidden", metin, re.I):
neden = "kredi ya da kota" if re.search(r"402|credit|quota|billing|payment", metin, re.I) else "yetki"
return self._gorunur_hata(f"Seçili model şu an kullanılamıyor ({neden} sorunu). Başka bir model seçin. "
f"Ayrıntı: {metin[:200]}")
if pencere.kapali_hata_mi(metin):
return self._gorunur_hata("Seçili model isteği reddetti ve nedenini bildirmedi. Başka bir model seçip "
"yeniden deneyin.")
cevrilen = self._hata_cevir(d)
if cevrilen is not None:
# Bekleyen mesajin bitis olayi hatadan SONRA cikmasin: once o birakilir.
once = [self._birak("commentary" if self.arac_goruldu else "final_answer")] if self.bekleyen is not None else []
return once + cevrilen
return self._sirala_normal(d, t)
def _hata_cevir(self, d):
"""Gecidin "event: error" olayi -> Codex'in tanidigi olaylar; None: gecici hata, oldugu gibi kalir."""
# Gecit kalici hatayi (kredi/yetki) "event: error" ile bildirip akisi kapatiyor; Codex bunu tanimaz,
# 5 kez yeniden baglanir ve "stream closed" der (10 Ekim 2026: "upstream 402"). Kalici olanlar Codex'in
# tanidigi bicime cevrilir; gecici olanlar oldugu gibi birakilir ki Codex yeniden denesin.
metin = str(d["error"].get("message") or d["error"].get("type") or "")
if pencere.aracsiz_mi(metin):
if self.aracsiz is not None:
self.aracsiz(self.hedef)
return self._gorunur_hata("Bu model araç çağrısını desteklemiyor; Codex'te çalışamaz. Başka bir model seçin.")
if self.dolu and pencere.kapali_hata_mi(metin):
# Pencereye yakin istekte gecidin nedensiz 400'u: buyuk olasilikla sigmadi. Codex'e sessiz kopma
# yerine anlasilir hata gider (ogrenme yapilmaz; neden kesin degil).
return [{"type": "response.failed", "response": {
"id": "resp_astrabridge_hata", "object": "response", "status": "failed", "output": [],
"error": {"code": "context_length_exceeded", "message":
"Geçit isteği reddetti ve nedenini bildirmedi. Sohbet seçili modelin bağlam penceresine çok yakın; "
"sığmamış olabilir. Sohbeti sıkıştırın ya da daha büyük pencereli bir model seçin."}}}]
if pencere.tasma_mi(metin):
if self.tasma is not None:
self.tasma(self.hedef, metin)
return [{"type": "response.failed", "response": {
"id": "resp_astrabridge_hata", "object": "response", "status": "failed", "output": [],
"error": {"code": "context_length_exceeded", "message": metin[:300]}}}]
if re.search(r"(?<![0-9])(401|402|403)(?![0-9])|credit|quota|billing|payment|unauthori[sz]ed|forbidden", metin, re.I):
neden = "kredi ya da kota" if re.search(r"402|credit|quota|billing|payment", metin, re.I) else "yetki"
return self._gorunur_hata(f"Seçili model şu an kullanılamıyor ({neden} sorunu). Başka bir model seçin. "
f"Ayrıntı: {metin[:200]}")
if pencere.kapali_hata_mi(metin):
return self._gorunur_hata("Seçili model isteği reddetti ve nedenini bildirmedi. Başka bir model seçip "
"yeniden deneyin.")
return None
def _sirala_normal(self, d, t):
item = d.get("item") if isinstance(d.get("item"), dict) else None
if item and item.get("type") in ARAC_OGELERI:
self.arac_goruldu = True
@@ -613,6 +636,8 @@ class SseRewriter:
return d
def feed(self, chunk):
self.buf += chunk
if b"\r" in self.buf:
self.buf = self.buf.replace(b"\r\n", b"\n") # CRLF ile ayrilan SSE de cevrilir
out = b""
while b"\n\n" in self.buf:
block, self.buf = self.buf.split(b"\n\n", 1)
@@ -624,6 +649,8 @@ class SseRewriter:
d = json.loads(b"\n".join(lines[i][5:].strip() for i in data_idx))
except Exception:
out += block + b"\n\n"; continue
if not isinstance(d, dict):
out += block + b"\n\n"; continue # "data: null" gibi govde akisi kesmez, oldugu gibi gecer
d = self._fix(d)
if d is None:
continue
@@ -757,7 +784,10 @@ class H(http.server.BaseHTTPRequestHandler):
if "chunked" in (self.headers.get("Transfer-Encoding") or "").lower():
out = bytearray()
while True:
line = self.rfile.readline().strip()
raw = self.rfile.readline()
if not raw:
break # baglanti kapandi (EOF): sonsuz donguye girme
line = raw.strip()
if not line:
continue
size = int(line.split(b";")[0], 16)
@@ -856,7 +886,7 @@ class H(http.server.BaseHTTPRequestHandler):
self.command = "GET"
if self.command == "GET" and path.rstrip("/").endswith("/status"):
durum, _, sayi = (IDE_DURUM[0] or "kapali, 0 model").partition(", ")
return self._send_json(200, {"ok": ZSTD_OK, "surum": SURUM, "zstd": ZSTD_OK, "ide_durum": durum,
return self._send_json(200, {"ok": ZSTD_OK, "surum": SURUM, "kod": KOD, "zstd": ZSTD_OK, "ide_durum": durum,
"ide_model": int((sayi.split() or ["0"])[0]),
"ozet": bool((CFG.get("ozet_kopru") or {}).get("enabled", False))})
if self.command == "POST" and path.rstrip("/").endswith("/stop"):
@@ -882,7 +912,7 @@ class H(http.server.BaseHTTPRequestHandler):
return self._control(path)
if DEBUG:
log(f"[{n} giris {self.command} {path[:70]}] cl={self.headers.get('Content-Length')} enc={self.headers.get('Content-Encoding')} govde={len(body)}")
if self.command == "POST" and path.rstrip("/").endswith("/responses"):
if self.command == "POST" and path.split("?", 1)[0].rstrip("/").endswith("/responses"):
return self._responses(n, path, body)
return self._passthrough(n, path, body)
@@ -900,23 +930,19 @@ class H(http.server.BaseHTTPRequestHandler):
except urllib.error.HTTPError as e:
resp = e; status = e.code
except Exception as e:
log(f"{tag} HATA {e}"); return self._send_json(502, {"error": {"message": f"bridge passthrough: {e}"}})
log(f"{tag} HATA {e}")
# Ag yokken de model secici bosalmasin: chatgpt.com'a hic ulasilamazsa son basarili liste verilir.
if self.command == "GET" and "/models" in path and self._son_listeyi_ver(tag, self.headers.get("chatgpt-account-id") or "-"):
return
return self._send_json(502, {"error": {"message": f"bridge passthrough: {e}"}})
if self.command == "GET" and "/models" in path:
acct = self.headers.get("chatgpt-account-id") or "-"
tok = (self.headers.get("Authorization") or "")[-8:]
if status == 200:
out = self._models(resp, tag)
return out
body = resp.read()[:300]
log(f"{tag} chatgpt.com {status} verdi (hesap={acct} token=..{tok}) govde={body[:160]!r}")
cached = LAST_MODELS.get(acct)
if cached:
self.send_response(200)
for k, v in cached["headers"]:
self.send_header(k, v)
self.send_header("Content-Length", str(len(cached["body"])))
self.end_headers(); self.wfile.write(cached["body"])
log(f"{tag} son basarili liste verildi ({len(cached['body'])} bayt, {cached['at']})")
log(f"{tag} chatgpt.com {status} verdi (hesap={acct}) govde={body[:160]!r}")
if self._son_listeyi_ver(tag, acct):
return
self.send_response(status)
self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(body)))
@@ -941,6 +967,19 @@ class H(http.server.BaseHTTPRequestHandler):
log(f"{tag} sesli on hazirlayici baslatilamadi: {type(e).__name__}")
self._stream(resp, status, tag, rewriter, izle)
def _son_listeyi_ver(self, tag, acct):
"""Bu hesabin son basarili model listesini gonderir. True: gonderildi."""
cached = LAST_MODELS.get(acct)
if not cached:
return False
self.send_response(200)
for k, v in cached["headers"]:
self.send_header(k, v)
self.send_header("Content-Length", str(len(cached["body"])))
self.end_headers(); self.wfile.write(cached["body"])
log(f"{tag} son basarili liste verildi ({len(cached['body'])} bayt, {cached['at']})")
return True
def _openai_ozet_coz(self, n, oge, ozet_cfg):
"""OpenAI'nin sifreli ozetini bir kez OpenAI'ye duz metne dokturur; sonucu diske onbellekler (icerik gunluge yazilmaz)."""
klasor = os.path.join(LOGDIR, "ozet-onbellek")
@@ -1006,12 +1045,11 @@ class H(http.server.BaseHTTPRequestHandler):
data = json.loads(raw)
models = data.get("models", [])
changed = 0
for m in models:
new = DISPLAY_MAP.get(m.get("slug"))
if new:
m["display_name"] = new; changed += 1
# Ozgun abonelik modellerinin adi, sirasi ve yeri degistirilmez (eski "display_map" ayari artik okunmaz).
by_slug = {m.get("slug"): m for m in models}
for x in EXTRA_MODELS:
ozgun_kimlikler = set(by_slug)
ekler = EXTRA_MODELS # tek kopya: liste hazirlanirken katalog yenilenirse sira ve icerik tutarli kalir
for x in ekler:
if x["slug"] in by_slug:
continue
base = by_slug.get(x.get("clone_from") or "gpt-6-astra") or (models[0] if models else {})
@@ -1051,13 +1089,14 @@ class H(http.server.BaseHTTPRequestHandler):
if not m.get("base_instructions") and not has_tmpl:
m.update(saved)
models.append(m); changed += 1
if EXTRA_MODELS:
ide_catalog.sirala_codex(models, EXTRA_MODELS)
if ekler:
ide_catalog.sirala_codex(models, ekler, ozgun_kimlikler)
out = json.dumps(data, ensure_ascii=False).encode("utf-8")
except Exception as e:
log(f"{tag} model listesi degistirilemedi: {e}")
out = raw; changed = 0
raw_len = len(out)
duz = out # sikistirilmamis govde: yedege bu yazilir
catalog_etag = 'W/"astrabridge-' + hashlib.sha256(out).hexdigest()[:24] + '"'
enc_out = None
if "gzip" in (self.headers.get("Accept-Encoding") or "").lower() and changed:
@@ -1070,15 +1109,16 @@ class H(http.server.BaseHTTPRequestHandler):
if kl in ("etag", "x-models-etag") and changed:
v = catalog_etag
hdrs_out.append((k, v))
if enc_out:
hdrs_out.append(("Content-Encoding", enc_out))
if changed and not enc_out:
if changed:
# Yedek her zaman sikistirilmamis govdeyle tutulur (istemci gzip istese de).
acct_ = self.headers.get("chatgpt-account-id") or "-"
onceki = LAST_MODELS.get(acct_)
LAST_MODELS[acct_] = {"headers": hdrs_out, "body": out, "at": time.strftime("%H:%M:%S")}
LAST_MODELS[acct_] = {"headers": list(hdrs_out), "body": duz, "at": time.strftime("%Y-%m-%d %H:%M:%S")}
# Liste 4 dakikada bir isteniyor; sadece icerik degistiyse diske yaz.
if not onceki or onceki.get("body") != out:
save_last_models(acct_, hdrs_out, out)
if not onceki or onceki.get("body") != duz:
save_last_models(acct_, list(hdrs_out), duz)
if enc_out:
hdrs_out.append(("Content-Encoding", enc_out))
self.send_response(200)
for k, v in hdrs_out:
self.send_header(k, v)
@@ -1120,6 +1160,7 @@ class H(http.server.BaseHTTPRequestHandler):
return soru
model_gecis.metni_degistir(req, sira, "[Köprü notu: kullanıcı model değiştirmek istedi ama ad belirsizdi; "
"model değişmedi. İşine devam et; cevabının sonunda şunu ilet: " + soru + "]")
self.komut_govdeyi_degistirdi = True
return None
hedef = sonuc["slug"]
if hedef.startswith("ide/") and MODEL_MAP.get(hedef) is None and "aiturk-ide" not in ROUTES:
@@ -1131,9 +1172,12 @@ class H(http.server.BaseHTTPRequestHandler):
return None if ortada else f"Sohbetin modeli zaten {sonuc['ad']}."
if ortada:
model_gecis.metni_degistir(req, sira, model_gecis.devir_notu(sonuc["ad"], model_adi(etkin)))
self.komut_govdeyi_degistirdi = True
return None
# Listede ayni modeli yeniden secmek yonlendirmeyi kaldirmaz (Codex ayni adi gonderir); geri donus yolu soylenir.
donus = f" Listede seçili modele dönmek için \"modeli {model_adi(gelen)} yap\" de." if hedef != gelen else ""
return (f"Sohbetin modeli artık {sonuc['ad']}. Önceki model {model_adi(etkin)} idi. "
f"Bundan sonraki işleri {sonuc['ad']} yapacak; hangi model diye sorulursa bunu söyle.")
f"Bundan sonraki işleri {sonuc['ad']} yapacak; hangi model diye sorulursa bunu söyle.{donus}")
def _responses(self, n, path, body):
try:
@@ -1147,6 +1191,7 @@ class H(http.server.BaseHTTPRequestHandler):
once = len(req.get("input") or []), json.dumps((req.get("input") or [None])[-3:], ensure_ascii=False, sort_keys=True)
sohbet = str(req.get("prompt_cache_key") or "")
if CFG.get("sesle_model", True) and sohbet:
self.komut_govdeyi_degistirdi = False
try:
yerel = self._model_komutu(n, req, sohbet, gelen)
except Exception as e:
@@ -1154,8 +1199,10 @@ class H(http.server.BaseHTTPRequestHandler):
log(f"[{n} {gelen}] model komutu atlandi: {type(e).__name__}: {e}")
if yerel is not None:
return self._yerel_yanit(gelen, yerel)
if self.komut_govdeyi_degistirdi:
govde_degisti = True # komut mesaji nota cevrildi: abonelik yolunda da degismis govde gider
try:
if model_gecis.gecmisi_temizle(req, bilinen_modeller()):
if model_gecis.gecmisi_temizle(req, bilinen_modeller(), YONLENDIRME.al(sohbet, gelen) or gelen):
govde_degisti = True
except Exception as e:
log(f"[{n} {gelen}] gecmis komut temizligi atlandi: {type(e).__name__}: {e}")
@@ -1176,7 +1223,7 @@ class H(http.server.BaseHTTPRequestHandler):
# Model degisimini uygulamak icin yazilan tek "." modele gonderilmez: hicbir model onu onay ya da
# istek sanip is yapmaz, yavas modelde asil soru beklemez. Secilen modelin adiyla aninda donulur.
log(f"[{n} {slug}] nokta mesaji kopruden karsilandi (modele gonderilmedi)")
return self._yerel_yanit(gelen, f"{model_adi(slug)} hazır. Sohbetin modeli artık {model_adi(slug)}.")
return self._yerel_yanit(gelen, f"{model_adi(slug)} seçildi. Sohbetin modeli artık {model_adi(slug)}.")
# Is hatti talimati yalniz masaustu uygulamasindan gelen istege eklenir; CLI hic gormez (is_hatti.py).
kaynak = self.headers.get("originator") or ""
if kaynak and kaynak not in GORULEN_ISTEMCI:
@@ -1206,10 +1253,15 @@ class H(http.server.BaseHTTPRequestHandler):
ozet_acik = ozet_cfg.get("enabled", False)
catalog = CFG.get("nine_router_catalog") or {}
prefix = catalog.get("prefix", "9r/")
if mapping is None and catalog.get("enabled") and slug.startswith(prefix):
# Newly added API models can be selected before the background refresh.
# Provider-qualified IDs are passed intact to 9Router, never to ChatGPT.
mapping = (catalog.get("route", "9router"), slug[len(prefix):], None)
if mapping is None and prefix and slug.startswith(prefix):
if catalog.get("enabled") and catalog.get("route", "9router") in ROUTES:
# Newly added API models can be selected before the background refresh.
# Provider-qualified IDs are passed intact to 9Router, never to ChatGPT.
mapping = (catalog.get("route", "9router"), slug[len(prefix):], None)
else:
# Rotasi kalmamis eski ad chatgpt.com'a baska bir adla gonderilmez ("model desteklenmiyor" donuyordu).
log(f"[{n} {slug}] bu on ekin rotasi yok; istek gonderilmedi")
return self._send_json(503, {"error": {"message": "Bu model artik bu koprude tanimli degil; listeden baska bir model secin."}})
ide_prefix, ide_route = "ide/", "aiturk-ide" # load_ide_catalog ile ayni degerler
if mapping is None and slug.startswith(ide_prefix) and ide_route in ROUTES:
mapping = (ide_route, slug[len(ide_prefix):], None)
@@ -1287,7 +1339,11 @@ class H(http.server.BaseHTTPRequestHandler):
return
# 9Router cevirisinden gecen hedefler (9Router ve AITURK IDE gecidi): ayni istek duzeltmeleri uygulanir.
via_router = route_name in (catalog.get("route", "9router"), ide_route)
route = ROUTES[route_name]
route = ROUTES.get(route_name)
if route is None:
# Katalog yenilemesi rotayi bu istek hazirlanirken kaldirdi (baglama kapandi ya da IDE oturumu gitti).
log(f"[{n} {slug}] rota artik yok: {route_name}")
return self._send_json(503, {"error": {"message": "Bu modelin baglantisi su an kapali; birazdan yeniden deneyin ya da baska bir model secin."}})
tag = f"[{n} {route_name.upper()} {slug}->{target}]"
req["model"] = target
req.pop("client_metadata", None)
@@ -1302,6 +1358,12 @@ class H(http.server.BaseHTTPRequestHandler):
if ozet_tetik:
ozet_kopru.ozet_istegi_hazirla(req, ozet_cfg.get("istem"))
log(f"{tag} ozet kopru: sikistirma istegi duz metin ozet istegine cevrildi (girdi={len(req.get('input', []))} oge)")
if via_router and CFG.get('resim_sadelestir', True):
# Uretilen resim (≈3 MB) 9Router cevirisinde duz yaziya donup sohbeti 3M token'a cikariyordu (24 Eylul 2026).
# Duzlestirmeden ONCE: kimliksiz arac ciktisi orada duz metne cevrilir ve icindeki resim taninmaz olur.
cikan, kazanc = resimleri_sadelestir(req, gorsel=bool((IDE_BILGI.get(slug) or {}).get("gorsel")))
if cikan:
log(f"{tag} {cikan} resim not ile degistirildi ({kazanc // 1024} KB)")
rewriter = None
if flat if flat is not None else route.get("flatten_tools"):
custom = flatten_request(req)
@@ -1310,11 +1372,6 @@ class H(http.server.BaseHTTPRequestHandler):
log(f"{tag} {duzelen} aracin semasi duzeltildi (ust duzey oneOf/anyOf/allOf)")
rewriter = SseRewriter(custom, hedef=target, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren)
fix_tool_ids(req)
if via_router and CFG.get('resim_sadelestir', True):
# Uretilen resim (≈3 MB) 9Router cevirisinde duz yaziya donup sohbeti 3M token'a cikariyordu (24 Eylul 2026).
cikan, kazanc = resimleri_sadelestir(req, gorsel=bool((IDE_BILGI.get(slug) or {}).get("gorsel")))
if cikan:
log(f"{tag} {cikan} resim not ile degistirildi ({kazanc // 1024} KB)")
if route_name == catalog.get('route', '9router') and target.startswith('ag/gemini-'):
merged = coalesce_context(req)
if merged:
@@ -1332,9 +1389,10 @@ class H(http.server.BaseHTTPRequestHandler):
log(f"{tag} girdi kullanici mesajiyla bitmiyordu ({sonlar}), devam mesaji eklendi")
hedef_pencere = (IDE_BILGI.get(slug) or {}).get("pencere")
# Penceresi bilinmeyen (varsayilan degerli) modelde bekci calismaz: tahmine dayanarak gecmis kirpilmaz.
pencere_biliniyor = "varsayilan" not in ((IDE_BILGI.get(slug) or {}).get("kaynak") or "varsayilan")
# Comboda tek bilinmeyen uye, bilinen kucuk uyenin sinirini gecersiz kilmaz (pencere.bekci_calisir).
pencere_biliniyor = bool(hedef_pencere) and pencere.bekci_calisir((IDE_BILGI.get(slug) or {}).get("kaynak"), hedef_pencere)
if hedef_pencere and pencere_biliniyor and CFG.get("tasma_bekcisi", True) and not ozet_tetik:
kirpilan, sigdi, tahmin = pencere.sigdir(req, hedef_pencere, pencere.oran_sec(target))
kirpilan, sigdi, tahmin = pencere.sigdir(req, hedef_pencere, pencere.oran_sec(target, bool((IDE_BILGI.get(slug) or {}).get("claude"))))
if rewriter is not None:
rewriter.dolu = tahmin >= hedef_pencere * 0.7
if kirpilan:
@@ -1359,8 +1417,8 @@ class H(http.server.BaseHTTPRequestHandler):
try:
resp = urllib.request.urlopen(r, timeout=UPSTREAM_IDLE_TIMEOUT)
except urllib.error.HTTPError as e:
errbody = e.read()[:400]
last = (e.code, errbody)
errbody = e.read() # istemciye TAM govde gider (400 bayta kesilince JSON bozuluyordu); gunlukte kisaltilir
last = (e.code, errbody[:400])
etxt = errbody.decode("utf-8", "replace")
# 9Router kalici ust-kaynak hatalarini (400/403 ...) 503 govdesine sararak
# donduruyor. Bunlari tekrar denemek dakikalarca bosuna bekletiyordu.
@@ -1381,6 +1439,9 @@ class H(http.server.BaseHTTPRequestHandler):
return self._send_sse_failed("context_length_exceeded", etxt[:300])
self.send_response(e.code)
self.send_header("Content-Type", "application/json")
bekle = e.headers.get("Retry-After") if e.headers is not None else None
if bekle:
self.send_header("Retry-After", str(bekle)) # 429'da ne kadar beklenecegi istemciye ulassin
self.send_header("Content-Length", str(len(errbody)))
self.end_headers(); self.wfile.write(errbody); return
except Exception as e:
@@ -1388,6 +1449,9 @@ class H(http.server.BaseHTTPRequestHandler):
if combo and is_timeout(e):
log(f"{tag} combo zaman asimi, tekrar gonderilmiyor (calisiyor olabilir): {e}")
break
if combo and kopma_mi(e):
log(f"{tag} combo istegi gonderildikten sonra baglanti koptu, tekrar gonderilmiyor (calisiyor olabilir): {e}")
break
log(f"{tag} deneme {attempt}: {e}")
time.sleep(min(2 * attempt, 8)); continue
# Headers go to the client from here on: a cut stream is never resent.
@@ -148,7 +148,11 @@ def adlandir(girdiler, dolu=(), turler=None):
for k, _ in girdiler:
ad = kisa[k] if sayac[kisa[k].lower()] == 1 else tam[k]
if ad.lower() in kullanilan:
ad = f"{ad} [{k[-8:]}]"
taban = ad
for uzunluk in range(8, len(k) + 8, 4): # son 8 karakter de cakisirsa ek uzatilir: iki satir ayni adi tasimaz
ad = f"{taban} [{k[-uzunluk:]}]"
if ad.lower() not in kullanilan:
break
kullanilan.add(ad.lower())
sonuc[k] = ad
return sonuc
@@ -187,13 +191,15 @@ def sec(data, tanimli=(), kapsam="ide", turler=None):
return [{"id": s["id"], "ad": s["ad"], "kind": s["kind"], "yol": s["yol"]} for s in secilen]
def sirala_codex(modeller, ekler):
def sirala_codex(modeller, ekler, ozgun_kimlikler=()):
"""Ozgun abonelik modelleri oldugu gibi kalir: sirasi, priority degeri ve adi degismez,
listenin basinda durur. Ek modeller onlarin ARKASINA eklenir: combo A-Z, sonra
saglayici A-Z / model A-Z. Ozgun bir modelle ayni adi tasiyan ek model " (AITURK)"
ekiyle ayirt edilir; secicide ozgunun yerine gecmis gibi gorunmez.
Kimlik ve yetenek alanlari korunur; kayitli model secimine dokunulmaz."""
bilgiler = {m["slug"]: m for m in ekler}
Kimlik ve yetenek alanlari korunur; kayitli model secimine dokunulmaz.
ozgun_kimlikler: ust listeden (chatgpt.com) gelen kimlikler. Ayarda ayni kimlik ek model diye yazilmis
olsa da o model ozgundur; yerinden ve sirasindan oynamaz."""
bilgiler = {m["slug"]: m for m in ekler if m["slug"] not in ozgun_kimlikler}
ozgunler = [m for m in modeller if m.get("slug", "") not in bilgiler]
ek_modeller = [m for m in modeller if m.get("slug", "") in bilgiler]
def yalin(ad): # "GPT-6-1-Sol" ile "GPT-6.1-Sol" ayni ad sayilir
@@ -348,5 +354,6 @@ def yukle(ua="AstraBridge/1.0 (codex)", timeout=30, home=None, ag=True):
bilgi = pencere.coz(g["id"], g["kind"], g.get("yol"), kayitlar.get(g["id"]), nano, ogr, aracsiz)
g["pencere"], g["gorsel"], g["arac"], g["pencere_kaynak"] = (bilgi["pencere"], bilgi["gorsel"],
bilgi["arac"], bilgi["kaynak"])
g["claude"] = pencere.claude_var(g["kind"], g.get("yol"), kayitlar.get(g["id"]))
g["aciklama"] = aciklama(g["id"], g["ad"], g["kind"], kayitlar.get(g["id"]), bilgi)
return "tamam", base, anahtar, girdiler
@@ -21,6 +21,7 @@ import time
DOSYA = "model-yonlendirme.json"
OMUR_SN = 7 * 24 * 3600
YENILE_SN = 24 * 3600
EN_COK_KAYIT = 200
EN_COK_KELIME = 14
_KILIT = threading.Lock()
@@ -38,7 +39,13 @@ SORU = ("hangi", "hangisi", "kim", "kimdir")
ATLA = {"model", "modeli", "modelini", "modele", "modeline", "modelin", "modelle", "modeller", "su", "bu", "o", "bir",
"olarak", "ile", "lutfen", "artik", "ana", "sohbet", "sohbetin", "chat", "chatin", "chatte", "simdi", "hadi",
"bana", "icin", "de", "da", "ve", "mi", "misin", "musun", "mu", "yeni", "diger", "baska", "olan", "codex",
"ya", "e", "a", "i", "u", "yi", "ye", "na", "ne", "nu", "seni", "sen", "biz", "onu", "ona"}
"ya", "e", "a", "i", "u", "yi", "ye", "na", "ne", "nu", "seni", "sen", "biz", "onu", "ona", "nokta"}
# Yaziyla soylenen surum rakamlari ("opus bes bes", "uc nokta sekiz"). "bir" belirtec de olabildigi icin
# ("bir model sec") yalniz baska bir rakamin ya da "nokta"nin yaninda rakam sayilir.
SAYI = {"sifir": "0", "bir": "1", "iki": "2", "uc": "3", "dort": "4", "bes": "5", "alti": "6", "yedi": "7",
"sekiz": "8", "dokuz": "9"}
# Komutun ardindan gelen ve is tasimayan ek ("... yap ve devam et"): komutu dusurmez, atilir.
DEVAM_EKI = {"ve", "sonra", "devam", "et", "edelim", "edin", "lutfen"}
# Konusmadan yaziya ceviride sik gorulen yazimlar -> model adindaki parca
TAKMA = {"cloud": "claude", "klod": "claude", "klaud": "claude", "claud": "claude", "opus": "opus", "opüs": "opus",
"cemini": "gemini", "jemini": "gemini", "cemin": "gemini", "flas": "flash", "fles": "flash",
@@ -101,6 +108,18 @@ def son_kullanici_metni(req):
return metin.strip(), sira, False
def _sayilari_cevir(kelimeler):
cikti = []
for i, k in enumerate(kelimeler):
if k in SAYI:
komsu = [kelimeler[j] for j in (i - 1, i + 1) if 0 <= j < len(kelimeler)]
if k != "bir" or any(x == "nokta" or x.isdigit() or (x in SAYI and x != "bir") for x in komsu):
cikti.append(SAYI[k])
continue
cikti.append(k)
return cikti
def komut_coz(metin):
"""None | {"tur": "gec", "ad": [parcalar]} | {"tur": "sor"}."""
kelimeler = re.findall(r"[a-z0-9]+", duz(metin))
@@ -112,6 +131,10 @@ def komut_coz(metin):
return None
fiil = [k for k in kelimeler if k in FIILLER]
if fiil:
son_fiil = max(i for i, k in enumerate(kelimeler) if k in FIILLER)
if all(k in DEVAM_EKI for k in kelimeler[son_fiil + 1:]):
kelimeler = kelimeler[:son_fiil + 1]
kelimeler = _sayilari_cevir(kelimeler)
ad = [TAKMA.get(k, k) for k in kelimeler if k not in ATLA and k not in fiil]
ad = [p for k in ad for p in re.findall(r"[a-z]+|\d+", k)]
return {"tur": "gec", "ad": ad} if ad else None
@@ -203,14 +226,19 @@ def metni_degistir(req, sira, yeni):
oge["content"] = metin if isinstance(icerik, str) else [{"type": icerik[0].get("type", "input_text"), "text": metin}]
def gecmisi_temizle(req, modeller):
def gecmisi_temizle(req, modeller, etkin=None):
"""Gecmisteki (son mesaj disindaki) islenmis "modeli X yap" komutlarini kisa nota cevirir; yoksa sonraki
model ayni cumleyi yeniden gorup "modeli ben degistiremem" diye cevap veriyor (olcum 11 Ekim 2026).
etkin: sohbetin su an gercekten kullandigi model. Verilirse, en son kullanici mesajindaki komut (ardindan
arac ciktisi ya da sikistirma tetigi geldigi icin "gecmis" gorunen) yalniz bu modele gecirdiyse nota cevrilir;
uygulanmamis komut ("baglanti kapali" denen gibi) "degistirildi" diye anlatilmaz.
Doner: degistirilen mesaj sayisi."""
girdi = req.get("input")
if not isinstance(girdi, list):
return 0
son = son_sira(girdi)
son_kullanici = max((i for i, oge in enumerate(girdi) if isinstance(oge, dict)
and oge.get("type", "message") == "message" and oge.get("role") == "user"), default=-1)
n = 0
for i, oge in enumerate(girdi):
if i == son or not (isinstance(oge, dict) and oge.get("type", "message") == "message" and oge.get("role") == "user"):
@@ -221,6 +249,8 @@ def gecmisi_temizle(req, modeller):
komut = komut_coz(metin)
sonuc = eslestir(komut["ad"], modeller) if komut and komut["tur"] == "gec" else None
if sonuc and "slug" in sonuc:
if etkin is not None and i == son_kullanici and sonuc["slug"] != etkin:
continue
metni_degistir(req, i, f"[Köprü notu: kullanıcı burada sohbetin modelini {sonuc['ad']} olarak değiştirdi; "
"değişiklik köprü tarafından yapıldı, senin bir şey yapman gerekmiyor.]")
n += 1
@@ -271,6 +301,10 @@ class Yonlendirme:
del self.veri[sohbet]
self._yaz()
return None
if simdi - float(k.get("zaman") or 0) > YENILE_SN:
# Kullanilan yonlendirme eskimez: omur son kullanimdan sayilir (gunde en cok bir yazma).
k["zaman"] = simdi
self._yaz()
return k["hedef"]
def koy(self, sohbet, gelen, hedef, simdi=None):
@@ -266,10 +266,11 @@ def coz_istegi(oge, model, talimat="Sen bir konusma ozeti aktaricisisin."):
def sse_metni(govde):
"""SSE govdesinden (bayt) asistan metnini toplar."""
"""SSE govdesinden (bayt) asistan metnini toplar. Yanit tamamlanmadiysa (akis yarida kesildi) None doner:
yarim metin tam ozet diye kalici onbellege yazilmasin."""
a = OzetAkisi()
a.feed(govde if govde.endswith(b"\n\n") else govde + b"\n\n")
return a.metin if a.bitti else a._topla()
return a.metin if a.bitti else None
# ---- Dongu freni ----
@@ -300,6 +301,8 @@ class DonguFreni:
def izin(self, anahtar, simdi):
"""Bu ozet istegine izin var mi? Varsa kaydeder."""
zamanlar = [t for t in self.kayit.get(anahtar, []) if simdi - t < self.pencere]
if not zamanlar:
self.ust_uste.pop(anahtar, None) # pencere suresi doldu: eski art arda sayaci sohbeti kilitlemesin
if len(zamanlar) >= self.sinir or self.ust_uste.get(anahtar, 0) >= self.art_arda:
self.kayit[anahtar] = zamanlar
return False
@@ -307,7 +310,7 @@ class DonguFreni:
self.kayit[anahtar] = zamanlar
self.ust_uste[anahtar] = self.ust_uste.get(anahtar, 0) + 1
if len(self.kayit) > 500: # bellek sismesin
for k in sorted(self.kayit, key=lambda k: self.kayit[k][-1])[:250]:
for k in sorted(self.kayit, key=lambda k: (self.kayit[k] or [0])[-1])[:250]:
self.kayit.pop(k, None)
self.ust_uste.pop(k, None)
return True
+59 -7
View File
@@ -16,6 +16,7 @@ Ust sinir TAVAN: ozeti abonelik modeli yazdigi icin gecmis onun penceresini asma
import json
import os
import re
import threading
import time
import urllib.request
@@ -59,7 +60,7 @@ def _oku(yol):
def _yaz(yol, veri):
gecici = yol + ".tmp"
gecici = "%s.%d.tmp" % (yol, threading.get_ident()) # es zamanli iki yazma ayni gecici dosyada carpismasin
with open(gecici, "w", encoding="utf-8") as f:
json.dump(veri, f, ensure_ascii=False)
os.replace(gecici, yol)
@@ -86,7 +87,11 @@ def nano_yukle(home, ua="AstraBridge/1.0 (codex)", timeout=20, simdi=None, getir
simdi = time.time() if simdi is None else simdi
yol = os.path.join(home, NANO_DOSYA) if home else ""
onbellek = _oku(yol) if yol else {}
if onbellek.get("modeller") and simdi - float(onbellek.get("zaman") or 0) < NANO_OMUR_SN:
try:
taze = simdi - float(onbellek.get("zaman") or 0) < NANO_OMUR_SN
except (TypeError, ValueError):
taze = False # bozuk "zaman" alani yenilemeyi engellemez
if onbellek.get("modeller") and taze:
return onbellek["modeller"]
if not ag:
return onbellek.get("modeller") or {}
@@ -242,6 +247,29 @@ def coz(kimlik, tur, yol, kayit=None, nano=None, ogr=None, aracsiz=()):
return sonuc
def claude_var(tur, yol, kayit=None):
"""Istek Claude'a gidebilir mi? Tek modelde yolundan, comboda yapraklarindan okunur (Opus-5 gibi combolarin
adinda "claude" gecmez; token tahmini yine Claude oraniyla yapilmalidir)."""
yollar = _yapraklar((kayit or {}).get("topology")) if tur == "combo" else [yol]
for y in yollar:
y = str(y or "").lower()
if y.startswith("9router/"):
y = y[len("9router/"):]
if "claude" in y or y.startswith("cc/"):
return True
return False
def bekci_calisir(kaynak, pencere_token):
"""Tasma bekcisi bu pencereye guvenebilir mi? Hakkinda hicbir sey bilinmeyen modelde calismaz (tahmine
dayanarak gecmis kirpilmaz). Comboda tek bilinmeyen uye, bilinen KUCUK uyenin sinirini gecersiz kilmaz:
ilan edilen pencere VARSAYILAN'dan kucukse o deger bilinen bir uyeden gelmistir."""
kaynak = kaynak or "varsayilan"
if "varsayilan" not in kaynak:
return True
return kaynak.startswith("uyeler:") and kaynak != "uyeler:varsayilan" and int(pencere_token or 0) < VARSAYILAN
def kisa(pencere):
"""8192 -> "8K", 131072 -> "128K", 272000 -> "272K"."""
return f"{round(pencere / 1024) if pencere < 262_144 and pencere % 1024 == 0 else round(pencere / 1000)}K"
@@ -263,14 +291,35 @@ KIRP_BAS = KIRP_SON = 1500
CLAUDE_BAYT_BOLU_TOKEN = 2.6
def oran_sec(hedef):
return CLAUDE_BAYT_BOLU_TOKEN if "claude" in str(hedef or "").lower() else BAYT_BOLU_TOKEN
def oran_sec(hedef, claude=False):
"""claude=True: hedefin adinda gecmese de (combo) istek Claude'a gidebilir (bkz. claude_var)."""
return CLAUDE_BAYT_BOLU_TOKEN if (claude or "claude" in str(hedef or "").lower()) else BAYT_BOLU_TOKEN
def tahmini_token(req, oran=None):
return int(len(json.dumps(req, ensure_ascii=False).encode("utf-8")) / (oran or BAYT_BOLU_TOKEN))
KOPRU_DEVAM_BASI = "(Önceki cevabın yarıda kaldı" # bridge.py claude_sonu_duzelt'in ekledigi mesajin basi
def _kopru_mesaji(oge):
icerik = oge.get("content")
if isinstance(icerik, list):
icerik = "".join(str(p.get("text") or "") for p in icerik if isinstance(p, dict))
return isinstance(icerik, str) and icerik.startswith(KOPRU_DEVAM_BASI)
def _son_arac_ciktisi(req):
"""En yeni arac ciktisinin metin sahipleri (kimlik kumesi): model bir sonraki adimi buna gore atar,
bu yuzden ancak baska hicbir sey yetmezse kirpilir."""
for it in reversed(req.get("input") or []):
if isinstance(it, dict) and "output" in it and str(it.get("type") or "").endswith("_output"):
deger = it.get("output")
return {id(it)} if isinstance(deger, str) else {id(p) for p in deger or [] if isinstance(p, dict)}
return set()
def _metin_parcalari(req):
"""Kirpilabilir (sahip, anahtar) ciftleri: arac ciktilari ile eski kullanici/asistan mesajlari.
Son kullanici mesajina ve talimat tasiyan developer/system mesajlarina (Codex kurallari, skill
@@ -278,8 +327,9 @@ def _metin_parcalari(req):
girdi = req.get("input")
if not isinstance(girdi, list):
return []
# Koprunun sona ekledigi "devam et" mesaji kullanicinin son mesaji degildir; korunan, ondan onceki gercek mesajdir.
son_kullanici = max((i for i, it in enumerate(girdi) if isinstance(it, dict) and it.get("role") == "user"
and it.get("type", "message") == "message"), default=-1)
and it.get("type", "message") == "message" and not _kopru_mesaji(it)), default=-1)
parcalar = []
for i, it in enumerate(girdi):
if not isinstance(it, dict) or i == son_kullanici or it.get("role") in ("developer", "system"):
@@ -302,11 +352,13 @@ def sigdir(req, pencere_token, oran=None):
if not pencere_token or tahmin <= sinir:
return 0, True, tahmin
kirpilan = 0
parcalar = sorted(_metin_parcalari(req), key=lambda sa: len(sa[0][sa[1]]), reverse=True)
en_yeni = _son_arac_ciktisi(req)
# Buyukten kucuge; en yeni arac ciktisi en sona kalir (yalniz digerleri yetmezse kirpilir).
parcalar = sorted(_metin_parcalari(req), key=lambda sa: (id(sa[0]) in en_yeni, -len(sa[0][sa[1]])))
for sahip, anahtar in parcalar:
metin = sahip[anahtar]
if len(metin) < KIRP_ESIK:
break
continue
sahip[anahtar] = (metin[:KIRP_BAS] + f"\n[... {len(metin) - KIRP_BAS - KIRP_SON} karakter çıkarıldı (köprü): "
f"seçili modelin bağlam penceresine sığmıyordu ...]\n" + metin[-KIRP_SON:])
kirpilan += 1
@@ -59,7 +59,8 @@ def resimleri_sadelestir(req, tut_bayt=TUT_BAYT, gorsel=False):
return 0, 0
son_kullanici = None
for i, it in enumerate(inp):
if isinstance(it, dict) and it.get('type') == 'message' and it.get('role') == 'user':
# 'type' alani olmayan mesaj da mesajdir (Responses girdisinde varsayilan tur).
if isinstance(it, dict) and it.get('type', 'message') == 'message' and it.get('role') == 'user':
son_kullanici = i
koru = set()
if son_kullanici is not None:
@@ -72,7 +73,7 @@ def resimleri_sadelestir(req, tut_bayt=TUT_BAYT, gorsel=False):
kalan = tut_bayt - sum(_boyut(p) for i in koru for p in inp[i].get('content') or [] if _medya_mi(p))
for i in range(len(inp) - 1, -1, -1):
it = inp[i]
if i in koru or not (isinstance(it, dict) and it.get('type') == 'message' and it.get('role') == 'user'):
if i in koru or not (isinstance(it, dict) and it.get('type', 'message') == 'message' and it.get('role') == 'user'):
continue
icerik = it.get('content')
resimler = [p for p in icerik if _medya_mi(p) and MEDYA_TURLERI.get(p.get('type')) == 'resim'] \
@@ -27,7 +27,7 @@ class ComboNotResent(unittest.TestCase):
def setUp(self):
self.upstream = Mock()
self.env = {
"json": json, "re": __import__("re"), "os": __import__("os"),
"json": json, "re": __import__("re"), "os": __import__("os"), "http": __import__("http.client"),
"time": types.SimpleNamespace(sleep=lambda s: None),
"urllib": types.SimpleNamespace(
request=types.SimpleNamespace(Request=urllib.request.Request, urlopen=self.upstream),
@@ -42,7 +42,7 @@ class ComboNotResent(unittest.TestCase):
"DEBUG": False, "UA": "test", "RETRIES": 6, "UPSTREAM_IDLE_TIMEOUT": 3600,
"NO_RETRY_PATTERNS": [], "pencere": __import__("pencere"), "IDE_BILGI": {}, "yarim": __import__("yarim"), "is_hatti": __import__("is_hatti"), "GORULEN_ISTEMCI": set(), "arac_semalarini_duzelt": lambda req: 0, "nokta_mesaji_mi": lambda req: False, "EXTRA_MODELS": [],
}
exec(compile(ast.Module(body=[pick("flatten_request"), pick("claude_sonu_duzelt"), pick("is_timeout"), responses],
exec(compile(ast.Module(body=[pick("flatten_request"), pick("claude_sonu_duzelt"), pick("is_timeout"), pick("kopma_mi"), responses],
type_ignores=[]), "bridge.py", "exec"), self.env)
def send(self, model):
@@ -85,6 +85,16 @@ class ComboNotResent(unittest.TestCase):
self.assertEqual(self.upstream.call_count, 3)
fake._stream.assert_called_once()
def test_combo_closed_by_the_peer_before_headers_is_not_resent(self):
import http.client
for error in (http.client.RemoteDisconnected("closed"), urllib.error.URLError(ConnectionResetError("reset"))):
with self.subTest(error=error):
self.upstream.reset_mock()
self.upstream.side_effect = error
fake = self.send("9r/Fusion-Orta")
self.assertEqual(self.upstream.call_count, 1)
fake._stream.assert_not_called()
def test_single_model_timeout_is_still_retried(self):
self.upstream.side_effect = [urllib.error.URLError(TimeoutError("timed out")), Mock(status=200)]
fake = self.send("9r/cc/claude-opus-5-5")
@@ -0,0 +1,441 @@
"""Audit of 11 Oct 2026, remaining findings: each fix has a test here (handler methods run through a fake handler)."""
import ast
import copy
import gzip
import hashlib
import io
import json
import tempfile
import time
import types
import unittest
import urllib.error
import urllib.request
from pathlib import Path
from unittest.mock import Mock
import ide_catalog
import is_hatti
import model_gecis
import ozet_kopru
import pencere
import resim_context
import yarim
SOURCE = Path(__file__).with_name("bridge.py").read_text(encoding="utf-8")
tree = ast.parse(SOURCE)
handler = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "H")
def top(*names):
nodes = [n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.ClassDef)) and n.name in names]
for node in tree.body:
if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id in names for t in node.targets):
nodes.insert(0, node)
return nodes
def method(name):
return next(n for n in handler.body if isinstance(n, ast.FunctionDef) and n.name == name)
def build(nodes, env):
exec(compile(ast.Module(body=nodes, type_ignores=[]), "bridge.py", "exec"), env)
return env
def user(text):
return {"type": "message", "role": "user", "content": [{"type": "input_text", "text": text}]}
def developer(text):
return {"type": "message", "role": "developer", "content": [{"type": "input_text", "text": text}]}
MODELS = [("gpt-6-sol", "GPT-6-Sol"), ("gpt-6-astra", "GPT-6-Astra"), ("gpt-6.1-sol", "GPT-6.1-Sol"),
("ide/9router-ag-gemini-3.8-flash-low", "agy/gemini-3.8-flash-low"),
("ide/9router-cc-claude-opus-5-5", "cc/opus-5-5")]
CALL = {"type": "function_call", "call_id": "c", "name": "shell", "arguments": "{}"}
OUT = {"type": "function_call_output", "call_id": "c", "output": "x"}
class SesliKomut(unittest.TestCase):
def pick(self, text):
command = model_gecis.komut_coz(text)
return model_gecis.eslestir(command["ad"], MODELS) if command and command["tur"] == "gec" else None
def test_numbers_said_as_words_and_a_continue_tail_still_switch(self):
for text, slug in [("modeli opus beş beş yap", "ide/9router-cc-claude-opus-5-5"),
("modeli gemini üç nokta sekiz flash low yap", "ide/9router-ag-gemini-3.8-flash-low"),
("modeli gpt altı nokta bir sol yap", "gpt-6.1-sol"),
("modeli gpt altı astra yap ve devam et", "gpt-6-astra"),
("modeli opus 5 5 yap sonra devam et", "ide/9router-cc-claude-opus-5-5")]:
self.assertEqual((self.pick(text) or {}).get("slug"), slug, text)
def test_a_task_after_the_command_is_not_swallowed(self):
for text in ("modeli opus 5 5 yap ve şu dosyayı düzelt", "bir model seç", "modeli iki kere değiştir"):
self.assertIsNone(self.pick(text), text)
def test_last_command_is_noted_only_when_it_really_took_effect(self):
opus = "ide/9router-cc-claude-opus-5-5"
refused = {"input": [user("modeli opus 5 5 yap"), {"type": "compaction_trigger"}]}
self.assertEqual(model_gecis.gecmisi_temizle(refused, MODELS, "gpt-6-sol"), 0) # e.g. "bağlantı kapalı"
self.assertEqual(refused["input"][0]["content"][0]["text"], "modeli opus 5 5 yap")
applied = {"input": [user("modeli opus 5 5 yap"), CALL, OUT]}
self.assertEqual(model_gecis.gecmisi_temizle(applied, MODELS, opus), 1) # mid-turn handover
self.assertIn("cc/opus-5-5 olarak değiştirdi", applied["input"][0]["content"][0]["text"])
def test_a_route_in_use_does_not_expire(self):
y = model_gecis.Yonlendirme(None)
y.koy("chat", "gpt-6-sol", "ide/x", simdi=1000)
day = 24 * 3600
for n in range(1, 12): # used every 1.5 days for 16 days
self.assertEqual(y.al("chat", "gpt-6-sol", simdi=1000 + n * 1.5 * day), "ide/x", n)
self.assertIsNone(y.al("chat", "gpt-6-sol", simdi=1000 + 16.5 * day + model_gecis.OMUR_SN + 1)) # unused for a week
class Pencere(unittest.TestCase):
def test_guard_runs_for_a_combo_whose_small_member_is_known(self):
self.assertFalse(pencere.bekci_calisir("varsayilan", 128_000))
self.assertFalse(pencere.bekci_calisir("", 128_000))
self.assertFalse(pencere.bekci_calisir("uyeler:varsayilan", 128_000))
self.assertTrue(pencere.bekci_calisir("uyeler:nanogpt,varsayilan", 8192)) # the 8K member is real
self.assertFalse(pencere.bekci_calisir("uyeler:nanogpt,varsayilan", 128_000)) # the minimum is the guess
for known in ("yerel", "nanogpt", "ogrenilen", "uyeler:nanogpt,yerel"):
self.assertTrue(pencere.bekci_calisir(known, 200_000), known)
def test_combo_with_a_claude_member_uses_the_claude_ratio(self):
combo = {"topology": {"kind": "combo", "strategy": "priority", "members": [
{"kind": "model", "model": "cx/gpt-6-astra"},
{"kind": "combo", "strategy": "priority", "members": [{"kind": "model", "model": "cc/claude-opus-5-5"}]}]}}
plain = {"topology": {"kind": "combo", "members": [{"kind": "model", "model": "ag/gemini-3.8-flash"}]}}
self.assertTrue(pencere.claude_var("combo", "Opus-5", combo))
self.assertFalse(pencere.claude_var("combo", "Hizli", plain))
self.assertTrue(pencere.claude_var("model", "no-think/claude-opus-5-5"))
self.assertFalse(pencere.claude_var("model", "nano/z-ai/glm-5.3"))
self.assertEqual(pencere.oran_sec("Opus-5", True), pencere.CLAUDE_BAYT_BOLU_TOKEN)
self.assertEqual(pencere.oran_sec("Opus-5"), pencere.BAYT_BOLU_TOKEN)
def test_a_corrupt_nano_cache_does_not_block_the_refresh(self):
home = tempfile.mkdtemp()
Path(home, pencere.NANO_DOSYA).write_text(json.dumps({"zaman": "dun", "modeller": {"eski": {"pencere": 9000}}}),
encoding="utf-8")
fresh = {"data": [{"id": "Yeni/M", "context_length": 65536, "capabilities": {}}]}
self.assertIn("yeni/m", pencere.nano_yukle(home, simdi=1000.0, getir=lambda: fresh))
def test_trimming_keeps_the_real_last_user_message_and_the_newest_tool_output(self):
marker = "(Önceki cevabın yarıda kaldı ya da sesli konuşma kapandı; bağlama göre devam et.)"
self.assertIn(marker, SOURCE) # the text bridge.py appends
self.assertTrue(marker.startswith(pencere.KOPRU_DEVAM_BASI))
req = {"input": [{"type": "function_call_output", "call_id": "a", "output": "eski " * 30000},
user("TALIMAT " * 5000),
{"type": "function_call_output", "call_id": "b", "output": "yeni " * 4000},
user(marker)]}
trimmed, fits, _ = pencere.sigdir(req, 30_000)
self.assertEqual((trimmed, fits), (1, True))
self.assertIn("çıkarıldı (köprü)", req["input"][0]["output"])
self.assertEqual(req["input"][1]["content"][0]["text"], "TALIMAT " * 5000) # the user's real last message
self.assertEqual(req["input"][2]["output"], "yeni " * 4000) # newest tool result untouched
# When nothing else is left, the newest tool output is trimmed rather than the request refused.
only = {"input": [user("soru"), {"type": "function_call_output", "call_id": "b", "output": "yeni " * 40000}]}
self.assertEqual(pencere.sigdir(only, 30_000)[:2], (1, True))
class Ozet(unittest.TestCase):
def test_a_cut_stream_is_not_returned_as_a_whole_summary(self):
delta = b'data: {"type":"response.output_text.delta","item_id":"m","delta":"yarim oz"}\n\n'
self.assertIsNone(ozet_kopru.sse_metni(delta))
done = delta + b'data: {"type":"response.completed","response":{}}\n\n'
self.assertEqual(ozet_kopru.sse_metni(done), "yarim oz")
def test_the_in_a_row_brake_lifts_after_the_window(self):
f = ozet_kopru.DonguFreni(sinir=8, pencere_sn=600, art_arda=3)
self.assertEqual([f.izin("s", t) for t in (0, 10, 20, 30)], [True, True, True, False])
self.assertFalse(f.izin("s", 500)) # still inside the window
self.assertTrue(f.izin("s", 24 * 3600)) # a day later the chat is not locked any more
def test_cleanup_survives_an_empty_record(self):
f = ozet_kopru.DonguFreni()
f.kayit = {"k%d" % i: [float(i)] for i in range(500)}
f.kayit["bos"] = []
self.assertTrue(f.izin("yeni", 1000.0))
self.assertLess(len(f.kayit), 300)
class Akis(unittest.TestCase):
env = build(top("fix_tool_ids", "SseRewriter", "phase_damgala", "asistan_mesaji_mi", "ID_PREFIX", "ARAC_OGELERI"),
{"json": json, "re": __import__("re"), "time": time, "log": lambda m: None, "pencere": pencere})
def events(self, raw):
return [json.loads(line[6:]) for line in raw.decode().splitlines() if line.startswith("data: ")]
def test_crlf_separated_events_are_translated(self):
flat = {"type": "error", "code": "bad_request", "message": "upstream 402"}
raw = b"event: error\r\ndata: " + json.dumps(flat).encode() + b"\r\n\r\n"
adapter = self.env["SseRewriter"](set())
out = self.events(adapter.feed(raw[:20]) + adapter.feed(raw[20:]) + adapter.flush())
self.assertEqual(out[-1]["type"], "response.completed")
self.assertIn("kredi ya da kota", out[1]["delta"])
watcher = yarim.Izleyici("k", "m")
watcher.feed(b'data: {"type":"response.output_text.delta","delta":"merhaba"}\r\n\r\n'
b'data: {"type":"response.completed"}\r\n\r\n')
self.assertEqual((watcher.metin, watcher.bitti), ("merhaba", True))
def test_a_non_object_event_body_passes_through(self):
adapter = self.env["SseRewriter"](set())
self.assertEqual(adapter.feed(b"data: null\n\ndata: [1]\n\n"), b"data: null\n\ndata: [1]\n\n")
def test_a_waiting_message_is_released_before_the_error_answer(self):
message = {"type": "response.output_item.done", "output_index": 0,
"item": {"id": "m1", "type": "message", "role": "assistant", "content": []}}
adapter = self.env["SseRewriter"](set(), dolu=True)
first = adapter.feed(b"data: " + json.dumps(message).encode() + b"\n\n")
self.assertEqual(first, b"") # held until what follows is known
out = self.events(adapter.feed(b'data: {"type":"error","message":"upstream 400"}\n\n') + adapter.flush())
self.assertEqual([e["type"] for e in out], ["response.output_item.done", "response.failed"])
self.assertEqual(out[0]["item"]["id"], "m1")
self.assertIn("nedenini bildirmedi", out[1]["response"]["error"]["message"]) # no false "did not fit"
self.assertIn("olabilir", out[1]["response"]["error"]["message"])
class Resim(unittest.TestCase):
def test_a_new_image_in_a_message_without_type_is_kept(self):
img = {"type": "input_image", "image_url": "data:image/png;base64," + "A" * 2000}
req = {"input": [{"role": "user", "content": [{"type": "input_text", "text": "bak"}, dict(img)]}]}
self.assertEqual(resim_context.resimleri_sadelestir(req), (0, 0))
self.assertEqual(req["input"][0]["content"][1]["type"], "input_image")
class Katalog(unittest.TestCase):
def test_names_stay_distinct_when_the_id_tail_collides_too(self):
names = ide_catalog.adlandir([("a-ortakson", "Ad"), ("b-ortakson", "Ad"), ("c-ortakson", "Ad")], dolu={"ad"})
self.assertEqual(len({v.lower() for v in names.values()}), 3)
def test_an_original_listed_as_extra_keeps_its_place(self):
models = [{"slug": "gpt-6-sol", "display_name": "GPT-6-Sol", "priority": 1},
{"slug": "gpt-6-astra", "display_name": "GPT-6-Astra", "priority": 2},
{"slug": "ide/a", "display_name": "Aaa", "priority": 0}]
extras = [{"slug": "gpt-6-astra"}, {"slug": "ide/a", "kind": "combo"}]
ide_catalog.sirala_codex(models, extras, {"gpt-6-sol", "gpt-6-astra"})
self.assertEqual([(m["slug"], m["priority"]) for m in models],
[("gpt-6-sol", 1), ("gpt-6-astra", 2), ("ide/a", 3)])
class Sahte:
"""A handler stand-in: real bridge methods, recorded output."""
def __init__(self, headers=None, command="POST"):
self.headers = headers or {}
self.command = command
self.path = "/backend-api/codex/responses"
self.wfile = io.BytesIO()
self.status = None
self.sent = []
self._passthrough = Mock()
self._yerel_yanit = Mock()
self._send_json = Mock()
self._stream = Mock()
self._tasma_ogren = Mock()
self._aracsiz_ogren = Mock()
def send_response(self, status):
self.status = status
def send_header(self, k, v):
self.sent.append((k, v))
def end_headers(self):
pass
class Zincir(unittest.TestCase):
"""_responses + _model_komutu with a conversation key, the way Codex sends it."""
def setUp(self):
self.env = build(top("nokta_mesaji_mi", "NOKTA_ISARETLERI", "is_timeout", "kopma_mi", "flatten_request",
"claude_sonu_duzelt") + [method("_responses"), method("_model_komutu")], {
"json": json, "re": __import__("re"), "os": __import__("os"), "copy": copy, "http": __import__("http.client"),
"time": types.SimpleNamespace(sleep=lambda s: None, time=time.time),
"urllib": types.SimpleNamespace(request=types.SimpleNamespace(Request=urllib.request.Request, urlopen=Mock()),
error=urllib.error),
"decompress": lambda body, enc: body, "MODEL_MAP": {}, "ROUTES": {}, "CFG": {}, "log": lambda m: None,
"model_gecis": model_gecis, "YONLENDIRME": model_gecis.Yonlendirme(None), "bilinen_modeller": lambda: MODELS,
"model_adi": lambda slug: dict(MODELS).get(slug, slug), "is_hatti": is_hatti, "GORULEN_ISTEMCI": set(),
"yarim": yarim, "ozet_kopru": ozet_kopru, "OZET_FRENI": ozet_kopru.DonguFreni(), "pencere": pencere,
"fix_tool_ids": lambda req: 0, "drop_foreign_items": lambda req: 0, "IDE_BILGI": {}, "EXTRA_MODELS": [],
"arac_semalarini_duzelt": lambda req: 0, "SseRewriter": Mock(), "DEBUG": False, "UA": "test", "RETRIES": 6,
"UPSTREAM_IDLE_TIMEOUT": 1, "NO_RETRY_PATTERNS": [], "pano_blogunu_ulastir": lambda req: (0, ""),
"resimleri_sadelestir": lambda req, gorsel=False: (0, 0),
})
Sahte._responses = self.env["_responses"]
Sahte._model_komutu = self.env["_model_komutu"]
def send(self, model, items, key="sohbet-1", path="/responses"):
fake = Sahte()
body = json.dumps({"model": model, "prompt_cache_key": key, "input": items}).encode()
fake._responses(1, path, body)
return fake, body
def test_mid_turn_note_reaches_the_subscription_model_behind_hook_messages(self):
items = [user("işe başla"), CALL, OUT, user("modeli sol yap"), developer("k1"), developer("k2"), developer("k3")]
fake, body = self.send("gpt-6-astra", items)
fake._yerel_yanit.assert_not_called()
args, kwargs = fake._passthrough.call_args
self.assertTrue(kwargs["rewritten"])
self.assertNotEqual(args[2], body)
sent = json.loads(args[2])
self.assertIn("ad belirsizdi", sent["input"][3]["content"][0]["text"])
self.assertEqual(sent["model"], "gpt-6-astra")
def test_idle_switch_says_how_to_return_and_routes_the_next_request(self):
fake, _ = self.send("gpt-6-sol", [user("modeli gpt 6 astra yap")])
answer = fake._yerel_yanit.call_args.args[1]
self.assertIn("Sohbetin modeli artık GPT-6-Astra", answer)
self.assertIn('"modeli GPT-6-Sol yap"', answer)
final = {"type": "message", "role": "assistant", "phase": "final_answer", "content": "ok"}
fake, _ = self.send("gpt-6-sol", [user("modeli gpt 6 astra yap"), final, user("devam")])
sent = json.loads(fake._passthrough.call_args.args[2])
self.assertEqual(sent["model"], "gpt-6-astra")
self.assertIn("GPT-6-Astra olarak değiştirdi", sent["input"][0]["content"][0]["text"])
back, _ = self.send("gpt-6-sol", [user("modeli gpt 6 astra yap"), final, user("modeli gpt 6 sol yap")])
self.assertNotIn("dönmek için", back._yerel_yanit.call_args.args[1]) # already back on the picked model
def test_a_refused_switch_is_not_told_as_done_after_compaction(self):
items = [user("modeli opus 5 5 yap"), {"type": "compaction_trigger"}] # the ide/ route is closed here
fake, body = self.send("gpt-6-sol", items)
self.assertEqual(fake._passthrough.call_args.args[2], body)
def test_lone_dot_answer_says_selected_not_ready(self):
self.env["CFG"]["nokta_karsila"] = True
fake, _ = self.send("gpt-6-sol", [user(".")])
self.assertIn("GPT-6-Sol seçildi", fake._yerel_yanit.call_args.args[1])
self.assertNotIn("hazır", fake._yerel_yanit.call_args.args[1])
def test_old_router_name_without_a_route_is_refused_locally(self):
fake, _ = self.send("9r/cc/claude-opus-5-5", [user("selam")])
self.assertEqual(fake._send_json.call_args.args[0], 503)
fake._passthrough.assert_not_called()
def test_a_route_removed_while_the_request_is_prepared_gives_503(self):
self.env["MODEL_MAP"]["ide/x"] = ("aiturk-ide", "x", None)
fake, _ = self.send("ide/x", [user("selam")])
self.assertEqual(fake._send_json.call_args.args[0], 503)
self.env["urllib"].request.urlopen.assert_not_called()
def external(self, error):
self.env["ROUTES"]["r"] = {"url": "https://example.invalid/v1/responses", "key": "test-only"}
self.env["MODEL_MAP"].update({"x/combo": ("r", "Takim", None), "x/tek": ("r", "cc/claude", None)})
self.env["urllib"].request.urlopen.side_effect = error
def test_combo_dropped_after_sending_is_not_resent_but_a_single_model_is(self):
import http.client
self.external(http.client.RemoteDisconnected("closed"))
self.send("x/combo", [user("selam")])
self.assertEqual(self.env["urllib"].request.urlopen.call_count, 1)
self.env["urllib"].request.urlopen.reset_mock()
self.external([urllib.error.URLError(ConnectionResetError("reset")), Mock(status=200)])
fake, _ = self.send("x/tek", [user("selam")])
self.assertEqual(self.env["urllib"].request.urlopen.call_count, 2)
fake._stream.assert_called_once()
self.env["urllib"].request.urlopen.reset_mock()
self.external([urllib.error.URLError(ConnectionRefusedError("refused")), Mock(status=200)])
self.send("x/combo", [user("selam")]) # never connected: safe to retry
self.assertEqual(self.env["urllib"].request.urlopen.call_count, 2)
def test_error_body_reaches_the_client_whole_with_retry_after(self):
whole = json.dumps({"error": {"message": "limit " + "x" * 900, "code": "rate_limit_exceeded"}}).encode()
self.external(urllib.error.HTTPError("https://example.invalid", 429, "x", {"Retry-After": "17"}, io.BytesIO(whole)))
fake, _ = self.send("x/tek", [user("selam")])
self.assertEqual(fake.status, 429)
self.assertEqual(fake.wfile.getvalue(), whole)
self.assertIn(("Retry-After", "17"), fake.sent)
self.assertIn(("Content-Length", str(len(whole))), fake.sent)
def test_image_in_an_orphan_tool_output_is_removed_before_flattening(self):
self.env["resimleri_sadelestir"] = resim_context.resimleri_sadelestir
self.env["CFG"]["nine_router_catalog"] = {"enabled": True, "route": "r", "prefix": "9r/"}
self.external(None)
self.env["ROUTES"]["r"]["flatten_tools"] = True
self.env["urllib"].request.urlopen.return_value = Mock(status=200)
picture = {"type": "input_image", "image_url": "data:image/png;base64," + "QUJD" * 500}
self.send("x/tek", [{"type": "function_call_output", "name": "image_gen", "output": [picture]}, user("devam")])
sent = self.env["urllib"].request.urlopen.call_args.args[0].data.decode()
self.assertNotIn("QUJDQUJD", sent)
self.assertIn("resim", sent)
class Govde(unittest.TestCase):
read = build([method("_read")], {})["_read"]
def test_chunked_upload_cut_short_ends_instead_of_spinning(self):
fake = types.SimpleNamespace(headers={"Transfer-Encoding": "chunked"}, rfile=io.BytesIO(b"5\r\nhello\r\n"))
self.assertEqual(Govde.read(fake), b"hello")
fake = types.SimpleNamespace(headers={"Transfer-Encoding": "chunked"}, rfile=io.BytesIO(b"5\r\nhello\r\n0\r\n\r\n"))
self.assertEqual(Govde.read(fake), b"hello")
class Liste(unittest.TestCase):
def env(self, **extra):
env = {"json": json, "copy": copy, "hashlib": hashlib, "time": time, "log": lambda m: None, "CFG": {},
"decompress": lambda raw, enc: raw, "EXTRA_MODELS": [], "IDE_PREFIX": "ide/", "SLIM_CLONES": False,
"SLIM_FIELDS": [], "ide_catalog": ide_catalog, "LAST_MODELS": {}, "save_last_models": Mock(),
"RESP_HOP": {"content-length", "content-encoding"}, "REQ_HOP": {"host"}, "CHATGPT": "https://example.invalid",
"UPSTREAM_IDLE_TIMEOUT": 1,
"urllib": types.SimpleNamespace(request=types.SimpleNamespace(Request=urllib.request.Request, urlopen=Mock()),
error=urllib.error)}
env.update(extra)
return build([method("_models"), method("_passthrough"), method("_son_listeyi_ver")], env)
UPSTREAM = {"models": [{"slug": "gpt-6-sol", "display_name": "GPT-6-Sol", "priority": 1},
{"slug": "gpt-6-astra", "display_name": "GPT-6-Astra", "priority": 2}]}
def test_gzip_answer_is_still_cached_plain_and_originals_keep_name_and_place(self):
extras = [{"slug": "gpt-6-astra", "display_name": "Baska Ad"}, {"slug": "ide/a", "display_name": "Aaa", "kind": "combo"}]
env = self.env(EXTRA_MODELS=extras, CFG={"display_map": {"gpt-6-sol": "Yeni Ad"}})
fake = Sahte({"Accept-Encoding": "gzip", "chatgpt-account-id": "hesap"}, "GET")
resp = types.SimpleNamespace(read=lambda: json.dumps(self.UPSTREAM).encode(), headers={"ETag": "x"})
env["_models"](fake, resp, "[t]")
listed = json.loads(gzip.decompress(fake.wfile.getvalue()))["models"]
self.assertEqual([(m["slug"], m["display_name"]) for m in listed],
[("gpt-6-sol", "GPT-6-Sol"), ("gpt-6-astra", "GPT-6-Astra"), ("ide/a", "Aaa")])
cached = env["LAST_MODELS"]["hesap"]
self.assertEqual(json.loads(cached["body"])["models"], listed) # plain JSON, usable as a fallback
self.assertNotIn("Content-Encoding", dict(cached["headers"]))
self.assertIn(("Content-Encoding", "gzip"), fake.sent)
env["save_last_models"].assert_called_once()
def test_no_network_still_serves_the_last_list(self):
cached = {"headers": [("Content-Type", "application/json")], "body": b'{"models": []}', "at": "x"}
env = self.env(LAST_MODELS={"hesap": cached})
env["urllib"].request.urlopen.side_effect = urllib.error.URLError(OSError("no network"))
fake = Sahte({"chatgpt-account-id": "hesap"}, "GET")
fake._son_listeyi_ver = types.MethodType(env["_son_listeyi_ver"], fake)
env["_passthrough"](fake, 1, "/backend-api/codex/models?client_version=1", b"")
self.assertEqual((fake.status, fake.wfile.getvalue()), (200, b'{"models": []}'))
other = Sahte({"chatgpt-account-id": "baska"}, "GET")
other._son_listeyi_ver = types.MethodType(env["_son_listeyi_ver"], other)
env["_passthrough"](other, 1, "/backend-api/codex/models", b"")
self.assertEqual(other._send_json.call_args.args[0], 502)
class Kucukler(unittest.TestCase):
def test_log_line_has_the_date_and_no_token_tail_and_status_reports_the_code(self):
self.assertIn('time.strftime("%Y-%m-%d %H:%M:%S ") + msg', SOURCE)
self.assertNotIn("token=..", SOURCE)
self.assertNotIn("DISPLAY_MAP", SOURCE)
self.assertIn('"kod": KOD', SOURCE)
def test_responses_path_with_a_query_is_still_routed(self):
env = build([method("_handle")], {"lock": __import__("threading").Lock(), "seq": [0], "DEBUG": False,
"log": lambda m: None})
fake = Sahte({})
fake.path = "/backend-api/codex/responses?stream=true"
fake._read = lambda: b"{}"
fake._responses = Mock()
env["_handle"](fake)
fake._responses.assert_called_once()
fake._passthrough.assert_not_called()
if __name__ == "__main__":
unittest.main()
@@ -36,6 +36,8 @@ class Izleyici:
def feed(self, chunk):
self.buf += chunk
if b"\r" in self.buf:
self.buf = self.buf.replace(b"\r\n", b"\n") # CRLF ile ayrilan SSE de izlenir
while b"\n\n" in self.buf:
blok, self.buf = self.buf.split(b"\n\n", 1)
if b"response.output_text.delta" in blok: