Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bd662ea92 | ||
|
|
b29cf1ad11 | ||
|
|
d0810c63dd | ||
|
|
e6cd197a2e | ||
|
|
02a5fa2080 | ||
|
|
4f0476e5f2 | ||
|
|
3726d2a553 | ||
|
|
8fbbe226db | ||
|
|
06039b904d | ||
|
|
7547de1852 | ||
|
|
ff53cafb5c | ||
|
|
77504b9aa4 | ||
|
|
11bbaddcc7 | ||
|
|
be0b33e89b | ||
|
|
1f4f2de428 | ||
|
|
27964e37b0 | ||
|
|
5556ad11af | ||
|
|
c8993d58cc | ||
|
|
23e8aa8191 | ||
|
|
c0eee2cc2a | ||
|
|
12eeec7b0f | ||
|
|
dd5f99c406 | ||
|
|
48103b9ad3 | ||
|
|
615fedde01 | ||
|
|
a46ff51019 | ||
|
|
8a612beba7 | ||
|
|
7c0dc467d5 | ||
|
|
e2793fa38f | ||
|
|
1e2b8e2b9c | ||
|
|
1445e204da | ||
|
|
d9a7661281 | ||
|
|
959f5f1459 | ||
|
|
acf88f8314 | ||
|
|
ac0fdcf355 | ||
|
|
9a54705ed5 | ||
|
|
9739222919 | ||
|
|
94e4aa2a74 | ||
|
|
adf73eeedd | ||
|
|
ce49d6d1cc | ||
|
|
8ee676d7c6 | ||
|
|
68a97b7489 | ||
|
|
6e91154f15 | ||
|
|
88e660768c | ||
|
|
bfd0ff196d | ||
|
|
1e0f2f9aea | ||
|
|
b560950f82 | ||
|
|
dea67fc153 | ||
|
|
1ce5ab82cf | ||
|
|
df63432c92 | ||
|
|
4220d35ebe | ||
|
|
01889d1383 | ||
|
|
1282de09ba | ||
|
|
0fa42332c1 | ||
|
|
21aa3c2247 | ||
|
|
55a1beaf9d | ||
|
|
1acb5dd90f | ||
|
|
61d765e2df | ||
|
|
b1db2870c1 | ||
|
|
7e4cc0915c | ||
|
|
58dbb4523b | ||
|
|
2ed9a6b2de | ||
|
|
03d3803dfc | ||
|
|
25d8c18c10 | ||
|
|
fe0a45a84f | ||
|
|
67a99be224 |
No files matched your search
@@ -0,0 +1,76 @@
|
||||
name: Server CI and Windows package
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [aiturk/main, fix/ide-windows-vcruntime]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 40
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=2560
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
github-server-url: https://gitea.turkservis.online
|
||||
- name: Exact source and Linux test environment
|
||||
run: |
|
||||
set -eu
|
||||
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
|
||||
python3 -m venv "$RUNNER_TEMP/bootstrap"
|
||||
"$RUNNER_TEMP/bootstrap/bin/pip" install uv
|
||||
"$RUNNER_TEMP/bootstrap/bin/uv" venv --python 3.11 "$RUNNER_TEMP/ci-venv"
|
||||
"$RUNNER_TEMP/bootstrap/bin/uv" pip install --python "$RUNNER_TEMP/ci-venv/bin/python" -e . pytest zstandard
|
||||
- name: Approval gate and file safety on the server
|
||||
run: |
|
||||
"$RUNNER_TEMP/ci-venv/bin/python" -m pytest tests/tools/test_protected_instruction_unattended.py tests/tools/test_file_write_safety.py tests/tools/test_computer_use_approval_isolation.py tests/cli/test_cli_approval_ui.py tests/cli/test_single_query_session_finalize.py -q -p no:cacheprovider -rf
|
||||
- name: Codex bridge regression tests
|
||||
run: '"$RUNNER_TEMP/ci-venv/bin/python" -m unittest discover -s apps/desktop/assets/codex-bridge'
|
||||
- name: Mobile and desktop startup regression tests
|
||||
run: |
|
||||
npm install --prefix "$RUNNER_TEMP/mobile-tests" --no-save --ignore-scripts --no-audit --no-fund --legacy-peer-deps vitest@4.1.10 ws@8.21.1
|
||||
ln -s "$RUNNER_TEMP/mobile-tests/node_modules" node_modules
|
||||
node node_modules/vitest/vitest.mjs run --root apps/desktop --config vitest.mobile.config.ts --maxWorkers=2
|
||||
|
||||
windows-package:
|
||||
if: github.ref == 'refs/heads/fix/ide-windows-vcruntime'
|
||||
needs: verify
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
# Windows packaging runs on Linux/Wine in the new server. No laptop runner.
|
||||
container: electronuserland/builder@sha256:b226b27b1a7e95ad25eb3d6414ea90c56c04eb6d8329967f4d721dce50b989ff
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=2560
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
github-server-url: https://gitea.turkservis.online
|
||||
- name: Install locked source dependencies
|
||||
run: npm ci --no-audit --no-fund
|
||||
- name: Native dependency staging regression tests
|
||||
run: npx vitest run --root apps/desktop scripts/stage-native-deps.test.mjs --maxWorkers=2
|
||||
- name: Build and package Windows x64
|
||||
run: |
|
||||
set -eu
|
||||
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
|
||||
npm run build --workspace=apps/desktop
|
||||
npm run builder --workspace=apps/desktop -- --win nsis zip --x64 --publish never
|
||||
git diff --exit-code
|
||||
cd apps/desktop/release
|
||||
sha256sum *.exe *.zip > SHA256SUMS
|
||||
- name: Preserve exact-source Windows package
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: aiturk-ide-windows-${{ github.sha }}
|
||||
if-no-files-found: error
|
||||
path: |
|
||||
apps/desktop/release/*.exe
|
||||
apps/desktop/release/*.zip
|
||||
apps/desktop/release/SHA256SUMS
|
||||
+1
-1
@@ -54,5 +54,5 @@ Nous Research telif bildirimi ve MIT metni [LICENSE](LICENSE) dosyasında korunu
|
||||
paket ayrıca `assets/NOTICE.txt` ve `assets/LICENSE.txt` içerir.
|
||||
AITURK/TurkServis dağıtımı Nous Research tarafından onaylanmış bir ürün değildir.
|
||||
|
||||
[AITURK kaynak deposu](https://gitea.twinpay.one/yilsem/aiturk-hermes-ide)
|
||||
[AITURK kaynak deposu](https://gitea.turkservis.online/yilsem/aiturk-hermes-ide)
|
||||
· [TurkServis](https://turkservis.online)
|
||||
@@ -0,0 +1,91 @@
|
||||
# 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).
|
||||
The public `/models` catalog supplies every OmniRoute chat model; a member does
|
||||
not need a private local model allowlist. `/router/catalog` supplies only the
|
||||
`kind` metadata used for sorting, never additional model IDs. Direct 9Router
|
||||
entries and internal 9Router combos are excluded. Codex's own subscription models
|
||||
stay first, in their own order and under their own names; added entries follow
|
||||
them: combos A–Z, then individual models by provider and name. A saved model
|
||||
selection remains unchanged. A failed or empty refresh keeps the last successful catalog.
|
||||
- `ozet_kopru.py`: conversation summaries are produced as plain text, so a summary made
|
||||
by one provider stays readable after the user switches to another.
|
||||
- `gemini_context.py`, `resim_context.py`: request fixes for routed models.
|
||||
|
||||
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.
|
||||
|
||||
## Version 8 (IDE beta.27): changing the model by saying so
|
||||
|
||||
Measured on a live voice chat (11 Oct 2026): a model picked on the phone reaches this machine only with a typed
|
||||
message that STARTS a turn; a message that joins a running turn (including the lone `.`) does not carry it.
|
||||
`model_gecis.py` therefore handles the change in the bridge. A short user message such as "modeli gemini flash low
|
||||
yap" (spoken through the voice assistant or typed) is matched against the model list; the chat's requests are then
|
||||
routed to that model (`model-yonlendirme.json`, keyed by `prompt_cache_key`) until Codex sends a different model
|
||||
for the chat, i.e. until the user picks another one in the list.
|
||||
|
||||
- Idle chat: the bridge answers itself ("Sohbetin modeli artık X ..."), which also tells the voice assistant.
|
||||
- Running turn: the command is replaced by a handover note and the SAME turn continues on the new model.
|
||||
- "seçili model hangisi" / "ana model kim" is answered by the bridge with the model actually in use.
|
||||
- An ambiguous name changes nothing and lists the closest names; a name matching no model is not a command.
|
||||
- A lone `.` that joins a running turn is dropped instead of answered (answering it cut the work short).
|
||||
- Handled commands left in the history are turned into notes so a later model does not answer them again.
|
||||
|
||||
Setting: `sesle_model` (default on).
|
||||
|
||||
Audit follow-up (11 Oct 2026, same version number; `GET .../status` now also returns `kod`, the first 8 hex digits
|
||||
of `bridge.py`'s SHA-256, to tell builds apart):
|
||||
|
||||
- Numbers said as words ("opus beş beş", "üç nokta sekiz") and a trailing "ve devam et" no longer drop the command.
|
||||
- Picking the same model again in the list does not clear a spoken route (Codex sends the same name), so the
|
||||
bridge's answer says how to go back. A route that is in use no longer expires after 7 days.
|
||||
- A command is turned into a "model was changed" note only if it took effect; a mid-turn note also reaches a
|
||||
subscription model when hook messages follow it.
|
||||
- Combos: a Claude member selects the stricter token estimate; one unknown member no longer switches the overflow
|
||||
guard off for a known small member; a combo dropped by the peer after the request was sent is not resent.
|
||||
- An old `9r/…` name without a route gets a local 503 instead of going to chatgpt.com; upstream error bodies
|
||||
reach Codex whole, with `Retry-After`.
|
||||
- The last model list is also served when chatgpt.com cannot be reached at all, and is cached when the answer
|
||||
was gzip-compressed. The old `display_map` setting is no longer read (subscription models keep their names).
|
||||
- Log lines carry the date; the access-token tail is no longer logged.
|
||||
|
||||
### Work handoff instruction (prepared, off by default)
|
||||
|
||||
`is_hatti.py`: when `aiturk_hat` is enabled and has a `komut`, the bridge prepends a developer message ("small work
|
||||
yourself; larger work goes to AITURK via this command") to requests whose `originator` header is `Codex Desktop`.
|
||||
The Codex CLI (`codex-tui`, `codex_exec`) never receives it and nothing is written to any file it reads. Each new
|
||||
`originator` value is logged once (`istemci goruldu`).
|
||||
|
||||
## Version 7 (IDE beta.26): external models behave like subscription models
|
||||
|
||||
Measured on 10 Oct 2026 with the unmodified Codex binary (`codex exec`, same conversation, model switched per turn).
|
||||
|
||||
| Area | Before | Now |
|
||||
| --- | --- | --- |
|
||||
| Context window | every added model declared 272 000 | `pencere.py`: NanoGPT's own list for `nano/*`, a local table for `cc`/`cx`/`ag`, a combo declares its smallest member, unknown = 128 000, nothing above 272 000 (the summarizer's window). Codex then compacts by itself when switching to a smaller model. |
|
||||
| Overflow | the target answered with an empty stream; Codex retried five times | `pencere.sigdir` trims the middle of oversized old parts in the outgoing copy; if it still does not fit Codex gets `context_length_exceeded`. A limit reported by the target is learned (`pencere-ogrenilen.json`). |
|
||||
| Tools | clones inherited `code_mode_only` + Responses Lite; GLM and Qwen failed 0/3 on simple tasks (empty answers, reasoning leaked into the answer) | `dis_arac_modu: "direct"` (default): classic tools. GLM 3/3, Qwen 2/3; Claude, `cx` and Gemini unchanged (6/6). `"kalip"` restores the old behaviour. |
|
||||
| Intermediate messages | external assistant messages had no `phase`; Codex treats those as final answers | `SseRewriter` stamps `commentary` / `final_answer`. Deltas are never delayed; only the item's `done` event waits for what follows. |
|
||||
| Interrupted answer | text written before a stop or an app exit was lost | `yarim.py` keeps it (memory only) when the *client* closes the stream and adds it as a note to the next user message of the same conversation, once. |
|
||||
| Images | removed from every old message for all external models | kept for vision-capable targets (user messages, newest first, 1 MB total). |
|
||||
| Summary brake | the 4th summary in 10 minutes failed the turn | three summaries in a row with no normal request in between, or eight in 10 minutes. |
|
||||
| Gateway errors | `event: error` (e.g. `upstream 402`) looked like a cut stream | permanent ones become `response.failed` with a readable message; short upstream bodies are logged. |
|
||||
| Start-up | the model list was fetched before listening; a slow gateway made the IDE give up after 10 s | window data comes from the cache at start and is refreshed in the background; the IDE waits 45 s. |
|
||||
|
||||
Not changed: subscription models, their order and names, and everything on the voice path (`/realtime/*` passes through untouched).
|
||||
|
||||
Settings in `bridge.json` (all optional): `dis_arac_modu`, `tasma_bekcisi`, `yarim_cevap`, `ozet_kopru.dongu_siniri`, `ozet_kopru.dongu_art_arda`.
|
||||
|
||||
A lone `.` sent as the user's message (used to apply a model change during a voice session) is answered by the bridge itself with "<model> seçildi." (the target is not probed, so it does not say "ready") and never reaches a model (`nokta_karsila`, default on). Targets that reject tool calls are remembered (`aracsiz-modeller.json`) and marked "araçsız" in the picker.
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Internet aramasi ve sayfa okuma: Codex'e sonradan eklenen modeller icin (11 Ekim 2026).
|
||||
|
||||
Codex'in yerlesik web aramasi OpenAI sunucusunda calisir ve yalniz Codex'in kendi modellerine verilir; kopru onu
|
||||
ek modele giden arac listesinden atar. Ek model aramayi bu betigi kabuk araciyla calistirarak yapar (internet.py
|
||||
modele komutu soyler). Anahtar gerekmez; yalniz standart kitaplik.
|
||||
|
||||
python ara.py ara "sorgu" [--adet 8]
|
||||
python ara.py oku <adres> [--en-cok 8000]
|
||||
"""
|
||||
import argparse
|
||||
import html
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"
|
||||
ARAMA = "https://html.duckduckgo.com/html/?"
|
||||
SURE = 25
|
||||
EN_COK_BAYT = 3_000_000
|
||||
|
||||
|
||||
def indir(adres, sure=SURE):
|
||||
adres = urllib.parse.quote(adres, safe=":/?#[]@!$&'()*+,;=%~") # Turkce harfli adres ("/wiki/Türkiye")
|
||||
istek = urllib.request.Request(adres, headers={"User-Agent": UA, "Accept-Language": "tr,en;q=0.8"})
|
||||
with urllib.request.urlopen(istek, timeout=sure) as yanit:
|
||||
ham = yanit.read(EN_COK_BAYT)
|
||||
kodlama = yanit.headers.get_content_charset() or "utf-8"
|
||||
tur = yanit.headers.get_content_type()
|
||||
try:
|
||||
return ham.decode(kodlama, "replace"), tur
|
||||
except LookupError:
|
||||
return ham.decode("utf-8", "replace"), tur
|
||||
|
||||
|
||||
def _duz(parca):
|
||||
return re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", "", parca))).strip()
|
||||
|
||||
|
||||
def sonuclari_coz(sayfa, adet=8):
|
||||
"""Arama sayfasindan [(baslik, adres, ozet)]."""
|
||||
cikti = []
|
||||
for blok in re.split(r'<div[^>]+class="[^"]*\bresult\b[^"]*"', sayfa)[1:]:
|
||||
bag = re.search(r'<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>(.*?)</a>', blok, re.S)
|
||||
if not bag:
|
||||
continue
|
||||
adres = html.unescape(bag.group(1))
|
||||
ic = re.search(r"[?&]uddg=([^&]+)", adres)
|
||||
if ic:
|
||||
adres = urllib.parse.unquote(ic.group(1))
|
||||
if adres.startswith("//"):
|
||||
adres = "https:" + adres
|
||||
if "duckduckgo.com/y.js" in adres: # reklam
|
||||
continue
|
||||
ozet = re.search(r'class="result__snippet"[^>]*>(.*?)</a>', blok, re.S)
|
||||
cikti.append((_duz(bag.group(2)), adres, _duz(ozet.group(1)) if ozet else ""))
|
||||
if len(cikti) >= adet:
|
||||
break
|
||||
return cikti
|
||||
|
||||
|
||||
def metne_cevir(sayfa):
|
||||
"""HTML -> okunur duz metin (betik, bicim ve gezinme kisimlari atilir)."""
|
||||
sayfa = re.sub(r"(?is)<(script|style|noscript|svg|nav|footer|header|form|iframe)\b.*?</\1>", " ", sayfa)
|
||||
sayfa = re.sub(r"(?is)<!--.*?-->", " ", sayfa)
|
||||
sayfa = re.sub(r"(?i)<(br|/p|/div|/li|/h[1-6]|/tr)\b[^>]*>", "\n", sayfa)
|
||||
metin = html.unescape(re.sub(r"<[^>]+>", " ", sayfa))
|
||||
satirlar = [re.sub(r"[ \t\r\f\v]+", " ", s).strip() for s in metin.split("\n")]
|
||||
return "\n".join(s for s in satirlar if s)
|
||||
|
||||
|
||||
def ara(sorgu, adet=8, indirici=indir):
|
||||
sayfa, _ = indirici(ARAMA + urllib.parse.urlencode({"q": sorgu}))
|
||||
sonuc = sonuclari_coz(sayfa, adet)
|
||||
if not sonuc:
|
||||
if 'no-results' in sayfa.lower() or 'no results found' in sayfa.lower():
|
||||
return "Sonuç bulunamadı. En fazla bir farklı sorgu deneyebilirsin."
|
||||
# DDG bot kontrolünü/bozuk HTML'yi sonuç yok diye göstermeyiz. Tek yedek istek.
|
||||
try:
|
||||
rss, _ = indirici('https://www.bing.com/search?' + urllib.parse.urlencode({'format': 'rss', 'q': sorgu}))
|
||||
kok = ET.fromstring(rss)
|
||||
if kok.tag != 'rss' or kok.find('channel') is None:
|
||||
raise ValueError('RSS arama yanıtı değil')
|
||||
sonuc = [(i.findtext('title') or '', i.findtext('link') or '', _duz(i.findtext('description') or ''))
|
||||
for i in kok.findall('./channel/item')
|
||||
if (i.findtext('link') or '').startswith(('https://', 'http://'))][:adet]
|
||||
except (ValueError, ET.ParseError, OSError) as hata:
|
||||
raise RuntimeError('Arama hizmeti engellendi veya yanıtı okunamadı; sorguları değiştirerek tekrar etme.') from hata
|
||||
if not sonuc:
|
||||
return "Sonuç bulunamadı. En fazla bir farklı sorgu deneyebilirsin."
|
||||
return "\n\n".join(f"{i}. {b}\n {a}\n {o}" for i, (b, a, o) in enumerate(sonuc, 1))
|
||||
|
||||
|
||||
def oku(adres, en_cok=8000, indirici=indir):
|
||||
if not re.match(r"https?://", adres):
|
||||
adres = "https://" + adres
|
||||
sayfa, tur = indirici(adres)
|
||||
metin = metne_cevir(sayfa) if "html" in tur or "<html" in sayfa[:2000].lower() else sayfa
|
||||
if len(metin) > en_cok:
|
||||
metin = metin[:en_cok] + f"\n[... kesildi: {len(metin)} karakterin ilk {en_cok} karakteri]"
|
||||
return metin or "Sayfada okunur metin bulunamadı."
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
p = argparse.ArgumentParser(description="Internet aramasi ve sayfa okuma")
|
||||
alt = p.add_subparsers(dest="is_", required=True)
|
||||
a = alt.add_parser("ara"); a.add_argument("sorgu", nargs="+"); a.add_argument("--adet", type=int, default=8)
|
||||
o = alt.add_parser("oku"); o.add_argument("adres"); o.add_argument("--en-cok", type=int, default=8000)
|
||||
s = p.parse_args(argv)
|
||||
try:
|
||||
metin = ara(" ".join(s.sorgu), max(1, min(s.adet, 20))) if s.is_ == "ara" else oku(s.adres, max(500, s.en_cok))
|
||||
kod = 0
|
||||
except Exception as e:
|
||||
metin, kod = f"Hata: {type(e).__name__}: {e}", 1
|
||||
sys.stdout.buffer.write((metin + "\n").encode("utf-8", "replace"))
|
||||
return kod
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,385 @@
|
||||
"""AITURK IDE "Codex Baglama" ayiricisi.
|
||||
|
||||
Kullanici karari (11 Ekim 2026): "Codex'in modelleri bir tarafta. Bizim sonradan eklediklerimiz diger tarafta.
|
||||
Bizim sonradan eklediklerimiz kopru kullaniyor. Codex'in modelleri kopru kullanmiyor." ve "Codex'i secip Codex'e
|
||||
yaziyorsan bizim kopru yardim amacli bile yaninda olamaz."
|
||||
|
||||
Codex'te tek adres ayari (`openai_base_url`) oldugu icin butun istekler buraya gelir. Bu dosya YALNIZ ayirir:
|
||||
|
||||
* Codex'in kendi modeli -> chatgpt.com'a; govde bayt bayt ayni gider, cevap geldigi gibi doner.
|
||||
* Bizim ekledigimiz model -> ayri bir surecte calisan kopruye (kopru.py). Codex'in kendi modelinin istegi
|
||||
(ide/..., 9r/..., model_map) o surece HIC gitmez.
|
||||
* Model listesi -> kopru ayaktaysa ek modelleri eklenmis liste; degilse chatgpt.com'un listesi.
|
||||
* Geri kalan her sey -> chatgpt.com'a, oldugu gibi.
|
||||
|
||||
BU DOSYAYA OZELLIK EKLENMEZ. Sikistirma, not, talimat, model komutu, gecmis duzeltme: hepsi kopru.py'dedir ve yalniz
|
||||
ek modeller icindir. Kopru cokerse, guncellenirse ya da yeniden baslatilirsa Codex'in kendi modelleri etkilenmez.
|
||||
|
||||
Veri klasoru (bridge.json, log/): --home <klasor> ya da AITURK_CODEX_BRIDGE_HOME. Ajan klasoru: --hermes-home.
|
||||
Yerel denetim: GET /aiturk/codex-baglama/status, POST .../stop, POST .../refresh, POST .../kopru-yenile.
|
||||
"""
|
||||
import gzip
|
||||
import hashlib
|
||||
import http.client
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SURUM = "8" # AITURK IDE'nin bekledigi kopru surumu (codex-link-config.ts CODEX_BRIDGE_VERSION)
|
||||
try:
|
||||
with open(os.path.abspath(__file__), "rb") as _f:
|
||||
KOD = hashlib.sha256(_f.read()).hexdigest()[:8]
|
||||
except OSError:
|
||||
KOD = ""
|
||||
|
||||
|
||||
def _arg(name, env):
|
||||
"""--name <deger> komut satirindan, yoksa ortam degiskeninden."""
|
||||
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
|
||||
HERMES = _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 = {}
|
||||
PORT = int(CFG.get("port", 8790))
|
||||
KOPRU_PORT = int(_arg("--kopru-port", "AITURK_CODEX_KOPRU_PORT") or PORT + 1)
|
||||
CHATGPT = CFG.get("chatgpt_backend", "https://chatgpt.com").rstrip("/")
|
||||
KOPRU = f"http://127.0.0.1:{KOPRU_PORT}"
|
||||
BEKLEME = float(CFG.get("upstream_idle_timeout", 3600)) # okuma basina; uzun cevap kesilmez (28 Eylul 2026 karari)
|
||||
LOGDIR = os.path.join(HOME, "log")
|
||||
|
||||
# Codex istek govdesini zstd ile sikistirir. Model adini okumak icin bir KOPYASI acilir; giden govde degismez.
|
||||
_VENDOR = os.path.join(HERE, "vendor", "cp%d%d-win_amd64" % sys.version_info[:2])
|
||||
if os.name == "nt" and os.path.isdir(_VENDOR):
|
||||
sys.path.insert(0, _VENDOR)
|
||||
|
||||
|
||||
def _zstd_ac(govde):
|
||||
try:
|
||||
from compression import zstd as _z
|
||||
return _z.decompress(govde)
|
||||
except ImportError:
|
||||
import zstandard
|
||||
return zstandard.ZstdDecompressor().decompressobj().decompress(govde)
|
||||
|
||||
|
||||
def _zstd_hazir():
|
||||
try:
|
||||
try:
|
||||
from compression import zstd as _z
|
||||
ornek = _z.compress(b"aiturk")
|
||||
except ImportError:
|
||||
import zstandard
|
||||
ornek = zstandard.ZstdCompressor().compress(b"aiturk")
|
||||
return _zstd_ac(ornek) == b"aiturk"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# zstd yoksa model adi okunamaz ve ek modeller ayrilamaz; durum ucu ok=false der, IDE Codex'i buraya yoneltmez.
|
||||
ZSTD_OK = _zstd_hazir()
|
||||
|
||||
kilit = threading.Lock()
|
||||
sira = [0]
|
||||
|
||||
|
||||
def log(msg):
|
||||
satir = time.strftime("%Y-%m-%d %H:%M:%S ") + msg
|
||||
try:
|
||||
os.makedirs(LOGDIR, exist_ok=True)
|
||||
with kilit:
|
||||
with open(os.path.join(LOGDIR, "ayirici.log"), "a", encoding="utf-8") as f:
|
||||
f.write(satir + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def ek_model_mu(slug):
|
||||
"""Bizim ekledigimiz model mi? Degilse Codex'in kendi modelidir."""
|
||||
slug = str(slug or "")
|
||||
onek = (CFG.get("nine_router_catalog") or {}).get("prefix", "9r/")
|
||||
return bool(slug) and (slug.startswith("ide/") or bool(onek and slug.startswith(onek))
|
||||
or slug in (CFG.get("model_map") or {}))
|
||||
|
||||
|
||||
def model_oku(govde, kodlama):
|
||||
"""Istekteki model adi; okunamazsa None (o zaman istek Codex'in kendi yoluna gider). Govdeye dokunmaz."""
|
||||
try:
|
||||
kodlama = (kodlama or "").strip().lower()
|
||||
ham = _zstd_ac(govde) if kodlama == "zstd" else gzip.decompress(govde) if kodlama == "gzip" else govde
|
||||
veri = json.loads(ham)
|
||||
return veri.get("model") if isinstance(veri, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
ISTEK_ATLA = {"host", "content-length", "accept-encoding", "transfer-encoding", "connection"}
|
||||
YANIT_ATLA = {"host", "content-length", "accept-encoding", "transfer-encoding", "connection", "content-encoding"}
|
||||
|
||||
|
||||
def kopru_sor(yol, yontem="GET", sure=5.0, govde=None):
|
||||
"""Kopru surecinin denetim ucuna sorar; (durum kodu, JSON) ya da ulasilamazsa (None, None)."""
|
||||
try:
|
||||
istek = urllib.request.Request(KOPRU + yol, data=govde, method=yontem)
|
||||
with urllib.request.urlopen(istek, timeout=sure) as yanit:
|
||||
return yanit.status, json.loads(yanit.read() or b"{}")
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
|
||||
class Kopru:
|
||||
"""Bizim kopruyu (kopru.py) ayri surec olarak calistirir; duserse yeniden baslatir."""
|
||||
|
||||
def __init__(self):
|
||||
self.surec = None
|
||||
self.duruyor = False
|
||||
self.baslangic = 0.0
|
||||
|
||||
def komut(self):
|
||||
k = [sys.executable, os.path.join(HERE, "kopru.py"), "--home", HOME, "--port", str(KOPRU_PORT)]
|
||||
return k + (["--hermes-home", HERMES] if HERMES else [])
|
||||
|
||||
def baslat(self):
|
||||
self.baslangic = time.time()
|
||||
self.surec = subprocess.Popen(self.komut(), cwd=HERE, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
||||
log(f"kopru baslatildi (surec {self.surec.pid}, kapi {KOPRU_PORT})")
|
||||
|
||||
def bekci(self):
|
||||
"""Kopru duserse yeniden baslatir: hemen cokuyorsa araligi acar (2, 4, ... en cok 30 sn)."""
|
||||
kopru_sor("/aiturk/codex-baglama/stop", "POST", 2.0, b"") # onceki ayiricidan kalmis sahipsiz kopru
|
||||
time.sleep(0.5)
|
||||
ara = 2.0
|
||||
while not self.duruyor:
|
||||
try:
|
||||
self.baslat()
|
||||
kod = self.surec.wait()
|
||||
except Exception as e:
|
||||
kod = f"{type(e).__name__}: {e}"
|
||||
if self.duruyor:
|
||||
break
|
||||
omur = time.time() - self.baslangic
|
||||
ara = 2.0 if omur > 60 else min(ara * 2, 30.0)
|
||||
log(f"kopru durdu (cikis {kod}, {int(omur)} sn calisti); {int(ara)} sn sonra yeniden baslatilacak")
|
||||
time.sleep(ara)
|
||||
|
||||
def yenile(self):
|
||||
"""Yalniz kopruyu yeniden baslatir; Codex'in kendi modellerinin trafigi kesilmez."""
|
||||
if kopru_sor("/aiturk/codex-baglama/stop", "POST", 3.0, b"")[0] is None and self.surec is not None:
|
||||
try:
|
||||
self.surec.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def durdur(self):
|
||||
self.duruyor = True
|
||||
kopru_sor("/aiturk/codex-baglama/stop", "POST", 3.0, b"")
|
||||
try:
|
||||
if self.surec is not None:
|
||||
self.surec.wait(timeout=3)
|
||||
except Exception:
|
||||
try:
|
||||
self.surec.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
KOPRU_SUREC = Kopru()
|
||||
|
||||
|
||||
class H(http.server.BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def _oku(self):
|
||||
if "chunked" in (self.headers.get("Transfer-Encoding") or "").lower():
|
||||
out = bytearray()
|
||||
while True:
|
||||
ham = self.rfile.readline()
|
||||
if not ham:
|
||||
break
|
||||
satir = ham.strip()
|
||||
if not satir:
|
||||
continue
|
||||
boyut = int(satir.split(b";")[0], 16)
|
||||
if boyut == 0:
|
||||
while self.rfile.readline().strip():
|
||||
pass
|
||||
break
|
||||
out += self.rfile.read(boyut)
|
||||
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 _json(self, durum, veri):
|
||||
b = json.dumps(veri).encode()
|
||||
self.send_response(durum)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(b)))
|
||||
self.end_headers()
|
||||
self.wfile.write(b)
|
||||
|
||||
def _denetim(self, yol, govde):
|
||||
son = yol.split("?", 1)[0].rstrip("/")
|
||||
if self.command == "POST" and son.endswith("/stop"):
|
||||
self._json(200, {"ok": True})
|
||||
log("AITURK IDE istegiyle kapatiliyor")
|
||||
threading.Thread(target=lambda: (KOPRU_SUREC.durdur(), time.sleep(0.2), os._exit(0)), daemon=True).start()
|
||||
return
|
||||
if self.command == "POST" and son.endswith("/kopru-yenile"):
|
||||
log("kopru yeniden baslatiliyor (istek uzerine); Codex'in kendi modelleri etkilenmez")
|
||||
KOPRU_SUREC.yenile()
|
||||
return self._json(200, {"ok": True})
|
||||
if son.endswith("/status") or (self.command == "POST" and son.endswith("/refresh")):
|
||||
kod, veri = kopru_sor(yol, self.command, 40.0 if son.endswith("/refresh") else 5.0,
|
||||
govde if self.command == "POST" else None)
|
||||
if kod == 200 and isinstance(veri, dict):
|
||||
veri.update({"ayirici": True, "ayirici_kod": KOD, "kopru": "calisiyor"})
|
||||
veri["ok"] = bool(veri.get("ok")) and ZSTD_OK
|
||||
return self._json(200, veri)
|
||||
return self._json(200, {"ok": ZSTD_OK, "surum": SURUM, "kod": KOD, "zstd": ZSTD_OK, "ide_durum": "kapali",
|
||||
"ide_model": 0, "ozet": False, "is_hatti": False, "ayirici": True,
|
||||
"ayirici_kod": KOD, "kopru": "kapali"})
|
||||
return self._json(404, {"error": {"message": "unknown control path"}})
|
||||
|
||||
def _ilet(self, n, hedef, yol, govde, etiket, yedekli=False, sessiz=False):
|
||||
"""Istegi hedefe OLDUGU GIBI iletir, cevabi geldigi gibi (akarak) geri verir.
|
||||
yedekli=True: cevap basliklari gelmeden bir sorun olursa hicbir sey gonderilmez ve False doner (cagiran baska
|
||||
hedefi dener). Diger durumlarda True doner."""
|
||||
tag = f"[{n} {etiket} {self.command} {yol[:70]}]"
|
||||
basliklar = {k: v for k, v in self.headers.items() if k.lower() not in ISTEK_ATLA}
|
||||
if self.command == "GET" and "/models" in yol:
|
||||
# Liste her seferinde tam gelsin: istemcinin elindeki onbellek damgasi iki tarafin listesini karistirir.
|
||||
basliklar = {k: v for k, v in basliklar.items() if k.lower() not in ("if-none-match", "if-modified-since")}
|
||||
istek = urllib.request.Request(hedef + yol, data=govde or None, headers=basliklar, method=self.command)
|
||||
try:
|
||||
yanit = urllib.request.urlopen(istek, timeout=20.0 if yedekli else BEKLEME)
|
||||
durum = yanit.status
|
||||
except urllib.error.HTTPError as e:
|
||||
yanit, durum = e, e.code
|
||||
except Exception as e:
|
||||
if yedekli:
|
||||
return False
|
||||
log(f"{tag} HATA {type(e).__name__}: {e}")
|
||||
if hedef == KOPRU:
|
||||
self._json(503, {"error": {"message": "AITURK köprüsü şu an çalışmıyor; birkaç saniye sonra yeniden deneyin "
|
||||
"ya da ekrandaki seçiciden Codex'in kendi modelini seçin."}})
|
||||
else:
|
||||
self._json(502, {"error": {"message": f"baglanti kurulamadi: {e}"}})
|
||||
return True
|
||||
if yedekli and durum != 200:
|
||||
try:
|
||||
yanit.read()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
if durum >= 400:
|
||||
hata = yanit.read()
|
||||
log(f"{tag} {durum}: {hata[:300]!r}")
|
||||
self.send_response(durum)
|
||||
for k, v in yanit.headers.items():
|
||||
if k.lower() not in YANIT_ATLA:
|
||||
self.send_header(k, v)
|
||||
self.send_header("Content-Length", str(len(hata)))
|
||||
self.end_headers()
|
||||
self.wfile.write(hata)
|
||||
return True
|
||||
self.send_response(durum)
|
||||
for k, v in yanit.headers.items():
|
||||
if k.lower() not in YANIT_ATLA:
|
||||
self.send_header(k, v)
|
||||
self.send_header("Transfer-Encoding", "chunked")
|
||||
self.end_headers()
|
||||
toplam = 0
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
parca = yanit.read1(4096) # gelen kadarini hemen verir; bekletmez
|
||||
except (OSError, http.client.HTTPException) as e:
|
||||
# Hedef koptu: baglanti kapatilir ki Codex yarim akisi tam sanmasin.
|
||||
log(f"{tag} hedef koptu ({toplam} bayt): {type(e).__name__}: {e}")
|
||||
self.close_connection = True
|
||||
return True
|
||||
if not parca:
|
||||
break
|
||||
toplam += len(parca)
|
||||
self.wfile.write(b"%x\r\n" % len(parca) + parca + b"\r\n")
|
||||
self.wfile.flush()
|
||||
self.wfile.write(b"0\r\n\r\n")
|
||||
self.wfile.flush()
|
||||
except (ConnectionAbortedError, BrokenPipeError, ConnectionResetError):
|
||||
log(f"{tag} istemci baglantiyi kapatti ({toplam} bayt)")
|
||||
self.close_connection = True
|
||||
return True
|
||||
if not sessiz:
|
||||
log(f"{tag} bitti status={durum} {toplam} bayt")
|
||||
return True
|
||||
|
||||
def _isle(self):
|
||||
with kilit:
|
||||
sira[0] += 1
|
||||
n = sira[0]
|
||||
if "websocket" in (self.headers.get("Upgrade") or "").lower():
|
||||
# Bugunku calisan durum: surekli acik baglanti reddedilir, Codex olagan yola duser.
|
||||
self.send_response(426)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
return
|
||||
govde = self._oku()
|
||||
yol = self.path
|
||||
if yol.startswith("/aiturk/codex-baglama/"):
|
||||
return self._denetim(yol, govde)
|
||||
son = yol.split("?", 1)[0].rstrip("/")
|
||||
if self.command == "POST" and son.endswith("/responses"):
|
||||
model = model_oku(govde, self.headers.get("Content-Encoding"))
|
||||
if ek_model_mu(model):
|
||||
return self._ilet(n, KOPRU, yol, govde, f"KOPRU {model}", sessiz=True)
|
||||
return self._ilet(n, CHATGPT, yol, govde, f"OZGUN {model or '?'}")
|
||||
if self.command == "GET" and "/models" in yol:
|
||||
# Ek modeller listede gorunsun diye liste koprüden istenir; kopru cevap vermezse chatgpt.com'un listesi.
|
||||
if self._ilet(n, KOPRU, yol, govde, "LISTE kopru", yedekli=True, sessiz=True):
|
||||
return
|
||||
return self._ilet(n, CHATGPT, yol, govde, "LISTE ozgun", sessiz=True)
|
||||
return self._ilet(n, CHATGPT, yol, govde, "OZGUN diger", sessiz=True)
|
||||
|
||||
do_POST = _isle
|
||||
do_GET = _isle
|
||||
do_PUT = _isle
|
||||
do_DELETE = _isle
|
||||
do_PATCH = _isle
|
||||
|
||||
|
||||
class Sunucu(http.server.ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = False # kapi doluysa sessizce yan yana dinlemek yerine acikca hata
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sunucu = Sunucu(("127.0.0.1", PORT), H)
|
||||
except OSError as e:
|
||||
log(f"HATA: 127.0.0.1:{PORT} acilamadi ({e}); baska bir surec bu kapiyi kullaniyor olabilir.")
|
||||
sys.exit(1)
|
||||
log(f"ayirici :{PORT} Codex'in kendi modelleri -> {CHATGPT} ek modeller -> kopru :{KOPRU_PORT} (kod {KOD})")
|
||||
threading.Thread(target=KOPRU_SUREC.bekci, daemon=True).start()
|
||||
sunucu.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,360 @@
|
||||
"""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" ve "hepsi": gecidin uye katalogundaki OmniRoute sohbet modelleri.
|
||||
Yerel config'teki model adlari uye katalogunu sinirlamaz.
|
||||
"""
|
||||
import json, os, re, urllib.request
|
||||
|
||||
import pencere
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
EN_COK_MODEL = 300
|
||||
AITURK_EKI = " (AITURK)"
|
||||
# Gecidin kisa saglayici kodu -> kullanicinin gormek istedigi kod (10 Ekim 2026: "agy/gemini").
|
||||
_SAGLAYICI_KISA = {"ag": "agy"}
|
||||
# Saglayici kodu zaten soyledigi icin model adinin basinda tekrar edilmeyen uretici adi ("cc/opus-5-5").
|
||||
_TEKRAR = ("claude-",)
|
||||
DUSUNMESIZ = "no-think-"
|
||||
|
||||
|
||||
def kisa_ad(ad):
|
||||
"""Gecit onekleri ("OmniRoute · ", "9Router · ", "9R · ") ve aradaki saglayici halkalari atilir:
|
||||
"nano/qwen/qwen3.8-27b" -> "nano/qwen3.8-27b", "9R · ag/gemini-3.8-flash" -> "ag/gemini-3.8-flash",
|
||||
combo adi oldugu gibi kalir. Kullanici karari (10 Ekim 2026): "9R" yazilmaz."""
|
||||
ad = (ad or "").split(" · ")[-1].strip()
|
||||
parcalar = [p for p in ad.split("/") if p]
|
||||
if len(parcalar) <= 2:
|
||||
return "/".join(parcalar) or ad
|
||||
return parcalar[0] + "/" + parcalar[-1]
|
||||
|
||||
|
||||
def gorunen_ad(kimlik, ad, tur="model"):
|
||||
"""Secicide gorunen ad (kullanici karari, 10 Ekim 2026): combo yalniz kendi adiyla; tek model
|
||||
"saglayici/model" kalibinda, kisa ve surumlu: "9R · cc/claude-opus-5-5" -> "cc/opus-5-5",
|
||||
"9R · ag/gemini-3.8-flash-high" -> "agy/gemini-3.8-flash-high", "Agnes 2.0 Flash" (agnes-agnes-2.0-flash)
|
||||
-> "agnes/agnes-2.0-flash", "kurnaz" (bekci-kurnaz) -> "bekci/kurnaz". Dusunmesiz kopya asil
|
||||
saglayicisiyla gorunur: "no-think/claude-opus-5-5" -> "cc/opus-5-5 (no-think)"."""
|
||||
yalin = kisa_ad(ad)
|
||||
if tur == "combo":
|
||||
return yalin
|
||||
kim, ek = kimlik or "", ""
|
||||
if kim.startswith(DUSUNMESIZ):
|
||||
kim, ek = kim[len(DUSUNMESIZ):], " (no-think)"
|
||||
if kim.startswith("9router-"):
|
||||
kim = kim[len("9router-"):]
|
||||
saglayici, _, model = yalin.partition("/")
|
||||
if not model or ek:
|
||||
kod, _, kalan = kim.partition("-")
|
||||
if not kod or not kalan:
|
||||
return yalin
|
||||
saglayici, model = kod, model or kalan
|
||||
model = model.replace(" ", "-")
|
||||
for tekrar in _TEKRAR:
|
||||
if model.lower().startswith(tekrar) and len(model) > len(tekrar):
|
||||
model = model[len(tekrar):]
|
||||
return f"{_SAGLAYICI_KISA.get(saglayici.lower(), saglayici)}/{model}{ek}"
|
||||
|
||||
|
||||
def adlandir(girdiler, dolu=(), turler=None):
|
||||
"""[(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.
|
||||
`turler` ({kimlik: "combo"|"model"}) verilirse adlar gorunen_ad kuraliyla yazilir."""
|
||||
if turler is None:
|
||||
kisa = {k: kisa_ad(a) for k, a in girdiler}
|
||||
else:
|
||||
kisa = {k: gorunen_ad(k, a, turler.get(k, "model")) for k, a in girdiler}
|
||||
tam = {k: (a or "").split(" · ")[-1].strip() for k, a in girdiler}
|
||||
sayac = {}
|
||||
for v in list(kisa.values()) + list(dolu):
|
||||
sayac[v.lower()] = sayac.get(v.lower(), 0) + 1
|
||||
sonuc, kullanilan = {}, {d.lower() for d in dolu}
|
||||
for k, _ in girdiler:
|
||||
ad = kisa[k] if sayac[kisa[k].lower()] == 1 else tam[k]
|
||||
if ad.lower() in kullanilan:
|
||||
taban = ad
|
||||
for uzunluk in range(8, len(k) + 8, 4): # son 8 karakter de cakisirsa ek uzatilir: iki satir ayni adi tasimaz
|
||||
ad = f"{taban} [{k[-uzunluk:]}]"
|
||||
if ad.lower() not in kullanilan:
|
||||
break
|
||||
kullanilan.add(ad.lower())
|
||||
sonuc[k] = ad
|
||||
return sonuc
|
||||
|
||||
|
||||
def sec(data, tanimli=(), kapsam="ide", turler=None):
|
||||
"""Gecidin uye katalogundan girdiler: [{'id','ad','kind'}], sirali ve tekrarsiz.
|
||||
Yalniz sohbet modelleri; resim/ses/video/embedding uretenler eklenmez."""
|
||||
turler = turler or {}
|
||||
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
|
||||
kind = turler.get(mid) or ts.get("kind") or "model"
|
||||
# Tek uye yolu: 9Router -> OmniRoute -> TurkServis -> Codex.
|
||||
# Dogrudan 9Router satirlari ayni modelin ikinci kopyasi olarak eklenmez.
|
||||
if ts.get("source") != "omniroute":
|
||||
continue
|
||||
# Combo adi biciminden tahmin edilmez; duz adli Agnes/Bekci modelleri de vardir.
|
||||
if kind == "combo" and mid.lower().startswith("9router-"):
|
||||
continue
|
||||
gorulen.add(mid)
|
||||
secilen.append({"id": mid, "ad": gorunen, "kind": kind, "sira": ts.get("order") or 0,
|
||||
"yol": str(m.get("description") or "").split(" · ")[-1].strip()})
|
||||
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"], "kind": s["kind"], "yol": s["yol"]} for s in secilen[:EN_COK_MODEL]]
|
||||
|
||||
|
||||
def sirala_codex(modeller, ekler, ozgun_kimlikler=()):
|
||||
"""Ozgun abonelik modelleri oldugu gibi kalir: sirasi, priority degeri ve adi degismez,
|
||||
listenin basinda durur. Ek modeller onlarin ARKASINA eklenir: combo A-Z, sonra
|
||||
saglayici A-Z / model A-Z. Ozgun bir modelle ayni adi tasiyan ek model " (AITURK)"
|
||||
ekiyle ayirt edilir; secicide ozgunun yerine gecmis gibi gorunmez.
|
||||
Kimlik ve yetenek alanlari korunur; kayitli model secimine dokunulmaz.
|
||||
ozgun_kimlikler: ust listeden (chatgpt.com) gelen kimlikler. Ayarda ayni kimlik ek model diye yazilmis
|
||||
olsa da o model ozgundur; yerinden ve sirasindan oynamaz."""
|
||||
bilgiler = {m["slug"]: m for m in ekler if m["slug"] not in ozgun_kimlikler}
|
||||
ozgunler = [m for m in modeller if m.get("slug", "") not in bilgiler]
|
||||
ek_modeller = [m for m in modeller if m.get("slug", "") in bilgiler]
|
||||
def yalin(ad): # "GPT-6-1-Sol" ile "GPT-6.1-Sol" ayni ad sayilir
|
||||
return re.sub(r"[^0-9a-z]+", "", (ad or "").casefold())
|
||||
|
||||
ozgun_adlar = {yalin(m.get("display_name") or m.get("slug", "")) for m in ozgunler}
|
||||
|
||||
def anahtar(m):
|
||||
slug = m.get("slug", "")
|
||||
bilgi = bilgiler.get(slug, {})
|
||||
ad = kisa_ad(m.get("display_name") or slug)
|
||||
if bilgi.get("kind") == "combo":
|
||||
return (0, ad.casefold(), "", slug)
|
||||
if slug.startswith("ide/"):
|
||||
mid = slug[len("ide/"):]
|
||||
if mid.startswith("9router-"):
|
||||
mid = mid[len("9router-"):]
|
||||
saglayici, _, model = ad.partition("/")
|
||||
if not model:
|
||||
saglayici, _, _ = mid.partition("-")
|
||||
model = ad
|
||||
else:
|
||||
saglayici, model = "OpenAI", ad
|
||||
return (1, saglayici.casefold(), model.casefold(), slug)
|
||||
|
||||
ek_modeller.sort(key=anahtar)
|
||||
taban = max([m.get("priority") or 0 for m in ozgunler], default=-1) + 1
|
||||
for n, model in enumerate(ek_modeller):
|
||||
model["priority"] = taban + n
|
||||
ad = model.get("display_name") or model.get("slug", "")
|
||||
if yalin(ad) in ozgun_adlar:
|
||||
model["display_name"] = f"{ad}{AITURK_EKI}"
|
||||
modeller[:] = ozgunler + ek_modeller
|
||||
|
||||
|
||||
# ---- Secicide model adinin altindaki satir ----
|
||||
# Telefonda bu satir tek satirdir ve yaklasik 30 karakterde kesilir (kullanici ekran goruntusu,
|
||||
# 10 Ekim 2026); en ayirt edici bilgi basa yazilir, ad tekrar edilmez.
|
||||
ACIKLAMA_SINIRI = 30
|
||||
KUCUK_PENCERE = 100_000 # bunun altindaki pencere alt satirda yazilir
|
||||
_AILE = (("opus", "opus"), ("sonnet", "sonnet"), ("haiku", "haiku"), ("astra", "astra"), ("terra", "terra"),
|
||||
("luna", "luna"), ("sol", "sol"), ("gemini", "gemini"), ("qwen", "qwen"), ("glm", "glm"),
|
||||
("abliterated", "abliterated"), ("deepseek", "deepseek"), ("llama", "llama"), ("gemma", "gemma"))
|
||||
_SAGLAYICI = {"cc": "Claude", "cx": "Codex", "ag": "Antigravity", "agy": "Antigravity", "nano": "NanoGPT", "hf": "HuggingFace",
|
||||
"huggingface": "HuggingFace", "agnes": "Agnes", "bekci": "Bekçi"}
|
||||
_SANSURSUZ = re.compile(r"uncensored|abliterat|obliterat|derestrict|heretic|unshackled", re.I)
|
||||
|
||||
|
||||
def kisa_model(ad):
|
||||
""""cc/claude-opus-5-5" -> "opus", "nano/z-ai/glm-5.3-flash-uncensored" -> "glm"; bilinmeyen ad "?"."""
|
||||
if not ad:
|
||||
return "?"
|
||||
son = ad.split("/")[-1].split(":")[0].lower()
|
||||
parcalar = [p for p in re.split(r"[^a-z]+", son) if p]
|
||||
for anahtar, kisa in _AILE:
|
||||
if anahtar in parcalar:
|
||||
return kisa
|
||||
return (parcalar[0] if parcalar else son) or "?"
|
||||
|
||||
|
||||
def _yapraklar(dugum, derinlik=0):
|
||||
"""Sirali (priority) zinciri duz model listesine acar; ic takim kendi uyeleriyle yer alir."""
|
||||
if not isinstance(dugum, dict) or derinlik > 6:
|
||||
return ["?"]
|
||||
if dugum.get("kind") != "combo":
|
||||
return [kisa_model(dugum.get("model"))]
|
||||
cikti = []
|
||||
for uye in dugum.get("members") or []:
|
||||
cikti.extend(_yapraklar(uye, derinlik + 1))
|
||||
return cikti or ["?"]
|
||||
|
||||
|
||||
def _sigdir(onek, adlar, ayrac):
|
||||
"""Sinira sigdigi kadar ad yazar, kalani "+N" ile sayar."""
|
||||
tekil = [a for i, a in enumerate(adlar) if i == 0 or a != adlar[i - 1]]
|
||||
for adet in range(len(tekil), 0, -1):
|
||||
kalan = len(tekil) - adet
|
||||
metin = onek + ayrac.join(tekil[:adet]) + (f" +{kalan}" if kalan else "")
|
||||
if len(metin) <= ACIKLAMA_SINIRI or adet == 1:
|
||||
return metin
|
||||
return onek.rstrip(": ")
|
||||
|
||||
|
||||
def aciklama(kimlik, ad, tur, kayit=None, bilgi=None):
|
||||
"""Model adinin altinda gorunecek kisa satir. kayit: gecidin /router/catalog girdisi (yoksa None).
|
||||
Takim -> uyeleri; sirali yedek -> sirasi; tek model -> saglayicisi ve adindan okunan ozellikleri.
|
||||
bilgi (pencere.coz sonucu): Codex'te isi etkileyen iki uyari one yazilir: kucuk baglam ("32K")
|
||||
ve arac cagiramayan model ("araçsız")."""
|
||||
if tur == "combo":
|
||||
topoloji = (kayit or {}).get("topology") or {}
|
||||
uyeler = topoloji.get("members") or []
|
||||
if topoloji.get("strategy") == "round-robin":
|
||||
return f"Dönüşümlü: {len(uyeler)} üye"
|
||||
takim = topoloji if topoloji.get("strategy") == "fusion" else None
|
||||
if takim is None and uyeler and isinstance(uyeler[0], dict) and uyeler[0].get("strategy") == "fusion":
|
||||
takim = uyeler[0]
|
||||
if takim is not None:
|
||||
return _sigdir("Takım: ", [kisa_model(u.get("model")) for u in takim.get("members") or []], "+")
|
||||
if uyeler:
|
||||
return _sigdir("Sıralı: ", _yapraklar(topoloji), ">")
|
||||
return "Takım"
|
||||
kisa = gorunen_ad(kimlik, ad, "model")
|
||||
kod = kisa.split("/")[0].lower() if "/" in kisa else (kimlik or "").split("-")[0].lower()
|
||||
parcalar = [_SAGLAYICI.get(kod, kod) or "Tek model"]
|
||||
if bilgi and bilgi.get("pencere") and bilgi["pencere"] < KUCUK_PENCERE:
|
||||
parcalar.append(pencere.kisa(bilgi["pencere"]))
|
||||
if bilgi and bilgi.get("arac") is False:
|
||||
parcalar.append("araçsız")
|
||||
if _SANSURSUZ.search(kisa) or _SANSURSUZ.search(kimlik or ""):
|
||||
parcalar.append("sansürsüz")
|
||||
if "thinking" in (kisa + " " + (kimlik or "")).lower():
|
||||
parcalar.append("düşünen")
|
||||
if len(parcalar) == 1:
|
||||
parcalar.insert(0, "Tek model")
|
||||
while len(parcalar) > 1 and len(" · ".join(parcalar)) > ACIKLAMA_SINIRI:
|
||||
parcalar.pop() # sona yazilan en az onemli ozellik duser; kelime ortasindan kesilmez
|
||||
return " · ".join(parcalar)[:ACIKLAMA_SINIRI]
|
||||
|
||||
|
||||
def yukle(ua="AstraBridge/1.0 (codex)", timeout=30, home=None, ag=True):
|
||||
"""Donus: (durum, base_url, anahtar, girdiler). durum: 'kapali' | 'anahtar-yok' | 'tamam'.
|
||||
Ag hatasinda istisna atar; cagiran son listeyi korur. home: koprunun veri klasoru (pencere onbellegi).
|
||||
Her girdi "pencere" (ilan edilecek baglam penceresi), "gorsel" ve "arac" alanlarini tasir."""
|
||||
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})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
data = json.load(resp).get("data")
|
||||
if not isinstance(data, list) or not data:
|
||||
raise ValueError("Gecit bos veya gecersiz model katalogu dondurdu")
|
||||
req = urllib.request.Request(base + "/router/catalog", headers={"Authorization": f"Bearer {anahtar}", "User-Agent": ua})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
metadata = json.load(resp).get("models")
|
||||
if not isinstance(metadata, list) or not metadata:
|
||||
raise ValueError("Gecit model turlerini dondurmedi")
|
||||
# Bu ucta uye listesinde olmayan ic kayitlar da bulunur. Yalniz tur bilgisi alinir;
|
||||
# modele erisim listesi her zaman /models yanitidir.
|
||||
turler = {m["id"]: m["kind"] for m in metadata if m.get("id") and m.get("kind") in ("combo", "model")}
|
||||
girdiler = sec(data, adlar + [varsayilan], ayar["kapsam"], turler)
|
||||
if not girdiler:
|
||||
raise ValueError("Gecitte kullanilabilir OmniRoute sohbet modeli yok")
|
||||
kayitlar = {m["id"]: m for m in metadata if m.get("id")}
|
||||
nano = pencere.nano_yukle(home, ua, ag=ag) # ag=False: acilista yalniz onbellek
|
||||
ogr = pencere.ogrenilen(home)
|
||||
aracsiz = pencere.aracsizlar(home)
|
||||
for g in girdiler:
|
||||
bilgi = pencere.coz(g["id"], g["kind"], g.get("yol"), kayitlar.get(g["id"]), nano, ogr, aracsiz)
|
||||
g["pencere"], g["gorsel"], g["arac"], g["pencere_kaynak"] = (bilgi["pencere"], bilgi["gorsel"],
|
||||
bilgi["arac"], bilgi["kaynak"])
|
||||
g["claude"] = pencere.claude_var(g["kind"], g.get("yol"), kayitlar.get(g["id"]))
|
||||
g["aciklama"] = aciklama(g["id"], g["ad"], g["kind"], kayitlar.get(g["id"]), bilgi)
|
||||
return "tamam", base, anahtar, girdiler
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Ek modellere internet aramasi (11 Ekim 2026).
|
||||
|
||||
Kullanici karari (11 Ekim 2026, sesli): "Arastirma yapabilmesini istiyorum. Ayni Codex'te yapabildikleri gibi olmasini
|
||||
istiyorum." Codex'in yerlesik web aramasi OpenAI sunucusunda calistigi icin ek modele verilemez (kopru onu arac
|
||||
listesinden atar); olcum: ek model arama araci bulamayinca turu bos bitirdi. Kopru bunun yerine ek modele giden
|
||||
istege, aramayi kabuk araciyla yapan hazir komutu (ara.py) soyleyen kisa bir not ekler. Codex'in kendi modelleri bu
|
||||
notu hic gormez: onlarin istegi kopruye ugramaz.
|
||||
|
||||
Ayar (bridge.json, istege bagli): "internet": {"enabled": false} kapatir; {"talimat": "..."} metni degistirir.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
ISARET = "[İnternet araması]"
|
||||
VARSAYILAN = (
|
||||
ISARET + " Bu modelde yerleşik web araması yok; internetten bilgi gerektiğinde (güncel olay, araştırma, kaynak, "
|
||||
"fiyat, \"araştır\" isteği) kabuk aracınla şu iki komutu kullan:\n"
|
||||
"Arama: {ara} \"sorgu\"\n"
|
||||
"Sayfa okuma: {oku} <adres>\n"
|
||||
"Önce ara, sonra işe yarayan iki üç sonucu oku, cevabında kaynak adreslerini belirt. Başka bir arama aracı "
|
||||
"arama (tool_search ile bulunmaz). Arama hizmeti engeli veya ayrıştırma hatasında sorguları tekrar tekrar "
|
||||
"değiştirme; aramanın yapılamadığını açıkça söyle. Gerçek sonuç yoksa en fazla bir farklı sorgu dene. Bilgi uydurma."
|
||||
)
|
||||
|
||||
|
||||
def python_yolu(calisan=None):
|
||||
"""Modelin calistiracagi yorumlayici. Kopru penceresiz (pythonw) calisiyorsa cikti veren python secilir."""
|
||||
calisan = calisan or sys.executable or "python"
|
||||
klasor, ad = os.path.split(calisan)
|
||||
if ad.lower() == "pythonw.exe":
|
||||
aday = os.path.join(klasor, "python.exe")
|
||||
if os.path.isfile(aday):
|
||||
return aday
|
||||
return calisan
|
||||
|
||||
|
||||
def talimat(ayar, betik, python=None):
|
||||
"""Eklenecek metin; kapaliysa ya da betik yoksa None."""
|
||||
ayar = ayar if isinstance(ayar, dict) else {}
|
||||
if ayar.get("enabled") is False or not betik or not os.path.isfile(betik):
|
||||
return None
|
||||
on = f'& "{python or python_yolu()}" "{betik}"' if os.name == 'nt' else f'"{python or python_yolu()}" "{betik}"'
|
||||
return str(ayar.get("talimat") or VARSAYILAN).replace("{ara}", on + " ara").replace("{oku}", on + " oku")
|
||||
|
||||
|
||||
def ekle(req, metin):
|
||||
"""Notu istegin basina developer mesaji olarak koyar. True: eklendi (zaten varsa eklenmez)."""
|
||||
girdi = req.get("input")
|
||||
if not metin or not isinstance(girdi, list):
|
||||
return False
|
||||
for oge in girdi[:4]:
|
||||
icerik = oge.get("content") if isinstance(oge, dict) else None
|
||||
yazi = icerik if isinstance(icerik, str) else " ".join(str(p.get("text") or "") for p in icerik or [] if isinstance(p, dict))
|
||||
if ISARET in yazi:
|
||||
return False
|
||||
girdi.insert(0, {"type": "message", "role": "developer", "content": [{"type": "input_text", "text": metin}]})
|
||||
return True
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Codex IDE'ye "is kucuk degilse AITURK yapacak" talimati (11 Ekim 2026).
|
||||
|
||||
Kullanici karari: Codex Baglama acikken talimat Codex IDE'nin (masaustu uygulamasi) gordugu sistem talimatinda durur;
|
||||
Codex CLI talimati hic gormez, varligindan haberi olmaz. Karari secili model verir; kucuk modellerin yanilmasi kabul
|
||||
edilir, ayri bir yazilimla onune gecilmez.
|
||||
|
||||
Bu yuzden talimat hicbir dosyaya yazilmaz (AGENTS.md'yi CLI da okur): kopru, yalniz masaustu uygulamasindan gelen
|
||||
isteklere, istek gecerken ekler. Istemci `originator` basligindan ayirt edilir (olcum 11 Ekim 2026: masaustu
|
||||
"Codex Desktop", etkilesimli CLI "codex-tui", `codex exec` "codex_exec").
|
||||
|
||||
Hat, Codex Baglama'nin parcasidir: ayri bir dugmesi yoktur, bu bilgisayarda isi gonderebilen bir AITURK komutu
|
||||
(`aiturk is`) bulundugu surece aciktir. Komut yoksa talimat eklenmez: gonderme yolu olmayan bir "AITURK'e gonder"
|
||||
talimati modeli yaniltir. Ayar (bridge.json, istege bagli): "aiturk_hat": {"enabled": false} kapatir;
|
||||
{"komut": "..."} komutu elle verir; {"talimat": "..."} metni degistirir.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
MASAUSTU = {"codex desktop"}
|
||||
ISARET = "[AITURK iş hattı]"
|
||||
VARSAYILAN = (
|
||||
ISARET + " Bu Codex, AITURK'e bağlı. Şu kurala göre çalış:\n"
|
||||
"1. Basit soruyu, bilgi isteğini ya da tek küçük düzeltmeyi kendin yap.\n"
|
||||
"2. Kullanıcı \"sen yap\" derse işi kendin yap.\n"
|
||||
"3. İş küçük değilse (birden çok dosya, birkaç adım, yeni özellik, araştırma gerektiren iş) kendin yapma. "
|
||||
"İşin tam metnini (kullanıcının isteği ve gereken bağlam) UTF-8 bir dosyaya yaz, işi şu komutla AITURK'e gönder, "
|
||||
"bitmesini bekle ve sonucu kullanıcıya kısaca aktar:\n{komut}\n"
|
||||
"Komut \"işi sen yap\" derse ya da hata verirse işi kendin yap. Emin değilsen işi küçük say ve kendin yap."
|
||||
)
|
||||
|
||||
|
||||
def masaustu_mu(originator):
|
||||
return str(originator or "").strip().lower() in MASAUSTU
|
||||
|
||||
|
||||
def adaylar(ortam=None):
|
||||
"""Isi gonderebilecek AITURK komutu adaylari, oncelik sirasiyla (kurulu AITURK, PATH, hat deneme kurulumu)."""
|
||||
ortam = os.environ if ortam is None else ortam
|
||||
yerel = ortam.get("LOCALAPPDATA") or ""
|
||||
liste = []
|
||||
if yerel:
|
||||
liste.append(os.path.join(yerel, "TurkServis", "AITURK", "program", "bin", "aiturk.cmd"))
|
||||
bulunan = shutil.which("aiturk")
|
||||
if bulunan:
|
||||
liste.append(bulunan)
|
||||
if yerel:
|
||||
liste.append(os.path.join(yerel, "TurkServis", "AITURK-is-hatti", "Scripts", "aiturk.exe"))
|
||||
return [y for i, y in enumerate(liste) if y and y not in liste[:i]]
|
||||
|
||||
|
||||
def destekliyor(yol, calistir=subprocess.run):
|
||||
"""Bu AITURK `is` komutunu taniyor mu? (Eski surumler tanimaz: "No such command 'is'.")"""
|
||||
if not os.path.isfile(yol):
|
||||
return False
|
||||
try:
|
||||
sonuc = calistir([yol, "is", "--help"], capture_output=True, timeout=40, cwd=os.path.dirname(yol),
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
||||
return sonuc.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def komut_bul(ortam=None, destek=destekliyor):
|
||||
"""Isi gonderebilen ilk AITURK komutunun yolu; yoksa None."""
|
||||
for yol in adaylar(ortam):
|
||||
if destek(yol):
|
||||
return yol
|
||||
return None
|
||||
|
||||
|
||||
def komut_satiri(yol, sohbet=""):
|
||||
"""Modelin calistiracagi hazir satir. Sohbet kimligi kopruden gelir (sohbetler karismasin); klasor verilmez:
|
||||
komut Codex'in o anki klasorunde calisir, AITURK de isi ayni klasorde yapar."""
|
||||
sohbet = "".join(c for c in str(sohbet or "") if c.isalnum() or c in "-_")
|
||||
return f'"{yol}" is' + (f" --sohbet {sohbet}" if sohbet else "") + ' --dosya "<iş metnini yazdığın dosya>"'
|
||||
|
||||
|
||||
def talimat(ayar, bulunan=None, sohbet=""):
|
||||
"""Eklenecek metin; hat kapaliysa ya da gonderme komutu yoksa None.
|
||||
bulunan: bu bilgisayarda bulunan AITURK komutunun yolu (komut_bul); ayardaki "komut" onun onune gecer."""
|
||||
ayar = ayar if isinstance(ayar, dict) else {}
|
||||
if ayar.get("enabled") is False:
|
||||
return None
|
||||
komut = str(ayar.get("komut") or "").strip() or (komut_satiri(bulunan, sohbet) if bulunan else "")
|
||||
if not komut:
|
||||
return None
|
||||
return str(ayar.get("talimat") or VARSAYILAN).replace("{komut}", komut)
|
||||
|
||||
|
||||
def ekle(req, metin):
|
||||
"""Talimati istegin basina developer mesaji olarak koyar. True: eklendi (zaten varsa eklenmez)."""
|
||||
girdi = req.get("input")
|
||||
if not metin or not isinstance(girdi, list):
|
||||
return False
|
||||
for oge in girdi[:3]:
|
||||
icerik = oge.get("content") if isinstance(oge, dict) else None
|
||||
yazi = icerik if isinstance(icerik, str) else " ".join(str(p.get("text") or "") for p in icerik or [] if isinstance(p, dict))
|
||||
if ISARET in yazi:
|
||||
return False
|
||||
girdi.insert(0, {"type": "message", "role": "developer", "content": [{"type": "input_text", "text": metin}]})
|
||||
return True
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Katman denetimi: Jev, katman combosunun her adimdaki cevabini gecirir ya da gecirmez (11 Ekim 2026).
|
||||
|
||||
Kullanici karari (11 Ekim 2026): "Jev kati olsun. Af yok istisna yok. Herhangi bir model Jev'e gelirse Jev bunu
|
||||
gecirmezse 1.Katmana yonlendirecek. 1.Katman combosu gecmezse 2.Katmana, 2.Katman gecmezse 3.Katmana, 3.Katman
|
||||
gecmezse 4.Katmana. 4.Katman combosu gelirse Jev bunu sorgulamadan gecirecek."
|
||||
|
||||
Yani denetim secili modelin katman olup olmamasina bakmaz: kopruye ugrayan her ek modelin cevabi denetlenir.
|
||||
Gecmezse adimi sirayla Katman-1 -> Katman-2 -> Katman-3 -> Katman-4 yapar; secili model zaten bir katmansa
|
||||
yalniz ondan sonrakiler denenir. Katmanlar OmniRoute combolaridir (KATMAN); kopru onlari yalniz adiyla cagirir.
|
||||
|
||||
Jev (TypeSafe System One) metin uretmez, sorulan evet/hayir sorularina olasilik verir. Kural tektir ve istisnasi
|
||||
yoktur: cevap istegi yapmiyorsa (ret) ya da sormadan kendi sartlariyla degistiriyorsa (sart) adim gecmez.
|
||||
Metni olmayan (yalniz arac cagiran) adimda sorulacak cevap yoktur. Jev'e ulasilamazsa cevap oldugu gibi verilir.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.request
|
||||
|
||||
ADRES = "https://api.typesafe.ai/v1/systemone"
|
||||
MODEL = "jev-1.13.0"
|
||||
KATMAN = ("Katman-1", "Katman-2", "Katman-3", "Katman-4")
|
||||
ESIK = {"ret": 0.5, "sart": 0.7}
|
||||
SORU = {
|
||||
"ret": ("The assistant did NOT do what the user asked or did NOT answer the user's question: it refused, declined, "
|
||||
"said it cannot or will not, avoided giving the requested judgment, or redirected to a different topic."),
|
||||
"sart": ("The assistant changed the request on its own: it added its own conditions, softened or relabeled what was "
|
||||
"asked, or left part of it out, instead of doing exactly what the user asked."),
|
||||
}
|
||||
_ANAHTAR = {"okundu": False, "deger": ""}
|
||||
|
||||
|
||||
def sonrakiler(hedef):
|
||||
"""Jev gecirmezse sirayla denenecek katmanlar. Secili model bir katmansa ondan sonrakiler; katman degilse
|
||||
butun katmanlar bastan (once Katman-1). Son katman (Katman-4) kendisi gelince bos doner: sorgulanmadan gecer."""
|
||||
ad = str(hedef or "")
|
||||
if not ad or ad == KATMAN[-1]:
|
||||
return []
|
||||
if ad in KATMAN:
|
||||
return list(KATMAN[KATMAN.index(ad) + 1:])
|
||||
return list(KATMAN)
|
||||
|
||||
|
||||
def anahtar(ortam=None, dosyalar=None):
|
||||
"""TypeSafe anahtari: ortam degiskeni, sonra ~/.typesafe/typesafe.env. Yoksa ""."""
|
||||
ortam = os.environ if ortam is None else ortam
|
||||
deger = str(ortam.get("TYPESAFE_API_KEY") or "").strip()
|
||||
if deger:
|
||||
return deger
|
||||
if dosyalar is None:
|
||||
if _ANAHTAR["okundu"]:
|
||||
return _ANAHTAR["deger"]
|
||||
dosyalar = [os.path.join(os.path.expanduser("~"), ".typesafe", "typesafe.env")]
|
||||
onbellek = True
|
||||
else:
|
||||
onbellek = False
|
||||
for yol in dosyalar:
|
||||
try:
|
||||
with open(yol, encoding="utf-8", errors="replace") as f:
|
||||
for satir in f:
|
||||
m = re.match(r"^\s*TYPESAFE_API_KEY\s*=\s*(.*)$", satir)
|
||||
if m:
|
||||
deger = m.group(1).strip().strip('"').strip("'")
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
if deger:
|
||||
break
|
||||
if onbellek:
|
||||
_ANAHTAR.update(okundu=True, deger=deger)
|
||||
return deger
|
||||
|
||||
|
||||
def _metin(oge):
|
||||
icerik = oge.get("content")
|
||||
if isinstance(icerik, str):
|
||||
return icerik
|
||||
return "".join(str(p.get("text") or "") for p in icerik or [] if isinstance(p, dict))
|
||||
|
||||
|
||||
def son_istek(req):
|
||||
"""Kullanicinin bu turdaki istegi: son kullanici mesaji. Sesli aktarimda yalniz <input> icindeki soz;
|
||||
baska sarmalli sistem mesajlari (kanca baglami) atlanir."""
|
||||
for oge in reversed(req.get("input") or []):
|
||||
if not (isinstance(oge, dict) and oge.get("type", "message") == "message" and oge.get("role") == "user"):
|
||||
continue
|
||||
metin = _metin(oge).strip()
|
||||
m = re.search(r"<realtime_delegation>.*?<input>(.*?)</input>", metin, re.S)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
if metin and not metin.startswith("<"):
|
||||
return metin
|
||||
return ""
|
||||
|
||||
|
||||
def hazirlik_mi(govde):
|
||||
"""Araç çağrısı veya commentary görüldüyse adım nihai cevap değildir.
|
||||
|
||||
Tam SSE satırları üzerinden bakılır; parçalı olay bir sonraki parçada tamamlanır.
|
||||
Araç argümanında geçen olay adı hazırlık sayılmaz.
|
||||
"""
|
||||
for satir in govde.splitlines():
|
||||
if not satir.startswith(b'data:'):
|
||||
continue
|
||||
try:
|
||||
olay = json.loads(satir[5:])
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
continue
|
||||
if not isinstance(olay, dict):
|
||||
continue
|
||||
tip = str(olay.get('type') or '')
|
||||
oge = olay.get('item') or {}
|
||||
if tip in ('response.output_item.added', 'response.output_item.done') and isinstance(oge, dict):
|
||||
if str(oge.get('type') or '').endswith('_call') or oge.get('phase') == 'commentary':
|
||||
return True
|
||||
if tip in ('response.function_call_arguments.delta', 'response.function_call_arguments.done',
|
||||
'response.custom_tool_call_input.delta', 'response.custom_tool_call_input.done'):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def komutlar(govde):
|
||||
"""Yazisi olmayan adimda Jev'e giden metin: modelin bu adimda cagirdigi araclar (ad ve arguman).
|
||||
govde: ust kaynagin SSE yaniti (bayt). Arac cagrisi yoksa ""."""
|
||||
satirlar = []
|
||||
for satir in govde.splitlines():
|
||||
if not satir.startswith(b"data:") or b"response.output_item.done" not in satir:
|
||||
continue
|
||||
try:
|
||||
oge = json.loads(satir[5:]).get("item") or {}
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
if isinstance(oge, dict) and str(oge.get("type") or "").endswith("_call"):
|
||||
arguman = oge.get("arguments") or oge.get("input") or oge.get("action") or ""
|
||||
if not isinstance(arguman, str):
|
||||
arguman = json.dumps(arguman, ensure_ascii=False)
|
||||
satirlar.append(f"[komut] {oge.get('name') or oge.get('type')}: {arguman}")
|
||||
return "\n".join(satirlar)
|
||||
|
||||
|
||||
def _gonder(govde, key):
|
||||
istek = urllib.request.Request(ADRES, data=json.dumps(govde).encode("utf-8"), method="POST",
|
||||
headers={"Authorization": "Bearer " + key, "Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(istek) as yanit:
|
||||
return json.load(yanit)
|
||||
|
||||
|
||||
def sor(istek, cevap, key, gonder=_gonder):
|
||||
"""Jev'e sorar. Donus: {"ret", "sart"} olasiliklari | None (sorulamadi)."""
|
||||
govde = {"model": MODEL, "state": {"user_request": istek, "assistant_reply": cevap},
|
||||
"questions": {k: {"type": "noul", "instructions": v} for k, v in SORU.items()}}
|
||||
try:
|
||||
yanitlar = (gonder(govde, key) or {}).get("answers") or {}
|
||||
return {k: float(yanitlar[k]["noul"]) for k in SORU}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def karar(puan):
|
||||
"""True: adim gecmez, sonraki katman yapar. Istisna yok."""
|
||||
return puan["ret"] >= ESIK["ret"] or puan["sart"] >= ESIK["sart"]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,393 @@
|
||||
"""Sesle ya da yaziyla "modeli sunu yap" komutu ve sohbet basina model yonlendirmesi (10 Ekim 2026).
|
||||
|
||||
Olcum (sesli sohbet 01a12780): telefonda secilen model bilgisayara yalnizca YENI TUR baslatan yazili mesajla
|
||||
ulasiyor. Calisan tura eklenen mesaj (nokta dahil) secimi tasimiyor; dort denemenin ucunde secim kayboldu.
|
||||
Codex'in pencereler arasi kanali da sohbet bir pencerede acik degilse istegi kabul etmiyor. Bu yuzden degisiklik
|
||||
koprude yapilir: kullanici "modeli gemini flash low yap" der (sesli asistan bunu her soz gibi ana modele iletir),
|
||||
kopru cumleyi tanir, o sohbetin isteklerini secilen modele yonlendirir ve cevabi kendisi verir.
|
||||
|
||||
- Model bostayken: kopru yerel cevap verir ("Sohbetin modeli artik ...").
|
||||
- Model calisirken (tur ortasi): komut mesaji kisa bir devir notuna cevrilir ve AYNI tur yeni modelle surer;
|
||||
yarim kalan is kesilmez, yeni model kaldigi yerden devam eder.
|
||||
- Kullanici sonradan listeden baska model secerse (Codex'in gonderdigi ad degisir) yonlendirme kendiliginden kalkar.
|
||||
|
||||
Ad bulunamazsa mesaj komut sayilmaz, modele oldugu gibi gider. Birden cok aday varsa degisiklik yapilmaz, adaylar sorulur.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import collections
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
DOSYA = "model-yonlendirme.json"
|
||||
OMUR_SN = 7 * 24 * 3600
|
||||
YENILE_SN = 24 * 3600
|
||||
EN_COK_KAYIT = 200
|
||||
EN_COK_KELIME = 14
|
||||
_KILIT = threading.Lock()
|
||||
|
||||
_TR = str.maketrans("çğıöşüâîû", "cgiosuaiu")
|
||||
# Yalniz olumlu emir/istek bicimleri: "yapma", "gecme", "yapay", "secili" gibi sozler komut fiili degildir.
|
||||
FIILLER = {"gec", "gecir", "gecelim", "gecirelim", "gecer", "gecirir", "gecsene", "degis", "degistir", "degistirelim",
|
||||
"degistirir", "yap", "yapalim", "yapar", "yapsana", "sec", "secelim", "secer", "kullan", "kullanalim",
|
||||
"cevir", "cevirelim", "olsun", "ayarla", "ayarlayalim"}
|
||||
OLUMSUZ = ("yapma", "gecme", "gecirme", "degistirme", "secme", "kullanma", "cevirme", "olmasin", "ayarlama", "degisme")
|
||||
# "Hangi model daha ucuz?" genel sorudur; yalniz bu sohbetin modelini soran soz kopruden cevaplanir.
|
||||
SORU_ISARETI = {"secili", "aktif", "anki", "suanki", "sohbet", "sohbetin", "sohbette", "chat", "chatte", "chatin", "ana",
|
||||
"modelsin", "modeldesin", "modeldeyiz", "modeldeyim", "kullaniyorsun", "kullaniliyor", "calisiyor"}
|
||||
SORU = ("hangi", "hangisi", "kim", "kimdir")
|
||||
ATLA = {"model", "modeli", "modelini", "modele", "modeline", "modelin", "modelle", "modeller", "su", "bu", "o", "bir",
|
||||
"olarak", "ile", "lutfen", "artik", "ana", "sohbet", "sohbetin", "chat", "chatin", "chatte", "simdi", "hadi",
|
||||
"bana", "icin", "de", "da", "ve", "mi", "misin", "musun", "mu", "yeni", "diger", "baska", "olan", "codex",
|
||||
"ya", "e", "a", "i", "u", "yi", "ye", "na", "ne", "nu", "seni", "sen", "biz", "onu", "ona", "nokta"}
|
||||
# Yaziyla soylenen surum rakamlari ("opus bes bes", "uc nokta sekiz"). "bir" belirtec de olabildigi icin
|
||||
# ("bir model sec") yalniz baska bir rakamin ya da "nokta"nin yaninda rakam sayilir.
|
||||
SAYI = {"sifir": "0", "bir": "1", "iki": "2", "uc": "3", "dort": "4", "bes": "5", "alti": "6", "yedi": "7",
|
||||
"sekiz": "8", "dokuz": "9"}
|
||||
# Komutun ardindan gelen ve is tasimayan ek ("... yap ve devam et"): komutu dusurmez, atilir.
|
||||
DEVAM_EKI = {"ve", "sonra", "devam", "et", "edelim", "edin", "lutfen"}
|
||||
# Konusmadan yaziya ceviride sik gorulen yazimlar -> model adindaki parca
|
||||
TAKMA = {"cloud": "claude", "klod": "claude", "klaud": "claude", "claud": "claude", "opus": "opus", "opüs": "opus",
|
||||
"cemini": "gemini", "jemini": "gemini", "cemin": "gemini", "flas": "flash", "fles": "flash",
|
||||
"kuven": "qwen", "kiven": "qwen", "kuen": "qwen", "kven": "qwen", "kodeks": "codex",
|
||||
"dusuk": "low", "yuksek": "high", "orta": "medium", "sonet": "sonnet", "haiku": "haiku",
|
||||
"sansursuz": "uncensored", "ansansird": "uncensored", "dusunen": "thinking", "dusunmeli": "thinking",
|
||||
"ciptii": "gpt", "cipiti": "gpt", "gipiti": "gpt"}
|
||||
|
||||
|
||||
# Listede kisa yazilan saglayici -> konusurken soylenen ad
|
||||
SAGLAYICI = {"cc": ("claude",), "agy": ("gemini", "antigravity"), "ag": ("gemini", "antigravity"), "cx": ("codex", "gpt")}
|
||||
|
||||
|
||||
def duz(metin):
|
||||
return str(metin or "").lower().translate(_TR)
|
||||
|
||||
|
||||
def parcala(metin):
|
||||
"""Harf ve rakam obeklerine ayirir: "agy/gemini-3.8-flash-low" -> agy gemini 3 8 flash low."""
|
||||
return [TAKMA.get(p, p) for p in re.findall(r"[a-z]+|\d+", duz(metin))]
|
||||
|
||||
|
||||
def son_sira(girdi):
|
||||
"""Son kullanici mesajinin sirasi. Arkasindaki talimat (developer/system) mesajlari atlanir: kancalar
|
||||
kullanici mesajinin ardina baglam ekler (olcum 10 Ekim 2026). Baska bir oge varsa -1."""
|
||||
if not isinstance(girdi, list):
|
||||
return -1
|
||||
for i in range(len(girdi) - 1, -1, -1):
|
||||
oge = girdi[i]
|
||||
if isinstance(oge, dict) and oge.get("type", "message") == "message":
|
||||
if oge.get("role") == "user":
|
||||
return i
|
||||
if oge.get("role") in ("developer", "system"):
|
||||
continue
|
||||
return -1
|
||||
return -1
|
||||
|
||||
|
||||
def son_kullanici_metni(req):
|
||||
"""(metin, sira, sesli). Son girdi kullanici metin mesaji degilse (None, -1, False).
|
||||
Sesli aktarimda (<realtime_delegation>) yalnizca <input> icindeki soz alinir."""
|
||||
girdi = req.get("input")
|
||||
sira = son_sira(girdi)
|
||||
if sira < 0:
|
||||
return None, -1, False
|
||||
son = girdi[sira]
|
||||
icerik = son.get("content")
|
||||
if isinstance(icerik, str):
|
||||
metin = icerik
|
||||
elif isinstance(icerik, list) and icerik and all(isinstance(p, dict) and p.get("type") in ("input_text", "text")
|
||||
for p in icerik):
|
||||
metin = "".join(str(p.get("text") or "") for p in icerik)
|
||||
else:
|
||||
return None, -1, False
|
||||
m = re.search(r"<realtime_delegation>.*?<input>(.*?)</input>", metin, re.S)
|
||||
if m:
|
||||
return m.group(1).strip(), sira, True
|
||||
if "<" in metin and ">" in metin:
|
||||
return None, -1, False # baska bir sarmalli sistem mesaji; komut aranmaz
|
||||
return metin.strip(), sira, False
|
||||
|
||||
|
||||
def _sayilari_cevir(kelimeler):
|
||||
cikti = []
|
||||
for i, k in enumerate(kelimeler):
|
||||
if k in SAYI:
|
||||
komsu = [kelimeler[j] for j in (i - 1, i + 1) if 0 <= j < len(kelimeler)]
|
||||
if k != "bir" or any(x == "nokta" or x.isdigit() or (x in SAYI and x != "bir") for x in komsu):
|
||||
cikti.append(SAYI[k])
|
||||
continue
|
||||
cikti.append(k)
|
||||
return cikti
|
||||
|
||||
|
||||
DOLGU = {"evet", "tamam", "hadi", "peki", "tabii", "hayir", "yani", "sey", "ee", "eee", "hmm", "olur", "simdi"}
|
||||
|
||||
|
||||
def _harfleri_birlestir(kelimeler):
|
||||
"""Harf harf soylenen ad tek sozcuk olur: "s e e d iki nokta bir turbo" -> "seed iki nokta bir turbo"."""
|
||||
cikti, harfler = [], []
|
||||
for k in kelimeler + [""]:
|
||||
if len(k) == 1 and k.isalpha():
|
||||
harfler.append(k)
|
||||
continue
|
||||
if len(harfler) >= 3:
|
||||
cikti.append("".join(harfler))
|
||||
else:
|
||||
cikti.extend(harfler)
|
||||
harfler = []
|
||||
if k:
|
||||
cikti.append(k)
|
||||
# Ad hem heceleyerek hem duz soylendiyse ("seed s e e d ...") tekrar atilir.
|
||||
return [k for i, k in enumerate(cikti) if i == 0 or k != cikti[i - 1]]
|
||||
|
||||
|
||||
def komut_coz(metin):
|
||||
"""None | {"tur": "gec", "ad": [parcalar]} | {"tur": "sor"}."""
|
||||
kelimeler = _harfleri_birlestir(re.findall(r"[a-z0-9]+", duz(metin)))
|
||||
if not kelimeler or len(kelimeler) > EN_COK_KELIME:
|
||||
return None
|
||||
if not any(k.startswith("model") for k in kelimeler):
|
||||
# "modeli" denmeden soylenen komut ("Seed iki nokta bir turbo yap", "gemini flash low'a gec"): yalniz cumle
|
||||
# bir komut fiiliyle bitiyorsa ve ad bir modele TAM uyuyorsa komut sayilir (kesin=False: cagiran, ad
|
||||
# bulunamazsa mesaji komut saymaz, modele birakir).
|
||||
if any(k.startswith(OLUMSUZ) for k in kelimeler) or kelimeler[-1] not in FIILLER or len(kelimeler) > 9:
|
||||
return None
|
||||
kelimeler = _sayilari_cevir(kelimeler)
|
||||
ad = [TAKMA.get(k, k) for k in kelimeler[:-1] if k not in ATLA and k not in DOLGU]
|
||||
ad = [p for k in ad for p in re.findall(r"[a-z]+|\d+", k)]
|
||||
return {"tur": "gec", "ad": ad, "kesin": False} if len(ad) >= 2 else None
|
||||
if len(kelimeler) <= 8 and any(k in SORU for k in kelimeler):
|
||||
return {"tur": "sor"} if any(k in SORU_ISARETI for k in kelimeler) else None
|
||||
if any(k.startswith(OLUMSUZ) for k in kelimeler):
|
||||
return None
|
||||
fiil = [k for k in kelimeler if k in FIILLER]
|
||||
if fiil:
|
||||
son_fiil = max(i for i, k in enumerate(kelimeler) if k in FIILLER)
|
||||
if all(k in DEVAM_EKI for k in kelimeler[son_fiil + 1:]):
|
||||
kelimeler = kelimeler[:son_fiil + 1]
|
||||
kelimeler = _sayilari_cevir(kelimeler)
|
||||
ad = [TAKMA.get(k, k) for k in kelimeler if k not in ATLA and k not in fiil and k not in DOLGU]
|
||||
ad = [p for k in ad for p in re.findall(r"[a-z]+|\d+", k)]
|
||||
# Ad soylenmemis ("modeli değiştir", "başka modele geç"): degisiklik yok, nasil soylenecegi anlatilir.
|
||||
return {"tur": "gec", "ad": ad, "kesin": True} if ad else {"tur": "nasil"}
|
||||
return None
|
||||
|
||||
|
||||
def _uyar(sorgu, parca):
|
||||
"""Turkce ek almis soz ("geminiye", "opusa") model parcasiyla baslar."""
|
||||
if sorgu == parca:
|
||||
return True
|
||||
if len(parca) >= 3 and sorgu.startswith(parca) and len(sorgu) - len(parca) <= 4 and not sorgu.isdigit():
|
||||
return True
|
||||
# Yarim soylenen ya da yarim yaziya dokulen soz adin basidir: "think" -> thinking, "obli" -> obliterated
|
||||
# (olcum 11 Ekim 2026: "qwen 3.8 27b obli think" uc kez bulunamadi).
|
||||
return len(sorgu) >= 4 and sorgu.isalpha() and parca.startswith(sorgu)
|
||||
|
||||
|
||||
def eslestir(ad, modeller):
|
||||
"""ad: sorgu parcalari; modeller: [(slug, gorunen ad)].
|
||||
Doner: {"slug", "ad"} (tek aday) | {"adaylar": [gorunen adlar]} (belirsiz) | None (ad bir modele benzemiyor)."""
|
||||
if not ad:
|
||||
return None
|
||||
puanli = []
|
||||
for slug, gorunen in modeller:
|
||||
parcalar = parcala(gorunen) or parcala(slug)
|
||||
kume = set(parcalar) | set(parcala(slug))
|
||||
for kisa, uzunlar in SAGLAYICI.items():
|
||||
if kisa in kume:
|
||||
kume.update(uzunlar)
|
||||
tutan = sum(1 for s in ad if any(_uyar(s, p) for p in kume))
|
||||
harf = sum(1 for s in ad if not s.isdigit() and any(_uyar(s, p) for p in kume))
|
||||
if harf: # yalniz rakam tutmasi ("3", "5") esleme sayilmaz
|
||||
kendi = [p for p in parcalar if p not in SAGLAYICI and p not in ("nano", "ide", "9router")]
|
||||
hepsi = len(kendi) if kendi and all(any(_uyar(s, p) for s in ad) for p in kendi) else 0
|
||||
puanli.append((tutan, -len(parcalar), slug, gorunen, harf, hepsi))
|
||||
if not puanli:
|
||||
return None
|
||||
en = max(p[0] for p in puanli)
|
||||
harfler = [s for s in ad if not s.isdigit()]
|
||||
en_harf = max(p[4] for p in puanli)
|
||||
if en_harf < len(harfler):
|
||||
return None # adin bir sozcugu hicbir modelde yok: komut degil
|
||||
iyi = sorted((p for p in puanli if p[0] == en and p[4] == len(harfler)), key=lambda p: (-p[1], p[3]))
|
||||
if not iyi:
|
||||
return None
|
||||
if en < len(ad): # sozcukler tuttu ama surum rakami uymadi: sor, tahmin etme
|
||||
return {"adaylar": [p[3] for p in iyi[:4]]}
|
||||
if len(iyi) == 1:
|
||||
return {"slug": iyi[0][2], "ad": iyi[0][3]}
|
||||
tam = [p for p in iyi if p[5]] # adinin butun parcalari soylenen model
|
||||
if tam:
|
||||
# Ayni adi tasiyanlar arasinda sira: Codex'in kendi abonelik modeli, sonra adi en uzun (en ozgul) olan,
|
||||
# sonra saglayici/model bicimindeki tekil model. Hala esitse sorulur.
|
||||
def oncelik(p):
|
||||
return (p[2].startswith("ide/"), -p[5], "/" not in p[3])
|
||||
tam.sort(key=oncelik)
|
||||
if len(tam) == 1 or oncelik(tam[0]) != oncelik(tam[1]):
|
||||
return {"slug": tam[0][2], "ad": tam[0][3]}
|
||||
return {"adaylar": [p[3] for p in tam[:4]]}
|
||||
# Adaylardan biri digerlerinin "fazladan sozcuklu" hali degilse sorulur. Biri digerlerinin govdesiyse
|
||||
# ("...-obliterated" ile "...-obliterated:thinking") soylenmeyen eki tasimayan secilir: kullanici "thinking"
|
||||
# demediyse dusunmeyeni istiyordur (olcum 11 Ekim 2026). Fark surum rakamiysa tahmin edilmez, sorulur.
|
||||
sayim = [(collections.Counter(parcala(p[3]) or parcala(p[2])), p) for p in iyi]
|
||||
sayim.sort(key=lambda c: sum(c[0].values()))
|
||||
govde, secilen = sayim[0]
|
||||
if all(not (govde - c) and (c - govde) and not any(k.isdigit() for k in (c - govde)) for c, _ in sayim[1:]):
|
||||
return {"slug": secilen[2], "ad": secilen[3]}
|
||||
return {"adaylar": [p[3] for p in iyi[:4]]}
|
||||
|
||||
|
||||
_ARAC = {"function_call", "function_call_output", "custom_tool_call", "custom_tool_call_output", "reasoning",
|
||||
"local_shell_call", "local_shell_call_output", "tool_search_call", "tool_search_output"}
|
||||
|
||||
|
||||
def tur_ortasi(req, sira):
|
||||
"""Mesaj calisan bir turun icine mi dustu? Geriye dogru, kullanici/talimat mesajlarini atlayarak bakilir:
|
||||
once arac ogesi ya da ara (commentary) mesaj gelirse tur suruyordur; once son cevap gelirse tur yeni baslamistir."""
|
||||
girdi = req.get("input") or []
|
||||
for oge in reversed(girdi[:max(sira, 0)]):
|
||||
if not isinstance(oge, dict):
|
||||
continue
|
||||
tur = oge.get("type", "message")
|
||||
if tur in _ARAC:
|
||||
return True
|
||||
if tur == "message":
|
||||
if oge.get("role") == "assistant":
|
||||
return oge.get("phase") == "commentary"
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def metni_degistir(req, sira, yeni):
|
||||
"""sira'daki kullanici mesajinin metnini yeni ile degistirir (sesli aktarimda yalnizca <input> icini)."""
|
||||
oge = req["input"][sira]
|
||||
icerik = oge.get("content")
|
||||
eski = icerik if isinstance(icerik, str) else "".join(str(p.get("text") or "") for p in icerik)
|
||||
if "<realtime_delegation>" in eski:
|
||||
metin = re.sub(r"(<input>).*?(</input>)", lambda m: m.group(1) + yeni + m.group(2), eski, count=1, flags=re.S)
|
||||
else:
|
||||
metin = yeni
|
||||
oge["content"] = metin if isinstance(icerik, str) else [{"type": icerik[0].get("type", "input_text"), "text": metin}]
|
||||
|
||||
|
||||
def gecmisi_temizle(req, modeller, etkin=None):
|
||||
"""Gecmisteki (son mesaj disindaki) islenmis "modeli X yap" komutlarini kisa nota cevirir; yoksa sonraki
|
||||
model ayni cumleyi yeniden gorup "modeli ben degistiremem" diye cevap veriyor (olcum 11 Ekim 2026).
|
||||
etkin: sohbetin su an gercekten kullandigi model. Verilirse, en son kullanici mesajindaki komut (ardindan
|
||||
arac ciktisi ya da sikistirma tetigi geldigi icin "gecmis" gorunen) yalniz bu modele gecirdiyse nota cevrilir;
|
||||
uygulanmamis komut ("baglanti kapali" denen gibi) "degistirildi" diye anlatilmaz.
|
||||
Doner: degistirilen mesaj sayisi."""
|
||||
girdi = req.get("input")
|
||||
if not isinstance(girdi, list):
|
||||
return 0
|
||||
son = son_sira(girdi)
|
||||
son_kullanici = max((i for i, oge in enumerate(girdi) if isinstance(oge, dict)
|
||||
and oge.get("type", "message") == "message" and oge.get("role") == "user"), default=-1)
|
||||
n = 0
|
||||
for i, oge in enumerate(girdi):
|
||||
if i == son or not (isinstance(oge, dict) and oge.get("type", "message") == "message" and oge.get("role") == "user"):
|
||||
continue
|
||||
metin, _, _ = son_kullanici_metni({"input": [oge]})
|
||||
if not metin or "model" not in duz(metin) or metin.startswith("[Köprü notu"):
|
||||
continue
|
||||
komut = komut_coz(metin)
|
||||
sonuc = eslestir(komut["ad"], modeller) if komut and komut["tur"] == "gec" else None
|
||||
if sonuc and "slug" in sonuc:
|
||||
if etkin is not None and i == son_kullanici and sonuc["slug"] != etkin:
|
||||
continue
|
||||
metni_degistir(req, i, f"[Köprü notu: kullanıcı burada sohbetin modelini {sonuc['ad']} olarak değiştirdi; "
|
||||
"değişiklik köprü tarafından yapıldı, senin bir şey yapman gerekmiyor.]")
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
BILGI_ISARETI = "[Model değiştirme]"
|
||||
BILGI = (BILGI_ISARETI + " Bu sohbetin modeli söylenerek değiştirilebilir: kullanıcı \"modeli <ad> yap\" dediğinde "
|
||||
"(örnek: \"modeli gemini flash low yap\", \"modeli opus beş beş yap\") değişikliği aradaki köprü yapar; senin bir "
|
||||
"araç çağırman ya da ayar değiştirmen gerekmez. Kullanıcı modeli değiştirip değiştiremeyeceğini sorarsa "
|
||||
"\"değiştiremem\" deme; bu cümleyi söylemesinin yeterli olduğunu kısaca anlat. Hangi modelde olduğunu öğrenmek "
|
||||
"için \"seçili model hangisi\" demesi yeter. Codex'in kendi modellerine (listenin başındaki abonelik modelleri) "
|
||||
"sesle geçilmez: kullanıcı onlardan birine geçmek isterse ekrandaki model seçiciden seçip tek başına \".\" "
|
||||
"yazması gerektiğini söyle.")
|
||||
NASIL = ("Hangi modele geçeyim? Adını söylemen yeterli, örneğin: \"modeli gemini flash low yap\" ya da "
|
||||
"\"modeli opus beş beş yap\". Şu anki modeli öğrenmek için \"seçili model hangisi\" de. Codex'in kendi "
|
||||
"modellerine sesle geçilmez; onlar için ekrandaki model seçiciden seçip tek başına \".\" yaz.")
|
||||
|
||||
|
||||
def bilgi_ekle(req):
|
||||
"""Modele, sohbetin modelinin soylenerek degistirilebildigini bildirir (olcum 11 Ekim 2026: Opus "bu sohbetin
|
||||
modelini buradan değiştiremem" dedi). Istegin basina tek developer mesaji; zaten varsa eklenmez. True: eklendi."""
|
||||
girdi = req.get("input")
|
||||
if not isinstance(girdi, list) or not girdi:
|
||||
return False
|
||||
for oge in girdi[:4]:
|
||||
icerik = oge.get("content") if isinstance(oge, dict) else None
|
||||
yazi = icerik if isinstance(icerik, str) else " ".join(str(p.get("text") or "") for p in icerik or [] if isinstance(p, dict))
|
||||
if BILGI_ISARETI in yazi:
|
||||
return False
|
||||
girdi.insert(0, {"type": "message", "role": "developer", "content": [{"type": "input_text", "text": BILGI}]})
|
||||
return True
|
||||
|
||||
|
||||
def devir_notu(yeni_ad, eski_ad):
|
||||
return (f"[Köprü notu: kullanıcı bu sohbetin modelini değiştirdi. Önceki model {eski_ad} idi; şu andan itibaren "
|
||||
f"cevap veren model sensin ({yeni_ad}). Yarım kalan işi kaldığı yerden sürdür, baştan başlama. "
|
||||
f"Cevabının ilk cümlesinde kısaca \"{yeni_ad} olarak devam ediyorum\" de.]")
|
||||
|
||||
|
||||
class Yonlendirme:
|
||||
"""sohbet anahtari -> {"gorulen": Codex'in gonderdigi ad, "hedef": yonlenen ad, "zaman"}; diske yazilir."""
|
||||
|
||||
def __init__(self, home):
|
||||
self.yol = os.path.join(home, DOSYA) if home else None
|
||||
self.veri = {}
|
||||
if self.yol:
|
||||
try:
|
||||
with open(self.yol, encoding="utf-8") as f:
|
||||
okunan = json.load(f)
|
||||
if isinstance(okunan, dict):
|
||||
self.veri = {k: v for k, v in okunan.items() if isinstance(v, dict) and v.get("hedef")}
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
def _yaz(self):
|
||||
if not self.yol:
|
||||
return
|
||||
try:
|
||||
gecici = self.yol + ".tmp"
|
||||
with open(gecici, "w", encoding="utf-8") as f:
|
||||
json.dump(self.veri, f, ensure_ascii=False)
|
||||
os.replace(gecici, self.yol)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def al(self, sohbet, gelen, simdi=None):
|
||||
"""Bu sohbet icin yonlenen ad; yoksa None. Codex baska bir ad gonderiyorsa (kullanici listeden yeni
|
||||
model secti) ya da kayit eskidiyse yonlendirme silinir."""
|
||||
simdi = simdi or time.time()
|
||||
with _KILIT:
|
||||
k = self.veri.get(sohbet)
|
||||
if not k:
|
||||
return None
|
||||
if k.get("gorulen") != gelen or simdi - float(k.get("zaman") or 0) > OMUR_SN:
|
||||
del self.veri[sohbet]
|
||||
self._yaz()
|
||||
return None
|
||||
if simdi - float(k.get("zaman") or 0) > YENILE_SN:
|
||||
# Kullanilan yonlendirme eskimez: omur son kullanimdan sayilir (gunde en cok bir yazma).
|
||||
k["zaman"] = simdi
|
||||
self._yaz()
|
||||
return k["hedef"]
|
||||
|
||||
def koy(self, sohbet, gelen, hedef, simdi=None):
|
||||
simdi = simdi or time.time()
|
||||
with _KILIT:
|
||||
if hedef == gelen:
|
||||
self.veri.pop(sohbet, None)
|
||||
else:
|
||||
self.veri[sohbet] = {"gorulen": gelen, "hedef": hedef, "zaman": simdi}
|
||||
if len(self.veri) > EN_COK_KAYIT:
|
||||
for eski in sorted(self.veri, key=lambda a: self.veri[a].get("zaman") or 0)[:-EN_COK_KAYIT]:
|
||||
del self.veri[eski]
|
||||
self._yaz()
|
||||
@@ -0,0 +1,316 @@
|
||||
"""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.)")
|
||||
|
||||
|
||||
KISA_MESAJI = ("AstraBridge: Sohbet ozeti cok kisa cikti (%d karakter, en az %d bekleniyordu) ve gecmisin yerine "
|
||||
"konmadi; mesajlariniz sohbette duruyor. Tekrar deneyin ya da daha guclu bir model secin.")
|
||||
|
||||
|
||||
def en_az_karakter(girdi_bayt, ayar=None):
|
||||
"""Bu buyuklukteki bir konusmanin ozeti en az kac karakter olmali.
|
||||
Yalniz bozuk/bos sayilacak kadar kisa ozeti yakalar; ozetin yeterliligini boyuttan cikarmaz.
|
||||
Olcum (10 Ekim 2026, 646.955 baytlik gunluk + bes olgu): guclu modelin dogru ozeti 1500-1700
|
||||
karakterdi; boyuta oranli yuksek esik (2587) bu dogru ozeti reddedip sohbeti kilitledi.
|
||||
Hafiza kaybinin asil onlemi ozeti guclu modele yazdirmaktir (bridge.py _ozet_openai)."""
|
||||
ayar = ayar or {}
|
||||
tavan = int(ayar.get("en_az_tavan", 300))
|
||||
oran = max(1, int(ayar.get("en_az_oran", 2000))) # her 2000 bayt girdi icin 1 karakter
|
||||
return max(0, min(tavan, int(girdi_bayt) // oran))
|
||||
|
||||
|
||||
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="", en_az=0):
|
||||
self.ic = ic
|
||||
self.tohum = tohum
|
||||
self.en_az = en_az # bundan kisa ozet kabul edilmez (bkz. en_az_karakter)
|
||||
self.kisa = False
|
||||
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
|
||||
if len(self.metin) < self.en_az:
|
||||
# Anormal kisa ozet gecmisin yerine konmaz: Codex hatayi gosterir, sohbet oldugu gibi kalir.
|
||||
self.kisa = True
|
||||
yanit = dict(d.get("response") or {})
|
||||
yanit["status"] = "failed"; yanit["output"] = []
|
||||
yanit["error"] = {"code": "astrabridge_ozet_kisa", "message": KISA_MESAJI % (len(self.metin), self.en_az)}
|
||||
return _olay({"type": "response.failed", "response": yanit})
|
||||
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 sonuc_notu(self):
|
||||
"""Gunluk satiri icin sonuc (icerik yazilmaz)."""
|
||||
if not self.metin:
|
||||
return "ozet URETILEMEDI"
|
||||
if self.kisa:
|
||||
return f"ozet REDDEDILDI: {len(self.metin)} karakter, en az {self.en_az} bekleniyordu; gecmis korunuyor"
|
||||
return f"ozet uretildi, {len(self.metin)} karakter"
|
||||
|
||||
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. Yanit tamamlanmadiysa (akis yarida kesildi) None doner:
|
||||
yarim metin tam ozet diye kalici onbellege yazilmasin."""
|
||||
a = OzetAkisi()
|
||||
a.feed(govde if govde.endswith(b"\n\n") else govde + b"\n\n")
|
||||
return a.metin if a.bitti else None
|
||||
|
||||
|
||||
# ---- 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:
|
||||
"""Ozet dongusunu durdurur, mesru sik sikistirmayi durdurmaz (10 Ekim 2026: kucuk esikli sohbette
|
||||
10 dakikadaki 4. ozet reddedilip tur dusuyordu). Iki ayri sinir:
|
||||
- art_arda: arada normal (ozet olmayan) istek gecmeden ust uste gelen ozet sayisi; gercek dongu budur.
|
||||
- sinir: pencere_sn icindeki toplam ozet sayisi; her ozetin arasina normal istek giren yavas donguyu keser."""
|
||||
|
||||
def __init__(self, sinir=8, pencere_sn=600, art_arda=3):
|
||||
self.sinir = sinir
|
||||
self.pencere = pencere_sn
|
||||
self.art_arda = art_arda
|
||||
self.kayit = {}
|
||||
self.ust_uste = {}
|
||||
|
||||
def normal(self, anahtar):
|
||||
"""Bu sohbette ozet olmayan bir istek gecti: art arda sayaci sifirlanir."""
|
||||
self.ust_uste.pop(anahtar, None)
|
||||
|
||||
def izin(self, anahtar, simdi):
|
||||
"""Bu ozet istegine izin var mi? Varsa kaydeder."""
|
||||
zamanlar = [t for t in self.kayit.get(anahtar, []) if simdi - t < self.pencere]
|
||||
if not zamanlar:
|
||||
self.ust_uste.pop(anahtar, None) # pencere suresi doldu: eski art arda sayaci sohbeti kilitlemesin
|
||||
if len(zamanlar) >= self.sinir or self.ust_uste.get(anahtar, 0) >= self.art_arda:
|
||||
self.kayit[anahtar] = zamanlar
|
||||
return False
|
||||
zamanlar.append(simdi)
|
||||
self.kayit[anahtar] = zamanlar
|
||||
self.ust_uste[anahtar] = self.ust_uste.get(anahtar, 0) + 1
|
||||
if len(self.kayit) > 500: # bellek sismesin
|
||||
for k in sorted(self.kayit, key=lambda k: (self.kayit[k] or [0])[-1])[:250]:
|
||||
self.kayit.pop(k, None)
|
||||
self.ust_uste.pop(k, None)
|
||||
return True
|
||||
@@ -0,0 +1,368 @@
|
||||
"""Eklenen modellerin gercek baglam penceresi ve yetenekleri (10 Ekim 2026).
|
||||
|
||||
Neden: gecit model basina pencere vermiyor; kopru her modele abonelik kalibinin penceresini (272000)
|
||||
kopyaliyordu. Codex, kucuk pencereli modele geciste sohbeti kendisi sikistirir, ama bunu ilan edilen
|
||||
pencereye gore yapar (core/src/session/turn.rs maybe_run_previous_model_inline_compact). Pencere dogru
|
||||
ilan edilince o yol kendiliginden calisir; yanlis ilan edilince istek hedefe sigmaz.
|
||||
|
||||
Kaynak sirasi (kucukten guvenilire degil, guvenilirden yedege):
|
||||
1. Hedefin kendi hatasindan ogrenilen sinir (ogren / ogrenilen): en guvenilir, diske yazilir.
|
||||
2. NanoGPT'nin acik model listesi (nano/... modelleri icin istegi tasiyan saglayicinin kendi degeri).
|
||||
3. Saglayici kisaltmasina gore yerel tablo (cc, cx, ag).
|
||||
4. Bilinmeyen: VARSAYILAN (temkinli) ve bir kez gunluk satiri.
|
||||
Combo: yapraklarinin en kucugu (yedege dusulurse de sigsin).
|
||||
Ust sinir TAVAN: ozeti abonelik modeli yazdigi icin gecmis onun penceresini asmamali.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
TAVAN = 272_000 # abonelik modellerinin penceresi; ozetleyen model bundan fazlasini okuyamaz
|
||||
VARSAYILAN = 128_000 # hakkinda hicbir sey bilinmeyen model
|
||||
TABAN = 8_000 # saglayici verisi bozuksa (0, eksi) kullanilmaz
|
||||
NANO_URL = "https://nano-gpt.com/api/v1/models?detailed=true"
|
||||
NANO_OMUR_SN = 24 * 3600
|
||||
NANO_DOSYA = "pencere-nano.json"
|
||||
OGRENILEN_DOSYA = "pencere-ogrenilen.json"
|
||||
ARACSIZ_DOSYA = "aracsiz-modeller.json"
|
||||
# Saglayici kisaltmasi -> (pencere, gorsel girdi, arac cagrisi). cc = Claude Code aboneligi, cx = Codex
|
||||
# aboneligi, ag = Antigravity. Degerler abonelik yollarinin temkinli sinirlaridir.
|
||||
_YEREL = (
|
||||
(re.compile(r"^cx/"), (272_000, True, True)),
|
||||
(re.compile(r"^cc/"), (200_000, True, True)),
|
||||
(re.compile(r"^ag/claude"), (200_000, True, True)),
|
||||
(re.compile(r"^ag/gemini"), (1_000_000, True, True)),
|
||||
(re.compile(r"^ag/gpt-oss"), (131_072, False, True)),
|
||||
)
|
||||
# "maximum context length is 131072 tokens", "prompt is too long: 3040251 tokens > 1000000 maximum",
|
||||
# "context window of 32768 tokens", "exceeds the limit of 65536" ...
|
||||
_SINIR = (
|
||||
re.compile(r"maximum context length (?:is|of) (\d{4,8})", re.I),
|
||||
re.compile(r"\d{4,9} tokens? > (\d{4,8}) maximum", re.I),
|
||||
re.compile(r"context (?:window|length|size)(?: limit)? (?:is|of) (\d{4,8})", re.I),
|
||||
re.compile(r"(?:limit|maximum) of (\d{4,8}) tokens", re.I),
|
||||
re.compile(r"max(?:imum)? context tokens\D{0,6}(\d{4,8})", re.I),
|
||||
)
|
||||
_TASMA = re.compile(r"context[_ ](?:length|window)[_ ]exceeded|maximum context length|prompt is too long|"
|
||||
r"context window|too many tokens|input is too long|exceeds? the (?:maximum |model.s )?context|max(?:imum)? context tokens", re.I)
|
||||
|
||||
|
||||
def _oku(yol):
|
||||
try:
|
||||
with open(yol, encoding="utf-8") as f:
|
||||
v = json.load(f)
|
||||
return v if isinstance(v, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _yaz(yol, veri):
|
||||
gecici = "%s.%d.tmp" % (yol, threading.get_ident()) # es zamanli iki yazma ayni gecici dosyada carpismasin
|
||||
with open(gecici, "w", encoding="utf-8") as f:
|
||||
json.dump(veri, f, ensure_ascii=False)
|
||||
os.replace(gecici, yol)
|
||||
|
||||
|
||||
def nano_sadelestir(ham):
|
||||
"""NanoGPT yaniti -> {model kimligi (kucuk harf): {"pencere", "gorsel", "arac"}}."""
|
||||
cikti = {}
|
||||
for m in (ham or {}).get("data") or []:
|
||||
kimlik = str(m.get("id") or "").lower()
|
||||
pencere = m.get("context_length")
|
||||
if not kimlik or not isinstance(pencere, int) or pencere < TABAN:
|
||||
continue
|
||||
yetenek = m.get("capabilities") or {}
|
||||
cikti[kimlik] = {"pencere": pencere, "gorsel": bool(yetenek.get("vision")),
|
||||
"arac": bool(yetenek.get("tool_calling"))}
|
||||
return cikti
|
||||
|
||||
|
||||
def nano_yukle(home, ua="AstraBridge/1.0 (codex)", timeout=20, simdi=None, getir=None, ag=True):
|
||||
"""Onbellek tazeyse onu, degilse agdan yenisini dondurur. Ag hatasinda eski onbellek (yasina bakilmadan)
|
||||
kullanilir; o da yoksa bos sozluk doner ve cagiran yerel tabloya duser. Hicbir durumda istisna atmaz.
|
||||
ag=False: aga cikilmaz, eldeki onbellek doner (koprunun acilisi beklemesin diye)."""
|
||||
simdi = time.time() if simdi is None else simdi
|
||||
yol = os.path.join(home, NANO_DOSYA) if home else ""
|
||||
onbellek = _oku(yol) if yol else {}
|
||||
try:
|
||||
taze = simdi - float(onbellek.get("zaman") or 0) < NANO_OMUR_SN
|
||||
except (TypeError, ValueError):
|
||||
taze = False # bozuk "zaman" alani yenilemeyi engellemez
|
||||
if onbellek.get("modeller") and taze:
|
||||
return onbellek["modeller"]
|
||||
if not ag:
|
||||
return onbellek.get("modeller") or {}
|
||||
try:
|
||||
if getir is None:
|
||||
istek = urllib.request.Request(NANO_URL, headers={"User-Agent": ua, "Accept": "application/json"})
|
||||
with urllib.request.urlopen(istek, timeout=timeout) as resp:
|
||||
ham = json.load(resp)
|
||||
else:
|
||||
ham = getir()
|
||||
modeller = nano_sadelestir(ham)
|
||||
if modeller:
|
||||
if yol:
|
||||
try:
|
||||
_yaz(yol, {"zaman": simdi, "modeller": modeller})
|
||||
except OSError:
|
||||
pass
|
||||
return modeller
|
||||
except Exception: # noqa: BLE001 - ag, JSON, sertifika: hepsi "yerel tabloya dus" demek
|
||||
pass
|
||||
return onbellek.get("modeller") or {}
|
||||
|
||||
|
||||
def ogrenilen(home):
|
||||
return {k: int(v) for k, v in _oku(os.path.join(home, OGRENILEN_DOSYA)).items()
|
||||
if isinstance(v, (int, float)) and v >= TABAN} if home else {}
|
||||
|
||||
|
||||
def aracsizlar(home):
|
||||
"""Hedefin "arac cagrisi desteklenmiyor" dedigi modeller (kimlik kumesi)."""
|
||||
return {k for k, v in _oku(os.path.join(home, ARACSIZ_DOSYA)).items() if v} if home else set()
|
||||
|
||||
|
||||
def aracsiz_mi(metin):
|
||||
return bool(re.search(r"does not support tool|tool_choice_unsupported|tools? (?:are|is) not supported|"
|
||||
r"function calling is not supported", metin or "", re.I))
|
||||
|
||||
|
||||
def ogren_aracsiz(home, kimlik):
|
||||
"""True: kayit yeni eklendi."""
|
||||
if not home or not kimlik:
|
||||
return False
|
||||
yol = os.path.join(home, ARACSIZ_DOSYA)
|
||||
veri = _oku(yol)
|
||||
if veri.get(kimlik):
|
||||
return False
|
||||
veri[kimlik] = True
|
||||
try:
|
||||
_yaz(yol, veri)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def kapali_hata_mi(metin):
|
||||
"""Gecit asil nedeni gizleyip yalniz "upstream 400" derse (olcum 11 Ekim 2026)."""
|
||||
return bool(re.fullmatch(r"\s*upstream 4(?:00|13)\s*", metin or "", re.I))
|
||||
|
||||
|
||||
_HIZ_SINIRI = re.compile(r"per minute|rate.?limit|tokens? per|\bTPM\b|\b429\b|too many requests", re.I)
|
||||
|
||||
|
||||
def hiz_siniri_mi(metin):
|
||||
"""Dakikalik hiz siniri hatasi baglam tasmasi degildir ("rate limit of 32768 tokens per minute")."""
|
||||
return bool(_HIZ_SINIRI.search(metin or ""))
|
||||
|
||||
|
||||
def tasma_mi(metin):
|
||||
return bool(_TASMA.search(metin or "")) and not hiz_siniri_mi(metin)
|
||||
|
||||
|
||||
def sinir_oku(metin):
|
||||
"""Hedefin tasma hatasindaki gercek sinir; yoksa None."""
|
||||
if hiz_siniri_mi(metin):
|
||||
return None
|
||||
for kalip in _SINIR:
|
||||
m = kalip.search(metin or "")
|
||||
if m and int(m.group(1)) >= TABAN:
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def ogren(home, kimlik, sinir):
|
||||
"""Hedefin bildirdigi siniri kaydeder (yalniz kucultur). True: kayit degisti."""
|
||||
if not home or not kimlik or not sinir or sinir < TABAN:
|
||||
return False
|
||||
yol = os.path.join(home, OGRENILEN_DOSYA)
|
||||
veri = _oku(yol)
|
||||
if isinstance(veri.get(kimlik), (int, float)) and veri[kimlik] <= sinir:
|
||||
return False
|
||||
veri[kimlik] = int(sinir)
|
||||
try:
|
||||
_yaz(yol, veri)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def yol_bilgisi(yol, nano):
|
||||
""""nano/z-ai/glm-5.3" ya da "cc/claude-opus-5-5" -> {"pencere","gorsel","arac","kaynak"} ya da None."""
|
||||
yol = (yol or "").strip()
|
||||
if yol.startswith("9router/"):
|
||||
yol = yol[len("9router/"):]
|
||||
if yol.startswith("nano/"):
|
||||
n = (nano or {}).get(yol[len("nano/"):].lower())
|
||||
if n:
|
||||
return {**n, "kaynak": "nanogpt"}
|
||||
return None
|
||||
for kalip, (pencere, gorsel, arac) in _YEREL:
|
||||
if kalip.match(yol):
|
||||
return {"pencere": pencere, "gorsel": gorsel, "arac": arac, "kaynak": "yerel"}
|
||||
return None
|
||||
|
||||
|
||||
def _yapraklar(dugum, derinlik=0):
|
||||
if not isinstance(dugum, dict) or derinlik > 6:
|
||||
return []
|
||||
if dugum.get("kind") != "combo":
|
||||
return [dugum.get("model")] if dugum.get("model") else []
|
||||
cikti = []
|
||||
for uye in dugum.get("members") or []:
|
||||
cikti.extend(_yapraklar(uye, derinlik + 1))
|
||||
return cikti
|
||||
|
||||
|
||||
def coz(kimlik, tur, yol, kayit=None, nano=None, ogr=None, aracsiz=()):
|
||||
"""Bir katalog girdisinin ilan edilecek penceresi ve yetenekleri.
|
||||
kimlik: gecit kimligi; tur: "combo" | "model"; yol: gecidin aciklamasindaki "saglayici/model" yolu;
|
||||
kayit: /router/catalog girdisi (combo topolojisi icin). Donus: {"pencere","gorsel","arac","kaynak"}."""
|
||||
bilinmeyen = {"pencere": VARSAYILAN, "gorsel": False, "arac": True, "kaynak": "varsayilan"}
|
||||
if tur == "combo":
|
||||
yapraklar = _yapraklar((kayit or {}).get("topology"))
|
||||
bilgiler = [yol_bilgisi(y, nano) or dict(bilinmeyen) for y in yapraklar]
|
||||
if not bilgiler:
|
||||
sonuc = dict(bilinmeyen)
|
||||
else:
|
||||
sonuc = {"pencere": min(b["pencere"] for b in bilgiler),
|
||||
# Bir takim uyesi resmi goremese de digerleri gorur; sirali yedekte ilk uye belirler.
|
||||
"gorsel": bool(bilgiler[0]["gorsel"]), "arac": bool(bilgiler[0]["arac"]),
|
||||
"kaynak": "uyeler:" + ",".join(sorted({b["kaynak"] for b in bilgiler}))}
|
||||
else:
|
||||
if (yol or "").startswith("no-think/"):
|
||||
# Dusunmesiz kopya: asil saglayici kimlikten okunur ("no-think-9router-cc-claude-..." -> cc).
|
||||
m = re.match(r"no-think-(?:9router-)?([a-z0-9]+)-", kimlik or "")
|
||||
yol = (m.group(1) + "/" + yol[len("no-think/"):]) if m else yol
|
||||
sonuc = yol_bilgisi(yol, nano) or dict(bilinmeyen)
|
||||
if kimlik in aracsiz:
|
||||
sonuc = {**sonuc, "arac": False}
|
||||
sinir = (ogr or {}).get(kimlik)
|
||||
if sinir and sinir < sonuc["pencere"]:
|
||||
sonuc = {**sonuc, "pencere": int(sinir), "kaynak": "ogrenilen"}
|
||||
sonuc["pencere"] = max(TABAN, min(TAVAN, int(sonuc["pencere"])))
|
||||
return sonuc
|
||||
|
||||
|
||||
def claude_var(tur, yol, kayit=None):
|
||||
"""Istek Claude'a gidebilir mi? Tek modelde yolundan, comboda yapraklarindan okunur (Opus-5 gibi combolarin
|
||||
adinda "claude" gecmez; token tahmini yine Claude oraniyla yapilmalidir)."""
|
||||
yollar = _yapraklar((kayit or {}).get("topology")) if tur == "combo" else [yol]
|
||||
for y in yollar:
|
||||
y = str(y or "").lower()
|
||||
if y.startswith("9router/"):
|
||||
y = y[len("9router/"):]
|
||||
if "claude" in y or y.startswith("cc/"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def bekci_calisir(kaynak, pencere_token):
|
||||
"""Tasma bekcisi bu pencereye guvenebilir mi? Hakkinda hicbir sey bilinmeyen modelde calismaz (tahmine
|
||||
dayanarak gecmis kirpilmaz). Comboda tek bilinmeyen uye, bilinen KUCUK uyenin sinirini gecersiz kilmaz:
|
||||
ilan edilen pencere VARSAYILAN'dan kucukse o deger bilinen bir uyeden gelmistir."""
|
||||
kaynak = kaynak or "varsayilan"
|
||||
if "varsayilan" not in kaynak:
|
||||
return True
|
||||
return kaynak.startswith("uyeler:") and kaynak != "uyeler:varsayilan" and int(pencere_token or 0) < VARSAYILAN
|
||||
|
||||
|
||||
def kisa(pencere):
|
||||
"""8192 -> "8K", 131072 -> "128K", 272000 -> "272K"."""
|
||||
return f"{round(pencere / 1024) if pencere < 262_144 and pencere % 1024 == 0 else round(pencere / 1000)}K"
|
||||
|
||||
|
||||
# ---- Istek oncesi tasma bekcisi ----
|
||||
# Codex sikistirmadan sonra kullanici mesajlarini AYNEN tutar; cok buyuk bir yapistirma (ya da dev arac
|
||||
# ciktisi) kucuk pencereli hedefe yine sigmaz ve hedef bos akisla doner (10 Ekim 2026 olcumu: 390 KB istek,
|
||||
# 64K pencere, Codex 5 kez yeniden deneyip "stream closed" ile dustu). Bekci, hedefe giden KOPYADA en buyuk
|
||||
# eski metin parcalarinin ortasini cikarir; yine sigmazsa istegi gondermeden anlasilir hata doner.
|
||||
BAYT_BOLU_TOKEN = 3.2 # olcum (10 Ekim 2026): 540 KB duz Turkce metin = 171016 token (3,16 bayt/token)
|
||||
DOLULUK = 0.97 # pencerenin bu oranini asan istek kirpilir / gonderilmez
|
||||
KIRP_ESIK = 6000 # bundan kisa metin parcasi kirpilmaz
|
||||
KIRP_BAS = KIRP_SON = 1500
|
||||
|
||||
|
||||
# Claude ayni metni daha cok tokena boler. Olcum (11 Ekim 2026): 607 KB'lik gercek Codex istegi 3,2 oraniyla
|
||||
# 189776 token tahmin edildi (pencere 200000) ve Claude tarafindan reddedildi; yani gercek oran 3,03'un altinda.
|
||||
CLAUDE_BAYT_BOLU_TOKEN = 2.6
|
||||
|
||||
|
||||
def oran_sec(hedef, claude=False):
|
||||
"""claude=True: hedefin adinda gecmese de (combo) istek Claude'a gidebilir (bkz. claude_var)."""
|
||||
return CLAUDE_BAYT_BOLU_TOKEN if (claude or "claude" in str(hedef or "").lower()) else BAYT_BOLU_TOKEN
|
||||
|
||||
|
||||
def tahmini_token(req, oran=None):
|
||||
return int(len(json.dumps(req, ensure_ascii=False).encode("utf-8")) / (oran or BAYT_BOLU_TOKEN))
|
||||
|
||||
|
||||
KOPRU_DEVAM_BASI = "(Önceki cevabın yarıda kaldı" # bridge.py claude_sonu_duzelt'in ekledigi mesajin basi
|
||||
|
||||
|
||||
def _kopru_mesaji(oge):
|
||||
icerik = oge.get("content")
|
||||
if isinstance(icerik, list):
|
||||
icerik = "".join(str(p.get("text") or "") for p in icerik if isinstance(p, dict))
|
||||
return isinstance(icerik, str) and icerik.startswith(KOPRU_DEVAM_BASI)
|
||||
|
||||
|
||||
def _son_arac_ciktisi(req):
|
||||
"""En yeni arac ciktisinin metin sahipleri (kimlik kumesi): model bir sonraki adimi buna gore atar,
|
||||
bu yuzden ancak baska hicbir sey yetmezse kirpilir."""
|
||||
for it in reversed(req.get("input") or []):
|
||||
if isinstance(it, dict) and "output" in it and str(it.get("type") or "").endswith("_output"):
|
||||
deger = it.get("output")
|
||||
return {id(it)} if isinstance(deger, str) else {id(p) for p in deger or [] if isinstance(p, dict)}
|
||||
return set()
|
||||
|
||||
|
||||
def _metin_parcalari(req):
|
||||
"""Kirpilabilir (sahip, anahtar) ciftleri: arac ciktilari ile eski kullanici/asistan mesajlari.
|
||||
Son kullanici mesajina ve talimat tasiyan developer/system mesajlarina (Codex kurallari, skill
|
||||
yonergeleri, kanca baglami) dokunulmaz."""
|
||||
girdi = req.get("input")
|
||||
if not isinstance(girdi, list):
|
||||
return []
|
||||
# Koprunun sona ekledigi "devam et" mesaji kullanicinin son mesaji degildir; korunan, ondan onceki gercek mesajdir.
|
||||
son_kullanici = max((i for i, it in enumerate(girdi) if isinstance(it, dict) and it.get("role") == "user"
|
||||
and it.get("type", "message") == "message" and not _kopru_mesaji(it)), default=-1)
|
||||
parcalar = []
|
||||
for i, it in enumerate(girdi):
|
||||
if not isinstance(it, dict) or i == son_kullanici or it.get("role") in ("developer", "system"):
|
||||
continue
|
||||
for alan in ("content", "output"):
|
||||
deger = it.get(alan)
|
||||
if isinstance(deger, str):
|
||||
parcalar.append((it, alan))
|
||||
elif isinstance(deger, list):
|
||||
for p in deger:
|
||||
if isinstance(p, dict) and isinstance(p.get("text"), str):
|
||||
parcalar.append((p, "text"))
|
||||
return parcalar
|
||||
|
||||
|
||||
def sigdir(req, pencere_token, oran=None):
|
||||
"""Istegi hedef pencereye sigdirir. Donus: (kirpilan parca sayisi, sigdi mi, tahmini token)."""
|
||||
sinir = int(pencere_token * DOLULUK)
|
||||
tahmin = tahmini_token(req, oran)
|
||||
if not pencere_token or tahmin <= sinir:
|
||||
return 0, True, tahmin
|
||||
kirpilan = 0
|
||||
en_yeni = _son_arac_ciktisi(req)
|
||||
# Buyukten kucuge; en yeni arac ciktisi en sona kalir (yalniz digerleri yetmezse kirpilir).
|
||||
parcalar = sorted(_metin_parcalari(req), key=lambda sa: (id(sa[0]) in en_yeni, -len(sa[0][sa[1]])))
|
||||
for sahip, anahtar in parcalar:
|
||||
metin = sahip[anahtar]
|
||||
if len(metin) < KIRP_ESIK:
|
||||
continue
|
||||
sahip[anahtar] = (metin[:KIRP_BAS] + f"\n[... {len(metin) - KIRP_BAS - KIRP_SON} karakter çıkarıldı (köprü): "
|
||||
f"seçili modelin bağlam penceresine sığmıyordu ...]\n" + metin[-KIRP_SON:])
|
||||
kirpilan += 1
|
||||
tahmin = tahmini_token(req, oran)
|
||||
if tahmin <= sinir:
|
||||
return kirpilan, True, tahmin
|
||||
return kirpilan, tahmin <= sinir, tahmin
|
||||
@@ -0,0 +1,112 @@
|
||||
"""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, gorsel=False):
|
||||
"""(değiştirilen parça sayısı, kazanılan bayt). req['input'] yerinde değişir.
|
||||
gorsel=True (hedef model resim görebiliyor): eski KULLANICI mesajlarındaki resimler de, en yeniden eskiye
|
||||
toplam tut_bayt'ı aşmadıkça korunur (10 Ekim 2026: resim görebilen modele geçince "göremiyorum" diyordu).
|
||||
Araç çıktılarındaki medya her durumda notla değiştirilir."""
|
||||
inp = req.get('input')
|
||||
if not isinstance(inp, list):
|
||||
return 0, 0
|
||||
son_kullanici = None
|
||||
for i, it in enumerate(inp):
|
||||
# 'type' alani olmayan mesaj da mesajdir (Responses girdisinde varsayilan tur).
|
||||
if isinstance(it, dict) and it.get('type', 'message') == 'message' and it.get('role') == 'user':
|
||||
son_kullanici = i
|
||||
koru = set()
|
||||
if son_kullanici is not None:
|
||||
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)
|
||||
if gorsel:
|
||||
kalan = tut_bayt - sum(_boyut(p) for i in koru for p in inp[i].get('content') or [] if _medya_mi(p))
|
||||
for i in range(len(inp) - 1, -1, -1):
|
||||
it = inp[i]
|
||||
if i in koru or not (isinstance(it, dict) and it.get('type', 'message') == 'message' and it.get('role') == 'user'):
|
||||
continue
|
||||
icerik = it.get('content')
|
||||
resimler = [p for p in icerik if _medya_mi(p) and MEDYA_TURLERI.get(p.get('type')) == 'resim'] \
|
||||
if isinstance(icerik, list) else []
|
||||
diger = [p for p in icerik if _medya_mi(p) and p not in resimler] if isinstance(icerik, list) else []
|
||||
boyut = sum(_boyut(p) for p in resimler)
|
||||
if resimler and not diger and boyut <= kalan:
|
||||
koru.add(i)
|
||||
kalan -= boyut
|
||||
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,87 @@
|
||||
"""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("kopru.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")
|
||||
ozgun = next(n for n in handler.body if isinstance(n, ast.FunctionDef) and n.name == "_ozgun")
|
||||
taraf = [n for n in source.body if isinstance(n, ast.FunctionDef) and n.name in ("ek_model_mu", "yonlendirilen")]
|
||||
|
||||
|
||||
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, "pencere": __import__("pencere"), "IDE_BILGI": {}, "yarim": __import__("yarim"), "jev_denetim": __import__("jev_denetim"), "internet": __import__("internet"), "is_hatti": __import__("is_hatti"), "GORULEN_ISTEMCI": set(), "arac_semalarini_duzelt": lambda req: 0, "nokta_mesaji_mi": lambda req: False, "EXTRA_MODELS": [],
|
||||
"DEBUG": False, "UA": "test", "RETRIES": 1, "UPSTREAM_IDLE_TIMEOUT": 3900,
|
||||
}
|
||||
exec(compile(ast.Module(body=taraf + [flatten, claude_sonu, responses, ozgun], 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={})
|
||||
fake._ozgun = lambda *a: self.env["_ozgun"](fake, *a)
|
||||
self.env["_responses"](fake, 1, "/responses", body)
|
||||
self.assertEqual(fake._passthrough.call_args.args[2], body)
|
||||
self.assertFalse(fake._passthrough.call_args.kwargs["rewritten"])
|
||||
self.upstream.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,214 @@
|
||||
"""The splitter (bridge.py): Codex's own models go to chatgpt.com byte for byte, added models to the bridge process.
|
||||
|
||||
Real sockets, no network: a fake chatgpt.com and a fake bridge process record what reaches them."""
|
||||
import gzip
|
||||
import hashlib
|
||||
import http.client
|
||||
import http.server
|
||||
import json
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import bridge
|
||||
|
||||
|
||||
class Kayit(http.server.BaseHTTPRequestHandler):
|
||||
"""Records every request; answers from the server's `cevap` function."""
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def _her(self):
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
govde = self.rfile.read(n) if n else b""
|
||||
self.server.gelen.append({"yontem": self.command, "yol": self.path, "baslik": dict(self.headers), "govde": govde})
|
||||
self.server.cevap(self, govde)
|
||||
|
||||
do_GET = do_POST = _her
|
||||
|
||||
|
||||
def sunucu(cevap):
|
||||
s = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Kayit)
|
||||
s.daemon_threads = True
|
||||
s.gelen, s.cevap = [], cevap
|
||||
threading.Thread(target=s.serve_forever, daemon=True).start()
|
||||
return s
|
||||
|
||||
|
||||
def duz(durum, govde, tur="application/json"):
|
||||
def f(h, _):
|
||||
h.send_response(durum)
|
||||
h.send_header("Content-Type", tur)
|
||||
h.send_header("Content-Length", str(len(govde)))
|
||||
h.end_headers()
|
||||
h.wfile.write(govde)
|
||||
return f
|
||||
|
||||
|
||||
def zstd(veri):
|
||||
try:
|
||||
from compression import zstd as z
|
||||
return z.compress(veri)
|
||||
except ImportError:
|
||||
import zstandard
|
||||
return zstandard.ZstdCompressor().compress(veri)
|
||||
|
||||
|
||||
class Ayirici(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.ozgun = sunucu(duz(200, b'event: response.completed\ndata: {"ok":1}\n\n', "text/event-stream"))
|
||||
self.kopru = sunucu(duz(200, b'event: response.completed\ndata: {"kopru":1}\n\n', "text/event-stream"))
|
||||
self.on = bridge.Sunucu(("127.0.0.1", 0), bridge.H)
|
||||
threading.Thread(target=self.on.serve_forever, daemon=True).start()
|
||||
self.eski = (bridge.CHATGPT, bridge.KOPRU, bridge.LOGDIR, dict(bridge.CFG))
|
||||
self.gecici = tempfile.TemporaryDirectory()
|
||||
bridge.CHATGPT = f"http://127.0.0.1:{self.ozgun.server_address[1]}"
|
||||
bridge.KOPRU = f"http://127.0.0.1:{self.kopru.server_address[1]}"
|
||||
bridge.LOGDIR = self.gecici.name
|
||||
bridge.CFG.clear()
|
||||
|
||||
def tearDown(self):
|
||||
for s in (self.on, self.ozgun, self.kopru):
|
||||
s.shutdown()
|
||||
s.server_close()
|
||||
bridge.CHATGPT, bridge.KOPRU, bridge.LOGDIR, cfg = self.eski
|
||||
bridge.CFG.clear()
|
||||
bridge.CFG.update(cfg)
|
||||
self.gecici.cleanup()
|
||||
|
||||
def iste(self, yontem, yol, govde=None, baslik=None):
|
||||
b = http.client.HTTPConnection("127.0.0.1", self.on.server_address[1], timeout=10)
|
||||
b.request(yontem, yol, body=govde, headers=baslik or {})
|
||||
y = b.getresponse()
|
||||
veri = y.read()
|
||||
b.close()
|
||||
return y.status, veri, dict(y.getheaders())
|
||||
|
||||
def test_codex_own_model_reaches_chatgpt_byte_for_byte(self):
|
||||
"""Compressed or not, with a command, a lone dot or a compaction trigger in it: nothing is changed."""
|
||||
istekler = [{"model": "gpt-6-astra", "input": [{"role": "user", "content": "modeli opus 5 5 yap"}]},
|
||||
{"model": "gpt-6-luna", "input": [{"role": "user", "content": "."}]},
|
||||
{"model": "gpt-6-astra", "prompt_cache_key": "s", "input": [{"type": "compaction_trigger"}]},
|
||||
{"model": "gpt-6-astra", "input": [{"type": "compaction", "encrypted_content": "ASTRABRIDGE-OZET-1:eA=="},
|
||||
{"type": "reasoning", "id": "rs_chatcmpl-x"}]}]
|
||||
for veri in istekler:
|
||||
ham = json.dumps(veri, ensure_ascii=False).encode("utf-8")
|
||||
for kodlama, govde in (("zstd", zstd(ham)), ("gzip", gzip.compress(ham)), ("", ham)):
|
||||
with self.subTest(model=veri["model"], kodlama=kodlama):
|
||||
baslik = {"Authorization": "Bearer sahte", "originator": "Codex Desktop", "chatgpt-account-id": "h1"}
|
||||
if kodlama:
|
||||
baslik["Content-Encoding"] = kodlama
|
||||
durum, cevap, _ = self.iste("POST", "/backend-api/codex/responses", govde, baslik)
|
||||
giden = self.ozgun.gelen[-1]
|
||||
self.assertEqual(hashlib.sha256(giden["govde"]).hexdigest(), hashlib.sha256(govde).hexdigest())
|
||||
basliklar = {k.lower(): v for k, v in giden["baslik"].items()}
|
||||
self.assertEqual(basliklar.get("content-encoding", ""), kodlama)
|
||||
self.assertEqual((basliklar["authorization"], basliklar["originator"], basliklar["chatgpt-account-id"]),
|
||||
("Bearer sahte", "Codex Desktop", "h1"))
|
||||
self.assertEqual(giden["yol"], "/backend-api/codex/responses")
|
||||
self.assertEqual((durum, cevap), (200, b'event: response.completed\ndata: {"ok":1}\n\n'))
|
||||
self.assertEqual(self.kopru.gelen, []) # the bridge process never saw any of them
|
||||
|
||||
def test_added_models_go_to_the_bridge_process_and_only_there(self):
|
||||
for model in ("ide/Opus-5", "9r/cc/claude-opus-5-5"):
|
||||
govde = zstd(json.dumps({"model": model, "input": []}).encode())
|
||||
durum, cevap, _ = self.iste("POST", "/backend-api/codex/responses", govde,
|
||||
{"Content-Encoding": "zstd", "originator": "Codex Desktop"})
|
||||
self.assertEqual((durum, cevap), (200, b'event: response.completed\ndata: {"kopru":1}\n\n'))
|
||||
self.assertEqual(self.kopru.gelen[-1]["govde"], govde)
|
||||
basliklar = {k.lower(): v for k, v in self.kopru.gelen[-1]["baslik"].items()}
|
||||
self.assertEqual((basliklar["originator"], basliklar["content-encoding"]), ("Codex Desktop", "zstd"))
|
||||
bridge.CFG["model_map"] = {"ozel-model": ["rota", "hedef"]}
|
||||
self.iste("POST", "/backend-api/codex/responses", json.dumps({"model": "ozel-model"}).encode())
|
||||
self.assertEqual(len(self.kopru.gelen), 3)
|
||||
self.assertEqual(self.ozgun.gelen, [])
|
||||
|
||||
def test_a_body_that_cannot_be_read_takes_codex_own_path_untouched(self):
|
||||
for govde, baslik in ((b"\x00\x01bozuk", {"Content-Encoding": "zstd"}), (b"json degil", {}), (b"[1,2]", {})):
|
||||
self.iste("POST", "/backend-api/codex/responses", govde, baslik)
|
||||
self.assertEqual(self.ozgun.gelen[-1]["govde"], govde)
|
||||
self.assertEqual(self.kopru.gelen, [])
|
||||
|
||||
def test_codex_own_model_keeps_working_while_the_bridge_process_is_down(self):
|
||||
self.kopru.shutdown()
|
||||
self.kopru.server_close()
|
||||
govde = json.dumps({"model": "gpt-6-astra", "input": []}).encode()
|
||||
self.assertEqual(self.iste("POST", "/backend-api/codex/responses", govde)[0], 200)
|
||||
# The model list falls back to chatgpt.com's own list, unchanged.
|
||||
self.ozgun.cevap = duz(200, b'{"models":[{"slug":"gpt-6-astra"}]}')
|
||||
durum, liste, _ = self.iste("GET", "/backend-api/codex/models?client_version=1", None, {"If-None-Match": "x"})
|
||||
self.assertEqual((durum, liste), (200, b'{"models":[{"slug":"gpt-6-astra"}]}'))
|
||||
# An added model says so instead of hanging or going to chatgpt.com under another name.
|
||||
durum, cevap, _ = self.iste("POST", "/backend-api/codex/responses", json.dumps({"model": "ide/Opus-5"}).encode())
|
||||
self.assertEqual(durum, 503)
|
||||
self.assertIn("köprüsü şu an çalışmıyor", json.loads(cevap)["error"]["message"])
|
||||
self.assertEqual([g["yol"].split("?")[0] for g in self.ozgun.gelen],
|
||||
["/backend-api/codex/responses", "/backend-api/codex/models"])
|
||||
# Status still answers in the shape AITURK IDE expects.
|
||||
durum, cevap, _ = self.iste("GET", "/aiturk/codex-baglama/status")
|
||||
d = json.loads(cevap)
|
||||
self.assertEqual((durum, d["surum"], d["kopru"], d["ayirici"]), (200, "8", "kapali", True))
|
||||
|
||||
def test_model_list_comes_from_the_bridge_and_falls_back_when_it_fails(self):
|
||||
self.kopru.cevap = duz(200, b'{"models":[{"slug":"gpt-6-astra"},{"slug":"ide/Opus-5"}]}')
|
||||
self.assertIn(b"ide/Opus-5", self.iste("GET", "/backend-api/codex/models")[1])
|
||||
self.assertEqual(self.ozgun.gelen, [])
|
||||
self.kopru.cevap = duz(500, b'{"error":"x"}')
|
||||
self.ozgun.cevap = duz(200, b'{"models":[{"slug":"gpt-6-astra"}]}')
|
||||
self.assertEqual(self.iste("GET", "/backend-api/codex/models")[1], b'{"models":[{"slug":"gpt-6-astra"}]}')
|
||||
|
||||
def test_everything_else_and_errors_pass_through_as_they_are(self):
|
||||
self.ozgun.cevap = duz(200, b'{"sonuc":1}')
|
||||
self.assertEqual(self.iste("POST", "/backend-api/codex/alpha/search", b'{"q":"x"}')[1], b'{"sonuc":1}')
|
||||
self.assertEqual(self.ozgun.gelen[-1]["govde"], b'{"q":"x"}')
|
||||
self.ozgun.cevap = duz(400, b'{"error":{"message":"gecersiz"}}')
|
||||
durum, cevap, _ = self.iste("POST", "/backend-api/codex/responses", json.dumps({"model": "gpt-6-astra"}).encode())
|
||||
self.assertEqual((durum, cevap), (400, b'{"error":{"message":"gecersiz"}}'))
|
||||
self.assertEqual(self.kopru.gelen, [])
|
||||
|
||||
def test_a_long_answer_streams_piece_by_piece_without_waiting_for_the_end(self):
|
||||
"""This morning's failure was an idle stream: Codex got no bytes for five minutes. Bytes must flow as they come."""
|
||||
def yavas(h, _):
|
||||
h.send_response(200)
|
||||
h.send_header("Content-Type", "text/event-stream")
|
||||
h.send_header("Transfer-Encoding", "chunked")
|
||||
h.end_headers()
|
||||
for parca in (b"data: bir\n\n", b"data: iki\n\n"):
|
||||
h.wfile.write(b"%x\r\n" % len(parca) + parca + b"\r\n")
|
||||
h.wfile.flush()
|
||||
time.sleep(1.0)
|
||||
h.wfile.write(b"0\r\n\r\n")
|
||||
self.ozgun.cevap = yavas
|
||||
b = http.client.HTTPConnection("127.0.0.1", self.on.server_address[1], timeout=10)
|
||||
bas = time.time()
|
||||
b.request("POST", "/backend-api/codex/responses", body=json.dumps({"model": "gpt-6-astra"}).encode())
|
||||
y = b.getresponse()
|
||||
ilk = y.read1(64)
|
||||
ilk_sure = time.time() - bas
|
||||
kalan = y.read()
|
||||
b.close()
|
||||
self.assertEqual(ilk, b"data: bir\n\n")
|
||||
self.assertLess(ilk_sure, 0.8) # the first piece arrived before the answer ended
|
||||
self.assertEqual(kalan, b"data: iki\n\n")
|
||||
|
||||
def test_status_is_the_bridge_status_plus_the_splitter_fields(self):
|
||||
self.kopru.cevap = duz(200, b'{"ok": true, "surum": "8", "kod": "abc", "ide_durum": "tamam", "ide_model": 147}')
|
||||
d = json.loads(self.iste("GET", "/aiturk/codex-baglama/status")[1])
|
||||
self.assertEqual((d["surum"], d["ide_model"], d["kopru"], d["ayirici"]), ("8", 147, "calisiyor", True))
|
||||
|
||||
def test_a_persistent_connection_is_refused_the_way_it_is_today(self):
|
||||
durum, _, _ = self.iste("GET", "/backend-api/codex/responses", None, {"Upgrade": "websocket", "Connection": "Upgrade"})
|
||||
self.assertEqual(durum, 426)
|
||||
self.assertEqual(self.ozgun.gelen + self.kopru.gelen, [])
|
||||
|
||||
def test_which_side_a_model_is_on(self):
|
||||
for slug, ek in (("ide/Opus-5", True), ("9r/ag/gemini", True), ("gpt-6-astra", False), ("gpt-6.1-sol", False),
|
||||
("", False), (None, False), ("idea", False)):
|
||||
self.assertEqual(bridge.ek_model_mu(slug), ek, slug)
|
||||
|
||||
|
||||
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("kopru.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,107 @@
|
||||
"""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("kopru.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"), "http": __import__("http.client"),
|
||||
"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": [], "pencere": __import__("pencere"), "IDE_BILGI": {}, "yarim": __import__("yarim"), "jev_denetim": __import__("jev_denetim"), "internet": __import__("internet"), "is_hatti": __import__("is_hatti"), "GORULEN_ISTEMCI": set(), "arac_semalarini_duzelt": lambda req: 0, "nokta_mesaji_mi": lambda req: False, "EXTRA_MODELS": [],
|
||||
}
|
||||
exec(compile(ast.Module(body=[pick("ek_model_mu"), pick("yonlendirilen"), pick("flatten_request"), pick("claude_sonu_duzelt"),
|
||||
pick("is_timeout"), pick("kopma_mi"), responses],
|
||||
type_ignores=[]), "bridge.py", "exec"), self.env)
|
||||
|
||||
def send(self, model):
|
||||
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_combo_closed_by_the_peer_before_headers_is_not_resent(self):
|
||||
import http.client
|
||||
for error in (http.client.RemoteDisconnected("closed"), urllib.error.URLError(ConnectionResetError("reset"))):
|
||||
with self.subTest(error=error):
|
||||
self.upstream.reset_mock()
|
||||
self.upstream.side_effect = error
|
||||
fake = self.send("9r/Fusion-Orta")
|
||||
self.assertEqual(self.upstream.call_count, 1)
|
||||
fake._stream.assert_not_called()
|
||||
|
||||
def test_single_model_timeout_is_still_retried(self):
|
||||
self.upstream.side_effect = [urllib.error.URLError(TimeoutError("timed out")), Mock(status=200)]
|
||||
fake = self.send("9r/cc/claude-opus-5-5")
|
||||
self.assertEqual(self.upstream.call_count, 2)
|
||||
fake._stream.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,478 @@
|
||||
"""Audit of 11 Oct 2026, remaining findings: each fix has a test here (handler methods run through a fake handler)."""
|
||||
import ast
|
||||
import copy
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import time
|
||||
import types
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
import ide_catalog
|
||||
import is_hatti
|
||||
import model_gecis
|
||||
import ozet_kopru
|
||||
import pencere
|
||||
import resim_context
|
||||
import yarim
|
||||
|
||||
SOURCE = Path(__file__).with_name("kopru.py").read_text(encoding="utf-8")
|
||||
tree = ast.parse(SOURCE)
|
||||
handler = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "H")
|
||||
|
||||
|
||||
def top(*names):
|
||||
nodes = [n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.ClassDef)) and n.name in names]
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id in names for t in node.targets):
|
||||
nodes.insert(0, node)
|
||||
return nodes
|
||||
|
||||
|
||||
def method(name):
|
||||
return next(n for n in handler.body if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
|
||||
|
||||
def build(nodes, env):
|
||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), "bridge.py", "exec"), env)
|
||||
return env
|
||||
|
||||
|
||||
def user(text):
|
||||
return {"type": "message", "role": "user", "content": [{"type": "input_text", "text": text}]}
|
||||
|
||||
|
||||
def developer(text):
|
||||
return {"type": "message", "role": "developer", "content": [{"type": "input_text", "text": text}]}
|
||||
|
||||
|
||||
MODELS = [("gpt-6-sol", "GPT-6-Sol"), ("gpt-6-astra", "GPT-6-Astra"), ("gpt-6.1-sol", "GPT-6.1-Sol"),
|
||||
("ide/9router-ag-gemini-3.8-flash-low", "agy/gemini-3.8-flash-low"),
|
||||
("ide/9router-cc-claude-opus-5-5", "cc/opus-5-5")]
|
||||
CALL = {"type": "function_call", "call_id": "c", "name": "shell", "arguments": "{}"}
|
||||
OUT = {"type": "function_call_output", "call_id": "c", "output": "x"}
|
||||
|
||||
|
||||
class SesliKomut(unittest.TestCase):
|
||||
def pick(self, text):
|
||||
command = model_gecis.komut_coz(text)
|
||||
return model_gecis.eslestir(command["ad"], MODELS) if command and command["tur"] == "gec" else None
|
||||
|
||||
def test_numbers_said_as_words_and_a_continue_tail_still_switch(self):
|
||||
for text, slug in [("modeli opus beş beş yap", "ide/9router-cc-claude-opus-5-5"),
|
||||
("modeli gemini üç nokta sekiz flash low yap", "ide/9router-ag-gemini-3.8-flash-low"),
|
||||
("modeli gpt altı nokta bir sol yap", "gpt-6.1-sol"),
|
||||
("modeli gpt altı astra yap ve devam et", "gpt-6-astra"),
|
||||
("modeli opus 5 5 yap sonra devam et", "ide/9router-cc-claude-opus-5-5")]:
|
||||
self.assertEqual((self.pick(text) or {}).get("slug"), slug, text)
|
||||
|
||||
def test_a_task_after_the_command_is_not_swallowed(self):
|
||||
for text in ("modeli opus 5 5 yap ve şu dosyayı düzelt", "bir model seç", "modeli iki kere değiştir"):
|
||||
self.assertIsNone(self.pick(text), text)
|
||||
|
||||
def test_last_command_is_noted_only_when_it_really_took_effect(self):
|
||||
opus = "ide/9router-cc-claude-opus-5-5"
|
||||
refused = {"input": [user("modeli opus 5 5 yap"), {"type": "compaction_trigger"}]}
|
||||
self.assertEqual(model_gecis.gecmisi_temizle(refused, MODELS, "gpt-6-sol"), 0) # e.g. "bağlantı kapalı"
|
||||
self.assertEqual(refused["input"][0]["content"][0]["text"], "modeli opus 5 5 yap")
|
||||
applied = {"input": [user("modeli opus 5 5 yap"), CALL, OUT]}
|
||||
self.assertEqual(model_gecis.gecmisi_temizle(applied, MODELS, opus), 1) # mid-turn handover
|
||||
self.assertIn("cc/opus-5-5 olarak değiştirdi", applied["input"][0]["content"][0]["text"])
|
||||
|
||||
def test_a_route_in_use_does_not_expire(self):
|
||||
y = model_gecis.Yonlendirme(None)
|
||||
y.koy("chat", "gpt-6-sol", "ide/x", simdi=1000)
|
||||
day = 24 * 3600
|
||||
for n in range(1, 12): # used every 1.5 days for 16 days
|
||||
self.assertEqual(y.al("chat", "gpt-6-sol", simdi=1000 + n * 1.5 * day), "ide/x", n)
|
||||
self.assertIsNone(y.al("chat", "gpt-6-sol", simdi=1000 + 16.5 * day + model_gecis.OMUR_SN + 1)) # unused for a week
|
||||
|
||||
|
||||
class Pencere(unittest.TestCase):
|
||||
def test_guard_runs_for_a_combo_whose_small_member_is_known(self):
|
||||
self.assertFalse(pencere.bekci_calisir("varsayilan", 128_000))
|
||||
self.assertFalse(pencere.bekci_calisir("", 128_000))
|
||||
self.assertFalse(pencere.bekci_calisir("uyeler:varsayilan", 128_000))
|
||||
self.assertTrue(pencere.bekci_calisir("uyeler:nanogpt,varsayilan", 8192)) # the 8K member is real
|
||||
self.assertFalse(pencere.bekci_calisir("uyeler:nanogpt,varsayilan", 128_000)) # the minimum is the guess
|
||||
for known in ("yerel", "nanogpt", "ogrenilen", "uyeler:nanogpt,yerel"):
|
||||
self.assertTrue(pencere.bekci_calisir(known, 200_000), known)
|
||||
|
||||
def test_combo_with_a_claude_member_uses_the_claude_ratio(self):
|
||||
combo = {"topology": {"kind": "combo", "strategy": "priority", "members": [
|
||||
{"kind": "model", "model": "cx/gpt-6-astra"},
|
||||
{"kind": "combo", "strategy": "priority", "members": [{"kind": "model", "model": "cc/claude-opus-5-5"}]}]}}
|
||||
plain = {"topology": {"kind": "combo", "members": [{"kind": "model", "model": "ag/gemini-3.8-flash"}]}}
|
||||
self.assertTrue(pencere.claude_var("combo", "Opus-5", combo))
|
||||
self.assertFalse(pencere.claude_var("combo", "Hizli", plain))
|
||||
self.assertTrue(pencere.claude_var("model", "no-think/claude-opus-5-5"))
|
||||
self.assertFalse(pencere.claude_var("model", "nano/z-ai/glm-5.3"))
|
||||
self.assertEqual(pencere.oran_sec("Opus-5", True), pencere.CLAUDE_BAYT_BOLU_TOKEN)
|
||||
self.assertEqual(pencere.oran_sec("Opus-5"), pencere.BAYT_BOLU_TOKEN)
|
||||
|
||||
def test_a_corrupt_nano_cache_does_not_block_the_refresh(self):
|
||||
home = tempfile.mkdtemp()
|
||||
Path(home, pencere.NANO_DOSYA).write_text(json.dumps({"zaman": "dun", "modeller": {"eski": {"pencere": 9000}}}),
|
||||
encoding="utf-8")
|
||||
fresh = {"data": [{"id": "Yeni/M", "context_length": 65536, "capabilities": {}}]}
|
||||
self.assertIn("yeni/m", pencere.nano_yukle(home, simdi=1000.0, getir=lambda: fresh))
|
||||
|
||||
def test_trimming_keeps_the_real_last_user_message_and_the_newest_tool_output(self):
|
||||
marker = "(Önceki cevabın yarıda kaldı ya da sesli konuşma kapandı; bağlama göre devam et.)"
|
||||
self.assertIn(marker, SOURCE) # the text bridge.py appends
|
||||
self.assertTrue(marker.startswith(pencere.KOPRU_DEVAM_BASI))
|
||||
req = {"input": [{"type": "function_call_output", "call_id": "a", "output": "eski " * 30000},
|
||||
user("TALIMAT " * 5000),
|
||||
{"type": "function_call_output", "call_id": "b", "output": "yeni " * 4000},
|
||||
user(marker)]}
|
||||
trimmed, fits, _ = pencere.sigdir(req, 30_000)
|
||||
self.assertEqual((trimmed, fits), (1, True))
|
||||
self.assertIn("çıkarıldı (köprü)", req["input"][0]["output"])
|
||||
self.assertEqual(req["input"][1]["content"][0]["text"], "TALIMAT " * 5000) # the user's real last message
|
||||
self.assertEqual(req["input"][2]["output"], "yeni " * 4000) # newest tool result untouched
|
||||
# When nothing else is left, the newest tool output is trimmed rather than the request refused.
|
||||
only = {"input": [user("soru"), {"type": "function_call_output", "call_id": "b", "output": "yeni " * 40000}]}
|
||||
self.assertEqual(pencere.sigdir(only, 30_000)[:2], (1, True))
|
||||
|
||||
|
||||
class Ozet(unittest.TestCase):
|
||||
def test_a_cut_stream_is_not_returned_as_a_whole_summary(self):
|
||||
delta = b'data: {"type":"response.output_text.delta","item_id":"m","delta":"yarim oz"}\n\n'
|
||||
self.assertIsNone(ozet_kopru.sse_metni(delta))
|
||||
done = delta + b'data: {"type":"response.completed","response":{}}\n\n'
|
||||
self.assertEqual(ozet_kopru.sse_metni(done), "yarim oz")
|
||||
|
||||
def test_the_in_a_row_brake_lifts_after_the_window(self):
|
||||
f = ozet_kopru.DonguFreni(sinir=8, pencere_sn=600, art_arda=3)
|
||||
self.assertEqual([f.izin("s", t) for t in (0, 10, 20, 30)], [True, True, True, False])
|
||||
self.assertFalse(f.izin("s", 500)) # still inside the window
|
||||
self.assertTrue(f.izin("s", 24 * 3600)) # a day later the chat is not locked any more
|
||||
|
||||
def test_cleanup_survives_an_empty_record(self):
|
||||
f = ozet_kopru.DonguFreni()
|
||||
f.kayit = {"k%d" % i: [float(i)] for i in range(500)}
|
||||
f.kayit["bos"] = []
|
||||
self.assertTrue(f.izin("yeni", 1000.0))
|
||||
self.assertLess(len(f.kayit), 300)
|
||||
|
||||
|
||||
class Akis(unittest.TestCase):
|
||||
env = build(top("fix_tool_ids", "SseRewriter", "phase_damgala", "asistan_mesaji_mi", "ID_PREFIX", "ARAC_OGELERI"),
|
||||
{"json": json, "re": __import__("re"), "time": time, "log": lambda m: None, "pencere": pencere})
|
||||
|
||||
def events(self, raw):
|
||||
return [json.loads(line[6:]) for line in raw.decode().splitlines() if line.startswith("data: ")]
|
||||
|
||||
def test_crlf_separated_events_are_translated(self):
|
||||
flat = {"type": "error", "code": "bad_request", "message": "upstream 402"}
|
||||
raw = b"event: error\r\ndata: " + json.dumps(flat).encode() + b"\r\n\r\n"
|
||||
adapter = self.env["SseRewriter"](set())
|
||||
out = self.events(adapter.feed(raw[:20]) + adapter.feed(raw[20:]) + adapter.flush())
|
||||
self.assertEqual(out[-1]["type"], "response.completed")
|
||||
self.assertIn("kredi ya da kota", out[1]["delta"])
|
||||
watcher = yarim.Izleyici("k", "m")
|
||||
watcher.feed(b'data: {"type":"response.output_text.delta","delta":"merhaba"}\r\n\r\n'
|
||||
b'data: {"type":"response.completed"}\r\n\r\n')
|
||||
self.assertEqual((watcher.metin, watcher.bitti), ("merhaba", True))
|
||||
|
||||
def test_a_non_object_event_body_passes_through(self):
|
||||
adapter = self.env["SseRewriter"](set())
|
||||
self.assertEqual(adapter.feed(b"data: null\n\ndata: [1]\n\n"), b"data: null\n\ndata: [1]\n\n")
|
||||
|
||||
def test_a_waiting_message_is_released_before_the_error_answer(self):
|
||||
message = {"type": "response.output_item.done", "output_index": 0,
|
||||
"item": {"id": "m1", "type": "message", "role": "assistant", "content": []}}
|
||||
adapter = self.env["SseRewriter"](set(), dolu=True)
|
||||
first = adapter.feed(b"data: " + json.dumps(message).encode() + b"\n\n")
|
||||
self.assertEqual(first, b"") # held until what follows is known
|
||||
out = self.events(adapter.feed(b'data: {"type":"error","message":"upstream 400"}\n\n') + adapter.flush())
|
||||
self.assertEqual([e["type"] for e in out], ["response.output_item.done", "response.failed"])
|
||||
self.assertEqual(out[0]["item"]["id"], "m1")
|
||||
self.assertIn("nedenini bildirmedi", out[1]["response"]["error"]["message"]) # no false "did not fit"
|
||||
self.assertIn("olabilir", out[1]["response"]["error"]["message"])
|
||||
|
||||
|
||||
class Resim(unittest.TestCase):
|
||||
def test_a_new_image_in_a_message_without_type_is_kept(self):
|
||||
img = {"type": "input_image", "image_url": "data:image/png;base64," + "A" * 2000}
|
||||
req = {"input": [{"role": "user", "content": [{"type": "input_text", "text": "bak"}, dict(img)]}]}
|
||||
self.assertEqual(resim_context.resimleri_sadelestir(req), (0, 0))
|
||||
self.assertEqual(req["input"][0]["content"][1]["type"], "input_image")
|
||||
|
||||
|
||||
class Katalog(unittest.TestCase):
|
||||
def test_names_stay_distinct_when_the_id_tail_collides_too(self):
|
||||
names = ide_catalog.adlandir([("a-ortakson", "Ad"), ("b-ortakson", "Ad"), ("c-ortakson", "Ad")], dolu={"ad"})
|
||||
self.assertEqual(len({v.lower() for v in names.values()}), 3)
|
||||
|
||||
def test_an_original_listed_as_extra_keeps_its_place(self):
|
||||
models = [{"slug": "gpt-6-sol", "display_name": "GPT-6-Sol", "priority": 1},
|
||||
{"slug": "gpt-6-astra", "display_name": "GPT-6-Astra", "priority": 2},
|
||||
{"slug": "ide/a", "display_name": "Aaa", "priority": 0}]
|
||||
extras = [{"slug": "gpt-6-astra"}, {"slug": "ide/a", "kind": "combo"}]
|
||||
ide_catalog.sirala_codex(models, extras, {"gpt-6-sol", "gpt-6-astra"})
|
||||
self.assertEqual([(m["slug"], m["priority"]) for m in models],
|
||||
[("gpt-6-sol", 1), ("gpt-6-astra", 2), ("ide/a", 3)])
|
||||
|
||||
|
||||
class Sahte:
|
||||
"""A handler stand-in: real bridge methods, recorded output."""
|
||||
|
||||
def __init__(self, headers=None, command="POST"):
|
||||
self.headers = headers or {}
|
||||
self.command = command
|
||||
self.path = "/backend-api/codex/responses"
|
||||
self.wfile = io.BytesIO()
|
||||
self.status = None
|
||||
self.sent = []
|
||||
self._passthrough = Mock()
|
||||
self._yerel_yanit = Mock()
|
||||
self._send_json = Mock()
|
||||
self._stream = Mock()
|
||||
self._tasma_ogren = Mock()
|
||||
self._aracsiz_ogren = Mock()
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, k, v):
|
||||
self.sent.append((k, v))
|
||||
|
||||
def end_headers(self):
|
||||
pass
|
||||
|
||||
|
||||
class Zincir(unittest.TestCase):
|
||||
"""_responses + _model_komutu with a conversation key, the way Codex sends it."""
|
||||
|
||||
def setUp(self):
|
||||
self.env = build(top("nokta_mesaji_mi", "NOKTA_ISARETLERI", "is_timeout", "kopma_mi", "flatten_request",
|
||||
"claude_sonu_duzelt", "ek_model_mu", "yonlendirilen")
|
||||
+ [method("_responses"), method("_model_komutu"), method("_ozgun")], {
|
||||
"gecit_modelleri": lambda: [],
|
||||
"json": json, "re": __import__("re"), "os": __import__("os"), "copy": copy, "http": __import__("http.client"),
|
||||
"time": types.SimpleNamespace(sleep=lambda s: None, time=time.time),
|
||||
"urllib": types.SimpleNamespace(request=types.SimpleNamespace(Request=urllib.request.Request, urlopen=Mock()),
|
||||
error=urllib.error),
|
||||
"decompress": lambda body, enc: body, "MODEL_MAP": {}, "ROUTES": {}, "CFG": {"model_bilgi": False}, "log": lambda m: None,
|
||||
"model_gecis": model_gecis, "YONLENDIRME": model_gecis.Yonlendirme(None), "bilinen_modeller": lambda: MODELS,
|
||||
"model_adi": lambda slug: dict(MODELS).get(slug, slug), "is_hatti": is_hatti, "GORULEN_ISTEMCI": set(),
|
||||
"yarim": yarim, "jev_denetim": __import__("jev_denetim"), "internet": __import__("internet"), "ozet_kopru": ozet_kopru, "OZET_FRENI": ozet_kopru.DonguFreni(), "pencere": pencere,
|
||||
"fix_tool_ids": lambda req: 0, "drop_foreign_items": lambda req: 0, "IDE_BILGI": {}, "EXTRA_MODELS": [],
|
||||
"arac_semalarini_duzelt": lambda req: 0, "SseRewriter": Mock(), "DEBUG": False, "UA": "test", "RETRIES": 6,
|
||||
"UPSTREAM_IDLE_TIMEOUT": 1, "NO_RETRY_PATTERNS": [], "pano_blogunu_ulastir": lambda req: (0, ""),
|
||||
"resimleri_sadelestir": lambda req, gorsel=False: (0, 0),
|
||||
})
|
||||
Sahte._responses = self.env["_responses"]
|
||||
Sahte._model_komutu = self.env["_model_komutu"]
|
||||
Sahte._ozgun = self.env["_ozgun"]
|
||||
|
||||
def send(self, model, items, key="sohbet-1", path="/responses"):
|
||||
fake = Sahte()
|
||||
body = json.dumps({"model": model, "prompt_cache_key": key, "input": items}).encode()
|
||||
fake._responses(1, path, body)
|
||||
return fake, body
|
||||
|
||||
def test_a_request_for_codex_own_model_is_passed_through_untouched(self):
|
||||
"""User decision, 11 Oct 2026: Codex's own models are one side, the added models the other; the bridge
|
||||
does not touch a request for Codex's own model (no command, dot, note, work line or compaction handling)."""
|
||||
self.env["CFG"].update({"nokta_karsila": True, "model_bilgi": True, "yarim_cevap": True,
|
||||
"ozet_kopru": {"enabled": True, "kapsam": "hepsi"},
|
||||
"aiturk_hat": {"enabled": True, "komut": "aiturk is"}})
|
||||
mid_turn = [user("işe başla"), CALL, OUT, user("modeli opus 5 5 yap"), developer("k1")]
|
||||
for items in ([user("modeli opus 5 5 yap")], [user(".")], [user("seçili model hangisi")], mid_turn,
|
||||
[user("uzun iş"), {"type": "compaction_trigger"}]):
|
||||
with self.subTest(items=items):
|
||||
fake = Sahte(headers={"originator": "Codex Desktop"})
|
||||
body = json.dumps({"model": "gpt-6-astra", "prompt_cache_key": "sohbet-1", "input": items}).encode()
|
||||
fake._responses(1, "/responses", body)
|
||||
fake._yerel_yanit.assert_not_called()
|
||||
args, kwargs = fake._passthrough.call_args
|
||||
self.assertEqual(args[2], body)
|
||||
self.assertFalse(kwargs["rewritten"])
|
||||
self.assertEqual(self.env["YONLENDIRME"].veri, {})
|
||||
|
||||
def test_the_bridge_does_not_even_help_codex_own_model(self):
|
||||
"""'If you pick Codex and write to Codex, our bridge cannot be beside it even to help.' Traces an added
|
||||
model or the bridge left earlier in the chat are not cleaned either: the body goes out byte for byte."""
|
||||
self.env["CFG"]["ozet_kopru"] = {"enabled": True, "kapsam": "hepsi"}
|
||||
self.env["fix_tool_ids"] = Mock(return_value=2)
|
||||
self.env["drop_foreign_items"] = Mock(return_value=3)
|
||||
summary = ozet_kopru.ozet_ogesi("eski köprü özeti", "t")
|
||||
fake, body = self.send("gpt-6-astra", [summary, {"type": "reasoning", "id": "rs_chatcmpl-x"}, user("devam")])
|
||||
args, kwargs = fake._passthrough.call_args
|
||||
self.assertEqual(args[2], body)
|
||||
self.assertFalse(kwargs["rewritten"])
|
||||
self.env["fix_tool_ids"].assert_not_called()
|
||||
self.env["drop_foreign_items"].assert_not_called()
|
||||
fake._yerel_yanit.assert_not_called()
|
||||
|
||||
def test_voice_switch_stays_among_the_added_models(self):
|
||||
opus, flash = "ide/9router-cc-claude-opus-5-5", "ide/9router-ag-gemini-3.8-flash-low"
|
||||
self.env["ROUTES"]["aiturk-ide"] = {"url": "https://example.invalid/v1/responses", "key": "k"}
|
||||
fake = Sahte()
|
||||
answer = fake._model_komutu(1, {"input": [user("modeli gemini flash low yap")]}, "sohbet-1", opus)
|
||||
self.assertIn("Sohbetin modeli artık agy/gemini-3.8-flash-low", answer)
|
||||
self.assertEqual(self.env["yonlendirilen"]("sohbet-1", opus), flash)
|
||||
# Codex's own model is not a voice target, from an added model either.
|
||||
answer = fake._model_komutu(1, {"input": [user("modeli gpt 6 astra yap")]}, "sohbet-1", opus)
|
||||
self.assertIn("GPT-6-Astra Codex'in kendi modeli", answer)
|
||||
self.assertIn("sesle geçilmiyor", answer)
|
||||
self.assertIn('"."', answer)
|
||||
self.assertEqual(self.env["yonlendirilen"]("sohbet-1", opus), flash)
|
||||
# In the middle of a turn the running model is told to pass that on; the work is not cut.
|
||||
req = {"input": [user("işe başla"), CALL, OUT, user("modeli gpt 6 astra yap")]}
|
||||
self.assertIsNone(fake._model_komutu(1, req, "sohbet-1", opus))
|
||||
self.assertIn("sesle yapılamıyor", req["input"][3]["content"][0]["text"])
|
||||
# A record left from before the rule that points at Codex's own model is ignored.
|
||||
self.env["YONLENDIRME"].koy("sohbet-2", opus, "gpt-6-astra")
|
||||
self.assertIsNone(self.env["yonlendirilen"]("sohbet-2", opus))
|
||||
|
||||
def test_a_refused_switch_is_not_told_as_done_after_compaction(self):
|
||||
items = [user("modeli opus 5 5 yap"), {"type": "compaction_trigger"}] # the ide/ route is closed here
|
||||
fake, body = self.send("gpt-6-sol", items)
|
||||
self.assertEqual(fake._passthrough.call_args.args[2], body)
|
||||
|
||||
def test_lone_dot_is_answered_for_an_added_model_and_left_alone_for_codex_own(self):
|
||||
self.env["CFG"]["nokta_karsila"] = True
|
||||
fake, _ = self.send("ide/9router-cc-claude-opus-5-5", [user(".")])
|
||||
self.assertIn("cc/opus-5-5 seçildi", fake._yerel_yanit.call_args.args[1])
|
||||
self.assertNotIn("hazır", fake._yerel_yanit.call_args.args[1])
|
||||
fake, body = self.send("gpt-6-sol", [user(".")])
|
||||
fake._yerel_yanit.assert_not_called()
|
||||
self.assertEqual(fake._passthrough.call_args.args[2], body)
|
||||
|
||||
def test_old_router_name_without_a_route_is_refused_locally(self):
|
||||
fake, _ = self.send("9r/cc/claude-opus-5-5", [user("selam")])
|
||||
self.assertEqual(fake._send_json.call_args.args[0], 503)
|
||||
fake._passthrough.assert_not_called()
|
||||
|
||||
def test_a_route_removed_while_the_request_is_prepared_gives_503(self):
|
||||
self.env["MODEL_MAP"]["ide/x"] = ("aiturk-ide", "x", None)
|
||||
fake, _ = self.send("ide/x", [user("selam")])
|
||||
self.assertEqual(fake._send_json.call_args.args[0], 503)
|
||||
self.env["urllib"].request.urlopen.assert_not_called()
|
||||
|
||||
def external(self, error):
|
||||
self.env["ROUTES"]["r"] = {"url": "https://example.invalid/v1/responses", "key": "test-only"}
|
||||
self.env["MODEL_MAP"].update({"x/combo": ("r", "Takim", None), "x/tek": ("r", "cc/claude", None)})
|
||||
self.env["urllib"].request.urlopen.side_effect = error
|
||||
|
||||
def test_combo_dropped_after_sending_is_not_resent_but_a_single_model_is(self):
|
||||
import http.client
|
||||
self.external(http.client.RemoteDisconnected("closed"))
|
||||
self.send("x/combo", [user("selam")])
|
||||
self.assertEqual(self.env["urllib"].request.urlopen.call_count, 1)
|
||||
self.env["urllib"].request.urlopen.reset_mock()
|
||||
self.external([urllib.error.URLError(ConnectionResetError("reset")), Mock(status=200)])
|
||||
fake, _ = self.send("x/tek", [user("selam")])
|
||||
self.assertEqual(self.env["urllib"].request.urlopen.call_count, 2)
|
||||
fake._stream.assert_called_once()
|
||||
self.env["urllib"].request.urlopen.reset_mock()
|
||||
self.external([urllib.error.URLError(ConnectionRefusedError("refused")), Mock(status=200)])
|
||||
self.send("x/combo", [user("selam")]) # never connected: safe to retry
|
||||
self.assertEqual(self.env["urllib"].request.urlopen.call_count, 2)
|
||||
|
||||
def test_error_body_reaches_the_client_whole_with_retry_after(self):
|
||||
whole = json.dumps({"error": {"message": "limit " + "x" * 900, "code": "rate_limit_exceeded"}}).encode()
|
||||
self.external(urllib.error.HTTPError("https://example.invalid", 429, "x", {"Retry-After": "17"}, io.BytesIO(whole)))
|
||||
fake, _ = self.send("x/tek", [user("selam")])
|
||||
self.assertEqual(fake.status, 429)
|
||||
self.assertEqual(fake.wfile.getvalue(), whole)
|
||||
self.assertIn(("Retry-After", "17"), fake.sent)
|
||||
self.assertIn(("Content-Length", str(len(whole))), fake.sent)
|
||||
|
||||
def test_image_in_an_orphan_tool_output_is_removed_before_flattening(self):
|
||||
self.env["resimleri_sadelestir"] = resim_context.resimleri_sadelestir
|
||||
self.env["CFG"]["nine_router_catalog"] = {"enabled": True, "route": "r", "prefix": "9r/"}
|
||||
self.external(None)
|
||||
self.env["ROUTES"]["r"]["flatten_tools"] = True
|
||||
self.env["urllib"].request.urlopen.return_value = Mock(status=200)
|
||||
picture = {"type": "input_image", "image_url": "data:image/png;base64," + "QUJD" * 500}
|
||||
self.send("x/tek", [{"type": "function_call_output", "name": "image_gen", "output": [picture]}, user("devam")])
|
||||
sent = self.env["urllib"].request.urlopen.call_args.args[0].data.decode()
|
||||
self.assertNotIn("QUJDQUJD", sent)
|
||||
self.assertIn("resim", sent)
|
||||
|
||||
|
||||
class Govde(unittest.TestCase):
|
||||
read = build([method("_read")], {})["_read"]
|
||||
|
||||
def test_chunked_upload_cut_short_ends_instead_of_spinning(self):
|
||||
fake = types.SimpleNamespace(headers={"Transfer-Encoding": "chunked"}, rfile=io.BytesIO(b"5\r\nhello\r\n"))
|
||||
self.assertEqual(Govde.read(fake), b"hello")
|
||||
fake = types.SimpleNamespace(headers={"Transfer-Encoding": "chunked"}, rfile=io.BytesIO(b"5\r\nhello\r\n0\r\n\r\n"))
|
||||
self.assertEqual(Govde.read(fake), b"hello")
|
||||
|
||||
|
||||
class Liste(unittest.TestCase):
|
||||
def env(self, **extra):
|
||||
env = {"json": json, "copy": copy, "hashlib": hashlib, "time": time, "log": lambda m: None, "CFG": {},
|
||||
"decompress": lambda raw, enc: raw, "EXTRA_MODELS": [], "IDE_PREFIX": "ide/", "SLIM_CLONES": False,
|
||||
"SLIM_FIELDS": [], "ide_catalog": ide_catalog, "LAST_MODELS": {}, "save_last_models": Mock(),
|
||||
"RESP_HOP": {"content-length", "content-encoding"}, "REQ_HOP": {"host"}, "CHATGPT": "https://example.invalid",
|
||||
"UPSTREAM_IDLE_TIMEOUT": 1,
|
||||
"urllib": types.SimpleNamespace(request=types.SimpleNamespace(Request=urllib.request.Request, urlopen=Mock()),
|
||||
error=urllib.error)}
|
||||
env.update(extra)
|
||||
return build([method("_models"), method("_passthrough"), method("_son_listeyi_ver")], env)
|
||||
|
||||
UPSTREAM = {"models": [{"slug": "gpt-6-sol", "display_name": "GPT-6-Sol", "priority": 1},
|
||||
{"slug": "gpt-6-astra", "display_name": "GPT-6-Astra", "priority": 2}]}
|
||||
|
||||
def test_gzip_answer_is_still_cached_plain_and_originals_keep_name_and_place(self):
|
||||
extras = [{"slug": "gpt-6-astra", "display_name": "Baska Ad"}, {"slug": "ide/a", "display_name": "Aaa", "kind": "combo"}]
|
||||
env = self.env(EXTRA_MODELS=extras, CFG={"display_map": {"gpt-6-sol": "Yeni Ad"}})
|
||||
fake = Sahte({"Accept-Encoding": "gzip", "chatgpt-account-id": "hesap"}, "GET")
|
||||
resp = types.SimpleNamespace(read=lambda: json.dumps(self.UPSTREAM).encode(), headers={"ETag": "x"})
|
||||
env["_models"](fake, resp, "[t]")
|
||||
listed = json.loads(gzip.decompress(fake.wfile.getvalue()))["models"]
|
||||
self.assertEqual([(m["slug"], m["display_name"]) for m in listed],
|
||||
[("gpt-6-sol", "GPT-6-Sol"), ("gpt-6-astra", "GPT-6-Astra"), ("ide/a", "Aaa")])
|
||||
cached = env["LAST_MODELS"]["hesap"]
|
||||
self.assertEqual(json.loads(cached["body"])["models"], listed) # plain JSON, usable as a fallback
|
||||
self.assertNotIn("Content-Encoding", dict(cached["headers"]))
|
||||
self.assertIn(("Content-Encoding", "gzip"), fake.sent)
|
||||
env["save_last_models"].assert_called_once()
|
||||
|
||||
def test_no_network_still_serves_the_last_list(self):
|
||||
cached = {"headers": [("Content-Type", "application/json")], "body": b'{"models": []}', "at": "x"}
|
||||
env = self.env(LAST_MODELS={"hesap": cached})
|
||||
env["urllib"].request.urlopen.side_effect = urllib.error.URLError(OSError("no network"))
|
||||
fake = Sahte({"chatgpt-account-id": "hesap"}, "GET")
|
||||
fake._son_listeyi_ver = types.MethodType(env["_son_listeyi_ver"], fake)
|
||||
env["_passthrough"](fake, 1, "/backend-api/codex/models?client_version=1", b"")
|
||||
self.assertEqual((fake.status, fake.wfile.getvalue()), (200, b'{"models": []}'))
|
||||
other = Sahte({"chatgpt-account-id": "baska"}, "GET")
|
||||
other._son_listeyi_ver = types.MethodType(env["_son_listeyi_ver"], other)
|
||||
env["_passthrough"](other, 1, "/backend-api/codex/models", b"")
|
||||
self.assertEqual(other._send_json.call_args.args[0], 502)
|
||||
|
||||
|
||||
class Kucukler(unittest.TestCase):
|
||||
def test_log_line_has_the_date_and_no_token_tail_and_status_reports_the_code(self):
|
||||
self.assertIn('time.strftime("%Y-%m-%d %H:%M:%S ") + msg', SOURCE)
|
||||
self.assertNotIn("token=..", SOURCE)
|
||||
self.assertNotIn("DISPLAY_MAP", SOURCE)
|
||||
self.assertIn('"kod": KOD', SOURCE)
|
||||
|
||||
def test_responses_path_with_a_query_is_still_routed(self):
|
||||
env = build([method("_handle")], {"lock": __import__("threading").Lock(), "seq": [0], "DEBUG": False,
|
||||
"log": lambda m: None})
|
||||
fake = Sahte({})
|
||||
fake.path = "/backend-api/codex/responses?stream=true"
|
||||
fake._read = lambda: b"{}"
|
||||
fake._responses = Mock()
|
||||
env["_handle"](fake)
|
||||
fake._responses.assert_called_once()
|
||||
fake._passthrough.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,107 @@
|
||||
import json, os, tempfile, unittest
|
||||
import ide_catalog as c
|
||||
|
||||
|
||||
def girdi(mid, ad, yetenek=("chat",), sira=0, kaynak="omniroute", kind="model"):
|
||||
return {"id": mid, "turkservis": {"display_name": ad, "capabilities": list(yetenek), "order": sira, "source": kaynak, "kind": kind}}
|
||||
|
||||
|
||||
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, kind="combo"),
|
||||
girdi("9router-Takim-Agir-6e65dba5", "OmniRoute · Takim-Agir", sira=8, kind="combo"),
|
||||
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")
|
||||
# Uye katalogu yerel config'teki adlarla sinirlanmaz.
|
||||
self.assertEqual([x["id"] for x in s], ["9router-nano-x", "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, kind="combo"),
|
||||
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. Dogrudan 9Router tekilleri de eklenmez.
|
||||
self.assertEqual([(x["id"], x["ad"]) for x in s],
|
||||
[("Takim-Agir", "Takim-Agir"), ("Ajan-Arac", "Ajan-Arac")])
|
||||
|
||||
def test_9router_modelleri_yalniz_omniroute_uzerinden_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")
|
||||
# Yerel model tanimi gerekmez; iki kaynak yerine yalniz OmniRoute yolu gelir.
|
||||
self.assertEqual([(x["id"], x["ad"]) for x in s],
|
||||
[("9router-nano-x", "nano/x"), ("vag-z", "vag/z")])
|
||||
|
||||
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", "nano/qwen3.8-27b"), ("9R · ag/gemini", "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,81 @@
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import ara
|
||||
import internet
|
||||
|
||||
SAYFA = '''
|
||||
<div class="result results_links results_links_deep web-result ">
|
||||
<h2 class="result__title"><a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fornek.com%2Fbir%3Fa%3D1&rut=x">Bir <b>başlık</b></a></h2>
|
||||
<a class="result__snippet" href="//x">Kısa & öz <b>özet</b></a>
|
||||
</div>
|
||||
<div class="result results_links web-result ">
|
||||
<a class="result__a" href="https://duckduckgo.com/y.js?ad=1">Reklam</a>
|
||||
</div>
|
||||
<div class="result results_links web-result ">
|
||||
<a class="result__a" href="https://ornek.org/iki">İkinci</a>
|
||||
</div>
|
||||
'''
|
||||
|
||||
|
||||
class AraTest(unittest.TestCase):
|
||||
def test_results_are_parsed_and_ads_skipped(self):
|
||||
self.assertEqual(ara.sonuclari_coz(SAYFA), [("Bir başlık", "https://ornek.com/bir?a=1", "Kısa & öz özet"),
|
||||
("İkinci", "https://ornek.org/iki", "")])
|
||||
self.assertEqual(len(ara.sonuclari_coz(SAYFA, adet=1)), 1)
|
||||
|
||||
def test_search_output_and_empty_result(self):
|
||||
metin = ara.ara("x", indirici=lambda adres: (SAYFA, "text/html"))
|
||||
self.assertIn("1. Bir başlık\n https://ornek.com/bir?a=1", metin)
|
||||
self.assertIn("Sonuç bulunamadı", ara.ara("x", indirici=lambda adres: ('<div class="no-results">no results</div>', "text/html")))
|
||||
|
||||
def test_bot_challenge_uses_one_rss_backup_not_false_no_results(self):
|
||||
calls = []
|
||||
def indirici(adres):
|
||||
calls.append(adres)
|
||||
return (('<form id="challenge-form">Bot challenge</form>' if len(calls) == 1 else
|
||||
'<rss><channel><item><title>OmniRoute</title><link>https://ornek.com/</link></item></channel></rss>'), 'text/html')
|
||||
self.assertIn('https://ornek.com/', ara.ara('OmniRoute', indirici=indirici))
|
||||
self.assertEqual(len(calls), 2)
|
||||
|
||||
def test_two_invalid_services_report_failure_not_no_results(self):
|
||||
with self.assertRaisesRegex(RuntimeError, 'Arama hizmeti'):
|
||||
ara.ara('x', indirici=lambda adres: ('<html>challenge</html>', 'text/html'))
|
||||
|
||||
def test_page_is_turned_into_text_and_cut(self):
|
||||
sayfa = "<html><head><style>a{}</style><script>x()</script></head><body><nav>menü</nav><p>Bir & iki</p><div>üç</div></body></html>"
|
||||
self.assertEqual(ara.oku("ornek.com", indirici=lambda adres: (sayfa, "text/html")), "Bir & iki\nüç")
|
||||
uzun = ara.oku("https://ornek.com", en_cok=10, indirici=lambda adres: ("<p>" + "a" * 50 + "</p>", "text/html"))
|
||||
self.assertTrue(uzun.startswith("a" * 10 + "\n[... kesildi: 50"))
|
||||
|
||||
def test_main_reports_errors_without_raising(self):
|
||||
self.assertEqual(ara.main(["oku", "http://127.0.0.1:9/yok"]), 1)
|
||||
|
||||
|
||||
class InternetTest(unittest.TestCase):
|
||||
def test_note_names_both_commands_and_is_added_once(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
betik = os.path.join(d, "ara.py")
|
||||
open(betik, "w").close()
|
||||
metin = internet.talimat(None, betik, python="py.exe")
|
||||
on = '& ' if os.name == 'nt' else ''
|
||||
self.assertIn(f'{on}"py.exe" "{betik}" ara "sorgu"', metin)
|
||||
self.assertIn(f'"py.exe" "{betik}" oku <adres>', metin)
|
||||
req = {"input": [{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "araştır"}]}]}
|
||||
self.assertTrue(internet.ekle(req, metin))
|
||||
self.assertFalse(internet.ekle(req, metin))
|
||||
self.assertEqual(req["input"][0]["role"], "developer")
|
||||
self.assertEqual(len(req["input"]), 2)
|
||||
self.assertIsNone(internet.talimat({"enabled": False}, betik))
|
||||
self.assertIsNone(internet.talimat(None, os.path.join(d, "ara.py"))) # betik yoksa not eklenmez
|
||||
|
||||
def test_windowless_python_is_swapped_for_the_console_one(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
open(os.path.join(d, "python.exe"), "w").close()
|
||||
self.assertEqual(internet.python_yolu(os.path.join(d, "pythonw.exe")), os.path.join(d, "python.exe"))
|
||||
self.assertEqual(internet.python_yolu(os.path.join("yok", "pythonw.exe")), os.path.join("yok", "pythonw.exe"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""The "send larger work to AITURK" instruction reaches only Codex Desktop, and only when a send command exists."""
|
||||
import unittest
|
||||
|
||||
import is_hatti as h
|
||||
|
||||
|
||||
class Hat(unittest.TestCase):
|
||||
def test_only_the_desktop_app_is_recognised(self):
|
||||
self.assertTrue(h.masaustu_mu("Codex Desktop"))
|
||||
for other in ("codex-tui", "codex_exec", "codex_cli_rs", "", None, "Codex Desktop Helper"):
|
||||
self.assertFalse(h.masaustu_mu(other), other)
|
||||
|
||||
def test_no_instruction_when_switched_off_or_without_a_send_command(self):
|
||||
self.assertIsNone(h.talimat(None))
|
||||
self.assertIsNone(h.talimat({}))
|
||||
self.assertIsNone(h.talimat({"enabled": True, "komut": " "}))
|
||||
self.assertIsNone(h.talimat({"enabled": False, "komut": "aiturk gonder"}))
|
||||
self.assertIsNone(h.talimat({"enabled": False}, "C:/a/aiturk.exe", "s1"))
|
||||
text = h.talimat({"enabled": True, "komut": "aiturk gonder --dosya is.md"})
|
||||
self.assertIn("aiturk gonder --dosya is.md", text)
|
||||
self.assertIn("sen yap", text)
|
||||
self.assertEqual(h.talimat({"enabled": True, "komut": "x", "talimat": "ozel {komut}"}), "ozel x")
|
||||
|
||||
def test_on_by_default_once_a_send_command_exists_on_this_computer(self):
|
||||
text = h.talimat(None, "C:/Program/aiturk.exe", "019a-bc_d")
|
||||
self.assertIn('"C:/Program/aiturk.exe" is --sohbet 019a-bc_d --dosya', text)
|
||||
self.assertIn('"C:/Program/aiturk.exe" is --dosya', h.talimat({}, "C:/Program/aiturk.exe", ""))
|
||||
# Sohbet kimligi komut satirina yazilir: kabuk karakteri tasiyamaz.
|
||||
self.assertIn("--sohbet abrm-rf ", h.komut_satiri("x", 'a"b; rm -rf /'))
|
||||
# Ayardaki komut bulunanin onune gecer.
|
||||
self.assertIn("elle", h.talimat({"komut": "elle"}, "C:/Program/aiturk.exe"))
|
||||
|
||||
def test_the_first_candidate_that_knows_the_command_is_used(self):
|
||||
import os
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
eski = os.path.join(d, "TurkServis", "AITURK", "program", "bin", "aiturk.cmd")
|
||||
yeni = os.path.join(d, "TurkServis", "AITURK-is-hatti", "Scripts", "aiturk.exe")
|
||||
for yol in (eski, yeni):
|
||||
os.makedirs(os.path.dirname(yol))
|
||||
open(yol, "w").close()
|
||||
ortam = {"LOCALAPPDATA": d}
|
||||
self.assertEqual(h.adaylar(ortam)[0], eski)
|
||||
self.assertEqual(h.komut_bul(ortam, destek=lambda y: y == yeni), yeni)
|
||||
self.assertEqual(h.komut_bul(ortam, destek=lambda y: True), eski)
|
||||
self.assertIsNone(h.komut_bul(ortam, destek=lambda y: False))
|
||||
|
||||
class Sonuc:
|
||||
returncode = 2
|
||||
self.assertFalse(h.destekliyor(eski, calistir=lambda *a, **k: Sonuc()))
|
||||
Sonuc.returncode = 0
|
||||
self.assertTrue(h.destekliyor(eski, calistir=lambda *a, **k: Sonuc()))
|
||||
self.assertFalse(h.destekliyor(os.path.join(d, "yok.exe"), calistir=lambda *a, **k: Sonuc()))
|
||||
|
||||
def patlar(*a, **k):
|
||||
raise OSError("x")
|
||||
self.assertFalse(h.destekliyor(eski, calistir=patlar))
|
||||
|
||||
def test_instruction_is_put_first_once_and_never_repeated(self):
|
||||
req = {"input": [{"type": "message", "role": "user", "content": "selam"}]}
|
||||
text = h.talimat({"enabled": True, "komut": "aiturk gonder"})
|
||||
self.assertTrue(h.ekle(req, text))
|
||||
self.assertEqual((req["input"][0]["role"], len(req["input"])), ("developer", 2))
|
||||
self.assertFalse(h.ekle(req, text))
|
||||
self.assertEqual(len(req["input"]), 2)
|
||||
self.assertFalse(h.ekle({"input": "duz metin"}, text))
|
||||
self.assertFalse(h.ekle(req, None))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Layer check (11 Oct 2026): Jev passes or blocks each step of Katman-1..3; a blocked step is redone by the next
|
||||
layer. Katman-4 is never asked. No exceptions."""
|
||||
import ast
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
import jev_denetim
|
||||
import yarim
|
||||
|
||||
SOURCE = Path(__file__).with_name("kopru.py").read_text(encoding="utf-8")
|
||||
tree = ast.parse(SOURCE)
|
||||
handler = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "H")
|
||||
|
||||
|
||||
def method(name):
|
||||
return next(n for n in handler.body if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
|
||||
|
||||
def user(text):
|
||||
return {"type": "message", "role": "user", "content": [{"type": "input_text", "text": text}]}
|
||||
|
||||
|
||||
def sse(*deltas):
|
||||
out = b""
|
||||
for d in deltas:
|
||||
out += b"event: response.output_text.delta\ndata: " + json.dumps(
|
||||
{"type": "response.output_text.delta", "delta": d}).encode() + b"\n\n"
|
||||
return out + b'event: response.completed\ndata: {"type": "response.completed"}\n\n'
|
||||
|
||||
|
||||
class Yanit:
|
||||
status = 200
|
||||
|
||||
def __init__(self, body):
|
||||
self.parts = [body[:len(body) // 2], body[len(body) // 2:]]
|
||||
self.headers = {"Content-Type": "text/event-stream"}
|
||||
|
||||
def read1(self, n):
|
||||
return self.parts.pop(0) if self.parts else b""
|
||||
|
||||
|
||||
class Karar(unittest.TestCase):
|
||||
def test_refusal_or_own_conditions_block(self):
|
||||
self.assertTrue(jev_denetim.karar({"ret": 0.84, "sart": 0.1}))
|
||||
self.assertTrue(jev_denetim.karar({"ret": 0.41, "sart": 0.95}))
|
||||
self.assertTrue(jev_denetim.karar({"ret": 0.5, "sart": 0.0}))
|
||||
|
||||
def test_normal_answer_passes(self):
|
||||
self.assertFalse(jev_denetim.karar({"ret": 0.05, "sart": 0.3}))
|
||||
|
||||
def test_no_exception_questions_exist(self):
|
||||
self.assertEqual(set(jev_denetim.SORU), {"ret", "sart"})
|
||||
|
||||
|
||||
class Katman(unittest.TestCase):
|
||||
def test_a_non_layer_model_falls_through_every_layer_from_the_top(self):
|
||||
self.assertEqual(jev_denetim.sonrakiler("Uncensored-Uygulama"),
|
||||
["Katman-1", "Katman-2", "Katman-3", "Katman-4"])
|
||||
self.assertEqual(jev_denetim.sonrakiler("ide/some-model"),
|
||||
["Katman-1", "Katman-2", "Katman-3", "Katman-4"])
|
||||
|
||||
def test_a_selected_layer_only_tries_the_ones_after_it(self):
|
||||
self.assertEqual(jev_denetim.sonrakiler("Katman-1"), ["Katman-2", "Katman-3", "Katman-4"])
|
||||
self.assertEqual(jev_denetim.sonrakiler("Katman-2"), ["Katman-3", "Katman-4"])
|
||||
self.assertEqual(jev_denetim.sonrakiler("Katman-3"), ["Katman-4"])
|
||||
|
||||
def test_last_layer_is_never_asked(self):
|
||||
self.assertEqual(jev_denetim.sonrakiler("Katman-4"), [])
|
||||
self.assertEqual(jev_denetim.sonrakiler(None), [])
|
||||
|
||||
|
||||
class Istek(unittest.TestCase):
|
||||
def test_last_user_request_is_taken_and_wrappers_are_skipped(self):
|
||||
voice = user("<realtime_delegation>\n <input>siteyi yap</input>\n</realtime_delegation>")
|
||||
self.assertEqual(jev_denetim.son_istek({"input": [user("eski"), voice]}), "siteyi yap")
|
||||
hook = user("<hook_context>pano</hook_context>")
|
||||
call = {"type": "function_call", "call_id": "c", "name": "shell", "arguments": "{}"}
|
||||
self.assertEqual(jev_denetim.son_istek({"input": [user("README'yi düzelt"), call, hook]}), "README'yi düzelt")
|
||||
self.assertEqual(jev_denetim.son_istek({"input": [call]}), "")
|
||||
|
||||
|
||||
class Sor(unittest.TestCase):
|
||||
def test_request_shape_and_scores(self):
|
||||
seen = {}
|
||||
|
||||
def gonder(govde, key):
|
||||
seen.update(govde=govde, key=key)
|
||||
return {"answers": {k: {"type": "noul", "noul": 0.25} for k in jev_denetim.SORU}}
|
||||
self.assertEqual(jev_denetim.sor("istek", "c" * 9000, "anahtar", gonder=gonder), {"ret": 0.25, "sart": 0.25})
|
||||
self.assertEqual(seen["key"], "anahtar")
|
||||
self.assertEqual(seen["govde"]["state"]["user_request"], "istek")
|
||||
self.assertEqual(seen["govde"]["state"]["assistant_reply"], "c" * 9000)
|
||||
|
||||
def test_failures_return_none_every_time(self):
|
||||
calls = []
|
||||
|
||||
def bozuk(govde, key):
|
||||
calls.append(1)
|
||||
raise OSError("ulasilamadi")
|
||||
for _ in range(5):
|
||||
self.assertIsNone(jev_denetim.sor("i", "c", "k", gonder=bozuk))
|
||||
self.assertEqual(len(calls), 5)
|
||||
|
||||
def test_key_is_read_from_env_or_file(self):
|
||||
self.assertEqual(jev_denetim.anahtar({"TYPESAFE_API_KEY": " a "}), "a")
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
yol = os.path.join(d, "typesafe.env")
|
||||
Path(yol).write_text('TYPESAFE_API_KEY="b"\n', encoding="utf-8")
|
||||
self.assertEqual(jev_denetim.anahtar({}, [yol]), "b")
|
||||
self.assertEqual(jev_denetim.anahtar({}, [os.path.join(d, "yok.env")]), "")
|
||||
|
||||
|
||||
class Akis(unittest.TestCase):
|
||||
"""_denetimli_akis through a fake handler: what reaches the client."""
|
||||
|
||||
def setUp(self):
|
||||
self.logs, self.asked, self.opened = [], [], []
|
||||
self.puanlar = [] # one verdict per asked answer; empty -> pass
|
||||
sahte = Mock()
|
||||
sahte.anahtar = lambda: "k"
|
||||
sahte.sor = self.sor
|
||||
sahte.karar = jev_denetim.karar
|
||||
sahte.komutlar = jev_denetim.komutlar
|
||||
sahte.hazirlik_mi = jev_denetim.hazirlik_mi
|
||||
env = {"jev_denetim": sahte, "yarim": yarim, "time": time, "http": __import__("http.client"),
|
||||
"log": self.logs.append, "RESP_HOP": set(), "json": json}
|
||||
adlar = ("_akis_basligi", "_denetimli_akis", "_stream")
|
||||
exec(compile(ast.Module(body=[method(a) for a in adlar], type_ignores=[]), "kopru.py", "exec"), env)
|
||||
self.h = type("H", (), {a: env[a] for a in adlar})()
|
||||
self.h.wfile = io.BytesIO()
|
||||
self.h.send_response = Mock()
|
||||
self.h.send_header = Mock()
|
||||
self.h.end_headers = Mock()
|
||||
self.cevap = {"Katman-1": sse("bir"), "Katman-2": sse("iki"), "Katman-3": sse("uc"), "Katman-4": sse("dort")}
|
||||
|
||||
def sor(self, istek, metin, key):
|
||||
self.asked.append(metin)
|
||||
return self.puanlar.pop(0) if self.puanlar else {"ret": 0.0, "sart": 0.0}
|
||||
|
||||
def ac(self, ad):
|
||||
self.opened.append(ad)
|
||||
if self.cevap[ad] is None:
|
||||
raise OSError("acilamadi")
|
||||
return Yanit(self.cevap[ad]), None, f"[{ad}]"
|
||||
|
||||
def sent(self):
|
||||
raw, out = self.h.wfile.getvalue(), b""
|
||||
while raw:
|
||||
size, _, rest = raw.partition(b"\r\n")
|
||||
n = int(size, 16)
|
||||
if n == 0:
|
||||
break
|
||||
out += rest[:n]
|
||||
raw = rest[n + 2:]
|
||||
return out
|
||||
|
||||
def calistir(self, body, sira=("Katman-2", "Katman-3", "Katman-4")):
|
||||
self.h._denetimli_akis(Yanit(body), "[1]", None, None, "yap", list(sira), self.ac)
|
||||
|
||||
def test_a_passed_step_is_delivered_unchanged(self):
|
||||
body = sse("Fenerbahçe'nin ", "13 puanı var.")
|
||||
self.calistir(body)
|
||||
self.assertEqual(self.sent(), body)
|
||||
self.assertEqual(self.asked, ["Fenerbahçe'nin 13 puanı var."])
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
def test_a_blocked_non_layer_model_goes_to_layer_1_first(self):
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}] # the selected (non-layer) model is blocked
|
||||
self.calistir(sse("Bunu yapamam."), sira=("Katman-1", "Katman-2", "Katman-3", "Katman-4"))
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-1"])
|
||||
self.assertEqual(self.opened, ["Katman-1"])
|
||||
self.assertEqual(self.asked, ["Bunu yapamam.", "bir"])
|
||||
|
||||
def test_blocked_layer_1_goes_to_layer_2_which_is_checked_too(self):
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}]
|
||||
self.calistir(sse("Bunu yapamam."))
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-2"])
|
||||
self.assertNotIn(b"yapamam", self.h.wfile.getvalue())
|
||||
self.assertEqual(self.opened, ["Katman-2"])
|
||||
self.assertEqual(self.asked, ["Bunu yapamam.", "iki"])
|
||||
self.h.send_response.assert_called_once()
|
||||
|
||||
def test_every_layer_blocked_ends_at_layer_4_unasked(self):
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}] * 3
|
||||
self.calistir(sse("hayir"))
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-4"])
|
||||
self.assertEqual(self.opened, ["Katman-2", "Katman-3", "Katman-4"])
|
||||
self.assertEqual(self.asked, ["hayir", "iki", "uc"]) # layer 4 is never asked
|
||||
|
||||
def test_blocked_layer_3_goes_straight_to_layer_4(self):
|
||||
self.puanlar = [{"ret": 0.0, "sart": 0.8}]
|
||||
self.calistir(sse("kendi sartimla"), sira=("Katman-4",))
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-4"])
|
||||
self.assertEqual(self.asked, ["kendi sartimla"])
|
||||
|
||||
def test_a_layer_that_cannot_be_opened_is_skipped(self):
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}]
|
||||
self.cevap["Katman-2"] = None
|
||||
self.calistir(sse("hayir"))
|
||||
self.assertEqual(self.opened, ["Katman-2", "Katman-3"])
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-3"])
|
||||
|
||||
def test_a_layer_that_answers_with_an_error_is_skipped(self):
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}]
|
||||
self.cevap["Katman-2"] = b'event: error\ndata: {"type":"error","error":{"type":"upstream_error","message":"upstream 400"}}\n\n'
|
||||
self.calistir(sse("hayir"))
|
||||
self.assertEqual(self.opened, ["Katman-2", "Katman-3"])
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-3"])
|
||||
self.assertNotIn(b"upstream 400", self.h.wfile.getvalue())
|
||||
|
||||
KOMUT = (b'event: response.output_item.done\ndata: {"type": "response.output_item.done", "item": '
|
||||
b'{"type": "function_call", "name": "shell", "arguments": "{\\"cmd\\": \\"dir\\"}"}}\n\n')
|
||||
|
||||
def test_a_step_without_text_passes_without_jev(self):
|
||||
body = self.KOMUT + sse()
|
||||
self.calistir(body)
|
||||
self.assertEqual(self.sent(), body)
|
||||
self.assertEqual(self.asked, [])
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
def test_tool_step_cannot_be_replaced_even_if_jev_would_refuse(self):
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}]
|
||||
body = self.KOMUT + sse()
|
||||
self.calistir(body)
|
||||
self.assertEqual(self.opened, [])
|
||||
self.assertEqual(self.asked, [])
|
||||
self.assertEqual(self.sent(), body)
|
||||
|
||||
def test_tool_event_reaches_client_before_upstream_finishes(self):
|
||||
first = (b'data: {"type":"response.output_item.added","item":'
|
||||
b'{"type":"function_call","name":"shell","arguments":""}}\n\n')
|
||||
owner = self
|
||||
|
||||
class Streaming(Yanit):
|
||||
def __init__(self):
|
||||
super().__init__(b'')
|
||||
self.parts = [first, owner.KOMUT + sse()]
|
||||
self.reads = 0
|
||||
|
||||
def read1(self, n):
|
||||
self.reads += 1
|
||||
if self.reads == 2:
|
||||
owner.assertIn(first, owner.h.wfile.getvalue())
|
||||
owner.assertEqual(owner.asked, [])
|
||||
owner.assertEqual(owner.opened, [])
|
||||
return super().read1(n)
|
||||
|
||||
self.h._denetimli_akis(Streaming(), '[1]', None, None, 'yap', ['Katman-1'], self.ac)
|
||||
self.assertEqual(self.sent(), first + self.KOMUT + sse())
|
||||
|
||||
def test_text_with_a_tool_call_is_preparation_not_a_final_refusal(self):
|
||||
body = sse('Önce dosyaları inceleyeceğim.') + self.KOMUT
|
||||
self.calistir(body)
|
||||
self.assertEqual(self.sent(), body)
|
||||
self.assertEqual(self.asked, [])
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
def test_explicit_commentary_passes_without_jev(self):
|
||||
body = (b'data: {"type":"response.output_item.added","item":'
|
||||
b'{"type":"message","role":"assistant","phase":"commentary"}}\n\n' + sse('Araştırıyorum.'))
|
||||
self.calistir(body)
|
||||
self.assertEqual(self.sent(), body)
|
||||
self.assertEqual(self.asked, [])
|
||||
|
||||
def test_an_empty_step_of_the_selected_model_goes_to_the_next_layer(self):
|
||||
self.calistir(sse()) # no text, no tool call
|
||||
self.assertEqual(self.asked, ["iki"]) # nothing to ask about the empty step itself
|
||||
self.assertEqual(self.opened, ["Katman-2"])
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-2"])
|
||||
|
||||
def test_an_empty_layer_goes_on_to_the_one_after_it(self):
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}]
|
||||
self.cevap["Katman-2"] = sse()
|
||||
self.calistir(sse("hayir"))
|
||||
self.assertEqual(self.opened, ["Katman-2", "Katman-3"])
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-3"])
|
||||
|
||||
def test_an_error_of_the_selected_model_goes_to_the_next_layer(self):
|
||||
self.h._denetimli_akis(None, "[1]", None, None, "yap", ["Katman-1", "Katman-2", "Katman-3", "Katman-4"], self.ac)
|
||||
self.assertEqual(self.opened, ["Katman-1"])
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-1"])
|
||||
|
||||
def test_an_answer_cut_midway_goes_to_the_next_layer(self):
|
||||
class Kopan(Yanit):
|
||||
def read1(self, n):
|
||||
if not self.parts:
|
||||
raise OSError("koptu")
|
||||
return self.parts.pop(0)
|
||||
self.h._denetimli_akis(Kopan(sse("yarim")), "[1]", None, None, "yap", ["Katman-2", "Katman-3", "Katman-4"], self.ac)
|
||||
self.assertEqual(self.opened, ["Katman-2"])
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-2"])
|
||||
self.assertNotIn(b"yarim", self.h.wfile.getvalue())
|
||||
|
||||
def test_when_no_layer_opens_the_blocked_answer_is_not_delivered(self):
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}]
|
||||
self.cevap.update({"Katman-2": None, "Katman-3": None, "Katman-4": None})
|
||||
self.calistir(sse("Bunu yapamam."))
|
||||
self.assertEqual(self.opened, ["Katman-2", "Katman-3", "Katman-4"])
|
||||
self.assertNotIn(b"yapamam", self.h.wfile.getvalue())
|
||||
self.assertEqual(self.sent(), b"") # the last layer said nothing: nothing is added
|
||||
|
||||
def test_the_last_layers_own_error_body_is_delivered_untouched(self):
|
||||
class Hata(Exception):
|
||||
def read(self):
|
||||
return b'{"error": {"message": "katman 4 hatasi"}}'
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}]
|
||||
|
||||
def ac(ad):
|
||||
raise Hata("HTTP 400")
|
||||
self.h._denetimli_akis(Yanit(sse("Bunu yapamam.")), "[1]", None, None, "yap", ["Katman-4"], ac)
|
||||
self.assertEqual(self.sent(), b'{"error": {"message": "katman 4 hatasi"}}')
|
||||
|
||||
def test_unreachable_jev_keeps_the_answer(self):
|
||||
self.puanlar = [None]
|
||||
body = sse("Bunu yapamam.")
|
||||
self.calistir(body)
|
||||
self.assertEqual(self.sent(), body)
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Bridge 7: tool-less models are learned, flat gateway errors are translated, instructions are never trimmed."""
|
||||
import ast
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import pencere
|
||||
|
||||
tree = ast.parse(Path(__file__).with_name("kopru.py").read_text(encoding="utf-8"))
|
||||
names = {"fix_tool_ids", "SseRewriter", "phase_damgala", "asistan_mesaji_mi", "nokta_mesaji_mi", "hazir_yaniti",
|
||||
"sema_duzelt", "arac_semalarini_duzelt"}
|
||||
nodes = [n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.ClassDef)) and n.name in names]
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id in
|
||||
{"ID_PREFIX", "FOREIGN_ID_MARK", "REFERENCE_TYPES", "INLINE_TYPES", "ARAC_OGELERI", "NOKTA_ISARETLERI"} for t in node.targets):
|
||||
nodes.insert(0, node)
|
||||
env = {"json": json, "re": __import__("re"), "time": __import__("time"), "log": lambda message: None, "pencere": pencere}
|
||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), "bridge.py", "exec"), env)
|
||||
|
||||
|
||||
def feed(event, **kw):
|
||||
adapter = env["SseRewriter"](set(), **kw)
|
||||
raw = b"data: " + json.dumps(event).encode() + b"\n\n"
|
||||
return [json.loads(line[6:]) for line in (adapter.feed(raw) + adapter.flush()).decode().splitlines()
|
||||
if line.startswith("data: ")]
|
||||
|
||||
|
||||
class Ek(unittest.TestCase):
|
||||
def test_model_without_tool_calls_is_reported_and_remembered(self):
|
||||
seen = []
|
||||
flat = {"type": "error", "code": "bad_request", "status_code": 400, "message":
|
||||
'[400]: {"error":{"message":"This model does not support tool calls.","code":"tool_choice_unsupported"}}'}
|
||||
out = feed(flat, hedef="m", aracsiz=seen.append)
|
||||
# A permanent error ends the turn with a visible answer instead of a silent retry.
|
||||
self.assertEqual([e["type"] for e in out], ["response.output_item.added", "response.output_text.delta",
|
||||
"response.output_item.done", "response.completed"])
|
||||
self.assertIn("araç çağrısını desteklemiyor", out[1]["delta"])
|
||||
self.assertEqual(out[2]["item"]["phase"], "final_answer")
|
||||
self.assertEqual(seen, ["m"])
|
||||
home = tempfile.mkdtemp()
|
||||
self.assertTrue(pencere.ogren_aracsiz(home, "m"))
|
||||
self.assertFalse(pencere.ogren_aracsiz(home, "m"))
|
||||
self.assertFalse(pencere.coz("m", "model", "cc/claude-opus-5-5", aracsiz=pencere.aracsizlar(home))["arac"])
|
||||
self.assertTrue(pencere.coz("n", "model", "cc/claude-opus-5-5", aracsiz=pencere.aracsizlar(home))["arac"])
|
||||
|
||||
def test_flat_overflow_error_is_translated_and_learned(self):
|
||||
seen = []
|
||||
flat = {"type": "error", "code": "bad_request", "message":
|
||||
"Your prompt exceeds the model's context length. Max context tokens: 65536."}
|
||||
out = feed(flat, hedef="m", tasma=lambda hedef, metin: seen.append(pencere.sinir_oku(metin)))
|
||||
self.assertEqual(out[0]["response"]["error"]["code"], "context_length_exceeded")
|
||||
self.assertEqual(seen, [65536])
|
||||
self.assertEqual(feed({"type": "error", "code": "server_error", "message": "upstream 503"})[0]["type"], "error")
|
||||
|
||||
def test_guard_never_trims_instruction_messages(self):
|
||||
big = "KURAL " * 40000
|
||||
req = {"input": [
|
||||
{"type": "message", "role": "developer", "content": [{"type": "input_text", "text": big}]},
|
||||
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "eski " * 40000}]},
|
||||
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "soru"}]}]}
|
||||
pencere.sigdir(req, 65536)
|
||||
self.assertEqual(req["input"][0]["content"][0]["text"], big)
|
||||
self.assertIn("çıkarıldı (köprü)", req["input"][1]["content"][0]["text"])
|
||||
|
||||
|
||||
class Sema(unittest.TestCase):
|
||||
def test_top_level_union_becomes_one_object_schema(self):
|
||||
req = {"tools": [
|
||||
{"type": "function", "name": "a", "parameters": {"oneOf": [
|
||||
{"type": "object", "properties": {"x": {"type": "string"}, "k": {"type": "string"}}, "required": ["x", "k"]},
|
||||
{"type": "object", "properties": {"y": {"type": "integer"}, "k": {"type": "string"}}, "required": ["y", "k"]}]}},
|
||||
{"type": "function", "name": "b", "parameters": {"type": "object", "properties": {"z": {"anyOf": [{"type": "string"}, {"type": "null"}]}}}},
|
||||
{"type": "function", "name": "c", "parameters": {"allOf": [
|
||||
{"type": "object", "properties": {"p": {"type": "string"}}, "required": ["p"]},
|
||||
{"type": "object", "properties": {"q": {"type": "string"}}, "required": ["q"]}]}}]}
|
||||
self.assertEqual(env["arac_semalarini_duzelt"](req), 2)
|
||||
a, b, c = (t["parameters"] for t in req["tools"])
|
||||
self.assertEqual((a["type"], sorted(a["properties"]), a["required"]), ("object", ["k", "x", "y"], ["k"]))
|
||||
self.assertNotIn("oneOf", a)
|
||||
self.assertIn("anyOf", b["properties"]["z"]) # nested unions are left alone
|
||||
self.assertEqual((sorted(c["properties"]), c["required"]), (["p", "q"], ["p", "q"]))
|
||||
self.assertEqual(env["arac_semalarini_duzelt"](req), 0)
|
||||
|
||||
|
||||
class Nokta(unittest.TestCase):
|
||||
def user(self, content):
|
||||
return {"type": "message", "role": "user", "content": content}
|
||||
|
||||
def test_only_a_lone_dot_from_the_user_is_answered_locally(self):
|
||||
is_dot = env["nokta_mesaji_mi"]
|
||||
self.assertTrue(is_dot({"input": [self.user([{"type": "input_text", "text": " . "}])]}))
|
||||
self.assertTrue(is_dot({"input": [self.user("x"), self.user(".")]}))
|
||||
for other in ("..", "?", "Selam", "", "1."):
|
||||
self.assertFalse(is_dot({"input": [self.user([{"type": "input_text", "text": other}])]}), other)
|
||||
self.assertFalse(is_dot({"input": [self.user("."), {"type": "function_call_output", "call_id": "c", "output": "x"}]}))
|
||||
self.assertFalse(is_dot({"input": [self.user([{"type": "input_text", "text": "."}, {"type": "input_image", "image_url": "x"}])]}))
|
||||
self.assertFalse(is_dot({"input": [{"type": "message", "role": "assistant", "content": "."}]}))
|
||||
self.assertFalse(is_dot({"input": []}))
|
||||
|
||||
def test_local_answer_is_a_complete_response_with_a_final_answer(self):
|
||||
raw = env["hazir_yaniti"]("ide/x", "cc/opus-5-5 hazır.").decode()
|
||||
events = [json.loads(line[6:]) for line in raw.splitlines() if line.startswith("data: ")]
|
||||
self.assertEqual([e["type"] for e in events], ["response.created", "response.output_item.added",
|
||||
"response.output_text.delta", "response.output_item.done", "response.completed"])
|
||||
done = events[-1]["response"]
|
||||
self.assertEqual(done["status"], "completed")
|
||||
self.assertEqual(done["output"][0]["phase"], "final_answer")
|
||||
self.assertEqual(done["output"][0]["content"][0]["text"], "cc/opus-5-5 hazır.")
|
||||
self.assertEqual(done["usage"]["total_tokens"], 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,141 @@
|
||||
"""The shipped IDE must use the same public OmniRoute catalog as every member."""
|
||||
import io
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import ide_catalog as c
|
||||
|
||||
|
||||
def row(mid, name, source="omniroute", capabilities=("chat",)):
|
||||
return {"id": mid, "turkservis": {"display_name": name, "source": source,
|
||||
"capabilities": list(capabilities)}}
|
||||
|
||||
|
||||
class MemberCatalog(unittest.TestCase):
|
||||
def test_metadata_classifies_only_public_models(self):
|
||||
public = [row("plain", "Plain"), row("team", "Team")]
|
||||
metadata = [{"id": "plain", "kind": "model"}, {"id": "team", "kind": "combo"},
|
||||
{"id": "internal", "kind": "combo"}]
|
||||
replies = [io.BytesIO(json.dumps({"data": public}).encode()),
|
||||
io.BytesIO(json.dumps({"models": metadata}).encode())]
|
||||
with patch.object(c, "ayar_oku", return_value={"enabled": True, "kapsam": "ide"}), \
|
||||
patch.object(c, "anahtar_oku", return_value="test-key"), \
|
||||
patch.object(c, "ide_yapilandirma", return_value=("https://example.test/v1", "", [])), \
|
||||
patch.object(c.urllib.request, "urlopen", side_effect=replies):
|
||||
status, _, _, selected = c.yukle()
|
||||
self.assertEqual(status, "tamam")
|
||||
self.assertEqual({m["id"]: m["kind"] for m in selected}, {"plain": "model", "team": "combo"})
|
||||
|
||||
def test_empty_catalog_is_a_refresh_failure(self):
|
||||
with patch.object(c, "ayar_oku", return_value={"enabled": True, "kapsam": "ide"}), \
|
||||
patch.object(c, "anahtar_oku", return_value="test-key"), \
|
||||
patch.object(c, "ide_yapilandirma", return_value=("https://example.test/v1", "", [])), \
|
||||
patch.object(c.urllib.request, "urlopen", return_value=io.BytesIO(b'{"data": []}')):
|
||||
with self.assertRaises(ValueError):
|
||||
c.yukle()
|
||||
|
||||
def test_public_omniroute_models_do_not_need_private_local_configuration(self):
|
||||
rows = [row("9router-nano-x", "9R · nano/x"), row("agnes-flat", "Agnes Flat"),
|
||||
row("Ajan-Arac", "Ajan-Arac"), row("image", "image", capabilities=("image",))]
|
||||
self.assertEqual({m["id"] for m in c.sec(rows, [])},
|
||||
{"9router-nano-x", "agnes-flat", "Ajan-Arac"})
|
||||
|
||||
def test_direct_route_is_never_added_beside_omniroute(self):
|
||||
rows = [row("9router-nano-x-direct", "9R · nano/x", "9router"),
|
||||
row("9router-nano-x", "9R · nano/x")]
|
||||
self.assertEqual([m["id"] for m in c.sec(rows, ["nano/x"], "hepsi")], ["9router-nano-x"])
|
||||
|
||||
def test_model_order_uses_combo_metadata_and_preserves_every_model(self):
|
||||
models = [{"slug": "gpt-z", "display_name": "GPT Z", "priority": 1},
|
||||
{"slug": "ide/bekci-kurnaz", "display_name": "kurnaz", "priority": 2},
|
||||
{"slug": "ide/Z-Team", "display_name": "Z-Team", "priority": 5},
|
||||
{"slug": "ide/9router-cc-opus", "display_name": "9R · cc/opus", "priority": 3},
|
||||
{"slug": "ide/A-Team", "display_name": "A-Team", "priority": 4}]
|
||||
extras = [{"slug": m["slug"], "kind": "combo" if m["slug"].endswith("Team") else "model"}
|
||||
for m in models if m["slug"].startswith("ide/")]
|
||||
c.sirala_codex(models, extras)
|
||||
self.assertEqual([m["slug"] for m in models],
|
||||
["gpt-z", "ide/A-Team", "ide/Z-Team", "ide/bekci-kurnaz", "ide/9router-cc-opus"])
|
||||
self.assertEqual([m["priority"] for m in models], [1, 2, 3, 4, 5])
|
||||
|
||||
def test_subscription_models_keep_their_own_order_priority_and_name(self):
|
||||
models = [{"slug": "ide/GPT-6-Astra", "display_name": "GPT-6-Astra", "priority": 3},
|
||||
{"slug": "gpt-6-astra", "display_name": "GPT-6-Astra", "priority": 2},
|
||||
{"slug": "ide/A-Team", "display_name": "A-Team", "priority": 0},
|
||||
{"slug": "gpt-6.1-sol", "display_name": "GPT-6.1-Sol", "priority": 1},
|
||||
{"slug": "codex-auto-review", "display_name": "Codex Auto Review", "priority": 43}]
|
||||
models.append({"slug": "ide/GPT-6-1-Sol", "display_name": "GPT-6-1-Sol", "priority": 2})
|
||||
extras = [{"slug": "ide/GPT-6-Astra", "kind": "combo"}, {"slug": "ide/A-Team", "kind": "combo"},
|
||||
{"slug": "ide/GPT-6-1-Sol", "kind": "combo"}]
|
||||
originals = [dict(m) for m in models if not m["slug"].startswith("ide/")]
|
||||
c.sirala_codex(models, extras)
|
||||
self.assertEqual([m for m in models if not m["slug"].startswith("ide/")], originals)
|
||||
self.assertEqual([m["slug"] for m in models],
|
||||
["gpt-6-astra", "gpt-6.1-sol", "codex-auto-review", "ide/A-Team", "ide/GPT-6-1-Sol",
|
||||
"ide/GPT-6-Astra"])
|
||||
self.assertEqual([m["priority"] for m in models[3:]], [44, 45, 46])
|
||||
self.assertEqual([m["display_name"] for m in models[4:]], ["GPT-6-1-Sol (AITURK)", "GPT-6-Astra (AITURK)"])
|
||||
|
||||
def test_description_line_tells_what_the_entry_is_and_fits_a_phone_row(self):
|
||||
yedek = {"kind": "combo", "model": "Yedek", "strategy": "priority", "members": [
|
||||
{"kind": "model", "model": "nano/z-ai/glm-5.3-flash-uncensored"},
|
||||
{"kind": "model", "model": "nano/abliteration-ai/abliterated-model-large-v2"}]}
|
||||
takim = {"topology": {"kind": "combo", "strategy": "priority", "members": [
|
||||
{"kind": "combo", "model": "Takim-Hizli", "strategy": "fusion", "members": [
|
||||
{"kind": "model", "model": "ag/gemini-3.8-flash-high"}, {"kind": "model", "model": "cc/claude-sonnet-5-5"}]},
|
||||
yedek]}}
|
||||
self.assertEqual(c.aciklama("Takim-Hizli", "Takim-Hizli", "combo", takim), "Takım: gemini+sonnet")
|
||||
sirali = {"topology": {"kind": "combo", "strategy": "priority", "members": [
|
||||
{"kind": "model", "model": "cx/gpt-6-astra"}, {"kind": "model", "model": "cc/claude-opus-5-5"},
|
||||
{"kind": "model", "model": "cc/claude-sonnet-5-5"}, {"kind": "model", "model": "ag/gemini-3.8-flash-high"}]}}
|
||||
self.assertEqual(c.aciklama("Ajan-Arac", "Ajan-Arac", "combo", sirali), "Sıralı: astra>opus>sonnet +1")
|
||||
# An unresolved first member stays visible instead of being hidden behind the fallback.
|
||||
eksik = {"topology": {"kind": "combo", "strategy": "priority", "members": [{"kind": "combo", "model": None}, yedek]}}
|
||||
self.assertEqual(c.aciklama("GPT-6-Sol", "GPT-6-Sol", "combo", eksik), "Sıralı: ?>glm>abliterated")
|
||||
donen = {"topology": {"kind": "combo", "strategy": "round-robin", "members": [yedek, yedek, yedek]}}
|
||||
self.assertEqual(c.aciklama("Takim-Sira", "Takim-Sira", "combo", donen), "Dönüşümlü: 3 üye")
|
||||
self.assertEqual(c.aciklama("X", "X", "combo", None), "Takım")
|
||||
self.assertEqual(c.aciklama("9router-cc-claude-opus-5-5", "9R · cc/claude-opus-5-5", "model"), "Tek model · Claude")
|
||||
self.assertEqual(c.aciklama("9router-nano-qwen-qwen3.8-27b-uncensored-thinking",
|
||||
"9R · nano/qwen3.8-27b-uncensored:thinking", "model"),
|
||||
"NanoGPT · sansürsüz · düşünen")
|
||||
self.assertEqual(c.aciklama("bekci-kurnaz", "kurnaz", "model"), "Tek model · Bekçi")
|
||||
# 9Router aliases: cc = Claude Code, cx = OpenAI Codex, ag = Antigravity.
|
||||
self.assertEqual(c.aciklama("9router-cx-gpt-6-astra", "9R · cx/gpt-6-astra", "model"), "Tek model · Codex")
|
||||
self.assertEqual(c.aciklama("9router-ag-gemini-3.8-flash-high", "9R · ag/gemini-3.8-flash-high", "model"), "Tek model · Antigravity")
|
||||
uzun = {"topology": {"kind": "combo", "strategy": "priority", "members": [
|
||||
{"kind": "model", "model": f"x/model{chr(97 + i) * 9}"} for i in range(8)]}}
|
||||
self.assertLessEqual(len(c.aciklama("U", "U", "combo", uzun)), c.ACIKLAMA_SINIRI)
|
||||
for ad in ("Takim-Hizli", "9R · cc/claude-opus-5-5"):
|
||||
self.assertNotIn(ad, c.aciklama(ad, ad, "combo" if "Takim" in ad else "model", takim))
|
||||
|
||||
def test_picker_names_are_combo_name_or_short_provider_slash_model(self):
|
||||
# User decision, 10 Oct 2026: no "9R" mark; combos by name; single models as provider/model.
|
||||
rows = [("Takim-Hizli", "Takim-Hizli", "combo"),
|
||||
("9router-cc-claude-opus-5-5", "9R · cc/claude-opus-5-5", "model"),
|
||||
("9router-cc-claude-opus-4-8", "9R · cc/claude-opus-4-8", "model"),
|
||||
("9router-cx-gpt-6-astra", "9R · cx/gpt-6-astra", "model"),
|
||||
("9router-ag-gemini-3.8-flash-high", "9R · ag/gemini-3.8-flash-high", "model"),
|
||||
("9router-nano-z-ai-glm-5.3-flash-uncensored", "9R · nano/glm-5.3-flash-uncensored", "model"),
|
||||
("agnes-agnes-2.0-flash", "Agnes 2.0 Flash", "model"),
|
||||
("agnes-agnes-2.0-flash-high", "agnes-2.0-flash-high", "model"),
|
||||
("bekci-bekci", "bekci", "model"), ("bekci-kurnaz", "kurnaz", "model"),
|
||||
("hf-deepseek-ai-DeepSeek-V3", "hf/DeepSeek V3", "model"),
|
||||
("no-think-9router-cc-claude-opus-5-5", "no-think/claude-opus-5-5", "model"),
|
||||
("no-think-9router-ag-claude-opus-4-6-thinking", "no-think/claude-opus-4-6-thinking", "model")]
|
||||
adlar = c.adlandir([(k, a) for k, a, _ in rows], turler={k: t for k, _, t in rows})
|
||||
self.assertEqual([adlar[k] for k, _, _ in rows],
|
||||
["Takim-Hizli", "cc/opus-5-5", "cc/opus-4-8", "cx/gpt-6-astra", "agy/gemini-3.8-flash-high",
|
||||
"nano/glm-5.3-flash-uncensored", "agnes/agnes-2.0-flash", "agnes/agnes-2.0-flash-high",
|
||||
"bekci/bekci", "bekci/kurnaz", "hf/DeepSeek-V3", "cc/opus-5-5 (no-think)",
|
||||
"agy/opus-4-6-thinking (no-think)"])
|
||||
self.assertFalse([a for a in adlar.values() if "9R" in a])
|
||||
# Added models sort after the combos by provider then model.
|
||||
models = [{"slug": "ide/" + k, "display_name": adlar[k], "priority": 0} for k, _, _ in rows]
|
||||
c.sirala_codex(models, [{"slug": "ide/" + k, "kind": t} for k, _, t in rows])
|
||||
gorunen = [m["display_name"] for m in models]
|
||||
self.assertEqual(gorunen[0], "Takim-Hizli")
|
||||
self.assertEqual(gorunen[1:], sorted(gorunen[1:], key=lambda a: tuple(p.casefold() for p in a.partition("/")[::2])))
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Bridge 8: "modeli X yap" is handled by the bridge; a per-chat route survives until the user picks another model."""
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import model_gecis as g
|
||||
|
||||
MODELS = [("gpt-6-sol", "GPT-6-Sol"), ("gpt-6-astra", "GPT-6-Astra"), ("gpt-6.1-sol", "GPT-6.1-Sol"),
|
||||
("ide/9router-ag-gemini-3.8-flash-low", "agy/gemini-3.8-flash-low"),
|
||||
("ide/9router-ag-gemini-3.8-flash-high", "agy/gemini-3.8-flash-high"),
|
||||
("ide/9router-cc-claude-opus-5-5", "cc/opus-5-5"), ("ide/9router-cc-claude-opus-5-5-nt", "cc/opus-5-5 (no-think)"),
|
||||
("ide/9router-nano-z-ai-glm-5.3-flash-uncensored", "nano/glm-5.3-flash-uncensored"),
|
||||
("ide/agnes-agnes-2.0-flash", "agnes/agnes-2.0-flash"), ("ide/agnes-agnes-2.5-flash", "agnes/agnes-2.5-flash")]
|
||||
|
||||
|
||||
def user(text):
|
||||
return {"type": "message", "role": "user", "content": [{"type": "input_text", "text": text}]}
|
||||
|
||||
|
||||
def voice(text):
|
||||
return user(f"<realtime_delegation>\n <input>{text}</input>\n <transcript_delta>user: x</transcript_delta>\n</realtime_delegation>")
|
||||
|
||||
|
||||
def pick(text):
|
||||
command = g.komut_coz(text)
|
||||
return g.eslestir(command["ad"], MODELS) if command and command["tur"] == "gec" else command
|
||||
|
||||
|
||||
class Komut(unittest.TestCase):
|
||||
def test_spoken_names_resolve_to_one_model(self):
|
||||
for text, slug in [("modeli gemini flash low yap", "ide/9router-ag-gemini-3.8-flash-low"),
|
||||
("Modeli Gemini 3.8 Flash High'a geçir", "ide/9router-ag-gemini-3.8-flash-high"),
|
||||
("modeli klod opus 5 5 yap", "ide/9router-cc-claude-opus-5-5"),
|
||||
("modeli opus 5 5'e geç", "ide/9router-cc-claude-opus-5-5"),
|
||||
("modeli gpt 6 astra yap", "gpt-6-astra"), ("model geç gpt 6.1 sol", "gpt-6.1-sol"),
|
||||
("modeli glm flaş sansürsüz yap", "ide/9router-nano-z-ai-glm-5.3-flash-uncensored")]:
|
||||
self.assertEqual((pick(text) or {}).get("slug"), slug, text)
|
||||
|
||||
def test_half_spoken_words_and_the_plain_variant(self):
|
||||
"""Measured 11 Oct 2026 in a voice session: "obli think" was not found three times, and "Qwen 3.8
|
||||
Obliterated" asked which one although the user had not said "thinking"."""
|
||||
qwen = MODELS + [("ide/9router-nano-qwen-qwen3.8-27b-obliterated", "nano/qwen3.8-27b-obliterated"),
|
||||
("ide/9router-nano-qwen-qwen3.8-27b-obliterated-thinking", "nano/qwen3.8-27b-obliterated:thinking"),
|
||||
("ide/9router-nano-qwen-qwen3.8-27b-uncensored", "nano/qwen3.8-27b-uncensored")]
|
||||
|
||||
def sec(text):
|
||||
return (g.eslestir(g.komut_coz(text)["ad"], qwen) or {}).get("slug")
|
||||
thinking = "ide/9router-nano-qwen-qwen3.8-27b-obliterated-thinking"
|
||||
self.assertEqual(sec("Modeli 3.8 Obliterated Think yap"), thinking)
|
||||
self.assertEqual(sec("Modeli Qwen... modeli Qwen 3.8 .27 B... Obli... Think yap"), thinking)
|
||||
self.assertEqual(sec("Modeli Qwen ... 3.8 Obliterated yap"), "ide/9router-nano-qwen-qwen3.8-27b-obliterated")
|
||||
self.assertEqual(sec("modeli klod opus yap"), "ide/9router-cc-claude-opus-5-5") # not the "(no-think)" one
|
||||
self.assertIn("adaylar", g.eslestir(g.komut_coz("modeli qwen 3.8 yap")["ad"], qwen)) # obliterated or uncensored: ask
|
||||
|
||||
def test_ambiguous_name_lists_candidates_and_changes_nothing(self):
|
||||
self.assertEqual(pick("modeli agnes yap"), {"adaylar": ["agnes/agnes-2.0-flash", "agnes/agnes-2.5-flash"]})
|
||||
self.assertIn("adaylar", pick("modeli flash yap")) # several unrelated Flash models: ask
|
||||
self.assertIn("adaylar", pick("modeli sol yap")) # GPT-6-Sol or GPT-6.1-Sol: ask
|
||||
self.assertIn("adaylar", pick("modeli gpt 999 astra yap")) # words fit, version does not: ask
|
||||
|
||||
def test_negations_and_general_questions_are_not_commands(self):
|
||||
for text in ("modeli gemini flash low yapma", "modeli değiştirme opus kalsın", "GPT 6 Astra modeli için test yap",
|
||||
"hangi model daha ucuz", "en iyi model hangisi", "modeli seçili dosyaya göre gemini flash low yap"):
|
||||
result = pick(text)
|
||||
self.assertFalse(result and ("slug" in result or result.get("tur") == "sor"), text)
|
||||
self.assertEqual(g.komut_coz("hangi modelsin"), {"tur": "sor"})
|
||||
|
||||
def test_ordinary_sentences_are_not_commands(self):
|
||||
for text in ("Masaüstünde kaç klasör var", "bu modeli kullanarak bir site yap", "yapay zeka modeli nedir",
|
||||
"hangi modeli önerirsin bu iş için bana söyle bakalım lütfen",
|
||||
"model " + "çok uzun bir cümle " * 5 + "yap"):
|
||||
self.assertFalse(pick(text) and "slug" in pick(text), text)
|
||||
self.assertEqual(g.komut_coz("seçili model hangisi"), {"tur": "sor"})
|
||||
self.assertEqual(g.komut_coz("bu chatte ana model kim"), {"tur": "sor"})
|
||||
|
||||
def test_voice_delegation_yields_only_the_spoken_input(self):
|
||||
text, index, spoken = g.son_kullanici_metni({"input": [user("a"), voice("modeli opus yap")]})
|
||||
self.assertEqual((text, index, spoken), ("modeli opus yap", 1, True))
|
||||
self.assertEqual(g.son_kullanici_metni({"input": [user("<skill>x</skill>")]})[0], None)
|
||||
self.assertEqual(g.son_kullanici_metni({"input": [{"type": "function_call_output", "output": "x"}]})[0], None)
|
||||
|
||||
|
||||
class TurOrtasi(unittest.TestCase):
|
||||
def test_message_after_tool_items_is_mid_turn_and_after_final_answer_is_not(self):
|
||||
final = {"type": "message", "role": "assistant", "phase": "final_answer", "content": "ok"}
|
||||
call = {"type": "function_call", "call_id": "c", "name": "shell", "arguments": "{}"}
|
||||
out = {"type": "function_call_output", "call_id": "c", "output": "x"}
|
||||
self.assertFalse(g.tur_ortasi({"input": [user("a"), final, user(".")]}, 2))
|
||||
self.assertTrue(g.tur_ortasi({"input": [user("a"), call, out, user(".")]}, 3))
|
||||
self.assertTrue(g.tur_ortasi({"input": [user("a"), call, out, voice("bekliyorum"), user(".")]}, 4))
|
||||
self.assertTrue(g.tur_ortasi({"input": [user("a"), {"type": "message", "role": "assistant", "phase": "commentary",
|
||||
"content": "bakıyorum"}, user(".")]}, 2))
|
||||
self.assertFalse(g.tur_ortasi({"input": [user(".")]}, 0))
|
||||
|
||||
def test_handover_note_replaces_only_the_spoken_input(self):
|
||||
req = {"input": [voice("modeli opus yap")]}
|
||||
g.metni_degistir(req, 0, g.devir_notu("cc/opus-5-5", "GPT-6-Sol"))
|
||||
text = req["input"][0]["content"][0]["text"]
|
||||
self.assertIn("<transcript_delta>", text)
|
||||
self.assertIn("cc/opus-5-5 olarak devam ediyorum", text)
|
||||
self.assertNotIn("modeli opus yap</input>", text)
|
||||
|
||||
|
||||
class Gecmis(unittest.TestCase):
|
||||
def test_handled_commands_in_history_become_notes_but_the_last_message_is_left_alone(self):
|
||||
req = {"input": [user("modeli gemini flash low yap"), user("Masaüstünde kaç dosya var"),
|
||||
voice("modeli opus 5 5 yap"), user("modeli gpt 6 astra yap")]}
|
||||
self.assertEqual(g.gecmisi_temizle(req, MODELS), 2)
|
||||
self.assertIn("agy/gemini-3.8-flash-low olarak değiştirdi", req["input"][0]["content"][0]["text"])
|
||||
self.assertEqual(req["input"][1]["content"][0]["text"], "Masaüstünde kaç dosya var")
|
||||
self.assertIn("<transcript_delta>", req["input"][2]["content"][0]["text"])
|
||||
self.assertEqual(req["input"][3]["content"][0]["text"], "modeli gpt 6 astra yap")
|
||||
self.assertEqual(g.gecmisi_temizle(req, MODELS), 0)
|
||||
|
||||
|
||||
class Bilgi(unittest.TestCase):
|
||||
def test_model_is_told_once_that_the_chat_model_changes_by_saying_so(self):
|
||||
req = {"input": [user("selam")]}
|
||||
self.assertTrue(g.bilgi_ekle(req))
|
||||
self.assertEqual(req["input"][0]["role"], "developer")
|
||||
self.assertIn("modeli <ad> yap", req["input"][0]["content"][0]["text"])
|
||||
self.assertFalse(g.bilgi_ekle(req))
|
||||
self.assertEqual(len(req["input"]), 2)
|
||||
self.assertFalse(g.bilgi_ekle({"input": []}))
|
||||
|
||||
def test_a_command_without_a_name_asks_which_model(self):
|
||||
for text in ("modeli değiştir", "başka modele geç", "modeli değiştirir misin"):
|
||||
self.assertEqual(g.komut_coz(text), {"tur": "nasil"}, text)
|
||||
self.assertIsNone(g.komut_coz("modeli değiştirme"))
|
||||
|
||||
|
||||
class Dogal(unittest.TestCase):
|
||||
GECIT = [("ide/9router/ModelARK/dola-seed-2-1-turbo-260628", "9router/ModelARK/dola-seed-2-1-turbo-260628"),
|
||||
("ide/9router/ModelARK/seed-2-0-pro-260328", "9router/ModelARK/seed-2-0-pro-260328")]
|
||||
|
||||
def test_command_without_the_word_model_and_a_spelled_out_name(self):
|
||||
for text in ("Seed iki nokta bir turbo yap", "Evet. Seed, S E E D iki nokta bir turbo yap"):
|
||||
command = g.komut_coz(text)
|
||||
self.assertEqual((command["ad"], command["kesin"]), (["seed", "2", "1", "turbo"], False), text)
|
||||
self.assertEqual(g.eslestir(command["ad"], self.GECIT)["slug"], "ide/9router/ModelARK/dola-seed-2-1-turbo-260628")
|
||||
self.assertTrue(g.komut_coz("Modeli Seed iki nokta bir turbo yap")["kesin"])
|
||||
self.assertEqual(g.eslestir(g.komut_coz("gemini flash low'a geç")["ad"], MODELS)["slug"],
|
||||
"ide/9router-ag-gemini-3.8-flash-low")
|
||||
|
||||
def test_ordinary_requests_ending_in_a_verb_are_not_commands(self):
|
||||
for text in ("test yap", "kahve yap", "yapma"):
|
||||
self.assertIsNone(g.komut_coz(text), text)
|
||||
for text in ("şu dosyayı düzelt ve commit yap", "rapor hazırla gemini flash low ile karşılaştırma yap"):
|
||||
command = g.komut_coz(text)
|
||||
self.assertFalse(command["kesin"])
|
||||
self.assertFalse((g.eslestir(command["ad"], MODELS) or {}).get("slug"), text)
|
||||
|
||||
|
||||
class Yon(unittest.TestCase):
|
||||
def test_route_lasts_until_the_user_picks_another_model_in_the_list(self):
|
||||
with tempfile.TemporaryDirectory() as home:
|
||||
y = g.Yonlendirme(home)
|
||||
y.koy("chat", "gpt-6-sol", "ide/x", simdi=1000)
|
||||
self.assertEqual(g.Yonlendirme(home).al("chat", "gpt-6-sol", simdi=1001), "ide/x") # survives a restart
|
||||
self.assertIsNone(y.al("other", "gpt-6-sol", simdi=1001))
|
||||
self.assertIsNone(y.al("chat", "gpt-6-astra", simdi=1002)) # user picked another
|
||||
self.assertIsNone(y.al("chat", "gpt-6-sol", simdi=1003)) # and it stays cleared
|
||||
y.koy("chat", "gpt-6-sol", "ide/x", simdi=1000)
|
||||
y.koy("chat", "gpt-6-sol", "gpt-6-sol", simdi=1001) # back to the picked one
|
||||
self.assertIsNone(y.al("chat", "gpt-6-sol", simdi=1002))
|
||||
y.koy("old", "a", "b", simdi=1000)
|
||||
self.assertIsNone(y.al("old", "a", simdi=1000 + g.OMUR_SN + 1))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,90 @@
|
||||
"""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("kopru.py").read_text(encoding="utf-8"))
|
||||
names = {"flatten_request", "fix_tool_ids", "drop_foreign_items", "SseRewriter", "phase_damgala",
|
||||
"asistan_mesaji_mi"}
|
||||
nodes = [n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.ClassDef)) and n.name in names]
|
||||
env = {"json": json, "re": __import__("re"), "log": lambda message: None, "pencere": __import__("pencere")}
|
||||
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", "ARAC_OGELERI"} 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")
|
||||
self.assertEqual(rewritten["item"]["id"], "ctc_chatcmpl-probe")
|
||||
|
||||
def test_custom_stream_ids_are_valid_before_history_is_saved(self):
|
||||
# Real failure after disabling the bridge and returning to OpenAI:
|
||||
# input[32].id fc_call_... was saved as a custom_tool_call by Codex.
|
||||
item = {"type": "function_call", "id": "fc_call_3f427ecc539d4c779c1252077701216f",
|
||||
"call_id": "call_original", "name": "probe",
|
||||
"arguments": json.dumps({"input": "keep this exact tool input"})}
|
||||
adapter = env["SseRewriter"]({"probe"})
|
||||
converted = []
|
||||
for kind in ("response.output_item.added", "response.output_item.done"):
|
||||
converted.append(adapter._fix({"type": kind, "item": dict(item)})["item"])
|
||||
final = adapter._fix({"type": "response.completed", "response": {"output": [dict(item)]}})
|
||||
converted.append(final["response"]["output"][0])
|
||||
for result in converted:
|
||||
self.assertEqual(result["type"], "custom_tool_call")
|
||||
self.assertEqual(result["id"], "ctc_call_3f427ecc539d4c779c1252077701216f")
|
||||
self.assertEqual(result["call_id"], "call_original")
|
||||
self.assertEqual(result["input"], "keep this exact tool input")
|
||||
history = {"input": converted}
|
||||
self.assertEqual(env["fix_tool_ids"](history), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,185 @@
|
||||
"""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_anormal_kisa_ozet_gecmisin_yerine_konmaz(self):
|
||||
# Esik yalniz bozuk sayilacak kadar kisa ozeti yakalar. 10 Ekim 2026 olcumu: 646.955 baytlik
|
||||
# sohbetin dogru ozeti 1501-1701 karakterdi ve boyuta oranli eski esik onu reddedip sohbeti kilitledi.
|
||||
self.assertEqual(o.en_az_karakter(1018151), 300)
|
||||
self.assertEqual(o.en_az_karakter(646955), 300)
|
||||
self.assertEqual(o.en_az_karakter(100000), 50)
|
||||
self.assertEqual(o.en_az_karakter(0), 0)
|
||||
self.assertEqual(o.en_az_karakter(1018151, {"en_az_tavan": 1500, "en_az_oran": 250}), 1500)
|
||||
for gercek_ozet in (1501, 1648, 1701):
|
||||
self.assertGreater(gercek_ozet, o.en_az_karakter(646955))
|
||||
ham = sse({"type": "response.created", "response": {"id": "r1"}},
|
||||
{"type": "response.output_item.done", "item": self._mesaj("k" * 120)},
|
||||
{"type": "response.completed", "response": {"id": "r1", "status": "completed"}})
|
||||
a = o.OzetAkisi(tohum="7", en_az=o.en_az_karakter(1018151))
|
||||
olaylar = coz(a.feed(ham) + a.flush())
|
||||
self.assertEqual([e["type"] for e in olaylar], ["response.created", "response.failed"])
|
||||
self.assertNotIn("compaction", json.dumps(olaylar))
|
||||
self.assertEqual(olaylar[1]["response"]["error"]["code"], "astrabridge_ozet_kisa")
|
||||
self.assertTrue(a.kisa)
|
||||
self.assertIn("REDDEDILDI", a.sonuc_notu())
|
||||
# Yeterli uzunlukta ozet ayni esikle kabul edilir; kucuk sohbetin kisa ozeti de reddedilmez.
|
||||
for metin, esik in (("u" * 1501, o.en_az_karakter(646955)), ("kisa ama dogru ozet", o.en_az_karakter(3000))):
|
||||
b = o.OzetAkisi(tohum="8", en_az=esik)
|
||||
ham2 = sse({"type": "response.output_item.done", "item": self._mesaj(metin)},
|
||||
{"type": "response.completed", "response": {"id": "r2"}})
|
||||
cikti = coz(b.feed(ham2))
|
||||
self.assertEqual(o.ozet_metni(cikti[0]["item"]), metin)
|
||||
self.assertFalse(b.kisa)
|
||||
|
||||
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_tight_loop_is_stopped_but_spaced_summaries_are_not(self):
|
||||
f = o.DonguFreni(sinir=8, pencere_sn=600, art_arda=3)
|
||||
# Summary after summary with no normal request in between: a real loop.
|
||||
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)) # another conversation is unaffected
|
||||
# A normal request between summaries (small-window model compacting every turn) keeps working.
|
||||
g = o.DonguFreni(sinir=8, pencere_sn=600, art_arda=3)
|
||||
izinler = []
|
||||
for t in range(0, 70, 10):
|
||||
izinler.append(g.izin("sohbet-c", t))
|
||||
g.normal("sohbet-c")
|
||||
self.assertEqual(izinler, [True] * 7)
|
||||
self.assertTrue(g.izin("sohbet-c", 70)) # the 8th in the window
|
||||
g.normal("sohbet-c")
|
||||
self.assertFalse(g.izin("sohbet-c", 80)) # the total cap still ends a slow loop
|
||||
self.assertTrue(g.izin("sohbet-c", 700)) # and lifts once the window passes
|
||||
self.assertIn("Dongu durduruldu", o.DONGU_MESAJI % 3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Bridge version 7: real context windows, phase stamping, overflow errors, images for vision models."""
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import ide_catalog as c
|
||||
import pencere
|
||||
import resim_context
|
||||
|
||||
tree = ast.parse(Path(__file__).with_name("kopru.py").read_text(encoding="utf-8"))
|
||||
names = {"fix_tool_ids", "SseRewriter", "phase_damgala", "asistan_mesaji_mi"}
|
||||
nodes = [n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.ClassDef)) and n.name in names]
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id in
|
||||
{"ID_PREFIX", "FOREIGN_ID_MARK", "REFERENCE_TYPES", "INLINE_TYPES", "ARAC_OGELERI"} for t in node.targets):
|
||||
nodes.insert(0, node)
|
||||
env = {"json": json, "re": __import__("re"), "time": __import__("time"), "log": lambda message: None, "pencere": pencere}
|
||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), "bridge.py", "exec"), env)
|
||||
|
||||
NANO = pencere.nano_sadelestir({"data": [
|
||||
{"id": "z-ai/glm-5.3-flash-uncensored", "context_length": 1048576,
|
||||
"capabilities": {"vision": True, "tool_calling": True}},
|
||||
{"id": "mlabonne/NeuralDaredevil-8B-abliterated", "context_length": 8192,
|
||||
"capabilities": {"vision": False, "tool_calling": False}},
|
||||
{"id": "nousresearch/hermes-3-llama-3.1-70b", "context_length": 65536,
|
||||
"capabilities": {"vision": False, "tool_calling": True}},
|
||||
{"id": "broken", "context_length": 0}]})
|
||||
|
||||
|
||||
def sse(*events):
|
||||
return b"".join(b"event: " + e["type"].encode() + b"\ndata: " + json.dumps(e).encode() + b"\n\n" for e in events)
|
||||
|
||||
|
||||
def parse(raw):
|
||||
return [json.loads(line[6:]) for line in raw.decode().splitlines() if line.startswith("data: ")]
|
||||
|
||||
|
||||
def message(text, ident="msg_1"):
|
||||
return {"type": "message", "id": ident, "role": "assistant", "content": [{"type": "output_text", "text": text}]}
|
||||
|
||||
|
||||
class Windows(unittest.TestCase):
|
||||
def test_provider_data_local_table_and_cap(self):
|
||||
coz = pencere.coz
|
||||
# The provider that carries the request decides; nothing is declared above the summarizer's window.
|
||||
self.assertEqual(coz("9router-nano-z-ai-glm-5.3-flash-uncensored", "model", "nano/z-ai/glm-5.3-flash-uncensored",
|
||||
nano=NANO)["pencere"], pencere.TAVAN)
|
||||
small = coz("9router-nano-mlabonne-NeuralDaredevil-8B-abliterated", "model",
|
||||
"nano/mlabonne/NeuralDaredevil-8B-abliterated", nano=NANO)
|
||||
self.assertEqual((small["pencere"], small["arac"], small["gorsel"]), (8192, False, False))
|
||||
self.assertEqual(coz("9router-cc-claude-opus-5-5", "model", "cc/claude-opus-5-5")["pencere"], 200000)
|
||||
self.assertEqual(coz("9router-cx-gpt-6-astra", "model", "cx/gpt-6-astra")["pencere"], 272000)
|
||||
self.assertEqual(coz("no-think-9router-cc-claude-opus-5-5", "model", "no-think/claude-opus-5-5")["pencere"], 200000)
|
||||
unknown = coz("agnes-agnes-2.5-flash", "model", "Agnes 2.5 Flash", nano=NANO)
|
||||
self.assertEqual((unknown["pencere"], unknown["kaynak"]), (pencere.VARSAYILAN, "varsayilan"))
|
||||
self.assertNotIn("broken", NANO)
|
||||
|
||||
def test_combo_declares_its_smallest_member(self):
|
||||
combo = {"topology": {"kind": "combo", "strategy": "priority", "members": [
|
||||
{"kind": "model", "model": "cx/gpt-6-astra"},
|
||||
{"kind": "combo", "strategy": "priority", "members": [
|
||||
{"kind": "model", "model": "nano/z-ai/glm-5.3-flash-uncensored"},
|
||||
{"kind": "model", "model": "nano/nousresearch/hermes-3-llama-3.1-70b"}]}]}}
|
||||
self.assertEqual(pencere.coz("X", "combo", "X", combo, NANO)["pencere"], 65536)
|
||||
self.assertEqual(pencere.coz("X", "combo", "X", None, NANO)["pencere"], pencere.VARSAYILAN)
|
||||
|
||||
def test_limit_learned_from_the_target_error_only_shrinks(self):
|
||||
home = tempfile.mkdtemp()
|
||||
text = "This model's maximum context length is 32768 tokens. However, you requested 51234 tokens"
|
||||
self.assertTrue(pencere.tasma_mi(text))
|
||||
self.assertEqual(pencere.sinir_oku(text), 32768)
|
||||
self.assertEqual(pencere.sinir_oku("prompt is too long: 3040251 tokens > 1000000 maximum"), 1000000)
|
||||
gateway = ("Your prompt exceeds the model's context length. Estimated input tokens: 171016. "
|
||||
"Max context tokens: 65536.")
|
||||
self.assertTrue(pencere.tasma_mi(gateway))
|
||||
self.assertEqual(pencere.sinir_oku(gateway), 65536)
|
||||
self.assertIsNone(pencere.sinir_oku("rate limit exceeded"))
|
||||
self.assertFalse(pencere.tasma_mi("rate limit exceeded"))
|
||||
self.assertTrue(pencere.ogren(home, "m", 32768))
|
||||
self.assertFalse(pencere.ogren(home, "m", 65536)) # a larger number never overrides
|
||||
self.assertEqual(pencere.ogrenilen(home), {"m": 32768})
|
||||
self.assertEqual(pencere.coz("m", "model", "cc/claude-opus-5-5", ogr=pencere.ogrenilen(home))["pencere"], 32768)
|
||||
|
||||
def test_nano_cache_survives_a_network_failure(self):
|
||||
home = tempfile.mkdtemp()
|
||||
fresh = {"data": [{"id": "A/b", "context_length": 16384, "capabilities": {}}]}
|
||||
self.assertEqual(pencere.nano_yukle(home, simdi=1000, getir=lambda: fresh)["a/b"]["pencere"], 16384)
|
||||
|
||||
def fail():
|
||||
raise OSError("offline")
|
||||
later = 1000 + pencere.NANO_OMUR_SN + 5
|
||||
self.assertEqual(pencere.nano_yukle(home, simdi=later, getir=fail)["a/b"]["pencere"], 16384)
|
||||
self.assertEqual(pencere.nano_yukle(tempfile.mkdtemp(), simdi=later, getir=fail), {})
|
||||
self.assertEqual((pencere.kisa(8192), pencere.kisa(131072), pencere.kisa(200000)), ("8K", "128K", "200K"))
|
||||
|
||||
def test_description_line_warns_about_small_window_and_no_tools(self):
|
||||
bilgi = {"pencere": 8192, "arac": False, "gorsel": False}
|
||||
line = c.aciklama("9router-nano-mlabonne-NeuralDaredevil-8B-abliterated", "9R · nano/NeuralDaredevil-8B-abliterated",
|
||||
"model", None, bilgi)
|
||||
self.assertEqual(line, "NanoGPT · 8K · araçsız") # warnings first; the rest no longer fits in 30
|
||||
self.assertLessEqual(len(line), c.ACIKLAMA_SINIRI)
|
||||
self.assertEqual(c.aciklama("9router-cc-claude-opus-5-5", "9R · cc/claude-opus-5-5", "model", None,
|
||||
{"pencere": 200000, "arac": True, "gorsel": True}), "Tek model · Claude")
|
||||
|
||||
|
||||
class Guard(unittest.TestCase):
|
||||
def req(self, old, last="son soru"):
|
||||
return {"instructions": "talimat", "input": [
|
||||
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": old}]},
|
||||
{"type": "function_call_output", "call_id": "c", "output": "cikti " * 4000},
|
||||
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": last}]}]}
|
||||
|
||||
def test_request_that_fits_is_untouched(self):
|
||||
req = self.req("kisa")
|
||||
before = json.dumps(req)
|
||||
self.assertEqual(pencere.sigdir(req, 272000)[:2], (0, True))
|
||||
self.assertEqual(json.dumps(req), before)
|
||||
|
||||
def test_oversized_old_parts_lose_their_middle_and_the_last_user_message_never_does(self):
|
||||
req = self.req("BAS " + "dolgu " * 60000 + " SON", last="yeni " * 3000)
|
||||
kirpilan, sigdi, tahmin = pencere.sigdir(req, 65536)
|
||||
self.assertTrue(sigdi)
|
||||
self.assertGreaterEqual(kirpilan, 1)
|
||||
old = req["input"][0]["content"][0]["text"]
|
||||
self.assertTrue(old.startswith("BAS ") and old.endswith(" SON"))
|
||||
self.assertIn("çıkarıldı (köprü)", old)
|
||||
self.assertEqual(req["input"][2]["content"][0]["text"], "yeni " * 3000) # the new message is intact
|
||||
self.assertLessEqual(tahmin, int(65536 * pencere.DOLULUK))
|
||||
|
||||
def test_a_last_message_larger_than_the_window_is_reported_not_sent(self):
|
||||
req = self.req("kisa", last="x" * 400000)
|
||||
self.assertFalse(pencere.sigdir(req, 32768)[1])
|
||||
|
||||
|
||||
class Phase(unittest.TestCase):
|
||||
def run_stream(self, *events, chunk=37):
|
||||
adapter = env["SseRewriter"](set())
|
||||
raw = sse(*events)
|
||||
out = b"".join(adapter.feed(raw[i:i + chunk]) for i in range(0, len(raw), chunk)) + adapter.flush()
|
||||
return parse(out)
|
||||
|
||||
def test_message_before_a_tool_call_is_commentary(self):
|
||||
call = {"type": "function_call", "id": "fc_1", "call_id": "call_1", "name": "shell", "arguments": "{}"}
|
||||
out = self.run_stream(
|
||||
{"type": "response.output_item.added", "item": message("")},
|
||||
{"type": "response.output_text.delta", "item_id": "msg_1", "delta": "bakiyorum"},
|
||||
{"type": "response.output_item.done", "item": message("bakiyorum")},
|
||||
{"type": "response.output_item.added", "item": call},
|
||||
{"type": "response.output_item.done", "item": call},
|
||||
{"type": "response.completed", "response": {"output": [message("bakiyorum"), call]}})
|
||||
self.assertEqual([e["type"] for e in out], [
|
||||
"response.output_item.added", "response.output_text.delta", "response.output_item.done",
|
||||
"response.output_item.added", "response.output_item.done", "response.completed"]) # order kept
|
||||
self.assertEqual(out[2]["item"]["phase"], "commentary")
|
||||
self.assertEqual(out[5]["response"]["output"][0]["phase"], "commentary")
|
||||
self.assertNotIn("phase", out[5]["response"]["output"][1])
|
||||
|
||||
def test_last_message_without_tool_calls_is_the_final_answer(self):
|
||||
out = self.run_stream(
|
||||
{"type": "response.output_item.done", "item": message("ara", "msg_a")},
|
||||
{"type": "response.output_item.added", "item": message("", "msg_b")},
|
||||
{"type": "response.output_item.done", "item": message("son", "msg_b")},
|
||||
{"type": "response.completed", "response": {"output": [message("ara", "msg_a"), message("son", "msg_b")]}})
|
||||
done = [e for e in out if e["type"] == "response.output_item.done"]
|
||||
self.assertEqual([e["item"]["phase"] for e in done], ["commentary", "final_answer"])
|
||||
self.assertEqual([o["phase"] for o in out[-1]["response"]["output"]], ["commentary", "final_answer"])
|
||||
self.assertEqual(out[-1]["type"], "response.completed") # done comes first
|
||||
|
||||
def test_existing_phase_and_cut_streams_are_left_alone(self):
|
||||
native = dict(message("x"), phase="final_answer")
|
||||
out = self.run_stream({"type": "response.output_item.done", "item": native},
|
||||
{"type": "response.completed", "response": {"output": [dict(native)]}})
|
||||
self.assertEqual(out[0]["item"]["phase"], "final_answer")
|
||||
# No terminal event: the held message is still delivered, unmarked rather than guessed.
|
||||
cut = self.run_stream({"type": "response.output_item.done", "item": message("yarim")})
|
||||
self.assertEqual(len(cut), 1)
|
||||
self.assertNotIn("phase", cut[0]["item"])
|
||||
|
||||
def test_overflow_failure_gets_the_code_codex_understands(self):
|
||||
seen = []
|
||||
adapter = env["SseRewriter"](set(), hedef="model-x", tasma=lambda hedef, metin: seen.append((hedef, metin)))
|
||||
failed = {"type": "response.failed", "response": {"error": {
|
||||
"code": "server_error", "message": "maximum context length is 32768 tokens"}}}
|
||||
out = parse(adapter.feed(sse(failed)) + adapter.flush())
|
||||
self.assertEqual(out[0]["response"]["error"]["code"], "context_length_exceeded")
|
||||
self.assertEqual(seen[0][0], "model-x")
|
||||
other = {"type": "response.failed", "response": {"error": {"code": "rate_limit", "message": "slow down"}}}
|
||||
self.assertEqual(parse(adapter.feed(sse(other)))[0]["response"]["error"]["code"], "rate_limit")
|
||||
|
||||
|
||||
class GatewayErrors(unittest.TestCase):
|
||||
def test_permanent_gateway_error_becomes_a_failure_codex_shows_at_once(self):
|
||||
adapter = env["SseRewriter"](set())
|
||||
raw = sse({"type": "error", "error": {"type": "upstream_error", "message": "upstream 402"}})
|
||||
out = parse(adapter.feed(raw) + adapter.flush())
|
||||
self.assertEqual(out[-1]["type"], "response.completed") # visible answer, turn ends normally
|
||||
self.assertIn("kredi ya da kota", out[1]["delta"])
|
||||
self.assertIn("upstream 402", out[1]["delta"])
|
||||
raw = sse({"type": "error", "error": {"type": "upstream_error", "message": "upstream 400"}})
|
||||
out = parse(env["SseRewriter"](set()).feed(raw))
|
||||
self.assertIn("nedenini bildirmedi", out[1]["delta"])
|
||||
self.assertEqual(out[-1]["type"], "response.completed")
|
||||
# A transient error is left alone so that Codex retries it.
|
||||
raw = sse({"type": "error", "error": {"type": "upstream_error", "message": "upstream 503"}})
|
||||
self.assertEqual(parse(env["SseRewriter"](set()).feed(raw))[0]["type"], "error")
|
||||
|
||||
|
||||
class Images(unittest.TestCase):
|
||||
def req(self):
|
||||
img = {"type": "input_image", "image_url": "data:image/png;base64," + "A" * 2000}
|
||||
return {"input": [
|
||||
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "bak"}, dict(img)]},
|
||||
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "OKUDUM"}]},
|
||||
{"type": "function_call_output", "call_id": "c", "output": [dict(img)]},
|
||||
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "ne yaziyordu"}]}]}
|
||||
|
||||
def test_text_model_gets_notes_vision_model_keeps_user_images(self):
|
||||
text_only = self.req()
|
||||
self.assertEqual(resim_context.resimleri_sadelestir(text_only)[0], 2)
|
||||
self.assertNotIn("input_image", json.dumps(text_only))
|
||||
vision = self.req()
|
||||
self.assertEqual(resim_context.resimleri_sadelestir(vision, gorsel=True)[0], 1) # only the tool output
|
||||
self.assertEqual(vision["input"][0]["content"][1]["type"], "input_image")
|
||||
self.assertNotIn("input_image", json.dumps(vision["input"][2]))
|
||||
big = self.req()
|
||||
self.assertEqual(resim_context.resimleri_sadelestir(big, tut_bayt=500, gorsel=True)[0], 2) # over budget
|
||||
|
||||
|
||||
|
||||
class ClaudeOrani(unittest.TestCase):
|
||||
def test_claude_targets_are_estimated_more_strictly_than_others(self):
|
||||
import pencere as p
|
||||
req = {"input": [{"type": "function_call_output", "call_id": "c", "output": "x" * 600_000},
|
||||
{"type": "message", "role": "user", "content": "son"}]}
|
||||
self.assertEqual(p.oran_sec("9router-cc-claude-opus-5-5"), p.CLAUDE_BAYT_BOLU_TOKEN)
|
||||
self.assertEqual(p.oran_sec("9router-ag-gemini-3.8-flash-low"), p.BAYT_BOLU_TOKEN)
|
||||
self.assertEqual(p.sigdir({"input": [dict(i) for i in req["input"]]}, 200_000)[0], 0) # 187500: fits
|
||||
trimmed, fits, estimate = p.sigdir(req, 200_000, p.oran_sec("cc-claude-opus")) # 230769: trimmed
|
||||
self.assertEqual((trimmed, fits), (1, True))
|
||||
self.assertLess(estimate, 194_000)
|
||||
self.assertTrue(p.kapali_hata_mi("upstream 400"))
|
||||
# A per-minute rate limit is neither an overflow nor a window size to learn.
|
||||
rate = "Rate limit reached: limit of 32768 tokens per minute, context window ok"
|
||||
self.assertFalse(p.tasma_mi(rate))
|
||||
self.assertIsNone(p.sinir_oku(rate))
|
||||
self.assertTrue(p.tasma_mi("maximum context length is 65536 tokens"))
|
||||
self.assertFalse(p.kapali_hata_mi("upstream 400: invalid tool schema"))
|
||||
|
||||
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,74 @@
|
||||
"""An answer cut by the client is carried to the next turn of the same conversation, once."""
|
||||
import json
|
||||
import unittest
|
||||
|
||||
import yarim
|
||||
|
||||
|
||||
def delta(text):
|
||||
return (b"event: response.output_text.delta\ndata: "
|
||||
+ json.dumps({"type": "response.output_text.delta", "delta": text}).encode() + b"\n\n")
|
||||
|
||||
|
||||
def user(text):
|
||||
return {"type": "message", "role": "user", "content": [{"type": "input_text", "text": text}]}
|
||||
|
||||
|
||||
class Yarim(unittest.TestCase):
|
||||
def setUp(self):
|
||||
yarim._KAYIT.clear()
|
||||
|
||||
def cut(self, key="sohbet-1", text="1. bir sayidir ve ilk siradadir. 2. iki sayidir ve cifttir. 3. uc"):
|
||||
izle = yarim.Izleyici(key, "ide/x")
|
||||
raw = delta(text[:20]) + delta(text[20:])
|
||||
for i in range(0, len(raw), 17):
|
||||
izle.feed(raw[i:i + 17])
|
||||
return izle
|
||||
|
||||
def test_cut_answer_reaches_the_next_user_message_once(self):
|
||||
izle = self.cut()
|
||||
self.assertTrue(yarim.kaydet(izle, simdi=100))
|
||||
req = {"input": [user("liste yaz"), user("kaldigin yerden devam et")]}
|
||||
self.assertGreater(yarim.uygula(req, "sohbet-1", simdi=200), 0)
|
||||
note = req["input"][-1]["content"][-1]["text"]
|
||||
self.assertIn("3. uc", note)
|
||||
self.assertIn('model="ide/x"', note)
|
||||
self.assertEqual(req["input"][-1]["content"][0]["text"], "kaldigin yerden devam et") # the user's words stay first
|
||||
again = {"input": [user("bir daha")]}
|
||||
self.assertEqual(yarim.uygula(again, "sohbet-1", simdi=300), 0) # used once
|
||||
self.assertEqual(yarim.uygula({"input": [user("x")]}, "baska-sohbet", simdi=300), 0)
|
||||
|
||||
def test_finished_short_or_keyless_answers_are_not_kept(self):
|
||||
done = self.cut()
|
||||
done.feed(b'event: response.completed\ndata: {"type": "response.completed"}\n\n')
|
||||
self.assertFalse(yarim.kaydet(done))
|
||||
self.assertFalse(yarim.kaydet(self.cut(text="kisa")))
|
||||
self.assertFalse(yarim.kaydet(self.cut(key="")))
|
||||
|
||||
def test_note_waits_for_a_real_user_turn_and_skips_text_codex_already_saved(self):
|
||||
yarim.kaydet(self.cut(), simdi=100)
|
||||
tool_turn = {"input": [user("x"), {"type": "function_call_output", "call_id": "c", "output": "ok"}]}
|
||||
self.assertEqual(yarim.uygula(tool_turn, "sohbet-1", simdi=110), 0)
|
||||
compaction = {"input": [user("x"), {"type": "compaction_trigger"}]}
|
||||
self.assertEqual(yarim.uygula(compaction, "sohbet-1", simdi=110), 0)
|
||||
self.assertIn("sohbet-1", yarim._KAYIT) # still waiting
|
||||
saved = {"input": [{"type": "message", "role": "assistant", "content": [
|
||||
{"type": "output_text", "text": "1. bir sayidir ve ilk siradadir. 2. iki sayidir ve cifttir. 3. uc"}]}, user("devam")]}
|
||||
self.assertEqual(yarim.uygula(saved, "sohbet-1", simdi=120), 0)
|
||||
self.assertNotIn("sohbet-1", yarim._KAYIT)
|
||||
yarim.kaydet(self.cut(), simdi=100)
|
||||
self.assertEqual(yarim.uygula({"input": [user("gec")]}, "sohbet-1", simdi=100 + yarim.OMUR_SN + 1), 0) # expired
|
||||
|
||||
def test_long_text_keeps_head_and_tail(self):
|
||||
yarim.kaydet(self.cut(text="BAS" + "x" * 40000 + "SON"), simdi=1)
|
||||
req = {"input": [user("devam")]}
|
||||
yarim.uygula(req, "sohbet-1", simdi=2)
|
||||
note = req["input"][-1]["content"][-1]["text"]
|
||||
self.assertLess(len(note), yarim.EN_COK_KARAKTER + 600)
|
||||
self.assertIn("BAS", note)
|
||||
self.assertTrue(note.rstrip().endswith("</yarim_cevap>"))
|
||||
self.assertIn("SON\n</yarim_cevap>", note)
|
||||
|
||||
|
||||
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("kopru.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
Whitespace-only changes.
@@ -0,0 +1,127 @@
|
||||
"""Yarida kesilen cevabi sonraki modele tasir (10 Ekim 2026).
|
||||
|
||||
Olcum: Codex kapanir ya da kullanici cevabi durdurursa o ana kadar yazilan metin sohbet gecmisine girmiyor;
|
||||
sonraki tur (ayni ya da baska modelle) "onceki cevabi goremiyorum" diyor. Kopru akisi zaten goruyor:
|
||||
ISTEMCI baglantiyi kapattiginda (yanit bitmeden) o ana kadarki metni bellekte tutar ve ayni sohbetin
|
||||
sonraki isteginde, kullanicinin yeni mesajina kisa bir not olarak ekler. Bir kez kullanilir.
|
||||
|
||||
Bilerek yapilmayanlar:
|
||||
- Ust kaynak kopmasinda (istemci hala bagli) kayit yok: Codex o istegi kendisi yeniden gonderir; not eklenirse
|
||||
model bastan yazmak yerine ortadan devam eder ve cevabin basi kaybolur.
|
||||
- Yarim metin asistan mesaji olarak eklenmez: Claude "asistan mesajiyla biten istek"i reddeder ve abonelik
|
||||
yolunda kimliksiz asistan ogesi risklidir. Kullanici mesajina eklenen not her saglayicida gecerlidir.
|
||||
- Diske yazilmaz; kopru yeniden baslarsa kayit gider.
|
||||
"""
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
OMUR_SN = 6 * 3600
|
||||
EN_COK_KAYIT = 50
|
||||
EN_COK_KARAKTER = 12_000 # nota konan yarim metnin ust siniri (basi ve sonu korunur)
|
||||
EN_AZ_KARAKTER = 40 # bundan kisa yarim metin tasinmaya degmez
|
||||
NOT_BASI = "[Köprü notu: bir önceki cevabın yarıda kesildi ve sohbet geçmişine kaydedilmedi."
|
||||
_KILIT = threading.Lock()
|
||||
_KAYIT = {} # sohbet anahtari -> {"zaman", "metin", "model"}
|
||||
|
||||
|
||||
class Izleyici:
|
||||
"""Akistan gecen parcalardan asistan metnini toplar; yanitin bitip bitmedigini izler."""
|
||||
|
||||
def __init__(self, anahtar, model):
|
||||
self.anahtar, self.model = anahtar, model
|
||||
self.buf = b""
|
||||
self.parcalar = []
|
||||
self.bitti = False
|
||||
|
||||
def feed(self, chunk):
|
||||
self.buf += chunk
|
||||
if b"\r" in self.buf:
|
||||
self.buf = self.buf.replace(b"\r\n", b"\n") # CRLF ile ayrilan SSE de izlenir
|
||||
while b"\n\n" in self.buf:
|
||||
blok, self.buf = self.buf.split(b"\n\n", 1)
|
||||
if b"response.output_text.delta" in blok:
|
||||
for satir in blok.split(b"\n"):
|
||||
if satir.startswith(b"data:"):
|
||||
try:
|
||||
d = json.loads(satir[5:])
|
||||
except ValueError:
|
||||
continue
|
||||
if d.get("type") == "response.output_text.delta" and isinstance(d.get("delta"), str):
|
||||
self.parcalar.append(d["delta"])
|
||||
elif b"response.completed" in blok or b"response.failed" in blok or b"response.incomplete" in blok:
|
||||
# Bitis, olayin TURUNDEN anlasilir; arac argumaninda gecen "response.completed" sozu bitis degildir.
|
||||
for satir in blok.split(b"\n"):
|
||||
if satir.startswith(b"data:"):
|
||||
try:
|
||||
tur = json.loads(satir[5:]).get("type")
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
if tur in ("response.completed", "response.failed", "response.incomplete"):
|
||||
self.bitti = True
|
||||
|
||||
@property
|
||||
def metin(self):
|
||||
return "".join(self.parcalar)
|
||||
|
||||
|
||||
def kaydet(izleyici, simdi=None):
|
||||
"""Istemci kapatti ve yanit bitmemisti: yarim metni sakla. True: saklandi."""
|
||||
metin = izleyici.metin.strip()
|
||||
if not izleyici.anahtar or izleyici.bitti or len(metin) < EN_AZ_KARAKTER:
|
||||
return False
|
||||
simdi = time.time() if simdi is None else simdi
|
||||
with _KILIT:
|
||||
_KAYIT[izleyici.anahtar] = {"zaman": simdi, "metin": metin, "model": izleyici.model}
|
||||
for eski in sorted(_KAYIT, key=lambda k: _KAYIT[k]["zaman"])[:max(0, len(_KAYIT) - EN_COK_KAYIT)]:
|
||||
del _KAYIT[eski]
|
||||
return True
|
||||
|
||||
|
||||
def _kisalt(metin):
|
||||
if len(metin) <= EN_COK_KARAKTER:
|
||||
return metin
|
||||
bas, son = EN_COK_KARAKTER // 4, EN_COK_KARAKTER - EN_COK_KARAKTER // 4
|
||||
return metin[:bas] + f"\n[... ortadan {len(metin) - bas - son} karakter çıkarıldı ...]\n" + metin[-son:]
|
||||
|
||||
|
||||
def uygula(req, anahtar, simdi=None):
|
||||
"""Ayni sohbetin sonraki isteginde yarim metni kullanicinin son mesajina not olarak ekler.
|
||||
Donus: eklenen karakter sayisi (0: eklenmedi). Kayit yalniz basariyla eklenince ya da eskiyince silinir."""
|
||||
if not anahtar:
|
||||
return 0
|
||||
simdi = time.time() if simdi is None else simdi
|
||||
with _KILIT:
|
||||
kayit = _KAYIT.get(anahtar)
|
||||
if kayit and simdi - kayit["zaman"] > OMUR_SN:
|
||||
del _KAYIT[anahtar]
|
||||
kayit = None
|
||||
girdi = req.get("input")
|
||||
if not kayit or not isinstance(girdi, list) or not girdi:
|
||||
return 0
|
||||
son = None
|
||||
for oge in reversed(girdi): # kancalarin kullanici mesajinin ardina ekledigi talimat mesajlari atlanir
|
||||
if isinstance(oge, dict) and oge.get("type", "message") == "message" and oge.get("role") in ("developer", "system"):
|
||||
continue
|
||||
son = oge
|
||||
break
|
||||
if not (isinstance(son, dict) and son.get("type", "message") == "message" and son.get("role") == "user"):
|
||||
return 0 # sikistirma istegi, arac sonucu turu vb.: yeni kullanici mesajini bekle
|
||||
metin = kayit["metin"]
|
||||
ham = json.dumps(girdi, ensure_ascii=False)
|
||||
if json.dumps(metin[:200], ensure_ascii=False)[1:-1] in ham:
|
||||
with _KILIT: # Codex bu metni gecmise kendisi yazmis: ikinci kez eklenmez
|
||||
_KAYIT.pop(anahtar, None)
|
||||
return 0
|
||||
notu = (f"{NOT_BASI} Kesilmeden önce yazdığın metin aşağıda. Kullanıcı devam etmeni isterse baştan yazma, "
|
||||
f"kaldığın yerden sürdür.]\n<yarim_cevap model=\"{kayit['model']}\">\n{_kisalt(metin)}\n</yarim_cevap>")
|
||||
icerik = son.get("content")
|
||||
if isinstance(icerik, str):
|
||||
son["content"] = [{"type": "input_text", "text": icerik}, {"type": "input_text", "text": notu}]
|
||||
elif isinstance(icerik, list):
|
||||
icerik.append({"type": "input_text", "text": notu})
|
||||
else:
|
||||
return 0
|
||||
with _KILIT:
|
||||
_KAYIT.pop(anahtar, None)
|
||||
return len(notu)
|
||||
@@ -0,0 +1,32 @@
|
||||
# Mobile connection repair — beta.19
|
||||
|
||||
The paired phone previously resolved the desktop's active profile. If that
|
||||
profile's startup promise was waiting for first-run setup, every phone API
|
||||
request waited too, even while the default backend was already serving.
|
||||
|
||||
REST and WebSocket requests now resolve the phone's explicit `profile` query
|
||||
(default when absent). Aggregate `profile=all` reaches the default process
|
||||
without rewriting the downstream query. Invalid profile identifiers, remote
|
||||
backends and a missing local runtime are refused; phone requests do not install
|
||||
software or silently fall back to a different profile. Resolution has an eight
|
||||
second deadline; this is not an agent, tool or scheduled-job execution deadline.
|
||||
|
||||
Shared-runtime completion wakes only a waiting setup for the same runtime root.
|
||||
Primary startup then resolves its own profile again. This does not grant
|
||||
installation consent or substitute a sibling profile's backend connection.
|
||||
|
||||
## Evidence
|
||||
|
||||
- 94 mobile transport, certificate, relay, profile and startup regression tests.
|
||||
- Electron TypeScript type check.
|
||||
- Opt-in `mobile-live.test.ts`, with `AITURK_TEST_BACKEND_PORT`: real local
|
||||
backend behind the production TLS proxy, pinned certificate, screen reads,
|
||||
cron create/update/pause/resume/delete and chat WebSocket ping. Test-created
|
||||
job is deleted; existing jobs are untouched. This test does not trigger an
|
||||
agent job or claim physical-phone/cellular acceptance.
|
||||
- Public relay hello was separately exercised using Android's production
|
||||
OkHttp/Conscrypt stack. A successful hello alone does not prove API readiness.
|
||||
|
||||
Publication, installed beta.19 and physical-phone acceptance are separate gates.
|
||||
Existing phone security exclusions are unchanged. Unsupported methods and
|
||||
features are not claimed to be implemented by this connection repair.
|
||||
@@ -0,0 +1,656 @@
|
||||
/**
|
||||
* Tests for electron/aiturk-login.ts — the one-click TurkServis sign-in.
|
||||
* Everything is injected (fake fetch, fake backend, fake CLI, fake clock), so
|
||||
* no network, browser or real CLI is touched. The fixture "keys" below are
|
||||
* obviously fake placeholders in the right shape.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
AITURK_CLI_KEY_HOLD_MS,
|
||||
AITURK_LOGIN_START_RETRY_DELAY_MS,
|
||||
type AiturkCliExit,
|
||||
AiturkLoginError,
|
||||
createAiturkCliRunner,
|
||||
createAiturkLogin,
|
||||
resolveAiturkCliPath
|
||||
} from './aiturk-login'
|
||||
|
||||
const DEVICE_CODE = `tsdl_${'d'.repeat(43)}`
|
||||
const IDE_KEY = 'tsk_FAKE-ide-key-for-tests'
|
||||
const CLI_KEY = 'tsk_FAKE-cli-key-for-tests'
|
||||
const URI = 'https://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e'
|
||||
|
||||
const startBody = (over: Record<string, unknown> = {}) => ({
|
||||
loginId: '0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||
deviceCode: DEVICE_CODE,
|
||||
userCode: 'ABCD2345',
|
||||
expiresIn: 600,
|
||||
interval: 3,
|
||||
verificationUri: URI,
|
||||
...over
|
||||
})
|
||||
|
||||
const approved = { state: 'approved', key: IDE_KEY, cliKey: CLI_KEY, account: 'acct_test' }
|
||||
|
||||
function harness(
|
||||
options: {
|
||||
start?: { status: number; body?: unknown }
|
||||
/** Thrown by successive `device-auth/start` calls before any answer is given. */
|
||||
startErrors?: Error[]
|
||||
polls?: ({ status: number; body?: unknown } | Error)[]
|
||||
cliExits?: AiturkCliExit[]
|
||||
validate?: unknown
|
||||
save?: unknown
|
||||
openExternal?: () => boolean
|
||||
} = {}
|
||||
) {
|
||||
const calls: { url: string; init: any }[] = []
|
||||
const polls = [...(options.polls ?? [])]
|
||||
const startErrors = [...(options.startErrors ?? [])]
|
||||
const cliExits = [...(options.cliExits ?? [0])]
|
||||
const clock = { now: 1_000_000 }
|
||||
const timers: { fn: () => void; ms: number; cleared: boolean }[] = []
|
||||
|
||||
const fetch = vi.fn(async (url: string, init: any) => {
|
||||
calls.push({ url, init })
|
||||
|
||||
if (url.endsWith('/start')) {
|
||||
const failure = startErrors.shift()
|
||||
|
||||
if (failure) {
|
||||
throw failure
|
||||
}
|
||||
|
||||
const r = options.start ?? { status: 201, body: startBody() }
|
||||
|
||||
return { status: r.status, json: async () => r.body }
|
||||
}
|
||||
|
||||
if (url.endsWith('/poll')) {
|
||||
const r = polls.shift() ?? { status: 200, body: { state: 'pending' } }
|
||||
|
||||
if (r instanceof Error) {
|
||||
throw r
|
||||
}
|
||||
|
||||
return { status: r.status, json: async () => r.body }
|
||||
}
|
||||
|
||||
return { status: 200, json: async () => ({}) }
|
||||
})
|
||||
|
||||
const backendRequest = vi.fn(async (request: any) => {
|
||||
if (request.path.endsWith('/validate')) {
|
||||
return options.validate ?? { ok: true, reachable: true, models: ['account/model-a', 'account/model-b'], message: '' }
|
||||
}
|
||||
|
||||
return options.save ?? { ok: true, id: 'turkservis', endpoints: [] }
|
||||
})
|
||||
|
||||
const runCli = vi.fn(async (_key: string, _options: { keepOtherAccount: boolean }) =>
|
||||
cliExits.length ? (cliExits.shift() as AiturkCliExit) : 0
|
||||
)
|
||||
|
||||
const openExternal = vi.fn(options.openExternal ?? (() => true))
|
||||
|
||||
const login = createAiturkLogin({
|
||||
fetch,
|
||||
openExternal,
|
||||
hostname: () => 'TEST-PC',
|
||||
backendRequest,
|
||||
runCli,
|
||||
now: () => clock.now,
|
||||
setTimer: (fn, ms) => {
|
||||
const timer = { fn, ms, cleared: false }
|
||||
timers.push(timer)
|
||||
|
||||
return timer
|
||||
},
|
||||
clearTimer: handle => {
|
||||
;(handle as { cleared: boolean }).cleared = true
|
||||
},
|
||||
timeoutSignal: () => undefined
|
||||
})
|
||||
|
||||
return { login, fetch, calls, backendRequest, runCli, openExternal, clock, timers }
|
||||
}
|
||||
|
||||
const leaks = (value: unknown) => /tsk_|tsdl_/.test(typeof value === 'string' ? value : JSON.stringify(value) ?? '')
|
||||
|
||||
describe('aiturk login: start', () => {
|
||||
it('asks the server for a code, opens the approval page and returns no secret', async () => {
|
||||
const h = harness()
|
||||
const started = await h.login.start({ profile: 'work' })
|
||||
|
||||
expect(started).toEqual({ userCode: 'ABCD2345', verificationUri: URI, expiresAt: 1_000_000 + 600_000 })
|
||||
expect(leaks(started)).toBe(false)
|
||||
expect(h.openExternal).toHaveBeenCalledExactlyOnceWith(URI)
|
||||
|
||||
const { url, init } = h.calls[0]
|
||||
expect(url).toBe('https://turkservis.online/v1/aiturk/device-auth/start')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.redirect).toBe('error')
|
||||
expect(JSON.parse(init.body)).toEqual({ client: 'ide', device_name: 'TEST-PC', also_cli: true })
|
||||
expect(init.headers.Authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://turkservis.online.evil.example/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||
'http://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||
'https://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e?next=//evil',
|
||||
'https://turkservis.online/account/aiturk/device-login/../../x',
|
||||
'https://evil.example/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||
''
|
||||
])('refuses a verification URL that is not exactly the approval page: %s', async verificationUri => {
|
||||
const h = harness({ start: { status: 201, body: startBody({ verificationUri }) } })
|
||||
|
||||
const error = await h.login.start().catch(e => e)
|
||||
expect(error).toBeInstanceOf(AiturkLoginError)
|
||||
expect(error.message).toBe('aiturk-login:invalid-response')
|
||||
expect(leaks(error.message)).toBe(false)
|
||||
expect(h.openExternal).not.toHaveBeenCalled()
|
||||
// The issued code is revoked instead of being left approvable.
|
||||
expect(h.calls.at(-1)?.url).toBe('https://turkservis.online/v1/aiturk/device-auth/cancel')
|
||||
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
||||
})
|
||||
|
||||
it('maps 429 to rate-limited and other failures to unavailable', async () => {
|
||||
await expect(harness({ start: { status: 429 } }).login.start()).rejects.toThrow('aiturk-login:rate-limited')
|
||||
await expect(harness({ start: { status: 422 } }).login.start()).rejects.toThrow('aiturk-login:unavailable')
|
||||
await expect(harness({ start: { status: 500 } }).login.start()).rejects.toThrow('aiturk-login:unavailable')
|
||||
})
|
||||
|
||||
it('tries start once more after a thrown network error (first request of a long-idle process)', async () => {
|
||||
const h = harness({ startErrors: [new TypeError('fetch failed')] })
|
||||
const pending = h.login.start()
|
||||
|
||||
await vi.waitFor(() => expect(h.timers.some(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toBe(true))
|
||||
expect(AITURK_LOGIN_START_RETRY_DELAY_MS).toBe(500)
|
||||
expect(h.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
|
||||
expect(h.openExternal).not.toHaveBeenCalled()
|
||||
h.timers.find(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)!.fn()
|
||||
|
||||
expect(await pending).toEqual({ userCode: 'ABCD2345', verificationUri: URI, expiresAt: 1_000_000 + 600_000 })
|
||||
expect(h.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(2)
|
||||
// One code only: the approval page is opened once.
|
||||
expect(h.openExternal).toHaveBeenCalledExactlyOnceWith(URI)
|
||||
})
|
||||
|
||||
it('gives up after the second network error, and never retries an HTTP answer or a timeout', async () => {
|
||||
const twice = harness({ startErrors: [new TypeError('fetch failed'), new TypeError('fetch failed')] })
|
||||
const failing = twice.login.start().catch(e => e)
|
||||
|
||||
await vi.waitFor(() => expect(twice.timers.some(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toBe(true))
|
||||
twice.timers.find(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)!.fn()
|
||||
expect((await failing).message).toBe('aiturk-login:unavailable')
|
||||
expect(twice.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(2)
|
||||
expect(twice.timers.filter(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toHaveLength(1)
|
||||
|
||||
for (const status of [500, 503, 429, 422]) {
|
||||
const answered = harness({ start: { status } })
|
||||
|
||||
await expect(answered.login.start()).rejects.toThrow(/^aiturk-login:(?:unavailable|rate-limited)$/)
|
||||
expect(answered.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
|
||||
expect(answered.timers).toHaveLength(0)
|
||||
}
|
||||
|
||||
const timedOut = harness({ startErrors: [new DOMException('The operation was aborted due to timeout', 'TimeoutError')] })
|
||||
|
||||
await expect(timedOut.login.start()).rejects.toThrow('aiturk-login:unavailable')
|
||||
expect(timedOut.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
|
||||
expect(timedOut.timers).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects malformed codes', async () => {
|
||||
await expect(
|
||||
harness({ start: { status: 201, body: startBody({ userCode: 'abcd2345' }) } }).login.start()
|
||||
).rejects.toThrow('aiturk-login:invalid-response')
|
||||
await expect(
|
||||
harness({ start: { status: 201, body: startBody({ deviceCode: 'nope' }) } }).login.start()
|
||||
).rejects.toThrow('aiturk-login:invalid-response')
|
||||
})
|
||||
|
||||
it('keeps a single login: a second start revokes the first code', async () => {
|
||||
const h = harness()
|
||||
await h.login.start()
|
||||
await h.login.start()
|
||||
|
||||
const urls = h.calls.map(c => c.url.split('/').pop())
|
||||
expect(urls).toEqual(['start', 'cancel', 'start'])
|
||||
expect(h.calls[1].init.headers.Authorization).toBe(`Bearer ${DEVICE_CODE}`)
|
||||
})
|
||||
|
||||
it('cancels the code when the browser cannot be opened', async () => {
|
||||
const h = harness({ openExternal: () => false })
|
||||
|
||||
await expect(h.login.start()).rejects.toThrow('aiturk-login:browser-failed')
|
||||
expect(h.calls.at(-1)?.url).toMatch(/\/cancel$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiturk login: poll', () => {
|
||||
it('reports pending while the user has not approved', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: { state: 'pending' } }] })
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'pending' })
|
||||
expect(h.calls[1].url).toBe('https://turkservis.online/v1/aiturk/device-auth/poll')
|
||||
expect(h.calls[1].init.headers.Authorization).toBe(`Bearer ${DEVICE_CODE}`)
|
||||
expect(h.calls[1].init.redirect).toBe('error')
|
||||
expect(h.backendRequest).not.toHaveBeenCalled()
|
||||
expect(h.runCli).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps waiting through a transient network error', async () => {
|
||||
const h = harness({ polls: [new Error(`boom ${DEVICE_CODE}`), { status: 503 }] })
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'pending' })
|
||||
expect(await h.login.poll()).toEqual({ state: 'pending' })
|
||||
})
|
||||
|
||||
it('reports expired on 401 and stops asking the server', async () => {
|
||||
const h = harness({ polls: [{ status: 401, body: { detail: 'expired' } }] })
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
||||
const count = h.fetch.mock.calls.length
|
||||
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
||||
expect(h.fetch.mock.calls.length).toBe(count)
|
||||
})
|
||||
|
||||
it('expires by the clock without a request', async () => {
|
||||
const h = harness()
|
||||
await h.login.start()
|
||||
h.clock.now += 600_000
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
||||
expect(h.calls.filter(c => c.url.endsWith('/poll'))).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('on approval saves the provider and pipes ONLY the CLI key to the CLI', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||
await h.login.start({ profile: 'work', connectionId: 'conn-1' })
|
||||
|
||||
const result = await h.login.poll()
|
||||
expect(result).toEqual({ state: 'connected', cli: 'ok' })
|
||||
expect(leaks(result)).toBe(false)
|
||||
|
||||
expect(h.backendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(h.backendRequest.mock.calls[0][0]).toEqual({
|
||||
connectionId: 'conn-1',
|
||||
path: '/api/providers/custom-endpoints/validate',
|
||||
method: 'POST',
|
||||
body: {
|
||||
id: 'turkservis',
|
||||
name: 'TurkServis · AITURK',
|
||||
base_url: 'https://ai.turkservis.online/v1',
|
||||
api_key: IDE_KEY,
|
||||
model: '',
|
||||
discover_models: true,
|
||||
make_default: true
|
||||
}
|
||||
})
|
||||
expect(h.backendRequest.mock.calls[1][0]).toEqual({
|
||||
profile: 'work',
|
||||
connectionId: 'conn-1',
|
||||
path: '/api/providers/custom-endpoints',
|
||||
method: 'POST',
|
||||
body: {
|
||||
id: 'turkservis',
|
||||
name: 'TurkServis · AITURK',
|
||||
base_url: 'https://ai.turkservis.online/v1',
|
||||
api_key: IDE_KEY,
|
||||
model: 'account/model-a',
|
||||
models: ['account/model-a', 'account/model-b'],
|
||||
discover_models: true,
|
||||
make_default: true
|
||||
}
|
||||
})
|
||||
|
||||
expect(h.runCli).toHaveBeenCalledExactlyOnceWith(CLI_KEY, { keepOtherAccount: true })
|
||||
// No key ever rides a URL.
|
||||
expect(h.calls.some(c => leaks(c.url))).toBe(false)
|
||||
})
|
||||
|
||||
it('does not ask for the one-time keys again on later polls', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||
await h.login.start()
|
||||
|
||||
const first = await h.login.poll()
|
||||
const fetches = h.fetch.mock.calls.length
|
||||
const second = await h.login.poll()
|
||||
|
||||
expect(second).toEqual(first)
|
||||
expect(h.fetch.mock.calls.length).toBe(fetches)
|
||||
expect(h.backendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(h.runCli).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shares one completion between overlapping polls', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||
await h.login.start()
|
||||
|
||||
const [a, b] = await Promise.all([h.login.poll(), h.login.poll()])
|
||||
expect(a).toEqual({ state: 'connected', cli: 'ok' })
|
||||
expect(b.state === 'connected' || b.state === 'pending').toBe(true)
|
||||
expect(h.runCli).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[0, 'ok'],
|
||||
[2, 'outdated'],
|
||||
[3, 'other-account'],
|
||||
[4, 'unknown-account'],
|
||||
[1, 'failed'],
|
||||
[null, 'failed'],
|
||||
['missing', 'missing']
|
||||
] as const)('maps CLI exit %s to cli=%s', async (exit, cli) => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }], cliExits: [exit] })
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'connected', cli })
|
||||
})
|
||||
|
||||
it('treats a throwing CLI runner as failed without leaking', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||
h.runCli.mockRejectedValueOnce(new Error(`spawn failed ${CLI_KEY}`))
|
||||
await h.login.start()
|
||||
|
||||
const result = await h.login.poll()
|
||||
expect(result).toEqual({ state: 'connected', cli: 'failed' })
|
||||
})
|
||||
|
||||
it('does not save or touch the CLI when the key is rejected by the backend', async () => {
|
||||
const h = harness({
|
||||
polls: [{ status: 200, body: approved }],
|
||||
validate: { ok: false, reachable: true, models: [], message: `bad key ${IDE_KEY}` }
|
||||
})
|
||||
|
||||
await h.login.start()
|
||||
|
||||
const result = await h.login.poll()
|
||||
expect(result).toEqual({ state: 'error', error: 'provider-failed' })
|
||||
expect(leaks(result)).toBe(false)
|
||||
expect(h.backendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(h.runCli).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not invent a model when the catalog is empty', async () => {
|
||||
const h = harness({
|
||||
polls: [{ status: 200, body: approved }],
|
||||
validate: { ok: true, reachable: true, models: [], message: '' }
|
||||
})
|
||||
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'error', error: 'no-models' })
|
||||
expect(h.backendRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('never surfaces backend error text (which could echo the key)', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||
h.backendRequest.mockRejectedValueOnce(new Error(`500: echoed ${IDE_KEY}`))
|
||||
await h.login.start()
|
||||
|
||||
const result = await h.login.poll()
|
||||
expect(result).toEqual({ state: 'error', error: 'provider-failed' })
|
||||
expect(leaks(result)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an approval whose key is malformed', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: { state: 'approved', key: 'not a key', cliKey: CLI_KEY } }] })
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'error', error: 'invalid-response' })
|
||||
expect(h.backendRequest).not.toHaveBeenCalled()
|
||||
expect(h.runCli).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiturk login: other CLI account', () => {
|
||||
async function otherAccount(overwriteExit: AiturkCliExit = 0) {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }], cliExits: [3, overwriteExit] })
|
||||
await h.login.start()
|
||||
expect(await h.login.poll()).toEqual({ state: 'connected', cli: 'other-account' })
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
it('overwrites only after the user confirms, without the keep flag', async () => {
|
||||
const h = await otherAccount()
|
||||
|
||||
const result = await h.login.overwriteCli()
|
||||
expect(result).toEqual({ cli: 'ok' })
|
||||
expect(leaks(result)).toBe(false)
|
||||
expect(h.runCli).toHaveBeenLastCalledWith(CLI_KEY, { keepOtherAccount: false })
|
||||
expect(await h.login.poll()).toEqual({ state: 'connected', cli: 'ok' })
|
||||
|
||||
// Single use: the held key is gone.
|
||||
await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite')
|
||||
expect(h.runCli).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reports an overwrite that still fails', async () => {
|
||||
const h = await otherAccount(4)
|
||||
|
||||
expect(await h.login.overwriteCli()).toEqual({ cli: 'unknown-account' })
|
||||
})
|
||||
|
||||
it('drops the held CLI key after five minutes', async () => {
|
||||
const h = await otherAccount()
|
||||
const hold = h.timers.find(t => t.ms === AITURK_CLI_KEY_HOLD_MS)
|
||||
expect(hold).toBeDefined()
|
||||
|
||||
h.clock.now += AITURK_CLI_KEY_HOLD_MS
|
||||
hold?.fn()
|
||||
|
||||
await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite')
|
||||
expect(h.runCli).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('refuses a stale held key even if the timer never fired', async () => {
|
||||
const h = await otherAccount()
|
||||
h.clock.now += AITURK_CLI_KEY_HOLD_MS
|
||||
|
||||
await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite')
|
||||
expect(h.runCli).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('drops the held CLI key on cancel', async () => {
|
||||
const h = await otherAccount()
|
||||
await h.login.cancel()
|
||||
|
||||
const error = await h.login.overwriteCli().catch(e => e)
|
||||
expect(error.message).toBe('aiturk-login:nothing-to-overwrite')
|
||||
expect(leaks(error.message)).toBe(false)
|
||||
expect(h.runCli).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('has nothing to overwrite when the CLI simply succeeded', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||
await h.login.start()
|
||||
await h.login.poll()
|
||||
|
||||
await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite')
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiturk login: cancel', () => {
|
||||
it('revokes the code on the server and clears the session', async () => {
|
||||
const h = harness()
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.cancel()).toEqual({ ok: true })
|
||||
const last = h.calls.at(-1)
|
||||
expect(last?.url).toBe('https://turkservis.online/v1/aiturk/device-auth/cancel')
|
||||
expect(last?.init.headers.Authorization).toBe(`Bearer ${DEVICE_CODE}`)
|
||||
|
||||
const count = h.fetch.mock.calls.length
|
||||
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
||||
expect(h.fetch.mock.calls.length).toBe(count)
|
||||
})
|
||||
|
||||
it('is a no-op without a login and survives a failing server', async () => {
|
||||
const h = harness()
|
||||
expect(await h.login.cancel()).toEqual({ ok: true })
|
||||
expect(h.fetch).not.toHaveBeenCalled()
|
||||
|
||||
await h.login.start()
|
||||
h.fetch.mockRejectedValueOnce(new Error('offline'))
|
||||
expect(await h.login.cancel()).toEqual({ ok: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiturk CLI runner', () => {
|
||||
const CLI_PATH = 'C:\\Users\\Test User\\AppData\\Local\\TurkServis\\AITURK\\program\\bin\\aiturk.cmd'
|
||||
const env = { LOCALAPPDATA: 'C:\\Users\\Test User\\AppData\\Local', SystemRoot: 'C:\\Windows' }
|
||||
|
||||
function fakeSpawn(behaviour: (child: any) => void = child => child.emit('close', 0)) {
|
||||
const state: any = { calls: [], stdin: [] as string[], killed: false }
|
||||
|
||||
const spawn = vi.fn((command: string, args: string[], options: any) => {
|
||||
const child: any = new EventEmitter()
|
||||
child.stdin = new EventEmitter()
|
||||
|
||||
child.stdin.end = (chunk: string) => {
|
||||
state.stdin.push(chunk)
|
||||
queueMicrotask(() => behaviour(child))
|
||||
}
|
||||
|
||||
child.kill = () => {
|
||||
state.killed = true
|
||||
}
|
||||
|
||||
state.calls.push({ command, args, options })
|
||||
|
||||
return child
|
||||
})
|
||||
|
||||
return { spawn, state }
|
||||
}
|
||||
|
||||
it('finds the CLI only at the fixed install path', () => {
|
||||
expect(resolveAiturkCliPath({ env, existsSync: p => p === CLI_PATH, platform: 'win32' })).toBe(CLI_PATH)
|
||||
expect(resolveAiturkCliPath({ env, existsSync: () => false, platform: 'win32' })).toBeNull()
|
||||
expect(resolveAiturkCliPath({ env: {}, existsSync: () => true, platform: 'win32' })).toBeNull()
|
||||
expect(resolveAiturkCliPath({ env: { LOCALAPPDATA: 'relative' }, existsSync: () => true, platform: 'win32' })).toBeNull()
|
||||
expect(resolveAiturkCliPath({ env, existsSync: () => true, platform: 'linux' })).toBeNull()
|
||||
})
|
||||
|
||||
it('reports missing without spawning anything', async () => {
|
||||
const { spawn } = fakeSpawn()
|
||||
const run = createAiturkCliRunner({ spawn, existsSync: () => false, env, platform: 'win32' })
|
||||
|
||||
expect(await run(CLI_KEY, { keepOtherAccount: true })).toBe('missing')
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends the key on stdin only, through an absolute cmd.exe, without a shell', async () => {
|
||||
const { spawn, state } = fakeSpawn()
|
||||
const run = createAiturkCliRunner({ spawn, existsSync: () => true, env, platform: 'win32' })
|
||||
|
||||
expect(await run(CLI_KEY, { keepOtherAccount: true })).toBe(0)
|
||||
|
||||
const { command, args, options } = state.calls[0]
|
||||
expect(command).toBe('C:\\Windows\\System32\\cmd.exe')
|
||||
expect(args).toEqual(['/d', '/s', '/c', `""${CLI_PATH}" login --key-stdin --keep-other-account"`])
|
||||
expect(options).toEqual({
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
windowsVerbatimArguments: true,
|
||||
stdio: ['pipe', 'ignore', 'ignore']
|
||||
})
|
||||
expect(leaks([command, args, options])).toBe(false)
|
||||
expect(state.stdin).toEqual([`${CLI_KEY}\n`])
|
||||
})
|
||||
|
||||
it('drops the keep flag for a confirmed overwrite', async () => {
|
||||
const { spawn, state } = fakeSpawn()
|
||||
const run = createAiturkCliRunner({ spawn, existsSync: () => true, env, platform: 'win32' })
|
||||
|
||||
await run(CLI_KEY, { keepOtherAccount: false })
|
||||
expect(state.calls[0].args[3]).toBe(`""${CLI_PATH}" login --key-stdin"`)
|
||||
})
|
||||
|
||||
it.each([0, 1, 2, 3, 4])('returns exit code %s', async code => {
|
||||
const { spawn } = fakeSpawn(child => child.emit('close', code))
|
||||
const run = createAiturkCliRunner({ spawn, existsSync: () => true, env, platform: 'win32' })
|
||||
|
||||
expect(await run(CLI_KEY, { keepOtherAccount: true })).toBe(code)
|
||||
})
|
||||
|
||||
it('returns null on spawn error, thrown spawn and stdin EPIPE', async () => {
|
||||
const errored = fakeSpawn(child => child.emit('error', new Error('ENOENT')))
|
||||
expect(
|
||||
await createAiturkCliRunner({ spawn: errored.spawn, existsSync: () => true, env, platform: 'win32' })(CLI_KEY, {
|
||||
keepOtherAccount: true
|
||||
})
|
||||
).toBeNull()
|
||||
|
||||
const thrower = vi.fn(() => {
|
||||
throw new Error('spawn EINVAL')
|
||||
})
|
||||
|
||||
expect(
|
||||
await createAiturkCliRunner({ spawn: thrower, existsSync: () => true, env, platform: 'win32' })(CLI_KEY, {
|
||||
keepOtherAccount: true
|
||||
})
|
||||
).toBeNull()
|
||||
|
||||
const epipe = fakeSpawn(child => {
|
||||
child.stdin.emit('error', new Error('EPIPE'))
|
||||
child.emit('close', 2)
|
||||
})
|
||||
|
||||
expect(
|
||||
await createAiturkCliRunner({ spawn: epipe.spawn, existsSync: () => true, env, platform: 'win32' })(CLI_KEY, {
|
||||
keepOtherAccount: true
|
||||
})
|
||||
).toBe(2)
|
||||
})
|
||||
|
||||
it('kills a CLI that hangs past the timeout', async () => {
|
||||
const { spawn, state } = fakeSpawn(() => {})
|
||||
|
||||
let fire = () => {}
|
||||
|
||||
const run = createAiturkCliRunner({
|
||||
spawn,
|
||||
existsSync: () => true,
|
||||
env,
|
||||
platform: 'win32',
|
||||
setTimer: (fn, ms) => {
|
||||
expect(ms).toBe(60_000)
|
||||
fire = fn
|
||||
|
||||
return 1
|
||||
},
|
||||
clearTimer: () => {}
|
||||
})
|
||||
|
||||
const pending = run(CLI_KEY, { keepOtherAccount: true })
|
||||
fire()
|
||||
|
||||
expect(await pending).toBeNull()
|
||||
expect(state.killed).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses an install path cmd.exe could reinterpret', async () => {
|
||||
const { spawn } = fakeSpawn()
|
||||
|
||||
const run = createAiturkCliRunner({
|
||||
spawn,
|
||||
existsSync: () => true,
|
||||
env: { LOCALAPPDATA: 'C:\\Users\\a%PATH%b\\AppData\\Local', SystemRoot: 'C:\\Windows' },
|
||||
platform: 'win32'
|
||||
})
|
||||
|
||||
expect(await run(CLI_KEY, { keepOtherAccount: true })).toBeNull()
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,668 @@
|
||||
/**
|
||||
* aiturk-login.ts
|
||||
*
|
||||
* One-click TurkServis sign-in for AITURK IDE (device-code flow).
|
||||
*
|
||||
* The IDE shows a short code, the browser opens the turkservis.online approval
|
||||
* page, and once the user approves, the server hands out two API keys exactly
|
||||
* once: one for the IDE's TurkServis provider and one for the AITURK CLI.
|
||||
*
|
||||
* Security contract (do not relax):
|
||||
* - Keys (`tsk_…`) and the device code (`tsdl_…`) never leave the main
|
||||
* process: they are not returned to the renderer, not logged, not put into
|
||||
* error messages, process arguments or URLs.
|
||||
* - The CLI receives its key on stdin only, is launched from an absolute
|
||||
* path without a shell, with a fixed argument list.
|
||||
* - Network calls go only to https://turkservis.online, refuse redirects and
|
||||
* time out after 20 s.
|
||||
*
|
||||
* All I/O is injected so the logic is unit-testable without Electron, a
|
||||
* network or a real CLI (see aiturk-login.test.ts).
|
||||
*/
|
||||
|
||||
export const AITURK_LOGIN_ORIGIN = 'https://turkservis.online'
|
||||
export const AITURK_LOGIN_FETCH_TIMEOUT_MS = 20_000
|
||||
/** Wait before the single second try of `device-auth/start` after a thrown network error. */
|
||||
export const AITURK_LOGIN_START_RETRY_DELAY_MS = 500
|
||||
export const AITURK_CLI_TIMEOUT_MS = 60_000
|
||||
export const AITURK_CLI_KEY_HOLD_MS = 5 * 60_000
|
||||
|
||||
const AITURK_PROVIDER_ID = 'turkservis'
|
||||
const AITURK_PROVIDER_NAME = 'TurkServis · AITURK'
|
||||
const AITURK_PROVIDER_BASE_URL = 'https://ai.turkservis.online/v1'
|
||||
|
||||
const VERIFICATION_URI_RE = /^https:\/\/turkservis\.online\/account\/aiturk\/device-login\/[0-9a-f-]{36}$/
|
||||
const DEVICE_CODE_RE = /^tsdl_[A-Za-z0-9_-]{43}$/
|
||||
const USER_CODE_RE = /^[A-HJ-NP-Z2-9]{8}$/
|
||||
const API_KEY_RE = /^tsk_[A-Za-z0-9._-]{8,500}$/
|
||||
const MAX_EXPIRES_IN_S = 3600
|
||||
|
||||
export type AiturkCliResult = 'ok' | 'missing' | 'outdated' | 'other-account' | 'unknown-account' | 'failed'
|
||||
|
||||
export type AiturkLoginErrorCode =
|
||||
| 'busy'
|
||||
| 'rate-limited'
|
||||
| 'unavailable'
|
||||
| 'invalid-response'
|
||||
| 'browser-failed'
|
||||
| 'provider-failed'
|
||||
| 'no-models'
|
||||
| 'nothing-to-overwrite'
|
||||
|
||||
/** Error whose message is ONLY a fixed code — never upstream text, so a key
|
||||
* echoed by a server or backend cannot ride an error into the renderer/log. */
|
||||
export class AiturkLoginError extends Error {
|
||||
code: AiturkLoginErrorCode
|
||||
|
||||
constructor(code: AiturkLoginErrorCode) {
|
||||
super(`aiturk-login:${code}`)
|
||||
this.name = 'AiturkLoginError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export interface AiturkLoginScope {
|
||||
profile?: null | string
|
||||
connectionId?: null | string
|
||||
}
|
||||
|
||||
export interface AiturkLoginStartResult {
|
||||
userCode: string
|
||||
verificationUri: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
export type AiturkLoginPollResult =
|
||||
| { state: 'pending' }
|
||||
| { state: 'expired' }
|
||||
| { state: 'connected'; cli: AiturkCliResult }
|
||||
| { state: 'error'; error: AiturkLoginErrorCode }
|
||||
|
||||
export interface AiturkFetchResponse {
|
||||
status: number
|
||||
json: () => Promise<unknown>
|
||||
}
|
||||
|
||||
export type AiturkFetch = (
|
||||
url: string,
|
||||
init: { method: string; headers: Record<string, string>; body?: string; redirect: 'error'; signal?: AbortSignal }
|
||||
) => Promise<AiturkFetchResponse>
|
||||
|
||||
export interface AiturkBackendRequest {
|
||||
path: string
|
||||
method: string
|
||||
body: unknown
|
||||
profile?: string
|
||||
connectionId?: string
|
||||
}
|
||||
|
||||
/** Outcome of one CLI launch: 'missing' when the CLI is not installed, the
|
||||
* numeric exit code otherwise, null when it could not run / timed out. */
|
||||
export type AiturkCliExit = 'missing' | null | number
|
||||
|
||||
export interface AiturkLoginDeps {
|
||||
fetch: AiturkFetch
|
||||
/** Open a URL in the system browser. `false` means it could not be opened. */
|
||||
openExternal: (url: string) => boolean | Promise<boolean | void> | void
|
||||
hostname: () => string
|
||||
/** Same route the renderer's hermesApi uses (hermes:api handler internals). */
|
||||
backendRequest: (request: AiturkBackendRequest) => Promise<unknown>
|
||||
/** Run `aiturk login --key-stdin [--keep-other-account]`, key on stdin. */
|
||||
runCli: (cliKey: string, options: { keepOtherAccount: boolean }) => Promise<AiturkCliExit>
|
||||
now: () => number
|
||||
setTimer?: (fn: () => void, ms: number) => unknown
|
||||
clearTimer?: (handle: unknown) => void
|
||||
timeoutSignal?: (ms: number) => AbortSignal | undefined
|
||||
}
|
||||
|
||||
interface Session {
|
||||
deviceCode: string
|
||||
userCode: string
|
||||
verificationUri: string
|
||||
expiresAt: number
|
||||
scope: { profile?: string; connectionId?: string }
|
||||
}
|
||||
|
||||
function deviceName(raw: unknown): string {
|
||||
// Printable, 1–60 chars. A hostname is not a secret, but keep it tidy.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const cleaned = String(raw ?? '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 60)
|
||||
|
||||
return cleaned || 'AITURK IDE'
|
||||
}
|
||||
|
||||
function cleanScope(scope: unknown): { profile?: string; connectionId?: string } {
|
||||
const source = (scope && typeof scope === 'object' ? scope : {}) as AiturkLoginScope
|
||||
const profile = typeof source.profile === 'string' ? source.profile.trim() : ''
|
||||
const connectionId = typeof source.connectionId === 'string' ? source.connectionId.trim() : ''
|
||||
|
||||
return {
|
||||
...(profile ? { profile } : {}),
|
||||
...(connectionId ? { connectionId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function cliResultFromExit(exit: AiturkCliExit, keepOtherAccount: boolean): AiturkCliResult {
|
||||
if (exit === 'missing') {
|
||||
return 'missing'
|
||||
}
|
||||
|
||||
if (exit === 0) {
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
if (exit === 2) {
|
||||
return 'outdated'
|
||||
}
|
||||
|
||||
if (exit === 3 && keepOtherAccount) {
|
||||
return 'other-account'
|
||||
}
|
||||
|
||||
if (exit === 4) {
|
||||
return 'unknown-account'
|
||||
}
|
||||
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
export function createAiturkLogin(deps: AiturkLoginDeps) {
|
||||
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
|
||||
const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as ReturnType<typeof setTimeout>))
|
||||
|
||||
const timeoutSignal =
|
||||
deps.timeoutSignal ?? (ms => (typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(ms) : undefined))
|
||||
|
||||
let session: null | Session = null
|
||||
// In-flight "approved → save provider → hand key to CLI" work. Concurrent
|
||||
// polls share it so the one-time key is fetched and consumed exactly once.
|
||||
let finishing: null | Promise<AiturkLoginPollResult> = null
|
||||
// Final outcome of the last login, replayed to later polls without a fetch.
|
||||
let settled: null | AiturkLoginPollResult = null
|
||||
// CLI key kept ONLY while the user decides whether to replace another
|
||||
// account's CLI login. Main-process memory, dropped after 5 minutes.
|
||||
let heldCli: null | { key: string; expiresAt: number; timer: unknown } = null
|
||||
|
||||
function dropHeldCli() {
|
||||
if (heldCli) {
|
||||
clearTimer(heldCli.timer)
|
||||
heldCli = null
|
||||
}
|
||||
}
|
||||
|
||||
function holdCli(key: string) {
|
||||
dropHeldCli()
|
||||
|
||||
const timer = setTimer(() => {
|
||||
heldCli = null
|
||||
}, AITURK_CLI_KEY_HOLD_MS)
|
||||
|
||||
// Never keep the app alive just for this timer.
|
||||
;(timer as { unref?: () => void } | null)?.unref?.()
|
||||
heldCli = { key, expiresAt: deps.now() + AITURK_CLI_KEY_HOLD_MS, timer }
|
||||
}
|
||||
|
||||
async function post(path: string, bearer: null | string, body?: unknown): Promise<AiturkFetchResponse> {
|
||||
return deps.fetch(`${AITURK_LOGIN_ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(bearer ? { Authorization: `Bearer ${bearer}` } : {})
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
redirect: 'error',
|
||||
signal: timeoutSignal(AITURK_LOGIN_FETCH_TIMEOUT_MS)
|
||||
})
|
||||
}
|
||||
|
||||
async function cancelOnServer(deviceCode: string) {
|
||||
try {
|
||||
await post('/v1/aiturk/device-auth/cancel', deviceCode)
|
||||
} catch {
|
||||
// Best effort: the code expires on its own within 10 minutes.
|
||||
}
|
||||
}
|
||||
|
||||
async function start(scope?: AiturkLoginScope): Promise<AiturkLoginStartResult> {
|
||||
if (finishing) {
|
||||
throw new AiturkLoginError('busy')
|
||||
}
|
||||
|
||||
// One login at a time: a new start replaces (and revokes) the previous one.
|
||||
const previous = session
|
||||
session = null
|
||||
settled = null
|
||||
dropHeldCli()
|
||||
|
||||
if (previous) {
|
||||
await cancelOnServer(previous.deviceCode)
|
||||
}
|
||||
|
||||
let response: AiturkFetchResponse | undefined
|
||||
|
||||
// A long-idle process can fail its first request on a dead pooled
|
||||
// connection. Only a THROWN network error (no HTTP answer, so no code was
|
||||
// handed to us) is tried once more; an HTTP answer is never retried, so
|
||||
// one start cannot knowingly leave two codes behind. A timeout already
|
||||
// used its whole budget and is not retried.
|
||||
for (let attempt = 1; response === undefined; attempt++) {
|
||||
try {
|
||||
response = await post('/v1/aiturk/device-auth/start', null, {
|
||||
client: 'ide',
|
||||
device_name: deviceName(deps.hostname()),
|
||||
also_cli: true
|
||||
})
|
||||
} catch (error) {
|
||||
const name = (error as null | { name?: unknown })?.name
|
||||
|
||||
if (attempt >= 2 || name === 'TimeoutError' || name === 'AbortError') {
|
||||
throw new AiturkLoginError('unavailable')
|
||||
}
|
||||
|
||||
await new Promise<void>(resolve => void setTimer(resolve, AITURK_LOGIN_START_RETRY_DELAY_MS))
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
throw new AiturkLoginError('rate-limited')
|
||||
}
|
||||
|
||||
if (response.status !== 201 && response.status !== 200) {
|
||||
throw new AiturkLoginError('unavailable')
|
||||
}
|
||||
|
||||
let data: any
|
||||
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch {
|
||||
throw new AiturkLoginError('invalid-response')
|
||||
}
|
||||
|
||||
const deviceCode = typeof data?.deviceCode === 'string' ? data.deviceCode : ''
|
||||
const userCode = typeof data?.userCode === 'string' ? data.userCode : ''
|
||||
const verificationUri = typeof data?.verificationUri === 'string' ? data.verificationUri : ''
|
||||
const expiresIn = Number(data?.expiresIn)
|
||||
const codeOk = DEVICE_CODE_RE.test(deviceCode)
|
||||
|
||||
if (
|
||||
!codeOk ||
|
||||
!USER_CODE_RE.test(userCode) ||
|
||||
!VERIFICATION_URI_RE.test(verificationUri) ||
|
||||
!Number.isFinite(expiresIn) ||
|
||||
expiresIn <= 0
|
||||
) {
|
||||
// Never open a URL that is not exactly our approval page.
|
||||
if (codeOk) {
|
||||
await cancelOnServer(deviceCode)
|
||||
}
|
||||
|
||||
throw new AiturkLoginError('invalid-response')
|
||||
}
|
||||
|
||||
if (session || finishing) {
|
||||
// A concurrent start won the race while we were waiting on the network.
|
||||
await cancelOnServer(deviceCode)
|
||||
|
||||
throw new AiturkLoginError('busy')
|
||||
}
|
||||
|
||||
const expiresAt = deps.now() + Math.min(expiresIn, MAX_EXPIRES_IN_S) * 1000
|
||||
session = { deviceCode, userCode, verificationUri, expiresAt, scope: cleanScope(scope) }
|
||||
|
||||
let opened: unknown
|
||||
|
||||
try {
|
||||
opened = await deps.openExternal(verificationUri)
|
||||
} catch {
|
||||
opened = false
|
||||
}
|
||||
|
||||
if (opened === false) {
|
||||
session = null
|
||||
await cancelOnServer(deviceCode)
|
||||
|
||||
throw new AiturkLoginError('browser-failed')
|
||||
}
|
||||
|
||||
return { userCode, verificationUri, expiresAt }
|
||||
}
|
||||
|
||||
async function saveProvider(apiKey: string, scope: Session['scope']) {
|
||||
const endpoint = {
|
||||
id: AITURK_PROVIDER_ID,
|
||||
name: AITURK_PROVIDER_NAME,
|
||||
base_url: AITURK_PROVIDER_BASE_URL,
|
||||
api_key: apiKey,
|
||||
discover_models: true,
|
||||
make_default: true
|
||||
}
|
||||
|
||||
let checked: any
|
||||
|
||||
try {
|
||||
// Mirrors the renderer's validateCustomEndpoint (no profile scope) and
|
||||
// saveCustomEndpoint (profile-scoped) calls.
|
||||
checked = await deps.backendRequest({
|
||||
...(scope.connectionId ? { connectionId: scope.connectionId } : {}),
|
||||
path: '/api/providers/custom-endpoints/validate',
|
||||
method: 'POST',
|
||||
body: { ...endpoint, model: '' }
|
||||
})
|
||||
} catch {
|
||||
throw new AiturkLoginError('provider-failed')
|
||||
}
|
||||
|
||||
if (!checked?.ok) {
|
||||
throw new AiturkLoginError('provider-failed')
|
||||
}
|
||||
|
||||
const models = Array.isArray(checked.models) ? checked.models.filter((m: unknown) => typeof m === 'string' && m) : []
|
||||
|
||||
if (!models.length) {
|
||||
throw new AiturkLoginError('no-models')
|
||||
}
|
||||
|
||||
let saved: any
|
||||
|
||||
try {
|
||||
saved = await deps.backendRequest({
|
||||
...scope,
|
||||
path: '/api/providers/custom-endpoints',
|
||||
method: 'POST',
|
||||
body: { ...endpoint, model: models[0], models }
|
||||
})
|
||||
} catch {
|
||||
throw new AiturkLoginError('provider-failed')
|
||||
}
|
||||
|
||||
if (saved?.ok === false) {
|
||||
throw new AiturkLoginError('provider-failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function handCliKey(cliKey: string, keepOtherAccount: boolean): Promise<AiturkCliResult> {
|
||||
let exit: AiturkCliExit
|
||||
|
||||
try {
|
||||
exit = await deps.runCli(cliKey, { keepOtherAccount })
|
||||
} catch {
|
||||
exit = null
|
||||
}
|
||||
|
||||
return cliResultFromExit(exit, keepOtherAccount)
|
||||
}
|
||||
|
||||
async function finish(key: string, cliKey: string, scope: Session['scope']): Promise<AiturkLoginPollResult> {
|
||||
try {
|
||||
await saveProvider(key, scope)
|
||||
} catch (error) {
|
||||
return { state: 'error', error: error instanceof AiturkLoginError ? error.code : 'provider-failed' }
|
||||
}
|
||||
|
||||
let cli: AiturkCliResult = 'failed'
|
||||
|
||||
if (cliKey) {
|
||||
cli = await handCliKey(cliKey, true)
|
||||
|
||||
if (cli === 'other-account') {
|
||||
holdCli(cliKey)
|
||||
}
|
||||
}
|
||||
|
||||
return { state: 'connected', cli }
|
||||
}
|
||||
|
||||
async function poll(): Promise<AiturkLoginPollResult> {
|
||||
if (finishing) {
|
||||
return finishing
|
||||
}
|
||||
|
||||
if (settled) {
|
||||
return settled
|
||||
}
|
||||
|
||||
const current = session
|
||||
|
||||
if (!current) {
|
||||
return { state: 'expired' }
|
||||
}
|
||||
|
||||
if (deps.now() >= current.expiresAt) {
|
||||
session = null
|
||||
|
||||
return { state: 'expired' }
|
||||
}
|
||||
|
||||
let response: AiturkFetchResponse
|
||||
|
||||
try {
|
||||
response = await post('/v1/aiturk/device-auth/poll', current.deviceCode)
|
||||
} catch {
|
||||
// Transient network trouble: keep waiting until the code expires.
|
||||
return { state: 'pending' }
|
||||
}
|
||||
|
||||
if (session !== current) {
|
||||
// Cancelled or replaced while the request was in flight.
|
||||
return settled ?? (finishing ? finishing : { state: 'expired' })
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
session = null
|
||||
|
||||
return { state: 'expired' }
|
||||
}
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { state: 'pending' }
|
||||
}
|
||||
|
||||
let data: any
|
||||
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch {
|
||||
return { state: 'pending' }
|
||||
}
|
||||
|
||||
if (session !== current) {
|
||||
return settled ?? (finishing ? finishing : { state: 'expired' })
|
||||
}
|
||||
|
||||
if (data?.state !== 'approved') {
|
||||
return { state: 'pending' }
|
||||
}
|
||||
|
||||
// The server returns the keys exactly once; the device code is spent.
|
||||
session = null
|
||||
|
||||
const key = typeof data.key === 'string' && API_KEY_RE.test(data.key) ? data.key : ''
|
||||
const cliKey = typeof data.cliKey === 'string' && API_KEY_RE.test(data.cliKey) ? data.cliKey : ''
|
||||
|
||||
if (!key) {
|
||||
settled = { state: 'error', error: 'invalid-response' }
|
||||
|
||||
return settled
|
||||
}
|
||||
|
||||
finishing = finish(key, cliKey, current.scope)
|
||||
.catch((): AiturkLoginPollResult => ({ state: 'error', error: 'provider-failed' }))
|
||||
.then(result => {
|
||||
settled = result
|
||||
finishing = null
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
return finishing
|
||||
}
|
||||
|
||||
async function cancel(): Promise<{ ok: true }> {
|
||||
const current = session
|
||||
session = null
|
||||
dropHeldCli()
|
||||
|
||||
if (!finishing) {
|
||||
settled = null
|
||||
}
|
||||
|
||||
if (current) {
|
||||
await cancelOnServer(current.deviceCode)
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/** User confirmed: replace the other account stored in the AITURK CLI. */
|
||||
async function overwriteCli(): Promise<{ cli: AiturkCliResult }> {
|
||||
const held = heldCli
|
||||
|
||||
if (!held || deps.now() >= held.expiresAt) {
|
||||
dropHeldCli()
|
||||
|
||||
throw new AiturkLoginError('nothing-to-overwrite')
|
||||
}
|
||||
|
||||
// Single use: drop it before launching so it cannot be replayed.
|
||||
dropHeldCli()
|
||||
|
||||
const cli = await handCliKey(held.key, false)
|
||||
|
||||
if (settled?.state === 'connected') {
|
||||
settled = { state: 'connected', cli }
|
||||
}
|
||||
|
||||
return { cli }
|
||||
}
|
||||
|
||||
return { start, poll, cancel, overwriteCli }
|
||||
}
|
||||
|
||||
// ── CLI runner ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AiturkCliRunnerDeps {
|
||||
/** child_process.spawn (injected for tests). */
|
||||
spawn: (command: string, args: string[], options: Record<string, unknown>) => any
|
||||
existsSync: (path: string) => boolean
|
||||
env: Record<string, string | undefined>
|
||||
platform?: string
|
||||
timeoutMs?: number
|
||||
setTimer?: (fn: () => void, ms: number) => unknown
|
||||
clearTimer?: (handle: unknown) => void
|
||||
}
|
||||
|
||||
// cmd.exe expands %VAR% and treats " specially even inside quotes; a path that
|
||||
// carries either (or a control char) is refused rather than risk reparsing.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const UNSAFE_CMD_PATH_RE = /["%^!\u0000-\u001f]/
|
||||
const WINDOWS_ABSOLUTE_RE = /^[A-Za-z]:\\/
|
||||
|
||||
/** `%LOCALAPPDATA%\TurkServis\AITURK\program\bin\aiturk.cmd`, or null. */
|
||||
export function resolveAiturkCliPath(deps: Pick<AiturkCliRunnerDeps, 'env' | 'existsSync' | 'platform'>): null | string {
|
||||
if ((deps.platform ?? process.platform) !== 'win32') {
|
||||
return null
|
||||
}
|
||||
|
||||
const localAppData = String(deps.env.LOCALAPPDATA || '').replace(/[\\/]+$/, '')
|
||||
|
||||
if (!WINDOWS_ABSOLUTE_RE.test(localAppData)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidate = `${localAppData}\\TurkServis\\AITURK\\program\\bin\\aiturk.cmd`
|
||||
|
||||
try {
|
||||
return deps.existsSync(candidate) ? candidate : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the CLI runner used by createAiturkLogin. The key goes to the child's
|
||||
* stdin and nowhere else: the argument list is a fixed array with no user
|
||||
* input, there is no `shell: true`, and stdout/stderr are discarded.
|
||||
*/
|
||||
export function createAiturkCliRunner(deps: AiturkCliRunnerDeps) {
|
||||
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
|
||||
const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as ReturnType<typeof setTimeout>))
|
||||
const timeoutMs = deps.timeoutMs ?? AITURK_CLI_TIMEOUT_MS
|
||||
|
||||
return function runCli(cliKey: string, options: { keepOtherAccount: boolean }): Promise<AiturkCliExit> {
|
||||
const cliPath = resolveAiturkCliPath(deps)
|
||||
|
||||
if (!cliPath) {
|
||||
return Promise.resolve('missing')
|
||||
}
|
||||
|
||||
const systemRoot = String(deps.env.SystemRoot || deps.env.SYSTEMROOT || 'C:\\Windows').replace(/[\\/]+$/, '')
|
||||
|
||||
if (UNSAFE_CMD_PATH_RE.test(cliPath) || !WINDOWS_ABSOLUTE_RE.test(systemRoot) || UNSAFE_CMD_PATH_RE.test(systemRoot)) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
const flags = options.keepOtherAccount ? 'login --key-stdin --keep-other-account' : 'login --key-stdin'
|
||||
|
||||
// A .cmd cannot be spawned directly without a shell; run it through an
|
||||
// absolute cmd.exe. With /s, cmd strips the outer quote pair and runs the
|
||||
// rest verbatim, so the (validated, quoted) path survives spaces.
|
||||
const args = ['/d', '/s', '/c', `""${cliPath}" ${flags}"`]
|
||||
|
||||
return new Promise(resolve => {
|
||||
let done = false
|
||||
let timer: unknown = null
|
||||
let child: any
|
||||
|
||||
const settle = (value: AiturkCliExit) => {
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
|
||||
done = true
|
||||
|
||||
if (timer !== null) {
|
||||
clearTimer(timer)
|
||||
}
|
||||
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
try {
|
||||
child = deps.spawn(`${systemRoot}\\System32\\cmd.exe`, args, {
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
windowsVerbatimArguments: true,
|
||||
stdio: ['pipe', 'ignore', 'ignore']
|
||||
})
|
||||
} catch {
|
||||
settle(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
timer = setTimer(() => {
|
||||
try {
|
||||
child.kill()
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
|
||||
settle(null)
|
||||
}, timeoutMs)
|
||||
|
||||
child.once('error', () => settle(null))
|
||||
child.once('close', (code: null | number) => settle(typeof code === 'number' ? code : null))
|
||||
|
||||
try {
|
||||
// A CLI that exits before reading stdin raises EPIPE here; ignore it,
|
||||
// the exit code is the answer.
|
||||
child.stdin.on('error', () => {})
|
||||
child.stdin.end(`${cliKey}\n`)
|
||||
} catch {
|
||||
// Same as above.
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export const AITURK_PRODUCT = Object.freeze({
|
||||
protocol: 'aiturk-ide',
|
||||
website: 'https://turkservis.online',
|
||||
downloads: 'https://turkservis.online/ide',
|
||||
repository: 'https://gitea.twinpay.one/yilsem/aiturk-hermes-ide',
|
||||
repository: 'https://gitea.turkservis.online/yilsem/aiturk-hermes-ide',
|
||||
apiBaseUrl: 'https://ai.turkservis.online/v1'
|
||||
})
|
||||
|
||||
|
||||
@@ -234,7 +234,7 @@ function downloadInstallScript(ref, destPath) {
|
||||
// placeholder is a real GitHub commit.
|
||||
const scriptName = installScriptName()
|
||||
const refType = isPinnedCommit(ref) ? 'commit' : 'branch'
|
||||
const url = `https://gitea.twinpay.one/yilsem/aiturk-hermes-ide/raw/${refType}/${encodeURIComponent(ref)}/scripts/${scriptName}`
|
||||
const url = `https://gitea.turkservis.online/yilsem/aiturk-hermes-ide/raw/${refType}/${encodeURIComponent(ref)}/scripts/${scriptName}`
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
||||
|
||||
@@ -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: 8, dongu_art_arda: 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 = '8'
|
||||
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: 8, dongu_art_arda: 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,402 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import path from 'node:path'
|
||||
|
||||
import { CODEX_BRIDGE_START_BUDGET_MS, type CodexLinkDeps, type CodexLinkPaths, createCodexLink } from './codex-link'
|
||||
import {
|
||||
CODEX_BRIDGE_VERSION,
|
||||
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 = path.join(PATHS.bridgeHome, 'bridge.json')
|
||||
const STATUS_OK = { ok: true, surum: CODEX_BRIDGE_VERSION, 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 = 45_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
|
||||
}
|
||||
}
|
||||
@@ -131,3 +131,37 @@ test('remote apply without a waiter has no first-run side effects', async () =>
|
||||
assert.equal(hidden, 0)
|
||||
assert.equal(gate.isLocalBootstrapConfirmed(), true)
|
||||
})
|
||||
|
||||
test('a completed shared runtime wakes only its own pending setup, without granting installation consent', async () => {
|
||||
let hidden = 0
|
||||
const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 })
|
||||
const pending = gate.wait(bootstrapBackend)
|
||||
assert.equal(gate.runtimeAvailable('/tmp/unrelated-runtime'), false)
|
||||
assert.equal(await settledState(pending), 'pending')
|
||||
assert.equal(gate.runtimeAvailable(bootstrapBackend.activeRoot), true)
|
||||
assert.equal(await pending, 'runtime-ready')
|
||||
assert.equal(gate.isLocalBootstrapConfirmed(), false)
|
||||
assert.equal(hidden, 1)
|
||||
assert.equal(gate.runtimeAvailable(bootstrapBackend.activeRoot), false)
|
||||
})
|
||||
|
||||
test('late runtime readiness does not revive a reset or remotely resolved waiter', async () => {
|
||||
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
|
||||
const pending = gate.wait(bootstrapBackend)
|
||||
gate.resetForRetry()
|
||||
assert.equal(await pending, 'reset')
|
||||
assert.equal(gate.runtimeAvailable(bootstrapBackend.activeRoot), false)
|
||||
})
|
||||
|
||||
test('runtime completed before wait does not leave a stale setup prompt pending', async () => {
|
||||
let prompts = 0
|
||||
const gate = createFirstRunSetupGate({
|
||||
isRuntimeAvailable: backend => backend.activeRoot === bootstrapBackend.activeRoot,
|
||||
promptChoice: () => prompts++,
|
||||
stuckAfterMs: 0
|
||||
})
|
||||
assert.equal(await gate.wait(bootstrapBackend), 'runtime-ready')
|
||||
assert.equal(gate.hasWaiter(), false)
|
||||
assert.equal(gate.isLocalBootstrapConfirmed(), false)
|
||||
assert.equal(prompts, 0)
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import path from 'node:path'
|
||||
|
||||
interface FirstRunSetupBackend {
|
||||
activeRoot?: string
|
||||
kind?: string
|
||||
@@ -5,6 +7,7 @@ interface FirstRunSetupBackend {
|
||||
}
|
||||
|
||||
interface FirstRunSetupGateOptions {
|
||||
isRuntimeAvailable?: (backend: FirstRunSetupBackend) => boolean
|
||||
hideChoice?: () => void
|
||||
log?: (message: string) => void
|
||||
onStuck?: (backend: FirstRunSetupBackend, stuckAfterMs: number) => void
|
||||
@@ -12,9 +15,10 @@ interface FirstRunSetupGateOptions {
|
||||
stuckAfterMs?: number
|
||||
}
|
||||
|
||||
export type FirstRunSetupDecision = 'continue-local' | 'remote-applied' | 'reset'
|
||||
export type FirstRunSetupDecision = 'continue-local' | 'remote-applied' | 'reset' | 'runtime-ready'
|
||||
|
||||
export function createFirstRunSetupGate({
|
||||
isRuntimeAvailable,
|
||||
hideChoice,
|
||||
log,
|
||||
onStuck,
|
||||
@@ -24,6 +28,7 @@ export function createFirstRunSetupGate({
|
||||
let localBootstrapConfirmed = false
|
||||
|
||||
let waiter: {
|
||||
activeRoot?: string
|
||||
promise: Promise<FirstRunSetupDecision>
|
||||
resolve: (decision: FirstRunSetupDecision) => void
|
||||
} | null = null
|
||||
@@ -65,6 +70,13 @@ export function createFirstRunSetupGate({
|
||||
return 'continue-local' as const
|
||||
}
|
||||
|
||||
// Recheck synchronously before registering the waiter: another profile
|
||||
// may have completed installation before this stale descriptor arrived.
|
||||
if (backend && isRuntimeAvailable?.(backend)) {
|
||||
if (backend.activeRoot) runtimeAvailable(backend.activeRoot)
|
||||
return 'runtime-ready' as const
|
||||
}
|
||||
|
||||
if (waiter) {
|
||||
return waiter.promise
|
||||
}
|
||||
@@ -78,7 +90,7 @@ export function createFirstRunSetupGate({
|
||||
resolveWaiter = resolve
|
||||
})
|
||||
|
||||
waiter = { promise, resolve: resolveWaiter }
|
||||
waiter = { activeRoot: backend?.activeRoot, promise, resolve: resolveWaiter }
|
||||
|
||||
return promise
|
||||
}
|
||||
@@ -102,6 +114,21 @@ export function createFirstRunSetupGate({
|
||||
settleWaiter('continue-local')
|
||||
}
|
||||
|
||||
// A sibling profile may finish installing the SAME shared runtime while the
|
||||
// primary boot is awaiting a setup choice. Re-resolve it; do not interpret
|
||||
// this as permission for another installation, or reuse the sibling's
|
||||
// profile/backend descriptor.
|
||||
const runtimeAvailable = (root: string) => {
|
||||
const canonical = (value: string) => {
|
||||
const absolute = path.resolve(value)
|
||||
return process.platform === 'win32' ? absolute.toLowerCase() : absolute
|
||||
}
|
||||
if (!waiter?.activeRoot || canonical(waiter.activeRoot) !== canonical(root)) return false
|
||||
settleWaiter('runtime-ready')
|
||||
hideChoice?.()
|
||||
return true
|
||||
}
|
||||
|
||||
const resetForRetry = () => {
|
||||
// Reset paths are followed by a renderer reload / fresh startHermes() call.
|
||||
// Settle the old boot explicitly so it cannot fall through into local
|
||||
@@ -140,6 +167,7 @@ export function createFirstRunSetupGate({
|
||||
isLocalBootstrapConfirmed,
|
||||
resetForRepair,
|
||||
resetForRetry,
|
||||
runtimeAvailable,
|
||||
shouldGate,
|
||||
wait
|
||||
}
|
||||
|
||||
+250
-13
@@ -1,4 +1,4 @@
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { execFile, execFileSync, spawn } from 'node:child_process'
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import http from 'node:http'
|
||||
@@ -9,6 +9,7 @@ import tls from 'node:tls'
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url'
|
||||
import { startChromeBridge } from './chrome-bridge.mjs'
|
||||
import { createMobileWindow } from './mobile-host.mjs'
|
||||
import { MobileVault } from './mobile-secrets.mjs'
|
||||
|
||||
import {
|
||||
app,
|
||||
@@ -31,8 +32,20 @@ import {
|
||||
shell,
|
||||
systemPreferences
|
||||
} from 'electron'
|
||||
import { WebSocket as RelayWebSocket } from 'ws'
|
||||
|
||||
import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state'
|
||||
import { createAiturkCliRunner, createAiturkLogin } from './aiturk-login'
|
||||
import { createCodexLink, createNodeCodexLinkDeps } from './codex-link'
|
||||
import { createMobileBackendResolver } from './mobile-backend'
|
||||
import {
|
||||
createMobileLink,
|
||||
createMobileLinkFileStore,
|
||||
createWindowsFirewall,
|
||||
defaultRouteAddress,
|
||||
type MobileLink,
|
||||
selectLanAddresses
|
||||
} from './mobile-lan-proxy'
|
||||
import { describeVcRuntimeResult, ensureWindowsVcRuntime } from './windows-vc-runtime'
|
||||
import { destroyKeepaliveAgents, downloadAgentFor, jsonAgentFor, withRetry } from './api-transport'
|
||||
import { appIconCandidates, resolveAppIcon } from './app-icon'
|
||||
@@ -2064,6 +2077,10 @@ function hideFirstRunSetupChoice() {
|
||||
function getFirstRunSetupGate() {
|
||||
if (!firstRunSetupGate) {
|
||||
firstRunSetupGate = createFirstRunSetupGate({
|
||||
isRuntimeAvailable: backend =>
|
||||
backend.activeRoot === ACTIVE_HERMES_ROOT &&
|
||||
resolveHermesBackend(['--profile', primaryProfileKey(), 'serve', '--host', '127.0.0.1', '--port', '0']).kind !==
|
||||
'bootstrap-needed',
|
||||
hideChoice: hideFirstRunSetupChoice,
|
||||
log: rememberLog,
|
||||
onStuck: (_backend, stuckAfterMs) => {
|
||||
@@ -5248,6 +5265,11 @@ async function ensureRuntime(backend) {
|
||||
error: null
|
||||
})
|
||||
|
||||
// Runtime installation is shared; profile processes and their data are not.
|
||||
// Wake only a setup waiter for this exact installed root, then let primary
|
||||
// startup resolve its own profile again (never adopt a pool connection).
|
||||
firstRunSetupGate?.runtimeAvailable(ACTIVE_HERMES_ROOT)
|
||||
|
||||
return backend
|
||||
}
|
||||
|
||||
@@ -6784,7 +6806,7 @@ function buildApplicationMenu() {
|
||||
|
||||
const checkForUpdatesItem = {
|
||||
label: 'Check for Updates…',
|
||||
click: () => IS_PACKAGED ? void shell.openExternal(AITURK_PRODUCT.downloads) : sendOpenUpdatesRequested()
|
||||
click: () => (IS_PACKAGED ? void shell.openExternal(AITURK_PRODUCT.downloads) : sendOpenUpdatesRequested())
|
||||
}
|
||||
|
||||
if (IS_MAC) {
|
||||
@@ -13070,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',
|
||||
@@ -17161,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
|
||||
@@ -17231,6 +17322,53 @@ ipcMain.handle('hermes:openExternal', (_event, url) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── AITURK one-click sign-in (device code) ───────────────────────────────
|
||||
// The API keys returned by turkservis.online stay in THIS process: the
|
||||
// provider is saved through the same backend route hermes:api uses and the
|
||||
// CLI key is piped to `aiturk login --key-stdin`. The renderer only ever sees
|
||||
// the short user code, the approval URL and a status. See aiturk-login.ts.
|
||||
const aiturkLogin = createAiturkLogin({
|
||||
fetch: (url, init) => fetch(url, init),
|
||||
openExternal: url => openExternalUrl(url),
|
||||
hostname: () => os.hostname(),
|
||||
backendRequest: request => handleHermesApiRequest(request),
|
||||
runCli: createAiturkCliRunner({ spawn, existsSync: fs.existsSync, env: process.env }),
|
||||
now: () => Date.now()
|
||||
})
|
||||
|
||||
// Only the primary window's top-level frame may drive a sign-in.
|
||||
function assertAiturkLoginSender(event) {
|
||||
const contents = mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null
|
||||
|
||||
if (!contents || event.sender !== contents || (event.senderFrame && event.senderFrame !== contents.mainFrame)) {
|
||||
throw new Error('aiturk-login:unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('aiturk:login:start', async (event, scope) => {
|
||||
assertAiturkLoginSender(event)
|
||||
|
||||
return aiturkLogin.start(scope)
|
||||
})
|
||||
|
||||
ipcMain.handle('aiturk:login:poll', async event => {
|
||||
assertAiturkLoginSender(event)
|
||||
|
||||
return aiturkLogin.poll()
|
||||
})
|
||||
|
||||
ipcMain.handle('aiturk:login:cancel', async event => {
|
||||
assertAiturkLoginSender(event)
|
||||
|
||||
return aiturkLogin.cancel()
|
||||
})
|
||||
|
||||
ipcMain.handle('aiturk:login:overwrite-cli', async event => {
|
||||
assertAiturkLoginSender(event)
|
||||
|
||||
return aiturkLogin.overwriteCli()
|
||||
})
|
||||
|
||||
// ── Find-in-page (Ctrl/Cmd+F) ─────────────────────────────────────────────
|
||||
// The desktop supports multiple BrowserWindows (one primary plus any
|
||||
// per-session secondary windows spawned via `hermes:window:openSession`).
|
||||
@@ -17588,8 +17726,11 @@ async function getUninstallSummary() {
|
||||
async function runDesktopUninstall(_mode) {
|
||||
// The upstream cleanup script removes Hermes-specific shortcuts, registry
|
||||
// entries and global launchers. AITURK is owned by its OS package manager.
|
||||
return { ok: false, error: 'aiturk-os-uninstaller',
|
||||
message: 'AITURK IDE işletim sisteminin uygulama yöneticisinden kaldırılır.' }
|
||||
return {
|
||||
ok: false,
|
||||
error: 'aiturk-os-uninstaller',
|
||||
message: 'AITURK IDE işletim sisteminin uygulama yöneticisinden kaldırılır.'
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:uninstall:summary', async () => getUninstallSummary())
|
||||
@@ -17770,19 +17911,110 @@ function openMobileConnectionWindow() {
|
||||
return
|
||||
}
|
||||
mobileConnectionWindow = createMobileWindow({
|
||||
directory: path.join(app.getPath('userData'), 'aiturk-mobile'),
|
||||
assets: path.dirname(fileURLToPath(import.meta.url)),
|
||||
resolveBackend: async () => {
|
||||
const profile = primaryProfileKey()
|
||||
if (globalRemoteActive() || profileHasRemoteOverride(profile)) throw new Error('Mobil bağlantı için yerel IDE bağlantısını seçin.')
|
||||
return ensureBackend(profile)
|
||||
}
|
||||
})
|
||||
assets: path.dirname(fileURLToPath(import.meta.url)),
|
||||
link: getMobileLink()
|
||||
})
|
||||
}
|
||||
|
||||
// "Telefon bağlantısı" (electron/mobile-lan-proxy.ts): TLS LAN proxy in front of
|
||||
// the loopback backend + pairing. One controller for the whole app run; it only
|
||||
// listens while the feature is on and a phone is paired or being paired.
|
||||
let mobileLink: MobileLink | null = null
|
||||
|
||||
function mobileLinkRemoteMode() {
|
||||
return globalRemoteActive() || profileHasRemoteOverride(primaryProfileKey())
|
||||
}
|
||||
|
||||
function getMobileLink(): MobileLink {
|
||||
if (mobileLink) {
|
||||
return mobileLink
|
||||
}
|
||||
|
||||
const directory = path.join(app.getPath('userData'), 'aiturk-mobile', 'mobile')
|
||||
|
||||
mobileLink = createMobileLink({
|
||||
// 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),
|
||||
resolveBackend: createMobileBackendResolver({
|
||||
isRemote: profile => mobileLinkRemoteMode() || profileHasRemoteOverride(profile),
|
||||
runtimeNeedsInstall: profile =>
|
||||
resolveHermesBackend(['--profile', profile, 'serve', '--host', '127.0.0.1', '--port', '0']).kind ===
|
||||
'bootstrap-needed',
|
||||
ensureBackend
|
||||
}),
|
||||
isRemoteMode: mobileLinkRemoteMode,
|
||||
firewall: createWindowsFirewall({
|
||||
spawn,
|
||||
platform: process.platform,
|
||||
env: process.env,
|
||||
// Read-only rule query (fixed arguments, no shell, no elevation). netsh answers in the
|
||||
// console code page; latin1 keeps every byte and the parser needs no translated word.
|
||||
run: (file, args) =>
|
||||
new Promise(resolve => {
|
||||
execFile(
|
||||
file,
|
||||
args,
|
||||
{ windowsHide: true, timeout: 8000, encoding: 'latin1', maxBuffer: 256 * 1024 },
|
||||
(error, stdout) => {
|
||||
const code = error
|
||||
? typeof (error as { code?: unknown }).code === 'number'
|
||||
? ((error as { code?: unknown }).code as number)
|
||||
: null
|
||||
: 0
|
||||
|
||||
resolve({ code, stdout: String(stdout ?? '') })
|
||||
}
|
||||
)
|
||||
})
|
||||
}),
|
||||
fetch: (url, init) => fetch(url, init as RequestInit),
|
||||
// Outbound relay socket (turkservis.online) and the pinned loopback sockets to our own proxy.
|
||||
WebSocket: RelayWebSocket,
|
||||
addresses: async () => selectLanAddresses(os.networkInterfaces(), await defaultRouteAddress()),
|
||||
version: app.getVersion(),
|
||||
pcName: os.hostname(),
|
||||
platform: process.platform,
|
||||
// Method + path + status only (no token, code, query or body). Successful
|
||||
// API traffic is not logged; pairing and refusals are, for diagnosis. Relay
|
||||
// lines are status changes only ("relay connected", "relay failed:<code>").
|
||||
log: line => {
|
||||
if (/^(?:relay|pair) /.test(line) || / \/aiturk\//.test(line) || / [45]\d\d$/.test(line)) {
|
||||
rememberLog(`[mobile-lan] ${line}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return mobileLink
|
||||
}
|
||||
|
||||
// Only the primary window's top-level frame may open the phone window.
|
||||
ipcMain.handle('aiturk:mobile:open', async event => {
|
||||
assertAiturkLoginSender(event)
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error('aiturk-mobile:unsupported')
|
||||
}
|
||||
|
||||
openMobileConnectionWindow()
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
app.on('will-quit', () => {
|
||||
void mobileLink?.stop().catch(() => undefined)
|
||||
})
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// Explicit development/beta entry point; never enables remote access on boot.
|
||||
if (process.platform === 'win32' && process.argv.includes('--aiturk-mobile')) openMobileConnectionWindow()
|
||||
// Resume the phone link only when the user turned it on earlier AND a phone
|
||||
// is paired; otherwise nothing listens (and no vault / UAC prompt is touched).
|
||||
if (process.platform === 'win32') {
|
||||
void getMobileLink()
|
||||
.start()
|
||||
.catch(error => rememberLog(`[mobile-lan] start failed: ${error instanceof Error ? error.message : 'error'}`))
|
||||
}
|
||||
if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
|
||||
void startChromeBridge({
|
||||
directory: path.join(process.env.LOCALAPPDATA, 'TurkServis', 'AITURK-IDE'),
|
||||
@@ -17793,7 +18025,12 @@ app.whenReady().then(() => {
|
||||
}
|
||||
return ensureBackend(profile)
|
||||
}
|
||||
}).then(stop => app.once('will-quit', () => { void stop() }))
|
||||
})
|
||||
.then(stop =>
|
||||
app.once('will-quit', () => {
|
||||
void stop()
|
||||
})
|
||||
)
|
||||
.catch(() => rememberLog('[chrome] Native bridge could not start.'))
|
||||
}
|
||||
// Warm the login-shell PATH resolution immediately so it usually completes
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
|
||||
import { createMobileBackendResolver } from './mobile-backend'
|
||||
|
||||
const local = { mode: 'local', baseUrl: 'http://127.0.0.1:12345', token: 'test-session' }
|
||||
|
||||
it('uses the phone profile even while another desktop profile is awaiting setup', async () => {
|
||||
const ensureBackend = vi.fn(async (profile: string) => {
|
||||
if (profile === 'fusion-frontier') return new Promise<typeof local>(() => {})
|
||||
return local
|
||||
})
|
||||
const resolve = createMobileBackendResolver({
|
||||
isRemote: () => false,
|
||||
runtimeNeedsInstall: () => false,
|
||||
ensureBackend
|
||||
})
|
||||
expect(await resolve('default')).toEqual({ baseUrl: local.baseUrl, token: local.token })
|
||||
expect(ensureBackend).toHaveBeenCalledExactlyOnceWith('default')
|
||||
})
|
||||
|
||||
it('never bootstraps an absent runtime or silently replaces a remote profile with default', async () => {
|
||||
const ensureBackend = vi.fn(async () => local)
|
||||
const resolve = createMobileBackendResolver({
|
||||
isRemote: profile => profile === 'remote',
|
||||
runtimeNeedsInstall: () => true,
|
||||
ensureBackend
|
||||
})
|
||||
await expect(resolve('default')).rejects.toThrow('runtime_not_ready')
|
||||
await expect(resolve('remote')).rejects.toThrow('remote_mode')
|
||||
expect(ensureBackend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves an explicitly selected local profile and rejects remote-mode changes during resolution', async () => {
|
||||
let remote = false
|
||||
const ensureBackend = vi.fn(async () => local)
|
||||
const resolve = createMobileBackendResolver({
|
||||
isRemote: () => remote,
|
||||
runtimeNeedsInstall: () => false,
|
||||
ensureBackend
|
||||
})
|
||||
await resolve('kodlama')
|
||||
expect(ensureBackend).toHaveBeenCalledExactlyOnceWith('kodlama')
|
||||
ensureBackend.mockImplementationOnce(async () => {
|
||||
remote = true
|
||||
return local
|
||||
})
|
||||
await expect(resolve('kodlama')).rejects.toThrow('remote_mode')
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Mobile routing is explicit and cannot start a first-run installer. */
|
||||
export function createMobileBackendResolver<Connection extends { mode: string; baseUrl: string; token: string }>({
|
||||
isRemote,
|
||||
runtimeNeedsInstall,
|
||||
ensureBackend
|
||||
}: {
|
||||
isRemote: (profile: string) => boolean
|
||||
runtimeNeedsInstall: (profile: string) => boolean
|
||||
ensureBackend: (profile: string) => Promise<Connection>
|
||||
}) {
|
||||
return async (profile: string) => {
|
||||
if (isRemote(profile)) throw new Error('aiturk-mobile:remote_mode')
|
||||
if (runtimeNeedsInstall(profile)) throw new Error('aiturk-mobile:runtime_not_ready')
|
||||
const connection = await ensureBackend(profile)
|
||||
if (connection.mode !== 'local' || isRemote(profile)) throw new Error('aiturk-mobile:remote_mode')
|
||||
return { baseUrl: connection.baseUrl, token: connection.token }
|
||||
}
|
||||
}
|
||||
@@ -1,94 +1,169 @@
|
||||
import { completeIce, rtcConfiguration, sendFrame } from './mobile/transport.mjs'
|
||||
import { drawQr, InviteScanner } from './mobile/qr.mjs'
|
||||
import { createDeviceIdentity, loadDeviceIdentity, assertRemoteIdentity, parsePhoneIdentity } from './mobile/peer-identity.mjs'
|
||||
const host = window.mobileHost, $ = id => document.getElementById(id), peers = new Map()
|
||||
const status = text => { $('status').textContent = text }
|
||||
function action(id, work) { $(id).onclick = async () => { $(id).disabled = true; try { await work() } catch (error) { status(error.message) } finally { $(id).disabled = false } } }
|
||||
const identityScanner = new InviteScanner(document.createElement('video'), async raw => {
|
||||
if (raw.length > 700) throw new Error('Telefon kimlik belgesi çok büyük.')
|
||||
const value = JSON.parse(raw)
|
||||
const pairingId = $('pairing').value.trim() || value.pairingId
|
||||
parsePhoneIdentity(raw, pairingId)
|
||||
if (!/^[0-9a-f-]{36}$/.test(pairingId)) throw new Error('Eşleştirme kimliği geçersiz.')
|
||||
$('pairing').value = pairingId; $('phone-identity').value = raw
|
||||
status('Telefon kimliği QR resminden alındı. Telefondaki kısa kodu girin ve bu telefonu ayrıca onaylayın; henüz izin verilmedi.')
|
||||
})
|
||||
$('phone-identity-file').onchange = async event => {
|
||||
try { await identityScanner.image(event.target.files[0]) }
|
||||
catch (error) { status(error.message) }
|
||||
finally { event.target.value = '' }
|
||||
// "Telefon bağlantısı" window UI. Talks to electron/mobile-host.mjs over the
|
||||
// `mobileHost` bridge (mobile-preload.cjs); all logic lives in the main process.
|
||||
/* global window, document */
|
||||
import { drawQr } from './mobile/qr.mjs'
|
||||
|
||||
const TEXT = {
|
||||
tr: {
|
||||
eyebrow: 'AITURK · BİLGİSAYAR', title: 'Telefon bağlantısı',
|
||||
intro: 'Telefonunuz internetin olduğu her yerden bu bilgisayardaki IDE’ye bağlanır; aynı Wi-Fi gerekmez. Bağlantı uçtan uca şifrelidir. Bilgisayar açık ve AITURK IDE çalışıyor olmalı.',
|
||||
toggle: 'Telefon bağlantısını kullan',
|
||||
remote: 'IDE şu an uzak bir ağ geçidine bağlı. Telefon bağlantısı yalnız yerel IDE bağlantısıyla çalışır; Ayarlar’dan yerel bağlantıyı seçin.',
|
||||
relayLabel: 'İnternet üzerinden bağlantı', lanLabel: 'Aynı Wi-Fi’da doğrudan bağlantı',
|
||||
relay: { connected: 'hazır', connecting: 'bağlanıyor…', failed: 'kurulamadı (yeniden denenecek)', idle: 'beklemede (telefon eklenince bağlanır)',
|
||||
replaced: 'başka bir AITURK IDE aynı kaydı kullanıyor (30 sn sonra yeniden denenecek)', busy: 'servis dolu (yeniden denenecek)' },
|
||||
lanOpen: port => `açık (bağlantı noktası ${port})`, lanOff: 'kapalı', lanIdle: 'izin verildi (telefon eklenince açılır)',
|
||||
lanNoRule: 'Windows Güvenlik Duvarı izni yok', lanDenied: 'kapalı (Windows izin penceresi reddedildi)', lanFailed: 'kapalı (Güvenlik Duvarı kuralı eklenemedi)',
|
||||
lanAllow: 'Aynı Wi-Fi’da doğrudan bağlantıya da izin ver', allow: 'İzin ver', lanDisallow: 'Doğrudan bağlantıyı kapat',
|
||||
reissued: 'Röle kimliği yenilendi. Daha önce eklenen telefonları yeniden eşleştirin: telefonda bu bilgisayarı kaldırıp “Telefon ekle” ile yeniden bağlayın.',
|
||||
addPhone: 'Telefon ekle', cancel: 'Vazgeç',
|
||||
scan: 'Telefonda AITURK Mobil → “Bilgisayara bağlan” → bu QR kodunu okutun.',
|
||||
orCode: 'ya da telefonda şu kodu yazın:',
|
||||
codePending: 'Kodla bağlanma hazırlanıyor…', codeReady: 'Kodla bağlanma hazır.',
|
||||
codeUnavailable: 'Kodla bağlanma şu an kullanılamıyor; QR kodunu okutun.', codeRetry: 'Yeniden dene',
|
||||
securityCode: code => `Güvenlik kodu: ${code} (telefonda da aynı kod görünmeli)`,
|
||||
anyNetwork: 'Telefon ve bilgisayar aynı ağda olmak zorunda değil.', sameWifi: 'Telefon ve bilgisayar aynı Wi-Fi ağında olmalı.',
|
||||
countdown: (m, s) => `Kod ${m}:${s} sonra geçersiz olur`, expired: 'Kodun süresi doldu. Yeniden “Telefon ekle” seçin.',
|
||||
devices: 'Bağlı telefonlar', noDevices: 'Henüz bağlı telefon yok.',
|
||||
added: 'Eklenme', lastSeen: 'Son görülme', online: 'şu an bağlı', remove: 'Kaldır',
|
||||
removed: name => `${name} kaldırıldı.`, paired: name => `${name} bağlandı.`,
|
||||
removeRule: 'Güvenlik Duvarı kuralını kaldır', ruleRemoved: 'Güvenlik Duvarı kuralı kaldırıldı.',
|
||||
renew: 'Röle kimliğini yenile', renewed: 'Röle kimliği yenilendi. Telefonları yeniden eşleştirin.',
|
||||
renewConfirm: 'Röle kimliği yenilenirse bağlı telefonların hepsi kaldırılır ve yeniden eşleştirilmeleri gerekir. Devam edilsin mi?',
|
||||
errors: {
|
||||
remote_mode: 'IDE uzak ağ geçidine bağlıyken telefon bağlantısı açılamaz.',
|
||||
vault_unavailable: 'Windows güvenli anahtar kasası kullanılamıyor; telefon bağlantısı açılamadı.',
|
||||
not_enabled: 'Önce telefon bağlantısını açın.',
|
||||
no_lan_address: 'Bu bilgisayar bir yerel ağa (Wi-Fi ya da kablo) bağlı görünmüyor ve internet üzerinden bağlantı da kurulamadı.',
|
||||
relay_unavailable: 'İnternete ulaşılamıyor, yeniden deneyin.',
|
||||
port_unavailable: 'Boş bağlantı noktası bulunamadı (47321–47340 kullanımda).',
|
||||
failed: 'İşlem tamamlanamadı. Yeniden deneyin.',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
eyebrow: 'AITURK · COMPUTER', title: 'Phone connection',
|
||||
intro: 'Your phone connects to the IDE on this computer from anywhere with internet access; the same Wi-Fi is not needed. The connection is end-to-end encrypted. The computer must be on and AITURK IDE must be running.',
|
||||
toggle: 'Use phone connection',
|
||||
remote: 'The IDE is connected to a remote gateway. Phone connection only works with the local IDE connection; choose the local connection in Settings.',
|
||||
relayLabel: 'Connection over the internet', lanLabel: 'Direct connection on the same Wi-Fi',
|
||||
relay: { connected: 'ready', connecting: 'connecting…', failed: 'could not be set up (will retry)', idle: 'waiting (connects once a phone is added)',
|
||||
replaced: 'another AITURK IDE is using the same registration (will retry in 30 s)', busy: 'service is full (will retry)' },
|
||||
lanOpen: port => `on (port ${port})`, lanOff: 'off', lanIdle: 'allowed (opens once a phone is added)',
|
||||
lanNoRule: 'no Windows Firewall permission', lanDenied: 'off (the Windows permission prompt was declined)', lanFailed: 'off (the Firewall rule could not be added)',
|
||||
lanAllow: 'Also allow a direct connection on the same Wi-Fi', allow: 'Allow', lanDisallow: 'Turn direct connection off',
|
||||
reissued: 'The relay identity was renewed. Pair the phones added earlier again: remove this computer on the phone and connect with “Add a phone”.',
|
||||
addPhone: 'Add a phone', cancel: 'Cancel',
|
||||
scan: 'On the phone: AITURK Mobile → “Connect to computer” → scan this QR code.',
|
||||
orCode: 'or type this code on the phone:',
|
||||
codePending: 'Preparing code sign-in…', codeReady: 'Connecting with the code is ready.',
|
||||
codeUnavailable: 'Connecting with the code is unavailable right now; scan the QR code.', codeRetry: 'Try again',
|
||||
securityCode: code => `Security code: ${code} (the phone must show the same code)`,
|
||||
anyNetwork: 'The phone and the computer do not have to be on the same network.', sameWifi: 'The phone and the computer must be on the same Wi-Fi network.',
|
||||
countdown: (m, s) => `The code expires in ${m}:${s}`, expired: 'The code expired. Choose “Add a phone” again.',
|
||||
devices: 'Connected phones', noDevices: 'No phone is connected yet.',
|
||||
added: 'Added', lastSeen: 'Last seen', online: 'connected now', remove: 'Remove',
|
||||
removed: name => `${name} was removed.`, paired: name => `${name} connected.`,
|
||||
removeRule: 'Remove the Firewall rule', ruleRemoved: 'The Firewall rule was removed.',
|
||||
renew: 'Renew the relay identity', renewed: 'The relay identity was renewed. Pair the phones again.',
|
||||
renewConfirm: 'Renewing the relay identity removes every connected phone; they must be paired again. Continue?',
|
||||
errors: {
|
||||
remote_mode: 'Phone connection cannot be turned on while the IDE uses a remote gateway.',
|
||||
vault_unavailable: 'The Windows secure key store is unavailable; phone connection could not be turned on.',
|
||||
not_enabled: 'Turn phone connection on first.',
|
||||
no_lan_address: 'This computer does not seem to be on a local network (Wi-Fi or cable) and the internet connection could not be set up either.',
|
||||
relay_unavailable: 'The internet cannot be reached, try again.',
|
||||
port_unavailable: 'No free port was found (47321–47340 are in use).',
|
||||
failed: 'That did not work. Try again.',
|
||||
},
|
||||
},
|
||||
}
|
||||
action('login-start', async () => { const result = await host.call('account.login.start'); $('login-code').textContent = `Açılan hesap sayfasında yalnız bu kodu onaylayın: ${result.code}` })
|
||||
action('login-check', async () => { const result = await host.call('account.login.check'); status(result.approved ? 'Hesaba giriş yapıldı. Bu bilgisayarı eşleştirebilirsiniz.' : 'Tarayıcıdaki kod onayı bekleniyor.') })
|
||||
action('logout', async () => { await host.call('account.logout'); listening = false; $('login-code').textContent = ''; status('Mobil oturum kapatıldı; hesap ve Google anahtarı bu bilgisayar kasasından temizlendi.') })
|
||||
async function desktopIdentity(create = false) {
|
||||
const setup = await host.call('setup')
|
||||
if (!setup.deviceId) throw new Error('Önce bu bilgisayarı hesapla eşleştirin.')
|
||||
if (setup.localFingerprint) return loadDeviceIdentity(setup.deviceId, setup.localFingerprint)
|
||||
if (!create) throw new Error('Önce bu bilgisayarda yeni telefon QR kodu oluşturun.')
|
||||
const identity = await createDeviceIdentity(setup.deviceId)
|
||||
await host.call('identity.save', { deviceId: setup.deviceId, fingerprint: identity.fingerprint })
|
||||
return identity
|
||||
const lang = (navigator.language || '').toLowerCase().startsWith('en') ? 'en' : 'tr'
|
||||
const t = TEXT[lang], host = window.mobileHost, $ = id => document.getElementById(id)
|
||||
const when = value => new Date(value).toLocaleString(lang === 'tr' ? 'tr-TR' : 'en-GB', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
document.documentElement.lang = lang; document.title = `AITURK · ${t.title}`
|
||||
for (const [id, key] of [['t-eyebrow', 'eyebrow'], ['t-title', 'title'], ['t-intro', 'intro'], ['t-remote', 'remote'], ['t-scan', 'scan'],
|
||||
['t-or-code', 'orCode'], ['t-devices', 'devices'], ['devices-empty', 'noDevices'], ['t-enabled', 'toggle'], ['t-relay-label', 'relayLabel'],
|
||||
['t-lan-label', 'lanLabel'], ['t-reissued', 'reissued'], ['lan-disallow', 'lanDisallow'], ['relay-renew', 'renew'],
|
||||
['pair-begin', 'addPhone'], ['pair-cancel', 'cancel'], ['pair-republish', 'codeRetry'], ['firewall-remove', 'removeRule']]) $(id).textContent = t[key]
|
||||
|
||||
let current = null, drawnQr = '', busy = false
|
||||
function notice(text) { $('notice').textContent = text || ''; $('notice').hidden = !text }
|
||||
const errorText = error => t.errors[/aiturk-mobile:([a-z_]+)/.exec(String(error?.message))?.[1]] || t.errors.failed
|
||||
|
||||
function tick() {
|
||||
const pairing = current?.pairing
|
||||
if (!pairing) return
|
||||
const left = Math.max(0, Math.ceil((pairing.expiresAt - Date.now()) / 1000))
|
||||
$('pair-countdown').textContent = t.countdown(Math.floor(left / 60), String(left % 60).padStart(2, '0'))
|
||||
if (left === 0) { notice(t.expired); void refresh() }
|
||||
}
|
||||
action('invite', async () => {
|
||||
const identity = await desktopIdentity(true), invite = await host.call('invite.create')
|
||||
drawQr($('invite-qr'), JSON.stringify({ ...invite, version: 2, desktopFingerprint: identity.fingerprint }))
|
||||
$('invite-qr').hidden = false
|
||||
$('invite-status').textContent = `Telefonda aynı hesaba giriş yapıp bu QR kodunu tarayın. Son geçerlilik: ${invite.expiresAt}`
|
||||
})
|
||||
action('invite-check', async () => { const result = await host.call('invite.check'); if (!result.pending) { status('QR ile gelen telefon henüz yok.'); return }; $('pairing').value = result.pending.pairingId; status(`${result.pending.name} bulundu. Telefondaki kısa kodu girip “Bu telefonu onayla” seçin.`) })
|
||||
action('save', async () => { const token = $('account').value; $('account').value = ''; await host.call('account.save', { token }); status('Hesap anahtarı Windows kasasına kaydedildi.') })
|
||||
action('pair', async () => { const pair = await host.call('desktop.pair'); $('code').textContent = `Açılan hesap sayfasında bu kodu onaylayın: ${pair.code}. Süre: ${pair.expiresAt}` })
|
||||
action('approve', async () => { await host.call('phone.approve', { pairingId: $('pairing').value.trim(), code: $('phone-code').value.trim().toUpperCase(), identity: $('phone-identity').value.trim() }); $('phone-code').value = ''; $('phone-identity').value = ''; status('Telefon bu bilgisayarda kimliğiyle onaylandı.') })
|
||||
action('refresh', async () => {
|
||||
const data = await host.call('devices'); $('devices').replaceChildren()
|
||||
for (const device of data.devices) {
|
||||
const row = document.createElement('p'), label = document.createElement('span'), button = document.createElement('button')
|
||||
label.textContent = `${device.name} · ${device.platform}${device.self ? ' · Bu bilgisayar' : ''}`
|
||||
button.textContent = 'Cihazı iptal et'
|
||||
button.onclick = async () => { button.disabled = true; try { await host.call('device.revoke', { deviceId: device.id, version: device.version }); row.remove(); status('Cihaz iptal edildi.') } catch (error) { status(error.message); button.disabled = false } }
|
||||
row.append(label, button); $('devices').append(row)
|
||||
}
|
||||
})
|
||||
function close(id) { const entry = peers.get(id); if (!entry) return; peers.delete(id); clearInterval(entry.timer); entry.pc.close(); void host.call('channel.close', { channelId: id }) }
|
||||
host.onEvent(event => { if (event.close) { close(event.channelId); return }; const entry = peers.get(event.channelId); if (entry?.channel?.readyState === 'open') sendFrame(entry.channel, event.frame) })
|
||||
async function connect(offer) {
|
||||
assertRemoteIdentity(offer.sdp, offer.trustedPhoneFingerprint)
|
||||
const ice = await host.call('ice')
|
||||
const identity = await desktopIdentity()
|
||||
const pc = new RTCPeerConnection({ ...rtcConfiguration(ice.iceServers), certificates: [identity.certificate] }), entry = { pc }; peers.set(offer.id, entry)
|
||||
pc.ondatachannel = event => {
|
||||
const channel = event.channel; entry.channel = channel
|
||||
if (channel.label !== 'aiturk-mobile-v1') { close(offer.id); return }
|
||||
let pending = 0
|
||||
channel.onmessage = async event => {
|
||||
if (typeof event.data !== 'string' || event.data.length > 65000 || ++pending > 8) { close(offer.id); return }
|
||||
let message
|
||||
try {
|
||||
message = JSON.parse(event.data)
|
||||
const result = await host.call('channel.request', { channelId: offer.id, message })
|
||||
if (channel.readyState === 'open') sendFrame(channel, { id: message.id, result })
|
||||
} catch (error) { if (message?.id && channel.readyState === 'open') channel.send(JSON.stringify({ id: message.id, error: error.message })) }
|
||||
finally { pending-- }
|
||||
}
|
||||
channel.onclose = () => close(offer.id)
|
||||
channel.onopen = () => status('Telefon şifreli veri kanalıyla bağlı.')
|
||||
}
|
||||
try {
|
||||
await pc.setRemoteDescription({ type: 'offer', sdp: offer.sdp })
|
||||
await pc.setLocalDescription(await pc.createAnswer()); await completeIce(pc)
|
||||
await host.call('channel.answer', { channelId: offer.id, sdp: pc.localDescription.sdp })
|
||||
entry.timer = setInterval(() => { void host.call('channel.heartbeat', { channelId: offer.id }).catch(() => close(offer.id)) }, 10000)
|
||||
} catch (error) { close(offer.id); throw error }
|
||||
|
||||
function render(state) {
|
||||
current = state
|
||||
$('enabled').checked = state.enabled
|
||||
$('remote-box').hidden = !state.remote
|
||||
// Status rows say what is REALLY up right now: the relay socket, the LAN socket, the Windows rule as queried.
|
||||
const relay = state.relay || { status: 'off' }, lan = state.lan || { direct: false, listening: false, port: null, firewall: 'unknown' }
|
||||
$('status-box').hidden = !state.enabled || state.remote
|
||||
const relayKind = relay.status === 'failed' && t.relay[relay.code] ? relay.code : relay.status === 'off' ? 'idle' : relay.status
|
||||
$('relay-status').textContent = t.relay[relayKind]; $('relay-status').dataset.state = relay.status
|
||||
$('reissued-box').hidden = !(state.enabled && relay.reissued)
|
||||
const noRule = lan.direct && lan.firewall === 'missing'
|
||||
$('lan-status').textContent = lan.listening ? t.lanOpen(lan.port) : noRule ? t.lanNoRule : lan.direct ? t.lanIdle
|
||||
: state.firewall === 'denied' ? t.lanDenied : state.firewall === 'failed' ? t.lanFailed : t.lanOff
|
||||
$('lan-status').dataset.state = lan.listening ? 'connected' : noRule ? 'failed' : 'off'
|
||||
$('lan-allow').hidden = lan.direct && !noRule; $('lan-allow').textContent = noRule ? t.allow : t.lanAllow
|
||||
$('lan-disallow').hidden = !lan.direct
|
||||
$('pair-section').hidden = !state.enabled || state.remote
|
||||
$('pair-begin').hidden = Boolean(state.pairing); $('pair-live').hidden = !state.pairing
|
||||
if (state.pairing) {
|
||||
if (drawnQr !== state.pairing.qr) { drawQr($('pair-qr'), state.pairing.qr); drawnQr = state.pairing.qr }
|
||||
$('pair-qr').setAttribute('aria-label', t.scan)
|
||||
$('pair-code').textContent = `${state.pairing.code.slice(0, 4)}-${state.pairing.code.slice(4)}`
|
||||
$('pair-code-path').textContent = { pending: t.codePending, ready: t.codeReady, unavailable: t.codeUnavailable }[state.pairing.codePath]
|
||||
// Offered only once every background try is over; while they run the line above says "hazırlanıyor…".
|
||||
$('pair-republish').hidden = state.pairing.codePath !== 'unavailable'
|
||||
$('pair-security').textContent = state.pairing.securityCode ? t.securityCode(state.pairing.securityCode) : ''
|
||||
$('t-same-wifi').textContent = /[?&]r=/.test(state.pairing.qr) ? t.anyNetwork : t.sameWifi
|
||||
tick()
|
||||
} else { drawnQr = ''; $('pair-code').textContent = '' }
|
||||
$('devices-section').hidden = !state.enabled && state.devices.length === 0
|
||||
$('devices-empty').hidden = state.devices.length > 0
|
||||
$('devices').replaceChildren(...state.devices.map(device => {
|
||||
const row = document.createElement('div'), label = document.createElement('div'), name = document.createElement('strong')
|
||||
const added = document.createElement('small'), seen = document.createElement('small'), button = document.createElement('button')
|
||||
row.className = 'device'; name.textContent = device.name
|
||||
added.textContent = `${t.added}: ${when(device.createdAt)}`
|
||||
seen.textContent = device.live > 0 ? t.online : `${t.lastSeen}: ${when(device.lastSeenAt)}`
|
||||
button.textContent = t.remove; button.className = 'danger'
|
||||
button.onclick = () => run(async () => { render(await host.call('device.revoke', { deviceId: device.id })); notice(t.removed(device.name)) })
|
||||
label.append(name, added, seen); row.append(label, button)
|
||||
return row
|
||||
}))
|
||||
$('footer').hidden = !state.enabled || state.remote
|
||||
$('relay-renew').hidden = !relay.ready
|
||||
$('firewall-remove').hidden = lan.firewall !== 'allowed' || state.firewall === 'not-needed'
|
||||
}
|
||||
let listening = false
|
||||
action('listen', async () => {
|
||||
if (listening) return
|
||||
listening = true; status('Onaylı telefon bağlantısı bekleniyor.')
|
||||
while (listening) {
|
||||
try { const data = await host.call('inbox'); for (const offer of data.channels) if (!peers.has(offer.id)) await connect(offer) }
|
||||
catch (error) { status(error.message); listening = false; break }
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
}
|
||||
})
|
||||
window.addEventListener('pagehide', () => { listening = false; for (const id of peers.keys()) close(id) })
|
||||
|
||||
async function run(work) {
|
||||
if (busy) return
|
||||
busy = true; document.body.setAttribute('aria-busy', 'true')
|
||||
for (const button of document.querySelectorAll('button,input')) button.disabled = true
|
||||
try { await work() } catch (error) { notice(errorText(error)); await refresh() }
|
||||
finally { busy = false; document.body.removeAttribute('aria-busy'); for (const button of document.querySelectorAll('button,input')) button.disabled = false }
|
||||
}
|
||||
async function refresh() { try { render(await host.call('state')) } catch (error) { notice(errorText(error)) } }
|
||||
|
||||
$('enabled').onchange = () => { const on = $('enabled').checked; void run(async () => { notice(''); render(await host.call(on ? 'enable' : 'disable')) }) }
|
||||
$('pair-begin').onclick = () => run(async () => { notice(''); render(await host.call('pair.begin')) })
|
||||
$('pair-cancel').onclick = () => run(async () => { render(await host.call('pair.cancel')) })
|
||||
$('pair-republish').onclick = () => run(async () => { render(await host.call('pair.republish')) })
|
||||
$('lan-allow').onclick = () => run(async () => { notice(''); render(await host.call('lan.allow')) })
|
||||
$('lan-disallow').onclick = () => run(async () => { notice(''); render(await host.call('lan.disallow')) })
|
||||
$('relay-renew').onclick = () => { if (window.confirm(t.renewConfirm)) void run(async () => { render(await host.call('relay.renew')); notice(t.renewed) }) }
|
||||
$('firewall-remove').onclick = () => run(async () => { render(await host.call('firewall.remove')); notice(t.ruleRemoved) })
|
||||
host.onEvent(event => { if (!event?.state) return; render(event.state); if (event.type === 'paired') notice(t.paired(event.name)) })
|
||||
setInterval(tick, 1000)
|
||||
// "Last seen" and the live marker change without an event; keep the list fresh while the window is open.
|
||||
setInterval(() => { if (!busy && current?.enabled) void refresh() }, 15000)
|
||||
void refresh()
|
||||
@@ -0,0 +1,27 @@
|
||||
/* "Telefon bağlantısı" window; builds on apps/mobile/src/style.css (copied to dist/mobile/). */
|
||||
.switch { display:flex; align-items:center; gap:10px; margin:22px 0; font-size:1.1rem; cursor:pointer }
|
||||
.switch input { width:26px; height:26px; margin:0; flex:none }
|
||||
.warning { border-left:3px solid var(--danger); background:var(--surface); padding:10px 14px; margin:16px 0 }
|
||||
.warning p { color:var(--text); margin:4px 0 }
|
||||
.code { font-family:ui-monospace,Consolas,monospace; font-size:2.6rem; font-weight:750; letter-spacing:.12em; color:var(--text); text-align:center; margin:10px 0; user-select:all }
|
||||
.countdown { text-align:center; color:var(--text); font-weight:600 }
|
||||
.hint { text-align:center }
|
||||
#pair-qr { display:block; margin:14px auto; border-radius:8px; width:260px; height:260px }
|
||||
#pair-live p { text-align:center }
|
||||
.device { display:flex; align-items:center; justify-content:space-between; gap:12px; border:1px solid var(--stroke); border-radius:8px; padding:10px 14px; margin:10px 0 }
|
||||
.device strong { color:var(--text); display:block; overflow-wrap:anywhere }
|
||||
.device small { display:block }
|
||||
.device button { flex:none; margin:0 }
|
||||
footer { margin-top:34px }
|
||||
button.link { background:none; color:var(--accent); min-height:0; padding:0; margin:0; font-weight:600; text-decoration:underline }
|
||||
.status-row { display:flex; flex-wrap:wrap; align-items:baseline; justify-content:space-between; gap:4px 12px; border-bottom:1px solid var(--stroke); padding:10px 0 }
|
||||
.status-row strong { color:var(--text); overflow-wrap:anywhere }
|
||||
.status-row strong::before { content:''; display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:8px; background:var(--stroke) }
|
||||
.status-row strong[data-state=connected]::before { background:var(--accent) }
|
||||
.status-row strong[data-state=failed]::before { background:var(--danger) }
|
||||
.status-actions { display:flex; flex-wrap:wrap; align-items:center; gap:12px; margin:12px 0 }
|
||||
.status-actions button { margin:0; min-height:44px }
|
||||
.security { font-family:ui-monospace,Consolas,monospace; color:var(--text); overflow-wrap:anywhere }
|
||||
footer { display:flex; flex-wrap:wrap; gap:8px 20px }
|
||||
footer button.link { min-height:44px }
|
||||
#pair-republish { display:block; min-height:44px; margin:0 auto; padding:0 12px }
|
||||
@@ -1,24 +1,34 @@
|
||||
<!doctype html><html lang="tr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'">
|
||||
<title>AITURK Mobil bağlantısı</title><link rel="stylesheet" href="mobile/style.css"></head><body><main>
|
||||
<p class="eyebrow">AITURK · BİLGİSAYAR</p><h1>Telefonunu bağla.</h1>
|
||||
<p>Bu pencere açıkken eşleştirdiğiniz Android telefon seçtiğiniz IDE konuşmasına erişebilir. Google anahtarı Windows güvenli kasasında tutulur.</p>
|
||||
<p id="status" role="status" aria-live="polite">Bağlantı kapalı.</p>
|
||||
<button id="login-start">AITURK hesabımla giriş yap</button><p id="login-code"></p><button id="login-check">Girişi kontrol et</button>
|
||||
<p>Çıkış, mobil bağlantıları kapatır ve bu penceredeki hesap/Google anahtarını Windows kasasından temizler; başlamış IDE işini durdurmaz.</p>
|
||||
<button id="logout">Mobil oturumdan çık ve kasayı temizle</button>
|
||||
<details><summary>Gelişmiş: hesap API anahtarı</summary>
|
||||
<label>AITURK hesap API anahtarı<input id="account" type="password" autocomplete="off"></label>
|
||||
<button id="save">Hesap anahtarını kaydet</button></details><button id="pair">Bu bilgisayarı hesabımla eşleştir</button>
|
||||
<p id="code"></p><hr>
|
||||
<button id="invite">Telefon için QR oluştur</button><canvas id="invite-qr" hidden aria-label="Süreli telefon eşleştirme QR kodu"></canvas>
|
||||
<p id="invite-status"></p><button id="invite-check">QR ile gelen telefonu getir</button>
|
||||
<h2>Telefona izin ver</h2><p>Telefondaki eşleştirme kimliğini ve 8 karakterli kodu buraya girin. Bu onay yalnız bu telefon ile bu bilgisayarı bağlar.</p>
|
||||
<label>Eşleştirme kimliği<input id="pairing"></label><label>Telefondaki kod<input id="phone-code" maxlength="8" autocapitalize="characters"></label>
|
||||
<label>Telefondan alınan kimlik belgesi<textarea id="phone-identity" maxlength="700"></textarea></label>
|
||||
<label>Telefon kimliği QR resmi<input id="phone-identity-file" type="file" accept="image/*"></label>
|
||||
<p>Yalnız kendi telefonunuzun gösterdiği belgenin tamamını buraya aktarın. Onay, bu telefonun sertifikasını bilgisayarınıza kaydeder; farklı sertifika yeniden açık eşleştirme gerektirir.</p>
|
||||
<button id="approve">Bu telefonu onayla</button><button id="listen">Mobil bağlantıyı aç</button>
|
||||
<h2>Cihazlar</h2><button id="refresh">Cihaz listesini yenile</button><div id="devices"></div>
|
||||
<p>Farklı ağlarda bağlantı kurulması ağınıza ve TURN hizmeti yapılandırmasına bağlıdır. Bu geliştirme sürümünde genel ağ erişimi henüz doğrulanmadı.</p>
|
||||
</main><script src="mobile/vendor/jsQR.js"></script><script type="module" src="mobile-host-ui.mjs"></script></body></html>
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'none'">
|
||||
<title>AITURK · Telefon bağlantısı</title><link rel="stylesheet" href="mobile/style.css"><link rel="stylesheet" href="mobile-host.css"></head><body><main>
|
||||
<p class="eyebrow" id="t-eyebrow">AITURK · BİLGİSAYAR</p><h1 id="t-title">Telefon bağlantısı</h1>
|
||||
<p id="t-intro"></p>
|
||||
<label class="switch"><input id="enabled" type="checkbox"><span id="t-enabled"></span></label>
|
||||
<p id="notice" class="status" role="status" aria-live="polite" hidden></p>
|
||||
<section id="remote-box" class="warning" hidden><p id="t-remote"></p></section>
|
||||
<section id="status-box" hidden>
|
||||
<div class="status-row"><span id="t-relay-label"></span><strong id="relay-status" role="status" aria-live="polite"></strong></div>
|
||||
<div class="status-row"><span id="t-lan-label"></span><strong id="lan-status" role="status" aria-live="polite"></strong></div>
|
||||
<div class="status-actions"><button id="lan-allow" class="secondary"></button><button id="lan-disallow" class="link" hidden></button></div>
|
||||
</section>
|
||||
<section id="reissued-box" class="warning" hidden><p id="t-reissued"></p></section>
|
||||
<section id="pair-section" hidden>
|
||||
<button id="pair-begin"></button>
|
||||
<div id="pair-live" hidden>
|
||||
<p id="t-scan"></p>
|
||||
<canvas id="pair-qr" role="img"></canvas>
|
||||
<p id="t-or-code"></p>
|
||||
<p id="pair-code" class="code" aria-live="off"></p>
|
||||
<p id="pair-code-path"></p>
|
||||
<button id="pair-republish" class="link" hidden></button>
|
||||
<p id="pair-countdown" class="countdown"></p>
|
||||
<p id="pair-security" class="security"></p>
|
||||
<p id="t-same-wifi" class="hint"></p>
|
||||
<button id="pair-cancel" class="secondary"></button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="devices-section" hidden>
|
||||
<h2 id="t-devices"></h2><p id="devices-empty"></p><div id="devices"></div>
|
||||
</section>
|
||||
<footer id="footer" hidden><button id="relay-renew" class="link" hidden></button> <button id="firewall-remove" class="link" hidden></button></footer>
|
||||
</main><script type="module" src="mobile-host-ui.mjs"></script></body></html>
|
||||
@@ -1,157 +1,52 @@
|
||||
// "Telefon bağlantısı" window: a thin IPC shell around the controller built in
|
||||
// electron/mobile-lan-proxy.ts (createMobileLink). The controller lives in the
|
||||
// main process for the whole app run — closing this window only cancels a
|
||||
// pairing in progress; paired phones stay connected.
|
||||
import { join } from 'node:path'
|
||||
import { hostname } from 'node:os'
|
||||
import { BrowserWindow, ipcMain, safeStorage, shell } from 'electron'
|
||||
import { MobileVault, LiveTokenIssuer } from './mobile-secrets.mjs'
|
||||
import { DeliveryJournal, MobileGatewayClient } from './mobile-adapter.mjs'
|
||||
import { AccountClient } from '../../mobile/src/transport.mjs'
|
||||
import { normalizeFingerprint, parsePhoneIdentity, assertRemoteIdentity } from '../../mobile/src/peer-identity.mjs'
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
|
||||
export function createMobileWindow({ directory, assets, resolveBackend, show = true }) {
|
||||
const vault = new MobileVault(join(directory, 'mobile'), safeStorage)
|
||||
const journal = new DeliveryJournal(join(directory, 'mobile', 'deliveries'))
|
||||
const issuer = new LiveTokenIssuer(vault)
|
||||
const clients = new Map()
|
||||
const window = new BrowserWindow({ width: 560, height: 800, show, title: 'AITURK Mobil bağlantısı',
|
||||
const CODED = /^aiturk-mobile:[a-z_]+$/
|
||||
|
||||
export function createMobileWindow({ assets, link, show = true }) {
|
||||
const window = new BrowserWindow({ width: 560, height: 800, show, title: 'AITURK · Telefon bağlantısı',
|
||||
webPreferences: { preload: join(assets, 'mobile-preload.cjs'), contextIsolation: true,
|
||||
nodeIntegration: false, sandbox: true, backgroundThrottling: false } })
|
||||
nodeIntegration: false, sandbox: true } })
|
||||
window.setMenu(null)
|
||||
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
window.webContents.on('will-navigate', event => event.preventDefault())
|
||||
const api = async () => new AccountClient(await vault.load())
|
||||
const call = async (path, body, method) => (await api()).call(path, body, method)
|
||||
const send = frame => { if (!window.isDestroyed()) window.webContents.send('aiturk-mobile-event', frame) }
|
||||
const handler = async (event, action, params = {}) => {
|
||||
if (event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) throw new Error('Geçersiz mobil pencere.')
|
||||
if (action === 'setup') {
|
||||
const secrets = await vault.load()
|
||||
return { paired: Boolean(secrets.deviceId), hasAccount: Boolean(secrets.accountToken), hasGoogle: Boolean(secrets.googleKey),
|
||||
deviceId: secrets.deviceId, localFingerprint: secrets.localFingerprint }
|
||||
}
|
||||
if (action === 'identity.save') {
|
||||
const previous = await vault.load(), fingerprint = normalizeFingerprint(params.fingerprint)
|
||||
if (!previous.deviceId || previous.deviceId !== params.deviceId ||
|
||||
(previous.localFingerprint && previous.localFingerprint !== fingerprint))
|
||||
throw new Error('Cihaz kimliği sessizce değiştirilemez. Yeniden eşleştirin.')
|
||||
await vault.save({ ...previous, localFingerprint: fingerprint })
|
||||
return { saved: true }
|
||||
}
|
||||
if (action === 'account.save') {
|
||||
if (typeof params.token !== 'string' || params.token.length < 12 || params.token.length > 512) throw new Error('AITURK hesap anahtarı geçersiz.')
|
||||
if (clients.size) throw new Error('Hesap değiştirmeden önce mobil bağlantıyı kapatın.')
|
||||
const previous = await vault.load()
|
||||
if (previous.accountToken && previous.accountToken !== params.token) throw new Error('Hesabı değiştirmek için önce mevcut cihazı iptal edin ve bağlantı penceresini yeniden açın.')
|
||||
await vault.save({ ...previous, accountToken: params.token })
|
||||
return { saved: true }
|
||||
}
|
||||
if (action === 'account.login.start') {
|
||||
const previous = await vault.load()
|
||||
if (previous.accountToken || clients.size) throw new Error('Kayıtlı hesap var. Önce mevcut cihazı iptal edin.')
|
||||
const login = await new AccountClient({}).call('/v1/aiturk/mobile-auth/start', {})
|
||||
if (!/^https:\/\/turkservis\.online\/account\/aiturk\/mobile-login\/[0-9a-f-]{36}$/.test(login.verificationUri)) throw new Error('Geçersiz giriş adresi.')
|
||||
await vault.save({ ...previous, pendingLogin: login })
|
||||
await shell.openExternal(login.verificationUri)
|
||||
return { code: login.userCode }
|
||||
}
|
||||
if (action === 'account.login.check') {
|
||||
const previous = await vault.load()
|
||||
if (previous.accountToken || clients.size) throw new Error('Mevcut hesap veya bağlantı sessizce değiştirilemez.')
|
||||
if (!previous.pendingLogin) throw new Error('Önce giriş başlatın.')
|
||||
const result = await new AccountClient({ accountToken: previous.pendingLogin.deviceCode }).call('/v1/aiturk/mobile-auth/poll', {})
|
||||
if (result.state !== 'approved') return { approved: false }
|
||||
delete previous.pendingLogin
|
||||
await vault.save({ ...previous, accountToken: result.accountToken })
|
||||
return { approved: true }
|
||||
}
|
||||
if (action === 'account.logout') {
|
||||
const previous = await vault.load(), token = previous.accountToken || previous.pendingLogin?.deviceCode
|
||||
if (token?.startsWith('tsml_')) await new AccountClient({ accountToken: token }).call('/v1/aiturk/mobile-auth/logout', {})
|
||||
for (const [id, entry] of clients) { entry.client.close(); send({ channelId: id, close: true }) }
|
||||
clients.clear(); await vault.save({})
|
||||
return { cleared: true }
|
||||
}
|
||||
if (action === 'desktop.pair') {
|
||||
const result = await call('/v1/aiturk/pairings', { display_name: hostname(), platform: 'windows-x64' })
|
||||
await vault.save({ ...await vault.load(), deviceId: result.device_id, deviceToken: result.device_token,
|
||||
pairingId: result.pairing_id })
|
||||
await shell.openExternal('https://turkservis.online' + result.verification_uri)
|
||||
return { code: result.user_code, expiresAt: result.expires_at }
|
||||
}
|
||||
if (action === 'phone.approve') {
|
||||
if (!/^[0-9a-f-]{36}$/.test(params.pairingId) || !/^[A-Z2-9]{8}$/.test(params.code)) throw new Error('Telefondaki eşleştirme bilgileri geçersiz.')
|
||||
const identity = parsePhoneIdentity(params.identity, params.pairingId)
|
||||
const previous = await vault.load(), existing = previous.phoneFingerprints?.[identity.deviceId]
|
||||
if (existing && existing !== identity.fingerprint) throw new Error('Kayıtlı telefon kimliği değiştirilemez. Cihazı iptal edip yeniden eşleştirin.')
|
||||
const result = await call(`/v1/aiturk/pairings/${params.pairingId}/approve`, { user_code: params.code })
|
||||
if (result.device_id !== identity.deviceId || result.status !== 'approved') throw new Error('Onaylanan telefon kimlik belgesiyle eşleşmiyor; bağlantı açılmadı.')
|
||||
const current = await vault.load()
|
||||
if (current.deviceId !== previous.deviceId || current.accountToken !== previous.accountToken) throw new Error('Onay sırasında hesap değişti; yeniden eşleştirin.')
|
||||
await vault.save({ ...current, phoneFingerprints: { ...current.phoneFingerprints, [identity.deviceId]: identity.fingerprint } })
|
||||
return result
|
||||
}
|
||||
if (action === 'devices') return call('/v1/aiturk/mobile/devices')
|
||||
if (action === 'invite.create') return call('/v1/aiturk/mobile/invites', {})
|
||||
if (action === 'invite.check') return call('/v1/aiturk/mobile/invites')
|
||||
if (action === 'device.revoke') {
|
||||
if (!/^[0-9a-f-]{36}$/.test(params.deviceId) || !Number.isInteger(params.version)) throw new Error('Geçersiz cihaz.')
|
||||
const result = await call(`/v1/aiturk/devices/${params.deviceId}/revoke`, { expected_version: params.version })
|
||||
for (const [id, entry] of clients) if (entry.phoneId === params.deviceId || result.self_revoked) {
|
||||
entry.client.close(); clients.delete(id); send({ channelId: id, close: true })
|
||||
}
|
||||
if (result.self_revoked) await vault.save({})
|
||||
return result
|
||||
}
|
||||
if (action === 'inbox') {
|
||||
const data = await call('/v1/aiturk/mobile/channels'), secrets = await vault.load()
|
||||
return { ...data, channels: data.channels.map(offer => {
|
||||
const fingerprint = secrets.phoneFingerprints?.[offer.phone_id]
|
||||
assertRemoteIdentity(offer.sdp, fingerprint)
|
||||
return { ...offer, trustedPhoneFingerprint: fingerprint }
|
||||
}) }
|
||||
}
|
||||
if (action === 'ice') return call('/v1/aiturk/mobile/ice')
|
||||
if (action === 'channel.answer') {
|
||||
const inbox = await call('/v1/aiturk/mobile/channels')
|
||||
const offer = inbox.channels.find(row => row.id === params.channelId)
|
||||
if (!offer || clients.has(offer.id) || clients.size >= 4) throw new Error('Bağlantı isteği artık geçerli değil.')
|
||||
assertRemoteIdentity(offer.sdp, (await vault.load()).phoneFingerprints?.[offer.phone_id])
|
||||
const authorize = () => call(`/v1/aiturk/mobile/channels/${offer.id}/heartbeat`, {})
|
||||
const client = new MobileGatewayClient(resolveBackend, frame => send({ channelId: offer.id, frame }),
|
||||
{ deviceId: offer.phone_id, journal, authorize })
|
||||
await call(`/v1/aiturk/mobile/channels/${offer.id}/answer`, { sdp: params.sdp })
|
||||
clients.set(offer.id, { client, phoneId: offer.phone_id, authorize })
|
||||
return { accepted: true }
|
||||
}
|
||||
if (action === 'channel.heartbeat') {
|
||||
const entry = clients.get(params.channelId)
|
||||
if (!entry) throw new Error('Bağlantı yok.')
|
||||
return entry.authorize()
|
||||
}
|
||||
if (action === 'channel.close') {
|
||||
clients.get(params.channelId)?.client.close(); clients.delete(params.channelId)
|
||||
return { closed: true }
|
||||
}
|
||||
if (action === 'channel.request') {
|
||||
const entry = clients.get(params.channelId)
|
||||
if (!entry) throw new Error('Bağlantı yok.')
|
||||
await entry.authorize()
|
||||
const message = params.message
|
||||
if (!message || JSON.stringify(message).length > 65000) throw new Error('İstek boyutu geçersiz.')
|
||||
if (message.action === 'google.key.save') {
|
||||
const key = message.params?.key
|
||||
if (typeof key !== 'string' || !/^[A-Za-z0-9_-]{20,256}$/.test(key)) throw new Error('Google anahtarı biçimi geçersiz.')
|
||||
await vault.save({ ...await vault.load(), googleKey: key })
|
||||
return { saved: true }
|
||||
}
|
||||
if (message.action === 'google.key.delete') {
|
||||
const secrets = await vault.load(); delete secrets.googleKey; await vault.save(secrets); return { deleted: true }
|
||||
}
|
||||
if (message.action === 'voice.token') return issuer.issue(entry.phoneId)
|
||||
return entry.client.handle(message)
|
||||
}
|
||||
throw new Error('Desteklenmeyen mobil işlem.')
|
||||
const actions = {
|
||||
state: () => link.state(),
|
||||
enable: () => link.enable(),
|
||||
disable: () => link.disable(),
|
||||
'pair.begin': () => link.pairBegin(),
|
||||
'pair.cancel': () => link.pairCancel(),
|
||||
// Publishes the running pairing's rendezvous record again; the code and the QR stay as they are.
|
||||
'pair.republish': () => link.pairRepublish(),
|
||||
devices: () => link.devices(),
|
||||
'device.revoke': params => link.deviceRevoke(params?.deviceId),
|
||||
// The only actions that may raise a UAC prompt; both are explicit button presses.
|
||||
'lan.allow': () => link.lanAllow(),
|
||||
'lan.disallow': () => link.lanDisallow(),
|
||||
'firewall.allow': () => link.lanAllow(),
|
||||
'firewall.retry': () => link.lanAllow(),
|
||||
'relay.renew': () => link.relayRenew(),
|
||||
'firewall.remove': () => link.firewallRemove(),
|
||||
}
|
||||
const handler = async (event, action, params) => {
|
||||
if (event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) throw new Error('aiturk-mobile:forbidden')
|
||||
if (typeof action !== 'string' || !Object.hasOwn(actions, action)) throw new Error('aiturk-mobile:unsupported')
|
||||
try { return await actions[action](params) } catch (error) {
|
||||
// Only fixed codes cross to the renderer; never upstream error text.
|
||||
throw new Error(CODED.test(error?.message) ? error.message : 'aiturk-mobile:failed')
|
||||
}
|
||||
}
|
||||
const unsubscribe = link.onEvent(event => { if (!window.isDestroyed()) window.webContents.send('aiturk-mobile-event', event) })
|
||||
ipcMain.handle('aiturk-mobile', handler)
|
||||
window.on('closed', () => { for (const entry of clients.values()) entry.client.close(); clients.clear(); ipcMain.removeHandler('aiturk-mobile') })
|
||||
window.on('closed', () => {
|
||||
unsubscribe(); ipcMain.removeHandler('aiturk-mobile')
|
||||
// The code on screen is gone with the window; do not leave pairing open behind it.
|
||||
void link.pairCancel().catch(() => {})
|
||||
})
|
||||
void window.loadFile(join(assets, 'mobile-host.html'))
|
||||
return window
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Tests for electron/mobile-lan-cert.ts — the dependency-free self-signed
|
||||
* certificate of the "Telefon bağlantısı" LAN proxy and its vault storage.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
createSelfSignedCertificate,
|
||||
fingerprintOfPem,
|
||||
isUsableIdentity,
|
||||
loadOrCreateLanIdentity
|
||||
} from './mobile-lan-cert'
|
||||
|
||||
function memoryVault(initial: Record<string, any> = {}) {
|
||||
const state = { value: structuredClone(initial), saves: 0 }
|
||||
|
||||
return {
|
||||
state,
|
||||
load: async () => structuredClone(state.value),
|
||||
save: async (value: Record<string, any>) => {
|
||||
state.value = structuredClone(value)
|
||||
state.saves++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('createSelfSignedCertificate', () => {
|
||||
it('produces a parseable, self-verifying ECDSA P-256 X.509 certificate', () => {
|
||||
const identity = createSelfSignedCertificate()
|
||||
const x509 = new crypto.X509Certificate(identity.cert)
|
||||
|
||||
expect(x509.verify(x509.publicKey)).toBe(true)
|
||||
expect(x509.publicKey.asymmetricKeyType).toBe('ec')
|
||||
expect(x509.publicKey.asymmetricKeyDetails?.namedCurve).toBe('prime256v1')
|
||||
expect(x509.checkPrivateKey(crypto.createPrivateKey(identity.key))).toBe(true)
|
||||
expect(new Date(x509.validTo).getTime()).toBeGreaterThan(Date.now() + 3000 * 24 * 3600e3)
|
||||
expect(new Date(x509.validFrom).getTime()).toBeLessThan(Date.now())
|
||||
})
|
||||
|
||||
it('fingerprint is the base64url SHA-256 of the DER bytes (43 characters, no padding)', () => {
|
||||
const identity = createSelfSignedCertificate()
|
||||
const der = new crypto.X509Certificate(identity.cert).raw
|
||||
|
||||
expect(identity.fingerprint).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
||||
expect(identity.fingerprint).toBe(crypto.createHash('sha256').update(der).digest('base64url'))
|
||||
expect(fingerprintOfPem(identity.cert)).toBe(identity.fingerprint)
|
||||
expect(createSelfSignedCertificate().fingerprint).not.toBe(identity.fingerprint)
|
||||
})
|
||||
|
||||
it('refuses a validity that UTCTime cannot carry', () => {
|
||||
expect(() => createSelfSignedCertificate({ now: new Date('2045-01-01T00:00:00Z') })).toThrow(
|
||||
'mobile-lan-cert:validity-out-of-range'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadOrCreateLanIdentity', () => {
|
||||
it('creates once, stores the key only in the vault, keeps the other vault fields, then reuses it', async () => {
|
||||
const vault = memoryVault({ accountToken: 'old-field', phoneFingerprints: { a: 'b' } })
|
||||
const first = await loadOrCreateLanIdentity(vault)
|
||||
|
||||
expect(first.created).toBe(true)
|
||||
expect(vault.state.value.accountToken).toBe('old-field')
|
||||
expect(vault.state.value.phoneFingerprints).toEqual({ a: 'b' })
|
||||
expect(vault.state.value.lanTls.key).toContain('PRIVATE KEY')
|
||||
|
||||
const second = await loadOrCreateLanIdentity(vault)
|
||||
|
||||
expect(second.created).toBe(false)
|
||||
expect(second.fingerprint).toBe(first.fingerprint)
|
||||
expect(vault.state.saves).toBe(1)
|
||||
})
|
||||
|
||||
it('replaces a corrupt, mismatched or nearly expired identity', async () => {
|
||||
const good = createSelfSignedCertificate()
|
||||
const other = createSelfSignedCertificate()
|
||||
|
||||
expect(isUsableIdentity(good)).toBe(true)
|
||||
expect(isUsableIdentity({ ...good, key: other.key })).toBe(false)
|
||||
expect(isUsableIdentity({ ...good, fingerprint: other.fingerprint })).toBe(false)
|
||||
expect(isUsableIdentity({ ...good, cert: 'not a certificate' })).toBe(false)
|
||||
expect(isUsableIdentity(good, new Date(Date.now() + 3640 * 24 * 3600e3))).toBe(false)
|
||||
|
||||
const vault = memoryVault({ lanTls: { ...good, key: other.key } })
|
||||
const renewed = await loadOrCreateLanIdentity(vault)
|
||||
|
||||
expect(renewed.created).toBe(true)
|
||||
expect(renewed.fingerprint).not.toBe(good.fingerprint)
|
||||
})
|
||||
|
||||
it('surfaces a vault failure instead of running without a stored key', async () => {
|
||||
const vault = {
|
||||
load: async () => {
|
||||
throw new Error('vault closed')
|
||||
},
|
||||
save: async () => undefined
|
||||
}
|
||||
|
||||
await expect(loadOrCreateLanIdentity(vault)).rejects.toThrow('vault closed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* mobile-lan-cert.ts
|
||||
*
|
||||
* Self-signed ECDSA P-256 certificate for the "Telefon bağlantısı" LAN proxy
|
||||
* (electron/mobile-lan-proxy.ts). No dependency: the X.509 structure is built
|
||||
* by hand and signed with node:crypto.
|
||||
*
|
||||
* The phone never trusts a CA for this server. It pins the certificate by
|
||||
* `fp` = SHA-256 of the certificate's DER bytes, base64url without padding
|
||||
* (43 characters), which it learns from the pairing QR / rendezvous record.
|
||||
*
|
||||
* The private key lives only in the injected vault (MobileVault → Electron
|
||||
* safeStorage → Windows DPAPI). Existing vault fields are preserved.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
export const MOBILE_LAN_CERT_DAYS = 3650
|
||||
/** Regenerate when less than this is left; the phone must then pair again. */
|
||||
const RENEW_BEFORE_MS = 30 * 24 * 3600e3
|
||||
const FINGERPRINT_RE = /^[A-Za-z0-9_-]{43}$/
|
||||
|
||||
export interface MobileLanIdentity {
|
||||
/** PEM certificate. */
|
||||
cert: string
|
||||
/** PEM PKCS#8 private key. Never leaves the main process. */
|
||||
key: string
|
||||
/** SHA-256 of the DER certificate, base64url, 43 characters. */
|
||||
fingerprint: string
|
||||
}
|
||||
|
||||
export interface MobileLanVault {
|
||||
load: () => Promise<Record<string, any>>
|
||||
save: (value: Record<string, any>) => Promise<void>
|
||||
}
|
||||
|
||||
function der(tag: number, ...parts: Buffer[]): Buffer {
|
||||
const body = Buffer.concat(parts)
|
||||
let length: Buffer
|
||||
|
||||
if (body.length < 128) {
|
||||
length = Buffer.from([body.length])
|
||||
} else {
|
||||
const bytes: number[] = []
|
||||
|
||||
for (let n = body.length; n > 0; n >>= 8) {
|
||||
bytes.unshift(n & 255)
|
||||
}
|
||||
|
||||
length = Buffer.from([0x80 | bytes.length, ...bytes])
|
||||
}
|
||||
|
||||
return Buffer.concat([Buffer.from([tag]), length, body])
|
||||
}
|
||||
|
||||
const seq = (...parts: Buffer[]) => der(0x30, ...parts)
|
||||
const oid = (hex: string) => der(0x06, Buffer.from(hex, 'hex'))
|
||||
const int = (value: Buffer) => der(0x02, value[0] & 0x80 ? Buffer.concat([Buffer.from([0]), value]) : value)
|
||||
|
||||
const utcTime = (date: Date) =>
|
||||
der(0x17, Buffer.from(`${date.toISOString().replace(/[-:T]/g, '').slice(2, 14)}Z`, 'ascii'))
|
||||
|
||||
const ECDSA_SHA256 = seq(oid('2a8648ce3d040302'))
|
||||
const name = (cn: string) => seq(der(0x31, seq(oid('550403'), der(0x0c, Buffer.from(cn, 'utf8')))))
|
||||
|
||||
export function fingerprintOfDer(certDer: Uint8Array): string {
|
||||
return crypto.createHash('sha256').update(certDer).digest('base64url')
|
||||
}
|
||||
|
||||
/** Fingerprint of a PEM certificate; throws when the PEM is not a certificate. */
|
||||
export function fingerprintOfPem(certPem: string): string {
|
||||
return fingerprintOfDer(new crypto.X509Certificate(certPem).raw)
|
||||
}
|
||||
|
||||
export function createSelfSignedCertificate(
|
||||
options: { commonName?: string; days?: number; now?: Date } = {}
|
||||
): MobileLanIdentity {
|
||||
const commonName = options.commonName ?? 'AITURK IDE'
|
||||
const days = options.days ?? MOBILE_LAN_CERT_DAYS
|
||||
const now = options.now ?? new Date()
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' })
|
||||
const serial = crypto.randomBytes(16)
|
||||
|
||||
serial[0] = (serial[0] & 0x7f) | 0x01
|
||||
|
||||
const notBefore = new Date(now.getTime() - 24 * 3600e3)
|
||||
const notAfter = new Date(now.getTime() + days * 24 * 3600e3)
|
||||
|
||||
if (notAfter.getUTCFullYear() >= 2050 || notBefore.getUTCFullYear() < 1950) {
|
||||
// UTCTime cannot carry these years; refuse rather than emit a wrong date.
|
||||
throw new Error('mobile-lan-cert:validity-out-of-range')
|
||||
}
|
||||
|
||||
const tbs = seq(
|
||||
der(0xa0, int(Buffer.from([2]))), // v3
|
||||
int(serial),
|
||||
ECDSA_SHA256,
|
||||
name(commonName),
|
||||
seq(utcTime(notBefore), utcTime(notAfter)),
|
||||
name(commonName),
|
||||
publicKey.export({ type: 'spki', format: 'der' }) as Buffer
|
||||
)
|
||||
|
||||
const signature = crypto.sign('sha256', tbs, privateKey)
|
||||
const certDer = seq(tbs, ECDSA_SHA256, der(0x03, Buffer.from([0]), signature))
|
||||
const body = (certDer.toString('base64').match(/.{1,64}/g) ?? []).join('\n')
|
||||
|
||||
return {
|
||||
cert: `-----BEGIN CERTIFICATE-----\n${body}\n-----END CERTIFICATE-----\n`,
|
||||
key: privateKey.export({ type: 'pkcs8', format: 'pem' }) as string,
|
||||
fingerprint: fingerprintOfDer(certDer)
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the stored pair is a usable, unexpired, matching identity. */
|
||||
export function isUsableIdentity(value: unknown, now: Date = new Date()): value is MobileLanIdentity {
|
||||
const row = value as MobileLanIdentity | null
|
||||
|
||||
if (!row || typeof row.cert !== 'string' || typeof row.key !== 'string' || !FINGERPRINT_RE.test(row.fingerprint)) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const x509 = new crypto.X509Certificate(row.cert)
|
||||
|
||||
return (
|
||||
fingerprintOfDer(x509.raw) === row.fingerprint &&
|
||||
x509.checkPrivateKey(crypto.createPrivateKey(row.key)) &&
|
||||
new Date(x509.validTo).getTime() - now.getTime() > RENEW_BEFORE_MS
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the LAN identity from the vault, or create and store one. Stored under
|
||||
* the vault's `lanTls` field; every other field is written back untouched.
|
||||
* `created` tells the caller that previously paired phones no longer match.
|
||||
*/
|
||||
export async function loadOrCreateLanIdentity(
|
||||
vault: MobileLanVault,
|
||||
options: { now?: () => Date; create?: typeof createSelfSignedCertificate } = {}
|
||||
): Promise<MobileLanIdentity & { created: boolean }> {
|
||||
const now = options.now ? options.now() : new Date()
|
||||
const secrets = (await vault.load()) ?? {}
|
||||
|
||||
if (isUsableIdentity(secrets.lanTls, now)) {
|
||||
const { cert, key, fingerprint } = secrets.lanTls
|
||||
|
||||
return { cert, key, fingerprint, created: false }
|
||||
}
|
||||
|
||||
const identity = (options.create ?? createSelfSignedCertificate)({ now })
|
||||
|
||||
await vault.save({ ...secrets, lanTls: identity })
|
||||
|
||||
return { ...identity, created: true }
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,139 @@
|
||||
/** Opt-in acceptance against a running local IDE backend. No credentials on disk. */
|
||||
import https from 'node:https'
|
||||
import crypto from 'node:crypto'
|
||||
import type { PeerCertificate } from 'node:tls'
|
||||
import { expect, it } from 'vitest'
|
||||
import { WebSocket } from 'ws'
|
||||
import { createSelfSignedCertificate } from './mobile-lan-cert'
|
||||
import { createLanProxy } from './mobile-lan-proxy'
|
||||
|
||||
it.skipIf(!process.env.AITURK_TEST_BACKEND_PORT)(
|
||||
'real backend: pinned TLS, screens, cron lifecycle and chat socket',
|
||||
async () => {
|
||||
const port = Number(process.env.AITURK_TEST_BACKEND_PORT)
|
||||
expect(Number.isInteger(port) && port > 0 && port < 65536).toBe(true)
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
const root = await (await fetch(baseUrl)).text()
|
||||
const session = root.match(/window\.__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"/)?.[1]
|
||||
expect(Boolean(session)).toBe(true)
|
||||
const identity = createSelfSignedCertificate()
|
||||
const checkServerIdentity = (_host: string, cert: PeerCertificate) =>
|
||||
crypto.createHash('sha256').update(cert.raw).digest('base64url') === identity.fingerprint
|
||||
? undefined
|
||||
: new Error('Certificate pin mismatch')
|
||||
const proxy = createLanProxy({
|
||||
tls: identity,
|
||||
version: 'acceptance',
|
||||
pcName: 'acceptance',
|
||||
resolveBackend: async profile => {
|
||||
if (profile !== 'default') throw new Error('Acceptance may only use the default profile')
|
||||
return { baseUrl, token: session! }
|
||||
}
|
||||
})
|
||||
const proxyPort = await proxy.listen(0, '127.0.0.1')
|
||||
let deviceToken = ''
|
||||
let createdId: string | undefined
|
||||
const call = (path: string, method = 'GET', body?: unknown): Promise<{ status: number; data: any }> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const req = https.request(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port: proxyPort,
|
||||
path,
|
||||
method,
|
||||
ca: identity.cert,
|
||||
checkServerIdentity,
|
||||
headers: {
|
||||
...(deviceToken ? { Authorization: `Bearer ${deviceToken}` } : {}),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
},
|
||||
res => {
|
||||
let value = ''
|
||||
res.on('data', chunk => (value += chunk))
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve({ status: res.statusCode!, data: value ? JSON.parse(value) : null })
|
||||
} catch {
|
||||
reject(new Error(`Non-JSON response: ${path}`))
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
req.setTimeout(60000, () => req.destroy(new Error(`Timeout: ${path}`)))
|
||||
req.on('error', reject)
|
||||
req.end(body === undefined ? undefined : JSON.stringify(body))
|
||||
})
|
||||
try {
|
||||
const pair = await call('/aiturk/pair/claim', 'POST', {
|
||||
code: proxy.beginPairing().code,
|
||||
device_name: 'Acceptance ephemeral'
|
||||
})
|
||||
expect(pair.status).toBe(200)
|
||||
deviceToken = pair.data.token
|
||||
for (const endpoint of [
|
||||
'health',
|
||||
'profiles',
|
||||
'sessions',
|
||||
'cron/jobs',
|
||||
'cron/blueprints',
|
||||
'cron/delivery-targets',
|
||||
'model/info',
|
||||
'skills',
|
||||
'tools/toolsets',
|
||||
'mcp/servers',
|
||||
'plugins/kanban/boards'
|
||||
]) {
|
||||
const response = await call(`/api/${endpoint}?profile=default`)
|
||||
expect(response.status, endpoint).toBe(200)
|
||||
}
|
||||
const created = await call('/api/cron/jobs?profile=default', 'POST', {
|
||||
name: `AITURK mobile acceptance ${Date.now()}`,
|
||||
schedule: '0 0 1 1 *',
|
||||
prompt: 'Temporary acceptance record; deleted immediately without execution.',
|
||||
deliver: 'local'
|
||||
})
|
||||
expect(created.status).toBe(200)
|
||||
createdId = created.data.id
|
||||
expect(typeof createdId).toBe('string')
|
||||
const route = `/api/cron/jobs/${encodeURIComponent(createdId!)}`
|
||||
expect((await call(`${route}/pause?profile=default`, 'POST')).status).toBe(200)
|
||||
expect(
|
||||
(await call(`${route}?profile=default`, 'PUT', { updates: { name: 'AITURK acceptance updated' } })).status
|
||||
).toBe(200)
|
||||
expect((await call(`${route}/resume?profile=default`, 'POST')).status).toBe(200)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const ws = new WebSocket(`wss://127.0.0.1:${proxyPort}/api/ws?profile=default`, {
|
||||
ca: identity.cert,
|
||||
checkServerIdentity,
|
||||
headers: { Authorization: `Bearer ${deviceToken}` }
|
||||
})
|
||||
const timer = setTimeout(() => {
|
||||
ws.terminate()
|
||||
reject(new Error('Chat ping timed out'))
|
||||
}, 15000)
|
||||
ws.on('open', () => ws.send(JSON.stringify({ jsonrpc: '2.0', id: 123, method: 'gateway.ping', params: {} })))
|
||||
ws.on('message', raw => {
|
||||
const message = JSON.parse(String(raw))
|
||||
if (message.id === 123) {
|
||||
clearTimeout(timer)
|
||||
ws.close()
|
||||
message.error ? reject(new Error('Chat ping rejected')) : resolve()
|
||||
}
|
||||
})
|
||||
ws.on('error', error => {
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
if (createdId) {
|
||||
const route = `/api/cron/jobs/${encodeURIComponent(createdId)}?profile=default`
|
||||
expect((await call(route, 'DELETE')).status).toBe(200)
|
||||
expect((await call(route)).status).toBe(404)
|
||||
}
|
||||
await proxy.stop()
|
||||
}
|
||||
},
|
||||
180000
|
||||
)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,733 @@
|
||||
/**
|
||||
* mobile-relay-client.ts
|
||||
*
|
||||
* "Telefon bağlantısı" through the turkservis.online tunnel (contract:
|
||||
* workspace/aiturk-mobil-tunel-spec.md, v3). The IDE opens ONE outbound
|
||||
* WebSocket to the gateway. For every phone stream the gateway announces
|
||||
* (`open`), this client opens a PLAIN TCP connection to this IDE's own TLS
|
||||
* proxy on loopback (electron/mobile-lan-proxy.ts) and copies bytes both ways.
|
||||
*
|
||||
* It never terminates TLS and never looks at a byte: the TLS session is
|
||||
* between the phone (which pins the proxy's certificate) and the proxy. So the
|
||||
* gateway — and this file — only ever carry ciphertext, and every rule (device
|
||||
* token, forbidden list, root 404, WebSocket path list) stays in the proxy.
|
||||
*
|
||||
* Security contract (do not relax):
|
||||
* - `hostToken` (`tsmr_…`) lives only in the injected credential store (the
|
||||
* controller keeps it in the safeStorage vault). It is sent in the
|
||||
* `Authorization` header of the host WebSocket and nowhere else: not in a
|
||||
* log line, a status object or an error message.
|
||||
* - Log lines are status changes only.
|
||||
* - Errors are fixed codes.
|
||||
*
|
||||
* Everything that touches the outside is injected (WebSocket connector, fetch,
|
||||
* local TCP dial, timers, credential store) so the tests run against a fake
|
||||
* relay on 127.0.0.1 and a fake clock.
|
||||
*/
|
||||
|
||||
export const MOBILE_RELAY_ORIGIN = 'https://turkservis.online'
|
||||
export const MOBILE_RELAY_PATH = '/v1/aiturk/mobile-relay'
|
||||
export const MOBILE_RELAY_FETCH_TIMEOUT_MS = 20_000
|
||||
/** Largest payload of one binary frame, either direction (contract §1). */
|
||||
export const RELAY_CHUNK_BYTES = 64 * 1024
|
||||
/** Stream id prefix of a binary frame: big-endian uint32. */
|
||||
export const RELAY_FRAME_HEADER_BYTES = 4
|
||||
/** Pending bytes allowed per direction of one stream (contract §1 / §2). */
|
||||
export const RELAY_BUFFER_LIMIT_BYTES = 1024 * 1024
|
||||
/** Reconnect delays: 2 s, then growing to 30 s. */
|
||||
export const RELAY_BACKOFF_MS: readonly number[] = [2000, 4000, 8000, 16_000, 30_000]
|
||||
/** The gateway pings every 20 s and drops a silent host after 60 s; we give up a little later. */
|
||||
export const RELAY_SILENCE_MS = 75_000
|
||||
/** Concurrent phone streams (the gateway allows 16 per relay). */
|
||||
export const RELAY_MAX_STREAMS = 16
|
||||
/** Upper bound for one frame on the host socket; a data frame is at most 64 KiB + 4. */
|
||||
export const RELAY_MAX_FRAME_BYTES = 1024 * 1024
|
||||
|
||||
const BUFFER_RESUME_BYTES = 256 * 1024
|
||||
const BUFFER_POLL_MS = 25
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
const HOST_TOKEN_RE = /^tsmr_[A-Za-z0-9_-]{43}$/
|
||||
|
||||
export const relayHostUrl = (relayId: string) => `wss://turkservis.online${MOBILE_RELAY_PATH}/host/${relayId}`
|
||||
|
||||
/** The `r` of the QR and the `relay` of the rendezvous record: a lower-case uuid, nothing else. */
|
||||
export function isRelayId(value: unknown): value is string {
|
||||
return typeof value === 'string' && UUID_RE.test(value)
|
||||
}
|
||||
|
||||
export interface RelayCredentials {
|
||||
relayId: string
|
||||
hostToken: string
|
||||
}
|
||||
|
||||
export function isRelayCredentials(value: unknown): value is RelayCredentials {
|
||||
const row = value as null | RelayCredentials
|
||||
|
||||
return Boolean(row) && isRelayId(row.relayId) && typeof row.hostToken === 'string' && HOST_TOKEN_RE.test(row.hostToken)
|
||||
}
|
||||
|
||||
/** Validates the `POST /sessions` answer: `{relayId, hostToken}` and nothing we would follow. */
|
||||
export function parseRelaySession(data: unknown): null | RelayCredentials {
|
||||
const row = (data && typeof data === 'object' ? data : {}) as Record<string, unknown>
|
||||
const candidate = { relayId: typeof row.relayId === 'string' ? row.relayId.toLowerCase() : row.relayId, hostToken: row.hostToken }
|
||||
|
||||
return isRelayCredentials(candidate) ? { relayId: candidate.relayId, hostToken: candidate.hostToken } : null
|
||||
}
|
||||
|
||||
export interface RelayFetchResponse {
|
||||
status: number
|
||||
json: () => Promise<unknown>
|
||||
}
|
||||
|
||||
export type RelayFetch = (url: string, init: Record<string, unknown>) => Promise<RelayFetchResponse>
|
||||
|
||||
export type RelayFailureCode =
|
||||
| 'register_failed'
|
||||
| 'rate_limited'
|
||||
| 'vault_unavailable'
|
||||
| 'connect_failed'
|
||||
| 'token_rejected'
|
||||
| 'replaced'
|
||||
| 'busy'
|
||||
| 'timeout'
|
||||
|
||||
/** Wait before the single second try of a request whose first try never got an answer. */
|
||||
export const MOBILE_NETWORK_RETRY_DELAY_MS = 500
|
||||
|
||||
/** An aborted / timed-out request already used its whole budget: it is not tried again. */
|
||||
export function isTimeoutError(error: unknown): boolean {
|
||||
const name = (error as null | { name?: unknown })?.name
|
||||
|
||||
return name === 'TimeoutError' || name === 'AbortError'
|
||||
}
|
||||
|
||||
export interface NetworkRetryOptions {
|
||||
/** Injected wait (tests: fake clock). Default: a real timer. */
|
||||
sleep?: (ms: number) => Promise<void>
|
||||
retryDelayMs?: number
|
||||
}
|
||||
|
||||
const realSleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
||||
|
||||
/**
|
||||
* `POST /sessions`; a fixed failure code instead of any upstream text.
|
||||
*
|
||||
* A process that sat idle for a long time can fail its first request on a dead
|
||||
* pooled keep-alive connection. So a THROWN network error (no HTTP answer at
|
||||
* all) is tried once more after ~0.5 s. Any HTTP answer is final: no retry.
|
||||
*/
|
||||
export async function registerRelaySession(
|
||||
fetcher: RelayFetch,
|
||||
options: NetworkRetryOptions = {}
|
||||
): Promise<RelayCredentials | { error: RelayFailureCode }> {
|
||||
const sleep = options.sleep ?? realSleep
|
||||
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
let response: RelayFetchResponse
|
||||
|
||||
try {
|
||||
response = await fetcher(`${MOBILE_RELAY_ORIGIN}${MOBILE_RELAY_PATH}/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: '{}',
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(MOBILE_RELAY_FETCH_TIMEOUT_MS)
|
||||
})
|
||||
} catch (error) {
|
||||
if (attempt >= 2 || isTimeoutError(error)) {
|
||||
return { error: 'register_failed' }
|
||||
}
|
||||
|
||||
await sleep(options.retryDelayMs ?? MOBILE_NETWORK_RETRY_DELAY_MS)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
if (response.status !== 201) {
|
||||
return { error: response.status === 429 ? 'rate_limited' : 'register_failed' }
|
||||
}
|
||||
|
||||
return parseRelaySession(await response.json()) ?? { error: 'register_failed' }
|
||||
} catch {
|
||||
return { error: 'register_failed' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── What the client needs from the outside ──────────────────────────────────
|
||||
|
||||
/** Host WebSocket (the `ws` package fits: it can send request headers). */
|
||||
export interface RelaySocket {
|
||||
on: (event: string, listener: (...args: any[]) => void) => unknown
|
||||
/** A string is a text frame, a Buffer a binary frame. */
|
||||
send: (data: Buffer | string) => unknown
|
||||
close: (code?: number) => unknown
|
||||
terminate?: () => unknown
|
||||
/** Stop / continue reading from the gateway (back-pressure towards a slow local connection). */
|
||||
pause?: () => unknown
|
||||
resume?: () => unknown
|
||||
readonly bufferedAmount?: number
|
||||
}
|
||||
|
||||
export type RelayConnect = (url: string, headers: Record<string, string>) => RelaySocket
|
||||
|
||||
export interface RelayWebSocketCtor {
|
||||
new (url: string, options?: Record<string, unknown>): any
|
||||
}
|
||||
|
||||
/** Plain TCP connection to the local proxy (`net.Socket` fits). */
|
||||
export interface RelayLocalSocket {
|
||||
on: (event: string, listener: (...args: any[]) => void) => unknown
|
||||
write: (data: Buffer) => boolean
|
||||
destroy: () => unknown
|
||||
pause: () => unknown
|
||||
resume: () => unknown
|
||||
readonly writableLength?: number
|
||||
}
|
||||
|
||||
/** null = the proxy is not listening right now. */
|
||||
export type RelayDial = () => null | RelayLocalSocket
|
||||
|
||||
/** One binary frame: stream id (big-endian uint32) + payload. */
|
||||
export function encodeRelayFrame(id: number, payload: Buffer): Buffer {
|
||||
const frame = Buffer.allocUnsafe(RELAY_FRAME_HEADER_BYTES + payload.length)
|
||||
|
||||
frame.writeUInt32BE(id, 0)
|
||||
payload.copy(frame, RELAY_FRAME_HEADER_BYTES)
|
||||
|
||||
return frame
|
||||
}
|
||||
|
||||
/** Pieces of at most `size` bytes, in order, without copying. */
|
||||
export function splitRelayChunks(chunk: Buffer, size = RELAY_CHUNK_BYTES): Buffer[] {
|
||||
const parts: Buffer[] = []
|
||||
|
||||
for (let offset = 0; offset < chunk.length; offset += size) {
|
||||
parts.push(chunk.subarray(offset, offset + size))
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
const validStreamId = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 0 && (value as number) <= 0xffffffff
|
||||
|
||||
// ── The client ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type RelayStatus = 'off' | 'connecting' | 'connected' | 'failed'
|
||||
|
||||
export interface RelayState {
|
||||
status: RelayStatus
|
||||
/** Fixed code while `failed` (a retry is always scheduled), otherwise null. */
|
||||
code: null | RelayFailureCode
|
||||
}
|
||||
|
||||
export interface RelayCredentialStore {
|
||||
/** null when there is no registration yet. Throwing = the vault is unusable. */
|
||||
load: () => Promise<null | RelayCredentials>
|
||||
/** `reissued` = this replaces a registration (rejected by the gateway, or renewed by the user). */
|
||||
save: (credentials: RelayCredentials, info: { reissued: boolean }) => Promise<void>
|
||||
clear: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface RelayClientDeps {
|
||||
connect: RelayConnect
|
||||
fetch: RelayFetch
|
||||
dial: RelayDial
|
||||
credentials: RelayCredentialStore
|
||||
setTimer?: (fn: () => void, ms: number) => unknown
|
||||
clearTimer?: (handle: unknown) => void
|
||||
/** Status changes only. */
|
||||
log?: (line: string) => void
|
||||
onState?: (state: RelayState) => void
|
||||
}
|
||||
|
||||
interface Stream {
|
||||
local: RelayLocalSocket
|
||||
paused: boolean
|
||||
}
|
||||
|
||||
export function createRelayClient(deps: RelayClientDeps) {
|
||||
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
|
||||
const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as NodeJS.Timeout))
|
||||
|
||||
let state: RelayState = { status: 'off', code: null }
|
||||
let wanted = false
|
||||
let generation = 0
|
||||
let attempt = 0
|
||||
let socket: null | RelaySocket = null
|
||||
let credentials: null | RelayCredentials = null
|
||||
/** The gateway rejected the stored registration; the next one replaces it. */
|
||||
let rejected = false
|
||||
let registering: null | Promise<RelayCredentials | { error: RelayFailureCode }> = null
|
||||
let retryTimer: unknown = null
|
||||
let silenceTimer: unknown = null
|
||||
const streams = new Map<number, Stream>()
|
||||
/** Same (injectable) clock as every other wait of this client. */
|
||||
const sleep = (ms: number) => new Promise<void>(resolve => void setTimer(resolve, ms))
|
||||
|
||||
function setState(status: RelayStatus, code: null | RelayFailureCode = null) {
|
||||
if (state.status === status && state.code === code) {
|
||||
return
|
||||
}
|
||||
|
||||
state = { status, code }
|
||||
deps.log?.(`relay ${status}${code ? `:${code}` : ''}`)
|
||||
|
||||
try {
|
||||
deps.onState?.(state)
|
||||
} catch {
|
||||
// a broken listener must not break the relay
|
||||
}
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
if (retryTimer !== null) {
|
||||
clearTimer(retryTimer)
|
||||
retryTimer = null
|
||||
}
|
||||
|
||||
if (silenceTimer !== null) {
|
||||
clearTimer(silenceTimer)
|
||||
silenceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Stored registration, or a new one. One request at a time. */
|
||||
function register(): Promise<RelayCredentials | { error: RelayFailureCode }> {
|
||||
if (credentials) {
|
||||
return Promise.resolve(credentials)
|
||||
}
|
||||
|
||||
registering ??= (async (): Promise<RelayCredentials | { error: RelayFailureCode }> => {
|
||||
let stored: null | RelayCredentials
|
||||
|
||||
try {
|
||||
stored = await deps.credentials.load()
|
||||
} catch {
|
||||
return { error: 'vault_unavailable' }
|
||||
}
|
||||
|
||||
if (isRelayCredentials(stored)) {
|
||||
credentials = stored
|
||||
|
||||
return stored
|
||||
}
|
||||
|
||||
const fresh = await registerRelaySession(deps.fetch, { sleep })
|
||||
|
||||
if ('error' in fresh) {
|
||||
return fresh
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.credentials.save(fresh, { reissued: rejected })
|
||||
} catch {
|
||||
return { error: 'vault_unavailable' }
|
||||
}
|
||||
|
||||
rejected = false
|
||||
credentials = fresh
|
||||
|
||||
return fresh
|
||||
})().finally(() => {
|
||||
registering = null
|
||||
})
|
||||
|
||||
return registering
|
||||
}
|
||||
|
||||
function dropStreams() {
|
||||
for (const stream of streams.values()) {
|
||||
stream.local.destroy()
|
||||
}
|
||||
|
||||
streams.clear()
|
||||
}
|
||||
|
||||
function retry(code: RelayFailureCode, delay?: number) {
|
||||
const wait = delay ?? RELAY_BACKOFF_MS[Math.min(attempt, RELAY_BACKOFF_MS.length - 1)]
|
||||
|
||||
attempt++
|
||||
setState('failed', code)
|
||||
retryTimer = setTimer(() => {
|
||||
retryTimer = null
|
||||
void connect()
|
||||
}, wait)
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (!wanted || socket) {
|
||||
return
|
||||
}
|
||||
|
||||
const mine = ++generation
|
||||
|
||||
clearTimers()
|
||||
setState('connecting')
|
||||
|
||||
const current = await register()
|
||||
|
||||
if (!wanted || mine !== generation) {
|
||||
return
|
||||
}
|
||||
|
||||
if ('error' in current) {
|
||||
retry(current.error)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let ws: RelaySocket
|
||||
|
||||
try {
|
||||
ws = deps.connect(relayHostUrl(current.relayId), { Authorization: `Bearer ${current.hostToken}` })
|
||||
} catch {
|
||||
retry('connect_failed')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
socket = ws
|
||||
|
||||
const send = (data: Buffer | string) => {
|
||||
if (socket === ws) {
|
||||
try {
|
||||
ws.send(data)
|
||||
} catch {
|
||||
// the close event follows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const control = (value: Record<string, unknown>) => send(JSON.stringify(value))
|
||||
|
||||
const watch = () => {
|
||||
if (silenceTimer !== null) {
|
||||
clearTimer(silenceTimer)
|
||||
}
|
||||
|
||||
silenceTimer = setTimer(() => {
|
||||
silenceTimer = null
|
||||
|
||||
if (socket === ws) {
|
||||
try {
|
||||
;(ws.terminate ?? ws.close).call(ws)
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
|
||||
down(1006, 'timeout')
|
||||
}
|
||||
}, RELAY_SILENCE_MS)
|
||||
}
|
||||
|
||||
const down = (code: number, reason?: RelayFailureCode) => {
|
||||
if (socket !== ws) {
|
||||
return
|
||||
}
|
||||
|
||||
socket = null
|
||||
clearTimers()
|
||||
dropStreams()
|
||||
|
||||
if (!wanted) {
|
||||
setState('off')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// 4401: the token was refused. 4404 / HTTP 404: the gateway no longer knows this relay
|
||||
// (unused for 30 days). Either way the registration is dead: take a new one.
|
||||
if (code === 4401 || code === 4404) {
|
||||
credentials = null
|
||||
rejected = true
|
||||
void deps.credentials
|
||||
.clear()
|
||||
.catch(() => undefined)
|
||||
.then(() => {
|
||||
if (wanted && socket === null && retryTimer === null && mine === generation) {
|
||||
retry('token_rejected')
|
||||
}
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (code === 4409) {
|
||||
// Another IDE instance holds this registration: wait 30 s instead of fighting it.
|
||||
retry('replaced', RELAY_BACKOFF_MS[RELAY_BACKOFF_MS.length - 1])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// 4429: the relay service is full; the normal growing delay applies.
|
||||
retry(reason ?? (code === 4429 ? 'busy' : 'connect_failed'))
|
||||
}
|
||||
|
||||
// Gateway → local back-pressure: while any local connection is behind, stop reading the host socket.
|
||||
const stalled = new Set<Stream>()
|
||||
|
||||
const unstall = (stream: Stream) => {
|
||||
if (stalled.delete(stream) && stalled.size === 0 && socket === ws) {
|
||||
ws.resume?.()
|
||||
}
|
||||
}
|
||||
|
||||
/** Ends one stream on both sides. `tell` = the gateway does not know yet. */
|
||||
const endStream = (id: number, stream: Stream, code: number, tell: boolean) => {
|
||||
if (streams.get(id) !== stream) {
|
||||
return
|
||||
}
|
||||
|
||||
streams.delete(id)
|
||||
unstall(stream)
|
||||
stream.local.destroy()
|
||||
|
||||
if (tell) {
|
||||
control({ t: 'close', id, code })
|
||||
}
|
||||
}
|
||||
|
||||
const openStream = (id: number) => {
|
||||
if (streams.has(id)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (streams.size >= RELAY_MAX_STREAMS) {
|
||||
return control({ t: 'close', id, code: 4429 })
|
||||
}
|
||||
|
||||
let local: null | RelayLocalSocket
|
||||
|
||||
try {
|
||||
local = deps.dial()
|
||||
} catch {
|
||||
local = null
|
||||
}
|
||||
|
||||
if (!local) {
|
||||
return control({ t: 'close', id, code: 4503 })
|
||||
}
|
||||
|
||||
const stream: Stream = { local, paused: false }
|
||||
let failed = false
|
||||
|
||||
const live = () => streams.get(id) === stream
|
||||
|
||||
// Local → gateway back-pressure: stop reading the proxy while the host socket is behind.
|
||||
const drain = () => {
|
||||
if (!live() || socket !== ws) {
|
||||
return
|
||||
}
|
||||
|
||||
if ((ws.bufferedAmount ?? 0) > BUFFER_RESUME_BYTES) {
|
||||
setTimer(drain, BUFFER_POLL_MS)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
stream.paused = false
|
||||
local.resume()
|
||||
}
|
||||
|
||||
streams.set(id, stream)
|
||||
local.on('connect', () => {
|
||||
if (live()) {
|
||||
control({ t: 'opened', id })
|
||||
}
|
||||
})
|
||||
local.on('data', (chunk: Buffer) => {
|
||||
if (!live()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bytes are forwarded exactly as read: no parsing, no TLS here.
|
||||
for (const part of splitRelayChunks(chunk)) {
|
||||
send(encodeRelayFrame(id, part))
|
||||
}
|
||||
|
||||
if (!stream.paused && (ws.bufferedAmount ?? 0) > RELAY_BUFFER_LIMIT_BYTES) {
|
||||
stream.paused = true
|
||||
local.pause()
|
||||
setTimer(drain, BUFFER_POLL_MS)
|
||||
}
|
||||
})
|
||||
local.on('drain', () => unstall(stream))
|
||||
local.on('error', () => {
|
||||
failed = true
|
||||
})
|
||||
local.on('close', () => endStream(id, stream, failed ? 1011 : 1000, true))
|
||||
}
|
||||
|
||||
ws.on('open', () => {
|
||||
if (socket === ws) {
|
||||
attempt = 0
|
||||
setState('connected')
|
||||
watch()
|
||||
}
|
||||
})
|
||||
ws.on('close', (code: number) => down(Number(code) || 1006))
|
||||
ws.on('error', () => down(1006))
|
||||
// A refusal at the HTTP level (before the WebSocket exists) means the same as the close codes.
|
||||
ws.on('unexpected-response', (req: { destroy?: () => void }, res: { statusCode?: number; resume?: () => void }) => {
|
||||
const status = Number(res?.statusCode)
|
||||
|
||||
res?.resume?.()
|
||||
req?.destroy?.()
|
||||
down(status === 401 || status === 403 ? 4401 : status === 404 ? 4404 : status === 429 ? 4429 : 1006)
|
||||
})
|
||||
ws.on('message', (data: unknown, isBinary?: boolean) => {
|
||||
if (socket !== ws) {
|
||||
return
|
||||
}
|
||||
|
||||
watch()
|
||||
|
||||
if (isBinary) {
|
||||
const frame = Buffer.isBuffer(data) ? data : Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as ArrayBuffer)
|
||||
|
||||
if (frame.length <= RELAY_FRAME_HEADER_BYTES) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = frame.readUInt32BE(0)
|
||||
const stream = streams.get(id)
|
||||
|
||||
// Late or unknown ids are ignored.
|
||||
if (!stream) {
|
||||
return
|
||||
}
|
||||
|
||||
stream.local.write(frame.subarray(RELAY_FRAME_HEADER_BYTES))
|
||||
|
||||
const pending = stream.local.writableLength ?? 0
|
||||
|
||||
// Gateway → local: a proxy that does not read must not make this process hold the data.
|
||||
// First stop reading the gateway; if the backlog still reaches 1 MiB, give the stream up.
|
||||
if (pending > RELAY_BUFFER_LIMIT_BYTES) {
|
||||
endStream(id, stream, 4408, true)
|
||||
} else if (pending > BUFFER_RESUME_BYTES && ws.pause && !stalled.has(stream)) {
|
||||
stalled.add(stream)
|
||||
ws.pause()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let message: any
|
||||
|
||||
try {
|
||||
message = JSON.parse(typeof data === 'string' ? data : Buffer.isBuffer(data) ? data.toString('utf8') : String(data))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (!message || typeof message !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
if (message.t === 'ping') {
|
||||
return control({ t: 'pong' })
|
||||
}
|
||||
|
||||
if (!validStreamId(message.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (message.t === 'open') {
|
||||
return openStream(message.id)
|
||||
}
|
||||
|
||||
if (message.t === 'close') {
|
||||
const stream = streams.get(message.id)
|
||||
|
||||
if (stream) {
|
||||
endStream(message.id, stream, 1000, false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const api = {
|
||||
state: (): RelayState => state,
|
||||
|
||||
/** Open phone streams right now. */
|
||||
streams: (): number => streams.size,
|
||||
|
||||
/**
|
||||
* Makes sure a registration exists (stored or new) and returns its id; null
|
||||
* when the gateway or the vault is unavailable. Does not open the socket.
|
||||
*/
|
||||
async ensureRegistered(): Promise<null | string> {
|
||||
const current = await register()
|
||||
|
||||
return 'error' in current ? null : current.relayId
|
||||
},
|
||||
|
||||
/**
|
||||
* "Röle kimliğini yenile": take a NEW registration. The old one is replaced
|
||||
* only once the new one is stored; false = nothing changed.
|
||||
*/
|
||||
async renew(): Promise<boolean> {
|
||||
const fresh = await registerRelaySession(deps.fetch, { sleep })
|
||||
|
||||
if ('error' in fresh) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.credentials.save(fresh, { reissued: true })
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
credentials = fresh
|
||||
rejected = false
|
||||
|
||||
if (wanted) {
|
||||
// Drop the socket that was authenticated with the old token and come back with the new one.
|
||||
api.stop()
|
||||
api.start()
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
start() {
|
||||
if (wanted) {
|
||||
return
|
||||
}
|
||||
|
||||
wanted = true
|
||||
attempt = 0
|
||||
void connect()
|
||||
},
|
||||
|
||||
stop() {
|
||||
wanted = false
|
||||
generation++
|
||||
clearTimers()
|
||||
|
||||
const ws = socket
|
||||
|
||||
socket = null
|
||||
dropStreams()
|
||||
|
||||
if (ws) {
|
||||
try {
|
||||
ws.close(1000)
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
|
||||
setState('off')
|
||||
}
|
||||
}
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
export type RelayClient = ReturnType<typeof createRelayClient>
|
||||
@@ -278,6 +278,28 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
setDisableF12: blocked => ipcRenderer.send('hermes:devtools:disable-f12', blocked),
|
||||
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
||||
aiturkLogin: {
|
||||
// One-click TurkServis sign-in. API keys never cross this bridge: main
|
||||
// saves the provider and hands the CLI its key (electron/aiturk-login.ts).
|
||||
start: scope => ipcRenderer.invoke('aiturk:login:start', scope),
|
||||
poll: () => ipcRenderer.invoke('aiturk:login:poll'),
|
||||
cancel: () => ipcRenderer.invoke('aiturk:login:cancel'),
|
||||
overwriteCli: () => ipcRenderer.invoke('aiturk:login:overwrite-cli')
|
||||
},
|
||||
aiturkMobile: {
|
||||
// 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
|
||||
|
||||
@@ -173,3 +173,21 @@ test('reset rejects with a typed error and never enters either backend', async (
|
||||
assert.equal(options.connectRemote.mock.calls.length, 0)
|
||||
assert.equal(options.ensureLocalRuntime.mock.calls.length, 0)
|
||||
})
|
||||
|
||||
test('a sibling runtime completion re-resolves the primary profile without re-running the stale installer', async () => {
|
||||
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
|
||||
const installed = { kind: 'python', activeRoot: bootstrapBackend.activeRoot, args: ['--profile', 'fusion-frontier'] }
|
||||
const prepareLocalBackend = vi.fn().mockResolvedValueOnce(bootstrapBackend).mockResolvedValue(installed)
|
||||
const options = startupOptions({ waitForDecision: gate.wait, prepareLocalBackend })
|
||||
const pending = runPrimaryBackendStartup(options)
|
||||
await vi.waitFor(() => assert.equal(gate.hasWaiter(), true))
|
||||
gate.runtimeAvailable(bootstrapBackend.activeRoot)
|
||||
await pending
|
||||
assert.deepEqual(options.ensureLocalRuntime.mock.calls, [[installed]])
|
||||
})
|
||||
|
||||
test('a runtime that disappeared after readiness cannot silently trigger another installation', async () => {
|
||||
const options = startupOptions({ waitForDecision: async () => 'runtime-ready' as const })
|
||||
await assert.rejects(runPrimaryBackendStartup(options), /no longer available/)
|
||||
assert.equal(options.ensureLocalRuntime.mock.calls.length, 0)
|
||||
})
|
||||
@@ -109,5 +109,13 @@ export async function runPrimaryBackendStartup<Backend, RuntimeBackend, Remote,
|
||||
throw new FirstRunSetupResetError()
|
||||
}
|
||||
|
||||
if (decision === 'runtime-ready') {
|
||||
const current = await prepareLocalBackend()
|
||||
if ((current as { kind?: string }).kind === 'bootstrap-needed') {
|
||||
throw new Error('The completed local runtime is no longer available. Retry setup.')
|
||||
}
|
||||
return { kind: 'local', backend: await ensureLocalRuntime(current) }
|
||||
}
|
||||
|
||||
return { kind: 'local', backend: await ensureLocalRuntime(backend) }
|
||||
}
|
||||
@@ -2,12 +2,12 @@
|
||||
"name": "aiturk-ide",
|
||||
"productName": "AITURK IDE",
|
||||
"private": true,
|
||||
"version": "1.0.0-beta.12",
|
||||
"version": "1.0.0-beta.28",
|
||||
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
||||
"author": "AITURK / TurkServis",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://gitea.twinpay.one/yilsem/aiturk-hermes-ide.git"
|
||||
"url": "git+https://gitea.turkservis.online/yilsem/aiturk-hermes-ide.git"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/electron-main.mjs",
|
||||
@@ -299,6 +299,16 @@
|
||||
"*.dll",
|
||||
"manifest.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "assets/codex-bridge",
|
||||
"to": "codex-bridge",
|
||||
"filter": [
|
||||
"**/*.py",
|
||||
"**/*.pyd",
|
||||
"**/LICENSE",
|
||||
"!test_*.py"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -64,11 +64,13 @@ await build({
|
||||
logLevel: 'info',
|
||||
})
|
||||
console.log(`bundled ${preloadOut}${isDev ? ' (dev)' : ''}`)
|
||||
for (const name of ['mobile-host.html', 'mobile-host-ui.mjs', 'mobile-preload.cjs']) {
|
||||
for (const name of ['mobile-host.html', 'mobile-host-ui.mjs', 'mobile-host.css', 'mobile-preload.cjs']) {
|
||||
cpSync(resolve(root, 'electron', name), resolve(distDir, name))
|
||||
}
|
||||
mkdirSync(resolve(distDir, 'mobile'), { recursive: true })
|
||||
for (const name of ['transport.mjs', 'peer-identity.mjs', 'style.css', 'qr.mjs']) {
|
||||
// The phone window only needs the shared stylesheet and the QR drawer (which
|
||||
// imports ./vendor/qrcode.mjs, staged below).
|
||||
for (const name of ['style.css', 'qr.mjs']) {
|
||||
cpSync(resolve(root, '../mobile/src', name), resolve(distDir, 'mobile', name))
|
||||
}
|
||||
// A clean `npm ci` hoists the mobile vendor packages to the workspace root, so
|
||||
@@ -84,5 +86,3 @@ function mobilePackageDir(name) {
|
||||
}
|
||||
mkdirSync(resolve(distDir, 'mobile/vendor'), { recursive: true })
|
||||
cpSync(resolve(mobilePackageDir('qrcode-generator'), 'dist/qrcode.mjs'), resolve(distDir, 'mobile/vendor/qrcode.mjs'))
|
||||
cpSync(resolve(mobilePackageDir('jsqr'), 'dist/jsQR.js'), resolve(distDir, 'mobile/vendor/jsQR.js'))
|
||||
cpSync(resolve(mobilePackageDir('jsqr'), 'LICENSE'), resolve(distDir, 'mobile/vendor/jsQR-LICENSE.txt'))
|
||||
@@ -36,7 +36,18 @@ function electronBuilderCli() {
|
||||
return path.join(path.dirname(pkgJson), rel)
|
||||
}
|
||||
|
||||
const dist = electronDistDir()
|
||||
const cliArgs = process.argv.slice(2)
|
||||
// An installed Electron dist belongs to the host. Cross-platform packaging
|
||||
// must download the requested target instead of copying the host executable.
|
||||
const crossPlatform =
|
||||
(cliArgs.includes('--win') && process.platform !== 'win32') ||
|
||||
(cliArgs.includes('--mac') && process.platform !== 'darwin') ||
|
||||
(cliArgs.includes('--linux') && process.platform !== 'linux')
|
||||
const crossArch =
|
||||
(cliArgs.includes('--x64') && process.arch !== 'x64') ||
|
||||
(cliArgs.includes('--arm64') && process.arch !== 'arm64') ||
|
||||
(cliArgs.includes('--ia32') && process.arch !== 'ia32')
|
||||
const dist = crossPlatform || crossArch ? null : electronDistDir()
|
||||
// Local `hermes desktop` builds only ever package (--dir or dist), never
|
||||
// publish a GitHub release — no CI workflow drives this script. But the npm
|
||||
// lifecycle env sets CI=1 (so esbuild's postinstall doesn't try interactive
|
||||
@@ -55,7 +66,7 @@ if (dist && fs.existsSync(distBinary(dist))) {
|
||||
"via @electron/get (electronVersion + ELECTRON_MIRROR)."
|
||||
)
|
||||
}
|
||||
args.push(...process.argv.slice(2))
|
||||
args.push(...cliArgs)
|
||||
|
||||
const result = spawnSync(process.execPath, [electronBuilderCli(), ...args], {
|
||||
stdio: "inherit",
|
||||
|
||||
@@ -546,7 +546,7 @@ export function stageGetWindowsInto(
|
||||
|
||||
export function installGetWindowsNativeBinding(
|
||||
srcRoot,
|
||||
{ resolveInstaller, spawn = spawnSync } = {}
|
||||
{ resolveInstaller, spawn = spawnSync, platform = process.platform, arch = process.arch } = {}
|
||||
) {
|
||||
let installerPath
|
||||
try {
|
||||
@@ -562,7 +562,11 @@ export function installGetWindowsNativeBinding(
|
||||
throw new Error(`[stage-native-deps] cannot resolve get-windows native installer: ${detail}`)
|
||||
}
|
||||
|
||||
const result = spawn(process.execPath, [installerPath, 'install', '--fallback-to-build'], {
|
||||
const crossTarget = platform !== process.platform || arch !== process.arch
|
||||
const installerArgs = crossTarget
|
||||
? ['install', `--target_platform=${platform}`, `--target_arch=${arch}`, ...(platform === 'win32' ? ['--target_libc=unknown'] : []), '--fallback-to-build=false']
|
||||
: ['install', '--fallback-to-build']
|
||||
const result = spawn(process.execPath, [installerPath, ...installerArgs], {
|
||||
cwd: srcRoot,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
@@ -606,11 +610,11 @@ export function stageGetWindows(
|
||||
)
|
||||
}
|
||||
|
||||
// Only a win32 host can produce the win32 binding, so a cross-platform pack
|
||||
// has nothing to gain from the native installer.
|
||||
// Download the target prebuild even on Linux/Wine; cross-target installs
|
||||
// never fall back to compiling a host binary.
|
||||
const install =
|
||||
platform === 'win32' && process.platform === 'win32'
|
||||
? () => installGetWindowsNativeBinding(srcRoot)
|
||||
platform === 'win32' && arch !== 'arm64'
|
||||
? () => installGetWindowsNativeBinding(srcRoot, { platform, arch })
|
||||
: undefined
|
||||
return stageGetWindowsInto(srcRoot, destRoot, { platform, arch, install })
|
||||
}
|
||||
|
||||
@@ -594,7 +594,9 @@ test('darwin staging ships the Swift helper executable and the rewritten windows
|
||||
|
||||
stageGetWindowsInto(srcRoot, destRoot, { platform: 'darwin' })
|
||||
|
||||
assert.equal(fs.statSync(join(destRoot, 'main')).mode & 0o777, 0o755)
|
||||
if (process.platform !== 'win32') {
|
||||
assert.equal(fs.statSync(join(destRoot, 'main')).mode & 0o777, 0o755)
|
||||
}
|
||||
const staged = fs.readFileSync(join(destRoot, 'lib', 'windows.js'), 'utf8')
|
||||
assert.match(staged, /Rewritten by stage-native-deps\.mjs/)
|
||||
assert.ok(!staged.includes('node-pre-gyp'), 'pre-gyp loader must not survive staging')
|
||||
@@ -635,3 +637,16 @@ test('win32-x64 staging fails when get-windows is absent', () => {
|
||||
/get-windows is not installed/
|
||||
)
|
||||
})
|
||||
|
||||
// Linux/Wine packaging must fetch the Windows payload, never compile for Linux.
|
||||
test('get-windows cross-target install requests the target prebuild without compilation fallback', () => {
|
||||
const calls = []
|
||||
const platform = process.platform === 'win32' ? 'linux' : 'win32'
|
||||
installGetWindowsNativeBinding('/fake/get-windows', {
|
||||
platform, arch: 'x64', resolveInstaller: () => '/fake/node-pre-gyp',
|
||||
spawn: (command, args) => { calls.push(args); return { status: 0 } }
|
||||
})
|
||||
assert.deepEqual(calls[0], ['/fake/node-pre-gyp', 'install',
|
||||
`--target_platform=${platform}`, '--target_arch=x64',
|
||||
...(platform === 'win32' ? ['--target_libc=unknown'] : []), '--fallback-to-build=false'])
|
||||
})
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { ListRow, SectionHeading, SettingsContent } from './primitives'
|
||||
import { UninstallSection } from './uninstall-section'
|
||||
|
||||
const RELEASE_NOTES_URL = 'https://gitea.twinpay.one/yilsem/aiturk-hermes-ide/releases'
|
||||
const RELEASE_NOTES_URL = 'https://gitea.turkservis.online/yilsem/aiturk-hermes-ide/releases'
|
||||
const INSTALLER_URL = 'https://turkservis.online/ide'
|
||||
|
||||
function relativeTime(ms: number | undefined, a: Translations['settings']['about']) {
|
||||
@@ -106,7 +106,7 @@ export function AboutSettings() {
|
||||
<p className="text-sm text-muted-foreground">{copy.about}</p>
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<a className="text-primary underline" href={INSTALLER_URL} rel="noreferrer" target="_blank">{copy.downloads}</a>
|
||||
<a className="text-primary underline" href="https://gitea.twinpay.one/yilsem/aiturk-hermes-ide" rel="noreferrer" target="_blank">{copy.source}</a>
|
||||
<a className="text-primary underline" href="https://gitea.turkservis.online/yilsem/aiturk-hermes-ide" rel="noreferrer" target="_blank">{copy.source}</a>
|
||||
</div>
|
||||
</section>
|
||||
<div className="flex flex-col items-center gap-3 pt-6 pb-2 text-center">
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n/context'
|
||||
|
||||
import type { CodexLinkStatus } from '../../../electron/codex-link-types'
|
||||
|
||||
import { CodexLinkSettings } from './codex-link-settings'
|
||||
|
||||
const notifyError = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/store/notifications', () => ({ notifyError }))
|
||||
|
||||
const RUNNING: CodexLinkStatus = {
|
||||
enabled: true,
|
||||
codexFound: true,
|
||||
venvReady: true,
|
||||
foreign: false,
|
||||
bridgeRunning: true,
|
||||
ideDurum: 'tamam',
|
||||
ideModel: 12,
|
||||
ozet: true,
|
||||
lastError: null
|
||||
}
|
||||
|
||||
const get = vi.fn<() => Promise<CodexLinkStatus>>()
|
||||
const set = vi.fn<(patch: { enabled?: boolean; sync?: boolean }) => Promise<CodexLinkStatus>>()
|
||||
|
||||
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(() => {
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { codexLink: { get, set } }
|
||||
})
|
||||
|
||||
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('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('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('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('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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
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 type { CodexLinkPatch, CodexLinkStatus } from '../../../electron/codex-link-types'
|
||||
|
||||
import { EmptyState, ListRow, ListRowSkeleton, SettingsContent, SettingsSection } from './primitives'
|
||||
|
||||
type Tone = 'muted' | 'ok' | 'warn'
|
||||
|
||||
interface StatusLine {
|
||||
id: string
|
||||
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 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)
|
||||
|
||||
useEffect(() => {
|
||||
if (!bridge) {
|
||||
return
|
||||
}
|
||||
|
||||
const ticket = generation.current
|
||||
|
||||
bridge.get().then(
|
||||
next => {
|
||||
if (generation.current === ticket) {
|
||||
setStatus(next)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (generation.current === ticket) {
|
||||
setLoadFailed(true)
|
||||
}
|
||||
}
|
||||
)
|
||||
}, [bridge])
|
||||
|
||||
const change = useCallback(
|
||||
async (patch: CodexLinkPatch, kind: 'sync' | 'toggle') => {
|
||||
if (!bridge || busy) {
|
||||
return
|
||||
}
|
||||
|
||||
const before = status
|
||||
generation.current += 1
|
||||
setBusy(kind)
|
||||
setLoadFailed(false)
|
||||
|
||||
// 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 })
|
||||
}
|
||||
|
||||
try {
|
||||
setStatus(await bridge.set(patch))
|
||||
} catch (error) {
|
||||
setStatus(before)
|
||||
notifyError(error, copy.saveFailed)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
},
|
||||
[bridge, busy, copy.saveFailed, status]
|
||||
)
|
||||
|
||||
if (!bridge) {
|
||||
return (
|
||||
<SettingsContent>
|
||||
<EmptyState description={copy.unavailable} title={copy.title} />
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
const lines = status ? codexLinkStatusLines(status, copy) : []
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<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')
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
description={copy.description}
|
||||
title={copy.title}
|
||||
/>
|
||||
|
||||
{!status && !loadFailed && <ListRowSkeleton wide />}
|
||||
|
||||
{loadFailed && !status && (
|
||||
<p className="py-3 text-[length:var(--conversation-caption-font-size)] text-amber-500/90" role="alert">
|
||||
{copy.loadFailed}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { AiturkLogin } from '@/components/aiturk-login'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -80,7 +81,10 @@ function toPayload(form: EndpointForm, models?: string[]): CustomEndpointUpdate
|
||||
|
||||
export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: CustomEndpointsSettingsProps) {
|
||||
const { t } = useI18n()
|
||||
const c = useAiturkCopy().custom
|
||||
const aiturkCopy = useAiturkCopy()
|
||||
const c = aiturkCopy.custom
|
||||
const loginCopy = aiturkCopy.login
|
||||
const loginLabel = loginCopy.settingsSignIn
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
@@ -129,6 +133,31 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
|
||||
}
|
||||
}, [])
|
||||
|
||||
// One-click sign-in saved the TurkServis provider in the main process:
|
||||
// reload the list and tell the app its default model changed.
|
||||
async function handleAiturkConnected() {
|
||||
try {
|
||||
const data = await getCustomEndpoints()
|
||||
setEndpoints(data.endpoints)
|
||||
const current = data.endpoints.find(endpoint => endpoint.id === 'turkservis')
|
||||
|
||||
if (current) {
|
||||
setForm(formFromEndpoint(current))
|
||||
setDiscoveredModels(current.models)
|
||||
|
||||
if (current.is_current) {
|
||||
onMainModelChanged?.(current.id, current.model)
|
||||
}
|
||||
}
|
||||
|
||||
triggerHaptic('success')
|
||||
onConfigSaved?.()
|
||||
notify({ kind: 'success', message: loginCopy.connected })
|
||||
} catch (err) {
|
||||
notifyError(err, c.loadFailed)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
setSaving(true)
|
||||
@@ -298,10 +327,13 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<Button className="mb-3" onClick={() => {
|
||||
setForm({ ...EMPTY_FORM, name: 'TurkServis · AITURK', baseUrl: AITURK_API_BASE_URL })
|
||||
setDiscoveredModels([])
|
||||
}} variant="outline">{c.preset}</Button>
|
||||
<div className="mb-3 flex flex-wrap items-start gap-2">
|
||||
<AiturkLogin label={loginLabel} onConnected={handleAiturkConnected} />
|
||||
<Button onClick={() => {
|
||||
setForm({ ...EMPTY_FORM, name: 'TurkServis · AITURK', baseUrl: AITURK_API_BASE_URL })
|
||||
setDiscoveredModels([])
|
||||
}} variant="outline">{c.preset}</Button>
|
||||
</div>
|
||||
<SectionHeading icon={Plus} title={form.id ? c.edit : c.add} />
|
||||
<div className="grid gap-3 rounded-md border border-border/50 p-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { IconDeviceMobile } from '@tabler/icons-react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router'
|
||||
|
||||
@@ -7,6 +8,8 @@ import { KbdCombo } from '@/components/ui/kbd'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { useAiturkCopy } from '@/i18n/aiturk'
|
||||
import { useCodexLinkCopy } from '@/i18n/codex-link'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import {
|
||||
Archive,
|
||||
@@ -18,6 +21,7 @@ import {
|
||||
Info,
|
||||
Keyboard,
|
||||
KeyRound,
|
||||
Link,
|
||||
Package,
|
||||
RefreshCw,
|
||||
Search,
|
||||
@@ -44,6 +48,7 @@ import { SKILLS_ROUTE } from '../routes'
|
||||
import { AboutSettings } from './about-settings'
|
||||
import { AppearanceSettings } from './appearance-settings'
|
||||
import { BillingSettings } from './billing'
|
||||
import { CodexLinkSettings } from './codex-link-settings'
|
||||
import { ConfigSettings } from './config-settings'
|
||||
import { SECTIONS } from './constants'
|
||||
import { GatewaySettings } from './gateway-settings'
|
||||
@@ -66,6 +71,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
'keys',
|
||||
'notifications',
|
||||
'billing',
|
||||
'codex-link',
|
||||
'plugins',
|
||||
'sessions',
|
||||
'about'
|
||||
@@ -73,6 +79,8 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
|
||||
export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
||||
const { t } = useI18n()
|
||||
const aiturkCopy = useAiturkCopy()
|
||||
const codexLinkCopy = useCodexLinkCopy()
|
||||
const navigate = useNavigate()
|
||||
const { hash, pathname, search } = useLocation()
|
||||
|
||||
@@ -249,6 +257,34 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
||||
label: t.settings.nav.gateway,
|
||||
onSelect: () => setActiveView('gateway')
|
||||
},
|
||||
// "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
|
||||
? [
|
||||
{
|
||||
active: false,
|
||||
icon: IconDeviceMobile,
|
||||
id: 'aiturk-phone',
|
||||
label: aiturkCopy.mobile.nav,
|
||||
onSelect: () => {
|
||||
void window.hermesDesktop?.aiturkMobile?.open().catch(error => notifyError(error, aiturkCopy.mobile.openFailed))
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
// "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,
|
||||
@@ -302,7 +338,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
||||
onSelect: () => setActiveView('about')
|
||||
}
|
||||
],
|
||||
[activeView, keysView, providerView, t, setActiveView, openProviderView, openKeysView]
|
||||
[activeView, keysView, providerView, t, aiturkCopy, codexLinkCopy, setActiveView, openProviderView, openKeysView]
|
||||
)
|
||||
|
||||
// Type-to-search: printable keystrokes on the Settings surface (outside any
|
||||
@@ -421,6 +457,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
||||
<BillingSettings />
|
||||
) : activeView === 'plugins' ? (
|
||||
<PluginsSettings />
|
||||
) : activeView === 'codex-link' ? (
|
||||
<CodexLinkSettings />
|
||||
) : (
|
||||
<SessionsSettings />
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { EnvVarInfo } from '@/types/hermes'
|
||||
export type SettingsView =
|
||||
| 'about'
|
||||
| 'billing'
|
||||
| 'codex-link'
|
||||
| 'connections'
|
||||
| 'gateway'
|
||||
| 'keybinds'
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { AiturkCliStatus } from '@/global'
|
||||
import { useAiturkCopy } from '@/i18n/aiturk'
|
||||
import { aiturkLoginScope } from '@/lib/aiturk-provider'
|
||||
import { Loader2 } from '@/lib/icons'
|
||||
import { notify } from '@/store/notifications'
|
||||
|
||||
const POLL_INTERVAL_MS = 3000
|
||||
|
||||
type Phase = 'idle' | 'starting' | 'waiting' | 'question' | 'replacing'
|
||||
|
||||
interface Waiting {
|
||||
userCode: string
|
||||
verificationUri: string
|
||||
}
|
||||
|
||||
type LoginCopy = ReturnType<typeof useAiturkCopy>
|
||||
|
||||
function errorCode(error: unknown): string {
|
||||
// The main process only ever throws fixed codes ("aiturk-login:<code>").
|
||||
return /aiturk-login:([a-z-]+)/.exec(error instanceof Error ? error.message : String(error))?.[1] ?? ''
|
||||
}
|
||||
|
||||
function errorText(code: string, copy: LoginCopy): string {
|
||||
switch (code) {
|
||||
case 'rate-limited':
|
||||
return copy.login.rateLimited
|
||||
|
||||
case 'browser-failed':
|
||||
return copy.login.browserFailed
|
||||
|
||||
case 'no-models':
|
||||
return copy.noModels
|
||||
|
||||
case 'provider-failed':
|
||||
|
||||
case 'invalid-response':
|
||||
return copy.failed
|
||||
|
||||
default:
|
||||
return copy.login.unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function cliText(cli: AiturkCliStatus, copy: LoginCopy): string {
|
||||
switch (cli) {
|
||||
case 'ok':
|
||||
return copy.login.cliOk
|
||||
|
||||
case 'outdated':
|
||||
return copy.login.cliOutdated
|
||||
|
||||
case 'failed':
|
||||
|
||||
case 'unknown-account':
|
||||
return copy.login.cliFailed
|
||||
|
||||
default:
|
||||
// 'missing' stays silent; 'other-account' is asked as a question.
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-click TurkServis sign-in, shared by onboarding and Settings.
|
||||
*
|
||||
* The API keys never reach this component: the main process shows us only the
|
||||
* short code, then reports `connected` once it has saved the provider and
|
||||
* handed the AITURK CLI its own key.
|
||||
*/
|
||||
export function useAiturkLogin(onConnected: () => Promise<void> | void) {
|
||||
const copy = useAiturkCopy()
|
||||
const [phase, setPhase] = useState<Phase>('idle')
|
||||
const [waiting, setWaiting] = useState<null | Waiting>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [info, setInfo] = useState('')
|
||||
const live = useRef(false)
|
||||
const mounted = useRef(true)
|
||||
const onConnectedRef = useRef(onConnected)
|
||||
const copyRef = useRef(copy)
|
||||
|
||||
onConnectedRef.current = onConnected
|
||||
copyRef.current = copy
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- mount flag + in-flight login flag, not an atom mirror
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
|
||||
return () => {
|
||||
mounted.current = false
|
||||
|
||||
// Leaving mid-login: revoke the code and drop anything main still holds.
|
||||
if (live.current) {
|
||||
live.current = false
|
||||
void window.hermesDesktop?.aiturkLogin?.cancel().catch(() => {})
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const finish = useCallback(async (cli: AiturkCliStatus) => {
|
||||
live.current = false
|
||||
const text = cliText(cli, copyRef.current)
|
||||
|
||||
if (mounted.current) {
|
||||
setWaiting(null)
|
||||
setPhase('idle')
|
||||
setInfo(text)
|
||||
}
|
||||
|
||||
try {
|
||||
await onConnectedRef.current()
|
||||
} catch {
|
||||
// The provider is saved; a failed refresh is the caller's concern.
|
||||
}
|
||||
|
||||
// Onboarding closes this panel as soon as it is connected; keep the CLI
|
||||
// note visible as a toast in that case.
|
||||
if (!mounted.current && text) {
|
||||
notify({ kind: cli === 'ok' ? 'success' : 'info', message: text })
|
||||
}
|
||||
}, [])
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- clears the in-flight login flag when polling ends
|
||||
useEffect(() => {
|
||||
if (phase !== 'waiting') {
|
||||
return
|
||||
}
|
||||
|
||||
let stopped = false
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const tick = async () => {
|
||||
const bridge = window.hermesDesktop?.aiturkLogin
|
||||
|
||||
if (stopped || !bridge) {
|
||||
return
|
||||
}
|
||||
|
||||
let result
|
||||
|
||||
try {
|
||||
result = await bridge.poll()
|
||||
} catch {
|
||||
result = { state: 'pending' as const }
|
||||
}
|
||||
|
||||
if (stopped) {
|
||||
return
|
||||
}
|
||||
|
||||
if (result.state === 'pending') {
|
||||
timer = setTimeout(() => void tick(), POLL_INTERVAL_MS)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (result.state === 'connected') {
|
||||
if (result.cli === 'other-account') {
|
||||
setWaiting(null)
|
||||
setPhase('question')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await finish(result.cli)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
live.current = false
|
||||
setWaiting(null)
|
||||
setPhase('idle')
|
||||
setError(result.state === 'expired' ? copyRef.current.login.expired : errorText(result.error, copyRef.current))
|
||||
}
|
||||
|
||||
timer = setTimeout(() => void tick(), POLL_INTERVAL_MS)
|
||||
|
||||
return () => {
|
||||
stopped = true
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [phase, finish])
|
||||
|
||||
const start = useCallback(async () => {
|
||||
const bridge = window.hermesDesktop?.aiturkLogin
|
||||
|
||||
setError('')
|
||||
setInfo('')
|
||||
|
||||
if (!bridge) {
|
||||
setError(copyRef.current.login.unsupported)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setPhase('starting')
|
||||
|
||||
try {
|
||||
const started = await bridge.start(aiturkLoginScope())
|
||||
|
||||
if (!mounted.current) {
|
||||
void bridge.cancel().catch(() => {})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
live.current = true
|
||||
setWaiting({ userCode: started.userCode, verificationUri: started.verificationUri })
|
||||
setPhase('waiting')
|
||||
} catch (err) {
|
||||
if (mounted.current) {
|
||||
setPhase('idle')
|
||||
setError(errorText(errorCode(err), copyRef.current))
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
live.current = false
|
||||
setWaiting(null)
|
||||
setPhase('idle')
|
||||
void window.hermesDesktop?.aiturkLogin?.cancel().catch(() => {})
|
||||
}, [])
|
||||
|
||||
const reopen = useCallback(() => {
|
||||
if (waiting) {
|
||||
void window.hermesDesktop?.openExternal(waiting.verificationUri).catch(() => {})
|
||||
}
|
||||
}, [waiting])
|
||||
|
||||
const replaceCli = useCallback(async () => {
|
||||
setPhase('replacing')
|
||||
|
||||
let cli: AiturkCliStatus = 'failed'
|
||||
|
||||
try {
|
||||
cli = (await window.hermesDesktop?.aiturkLogin?.overwriteCli())?.cli ?? 'failed'
|
||||
} catch {
|
||||
cli = 'failed'
|
||||
}
|
||||
|
||||
await finish(cli === 'other-account' ? 'failed' : cli)
|
||||
}, [finish])
|
||||
|
||||
const keepCli = useCallback(async () => {
|
||||
void window.hermesDesktop?.aiturkLogin?.cancel().catch(() => {})
|
||||
await finish('missing')
|
||||
}, [finish])
|
||||
|
||||
return { phase, waiting, error, info, start, cancel, reopen, replaceCli, keepCli }
|
||||
}
|
||||
|
||||
interface AiturkLoginProps {
|
||||
/** Runs once the TurkServis provider is saved (refresh lists, advance onboarding…). */
|
||||
onConnected: () => Promise<void> | void
|
||||
/** Idle button label; defaults to "Giriş yap". */
|
||||
label?: string
|
||||
variant?: 'default' | 'outline'
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function AiturkLogin({ onConnected, label, variant = 'default', disabled }: AiturkLoginProps) {
|
||||
const copy = useAiturkCopy()
|
||||
const login = useAiturkLogin(onConnected)
|
||||
|
||||
if (login.phase === 'waiting' && login.waiting) {
|
||||
return (
|
||||
<div className="grid gap-3" data-testid="aiturk-login-code">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">{copy.login.codeLabel}</div>
|
||||
<output aria-label={copy.login.codeLabel} className="block font-mono text-3xl font-semibold tracking-[0.3em]">
|
||||
{login.waiting.userCode}
|
||||
</output>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{copy.login.confirmHint}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={login.reopen} size="sm" variant="outline">
|
||||
{copy.login.reopen}
|
||||
</Button>
|
||||
<Button onClick={login.cancel} size="sm" variant="ghost">
|
||||
{copy.login.cancel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (login.phase === 'question' || login.phase === 'replacing') {
|
||||
const replacing = login.phase === 'replacing'
|
||||
|
||||
return (
|
||||
<div className="grid gap-3" role="group">
|
||||
<p className="text-sm">{copy.login.cliOtherAccount}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={replacing} onClick={() => void login.replaceCli()} size="sm">
|
||||
{replacing ? <Loader2 className="animate-spin" /> : null}
|
||||
{copy.login.cliReplace}
|
||||
</Button>
|
||||
<Button disabled={replacing} onClick={() => void login.keepCli()} size="sm" variant="outline">
|
||||
{copy.login.cliKeep}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const starting = login.phase === 'starting'
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<div>
|
||||
<Button disabled={disabled || starting} onClick={() => void login.start()} variant={variant}>
|
||||
{starting ? <Loader2 className="animate-spin" /> : null}
|
||||
{starting ? copy.login.starting : (label ?? copy.login.signIn)}
|
||||
</Button>
|
||||
</div>
|
||||
{login.error ? (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{login.error}
|
||||
</p>
|
||||
) : null}
|
||||
{login.info ? (
|
||||
<p className="text-sm text-muted-foreground" role="status">
|
||||
{login.info}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { saveCustomEndpoint, validateCustomEndpoint } from '@/hermes'
|
||||
@@ -9,7 +9,12 @@ import { AiturkProvider } from './aiturk-provider'
|
||||
vi.mock('@/hermes', () => ({ saveCustomEndpoint: vi.fn(), validateCustomEndpoint: vi.fn() }))
|
||||
vi.mock('./flow', () => ({ DocsLink: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a> }))
|
||||
|
||||
afterEach(() => { cleanup(); vi.resetAllMocks() })
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.resetAllMocks()
|
||||
vi.useRealTimers()
|
||||
delete (window as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
function setup() {
|
||||
const onConnected = vi.fn().mockResolvedValue(undefined)
|
||||
@@ -17,6 +22,7 @@ function setup() {
|
||||
const input = screen.getByLabelText('TurkServis API anahtarı') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { value: 'test-key-never-real' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Bağlan' }))
|
||||
|
||||
return { input, onConnected }
|
||||
}
|
||||
|
||||
@@ -47,3 +53,163 @@ describe('TurkServis provider setup', () => {
|
||||
expect(saveCustomEndpoint).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
const URI = 'https://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e'
|
||||
|
||||
function setupLogin(polls: unknown[]) {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const bridge = {
|
||||
start: vi.fn().mockResolvedValue({ userCode: 'ABCD2345', verificationUri: URI, expiresAt: Date.now() + 600_000 }),
|
||||
poll: vi.fn(async () => polls.shift() ?? { state: 'pending' }),
|
||||
cancel: vi.fn().mockResolvedValue({ ok: true }),
|
||||
overwriteCli: vi.fn().mockResolvedValue({ cli: 'ok' })
|
||||
}
|
||||
|
||||
const openExternal = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { aiturkLogin: bridge, openExternal }
|
||||
const onConnected = vi.fn().mockResolvedValue(undefined)
|
||||
render(<I18nProvider configClient={null}><AiturkProvider onConnected={onConnected} /></I18nProvider>)
|
||||
|
||||
return { bridge, onConnected, openExternal }
|
||||
}
|
||||
|
||||
const click = async (name: string) => {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
const tick = () =>
|
||||
act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
})
|
||||
|
||||
describe('TurkServis one-click sign-in', () => {
|
||||
it('shows the code, lets the user reopen the page and polls every 3 seconds', async () => {
|
||||
const { bridge, onConnected, openExternal } = setupLogin([{ state: 'pending' }, { state: 'pending' }])
|
||||
await click('Giriş yap')
|
||||
|
||||
expect(bridge.start).toHaveBeenCalledOnce()
|
||||
expect(screen.getByLabelText('Giriş kodu').textContent).toBe('ABCD2345')
|
||||
expect(screen.getByText('Tarayıcıda açılan sayfadaki kod bununla aynıysa onaylayın.')).toBeTruthy()
|
||||
expect(bridge.poll).not.toHaveBeenCalled()
|
||||
|
||||
await tick()
|
||||
expect(bridge.poll).toHaveBeenCalledTimes(1)
|
||||
await tick()
|
||||
expect(bridge.poll).toHaveBeenCalledTimes(2)
|
||||
|
||||
await click('Sayfayı yeniden aç')
|
||||
expect(openExternal).toHaveBeenCalledWith(URI)
|
||||
expect(onConnected).not.toHaveBeenCalled()
|
||||
// No key ever reaches the renderer through this flow.
|
||||
expect(saveCustomEndpoint).not.toHaveBeenCalled()
|
||||
expect(validateCustomEndpoint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onConnected once the main process reports the provider connected', async () => {
|
||||
const { bridge, onConnected } = setupLogin([{ state: 'pending' }, { state: 'connected', cli: 'ok' }])
|
||||
await click('Giriş yap')
|
||||
await tick()
|
||||
expect(onConnected).not.toHaveBeenCalled()
|
||||
await tick()
|
||||
|
||||
expect(onConnected).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('status').textContent).toBe('AITURK CLI de bu hesapla hazır.')
|
||||
expect(screen.queryByLabelText('Giriş kodu')).toBeNull()
|
||||
await tick()
|
||||
expect(bridge.poll).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing', ''],
|
||||
['outdated', 'AITURK CLI güncellenince kendiliğinden bağlanması için yeniden giriş yapın.'],
|
||||
['failed', 'AITURK CLI bağlanamadı; terminalde `aiturk login` çalıştırın.'],
|
||||
['unknown-account', 'AITURK CLI bağlanamadı; terminalde `aiturk login` çalıştırın.']
|
||||
])('connects and explains CLI result %s', async (cli, text) => {
|
||||
const { onConnected } = setupLogin([{ state: 'connected', cli }])
|
||||
await click('Giriş yap')
|
||||
await tick()
|
||||
|
||||
expect(onConnected).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByRole('status')?.textContent ?? '').toBe(text)
|
||||
})
|
||||
|
||||
it('asks before replacing another CLI account, then replaces on confirm', async () => {
|
||||
const { bridge, onConnected } = setupLogin([{ state: 'connected', cli: 'other-account' }])
|
||||
await click('Giriş yap')
|
||||
await tick()
|
||||
|
||||
expect(screen.getByText('AITURK CLI’de başka bir hesap kayıtlı. Bu hesapla değiştirilsin mi?')).toBeTruthy()
|
||||
expect(onConnected).not.toHaveBeenCalled()
|
||||
expect(bridge.overwriteCli).not.toHaveBeenCalled()
|
||||
|
||||
await click('Değiştir')
|
||||
expect(bridge.overwriteCli).toHaveBeenCalledOnce()
|
||||
expect(onConnected).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('status').textContent).toBe('AITURK CLI de bu hesapla hazır.')
|
||||
})
|
||||
|
||||
it('leaves the other CLI account alone when the user says so', async () => {
|
||||
const { bridge, onConnected } = setupLogin([{ state: 'connected', cli: 'other-account' }])
|
||||
await click('Giriş yap')
|
||||
await tick()
|
||||
await click('Olduğu gibi bırak')
|
||||
|
||||
expect(bridge.overwriteCli).not.toHaveBeenCalled()
|
||||
expect(bridge.cancel).toHaveBeenCalledOnce()
|
||||
expect(onConnected).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cancels on Vazgeç and returns to the sign-in button', async () => {
|
||||
const { bridge, onConnected } = setupLogin([])
|
||||
await click('Giriş yap')
|
||||
await click('Vazgeç')
|
||||
|
||||
expect(bridge.cancel).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('button', { name: 'Giriş yap' })).toBeTruthy()
|
||||
await tick()
|
||||
expect(bridge.poll).not.toHaveBeenCalled()
|
||||
expect(onConnected).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports an expired code and a failed start without connecting', async () => {
|
||||
const { bridge, onConnected } = setupLogin([{ state: 'expired' }])
|
||||
await click('Giriş yap')
|
||||
await tick()
|
||||
expect(screen.getByRole('alert').textContent).toBe('Giriş kodunun süresi doldu. Yeniden deneyin.')
|
||||
|
||||
bridge.start.mockRejectedValueOnce(
|
||||
new Error("Error invoking remote method 'aiturk:login:start': AiturkLoginError: aiturk-login:rate-limited")
|
||||
)
|
||||
await click('Giriş yap')
|
||||
expect(screen.getByRole('alert').textContent).toBe('Çok fazla deneme yapıldı. Biraz sonra yeniden deneyin.')
|
||||
expect(onConnected).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('revokes a pending login when the panel goes away', async () => {
|
||||
const { bridge } = setupLogin([])
|
||||
await click('Giriş yap')
|
||||
cleanup()
|
||||
|
||||
expect(bridge.cancel).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('still connects with a pasted API key', async () => {
|
||||
vi.mocked(validateCustomEndpoint).mockResolvedValue({ ok: true, reachable: true, models: ['account/model-a'], message: '' })
|
||||
vi.mocked(saveCustomEndpoint).mockResolvedValue({ id: 'turkservis', endpoints: [], current: { base_url: 'https://ai.turkservis.online/v1', model: 'account/model-a', provider: 'turkservis' }, ok: true })
|
||||
const { bridge, onConnected } = setupLogin([])
|
||||
vi.useRealTimers()
|
||||
|
||||
expect(screen.getByText('ya da API anahtarı yapıştırın')).toBeTruthy()
|
||||
fireEvent.change(screen.getByLabelText('TurkServis API anahtarı'), { target: { value: 'test-key-never-real' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Bağlan' }))
|
||||
|
||||
await waitFor(() => expect(onConnected).toHaveBeenCalledOnce())
|
||||
expect(saveCustomEndpoint).toHaveBeenCalledWith(expect.objectContaining({ api_key: 'test-key-never-real', model: 'account/model-a' }))
|
||||
expect(bridge.start).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { AiturkLogin } from '@/components/aiturk-login'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { saveCustomEndpoint, validateCustomEndpoint } from '@/hermes'
|
||||
@@ -18,18 +19,22 @@ export function AiturkProvider({ onConnected }: { onConnected: () => Promise<voi
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function connect() {
|
||||
if (busy || !key.trim()) return
|
||||
if (busy || !key.trim()) {return}
|
||||
setBusy(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
// Discovery goes through the connected backend's existing credential
|
||||
// boundary. Do not ship a shared key or hard-code a model catalog.
|
||||
const checked = await validateCustomEndpoint(aiturkEndpoint(key))
|
||||
if (!checked.ok) throw new Error(checked.message || copy.failed)
|
||||
|
||||
if (!checked.ok) {throw new Error(checked.message || copy.failed)}
|
||||
const model = checked.models[0]
|
||||
if (!model) throw new Error(copy.noModels)
|
||||
|
||||
if (!model) {throw new Error(copy.noModels)}
|
||||
const saved = await saveCustomEndpoint(aiturkEndpoint(key, model, checked.models))
|
||||
if (saved.ok === false) throw new Error(copy.failed)
|
||||
|
||||
if (saved.ok === false) {throw new Error(copy.failed)}
|
||||
setKey('')
|
||||
await onConnected()
|
||||
} catch (err) {
|
||||
@@ -45,16 +50,18 @@ export function AiturkProvider({ onConnected }: { onConnected: () => Promise<voi
|
||||
<h3 className="font-semibold">TurkServis · AITURK</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{copy.pitch}</p>
|
||||
</div>
|
||||
<AiturkLogin disabled={busy} onConnected={onConnected} />
|
||||
<div className="text-xs text-muted-foreground">{copy.login.orPasteKey}</div>
|
||||
<label className="grid gap-2 text-sm">
|
||||
{copy.key}
|
||||
<Input autoComplete="off" disabled={busy} onChange={e => setKey(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) void connect() }}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) {void connect()} }}
|
||||
placeholder={copy.key} type="password" value={key} />
|
||||
</label>
|
||||
{error ? <p className="text-sm text-destructive" role="alert">{error}</p> : null}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<DocsLink href={AITURK_ACCOUNT_URL}>{copy.getKey}</DocsLink>
|
||||
<Button disabled={busy || !key.trim()} onClick={() => void connect()}>
|
||||
<Button disabled={busy || !key.trim()} onClick={() => void connect()} variant="outline">
|
||||
{busy ? <Loader2 className="animate-spin" /> : null}{busy ? t.common.connecting : t.common.connect}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Vendored
+34
@@ -1,6 +1,7 @@
|
||||
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 { WakeIndicatorState } from './lib/wake-indicator'
|
||||
@@ -323,6 +324,25 @@ declare global {
|
||||
setDisableF12?: (blocked: boolean) => void
|
||||
setPreviewShortcutActive?: (active: boolean) => void
|
||||
openExternal: (url: string) => Promise<void>
|
||||
/** One-click TurkServis sign-in (electron/aiturk-login.ts). API keys stay
|
||||
* in the main process; only the short code and a status come back. */
|
||||
aiturkLogin?: {
|
||||
start: (scope?: { connectionId?: string; profile?: string }) => Promise<AiturkLoginStart>
|
||||
poll: () => Promise<AiturkLoginPoll>
|
||||
cancel: () => Promise<{ ok: true }>
|
||||
overwriteCli: () => Promise<{ cli: AiturkCliStatus }>
|
||||
}
|
||||
/** "Telefon bağlantısı": opens the pairing window (electron/mobile-host.mjs). Windows only. */
|
||||
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,
|
||||
@@ -1212,6 +1232,20 @@ export type DesktopBootstrapEvent =
|
||||
docsUrl: string
|
||||
}
|
||||
|
||||
export type AiturkCliStatus = 'ok' | 'missing' | 'outdated' | 'other-account' | 'unknown-account' | 'failed'
|
||||
|
||||
export interface AiturkLoginStart {
|
||||
userCode: string
|
||||
verificationUri: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
export type AiturkLoginPoll =
|
||||
| { state: 'pending' }
|
||||
| { state: 'expired' }
|
||||
| { state: 'connected'; cli: AiturkCliStatus }
|
||||
| { state: 'error'; error: string }
|
||||
|
||||
export interface HermesApiRequest {
|
||||
path: string
|
||||
method?: string
|
||||
|
||||
@@ -30,6 +30,28 @@ const en = {
|
||||
getKey: 'My account and API keys',
|
||||
noModels: 'No models are available for this key.',
|
||||
failed: 'Could not connect to TurkServis.',
|
||||
login: {
|
||||
signIn: 'Sign in',
|
||||
settingsSignIn: 'Sign in to AITURK',
|
||||
starting: 'Starting sign-in…',
|
||||
codeLabel: 'Sign-in code',
|
||||
confirmHint: 'Approve if the code on the page opened in your browser matches this one.',
|
||||
reopen: 'Reopen the page',
|
||||
cancel: 'Cancel',
|
||||
orPasteKey: 'or paste an API key',
|
||||
connected: 'Your TurkServis account is connected.',
|
||||
expired: 'The sign-in code expired. Try again.',
|
||||
rateLimited: 'Too many attempts. Try again in a little while.',
|
||||
unavailable: 'Could not start sign-in. Check your internet connection and try again.',
|
||||
browserFailed: 'Could not open the browser. Try again.',
|
||||
unsupported: 'One-click sign-in is not available here. Paste an API key instead.',
|
||||
cliOk: 'AITURK CLI is ready with this account too.',
|
||||
cliOutdated: 'After AITURK CLI is updated, sign in again to connect it automatically.',
|
||||
cliOtherAccount: 'AITURK CLI has a different account saved. Replace it with this account?',
|
||||
cliReplace: 'Replace',
|
||||
cliKeep: 'Leave as is',
|
||||
cliFailed: 'AITURK CLI could not be connected; run `aiturk login` in a terminal.'
|
||||
},
|
||||
custom: {
|
||||
title: 'Custom endpoints',
|
||||
active: 'Active',
|
||||
@@ -64,6 +86,10 @@ const en = {
|
||||
found: (n: number) => `Endpoint is reachable. Found ${n} models.`,
|
||||
confirmDelete: (name: string) => `Delete ${name}?`
|
||||
},
|
||||
mobile: {
|
||||
nav: 'Phone connection',
|
||||
openFailed: 'Could not open the phone connection window.'
|
||||
},
|
||||
about: 'TurkServis distribution based on Hermes Agent. Upstream: Nous Research, MIT license.',
|
||||
downloads: 'Official downloads and updates',
|
||||
source: 'Source code and license',
|
||||
@@ -79,6 +105,28 @@ const tr: typeof en = {
|
||||
getKey: 'Hesabım ve API anahtarlarım',
|
||||
noModels: 'Bu anahtarla kullanılabilecek model bulunamadı.',
|
||||
failed: 'TurkServis bağlantısı kurulamadı.',
|
||||
login: {
|
||||
signIn: 'Giriş yap',
|
||||
settingsSignIn: 'AITURK’e giriş yap',
|
||||
starting: 'Giriş başlatılıyor…',
|
||||
codeLabel: 'Giriş kodu',
|
||||
confirmHint: 'Tarayıcıda açılan sayfadaki kod bununla aynıysa onaylayın.',
|
||||
reopen: 'Sayfayı yeniden aç',
|
||||
cancel: 'Vazgeç',
|
||||
orPasteKey: 'ya da API anahtarı yapıştırın',
|
||||
connected: 'TurkServis hesabınız bağlandı.',
|
||||
expired: 'Giriş kodunun süresi doldu. Yeniden deneyin.',
|
||||
rateLimited: 'Çok fazla deneme yapıldı. Biraz sonra yeniden deneyin.',
|
||||
unavailable: 'Giriş başlatılamadı. İnternet bağlantınızı kontrol edip yeniden deneyin.',
|
||||
browserFailed: 'Tarayıcı açılamadı. Yeniden deneyin.',
|
||||
unsupported: 'Tek tıkla giriş burada kullanılamıyor. API anahtarı yapıştırın.',
|
||||
cliOk: 'AITURK CLI de bu hesapla hazır.',
|
||||
cliOutdated: 'AITURK CLI güncellenince kendiliğinden bağlanması için yeniden giriş yapın.',
|
||||
cliOtherAccount: 'AITURK CLI’de başka bir hesap kayıtlı. Bu hesapla değiştirilsin mi?',
|
||||
cliReplace: 'Değiştir',
|
||||
cliKeep: 'Olduğu gibi bırak',
|
||||
cliFailed: 'AITURK CLI bağlanamadı; terminalde `aiturk login` çalıştırın.'
|
||||
},
|
||||
custom: {
|
||||
title: 'Özel API bağlantıları',
|
||||
active: 'Etkin',
|
||||
@@ -113,6 +161,10 @@ const tr: typeof en = {
|
||||
found: n => `API adresine erişiliyor. ${n} model bulundu.`,
|
||||
confirmDelete: name => `${name} silinsin mi?`
|
||||
},
|
||||
mobile: {
|
||||
nav: 'Telefon bağlantısı',
|
||||
openFailed: 'Telefon bağlantısı penceresi açılamadı.'
|
||||
},
|
||||
about: 'Hermes Agent tabanlı TurkServis dağıtımı. Kaynak: Nous Research, MIT lisansı.',
|
||||
downloads: 'Resmî indirmeler ve güncellemeler',
|
||||
source: 'Kaynak kodu ve lisans',
|
||||
|
||||
@@ -0,0 +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 = {
|
||||
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',
|
||||
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.'
|
||||
},
|
||||
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 type CodexLinkCopy = typeof en
|
||||
|
||||
export function useCodexLinkCopy(): CodexLinkCopy {
|
||||
const { locale } = useI18n()
|
||||
|
||||
return locale === 'tr' ? tr : en
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
import { connectionScoped, profileScoped } from '@/api/client'
|
||||
import type { CustomEndpointUpdate } from '@/types/hermes'
|
||||
|
||||
export const AITURK_API_BASE_URL = 'https://ai.turkservis.online/v1'
|
||||
export const AITURK_ACCOUNT_URL = 'https://turkservis.online/panel'
|
||||
|
||||
/** Where the main process should save the provider after a one-click sign-in:
|
||||
* the same (connection, profile) the renderer's saveCustomEndpoint targets. */
|
||||
export function aiturkLoginScope(): { connectionId?: string; profile?: string } {
|
||||
return { ...connectionScoped(), ...profileScoped() }
|
||||
}
|
||||
|
||||
export function aiturkEndpoint(apiKey: string, model = '', models?: string[]): CustomEndpointUpdate {
|
||||
return {
|
||||
id: 'turkservis',
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: [
|
||||
'electron/mobile-*.test.ts',
|
||||
'electron/first-run-setup*.test.ts',
|
||||
'electron/primary-backend-startup.test.ts',
|
||||
'electron/codex-link*.test.ts'
|
||||
]
|
||||
}
|
||||
})
|
||||
@@ -1470,6 +1470,34 @@ def _wait_for_oneshot_background_completions(cli) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _emit_approval_required(session_id) -> None:
|
||||
"""Report approvals a one-shot run could not put in front of a human.
|
||||
|
||||
One ``approval_required: <json>`` line per blocked request on stderr,
|
||||
next to ``session_id:``, so the process that launched this run can tell
|
||||
"blocked, needs a human" from "still working" without parsing prose.
|
||||
Nothing was applied for these requests; resuming the same session in an
|
||||
interactive surface is the way to get them approved.
|
||||
"""
|
||||
try:
|
||||
from tools.approval import drain_human_approval_required
|
||||
records = drain_human_approval_required()
|
||||
except Exception:
|
||||
return
|
||||
for record in records:
|
||||
try:
|
||||
if not record.get("session_id"):
|
||||
record["session_id"] = session_id or ""
|
||||
record["resume"] = "interactive"
|
||||
print(
|
||||
"approval_required: "
|
||||
+ json.dumps(record, ensure_ascii=True, sort_keys=True),
|
||||
file=sys.stderr,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
def _finalize_single_query(cli) -> None:
|
||||
"""Close one-shot CLI resources before releasing the active session lease."""
|
||||
try:
|
||||
@@ -22360,6 +22388,7 @@ def main(
|
||||
|
||||
# Session ID goes to stderr so piped stdout is clean.
|
||||
print(f"\nsession_id: {cli.session_id}", file=sys.stderr)
|
||||
_emit_approval_required(cli.session_id)
|
||||
|
||||
# Ensure proper exit code for automation wrappers.
|
||||
#
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# AITURK IDE beta.20 mobile adapter
|
||||
|
||||
The mobile catalog now uses the official live Hermes catalog, its cache and
|
||||
removed-entry list. Parser and nine official offline seed entries are ported
|
||||
from NousResearch/hermes-agent ad12263a5ed43109d3942a8af04d4cef210fa6ae.
|
||||
Only PyYAML import compatibility differs in the parser. The existing installer
|
||||
checks out the catalog's exact commit; client URLs cannot override catalog names.
|
||||
Version/platform incompatibility is reported before installation.
|
||||
|
||||
Mobile subagent roster/tail RPCs are connected to the existing live registry.
|
||||
Roster supports durable conversation lineage after reconnect, like upstream.
|
||||
Tail and interrupt retain exact live-session/transport authority. Transcript
|
||||
reads are bounded and confined to the profile's delegation log directory.
|
||||
The existing mobile fork fallback already uses session.branch only for -32601;
|
||||
it was not a missing feature and is unchanged.
|
||||
|
||||
No new model, team, cron job, permission, or account is installed by these changes.
|
||||
The mobile Keys/Files/Vault/Connectors exclusions remain as before; this release
|
||||
does not claim those unsupported remote screens are implemented. Android beta.5
|
||||
can use the new endpoints without a new APK. Physical-phone acceptance is separate.
|
||||
@@ -769,7 +769,10 @@ def bounded_git_probe(argv: Sequence[str], *, timeout: float) -> str:
|
||||
openai/codex#36793). ``process_group`` only changes which group the child
|
||||
belongs to; it does not detach the terminal or alter the fast path.
|
||||
"""
|
||||
result = bounded_probe_run(argv, timeout=timeout, env=noninteractive_git_env())
|
||||
env = noninteractive_git_env()
|
||||
# Read-only probes must not fetch missing trees in partial clones.
|
||||
env["GIT_NO_LAZY_FETCH"] = "1"
|
||||
result = bounded_probe_run(argv, timeout=timeout, env=env)
|
||||
if result is None or result.returncode != 0:
|
||||
return ""
|
||||
return (result.stdout or "").strip()
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Connect the AITURK distribution's media tools to its existing member key."""
|
||||
|
||||
|
||||
def configure_existing_media(cfg: dict) -> bool:
|
||||
"""Migrate an existing member endpoint when the upgraded backend starts."""
|
||||
providers = cfg.get("providers") or {}
|
||||
entries = providers.values() if isinstance(providers, dict) else []
|
||||
for entry in entries:
|
||||
if isinstance(entry, dict) and configure_media(cfg, entry):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def configure_media(cfg: dict, entry: dict) -> bool:
|
||||
if entry.get("base_url", "").rstrip("/") != "https://ai.turkservis.online/v1":
|
||||
return False
|
||||
key_env = entry.get("key_env")
|
||||
if not isinstance(key_env, str) or not key_env:
|
||||
return False
|
||||
servers = cfg.get("mcp_servers")
|
||||
if servers is None:
|
||||
servers = cfg["mcp_servers"] = {}
|
||||
if not isinstance(servers, dict):
|
||||
return False
|
||||
name = "turkservis-media"
|
||||
existing = servers.get(name, {})
|
||||
if not isinstance(existing, dict):
|
||||
return False
|
||||
if existing and existing.get("url") != "https://ai.turkservis.online/mcp":
|
||||
return False
|
||||
updated = {
|
||||
**existing,
|
||||
"url": "https://ai.turkservis.online/mcp",
|
||||
"headers": {"Authorization": "Bearer ${" + key_env + "}"},
|
||||
"timeout": 300,
|
||||
"tools": {"include": ["list_capabilities", "generate_media"]},
|
||||
}
|
||||
if updated == existing:
|
||||
return False
|
||||
servers[name] = updated
|
||||
return True
|
||||
@@ -0,0 +1,54 @@
|
||||
"""AITURK adapter for the upstream catalog and existing pinned installer.
|
||||
|
||||
Catalog parsing/cache comes from NousResearch/hermes-agent ad12263a5ed4.
|
||||
The catalog supplies the repository and immutable revision, never the client.
|
||||
"""
|
||||
from dataclasses import asdict
|
||||
import sys
|
||||
|
||||
from hermes_cli import plugin_catalog as catalog
|
||||
|
||||
|
||||
def catalog_payload():
|
||||
from hermes_cli.plugins_cmd import _discover_all_plugins, _read_install_metadata
|
||||
|
||||
installed = {row[0] for row in _discover_all_plugins()}
|
||||
metadata = _read_install_metadata()
|
||||
removed = catalog.resolved_removed_entries()
|
||||
entries = []
|
||||
for entry in catalog.load_catalog_live():
|
||||
if catalog.match_removed(entry.name, removed) or catalog.match_removed(entry.repo, removed):
|
||||
continue
|
||||
row = entry.to_dict()
|
||||
record = metadata.get(entry.name, {})
|
||||
revision = record.get("revision")
|
||||
row.update(installed=entry.name in installed, installed_sha=revision,
|
||||
update_available=bool(revision and revision != entry.sha),
|
||||
sha_short=entry.sha[:12], capability_summary=catalog.entry_capability_summary(entry))
|
||||
entries.append(row)
|
||||
return {"entries": entries, "removed": [asdict(row) for row in removed]}
|
||||
|
||||
|
||||
def install_catalog_plugin(name, *, force=False, enable=True):
|
||||
from hermes_cli.plugins_cmd import dashboard_install_plugin
|
||||
|
||||
entry = catalog.get_live_catalog_entry(name)
|
||||
if entry is None:
|
||||
return {"ok": False, "error": "Eklenti katalogda bulunamadı."}
|
||||
removed = catalog.resolved_removed_entries()
|
||||
if catalog.match_removed(name, removed) or catalog.match_removed(entry.repo, removed):
|
||||
return {"ok": False, "error": "Bu eklenti katalogdan kaldırılmış."}
|
||||
platform = {"win32": "windows", "darwin": "macos"}.get(sys.platform, sys.platform)
|
||||
supported = {"win": "windows", "win32": "windows", "darwin": "macos", "mac": "macos"}
|
||||
if entry.platforms and platform not in {supported.get(p.lower(), p.lower()) for p in entry.platforms}:
|
||||
return {"ok": False, "error": "Bu eklenti bilgisayarın işletim sistemini desteklemiyor."}
|
||||
if entry.requires_hermes:
|
||||
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
||||
from hermes_cli import __version__
|
||||
try:
|
||||
compatible = __version__ in SpecifierSet(entry.requires_hermes)
|
||||
except InvalidSpecifier:
|
||||
return {"ok": False, "error": "Katalogdaki sürüm koşulu geçersiz; eklenti kurulmadı."}
|
||||
if not compatible:
|
||||
return {"ok": False, "error": f"Bu eklenti Hermes {entry.requires_hermes} gerektiriyor."}
|
||||
return dashboard_install_plugin(entry.install_identifier, force=force, enable=enable, ref=entry.sha)
|
||||
@@ -0,0 +1,533 @@
|
||||
"""Plugin catalog — curated, Nous-approved Hermes plugins shipped with the repo.
|
||||
|
||||
Mirrors the ``optional-mcps/`` MCP-catalog pattern: one YAML file per entry under the in-tree
|
||||
``plugin-catalog/`` directory, pinned to an exact 40-character commit SHA. Presence in the directory IS
|
||||
the human-merged approval gate; SHA bumps are new, re-reviewed PRs; ``removed.yaml`` is the kill list
|
||||
(installs of a removed name/repo are refused with the recorded reason). Full policy:
|
||||
``plugin-catalog/README.md``.
|
||||
|
||||
Live refresh: the docs build publishes the same data as ONE JSON document
|
||||
(``website/scripts/extract-plugins.py`` → ``/docs/api/plugin-catalog.json``, like the skills index), so
|
||||
an installed Hermes sees new entries and removals without updating. A fetch failure reuses the last valid
|
||||
cached copy regardless of age, then falls back to the in-tree copy when no valid cache exists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CATALOG_TIERS = ("official", "community")
|
||||
# Browse taxonomy for the catalog page / picker. Entries without one land on the Desktop shelf
|
||||
# (the common case for community submissions); "general" is for plugins that fit no shelf.
|
||||
CATALOG_CATEGORIES = ("desktop", "memory", "platform", "web", "tools", "voice", "automation", "models", "general")
|
||||
LIVE_CATALOG_URL = "https://hermes-agent.nousresearch.com/docs/api/plugin-catalog.json"
|
||||
LIVE_CATALOG_TTL_SECONDS = 6 * 60 * 60
|
||||
# Offline pins expire, but cached removals remain a permanent kill list.
|
||||
LIVE_CATALOG_MAX_STALE_SECONDS = 24 * 60 * 60
|
||||
LIVE_CATALOG_FAILURE_TTL_SECONDS = 60.0
|
||||
_REQUEST_TIMEOUT = 5.0
|
||||
_MAX_LIVE_BYTES = 2 * 1024 * 1024
|
||||
|
||||
_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
_VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,31}$")
|
||||
# Catalog images may only come from GitHub: the Desktop catalog browser never fans out to
|
||||
# third-party hosts, and a raw URL pinned to the entry's commit is as immutable as the sha.
|
||||
IMAGE_HOSTS = ("raw.githubusercontent.com", "github.com")
|
||||
IMAGE_HOST_SUFFIX = ".githubusercontent.com"
|
||||
|
||||
|
||||
def is_allowed_image_url(url: str) -> bool:
|
||||
from urllib.parse import urlsplit
|
||||
parts = urlsplit(url)
|
||||
host = (parts.hostname or "").lower()
|
||||
return parts.scheme == "https" and bool(host) and (host in IMAGE_HOSTS or host.endswith(IMAGE_HOST_SUFFIX))
|
||||
_NAME_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemovedEntry:
|
||||
name: str
|
||||
repo: str = ""
|
||||
reason: str = ""
|
||||
date: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CatalogCapabilities:
|
||||
provides_tools: List[str] = field(default_factory=list)
|
||||
provides_hooks: List[str] = field(default_factory=list)
|
||||
provides_middleware: List[str] = field(default_factory=list)
|
||||
requires_env: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginCatalogEntry:
|
||||
name: str # catalog key, [a-z0-9_-]{1,64}
|
||||
repo: str # https:// git URL
|
||||
sha: str # 40-hex pinned commit — mandatory
|
||||
description: str
|
||||
maintainer: str
|
||||
tier: str = "community"
|
||||
category: str = "desktop"
|
||||
requires_hermes: str = ""
|
||||
subdir: str = ""
|
||||
docs_url: str = ""
|
||||
version: str = "" # human label for the pinned sha ("1.4.0"); cosmetic, never parsed
|
||||
image: str = "" # https image URL on a GitHub host; shown on catalog cards
|
||||
screenshots: List[str] = field(default_factory=list) # GitHub-hosted https URLs; gallery on /docs/plugins/<name>
|
||||
readme: bool = False # docs site renders the README from the pinned commit on the entry's page
|
||||
platforms: List[str] = field(default_factory=list) # empty = all OSes
|
||||
title: str = "" # human name ("NVIDIA App"); empty = derived from ``name``
|
||||
onboarding: bool = False # curated: offered on the desktop onboarding card
|
||||
capabilities: CatalogCapabilities = field(default_factory=CatalogCapabilities)
|
||||
known_issues: List[str] = field(default_factory=list) # #124058: informational; drivers come from plugin-catalog/*.yaml
|
||||
|
||||
@property
|
||||
def install_identifier(self) -> str:
|
||||
"""``_install_plugin_core`` identifier (``repo#subdir`` for monorepo entries)."""
|
||||
return f"{self.repo}#{self.subdir}" if self.subdir else self.repo
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
caps = self.capabilities
|
||||
return {
|
||||
"name": self.name, "repo": self.repo, "sha": self.sha, "description": self.description,
|
||||
"maintainer": self.maintainer, "tier": self.tier, "category": self.category,
|
||||
"requires_hermes": self.requires_hermes,
|
||||
"subdir": self.subdir, "docs_url": self.docs_url, "version": self.version, "image": self.image,
|
||||
"screenshots": list(self.screenshots), "readme": self.readme,
|
||||
"platforms": list(self.platforms), "title": self.title, "onboarding": self.onboarding,
|
||||
"capabilities": {
|
||||
"provides_tools": list(caps.provides_tools), "provides_hooks": list(caps.provides_hooks),
|
||||
"provides_middleware": list(caps.provides_middleware), "requires_env": list(caps.requires_env),
|
||||
},
|
||||
"known_issues": list(self.known_issues),
|
||||
}
|
||||
|
||||
|
||||
def get_catalog_dir() -> Path:
|
||||
"""The ``plugin-catalog/`` directory shipped with this checkout."""
|
||||
return Path(__file__).resolve().parent.parent / "plugin-catalog"
|
||||
|
||||
|
||||
# ── Parsing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _str_list(raw: Any) -> List[str]:
|
||||
return [str(x) for x in raw if isinstance(x, (str, int, float))] if isinstance(raw, list) else []
|
||||
|
||||
|
||||
def entry_from_mapping(data: Any, label: str) -> Optional[PluginCatalogEntry]:
|
||||
"""Validate one entry mapping (YAML file or live-JSON element); ``None`` + warning on any failure."""
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("Plugin catalog: %s: entry must be a mapping", label)
|
||||
return None
|
||||
name = str(data.get("name") or "")
|
||||
repo = str(data.get("repo") or "")
|
||||
sha = str(data.get("sha") or "").strip().lower()
|
||||
tier = str(data.get("tier") or "community")
|
||||
category = str(data.get("category") or "desktop")
|
||||
problem = (
|
||||
f"invalid name {name!r} (must match [a-z0-9_-]{{1,64}})" if not _NAME_RE.match(name)
|
||||
else f"repo must be an https:// URL (got {repo!r})" if not repo.startswith("https://")
|
||||
else f"sha must be a full 40-character hex commit SHA (got {data.get('sha')!r})" if not _SHA_RE.match(sha)
|
||||
else f"tier must be one of {'/'.join(CATALOG_TIERS)} (got {tier!r})" if tier not in CATALOG_TIERS
|
||||
else f"category must be one of {'/'.join(CATALOG_CATEGORIES)} (got {category!r})"
|
||||
if category not in CATALOG_CATEGORIES
|
||||
else None)
|
||||
if problem:
|
||||
logger.warning("Plugin catalog: %s: %s", label, problem)
|
||||
return None
|
||||
caps_raw = data.get("capabilities")
|
||||
caps: Dict[str, Any] = caps_raw if isinstance(caps_raw, dict) else {}
|
||||
version = str(data.get("version") or "").strip()
|
||||
if version and not _VERSION_RE.match(version):
|
||||
logger.warning("Plugin catalog: %s: ignoring version %r (max 32 chars of [A-Za-z0-9._+-])", label, version)
|
||||
version = ""
|
||||
image = str(data.get("image") or "").strip()
|
||||
if image and not is_allowed_image_url(image):
|
||||
logger.warning("Plugin catalog: %s: ignoring image %r (must be https on a GitHub host)", label, image)
|
||||
image = ""
|
||||
screenshots = [s for s in _str_list(data.get("screenshots")) if is_allowed_image_url(s)]
|
||||
if len(screenshots) != len(_str_list(data.get("screenshots"))):
|
||||
logger.warning("Plugin catalog: %s: ignoring screenshots off GitHub hosts", label)
|
||||
return PluginCatalogEntry(
|
||||
name=name, repo=repo, sha=sha,
|
||||
description=str(data.get("description") or "").strip(),
|
||||
maintainer=str(data.get("maintainer") or "").strip(), tier=tier, category=category,
|
||||
requires_hermes=str(data.get("requires_hermes") or "").strip(),
|
||||
subdir=str(data.get("subdir") or "").strip(), docs_url=str(data.get("docs_url") or "").strip(),
|
||||
version=version, image=image, screenshots=screenshots, readme=data.get("readme") is not False,
|
||||
platforms=_str_list(data.get("platforms")),
|
||||
title=str(data.get("title") or "").strip(), onboarding=data.get("onboarding") is True,
|
||||
known_issues=_str_list(data.get("known_issues")),
|
||||
capabilities=CatalogCapabilities(
|
||||
provides_tools=_str_list(caps.get("provides_tools")), provides_hooks=_str_list(caps.get("provides_hooks")),
|
||||
provides_middleware=_str_list(caps.get("provides_middleware")),
|
||||
requires_env=_str_list(caps.get("requires_env"))),
|
||||
)
|
||||
|
||||
|
||||
def _read_yaml(path: Path) -> Any:
|
||||
try:
|
||||
return yaml.safe_load(path.read_text(encoding="utf-8-sig")) or {}
|
||||
except Exception as exc:
|
||||
logger.warning("Plugin catalog: failed to read %s: %s", path, exc)
|
||||
return None
|
||||
|
||||
|
||||
def _removed_from_list(raw_list: Any) -> List[RemovedEntry]:
|
||||
if not isinstance(raw_list, list):
|
||||
return []
|
||||
return [
|
||||
RemovedEntry(name=str(r["name"]), repo=str(r.get("repo") or ""), reason=str(r.get("reason") or ""),
|
||||
date=str(r.get("date") or ""))
|
||||
for r in raw_list if isinstance(r, dict) and r.get("name")]
|
||||
|
||||
|
||||
# ── In-tree catalog ──────────────────────────────────────────────────────────
|
||||
|
||||
def load_catalog(catalog_dir: Optional[Path] = None) -> List[PluginCatalogEntry]:
|
||||
"""Every valid ``*.yaml`` entry in the catalog dir (``removed.yaml`` excluded), sorted by file name.
|
||||
Malformed entries are skipped with a warning — never raises."""
|
||||
root = catalog_dir or get_catalog_dir()
|
||||
if not root.is_dir():
|
||||
return []
|
||||
entries = []
|
||||
for path in sorted(root.glob("*.yaml")):
|
||||
if path.name == "removed.yaml":
|
||||
continue
|
||||
data = _read_yaml(path)
|
||||
entry = entry_from_mapping(data, str(path)) if data is not None else None
|
||||
if entry is not None:
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
def load_removed_list(catalog_dir: Optional[Path] = None) -> List[RemovedEntry]:
|
||||
"""``removed.yaml``'s ``removed:`` list; missing/malformed → empty."""
|
||||
path = (catalog_dir or get_catalog_dir()) / "removed.yaml"
|
||||
data = _read_yaml(path) if path.is_file() else None
|
||||
return _removed_from_list(data.get("removed")) if isinstance(data, dict) else []
|
||||
|
||||
|
||||
def get_catalog_entry(name: str, catalog_dir: Optional[Path] = None) -> Optional[PluginCatalogEntry]:
|
||||
return next((e for e in load_catalog(catalog_dir) if e.name == name), None)
|
||||
|
||||
|
||||
def filter_entries(entries: List[PluginCatalogEntry], query: str) -> List[PluginCatalogEntry]:
|
||||
"""Case-insensitive substring match over name, description and declared tools; empty query = all."""
|
||||
q = (query or "").strip().lower()
|
||||
if not q:
|
||||
return entries
|
||||
return [e for e in entries
|
||||
if any(q in h.lower() for h in (e.name, e.description, *e.capabilities.provides_tools))]
|
||||
|
||||
|
||||
def search_catalog(query: str) -> List[PluginCatalogEntry]:
|
||||
return filter_entries(load_catalog(), query)
|
||||
|
||||
|
||||
# ── Removed / blocklist ──────────────────────────────────────────────────────
|
||||
|
||||
_SCP_URL_RE = re.compile(r"^(?:[^@/\s]+@)?([^:/\s]+):(?!//)(.+)$") # git@host:owner/repo
|
||||
|
||||
|
||||
def _normalize_repo(url: str) -> str:
|
||||
"""Canonical ``host/path`` for a repo URL: scheme, user, ``www.``, ``.git`` and trailing slashes are
|
||||
spelling, not identity — the kill list must match ``git@github.com:Evil/Bad.git`` when it names
|
||||
``https://github.com/evil/bad``."""
|
||||
from urllib.parse import urlsplit
|
||||
text = url.strip()
|
||||
scp = _SCP_URL_RE.match(text)
|
||||
if scp:
|
||||
host, path = scp.group(1), scp.group(2)
|
||||
elif "://" in text:
|
||||
parts = urlsplit(text)
|
||||
host, path = parts.hostname or "", parts.path
|
||||
else:
|
||||
host, path = "", text
|
||||
host = host.lower().removeprefix("www.")
|
||||
path = path.strip("/").removesuffix(".git").rstrip("/").lower()
|
||||
return f"{host}/{path}" if host else path
|
||||
|
||||
|
||||
def find_removed(name_or_repo: str, catalog_dir: Optional[Path] = None) -> Optional[RemovedEntry]:
|
||||
"""Match a catalog name or repo URL (``.git``/trailing-slash insensitive) against the kill list.
|
||||
|
||||
The in-tree list and the live-fetched list are UNIONED: a removal published after this checkout
|
||||
shipped must still block, and a stale live cache must not un-block an in-tree removal.
|
||||
"""
|
||||
if not name_or_repo:
|
||||
return None
|
||||
entries = load_removed_list(catalog_dir)
|
||||
if catalog_dir is None:
|
||||
entries = entries + live_removed_list()
|
||||
return match_removed(name_or_repo, entries)
|
||||
|
||||
|
||||
def resolved_removed_entries() -> List[RemovedEntry]:
|
||||
"""The full kill list (in-tree UNION live) in one resolution. Callers that match many candidates
|
||||
— e.g. a plugins-hub rebuild annotating every installed plugin — resolve the list once instead
|
||||
of paying a live-catalog fetch per candidate."""
|
||||
return load_removed_list() + live_removed_list()
|
||||
|
||||
|
||||
def cached_removed_entries() -> List[RemovedEntry]:
|
||||
"""In-tree list UNION the last fetched live copy, with NO network round-trip — for the load-time and
|
||||
``enable`` checks that run in every process and must never block on a dead catalog host."""
|
||||
cached = _stale_live_cache(_live_cache_path()) or {}
|
||||
return load_removed_list() + _removed_from_list(cached.get("removed"))
|
||||
|
||||
|
||||
def match_removed(
|
||||
candidate: str, entries: List[RemovedEntry]
|
||||
) -> Optional[RemovedEntry]:
|
||||
"""One candidate against a pre-resolved kill list: exact name or normalized repo URL match."""
|
||||
if not candidate:
|
||||
return None
|
||||
text = candidate.strip()
|
||||
text_repo = _normalize_repo(text)
|
||||
for entry in entries:
|
||||
if text == entry.name or (
|
||||
entry.repo and text_repo == _normalize_repo(entry.repo)
|
||||
):
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
# ── Live catalog ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _live_cache_path() -> Path:
|
||||
from hermes_constants import get_hermes_home
|
||||
return get_hermes_home() / "cache" / "plugin-catalog.json"
|
||||
|
||||
|
||||
def invalidate_live_cache_for_home(home: Path) -> None:
|
||||
"""Best-effort removal of the cached live catalog under *home* (any profile's home).
|
||||
|
||||
``hermes update`` drops it for every profile after the checkout changes: a snapshot fetched
|
||||
before the bump would otherwise out-vote the newer in-tree catalog (pins the update just
|
||||
changed, entries it just added) for the rest of :data:`LIVE_CATALOG_TTL_SECONDS` (#119340).
|
||||
The next :func:`fetch_live_catalog` re-fetches the published doc, or falls back to the
|
||||
in-tree catalog while the network is down — both newer than what was deleted. Safe when the
|
||||
cache is absent (first run, other profiles that never opened the plugins hub).
|
||||
"""
|
||||
try:
|
||||
(Path(home) / "cache" / "plugin-catalog.json").unlink(missing_ok=True)
|
||||
except Exception as exc:
|
||||
logger.debug("Plugin catalog: could not drop the live cache under %s: %s", home, exc)
|
||||
|
||||
|
||||
# Wall-clock deadline of the last failed live fetch. Without it a dead catalog host costs one
|
||||
# full request timeout PER CALL (the plugins hub and ``plugins list`` used to ask once per
|
||||
# installed plugin), so the dashboard event loop stalled for minutes.
|
||||
_live_fetch_failed_until = 0.0
|
||||
|
||||
|
||||
def _stale_live_cache(cache: Path) -> Optional[Dict[str, Any]]:
|
||||
"""A previously fetched copy still beats the in-tree one when the network is down — for
|
||||
:data:`LIVE_CATALOG_MAX_STALE_SECONDS`. Past that its pins may trail the checkout's own catalog
|
||||
(a 90-day-old cache outranked a freshly updated in-tree pin), so the entries are dropped and the
|
||||
caller falls back to in-tree; the removals are kept."""
|
||||
try:
|
||||
if not cache.is_file():
|
||||
return None
|
||||
data = json.loads(cache.read_text(encoding="utf-8-sig"))
|
||||
if time.time() - cache.stat().st_mtime > LIVE_CATALOG_MAX_STALE_SECONDS:
|
||||
data = {**data, "entries": []}
|
||||
return data
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def fetch_live_catalog(*, force: bool = False) -> Optional[Dict[str, Any]]:
|
||||
"""The published ``plugin-catalog.json`` (``{"entries": [...], "removed": [...]}``), cached under
|
||||
``HERMES_HOME/cache`` for :data:`LIVE_CATALOG_TTL_SECONDS`. ``None`` on ANY failure — callers fall
|
||||
back to the in-tree catalog. A failed network attempt is remembered for
|
||||
:data:`LIVE_CATALOG_FAILURE_TTL_SECONDS` so a dead host costs one timeout per TTL window, not one
|
||||
per call (``force`` bypasses both caches)."""
|
||||
global _live_fetch_failed_until
|
||||
cache = _live_cache_path()
|
||||
try:
|
||||
if not force and cache.is_file() and time.time() - cache.stat().st_mtime < LIVE_CATALOG_TTL_SECONDS:
|
||||
return json.loads(cache.read_text(encoding="utf-8-sig"))
|
||||
except Exception as exc:
|
||||
logger.debug("Plugin catalog: unreadable live cache %s: %s", cache, exc)
|
||||
if not force and time.time() < _live_fetch_failed_until:
|
||||
return _stale_live_cache(cache)
|
||||
try:
|
||||
import httpx
|
||||
from hermes_constants import mkdir_under_hermes_home
|
||||
from utils import atomic_write_text
|
||||
|
||||
resp = httpx.get(LIVE_CATALOG_URL, timeout=_REQUEST_TIMEOUT, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
if len(resp.content) > _MAX_LIVE_BYTES:
|
||||
raise ValueError("live catalog payload too large")
|
||||
data = resp.json()
|
||||
if not isinstance(data, dict) or not isinstance(data.get("entries"), list):
|
||||
raise ValueError("unexpected live catalog payload")
|
||||
mkdir_under_hermes_home(cache.parent)
|
||||
# Atomic: a concurrent reader (gateway, TUI, a second CLI) must never see a half-written
|
||||
# document, which would read as a fetch failure and start its own 60 s failure window.
|
||||
atomic_write_text(cache, json.dumps(data), tmp_prefix=f"{cache.name}.tmp-")
|
||||
return data
|
||||
except Exception as exc:
|
||||
logger.debug("Plugin catalog: live fetch failed: %s", exc)
|
||||
_live_fetch_failed_until = time.time() + LIVE_CATALOG_FAILURE_TTL_SECONDS
|
||||
return _stale_live_cache(cache)
|
||||
|
||||
|
||||
_in_tree_catalog_time: Optional[float] = -1.0 # -1 = not resolved yet; None = no usable in-tree time
|
||||
|
||||
|
||||
def in_tree_catalog_time() -> Optional[float]:
|
||||
"""Commit time (epoch) of the last change to this checkout's ``plugin-catalog/``, or ``None`` when
|
||||
the install is not a git checkout (a release/pip install cannot be newer than the published doc).
|
||||
On a git checkout whose path history is unreadable without network — the treeless ``tree:0`` layout
|
||||
``hermes update`` produces — the newest checked-out catalog file's mtime stands in for it, so a
|
||||
freshly updated checkout still outranks a doc fetched before the bump. Resolved once per process."""
|
||||
global _in_tree_catalog_time
|
||||
if _in_tree_catalog_time != -1.0:
|
||||
return _in_tree_catalog_time
|
||||
root = get_catalog_dir().parent
|
||||
resolved: Optional[float] = None
|
||||
if (root / ".git").exists():
|
||||
try:
|
||||
from hermes_cli._subprocess_compat import bounded_git_probe
|
||||
|
||||
# Path history needs missing trees on tree:0 clones. The shared probe disables
|
||||
# lazy fetch only for its child and bounds timeout cleanup on every platform.
|
||||
out = bounded_git_probe(
|
||||
["git", "-C", str(root), "log", "-1", "--format=%ct", "--", "plugin-catalog"],
|
||||
timeout=10,
|
||||
)
|
||||
resolved = float(out) if out else None
|
||||
except Exception as exc:
|
||||
logger.debug("Plugin catalog: could not date the in-tree catalog: %s", exc)
|
||||
if resolved is None:
|
||||
# "History unreadable without network" must not collapse into "not a git checkout":
|
||||
# ``None`` feeds the frozen-copy rule that defers to the live doc, which would let a
|
||||
# cache fetched before ``hermes update`` re-pin the old sha on exactly the installs
|
||||
# that just bumped it. A treeless clone still has its checked-out files — only the
|
||||
# historical trees are missing — and their mtime dates the checkout that wrote them.
|
||||
resolved = _catalog_worktree_mtime()
|
||||
_in_tree_catalog_time = resolved
|
||||
return resolved
|
||||
|
||||
|
||||
def _catalog_worktree_mtime() -> Optional[float]:
|
||||
"""Newest mtime under the checkout's ``plugin-catalog/``: a no-network freshness signal for clones
|
||||
whose path history is unreachable (treeless or offline). ``None`` when nothing is checked out."""
|
||||
try:
|
||||
times = [p.stat().st_mtime for p in get_catalog_dir().rglob("*") if p.is_file()]
|
||||
except OSError:
|
||||
return None
|
||||
return max(times) if times else None
|
||||
|
||||
|
||||
def _live_generated_time(data: Dict[str, Any]) -> Optional[float]:
|
||||
raw = data.get("generated_at")
|
||||
if not isinstance(raw, str) or not raw:
|
||||
return None
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
return (parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)).timestamp()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _prefer_in_tree_entry(tree: PluginCatalogEntry, live: PluginCatalogEntry, tree_is_newer: Optional[bool]) -> bool:
|
||||
"""For one entry present in both sources with a different pin: the newer catalog wins. Newer is
|
||||
decided by the checkout's catalog commit time vs the doc's ``generated_at`` when both resolve;
|
||||
otherwise by the entries' ``version`` labels when both parse; otherwise the live doc wins (a release
|
||||
install's in-tree copy is frozen at release time)."""
|
||||
if tree.sha == live.sha:
|
||||
return False
|
||||
if tree_is_newer is not None:
|
||||
return tree_is_newer
|
||||
if tree.version and live.version:
|
||||
try:
|
||||
from packaging.version import Version
|
||||
return Version(tree.version) > Version(live.version)
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def load_catalog_live() -> List[PluginCatalogEntry]:
|
||||
"""Entries from the live (or cached) catalog, else the in-tree catalog. When both name an entry at
|
||||
different pins the NEWER source supplies it — right after ``hermes update`` bumps an in-tree pin,
|
||||
a cache fetched before the bump must not re-install the old one (see :func:`_prefer_in_tree_entry`)."""
|
||||
data = fetch_live_catalog()
|
||||
if data is None:
|
||||
return load_catalog()
|
||||
entries = [e for i, raw in enumerate(data["entries"])
|
||||
if (e := entry_from_mapping(raw, f"{LIVE_CATALOG_URL}#{i}")) is not None]
|
||||
if not entries:
|
||||
return load_catalog()
|
||||
in_tree = {e.name: e for e in load_catalog()}
|
||||
live_t, tree_t = _live_generated_time(data), in_tree_catalog_time()
|
||||
tree_is_newer = (tree_t > live_t) if (live_t is not None and tree_t is not None) else None
|
||||
raw_by_name = {str(raw.get("name")): raw for raw in data["entries"] if isinstance(raw, dict)}
|
||||
return [in_tree[e.name] if e.name in in_tree and _prefer_in_tree_entry(in_tree[e.name], e, tree_is_newer)
|
||||
else _with_curated_fields(e, in_tree.get(e.name), raw_by_name.get(e.name) or {})
|
||||
for e in entries]
|
||||
|
||||
|
||||
# Curated display fields a published doc older than the field does not carry. ``generated_at`` is the
|
||||
# docs build time, not the content time, so a rebuild of an older catalog outranks a checkout that added
|
||||
# the field; a doc that has the key (even ``false``) decides.
|
||||
_CURATED_FIELDS = ("onboarding", "title")
|
||||
|
||||
|
||||
def _with_curated_fields(live: PluginCatalogEntry, tree: Optional[PluginCatalogEntry], raw: Dict[str, Any]
|
||||
) -> PluginCatalogEntry:
|
||||
if tree is None or tree.sha != live.sha:
|
||||
return live
|
||||
missing = {key: getattr(tree, key) for key in _CURATED_FIELDS if key not in raw}
|
||||
return dataclasses.replace(live, **missing) if missing else live
|
||||
|
||||
|
||||
def live_removed_list() -> List[RemovedEntry]:
|
||||
data = fetch_live_catalog()
|
||||
return _removed_from_list(data.get("removed")) if data else []
|
||||
|
||||
|
||||
def get_live_catalog_entry(name: str) -> Optional[PluginCatalogEntry]:
|
||||
return next((e for e in load_catalog_live() if e.name == name), None)
|
||||
|
||||
|
||||
# ── Human summaries ──────────────────────────────────────────────────────────
|
||||
|
||||
def entry_capability_summary(entry: PluginCatalogEntry) -> str:
|
||||
"""One paragraph shown at install/enable prompts: what the user is granting."""
|
||||
caps = entry.capabilities
|
||||
parts = [f"{label} {', '.join(items)}" for label, items in (
|
||||
("registers tool(s):", caps.provides_tools), ("hook(s):", caps.provides_hooks),
|
||||
("middleware:", caps.provides_middleware), ("requires env var(s):", caps.requires_env)) if items]
|
||||
bits = [f"{entry.name} ({entry.tier}, maintained by {entry.maintainer})"]
|
||||
if entry.description:
|
||||
bits.append(entry.description)
|
||||
bits.append(f"This plugin {'; '.join(parts) if parts else 'declares no tools, hooks, middleware, or env vars'}.")
|
||||
if entry.platforms:
|
||||
bits.append(f"Platforms: {', '.join(entry.platforms)}.")
|
||||
if entry.requires_hermes:
|
||||
bits.append(f"Requires Hermes {entry.requires_hermes}.")
|
||||
if entry.known_issues:
|
||||
# #124058: informational — the catalog documents traps (unsupported
|
||||
# install-method/mode combinations); surface them at install prompts
|
||||
# without blocking the install.
|
||||
bits.append(f"Known issues: {'; '.join(entry.known_issues)}.")
|
||||
return " ".join(bits)
|
||||
@@ -564,6 +564,22 @@ def _install_metadata_path() -> Path:
|
||||
return get_hermes_home() / "plugins" / _INSTALL_METADATA_FILE
|
||||
|
||||
|
||||
def _remove_plugin_tree(path: Path) -> None:
|
||||
"""Remove one validated plugin tree, including read-only Git objects on Windows."""
|
||||
import stat
|
||||
target = path.resolve()
|
||||
if _plugins_dir().resolve() not in target.parents:
|
||||
raise ValueError("Plugin cleanup must stay strictly inside the plugin directory")
|
||||
|
||||
def retry_readonly(function, failed_path, exc_info):
|
||||
if os.name != "nt" or not isinstance(exc_info[1], PermissionError):
|
||||
raise exc_info[1]
|
||||
os.chmod(failed_path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
function(failed_path)
|
||||
|
||||
shutil.rmtree(path, onerror=retry_readonly)
|
||||
|
||||
|
||||
def _read_install_metadata() -> dict[str, dict[str, object]]:
|
||||
"""Read profile-local, non-secret plugin source metadata from disk."""
|
||||
path = _install_metadata_path()
|
||||
@@ -867,7 +883,7 @@ def _install_plugin_core(
|
||||
_write_install_metadata(new_metadata)
|
||||
except Exception:
|
||||
if target.exists():
|
||||
shutil.rmtree(target)
|
||||
_remove_plugin_tree(target)
|
||||
if replaced_existing and backup.exists():
|
||||
os.replace(backup, target)
|
||||
if old_metadata:
|
||||
@@ -1208,7 +1224,7 @@ def _remove_plugin_core(target: Path) -> None:
|
||||
"""Remove one plugin and its metadata without splitting their state."""
|
||||
metadata = _read_install_metadata()
|
||||
if target.name not in metadata:
|
||||
shutil.rmtree(target)
|
||||
_remove_plugin_tree(target)
|
||||
return
|
||||
|
||||
updated = dict(metadata)
|
||||
@@ -1230,7 +1246,7 @@ def _remove_plugin_core(target: Path) -> None:
|
||||
) from restore_exc
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
raise
|
||||
shutil.rmtree(staging)
|
||||
_remove_plugin_tree(staging)
|
||||
|
||||
|
||||
def cmd_remove(name: str) -> None:
|
||||
@@ -2641,6 +2657,7 @@ def dashboard_install_plugin(
|
||||
*,
|
||||
force: bool,
|
||||
enable: bool,
|
||||
ref: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Non-interactive install for the web dashboard. Returns a JSON-serializable dict."""
|
||||
warnings: list[str] = []
|
||||
@@ -2657,6 +2674,7 @@ def dashboard_install_plugin(
|
||||
target, installed_manifest, installed_name = _install_plugin_core(
|
||||
identifier,
|
||||
force=force,
|
||||
ref=ref,
|
||||
)
|
||||
except PluginScanBlocked as exc:
|
||||
findings = []
|
||||
|
||||
@@ -747,9 +747,11 @@ class FontSetBody(BaseModel):
|
||||
# --- from web_server.py (originally lines 19681-19684) ---
|
||||
|
||||
class _AgentPluginInstallBody(BaseModel):
|
||||
identifier: str
|
||||
identifier: str = ""
|
||||
force: bool = False
|
||||
enable: bool = True
|
||||
catalog_name: Optional[str] = None
|
||||
ref: Optional[str] = None
|
||||
|
||||
|
||||
# --- from web_server.py (originally lines 19896-19898) ---
|
||||
@@ -763,4 +765,3 @@ class _PluginProvidersPutBody(BaseModel):
|
||||
|
||||
class _PluginVisibilityBody(BaseModel):
|
||||
hidden: bool
|
||||
|
||||
+17
-14
@@ -405,12 +405,6 @@ def _eager_reconcile_own_session_db() -> None:
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(app: "FastAPI"):
|
||||
from hermes_cli.aiturk_media import configure_existing_media
|
||||
from hermes_cli.config import read_raw_config, save_config
|
||||
with _CONFIG_MUTATION_LOCK:
|
||||
media_cfg = read_raw_config()
|
||||
if configure_existing_media(media_cfg):
|
||||
save_config(media_cfg)
|
||||
app.state.event_channels = {} # dict[str, set]
|
||||
app.state.event_lock = asyncio.Lock()
|
||||
app.state.pty_active_session_files = {} # dict[str, Path]
|
||||
@@ -8829,9 +8823,6 @@ def _write_custom_endpoint(cfg: Dict[str, Any], body: CustomEndpointUpdate) -> T
|
||||
providers[endpoint_id] = entry
|
||||
cfg["providers"] = providers
|
||||
|
||||
from hermes_cli.aiturk_media import configure_media
|
||||
configure_media(cfg, entry)
|
||||
|
||||
if body.make_default:
|
||||
cfg["model"] = _apply_main_model_assignment(
|
||||
cfg.get("model", {}), endpoint_id, model, base_url
|
||||
@@ -19079,16 +19070,28 @@ async def get_plugins_hub(request: Request):
|
||||
raise HTTPException(status_code=500, detail="Failed to build plugins hub.") from exc
|
||||
|
||||
|
||||
@app.get("/api/dashboard/plugins/catalog")
|
||||
async def get_plugins_catalog(request: Request):
|
||||
_require_token(request)
|
||||
from hermes_cli.aiturk_plugin_catalog import catalog_payload
|
||||
return await asyncio.to_thread(catalog_payload)
|
||||
|
||||
|
||||
@app.post("/api/dashboard/agent-plugins/install")
|
||||
async def post_agent_plugin_install(request: Request, body: _AgentPluginInstallBody):
|
||||
_require_token(request)
|
||||
from hermes_cli.plugins_cmd import dashboard_install_plugin
|
||||
|
||||
result = dashboard_install_plugin(
|
||||
body.identifier.strip(),
|
||||
force=body.force,
|
||||
enable=body.enable,
|
||||
)
|
||||
if body.catalog_name:
|
||||
from hermes_cli.aiturk_plugin_catalog import install_catalog_plugin
|
||||
result = await asyncio.to_thread(
|
||||
install_catalog_plugin, body.catalog_name, force=body.force, enable=body.enable,
|
||||
)
|
||||
else:
|
||||
result = await asyncio.to_thread(
|
||||
dashboard_install_plugin, body.identifier.strip(), force=body.force,
|
||||
enable=body.enable, ref=body.ref,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
|
||||
Generated
+1
-1
@@ -65,7 +65,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "aiturk-ide",
|
||||
"version": "1.0.0-beta.12",
|
||||
"version": "1.0.0-beta.28",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@assistant-ui/core": "0.2.23",
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# Hermes Plugin Catalog
|
||||
|
||||
Curated, Nous-approved Hermes plugins. Each YAML file in this directory
|
||||
(except `removed.yaml`) is one catalog entry, discoverable via
|
||||
`hermes plugins catalog` / `hermes plugins search` and installable with
|
||||
`hermes plugins install <name>`.
|
||||
|
||||
## Admission policy
|
||||
|
||||
Presence in this directory **is** the trust signal. The rules that keep it
|
||||
meaningful:
|
||||
|
||||
<!-- admission-rules:start (mirrored in website/docs/developer-guide/plugins/catalog-submission.md; tests/website/test_catalog_rules_mirror.py keeps them identical) -->
|
||||
1. **Human-merged gate.** Entries are added *only* via a PR to the
|
||||
`hermes-agent` repository, reviewed and merged by a maintainer. There is
|
||||
no self-serve registry, no automated ingestion.
|
||||
2. **Exact SHA pins are mandatory.** Every entry pins a full 40-character
|
||||
commit SHA. Branches, tags, and short SHAs are rejected by the loader.
|
||||
Installs clone the repository and check out exactly that commit.
|
||||
3. **No self-updating code.** A listed plugin must not fetch and replace
|
||||
its own files (in-app "check for updates", signed release downloaders,
|
||||
remote `plugin.js` loaders). The exact SHA pin *is* the trust model; a
|
||||
self-updater lets an installed copy move to a commit nobody reviewed.
|
||||
Updates reach users only through a SHA-bump PR here plus
|
||||
`hermes plugins update <name>`. Keep the updater in the standalone
|
||||
distribution if you want one; strip it from the catalog build.
|
||||
4. **SHA bumps are new PRs.** Updating an entry's pin is a new PR whose diff
|
||||
(old SHA → new SHA) is re-reviewed like any other change — reviewers are
|
||||
expected to look at the upstream commit range being adopted.
|
||||
5. **Owner-or-major-contributor submissions, or a maintainer-curated sweep.**
|
||||
An entry may be submitted by the plugin repository's owner or a major
|
||||
contributor to it; drive-by submissions of third-party repos are declined.
|
||||
Hermes maintainers may also add entries in batches from a reviewed sweep
|
||||
of community plugins (every pin validated and scanned at the pinned
|
||||
commit, self-updater and credential-store checks run, English-first UI).
|
||||
Authors of swept-in entries keep control: a PR from the owner adjusting
|
||||
or removing their entry is accepted on request, and SHA bumps stay
|
||||
owner-or-maintainer PRs under rule 4.
|
||||
6. **Declared capabilities must match reality.** The `capabilities:` block
|
||||
(tools, hooks, middleware, env vars) must match what the plugin actually
|
||||
registers at the pinned commit. Validation fails the entry otherwise —
|
||||
undeclared capability creep is treated as a security issue.
|
||||
7. **The install scanner runs at admission.** `hermes plugins validate` includes
|
||||
the `security scan` check: `dangerous` fails the entry; `caution` findings
|
||||
appear as warnings in the CI log and the reviewer reads them before merging.
|
||||
In exchange, installs at the pinned SHA accept `caution` without a prompt
|
||||
(`dangerous` still blocks). Review the warnings; do not merge past them.
|
||||
8. **Desktop plugins stay inside the SDK surface.** A `desktop/plugin.js` runs
|
||||
in the Desktop renderer with the app's full authority (the loader isolates
|
||||
errors, not capabilities), so a listed one may only use the plugin SDK:
|
||||
no prototype patching (`X.prototype.y =`, `Object.defineProperty(...prototype`),
|
||||
no `eval`/`new Function`, no `import()` of anything but `@hermes/plugin-sdk`
|
||||
/ `react` (app bundle chunks, blob or http URLs included), no script-tag
|
||||
injection, no reaching into the app's internal stores or its own markup
|
||||
(querying `data-slot` / `data-tour` / `data-sidebar` / `data-testid`
|
||||
elements from `document`, or a `document.body` MutationObserver, to restyle,
|
||||
hide, click or rewrite core UI). `hermes plugins validate` refuses these at
|
||||
admission (`desktop surface` check); a plugin that needs a capability the
|
||||
SDK lacks asks for an SDK hook instead of patching around it.
|
||||
9. **No runtime overrides of Hermes core.** A listed plugin extends Hermes only
|
||||
through public surfaces: hooks, middleware, provider profiles and the
|
||||
other `register_*` APIs, and Desktop SDK slots and routes. It must not
|
||||
replace, wrap or rebind core functions, methods, module attributes or
|
||||
private dicts in place (`AIAgent.<method> = ...`, `setattr(server, ...)`,
|
||||
`sys.modules[...]`, writes into a core module's tables). Two plugins
|
||||
patching the same seam silently break each other, and every core release
|
||||
can break both. `hermes plugins validate` refuses these at admission (`no
|
||||
core override` check). If the hook you need does not exist, open an issue
|
||||
describing it: we would rather add the seam than list a patch.
|
||||
10. **Dependency security policy is the plugin's.** Hermes's 14-day
|
||||
`exclude-newer` quarantine covers Hermes's own dependencies only; a plugin's
|
||||
`python_dependencies` / `pyproject.toml` install under the plugin's policy
|
||||
(no quarantine, still inside Hermes's core constraints). Reviewers read the
|
||||
dependency list at the pinned SHA: bare floors (`>=X` with no upper bound)
|
||||
and floors on the newest release get a request for the oldest
|
||||
API-compatible floor plus an upper bound, and authors are strongly
|
||||
recommended to run their own release quarantine (`uv --exclude-newer` in
|
||||
their CI) — see the developer guide's *Dependency security policy*. A
|
||||
recent floor alone is not grounds to hold an entry.
|
||||
|
||||
11. **Credentials stay with their owner.** A plugin reads the credentials it is
|
||||
configured with: the env vars in `requires_env` and its own `config_schema`
|
||||
secrets. Reading another tool's login (a vendor CLI's token file, a browser
|
||||
profile) must be disclosed in the PR and is a trust-tier call for a
|
||||
maintainer. Refreshing, rotating or writing another client's OAuth tokens, or
|
||||
presenting itself as another vendor's client, is not admitted without an
|
||||
explicit maintainer ruling; a read-only build is the usual way through.
|
||||
12. **Approvals and unattended runs are respected.** A plugin never routes around
|
||||
Hermes's approval system: no auto-approving, no disabling guards, and no
|
||||
spawning Hermes or shell children that inherit YOLO or non-interactive mode
|
||||
to run commands nobody approved. Anything that waits for a person (a prompt,
|
||||
an OAuth browser flow) fails cleanly or times out under cron, the messaging
|
||||
gateway and other unattended runs instead of hanging the agent.
|
||||
13. **Risky behaviour is disclosed.** What a user would want to know before
|
||||
installing goes in the PR description and the plugin's README: network calls
|
||||
to third-party services, reads outside the plugin's own data, shell commands,
|
||||
long-running background processes, stored credentials. Telemetry and usage
|
||||
reporting are opt-in. Reviewers summarise these as disclosure lines on the
|
||||
entry PR; undisclosed behaviour found in review is a request for changes.
|
||||
14. **Compatibility metadata is truthful.** `requires_hermes` is a SemVer floor
|
||||
(`">=0.21.5"`), never a CalVer date, and never newer than the current release
|
||||
(the loader skips the plugin otherwise). `version` matches the pinned code,
|
||||
and Python dependencies resolve under Hermes's core constraints
|
||||
(`hermes plugins validate --install-deps` is what CI runs).
|
||||
15. **No skins or forks of bundled plugins.** A change to a bundled plugin is a
|
||||
PR against `hermes-agent`, not a competing listing, and vendor-lookalike skins
|
||||
are not listed under Nous branding.
|
||||
16. **One listing per plugin lineage.** A fork of a listed community plugin is
|
||||
listed only when it is materially different from the original: a different
|
||||
transport or architecture, or capability the original lacks and its author
|
||||
declined or has not answered a PR for 30 days. Improvements to a listed plugin
|
||||
go upstream as a PR to its author. A fork that renames, rebrands or adds small
|
||||
changes is declined in favour of the original. A listed fork names its origin
|
||||
in its disclosure line (`Derived from <entry>`).
|
||||
<!-- admission-rules:end -->
|
||||
|
||||
The step-by-step submission guide, with the same rules and what reviewers check,
|
||||
lives at
|
||||
[Submitting to the plugin catalog](https://hermes-agent.nousresearch.com/docs/developer-guide/plugins/catalog-submission).
|
||||
|
||||
## Entry schema
|
||||
|
||||
```yaml
|
||||
name: example-plugin # [a-z0-9_-]{1,64}, the catalog key
|
||||
repo: https://github.com/owner/repo # https:// only
|
||||
sha: <40-hex commit sha> # mandatory exact pin
|
||||
subdir: "" # optional; plain relative path inside the repo
|
||||
# ([A-Za-z0-9._/-]+ only: no '..', '.', empty
|
||||
# segments, absolute, or backslash forms)
|
||||
description: One-line description.
|
||||
maintainer: OwnerName
|
||||
tier: official # official | community (default community)
|
||||
category: memory # desktop | memory | platform | web | tools | voice | automation | models | general
|
||||
# (default desktop) — the shelf the entry sits on at /docs/plugins
|
||||
requires_hermes: ">=0.19" # optional
|
||||
docs_url: "" # optional
|
||||
version: "1.4.0" # optional human label for the sha (quote it); shown as "1.4.0 @ abcd1234"
|
||||
image: "" # optional https image on a GitHub host, 2:1 (e.g. 1200x600), e.g.
|
||||
# https://raw.githubusercontent.com/owner/repo/<sha>/docs/banner.png
|
||||
screenshots: [] # optional, up to 6 https images on a GitHub host; gallery on /docs/plugins/<name>
|
||||
readme: true # optional, default true; the README at the PINNED SHA renders on /docs/plugins/<name>
|
||||
platforms: [] # optional, e.g. [linux, macos]; empty = all
|
||||
title: "" # optional human name on cards ("NVIDIA App"); default = name
|
||||
onboarding: false # optional; offered on the Desktop onboarding card (official entries only)
|
||||
known_issues: [] # optional strings shown at the install prompt; informational, never blocks
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
```
|
||||
|
||||
`version`, `image`, `screenshots` and `readme` are cosmetic: none is parsed or
|
||||
used to pick what installs. The sha stays the release; bump `version` in the
|
||||
same PR that bumps `sha` so the label on the card matches the code. Images must
|
||||
live on `raw.githubusercontent.com`, `github.com` or `*.githubusercontent.com`
|
||||
so the Desktop catalog and the docs site never fetch from third-party hosts;
|
||||
pin the raw URL to the entry's commit and the picture is as immutable as the
|
||||
code.
|
||||
|
||||
Every entry gets a page at `https://hermes-agent.nousresearch.com/docs/plugins/<name>`
|
||||
and every maintainer a page at `/docs/plugins/by/<maintainer>`, both generated
|
||||
from these files at docs build time. `screenshots:` fills the page's gallery;
|
||||
the build fetches the README (from `subdir` if set, else the repo root) **at the
|
||||
pinned sha** — GitHub and GitLab repos, on by default, `readme: false` opts out — and renders it
|
||||
through an allowlist (raw HTML dropped, links and images resolved against the
|
||||
pinned tree). The page therefore shows the README the reviewer read, and it
|
||||
changes only when a reviewed re-pin lands.
|
||||
|
||||
## removed.yaml — the blocklist
|
||||
|
||||
When an entry is pulled from the catalog for security or policy reasons, it
|
||||
is recorded in `removed.yaml` with a reason and date. The installer refuses
|
||||
to install anything matching a removed entry's name or repo URL, so a
|
||||
malicious plugin cannot be re-installed from a stale identifier after
|
||||
removal. A plugin that lands on the list after a user installed it stops
|
||||
updating, cannot be enabled and is refused at load time. Removals, like
|
||||
additions, land via reviewed PRs.
|
||||
|
||||
Delisting is different from removal: an entry that is unmaintained, superseded, squatting a
|
||||
name it is not affiliated with, or no longer meets a rule is deleted from the catalog (plain
|
||||
file removal, users who already installed it are unaffected). It is welcome back once the
|
||||
problem is fixed; a name squatter comes back under a distinct name. When a new rule affects
|
||||
entries that are already listed, their authors get an issue on their repository explaining
|
||||
the change and time to update before anything is delisted.
|
||||
|
||||
## Names
|
||||
|
||||
The catalog key and the manifest `name:` are what users search, install, and — for memory
|
||||
providers — put in `memory.provider`. A `memory` / `exclusive` entry must not reuse the name of
|
||||
another provider or of a well-known upstream project it is not affiliated with: two providers
|
||||
registering the same `register_memory_provider` name make `memory.provider` ambiguous
|
||||
(whichever loads last wins). Reviewers check the registered provider name, not just the file
|
||||
name, and the affiliated project gets the bare key.
|
||||
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user