Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88e660768c | ||
|
|
bfd0ff196d | ||
|
|
1e0f2f9aea | ||
|
|
b560950f82 | ||
|
|
dea67fc153 | ||
|
|
1ce5ab82cf | ||
|
|
df63432c92 | ||
|
|
4220d35ebe | ||
|
|
01889d1383 |
@@ -43,6 +43,14 @@ jobs:
|
||||
New-Item -ItemType Junction -Path node_modules -Target (Join-Path $deps 'node_modules') | Out-Null
|
||||
node node_modules/vitest/vitest.mjs run --root apps/desktop --config vitest.mobile.config.ts
|
||||
exit $LASTEXITCODE
|
||||
- name: Codex koprusu (Python, bagimliliksiz) testleri
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$py = (Get-Command python -ErrorAction SilentlyContinue).Source
|
||||
if ($py) { & $py -m unittest discover -s apps/desktop/assets/codex-bridge }
|
||||
else { & py -3 -m unittest discover -s apps/desktop/assets/codex-bridge }
|
||||
exit $LASTEXITCODE
|
||||
|
||||
approval-gate-windows:
|
||||
runs-on: windows-latest
|
||||
|
||||
@@ -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/<id>`
|
||||
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 <data dir> --hermes-home <agent home>`. The IDE writes
|
||||
`<data dir>/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.
|
||||
@@ -0,0 +1,953 @@
|
||||
"""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/<id>" 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 <dir> 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 = "4"
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# Codex sends request bodies zstd-compressed. Python gained zstd in 3.14; for older
|
||||
# interpreters the matching `zstandard` build ships next to this file.
|
||||
_VENDOR = os.path.join(HERE, "vendor", "cp%d%d-win_amd64" % sys.version_info[:2])
|
||||
if os.path.isdir(_VENDOR):
|
||||
sys.path.insert(0, _VENDOR)
|
||||
|
||||
|
||||
def _zstd_decompress(body):
|
||||
try:
|
||||
from compression import zstd as _z
|
||||
return _z.decompress(body)
|
||||
except ImportError:
|
||||
import zstandard
|
||||
return zstandard.ZstdDecompressor().decompressobj().decompress(body)
|
||||
|
||||
|
||||
def _zstd_ready():
|
||||
try:
|
||||
try:
|
||||
from compression import zstd as _z
|
||||
sample = _z.compress(b"aiturk")
|
||||
except ImportError:
|
||||
import zstandard
|
||||
sample = zstandard.ZstdCompressor().compress(b"aiturk")
|
||||
return _zstd_decompress(sample) == b"aiturk"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# Without zstd every Codex request would fail; the status endpoint then reports
|
||||
# ok=false so that the IDE never points Codex at this bridge.
|
||||
ZSTD_OK = _zstd_ready()
|
||||
|
||||
|
||||
def _arg(name, env):
|
||||
"""--name <value> 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/<id>" 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":
|
||||
return _zstd_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 == "POST" and path.rstrip("/").endswith("/refresh"):
|
||||
# The IDE asks for this after a sign-in or a settings change, so the model
|
||||
# list does not wait for the next periodic refresh.
|
||||
try:
|
||||
load_ide_catalog()
|
||||
except Exception as e:
|
||||
log(f"katalog yenileme hatasi: {e}")
|
||||
path = "/aiturk/codex-baglama/status"
|
||||
self.command = "GET"
|
||||
if self.command == "GET" and path.rstrip("/").endswith("/status"):
|
||||
durum, _, sayi = (IDE_DURUM[0] or "kapali, 0 model").partition(", ")
|
||||
return self._send_json(200, {"ok": ZSTD_OK, "surum": SURUM, "zstd": ZSTD_OK, "ide_durum": durum,
|
||||
"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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,176 @@
|
||||
"""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 + 9Router'a eklenmis saglayicilarin modelleri +
|
||||
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
|
||||
|
||||
|
||||
DOKUZ_ISARET = "9R · "
|
||||
|
||||
|
||||
def _isaret(ad):
|
||||
"""Gecidin dogrudan 9Router'dan gelen tekil modele koydugu kisa isaret korunur (kullanici karari,
|
||||
9 Ekim 2026: "9Router" yazisi uzun, hic yazmamak da sorun; "9R" olarak kisa gorunsun)."""
|
||||
return DOKUZ_ISARET if (ad or "").startswith(DOKUZ_ISARET) else ""
|
||||
|
||||
|
||||
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."""
|
||||
on = _isaret(ad)
|
||||
ad = (ad or "").split(" · ")[-1].strip()
|
||||
parcalar = [p for p in ad.split("/") if p]
|
||||
if len(parcalar) <= 2:
|
||||
return on + ("/".join(parcalar) or ad)
|
||||
return on + 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: _isaret(a) + (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
|
||||
# 9Router'dan gelen combolar Codex'te gosterilmez (kullanici karari, 9 Ekim 2026): dogrudan
|
||||
# 9Router kaynagi ve gecidin "9router" saglayicisi uzerinden gelen kopyalari.
|
||||
if combo and (ts.get("source") == "9router" or mid.lower().startswith("9router-")):
|
||||
continue
|
||||
# 9Router'a eklenmis saglayicilarin tekil modelleri her zaman eklenir (kullanici karari, 9 Ekim 2026:
|
||||
# "9Router'in combosu olmasin dedim, 9Router'in modeli olsun"). Ayni modelin OmniRoute uzerinden
|
||||
# gelen kopyasi yalniz IDE ayarinda tanimliysa eklenir; boylece her model listede bir kez durur.
|
||||
dokuz_model = not combo and ts.get("source") == "9router"
|
||||
if kapsam != "hepsi" and not combo and not dokuz_model 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"]))
|
||||
# Gecit ayni adin ikinci yoluna "ad (2)" der; birincisi listede yoksa sayac anlamsizdir.
|
||||
adlar = {s["ad"].split(" · ")[-1].strip().lower() for s in secilen}
|
||||
for s in secilen:
|
||||
taban = re.sub(r" \(\d+\)$", "", s["ad"])
|
||||
if taban != s["ad"] and taban.split(" · ")[-1].strip().lower() not in adlar:
|
||||
adlar.add(taban.split(" · ")[-1].strip().lower())
|
||||
s["ad"] = taban
|
||||
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"])
|
||||
@@ -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 = "<onceki_konusma_ozeti>"
|
||||
OZET_BITIS = "</onceki_konusma_ozeti>"
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,108 @@
|
||||
import json, os, tempfile, unittest
|
||||
import ide_catalog as c
|
||||
|
||||
|
||||
def girdi(mid, ad, yetenek=("chat",), sira=0, kaynak="omniroute"):
|
||||
return {"id": mid, "turkservis": {"display_name": ad, "capabilities": list(yetenek), "order": sira, "source": kaynak}}
|
||||
|
||||
|
||||
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"])
|
||||
|
||||
def test_9router_combolari_gosterilmez(self):
|
||||
katalog = [girdi("9router-Takim-Agir-6e65dba5", "Takim-Agir", kaynak="9router", sira=1),
|
||||
girdi("9router-Takim-Agir", "Takim-Agir (2)", sira=2),
|
||||
girdi("Takim-Agir", "Takim-Agir (3)", sira=3),
|
||||
girdi("Ajan-Arac", "Ajan-Arac", sira=4),
|
||||
girdi("9router-nano-x", "nano/x", kaynak="9router", sira=5)]
|
||||
s = c.sec(katalog, ["nano/x"], "ide")
|
||||
# 9Router combosu ve gecidin 9router saglayicisindaki kopyasi yok; OmniRoute'un kendi combosu
|
||||
# sayacsiz adiyla kalir. 9Router'in tekil modeli (combo degil) etkilenmez.
|
||||
self.assertEqual([(x["id"], x["ad"]) for x in s],
|
||||
[("Takim-Agir", "Takim-Agir"), ("Ajan-Arac", "Ajan-Arac"), ("9router-nano-x", "nano/x")])
|
||||
|
||||
def test_9router_saglayici_modelleri_tanimsiz_da_olsa_eklenir(self):
|
||||
katalog = [girdi("9router-nano-x-aaaa1111", "nano/x", kaynak="9router", sira=1),
|
||||
girdi("9router-nano-x", "nano/x (2)", sira=2),
|
||||
girdi("9router-ocg-y-bbbb2222", "ocg/y", kaynak="9router", sira=3),
|
||||
girdi("vag-z", "vag/z", sira=4),
|
||||
girdi("9router-ses", "nano/ses", ("tts",), kaynak="9router", sira=5),
|
||||
girdi("9router-Takim-Agir-6e65dba5", "Takim-Agir", kaynak="9router", sira=6)]
|
||||
s = c.sec(katalog, [], "ide")
|
||||
# 9Router modelleri IDE ayarinda adi gecmese de gelir; OmniRoute kopyasi, OmniRoute'un kendi
|
||||
# saglayici modeli, sohbet olmayan model ve 9Router combosu gelmez.
|
||||
self.assertEqual([(x["id"], x["ad"]) for x in s],
|
||||
[("9router-nano-x-aaaa1111", "nano/x"), ("9router-ocg-y-bbbb2222", "ocg/y")])
|
||||
|
||||
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"),
|
||||
("9R · nano/qwen/qwen3.8-27b", "9R · nano/qwen3.8-27b"), ("9R · ag/gemini", "9R · ag/gemini"),
|
||||
("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), 3) # iki 9Router combosu hicbir kapsamda gosterilmez
|
||||
self.assertNotIn("gorsel", [x["id"] for x in s])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Codex compresses request bodies with zstd; the bridge must be able to read them."""
|
||||
import ast
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
tree = ast.parse(Path(__file__).with_name("bridge.py").read_text(encoding="utf-8"))
|
||||
wanted = {"_zstd_decompress", "_zstd_ready", "decompress"}
|
||||
env = {}
|
||||
exec(compile(ast.Module(body=[n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in wanted],
|
||||
type_ignores=[]), "bridge.py", "exec"), env)
|
||||
|
||||
|
||||
class Zstd(unittest.TestCase):
|
||||
def test_self_check_matches_reality(self):
|
||||
try:
|
||||
try:
|
||||
from compression import zstd as z
|
||||
packed = z.compress(b"govde" * 50)
|
||||
except ImportError:
|
||||
import zstandard
|
||||
packed = zstandard.ZstdCompressor().compress(b"govde" * 50)
|
||||
except ImportError:
|
||||
self.assertFalse(env["_zstd_ready"]()) # no zstd here: the bridge must say so
|
||||
return
|
||||
self.assertTrue(env["_zstd_ready"]())
|
||||
self.assertEqual(env["decompress"](packed, "zstd"), b"govde" * 50)
|
||||
|
||||
def test_other_encodings(self):
|
||||
import gzip
|
||||
self.assertEqual(env["decompress"](gzip.compress(b"x"), "gzip"), b"x")
|
||||
self.assertEqual(env["decompress"](b"duz", None), b"duz")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
Copyright (c) 2016, Gregory Szorc
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
# Copyright (c) 2017-present, Gregory Szorc
|
||||
# All rights reserved.
|
||||
#
|
||||
# This software may be modified and distributed under the terms
|
||||
# of the BSD license. See the LICENSE file for details.
|
||||
|
||||
# ruff: noqa: F403, F405
|
||||
|
||||
"""Python interface to the Zstandard (zstd) compression library."""
|
||||
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
# This module serves 2 roles:
|
||||
#
|
||||
# 1) Export the C or CFFI "backend" through a central module.
|
||||
# 2) Implement additional functionality built on top of C or CFFI backend.
|
||||
import builtins
|
||||
import io
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from collections.abc import Buffer
|
||||
else:
|
||||
from typing import ByteString as Buffer
|
||||
|
||||
# Some Python implementations don't support C extensions. That's why we have
|
||||
# a CFFI implementation in the first place. The code here import one of our
|
||||
# "backends" then re-exports the symbols from this module. For convenience,
|
||||
# we support falling back to the CFFI backend if the C extension can't be
|
||||
# imported. But for performance reasons, we only do this on unknown Python
|
||||
# implementation. Notably, for CPython we require the C extension by default.
|
||||
# Because someone will inevitably want special behavior, the behavior is
|
||||
# configurable via an environment variable. A potentially better way to handle
|
||||
# this is to import a special ``__importpolicy__`` module or something
|
||||
# defining a variable and `setup.py` could write the file with whatever
|
||||
# policy was specified at build time. Until someone needs it, we go with
|
||||
# the hacky but simple environment variable approach.
|
||||
_module_policy = os.environ.get(
|
||||
"PYTHON_ZSTANDARD_IMPORT_POLICY", "default"
|
||||
).strip()
|
||||
|
||||
if _module_policy == "default":
|
||||
if platform.python_implementation() in ("CPython",):
|
||||
from .backend_c import * # type: ignore
|
||||
|
||||
backend = "cext"
|
||||
elif platform.python_implementation() in ("PyPy",):
|
||||
from .backend_cffi import * # type: ignore
|
||||
|
||||
backend = "cffi"
|
||||
else:
|
||||
try:
|
||||
from .backend_c import *
|
||||
|
||||
backend = "cext"
|
||||
except ImportError:
|
||||
from .backend_cffi import *
|
||||
|
||||
backend = "cffi"
|
||||
elif _module_policy == "cffi_fallback":
|
||||
try:
|
||||
from .backend_c import *
|
||||
|
||||
backend = "cext"
|
||||
except ImportError:
|
||||
from .backend_cffi import *
|
||||
|
||||
backend = "cffi"
|
||||
elif _module_policy == "rust":
|
||||
from .backend_rust import * # type: ignore
|
||||
|
||||
backend = "rust"
|
||||
elif _module_policy == "cext":
|
||||
from .backend_c import *
|
||||
|
||||
backend = "cext"
|
||||
elif _module_policy == "cffi":
|
||||
from .backend_cffi import *
|
||||
|
||||
backend = "cffi"
|
||||
else:
|
||||
raise ImportError(
|
||||
"unknown module import policy: %s; use default, cffi_fallback, "
|
||||
"cext, or cffi" % _module_policy
|
||||
)
|
||||
|
||||
# Keep this in sync with python-zstandard.h, rust-ext/src/lib.rs, and debian/changelog.
|
||||
__version__ = "0.25.0"
|
||||
|
||||
_MODE_CLOSED = 0
|
||||
_MODE_READ = 1
|
||||
_MODE_WRITE = 2
|
||||
|
||||
|
||||
def open(
|
||||
filename,
|
||||
mode="rb",
|
||||
cctx=None,
|
||||
dctx=None,
|
||||
encoding=None,
|
||||
errors=None,
|
||||
newline=None,
|
||||
closefd=None,
|
||||
):
|
||||
"""Create a file object with zstd (de)compression.
|
||||
|
||||
The object returned from this function will be a
|
||||
:py:class:`ZstdDecompressionReader` if opened for reading in binary mode,
|
||||
a :py:class:`ZstdCompressionWriter` if opened for writing in binary mode,
|
||||
or an ``io.TextIOWrapper`` if opened for reading or writing in text mode.
|
||||
|
||||
:param filename:
|
||||
``bytes``, ``str``, or ``os.PathLike`` defining a file to open or a
|
||||
file object (with a ``read()`` or ``write()`` method).
|
||||
:param mode:
|
||||
``str`` File open mode. Accepts any of the open modes recognized by
|
||||
``open()``.
|
||||
:param cctx:
|
||||
``ZstdCompressor`` to use for compression. If not specified and file
|
||||
is opened for writing, the default ``ZstdCompressor`` will be used.
|
||||
:param dctx:
|
||||
``ZstdDecompressor`` to use for decompression. If not specified and file
|
||||
is opened for reading, the default ``ZstdDecompressor`` will be used.
|
||||
:param encoding:
|
||||
``str`` that defines text encoding to use when file is opened in text
|
||||
mode.
|
||||
:param errors:
|
||||
``str`` defining text encoding error handling mode.
|
||||
:param newline:
|
||||
``str`` defining newline to use in text mode.
|
||||
:param closefd:
|
||||
``bool`` whether to close the file when the returned object is closed.
|
||||
Only used if a file object is passed. If a filename is specified, the
|
||||
opened file is always closed when the returned object is closed.
|
||||
"""
|
||||
normalized_mode = mode.replace("t", "")
|
||||
|
||||
if normalized_mode in ("r", "rb"):
|
||||
dctx = dctx or ZstdDecompressor()
|
||||
open_mode = "r"
|
||||
raw_open_mode = "rb"
|
||||
elif normalized_mode in ("w", "wb", "a", "ab", "x", "xb"):
|
||||
cctx = cctx or ZstdCompressor()
|
||||
open_mode = "w"
|
||||
raw_open_mode = normalized_mode
|
||||
if not raw_open_mode.endswith("b"):
|
||||
raw_open_mode = raw_open_mode + "b"
|
||||
else:
|
||||
raise ValueError("Invalid mode: {!r}".format(mode))
|
||||
|
||||
if hasattr(os, "PathLike"):
|
||||
types = (str, bytes, os.PathLike)
|
||||
else:
|
||||
types = (str, bytes)
|
||||
|
||||
if isinstance(filename, types): # type: ignore
|
||||
inner_fh = builtins.open(filename, raw_open_mode)
|
||||
closefd = True
|
||||
elif hasattr(filename, "read") or hasattr(filename, "write"):
|
||||
inner_fh = filename
|
||||
closefd = bool(closefd)
|
||||
else:
|
||||
raise TypeError(
|
||||
"filename must be a str, bytes, file or PathLike object"
|
||||
)
|
||||
|
||||
if open_mode == "r":
|
||||
fh = dctx.stream_reader(inner_fh, closefd=closefd)
|
||||
elif open_mode == "w":
|
||||
fh = cctx.stream_writer(inner_fh, closefd=closefd)
|
||||
else:
|
||||
raise RuntimeError("logic error in zstandard.open() handling open mode")
|
||||
|
||||
if "b" not in normalized_mode:
|
||||
return io.TextIOWrapper(
|
||||
fh, encoding=encoding, errors=errors, newline=newline
|
||||
)
|
||||
else:
|
||||
return fh
|
||||
|
||||
|
||||
def compress(data: Buffer, level: int = 3) -> bytes:
|
||||
"""Compress source data using the zstd compression format.
|
||||
|
||||
This performs one-shot compression using basic/default compression
|
||||
settings.
|
||||
|
||||
This method is provided for convenience and is equivalent to calling
|
||||
``ZstdCompressor(level=level).compress(data)``.
|
||||
|
||||
If you find yourself calling this function in a tight loop,
|
||||
performance will be greater if you construct a single ``ZstdCompressor``
|
||||
and repeatedly call ``compress()`` on it.
|
||||
"""
|
||||
cctx = ZstdCompressor(level=level)
|
||||
|
||||
return cctx.compress(data)
|
||||
|
||||
|
||||
def decompress(data: Buffer, max_output_size: int = 0) -> bytes:
|
||||
"""Decompress a zstd frame into its original data.
|
||||
|
||||
This performs one-shot decompression using basic/default compression
|
||||
settings.
|
||||
|
||||
This method is provided for convenience and is equivalent to calling
|
||||
``ZstdDecompressor().decompress(data, max_output_size=max_output_size)``.
|
||||
|
||||
If you find yourself calling this function in a tight loop, performance
|
||||
will be greater if you construct a single ``ZstdDecompressor`` and
|
||||
repeatedly call ``decompress()`` on it.
|
||||
"""
|
||||
dctx = ZstdDecompressor()
|
||||
|
||||
return dctx.decompress(data, max_output_size=max_output_size)
|
||||
+481
@@ -0,0 +1,481 @@
|
||||
# Copyright (c) 2016-present, Gregory Szorc
|
||||
# All rights reserved.
|
||||
#
|
||||
# This software may be modified and distributed under the terms
|
||||
# of the BSD license. See the LICENSE file for details.
|
||||
|
||||
import os
|
||||
from typing import (
|
||||
IO,
|
||||
BinaryIO,
|
||||
ByteString,
|
||||
Generator,
|
||||
Iterable,
|
||||
List,
|
||||
Optional,
|
||||
Set,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
|
||||
FLUSH_BLOCK: int
|
||||
FLUSH_FRAME: int
|
||||
|
||||
COMPRESSOBJ_FLUSH_FINISH: int
|
||||
COMPRESSOBJ_FLUSH_BLOCK: int
|
||||
|
||||
CONTENTSIZE_UNKNOWN: int
|
||||
CONTENTSIZE_ERROR: int
|
||||
|
||||
MAX_COMPRESSION_LEVEL: int
|
||||
|
||||
COMPRESSION_RECOMMENDED_INPUT_SIZE: int
|
||||
COMPRESSION_RECOMMENDED_OUTPUT_SIZE: int
|
||||
|
||||
DECOMPRESSION_RECOMMENDED_INPUT_SIZE: int
|
||||
DECOMPRESSION_RECOMMENDED_OUTPUT_SIZE: int
|
||||
|
||||
BLOCKSIZELOG_MAX: int
|
||||
BLOCKSIZE_MAX: int
|
||||
|
||||
WINDOWLOG_MIN: int
|
||||
WINDOWLOG_MAX: int
|
||||
|
||||
CHAINLOG_MIN: int
|
||||
CHAINLOG_MAX: int
|
||||
HASHLOG_MIN: int
|
||||
HASHLOG_MAX: int
|
||||
MINMATCH_MIN: int
|
||||
MINMATCH_MAX: int
|
||||
SEARCHLOG_MIN: int
|
||||
SEARCHLOG_MAX: int
|
||||
SEARCHLENGTH_MIN: int
|
||||
SEARCHLENGTH_MAX: int
|
||||
TARGETLENGTH_MIN: int
|
||||
TARGETLENGTH_MAX: int
|
||||
LDM_MINMATCH_MIN: int
|
||||
LDM_MINMATCH_MAX: int
|
||||
LDM_BUCKETSIZELOG_MAX: int
|
||||
|
||||
STRATEGY_FAST: int
|
||||
STRATEGY_DFAST: int
|
||||
STRATEGY_GREEDY: int
|
||||
STRATEGY_LAZY: int
|
||||
STRATEGY_LAZY2: int
|
||||
STRATEGY_BTLAZY2: int
|
||||
STRATEGY_BTOPT: int
|
||||
STRATEGY_BTULTRA: int
|
||||
STRATEGY_BTULTRA2: int
|
||||
|
||||
DICT_TYPE_AUTO: int
|
||||
DICT_TYPE_RAWCONTENT: int
|
||||
DICT_TYPE_FULLDICT: int
|
||||
|
||||
FORMAT_ZSTD1: int
|
||||
FORMAT_ZSTD1_MAGICLESS: int
|
||||
|
||||
ZSTD_VERSION: Tuple[int, int, int]
|
||||
FRAME_HEADER: bytes
|
||||
MAGIC_NUMBER: int
|
||||
|
||||
backend: str
|
||||
backend_features: Set[str]
|
||||
__version__: str
|
||||
|
||||
class ZstdError(Exception): ...
|
||||
|
||||
class BufferSegment(object):
|
||||
offset: int
|
||||
def __len__(self) -> int: ...
|
||||
def tobytes(self) -> bytes: ...
|
||||
|
||||
class BufferSegments(object):
|
||||
def __len__(self) -> int: ...
|
||||
def __getitem__(self, i: int) -> BufferSegment: ...
|
||||
|
||||
class BufferWithSegments(object):
|
||||
size: int
|
||||
def __init__(self, data: ByteString, segments: ByteString): ...
|
||||
def __len__(self) -> int: ...
|
||||
def __getitem__(self, i: int) -> BufferSegment: ...
|
||||
def segments(self): ...
|
||||
def tobytes(self) -> bytes: ...
|
||||
|
||||
class BufferWithSegmentsCollection(object):
|
||||
def __init__(self, *args): ...
|
||||
def __len__(self) -> int: ...
|
||||
def __getitem__(self, i: int) -> BufferSegment: ...
|
||||
def size(self) -> int: ...
|
||||
|
||||
class ZstdCompressionParameters(object):
|
||||
@staticmethod
|
||||
def from_level(
|
||||
level: int, source_size: int = ..., dict_size: int = ..., **kwargs
|
||||
) -> "ZstdCompressionParameters": ...
|
||||
def __init__(
|
||||
self,
|
||||
format: int = ...,
|
||||
compression_level: int = ...,
|
||||
window_log: int = ...,
|
||||
hash_log: int = ...,
|
||||
chain_log: int = ...,
|
||||
search_log: int = ...,
|
||||
min_match: int = ...,
|
||||
target_length: int = ...,
|
||||
strategy: int = ...,
|
||||
write_content_size: int = ...,
|
||||
write_checksum: int = ...,
|
||||
write_dict_id: int = ...,
|
||||
job_size: int = ...,
|
||||
overlap_log: int = ...,
|
||||
force_max_window: int = ...,
|
||||
enable_ldm: int = ...,
|
||||
ldm_hash_log: int = ...,
|
||||
ldm_min_match: int = ...,
|
||||
ldm_bucket_size_log: int = ...,
|
||||
ldm_hash_rate_log: int = ...,
|
||||
threads: int = ...,
|
||||
): ...
|
||||
@property
|
||||
def format(self) -> int: ...
|
||||
@property
|
||||
def compression_level(self) -> int: ...
|
||||
@property
|
||||
def window_log(self) -> int: ...
|
||||
@property
|
||||
def hash_log(self) -> int: ...
|
||||
@property
|
||||
def chain_log(self) -> int: ...
|
||||
@property
|
||||
def search_log(self) -> int: ...
|
||||
@property
|
||||
def min_match(self) -> int: ...
|
||||
@property
|
||||
def target_length(self) -> int: ...
|
||||
@property
|
||||
def strategy(self) -> int: ...
|
||||
@property
|
||||
def write_content_size(self) -> int: ...
|
||||
@property
|
||||
def write_checksum(self) -> int: ...
|
||||
@property
|
||||
def write_dict_id(self) -> int: ...
|
||||
@property
|
||||
def job_size(self) -> int: ...
|
||||
@property
|
||||
def overlap_log(self) -> int: ...
|
||||
@property
|
||||
def force_max_window(self) -> int: ...
|
||||
@property
|
||||
def enable_ldm(self) -> int: ...
|
||||
@property
|
||||
def ldm_hash_log(self) -> int: ...
|
||||
@property
|
||||
def ldm_min_match(self) -> int: ...
|
||||
@property
|
||||
def ldm_bucket_size_log(self) -> int: ...
|
||||
@property
|
||||
def ldm_hash_rate_log(self) -> int: ...
|
||||
@property
|
||||
def threads(self) -> int: ...
|
||||
def estimated_compression_context_size(self) -> int: ...
|
||||
|
||||
class CompressionParameters(ZstdCompressionParameters): ...
|
||||
|
||||
class ZstdCompressionDict(object):
|
||||
k: int
|
||||
d: int
|
||||
def __init__(
|
||||
self,
|
||||
data: ByteString,
|
||||
dict_type: int = ...,
|
||||
k: int = ...,
|
||||
d: int = ...,
|
||||
): ...
|
||||
def __len__(self) -> int: ...
|
||||
def dict_id(self) -> int: ...
|
||||
def as_bytes(self) -> bytes: ...
|
||||
def precompute_compress(
|
||||
self,
|
||||
level: int = ...,
|
||||
compression_params: ZstdCompressionParameters = ...,
|
||||
): ...
|
||||
|
||||
class ZstdCompressionObj(object):
|
||||
def compress(self, data: ByteString) -> bytes: ...
|
||||
def flush(self, flush_mode: int = ...) -> bytes: ...
|
||||
|
||||
class ZstdCompressionChunker(object):
|
||||
def compress(self, data: ByteString): ...
|
||||
def flush(self): ...
|
||||
def finish(self): ...
|
||||
|
||||
class ZstdCompressionReader(BinaryIO):
|
||||
def __enter__(self) -> "ZstdCompressionReader": ...
|
||||
def __exit__(self, exc_type, exc_value, exc_tb): ...
|
||||
def readable(self) -> bool: ...
|
||||
def writable(self) -> bool: ...
|
||||
def seekable(self) -> bool: ...
|
||||
def readline(self, limit: int = ...) -> bytes: ...
|
||||
def readlines(self, hint: int = ...) -> List[bytes]: ...
|
||||
def write(self, data: ByteString): ...
|
||||
def writelines(self, data: Iterable[bytes]): ...
|
||||
def isatty(self) -> bool: ...
|
||||
def flush(self): ...
|
||||
def close(self): ...
|
||||
@property
|
||||
def closed(self) -> bool: ...
|
||||
def tell(self) -> int: ...
|
||||
def readall(self) -> bytes: ...
|
||||
def __iter__(self): ...
|
||||
def __next__(self): ...
|
||||
def next(self): ...
|
||||
def read(self, size: int = ...) -> bytes: ...
|
||||
def read1(self, size: int = ...) -> bytes: ...
|
||||
def readinto(self, b) -> int: ...
|
||||
def readinto1(self, b) -> int: ...
|
||||
|
||||
class ZstdCompressionWriter(BinaryIO):
|
||||
def __enter__(self) -> "ZstdCompressionWriter": ...
|
||||
def __exit__(self, exc_type, exc_value, exc_tb): ...
|
||||
def memory_size(self) -> int: ...
|
||||
def fileno(self) -> int: ...
|
||||
def close(self): ...
|
||||
@property
|
||||
def closed(self) -> bool: ...
|
||||
def isatty(self) -> bool: ...
|
||||
def readable(self) -> bool: ...
|
||||
def readline(self, size: int = ...) -> bytes: ...
|
||||
def readlines(self, hint: int = ...) -> List[bytes]: ...
|
||||
def seek(self, offset: int, whence: int = ...): ...
|
||||
def seekable(self) -> bool: ...
|
||||
def truncate(self, size: int = ...): ...
|
||||
def writable(self) -> bool: ...
|
||||
def writelines(self, lines: Iterable[bytes]): ...
|
||||
def read(self, size: int = ...) -> bytes: ...
|
||||
def readall(self) -> bytes: ...
|
||||
def readinto(self, b): ...
|
||||
def write(self, data: ByteString) -> int: ...
|
||||
def flush(self, flush_mode: int = ...) -> int: ...
|
||||
def tell(self) -> int: ...
|
||||
|
||||
class ZstdCompressor(object):
|
||||
def __init__(
|
||||
self,
|
||||
level: int = ...,
|
||||
dict_data: Optional[ZstdCompressionDict] = ...,
|
||||
compression_params: Optional[ZstdCompressionParameters] = ...,
|
||||
write_checksum: Optional[bool] = ...,
|
||||
write_content_size: Optional[bool] = ...,
|
||||
write_dict_id: Optional[bool] = ...,
|
||||
threads: int = ...,
|
||||
): ...
|
||||
def memory_size(self) -> int: ...
|
||||
def compress(self, data: ByteString) -> bytes: ...
|
||||
def compressobj(self, size: int = ...) -> ZstdCompressionObj: ...
|
||||
def chunker(
|
||||
self, size: int = ..., chunk_size: int = ...
|
||||
) -> ZstdCompressionChunker: ...
|
||||
def copy_stream(
|
||||
self,
|
||||
ifh: IO[bytes],
|
||||
ofh: IO[bytes],
|
||||
size: int = ...,
|
||||
read_size: int = ...,
|
||||
write_size: int = ...,
|
||||
) -> Tuple[int, int]: ...
|
||||
def stream_reader(
|
||||
self,
|
||||
source: Union[IO[bytes], ByteString],
|
||||
size: int = ...,
|
||||
read_size: int = ...,
|
||||
*,
|
||||
closefd: bool = ...,
|
||||
) -> ZstdCompressionReader: ...
|
||||
def stream_writer(
|
||||
self,
|
||||
writer: IO[bytes],
|
||||
size: int = ...,
|
||||
write_size: int = ...,
|
||||
write_return_read: bool = ...,
|
||||
*,
|
||||
closefd: bool = ...,
|
||||
) -> ZstdCompressionWriter: ...
|
||||
def read_to_iter(
|
||||
self,
|
||||
reader: Union[IO[bytes], ByteString],
|
||||
size: int = ...,
|
||||
read_size: int = ...,
|
||||
write_size: int = ...,
|
||||
) -> Generator[bytes, None, None]: ...
|
||||
def frame_progression(self) -> Tuple[int, int, int]: ...
|
||||
def multi_compress_to_buffer(
|
||||
self,
|
||||
data: Union[
|
||||
BufferWithSegments,
|
||||
BufferWithSegmentsCollection,
|
||||
List[ByteString],
|
||||
],
|
||||
threads: int = ...,
|
||||
) -> BufferWithSegmentsCollection: ...
|
||||
|
||||
class ZstdDecompressionObj(object):
|
||||
def decompress(self, data: ByteString) -> bytes: ...
|
||||
def flush(self, length: int = ...) -> bytes: ...
|
||||
@property
|
||||
def unused_data(self) -> bytes: ...
|
||||
@property
|
||||
def unconsumed_tail(self) -> bytes: ...
|
||||
@property
|
||||
def eof(self) -> bool: ...
|
||||
|
||||
class ZstdDecompressionReader(BinaryIO):
|
||||
def __enter__(self) -> "ZstdDecompressionReader": ...
|
||||
def __exit__(self, exc_type, exc_value, exc_tb): ...
|
||||
def readable(self) -> bool: ...
|
||||
def writable(self) -> bool: ...
|
||||
def seekable(self) -> bool: ...
|
||||
def readline(self, size: int = ...): ...
|
||||
def readlines(self, hint: int = ...): ...
|
||||
def write(self, data: ByteString): ...
|
||||
def writelines(self, lines: Iterable[bytes]): ...
|
||||
def isatty(self) -> bool: ...
|
||||
def flush(self): ...
|
||||
def close(self): ...
|
||||
@property
|
||||
def closed(self) -> bool: ...
|
||||
def tell(self) -> int: ...
|
||||
def readall(self) -> bytes: ...
|
||||
def __iter__(self): ...
|
||||
def __next__(self): ...
|
||||
def next(self): ...
|
||||
def read(self, size: int = ...) -> bytes: ...
|
||||
def readinto(self, b) -> int: ...
|
||||
def read1(self, size: int = ...) -> bytes: ...
|
||||
def readinto1(self, b) -> int: ...
|
||||
def seek(self, pos: int, whence: int = ...) -> int: ...
|
||||
|
||||
class ZstdDecompressionWriter(BinaryIO):
|
||||
def __enter__(self) -> "ZstdDecompressionWriter": ...
|
||||
def __exit__(self, exc_type, exc_value, exc_tb): ...
|
||||
def memory_size(self) -> int: ...
|
||||
def close(self): ...
|
||||
@property
|
||||
def closed(self) -> bool: ...
|
||||
def fileno(self) -> int: ...
|
||||
def flush(self): ...
|
||||
def isatty(self) -> bool: ...
|
||||
def readable(self) -> bool: ...
|
||||
def readline(self, size: int = ...): ...
|
||||
def readlines(self, hint: int = ...): ...
|
||||
def seek(self, offset: int, whence: int = ...): ...
|
||||
def seekable(self) -> bool: ...
|
||||
def tell(self): ...
|
||||
def truncate(self, size: int = ...): ...
|
||||
def writable(self) -> bool: ...
|
||||
def writelines(self, lines: Iterable[bytes]): ...
|
||||
def read(self, size: int = ...): ...
|
||||
def readall(self): ...
|
||||
def readinto(self, b): ...
|
||||
def write(self, data: ByteString) -> int: ...
|
||||
|
||||
class ZstdDecompressor(object):
|
||||
def __init__(
|
||||
self,
|
||||
dict_data: Optional[ZstdCompressionDict] = ...,
|
||||
max_window_size: int = ...,
|
||||
format: int = ...,
|
||||
): ...
|
||||
def memory_size(self) -> int: ...
|
||||
def decompress(
|
||||
self,
|
||||
data: ByteString,
|
||||
max_output_size: int = ...,
|
||||
read_across_frames: bool = ...,
|
||||
allow_extra_data: bool = ...,
|
||||
) -> bytes: ...
|
||||
def stream_reader(
|
||||
self,
|
||||
source: Union[IO[bytes], ByteString],
|
||||
read_size: int = ...,
|
||||
read_across_frames: bool = ...,
|
||||
*,
|
||||
closefd=False,
|
||||
) -> ZstdDecompressionReader: ...
|
||||
def decompressobj(
|
||||
self, write_size: int = ..., read_across_frames: bool = False
|
||||
) -> ZstdDecompressionObj: ...
|
||||
def read_to_iter(
|
||||
self,
|
||||
reader: Union[IO[bytes], ByteString],
|
||||
read_size: int = ...,
|
||||
write_size: int = ...,
|
||||
skip_bytes: int = ...,
|
||||
) -> Generator[bytes, None, None]: ...
|
||||
def stream_writer(
|
||||
self,
|
||||
writer: IO[bytes],
|
||||
write_size: int = ...,
|
||||
write_return_read: bool = ...,
|
||||
*,
|
||||
closefd: bool = ...,
|
||||
) -> ZstdDecompressionWriter: ...
|
||||
def copy_stream(
|
||||
self,
|
||||
ifh: IO[bytes],
|
||||
ofh: IO[bytes],
|
||||
read_size: int = ...,
|
||||
write_size: int = ...,
|
||||
) -> Tuple[int, int]: ...
|
||||
def decompress_content_dict_chain(
|
||||
self, frames: list[ByteString]
|
||||
) -> bytes: ...
|
||||
def multi_decompress_to_buffer(
|
||||
self,
|
||||
frames: Union[
|
||||
BufferWithSegments,
|
||||
BufferWithSegmentsCollection,
|
||||
List[ByteString],
|
||||
],
|
||||
decompressed_sizes: ByteString = ...,
|
||||
threads: int = ...,
|
||||
) -> BufferWithSegmentsCollection: ...
|
||||
|
||||
class FrameParameters(object):
|
||||
content_size: int
|
||||
window_size: int
|
||||
dict_id: int
|
||||
has_checksum: bool
|
||||
|
||||
def estimate_decompression_context_size() -> int: ...
|
||||
def frame_content_size(data: ByteString) -> int: ...
|
||||
def frame_header_size(data: ByteString) -> int: ...
|
||||
def get_frame_parameters(
|
||||
data: ByteString, format: Optional[int] = None
|
||||
) -> FrameParameters: ...
|
||||
def train_dictionary(
|
||||
dict_size: int,
|
||||
samples: list[ByteString],
|
||||
k: int = ...,
|
||||
d: int = ...,
|
||||
f: int = ...,
|
||||
split_point: float = ...,
|
||||
accel: int = ...,
|
||||
notifications: int = ...,
|
||||
dict_id: int = ...,
|
||||
level: int = ...,
|
||||
steps: int = ...,
|
||||
threads: int = ...,
|
||||
) -> ZstdCompressionDict: ...
|
||||
def open(
|
||||
filename: Union[bytes, str, os.PathLike, BinaryIO],
|
||||
mode: str = ...,
|
||||
cctx: Optional[ZstdCompressor] = ...,
|
||||
dctx: Optional[ZstdDecompressor] = ...,
|
||||
encoding: Optional[str] = ...,
|
||||
errors: Optional[str] = ...,
|
||||
newline: Optional[str] = ...,
|
||||
closefd: bool = ...,
|
||||
): ...
|
||||
def compress(data: ByteString, level: int = ...) -> bytes: ...
|
||||
def decompress(data: ByteString, max_output_size: int = ...) -> bytes: ...
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
+4478
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,123 +0,0 @@
|
||||
import schema from './codex-link-control.schema.json'
|
||||
import type { CodexLinkControlRequest, CodexLinkControlResponse } from './codex-link-control.types'
|
||||
import { canonicalJson, CodexLinkValidationError, parseStrictJson } from './codex-link-json'
|
||||
|
||||
export const CODEX_LINK_REQUEST_BYTES = 64 * 1024
|
||||
export const CODEX_LINK_RESPONSE_BYTES = 1024 * 1024
|
||||
type Rule = Record<string, any>
|
||||
const definitions: Record<string, Rule> = schema.$defs
|
||||
|
||||
function matches(value: any, rule: Rule): boolean {
|
||||
if (rule.$ref) {
|
||||
const target = definitions[rule.$ref.replace('#/$defs/', '')]
|
||||
|
||||
return !!target && matches(value, target)
|
||||
}
|
||||
|
||||
if (rule.oneOf) {
|
||||
return rule.oneOf.filter((r: Rule) => matches(value, r)).length === 1
|
||||
}
|
||||
|
||||
if (rule.anyOf) {
|
||||
return rule.anyOf.some((r: Rule) => matches(value, r))
|
||||
}
|
||||
|
||||
if ('const' in rule && value !== rule.const) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (rule.enum && !rule.enum.includes(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
switch (rule.type) {
|
||||
case 'object': {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const keys = Object.keys(value)
|
||||
|
||||
return (
|
||||
keys.length >= (rule.minProperties ?? 0) &&
|
||||
rule.required.every((key: string) => Object.hasOwn(value, key)) &&
|
||||
keys.every(key => Object.hasOwn(rule.properties, key) && matches(value[key], rule.properties[key]))
|
||||
)
|
||||
}
|
||||
|
||||
case 'array':
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length >= (rule.minItems ?? 0) &&
|
||||
(!rule.uniqueItems || new Set(value.map(canonicalJson)).size === value.length) &&
|
||||
value.every(item => matches(item, rule.items))
|
||||
)
|
||||
|
||||
case 'string':
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
[...value].length >= (rule.minLength ?? 0) &&
|
||||
(!rule.pattern || new RegExp(rule.pattern).test(value))
|
||||
)
|
||||
|
||||
case 'integer':
|
||||
return (
|
||||
Number.isSafeInteger(value) &&
|
||||
value >= (rule.minimum ?? -Number.MAX_SAFE_INTEGER) &&
|
||||
value <= (rule.maximum ?? Number.MAX_SAFE_INTEGER)
|
||||
)
|
||||
|
||||
case 'boolean':
|
||||
return typeof value === 'boolean'
|
||||
|
||||
case 'null':
|
||||
return value === null
|
||||
|
||||
case undefined:
|
||||
return 'const' in rule || 'enum' in rule
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function validateControlRequest(value: unknown): asserts value is CodexLinkControlRequest {
|
||||
const text = canonicalJson(value)
|
||||
|
||||
if (
|
||||
new TextEncoder().encode(text).byteLength > CODEX_LINK_REQUEST_BYTES ||
|
||||
!matches(value, definitions.control_request)
|
||||
) {
|
||||
throw new CodexLinkValidationError('invalid_control_request')
|
||||
}
|
||||
}
|
||||
|
||||
export function parseControlResponse(
|
||||
raw: Uint8Array | string,
|
||||
request: CodexLinkControlRequest
|
||||
): CodexLinkControlResponse {
|
||||
const value = parseStrictJson(raw, CODEX_LINK_RESPONSE_BYTES) as CodexLinkControlResponse
|
||||
|
||||
if (
|
||||
!matches(value, definitions.control_response) ||
|
||||
value.request_id !== request.request_id ||
|
||||
value.operation_id !== request.operation_id
|
||||
) {
|
||||
throw new CodexLinkValidationError('invalid_control_response')
|
||||
}
|
||||
|
||||
const expected = {
|
||||
settings_get: 'settings_result',
|
||||
settings_update: 'settings_result',
|
||||
status_get: 'status_result',
|
||||
context_views_get: 'context_views_result',
|
||||
catalog_sync: 'control_accepted',
|
||||
context_compact_view: 'control_accepted'
|
||||
}
|
||||
|
||||
if (value.kind !== 'control_error' && value.kind !== expected[request.kind]) {
|
||||
throw new CodexLinkValidationError('unexpected_control_response')
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// Generated by scripts/generate-codex-link-control.py. Do not edit.
|
||||
export const CODEX_LINK_CONTROL_SCHEMA_HASH = "sha256:4c46b591ccd508670835f6dacd7f61c30793e98181d63089512b7d812d1b85b8"
|
||||
export type CodexLinkCatalogSync = { "contract_version": "codex-link/1"; "kind": "catalog_sync"; "request_id": string; "operation_id": string }
|
||||
export type CodexLinkContextCompactView = { "contract_version": "codex-link/1"; "kind": "context_compact_view"; "request_id": string; "operation_id": string; "view_handle": string; "expected_checkpoint_revision": number }
|
||||
export type CodexLinkContextView = { "view_handle": string; "can_compact": boolean; "label": string; "checkpoint_revision": number; "current_model_label": string; "limiting_target_id": (string | null); "target_budgets": (Array<CodexLinkTargetBudget> | null); "active_summary_count": number; "summary_tokens": (number | null); "summary_token_measurement": ({ "target_id": string; "tokenizer_id": string; "tokenizer_revision": string; "count_method": "exact_tokenizer" | "verified_upper_bound" | "provider_estimate" } | null); "last_operation_id": (string | null); "last_operation_state": ("accepted" | "running" | "completed" | "failed" | "indeterminate" | null); "last_operation_error": (CodexLinkError | null); "compact_attempts": Array<{ "attempt_id": string; "member_label": string; "state": "prepared" | "running" | "completed" | "failed" | "indeterminate"; "usage": CodexLinkUsage }> }
|
||||
export type CodexLinkContextViewsGet = { "contract_version": "codex-link/1"; "kind": "context_views_get"; "request_id": string; "operation_id": string }
|
||||
export type CodexLinkContextViewsResult = { "contract_version": "codex-link/1"; "kind": "context_views_result"; "request_id": string; "operation_id": string; "settings_revision": number; "views": Array<CodexLinkContextView> }
|
||||
export type CodexLinkControlAccepted = { "contract_version": "codex-link/1"; "kind": "control_accepted"; "request_id": string; "operation_id": string; "settings_revision": number; "operation_state": "accepted" | "running" | "completed" | "failed" | "indeterminate"; "error": (CodexLinkError | null) }
|
||||
export type CodexLinkControlError = { "contract_version": "codex-link/1"; "kind": "control_error"; "request_id": string; "operation_id": string; "settings_revision": number; "error": CodexLinkError }
|
||||
export type CodexLinkControlRequest = (CodexLinkSettingsGet | CodexLinkSettingsUpdate | CodexLinkStatusGet | CodexLinkCatalogSync | CodexLinkContextViewsGet | CodexLinkContextCompactView)
|
||||
export type CodexLinkControlResponse = (CodexLinkSettingsResult | CodexLinkStatusResult | CodexLinkContextViewsResult | CodexLinkControlAccepted | CodexLinkControlError)
|
||||
export type CodexLinkError = { "code": string; "message": string; "recovery_action": "retry_control_read" | "reconcile_only" | "repair_required" | "select_route" | "reconfigure" | "none" }
|
||||
export type CodexLinkOutputCapEnforcement = ({ "method": "request_field"; "field": "max_output_tokens" | "max_tokens" | "max_completion_tokens" | "num_predict"; "value": number; "proof_revision": string; "verified": true } | { "method": "provider_fixed_maximum"; "value": number; "proof_revision": string; "verified": true })
|
||||
export type CodexLinkSettings = { "settings_revision": number; "enabled": boolean; "compression_enabled": boolean; "threshold_tokens": number; "source_binding_handle": (string | null); "compact_route_id": (string | null); "legacy_intent_imported": boolean; "ownership_manifest_revision": number; "activation_state": "disabled" | "pending_runtime_acceptance" | "ready" | "restart_required" | "conflict" | "recovery_pending" }
|
||||
export type CodexLinkSettingsGet = { "contract_version": "codex-link/1"; "kind": "settings_get"; "request_id": string; "operation_id": string }
|
||||
export type CodexLinkSettingsResult = { "contract_version": "codex-link/1"; "kind": "settings_result"; "request_id": string; "operation_id": string; "settings": CodexLinkSettings; "available_sources": Array<{ "source_binding_handle": string; "label": string; "authorized": boolean }> }
|
||||
export type CodexLinkSettingsUpdate = { "contract_version": "codex-link/1"; "kind": "settings_update"; "request_id": string; "operation_id": string; "expected_settings_revision": number; "patch": { "enabled"?: boolean; "compression_enabled"?: boolean; "threshold_tokens"?: number; "source_binding_handle"?: string } }
|
||||
export type CodexLinkStatusGet = { "contract_version": "codex-link/1"; "kind": "status_get"; "request_id": string; "operation_id": string }
|
||||
export type CodexLinkStatusResult = { "contract_version": "codex-link/1"; "kind": "status_result"; "request_id": string; "operation_id": string; "status": CodexLinkStatusSnapshot }
|
||||
export type CodexLinkStatusSnapshot = { "settings_revision": number; "runtime_mode": "native" | "managed" | "unknown"; "native_available": boolean; "external_admission": "open" | "closed" | "draining" | "recovery_required"; "needs_restart": boolean; "catalog_state": "unknown" | "fresh" | "stale" | "unauthorized" | "empty"; "active_turns": number; "uncertain_operations": number; "last_operation_id": (string | null); "error": (CodexLinkError | null) }
|
||||
export type CodexLinkTargetBudget = { "target_id": string; "canonical_leaf_id": string; "limits_source": "provider_contract" | "provider_api" | "verified_adapter" | "verified_measurement"; "limits_revision": string; "tokenizer_id": string; "tokenizer_revision": string; "count_method": "exact_tokenizer" | "verified_upper_bound" | "provider_estimate"; "serialization_revision": string; "input_tokens": number; "context_window_tokens": number; "output_cap_tokens": number; "safety_margin_tokens": number; "max_input_tokens": (number | null); "max_output_tokens": number; "request_bytes": number; "max_request_bytes": number; "output_cap_enforcement": CodexLinkOutputCapEnforcement }
|
||||
export type CodexLinkUsage = { "input_tokens": (number | null); "output_tokens": (number | null); "measurement": "measured" | "estimated" | "unknown"; "cost_microunits": (number | null); "currency": ("USD" | null); "cost_source": (string | null) }
|
||||
@@ -1,259 +0,0 @@
|
||||
// Strict P1 JSON domain/parser, shared algorithm with router codexLinkValidation.
|
||||
// Browser-safe; never apply to the opaque native/provider JSON body.
|
||||
type Data = Record<string, any>
|
||||
|
||||
export class CodexLinkValidationError extends Error {
|
||||
readonly code: string
|
||||
constructor(code: string) {
|
||||
super(code)
|
||||
this.code = code
|
||||
this.name = 'CodexLinkValidationError'
|
||||
}
|
||||
}
|
||||
|
||||
function requireValue(condition: unknown, code: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new CodexLinkValidationError(code)
|
||||
}
|
||||
}
|
||||
|
||||
function object(value: unknown): Data {
|
||||
requireValue(value !== null && typeof value === 'object' && !Array.isArray(value), 'schema_rejected')
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
requireValue(prototype === Object.prototype || prototype === null, 'schema_rejected')
|
||||
|
||||
return value as Data
|
||||
}
|
||||
|
||||
function integer(value: unknown, positive = false): asserts value is number {
|
||||
requireValue(Number.isSafeInteger(value) && (value as number) >= (positive ? 1 : 0), 'schema_rejected')
|
||||
}
|
||||
|
||||
function id(value: unknown): asserts value is string {
|
||||
requireValue(typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(value), 'schema_rejected')
|
||||
}
|
||||
|
||||
function exactKeys(value: Data, keys: string[]) {
|
||||
requireValue(
|
||||
Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)),
|
||||
'schema_rejected'
|
||||
)
|
||||
}
|
||||
|
||||
function unicode(value: string) {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const code = value.charCodeAt(i)
|
||||
|
||||
if (code >= 0xd800 && code <= 0xdbff) {
|
||||
const low = value.charCodeAt(++i)
|
||||
requireValue(low >= 0xdc00 && low <= 0xdfff, 'invalid_unicode')
|
||||
} else {
|
||||
requireValue(code < 0xdc00 || code > 0xdfff, 'invalid_unicode')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** P1 canonical domain: ASCII object keys, safe integers, exact Unicode text. */
|
||||
export function canonicalJson(value: unknown): string {
|
||||
const ancestors = new Set<object>()
|
||||
|
||||
function encode(item: unknown, depth: number): string {
|
||||
requireValue(depth <= 256, 'invalid_json')
|
||||
|
||||
if (item === null || typeof item === 'boolean') {
|
||||
return JSON.stringify(item)
|
||||
}
|
||||
|
||||
if (typeof item === 'string') {
|
||||
unicode(item)
|
||||
|
||||
return JSON.stringify(item)
|
||||
}
|
||||
|
||||
if (typeof item === 'number') {
|
||||
requireValue(Number.isInteger(item), 'floats_or_non_json_type')
|
||||
requireValue(Number.isSafeInteger(item), 'unsafe_integer')
|
||||
|
||||
return JSON.stringify(item)
|
||||
}
|
||||
|
||||
requireValue(typeof item === 'object' && item !== null, 'floats_or_non_json_type')
|
||||
requireValue(!ancestors.has(item), 'invalid_json')
|
||||
ancestors.add(item)
|
||||
let result: string
|
||||
|
||||
if (Array.isArray(item)) {
|
||||
result = '[' + item.map(child => encode(child, depth + 1)).join(',') + ']'
|
||||
} else {
|
||||
const data = object(item)
|
||||
const keys = Object.keys(data).sort()
|
||||
requireValue(
|
||||
keys.every(key => [...key].every(char => char.charCodeAt(0) <= 127)),
|
||||
'non_ascii_key'
|
||||
)
|
||||
result = '{' + keys.map(key => JSON.stringify(key) + ':' + encode(data[key], depth + 1)).join(',') + '}'
|
||||
}
|
||||
|
||||
ancestors.delete(item)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return encode(value, 0)
|
||||
}
|
||||
|
||||
/** A bounded parser that detects duplicates before JSON.parse can erase them. */
|
||||
export function parseStrictJson(raw: string | Uint8Array, maxBytes: number): unknown {
|
||||
integer(maxBytes, true)
|
||||
const rawBytes = typeof raw === 'string' ? new TextEncoder().encode(raw).byteLength : raw.byteLength
|
||||
requireValue(rawBytes <= maxBytes, 'transport_bytes_exceeded')
|
||||
let text: string
|
||||
|
||||
try {
|
||||
text = typeof raw === 'string' ? raw : new TextDecoder('utf-8', { fatal: true }).decode(raw)
|
||||
} catch {
|
||||
throw new CodexLinkValidationError('invalid_json')
|
||||
}
|
||||
|
||||
unicode(text)
|
||||
let position = 0
|
||||
|
||||
function whitespace() {
|
||||
while (/[ \t\r\n]/.test(text[position] ?? '') && position < text.length) {
|
||||
position++
|
||||
}
|
||||
}
|
||||
|
||||
function quoted(): string {
|
||||
const start = position++
|
||||
|
||||
while (position < text.length) {
|
||||
const next = text[position++]
|
||||
|
||||
if (next === '\\') {
|
||||
position++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (next === '"') {
|
||||
try {
|
||||
const value = JSON.parse(text.slice(start, position))
|
||||
unicode(value)
|
||||
|
||||
return value
|
||||
} catch (error) {
|
||||
if (error instanceof CodexLinkValidationError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw new CodexLinkValidationError('invalid_json')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new CodexLinkValidationError('invalid_json')
|
||||
}
|
||||
|
||||
function read(depth: number): unknown {
|
||||
requireValue(depth <= 256, 'invalid_json')
|
||||
whitespace()
|
||||
const token = text[position]
|
||||
|
||||
if (token === '"') {
|
||||
return quoted()
|
||||
}
|
||||
|
||||
if (token === '{') {
|
||||
position++
|
||||
whitespace()
|
||||
const value: Data = Object.create(null)
|
||||
|
||||
if (text[position] === '}') {
|
||||
position++
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
while (position < text.length) {
|
||||
requireValue(text[position] === '"', 'invalid_json')
|
||||
const key = quoted()
|
||||
requireValue(
|
||||
[...key].every(char => char.charCodeAt(0) <= 127),
|
||||
'non_ascii_key'
|
||||
)
|
||||
requireValue(!Object.hasOwn(value, key), 'duplicate_json_key')
|
||||
whitespace()
|
||||
requireValue(text[position++] === ':', 'invalid_json')
|
||||
value[key] = read(depth + 1)
|
||||
whitespace()
|
||||
const separator = text[position++]
|
||||
|
||||
if (separator === '}') {
|
||||
return value
|
||||
}
|
||||
|
||||
requireValue(separator === ',', 'invalid_json')
|
||||
whitespace()
|
||||
}
|
||||
} else if (token === '[') {
|
||||
position++
|
||||
whitespace()
|
||||
const value: unknown[] = []
|
||||
|
||||
if (text[position] === ']') {
|
||||
position++
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
while (position < text.length) {
|
||||
value.push(read(depth + 1))
|
||||
whitespace()
|
||||
const separator = text[position++]
|
||||
|
||||
if (separator === ']') {
|
||||
return value
|
||||
}
|
||||
|
||||
requireValue(separator === ',', 'invalid_json')
|
||||
}
|
||||
} else {
|
||||
for (const [word, value] of [
|
||||
['true', true],
|
||||
['false', false],
|
||||
['null', null]
|
||||
] as const) {
|
||||
if (text.startsWith(word, position)) {
|
||||
position += word.length
|
||||
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
if (/^(?:NaN|[-+]?Infinity)/.test(text.slice(position))) {
|
||||
throw new CodexLinkValidationError('floats_or_non_json_type')
|
||||
}
|
||||
|
||||
const match = /^-?(?:0|[1-9]\d*)/.exec(text.slice(position))
|
||||
|
||||
if (match) {
|
||||
position += match[0].length
|
||||
requireValue(!/[.eE]/.test(text[position] ?? ''), 'floats_or_non_json_type')
|
||||
const number = Number(match[0])
|
||||
requireValue(Number.isSafeInteger(number), 'unsafe_integer')
|
||||
|
||||
return number
|
||||
}
|
||||
}
|
||||
|
||||
throw new CodexLinkValidationError('invalid_json')
|
||||
}
|
||||
|
||||
const value = read(0)
|
||||
whitespace()
|
||||
requireValue(position === text.length, 'invalid_json')
|
||||
canonicalJson(value)
|
||||
|
||||
return value
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { PassThrough } from 'node:stream'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { parseControlResponse, validateControlRequest } from '../codex-link-control'
|
||||
import controlSchema from '../codex-link-control.schema.json'
|
||||
import { CODEX_LINK_CONTROL_SCHEMA_HASH } from '../codex-link-control.types'
|
||||
import type { CodexLinkControlRequest } from '../codex-link-control.types'
|
||||
import { canonicalJson } from '../codex-link-json'
|
||||
|
||||
import { createCodexLinkClient } from './codex-link-client'
|
||||
|
||||
const request: CodexLinkControlRequest = {
|
||||
contract_version: 'codex-link/1',
|
||||
kind: 'settings_get',
|
||||
request_id: 'req-1',
|
||||
operation_id: 'op-1'
|
||||
}
|
||||
|
||||
const response = {
|
||||
...request,
|
||||
kind: 'settings_result',
|
||||
settings: {
|
||||
settings_revision: 1,
|
||||
enabled: true,
|
||||
compression_enabled: true,
|
||||
threshold_tokens: 250000,
|
||||
source_binding_handle: null,
|
||||
compact_route_id: null,
|
||||
legacy_intent_imported: false,
|
||||
ownership_manifest_revision: 0,
|
||||
activation_state: 'pending_runtime_acceptance'
|
||||
},
|
||||
available_sources: []
|
||||
}
|
||||
|
||||
function harness(reply: string = JSON.stringify(response), env: Record<string, string> = {}) {
|
||||
let stdin = ''
|
||||
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
stdin: new PassThrough(),
|
||||
kill: vi.fn()
|
||||
})
|
||||
|
||||
child.stdin.on('data', chunk => {
|
||||
stdin += chunk.toString()
|
||||
})
|
||||
child.stdin.on('finish', () =>
|
||||
queueMicrotask(() => {
|
||||
const bytes = Buffer.from(reply)
|
||||
|
||||
// UTF-8 chunks may end inside a multi-byte character.
|
||||
for (const byte of bytes) {
|
||||
child.stdout.write(Buffer.from([byte]))
|
||||
}
|
||||
|
||||
child.stderr.write('diagnostic that must not become a response')
|
||||
child.emit('close', 0)
|
||||
})
|
||||
)
|
||||
const spawn = vi.fn(() => child)
|
||||
|
||||
const control = createCodexLinkClient({
|
||||
spawn,
|
||||
existsSync: () => true,
|
||||
platform: 'win32',
|
||||
env: { LOCALAPPDATA: 'C:\\Users\\Example User\\AppData\\Local', SystemRoot: 'C:\\Windows', ...env }
|
||||
})
|
||||
|
||||
return { control, spawn, child, stdin: () => stdin }
|
||||
}
|
||||
|
||||
describe('Codex connection control boundary', () => {
|
||||
it('pins the generated control-only schema', () => {
|
||||
expect('sha256:' + createHash('sha256').update(canonicalJson(controlSchema)).digest('hex')).toBe(CODEX_LINK_CONTROL_SCHEMA_HASH)
|
||||
expect(controlSchema.$defs).not.toHaveProperty('runtime_attach')
|
||||
expect(controlSchema.$defs).not.toHaveProperty('checkpoint_commit')
|
||||
})
|
||||
it('uses only fixed command arguments and sends the exact request on stdin with EOF', async () => {
|
||||
const h = harness()
|
||||
expect(await h.control(request)).toEqual(response)
|
||||
expect(h.stdin()).toBe(JSON.stringify(request) + '\n')
|
||||
const [file, args, options] = h.spawn.mock.calls[0] as unknown as [string, string[], any]
|
||||
expect(file).toBe('C:\\Windows\\System32\\cmd.exe')
|
||||
expect(args).toEqual([
|
||||
'/d',
|
||||
'/s',
|
||||
'/c',
|
||||
'""C:\\Users\\Example User\\AppData\\Local\\TurkServis\\AITURK\\program\\bin\\aiturk.cmd" codex-link control --stdin"'
|
||||
])
|
||||
expect(options).toMatchObject({ shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
})
|
||||
it('rejects runtime and arbitrary path/credential commands before spawning', async () => {
|
||||
const h = harness()
|
||||
await expect(h.control({ ...request, kind: 'runtime_attach' })).rejects.toThrow()
|
||||
await expect(h.control({ ...request, path: 'C:\\private' })).rejects.toThrow()
|
||||
expect(h.spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
it('rejects shell expansion in the entrypoint path', async () => {
|
||||
const h = harness(undefined, { LOCALAPPDATA: 'C:\\Users\\%PATH%\\Local' })
|
||||
await expect(h.control(request)).rejects.toThrow('invalid_entrypoint')
|
||||
expect(h.spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
it('binds the response to both request and operation identity', () => {
|
||||
expect(() =>
|
||||
parseControlResponse(JSON.stringify({ ...response, operation_id: 'other-chat-op' }), request)
|
||||
).toThrow()
|
||||
expect(() => parseControlResponse(JSON.stringify({ ...response, request_id: 'other-request' }), request)).toThrow()
|
||||
})
|
||||
it('rejects duplicate keys, floats, trailing JSON and invalid UTF8', () => {
|
||||
expect(() =>
|
||||
parseControlResponse(
|
||||
JSON.stringify(response).replace('"settings_revision":1', '"settings_revision":1,"settings_revision":2'),
|
||||
request
|
||||
)
|
||||
).toThrow('duplicate_json_key')
|
||||
expect(() => parseControlResponse(JSON.stringify(response).replace('250000', '250000.0'), request)).toThrow(
|
||||
'floats_or_non_json_type'
|
||||
)
|
||||
expect(() => parseControlResponse(JSON.stringify(response) + '{}', request)).toThrow()
|
||||
expect(() => parseControlResponse(new Uint8Array([0xc3, 0x28]), request)).toThrow()
|
||||
})
|
||||
it('rejects oversized requests and unsafe integer settings', () => {
|
||||
expect(() =>
|
||||
validateControlRequest({
|
||||
...request,
|
||||
kind: 'settings_update',
|
||||
expected_settings_revision: 1,
|
||||
patch: { threshold_tokens: Number.MAX_SAFE_INTEGER + 1 }
|
||||
})
|
||||
).toThrow()
|
||||
expect(() => validateControlRequest({ ...request, operation_id: 'x'.repeat(70000) })).toThrow()
|
||||
})
|
||||
it('does not retry a write after a broken reply', async () => {
|
||||
const h = harness('broken')
|
||||
await expect(h.control({ ...request, kind: 'catalog_sync' })).rejects.toThrow('outcome_unknown')
|
||||
expect(h.spawn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('preserves a structured CLI rejection returned with exit code one', async () => {
|
||||
const rejection = { ...request, kind: 'control_error', settings_revision: 2, error: { code: 'stale_settings_cas', message: 'Ayarlar değişti.', recovery_action: 'retry_control_read' } }
|
||||
const h = harness()
|
||||
h.child.stdin.removeAllListeners('finish')
|
||||
h.child.stdin.on('finish', () => queueMicrotask(() => {
|
||||
h.child.stdout.write(JSON.stringify(rejection))
|
||||
h.child.emit('close', 1)
|
||||
}))
|
||||
expect(await h.control(request)).toEqual(rejection)
|
||||
expect(h.spawn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('preserves exact Unicode when response bytes arrive separately', async () => {
|
||||
const h = harness(
|
||||
JSON.stringify({
|
||||
...response,
|
||||
available_sources: [{ source_binding_handle: 'source-a', label: 'Türkçe é/é 🧪', authorized: true }]
|
||||
})
|
||||
)
|
||||
|
||||
const result = await h.control(request)
|
||||
expect(result.kind === 'settings_result' && result.available_sources[0].label).toBe('Türkçe é/é 🧪')
|
||||
})
|
||||
it('bounds stdout and terminates only the short proxy', async () => {
|
||||
const h = harness('x'.repeat(1024 * 1024 + 1))
|
||||
// Avoid a million small chunks: replace the synthetic finish listener.
|
||||
h.child.stdin.removeAllListeners('finish')
|
||||
h.child.stdin.on('finish', () => queueMicrotask(() => h.child.stdout.emit('data', Buffer.alloc(1024 * 1024 + 1))))
|
||||
await expect(h.control(request)).rejects.toThrow('outcome_unknown')
|
||||
expect(h.child.kill).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,132 +0,0 @@
|
||||
import { CODEX_LINK_RESPONSE_BYTES, parseControlResponse, validateControlRequest } from '../codex-link-control'
|
||||
import type { CodexLinkControlResponse } from '../codex-link-control.types'
|
||||
|
||||
import { type AiturkCliRunnerDeps, resolveAiturkCliPath } from './aiturk-login'
|
||||
|
||||
type Dependencies = AiturkCliRunnerDeps
|
||||
// Only the fixed entrypoint and fixed arguments go through cmd.exe. All values travel on stdin.
|
||||
// eslint-disable-next-line no-control-regex -- Reject control bytes in executable paths before cmd parsing.
|
||||
const UNSAFE_PATH = /["%^!\u0000-\u001f]/
|
||||
const ABSOLUTE_PATH = /^[A-Za-z]:\\/
|
||||
|
||||
export function createCodexLinkClient(deps: Dependencies) {
|
||||
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
|
||||
const clearTimer = deps.clearTimer ?? (timer => clearTimeout(timer as ReturnType<typeof setTimeout>))
|
||||
|
||||
return async function control(raw: unknown): Promise<CodexLinkControlResponse> {
|
||||
validateControlRequest(raw)
|
||||
const cli = resolveAiturkCliPath(deps)
|
||||
|
||||
if (!cli) {
|
||||
throw new Error('codex-link:cli_unavailable')
|
||||
}
|
||||
|
||||
const systemRoot = String(deps.env.SystemRoot || deps.env.SYSTEMROOT || 'C:\\Windows').replace(/[\\/]+$/, '')
|
||||
|
||||
if (UNSAFE_PATH.test(cli) || UNSAFE_PATH.test(systemRoot) || !ABSOLUTE_PATH.test(systemRoot)) {
|
||||
throw new Error('codex-link:invalid_entrypoint')
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
let timer: unknown
|
||||
let bytes = 0
|
||||
const chunks: Buffer[] = []
|
||||
let child: any
|
||||
|
||||
const finish = (error?: Error, response?: CodexLinkControlResponse) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
|
||||
if (timer !== undefined) {
|
||||
clearTimer(timer)
|
||||
}
|
||||
|
||||
error ? reject(error) : resolve(response!)
|
||||
}
|
||||
|
||||
try {
|
||||
child = deps.spawn(
|
||||
`${systemRoot}\\System32\\cmd.exe`,
|
||||
['/d', '/s', '/c', `""${cli}" codex-link control --stdin"`],
|
||||
{
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
windowsVerbatimArguments: true,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
finish(new Error('codex-link:cli_unavailable'))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
timer = setTimer(() => {
|
||||
// The durable manager may have accepted a write. Never repeat it under a new ID.
|
||||
finish(new Error('codex-link:outcome_unknown'))
|
||||
|
||||
try {
|
||||
child.kill()
|
||||
} catch {
|
||||
/* proxy already exited; manager is independent */
|
||||
}
|
||||
}, deps.timeoutMs ?? 30_000)
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
bytes += chunk.length
|
||||
|
||||
if (bytes > CODEX_LINK_RESPONSE_BYTES) {
|
||||
finish(new Error('codex-link:outcome_unknown'))
|
||||
|
||||
try {
|
||||
child.kill()
|
||||
} catch {
|
||||
/* no process tree termination */
|
||||
}
|
||||
} else {
|
||||
chunks.push(Buffer.from(chunk))
|
||||
}
|
||||
})
|
||||
// Drain concurrently, with no history, paths, credentials or uncontrolled diagnostics logged.
|
||||
child.stderr.on('data', () => {})
|
||||
child.stdin.on('error', () => {})
|
||||
child.once('error', () => finish(new Error('codex-link:outcome_unknown')))
|
||||
child.once('close', (code: number | null) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (code !== 0 && code !== 1) {
|
||||
finish(new Error('codex-link:outcome_unknown'))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = parseControlResponse(Buffer.concat(chunks), raw)
|
||||
|
||||
if (code === 1 && response.kind !== 'control_error') {
|
||||
finish(new Error('codex-link:outcome_unknown'))
|
||||
} else {
|
||||
finish(undefined, response)
|
||||
}
|
||||
} catch {
|
||||
finish(new Error('codex-link:outcome_unknown'))
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
child.stdin.end(JSON.stringify(raw) + '\n')
|
||||
} catch {
|
||||
finish(new Error('codex-link:outcome_unknown'))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
addCodexLinkLine,
|
||||
CODEX_LINK_LINE,
|
||||
CODEX_LINK_RUN_COMMAND_MAX,
|
||||
codexAutostartPlan,
|
||||
codexBridgeArgs,
|
||||
codexBridgeCommandLine,
|
||||
codexBridgeConfig,
|
||||
codexBridgeLauncherSource,
|
||||
inspectCodexConfig,
|
||||
parseCodexLinkSettings,
|
||||
removeCodexLinkLine,
|
||||
serializeCodexLinkSettings
|
||||
} from './codex-link-config'
|
||||
|
||||
const FOREIGN = 'openai_base_url = "http://127.0.0.1:8799/backend-api/codex"'
|
||||
|
||||
// Add then remove must hand the user's file back exactly as it was.
|
||||
function expectRoundTrip(original: string) {
|
||||
const added = addCodexLinkLine(original)
|
||||
|
||||
expect(added.changed).toBe(true)
|
||||
expect(added.owner).toBe('ours')
|
||||
expect(inspectCodexConfig(added.text)).toBe('ours')
|
||||
|
||||
const removed = removeCodexLinkLine(added.text)
|
||||
|
||||
expect(removed.changed).toBe(true)
|
||||
expect(removed.text).toBe(original)
|
||||
|
||||
return added.text
|
||||
}
|
||||
|
||||
describe('the contract line', () => {
|
||||
it('is the exact text Codex and the bridge agree on', () => {
|
||||
expect(CODEX_LINK_LINE).toBe(
|
||||
'openai_base_url = "http://127.0.0.1:8795/backend-api/codex" # AITURK IDE Codex Baglama'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addCodexLinkLine', () => {
|
||||
it('writes the line into an empty file', () => {
|
||||
expect(expectRoundTrip('')).toBe(`${CODEX_LINK_LINE}\n`)
|
||||
})
|
||||
|
||||
it('puts the line before the first table when the file only has tables', () => {
|
||||
const original = '[projects."C:\\\\work"]\ntrust_level = "trusted"\n\n[mcp_servers.x]\ncommand = "node"\n'
|
||||
const text = expectRoundTrip(original)
|
||||
|
||||
expect(text.indexOf(CODEX_LINK_LINE)).toBeLessThan(text.indexOf('[projects.'))
|
||||
expect(text.endsWith(original)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps existing top-level keys and tables intact', () => {
|
||||
const original = '# my config\nmodel = "gpt-6"\napproval_policy = "never"\n\n[features]\nweb = true\n'
|
||||
const text = expectRoundTrip(original)
|
||||
|
||||
expect(text.indexOf(CODEX_LINK_LINE)).toBeLessThan(text.indexOf('[features]'))
|
||||
expect(text).toContain('model = "gpt-6"\napproval_policy = "never"\n\n[features]\nweb = true\n')
|
||||
})
|
||||
|
||||
it('preserves a CRLF file and writes its own line with CRLF', () => {
|
||||
const original = 'model = "gpt-6"\r\n\r\n[features]\r\nweb = true\r\n'
|
||||
const text = expectRoundTrip(original)
|
||||
|
||||
expect(text).toBe(`${CODEX_LINK_LINE}\r\n${original}`)
|
||||
expect(text.replace(/\r\n/g, '')).not.toMatch(/[\r\n]/)
|
||||
})
|
||||
|
||||
it('keeps a byte-order mark at the very start', () => {
|
||||
const text = expectRoundTrip('\uFEFFmodel = "gpt-6"\n')
|
||||
|
||||
expect(text.startsWith(`\uFEFF${CODEX_LINK_LINE}\n`)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a file without a trailing newline exactly as it was', () => {
|
||||
expectRoundTrip('model = "gpt-6"\n[features]\nweb = true')
|
||||
})
|
||||
|
||||
it('is idempotent once our line is there', () => {
|
||||
const once = addCodexLinkLine('model = "gpt-6"\n[features]\n').text
|
||||
const twice = addCodexLinkLine(once)
|
||||
|
||||
expect(twice.changed).toBe(false)
|
||||
expect(twice.owner).toBe('ours')
|
||||
expect(twice.text).toBe(once)
|
||||
})
|
||||
|
||||
it('refreshes a stale copy of our own line in place', () => {
|
||||
const stale = 'model = "gpt-6"\nopenai_base_url = "http://127.0.0.1:1/x" # AITURK IDE Codex Baglama\n[features]\n'
|
||||
const edit = addCodexLinkLine(stale)
|
||||
|
||||
expect(edit.changed).toBe(true)
|
||||
expect(edit.text).toBe(`model = "gpt-6"\n${CODEX_LINK_LINE}\n[features]\n`)
|
||||
})
|
||||
|
||||
it('still owns its line after Codex rewrote the config and dropped the comment', () => {
|
||||
const rewritten = 'openai_base_url = "http://127.0.0.1:8795/backend-api/codex"\nmodel = "x"\n'
|
||||
|
||||
expect(inspectCodexConfig(rewritten)).toBe('ours')
|
||||
expect(removeCodexLinkLine(rewritten).text).toBe('model = "x"\n')
|
||||
// Another local port is somebody else's bridge.
|
||||
expect(inspectCodexConfig('openai_base_url = "http://127.0.0.1:8799/backend-api/codex"\n')).toBe('foreign')
|
||||
})
|
||||
|
||||
it('does not touch a foreign openai_base_url and reports it', () => {
|
||||
for (const original of [
|
||||
`${FOREIGN}\n[features]\n`,
|
||||
`model = "gpt-6"\r\n ${FOREIGN} # my own bridge\r\n`,
|
||||
'"openai_base_url" = "https://example.test/v1"\n',
|
||||
"'openai_base_url'='https://example.test/v1'\n"
|
||||
]) {
|
||||
const edit = addCodexLinkLine(original)
|
||||
|
||||
expect(edit).toEqual({ changed: false, owner: 'foreign', text: original })
|
||||
expect(inspectCodexConfig(original)).toBe('foreign')
|
||||
}
|
||||
})
|
||||
|
||||
it('does not treat the same key under a table as top-level', () => {
|
||||
const original = 'model = "gpt-6"\n\n[model_providers.mine]\nopenai_base_url = "https://example.test/v1"\n'
|
||||
|
||||
expect(inspectCodexConfig(original)).toBe('absent')
|
||||
|
||||
const text = expectRoundTrip(original)
|
||||
|
||||
expect(text.indexOf(CODEX_LINK_LINE)).toBeLessThan(text.indexOf('[model_providers.mine]'))
|
||||
expect(text).toContain('[model_providers.mine]\nopenai_base_url = "https://example.test/v1"\n')
|
||||
})
|
||||
|
||||
it('ignores the key inside a comment', () => {
|
||||
const original = '# openai_base_url = "https://example.test/v1"\n #openai_base_url = "x"\nmodel = "gpt-6"\n'
|
||||
|
||||
expect(inspectCodexConfig(original)).toBe('absent')
|
||||
expectRoundTrip(original)
|
||||
})
|
||||
|
||||
it('ignores look-alikes inside multi-line values', () => {
|
||||
// Neither the "key" inside the string nor the "[table]" inside it is real,
|
||||
// so the genuine foreign key after it must still be found.
|
||||
const inString = 'notes = """\nopenai_base_url = "nope"\n[not.a.table]\n"""\nmodel = "gpt-6"\n'
|
||||
|
||||
expect(inspectCodexConfig(inString)).toBe('absent')
|
||||
expect(inspectCodexConfig(`${inString}${FOREIGN}\n`)).toBe('foreign')
|
||||
|
||||
const inArray = 'notify = [\n "a",\n ["b"]\n]\n'
|
||||
|
||||
expect(inspectCodexConfig(`${inArray}${FOREIGN}\n`)).toBe('foreign')
|
||||
expect(inspectCodexConfig(`literal = '''\n[x]\n'''\n${FOREIGN}\n`)).toBe('foreign')
|
||||
})
|
||||
|
||||
it('is not fooled by a bracket or hash inside a quoted value', () => {
|
||||
const original = `title = "a [b] # c"\n${FOREIGN}\n`
|
||||
|
||||
expect(inspectCodexConfig(original)).toBe('foreign')
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeCodexLinkLine', () => {
|
||||
it('removes only our line, wherever the user moved it', () => {
|
||||
const text = `model = "gpt-6"\r\n${CODEX_LINK_LINE}\r\napproval_policy = "never"\r\n[features]\r\n`
|
||||
const edit = removeCodexLinkLine(text)
|
||||
|
||||
expect(edit).toEqual({
|
||||
changed: true,
|
||||
owner: 'absent',
|
||||
text: 'model = "gpt-6"\r\napproval_policy = "never"\r\n[features]\r\n'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves a file without our line untouched', () => {
|
||||
for (const original of ['', 'model = "gpt-6"\n', `${FOREIGN}\n[features]\n`]) {
|
||||
expect(removeCodexLinkLine(original).changed).toBe(false)
|
||||
expect(removeCodexLinkLine(original).text).toBe(original)
|
||||
}
|
||||
})
|
||||
|
||||
it('never removes a foreign line', () => {
|
||||
expect(removeCodexLinkLine(`${FOREIGN}\n`)).toEqual({ changed: false, owner: 'foreign', text: `${FOREIGN}\n` })
|
||||
})
|
||||
|
||||
it('leaves our marker alone when it sits under a table', () => {
|
||||
const original = `[model_providers.mine]\n${CODEX_LINK_LINE}\n`
|
||||
|
||||
expect(removeCodexLinkLine(original)).toEqual({ changed: false, owner: 'absent', text: original })
|
||||
})
|
||||
})
|
||||
|
||||
describe('preference file', () => {
|
||||
it('is on by default when the file is missing or malformed', () => {
|
||||
for (const raw of [null, undefined, '', '{', 'null', '[]', '"off"', '{"kapsam":"ide"}', '{"enabled":"false"}']) {
|
||||
expect(parseCodexLinkSettings(raw).enabled).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('is off only when explicitly disabled', () => {
|
||||
expect(parseCodexLinkSettings('{"enabled": false, "kapsam": "ide"}').enabled).toBe(false)
|
||||
expect(parseCodexLinkSettings('{"enabled": true}').enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('writes the contract shape and keeps unknown fields', () => {
|
||||
expect(JSON.parse(serializeCodexLinkSettings({}, false))).toEqual({ enabled: false, kapsam: 'ide' })
|
||||
|
||||
const { stored } = parseCodexLinkSettings('{"enabled": false, "kapsam": "hepsi", "gelecek": {"a": 1}}')
|
||||
|
||||
expect(JSON.parse(serializeCodexLinkSettings(stored, true))).toEqual({
|
||||
enabled: true,
|
||||
gelecek: { a: 1 },
|
||||
kapsam: 'hepsi'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('bridge configuration and command', () => {
|
||||
const launch = {
|
||||
bridgeHome: 'C:\\Users\\a b\\AppData\\Local\\TurkServis\\AITURK-IDE\\codex-bridge',
|
||||
bridgeScript: 'C:\\Program Files\\AITURK IDE\\resources\\codex-bridge\\bridge.py',
|
||||
hermesHome: 'C:\\Users\\a b\\AppData\\Local\\TurkServis\\AITURK-IDE\\agent'
|
||||
}
|
||||
|
||||
const python = `${launch.hermesHome}\\hermes-agent\\venv\\Scripts\\pythonw.exe`
|
||||
|
||||
it('writes the agreed bridge.json', () => {
|
||||
expect(codexBridgeConfig()).toEqual({
|
||||
port: 8795,
|
||||
chatgpt_backend: 'https://chatgpt.com',
|
||||
routes: {},
|
||||
nine_router_catalog: { enabled: false },
|
||||
pano_kanca_user: false,
|
||||
ozet_kopru: { enabled: true, kapsam: 'hepsi', openai_modeli: 'gpt-6-luna', dongu_siniri: 3 }
|
||||
})
|
||||
})
|
||||
|
||||
it('passes the same arguments to the spawn and the login item', () => {
|
||||
expect(codexBridgeArgs(launch)).toEqual([
|
||||
launch.bridgeScript,
|
||||
'--home',
|
||||
launch.bridgeHome,
|
||||
'--hermes-home',
|
||||
launch.hermesHome
|
||||
])
|
||||
expect(codexBridgeCommandLine(python, launch)).toBe(
|
||||
`"${python}" "${launch.bridgeScript}" --home "${launch.bridgeHome}" --hermes-home "${launch.hermesHome}"`
|
||||
)
|
||||
})
|
||||
|
||||
it('drops a trailing separator that would escape the closing quote', () => {
|
||||
expect(
|
||||
codexBridgeCommandLine('C:\\py\\pythonw.exe', {
|
||||
bridgeHome: 'C:\\d\\',
|
||||
bridgeScript: 'C:\\b.py',
|
||||
hermesHome: 'C:\\h\\'
|
||||
})
|
||||
).toBe('"C:\\py\\pythonw.exe" "C:\\b.py" --home "C:\\d" --hermes-home "C:\\h"')
|
||||
})
|
||||
|
||||
it('uses the direct command for the login item when it fits a Run value', () => {
|
||||
const short = { bridgeHome: 'C:\\d', bridgeScript: 'C:\\b\\bridge.py', hermesHome: 'C:\\h' }
|
||||
|
||||
expect(codexAutostartPlan('C:\\py\\pythonw.exe', short, 'C:\\d\\baslat.pyw')).toEqual({
|
||||
command: '"C:\\py\\pythonw.exe" "C:\\b\\bridge.py" --home "C:\\d" --hermes-home "C:\\h"',
|
||||
launcher: null
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a launcher when the direct command is too long for a Run value', () => {
|
||||
expect(codexBridgeCommandLine(python, launch).length).toBeGreaterThan(CODEX_LINK_RUN_COMMAND_MAX)
|
||||
|
||||
const launcherPath = `${launch.bridgeHome}\\baslat.pyw`
|
||||
const plan = codexAutostartPlan(python, launch, launcherPath)
|
||||
|
||||
expect(plan?.command).toBe(`"${python}" "${launcherPath}"`)
|
||||
expect(plan?.command.length).toBeLessThanOrEqual(CODEX_LINK_RUN_COMMAND_MAX)
|
||||
expect(plan?.launcher).toEqual({ path: launcherPath, source: codexBridgeLauncherSource(launch) })
|
||||
// The launcher carries the very same argv as the direct command.
|
||||
expect(plan?.launcher?.source).toContain(`ARGV = ${JSON.stringify(codexBridgeArgs(launch))}`)
|
||||
})
|
||||
|
||||
it('registers nothing when not even the launcher command fits', () => {
|
||||
const deep = `C:\\${'x'.repeat(300)}`
|
||||
|
||||
expect(codexAutostartPlan(`${deep}\\pythonw.exe`, launch, `${deep}\\baslat.pyw`)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* Codex Link ("Codex Bağlama") — the pure half.
|
||||
*
|
||||
* AITURK IDE ships a small local Python bridge that lets the OpenAI Codex
|
||||
* installed on this machine use the IDE's models and combos. Turning the
|
||||
* feature on means editing a file the IDE does not own (Codex's `config.toml`)
|
||||
* and registering a login item, so every text transformation lives here as a
|
||||
* dependency-free, byte-preserving function that can be proven without
|
||||
* Electron, a filesystem, or a registry. `codex-link.ts` owns the decisions and
|
||||
* the side effects.
|
||||
*
|
||||
* Invariants:
|
||||
* - Only the line carrying OUR trailing comment is ever written or removed.
|
||||
* - An `openai_base_url` that is not ours means someone else's bridge is in
|
||||
* charge: report `foreign` and change nothing.
|
||||
* - Everything else in the file — including its line endings and BOM — comes
|
||||
* back byte for byte.
|
||||
*/
|
||||
|
||||
export const CODEX_LINK_PORT = 8795
|
||||
export const CODEX_LINK_ORIGIN = `http://127.0.0.1:${CODEX_LINK_PORT}`
|
||||
export const CODEX_LINK_STATUS_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama/status`
|
||||
export const CODEX_LINK_STOP_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama/stop`
|
||||
export const CODEX_LINK_REFRESH_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama/refresh`
|
||||
|
||||
/** The trailing comment that marks the line as ours. */
|
||||
export const CODEX_LINK_MARKER = '# AITURK IDE Codex Baglama'
|
||||
// Must equal SURUM in assets/codex-bridge/bridge.py; a running bridge that reports
|
||||
// another version is restarted so an updated IDE never keeps an old bridge alive.
|
||||
export const CODEX_BRIDGE_VERSION = '4'
|
||||
export const CODEX_LINK_LINE = `openai_base_url = "${CODEX_LINK_ORIGIN}/backend-api/codex" ${CODEX_LINK_MARKER}`
|
||||
|
||||
export const CODEX_LINK_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'
|
||||
export const CODEX_LINK_RUN_VALUE = 'AITURK-IDE-Codex-Baglama'
|
||||
export const CODEX_LINK_LAUNCHER_NAME = 'baslat.pyw'
|
||||
|
||||
// Windows documents Run-key command lines as "no longer than 260 characters".
|
||||
// A longer value is not reliably started at sign-in, so the direct command is
|
||||
// only used when it fits (see autostartPlan).
|
||||
export const CODEX_LINK_RUN_COMMAND_MAX = 260
|
||||
|
||||
/** Who owns the top-level `openai_base_url` in a Codex config. */
|
||||
export type CodexConfigOwner = 'absent' | 'foreign' | 'ours'
|
||||
|
||||
interface Line {
|
||||
eol: string
|
||||
text: string
|
||||
}
|
||||
|
||||
const BOM = '\uFEFF'
|
||||
const KEY_PATTERN = /^[ \t]*(?:openai_base_url|"openai_base_url"|'openai_base_url')[ \t]*=/
|
||||
|
||||
// Split into lines WITHOUT losing anything: joining `text + eol` of every entry
|
||||
// reproduces the input exactly, whatever mix of \n, \r\n and \r it used.
|
||||
function splitLines(source: string): Line[] {
|
||||
const lines: Line[] = []
|
||||
let start = 0
|
||||
|
||||
while (start < source.length) {
|
||||
let end = start
|
||||
|
||||
while (end < source.length && source[end] !== '\n' && source[end] !== '\r') {
|
||||
end += 1
|
||||
}
|
||||
|
||||
let eol = ''
|
||||
|
||||
if (end < source.length) {
|
||||
eol = source[end] === '\r' && source[end + 1] === '\n' ? '\r\n' : source[end]
|
||||
}
|
||||
|
||||
lines.push({ eol, text: source.slice(start, end) })
|
||||
start = end + eol.length
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
function joinLines(lines: Line[]): string {
|
||||
return lines.map(line => line.text + line.eol).join('')
|
||||
}
|
||||
|
||||
function splitBom(source: string): { body: string; bom: string } {
|
||||
return source.startsWith(BOM) ? { body: source.slice(1), bom: BOM } : { body: source, bom: '' }
|
||||
}
|
||||
|
||||
type MultilineQuote = "'''" | '"""' | null
|
||||
|
||||
interface ValueState {
|
||||
depth: number
|
||||
multiline: MultilineQuote
|
||||
}
|
||||
|
||||
// Index just past the `=` of a `key = value` line, or the line length when the
|
||||
// line holds no value (blank, comment, malformed). Quoted keys may contain `=`.
|
||||
function valueStart(text: string): number {
|
||||
let pos = 0
|
||||
|
||||
while (pos < text.length) {
|
||||
const char = text[pos]
|
||||
|
||||
if (char === '#') {
|
||||
return text.length
|
||||
}
|
||||
|
||||
if (char === '=') {
|
||||
return pos + 1
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
pos += 1
|
||||
|
||||
while (pos < text.length && text[pos] !== char) {
|
||||
pos += char === '"' && text[pos] === '\\' ? 2 : 1
|
||||
}
|
||||
}
|
||||
|
||||
pos += 1
|
||||
}
|
||||
|
||||
return text.length
|
||||
}
|
||||
|
||||
// Walk a value just far enough to know whether the NEXT line is still inside it
|
||||
// (multi-line string or array). That is the only TOML the top-level scan needs:
|
||||
// without it, text inside a multi-line value could pass for a key or a table
|
||||
// header.
|
||||
function scanValue(text: string, from: number, state: ValueState): ValueState {
|
||||
let { depth, multiline } = state
|
||||
let pos = from
|
||||
|
||||
while (pos < text.length) {
|
||||
if (multiline) {
|
||||
if (multiline === '"""' && text[pos] === '\\') {
|
||||
pos += 2
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (text.startsWith(multiline, pos)) {
|
||||
pos += 3
|
||||
|
||||
// Up to two quotes may sit directly before the closing delimiter.
|
||||
while (text[pos] === multiline[0]) {
|
||||
pos += 1
|
||||
}
|
||||
|
||||
multiline = null
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
pos += 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const char = text[pos]
|
||||
|
||||
if (char === '#') {
|
||||
break
|
||||
}
|
||||
|
||||
if (text.startsWith('"""', pos) || text.startsWith("'''", pos)) {
|
||||
multiline = text.slice(pos, pos + 3) as MultilineQuote
|
||||
pos += 3
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
pos += 1
|
||||
|
||||
while (pos < text.length && text[pos] !== char) {
|
||||
pos += char === '"' && text[pos] === '\\' ? 2 : 1
|
||||
}
|
||||
|
||||
pos += 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '[' || char === '{') {
|
||||
depth += 1
|
||||
} else if (char === ']' || char === '}') {
|
||||
depth = Math.max(0, depth - 1)
|
||||
}
|
||||
|
||||
pos += 1
|
||||
}
|
||||
|
||||
return { depth, multiline }
|
||||
}
|
||||
|
||||
// Indices of the top-level `openai_base_url` lines: those before the first
|
||||
// `[table]` header, outside comments and multi-line values.
|
||||
function topLevelEntries(lines: Line[]): { index: number; ours: boolean }[] {
|
||||
const entries: { index: number; ours: boolean }[] = []
|
||||
let state: ValueState = { depth: 0, multiline: null }
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const { text } = lines[index]
|
||||
let from = 0
|
||||
|
||||
if (!state.multiline && state.depth === 0) {
|
||||
if (text.trimStart().startsWith('[')) {
|
||||
break
|
||||
}
|
||||
|
||||
if (KEY_PATTERN.test(text)) {
|
||||
// Ours by marker or by value: Codex may rewrite its config and drop the comment.
|
||||
entries.push({
|
||||
index,
|
||||
ours: text.includes(CODEX_LINK_MARKER) || text.includes(`"${CODEX_LINK_ORIGIN}/backend-api/codex"`)
|
||||
})
|
||||
}
|
||||
|
||||
from = valueStart(text)
|
||||
}
|
||||
|
||||
state = scanValue(text, from, state)
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
function ownerOf(entries: { ours: boolean }[]): CodexConfigOwner {
|
||||
if (entries.some(entry => !entry.ours)) {
|
||||
return 'foreign'
|
||||
}
|
||||
|
||||
return entries.length > 0 ? 'ours' : 'absent'
|
||||
}
|
||||
|
||||
/** Who owns the top-level `openai_base_url` of this config text. */
|
||||
export function inspectCodexConfig(source: string): CodexConfigOwner {
|
||||
return ownerOf(topLevelEntries(splitLines(splitBom(source).body)))
|
||||
}
|
||||
|
||||
export interface CodexConfigEdit {
|
||||
changed: boolean
|
||||
owner: CodexConfigOwner
|
||||
text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Point Codex at the bridge. Idempotent; a foreign `openai_base_url` leaves the
|
||||
* text untouched and reports `foreign`.
|
||||
*
|
||||
* The line goes on the very first line: top-level keys must precede the first
|
||||
* table header, and the top of the file is the one position that is always
|
||||
* top-level without having to understand the values below it.
|
||||
*/
|
||||
export function addCodexLinkLine(source: string): CodexConfigEdit {
|
||||
const { body, bom } = splitBom(source)
|
||||
const lines = splitLines(body)
|
||||
const entries = topLevelEntries(lines)
|
||||
const owner = ownerOf(entries)
|
||||
|
||||
if (owner === 'foreign') {
|
||||
return { changed: false, owner, text: source }
|
||||
}
|
||||
|
||||
if (owner === 'ours') {
|
||||
const [first, ...duplicates] = entries
|
||||
|
||||
if (duplicates.length === 0 && lines[first.index].text === CODEX_LINK_LINE) {
|
||||
return { changed: false, owner, text: source }
|
||||
}
|
||||
|
||||
// A stale copy of our line (older address) or an accidental duplicate:
|
||||
// normalize to exactly one current line, in place.
|
||||
const drop = new Set(duplicates.map(entry => entry.index))
|
||||
lines[first.index] = { ...lines[first.index], text: CODEX_LINK_LINE }
|
||||
|
||||
return { changed: true, owner, text: bom + joinLines(lines.filter((_line, index) => !drop.has(index))) }
|
||||
}
|
||||
|
||||
const eol = body.includes('\r\n') ? '\r\n' : '\n'
|
||||
|
||||
return { changed: true, owner: 'ours', text: `${bom}${CODEX_LINK_LINE}${eol}${body}` }
|
||||
}
|
||||
|
||||
/** Remove our line (and only ours). A foreign key is left exactly as it was. */
|
||||
export function removeCodexLinkLine(source: string): CodexConfigEdit {
|
||||
const { body, bom } = splitBom(source)
|
||||
const lines = splitLines(body)
|
||||
const entries = topLevelEntries(lines)
|
||||
const drop = new Set(entries.filter(entry => entry.ours).map(entry => entry.index))
|
||||
const remaining = ownerOf(entries.filter(entry => !entry.ours))
|
||||
|
||||
if (drop.size === 0) {
|
||||
return { changed: false, owner: remaining, text: source }
|
||||
}
|
||||
|
||||
return { changed: true, owner: remaining, text: bom + joinLines(lines.filter((_line, index) => !drop.has(index))) }
|
||||
}
|
||||
|
||||
// ── Preference file ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface CodexLinkSettings {
|
||||
enabled: boolean
|
||||
/** Every field of the stored object, so a rewrite keeps what it did not set. */
|
||||
stored: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Missing, unreadable or malformed means ON: the feature is on by default. */
|
||||
export function parseCodexLinkSettings(raw: null | string | undefined): CodexLinkSettings {
|
||||
if (typeof raw !== 'string') {
|
||||
return { enabled: true, stored: {} }
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return { enabled: true, stored: {} }
|
||||
}
|
||||
|
||||
return { enabled: parsed.enabled !== false, stored: parsed as Record<string, unknown> }
|
||||
} catch {
|
||||
return { enabled: true, stored: {} }
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeCodexLinkSettings(stored: Record<string, unknown>, enabled: boolean): string {
|
||||
const kapsam = typeof stored.kapsam === 'string' && stored.kapsam ? stored.kapsam : 'ide'
|
||||
|
||||
return `${JSON.stringify({ ...stored, enabled, kapsam }, null, 2)}\n`
|
||||
}
|
||||
|
||||
// ── Bridge configuration ────────────────────────────────────────────────────
|
||||
|
||||
/** `bridge.json`, rewritten on every apply. */
|
||||
export function codexBridgeConfig() {
|
||||
return {
|
||||
port: CODEX_LINK_PORT,
|
||||
chatgpt_backend: 'https://chatgpt.com',
|
||||
routes: {},
|
||||
nine_router_catalog: { enabled: false },
|
||||
pano_kanca_user: false,
|
||||
ozet_kopru: { enabled: true, kapsam: 'hepsi', openai_modeli: 'gpt-6-luna', dongu_siniri: 3 }
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeCodexBridgeConfig(): string {
|
||||
return `${JSON.stringify(codexBridgeConfig(), null, 2)}\n`
|
||||
}
|
||||
|
||||
// ── Bridge command line ─────────────────────────────────────────────────────
|
||||
|
||||
export interface CodexBridgeLaunch {
|
||||
bridgeHome: string
|
||||
bridgeScript: string
|
||||
hermesHome: string
|
||||
}
|
||||
|
||||
/** Arguments after the interpreter — identical for the spawn and the login item. */
|
||||
export function codexBridgeArgs({ bridgeHome, bridgeScript, hermesHome }: CodexBridgeLaunch): string[] {
|
||||
return [bridgeScript, '--home', bridgeHome, '--hermes-home', hermesHome]
|
||||
}
|
||||
|
||||
// A trailing backslash would escape the closing quote (`"C:\dir\"`).
|
||||
function quoted(value: string): string {
|
||||
return `"${value.replace(/[\\/]+$/, '')}"`
|
||||
}
|
||||
|
||||
/** `"<pythonw>" "<bridge.py>" --home "<data>" --hermes-home "<agent>"` */
|
||||
export function codexBridgeCommandLine(python: string, launch: CodexBridgeLaunch): string {
|
||||
const [script, homeFlag, home, hermesFlag, hermes] = codexBridgeArgs(launch)
|
||||
|
||||
return `${quoted(python)} ${quoted(script)} ${homeFlag} ${quoted(home)} ${hermesFlag} ${quoted(hermes)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* A tiny windowless launcher for the login item, used when the direct command
|
||||
* would not fit a Run value. It runs the bridge exactly as the direct command
|
||||
* would: same script, same arguments, the script's folder first on sys.path so
|
||||
* its helper modules import.
|
||||
*/
|
||||
export function codexBridgeLauncherSource(launch: CodexBridgeLaunch): string {
|
||||
const argv = JSON.stringify(codexBridgeArgs(launch))
|
||||
|
||||
return [
|
||||
'# Written by AITURK IDE (Settings > Codex Baglama). Regenerated on every sync; do not edit.',
|
||||
'import os',
|
||||
'import runpy',
|
||||
'import sys',
|
||||
'',
|
||||
`ARGV = ${argv}`,
|
||||
'',
|
||||
'sys.argv = list(ARGV)',
|
||||
'sys.path.insert(0, os.path.dirname(ARGV[0]))',
|
||||
"runpy.run_path(ARGV[0], run_name='__main__')",
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export interface CodexAutostartPlan {
|
||||
command: string
|
||||
/** Launcher file to write first; null when the direct command is used. */
|
||||
launcher: { path: string; source: string } | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The login-item command. Prefer the direct bridge command; fall back to the
|
||||
* launcher when that exceeds the Run-value limit. Returns null when not even
|
||||
* the launcher form fits (nothing is registered rather than something that
|
||||
* silently never starts).
|
||||
*/
|
||||
export function codexAutostartPlan(
|
||||
python: string,
|
||||
launch: CodexBridgeLaunch,
|
||||
launcherPath: string
|
||||
): CodexAutostartPlan | null {
|
||||
const direct = codexBridgeCommandLine(python, launch)
|
||||
|
||||
if (direct.length <= CODEX_LINK_RUN_COMMAND_MAX) {
|
||||
return { command: direct, launcher: null }
|
||||
}
|
||||
|
||||
const viaLauncher = `${quoted(python)} ${quoted(launcherPath)}`
|
||||
|
||||
if (viaLauncher.length > CODEX_LINK_RUN_COMMAND_MAX) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { command: viaLauncher, launcher: { path: launcherPath, source: codexBridgeLauncherSource(launch) } }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Codex Link ("Codex Bağlama") — the shape that crosses the IPC bridge.
|
||||
*
|
||||
* Types only, so the renderer can import them without pulling main-process
|
||||
* code into its program. See codex-link.ts for what the fields mean in action.
|
||||
*/
|
||||
|
||||
export type CodexLinkIdeState = 'anahtar-yok' | 'kapali' | 'tamam'
|
||||
|
||||
/** Stable codes; the renderer owns the wording. Detail goes to the desktop log. */
|
||||
export type CodexLinkError =
|
||||
| 'autostart-failed'
|
||||
| 'bridge-config-failed'
|
||||
| 'bridge-missing'
|
||||
| 'bridge-start-failed'
|
||||
| 'config-write-failed'
|
||||
| 'settings-write-failed'
|
||||
|
||||
export interface CodexLinkStatus {
|
||||
enabled: boolean
|
||||
/** Codex's `config.toml` exists on this machine. */
|
||||
codexFound: boolean
|
||||
/** The IDE's own Python environment exists (first-run setup finished). */
|
||||
venvReady: boolean
|
||||
/** Codex already uses another bridge; nothing of its setup is touched. */
|
||||
foreign: boolean
|
||||
bridgeRunning: boolean
|
||||
/** From the bridge's status endpoint; null while it is not answering. */
|
||||
ideDurum: CodexLinkIdeState | null
|
||||
ideModel: null | number
|
||||
ozet: boolean | null
|
||||
lastError: CodexLinkError | null
|
||||
}
|
||||
|
||||
export interface CodexLinkPatch {
|
||||
enabled?: boolean
|
||||
/** "Sync now": re-run the apply without changing the preference. */
|
||||
sync?: boolean
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { CODEX_BRIDGE_START_BUDGET_MS, type CodexLinkDeps, type CodexLinkPaths, createCodexLink } from './codex-link'
|
||||
import {
|
||||
CODEX_LINK_LINE,
|
||||
CODEX_LINK_REFRESH_URL,
|
||||
CODEX_LINK_STATUS_URL,
|
||||
CODEX_LINK_STOP_URL,
|
||||
codexBridgeArgs,
|
||||
codexBridgeConfig
|
||||
} from './codex-link-config'
|
||||
|
||||
const PATHS: CodexLinkPaths = {
|
||||
settingsFile: 'D:\\ide\\codex-baglama.json',
|
||||
bridgeHome: 'D:\\ide\\codex-bridge',
|
||||
hermesHome: 'D:\\ide\\agent',
|
||||
bridgeScript: 'D:\\app\\codex-bridge\\bridge.py',
|
||||
pythonw: 'D:\\ide\\agent\\venv\\Scripts\\pythonw.exe',
|
||||
python: 'D:\\ide\\agent\\venv\\Scripts\\python.exe',
|
||||
codexConfig: 'D:\\home\\.codex\\config.toml'
|
||||
}
|
||||
|
||||
const BRIDGE_JSON = `${PATHS.bridgeHome}\\bridge.json`
|
||||
const STATUS_OK = { ok: true, surum: '4', ide_durum: 'tamam', ide_model: 7, ozet: true }
|
||||
const USER_CONFIG = 'model = "gpt-6"\r\n\r\n[features]\r\nweb = true\r\n'
|
||||
|
||||
interface World {
|
||||
/** What the machine looks like; mutated by the fake effects. */
|
||||
files: Map<string, string>
|
||||
/** Is something answering on the bridge port? */
|
||||
listening: boolean
|
||||
/** Version the listening bridge reports before any spawn; a spawn brings the current one. */
|
||||
staleVersion: null | string
|
||||
/** Does a spawn bring the bridge up? `false` = it never answers. */
|
||||
spawnWorks: boolean
|
||||
/** Spawned bridge only starts answering after this many status probes. */
|
||||
probesUntilUp: number
|
||||
autostart: null | string
|
||||
regFails: boolean
|
||||
/** Every observable effect, in order. */
|
||||
events: string[]
|
||||
clock: number
|
||||
}
|
||||
|
||||
function createWorld(patch: Partial<World> & { files?: Map<string, string> } = {}) {
|
||||
const world: World = {
|
||||
autostart: null,
|
||||
clock: 0,
|
||||
events: [],
|
||||
files: new Map([
|
||||
[PATHS.codexConfig, USER_CONFIG],
|
||||
[PATHS.bridgeScript, '# bridge'],
|
||||
[PATHS.pythonw, ''],
|
||||
[PATHS.python, '']
|
||||
]),
|
||||
listening: false,
|
||||
staleVersion: null,
|
||||
probesUntilUp: 0,
|
||||
regFails: false,
|
||||
spawnWorks: true,
|
||||
...patch
|
||||
}
|
||||
|
||||
let spawned = false
|
||||
let probesSinceSpawn = 0
|
||||
|
||||
const deps: CodexLinkDeps = {
|
||||
paths: PATHS,
|
||||
exists: file => world.files.has(file),
|
||||
readText: file => world.files.get(file) ?? null,
|
||||
writeTextAtomic: (file, text) => {
|
||||
world.events.push(`write ${file}`)
|
||||
world.files.set(file, text)
|
||||
},
|
||||
spawnBridge: (command, args, env) => {
|
||||
world.events.push(`spawn ${command} ${JSON.stringify(args)} ${JSON.stringify(env)}`)
|
||||
spawned = true
|
||||
probesSinceSpawn = 0
|
||||
world.staleVersion = null
|
||||
},
|
||||
request: async (method, url) => {
|
||||
if (method === 'GET' && url === CODEX_LINK_STATUS_URL) {
|
||||
if (spawned && world.spawnWorks && !world.listening) {
|
||||
probesSinceSpawn += 1
|
||||
|
||||
if (probesSinceSpawn > world.probesUntilUp) {
|
||||
world.listening = true
|
||||
}
|
||||
}
|
||||
|
||||
if (world.listening) {
|
||||
world.events.push('status ok')
|
||||
|
||||
return world.staleVersion ? { ...STATUS_OK, surum: world.staleVersion } : STATUS_OK
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (method === 'POST' && url === CODEX_LINK_REFRESH_URL) {
|
||||
world.events.push('refresh')
|
||||
|
||||
return world.listening ? STATUS_OK : null
|
||||
}
|
||||
|
||||
if (method === 'POST' && url === CODEX_LINK_STOP_URL) {
|
||||
world.events.push('stop')
|
||||
|
||||
if (!world.listening) {
|
||||
return null
|
||||
}
|
||||
|
||||
world.listening = false
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
throw new Error(`unexpected request ${method} ${url}`)
|
||||
},
|
||||
setAutostart: async command => {
|
||||
world.events.push('autostart set')
|
||||
|
||||
if (world.regFails) {
|
||||
throw new Error('reg add exited 1')
|
||||
}
|
||||
|
||||
world.autostart = command
|
||||
},
|
||||
clearAutostart: async () => {
|
||||
world.events.push('autostart clear')
|
||||
world.autostart = null
|
||||
},
|
||||
sleep: async ms => {
|
||||
world.clock += ms
|
||||
},
|
||||
now: () => world.clock,
|
||||
log: () => undefined
|
||||
}
|
||||
|
||||
return { link: createCodexLink(deps), world }
|
||||
}
|
||||
|
||||
const writes = (world: World) => world.events.filter(event => event.startsWith('write '))
|
||||
const spawns = (world: World) => world.events.filter(event => event.startsWith('spawn '))
|
||||
// Effects that change the machine (a status probe does not).
|
||||
const effects = (world: World) => world.events.filter(event => event !== 'status ok')
|
||||
|
||||
describe('applyCodexLink', () => {
|
||||
it('touches nothing when Codex is not installed', async () => {
|
||||
const { link, world } = createWorld()
|
||||
world.files.delete(PATHS.codexConfig)
|
||||
|
||||
const status = await link.apply()
|
||||
|
||||
expect(status).toMatchObject({ enabled: true, codexFound: false, bridgeRunning: false, lastError: null })
|
||||
expect(effects(world)).toEqual([])
|
||||
expect(world.autostart).toBeNull()
|
||||
})
|
||||
|
||||
it('touches nothing when Codex already uses another bridge', async () => {
|
||||
const foreign = 'openai_base_url = "http://127.0.0.1:8799/backend-api/codex"\n[features]\n'
|
||||
const { link, world } = createWorld({ listening: true })
|
||||
world.files.set(PATHS.codexConfig, foreign)
|
||||
|
||||
const status = await link.apply({ sync: true })
|
||||
|
||||
expect(status).toMatchObject({ enabled: true, codexFound: true, foreign: true, bridgeRunning: true })
|
||||
expect(effects(world)).toEqual([])
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(foreign)
|
||||
})
|
||||
|
||||
it('waits for the IDE setup: no venv, no changes', async () => {
|
||||
const { link, world } = createWorld()
|
||||
world.files.delete(PATHS.pythonw)
|
||||
world.files.delete(PATHS.python)
|
||||
|
||||
const status = await link.apply()
|
||||
|
||||
expect(status).toMatchObject({ enabled: true, codexFound: true, venvReady: false, foreign: false })
|
||||
expect(effects(world)).toEqual([])
|
||||
})
|
||||
|
||||
it('brings everything up in the safe order', async () => {
|
||||
const { link, world } = createWorld({ probesUntilUp: 2 })
|
||||
|
||||
const status = await link.apply()
|
||||
|
||||
expect(status).toEqual({
|
||||
enabled: true,
|
||||
codexFound: true,
|
||||
venvReady: true,
|
||||
foreign: false,
|
||||
bridgeRunning: true,
|
||||
ideDurum: 'tamam',
|
||||
ideModel: 7,
|
||||
ozet: true,
|
||||
lastError: null
|
||||
})
|
||||
|
||||
const env = { AITURK_CODEX_BRIDGE_HOME: PATHS.bridgeHome, HERMES_HOME: PATHS.hermesHome }
|
||||
|
||||
const args = codexBridgeArgs({
|
||||
bridgeHome: PATHS.bridgeHome,
|
||||
bridgeScript: PATHS.bridgeScript,
|
||||
hermesHome: PATHS.hermesHome
|
||||
})
|
||||
|
||||
// bridge.json → start → verified answer → config.toml → login item
|
||||
expect(world.events).toEqual([
|
||||
`write ${BRIDGE_JSON}`,
|
||||
`spawn ${PATHS.pythonw} ${JSON.stringify(args)} ${JSON.stringify(env)}`,
|
||||
'status ok',
|
||||
`write ${PATHS.codexConfig}`,
|
||||
'autostart set'
|
||||
])
|
||||
|
||||
expect(JSON.parse(world.files.get(BRIDGE_JSON) ?? '')).toEqual(codexBridgeConfig())
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(`${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
|
||||
expect(world.autostart).toBe(
|
||||
`"${PATHS.pythonw}" "${PATHS.bridgeScript}" --home "${PATHS.bridgeHome}" --hermes-home "${PATHS.hermesHome}"`
|
||||
)
|
||||
})
|
||||
|
||||
it('never points Codex at a bridge that did not come up', async () => {
|
||||
const { link, world } = createWorld({ spawnWorks: false })
|
||||
|
||||
const status = await link.apply()
|
||||
|
||||
expect(status).toMatchObject({ enabled: true, bridgeRunning: false, lastError: 'bridge-start-failed' })
|
||||
expect(spawns(world)).toHaveLength(1)
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
|
||||
expect(world.autostart).toBeNull()
|
||||
expect(writes(world)).toEqual([`write ${BRIDGE_JSON}`])
|
||||
// The start poll is bounded by its one budget.
|
||||
expect(world.clock).toBe(CODEX_BRIDGE_START_BUDGET_MS)
|
||||
})
|
||||
|
||||
it('takes its own line back out when the bridge can no longer be started', async () => {
|
||||
const { link, world } = createWorld({ spawnWorks: false })
|
||||
world.files.set(PATHS.codexConfig, `${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
|
||||
|
||||
const status = await link.apply()
|
||||
|
||||
expect(status.lastError).toBe('bridge-start-failed')
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
|
||||
})
|
||||
|
||||
it('does not start a second bridge when one is already listening', async () => {
|
||||
const { link, world } = createWorld({ listening: true })
|
||||
|
||||
await link.apply()
|
||||
await link.apply({ sync: true })
|
||||
|
||||
expect(spawns(world)).toEqual([])
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(`${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
|
||||
// The config is written once; the login item is refreshed on every apply.
|
||||
expect(writes(world).filter(event => event === `write ${PATHS.codexConfig}`)).toHaveLength(1)
|
||||
expect(world.events.filter(event => event === 'autostart set')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('replaces a bridge left running by an older IDE build', async () => {
|
||||
const { link, world } = createWorld({ listening: true, staleVersion: '0' })
|
||||
|
||||
const status = await link.apply()
|
||||
|
||||
expect(world.events.indexOf('stop')).toBeGreaterThanOrEqual(0)
|
||||
expect(spawns(world)).toHaveLength(1)
|
||||
expect(world.events.indexOf('stop')).toBeLessThan(world.events.findIndex(event => event.startsWith('spawn')))
|
||||
expect(status.bridgeRunning).toBe(true)
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(`${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
|
||||
})
|
||||
|
||||
it('asks a running bridge to re-read the IDE session on every apply', async () => {
|
||||
const { link, world } = createWorld({ listening: true })
|
||||
|
||||
await link.apply({ sync: true })
|
||||
|
||||
expect(world.events).toContain('refresh')
|
||||
expect(spawns(world)).toEqual([])
|
||||
})
|
||||
|
||||
it('falls back to python.exe when pythonw.exe is missing', async () => {
|
||||
const { link, world } = createWorld()
|
||||
world.files.delete(PATHS.pythonw)
|
||||
|
||||
await link.apply()
|
||||
|
||||
expect(spawns(world)[0]).toContain(`spawn ${PATHS.python} `)
|
||||
expect(world.autostart?.startsWith(`"${PATHS.python}" `)).toBe(true)
|
||||
})
|
||||
|
||||
it('reports a missing bridge script instead of starting anything', async () => {
|
||||
const { link, world } = createWorld()
|
||||
world.files.delete(PATHS.bridgeScript)
|
||||
|
||||
const status = await link.apply()
|
||||
|
||||
expect(status.lastError).toBe('bridge-missing')
|
||||
expect(effects(world)).toEqual([])
|
||||
})
|
||||
|
||||
it('surfaces a registry failure but keeps the working link', async () => {
|
||||
const { link, world } = createWorld({ listening: true, regFails: true })
|
||||
|
||||
const status = await link.apply()
|
||||
|
||||
expect(status).toMatchObject({ bridgeRunning: true, lastError: 'autostart-failed' })
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(`${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
|
||||
})
|
||||
|
||||
it('undoes everything when turned off', async () => {
|
||||
const { link, world } = createWorld({ listening: true })
|
||||
|
||||
await link.apply()
|
||||
world.events.length = 0
|
||||
|
||||
const status = await link.apply({ enabled: false })
|
||||
|
||||
expect(status).toMatchObject({ enabled: false, codexFound: true, bridgeRunning: false, lastError: null })
|
||||
expect(world.events).toEqual([
|
||||
`write ${PATHS.settingsFile}`,
|
||||
`write ${PATHS.codexConfig}`,
|
||||
'autostart clear',
|
||||
'stop'
|
||||
])
|
||||
expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({ enabled: false, kapsam: 'ide' })
|
||||
// Byte for byte what the user had before the feature touched it.
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
|
||||
expect(world.autostart).toBeNull()
|
||||
expect(world.listening).toBe(false)
|
||||
})
|
||||
|
||||
it('stays off across applies and comes back when turned on', async () => {
|
||||
const { link, world } = createWorld()
|
||||
world.files.set(PATHS.settingsFile, '{"enabled": false, "kapsam": "ide", "not": "ours"}')
|
||||
|
||||
expect(await link.apply()).toMatchObject({ enabled: false, bridgeRunning: false })
|
||||
expect(spawns(world)).toEqual([])
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
|
||||
|
||||
expect(await link.apply({ enabled: true })).toMatchObject({ enabled: true, bridgeRunning: true })
|
||||
expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({
|
||||
enabled: true,
|
||||
kapsam: 'ide',
|
||||
not: 'ours'
|
||||
})
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(`${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
|
||||
})
|
||||
|
||||
it('does not stop a bridge that serves a foreign config when turned off', async () => {
|
||||
const foreign = 'openai_base_url = "http://127.0.0.1:8799/backend-api/codex"\n'
|
||||
const { link, world } = createWorld({ listening: true })
|
||||
world.files.set(PATHS.codexConfig, foreign)
|
||||
|
||||
const status = await link.apply({ enabled: false })
|
||||
|
||||
expect(status).toMatchObject({ enabled: false, foreign: true, bridgeRunning: true })
|
||||
expect(world.events).not.toContain('stop')
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(foreign)
|
||||
expect(world.listening).toBe(true)
|
||||
})
|
||||
|
||||
it('runs concurrent applies one after another', async () => {
|
||||
const { link, world } = createWorld({ probesUntilUp: 3 })
|
||||
|
||||
const [first, second] = await Promise.all([link.apply(), link.apply({ enabled: false })])
|
||||
|
||||
expect(first.enabled).toBe(true)
|
||||
expect(second).toMatchObject({ enabled: false, bridgeRunning: false })
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
|
||||
expect(world.autostart).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('reads without changing anything', async () => {
|
||||
const { link, world } = createWorld({ listening: true })
|
||||
|
||||
const status = await link.getStatus()
|
||||
|
||||
expect(status).toMatchObject({
|
||||
enabled: true,
|
||||
codexFound: true,
|
||||
venvReady: true,
|
||||
foreign: false,
|
||||
bridgeRunning: true,
|
||||
ideDurum: 'tamam',
|
||||
ideModel: 7,
|
||||
ozet: true
|
||||
})
|
||||
expect(effects(world)).toEqual([])
|
||||
})
|
||||
|
||||
it('reports a bridge that is not answering', async () => {
|
||||
const { link, world } = createWorld()
|
||||
|
||||
expect(await link.getStatus()).toMatchObject({ bridgeRunning: false, ideDurum: null, ideModel: null, ozet: null })
|
||||
expect(effects(world)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,527 @@
|
||||
/**
|
||||
* Codex Link ("Codex Bağlama") — decisions and side effects.
|
||||
*
|
||||
* When the feature is on and OpenAI Codex is installed on this machine, the IDE
|
||||
* runs a small local Python bridge, points Codex's `config.toml` at it and
|
||||
* registers the bridge as a login item, so Codex keeps working with the IDE's
|
||||
* models while the IDE itself is closed. Turning it off undoes all three.
|
||||
*
|
||||
* Ordering is the safety property: Codex cannot work at all while its config
|
||||
* points at a dead address, so the config line is written only AFTER the bridge
|
||||
* has answered, and it is taken back out if the bridge cannot be brought up.
|
||||
*
|
||||
* Electron-free on purpose. `createCodexLink` takes every effect as a
|
||||
* dependency (files, spawn, HTTP, registry) so the ordering above is provable
|
||||
* in a unit test; `createNodeCodexLinkDeps` is the real wiring main.ts uses.
|
||||
* The text transformations live in `codex-link-config.ts`.
|
||||
*/
|
||||
|
||||
import { execFile, spawn } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
addCodexLinkLine,
|
||||
CODEX_BRIDGE_VERSION,
|
||||
CODEX_LINK_LAUNCHER_NAME,
|
||||
CODEX_LINK_REFRESH_URL,
|
||||
CODEX_LINK_RUN_KEY,
|
||||
CODEX_LINK_RUN_VALUE,
|
||||
CODEX_LINK_STATUS_URL,
|
||||
CODEX_LINK_STOP_URL,
|
||||
codexAutostartPlan,
|
||||
codexBridgeArgs,
|
||||
type CodexBridgeLaunch,
|
||||
inspectCodexConfig,
|
||||
parseCodexLinkSettings,
|
||||
removeCodexLinkLine,
|
||||
serializeCodexBridgeConfig,
|
||||
serializeCodexLinkSettings
|
||||
} from './codex-link-config'
|
||||
import type { CodexLinkError, CodexLinkIdeState, CodexLinkPatch, CodexLinkStatus } from './codex-link-types'
|
||||
import { hiddenWindowsChildOptions } from './windows-child-options'
|
||||
|
||||
export type { CodexLinkError, CodexLinkIdeState, CodexLinkPatch, CodexLinkStatus } from './codex-link-types'
|
||||
|
||||
export interface CodexLinkPaths {
|
||||
/** `<data root>/codex-baglama.json` */
|
||||
settingsFile: string
|
||||
/** `<data root>/codex-bridge` — the bridge's data folder. */
|
||||
bridgeHome: string
|
||||
hermesHome: string
|
||||
/** `<resources>/codex-bridge/bridge.py` */
|
||||
bridgeScript: string
|
||||
/** Windowless interpreter, preferred. */
|
||||
pythonw: string
|
||||
/** Console interpreter, used hidden when pythonw is absent. */
|
||||
python: string
|
||||
/** `<CODEX_HOME or ~/.codex>/config.toml` */
|
||||
codexConfig: string
|
||||
}
|
||||
|
||||
export interface CodexLinkDeps {
|
||||
paths: CodexLinkPaths
|
||||
exists: (file: string) => boolean
|
||||
/** null when the file is missing; throws when it cannot be read losslessly. */
|
||||
readText: (file: string) => null | string
|
||||
/** tmp + rename; creates the parent folder. */
|
||||
writeTextAtomic: (file: string, text: string) => void
|
||||
/** Start the bridge detached and hidden; it must outlive the IDE. */
|
||||
spawnBridge: (command: string, args: string[], env: Record<string, string>) => void
|
||||
/** Parsed JSON body, or null when nothing answered within `budgetMs`. */
|
||||
request: (method: 'GET' | 'POST', url: string, budgetMs: number) => Promise<unknown>
|
||||
setAutostart: (command: string) => Promise<void>
|
||||
clearAutostart: () => Promise<void>
|
||||
sleep: (ms: number) => Promise<void>
|
||||
now: () => number
|
||||
log: (message: string) => void
|
||||
}
|
||||
|
||||
// The single wait this feature owns: how long a freshly spawned bridge gets to
|
||||
// answer its status endpoint before the apply gives up.
|
||||
export const CODEX_BRIDGE_START_BUDGET_MS = 10_000
|
||||
const START_POLL_INTERVAL_MS = 500
|
||||
// A probe of an already-running (or absent) local bridge. Loopback answers or
|
||||
// refuses at once; the bound only keeps Settings from hanging on a wedged port.
|
||||
const PROBE_BUDGET_MS = 2_000
|
||||
|
||||
interface BridgeProbe {
|
||||
ideDurum: CodexLinkIdeState | null
|
||||
ideModel: null | number
|
||||
ozet: boolean | null
|
||||
running: boolean
|
||||
surum: null | string
|
||||
}
|
||||
|
||||
const NOT_RUNNING: BridgeProbe = { ideDurum: null, ideModel: null, ozet: null, running: false, surum: null }
|
||||
|
||||
function readProbe(body: unknown): BridgeProbe {
|
||||
if (!body || typeof body !== 'object' || (body as { ok?: unknown }).ok !== true) {
|
||||
return NOT_RUNNING
|
||||
}
|
||||
|
||||
const reply = body as { ide_durum?: unknown; ide_model?: unknown; ozet?: unknown; surum?: unknown }
|
||||
const state = reply.ide_durum
|
||||
|
||||
return {
|
||||
ideDurum: state === 'tamam' || state === 'kapali' || state === 'anahtar-yok' ? state : null,
|
||||
ideModel: typeof reply.ide_model === 'number' && Number.isFinite(reply.ide_model) ? reply.ide_model : null,
|
||||
ozet: typeof reply.ozet === 'boolean' ? reply.ozet : null,
|
||||
running: true,
|
||||
surum: typeof reply.surum === 'string' ? reply.surum : null
|
||||
}
|
||||
}
|
||||
|
||||
function describe(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
export interface CodexLink {
|
||||
/** Side-effect free: reads the preference, the files and the status endpoint. */
|
||||
getStatus: () => Promise<CodexLinkStatus>
|
||||
/** Persist `patch.enabled` (if given), make the machine match, report. */
|
||||
apply: (patch?: CodexLinkPatch) => Promise<CodexLinkStatus>
|
||||
}
|
||||
|
||||
export function createCodexLink(deps: CodexLinkDeps): CodexLink {
|
||||
const { paths } = deps
|
||||
let lastError: CodexLinkError | null = null
|
||||
let queue: Promise<unknown> = Promise.resolve()
|
||||
|
||||
const launch: CodexBridgeLaunch = {
|
||||
bridgeHome: paths.bridgeHome,
|
||||
bridgeScript: paths.bridgeScript,
|
||||
hermesHome: paths.hermesHome
|
||||
}
|
||||
|
||||
const fail = (code: CodexLinkError, error?: unknown) => {
|
||||
lastError = code
|
||||
deps.log(`[codex-link] ${code}${error === undefined ? '' : `: ${describe(error)}`}`)
|
||||
}
|
||||
|
||||
const readSettings = () => {
|
||||
try {
|
||||
return parseCodexLinkSettings(deps.readText(paths.settingsFile))
|
||||
} catch {
|
||||
return parseCodexLinkSettings(null)
|
||||
}
|
||||
}
|
||||
|
||||
// The interpreter to run the bridge with, or null while the IDE's own
|
||||
// first-run setup has not produced the venv yet.
|
||||
const interpreter = () =>
|
||||
deps.exists(paths.pythonw) ? paths.pythonw : deps.exists(paths.python) ? paths.python : null
|
||||
|
||||
// The owner of Codex's base URL. An unreadable config counts as foreign for
|
||||
// the purpose of "do not touch": we only edit what we could read back intact.
|
||||
const readCodexConfig = (): { found: boolean; owner: 'absent' | 'foreign' | 'ours'; text: null | string } => {
|
||||
if (!deps.exists(paths.codexConfig)) {
|
||||
return { found: false, owner: 'absent', text: null }
|
||||
}
|
||||
|
||||
try {
|
||||
const text = deps.readText(paths.codexConfig)
|
||||
|
||||
return text === null
|
||||
? { found: false, owner: 'absent', text: null }
|
||||
: { found: true, owner: inspectCodexConfig(text), text }
|
||||
} catch (error) {
|
||||
deps.log(`[codex-link] config.toml could not be read: ${describe(error)}`)
|
||||
|
||||
return { found: true, owner: 'foreign', text: null }
|
||||
}
|
||||
}
|
||||
|
||||
const probe = async (budgetMs = PROBE_BUDGET_MS): Promise<BridgeProbe> => {
|
||||
try {
|
||||
return readProbe(await deps.request('GET', CODEX_LINK_STATUS_URL, budgetMs))
|
||||
} catch {
|
||||
return NOT_RUNNING
|
||||
}
|
||||
}
|
||||
|
||||
const statusFrom = (
|
||||
enabled: boolean,
|
||||
config: { found: boolean; owner: string },
|
||||
bridge: BridgeProbe
|
||||
): CodexLinkStatus => ({
|
||||
enabled,
|
||||
codexFound: config.found,
|
||||
venvReady: interpreter() !== null,
|
||||
foreign: config.owner === 'foreign',
|
||||
bridgeRunning: bridge.running,
|
||||
ideDurum: bridge.ideDurum,
|
||||
ideModel: bridge.ideModel,
|
||||
ozet: bridge.ozet,
|
||||
lastError
|
||||
})
|
||||
|
||||
const removeOurLine = (text: null | string): boolean => {
|
||||
if (text === null) {
|
||||
return true
|
||||
}
|
||||
|
||||
const edit = removeCodexLinkLine(text)
|
||||
|
||||
if (!edit.changed) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
deps.writeTextAtomic(paths.codexConfig, edit.text)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
fail('config-write-failed', error)
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Off: take back everything we put there. Each step is independent — a
|
||||
// failure in one must not leave the others in place.
|
||||
const tearDown = async (config: ReturnType<typeof readCodexConfig>): Promise<BridgeProbe> => {
|
||||
removeOurLine(config.text)
|
||||
|
||||
try {
|
||||
await deps.clearAutostart()
|
||||
} catch (error) {
|
||||
fail('autostart-failed', error)
|
||||
}
|
||||
|
||||
// A foreign config means whatever listens on the port serves someone
|
||||
// else's setup; stopping it would break a bridge we do not own.
|
||||
if (config.owner === 'foreign') {
|
||||
return probe()
|
||||
}
|
||||
|
||||
try {
|
||||
// No answer means nothing of ours is listening; leave it alone.
|
||||
return (await deps.request('POST', CODEX_LINK_STOP_URL, PROBE_BUDGET_MS)) === null ? probe() : NOT_RUNNING
|
||||
} catch {
|
||||
return probe()
|
||||
}
|
||||
}
|
||||
|
||||
const startBridge = async (python: string): Promise<BridgeProbe> => {
|
||||
try {
|
||||
deps.spawnBridge(python, codexBridgeArgs(launch), {
|
||||
AITURK_CODEX_BRIDGE_HOME: paths.bridgeHome,
|
||||
HERMES_HOME: paths.hermesHome
|
||||
})
|
||||
} catch (error) {
|
||||
deps.log(`[codex-link] bridge spawn failed: ${describe(error)}`)
|
||||
|
||||
return NOT_RUNNING
|
||||
}
|
||||
|
||||
const deadline = deps.now() + CODEX_BRIDGE_START_BUDGET_MS
|
||||
|
||||
for (;;) {
|
||||
const remaining = deadline - deps.now()
|
||||
|
||||
if (remaining <= 0) {
|
||||
return NOT_RUNNING
|
||||
}
|
||||
|
||||
const result = await probe(Math.min(PROBE_BUDGET_MS, remaining))
|
||||
|
||||
if (result.running) {
|
||||
return result
|
||||
}
|
||||
|
||||
await deps.sleep(Math.min(START_POLL_INTERVAL_MS, Math.max(0, deadline - deps.now())))
|
||||
}
|
||||
}
|
||||
|
||||
// On: bridge.json → bridge up (verified) → config.toml → login item.
|
||||
const setUp = async (python: string, config: ReturnType<typeof readCodexConfig>): Promise<BridgeProbe> => {
|
||||
if (!deps.exists(paths.bridgeScript)) {
|
||||
fail('bridge-missing', paths.bridgeScript)
|
||||
|
||||
return probe()
|
||||
}
|
||||
|
||||
try {
|
||||
deps.writeTextAtomic(path.join(paths.bridgeHome, 'bridge.json'), serializeCodexBridgeConfig())
|
||||
} catch (error) {
|
||||
fail('bridge-config-failed', error)
|
||||
|
||||
return probe()
|
||||
}
|
||||
|
||||
let bridge = await probe()
|
||||
|
||||
if (bridge.running && bridge.surum !== CODEX_BRIDGE_VERSION) {
|
||||
// A bridge from an older IDE build is still listening: replace it.
|
||||
try {
|
||||
await deps.request('POST', CODEX_LINK_STOP_URL, PROBE_BUDGET_MS)
|
||||
} catch {
|
||||
// It stops answering once it exits; the start below verifies the new one.
|
||||
}
|
||||
|
||||
await deps.sleep(START_POLL_INTERVAL_MS * 2)
|
||||
bridge = NOT_RUNNING
|
||||
}
|
||||
|
||||
if (bridge.running) {
|
||||
// Already up: have it re-read the IDE session and model list now (a sign-in
|
||||
// since its last periodic refresh would otherwise show up minutes later).
|
||||
try {
|
||||
const refreshed = readProbe(await deps.request('POST', CODEX_LINK_REFRESH_URL, CODEX_BRIDGE_START_BUDGET_MS))
|
||||
|
||||
bridge = refreshed.running ? refreshed : bridge
|
||||
} catch {
|
||||
// The status it already gave stays valid.
|
||||
}
|
||||
} else {
|
||||
bridge = await startBridge(python)
|
||||
}
|
||||
|
||||
if (!bridge.running) {
|
||||
fail('bridge-start-failed')
|
||||
// Fail open: a line left over from an earlier run would now point Codex
|
||||
// at a dead address. Without it Codex simply talks to OpenAI directly.
|
||||
removeOurLine(config.text)
|
||||
|
||||
return bridge
|
||||
}
|
||||
|
||||
const edit = addCodexLinkLine(config.text ?? '')
|
||||
|
||||
if (edit.changed) {
|
||||
try {
|
||||
deps.writeTextAtomic(paths.codexConfig, edit.text)
|
||||
} catch (error) {
|
||||
fail('config-write-failed', error)
|
||||
|
||||
return bridge
|
||||
}
|
||||
}
|
||||
|
||||
// Rewritten on every apply: an IDE update can move the bridge script.
|
||||
try {
|
||||
const plan = codexAutostartPlan(python, launch, path.join(paths.bridgeHome, CODEX_LINK_LAUNCHER_NAME))
|
||||
|
||||
if (!plan) {
|
||||
throw new Error('the bridge command does not fit a Run value')
|
||||
}
|
||||
|
||||
if (plan.launcher) {
|
||||
deps.writeTextAtomic(plan.launcher.path, plan.launcher.source)
|
||||
}
|
||||
|
||||
await deps.setAutostart(plan.command)
|
||||
} catch (error) {
|
||||
fail('autostart-failed', error)
|
||||
}
|
||||
|
||||
return bridge
|
||||
}
|
||||
|
||||
const run = async (patch?: CodexLinkPatch): Promise<CodexLinkStatus> => {
|
||||
lastError = null
|
||||
|
||||
if (typeof patch?.enabled === 'boolean') {
|
||||
try {
|
||||
deps.writeTextAtomic(paths.settingsFile, serializeCodexLinkSettings(readSettings().stored, patch.enabled))
|
||||
} catch (error) {
|
||||
// The preference did not stick, so do not act on it either: what the
|
||||
// machine does must match what the file says after a restart.
|
||||
fail('settings-write-failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
const { enabled } = readSettings()
|
||||
const config = readCodexConfig()
|
||||
|
||||
if (!enabled) {
|
||||
return statusFrom(enabled, config, await tearDown(config))
|
||||
}
|
||||
|
||||
const python = interpreter()
|
||||
|
||||
if (!config.found || !python || config.owner === 'foreign') {
|
||||
return statusFrom(enabled, config, await probe())
|
||||
}
|
||||
|
||||
return statusFrom(enabled, config, await setUp(python, config))
|
||||
}
|
||||
|
||||
return {
|
||||
getStatus: async () => statusFrom(readSettings().enabled, readCodexConfig(), await probe()),
|
||||
// One apply at a time: two interleaved runs could write the config line
|
||||
// from one while the other is tearing the bridge down.
|
||||
apply: patch => {
|
||||
const next = queue.then(() => run(patch))
|
||||
queue = next.catch(() => undefined)
|
||||
|
||||
return next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Real dependencies ───────────────────────────────────────────────────────
|
||||
|
||||
function writeTextAtomic(file: string, text: string): void {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
const tmp = `${file}.${process.pid}.tmp`
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tmp, text, 'utf8')
|
||||
fs.renameSync(tmp, file)
|
||||
} catch (error) {
|
||||
fs.rmSync(tmp, { force: true })
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Decode as UTF-8 and insist the bytes survive the round trip. A config in
|
||||
// another encoding would be corrupted by a rewrite, so refuse to return it.
|
||||
function readTextLossless(file: string): null | string {
|
||||
let bytes: Buffer
|
||||
|
||||
try {
|
||||
bytes = fs.readFileSync(file)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
const text = bytes.toString('utf8')
|
||||
|
||||
if (!Buffer.from(text, 'utf8').equals(bytes)) {
|
||||
throw new Error('file is not valid UTF-8')
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
function runReg(args: string[]): Promise<{ code: number; stderr: string }> {
|
||||
const reg = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'reg.exe')
|
||||
|
||||
return new Promise(resolve => {
|
||||
execFile(reg, args, hiddenWindowsChildOptions({ encoding: 'utf8' }), (error, _stdout, stderr) => {
|
||||
const exit = error ? (typeof (error as { code?: unknown }).code === 'number' ? (error as any).code : 1) : 0
|
||||
resolve({ code: exit, stderr: String(stderr || (error ? error.message : '')).trim() })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function requestJson(method: 'GET' | 'POST', url: string, budgetMs: number): Promise<unknown> {
|
||||
try {
|
||||
const response = await fetch(url, { method, signal: AbortSignal.timeout(Math.max(1, budgetMs)) })
|
||||
|
||||
return response.ok ? await response.json() : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function createNodeCodexLinkDeps(paths: CodexLinkPaths, log: (message: string) => void): CodexLinkDeps {
|
||||
return {
|
||||
paths,
|
||||
exists: file => fs.existsSync(file),
|
||||
readText: readTextLossless,
|
||||
writeTextAtomic,
|
||||
spawnBridge: (command, args, env) => {
|
||||
fs.mkdirSync(paths.bridgeHome, { recursive: true })
|
||||
|
||||
// Detached with no stdio and unref'd: the bridge is deliberately NOT a
|
||||
// child the IDE cleans up on quit — Codex depends on it staying alive.
|
||||
const child = spawn(
|
||||
command,
|
||||
args,
|
||||
hiddenWindowsChildOptions({
|
||||
cwd: paths.bridgeHome,
|
||||
detached: true,
|
||||
env: { ...process.env, ...env },
|
||||
stdio: 'ignore'
|
||||
}) as any
|
||||
)
|
||||
|
||||
child.on('error', error => log(`[codex-link] bridge process error: ${error.message}`))
|
||||
child.unref()
|
||||
},
|
||||
request: requestJson,
|
||||
setAutostart: async command => {
|
||||
const result = await runReg([
|
||||
'add',
|
||||
CODEX_LINK_RUN_KEY,
|
||||
'/v',
|
||||
CODEX_LINK_RUN_VALUE,
|
||||
'/t',
|
||||
'REG_SZ',
|
||||
'/d',
|
||||
command,
|
||||
'/f'
|
||||
])
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`reg add exited ${result.code}: ${result.stderr}`)
|
||||
}
|
||||
},
|
||||
clearAutostart: async () => {
|
||||
// `reg delete` fails on a value that is not there; absent is already the
|
||||
// state we want, so look first.
|
||||
const present = await runReg(['query', CODEX_LINK_RUN_KEY, '/v', CODEX_LINK_RUN_VALUE])
|
||||
|
||||
if (present.code !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await runReg(['delete', CODEX_LINK_RUN_KEY, '/v', CODEX_LINK_RUN_VALUE, '/f'])
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`reg delete exited ${result.code}: ${result.stderr}`)
|
||||
}
|
||||
},
|
||||
sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
|
||||
now: () => Date.now(),
|
||||
log
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ import { WebSocket as RelayWebSocket } from 'ws'
|
||||
|
||||
import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state'
|
||||
import { createAiturkCliRunner, createAiturkLogin } from './aiturk-login'
|
||||
import { createCodexLinkClient } from './codex-link-client'
|
||||
import { createCodexLink, createNodeCodexLinkDeps } from './codex-link'
|
||||
import { createMobileBackendResolver } from './mobile-backend'
|
||||
import {
|
||||
createMobileLink,
|
||||
@@ -13092,6 +13092,9 @@ async function startHermes() {
|
||||
// accumulated count of the resolved episode.
|
||||
bootstrapRepairAttempt = 0
|
||||
|
||||
// The local backend is up, so the venv the Codex bridge runs on exists.
|
||||
syncCodexLinkOnBoot()
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'local',
|
||||
@@ -17183,6 +17186,72 @@ ipcMain.handle('hermes:quick-entry:settings:set', async (_event, patch) => {
|
||||
return applyQuickEntrySettings(next)
|
||||
})
|
||||
|
||||
// Codex Link ("Codex Bağlama"): lets the OpenAI Codex on this machine use the
|
||||
// IDE's models through a small local bridge. Main owns the preference file, the
|
||||
// bridge process, Codex's config line and the login item; Settings reads the
|
||||
// resulting state and asks for changes over IPC. Windows only (the login item is
|
||||
// a Run-key value). All decisions live in electron/codex-link.ts.
|
||||
const AITURK_IDE_DATA_ROOT = path.dirname(HERMES_HOME)
|
||||
|
||||
const codexLink = createCodexLink(
|
||||
createNodeCodexLinkDeps(
|
||||
{
|
||||
settingsFile: path.join(AITURK_IDE_DATA_ROOT, 'codex-baglama.json'),
|
||||
bridgeHome: path.join(AITURK_IDE_DATA_ROOT, 'codex-bridge'),
|
||||
hermesHome: HERMES_HOME,
|
||||
bridgeScript: IS_PACKAGED
|
||||
? path.join(process.resourcesPath, 'codex-bridge', 'bridge.py')
|
||||
: path.join(APP_ROOT, 'assets', 'codex-bridge', 'bridge.py'),
|
||||
pythonw: path.join(VENV_ROOT, 'Scripts', 'pythonw.exe'),
|
||||
python: getVenvPython(VENV_ROOT),
|
||||
codexConfig: path.join(process.env.CODEX_HOME || path.join(app.getPath('home'), '.codex'), 'config.toml')
|
||||
},
|
||||
rememberLog
|
||||
)
|
||||
)
|
||||
|
||||
let codexLinkBootSynced = false
|
||||
|
||||
// Once per launch, after the local backend is ready. Never allowed to affect
|
||||
// startup: it is not awaited and every failure ends in the log.
|
||||
function syncCodexLinkOnBoot() {
|
||||
if (!IS_WINDOWS || codexLinkBootSynced) {
|
||||
return
|
||||
}
|
||||
|
||||
codexLinkBootSynced = true
|
||||
void codexLink.apply().catch(error => rememberLog(`[codex-link] boot sync failed: ${error?.message ?? error}`))
|
||||
}
|
||||
|
||||
// Only the primary window's top-level frame may read or change the link.
|
||||
function assertCodexLinkSender(event) {
|
||||
const contents = mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null
|
||||
|
||||
if (
|
||||
!IS_WINDOWS ||
|
||||
!contents ||
|
||||
event.sender !== contents ||
|
||||
(event.senderFrame && event.senderFrame !== contents.mainFrame)
|
||||
) {
|
||||
throw new Error('codex-link:unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('aiturk:codex-link:get', async event => {
|
||||
assertCodexLinkSender(event)
|
||||
|
||||
return codexLink.getStatus()
|
||||
})
|
||||
|
||||
ipcMain.handle('aiturk:codex-link:set', async (event, patch) => {
|
||||
assertCodexLinkSender(event)
|
||||
|
||||
return codexLink.apply({
|
||||
enabled: typeof patch?.enabled === 'boolean' ? patch.enabled : undefined,
|
||||
sync: patch?.sync === true
|
||||
})
|
||||
})
|
||||
|
||||
// Quick window → main → PRIMARY renderer. We never submit here: the renderer
|
||||
// owns the one prompt-submit path, and forwarding keeps it that way. The
|
||||
// payload is `{ target, text }` — target routing (current chat / a picked
|
||||
@@ -17267,16 +17336,6 @@ const aiturkLogin = createAiturkLogin({
|
||||
now: () => Date.now()
|
||||
})
|
||||
|
||||
const codexLinkControl = createCodexLinkClient({ spawn, existsSync: fs.existsSync, env: process.env })
|
||||
ipcMain.handle('aiturk:codex-link:control', async (event, request) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!window || (window !== mainWindow && !instanceWindows.has(window)) ||
|
||||
event.senderFrame !== event.sender.mainFrame) {
|
||||
throw new Error('codex-link:unavailable')
|
||||
}
|
||||
return codexLinkControl(request)
|
||||
})
|
||||
|
||||
// Only the primary window's top-level frame may drive a sign-in.
|
||||
function assertAiturkLoginSender(event) {
|
||||
const contents = mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null
|
||||
@@ -17874,7 +17933,6 @@ function getMobileLink(): MobileLink {
|
||||
const directory = path.join(app.getPath('userData'), 'aiturk-mobile', 'mobile')
|
||||
|
||||
mobileLink = createMobileLink({
|
||||
codexLinkControl,
|
||||
// Same vault file as before (mobile-secrets.bin, safeStorage); older fields are kept.
|
||||
vault: new MobileVault(directory, safeStorage),
|
||||
store: createMobileLinkFileStore(directory, path.join(directory, 'lan-link.json'), fs.promises),
|
||||
|
||||
@@ -271,63 +271,6 @@ async function startProxy(over: Record<string, unknown> = {}) {
|
||||
return { proxy, port, identity, clock, logs, saved, ended, pair, ca: identity.cert }
|
||||
}
|
||||
|
||||
describe('LAN proxy: narrow Codex control', () => {
|
||||
const route = '/api/aiturk/codex-link/control'
|
||||
const body = { contract_version: 'codex-link/1', kind: 'settings_get', request_id: 'phone-read-1', operation_id: 'phone-op-1' }
|
||||
it('requires pairing before invoking local control and never starts a backend', async () => {
|
||||
const calls: unknown[] = []
|
||||
const t = await startProxy({ codexLinkControl: async request => { calls.push(request); return { ...request, kind: 'control_error', settings_revision: 0, error: { code: 'pending', message: 'Not ready', recovery_action: 'none' } } } })
|
||||
const before = resolveCalls
|
||||
try {
|
||||
expect((await call(t.port, 'POST', route, { ca: t.ca, body })).status).toBe(401)
|
||||
expect(calls).toHaveLength(0)
|
||||
const phone = await t.pair()
|
||||
const result = await call(t.port, 'POST', route, { ca: t.ca, token: phone.token, body })
|
||||
expect(result.status).toBe(200)
|
||||
expect(calls).toEqual([body])
|
||||
expect(resolveCalls).toBe(before)
|
||||
expect(JSON.stringify(calls)).not.toContain(phone.token)
|
||||
} finally { await t.proxy.stop() }
|
||||
})
|
||||
it('rejects runtime commands, extra paths, duplicate keys and large bodies', async () => {
|
||||
let calls = 0
|
||||
const t = await startProxy({ codexLinkControl: async () => { calls++; throw new Error('must not execute') } })
|
||||
try {
|
||||
const phone = await t.pair()
|
||||
for (const rawBody of [
|
||||
JSON.stringify({ ...body, kind: 'runtime_attach' }),
|
||||
JSON.stringify({ ...body, path: 'C:\\private' }),
|
||||
JSON.stringify(body).replace('"kind":"settings_get"', '"kind":"settings_get","kind":"status_get"'),
|
||||
'x'.repeat(65537)
|
||||
]) {
|
||||
expect((await call(t.port, 'POST', route, { ca: t.ca, token: phone.token, rawBody })).status).toBe(400)
|
||||
}
|
||||
expect(calls).toBe(0)
|
||||
} finally { await t.proxy.stop() }
|
||||
})
|
||||
it('returns a fixed unknown-outcome code without leaking CLI diagnostics', async () => {
|
||||
const t = await startProxy({ codexLinkControl: async () => { throw new Error('private-key-and-path') } })
|
||||
try {
|
||||
const phone = await t.pair()
|
||||
const result = await call(t.port, 'POST', route, { ca: t.ca, token: phone.token, body })
|
||||
expect(result.status).toBe(503)
|
||||
expect(result.json).toEqual({ error: 'codex_link_outcome_unknown' })
|
||||
expect(result.text).not.toContain('private-key')
|
||||
} finally { await t.proxy.stop() }
|
||||
})
|
||||
it('rejects revoked phones and unsupported methods', async () => {
|
||||
let calls = 0
|
||||
const t = await startProxy({ codexLinkControl: async () => { calls++; return {} } })
|
||||
try {
|
||||
const phone = await t.pair()
|
||||
expect((await call(t.port, 'GET', route, { ca: t.ca, token: phone.token })).status).toBe(405)
|
||||
await t.proxy.revoke(phone.deviceId)
|
||||
expect((await call(t.port, 'POST', route, { ca: t.ca, token: phone.token, body })).status).toBe(401)
|
||||
expect(calls).toBe(0)
|
||||
} finally { await t.proxy.stop() }
|
||||
})
|
||||
})
|
||||
|
||||
describe('LAN proxy: pairing (hello / claim)', () => {
|
||||
it('hello is unauthenticated and reports whether pairing is open', async () => {
|
||||
const t = await startProxy()
|
||||
|
||||
@@ -42,9 +42,6 @@ import http from 'node:http'
|
||||
import https from 'node:https'
|
||||
import net from 'node:net'
|
||||
import type { Duplex } from 'node:stream'
|
||||
import { CODEX_LINK_REQUEST_BYTES, validateControlRequest } from '../codex-link-control'
|
||||
import { parseStrictJson } from '../codex-link-json'
|
||||
import type { CodexLinkControlRequest, CodexLinkControlResponse } from '../codex-link-control.types'
|
||||
|
||||
import { loadOrCreateLanIdentity, type MobileLanIdentity, type MobileLanVault } from './mobile-lan-cert'
|
||||
import {
|
||||
@@ -709,7 +706,6 @@ export interface MobileDeviceView {
|
||||
export type PairingEndReason = 'claimed' | 'locked' | 'expired' | 'cancelled'
|
||||
|
||||
export interface LanProxyOptions {
|
||||
codexLinkControl?: (request: CodexLinkControlRequest) => Promise<CodexLinkControlResponse>
|
||||
tls: { key: string; cert: string }
|
||||
/** Must resolve the LOCAL backend or throw (remote mode, backend down). */
|
||||
resolveBackend: (profile: string) => Promise<MobileBackend>
|
||||
@@ -1112,35 +1108,6 @@ export function createLanProxy(options: LanProxyOptions) {
|
||||
return reply(req, res, path, 403, { error: 'forbidden' })
|
||||
}
|
||||
|
||||
if (path === '/api/aiturk/codex-link/control') {
|
||||
if (req.method !== 'POST') return reply(req, res, path, 405, { error: 'method_not_allowed' })
|
||||
if (!options.codexLinkControl) return reply(req, res, path, 503, { error: 'codex_link_unavailable' })
|
||||
if (!/^application\/json(?:;|$)/i.test(String(req.headers['content-type'] ?? ''))) {
|
||||
return reply(req, res, path, 400, { error: 'invalid_request' })
|
||||
}
|
||||
let request: CodexLinkControlRequest
|
||||
try {
|
||||
if (Number(req.headers['content-length'] ?? 0) > CODEX_LINK_REQUEST_BYTES) throw new Error('large_body')
|
||||
let size = 0
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length
|
||||
if (size > CODEX_LINK_REQUEST_BYTES) throw new Error('large_body')
|
||||
chunks.push(Buffer.from(chunk))
|
||||
}
|
||||
const parsed = parseStrictJson(Buffer.concat(chunks), CODEX_LINK_REQUEST_BYTES)
|
||||
validateControlRequest(parsed)
|
||||
request = parsed
|
||||
} catch { return reply(req, res, path, 400, { error: 'invalid_request' }) }
|
||||
// Pairing can be revoked while a slow body is being read.
|
||||
if (!devices.has(device.tokenHash)) return reply(req, res, path, 401, { error: 'unauthorized' })
|
||||
try {
|
||||
const result = await options.codexLinkControl(request)
|
||||
if (!devices.has(device.tokenHash)) return reply(req, res, path, 401, { error: 'unauthorized' })
|
||||
return reply(req, res, path, 200, result)
|
||||
} catch { return reply(req, res, path, 503, { error: 'codex_link_outcome_unknown' }) }
|
||||
}
|
||||
|
||||
const backend = await localBackend(url)
|
||||
|
||||
if (res.destroyed) return
|
||||
@@ -1543,7 +1510,6 @@ export type MobileLinkEvent =
|
||||
{ type: 'state'; state: MobileLinkState } | { type: 'paired'; name: string; state: MobileLinkState }
|
||||
|
||||
export interface MobileLinkDeps {
|
||||
codexLinkControl?: (request: CodexLinkControlRequest) => Promise<CodexLinkControlResponse>
|
||||
vault: MobileLanVault
|
||||
store: MobileLinkStore
|
||||
resolveBackend: (profile: string) => Promise<MobileBackend>
|
||||
@@ -1854,7 +1820,6 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
}
|
||||
|
||||
proxy = createLanProxy({
|
||||
codexLinkControl: deps.codexLinkControl,
|
||||
tls: identity,
|
||||
resolveBackend: async profile => {
|
||||
if (deps.isRemoteMode()) {
|
||||
|
||||
@@ -13,7 +13,6 @@ const hudNativeDrag = hudWindowing?.nativeDrag === true
|
||||
const launchFlags = ipcRenderer.sendSync('hermes:launch-flags')
|
||||
|
||||
contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
codexLink: { control: request => ipcRenderer.invoke('aiturk:codex-link:control', request) },
|
||||
setUiLanguage: (locale: string) => ipcRenderer.send('hermes:ui-language', locale),
|
||||
glassSupported: translucencySupport?.glass === true,
|
||||
translucencySupported: translucencySupport?.translucency === true,
|
||||
@@ -291,6 +290,16 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
// Opens the "Telefon bağlantısı" window (electron/mobile-host.mjs).
|
||||
open: () => ipcRenderer.invoke('aiturk:mobile:open')
|
||||
},
|
||||
// "Codex Bağlama" (electron/codex-link.ts). Windows only: absent elsewhere,
|
||||
// so Settings does not offer a page that cannot do anything.
|
||||
...(process.platform === 'win32'
|
||||
? {
|
||||
codexLink: {
|
||||
get: () => ipcRenderer.invoke('aiturk:codex-link:get'),
|
||||
set: patch => ipcRenderer.invoke('aiturk:codex-link:set', patch)
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
mcpOauth: {
|
||||
// One-shot loopback listener for MCP OAuth against remote backends: bind
|
||||
// on this machine, hand redirectUri to mcp.servers.oauth.start, then wait
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "aiturk-ide",
|
||||
"productName": "AITURK IDE",
|
||||
"private": true,
|
||||
"version": "1.0.0-beta.21",
|
||||
"version": "1.0.0-beta.23",
|
||||
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
||||
"author": "AITURK / TurkServis",
|
||||
"repository": {
|
||||
@@ -299,6 +299,16 @@
|
||||
"*.dll",
|
||||
"manifest.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "assets/codex-bridge",
|
||||
"to": "codex-bridge",
|
||||
"filter": [
|
||||
"**/*.py",
|
||||
"**/*.pyd",
|
||||
"**/LICENSE",
|
||||
"!test_*.py"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium } from '@playwright/test'
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const output = process.argv[2]
|
||||
if (!output || !path.isAbsolute(output)) throw new Error('An absolute evidence output directory is required')
|
||||
await mkdir(output, { recursive: true })
|
||||
const port = 5178
|
||||
const server = spawn(process.execPath, [path.resolve(root, '../../node_modules/vite/bin/vite.js'), '--host', '127.0.0.1', '--port', String(port), '--strictPort'], { cwd: root, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let log = ''
|
||||
const capture = chunk => { log = (log + String(chunk)).slice(-12000) }
|
||||
server.stdout.on('data', capture); server.stderr.on('data', capture)
|
||||
let browser
|
||||
try {
|
||||
let ready = false
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (server.exitCode !== null) throw new Error('Preview server exited: ' + log)
|
||||
try { ready = (await fetch(`http://127.0.0.1:${port}/visual-fixtures/codex-link-settings.html`)).ok } catch {}
|
||||
if (ready) break
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
}
|
||||
if (!ready) throw new Error('Preview server not ready: ' + log)
|
||||
browser = await chromium.launch({ channel: 'chrome', headless: true })
|
||||
const evidence = []
|
||||
for (const width of [360, 390, 414, 1280]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 }, reducedMotion: 'reduce' })
|
||||
const errors = []
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
await page.goto(`http://127.0.0.1:${port}/visual-fixtures/codex-link-settings.html`, { waitUntil: 'domcontentloaded', timeout: 90000 })
|
||||
await page.getByText('Proje sohbeti · örnek kayıt').waitFor({ timeout: 90000 })
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
const layout = await page.evaluate(() => ({ width: innerWidth, scroll: document.documentElement.scrollWidth, buttons: [...document.querySelectorAll('button:not([role=switch]), input, select, label[for="codex-link-enabled"], label[for="codex-link-compression"]')].map(element => ({ text: element.textContent || element.id, height: element.getBoundingClientRect().height })) }))
|
||||
if (layout.scroll > width) throw new Error(`Horizontal overflow at ${width}: ${layout.scroll}`)
|
||||
await writeFile(path.join(output, `layout-${width}.json`), JSON.stringify(layout, null, 2) + '\n')
|
||||
await page.screenshot({ path: path.join(output, `codex-link-${width}.png`), fullPage: true })
|
||||
if (layout.buttons.some(button => button.height < 43.5)) throw new Error(`Small touch target at ${width}: ${JSON.stringify(layout.buttons.filter(button => button.height < 43.5))}`)
|
||||
if (errors.length) throw new Error(errors.join('\n'))
|
||||
// Tap the label as a finger would, then verify the manager-confirmed value.
|
||||
await page.locator('label[for="codex-link-enabled"]').click()
|
||||
await page.waitForFunction(() => document.querySelector('#codex-link-enabled')?.getAttribute('aria-checked') === 'false')
|
||||
await page.getByRole('button', { name: 'Şimdi sıkıştır' }).scrollIntoViewIfNeeded()
|
||||
await page.screenshot({ path: path.join(output, `codex-link-${width}-history.png`), fullPage: true })
|
||||
evidence.push({ width, horizontal_overflow: false, touch_targets_passed: true, preference_update_passed: true, errors })
|
||||
await page.close()
|
||||
}
|
||||
await writeFile(path.join(output, 'layout-result.json'), JSON.stringify({ synthetic_fixture: true, real_manager: false, checks: evidence }, null, 2) + '\n')
|
||||
console.log(JSON.stringify({ passed: evidence.length, output }))
|
||||
} finally {
|
||||
await browser?.close()
|
||||
server.kill()
|
||||
await writeFile(path.join(output, 'preview-server.log'), log)
|
||||
}
|
||||
@@ -3,194 +3,191 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n/context'
|
||||
|
||||
import type {
|
||||
CodexLinkContextView,
|
||||
CodexLinkControlRequest,
|
||||
CodexLinkControlResponse,
|
||||
CodexLinkSettings as SavedSettings
|
||||
} from '../../../codex-link-control.types'
|
||||
import type { CodexLinkStatus } from '../../../electron/codex-link-types'
|
||||
|
||||
import { CodexLinkSettings } from './codex-link-settings'
|
||||
|
||||
let settings: SavedSettings
|
||||
let views: CodexLinkContextView[]
|
||||
let writes: CodexLinkControlRequest[]
|
||||
let failWrite: boolean
|
||||
const notifyError = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/store/notifications', () => ({ notifyError }))
|
||||
|
||||
const control = vi.fn(async (request: CodexLinkControlRequest): Promise<CodexLinkControlResponse> => {
|
||||
const base = {
|
||||
contract_version: 'codex-link/1' as const,
|
||||
request_id: request.request_id,
|
||||
operation_id: request.operation_id
|
||||
}
|
||||
const RUNNING: CodexLinkStatus = {
|
||||
enabled: true,
|
||||
codexFound: true,
|
||||
venvReady: true,
|
||||
foreign: false,
|
||||
bridgeRunning: true,
|
||||
ideDurum: 'tamam',
|
||||
ideModel: 12,
|
||||
ozet: true,
|
||||
lastError: null
|
||||
}
|
||||
|
||||
if (request.kind === 'status_get') {
|
||||
return {
|
||||
...base,
|
||||
kind: 'status_result',
|
||||
status: {
|
||||
settings_revision: settings.settings_revision,
|
||||
runtime_mode: 'managed',
|
||||
native_available: true,
|
||||
external_admission: 'open',
|
||||
needs_restart: false,
|
||||
catalog_state: 'fresh',
|
||||
active_turns: 0,
|
||||
uncertain_operations: 0,
|
||||
last_operation_id: null,
|
||||
error: null
|
||||
}
|
||||
}
|
||||
}
|
||||
const get = vi.fn<() => Promise<CodexLinkStatus>>()
|
||||
const set = vi.fn<(patch: { enabled?: boolean; sync?: boolean }) => Promise<CodexLinkStatus>>()
|
||||
|
||||
if (request.kind === 'context_views_get') {
|
||||
return { ...base, kind: 'context_views_result', settings_revision: settings.settings_revision, views }
|
||||
}
|
||||
|
||||
if (request.kind === 'settings_update') {
|
||||
writes.push(request)
|
||||
|
||||
if (failWrite) {
|
||||
throw new Error('outcome_unknown')
|
||||
}
|
||||
|
||||
settings = { ...settings, ...request.patch, settings_revision: settings.settings_revision + 1 }
|
||||
} else if (request.kind !== 'settings_get') {
|
||||
writes.push(request)
|
||||
|
||||
return {
|
||||
...base,
|
||||
kind: 'control_accepted',
|
||||
settings_revision: settings.settings_revision,
|
||||
operation_state: 'accepted',
|
||||
error: null
|
||||
}
|
||||
}
|
||||
|
||||
return { ...base, kind: 'settings_result', settings, available_sources: [] }
|
||||
})
|
||||
|
||||
const mount = () =>
|
||||
render(
|
||||
<I18nProvider configClient={null} initialLocale="tr">
|
||||
function renderPage(locale: 'en' | 'tr' = 'tr') {
|
||||
return render(
|
||||
<I18nProvider configClient={null} initialLocale={locale}>
|
||||
<CodexLinkSettings />
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function statusTexts() {
|
||||
return screen.getAllByRole('listitem').map(item => item.textContent)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
control.mockClear()
|
||||
writes = []
|
||||
views = []
|
||||
failWrite = false
|
||||
settings = {
|
||||
settings_revision: 7,
|
||||
enabled: false,
|
||||
compression_enabled: true,
|
||||
threshold_tokens: 250000,
|
||||
source_binding_handle: null,
|
||||
compact_route_id: 'Compact',
|
||||
legacy_intent_imported: true,
|
||||
ownership_manifest_revision: 2,
|
||||
activation_state: 'disabled'
|
||||
}
|
||||
vi.stubGlobal('hermesDesktop', { codexLink: { control } } satisfies Pick<NonNullable<typeof window.hermesDesktop>, 'codexLink'>)
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { codexLink: { get, set } }
|
||||
})
|
||||
|
||||
describe('Codex connection settings', () => {
|
||||
it('shows the persisted disabled preference, without turning it on during mount', async () => {
|
||||
mount()
|
||||
const toggle = await screen.findByRole('switch', { name: 'Modelleri Codex’e bağla' })
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('false')
|
||||
expect(writes).toEqual([])
|
||||
expect((screen.getByLabelText('Sıkıştırma eşiği (token)') as HTMLInputElement).value).toBe('250000')
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.resetAllMocks()
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
describe('Codex Link settings', () => {
|
||||
it('explains the feature and shows a healthy link in Turkish', async () => {
|
||||
get.mockResolvedValue(RUNNING)
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText('Köprü çalışıyor.')).toBeTruthy()
|
||||
expect(statusTexts()).toEqual(['Bu bilgisayarda Codex bulundu.', 'Köprü çalışıyor.', "Codex'e 12 model eklendi."])
|
||||
expect(
|
||||
screen.getByText(
|
||||
"AITURK IDE'deki modelleri ve comboları bu bilgisayardaki Codex'e ekler; modeller arasında geçerken sohbet bağlamını korur."
|
||||
)
|
||||
).toBeTruthy()
|
||||
expect(screen.getByText("Codex'te yeni modelleri görmek için Codex'i yeniden açın.")).toBeTruthy()
|
||||
expect(screen.getByRole('switch', { name: 'Codex Bağlama' }).getAttribute('aria-checked')).toBe('true')
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
it('uses a checkpoint-safe settings patch and rejects decimal/unsafe thresholds', async () => {
|
||||
mount()
|
||||
const input = await screen.findByLabelText('Sıkıştırma eşiği (token)')
|
||||
fireEvent.change(input, { target: { value: '1.5' } })
|
||||
expect((screen.getByRole('button', { name: 'Kaydet' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
fireEvent.change(input, { target: { value: '9007199254740992' } })
|
||||
expect((screen.getByRole('button', { name: 'Kaydet' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
fireEvent.change(input, { target: { value: '125000' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Kaydet' }))
|
||||
await waitFor(() => expect(writes).toHaveLength(1))
|
||||
expect(writes[0]).toMatchObject({
|
||||
kind: 'settings_update',
|
||||
expected_settings_revision: 7,
|
||||
patch: { threshold_tokens: 125000 }
|
||||
|
||||
it('renders in English for other locales', async () => {
|
||||
get.mockResolvedValue(RUNNING)
|
||||
renderPage('en')
|
||||
|
||||
expect(await screen.findByText('The bridge is running.')).toBeTruthy()
|
||||
expect(statusTexts()).toEqual([
|
||||
'Codex was found on this computer.',
|
||||
'The bridge is running.',
|
||||
'12 models added to Codex.'
|
||||
])
|
||||
expect(screen.getByRole('button', { name: 'Sync now' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('says only that Codex is missing when it is not installed', async () => {
|
||||
get.mockResolvedValue({ ...RUNNING, codexFound: false, bridgeRunning: false, ideDurum: null, ideModel: null })
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText('Bu bilgisayarda Codex bulunamadı.')).toBeTruthy()
|
||||
expect(statusTexts()).toEqual(['Bu bilgisayarda Codex bulunamadı.'])
|
||||
})
|
||||
|
||||
it('says another bridge is in charge and does not claim ours is running', async () => {
|
||||
get.mockResolvedValue({ ...RUNNING, foreign: true })
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText(/başka bir köprü kullanıyor/)).toBeTruthy()
|
||||
expect(statusTexts()).toEqual([
|
||||
'Bu bilgisayarda Codex bulundu.',
|
||||
'Bu bilgisayarda Codex başka bir köprü kullanıyor; ayarına dokunulmadı.'
|
||||
])
|
||||
})
|
||||
|
||||
it('says it is waiting for the IDE setup', async () => {
|
||||
get.mockResolvedValue({ ...RUNNING, venvReady: false, bridgeRunning: false, ideDurum: null, ideModel: null })
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText('IDE kurulumu tamamlanınca etkinleşecek.')).toBeTruthy()
|
||||
expect(screen.queryByText('Köprü çalışmıyor.')).toBeNull()
|
||||
})
|
||||
|
||||
it('asks for a sign-in when the bridge has no IDE session', async () => {
|
||||
get.mockResolvedValue({ ...RUNNING, ideDurum: 'anahtar-yok', ideModel: 0 })
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText('IDE oturumu bulunamadı, giriş yapın.')).toBeTruthy()
|
||||
expect(screen.queryByText(/model eklendi/)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a stopped bridge together with the last error', async () => {
|
||||
get.mockResolvedValue({
|
||||
...RUNNING,
|
||||
bridgeRunning: false,
|
||||
ideDurum: null,
|
||||
ideModel: null,
|
||||
lastError: 'bridge-start-failed'
|
||||
})
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText('Köprü çalışmıyor.')).toBeTruthy()
|
||||
expect(screen.getByText('Köprü başlatılamadı; Codex ayarına dokunulmadı.')).toBeTruthy()
|
||||
})
|
||||
it('keeps an uncertain operation across remount and reconciles the exact ID/body', async () => {
|
||||
failWrite = true
|
||||
const first = mount()
|
||||
fireEvent.click(await screen.findByRole('switch', { name: 'Modelleri Codex’e bağla' }))
|
||||
await screen.findByRole('button', { name: 'İşlemi kontrol et' })
|
||||
expect(writes).toHaveLength(1)
|
||||
const original = writes[0]
|
||||
first.unmount()
|
||||
mount()
|
||||
failWrite = false
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'İşlemi kontrol et' }))
|
||||
await waitFor(() => expect(writes).toHaveLength(2))
|
||||
expect(writes[1]).toEqual(original)
|
||||
await waitFor(() => expect(sessionStorage.getItem('aiturk.codex-link.pending-control.v1')).toBeNull())
|
||||
|
||||
it('turns the link off through the bridge and adopts the reply', async () => {
|
||||
get.mockResolvedValue(RUNNING)
|
||||
set.mockResolvedValue({ ...RUNNING, enabled: false, bridgeRunning: false, ideDurum: null, ideModel: null })
|
||||
renderPage()
|
||||
|
||||
await screen.findByText('Köprü çalışıyor.')
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Codex Bağlama' }))
|
||||
|
||||
expect(set).toHaveBeenCalledWith({ enabled: false })
|
||||
expect(await screen.findByText('Codex Bağlama kapalı. Codex kendi ayarlarıyla çalışıyor.')).toBeTruthy()
|
||||
expect(screen.getByRole('switch', { name: 'Codex Bağlama' }).getAttribute('aria-checked')).toBe('false')
|
||||
})
|
||||
it('does not present unknown token measurement as zero or enable unsafe compaction', async () => {
|
||||
views = [
|
||||
{
|
||||
view_handle: 'view-a',
|
||||
can_compact: false,
|
||||
label: 'Ayrı sohbet',
|
||||
checkpoint_revision: 3,
|
||||
current_model_label: 'Model A',
|
||||
limiting_target_id: null,
|
||||
target_budgets: null,
|
||||
active_summary_count: 2,
|
||||
summary_tokens: null,
|
||||
summary_token_measurement: null,
|
||||
last_operation_id: null,
|
||||
last_operation_state: null,
|
||||
last_operation_error: null,
|
||||
compact_attempts: []
|
||||
}
|
||||
]
|
||||
mount()
|
||||
await screen.findByText('Ayrı sohbet')
|
||||
expect(screen.getAllByText(/Henüz ölçülmedi/).length).toBeGreaterThanOrEqual(3)
|
||||
expect((screen.getByRole('button', { name: 'Şimdi sıkıştır' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
|
||||
it('rolls the switch back and reports when the change fails', async () => {
|
||||
get.mockResolvedValue(RUNNING)
|
||||
set.mockRejectedValue(new Error('codex-link:unavailable'))
|
||||
renderPage()
|
||||
|
||||
await screen.findByText('Köprü çalışıyor.')
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Codex Bağlama' }))
|
||||
|
||||
await waitFor(() => expect(notifyError).toHaveBeenCalledWith(expect.any(Error), 'Codex Bağlama değiştirilemedi.'))
|
||||
expect(screen.getByRole('switch', { name: 'Codex Bağlama' }).getAttribute('aria-checked')).toBe('true')
|
||||
expect(screen.getByText('Köprü çalışıyor.')).toBeTruthy()
|
||||
})
|
||||
it('binds manual compaction to a manager view and its exact checkpoint revision', async () => {
|
||||
views = [
|
||||
{
|
||||
view_handle: 'opaque-view-a',
|
||||
can_compact: true,
|
||||
label: 'Sohbet A',
|
||||
checkpoint_revision: 9,
|
||||
current_model_label: 'Model A',
|
||||
limiting_target_id: null,
|
||||
target_budgets: null,
|
||||
active_summary_count: 0,
|
||||
summary_tokens: null,
|
||||
summary_token_measurement: null,
|
||||
last_operation_id: null,
|
||||
last_operation_state: null,
|
||||
last_operation_error: null,
|
||||
compact_attempts: []
|
||||
}
|
||||
]
|
||||
mount()
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Şimdi sıkıştır' }))
|
||||
await waitFor(() => expect(writes).toHaveLength(1))
|
||||
expect(writes[0]).toMatchObject({
|
||||
kind: 'context_compact_view',
|
||||
view_handle: 'opaque-view-a',
|
||||
expected_checkpoint_revision: 9
|
||||
})
|
||||
expect(writes[0]).not.toHaveProperty('thread_id')
|
||||
|
||||
it('syncs on demand and keeps the button disabled while it runs', async () => {
|
||||
get.mockResolvedValue({ ...RUNNING, bridgeRunning: false, ideDurum: null, ideModel: null })
|
||||
|
||||
let finish: (status: CodexLinkStatus) => void = () => undefined
|
||||
|
||||
set.mockReturnValue(
|
||||
new Promise<CodexLinkStatus>(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
renderPage()
|
||||
|
||||
await screen.findByText('Köprü çalışmıyor.')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Şimdi eşitle' }))
|
||||
|
||||
expect(set).toHaveBeenCalledTimes(1)
|
||||
expect(set).toHaveBeenCalledWith({ sync: true })
|
||||
|
||||
const pending = screen.getByRole('button', { name: 'Eşitleniyor…' }) as HTMLButtonElement
|
||||
|
||||
expect(pending.disabled).toBe(true)
|
||||
fireEvent.click(pending)
|
||||
expect(set).toHaveBeenCalledTimes(1)
|
||||
|
||||
finish(RUNNING)
|
||||
|
||||
expect(await screen.findByText('Köprü çalışıyor.')).toBeTruthy()
|
||||
expect((screen.getByRole('button', { name: 'Şimdi eşitle' }) as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('offers nothing to click where the desktop bridge is absent', () => {
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
renderPage()
|
||||
|
||||
expect(screen.getByText('Codex Bağlama yalnızca Windows masaüstü uygulamasında kullanılabilir.')).toBeTruthy()
|
||||
expect(screen.queryByRole('switch')).toBeNull()
|
||||
expect(get).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,471 +1,233 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useCodexLinkCopy } from '@/i18n/codex-link'
|
||||
import { Cpu } from '@/lib/icons'
|
||||
import { type CodexLinkCopy, useCodexLinkCopy } from '@/i18n/codex-link'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AlertTriangle, Check, CircleIcon, Link, RefreshCw } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { validateControlRequest } from '../../../codex-link-control'
|
||||
import type {
|
||||
CodexLinkContextView,
|
||||
CodexLinkControlRequest,
|
||||
CodexLinkControlResponse,
|
||||
CodexLinkSettingsResult,
|
||||
CodexLinkSettingsUpdate,
|
||||
CodexLinkStatusSnapshot
|
||||
} from '../../../codex-link-control.types'
|
||||
import type { CodexLinkPatch, CodexLinkStatus } from '../../../electron/codex-link-types'
|
||||
|
||||
import { SettingsContent, SettingsSection } from './primitives'
|
||||
import { EmptyState, ListRow, ListRowSkeleton, SettingsContent, SettingsSection } from './primitives'
|
||||
|
||||
const PENDING_KEY = 'aiturk.codex-link.pending-control.v1'
|
||||
type Tone = 'muted' | 'ok' | 'warn'
|
||||
|
||||
function envelope() {
|
||||
return {
|
||||
contract_version: 'codex-link/1' as const,
|
||||
request_id: crypto.randomUUID(),
|
||||
operation_id: crypto.randomUUID()
|
||||
}
|
||||
}
|
||||
|
||||
function readPending(): CodexLinkControlRequest | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(PENDING_KEY)
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
const value: unknown = JSON.parse(raw)
|
||||
validateControlRequest(value)
|
||||
|
||||
return ['settings_update', 'catalog_sync', 'context_compact_view'].includes(value.kind) ? value : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function savedPending(value: CodexLinkControlRequest | null) {
|
||||
if (value) {
|
||||
sessionStorage.setItem(PENDING_KEY, JSON.stringify(value))
|
||||
} else {
|
||||
sessionStorage.removeItem(PENDING_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
function Preference({
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
disabled,
|
||||
onChange
|
||||
}: {
|
||||
interface StatusLine {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
disabled: boolean
|
||||
onChange: (value: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="flex min-h-11 cursor-pointer items-center gap-4 py-3" htmlFor={id}>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium">{label}</span>
|
||||
<span className="mt-1 block text-sm text-muted-foreground">{description}</span>
|
||||
</span>
|
||||
<Switch aria-label={label} checked={checked} disabled={disabled} id={id} onCheckedChange={onChange} />
|
||||
</label>
|
||||
)
|
||||
text: string
|
||||
tone: Tone
|
||||
}
|
||||
|
||||
/**
|
||||
* What the user needs to know, top-down, stopping at the first fact that makes
|
||||
* the rest moot (no Codex → nothing else to say; another bridge → ours is not
|
||||
* involved; setup pending → the bridge cannot be running yet).
|
||||
*/
|
||||
export function codexLinkStatusLines(status: CodexLinkStatus, copy: CodexLinkCopy): StatusLine[] {
|
||||
const lines: StatusLine[] = []
|
||||
const error = status.lastError ? [{ id: 'error', text: copy.errors[status.lastError], tone: 'warn' as const }] : []
|
||||
|
||||
if (!status.codexFound) {
|
||||
return [{ id: 'codex', text: copy.codexMissing, tone: 'muted' }, ...error]
|
||||
}
|
||||
|
||||
lines.push({ id: 'codex', text: copy.codexFound, tone: 'ok' })
|
||||
|
||||
if (status.foreign) {
|
||||
return [...lines, { id: 'foreign', text: copy.foreign, tone: 'warn' }, ...error]
|
||||
}
|
||||
|
||||
if (!status.enabled) {
|
||||
return [...lines, { id: 'off', text: copy.off, tone: 'muted' }, ...error]
|
||||
}
|
||||
|
||||
if (!status.venvReady) {
|
||||
return [...lines, { id: 'venv', text: copy.venvPending, tone: 'muted' }, ...error]
|
||||
}
|
||||
|
||||
if (!status.bridgeRunning) {
|
||||
return [...lines, { id: 'bridge', text: copy.bridgeStopped, tone: 'warn' }, ...error]
|
||||
}
|
||||
|
||||
lines.push({ id: 'bridge', text: copy.bridgeRunning, tone: 'ok' })
|
||||
|
||||
if (status.ideDurum === 'anahtar-yok') {
|
||||
lines.push({ id: 'ide', text: copy.signIn, tone: 'warn' })
|
||||
} else if (status.ideDurum === 'tamam' && status.ideModel !== null) {
|
||||
lines.push({ id: 'ide', text: copy.models(status.ideModel), tone: 'ok' })
|
||||
}
|
||||
|
||||
return [...lines, ...error]
|
||||
}
|
||||
|
||||
const TONE_ICON = { muted: CircleIcon, ok: Check, warn: AlertTriangle } as const
|
||||
|
||||
const TONE_CLASS: Record<Tone, string> = {
|
||||
muted: 'text-(--ui-text-tertiary)',
|
||||
ok: 'text-(--ui-text-secondary)',
|
||||
warn: 'text-amber-500/90'
|
||||
}
|
||||
|
||||
// Phone-sized hit area around the compact switch: the visible control keeps the
|
||||
// design-system size, the label that wraps it is the 44px target.
|
||||
const TOUCH_TARGET = 'flex min-h-11 min-w-11 cursor-pointer items-center @2xl:justify-end'
|
||||
|
||||
/**
|
||||
* Settings → Codex Link. Main owns everything real (the preference file, the
|
||||
* bridge, Codex's config, the login item); this page reads the state it reports
|
||||
* and asks for changes, adopting whatever comes back as the truth.
|
||||
*/
|
||||
export function CodexLinkSettings() {
|
||||
const copy = useCodexLinkCopy()
|
||||
const [settings, setSettings] = useState<CodexLinkSettingsResult | null>(null)
|
||||
const [status, setStatus] = useState<CodexLinkStatusSnapshot | null>(null)
|
||||
const [views, setViews] = useState<CodexLinkContextView[]>([])
|
||||
const [threshold, setThreshold] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [pending, setPending] = useState(readPending)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const mounted = useRef(true)
|
||||
const writeLock = useRef(false)
|
||||
const readLock = useRef(false)
|
||||
const bridge = typeof window === 'undefined' ? undefined : window.hermesDesktop?.codexLink
|
||||
const [status, setStatus] = useState<CodexLinkStatus | null>(null)
|
||||
const [loadFailed, setLoadFailed] = useState(false)
|
||||
const [busy, setBusy] = useState<'sync' | 'toggle' | null>(null)
|
||||
// A slow initial read must not overwrite the answer to a newer change.
|
||||
const generation = useRef(0)
|
||||
const savedRevision = useRef(-1)
|
||||
const bridge = window.hermesDesktop?.codexLink
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!bridge || readLock.current || writeLock.current) {
|
||||
return
|
||||
}
|
||||
|
||||
readLock.current = true
|
||||
const current = generation.current
|
||||
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
['settings_get', 'status_get', 'context_views_get'].map(kind =>
|
||||
bridge.control({ ...envelope(), kind } as CodexLinkControlRequest)
|
||||
)
|
||||
)
|
||||
|
||||
if (!mounted.current || current !== generation.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const [saved, live, chats] = results
|
||||
|
||||
if (saved.kind !== 'settings_result' || live.kind !== 'status_result' || chats.kind !== 'context_views_result') {
|
||||
throw new Error('read_failed')
|
||||
}
|
||||
|
||||
// A write in another window may fall between these three reads. Never paint a mixed revision.
|
||||
if (
|
||||
saved.settings.settings_revision !== live.status.settings_revision ||
|
||||
saved.settings.settings_revision !== chats.settings_revision
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (saved.settings.settings_revision < savedRevision.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (savedRevision.current !== saved.settings.settings_revision) {
|
||||
setThreshold(String(saved.settings.threshold_tokens))
|
||||
}
|
||||
|
||||
savedRevision.current = saved.settings.settings_revision
|
||||
setSettings(saved)
|
||||
setStatus(live.status)
|
||||
setViews(chats.views)
|
||||
setError('')
|
||||
} catch {
|
||||
if (mounted.current && current === generation.current) {
|
||||
setError(copy.failed)
|
||||
}
|
||||
} finally {
|
||||
readLock.current = false
|
||||
}
|
||||
}, [bridge, copy.failed])
|
||||
|
||||
// This is a component lifetime flag, not a mirror of a reactive atom/value.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
void refresh()
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void refresh()
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
return () => {
|
||||
mounted.current = false
|
||||
// The current numeric generation is invalidated; it is not a captured DOM ref.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
generation.current++
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
const send = async (request: CodexLinkControlRequest) => {
|
||||
if (!bridge || writeLock.current) {
|
||||
if (!bridge) {
|
||||
return
|
||||
}
|
||||
|
||||
writeLock.current = true
|
||||
generation.current++
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setNotice('')
|
||||
const ticket = generation.current
|
||||
|
||||
// Persist the exact operation before dispatch: an uncertain reply must reuse this ID and body.
|
||||
try {
|
||||
savedPending(request)
|
||||
setPending(request)
|
||||
} catch {
|
||||
writeLock.current = false
|
||||
setBusy(false)
|
||||
setError(copy.failed)
|
||||
bridge.get().then(
|
||||
next => {
|
||||
if (generation.current === ticket) {
|
||||
setStatus(next)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (generation.current === ticket) {
|
||||
setLoadFailed(true)
|
||||
}
|
||||
}
|
||||
)
|
||||
}, [bridge])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result: CodexLinkControlResponse = await bridge.control(request)
|
||||
|
||||
if (!mounted.current) {
|
||||
const change = useCallback(
|
||||
async (patch: CodexLinkPatch, kind: 'sync' | 'toggle') => {
|
||||
if (!bridge || busy) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
(result.kind === 'control_error' && result.error.recovery_action === 'reconcile_only') ||
|
||||
(result.kind === 'control_accepted' && result.operation_state === 'indeterminate')
|
||||
) {
|
||||
setError(copy.unknown)
|
||||
const before = status
|
||||
generation.current += 1
|
||||
setBusy(kind)
|
||||
setLoadFailed(false)
|
||||
|
||||
return
|
||||
// The switch moves at once; main's reply (or the rollback) has the last word.
|
||||
if (typeof patch.enabled === 'boolean' && before) {
|
||||
setStatus({ ...before, enabled: patch.enabled })
|
||||
}
|
||||
|
||||
savedPending(null)
|
||||
setPending(null)
|
||||
try {
|
||||
setStatus(await bridge.set(patch))
|
||||
} catch (error) {
|
||||
setStatus(before)
|
||||
notifyError(error, copy.saveFailed)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
},
|
||||
[bridge, busy, copy.saveFailed, status]
|
||||
)
|
||||
|
||||
if (result.kind === 'control_error') {
|
||||
setNotice(
|
||||
result.error.code.includes('conflict') || result.error.code.includes('cas')
|
||||
? copy.conflict
|
||||
: result.error.message
|
||||
)
|
||||
} else if (result.kind === 'settings_result') {
|
||||
savedRevision.current = result.settings.settings_revision
|
||||
setSettings(result)
|
||||
setThreshold(String(result.settings.threshold_tokens))
|
||||
setNotice(copy.saved)
|
||||
} else if (result.kind === 'control_accepted') {
|
||||
setNotice(result.error?.message ?? copy.accepted)
|
||||
}
|
||||
} catch {
|
||||
if (mounted.current) {
|
||||
setError(copy.unknown)
|
||||
}
|
||||
} finally {
|
||||
writeLock.current = false
|
||||
|
||||
if (mounted.current) {
|
||||
setBusy(false)
|
||||
void refresh()
|
||||
}
|
||||
}
|
||||
if (!bridge) {
|
||||
return (
|
||||
<SettingsContent>
|
||||
<EmptyState description={copy.unavailable} title={copy.title} />
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
const update = (patch: CodexLinkSettingsUpdate['patch']) => {
|
||||
if (settings && !pending) {
|
||||
void send({
|
||||
...envelope(),
|
||||
kind: 'settings_update',
|
||||
expected_settings_revision: settings.settings.settings_revision,
|
||||
patch
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const thresholdValue = /^[0-9]+$/.test(threshold) ? Number(threshold) : NaN
|
||||
const validThreshold = Number.isSafeInteger(thresholdValue) && thresholdValue > 0
|
||||
const locked = busy || pending !== null
|
||||
const number = (n: number | null) => (n === null ? copy.unmeasured : n.toLocaleString())
|
||||
const lines = status ? codexLinkStatusLines(status, copy) : []
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="min-w-0 py-4" data-testid="codex-link-settings">
|
||||
<SettingsSection icon={Cpu} title={copy.title}>
|
||||
<p className="mb-4 text-sm text-muted-foreground">{copy.intro}</p>
|
||||
{!bridge ? (
|
||||
<p role="alert">{copy.unavailable}</p>
|
||||
) : !settings ? (
|
||||
<p role="status">{error || copy.loading}</p>
|
||||
) : (
|
||||
<>
|
||||
<Preference
|
||||
checked={settings.settings.enabled}
|
||||
description={copy.enabledHelp}
|
||||
disabled={locked}
|
||||
id="codex-link-enabled"
|
||||
label={copy.enabled}
|
||||
onChange={enabled => update({ enabled })}
|
||||
<SettingsSection icon={Link} title={copy.title}>
|
||||
<ListRow
|
||||
action={
|
||||
<label className={TOUCH_TARGET}>
|
||||
<Switch
|
||||
aria-label={copy.title}
|
||||
checked={status?.enabled ?? true}
|
||||
disabled={!status || busy !== null}
|
||||
onCheckedChange={enabled => {
|
||||
triggerHaptic('selection')
|
||||
void change({ enabled }, 'toggle')
|
||||
}}
|
||||
/>
|
||||
<Preference
|
||||
checked={settings.settings.compression_enabled}
|
||||
description={copy.compressionHelp}
|
||||
disabled={locked}
|
||||
id="codex-link-compression"
|
||||
label={copy.compression}
|
||||
onChange={compression_enabled => update({ compression_enabled })}
|
||||
/>
|
||||
<div className="my-4 space-y-2">
|
||||
<label className="block text-sm font-medium" htmlFor="codex-link-threshold">
|
||||
{copy.threshold}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Input
|
||||
aria-describedby="codex-link-threshold-help"
|
||||
aria-invalid={!validThreshold}
|
||||
className="min-h-11 w-full min-w-0 sm:w-48"
|
||||
disabled={locked}
|
||||
id="codex-link-threshold"
|
||||
inputMode="numeric"
|
||||
onChange={event => setThreshold(event.target.value)}
|
||||
value={threshold}
|
||||
/>
|
||||
<Button
|
||||
className="min-h-11"
|
||||
disabled={locked || !validThreshold || thresholdValue === settings.settings.threshold_tokens}
|
||||
onClick={() => update({ threshold_tokens: thresholdValue })}
|
||||
>
|
||||
{copy.save}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground" id="codex-link-threshold-help">
|
||||
{validThreshold ? copy.thresholdHelp : copy.invalidThreshold}
|
||||
</p>
|
||||
</div>
|
||||
<div className="my-4 space-y-2">
|
||||
<label className="block text-sm font-medium" htmlFor="codex-link-source">
|
||||
{copy.source}
|
||||
</label>
|
||||
{settings.available_sources.length ? (
|
||||
<select
|
||||
className="min-h-11 w-full min-w-0 rounded-md border bg-background px-3 text-sm"
|
||||
disabled={locked}
|
||||
id="codex-link-source"
|
||||
onChange={event => update({ source_binding_handle: event.target.value })}
|
||||
value={settings.settings.source_binding_handle ?? ''}
|
||||
>
|
||||
{!settings.settings.source_binding_handle && (
|
||||
<option disabled value="">
|
||||
{copy.noSource}
|
||||
</option>
|
||||
)}
|
||||
{settings.available_sources.map(source => (
|
||||
<option
|
||||
disabled={!source.authorized}
|
||||
key={source.source_binding_handle}
|
||||
value={source.source_binding_handle}
|
||||
>
|
||||
{source.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{copy.noSource}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm" role="status">
|
||||
{copy.activation[settings.settings.activation_state]}
|
||||
{status ? ` · ${copy.admission[status.external_admission]}` : ''}
|
||||
</p>
|
||||
{status?.needs_restart && <p className="mt-2 text-sm text-muted-foreground">{copy.restart}</p>}
|
||||
{status?.error && (
|
||||
<p className="mt-2 text-sm" role="alert">
|
||||
{status.error.message}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
className="min-h-11"
|
||||
disabled={locked}
|
||||
onClick={() => void send({ ...envelope(), kind: 'catalog_sync' })}
|
||||
variant="outline"
|
||||
>
|
||||
{copy.sync}
|
||||
</Button>
|
||||
<Button className="min-h-11" disabled={busy} onClick={() => void refresh()} variant="outline">
|
||||
{copy.refresh}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{pending && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-sm" role="alert">
|
||||
{copy.unknown}
|
||||
</p>
|
||||
<Button className="min-h-11" disabled={busy} onClick={() => void send(pending)}>
|
||||
{copy.reconcile}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{error && settings && (
|
||||
<p className="mt-3 text-sm" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{notice && (
|
||||
<p className="mt-3 text-sm" role="status">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
</SettingsSection>
|
||||
{settings && (
|
||||
<SettingsSection icon={Cpu} title={copy.chats}>
|
||||
{!views.length && <p className="text-sm text-muted-foreground">{copy.empty}</p>}
|
||||
{views.map(view => {
|
||||
const target = view.target_budgets?.find(item => item.target_id === view.limiting_target_id)
|
||||
</label>
|
||||
}
|
||||
description={copy.description}
|
||||
title={copy.title}
|
||||
/>
|
||||
|
||||
const hardLimit = target
|
||||
? Math.min(
|
||||
target.max_input_tokens ?? Number.MAX_SAFE_INTEGER,
|
||||
target.context_window_tokens - target.output_cap_tokens - target.safety_margin_tokens
|
||||
)
|
||||
: null
|
||||
{!status && !loadFailed && <ListRowSkeleton wide />}
|
||||
|
||||
const limit =
|
||||
hardLimit === null
|
||||
? null
|
||||
: settings.settings.compression_enabled
|
||||
? Math.min(settings.settings.threshold_tokens, hardLimit)
|
||||
: hardLimit
|
||||
|
||||
return (
|
||||
<article className="min-w-0 border-b py-4 last:border-b-0" key={view.view_handle}>
|
||||
<h3 className="break-words text-sm font-medium">{view.label}</h3>
|
||||
<p className="break-words text-sm text-muted-foreground">{view.current_model_label}</p>
|
||||
<dl className="my-3 grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-2 text-sm">
|
||||
<dt>{copy.current}</dt>
|
||||
<dd className="break-words">
|
||||
{number(target?.input_tokens ?? null)}
|
||||
{target?.count_method === 'provider_estimate' ? ` · ${copy.estimated}` : ''}
|
||||
{target?.count_method === 'verified_upper_bound' ? ` · ${copy.upperBound}` : ''}
|
||||
</dd>
|
||||
<dt>{copy.limit}</dt>
|
||||
<dd className="break-words">{number(limit)}</dd>
|
||||
<dt>{copy.summaries}</dt>
|
||||
<dd>
|
||||
{view.active_summary_count} · {number(view.summary_tokens)}{' '}
|
||||
{view.summary_tokens !== null ? copy.tokens : ''}
|
||||
{view.summary_token_measurement?.count_method === 'provider_estimate' ? ` · ${copy.estimated}` : ''}
|
||||
{view.summary_token_measurement?.count_method === 'verified_upper_bound' ? ` · ${copy.upperBound}` : ''}
|
||||
</dd>
|
||||
</dl>
|
||||
{view.last_operation_state && (
|
||||
<p className="my-2 text-sm" role="status">
|
||||
{copy.operation[view.last_operation_state]}
|
||||
</p>
|
||||
)}
|
||||
{view.last_operation_error && (
|
||||
<p className="my-2 text-sm" role="alert">
|
||||
{view.last_operation_error.message}
|
||||
</p>
|
||||
)}
|
||||
{view.compact_attempts.map(attempt => (
|
||||
<p className="break-words text-sm text-muted-foreground" key={attempt.attempt_id}>
|
||||
{attempt.member_label} · {copy.operation[attempt.state]}
|
||||
</p>
|
||||
))}
|
||||
<Button
|
||||
className="mt-2 min-h-11"
|
||||
disabled={locked || !view.can_compact}
|
||||
onClick={() =>
|
||||
void send({
|
||||
...envelope(),
|
||||
kind: 'context_compact_view',
|
||||
view_handle: view.view_handle,
|
||||
expected_checkpoint_revision: view.checkpoint_revision
|
||||
})
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
{copy.compact}
|
||||
</Button>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</SettingsSection>
|
||||
{loadFailed && !status && (
|
||||
<p className="py-3 text-[length:var(--conversation-caption-font-size)] text-amber-500/90" role="alert">
|
||||
{copy.loadFailed}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<ListRow
|
||||
below={
|
||||
<ul aria-live="polite" className="mt-1.5 grid gap-1.5">
|
||||
{lines.map(line => {
|
||||
const Icon = TONE_ICON[line.tone]
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
'flex min-w-0 items-start gap-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height)',
|
||||
TONE_CLASS[line.tone]
|
||||
)}
|
||||
data-tone={line.tone}
|
||||
key={line.id}
|
||||
>
|
||||
<Icon aria-hidden className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span className="min-w-0 break-words">{line.text}</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
}
|
||||
title={copy.statusTitle}
|
||||
wide
|
||||
/>
|
||||
)}
|
||||
|
||||
<ListRow
|
||||
action={
|
||||
<Button
|
||||
className="min-h-11 w-full @2xl:w-auto"
|
||||
disabled={!status || busy !== null}
|
||||
onClick={() => {
|
||||
triggerHaptic('open')
|
||||
void change({ sync: true }, 'sync')
|
||||
}}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
<RefreshCw />
|
||||
{busy === 'sync' ? copy.syncing : copy.sync}
|
||||
</Button>
|
||||
}
|
||||
description={copy.syncDesc}
|
||||
title={copy.syncTitle}
|
||||
/>
|
||||
|
||||
<p className="pt-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{copy.restartNote}
|
||||
</p>
|
||||
</SettingsSection>
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Info,
|
||||
Keyboard,
|
||||
KeyRound,
|
||||
Link,
|
||||
Package,
|
||||
RefreshCw,
|
||||
Search,
|
||||
@@ -47,8 +48,8 @@ import { SKILLS_ROUTE } from '../routes'
|
||||
import { AboutSettings } from './about-settings'
|
||||
import { AppearanceSettings } from './appearance-settings'
|
||||
import { BillingSettings } from './billing'
|
||||
import { ConfigSettings } from './config-settings'
|
||||
import { CodexLinkSettings } from './codex-link-settings'
|
||||
import { ConfigSettings } from './config-settings'
|
||||
import { SECTIONS } from './constants'
|
||||
import { GatewaySettings } from './gateway-settings'
|
||||
import { KeybindSettings } from './keybind-settings'
|
||||
@@ -63,7 +64,6 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
...SECTIONS.map(s => `config:${s.id}` as SettingsViewId),
|
||||
'providers',
|
||||
'gateway',
|
||||
'codex-link',
|
||||
// Legacy alias: the Connections page merged into Gateways. Kept in the enum
|
||||
// so saved `?tab=connections` deep links still resolve (redirected below).
|
||||
'connections',
|
||||
@@ -71,6 +71,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
'keys',
|
||||
'notifications',
|
||||
'billing',
|
||||
'codex-link',
|
||||
'plugins',
|
||||
'sessions',
|
||||
'about'
|
||||
@@ -256,13 +257,6 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
||||
label: t.settings.nav.gateway,
|
||||
onSelect: () => setActiveView('gateway')
|
||||
},
|
||||
{
|
||||
active: activeView === 'codex-link',
|
||||
icon: Cpu,
|
||||
id: 'codex-link',
|
||||
label: codexLinkCopy.title,
|
||||
onSelect: () => setActiveView('codex-link')
|
||||
},
|
||||
// "Telefon bağlantısı" lives in its own small window (QR + code); this
|
||||
// entry only opens it. Present only where the desktop bridge offers it.
|
||||
...(window.hermesDesktop?.aiturkMobile
|
||||
@@ -278,6 +272,19 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
||||
}
|
||||
]
|
||||
: []),
|
||||
// "Codex Bağlama": present only where the desktop bridge offers it
|
||||
// (Windows). A stale ?tab=codex-link elsewhere renders its own notice.
|
||||
...(window.hermesDesktop?.codexLink
|
||||
? [
|
||||
{
|
||||
active: activeView === 'codex-link',
|
||||
icon: Link,
|
||||
id: 'codex-link',
|
||||
label: codexLinkCopy.nav,
|
||||
onSelect: () => setActiveView('codex-link')
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
active: activeView === 'keybinds',
|
||||
icon: Keyboard,
|
||||
@@ -425,8 +432,6 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
||||
// 'connections' renders the unified page too so the frame before
|
||||
// the alias redirect lands doesn't flash the fallback view.
|
||||
<GatewaySettings />
|
||||
) : activeView === 'codex-link' ? (
|
||||
<CodexLinkSettings />
|
||||
) : activeView === 'keybinds' ? (
|
||||
<KeybindSettings />
|
||||
) : activeView.startsWith('config:') ? (
|
||||
@@ -452,6 +457,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
||||
<BillingSettings />
|
||||
) : activeView === 'plugins' ? (
|
||||
<PluginsSettings />
|
||||
) : activeView === 'codex-link' ? (
|
||||
<CodexLinkSettings />
|
||||
) : (
|
||||
<SessionsSettings />
|
||||
)
|
||||
|
||||
@@ -7,8 +7,8 @@ import type { EnvVarInfo } from '@/types/hermes'
|
||||
export type SettingsView =
|
||||
| 'about'
|
||||
| 'billing'
|
||||
| 'connections'
|
||||
| 'codex-link'
|
||||
| 'connections'
|
||||
| 'gateway'
|
||||
| 'keybinds'
|
||||
| 'keys'
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('ShareControls', () => {
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
const input = within(dialog).getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'not a valid code !!!' } })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Yükle' }))
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Yükle', exact: true }))
|
||||
expect(within(dialog).getByText('Bu metin bir harita koduna benzemiyor.')).toBeTruthy()
|
||||
expect((input as HTMLTextAreaElement).value).toBe('not a valid code !!!')
|
||||
})
|
||||
|
||||
Vendored
+8
-2
@@ -1,8 +1,8 @@
|
||||
import type { GatewayWsUrlResult } from '@hermes/shared'
|
||||
import type { TranslucencyState } from '@hermes/shared/translucency'
|
||||
|
||||
import type { CodexLinkPatch, CodexLinkStatus } from '../electron/codex-link-types'
|
||||
import type { PoolLimits } from '../electron/pool-limits'
|
||||
import type { CodexLinkControlRequest, CodexLinkControlResponse } from '../codex-link-control.types'
|
||||
|
||||
import type { WakeIndicatorState } from './lib/wake-indicator'
|
||||
import type {
|
||||
@@ -18,7 +18,6 @@ export {}
|
||||
declare global {
|
||||
interface Window {
|
||||
hermesDesktop: {
|
||||
codexLink?: { control: (request: CodexLinkControlRequest) => Promise<CodexLinkControlResponse> }
|
||||
setUiLanguage?: (locale: string) => void
|
||||
// Resolve a backend connection. Omit `profile` (or pass the primary) for
|
||||
// the window's backend; pass a named profile to lazily spawn/reuse that
|
||||
@@ -337,6 +336,13 @@ declare global {
|
||||
aiturkMobile?: {
|
||||
open: () => Promise<{ ok: true }>
|
||||
}
|
||||
/** "Codex Bağlama" (electron/codex-link.ts): lets the Codex on this machine
|
||||
* use the IDE's models. `get` only reads; `set` applies and returns the
|
||||
* state that actually resulted. Windows only. */
|
||||
codexLink?: {
|
||||
get: () => Promise<CodexLinkStatus>
|
||||
set: (patch: CodexLinkPatch) => Promise<CodexLinkStatus>
|
||||
}
|
||||
/** One-shot loopback callback listener for MCP OAuth against remote
|
||||
* backends (electron/mcp-oauth-callback-ipc.ts): bind on THIS machine,
|
||||
* pass redirectUri as client_redirect_uri to mcp.servers.oauth.start,
|
||||
|
||||
@@ -1,125 +1,80 @@
|
||||
import type { CodexLinkError } from '../../electron/codex-link-types'
|
||||
|
||||
import { useI18n } from './context'
|
||||
|
||||
// Copy for Settings → Codex Link ("Codex Bağlama"). Kept beside the other
|
||||
// AITURK-distribution strings (see ./aiturk): Turkish for `tr`, English for
|
||||
// every other locale.
|
||||
const en = {
|
||||
title: 'Connect Codex',
|
||||
intro: 'Use this IDE’s models and combos in Codex. Each chat keeps its own memory.',
|
||||
enabled: 'Connect models to Codex',
|
||||
enabledHelp: 'Enabled on first installation. Your choice is saved.',
|
||||
compression: 'Automatic compaction',
|
||||
compressionHelp:
|
||||
'Checks the complete request before sending. Earlier summaries are retained as the available context allows.',
|
||||
threshold: 'Compaction threshold (tokens)',
|
||||
thresholdHelp:
|
||||
'The effective threshold is lower when a model has less available input space. Output space is reserved too.',
|
||||
invalidThreshold: 'Enter a positive whole number.',
|
||||
save: 'Save',
|
||||
source: 'Connected account',
|
||||
noSource: 'No authorized model source is available.',
|
||||
sync: 'Refresh models',
|
||||
refresh: 'Refresh status',
|
||||
loading: 'Reading saved settings…',
|
||||
unavailable: 'The local Codex connection service is unavailable. Check the AITURK installation.',
|
||||
failed: 'Could not read the latest state.',
|
||||
unknown: 'The result is not confirmed. Check this same operation before making another change.',
|
||||
reconcile: 'Check operation',
|
||||
conflict: 'Settings changed elsewhere. The latest saved state has been loaded.',
|
||||
saved: 'Settings saved.',
|
||||
accepted: 'Operation accepted. Its progress appears below.',
|
||||
chats: 'Chat memory',
|
||||
empty: 'No managed Codex chat is currently visible.',
|
||||
compact: 'Compact now',
|
||||
summaries: 'Saved summaries',
|
||||
tokens: 'tokens',
|
||||
unmeasured: 'Not measured yet',
|
||||
current: 'Current input',
|
||||
estimated: 'Estimate',
|
||||
upperBound: 'Verified upper bound',
|
||||
limit: 'Effective threshold',
|
||||
restart: 'The next Codex launch will use the new runtime. Existing chats keep their current process.',
|
||||
activation: {
|
||||
disabled: 'Disabled',
|
||||
pending_runtime_acceptance: 'Runtime verification pending',
|
||||
ready: 'Ready',
|
||||
restart_required: 'Restart required',
|
||||
conflict: 'Configuration conflict',
|
||||
recovery_pending: 'Recovering the saved state'
|
||||
},
|
||||
admission: {
|
||||
open: 'External models available',
|
||||
closed: 'External models unavailable',
|
||||
draining: 'Finishing active work',
|
||||
recovery_required: 'Operation recovery required'
|
||||
},
|
||||
operation: {
|
||||
accepted: 'Accepted',
|
||||
prepared: 'Prepared',
|
||||
running: 'Running',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
indeterminate: 'Result unknown'
|
||||
}
|
||||
nav: 'Codex Link',
|
||||
title: 'Codex Link',
|
||||
description:
|
||||
'Adds the models and combos from AITURK IDE to the Codex on this computer and keeps the conversation context when you switch between models.',
|
||||
unavailable: 'Codex Link is only available in the Windows desktop app.',
|
||||
loadFailed: 'Could not read the Codex Link status.',
|
||||
saveFailed: 'Could not change Codex Link.',
|
||||
statusTitle: 'Status',
|
||||
off: 'Codex Link is off. Codex runs with its own settings.',
|
||||
codexFound: 'Codex was found on this computer.',
|
||||
codexMissing: 'Codex was not found on this computer.',
|
||||
foreign: 'Codex on this computer uses another bridge; its settings were not touched.',
|
||||
venvPending: 'It will turn on once the IDE setup is complete.',
|
||||
bridgeRunning: 'The bridge is running.',
|
||||
bridgeStopped: 'The bridge is not running.',
|
||||
models: (count: number) => (count === 1 ? '1 model added to Codex.' : `${count} models added to Codex.`),
|
||||
signIn: 'No IDE session was found; sign in.',
|
||||
errors: {
|
||||
'autostart-failed': 'Could not set the bridge to start with Windows.',
|
||||
'bridge-config-failed': 'Could not write the bridge settings.',
|
||||
'bridge-missing': 'The bridge files are missing from this installation.',
|
||||
'bridge-start-failed': 'The bridge could not be started; the Codex settings were left unchanged.',
|
||||
'config-write-failed': 'Could not write the Codex settings file.',
|
||||
'settings-write-failed': 'Could not save this setting.'
|
||||
} satisfies Record<CodexLinkError, string>,
|
||||
syncTitle: 'Sync now',
|
||||
syncDesc: 'Checks the bridge and the Codex settings again.',
|
||||
sync: 'Sync now',
|
||||
syncing: 'Syncing…',
|
||||
restartNote: 'Reopen Codex to see new models there.'
|
||||
}
|
||||
|
||||
const tr: typeof en = {
|
||||
nav: 'Codex Bağlama',
|
||||
title: 'Codex Bağlama',
|
||||
intro: 'Bu IDE’deki modelleri ve comboları Codex’te kullanın. Her sohbet kendi hafızasını tutar.',
|
||||
enabled: 'Modelleri Codex’e bağla',
|
||||
enabledHelp: 'İlk kurulumda açıktır. Değiştirdiğiniz tercih kaydedilir.',
|
||||
compression: 'Otomatik sıkıştırma',
|
||||
compressionHelp:
|
||||
'Gönderilmeden önce isteğin tamamı kontrol edilir. Önceki özetler kullanılabilir bağlamın izin verdiği ölçüde korunur.',
|
||||
threshold: 'Sıkıştırma eşiği (token)',
|
||||
thresholdHelp:
|
||||
'Modelin kullanılabilir giriş alanı daha küçükse etkin eşik düşer. Yanıt için gereken alan da ayrılır.',
|
||||
invalidThreshold: 'Pozitif bir tam sayı girin.',
|
||||
save: 'Kaydet',
|
||||
source: 'Bağlı hesap',
|
||||
noSource: 'Yetkili model kaynağı bulunamadı.',
|
||||
sync: 'Modelleri yenile',
|
||||
refresh: 'Durumu yenile',
|
||||
loading: 'Kayıtlı ayarlar okunuyor…',
|
||||
unavailable: 'Yerel Codex bağlantı hizmetine ulaşılamıyor. AITURK kurulumunu kontrol edin.',
|
||||
failed: 'Güncel durum okunamadı.',
|
||||
unknown: 'İşlemin sonucu henüz doğrulanmadı. Yeni değişiklikten önce aynı işlemin durumunu kontrol edin.',
|
||||
reconcile: 'İşlemi kontrol et',
|
||||
conflict: 'Ayarlar başka bir yerde değişti. Güncel kayıtlı ayarlar yüklendi.',
|
||||
saved: 'Ayarlar kaydedildi.',
|
||||
accepted: 'İşlem kabul edildi. İlerlemesi aşağıda gösterilir.',
|
||||
chats: 'Sohbet hafızası',
|
||||
empty: 'Şu anda görünen yönetilen Codex sohbeti yok.',
|
||||
compact: 'Şimdi sıkıştır',
|
||||
summaries: 'Kayıtlı özetler',
|
||||
tokens: 'token',
|
||||
unmeasured: 'Henüz ölçülmedi',
|
||||
current: 'Mevcut giriş',
|
||||
estimated: 'Tahmin',
|
||||
upperBound: 'Doğrulanmış üst sınır',
|
||||
limit: 'Etkin eşik',
|
||||
restart: 'Yeni runtime sonraki Codex açılışında kullanılacak. Açık sohbetler mevcut süreçleriyle devam eder.',
|
||||
activation: {
|
||||
disabled: 'Kapalı',
|
||||
pending_runtime_acceptance: 'Runtime doğrulaması bekleniyor',
|
||||
ready: 'Hazır',
|
||||
restart_required: 'Yeniden açılış gerekli',
|
||||
conflict: 'Ayar çakışması',
|
||||
recovery_pending: 'Kayıtlı durum kurtarılıyor'
|
||||
description:
|
||||
"AITURK IDE'deki modelleri ve comboları bu bilgisayardaki Codex'e ekler; modeller arasında geçerken sohbet bağlamını korur.",
|
||||
unavailable: 'Codex Bağlama yalnızca Windows masaüstü uygulamasında kullanılabilir.',
|
||||
loadFailed: 'Codex Bağlama durumu okunamadı.',
|
||||
saveFailed: 'Codex Bağlama değiştirilemedi.',
|
||||
statusTitle: 'Durum',
|
||||
off: 'Codex Bağlama kapalı. Codex kendi ayarlarıyla çalışıyor.',
|
||||
codexFound: 'Bu bilgisayarda Codex bulundu.',
|
||||
codexMissing: 'Bu bilgisayarda Codex bulunamadı.',
|
||||
foreign: 'Bu bilgisayarda Codex başka bir köprü kullanıyor; ayarına dokunulmadı.',
|
||||
venvPending: 'IDE kurulumu tamamlanınca etkinleşecek.',
|
||||
bridgeRunning: 'Köprü çalışıyor.',
|
||||
bridgeStopped: 'Köprü çalışmıyor.',
|
||||
models: (count: number) => `Codex'e ${count} model eklendi.`,
|
||||
signIn: 'IDE oturumu bulunamadı, giriş yapın.',
|
||||
errors: {
|
||||
'autostart-failed': 'Köprü Windows açılışında başlayacak şekilde ayarlanamadı.',
|
||||
'bridge-config-failed': 'Köprü ayarları yazılamadı.',
|
||||
'bridge-missing': 'Köprü dosyaları bu kurulumda bulunamadı.',
|
||||
'bridge-start-failed': 'Köprü başlatılamadı; Codex ayarına dokunulmadı.',
|
||||
'config-write-failed': 'Codex ayar dosyası yazılamadı.',
|
||||
'settings-write-failed': 'Bu ayar kaydedilemedi.'
|
||||
},
|
||||
admission: {
|
||||
open: 'Harici modeller kullanılabilir',
|
||||
closed: 'Harici modeller kullanılamıyor',
|
||||
draining: 'Etkin işler bitiriliyor',
|
||||
recovery_required: 'İşlem durumu uzlaştırılmalı'
|
||||
},
|
||||
operation: {
|
||||
accepted: 'Kabul edildi',
|
||||
prepared: 'Hazırlandı',
|
||||
running: 'Çalışıyor',
|
||||
completed: 'Tamamlandı',
|
||||
failed: 'Başarısız',
|
||||
indeterminate: 'Sonuç belirsiz'
|
||||
}
|
||||
syncTitle: 'Şimdi eşitle',
|
||||
syncDesc: 'Köprüyü ve Codex ayarını yeniden denetler.',
|
||||
sync: 'Şimdi eşitle',
|
||||
syncing: 'Eşitleniyor…',
|
||||
restartNote: "Codex'te yeni modelleri görmek için Codex'i yeniden açın."
|
||||
}
|
||||
|
||||
export function useCodexLinkCopy() {
|
||||
return useI18n().locale === 'tr' ? tr : en
|
||||
export type CodexLinkCopy = typeof en
|
||||
|
||||
export function useCodexLinkCopy(): CodexLinkCopy {
|
||||
const { locale } = useI18n()
|
||||
|
||||
return locale === 'tr' ? tr : en
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
// Synthetic, isolated visual acceptance fixture. Never contacts the installed manager.
|
||||
import '../styles.css'
|
||||
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import type { CodexLinkControlRequest, CodexLinkControlResponse, CodexLinkSettings as Settings } from '../../codex-link-control.types'
|
||||
import { CodexLinkSettings } from '../app/settings/codex-link-settings'
|
||||
import { I18nProvider } from '../i18n/context'
|
||||
import { ThemeProvider } from '../themes/context'
|
||||
|
||||
let saved: Settings = { settings_revision: 1, enabled: true, compression_enabled: true, threshold_tokens: 250000, source_binding_handle: 'source-demo', compact_route_id: 'Compact', legacy_intent_imported: false, ownership_manifest_revision: 1, activation_state: 'ready' }
|
||||
const budget = { target_id: 'target-demo', canonical_leaf_id: 'leaf-demo', limits_source: 'verified_adapter' as const, limits_revision: 'limits-1', tokenizer_id: 'tokenizer-demo', tokenizer_revision: '1', count_method: 'exact_tokenizer' as const, serialization_revision: '1', input_tokens: 85000, context_window_tokens: 100000, output_cap_tokens: 8000, safety_margin_tokens: 1000, max_input_tokens: null, max_output_tokens: 8000, request_bytes: 400000, max_request_bytes: 4000000, output_cap_enforcement: { method: 'request_field' as const, field: 'max_output_tokens' as const, value: 8000, proof_revision: '1', verified: true as const } }
|
||||
|
||||
const bridge: Pick<NonNullable<typeof window.hermesDesktop>, 'codexLink'> = { codexLink: { control: async (request: CodexLinkControlRequest): Promise<CodexLinkControlResponse> => {
|
||||
const base = { contract_version: 'codex-link/1' as const, request_id: request.request_id, operation_id: request.operation_id }
|
||||
|
||||
if (request.kind === 'status_get') {return { ...base, kind: 'status_result', status: { settings_revision: saved.settings_revision, runtime_mode: 'managed', native_available: true, external_admission: 'open', needs_restart: false, catalog_state: 'fresh', active_turns: 0, uncertain_operations: 0, last_operation_id: null, error: null } }}
|
||||
|
||||
if (request.kind === 'context_views_get') {return { ...base, kind: 'context_views_result', settings_revision: saved.settings_revision, views: [{ view_handle: 'view-demo', can_compact: true, label: 'Proje sohbeti · örnek kayıt', checkpoint_revision: 4, current_model_label: '100 bin bağlamlı model', limiting_target_id: budget.target_id, target_budgets: [budget], active_summary_count: 3, summary_tokens: 20000, summary_token_measurement: { target_id: budget.target_id, tokenizer_id: budget.tokenizer_id, tokenizer_revision: budget.tokenizer_revision, count_method: budget.count_method }, last_operation_id: null, last_operation_state: null, last_operation_error: null, compact_attempts: [] }] }}
|
||||
|
||||
if (request.kind === 'settings_update') {saved = { ...saved, ...request.patch, settings_revision: saved.settings_revision + 1 }}
|
||||
|
||||
if (request.kind === 'catalog_sync' || request.kind === 'context_compact_view') {return { ...base, kind: 'control_accepted', settings_revision: saved.settings_revision, operation_state: 'accepted', error: null }}
|
||||
|
||||
return { ...base, kind: 'settings_result', settings: saved, available_sources: [{ source_binding_handle: 'source-demo', label: 'AITURK hesabı', authorized: true }] }
|
||||
} } }
|
||||
|
||||
Object.assign(window, { hermesDesktop: bridge })
|
||||
createRoot(document.getElementById('root')!).render(<I18nProvider configClient={null} initialLocale="tr"><ThemeProvider><main style={{ maxWidth: 820, margin: '0 auto' }}><CodexLinkSettings /></main></ThemeProvider></I18nProvider>)
|
||||
@@ -16,6 +16,6 @@
|
||||
"rootDir": "..",
|
||||
"outDir": "build/electron-types"
|
||||
},
|
||||
"include": ["electron", "codex-link-control.ts", "codex-link-control.types.ts", "codex-link-control.schema.json", "codex-link-json.ts", "../shared/src/data-url-read-max.ts", "../shared/src/translucency.ts"],
|
||||
"include": ["electron", "../shared/src/data-url-read-max.ts", "../shared/src/translucency.ts"],
|
||||
"exclude": ["src", "electron/**/*.e2e.mts"]
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="tr"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Codex Bağlama kabul görünümü</title></head><body><div id="root"></div><script type="module" src="../src/visual-fixtures/codex-link-settings.tsx"></script></body></html>
|
||||
@@ -6,7 +6,8 @@ export default defineConfig({
|
||||
include: [
|
||||
'electron/mobile-*.test.ts',
|
||||
'electron/first-run-setup*.test.ts',
|
||||
'electron/primary-backend-startup.test.ts'
|
||||
'electron/primary-backend-startup.test.ts',
|
||||
'electron/codex-link*.test.ts'
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
Generated
+1
-1
@@ -65,7 +65,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "aiturk-ide",
|
||||
"version": "1.0.0-beta.21",
|
||||
"version": "1.0.0-beta.23",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@assistant-ui/core": "0.2.23",
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
"""Generate the IDE's narrow control contract from the reviewed gateway schema.
|
||||
|
||||
Usage: python generate-codex-link-control.py PATH_TO_SCHEMA [--check]
|
||||
No runtime/namespace/archive messages are included in this surface.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
schema = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
definitions = schema["$defs"]
|
||||
selected = {}
|
||||
|
||||
def collect(name):
|
||||
if name in selected:
|
||||
return
|
||||
selected[name] = definitions[name]
|
||||
def visit(value):
|
||||
if isinstance(value, dict):
|
||||
if "$ref" in value:
|
||||
collect(value["$ref"].removeprefix("#/$defs/"))
|
||||
for child in value.values():
|
||||
visit(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
visit(child)
|
||||
visit(selected[name])
|
||||
|
||||
for name in ("control_request", "control_response"):
|
||||
collect(name)
|
||||
|
||||
def typename(name):
|
||||
return "CodexLink" + "".join(part.title() for part in name.split("_"))
|
||||
|
||||
def ts(rule):
|
||||
if "$ref" in rule:
|
||||
return typename(rule["$ref"].split("/")[-1])
|
||||
if "const" in rule:
|
||||
return json.dumps(rule["const"], ensure_ascii=False)
|
||||
if "enum" in rule:
|
||||
return " | ".join(json.dumps(x, ensure_ascii=False) for x in rule["enum"])
|
||||
for key in ("oneOf", "anyOf"):
|
||||
if key in rule:
|
||||
return "(" + " | ".join(ts(x) for x in rule[key]) + ")"
|
||||
kind = rule["type"]
|
||||
if kind == "object":
|
||||
return "{ " + "; ".join(json.dumps(k) + ("" if k in rule["required"] else "?") + ": " + ts(v) for k, v in rule["properties"].items()) + " }"
|
||||
if kind == "array":
|
||||
return "Array<" + ts(rule["items"]) + ">"
|
||||
return {"integer": "number", "boolean": "boolean", "string": "string", "null": "null"}[kind]
|
||||
|
||||
subset = {"$defs": {k: selected[k] for k in sorted(selected)}}
|
||||
canonical = json.dumps(subset, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
|
||||
digest = "sha256:" + hashlib.sha256(canonical).hexdigest()
|
||||
types = "// Generated by scripts/generate-codex-link-control.py. Do not edit.\n"
|
||||
types += "export const CODEX_LINK_CONTROL_SCHEMA_HASH = " + json.dumps(digest) + "\n"
|
||||
types += "\n".join("export type " + typename(k) + " = " + ts(v) for k, v in sorted(selected.items())) + "\n"
|
||||
outputs = {
|
||||
root / "apps/desktop/codex-link-control.schema.json": json.dumps(subset, ensure_ascii=False, indent=2) + "\n",
|
||||
root / "apps/desktop/codex-link-control.types.ts": types,
|
||||
}
|
||||
for path, content in outputs.items():
|
||||
if "--check" in sys.argv:
|
||||
if not path.exists() or path.read_text(encoding="utf-8") != content:
|
||||
raise SystemExit("Generated control contract is stale: " + str(path))
|
||||
else:
|
||||
path.write_text(content, encoding="utf-8", newline="\n")
|
||||
print(digest)
|
||||
Reference in New Issue
Block a user