From 7c0dc467d5343ce05c6b22b91aae35104ecbef2b Mon Sep 17 00:00:00 2001 From: yilsem Date: Sun, 11 Oct 2026 01:50:08 +0300 Subject: [PATCH] fix(codex-bridge): remaining audit findings (routing, trimming, retries, model list fallback, logging) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/desktop/assets/codex-bridge/README.md | 26 +- apps/desktop/assets/codex-bridge/bridge.py | 216 ++++++--- .../assets/codex-bridge/ide_catalog.py | 15 +- .../assets/codex-bridge/model_gecis.py | 38 +- .../desktop/assets/codex-bridge/ozet_kopru.py | 9 +- apps/desktop/assets/codex-bridge/pencere.py | 66 ++- .../assets/codex-bridge/resim_context.py | 5 +- .../assets/codex-bridge/test_combo_tekrar.py | 14 +- .../assets/codex-bridge/test_denetim_ek.py | 441 ++++++++++++++++++ apps/desktop/assets/codex-bridge/yarim.py | 2 + 10 files changed, 731 insertions(+), 101 deletions(-) create mode 100644 apps/desktop/assets/codex-bridge/test_denetim_ek.py diff --git a/apps/desktop/assets/codex-bridge/README.md b/apps/desktop/assets/codex-bridge/README.md index c19b350..f7ecec7 100644 --- a/apps/desktop/assets/codex-bridge/README.md +++ b/apps/desktop/assets/codex-bridge/README.md @@ -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 " 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 " 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. diff --git a/apps/desktop/assets/codex-bridge/bridge.py b/apps/desktop/assets/codex-bridge/bridge.py index 42495c1..d3be827 100644 --- a/apps/desktop/assets/codex-bridge/bridge.py +++ b/apps/desktop/assets/codex-bridge/bridge.py @@ -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"(? 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"(?{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. diff --git a/apps/desktop/assets/codex-bridge/ide_catalog.py b/apps/desktop/assets/codex-bridge/ide_catalog.py index fb4687f..ca85231 100644 --- a/apps/desktop/assets/codex-bridge/ide_catalog.py +++ b/apps/desktop/assets/codex-bridge/ide_catalog.py @@ -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 diff --git a/apps/desktop/assets/codex-bridge/model_gecis.py b/apps/desktop/assets/codex-bridge/model_gecis.py index 94a77ea..902977a 100644 --- a/apps/desktop/assets/codex-bridge/model_gecis.py +++ b/apps/desktop/assets/codex-bridge/model_gecis.py @@ -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): diff --git a/apps/desktop/assets/codex-bridge/ozet_kopru.py b/apps/desktop/assets/codex-bridge/ozet_kopru.py index cac60a4..83b4190 100644 --- a/apps/desktop/assets/codex-bridge/ozet_kopru.py +++ b/apps/desktop/assets/codex-bridge/ozet_kopru.py @@ -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 diff --git a/apps/desktop/assets/codex-bridge/pencere.py b/apps/desktop/assets/codex-bridge/pencere.py index f4ecbd8..3a1d751 100644 --- a/apps/desktop/assets/codex-bridge/pencere.py +++ b/apps/desktop/assets/codex-bridge/pencere.py @@ -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 diff --git a/apps/desktop/assets/codex-bridge/resim_context.py b/apps/desktop/assets/codex-bridge/resim_context.py index 33cde0d..d877ef0 100644 --- a/apps/desktop/assets/codex-bridge/resim_context.py +++ b/apps/desktop/assets/codex-bridge/resim_context.py @@ -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'] \ diff --git a/apps/desktop/assets/codex-bridge/test_combo_tekrar.py b/apps/desktop/assets/codex-bridge/test_combo_tekrar.py index 842ad71..311f9b2 100644 --- a/apps/desktop/assets/codex-bridge/test_combo_tekrar.py +++ b/apps/desktop/assets/codex-bridge/test_combo_tekrar.py @@ -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") diff --git a/apps/desktop/assets/codex-bridge/test_denetim_ek.py b/apps/desktop/assets/codex-bridge/test_denetim_ek.py new file mode 100644 index 0000000..bf15f39 --- /dev/null +++ b/apps/desktop/assets/codex-bridge/test_denetim_ek.py @@ -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() diff --git a/apps/desktop/assets/codex-bridge/yarim.py b/apps/desktop/assets/codex-bridge/yarim.py index 93a87e3..d647a89 100644 --- a/apps/desktop/assets/codex-bridge/yarim.py +++ b/apps/desktop/assets/codex-bridge/yarim.py @@ -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: