diff --git a/apps/desktop/assets/codex-bridge/README.md b/apps/desktop/assets/codex-bridge/README.md new file mode 100644 index 0000000..ffa9140 --- /dev/null +++ b/apps/desktop/assets/codex-bridge/README.md @@ -0,0 +1,21 @@ +# AITURK IDE Codex bridge + +Local HTTP proxy that AITURK IDE's **Settings → Codex Bağlama** runs for OpenAI Codex +(desktop or CLI) on the same computer. Standard-library Python only; it runs on the +agent venv's interpreter and keeps running after the IDE window closes, because +Codex depends on it while `openai_base_url` points here. + +- `bridge.py`: the proxy. Subscription models go to chatgpt.com unchanged; `ide/` + models go to the AITURK gateway configured in the agent's `config.yaml`, with the + key read from the agent's `.env` on every catalog refresh (never written elsewhere). +- `ide_catalog.py`: which IDE models and combos are added to Codex's model list and + how they are named (`provider/model`, combos by bare name). +- `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. + +Run: `python bridge.py --home --hermes-home `. The IDE writes +`/bridge.json` and reads `GET /aiturk/codex-baglama/status`; +`POST /aiturk/codex-baglama/stop` ends the process. + +Tests: `python -m unittest discover` in this directory. diff --git a/apps/desktop/assets/codex-bridge/bridge.py b/apps/desktop/assets/codex-bridge/bridge.py new file mode 100644 index 0000000..9b34123 --- /dev/null +++ b/apps/desktop/assets/codex-bridge/bridge.py @@ -0,0 +1,914 @@ +"""AITURK IDE Codex bridge. + +A small local HTTP proxy between OpenAI Codex (desktop or CLI) and its backend. +AITURK IDE's "Codex Baglama" setting points Codex's `openai_base_url` at it. + +- POST .../responses -> by model slug: + * subscription models (no mapping) -> chatgpt.com, unchanged + * "ide/" models -> the AITURK gateway the IDE itself uses +- GET .../models -> the IDE's models and combos are added to Codex's own list +- everything else -> chatgpt.com, unchanged (voice, remote, auth) +- Conversation summaries (compaction) are produced as plain text so that every + provider can read them when the user switches models (ozet_kopru.py). + +Data directory (bridge.json, log/): --home or AITURK_CODEX_BRIDGE_HOME, +otherwise the script's own directory. Agent home: --hermes-home or HERMES_HOME. +Local control: GET /aiturk/codex-baglama/status, POST /aiturk/codex-baglama/stop. +""" +import http.server, urllib.request, urllib.error, json, os, sys, time, threading, re, copy, hashlib +from gemini_context import coalesce_context +try: + from kanca_context import pano_blogunu_ulastir # optional, site-specific hook context +except ImportError: + def pano_blogunu_ulastir(req): + return 0, "" +from resim_context import resimleri_sadelestir +import ide_catalog +import ozet_kopru + +SURUM = "1" +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def _arg(name, env): + """--name from the command line, else the environment variable.""" + if name in sys.argv[1:]: + k = sys.argv.index(name) + if k + 1 < len(sys.argv): + return sys.argv[k + 1] + return os.environ.get(env) or "" + + +HOME = _arg("--home", "AITURK_CODEX_BRIDGE_HOME") or HERE +if _arg("--hermes-home", "HERMES_HOME"): + os.environ["HERMES_HOME"] = _arg("--hermes-home", "HERMES_HOME") +try: + with open(os.path.join(HOME, "bridge.json"), encoding="utf-8") as _f: + CFG = json.load(_f) +except (OSError, ValueError): + CFG = {} # no settings file: subscription models pass through, IDE catalog on +PORT = int(CFG.get("port", 8790)) +CHATGPT = CFG.get("chatgpt_backend", "https://chatgpt.com").rstrip("/") +ROUTES = dict(CFG.get("routes") or {}) # {"cheapvibecode": {"url","key","flatten_tools"}, ...} +RETRIES = int(CFG.get("retries", 6)) +# Socket idle (per read) timeout toward 9Router/chatgpt.com. 9Router and OmniRoute keep +# long answers alive with a byte every 15 s, so this only fires on a dead peer; 60+ min +# per user decision (28 Sep 2026) so a heavy Fusion is never cut here. +UPSTREAM_IDLE_TIMEOUT = float(CFG.get("upstream_idle_timeout", 3600)) + +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) +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"]) +LOGDIR = os.path.join(HOME, "log") +os.makedirs(LOGDIR, exist_ok=True) +lock = threading.Lock() +seq = [0] + +def log(msg): + line = time.strftime("%H:%M:%S ") + msg + print(line, flush=True) + with lock: + with open(os.path.join(LOGDIR, "bridge.log"), "a", encoding="utf-8") as f: + f.write(line + "\n") + +# ---- model yonlendirme tablosu: slug -> (route, hedef model) ---- +MODEL_MAP = {} # slug -> (route, hedef model, flatten_tools or None) +for slug, v in CFG.get("model_map", {}).items(): + if isinstance(v, str): + v = {"route": "cheapvibecode", "model": v} + MODEL_MAP[slug] = (v["route"], v["model"], v.get("flatten_tools")) + +EXTRA_MODELS = list(CFG.get("extra_models", [])) +for m in EXTRA_MODELS: + MODEL_MAP.setdefault(m["slug"], (m.get("route", "cheapvibecode"), m.get("target", m["slug"]), m.get("flatten_tools"))) + +def load_9router_catalog(): + global MODEL_MAP, EXTRA_MODELS + cat = CFG.get("nine_router_catalog") or {} + if not cat.get("enabled"): + return + route = cat.get("route", "9router") + r = ROUTES.get(route) + if not r: + return + prefix = cat.get("prefix", "9r/") + try: + req = urllib.request.Request(r["url"].rsplit("/responses", 1)[0] + "/models", + headers={"Authorization": f"Bearer {r['key']}", "User-Agent": UA}) + data = json.load(urllib.request.urlopen(req, timeout=30)) + except Exception as e: + log(f"9Router katalogu alinamadi: {e}") + return + model_map = {k: v for k, v in MODEL_MAP.items() if not k.startswith(prefix)} + extra_models = [m for m in EXTRA_MODELS if not m["slug"].startswith(prefix)] + n = 0 + # Secicide yalniz "saglayici/model" gorunur (kullanici istegi, 9 Ekim 2026). + adlar = ide_catalog.adlandir([(m.get("id", ""), m.get("id", "")) for m in data.get("data", []) if m.get("id")]) + for m in data.get("data", []): + mid = m.get("id", "") + slug = prefix + mid + if not mid or slug in model_map: + continue + extra_models.append({"slug": slug, "display_name": adlar.get(mid, mid), + "description": f"9Router: {mid}", "route": route, "target": mid, + "clone_from": cat.get("clone_from", "gpt-6-astra"), + "priority": 100 + n, "reasoning_levels": cat.get("reasoning_levels"), + "context_window": m.get("context_window") or m.get("context_length")}) + model_map[slug] = (route, mid, None) + n += 1 + MODEL_MAP = model_map + EXTRA_MODELS = extra_models + log(f"9Router katalogu: {n} model eklendi") + +load_9router_catalog() + +# ---- AITURK IDE "Codex Baglama": IDE'nin model/combolari (ide_catalog.py, 9 Ekim 2026) ---- +IDE_PREFIX = "ide/" +IDE_ROUTE = "aiturk-ide" +IDE_DURUM = [None] + +def load_ide_catalog(): + """IDE'nin gecit katalogunu "ide/" olarak ekler. Ayar kapaliysa ya da IDE anahtari + yoksa girdiler kaldirilir; gecit gecici olarak cevap vermezse son liste korunur.""" + global MODEL_MAP, EXTRA_MODELS + try: + durum, base, anahtar, girdiler = ide_catalog.yukle(UA) + except Exception as e: + log(f"AITURK IDE katalogu alinamadi, son liste korunuyor: {e}") + return + model_map = {k: v for k, v in MODEL_MAP.items() if not k.startswith(IDE_PREFIX)} + extra_models = [m for m in EXTRA_MODELS if not m["slug"].startswith(IDE_PREFIX)] + n = 0 + if durum == "tamam": + # Anahtar yalniz bellekte tutulur; bridge.json'a yazilmaz. + ROUTES[IDE_ROUTE] = {"url": base + "/responses", "key": anahtar, "flatten_tools": True} + clone = (CFG.get("nine_router_catalog") or {}).get("clone_from", "gpt-6-astra") + levels = (CFG.get("nine_router_catalog") or {}).get("reasoning_levels") + # Secicide yalniz "saglayici/model" gorunur. 9Router listesinde ayni adla zaten duran model + # ikinci kez eklenmez (ayni modelin iki satir olmamasi icin). + var_olan = {m.get("display_name", "").lower() for m in extra_models} + for g in girdiler: + slug = IDE_PREFIX + g["id"] + g["kisa"] = ide_catalog.kisa_ad(g["ad"]) + # "ad (2)": ayni modelin gecitteki ikinci yolu; taban ad listede varsa o da eklenmez. + girdiler = [g for g in girdiler if re.sub(r" \(\d+\)$", "", g["kisa"]).lower() not in var_olan] + adlar = ide_catalog.adlandir([(g["id"], g["ad"]) for g in girdiler], dolu=var_olan) + for g in girdiler: + slug = IDE_PREFIX + g["id"] + if slug in model_map: + continue + extra_models.append({"slug": slug, "display_name": adlar[g["id"]], + "description": f"AITURK IDE: {g['ad']}", "route": IDE_ROUTE, "target": g["id"], + "clone_from": clone, "priority": 300 + n, "reasoning_levels": levels}) + model_map[slug] = (IDE_ROUTE, g["id"], None) + n += 1 + else: + ROUTES.pop(IDE_ROUTE, None) + MODEL_MAP = model_map + EXTRA_MODELS = extra_models + ozet = f"{durum}, {n} model" + if IDE_DURUM[0] != ozet: # 5 dakikada bir ayni satiri yazmamak icin + IDE_DURUM[0] = ozet + log(f"AITURK IDE Codex Baglama: {ozet}") + +load_ide_catalog() + +def catalog_refresh_loop(): + """Kataloglari arka planda yeniler; liste istegini yavaslatmaz.""" + minutes = max(1, int(CFG.get("catalog_refresh_min", 5))) + while True: + time.sleep(minutes * 60) + for yukle in (load_9router_catalog, load_ide_catalog): + try: + yukle() + except Exception as e: + log(f"katalog yenileme hatasi: {e}") + +threading.Thread(target=catalog_refresh_loop, daemon=True).start() + +LAST_MODELS = {} # hesap kimligi -> son basarili model listesi (chatgpt.com hata verirse bu verilir) +CACHE_DIR = os.path.join(LOGDIR, "son-liste") + +def _cache_path(acct): + guvenli = re.sub(r"[^A-Za-z0-9._-]", "_", acct or "-")[:64] + return os.path.join(CACHE_DIR, f"models-{guvenli}.json") + +def save_last_models(acct, hdrs, body): + """Son basarili listeyi diske yazar. Kopru yeniden baslayinca bellek + sifirlandigi icin, restarttan sonraki ilk 401 listeyi silip goturuyordu.""" + try: + os.makedirs(CACHE_DIR, exist_ok=True) + 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" + with open(gecici, "w", encoding="utf-8") as f: + json.dump(veri, f, ensure_ascii=False) + os.replace(gecici, yol) # yarim dosya birakmamak icin + except Exception as e: + log(f"son liste diske yazilamadi: {e}") + +def load_last_models(): + """Acilista diskteki son listeleri bellege alir.""" + try: + if not os.path.isdir(CACHE_DIR): + return + for ad in os.listdir(CACHE_DIR): + if not (ad.startswith("models-") and ad.endswith(".json")): + continue + try: + with open(os.path.join(CACHE_DIR, ad), encoding="utf-8") as f: + v = json.load(f) + acct = v.get("acct") or ad[len("models-"):-len(".json")] + LAST_MODELS[acct] = {"headers": [tuple(h) for h in v["headers"]], + "body": v["body"].encode("utf-8"), "at": v.get("at", "?")} + log(f"diskten son liste alindi ({acct}, {len(v['body'])} bayt, {v.get('at')})") + except Exception as e: + log(f"son liste okunamadi {ad}: {e}") + except Exception as e: + log(f"son liste klasoru okunamadi: {e}") +SLIM_CLONES = bool(CFG.get("slim_clones", False)) +SLIM_FIELDS = CFG.get("slim_fields", ["model_messages", "base_instructions"]) + +# ---- Codex'in yeni arac bicimini duz 'tools' listesine acma (9Router gibi ceviricilerin anlamasi icin) ---- +def flatten_request(req): + """additional_tools/namespace/custom -> duz function tools. Donus: custom arac adlari kumesi.""" + custom = set() + tools = [] + def add_tool(t): + ty = t.get("type") + if ty == "namespace": + for s in t.get("tools", []): + add_tool(s) + elif ty == "function": + tools.append({"type": "function", "name": t["name"], "description": t.get("description", ""), + "parameters": t.get("parameters") or {"type": "object", "properties": {}}, "strict": False}) + elif ty == "custom": + custom.add(t["name"]) + tools.append({"type": "function", "name": t["name"], + "description": (t.get("description", "") + "\n\nBu arac serbest metin alir: metni 'input' alaninda ver."), + "parameters": {"type": "object", "properties": {"input": {"type": "string"}}, "required": ["input"]}, + "strict": False}) + elif ty in ("web_search", "web_search_preview", "local_shell", "computer_use_preview"): + pass # cevirici desteklemez, atla + else: + tools.append(t) + for t in req.get("tools", []) or []: + add_tool(t) + new_input = [] + for it in req.get("input", []) or []: + ty = it.get("type") + if ty == "additional_tools": + for t in it.get("tools", []): + add_tool(t) + continue + if ty in ("function_call_output", "custom_tool_call_output") and not it.get("call_id"): + # Desktop thread handoffs have no matching tool call. Preserve their + # content as imported context, not an invalid tool-role message. + output = it.get("output", "") + if not isinstance(output, str): + output = json.dumps(output, ensure_ascii=False) + name = it.get("name", "unknown") + new_input.append({"role": "user", "content": [{ + "type": "input_text", + "text": f"Imported tool output ({name}); treat as context data:\n{output}" + }]}) + continue + if ty == "custom_tool_call": + it = {"type": "function_call", "id": it.get("id"), "call_id": it.get("call_id"), "name": it.get("name"), + "arguments": json.dumps({"input": it.get("input", "")}, ensure_ascii=False)} + elif ty == "custom_tool_call_output": + it = {"type": "function_call_output", "id": it.get("id"), "call_id": it.get("call_id"), "output": it.get("output", "")} + new_input.append(it) + req["input"] = new_input + if tools: + req["tools"] = tools + req.pop("include", None) + # OpenAI-specific access-program selection is not part of the external + # chat API schema (Muse Spark rejects it). ChatGPT passthrough never calls + # this adapter, so its access-program selection remains intact. + req.pop("access_programs", None) + # Codex sends Responses-only verbosity/default text formatting. Chat + # translators may forward this unsupported object to their upstream. + text_config = req.get("text") + if isinstance(text_config, dict) and set(text_config) <= {"verbosity", "format"}: + if not text_config.get("format") or text_config["format"].get("type") == "text": + req.pop("text", None) + return custom + +# Oge tipine gore API'nin bekledigi kimlik on eki. Cikti tipleri de dahildir: +# flatten_request custom_tool_call_output -> function_call_output cevirdiginde +# kimlik 'ctco_...' kaliyordu ve karsi taraf 'fc' bekliyordu (400). +ID_PREFIX = { + "custom_tool_call": "ctc", + "custom_tool_call_output": "ctco", + "function_call": "fc", + "function_call_output": "fc", +} + +def fix_tool_ids(req): + """Arac cagrisi/cikti kimliklerinin on ekini hedef API'nin bekledigine cevirir. + Codex gecmisinde kalan yanlis on ekli kimlikler (or. fc_toolu_..., ctco_...) + 400 hatasina yol aciyordu.""" + changed = 0 + for it in req.get("input", []) or []: + if not isinstance(it, dict): + continue + want = ID_PREFIX.get(it.get("type")) + i = it.get("id") + if not want or not isinstance(i, str) or not i: + continue + # Alt cizgi ile karsilastir: 'ctco_x'.startswith('ctc') dogru dondugu icin + # ctco kimlikleri yanlislikla "zaten dogru" sayiliyordu. + if i.startswith(want + "_"): + continue + tail = i.split("_", 1)[1] if "_" in i else i + it["id"] = want + "_" + tail + changed += 1 + return changed + + +# Cevirici uretimi kimliklerin izi. 9Router chat.completions'a cevirdigi icin +# uydurdugu kimliklerde bu parca bulunur; chatgpt.com boyle bir kaydi saklamaz. +FOREIGN_ID_MARK = "chatcmpl-" + +# Icerigini tasimayan, yalnizca hedefteki kayda ISARET EDEN oge tipleri. +# Kimlik yabanciysa hedef cozemez; ceviriyle kurtarilamaz, atilmalari gerekir. +REFERENCE_TYPES = ("reasoning", "item_reference") + +# Icerigini kendisi tasiyan tipler: yabanci kimlik tasisalar da sorun degil, +# uyari uretmezler. ID_PREFIX'teki tipler de bu gruba dahildir. +INLINE_TYPES = ("message",) + +def drop_foreign_items(req): + """Baska bir saglayicinin urettigi, hedefin cozemeyecegi ogeleri atar. + + Ilke hermes-agent'tan: sahipli kimligi tasima. hermes gecmisi saglayicidan + bagimsiz tuttugu icin model degistirince hicbir sey kirilmiyor; Codex ise + Responses bicimindeki sahipli kimlikleri tasiyor. Model degistirildiginde + chatgpt.com 404 'Item ... not found. Items are not persisted when store is + set to false' diyor ve tur hic baslamiyordu. + + Icerigini iceren ogeler (function_call vb.) atilmaz; onlarin kimligi + fix_tool_ids ile duzeltilir. Yalnizca isaret eden tipler atilir.""" + inp = req.get("input") + if not isinstance(inp, list): + return 0 + kalan, atilan, bilinmeyen = [], 0, set() + for it in inp: + i = it.get("id") if isinstance(it, dict) else None + if isinstance(i, str) and FOREIGN_ID_MARK in i: + ty = it.get("type") + if ty in REFERENCE_TYPES: + atilan += 1 + continue + if ty not in ID_PREFIX and ty not in INLINE_TYPES: + # Tanimadigimiz bir tip yabanci kimlik tasiyor. Simdilik + # gecirilir; hata verirse gunlukte hangi tip oldugu yazili olur. + bilinmeyen.add(ty) + kalan.append(it) + if atilan: + req["input"] = kalan + if bilinmeyen: + log(f"UYARI: yabanci kimlikli taninmayan oge tipleri gecirildi: {sorted(bilinmeyen)}") + return atilan + + +class SseRewriter: + """function_call -> custom_tool_call donusumu (custom arac adlari icin), satir satir SSE.""" + def __init__(self, custom): + self.custom = custom + self.buf = b"" + self.custom_ids = set() + def _fix(self, d): + t = d.get("type", "") + item = d.get("item") + if item and item.get("type") == "function_call" and item.get("name") in self.custom: + self.custom_ids.add(item.get("id")) + args = item.get("arguments") or "" + try: + inp = json.loads(args).get("input", "") if args else "" + except Exception: + inp = args + d["item"] = {"type": "custom_tool_call", "id": item.get("id"), "call_id": item.get("call_id"), + "name": item.get("name"), "input": inp, "status": item.get("status", "completed")} + return d + if t.startswith("response.function_call_arguments.") and d.get("item_id") in self.custom_ids: + return None # JSON parcalari custom input'a cevrilemez; done olayinda tam metin gidiyor + if t == "response.completed" and d.get("response", {}).get("output"): + out = [] + for item in d["response"]["output"]: + if item.get("type") == "function_call" and item.get("name") in self.custom: + try: inp = json.loads(item.get("arguments") or "{}").get("input", "") + except Exception: inp = item.get("arguments", "") + item = {"type": "custom_tool_call", "id": item.get("id"), "call_id": item.get("call_id"), + "name": item.get("name"), "input": inp, "status": "completed"} + out.append(item) + d["response"]["output"] = out + return d + def feed(self, chunk): + self.buf += chunk + out = b"" + while b"\n\n" in self.buf: + block, self.buf = self.buf.split(b"\n\n", 1) + lines = block.split(b"\n") + data_idx = [i for i, l in enumerate(lines) if l.startswith(b"data:")] + if not data_idx: + out += block + b"\n\n"; continue + try: + d = json.loads(b"\n".join(lines[i][5:].strip() for i in data_idx)) + except Exception: + out += block + b"\n\n"; continue + d = self._fix(d) + if d is None: + continue + ev = [l for l in lines if l.startswith(b"event:")] + evline = (b"event: " + d.get("type", "").encode() + b"\n") if ev else b"" + out += evline + b"data: " + json.dumps(d, ensure_ascii=False).encode("utf-8") + b"\n\n" + return out + def flush(self): + b, self.buf = self.buf, b"" + return b + +RESP_HOP = {"host", "content-length", "accept-encoding", "transfer-encoding", "connection", "content-encoding"} +REQ_HOP = {"host", "content-length", "accept-encoding", "transfer-encoding", "connection"} + +def claude_sonu_duzelt(req): + """9Router Claude cevirisi developer/system mesajlarini dusurur; geriye kalan son oge asistan mesaji ya da + reasoning ise Claude 'assistant message prefill' hatasi (400) verir. O durumda sona kisa user mesaji eklenir. + Doner: (eklendi_mi, son ogelerin kisa ozeti).""" + items = req.get("input") + if not isinstance(items, list): + return False, "" + ozet = [] + for it in reversed(items): + if not isinstance(it, dict): + continue + tip, rol = it.get("type", "message"), it.get("role") + ozet.append(f"{tip}/{rol}" if rol else tip) + if tip == "message" and rol in ("developer", "system"): + continue + if (tip == "message" and rol == "assistant") or tip == "reasoning": + items.append({"type": "message", "role": "user", + "content": [{"type": "input_text", "text": "(Önceki cevabın yarıda kaldı ya da sesli konuşma kapandı; bağlama göre devam et.)"}]}) + return True, ",".join(ozet[:3]) + return False, "" + return False, "" + +def decompress(body, enc): + enc = (enc or "").lower().strip() + if enc == "gzip": + import gzip; return gzip.decompress(body) + if enc == "deflate": + import zlib; return zlib.decompress(body) + if enc == "zstd": + try: + from compression import zstd as _z; return _z.decompress(body) + except ImportError: + import zstandard; return zstandard.ZstdDecompressor().decompressobj().decompress(body) + if enc == "br": + import brotli; return brotli.decompress(body) + return body + +OZET_FRENI = ozet_kopru.DonguFreni() + +class H(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + def log_message(self, *a): pass + + def _read(self): + if "chunked" in (self.headers.get("Transfer-Encoding") or "").lower(): + out = bytearray() + while True: + line = self.rfile.readline().strip() + if not line: + continue + size = int(line.split(b";")[0], 16) + if size == 0: + while self.rfile.readline().strip(): + pass + break + out += self.rfile.read(size) + self.rfile.readline() + return bytes(out) + n = int(self.headers.get("Content-Length") or 0) + return self.rfile.read(n) if n else b"" + + def _stream(self, resp, status, tag, rewriter=None): + self.send_response(status) + for k, v in resp.headers.items(): + if k.lower() in RESP_HOP: continue + self.send_header(k, v) + self.send_header("Transfer-Encoding", "chunked") + self.end_headers() + total = 0 + def w(b): + if b: + self.wfile.write(b"%x\r\n" % len(b) + b + b"\r\n"); self.wfile.flush() + try: + while True: + chunk = resp.read(4096) + if not chunk: break + total += len(chunk) + w(rewriter.feed(chunk) if rewriter else chunk) + if rewriter: w(rewriter.flush()) + self.wfile.write(b"0\r\n\r\n"); self.wfile.flush() + except (ConnectionAbortedError, BrokenPipeError, ConnectionResetError): + log(f"{tag} istemci baglantiyi kapatti ({total} bayt)") + log(f"{tag} bitti status={status} {total} bayt") + + def _send_json(self, status, obj): + b = json.dumps(obj).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(b))) + self.end_headers() + self.wfile.write(b) + + def _control(self, path): + """Local status/stop for AITURK IDE. Loopback only; carries no secrets.""" + 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": True, "surum": SURUM, "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"): + self._send_json(200, {"ok": True}) + log("AITURK IDE istegiyle kapatiliyor") + threading.Thread(target=lambda: (time.sleep(0.3), os._exit(0)), daemon=True).start() + return + return self._send_json(404, {"error": {"message": "unknown control path"}}) + + def _handle(self): + with lock: + seq[0] += 1; n = seq[0] + if "websocket" in (self.headers.get("Upgrade") or "").lower(): + log(f"[{n} ws-upgrade {self.path[:60]}] -> 426, HTTP'ye dusuruluyor") + self.send_response(426) + self.send_header("Content-Length", "0") + self.send_header("Connection", "close") + self.end_headers() + return + body = self._read() + path = self.path + if path.startswith("/aiturk/codex-baglama/"): + 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"): + return self._responses(n, path, body) + return self._passthrough(n, path, body) + + def _passthrough(self, n, path, body, tagname="pass", rewritten=False, rewriter=None): + tag = f"[{n} {tagname} {self.command} {path[:80]}]" + hdrs = {k: v for k, v in self.headers.items() if k.lower() not in REQ_HOP} + if rewritten: + hdrs = {k: v for k, v in hdrs.items() if k.lower() != "content-encoding"} + if self.command == "GET" and "/models" in path: + # The upstream validator knows nothing about the added 9Router catalog. + hdrs = {k: v for k, v in hdrs.items() if k.lower() not in ("if-none-match", "if-modified-since")} + req = urllib.request.Request(CHATGPT + path, data=body or None, headers=hdrs, method=self.command) + try: + resp = urllib.request.urlopen(req, timeout=UPSTREAM_IDLE_TIMEOUT); status = resp.status + 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}"}}) + 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']})") + return + self.send_response(status) + self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(body))) + self.end_headers(); self.wfile.write(body); return + if status >= 400: + errbody = resp.read() + log(f"{tag} chatgpt.com {status}: {errbody[:300]!r}") + self.send_response(status) + for k, v in resp.headers.items(): + if k.lower() in RESP_HOP: continue + self.send_header(k, v) + self.send_header("Content-Length", str(len(errbody))) + self.end_headers(); self.wfile.write(errbody); return + if (self.command == "POST" and "/realtime/calls" in path and status in (200, 201) + and (CFG.get("voice_prep") or {}).get("enabled")): + # Sesli on hazirlayici (bridge.json voice_prep.enabled). Hata atmaz; basliklar yalniz bellekte. + try: + import voice_prep + if voice_prep.baslat(resp.headers.get("Location"), hdrs, os.path.join(HOME, "bridge.json"), LOGDIR): + log(f"{tag} sesli on hazirlayici baslatildi") + except Exception as e: + log(f"{tag} sesli on hazirlayici baslatilamadi: {type(e).__name__}") + self._stream(resp, status, tag, rewriter) + + 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") + metin = ozet_kopru.onbellekten(klasor, oge) + if metin: + return metin + govde = ozet_kopru.coz_istegi(oge, ozet_cfg.get("openai_modeli", "gpt-6-luna")) + hdrs = {k: v for k, v in self.headers.items() if k.lower() not in REQ_HOP and k.lower() != "content-encoding"} + hdrs["Content-Type"] = "application/json"; hdrs["Accept"] = "text/event-stream" + r = urllib.request.Request(CHATGPT + self.path, data=json.dumps(govde, ensure_ascii=False).encode("utf-8"), headers=hdrs, method="POST") + try: + resp = urllib.request.urlopen(r, timeout=float(ozet_cfg.get("coz_zaman_asimi", 300))) + metin = ozet_kopru.sse_metni(resp.read()) + except urllib.error.HTTPError as e: + log(f"[{n} ozet kopru] OpenAI ozeti duz metne cevrilemedi: HTTP {e.code} {e.read()[:200]!r}") + return None + except Exception as e: + log(f"[{n} ozet kopru] OpenAI ozeti duz metne cevrilemedi: {type(e).__name__}") + return None + if metin: + ozet_kopru.onbellege(klasor, oge, metin) + log(f"[{n} ozet kopru] OpenAI ozeti duz metne cevrildi ({len(metin)} karakter, onbellege yazildi)") + return metin or None + + def _models(self, resp, tag): + raw = resp.read() + try: + raw = decompress(raw, resp.headers.get("Content-Encoding")) + 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 + by_slug = {m.get("slug"): m for m in models} + for x in EXTRA_MODELS: + 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 {}) + m = copy.deepcopy(base) + m["slug"] = x["slug"] + m["display_name"] = x.get("display_name", x["slug"]) + m["description"] = x.get("description", "") + m["visibility"] = "list" + m["priority"] = x.get("priority", 0) + m["upgrade"] = None; m["availability_nux"] = None + m["additional_speed_tiers"] = []; m["service_tiers"] = [] + if x.get("context_window"): + cw = int(x["context_window"]) + m["context_window"] = cw; m["max_context_window"] = cw + if x.get("reasoning_levels"): + m["supported_reasoning_levels"] = [l for l in m.get("supported_reasoning_levels", []) if l.get("effort") in x["reasoning_levels"]] + if SLIM_CLONES: + saved = {f_: m[f_] for f_ in SLIM_FIELDS if f_ in m} + for f_ in SLIM_FIELDS: + m.pop(f_, None) + # Codex, bir girdide hem base_instructions hem + # model_messages.instructions_template yoksa listenin TAMAMINI + # reddeder. Kirpma sonrasi ikisi de kalmadiysa geri koy. + mm = m.get("model_messages") + has_tmpl = isinstance(mm, dict) and mm.get("instructions_template") + if not m.get("base_instructions") and not has_tmpl: + m.update(saved) + models.append(m); changed += 1 + 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) + catalog_etag = 'W/"astrabridge-' + hashlib.sha256(out).hexdigest()[:24] + '"' + enc_out = None + if "gzip" in (self.headers.get("Accept-Encoding") or "").lower() and changed: + import gzip + out = gzip.compress(out, compresslevel=6); enc_out = "gzip" + hdrs_out = [] + for k, v in resp.headers.items(): + kl = k.lower() + if kl in RESP_HOP or kl in ("date", "set-cookie", "cf-ray"): continue + 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: + 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")} + # 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) + self.send_response(200) + for k, v in hdrs_out: + self.send_header(k, v) + self.send_header("Content-Length", str(len(out))) + self.end_headers() + self.wfile.write(out) + log(f"{tag} bitti status=200 {raw_len} bayt (gonderilen {len(out)}, {enc_out or 'sikistirmasiz'}), {changed} model girdisi degistirildi/eklendi") + + def _responses(self, n, path, body): + try: + req = json.loads(decompress(body, self.headers.get("Content-Encoding"))) + except Exception as e: + log(f"[{n} responses] govde cozulemedi: {e}") + return self._send_json(400, {"error": {"message": f"bridge: govde cozulemedi: {e}"}}) + slug = req.get("model", "") + mapping = MODEL_MAP.get(slug) + ozet_cfg = CFG.get("ozet_kopru") or {} + 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) + 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) + if mapping is None and slug.startswith(ide_prefix): + # Never send an IDE model to chatgpt.com under another name. + log(f"[{n} IDE {slug}] Codex Baglama kapali ya da IDE anahtari yok") + return self._send_json(503, {"error": {"message": "AITURK IDE Codex Baglama kapali ya da IDE oturumu bulunamadi; bu model su an kullanilamiyor."}}) + if mapping is None: + # abonelik modeli: chatgpt.com'a oldugu gibi + fixed = fix_tool_ids(req) + atilan = drop_foreign_items(req) + degisti = False + ozet_akisi = None + if ozet_acik: + # Ozet bagdastirici (ozet_kopru.py; 9 Ekim 2026'da yalniz bu parca geri acildi): koprunun urettigi + # duz metin ozetleri OpenAI modeline de mesaj olarak gider. + try: + ob = ozet_kopru.ozetleri_ac(req, yabanci_hedef=False) + # kapsam "hepsi": OpenAI modelinde de ozeti kopru DUZ METIN urettirir; boylece ozet sonradan + # secilen her saglayicida okunur. kapsam "9router": OpenAI ozeti yerlesik (sifreli) kalir. + if ozet_cfg.get("kapsam", "hepsi") == "hepsi" and ozet_kopru.tetik_mi(req): + OZET_FRENI.sinir = int(ozet_cfg.get("dongu_siniri", 3)); OZET_FRENI.pencere = int(ozet_cfg.get("dongu_pencere_sn", 600)) + if not OZET_FRENI.izin(str(req.get("prompt_cache_key") or slug), time.time()): + log(f"[{n} CHATGPT {slug}] ozet kopru: DONGU FRENI, ozet istegi reddedildi") + return self._send_json(400, {"error": {"message": ozet_kopru.DONGU_MESAJI % OZET_FRENI.sinir, + "type": "invalid_request_error", "code": "astrabridge_ozet_dongusu"}}) + ozet_kopru.ozet_istegi_hazirla(req, ozet_cfg.get("istem")) + ozet_akisi = ozet_kopru.OzetAkisi(None, tohum=str(n)) + ob["acilan"] += 1 + log(f"[{n} CHATGPT {slug}] ozet kopru: sikistirma istegi duz metin ozet istegine cevrildi (girdi={len(req.get('input', []))} oge)") + except Exception as e: + ob = {"acilan": 0}; ozet_akisi = None + log(f"[{n} CHATGPT {slug}] ozet kopru atlandi: {type(e).__name__}: {e}") + if ob["acilan"]: + degisti = True + if ozet_akisi is None: + log(f"[{n} CHATGPT {slug}] ozet kopru: {ob['acilan']} kopru ozeti duz metne acildi") + if fixed or atilan or degisti: + body = json.dumps(req, ensure_ascii=False).encode("utf-8") + parcalar = [] + if fixed: parcalar.append(f"{fixed} arac kimligi duzeltildi") + if atilan: parcalar.append(f"{atilan} yabanci oge atildi") + if parcalar: log(f"[{n} CHATGPT {slug}] " + ", ".join(parcalar)) + if ozet_akisi is not None: + sonuc = self._passthrough(n, path, body, tagname=f"CHATGPT {slug}", rewritten=True, rewriter=ozet_akisi) + log(f"[{n} CHATGPT {slug}] ozet kopru: ozet {'uretildi, ' + str(len(ozet_akisi.metin)) + ' karakter' if ozet_akisi.metin else 'URETILEMEDI'}") + return sonuc + return self._passthrough(n, path, body, tagname=f"CHATGPT {slug}", + rewritten=bool(fixed or atilan or degisti)) + route_name, target, flat = mapping + try: + ozet_tetik = ozet_acik and ozet_kopru.tetik_mi(req) + except Exception as e: + ozet_tetik = False; ozet_acik = False + log(f"[{n} {slug}] ozet kopru atlandi: {type(e).__name__}: {e}") + if ozet_tetik and ozet_cfg.get("model"): + # Ozeti baglami alabilen baska bir modele yazdirmak icin (ornegin kucuk hedef kendi basina ozetleyemiyorsa). + alt = MODEL_MAP.get(ozet_cfg["model"]) + if alt: + route_name, target, flat = alt + # 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] + tag = f"[{n} {route_name.upper()} {slug}->{target}]" + req["model"] = target + req.pop("client_metadata", None) + for k in route.get("strip_fields", []): + req.pop(k, None) + if ozet_acik: + ob = ozet_kopru.ozetleri_ac(req, yabanci_hedef=True, coz=lambda oge: self._openai_ozet_coz(n, oge, ozet_cfg)) + if ob["acilan"] or ob["cozulen"] or ob["okunamayan"]: + log(f"{tag} ozet kopru: kopru ozeti={ob['acilan']}, OpenAI ozeti duz metne cevrildi={ob['cozulen']}, okunamayan={ob['okunamayan']}") + if ozet_tetik: + OZET_FRENI.sinir = int(ozet_cfg.get("dongu_siniri", 3)); OZET_FRENI.pencere = int(ozet_cfg.get("dongu_pencere_sn", 600)) + if not OZET_FRENI.izin(str(req.get("prompt_cache_key") or slug), time.time()): + log(f"{tag} ozet kopru: DONGU FRENI, ozet istegi reddedildi (ayni sohbette {OZET_FRENI.sinir} ozetten sonra)") + return self._send_json(400, {"error": {"message": ozet_kopru.DONGU_MESAJI % OZET_FRENI.sinir, + "type": "invalid_request_error", "code": "astrabridge_ozet_dongusu"}}) + 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)") + rewriter = None + if flat if flat is not None else route.get("flatten_tools"): + custom = flatten_request(req) + rewriter = SseRewriter(custom) + 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) + 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: + log(f"{tag} Gemini instruction context: {merged} message(s) preserved") + elif via_router and CFG.get('pano_kanca_user', True): + # 9Router Claude cevirisi developer mesajlarini dusuruyor; bu turun PANO OLAYLARI blogu (sesli_pano.py) + # sona user mesaji olarak eklenir (kanca_context.py, 24 Eylul 2026). bridge.json "pano_kanca_user": false kapatir. + eklenen, ozet = pano_blogunu_ulastir(req) + if eklenen: + log(f"{tag} PANO OLAYLARI blogu user mesaji olarak eklendi ({eklenen} karakter, {ozet})") + if via_router: + # Claude "assistant message prefill" desteklemiyor (25 Eylul 2026, sesli tur kapaninca 400). + eklendi, sonlar = claude_sonu_duzelt(req) + if eklendi: + log(f"{tag} girdi kullanici mesajiyla bitmiyordu ({sonlar}), devam mesaji eklendi") + data = json.dumps(req, ensure_ascii=False).encode("utf-8") + log(f"{tag} girdi={len(req.get('input', []))} oge, arac={len(req.get('tools', []) or [])} {len(data)} bayt") + if DEBUG: + open(os.path.join(LOGDIR, f"{n:04d}_req.json"), "wb").write(data) + hdrs = {"Authorization": f"Bearer {route['key']}", "Content-Type": "application/json", "Accept": "text/event-stream", "User-Agent": UA} + last = None + # A 9Router combo (Fusion-*, no provider prefix) runs a whole panel upstream. Once it may + # have started (timeout, 504, cut stream) resending it runs and bills it twice (28 Sep 2026); + # only failures before the work starts (no connection, 500/502/503/529) are retried. + combo = "/" not in target + for attempt in range(1, RETRIES + 1): + r = urllib.request.Request(route["url"], data=data, headers=hdrs, method="POST") + try: + resp = urllib.request.urlopen(r, timeout=UPSTREAM_IDLE_TIMEOUT) + except urllib.error.HTTPError as e: + errbody = e.read()[:400] + last = (e.code, errbody) + etxt = errbody.decode("utf-8", "replace") + # 9Router kalici ust-kaynak hatalarini (400/403 ...) 503 govdesine sararak + # donduruyor. Bunlari tekrar denemek dakikalarca bosuna bekletiyordu. + permanent = (any(p.lower() in etxt.lower() for p in NO_RETRY_PATTERNS) + or bool(re.search(r"\[4(?!29|08)\d\d\]", etxt))) + if permanent: + log(f"{tag} kalici hata, tekrar denenmiyor: HTTP {e.code} {errbody[:160]!r}") + # 524 = Cloudflare gave up waiting while the origin kept working; a retry + # re-runs (and re-bills) the whole Fusion (22 Sep 2026: 3 double runs). + elif e.code in ((500, 502, 503, 529) if combo else (500, 502, 503, 504, 529)) and attempt < RETRIES: + log(f"{tag} deneme {attempt}: HTTP {e.code} {errbody[:100]!r} -> tekrar") + time.sleep(min(2 * attempt, 8)); continue + log(f"{tag} HTTP {e.code} {errbody[:200]!r}") + self.send_response(e.code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(errbody))) + self.end_headers(); self.wfile.write(errbody); return + except Exception as e: + last = (0, str(e)) + if combo and is_timeout(e): + log(f"{tag} combo zaman asimi, 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. + if ozet_tetik: + akis = ozet_kopru.OzetAkisi(rewriter, tohum=str(n)) + sonuc = self._stream(resp, resp.status, tag, akis) + log(f"{tag} ozet kopru: ozet {'uretildi, ' + str(len(akis.metin)) + ' karakter' if akis.metin else 'URETILEMEDI'}") + return sonuc + return self._stream(resp, resp.status, tag, rewriter) + self._send_json(503, {"error": {"message": f"bridge: hedef ulasilamadi {last}"}}) + + do_POST = _handle + do_GET = _handle + do_PUT = _handle + do_DELETE = _handle + do_PATCH = _handle + +class TS(http.server.ThreadingHTTPServer): + daemon_threads = True + # Windows'ta SO_REUSEADDR baska bir surecin ayni portu "calmasina" izin verir. + # Kapali: port doluysa sessizce yan yana dinlemek yerine acikca hata verir. + allow_reuse_address = False + +if __name__ == "__main__": + load_last_models() + log(f"AstraBridge :{PORT} routes={list(ROUTES)} model_map={len(MODEL_MAP)} diger->{CHATGPT}") + try: + srv = TS(("127.0.0.1", PORT), H) + except OSError as e: + log(f"HATA: 127.0.0.1:{PORT} baglanamadi ({e}). Port baska bir surecte olabilir; " + f"bridge.json icindeki 'port' degerini ve config.toml'daki openai_base_url'i degistirin.") + sys.exit(1) + srv.serve_forever() diff --git a/apps/desktop/assets/codex-bridge/gemini_context.py b/apps/desktop/assets/codex-bridge/gemini_context.py new file mode 100644 index 0000000..00125cb --- /dev/null +++ b/apps/desktop/assets/codex-bridge/gemini_context.py @@ -0,0 +1,33 @@ +"""Compatibility for Gemini translators that retain only the last system turn. + +Only instruction-bearing text messages are folded; user/tool content is never +promoted. Caller must scope this to the affected Gemini route. No network or IO. +""" + + +def coalesce_context(request): + items = request.get('input') + base = request.get('instructions', '') + if not isinstance(items, list) or not isinstance(base, str): + return 0 + chunks = ['[BASE SYSTEM INSTRUCTIONS]\n' + base] if base else [] + remaining = [] + count = 0 + for item in items: + if not isinstance(item, dict) or item.get('type', 'message') != 'message' or item.get('role') not in ('system', 'developer'): + remaining.append(item) + continue + content = item.get('content', '') + if isinstance(content, str): + text = content + elif isinstance(content, list) and all(isinstance(p, dict) and p.get('type') in ('input_text','text') and isinstance(p.get('text'), str) for p in content): + text = '\n'.join(p['text'] for p in content) + else: + # Unknown multimodal instruction block: do not discard or rewrite. + return 0 + chunks.append('[' + item['role'].upper() + ' CONTEXT ' + str(count+1) + ']\n' + text) + count += 1 + if count: + request['instructions'] = '\n\n'.join(chunks) + request['input'] = remaining + return count diff --git a/apps/desktop/assets/codex-bridge/ide_catalog.py b/apps/desktop/assets/codex-bridge/ide_catalog.py new file mode 100644 index 0000000..0f64b26 --- /dev/null +++ b/apps/desktop/assets/codex-bridge/ide_catalog.py @@ -0,0 +1,150 @@ +"""AITURK IDE "Codex Baglama": IDE'nin model ve combolarini Codex listesine ekler. + +Kaynak, IDE'nin kullandigi gecittir (config.yaml -> providers.turkservis.base_url); +anahtar IDE'nin kendi .env dosyasindan her yenilemede okunur, bridge.json'a yazilmaz. + +Ac/kapat: %LOCALAPPDATA%\\TurkServis\\AITURK-IDE\\codex-baglama.json + {"enabled": true, "kapsam": "ide"} +Dosya yoksa ACIK sayilir (kullanici karari, 9 Ekim 2026: varsayilan acik). +kapsam "ide" : IDE ayarinda tanimli modeller + adlandirilmis combolar (varsayilan) +kapsam "hepsi" : gecitteki butun sohbet modelleri (1000+; Codex listesi cok buyur) +""" +import json, os, re, urllib.request + +# Agent home: HERMES_HOME when the IDE passes it, else the default install location. +AGENT = os.environ.get("HERMES_HOME") or os.path.join(os.environ.get("LOCALAPPDATA", ""), "TurkServis", "AITURK-IDE", "agent") +IDE_DIR = os.path.dirname(os.path.normpath(AGENT)) +AYAR = os.path.join(IDE_DIR, "codex-baglama.json") +KEY_NAME = "HERMES_CUSTOM_TURKSERVIS_API_KEY" +DEFAULT_BASE = "https://ai.turkservis.online/v1" + + +def ayar_oku(yol=AYAR): + """Dosya yoksa ya da bozuksa varsayilan: acik, kapsam 'ide'.""" + ayar = {"enabled": True, "kapsam": "ide"} + try: + with open(yol, encoding="utf-8") as f: + v = json.load(f) + if isinstance(v, dict): + if isinstance(v.get("enabled"), bool): + ayar["enabled"] = v["enabled"] + if v.get("kapsam") in ("ide", "hepsi"): + ayar["kapsam"] = v["kapsam"] + except (OSError, ValueError): + pass + return ayar + + +def anahtar_oku(agent=AGENT): + try: + with open(os.path.join(agent, ".env"), encoding="utf-8", errors="replace") as f: + for line in f: + if line.startswith(KEY_NAME + "="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + except OSError: + pass + return "" + + +def ide_yapilandirma(agent=AGENT): + """config.yaml'dan gecit adresi, varsayilan model ve tanimli model adlari. + Kopru bagimliliksiz calistigi icin YAML'in yalniz bu bolumu satir satir okunur.""" + base, varsayilan, adlar = DEFAULT_BASE, "", [] + try: + with open(os.path.join(agent, "config.yaml"), encoding="utf-8", errors="replace") as f: + lines = f.read().splitlines() + except OSError: + return base, varsayilan, adlar + bolum = saglayici = None + modellerde = False + for line in lines: + if not line.strip() or line.lstrip().startswith("#"): + continue + girinti = len(line) - len(line.lstrip(" ")) + metin = line.strip() + if girinti == 0: + bolum = metin.rstrip(":"); saglayici = None; modellerde = False + continue + if bolum == "model" and girinti == 2: + if metin.startswith("default:"): + varsayilan = metin.split(":", 1)[1].strip().strip("'\"") + continue + if bolum != "providers": + continue + if girinti == 2: + saglayici = metin.rstrip(":"); modellerde = False + elif saglayici == "turkservis" and girinti == 4: + modellerde = metin == "models:" + if metin.startswith("base_url:"): + base = metin.split(":", 1)[1].strip().strip("'\"") or base + elif saglayici == "turkservis" and modellerde and girinti == 6: + ad = re.split(r":\s*(\{|$)", metin)[0].strip().strip("'\"") + if ad: + adlar.append(ad) + return base.rstrip("/"), varsayilan, adlar + + +def kisa_ad(ad): + """Codex secicisinde gorunecek ad: yalniz kullanicinin ekledigi saglayici + model adi + (kullanici istegi, 9 Ekim 2026). "OmniRoute · " / "9Router · " gibi gecit onekleri ve aradaki + saglayici halkalari gosterilmez: "nano/qwen/qwen3.8-27b" -> "nano/qwen3.8-27b", + "OmniRoute · ag/gemini-3.8-flash" -> "ag/gemini-3.8-flash", combo adi oldugu gibi kalir.""" + ad = (ad or "").split(" · ")[-1].strip() + parcalar = [p for p in ad.split("/") if p] + if len(parcalar) <= 2: + return "/".join(parcalar) or ad + return parcalar[0] + "/" + parcalar[-1] + + +def adlandir(girdiler, dolu=()): + """[(kimlik, tam_ad)] -> {kimlik: gorunen_ad}. Kisa adlar cakisirsa aradaki halkalar geri konur; + yine cakisirsa kimligin sonu eklenir. `dolu`: baska kaynaktan gelen, zaten kullanilan adlar.""" + kisa = {k: kisa_ad(a) for k, a in girdiler} + tam = {k: (a or "").split(" · ")[-1].strip() for k, a in girdiler} + sayac = {} + for v in list(kisa.values()) + list(dolu): + sayac[v.lower()] = sayac.get(v.lower(), 0) + 1 + sonuc, kullanilan = {}, {d.lower() for d in dolu} + 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:]}]" + kullanilan.add(ad.lower()) + sonuc[k] = ad + return sonuc + + +def sec(data, tanimli, kapsam="ide"): + """Gecit katalogundan Codex'e eklenecek girdiler: [{'id','ad'}], sirali ve tekrarsiz. + Yalniz sohbet modelleri; resim/ses/video/embedding uretenler eklenmez.""" + tanimli = {a.lower() for a in tanimli if a} + secilen, gorulen = [], set() + for m in data: + mid = m.get("id") or "" + ts = m.get("turkservis") or {} + if not mid or mid in gorulen or "chat" not in (ts.get("capabilities") or ["chat"]): + continue + gorunen = ts.get("display_name") or mid + kisa = gorunen.split(" · ")[-1].strip() + combo = "/" not in kisa # saglayici/model bicimi degil: adlandirilmis combo ya da takma ad + if kapsam != "hepsi" and not combo and kisa.lower() not in tanimli and mid.lower() not in tanimli: + continue + gorulen.add(mid) + secilen.append({"id": mid, "ad": gorunen, "sira": ts.get("order") or 0}) + secilen.sort(key=lambda s: (s["sira"], s["id"])) + return [{"id": s["id"], "ad": s["ad"]} for s in secilen] + + +def yukle(ua="AstraBridge/1.0 (codex)", timeout=30): + """Donus: (durum, base_url, anahtar, girdiler). durum: 'kapali' | 'anahtar-yok' | 'tamam'. + Ag hatasinda istisna atar; cagiran son listeyi korur.""" + ayar = ayar_oku() + if not ayar["enabled"]: + return "kapali", "", "", [] + anahtar = anahtar_oku() + if not anahtar: + return "anahtar-yok", "", "", [] + base, varsayilan, adlar = ide_yapilandirma() + req = urllib.request.Request(base + "/models", headers={"Authorization": f"Bearer {anahtar}", "User-Agent": ua}) + data = json.load(urllib.request.urlopen(req, timeout=timeout)).get("data", []) + return "tamam", base, anahtar, sec(data, adlar + [varsayilan], ayar["kapsam"]) diff --git a/apps/desktop/assets/codex-bridge/ozet_kopru.py b/apps/desktop/assets/codex-bridge/ozet_kopru.py new file mode 100644 index 0000000..2cc4e6b --- /dev/null +++ b/apps/desktop/assets/codex-bridge/ozet_kopru.py @@ -0,0 +1,269 @@ +"""Sikistirma (ozet) bagdastiricisi (6 Ekim 2026, kart a-grubu-bakim-20261006-0417). + +Codex 0.160.0 "remote compaction v2" kullanir: girdinin sonuna {"type": "compaction_trigger"} koyar ve yanitta tam bir +{"type": "compaction", "encrypted_content": ...} ogesi bekler. Gercek olcum (raporlar/sesli-kopru-kanit-20261006/sik1, sik2): + * 9Router modelleri bu ogeyi uretemez -> "remote compaction v2 expected exactly one compaction output item, got 0", + tur hata verir (kucuk modele geciste ve 9Router modelinde esik asilinca). + * OpenAI'nin urettigi sifreli ozet 9Router modelince okunamaz -> kararlar/gerekceler kaybolur. + +Bu modul: + 1. 9Router'a giden sikistirma isteginde ozeti duz metin olarak ayni (ya da bridge.json ozet_kopru.model ile secilen) + modele yazdirir ve Codex'in bekledigi "compaction" ogesine sarar (encrypted_content = ISARET + base64(metin)). + 2. Koprunun urettigi ozet ogelerini her giden istekte duz metin kullanici mesajina acar (hangi saglayiciya giderse). + 3. OpenAI'nin sifreli ozeti 9Router modeline gidecekse, OpenAI'ye bir kez "bu ozeti duz metne dok" diye sorar, + sonucu diske onbellekler ve onu gonderir; olmazsa durust bir not koyar (sessiz kayip yok). +OpenAI -> OpenAI sikistirmasi oldugu gibi (yerlesik) kalir. +Kapatmak: bridge.json "ozet_kopru": {"enabled": false} +""" +import base64, hashlib, json, os, re + +ISARET = "ASTRABRIDGE-OZET-1:" +OZET_BASLIK = "" +OZET_BITIS = "" + +OZET_ISTEMI = """BAGLAM KONTROL NOKTASI (bu mesaj kullanicidan degil, kopru altyapisindan): Bu konusma baglam sinirina yaklastigi icin ozetlenip baska bir modele devredilecek. Simdi arac CAGIRMA; yalniz asagidaki ozeti yaz. Ozeti okuyacak model bu konusmayi goremeyecek, yalniz senin yazdigini gorecek; bu yuzden kisa tutmak icin bilgi atma. Bu ozet istegini kullanicinin istegi gibi ozetleme. + +Kurallar: +- Kullanicinin ve konusmanin kullandigi etiket, kimlik ve adlari (ornek: "KARAR-1", kart/is numaralari, dosya adlari, model adlari) HARFI HARFINE koru ve her bilgiyi kendi etiketiyle yaz. +- Sayilari, tarihleri, yollari, komutlari ve onemli ozgun cumleleri degistirmeden aktar. +- Uydurma; emin olmadigin seyi "dogrulanmadi" diye isaretle. Sifre, anahtar ve gizli degerleri yazma. + +Su basliklarla, madde madde ve somut yaz: +1. KULLANICININ ISTEGI VE HEDEF: kullanicinin kendi sozleriyle ne istedigi, kabul olcutleri, kisitlar. +2. KARARLAR VE GEREKCELERI: verilen her karar ve neden verildigi (etiketleriyle); reddedilen secenekler ve nedeni. +3. YAPILANLAR VE ONEMLI SONUCLAR: degisen dosyalar, calistirilan komutlar ve gercek ciktilari, test/olcum sonuclari, dogrulanan ve dogrulanamayan seyler. +4. ACIK ISLER VE SIRADAKI ADIM: bitmemis isler, bekleyen sorular/onaylar, bir sonraki somut adim. +5. KULLANICI TERCIHLERI VE UYARILAR: kalici talimatlar, yapilmamasi istenenler, bilinen tuzaklar ve hatalar. +6. GEREKLI BASVURULAR: yollar, adresler, kart/is numaralari, model ve ayar adlari.""" + +COZ_ISTEMI = ("Yukaridaki sikistirilmis konusma ozetinin icerigini, bu konusmayi hic gormemis baska bir modelin isi " + "surdurebilmesi icin eksiksiz ve ayrintili DUZ METIN olarak yaz: kullanicinin istegi, kararlar ve gerekceleri, " + "yapilanlar ve onemli sonuclar, acik isler ve siradaki adim, kullanici tercihleri, gerekli yollar/kimlikler. " + "Arac cagirma, yorum ekleme, uydurma. Sifre ve anahtar yazma.") + +OKUNAMADI_NOTU = ("(Kopru notu: Bu noktadan onceki konusma, onceki modelin sifreli ozetinde duruyor ve bu modele " + "aktarilamadi. Onceki kararlari bilmiyorsan uydurma; kullaniciya sor.)") + + +def tetik_mi(req): + """Istek bir sikistirma istegi mi (girdinin son ogesi compaction_trigger)?""" + ogeler = req.get("input") + return (isinstance(ogeler, list) and bool(ogeler) and isinstance(ogeler[-1], dict) + and ogeler[-1].get("type") == "compaction_trigger") + + +def ozet_ogesi(metin, kimlik_tohumu=""): + """Duz metin ozeti Codex'in bekledigi compaction ogesine sarar.""" + kimlik = "cmp_astrabridge_" + hashlib.sha256((kimlik_tohumu + metin).encode("utf-8")).hexdigest()[:32] + return {"type": "compaction", "id": kimlik, + "encrypted_content": ISARET + base64.b64encode(metin.encode("utf-8")).decode("ascii")} + + +def ozet_metni(oge): + """Koprunun urettigi compaction ogesinden duz metni cikarir; kopru ogesi degilse None.""" + icerik = oge.get("encrypted_content") if isinstance(oge, dict) else None + if not isinstance(icerik, str) or not icerik.startswith(ISARET): + return None + try: + return base64.b64decode(icerik[len(ISARET):]).decode("utf-8") + except Exception: + return None + + +def _ozet_mesaji(metin): + return {"type": "message", "role": "user", + "content": [{"type": "input_text", "text": OZET_BASLIK + "\n" + metin.strip() + "\n" + OZET_BITIS}]} + + +def ozetleri_ac(req, yabanci_hedef=False, coz=None): + """Giden istekteki compaction ogelerini hedefin okuyabilecegi bicime getirir (yerinde). + - Koprunun urettigi ozet -> duz metin kullanici mesaji (her hedefte). + - Baska saglayicinin (OpenAI) sifreli ozeti: hedef OpenAI ise dokunulmaz; hedef yabanciysa (9Router) coz(oge) + ile duz metne cevrilir, olmazsa OKUNAMADI_NOTU konur. + Doner: {'acilan': n, 'cozulen': n, 'okunamayan': n}""" + bilgi = {"acilan": 0, "cozulen": 0, "okunamayan": 0} + ogeler = req.get("input") + if not isinstance(ogeler, list): + return bilgi + yeni = [] + for oge in ogeler: + if isinstance(oge, dict) and oge.get("type") == "compaction": + metin = ozet_metni(oge) + if metin is not None: + yeni.append(_ozet_mesaji(metin)); bilgi["acilan"] += 1 + continue + if yabanci_hedef: + cozulmus = None + if coz is not None: + try: + cozulmus = coz(oge) + except Exception: + cozulmus = None + if cozulmus: + yeni.append(_ozet_mesaji(cozulmus)); bilgi["cozulen"] += 1 + else: + yeni.append({"type": "message", "role": "user", + "content": [{"type": "input_text", "text": OKUNAMADI_NOTU}]}); bilgi["okunamayan"] += 1 + continue + yeni.append(oge) + if bilgi["acilan"] or bilgi["cozulen"] or bilgi["okunamayan"]: + req["input"] = yeni + return bilgi + + +def ozet_istegi_hazirla(req, istem=None): + """Sikistirma istegini (sonunda compaction_trigger) duz metin ozet istegine cevirir (yerinde).""" + ogeler = [o for o in req.get("input") or [] if not (isinstance(o, dict) and o.get("type") == "compaction_trigger")] + ogeler.append({"type": "message", "role": "user", "content": [{"type": "input_text", "text": istem or OZET_ISTEMI}]}) + req["input"] = ogeler + req["tool_choice"] = "none" + req["stream"] = True + return req + + +def _olay(d): + return b"event: " + d["type"].encode() + b"\ndata: " + json.dumps(d, ensure_ascii=False).encode("utf-8") + b"\n\n" + + +class OzetAkisi: + """Ozet isteginin SSE yanitini Codex'in bekledigi tek 'compaction' ogesine cevirir. + ic: once calisan cevirici (SseRewriter). Metin gelmezse compaction ogesi uretmez (Codex hatayi gosterir).""" + + def __init__(self, ic=None, tohum=""): + self.ic = ic + self.tohum = tohum + self.buf = b"" + self.parcalar = {} # item_id -> delta metni + self.bitenler = [] # tamamlanan mesaj metinleri + self.metin = None # sonuc (gunluk/test icin) + self.bitti = False + + def _topla(self): + metin = "\n\n".join(t for t in self.bitenler if t.strip()) + if not metin.strip(): + metin = "\n\n".join(t for t in self.parcalar.values() if t.strip()) + return metin.strip() + + def _blok(self, blok): + veri = [s[5:].strip() for s in re.split(rb"\r?\n", blok) if s.startswith(b"data:")] + if not veri: + return b"" + try: + d = json.loads(b"\n".join(veri)) + except Exception: + return b"" + if not isinstance(d, dict): + return b"" + t = d.get("type", "") + if t == "response.created": + return _olay(d) + if t == "response.output_text.delta": + self.parcalar[d.get("item_id")] = self.parcalar.get(d.get("item_id"), "") + (d.get("delta") or "") + return b": ozet\n\n" # baglantiyi canli tutar; Codex yorum satirini yok sayar + if t == "response.output_item.done": + oge = d.get("item") or {} + if oge.get("type") == "message": + self.bitenler.append("".join(p.get("text", "") for p in oge.get("content") or [] + if isinstance(p, dict) and p.get("type") == "output_text")) + return b": ozet\n\n" + if t == "response.completed": + self.metin = self._topla() + self.bitti = True + if not self.metin: + return _olay(d) # ozet yok: Codex "0 compaction ogesi" hatasini gosterir + oge = ozet_ogesi(self.metin, self.tohum) + yanit = dict(d.get("response") or {}) + yanit["output"] = [oge] + return (_olay({"type": "response.output_item.done", "output_index": 0, "item": oge}) + + _olay({"type": "response.completed", "response": yanit})) + if t in ("response.failed", "response.incomplete", "error"): + return _olay(d) + return b"" + + def feed(self, chunk): + if self.ic: + chunk = self.ic.feed(chunk) + self.buf += chunk + out = b"" + while True: + m = re.search(rb"\r?\n\r?\n", self.buf) + if not m: + break + blok, self.buf = self.buf[:m.start()], self.buf[m.end():] + out += self._blok(blok) + return out + + def flush(self): + if self.ic: + self.buf += self.ic.flush() + self.buf = b"" + return b"" + + +# ---- OpenAI'nin sifreli ozetini duz metne cevirme (onbellekli) ---- +def onbellek_yolu(klasor, oge): + kimlik = str(oge.get("id") or hashlib.sha256(str(oge.get("encrypted_content")).encode()).hexdigest()[:32]) + return os.path.join(klasor, re.sub(r"[^A-Za-z0-9_.-]", "_", kimlik)[:80] + ".txt") + + +def onbellekten(klasor, oge): + try: + with open(onbellek_yolu(klasor, oge), encoding="utf-8") as f: + return f.read().strip() or None + except OSError: + return None + + +def onbellege(klasor, oge, metin): + os.makedirs(klasor, exist_ok=True) + yol = onbellek_yolu(klasor, oge) + with open(yol + ".tmp", "w", encoding="utf-8") as f: + f.write(metin) + os.replace(yol + ".tmp", yol) + + +def coz_istegi(oge, model, talimat="Sen bir konusma ozeti aktaricisisin."): + """Sifreli ozeti duz metne dokturmek icin OpenAI'ye gidecek istek govdesi.""" + temiz = {k: v for k, v in oge.items() if k in ("type", "id", "encrypted_content")} + return {"model": model, "instructions": talimat, "store": False, "stream": True, "tools": [], + "tool_choice": "none", "parallel_tool_calls": False, + "reasoning": {"effort": "low", "context": "all_turns"}, # chatgpt.com arka ucu bunu sart kosuyor + "include": ["reasoning.encrypted_content"], + "input": [temiz, {"type": "message", "role": "user", "content": [{"type": "input_text", "text": COZ_ISTEMI}]}]} + + +def sse_metni(govde): + """SSE govdesinden (bayt) asistan metnini toplar.""" + 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() + + +# ---- Dongu freni ---- +# Ozetten sonra da baglam hedefin esigini asiyorsa (Codex son kullanici mesajlarini aynen tuttugu icin olabilir) Codex +# ayni turda tekrar tekrar sikistirma ister; her biri basarili oldugu icin dongu kendiliginden bitmez (olcum: sik6). +DONGU_MESAJI = ("AstraBridge: Bu sohbetin baglami, ozetlendikten sonra da secili modelin sinirina sigmiyor " + "(kisa surede %d ozet denendi). Dongu durduruldu. Daha buyuk baglam pencereli bir model secin ya da " + "yeni sohbet acin; mesajlariniz sohbette duruyor.") + + +class DonguFreni: + """Ayni sohbet icin kisa surede cok sayida ozet istegini sayar.""" + + def __init__(self, sinir=3, pencere_sn=600): + self.sinir = sinir + self.pencere = pencere_sn + self.kayit = {} + + 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 len(zamanlar) >= self.sinir: + self.kayit[anahtar] = zamanlar + return False + zamanlar.append(simdi) + self.kayit[anahtar] = zamanlar + if len(self.kayit) > 500: # bellek sismesin + for k in sorted(self.kayit, key=lambda k: self.kayit[k][-1])[:250]: + self.kayit.pop(k, None) + return True diff --git a/apps/desktop/assets/codex-bridge/resim_context.py b/apps/desktop/assets/codex-bridge/resim_context.py new file mode 100644 index 0000000..a389282 --- /dev/null +++ b/apps/desktop/assets/codex-bridge/resim_context.py @@ -0,0 +1,94 @@ +"""9Router'a giden isteklerde resim, video, ses, PDF ve dev içerikleri sadeleştirir (24 Eylül 2026, kullanıcı onayı). + +Sorun: Codex, image_gen aracının ürettiği resmi (≈3 MB base64 PNG) araç çıktısı olarak sohbete geri koyuyor. +9Router'ın Claude çevirisi bunu düz yazı sayıyor: istek 3 milyon token'a çıktı ("prompt is too long: +3040251 tokens > 1000000 maximum") ve sohbetteki sonraki HER tur 400 ile düştü; sohbet kurtarılamadı. +Aynısı video, ses, PDF/dosya ve çok büyük araç çıktılarıyla da olabilir (kullanıcı isteği: "tekrar böyle sorun olmasın"). + +Kural: +- Medya parçaları (resim, dosya/PDF, ses, video): araç çıktılarında ve eski mesajlarda kısa notla değiştirilir. + Kullanıcının SON mesajındaki medya toplamı TUT_BAYT'ı aşmıyorsa korunur (yeni eklenen dosyayı model görebilsin). +- Tür ne olursa olsun tek başına DEV_BAYT'tan büyük her parça (son mesajdaki korunanlar hariç) notla değiştirilir. +- Düz yazı araç çıktısı DEV_BAYT'tan büyükse başı ve sonu kalır, ortası notla kısaltılır. +Codex'in kendi kaydına dokunulmaz; yalnız 9Router'a giden kopya değişir. +""" +import json + +TUT_BAYT = 1_000_000 +DEV_BAYT = 1_000_000 +RESIM_TUT_BAYT = TUT_BAYT # eski ad +MEDYA_TURLERI = {'input_image': 'resim', 'image': 'resim', 'image_url': 'resim', + 'input_file': 'dosya/PDF', 'file': 'dosya/PDF', + 'input_audio': 'ses', 'audio': 'ses', + 'input_video': 'video', 'video': 'video'} + + +def _boyut(p): + try: + return len(json.dumps(p, ensure_ascii=False)) + except (TypeError, ValueError): + return len(str(p)) + + +def _medya_mi(p): + return isinstance(p, dict) and p.get('type') in MEDYA_TURLERI + + +def _not(p): + tur = MEDYA_TURLERI.get(p.get('type'), p.get('type') or 'içerik') if isinstance(p, dict) else 'içerik' + ad = p.get('filename') or p.get('file_path') or p.get('path') or '' if isinstance(p, dict) else '' + ek = f' ({ad})' if ad else '' + return {'type': 'input_text', + 'text': f'[{tur}{ek} çıkarıldı (AstraBridge): bu yoldan gönderilemiyor, ~{_boyut(p) // 1024} KB; ' + 'gerekirse dosyayı yolundan aç]'} + + +def _kisalt(metin): + yarim = DEV_BAYT // 4 + return (metin[:yarim] + f'\n[... {len(metin) - 2 * yarim} karakter çıkarıldı (AstraBridge): çıktı çok büyüktü ...]\n' + + metin[-yarim:]) + + +def resimleri_sadelestir(req, tut_bayt=TUT_BAYT): + """(değiştirilen parça sayısı, kazanılan bayt). req['input'] yerinde değişir.""" + inp = req.get('input') + if not isinstance(inp, list): + 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': + son_kullanici = i + koru = set() + if son_kullanici is not None: + icerik = inp[son_kullanici].get('content') + if isinstance(icerik, list): + medya = [p for p in icerik if _medya_mi(p)] + if medya and sum(_boyut(p) for p in medya) <= tut_bayt: + koru.add(son_kullanici) + sayi = kazanc = 0 + for i, it in enumerate(inp): + if not isinstance(it, dict) or i in koru: + continue + for alan in ('content', 'output'): + deger = it.get(alan) + if isinstance(deger, str) and len(deger) > DEV_BAYT: + yeni = _kisalt(deger) + kazanc += len(deger) - len(yeni) + sayi += 1 + it[alan] = yeni + continue + if not isinstance(deger, list): + continue + degisti = False + yeni_liste = [] + for p in deger: + if _medya_mi(p) or _boyut(p) > DEV_BAYT: + kazanc += _boyut(p) + sayi += 1 + degisti = True + yeni_liste.append(_not(p)) + else: + yeni_liste.append(p) + if degisti: + it[alan] = yeni_liste + return sayi, kazanc diff --git a/apps/desktop/assets/codex-bridge/test_access_programs.py b/apps/desktop/assets/codex-bridge/test_access_programs.py new file mode 100644 index 0000000..cb126e3 --- /dev/null +++ b/apps/desktop/assets/codex-bridge/test_access_programs.py @@ -0,0 +1,83 @@ +"""Regression coverage for Codex -> external API access_programs compatibility.""" +import ast +import copy +import json +from pathlib import Path +import unittest +from unittest.mock import Mock +import urllib.request + + +# Load only the actual adapter and routing method: no catalog/network/thread +# startup or credential loading is needed to exercise the production code. +source = ast.parse(Path(__file__).with_name("bridge.py").read_text(encoding="utf-8")) +flatten = next(n for n in source.body if isinstance(n, ast.FunctionDef) and n.name == "flatten_request") +claude_sonu = next(n for n in source.body if isinstance(n, ast.FunctionDef) and n.name == "claude_sonu_duzelt") +handler = next(n for n in source.body if isinstance(n, ast.ClassDef) and n.name == "H") +responses = next(n for n in handler.body if isinstance(n, ast.FunctionDef) and n.name == "_responses") + + +class AccessPrograms(unittest.TestCase): + def setUp(self): + self.upstream = Mock(return_value=Mock(status=200)) + self.env = { + "json": json, + "urllib": Mock(request=Mock(Request=urllib.request.Request, urlopen=self.upstream)), + "decompress": lambda body, encoding: body, + "MODEL_MAP": {}, + "CFG": {"nine_router_catalog": {"enabled": True, "route": "9router", "prefix": "9r/"}, + "resim_sadelestir": False, "pano_kanca_user": False}, + "ROUTES": {"9router": {"url": "https://example.invalid/v1/responses", "key": "test-only", "flatten_tools": True}}, + "fix_tool_ids": lambda req: 0, + "drop_foreign_items": lambda req: 0, + "SseRewriter": Mock(), + "log": lambda message: None, + "DEBUG": False, "UA": "test", "RETRIES": 1, "UPSTREAM_IDLE_TIMEOUT": 3900, + } + exec(compile(ast.Module(body=[flatten, claude_sonu, responses], type_ignores=[]), "bridge.py", "exec"), self.env) + + def request(self, model): + return {"model": model, "access_programs": {"cyber": "standard"}, + "metadata": {"access_programs": "preserve nested data"}, + "input": [{"role": "user", "content": [{"type": "input_text", "text": "hello"}]}], + "tools": [{"type": "function", "name": "echo", "parameters": {"type": "object", "properties": {}}}]} + + def test_adapter_removes_only_top_level_field(self): + req = self.request("meta/muse-spark-1.3") + original = copy.deepcopy(req) + self.env["flatten_request"](req) + self.assertNotIn("access_programs", req) + self.assertEqual(req["metadata"], original["metadata"]) + self.assertEqual(req["input"], original["input"]) + self.assertEqual(req["tools"][0]["name"], "echo") + + def test_absent_null_and_empty_values(self): + for value in (None, {}, {"cyber": "standard"}): + with self.subTest(value=value): + req = {"input": [], "access_programs": value} + self.env["flatten_request"](req) + self.assertNotIn("access_programs", req) + self.env["flatten_request"]({"input": []}) + + def test_model_and_combo_external_request_strips_field(self): + for model in ("9r/meta/muse-spark-1.3-contributor", "9r/Fusion-Hizli"): + with self.subTest(model=model): + body = json.dumps(self.request(model)).encode() + fake = Mock(headers={}) + self.env["_responses"](fake, 1, "/responses", body) + sent = json.loads(self.upstream.call_args.args[0].data) + self.assertNotIn("access_programs", sent) + self.assertEqual(sent["model"], model.removeprefix("9r/")) + self.assertIn("access_programs", json.loads(body)) + fake._stream.assert_called_once() + + def test_chatgpt_passthrough_preserves_original_body(self): + body = json.dumps(self.request("gpt-6-astra")).encode() + fake = Mock(headers={}) + self.env["_responses"](fake, 1, "/responses", body) + self.assertEqual(fake._passthrough.call_args.args[2], body) + self.upstream.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/desktop/assets/codex-bridge/test_claude_sonu.py b/apps/desktop/assets/codex-bridge/test_claude_sonu.py new file mode 100644 index 0000000..b5deeda --- /dev/null +++ b/apps/desktop/assets/codex-bridge/test_claude_sonu.py @@ -0,0 +1,45 @@ +"""claude_sonu_duzelt testleri (9Router Claude prefill 400).""" +import ast +import json +from pathlib import Path +import unittest + +tree = ast.parse(Path(__file__).with_name("bridge.py").read_text(encoding="utf-8")) +env = {} +exec(compile(ast.Module(body=[n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == "claude_sonu_duzelt"], type_ignores=[]), "bridge.py", "exec"), env) +f = env["claude_sonu_duzelt"] + + +def msg(role, text="x"): + return {"type": "message", "role": role, "content": [{"type": "input_text", "text": text}]} + + +class ClaudeSonu(unittest.TestCase): + def test_asistanla_biten_duzelir(self): + req = {"input": [msg("user"), msg("assistant")]} + self.assertTrue(f(req)[0]) + self.assertEqual(req["input"][-1]["role"], "user") + + def test_asistan_sonra_developer_duzelir(self): + # Sesli tur kapaninca: ... assistant, developer (9Router developer'i dusurur -> asistanla biter) + req = {"input": [msg("user"), msg("assistant"), msg("developer")]} + self.assertTrue(f(req)[0]) + self.assertEqual(req["input"][-1]["role"], "user") + + def test_reasoning_ile_biten_duzelir(self): + req = {"input": [msg("user"), {"type": "reasoning", "id": "rs_1"}]} + self.assertTrue(f(req)[0]) + + def test_kullaniciyla_biten_degismez(self): + req = {"input": [msg("assistant"), msg("user"), msg("developer")]} + self.assertFalse(f(req)[0]) + self.assertEqual(len(req["input"]), 3) + + def test_arac_sonucuyla_biten_degismez(self): + req = {"input": [msg("user"), {"type": "custom_tool_call", "call_id": "c"}, {"type": "custom_tool_call_output", "call_id": "c"}]} + self.assertFalse(f(req)[0]) + + +if __name__ == "__main__": + unittest.main() + diff --git a/apps/desktop/assets/codex-bridge/test_combo_tekrar.py b/apps/desktop/assets/codex-bridge/test_combo_tekrar.py new file mode 100644 index 0000000..7f8821f --- /dev/null +++ b/apps/desktop/assets/codex-bridge/test_combo_tekrar.py @@ -0,0 +1,96 @@ +"""A 9Router combo (Fusion-*) must not be sent twice once it may be running upstream. + +22 Sep 2026: after a Cloudflare 524 the bridge resent 9r/Fusion-Orta three times and the whole +Fusion ran (and billed) again. Failures before the work starts may still be retried. +""" +import ast +import io +import json +from pathlib import Path +import types +import unittest +from unittest.mock import Mock +import urllib.error +import urllib.request + +source = ast.parse(Path(__file__).with_name("bridge.py").read_text(encoding="utf-8")) +pick = lambda name: next(n for n in source.body if isinstance(n, ast.FunctionDef) and n.name == name) +handler = next(n for n in source.body if isinstance(n, ast.ClassDef) and n.name == "H") +responses = next(n for n in handler.body if isinstance(n, ast.FunctionDef) and n.name == "_responses") + + +def http_error(code, body=b"{}"): + return urllib.error.HTTPError("https://example.invalid", code, "x", {}, io.BytesIO(body)) + + +class ComboNotResent(unittest.TestCase): + def setUp(self): + self.upstream = Mock() + self.env = { + "json": json, "re": __import__("re"), "os": __import__("os"), + "time": types.SimpleNamespace(sleep=lambda s: None), + "urllib": types.SimpleNamespace( + request=types.SimpleNamespace(Request=urllib.request.Request, urlopen=self.upstream), + error=urllib.error), + "decompress": lambda body, encoding: body, + "MODEL_MAP": {}, + "CFG": {"nine_router_catalog": {"enabled": True, "route": "9router", "prefix": "9r/"}, + "resim_sadelestir": False, "pano_kanca_user": False}, + "ROUTES": {"9router": {"url": "https://example.invalid/v1/responses", "key": "test-only", "flatten_tools": False}}, + "fix_tool_ids": lambda req: 0, "drop_foreign_items": lambda req: 0, + "SseRewriter": Mock(), "log": lambda message: None, + "DEBUG": False, "UA": "test", "RETRIES": 6, "UPSTREAM_IDLE_TIMEOUT": 3600, + "NO_RETRY_PATTERNS": [], + } + exec(compile(ast.Module(body=[pick("flatten_request"), pick("claude_sonu_duzelt"), pick("is_timeout"), responses], + type_ignores=[]), "bridge.py", "exec"), self.env) + + def send(self, model): + body = json.dumps({"model": model, "input": [{"role": "user", "content": [{"type": "input_text", "text": "8 vezir"}]}]}).encode() + fake = Mock(headers={}) + self.env["_responses"](fake, 1, "/responses", body) + return fake + + def test_combo_timeout_is_not_resent(self): + self.upstream.side_effect = urllib.error.URLError(TimeoutError("timed out")) + fake = self.send("9r/Fusion-Orta") + self.assertEqual(self.upstream.call_count, 1) + fake._stream.assert_not_called() + + def test_combo_504_and_524_are_not_resent(self): + for code in (504, 524): + with self.subTest(code=code): + self.upstream.reset_mock() + self.upstream.side_effect = http_error(code) + self.send("9r/Fusion-Agir") + self.assertEqual(self.upstream.call_count, 1) + + def test_combo_cut_mid_stream_is_not_resent(self): + self.upstream.side_effect = None + self.upstream.return_value = Mock(status=200) + fake = self.send("9r/Fusion-Orta") + fake._stream.side_effect = TimeoutError("read timed out") + self.upstream.reset_mock() + with self.assertRaises(TimeoutError): + self.send_with(fake, "9r/Fusion-Orta") + self.assertEqual(self.upstream.call_count, 1) + + def send_with(self, fake, model): + body = json.dumps({"model": model, "input": [{"role": "user", "content": [{"type": "input_text", "text": "8 vezir"}]}]}).encode() + self.env["_responses"](fake, 1, "/responses", body) + + def test_combo_failure_before_work_starts_is_retried(self): + self.upstream.side_effect = [http_error(503), urllib.error.URLError(ConnectionRefusedError("refused")), Mock(status=200)] + fake = self.send("9r/Fusion-Orta") + self.assertEqual(self.upstream.call_count, 3) + fake._stream.assert_called_once() + + 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") + self.assertEqual(self.upstream.call_count, 2) + fake._stream.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/desktop/assets/codex-bridge/test_ide_catalog.py b/apps/desktop/assets/codex-bridge/test_ide_catalog.py new file mode 100644 index 0000000..9c2de87 --- /dev/null +++ b/apps/desktop/assets/codex-bridge/test_ide_catalog.py @@ -0,0 +1,82 @@ +import json, os, tempfile, unittest +import ide_catalog as c + + +def girdi(mid, ad, yetenek=("chat",), sira=0): + return {"id": mid, "turkservis": {"display_name": ad, "capabilities": list(yetenek), "order": sira}} + + +KATALOG = [ + girdi("Ajan-Arac", "OmniRoute · Ajan-Arac", sira=5), + girdi("9router-cc-opus", "OmniRoute · cc/claude-opus-5-5", sira=2), + girdi("9router-nano-x", "OmniRoute · nano/x", sira=1), + girdi("gorsel", "OmniRoute · gpt-image-1", ("image",)), + girdi("9router-Takim-Agir", "OmniRoute · Takim-Agir", sira=9), + girdi("9router-Takim-Agir-6e65dba5", "OmniRoute · Takim-Agir", sira=8), + girdi("Ajan-Arac", "OmniRoute · Ajan-Arac (tekrar)"), +] + + +class Ayar(unittest.TestCase): + def test_dosya_yoksa_varsayilan_acik(self): + self.assertEqual(c.ayar_oku(os.path.join(tempfile.gettempdir(), "yok-boyle-dosya.json")), {"enabled": True, "kapsam": "ide"}) + + def test_kapatma_ve_bozuk_dosya(self): + with tempfile.TemporaryDirectory() as d: + yol = os.path.join(d, "a.json") + for icerik, beklenen in [('{"enabled": false}', {"enabled": False, "kapsam": "ide"}), + ('{"enabled": true, "kapsam": "hepsi"}', {"enabled": True, "kapsam": "hepsi"}), + ('{"enabled": "hayir", "kapsam": "x"}', {"enabled": True, "kapsam": "ide"}), + ('bozuk', {"enabled": True, "kapsam": "ide"})]: + open(yol, "w", encoding="utf-8").write(icerik) + self.assertEqual(c.ayar_oku(yol), beklenen, icerik) + + +class Yapilandirma(unittest.TestCase): + def test_config_yaml_okuma(self): + with tempfile.TemporaryDirectory() as d: + open(os.path.join(d, "config.yaml"), "w", encoding="utf-8").write( + "model:\n default: Ajan-Arac\n provider: turkservis\nproviders:\n baska:\n models:\n Yabanci: {}\n" + " turkservis:\n base_url: https://ornek.test/v1/\n # yorum\n models:\n Hermes-Master: {}\n" + " 'cc/claude-opus-5-5': {}\n timeout: 3\nmcp_servers:\n x:\n models:\n Degil: {}\n") + open(os.path.join(d, ".env"), "w", encoding="utf-8").write("A=1\nHERMES_CUSTOM_TURKSERVIS_API_KEY='sahte-anahtar'\n") + self.assertEqual(c.ide_yapilandirma(d), ("https://ornek.test/v1", "Ajan-Arac", ["Hermes-Master", "cc/claude-opus-5-5"])) + self.assertEqual(c.anahtar_oku(d), "sahte-anahtar") + + def test_dosyalar_yoksa(self): + with tempfile.TemporaryDirectory() as d: + self.assertEqual(c.ide_yapilandirma(d), (c.DEFAULT_BASE, "", [])) + self.assertEqual(c.anahtar_oku(d), "") + + +class Secim(unittest.TestCase): + def test_ide_kapsami(self): + s = c.sec(KATALOG, ["cc/claude-opus-5-5"], "ide") + # Tanimli model + adlandirilmis combolar; tanimsiz saglayici modeli ve gorsel modeli yok. + self.assertEqual([x["id"] for x in s], ["9router-cc-opus", "Ajan-Arac", "9router-Takim-Agir-6e65dba5", "9router-Takim-Agir"]) + + def test_gorunen_ad_yalniz_saglayici_ve_model(self): + for tam, beklenen in [("OmniRoute · ag/gemini-3.8-flash", "ag/gemini-3.8-flash"), + ("nano/qwen/qwen3.8-27b-obliterated:thinking", "nano/qwen3.8-27b-obliterated:thinking"), + ("9Router · Nous-Research/inclusionai/ling-3.1-flash", "Nous-Research/ling-3.1-flash"), + ("cf/@cf/qwen/qwq-32b", "cf/qwq-32b"), + ("OmniRoute · Ajan-Arac", "Ajan-Arac"), ("cc/claude-opus-5-5", "cc/claude-opus-5-5")]: + self.assertEqual(c.kisa_ad(tam), beklenen, tam) + + def test_cakisan_adlar_ayirt_edilir(self): + adlar = c.adlandir([("a", "nano/z-ai/glm-5.3"), ("b", "nano/baska/glm-5.3"), ("d", "cc/opus"), ("e-12345678", "OmniRoute · Takim-Agir")], + dolu={"takim-agir"}) + self.assertEqual(adlar["a"], "nano/z-ai/glm-5.3") # kisa adi cakisan: aradaki halka geri gelir + self.assertEqual(adlar["b"], "nano/baska/glm-5.3") + self.assertEqual(adlar["d"], "cc/opus") + self.assertEqual(adlar["e-12345678"], "Takim-Agir [12345678]") + self.assertEqual(len(set(adlar.values())), 4) + + def test_hepsi_kapsami_yalniz_sohbet(self): + s = c.sec(KATALOG, [], "hepsi") + self.assertEqual(len(s), 5) + self.assertNotIn("gorsel", [x["id"] for x in s]) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/desktop/assets/codex-bridge/test_model_switch.py b/apps/desktop/assets/codex-bridge/test_model_switch.py new file mode 100644 index 0000000..da8e035 --- /dev/null +++ b/apps/desktop/assets/codex-bridge/test_model_switch.py @@ -0,0 +1,68 @@ +"""Provider-switch history regression tests, without startup or network access.""" +import ast +import copy +import json +from pathlib import Path +import unittest + + +tree = ast.parse(Path(__file__).with_name("bridge.py").read_text(encoding="utf-8")) +names = {"flatten_request", "fix_tool_ids", "drop_foreign_items", "SseRewriter"} +nodes = [n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.ClassDef)) and n.name in names] +env = {"json": json, "log": lambda message: None} +for node in tree.body: + if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id in + {"ID_PREFIX", "FOREIGN_ID_MARK", "REFERENCE_TYPES", "INLINE_TYPES"} for t in node.targets): + nodes.insert(0, node) +exec(compile(ast.Module(body=nodes, type_ignores=[]), "bridge.py", "exec"), env) + + +class ModelSwitch(unittest.TestCase): + def test_external_to_chatgpt_preserves_messages_and_paired_tool_calls(self): + req = {"input": [ + {"type": "reasoning", "id": "rs_native", "encrypted_content": "native-reasoning"}, + {"type": "reasoning", "id": "rs_chatcmpl-foreign"}, + {"type": "item_reference", "id": "chatcmpl-foreign"}, + {"type": "message", "id": "msg_chatcmpl-foreign", "role": "assistant", "content": "remember-4827"}, + {"type": "custom_tool_call", "id": "fc_chatcmpl-call", "call_id": "call_4827", "name": "probe", "input": "hello"}, + {"type": "custom_tool_call_output", "id": "fc_chatcmpl-output", "call_id": "call_4827", "output": "result-4827"}, + ]} + self.assertEqual(env["fix_tool_ids"](req), 2) + self.assertEqual(env["drop_foreign_items"](req), 2) + self.assertEqual(req["input"][0]["encrypted_content"], "native-reasoning") + self.assertEqual(req["input"][1]["content"], "remember-4827") + self.assertEqual(req["input"][2]["id"], "ctc_chatcmpl-call") + self.assertEqual(req["input"][3]["id"], "ctco_chatcmpl-output") + self.assertEqual(req["input"][2]["call_id"], req["input"][3]["call_id"]) + self.assertEqual(req["input"][3]["output"], "result-4827") + + def test_chatgpt_to_external_preserves_custom_tool_history(self): + original = {"access_programs": {"cyber": "standard"}, "input": [ + {"type": "custom_tool_call", "id": "ctc_4827", "call_id": "call_4827", "name": "probe", "input": "remember-4827"}, + {"type": "custom_tool_call_output", "id": "ctco_4827", "call_id": "call_4827", "output": "result-4827"}, + ], "tools": [{"type": "namespace", "name": "functions", "tools": [{"type": "custom", "name": "probe"}]}]} + req = copy.deepcopy(original) + self.assertEqual(env["flatten_request"](req), {"probe"}) + env["fix_tool_ids"](req) + self.assertEqual(json.loads(req["input"][0]["arguments"]), {"input": "remember-4827"}) + self.assertEqual(req["input"][1]["output"], "result-4827") + self.assertEqual(req["input"][0]["call_id"], req["input"][1]["call_id"]) + self.assertEqual(req["input"][0]["type"], "function_call") + self.assertEqual(req["input"][1]["type"], "function_call_output") + self.assertIn("access_programs", original) + + def test_streamed_external_custom_tool_can_return_to_codex(self): + event = {"type": "response.output_item.done", "item": { + "type": "function_call", "id": "fc_chatcmpl-probe", "call_id": "call_4827", + "name": "probe", "arguments": json.dumps({"input": "remember-4827"})}} + raw = ("event: response.output_item.done\ndata: " + json.dumps(event) + "\n\n").encode() + adapter = env["SseRewriter"]({"probe"}) + out = adapter.feed(raw[:25]) + adapter.feed(raw[25:]) + adapter.flush() + rewritten = json.loads(next(line[6:] for line in out.decode().splitlines() if line.startswith("data: "))) + self.assertEqual(rewritten["item"]["type"], "custom_tool_call") + self.assertEqual(rewritten["item"]["input"], "remember-4827") + self.assertEqual(rewritten["item"]["call_id"], "call_4827") + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/desktop/assets/codex-bridge/test_ozet_kopru.py b/apps/desktop/assets/codex-bridge/test_ozet_kopru.py new file mode 100644 index 0000000..321c72a --- /dev/null +++ b/apps/desktop/assets/codex-bridge/test_ozet_kopru.py @@ -0,0 +1,145 @@ +"""ozet_kopru testleri: 9Router icin sikistirma bagdastiricisi ve saglayicilar arasi ozet aktarimi.""" +import json +import os +import tempfile +import unittest + +import ozet_kopru as o + + +def msg(rol, metin): + return {"type": "message", "role": rol, "content": [{"type": "input_text", "text": metin}]} + + +def sse(*olaylar): + return b"".join(b"event: " + e["type"].encode() + b"\ndata: " + json.dumps(e, ensure_ascii=False).encode() + b"\n\n" for e in olaylar) + + +def coz(veri): + return [json.loads(s[5:]) for blok in veri.split(b"\n\n") for s in blok.split(b"\n") if s.startswith(b"data:")] + + +OPENAI_OZETI = {"type": "compaction", "id": "cmp_0123", "encrypted_content": "gAAAAABsahte", "internal_chat_message_metadata_passthrough": {"turn_id": "t"}} + + +class Istek(unittest.TestCase): + def test_tetik_algilanir_ve_ozet_istegine_cevrilir(self): + req = {"model": "m", "tools": [{"type": "function", "name": "x"}], "input": [msg("user", "KARAR-1: 14 Kasim"), {"type": "compaction_trigger"}]} + self.assertTrue(o.tetik_mi(req)) + self.assertFalse(o.tetik_mi({"input": [msg("user", "normal")]})) + self.assertFalse(o.tetik_mi({"input": []})) + o.ozet_istegi_hazirla(req) + self.assertNotIn("compaction_trigger", json.dumps(req)) + self.assertEqual(req["input"][0]["content"][0]["text"], "KARAR-1: 14 Kasim") # gecmis aynen kalir + self.assertEqual(req["input"][-1]["role"], "user") + istem = req["input"][-1]["content"][0]["text"] + for baslik in ("KARARLAR VE GEREKCELERI", "ACIK ISLER", "ONEMLI SONUCLAR", "KULLANICININ ISTEGI"): + self.assertIn(baslik, istem) + self.assertEqual(req["tool_choice"], "none") + + def test_kopru_ozeti_her_hedefte_duz_metne_acilir(self): + oge = o.ozet_ogesi("KARAR-1: teslim 14 Kasim\nACIK-IS-1: fatura onayi") + self.assertTrue(oge["id"].startswith("cmp_astrabridge_")) + self.assertNotIn("KARAR-1", json.dumps(oge)) # ogede duz metin gorunmez (base64) + for yabanci in (False, True): + req = {"input": [msg("user", "once"), dict(oge), msg("user", "soru")]} + b = o.ozetleri_ac(req, yabanci_hedef=yabanci) + self.assertEqual(b["acilan"], 1) + metin = req["input"][1]["content"][0]["text"] + self.assertEqual(req["input"][1]["role"], "user") + self.assertIn("KARAR-1: teslim 14 Kasim", metin) + self.assertTrue(metin.startswith(o.OZET_BASLIK)) + self.assertNotIn('"compaction"', json.dumps(req)) + + def test_openai_ozeti_openai_hedefinde_dokunulmaz(self): + req = {"input": [dict(OPENAI_OZETI), msg("user", "soru")]} + once = json.dumps(req, sort_keys=True) + b = o.ozetleri_ac(req, yabanci_hedef=False) + self.assertEqual(json.dumps(req, sort_keys=True), once) + self.assertEqual(b, {"acilan": 0, "cozulen": 0, "okunamayan": 0}) + + def test_openai_ozeti_yabanci_hedefte_duz_metne_cevrilir_ya_da_not_dusulur(self): + req = {"input": [dict(OPENAI_OZETI), msg("user", "soru")]} + b = o.ozetleri_ac(req, yabanci_hedef=True, coz=lambda oge: "KARAR-2: saglayici degismeyecek") + self.assertEqual(b["cozulen"], 1) + self.assertIn("KARAR-2", req["input"][0]["content"][0]["text"]) + for bozuk in (None, lambda oge: None, lambda oge: 1 / 0): + req = {"input": [dict(OPENAI_OZETI), msg("user", "soru")]} + b = o.ozetleri_ac(req, yabanci_hedef=True, coz=bozuk) + self.assertEqual(b["okunamayan"], 1) + self.assertEqual(req["input"][0]["content"][0]["text"], o.OKUNAMADI_NOTU) # sessiz kayip yok + self.assertNotIn("gAAAAAB", json.dumps(req)) + + def test_coz_istegi_yalniz_gerekli_alanlari_tasir(self): + g = o.coz_istegi(OPENAI_OZETI, "gpt-6-luna") + self.assertEqual(g["input"][0], {"type": "compaction", "id": "cmp_0123", "encrypted_content": "gAAAAABsahte"}) + self.assertEqual(g["model"], "gpt-6-luna") + self.assertFalse(g["store"]) + + +class Akis(unittest.TestCase): + def _mesaj(self, metin, kimlik="msg_1"): + return {"id": kimlik, "type": "message", "role": "assistant", "content": [{"type": "output_text", "text": metin}]} + + def test_metin_tek_compaction_ogesine_sarilir(self): + ozet = "1. KULLANICININ ISTEGI: x\n2. KARARLAR: KARAR-1 14 Kasim" + ham = sse({"type": "response.created", "response": {"id": "r1"}}, + {"type": "response.output_item.added", "item": {"id": "msg_1", "type": "message"}}, + {"type": "response.output_text.delta", "item_id": "msg_1", "delta": ozet[:10]}, + {"type": "response.output_text.delta", "item_id": "msg_1", "delta": ozet[10:]}, + {"type": "response.output_item.done", "item": self._mesaj(ozet)}, + {"type": "response.completed", "response": {"id": "r1", "output": [self._mesaj(ozet)], "usage": {"input_tokens": 5}}}) + a = o.OzetAkisi(tohum="7") + out = b"".join(a.feed(ham[i:i + 53]) for i in range(0, len(ham), 53)) + a.flush() + olaylar = coz(out) + self.assertEqual([e["type"] for e in olaylar], ["response.created", "response.output_item.done", "response.completed"]) + oge = olaylar[1]["item"] + self.assertEqual(oge["type"], "compaction") + self.assertEqual(o.ozet_metni(oge), ozet) + self.assertEqual(olaylar[2]["response"]["output"], [oge]) # Codex tam bir compaction ogesi gorur + self.assertEqual(olaylar[2]["response"]["usage"], {"input_tokens": 5}) + self.assertEqual(a.metin, ozet) + self.assertNotIn("KARAR-1", out.decode()) # ozet metni akista duz gorunmez + + def test_yalniz_delta_gelirse_de_toplanir(self): + ham = sse({"type": "response.created", "response": {}}, + {"type": "response.output_text.delta", "item_id": "a", "delta": "ozet "}, + {"type": "response.output_text.delta", "item_id": "a", "delta": "metni"}, + {"type": "response.completed", "response": {}}) + a = o.OzetAkisi() + self.assertEqual(o.ozet_metni(coz(a.feed(ham))[1]["item"]), "ozet metni") + + def test_metin_yoksa_compaction_uretilmez_hata_gorunur(self): + ham = sse({"type": "response.created", "response": {}}, + {"type": "response.output_item.done", "item": {"type": "function_call", "name": "x"}}, + {"type": "response.completed", "response": {"output": []}}) + a = o.OzetAkisi() + olaylar = coz(a.feed(ham)) + self.assertNotIn("compaction", json.dumps(olaylar)) + self.assertFalse(a.metin) + hata = sse({"type": "response.created", "response": {}}, {"type": "response.failed", "response": {"error": {"message": "x"}}}) + self.assertIn("response.failed", o.OzetAkisi().feed(hata).decode()) + + def test_onbellek(self): + klasor = os.path.join(tempfile.mkdtemp(), "c") + self.assertIsNone(o.onbellekten(klasor, OPENAI_OZETI)) + o.onbellege(klasor, OPENAI_OZETI, "duz metin ozet") + self.assertEqual(o.onbellekten(klasor, OPENAI_OZETI), "duz metin ozet") + + def test_sse_metni(self): + ham = sse({"type": "response.created", "response": {}}, {"type": "response.output_item.done", "item": self._mesaj("cozulmus ozet")}, + {"type": "response.completed", "response": {}}) + self.assertEqual(o.sse_metni(ham), "cozulmus ozet") + + +class Fren(unittest.TestCase): + def test_ayni_sohbette_ust_uste_ozet_durdurulur(self): + f = o.DonguFreni(sinir=3, pencere_sn=600) + self.assertEqual([f.izin("sohbet-a", t) for t in (0, 10, 20, 30, 40)], [True, True, True, False, False]) + self.assertTrue(f.izin("sohbet-b", 40)) # baska sohbet etkilenmez + self.assertTrue(f.izin("sohbet-a", 700)) # pencere gecince yeniden izin + self.assertIn("Dongu durduruldu", o.DONGU_MESAJI % 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/desktop/assets/codex-bridge/test_resim_context.py b/apps/desktop/assets/codex-bridge/test_resim_context.py new file mode 100644 index 0000000..c3571b3 --- /dev/null +++ b/apps/desktop/assets/codex-bridge/test_resim_context.py @@ -0,0 +1,71 @@ +"""py -3 -m unittest test_resim_context (astra-bridge klasöründe)""" +import unittest + +from resim_context import resimleri_sadelestir + + +def resim(bayt): + return {'type': 'input_image', 'image_url': 'data:image/png;base64,' + 'A' * bayt} + + +class Resim(unittest.TestCase): + def test_generated_image_in_tool_output_is_replaced(self): + req = {'input': [ + {'type': 'message', 'role': 'user', 'content': [{'type': 'input_text', 'text': 'foto'}, resim(150_000)]}, + {'type': 'custom_tool_call_output', 'call_id': 'x', + 'output': [{'type': 'input_text', 'text': 'tamam'}, resim(3_000_000)]}, + ]} + sayi, kazanc = resimleri_sadelestir(req) + self.assertEqual(sayi, 1) # son user mesajındaki küçük foto korunur, araç çıktısındaki resim çıkar + self.assertGreater(kazanc, 3_000_000) + self.assertEqual(req['input'][1]['output'][1]['type'], 'input_text') + + def test_latest_user_photos_kept_when_small(self): + req = {'input': [ + {'type': 'message', 'role': 'user', 'content': [resim(10_000)]}, + {'type': 'message', 'role': 'assistant', 'content': [{'type': 'output_text', 'text': 'ok'}]}, + {'type': 'message', 'role': 'user', 'content': [{'type': 'input_text', 'text': 'yeni'}, resim(200_000)]}, + ]} + sayi, _ = resimleri_sadelestir(req) + self.assertEqual(sayi, 1) # yalnız eski mesajdaki resim + self.assertEqual(req['input'][2]['content'][1]['type'], 'input_image') + self.assertEqual(req['input'][0]['content'][0]['type'], 'input_text') + + def test_latest_user_photos_dropped_when_too_big(self): + req = {'input': [{'type': 'message', 'role': 'user', 'content': [resim(1_200_000)]}]} + self.assertEqual(resimleri_sadelestir(req)[0], 1) + + def test_pdf_audio_video_in_old_messages_and_tool_output_are_replaced(self): + pdf = {'type': 'input_file', 'filename': 'rapor.pdf', 'file_data': 'data:application/pdf;base64,' + 'P' * 500_000} + ses = {'type': 'input_audio', 'input_audio': {'data': 'S' * 400_000, 'format': 'wav'}} + video = {'type': 'input_video', 'video_url': 'data:video/mp4;base64,' + 'V' * 300_000} + req = {'input': [ + {'type': 'message', 'role': 'user', 'content': [pdf, ses]}, + {'type': 'function_call_output', 'call_id': 'y', 'output': [video]}, + {'type': 'message', 'role': 'user', 'content': [{'type': 'input_text', 'text': 'devam'}]}, + ]} + sayi, _ = resimleri_sadelestir(req) + self.assertEqual(sayi, 3) + self.assertIn('rapor.pdf', req['input'][0]['content'][0]['text']) + self.assertIn('ses', req['input'][0]['content'][1]['text']) + self.assertIn('video', req['input'][1]['output'][0]['text']) + + def test_huge_text_tool_output_is_shortened(self): + req = {'input': [{'type': 'function_call_output', 'call_id': 'z', 'output': 'x' * 3_000_000}]} + sayi, kazanc = resimleri_sadelestir(req) + self.assertEqual(sayi, 1) + self.assertLess(len(req['input'][0]['output']), 600_000) + self.assertIn('çıkarıldı', req['input'][0]['output']) + + def test_unknown_huge_part_is_replaced(self): + req = {'input': [{'type': 'custom_tool_call_output', 'call_id': 'q', + 'output': [{'type': 'yeni_bir_tur', 'veri': 'Z' * 1_500_000}]}]} + self.assertEqual(resimleri_sadelestir(req)[0], 1) + + def test_request_without_images_untouched(self): + req = {'input': [{'type': 'message', 'role': 'user', 'content': [{'type': 'input_text', 'text': 'x'}]}]} + self.assertEqual(resimleri_sadelestir(req), (0, 0)) + + +if __name__ == '__main__': + unittest.main()