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:
1 parent
e2793fa38f
commit
7c0dc467d5
10 files changed
+731
-101
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user