Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
9240576937 | ||
|
|
233885d3a7 | ||
|
|
9a1d831d4b | ||
|
|
1fe367177c | ||
|
|
40c3c74282 | ||
|
|
ee73fa8cce | ||
|
|
f960716483 | ||
|
|
5f75dcc99a | ||
|
|
ac6687181e | ||
|
|
3f12c627ab | ||
|
|
4115f1b4c0 |
@@ -0,0 +1,118 @@
|
|||||||
|
# AITURK IDE ajan CI (kart turkservis-20261007-1421-2).
|
||||||
|
#
|
||||||
|
# Bu depo bir yukari akis catalidir; .github/workflows altindaki GitHub is akislari Gitea'da kosmaz. .gitea/workflows
|
||||||
|
# klasoru var oldugu surece Gitea yalniz buradaki is akislarini okur.
|
||||||
|
#
|
||||||
|
# Ne kosar: korumali talimat dosyasi onay kapisinin testleri (insana sorulamayan kosuda aninda BLOCKED, gec onayin
|
||||||
|
# uygulanmamasi, yolo ile kapinin acilmamasi) ve dosya yazma guvenligi testleri. Kosucu Windows'tur (etiket
|
||||||
|
# windows-latest; 7 Ekim 2026'da cevrimici kosucu hermes-laptop-windows): urunun hedefi Windows x64'tur ve hata
|
||||||
|
# Windows asama kosusunda gorulmustur.
|
||||||
|
#
|
||||||
|
# Ne sayilmaz (durustluk notu): tests/tools/test_file_write_safety.py icindeki 7 test Windows'ta bu degisiklikten
|
||||||
|
# bagimsiz olarak gecemez; yukari akis bunlari POSIX icin yazmistir (/etc, /private/etc, /boot yollari; yonetici
|
||||||
|
# ayricaligi isteyen symlink; POSIX stat alanlari). Asagida adlariyla deselect edilir; degismemis 58dbb4523b
|
||||||
|
# commit'inde de ayni 7 test kaliyordu (7 Ekim 2026 olcumu). Tam yukari akis test takimi burada KOSMAZ.
|
||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- aiturk/main
|
||||||
|
- fix/ide-windows-vcruntime
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
mobile-link-windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: https://gitea.twinpay.one/yilsem/ci-actions-checkout@11d5960a326750d5838078e36cf38b85af677262 # upstream v4.4.0
|
||||||
|
with:
|
||||||
|
github-server-url: https://gitea.twinpay.one
|
||||||
|
- name: Mobil TLS, profil ve baslatma regresyonlari
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$deps = Join-Path $env:RUNNER_TEMP "mobile-tests-$env:GITHUB_RUN_ID"
|
||||||
|
New-Item -ItemType Directory -Force -Path $deps | Out-Null
|
||||||
|
npm install --prefix $deps --no-save --ignore-scripts --no-audit --no-fund --legacy-peer-deps vitest@4.1.10 ws@8.21.1
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Test bagimliliklari kurulamadi' }
|
||||||
|
New-Item -ItemType Junction -Path node_modules -Target (Join-Path $deps 'node_modules') | Out-Null
|
||||||
|
node node_modules/vitest/vitest.mjs run --root apps/desktop --config vitest.mobile.config.ts
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
- name: Codex koprusu (Python, bagimliliksiz) testleri
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$py = (Get-Command python -ErrorAction SilentlyContinue).Source
|
||||||
|
if ($py) { & $py -m unittest discover -s apps/desktop/assets/codex-bridge }
|
||||||
|
else { & py -3 -m unittest discover -s apps/desktop/assets/codex-bridge }
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
|
||||||
|
approval-gate-windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 40
|
||||||
|
steps:
|
||||||
|
- name: Kaynagi al
|
||||||
|
uses: https://gitea.twinpay.one/yilsem/ci-actions-checkout@11d5960a326750d5838078e36cf38b85af677262 # upstream v4.4.0
|
||||||
|
with:
|
||||||
|
github-server-url: https://gitea.twinpay.one
|
||||||
|
|
||||||
|
- name: Ortami kur
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
"kosucu: $env:COMPUTERNAME / $env:USERNAME"
|
||||||
|
"checkout SHA: $(git rev-parse HEAD) / beklenen: $env:GITHUB_SHA"
|
||||||
|
$py = $null
|
||||||
|
$launcher = Get-Command py -ErrorAction SilentlyContinue
|
||||||
|
if ($launcher) { $py = (& $launcher.Source -3 -c 'import sys; print(sys.executable)').Trim() }
|
||||||
|
if (-not $py) {
|
||||||
|
$cmd = Get-Command python -ErrorAction SilentlyContinue
|
||||||
|
if ($cmd -and $cmd.Source -notlike '*\WindowsApps\*') { $py = $cmd.Source }
|
||||||
|
}
|
||||||
|
if (-not $py) { throw 'Python bulunamadi' }
|
||||||
|
"onyukleme python: $py"
|
||||||
|
& $py -m pip install --quiet --disable-pip-version-check --target "$env:RUNNER_TEMP\uvboot" uv
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'uv kurulamadi' }
|
||||||
|
$env:PYTHONPATH = "$env:RUNNER_TEMP\uvboot"
|
||||||
|
& $py -m uv venv --python 3.11 "$env:RUNNER_TEMP\ci-venv"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'venv olusturulamadi' }
|
||||||
|
& $py -m uv pip install --python "$env:RUNNER_TEMP\ci-venv\Scripts\python.exe" -e . pytest
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'bagimliliklar kurulamadi' }
|
||||||
|
$env:PYTHONPATH = ''
|
||||||
|
& "$env:RUNNER_TEMP\ci-venv\Scripts\python.exe" --version
|
||||||
|
|
||||||
|
- name: Onay kapisi testleri
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
$env:PYTHONDONTWRITEBYTECODE = '1'
|
||||||
|
$env:PYTHONUTF8 = '1'
|
||||||
|
$venv = "$env:RUNNER_TEMP\ci-venv\Scripts\python.exe"
|
||||||
|
$f = 'tests/tools/test_file_write_safety.py'
|
||||||
|
$sayilmayan = @(
|
||||||
|
"${f}::TestCheckSensitivePathMacOSBypass::test_etc_hosts_blocked",
|
||||||
|
"${f}::TestCheckSensitivePathMacOSBypass::test_private_etc_hosts_blocked",
|
||||||
|
"${f}::TestCheckSensitivePathMacOSBypass::test_private_etc_ssh_config_blocked",
|
||||||
|
"${f}::TestCheckSensitivePathMacOSBypass::test_private_var_blocked",
|
||||||
|
"${f}::TestCheckSensitivePathMacOSBypass::test_boot_still_blocked",
|
||||||
|
"${f}::TestAtomicWrite::test_patch_routes_through_atomic_write",
|
||||||
|
"${f}::TestProtectedInstructionFiles::test_symlink_to_protected_file_is_gated"
|
||||||
|
)
|
||||||
|
$deselect = @()
|
||||||
|
foreach ($t in $sayilmayan) { $deselect += '--deselect'; $deselect += $t }
|
||||||
|
"CI OZETI: Windows'ta sayilmayan (deselect) test sayisi: $($sayilmayan.Count)"
|
||||||
|
$sayilmayan | ForEach-Object { " sayilmadi: $_" }
|
||||||
|
& $venv -m pytest tests/tools/test_protected_instruction_unattended.py $f `
|
||||||
|
tests/tools/test_computer_use_approval_isolation.py tests/cli/test_cli_approval_ui.py `
|
||||||
|
tests/cli/test_single_query_session_finalize.py `
|
||||||
|
tests/hermes_cli/test_plugin_catalog.py tests/hermes_cli/test_aiturk_plugin_catalog.py `
|
||||||
|
tests/hermes_cli/test_plugin_install_ref.py `
|
||||||
|
tests/tui_gateway/test_mobile_subagents.py @deselect -q -p no:cacheprovider -rf
|
||||||
|
$kod = $LASTEXITCODE
|
||||||
|
"pytest cikis kodu: $kod"
|
||||||
|
exit $kod
|
||||||
@@ -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.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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,359 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
|
||||||
|
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,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,377 @@
|
|||||||
|
"""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 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
|
||||||
|
return len(parca) >= 3 and sorgu.startswith(parca) and len(sorgu) - len(parca) <= 4 and not sorgu.isdigit()
|
||||||
|
|
||||||
|
|
||||||
|
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]]}
|
||||||
|
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.")
|
||||||
|
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.")
|
||||||
|
|
||||||
|
|
||||||
|
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,83 @@
|
|||||||
|
"""Regression coverage for Codex -> external API access_programs compatibility."""
|
||||||
|
import ast
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
# Load only the actual adapter and routing method: no catalog/network/thread
|
||||||
|
# startup or credential loading is needed to exercise the production code.
|
||||||
|
source = ast.parse(Path(__file__).with_name("bridge.py").read_text(encoding="utf-8"))
|
||||||
|
flatten = next(n for n in source.body if isinstance(n, ast.FunctionDef) and n.name == "flatten_request")
|
||||||
|
claude_sonu = next(n for n in source.body if isinstance(n, ast.FunctionDef) and n.name == "claude_sonu_duzelt")
|
||||||
|
handler = next(n for n in source.body if isinstance(n, ast.ClassDef) and n.name == "H")
|
||||||
|
responses = next(n for n in handler.body if isinstance(n, ast.FunctionDef) and n.name == "_responses")
|
||||||
|
|
||||||
|
|
||||||
|
class AccessPrograms(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.upstream = Mock(return_value=Mock(status=200))
|
||||||
|
self.env = {
|
||||||
|
"json": json,
|
||||||
|
"urllib": Mock(request=Mock(Request=urllib.request.Request, urlopen=self.upstream)),
|
||||||
|
"decompress": lambda body, encoding: body,
|
||||||
|
"MODEL_MAP": {},
|
||||||
|
"CFG": {"nine_router_catalog": {"enabled": True, "route": "9router", "prefix": "9r/"},
|
||||||
|
"resim_sadelestir": False, "pano_kanca_user": False},
|
||||||
|
"ROUTES": {"9router": {"url": "https://example.invalid/v1/responses", "key": "test-only", "flatten_tools": True}},
|
||||||
|
"fix_tool_ids": lambda req: 0,
|
||||||
|
"drop_foreign_items": lambda req: 0,
|
||||||
|
"SseRewriter": Mock(),
|
||||||
|
"log": lambda message: None, "pencere": __import__("pencere"), "IDE_BILGI": {}, "yarim": __import__("yarim"), "is_hatti": __import__("is_hatti"), "GORULEN_ISTEMCI": set(), "arac_semalarini_duzelt": lambda req: 0, "nokta_mesaji_mi": lambda req: False, "EXTRA_MODELS": [],
|
||||||
|
"DEBUG": False, "UA": "test", "RETRIES": 1, "UPSTREAM_IDLE_TIMEOUT": 3900,
|
||||||
|
}
|
||||||
|
exec(compile(ast.Module(body=[flatten, claude_sonu, responses], type_ignores=[]), "bridge.py", "exec"), self.env)
|
||||||
|
|
||||||
|
def request(self, model):
|
||||||
|
return {"model": model, "access_programs": {"cyber": "standard"},
|
||||||
|
"metadata": {"access_programs": "preserve nested data"},
|
||||||
|
"input": [{"role": "user", "content": [{"type": "input_text", "text": "hello"}]}],
|
||||||
|
"tools": [{"type": "function", "name": "echo", "parameters": {"type": "object", "properties": {}}}]}
|
||||||
|
|
||||||
|
def test_adapter_removes_only_top_level_field(self):
|
||||||
|
req = self.request("meta/muse-spark-1.3")
|
||||||
|
original = copy.deepcopy(req)
|
||||||
|
self.env["flatten_request"](req)
|
||||||
|
self.assertNotIn("access_programs", req)
|
||||||
|
self.assertEqual(req["metadata"], original["metadata"])
|
||||||
|
self.assertEqual(req["input"], original["input"])
|
||||||
|
self.assertEqual(req["tools"][0]["name"], "echo")
|
||||||
|
|
||||||
|
def test_absent_null_and_empty_values(self):
|
||||||
|
for value in (None, {}, {"cyber": "standard"}):
|
||||||
|
with self.subTest(value=value):
|
||||||
|
req = {"input": [], "access_programs": value}
|
||||||
|
self.env["flatten_request"](req)
|
||||||
|
self.assertNotIn("access_programs", req)
|
||||||
|
self.env["flatten_request"]({"input": []})
|
||||||
|
|
||||||
|
def test_model_and_combo_external_request_strips_field(self):
|
||||||
|
for model in ("9r/meta/muse-spark-1.3-contributor", "9r/Fusion-Hizli"):
|
||||||
|
with self.subTest(model=model):
|
||||||
|
body = json.dumps(self.request(model)).encode()
|
||||||
|
fake = Mock(headers={})
|
||||||
|
self.env["_responses"](fake, 1, "/responses", body)
|
||||||
|
sent = json.loads(self.upstream.call_args.args[0].data)
|
||||||
|
self.assertNotIn("access_programs", sent)
|
||||||
|
self.assertEqual(sent["model"], model.removeprefix("9r/"))
|
||||||
|
self.assertIn("access_programs", json.loads(body))
|
||||||
|
fake._stream.assert_called_once()
|
||||||
|
|
||||||
|
def test_chatgpt_passthrough_preserves_original_body(self):
|
||||||
|
body = json.dumps(self.request("gpt-6-astra")).encode()
|
||||||
|
fake = Mock(headers={})
|
||||||
|
self.env["_responses"](fake, 1, "/responses", body)
|
||||||
|
self.assertEqual(fake._passthrough.call_args.args[2], body)
|
||||||
|
self.upstream.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""claude_sonu_duzelt testleri (9Router Claude prefill 400)."""
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
tree = ast.parse(Path(__file__).with_name("bridge.py").read_text(encoding="utf-8"))
|
||||||
|
env = {}
|
||||||
|
exec(compile(ast.Module(body=[n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == "claude_sonu_duzelt"], type_ignores=[]), "bridge.py", "exec"), env)
|
||||||
|
f = env["claude_sonu_duzelt"]
|
||||||
|
|
||||||
|
|
||||||
|
def msg(role, text="x"):
|
||||||
|
return {"type": "message", "role": role, "content": [{"type": "input_text", "text": text}]}
|
||||||
|
|
||||||
|
|
||||||
|
class ClaudeSonu(unittest.TestCase):
|
||||||
|
def test_asistanla_biten_duzelir(self):
|
||||||
|
req = {"input": [msg("user"), msg("assistant")]}
|
||||||
|
self.assertTrue(f(req)[0])
|
||||||
|
self.assertEqual(req["input"][-1]["role"], "user")
|
||||||
|
|
||||||
|
def test_asistan_sonra_developer_duzelir(self):
|
||||||
|
# Sesli tur kapaninca: ... assistant, developer (9Router developer'i dusurur -> asistanla biter)
|
||||||
|
req = {"input": [msg("user"), msg("assistant"), msg("developer")]}
|
||||||
|
self.assertTrue(f(req)[0])
|
||||||
|
self.assertEqual(req["input"][-1]["role"], "user")
|
||||||
|
|
||||||
|
def test_reasoning_ile_biten_duzelir(self):
|
||||||
|
req = {"input": [msg("user"), {"type": "reasoning", "id": "rs_1"}]}
|
||||||
|
self.assertTrue(f(req)[0])
|
||||||
|
|
||||||
|
def test_kullaniciyla_biten_degismez(self):
|
||||||
|
req = {"input": [msg("assistant"), msg("user"), msg("developer")]}
|
||||||
|
self.assertFalse(f(req)[0])
|
||||||
|
self.assertEqual(len(req["input"]), 3)
|
||||||
|
|
||||||
|
def test_arac_sonucuyla_biten_degismez(self):
|
||||||
|
req = {"input": [msg("user"), {"type": "custom_tool_call", "call_id": "c"}, {"type": "custom_tool_call_output", "call_id": "c"}]}
|
||||||
|
self.assertFalse(f(req)[0])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""A 9Router combo (Fusion-*) must not be sent twice once it may be running upstream.
|
||||||
|
|
||||||
|
22 Sep 2026: after a Cloudflare 524 the bridge resent 9r/Fusion-Orta three times and the whole
|
||||||
|
Fusion ran (and billed) again. Failures before the work starts may still be retried.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
source = ast.parse(Path(__file__).with_name("bridge.py").read_text(encoding="utf-8"))
|
||||||
|
pick = lambda name: next(n for n in source.body if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||||
|
handler = next(n for n in source.body if isinstance(n, ast.ClassDef) and n.name == "H")
|
||||||
|
responses = next(n for n in handler.body if isinstance(n, ast.FunctionDef) and n.name == "_responses")
|
||||||
|
|
||||||
|
|
||||||
|
def http_error(code, body=b"{}"):
|
||||||
|
return urllib.error.HTTPError("https://example.invalid", code, "x", {}, io.BytesIO(body))
|
||||||
|
|
||||||
|
|
||||||
|
class ComboNotResent(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.upstream = Mock()
|
||||||
|
self.env = {
|
||||||
|
"json": json, "re": __import__("re"), "os": __import__("os"), "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"), "is_hatti": __import__("is_hatti"), "GORULEN_ISTEMCI": set(), "arac_semalarini_duzelt": lambda req: 0, "nokta_mesaji_mi": lambda req: False, "EXTRA_MODELS": [],
|
||||||
|
}
|
||||||
|
exec(compile(ast.Module(body=[pick("flatten_request"), pick("claude_sonu_duzelt"), pick("is_timeout"), 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,441 @@
|
|||||||
|
"""Audit of 11 Oct 2026, remaining findings: each fix has a test here (handler methods run through a fake handler)."""
|
||||||
|
import ast
|
||||||
|
import copy
|
||||||
|
import gzip
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import ide_catalog
|
||||||
|
import is_hatti
|
||||||
|
import model_gecis
|
||||||
|
import ozet_kopru
|
||||||
|
import pencere
|
||||||
|
import resim_context
|
||||||
|
import yarim
|
||||||
|
|
||||||
|
SOURCE = Path(__file__).with_name("bridge.py").read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(SOURCE)
|
||||||
|
handler = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "H")
|
||||||
|
|
||||||
|
|
||||||
|
def top(*names):
|
||||||
|
nodes = [n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.ClassDef)) and n.name in names]
|
||||||
|
for node in tree.body:
|
||||||
|
if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id in names for t in node.targets):
|
||||||
|
nodes.insert(0, node)
|
||||||
|
return nodes
|
||||||
|
|
||||||
|
|
||||||
|
def method(name):
|
||||||
|
return next(n for n in handler.body if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||||
|
|
||||||
|
|
||||||
|
def build(nodes, env):
|
||||||
|
exec(compile(ast.Module(body=nodes, type_ignores=[]), "bridge.py", "exec"), env)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def user(text):
|
||||||
|
return {"type": "message", "role": "user", "content": [{"type": "input_text", "text": text}]}
|
||||||
|
|
||||||
|
|
||||||
|
def developer(text):
|
||||||
|
return {"type": "message", "role": "developer", "content": [{"type": "input_text", "text": text}]}
|
||||||
|
|
||||||
|
|
||||||
|
MODELS = [("gpt-6-sol", "GPT-6-Sol"), ("gpt-6-astra", "GPT-6-Astra"), ("gpt-6.1-sol", "GPT-6.1-Sol"),
|
||||||
|
("ide/9router-ag-gemini-3.8-flash-low", "agy/gemini-3.8-flash-low"),
|
||||||
|
("ide/9router-cc-claude-opus-5-5", "cc/opus-5-5")]
|
||||||
|
CALL = {"type": "function_call", "call_id": "c", "name": "shell", "arguments": "{}"}
|
||||||
|
OUT = {"type": "function_call_output", "call_id": "c", "output": "x"}
|
||||||
|
|
||||||
|
|
||||||
|
class SesliKomut(unittest.TestCase):
|
||||||
|
def pick(self, text):
|
||||||
|
command = model_gecis.komut_coz(text)
|
||||||
|
return model_gecis.eslestir(command["ad"], MODELS) if command and command["tur"] == "gec" else None
|
||||||
|
|
||||||
|
def test_numbers_said_as_words_and_a_continue_tail_still_switch(self):
|
||||||
|
for text, slug in [("modeli opus beş beş yap", "ide/9router-cc-claude-opus-5-5"),
|
||||||
|
("modeli gemini üç nokta sekiz flash low yap", "ide/9router-ag-gemini-3.8-flash-low"),
|
||||||
|
("modeli gpt altı nokta bir sol yap", "gpt-6.1-sol"),
|
||||||
|
("modeli gpt altı astra yap ve devam et", "gpt-6-astra"),
|
||||||
|
("modeli opus 5 5 yap sonra devam et", "ide/9router-cc-claude-opus-5-5")]:
|
||||||
|
self.assertEqual((self.pick(text) or {}).get("slug"), slug, text)
|
||||||
|
|
||||||
|
def test_a_task_after_the_command_is_not_swallowed(self):
|
||||||
|
for text in ("modeli opus 5 5 yap ve şu dosyayı düzelt", "bir model seç", "modeli iki kere değiştir"):
|
||||||
|
self.assertIsNone(self.pick(text), text)
|
||||||
|
|
||||||
|
def test_last_command_is_noted_only_when_it_really_took_effect(self):
|
||||||
|
opus = "ide/9router-cc-claude-opus-5-5"
|
||||||
|
refused = {"input": [user("modeli opus 5 5 yap"), {"type": "compaction_trigger"}]}
|
||||||
|
self.assertEqual(model_gecis.gecmisi_temizle(refused, MODELS, "gpt-6-sol"), 0) # e.g. "bağlantı kapalı"
|
||||||
|
self.assertEqual(refused["input"][0]["content"][0]["text"], "modeli opus 5 5 yap")
|
||||||
|
applied = {"input": [user("modeli opus 5 5 yap"), CALL, OUT]}
|
||||||
|
self.assertEqual(model_gecis.gecmisi_temizle(applied, MODELS, opus), 1) # mid-turn handover
|
||||||
|
self.assertIn("cc/opus-5-5 olarak değiştirdi", applied["input"][0]["content"][0]["text"])
|
||||||
|
|
||||||
|
def test_a_route_in_use_does_not_expire(self):
|
||||||
|
y = model_gecis.Yonlendirme(None)
|
||||||
|
y.koy("chat", "gpt-6-sol", "ide/x", simdi=1000)
|
||||||
|
day = 24 * 3600
|
||||||
|
for n in range(1, 12): # used every 1.5 days for 16 days
|
||||||
|
self.assertEqual(y.al("chat", "gpt-6-sol", simdi=1000 + n * 1.5 * day), "ide/x", n)
|
||||||
|
self.assertIsNone(y.al("chat", "gpt-6-sol", simdi=1000 + 16.5 * day + model_gecis.OMUR_SN + 1)) # unused for a week
|
||||||
|
|
||||||
|
|
||||||
|
class Pencere(unittest.TestCase):
|
||||||
|
def test_guard_runs_for_a_combo_whose_small_member_is_known(self):
|
||||||
|
self.assertFalse(pencere.bekci_calisir("varsayilan", 128_000))
|
||||||
|
self.assertFalse(pencere.bekci_calisir("", 128_000))
|
||||||
|
self.assertFalse(pencere.bekci_calisir("uyeler:varsayilan", 128_000))
|
||||||
|
self.assertTrue(pencere.bekci_calisir("uyeler:nanogpt,varsayilan", 8192)) # the 8K member is real
|
||||||
|
self.assertFalse(pencere.bekci_calisir("uyeler:nanogpt,varsayilan", 128_000)) # the minimum is the guess
|
||||||
|
for known in ("yerel", "nanogpt", "ogrenilen", "uyeler:nanogpt,yerel"):
|
||||||
|
self.assertTrue(pencere.bekci_calisir(known, 200_000), known)
|
||||||
|
|
||||||
|
def test_combo_with_a_claude_member_uses_the_claude_ratio(self):
|
||||||
|
combo = {"topology": {"kind": "combo", "strategy": "priority", "members": [
|
||||||
|
{"kind": "model", "model": "cx/gpt-6-astra"},
|
||||||
|
{"kind": "combo", "strategy": "priority", "members": [{"kind": "model", "model": "cc/claude-opus-5-5"}]}]}}
|
||||||
|
plain = {"topology": {"kind": "combo", "members": [{"kind": "model", "model": "ag/gemini-3.8-flash"}]}}
|
||||||
|
self.assertTrue(pencere.claude_var("combo", "Opus-5", combo))
|
||||||
|
self.assertFalse(pencere.claude_var("combo", "Hizli", plain))
|
||||||
|
self.assertTrue(pencere.claude_var("model", "no-think/claude-opus-5-5"))
|
||||||
|
self.assertFalse(pencere.claude_var("model", "nano/z-ai/glm-5.3"))
|
||||||
|
self.assertEqual(pencere.oran_sec("Opus-5", True), pencere.CLAUDE_BAYT_BOLU_TOKEN)
|
||||||
|
self.assertEqual(pencere.oran_sec("Opus-5"), pencere.BAYT_BOLU_TOKEN)
|
||||||
|
|
||||||
|
def test_a_corrupt_nano_cache_does_not_block_the_refresh(self):
|
||||||
|
home = tempfile.mkdtemp()
|
||||||
|
Path(home, pencere.NANO_DOSYA).write_text(json.dumps({"zaman": "dun", "modeller": {"eski": {"pencere": 9000}}}),
|
||||||
|
encoding="utf-8")
|
||||||
|
fresh = {"data": [{"id": "Yeni/M", "context_length": 65536, "capabilities": {}}]}
|
||||||
|
self.assertIn("yeni/m", pencere.nano_yukle(home, simdi=1000.0, getir=lambda: fresh))
|
||||||
|
|
||||||
|
def test_trimming_keeps_the_real_last_user_message_and_the_newest_tool_output(self):
|
||||||
|
marker = "(Önceki cevabın yarıda kaldı ya da sesli konuşma kapandı; bağlama göre devam et.)"
|
||||||
|
self.assertIn(marker, SOURCE) # the text bridge.py appends
|
||||||
|
self.assertTrue(marker.startswith(pencere.KOPRU_DEVAM_BASI))
|
||||||
|
req = {"input": [{"type": "function_call_output", "call_id": "a", "output": "eski " * 30000},
|
||||||
|
user("TALIMAT " * 5000),
|
||||||
|
{"type": "function_call_output", "call_id": "b", "output": "yeni " * 4000},
|
||||||
|
user(marker)]}
|
||||||
|
trimmed, fits, _ = pencere.sigdir(req, 30_000)
|
||||||
|
self.assertEqual((trimmed, fits), (1, True))
|
||||||
|
self.assertIn("çıkarıldı (köprü)", req["input"][0]["output"])
|
||||||
|
self.assertEqual(req["input"][1]["content"][0]["text"], "TALIMAT " * 5000) # the user's real last message
|
||||||
|
self.assertEqual(req["input"][2]["output"], "yeni " * 4000) # newest tool result untouched
|
||||||
|
# When nothing else is left, the newest tool output is trimmed rather than the request refused.
|
||||||
|
only = {"input": [user("soru"), {"type": "function_call_output", "call_id": "b", "output": "yeni " * 40000}]}
|
||||||
|
self.assertEqual(pencere.sigdir(only, 30_000)[:2], (1, True))
|
||||||
|
|
||||||
|
|
||||||
|
class Ozet(unittest.TestCase):
|
||||||
|
def test_a_cut_stream_is_not_returned_as_a_whole_summary(self):
|
||||||
|
delta = b'data: {"type":"response.output_text.delta","item_id":"m","delta":"yarim oz"}\n\n'
|
||||||
|
self.assertIsNone(ozet_kopru.sse_metni(delta))
|
||||||
|
done = delta + b'data: {"type":"response.completed","response":{}}\n\n'
|
||||||
|
self.assertEqual(ozet_kopru.sse_metni(done), "yarim oz")
|
||||||
|
|
||||||
|
def test_the_in_a_row_brake_lifts_after_the_window(self):
|
||||||
|
f = ozet_kopru.DonguFreni(sinir=8, pencere_sn=600, art_arda=3)
|
||||||
|
self.assertEqual([f.izin("s", t) for t in (0, 10, 20, 30)], [True, True, True, False])
|
||||||
|
self.assertFalse(f.izin("s", 500)) # still inside the window
|
||||||
|
self.assertTrue(f.izin("s", 24 * 3600)) # a day later the chat is not locked any more
|
||||||
|
|
||||||
|
def test_cleanup_survives_an_empty_record(self):
|
||||||
|
f = ozet_kopru.DonguFreni()
|
||||||
|
f.kayit = {"k%d" % i: [float(i)] for i in range(500)}
|
||||||
|
f.kayit["bos"] = []
|
||||||
|
self.assertTrue(f.izin("yeni", 1000.0))
|
||||||
|
self.assertLess(len(f.kayit), 300)
|
||||||
|
|
||||||
|
|
||||||
|
class Akis(unittest.TestCase):
|
||||||
|
env = build(top("fix_tool_ids", "SseRewriter", "phase_damgala", "asistan_mesaji_mi", "ID_PREFIX", "ARAC_OGELERI"),
|
||||||
|
{"json": json, "re": __import__("re"), "time": time, "log": lambda m: None, "pencere": pencere})
|
||||||
|
|
||||||
|
def events(self, raw):
|
||||||
|
return [json.loads(line[6:]) for line in raw.decode().splitlines() if line.startswith("data: ")]
|
||||||
|
|
||||||
|
def test_crlf_separated_events_are_translated(self):
|
||||||
|
flat = {"type": "error", "code": "bad_request", "message": "upstream 402"}
|
||||||
|
raw = b"event: error\r\ndata: " + json.dumps(flat).encode() + b"\r\n\r\n"
|
||||||
|
adapter = self.env["SseRewriter"](set())
|
||||||
|
out = self.events(adapter.feed(raw[:20]) + adapter.feed(raw[20:]) + adapter.flush())
|
||||||
|
self.assertEqual(out[-1]["type"], "response.completed")
|
||||||
|
self.assertIn("kredi ya da kota", out[1]["delta"])
|
||||||
|
watcher = yarim.Izleyici("k", "m")
|
||||||
|
watcher.feed(b'data: {"type":"response.output_text.delta","delta":"merhaba"}\r\n\r\n'
|
||||||
|
b'data: {"type":"response.completed"}\r\n\r\n')
|
||||||
|
self.assertEqual((watcher.metin, watcher.bitti), ("merhaba", True))
|
||||||
|
|
||||||
|
def test_a_non_object_event_body_passes_through(self):
|
||||||
|
adapter = self.env["SseRewriter"](set())
|
||||||
|
self.assertEqual(adapter.feed(b"data: null\n\ndata: [1]\n\n"), b"data: null\n\ndata: [1]\n\n")
|
||||||
|
|
||||||
|
def test_a_waiting_message_is_released_before_the_error_answer(self):
|
||||||
|
message = {"type": "response.output_item.done", "output_index": 0,
|
||||||
|
"item": {"id": "m1", "type": "message", "role": "assistant", "content": []}}
|
||||||
|
adapter = self.env["SseRewriter"](set(), dolu=True)
|
||||||
|
first = adapter.feed(b"data: " + json.dumps(message).encode() + b"\n\n")
|
||||||
|
self.assertEqual(first, b"") # held until what follows is known
|
||||||
|
out = self.events(adapter.feed(b'data: {"type":"error","message":"upstream 400"}\n\n') + adapter.flush())
|
||||||
|
self.assertEqual([e["type"] for e in out], ["response.output_item.done", "response.failed"])
|
||||||
|
self.assertEqual(out[0]["item"]["id"], "m1")
|
||||||
|
self.assertIn("nedenini bildirmedi", out[1]["response"]["error"]["message"]) # no false "did not fit"
|
||||||
|
self.assertIn("olabilir", out[1]["response"]["error"]["message"])
|
||||||
|
|
||||||
|
|
||||||
|
class Resim(unittest.TestCase):
|
||||||
|
def test_a_new_image_in_a_message_without_type_is_kept(self):
|
||||||
|
img = {"type": "input_image", "image_url": "data:image/png;base64," + "A" * 2000}
|
||||||
|
req = {"input": [{"role": "user", "content": [{"type": "input_text", "text": "bak"}, dict(img)]}]}
|
||||||
|
self.assertEqual(resim_context.resimleri_sadelestir(req), (0, 0))
|
||||||
|
self.assertEqual(req["input"][0]["content"][1]["type"], "input_image")
|
||||||
|
|
||||||
|
|
||||||
|
class Katalog(unittest.TestCase):
|
||||||
|
def test_names_stay_distinct_when_the_id_tail_collides_too(self):
|
||||||
|
names = ide_catalog.adlandir([("a-ortakson", "Ad"), ("b-ortakson", "Ad"), ("c-ortakson", "Ad")], dolu={"ad"})
|
||||||
|
self.assertEqual(len({v.lower() for v in names.values()}), 3)
|
||||||
|
|
||||||
|
def test_an_original_listed_as_extra_keeps_its_place(self):
|
||||||
|
models = [{"slug": "gpt-6-sol", "display_name": "GPT-6-Sol", "priority": 1},
|
||||||
|
{"slug": "gpt-6-astra", "display_name": "GPT-6-Astra", "priority": 2},
|
||||||
|
{"slug": "ide/a", "display_name": "Aaa", "priority": 0}]
|
||||||
|
extras = [{"slug": "gpt-6-astra"}, {"slug": "ide/a", "kind": "combo"}]
|
||||||
|
ide_catalog.sirala_codex(models, extras, {"gpt-6-sol", "gpt-6-astra"})
|
||||||
|
self.assertEqual([(m["slug"], m["priority"]) for m in models],
|
||||||
|
[("gpt-6-sol", 1), ("gpt-6-astra", 2), ("ide/a", 3)])
|
||||||
|
|
||||||
|
|
||||||
|
class Sahte:
|
||||||
|
"""A handler stand-in: real bridge methods, recorded output."""
|
||||||
|
|
||||||
|
def __init__(self, headers=None, command="POST"):
|
||||||
|
self.headers = headers or {}
|
||||||
|
self.command = command
|
||||||
|
self.path = "/backend-api/codex/responses"
|
||||||
|
self.wfile = io.BytesIO()
|
||||||
|
self.status = None
|
||||||
|
self.sent = []
|
||||||
|
self._passthrough = Mock()
|
||||||
|
self._yerel_yanit = Mock()
|
||||||
|
self._send_json = Mock()
|
||||||
|
self._stream = Mock()
|
||||||
|
self._tasma_ogren = Mock()
|
||||||
|
self._aracsiz_ogren = Mock()
|
||||||
|
|
||||||
|
def send_response(self, status):
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
def send_header(self, k, v):
|
||||||
|
self.sent.append((k, v))
|
||||||
|
|
||||||
|
def end_headers(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Zincir(unittest.TestCase):
|
||||||
|
"""_responses + _model_komutu with a conversation key, the way Codex sends it."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.env = build(top("nokta_mesaji_mi", "NOKTA_ISARETLERI", "is_timeout", "kopma_mi", "flatten_request",
|
||||||
|
"claude_sonu_duzelt") + [method("_responses"), method("_model_komutu")], {
|
||||||
|
"json": json, "re": __import__("re"), "os": __import__("os"), "copy": copy, "http": __import__("http.client"),
|
||||||
|
"time": types.SimpleNamespace(sleep=lambda s: None, time=time.time),
|
||||||
|
"urllib": types.SimpleNamespace(request=types.SimpleNamespace(Request=urllib.request.Request, urlopen=Mock()),
|
||||||
|
error=urllib.error),
|
||||||
|
"decompress": lambda body, enc: body, "MODEL_MAP": {}, "ROUTES": {}, "CFG": {"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, "ozet_kopru": ozet_kopru, "OZET_FRENI": ozet_kopru.DonguFreni(), "pencere": pencere,
|
||||||
|
"fix_tool_ids": lambda req: 0, "drop_foreign_items": lambda req: 0, "IDE_BILGI": {}, "EXTRA_MODELS": [],
|
||||||
|
"arac_semalarini_duzelt": lambda req: 0, "SseRewriter": Mock(), "DEBUG": False, "UA": "test", "RETRIES": 6,
|
||||||
|
"UPSTREAM_IDLE_TIMEOUT": 1, "NO_RETRY_PATTERNS": [], "pano_blogunu_ulastir": lambda req: (0, ""),
|
||||||
|
"resimleri_sadelestir": lambda req, gorsel=False: (0, 0),
|
||||||
|
})
|
||||||
|
Sahte._responses = self.env["_responses"]
|
||||||
|
Sahte._model_komutu = self.env["_model_komutu"]
|
||||||
|
|
||||||
|
def send(self, model, items, key="sohbet-1", path="/responses"):
|
||||||
|
fake = Sahte()
|
||||||
|
body = json.dumps({"model": model, "prompt_cache_key": key, "input": items}).encode()
|
||||||
|
fake._responses(1, path, body)
|
||||||
|
return fake, body
|
||||||
|
|
||||||
|
def test_mid_turn_note_reaches_the_subscription_model_behind_hook_messages(self):
|
||||||
|
items = [user("işe başla"), CALL, OUT, user("modeli sol yap"), developer("k1"), developer("k2"), developer("k3")]
|
||||||
|
fake, body = self.send("gpt-6-astra", items)
|
||||||
|
fake._yerel_yanit.assert_not_called()
|
||||||
|
args, kwargs = fake._passthrough.call_args
|
||||||
|
self.assertTrue(kwargs["rewritten"])
|
||||||
|
self.assertNotEqual(args[2], body)
|
||||||
|
sent = json.loads(args[2])
|
||||||
|
self.assertIn("ad belirsizdi", sent["input"][3]["content"][0]["text"])
|
||||||
|
self.assertEqual(sent["model"], "gpt-6-astra")
|
||||||
|
|
||||||
|
def test_idle_switch_says_how_to_return_and_routes_the_next_request(self):
|
||||||
|
fake, _ = self.send("gpt-6-sol", [user("modeli gpt 6 astra yap")])
|
||||||
|
answer = fake._yerel_yanit.call_args.args[1]
|
||||||
|
self.assertIn("Sohbetin modeli artık GPT-6-Astra", answer)
|
||||||
|
self.assertIn('"modeli GPT-6-Sol yap"', answer)
|
||||||
|
final = {"type": "message", "role": "assistant", "phase": "final_answer", "content": "ok"}
|
||||||
|
fake, _ = self.send("gpt-6-sol", [user("modeli gpt 6 astra yap"), final, user("devam")])
|
||||||
|
sent = json.loads(fake._passthrough.call_args.args[2])
|
||||||
|
self.assertEqual(sent["model"], "gpt-6-astra")
|
||||||
|
self.assertIn("GPT-6-Astra olarak değiştirdi", sent["input"][0]["content"][0]["text"])
|
||||||
|
back, _ = self.send("gpt-6-sol", [user("modeli gpt 6 astra yap"), final, user("modeli gpt 6 sol yap")])
|
||||||
|
self.assertNotIn("dönmek için", back._yerel_yanit.call_args.args[1]) # already back on the picked model
|
||||||
|
|
||||||
|
def test_a_refused_switch_is_not_told_as_done_after_compaction(self):
|
||||||
|
items = [user("modeli opus 5 5 yap"), {"type": "compaction_trigger"}] # the ide/ route is closed here
|
||||||
|
fake, body = self.send("gpt-6-sol", items)
|
||||||
|
self.assertEqual(fake._passthrough.call_args.args[2], body)
|
||||||
|
|
||||||
|
def test_lone_dot_answer_says_selected_not_ready(self):
|
||||||
|
self.env["CFG"]["nokta_karsila"] = True
|
||||||
|
fake, _ = self.send("gpt-6-sol", [user(".")])
|
||||||
|
self.assertIn("GPT-6-Sol seçildi", fake._yerel_yanit.call_args.args[1])
|
||||||
|
self.assertNotIn("hazır", fake._yerel_yanit.call_args.args[1])
|
||||||
|
|
||||||
|
def test_old_router_name_without_a_route_is_refused_locally(self):
|
||||||
|
fake, _ = self.send("9r/cc/claude-opus-5-5", [user("selam")])
|
||||||
|
self.assertEqual(fake._send_json.call_args.args[0], 503)
|
||||||
|
fake._passthrough.assert_not_called()
|
||||||
|
|
||||||
|
def test_a_route_removed_while_the_request_is_prepared_gives_503(self):
|
||||||
|
self.env["MODEL_MAP"]["ide/x"] = ("aiturk-ide", "x", None)
|
||||||
|
fake, _ = self.send("ide/x", [user("selam")])
|
||||||
|
self.assertEqual(fake._send_json.call_args.args[0], 503)
|
||||||
|
self.env["urllib"].request.urlopen.assert_not_called()
|
||||||
|
|
||||||
|
def external(self, error):
|
||||||
|
self.env["ROUTES"]["r"] = {"url": "https://example.invalid/v1/responses", "key": "test-only"}
|
||||||
|
self.env["MODEL_MAP"].update({"x/combo": ("r", "Takim", None), "x/tek": ("r", "cc/claude", None)})
|
||||||
|
self.env["urllib"].request.urlopen.side_effect = error
|
||||||
|
|
||||||
|
def test_combo_dropped_after_sending_is_not_resent_but_a_single_model_is(self):
|
||||||
|
import http.client
|
||||||
|
self.external(http.client.RemoteDisconnected("closed"))
|
||||||
|
self.send("x/combo", [user("selam")])
|
||||||
|
self.assertEqual(self.env["urllib"].request.urlopen.call_count, 1)
|
||||||
|
self.env["urllib"].request.urlopen.reset_mock()
|
||||||
|
self.external([urllib.error.URLError(ConnectionResetError("reset")), Mock(status=200)])
|
||||||
|
fake, _ = self.send("x/tek", [user("selam")])
|
||||||
|
self.assertEqual(self.env["urllib"].request.urlopen.call_count, 2)
|
||||||
|
fake._stream.assert_called_once()
|
||||||
|
self.env["urllib"].request.urlopen.reset_mock()
|
||||||
|
self.external([urllib.error.URLError(ConnectionRefusedError("refused")), Mock(status=200)])
|
||||||
|
self.send("x/combo", [user("selam")]) # never connected: safe to retry
|
||||||
|
self.assertEqual(self.env["urllib"].request.urlopen.call_count, 2)
|
||||||
|
|
||||||
|
def test_error_body_reaches_the_client_whole_with_retry_after(self):
|
||||||
|
whole = json.dumps({"error": {"message": "limit " + "x" * 900, "code": "rate_limit_exceeded"}}).encode()
|
||||||
|
self.external(urllib.error.HTTPError("https://example.invalid", 429, "x", {"Retry-After": "17"}, io.BytesIO(whole)))
|
||||||
|
fake, _ = self.send("x/tek", [user("selam")])
|
||||||
|
self.assertEqual(fake.status, 429)
|
||||||
|
self.assertEqual(fake.wfile.getvalue(), whole)
|
||||||
|
self.assertIn(("Retry-After", "17"), fake.sent)
|
||||||
|
self.assertIn(("Content-Length", str(len(whole))), fake.sent)
|
||||||
|
|
||||||
|
def test_image_in_an_orphan_tool_output_is_removed_before_flattening(self):
|
||||||
|
self.env["resimleri_sadelestir"] = resim_context.resimleri_sadelestir
|
||||||
|
self.env["CFG"]["nine_router_catalog"] = {"enabled": True, "route": "r", "prefix": "9r/"}
|
||||||
|
self.external(None)
|
||||||
|
self.env["ROUTES"]["r"]["flatten_tools"] = True
|
||||||
|
self.env["urllib"].request.urlopen.return_value = Mock(status=200)
|
||||||
|
picture = {"type": "input_image", "image_url": "data:image/png;base64," + "QUJD" * 500}
|
||||||
|
self.send("x/tek", [{"type": "function_call_output", "name": "image_gen", "output": [picture]}, user("devam")])
|
||||||
|
sent = self.env["urllib"].request.urlopen.call_args.args[0].data.decode()
|
||||||
|
self.assertNotIn("QUJDQUJD", sent)
|
||||||
|
self.assertIn("resim", sent)
|
||||||
|
|
||||||
|
|
||||||
|
class Govde(unittest.TestCase):
|
||||||
|
read = build([method("_read")], {})["_read"]
|
||||||
|
|
||||||
|
def test_chunked_upload_cut_short_ends_instead_of_spinning(self):
|
||||||
|
fake = types.SimpleNamespace(headers={"Transfer-Encoding": "chunked"}, rfile=io.BytesIO(b"5\r\nhello\r\n"))
|
||||||
|
self.assertEqual(Govde.read(fake), b"hello")
|
||||||
|
fake = types.SimpleNamespace(headers={"Transfer-Encoding": "chunked"}, rfile=io.BytesIO(b"5\r\nhello\r\n0\r\n\r\n"))
|
||||||
|
self.assertEqual(Govde.read(fake), b"hello")
|
||||||
|
|
||||||
|
|
||||||
|
class Liste(unittest.TestCase):
|
||||||
|
def env(self, **extra):
|
||||||
|
env = {"json": json, "copy": copy, "hashlib": hashlib, "time": time, "log": lambda m: None, "CFG": {},
|
||||||
|
"decompress": lambda raw, enc: raw, "EXTRA_MODELS": [], "IDE_PREFIX": "ide/", "SLIM_CLONES": False,
|
||||||
|
"SLIM_FIELDS": [], "ide_catalog": ide_catalog, "LAST_MODELS": {}, "save_last_models": Mock(),
|
||||||
|
"RESP_HOP": {"content-length", "content-encoding"}, "REQ_HOP": {"host"}, "CHATGPT": "https://example.invalid",
|
||||||
|
"UPSTREAM_IDLE_TIMEOUT": 1,
|
||||||
|
"urllib": types.SimpleNamespace(request=types.SimpleNamespace(Request=urllib.request.Request, urlopen=Mock()),
|
||||||
|
error=urllib.error)}
|
||||||
|
env.update(extra)
|
||||||
|
return build([method("_models"), method("_passthrough"), method("_son_listeyi_ver")], env)
|
||||||
|
|
||||||
|
UPSTREAM = {"models": [{"slug": "gpt-6-sol", "display_name": "GPT-6-Sol", "priority": 1},
|
||||||
|
{"slug": "gpt-6-astra", "display_name": "GPT-6-Astra", "priority": 2}]}
|
||||||
|
|
||||||
|
def test_gzip_answer_is_still_cached_plain_and_originals_keep_name_and_place(self):
|
||||||
|
extras = [{"slug": "gpt-6-astra", "display_name": "Baska Ad"}, {"slug": "ide/a", "display_name": "Aaa", "kind": "combo"}]
|
||||||
|
env = self.env(EXTRA_MODELS=extras, CFG={"display_map": {"gpt-6-sol": "Yeni Ad"}})
|
||||||
|
fake = Sahte({"Accept-Encoding": "gzip", "chatgpt-account-id": "hesap"}, "GET")
|
||||||
|
resp = types.SimpleNamespace(read=lambda: json.dumps(self.UPSTREAM).encode(), headers={"ETag": "x"})
|
||||||
|
env["_models"](fake, resp, "[t]")
|
||||||
|
listed = json.loads(gzip.decompress(fake.wfile.getvalue()))["models"]
|
||||||
|
self.assertEqual([(m["slug"], m["display_name"]) for m in listed],
|
||||||
|
[("gpt-6-sol", "GPT-6-Sol"), ("gpt-6-astra", "GPT-6-Astra"), ("ide/a", "Aaa")])
|
||||||
|
cached = env["LAST_MODELS"]["hesap"]
|
||||||
|
self.assertEqual(json.loads(cached["body"])["models"], listed) # plain JSON, usable as a fallback
|
||||||
|
self.assertNotIn("Content-Encoding", dict(cached["headers"]))
|
||||||
|
self.assertIn(("Content-Encoding", "gzip"), fake.sent)
|
||||||
|
env["save_last_models"].assert_called_once()
|
||||||
|
|
||||||
|
def test_no_network_still_serves_the_last_list(self):
|
||||||
|
cached = {"headers": [("Content-Type", "application/json")], "body": b'{"models": []}', "at": "x"}
|
||||||
|
env = self.env(LAST_MODELS={"hesap": cached})
|
||||||
|
env["urllib"].request.urlopen.side_effect = urllib.error.URLError(OSError("no network"))
|
||||||
|
fake = Sahte({"chatgpt-account-id": "hesap"}, "GET")
|
||||||
|
fake._son_listeyi_ver = types.MethodType(env["_son_listeyi_ver"], fake)
|
||||||
|
env["_passthrough"](fake, 1, "/backend-api/codex/models?client_version=1", b"")
|
||||||
|
self.assertEqual((fake.status, fake.wfile.getvalue()), (200, b'{"models": []}'))
|
||||||
|
other = Sahte({"chatgpt-account-id": "baska"}, "GET")
|
||||||
|
other._son_listeyi_ver = types.MethodType(env["_son_listeyi_ver"], other)
|
||||||
|
env["_passthrough"](other, 1, "/backend-api/codex/models", b"")
|
||||||
|
self.assertEqual(other._send_json.call_args.args[0], 502)
|
||||||
|
|
||||||
|
|
||||||
|
class Kucukler(unittest.TestCase):
|
||||||
|
def test_log_line_has_the_date_and_no_token_tail_and_status_reports_the_code(self):
|
||||||
|
self.assertIn('time.strftime("%Y-%m-%d %H:%M:%S ") + msg', SOURCE)
|
||||||
|
self.assertNotIn("token=..", SOURCE)
|
||||||
|
self.assertNotIn("DISPLAY_MAP", SOURCE)
|
||||||
|
self.assertIn('"kod": KOD', SOURCE)
|
||||||
|
|
||||||
|
def test_responses_path_with_a_query_is_still_routed(self):
|
||||||
|
env = build([method("_handle")], {"lock": __import__("threading").Lock(), "seq": [0], "DEBUG": False,
|
||||||
|
"log": lambda m: None})
|
||||||
|
fake = Sahte({})
|
||||||
|
fake.path = "/backend-api/codex/responses?stream=true"
|
||||||
|
fake._read = lambda: b"{}"
|
||||||
|
fake._responses = Mock()
|
||||||
|
env["_handle"](fake)
|
||||||
|
fake._responses.assert_called_once()
|
||||||
|
fake._passthrough.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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,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,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("bridge.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,154 @@
|
|||||||
|
"""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_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 klod opus yap")) # two Opus entries: 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("bridge.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("bridge.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("bridge.py").read_text(encoding="utf-8"))
|
||||||
|
wanted = {"_zstd_decompress", "_zstd_ready", "decompress"}
|
||||||
|
env = {}
|
||||||
|
exec(compile(ast.Module(body=[n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in wanted],
|
||||||
|
type_ignores=[]), "bridge.py", "exec"), env)
|
||||||
|
|
||||||
|
|
||||||
|
class Zstd(unittest.TestCase):
|
||||||
|
def test_self_check_matches_reality(self):
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
from compression import zstd as z
|
||||||
|
packed = z.compress(b"govde" * 50)
|
||||||
|
except ImportError:
|
||||||
|
import zstandard
|
||||||
|
packed = zstandard.ZstdCompressor().compress(b"govde" * 50)
|
||||||
|
except ImportError:
|
||||||
|
self.assertFalse(env["_zstd_ready"]()) # no zstd here: the bridge must say so
|
||||||
|
return
|
||||||
|
self.assertTrue(env["_zstd_ready"]())
|
||||||
|
self.assertEqual(env["decompress"](packed, "zstd"), b"govde" * 50)
|
||||||
|
|
||||||
|
def test_other_encodings(self):
|
||||||
|
import gzip
|
||||||
|
self.assertEqual(env["decompress"](gzip.compress(b"x"), "gzip"), b"x")
|
||||||
|
self.assertEqual(env["decompress"](b"duz", None), b"duz")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Vendored
+27
@@ -0,0 +1,27 @@
|
|||||||
|
Copyright (c) 2016, Gregory Szorc
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without modification,
|
||||||
|
are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
3. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
may be used to endorse or promote products derived from this software without
|
||||||
|
specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||||
|
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||||
|
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||||
|
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||||
|
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||||
|
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||||
|
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||||
|
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
+217
@@ -0,0 +1,217 @@
|
|||||||
|
# Copyright (c) 2017-present, Gregory Szorc
|
||||||
|
# All rights reserved.
|
||||||
|
#
|
||||||
|
# This software may be modified and distributed under the terms
|
||||||
|
# of the BSD license. See the LICENSE file for details.
|
||||||
|
|
||||||
|
# ruff: noqa: F403, F405
|
||||||
|
|
||||||
|
"""Python interface to the Zstandard (zstd) compression library."""
|
||||||
|
|
||||||
|
from __future__ import absolute_import, unicode_literals
|
||||||
|
|
||||||
|
# This module serves 2 roles:
|
||||||
|
#
|
||||||
|
# 1) Export the C or CFFI "backend" through a central module.
|
||||||
|
# 2) Implement additional functionality built on top of C or CFFI backend.
|
||||||
|
import builtins
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if sys.version_info >= (3, 12):
|
||||||
|
from collections.abc import Buffer
|
||||||
|
else:
|
||||||
|
from typing import ByteString as Buffer
|
||||||
|
|
||||||
|
# Some Python implementations don't support C extensions. That's why we have
|
||||||
|
# a CFFI implementation in the first place. The code here import one of our
|
||||||
|
# "backends" then re-exports the symbols from this module. For convenience,
|
||||||
|
# we support falling back to the CFFI backend if the C extension can't be
|
||||||
|
# imported. But for performance reasons, we only do this on unknown Python
|
||||||
|
# implementation. Notably, for CPython we require the C extension by default.
|
||||||
|
# Because someone will inevitably want special behavior, the behavior is
|
||||||
|
# configurable via an environment variable. A potentially better way to handle
|
||||||
|
# this is to import a special ``__importpolicy__`` module or something
|
||||||
|
# defining a variable and `setup.py` could write the file with whatever
|
||||||
|
# policy was specified at build time. Until someone needs it, we go with
|
||||||
|
# the hacky but simple environment variable approach.
|
||||||
|
_module_policy = os.environ.get(
|
||||||
|
"PYTHON_ZSTANDARD_IMPORT_POLICY", "default"
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
if _module_policy == "default":
|
||||||
|
if platform.python_implementation() in ("CPython",):
|
||||||
|
from .backend_c import * # type: ignore
|
||||||
|
|
||||||
|
backend = "cext"
|
||||||
|
elif platform.python_implementation() in ("PyPy",):
|
||||||
|
from .backend_cffi import * # type: ignore
|
||||||
|
|
||||||
|
backend = "cffi"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
from .backend_c import *
|
||||||
|
|
||||||
|
backend = "cext"
|
||||||
|
except ImportError:
|
||||||
|
from .backend_cffi import *
|
||||||
|
|
||||||
|
backend = "cffi"
|
||||||
|
elif _module_policy == "cffi_fallback":
|
||||||
|
try:
|
||||||
|
from .backend_c import *
|
||||||
|
|
||||||
|
backend = "cext"
|
||||||
|
except ImportError:
|
||||||
|
from .backend_cffi import *
|
||||||
|
|
||||||
|
backend = "cffi"
|
||||||
|
elif _module_policy == "rust":
|
||||||
|
from .backend_rust import * # type: ignore
|
||||||
|
|
||||||
|
backend = "rust"
|
||||||
|
elif _module_policy == "cext":
|
||||||
|
from .backend_c import *
|
||||||
|
|
||||||
|
backend = "cext"
|
||||||
|
elif _module_policy == "cffi":
|
||||||
|
from .backend_cffi import *
|
||||||
|
|
||||||
|
backend = "cffi"
|
||||||
|
else:
|
||||||
|
raise ImportError(
|
||||||
|
"unknown module import policy: %s; use default, cffi_fallback, "
|
||||||
|
"cext, or cffi" % _module_policy
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keep this in sync with python-zstandard.h, rust-ext/src/lib.rs, and debian/changelog.
|
||||||
|
__version__ = "0.25.0"
|
||||||
|
|
||||||
|
_MODE_CLOSED = 0
|
||||||
|
_MODE_READ = 1
|
||||||
|
_MODE_WRITE = 2
|
||||||
|
|
||||||
|
|
||||||
|
def open(
|
||||||
|
filename,
|
||||||
|
mode="rb",
|
||||||
|
cctx=None,
|
||||||
|
dctx=None,
|
||||||
|
encoding=None,
|
||||||
|
errors=None,
|
||||||
|
newline=None,
|
||||||
|
closefd=None,
|
||||||
|
):
|
||||||
|
"""Create a file object with zstd (de)compression.
|
||||||
|
|
||||||
|
The object returned from this function will be a
|
||||||
|
:py:class:`ZstdDecompressionReader` if opened for reading in binary mode,
|
||||||
|
a :py:class:`ZstdCompressionWriter` if opened for writing in binary mode,
|
||||||
|
or an ``io.TextIOWrapper`` if opened for reading or writing in text mode.
|
||||||
|
|
||||||
|
:param filename:
|
||||||
|
``bytes``, ``str``, or ``os.PathLike`` defining a file to open or a
|
||||||
|
file object (with a ``read()`` or ``write()`` method).
|
||||||
|
:param mode:
|
||||||
|
``str`` File open mode. Accepts any of the open modes recognized by
|
||||||
|
``open()``.
|
||||||
|
:param cctx:
|
||||||
|
``ZstdCompressor`` to use for compression. If not specified and file
|
||||||
|
is opened for writing, the default ``ZstdCompressor`` will be used.
|
||||||
|
:param dctx:
|
||||||
|
``ZstdDecompressor`` to use for decompression. If not specified and file
|
||||||
|
is opened for reading, the default ``ZstdDecompressor`` will be used.
|
||||||
|
:param encoding:
|
||||||
|
``str`` that defines text encoding to use when file is opened in text
|
||||||
|
mode.
|
||||||
|
:param errors:
|
||||||
|
``str`` defining text encoding error handling mode.
|
||||||
|
:param newline:
|
||||||
|
``str`` defining newline to use in text mode.
|
||||||
|
:param closefd:
|
||||||
|
``bool`` whether to close the file when the returned object is closed.
|
||||||
|
Only used if a file object is passed. If a filename is specified, the
|
||||||
|
opened file is always closed when the returned object is closed.
|
||||||
|
"""
|
||||||
|
normalized_mode = mode.replace("t", "")
|
||||||
|
|
||||||
|
if normalized_mode in ("r", "rb"):
|
||||||
|
dctx = dctx or ZstdDecompressor()
|
||||||
|
open_mode = "r"
|
||||||
|
raw_open_mode = "rb"
|
||||||
|
elif normalized_mode in ("w", "wb", "a", "ab", "x", "xb"):
|
||||||
|
cctx = cctx or ZstdCompressor()
|
||||||
|
open_mode = "w"
|
||||||
|
raw_open_mode = normalized_mode
|
||||||
|
if not raw_open_mode.endswith("b"):
|
||||||
|
raw_open_mode = raw_open_mode + "b"
|
||||||
|
else:
|
||||||
|
raise ValueError("Invalid mode: {!r}".format(mode))
|
||||||
|
|
||||||
|
if hasattr(os, "PathLike"):
|
||||||
|
types = (str, bytes, os.PathLike)
|
||||||
|
else:
|
||||||
|
types = (str, bytes)
|
||||||
|
|
||||||
|
if isinstance(filename, types): # type: ignore
|
||||||
|
inner_fh = builtins.open(filename, raw_open_mode)
|
||||||
|
closefd = True
|
||||||
|
elif hasattr(filename, "read") or hasattr(filename, "write"):
|
||||||
|
inner_fh = filename
|
||||||
|
closefd = bool(closefd)
|
||||||
|
else:
|
||||||
|
raise TypeError(
|
||||||
|
"filename must be a str, bytes, file or PathLike object"
|
||||||
|
)
|
||||||
|
|
||||||
|
if open_mode == "r":
|
||||||
|
fh = dctx.stream_reader(inner_fh, closefd=closefd)
|
||||||
|
elif open_mode == "w":
|
||||||
|
fh = cctx.stream_writer(inner_fh, closefd=closefd)
|
||||||
|
else:
|
||||||
|
raise RuntimeError("logic error in zstandard.open() handling open mode")
|
||||||
|
|
||||||
|
if "b" not in normalized_mode:
|
||||||
|
return io.TextIOWrapper(
|
||||||
|
fh, encoding=encoding, errors=errors, newline=newline
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return fh
|
||||||
|
|
||||||
|
|
||||||
|
def compress(data: Buffer, level: int = 3) -> bytes:
|
||||||
|
"""Compress source data using the zstd compression format.
|
||||||
|
|
||||||
|
This performs one-shot compression using basic/default compression
|
||||||
|
settings.
|
||||||
|
|
||||||
|
This method is provided for convenience and is equivalent to calling
|
||||||
|
``ZstdCompressor(level=level).compress(data)``.
|
||||||
|
|
||||||
|
If you find yourself calling this function in a tight loop,
|
||||||
|
performance will be greater if you construct a single ``ZstdCompressor``
|
||||||
|
and repeatedly call ``compress()`` on it.
|
||||||
|
"""
|
||||||
|
cctx = ZstdCompressor(level=level)
|
||||||
|
|
||||||
|
return cctx.compress(data)
|
||||||
|
|
||||||
|
|
||||||
|
def decompress(data: Buffer, max_output_size: int = 0) -> bytes:
|
||||||
|
"""Decompress a zstd frame into its original data.
|
||||||
|
|
||||||
|
This performs one-shot decompression using basic/default compression
|
||||||
|
settings.
|
||||||
|
|
||||||
|
This method is provided for convenience and is equivalent to calling
|
||||||
|
``ZstdDecompressor().decompress(data, max_output_size=max_output_size)``.
|
||||||
|
|
||||||
|
If you find yourself calling this function in a tight loop, performance
|
||||||
|
will be greater if you construct a single ``ZstdDecompressor`` and
|
||||||
|
repeatedly call ``decompress()`` on it.
|
||||||
|
"""
|
||||||
|
dctx = ZstdDecompressor()
|
||||||
|
|
||||||
|
return dctx.decompress(data, max_output_size=max_output_size)
|
||||||
+481
@@ -0,0 +1,481 @@
|
|||||||
|
# Copyright (c) 2016-present, Gregory Szorc
|
||||||
|
# All rights reserved.
|
||||||
|
#
|
||||||
|
# This software may be modified and distributed under the terms
|
||||||
|
# of the BSD license. See the LICENSE file for details.
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import (
|
||||||
|
IO,
|
||||||
|
BinaryIO,
|
||||||
|
ByteString,
|
||||||
|
Generator,
|
||||||
|
Iterable,
|
||||||
|
List,
|
||||||
|
Optional,
|
||||||
|
Set,
|
||||||
|
Tuple,
|
||||||
|
Union,
|
||||||
|
)
|
||||||
|
|
||||||
|
FLUSH_BLOCK: int
|
||||||
|
FLUSH_FRAME: int
|
||||||
|
|
||||||
|
COMPRESSOBJ_FLUSH_FINISH: int
|
||||||
|
COMPRESSOBJ_FLUSH_BLOCK: int
|
||||||
|
|
||||||
|
CONTENTSIZE_UNKNOWN: int
|
||||||
|
CONTENTSIZE_ERROR: int
|
||||||
|
|
||||||
|
MAX_COMPRESSION_LEVEL: int
|
||||||
|
|
||||||
|
COMPRESSION_RECOMMENDED_INPUT_SIZE: int
|
||||||
|
COMPRESSION_RECOMMENDED_OUTPUT_SIZE: int
|
||||||
|
|
||||||
|
DECOMPRESSION_RECOMMENDED_INPUT_SIZE: int
|
||||||
|
DECOMPRESSION_RECOMMENDED_OUTPUT_SIZE: int
|
||||||
|
|
||||||
|
BLOCKSIZELOG_MAX: int
|
||||||
|
BLOCKSIZE_MAX: int
|
||||||
|
|
||||||
|
WINDOWLOG_MIN: int
|
||||||
|
WINDOWLOG_MAX: int
|
||||||
|
|
||||||
|
CHAINLOG_MIN: int
|
||||||
|
CHAINLOG_MAX: int
|
||||||
|
HASHLOG_MIN: int
|
||||||
|
HASHLOG_MAX: int
|
||||||
|
MINMATCH_MIN: int
|
||||||
|
MINMATCH_MAX: int
|
||||||
|
SEARCHLOG_MIN: int
|
||||||
|
SEARCHLOG_MAX: int
|
||||||
|
SEARCHLENGTH_MIN: int
|
||||||
|
SEARCHLENGTH_MAX: int
|
||||||
|
TARGETLENGTH_MIN: int
|
||||||
|
TARGETLENGTH_MAX: int
|
||||||
|
LDM_MINMATCH_MIN: int
|
||||||
|
LDM_MINMATCH_MAX: int
|
||||||
|
LDM_BUCKETSIZELOG_MAX: int
|
||||||
|
|
||||||
|
STRATEGY_FAST: int
|
||||||
|
STRATEGY_DFAST: int
|
||||||
|
STRATEGY_GREEDY: int
|
||||||
|
STRATEGY_LAZY: int
|
||||||
|
STRATEGY_LAZY2: int
|
||||||
|
STRATEGY_BTLAZY2: int
|
||||||
|
STRATEGY_BTOPT: int
|
||||||
|
STRATEGY_BTULTRA: int
|
||||||
|
STRATEGY_BTULTRA2: int
|
||||||
|
|
||||||
|
DICT_TYPE_AUTO: int
|
||||||
|
DICT_TYPE_RAWCONTENT: int
|
||||||
|
DICT_TYPE_FULLDICT: int
|
||||||
|
|
||||||
|
FORMAT_ZSTD1: int
|
||||||
|
FORMAT_ZSTD1_MAGICLESS: int
|
||||||
|
|
||||||
|
ZSTD_VERSION: Tuple[int, int, int]
|
||||||
|
FRAME_HEADER: bytes
|
||||||
|
MAGIC_NUMBER: int
|
||||||
|
|
||||||
|
backend: str
|
||||||
|
backend_features: Set[str]
|
||||||
|
__version__: str
|
||||||
|
|
||||||
|
class ZstdError(Exception): ...
|
||||||
|
|
||||||
|
class BufferSegment(object):
|
||||||
|
offset: int
|
||||||
|
def __len__(self) -> int: ...
|
||||||
|
def tobytes(self) -> bytes: ...
|
||||||
|
|
||||||
|
class BufferSegments(object):
|
||||||
|
def __len__(self) -> int: ...
|
||||||
|
def __getitem__(self, i: int) -> BufferSegment: ...
|
||||||
|
|
||||||
|
class BufferWithSegments(object):
|
||||||
|
size: int
|
||||||
|
def __init__(self, data: ByteString, segments: ByteString): ...
|
||||||
|
def __len__(self) -> int: ...
|
||||||
|
def __getitem__(self, i: int) -> BufferSegment: ...
|
||||||
|
def segments(self): ...
|
||||||
|
def tobytes(self) -> bytes: ...
|
||||||
|
|
||||||
|
class BufferWithSegmentsCollection(object):
|
||||||
|
def __init__(self, *args): ...
|
||||||
|
def __len__(self) -> int: ...
|
||||||
|
def __getitem__(self, i: int) -> BufferSegment: ...
|
||||||
|
def size(self) -> int: ...
|
||||||
|
|
||||||
|
class ZstdCompressionParameters(object):
|
||||||
|
@staticmethod
|
||||||
|
def from_level(
|
||||||
|
level: int, source_size: int = ..., dict_size: int = ..., **kwargs
|
||||||
|
) -> "ZstdCompressionParameters": ...
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
format: int = ...,
|
||||||
|
compression_level: int = ...,
|
||||||
|
window_log: int = ...,
|
||||||
|
hash_log: int = ...,
|
||||||
|
chain_log: int = ...,
|
||||||
|
search_log: int = ...,
|
||||||
|
min_match: int = ...,
|
||||||
|
target_length: int = ...,
|
||||||
|
strategy: int = ...,
|
||||||
|
write_content_size: int = ...,
|
||||||
|
write_checksum: int = ...,
|
||||||
|
write_dict_id: int = ...,
|
||||||
|
job_size: int = ...,
|
||||||
|
overlap_log: int = ...,
|
||||||
|
force_max_window: int = ...,
|
||||||
|
enable_ldm: int = ...,
|
||||||
|
ldm_hash_log: int = ...,
|
||||||
|
ldm_min_match: int = ...,
|
||||||
|
ldm_bucket_size_log: int = ...,
|
||||||
|
ldm_hash_rate_log: int = ...,
|
||||||
|
threads: int = ...,
|
||||||
|
): ...
|
||||||
|
@property
|
||||||
|
def format(self) -> int: ...
|
||||||
|
@property
|
||||||
|
def compression_level(self) -> int: ...
|
||||||
|
@property
|
||||||
|
def window_log(self) -> int: ...
|
||||||
|
@property
|
||||||
|
def hash_log(self) -> int: ...
|
||||||
|
@property
|
||||||
|
def chain_log(self) -> int: ...
|
||||||
|
@property
|
||||||
|
def search_log(self) -> int: ...
|
||||||
|
@property
|
||||||
|
def min_match(self) -> int: ...
|
||||||
|
@property
|
||||||
|
def target_length(self) -> int: ...
|
||||||
|
@property
|
||||||
|
def strategy(self) -> int: ...
|
||||||
|
@property
|
||||||
|
def write_content_size(self) -> int: ...
|
||||||
|
@property
|
||||||
|
def write_checksum(self) -> int: ...
|
||||||
|
@property
|
||||||
|
def write_dict_id(self) -> int: ...
|
||||||
|
@property
|
||||||
|
def job_size(self) -> int: ...
|
||||||
|
@property
|
||||||
|
def overlap_log(self) -> int: ...
|
||||||
|
@property
|
||||||
|
def force_max_window(self) -> int: ...
|
||||||
|
@property
|
||||||
|
def enable_ldm(self) -> int: ...
|
||||||
|
@property
|
||||||
|
def ldm_hash_log(self) -> int: ...
|
||||||
|
@property
|
||||||
|
def ldm_min_match(self) -> int: ...
|
||||||
|
@property
|
||||||
|
def ldm_bucket_size_log(self) -> int: ...
|
||||||
|
@property
|
||||||
|
def ldm_hash_rate_log(self) -> int: ...
|
||||||
|
@property
|
||||||
|
def threads(self) -> int: ...
|
||||||
|
def estimated_compression_context_size(self) -> int: ...
|
||||||
|
|
||||||
|
class CompressionParameters(ZstdCompressionParameters): ...
|
||||||
|
|
||||||
|
class ZstdCompressionDict(object):
|
||||||
|
k: int
|
||||||
|
d: int
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
data: ByteString,
|
||||||
|
dict_type: int = ...,
|
||||||
|
k: int = ...,
|
||||||
|
d: int = ...,
|
||||||
|
): ...
|
||||||
|
def __len__(self) -> int: ...
|
||||||
|
def dict_id(self) -> int: ...
|
||||||
|
def as_bytes(self) -> bytes: ...
|
||||||
|
def precompute_compress(
|
||||||
|
self,
|
||||||
|
level: int = ...,
|
||||||
|
compression_params: ZstdCompressionParameters = ...,
|
||||||
|
): ...
|
||||||
|
|
||||||
|
class ZstdCompressionObj(object):
|
||||||
|
def compress(self, data: ByteString) -> bytes: ...
|
||||||
|
def flush(self, flush_mode: int = ...) -> bytes: ...
|
||||||
|
|
||||||
|
class ZstdCompressionChunker(object):
|
||||||
|
def compress(self, data: ByteString): ...
|
||||||
|
def flush(self): ...
|
||||||
|
def finish(self): ...
|
||||||
|
|
||||||
|
class ZstdCompressionReader(BinaryIO):
|
||||||
|
def __enter__(self) -> "ZstdCompressionReader": ...
|
||||||
|
def __exit__(self, exc_type, exc_value, exc_tb): ...
|
||||||
|
def readable(self) -> bool: ...
|
||||||
|
def writable(self) -> bool: ...
|
||||||
|
def seekable(self) -> bool: ...
|
||||||
|
def readline(self, limit: int = ...) -> bytes: ...
|
||||||
|
def readlines(self, hint: int = ...) -> List[bytes]: ...
|
||||||
|
def write(self, data: ByteString): ...
|
||||||
|
def writelines(self, data: Iterable[bytes]): ...
|
||||||
|
def isatty(self) -> bool: ...
|
||||||
|
def flush(self): ...
|
||||||
|
def close(self): ...
|
||||||
|
@property
|
||||||
|
def closed(self) -> bool: ...
|
||||||
|
def tell(self) -> int: ...
|
||||||
|
def readall(self) -> bytes: ...
|
||||||
|
def __iter__(self): ...
|
||||||
|
def __next__(self): ...
|
||||||
|
def next(self): ...
|
||||||
|
def read(self, size: int = ...) -> bytes: ...
|
||||||
|
def read1(self, size: int = ...) -> bytes: ...
|
||||||
|
def readinto(self, b) -> int: ...
|
||||||
|
def readinto1(self, b) -> int: ...
|
||||||
|
|
||||||
|
class ZstdCompressionWriter(BinaryIO):
|
||||||
|
def __enter__(self) -> "ZstdCompressionWriter": ...
|
||||||
|
def __exit__(self, exc_type, exc_value, exc_tb): ...
|
||||||
|
def memory_size(self) -> int: ...
|
||||||
|
def fileno(self) -> int: ...
|
||||||
|
def close(self): ...
|
||||||
|
@property
|
||||||
|
def closed(self) -> bool: ...
|
||||||
|
def isatty(self) -> bool: ...
|
||||||
|
def readable(self) -> bool: ...
|
||||||
|
def readline(self, size: int = ...) -> bytes: ...
|
||||||
|
def readlines(self, hint: int = ...) -> List[bytes]: ...
|
||||||
|
def seek(self, offset: int, whence: int = ...): ...
|
||||||
|
def seekable(self) -> bool: ...
|
||||||
|
def truncate(self, size: int = ...): ...
|
||||||
|
def writable(self) -> bool: ...
|
||||||
|
def writelines(self, lines: Iterable[bytes]): ...
|
||||||
|
def read(self, size: int = ...) -> bytes: ...
|
||||||
|
def readall(self) -> bytes: ...
|
||||||
|
def readinto(self, b): ...
|
||||||
|
def write(self, data: ByteString) -> int: ...
|
||||||
|
def flush(self, flush_mode: int = ...) -> int: ...
|
||||||
|
def tell(self) -> int: ...
|
||||||
|
|
||||||
|
class ZstdCompressor(object):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
level: int = ...,
|
||||||
|
dict_data: Optional[ZstdCompressionDict] = ...,
|
||||||
|
compression_params: Optional[ZstdCompressionParameters] = ...,
|
||||||
|
write_checksum: Optional[bool] = ...,
|
||||||
|
write_content_size: Optional[bool] = ...,
|
||||||
|
write_dict_id: Optional[bool] = ...,
|
||||||
|
threads: int = ...,
|
||||||
|
): ...
|
||||||
|
def memory_size(self) -> int: ...
|
||||||
|
def compress(self, data: ByteString) -> bytes: ...
|
||||||
|
def compressobj(self, size: int = ...) -> ZstdCompressionObj: ...
|
||||||
|
def chunker(
|
||||||
|
self, size: int = ..., chunk_size: int = ...
|
||||||
|
) -> ZstdCompressionChunker: ...
|
||||||
|
def copy_stream(
|
||||||
|
self,
|
||||||
|
ifh: IO[bytes],
|
||||||
|
ofh: IO[bytes],
|
||||||
|
size: int = ...,
|
||||||
|
read_size: int = ...,
|
||||||
|
write_size: int = ...,
|
||||||
|
) -> Tuple[int, int]: ...
|
||||||
|
def stream_reader(
|
||||||
|
self,
|
||||||
|
source: Union[IO[bytes], ByteString],
|
||||||
|
size: int = ...,
|
||||||
|
read_size: int = ...,
|
||||||
|
*,
|
||||||
|
closefd: bool = ...,
|
||||||
|
) -> ZstdCompressionReader: ...
|
||||||
|
def stream_writer(
|
||||||
|
self,
|
||||||
|
writer: IO[bytes],
|
||||||
|
size: int = ...,
|
||||||
|
write_size: int = ...,
|
||||||
|
write_return_read: bool = ...,
|
||||||
|
*,
|
||||||
|
closefd: bool = ...,
|
||||||
|
) -> ZstdCompressionWriter: ...
|
||||||
|
def read_to_iter(
|
||||||
|
self,
|
||||||
|
reader: Union[IO[bytes], ByteString],
|
||||||
|
size: int = ...,
|
||||||
|
read_size: int = ...,
|
||||||
|
write_size: int = ...,
|
||||||
|
) -> Generator[bytes, None, None]: ...
|
||||||
|
def frame_progression(self) -> Tuple[int, int, int]: ...
|
||||||
|
def multi_compress_to_buffer(
|
||||||
|
self,
|
||||||
|
data: Union[
|
||||||
|
BufferWithSegments,
|
||||||
|
BufferWithSegmentsCollection,
|
||||||
|
List[ByteString],
|
||||||
|
],
|
||||||
|
threads: int = ...,
|
||||||
|
) -> BufferWithSegmentsCollection: ...
|
||||||
|
|
||||||
|
class ZstdDecompressionObj(object):
|
||||||
|
def decompress(self, data: ByteString) -> bytes: ...
|
||||||
|
def flush(self, length: int = ...) -> bytes: ...
|
||||||
|
@property
|
||||||
|
def unused_data(self) -> bytes: ...
|
||||||
|
@property
|
||||||
|
def unconsumed_tail(self) -> bytes: ...
|
||||||
|
@property
|
||||||
|
def eof(self) -> bool: ...
|
||||||
|
|
||||||
|
class ZstdDecompressionReader(BinaryIO):
|
||||||
|
def __enter__(self) -> "ZstdDecompressionReader": ...
|
||||||
|
def __exit__(self, exc_type, exc_value, exc_tb): ...
|
||||||
|
def readable(self) -> bool: ...
|
||||||
|
def writable(self) -> bool: ...
|
||||||
|
def seekable(self) -> bool: ...
|
||||||
|
def readline(self, size: int = ...): ...
|
||||||
|
def readlines(self, hint: int = ...): ...
|
||||||
|
def write(self, data: ByteString): ...
|
||||||
|
def writelines(self, lines: Iterable[bytes]): ...
|
||||||
|
def isatty(self) -> bool: ...
|
||||||
|
def flush(self): ...
|
||||||
|
def close(self): ...
|
||||||
|
@property
|
||||||
|
def closed(self) -> bool: ...
|
||||||
|
def tell(self) -> int: ...
|
||||||
|
def readall(self) -> bytes: ...
|
||||||
|
def __iter__(self): ...
|
||||||
|
def __next__(self): ...
|
||||||
|
def next(self): ...
|
||||||
|
def read(self, size: int = ...) -> bytes: ...
|
||||||
|
def readinto(self, b) -> int: ...
|
||||||
|
def read1(self, size: int = ...) -> bytes: ...
|
||||||
|
def readinto1(self, b) -> int: ...
|
||||||
|
def seek(self, pos: int, whence: int = ...) -> int: ...
|
||||||
|
|
||||||
|
class ZstdDecompressionWriter(BinaryIO):
|
||||||
|
def __enter__(self) -> "ZstdDecompressionWriter": ...
|
||||||
|
def __exit__(self, exc_type, exc_value, exc_tb): ...
|
||||||
|
def memory_size(self) -> int: ...
|
||||||
|
def close(self): ...
|
||||||
|
@property
|
||||||
|
def closed(self) -> bool: ...
|
||||||
|
def fileno(self) -> int: ...
|
||||||
|
def flush(self): ...
|
||||||
|
def isatty(self) -> bool: ...
|
||||||
|
def readable(self) -> bool: ...
|
||||||
|
def readline(self, size: int = ...): ...
|
||||||
|
def readlines(self, hint: int = ...): ...
|
||||||
|
def seek(self, offset: int, whence: int = ...): ...
|
||||||
|
def seekable(self) -> bool: ...
|
||||||
|
def tell(self): ...
|
||||||
|
def truncate(self, size: int = ...): ...
|
||||||
|
def writable(self) -> bool: ...
|
||||||
|
def writelines(self, lines: Iterable[bytes]): ...
|
||||||
|
def read(self, size: int = ...): ...
|
||||||
|
def readall(self): ...
|
||||||
|
def readinto(self, b): ...
|
||||||
|
def write(self, data: ByteString) -> int: ...
|
||||||
|
|
||||||
|
class ZstdDecompressor(object):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
dict_data: Optional[ZstdCompressionDict] = ...,
|
||||||
|
max_window_size: int = ...,
|
||||||
|
format: int = ...,
|
||||||
|
): ...
|
||||||
|
def memory_size(self) -> int: ...
|
||||||
|
def decompress(
|
||||||
|
self,
|
||||||
|
data: ByteString,
|
||||||
|
max_output_size: int = ...,
|
||||||
|
read_across_frames: bool = ...,
|
||||||
|
allow_extra_data: bool = ...,
|
||||||
|
) -> bytes: ...
|
||||||
|
def stream_reader(
|
||||||
|
self,
|
||||||
|
source: Union[IO[bytes], ByteString],
|
||||||
|
read_size: int = ...,
|
||||||
|
read_across_frames: bool = ...,
|
||||||
|
*,
|
||||||
|
closefd=False,
|
||||||
|
) -> ZstdDecompressionReader: ...
|
||||||
|
def decompressobj(
|
||||||
|
self, write_size: int = ..., read_across_frames: bool = False
|
||||||
|
) -> ZstdDecompressionObj: ...
|
||||||
|
def read_to_iter(
|
||||||
|
self,
|
||||||
|
reader: Union[IO[bytes], ByteString],
|
||||||
|
read_size: int = ...,
|
||||||
|
write_size: int = ...,
|
||||||
|
skip_bytes: int = ...,
|
||||||
|
) -> Generator[bytes, None, None]: ...
|
||||||
|
def stream_writer(
|
||||||
|
self,
|
||||||
|
writer: IO[bytes],
|
||||||
|
write_size: int = ...,
|
||||||
|
write_return_read: bool = ...,
|
||||||
|
*,
|
||||||
|
closefd: bool = ...,
|
||||||
|
) -> ZstdDecompressionWriter: ...
|
||||||
|
def copy_stream(
|
||||||
|
self,
|
||||||
|
ifh: IO[bytes],
|
||||||
|
ofh: IO[bytes],
|
||||||
|
read_size: int = ...,
|
||||||
|
write_size: int = ...,
|
||||||
|
) -> Tuple[int, int]: ...
|
||||||
|
def decompress_content_dict_chain(
|
||||||
|
self, frames: list[ByteString]
|
||||||
|
) -> bytes: ...
|
||||||
|
def multi_decompress_to_buffer(
|
||||||
|
self,
|
||||||
|
frames: Union[
|
||||||
|
BufferWithSegments,
|
||||||
|
BufferWithSegmentsCollection,
|
||||||
|
List[ByteString],
|
||||||
|
],
|
||||||
|
decompressed_sizes: ByteString = ...,
|
||||||
|
threads: int = ...,
|
||||||
|
) -> BufferWithSegmentsCollection: ...
|
||||||
|
|
||||||
|
class FrameParameters(object):
|
||||||
|
content_size: int
|
||||||
|
window_size: int
|
||||||
|
dict_id: int
|
||||||
|
has_checksum: bool
|
||||||
|
|
||||||
|
def estimate_decompression_context_size() -> int: ...
|
||||||
|
def frame_content_size(data: ByteString) -> int: ...
|
||||||
|
def frame_header_size(data: ByteString) -> int: ...
|
||||||
|
def get_frame_parameters(
|
||||||
|
data: ByteString, format: Optional[int] = None
|
||||||
|
) -> FrameParameters: ...
|
||||||
|
def train_dictionary(
|
||||||
|
dict_size: int,
|
||||||
|
samples: list[ByteString],
|
||||||
|
k: int = ...,
|
||||||
|
d: int = ...,
|
||||||
|
f: int = ...,
|
||||||
|
split_point: float = ...,
|
||||||
|
accel: int = ...,
|
||||||
|
notifications: int = ...,
|
||||||
|
dict_id: int = ...,
|
||||||
|
level: int = ...,
|
||||||
|
steps: int = ...,
|
||||||
|
threads: int = ...,
|
||||||
|
) -> ZstdCompressionDict: ...
|
||||||
|
def open(
|
||||||
|
filename: Union[bytes, str, os.PathLike, BinaryIO],
|
||||||
|
mode: str = ...,
|
||||||
|
cctx: Optional[ZstdCompressor] = ...,
|
||||||
|
dctx: Optional[ZstdDecompressor] = ...,
|
||||||
|
encoding: Optional[str] = ...,
|
||||||
|
errors: Optional[str] = ...,
|
||||||
|
newline: Optional[str] = ...,
|
||||||
|
closefd: bool = ...,
|
||||||
|
): ...
|
||||||
|
def compress(data: ByteString, level: int = ...) -> bytes: ...
|
||||||
|
def decompress(data: ByteString, max_output_size: int = ...) -> bytes: ...
|
||||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
+4478
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
Binary file not shown.
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"product": "Microsoft Visual C++ 2015-2022 Redistributable (VC143 CRT)",
|
||||||
|
"version": "14.44.35211.0",
|
||||||
|
"architecture": "x64",
|
||||||
|
"publisher": "Microsoft Corporation (Authenticode doğrulandı)",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "msvcp140.dll",
|
||||||
|
"size": 557728,
|
||||||
|
"sha256": "0f885b509a685d2bbfa652fed26b5fb31d88fbdab0a978c641d1c7b8aa460aa9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "msvcp140_1.dll",
|
||||||
|
"size": 35952,
|
||||||
|
"sha256": "bfad5aef4c63a669e3c140655cdfdf395b6c979b400a447bd5dcb65ed8826c3d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "msvcp140_2.dll",
|
||||||
|
"size": 280200,
|
||||||
|
"sha256": "3ea06f0ee098b4823cb79599df3780e7f23cce52c19aac31d2a0d47efe33a5e9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "msvcp140_atomic_wait.dll",
|
||||||
|
"size": 50304,
|
||||||
|
"sha256": "640b2aefced484d0368eea5bdd06addd0658a3a70a49256e560d6923b404a479"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "msvcp140_codecvt_ids.dll",
|
||||||
|
"size": 31872,
|
||||||
|
"sha256": "f2069a52880ec885ee7f0511186100eb7fada0411a2b4948fafea7735b878a18"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "concrt140.dll",
|
||||||
|
"size": 324208,
|
||||||
|
"sha256": "2405355f0a58067b258f8df33c327e3a3d716eaac5a3a5aebb757842d85bd376"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vcruntime140.dll",
|
||||||
|
"size": 124544,
|
||||||
|
"sha256": "d5e4d9a3e835fa679450145d6a7d94e36573a509317111904d9b3712c30d9066",
|
||||||
|
"ownedByPython": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vcruntime140_1.dll",
|
||||||
|
"size": 49792,
|
||||||
|
"sha256": "1f2d41c4aa5db0bc33ebf7b66d72943a817d7ce6cbe880502a9403823633093f",
|
||||||
|
"ownedByPython": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,401 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
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 = `${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(hidden, 0)
|
||||||
assert.equal(gate.isLocalBootstrapConfirmed(), true)
|
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 {
|
interface FirstRunSetupBackend {
|
||||||
activeRoot?: string
|
activeRoot?: string
|
||||||
kind?: string
|
kind?: string
|
||||||
@@ -5,6 +7,7 @@ interface FirstRunSetupBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface FirstRunSetupGateOptions {
|
interface FirstRunSetupGateOptions {
|
||||||
|
isRuntimeAvailable?: (backend: FirstRunSetupBackend) => boolean
|
||||||
hideChoice?: () => void
|
hideChoice?: () => void
|
||||||
log?: (message: string) => void
|
log?: (message: string) => void
|
||||||
onStuck?: (backend: FirstRunSetupBackend, stuckAfterMs: number) => void
|
onStuck?: (backend: FirstRunSetupBackend, stuckAfterMs: number) => void
|
||||||
@@ -12,9 +15,10 @@ interface FirstRunSetupGateOptions {
|
|||||||
stuckAfterMs?: number
|
stuckAfterMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FirstRunSetupDecision = 'continue-local' | 'remote-applied' | 'reset'
|
export type FirstRunSetupDecision = 'continue-local' | 'remote-applied' | 'reset' | 'runtime-ready'
|
||||||
|
|
||||||
export function createFirstRunSetupGate({
|
export function createFirstRunSetupGate({
|
||||||
|
isRuntimeAvailable,
|
||||||
hideChoice,
|
hideChoice,
|
||||||
log,
|
log,
|
||||||
onStuck,
|
onStuck,
|
||||||
@@ -24,6 +28,7 @@ export function createFirstRunSetupGate({
|
|||||||
let localBootstrapConfirmed = false
|
let localBootstrapConfirmed = false
|
||||||
|
|
||||||
let waiter: {
|
let waiter: {
|
||||||
|
activeRoot?: string
|
||||||
promise: Promise<FirstRunSetupDecision>
|
promise: Promise<FirstRunSetupDecision>
|
||||||
resolve: (decision: FirstRunSetupDecision) => void
|
resolve: (decision: FirstRunSetupDecision) => void
|
||||||
} | null = null
|
} | null = null
|
||||||
@@ -65,6 +70,13 @@ export function createFirstRunSetupGate({
|
|||||||
return 'continue-local' as const
|
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) {
|
if (waiter) {
|
||||||
return waiter.promise
|
return waiter.promise
|
||||||
}
|
}
|
||||||
@@ -78,7 +90,7 @@ export function createFirstRunSetupGate({
|
|||||||
resolveWaiter = resolve
|
resolveWaiter = resolve
|
||||||
})
|
})
|
||||||
|
|
||||||
waiter = { promise, resolve: resolveWaiter }
|
waiter = { activeRoot: backend?.activeRoot, promise, resolve: resolveWaiter }
|
||||||
|
|
||||||
return promise
|
return promise
|
||||||
}
|
}
|
||||||
@@ -102,6 +114,21 @@ export function createFirstRunSetupGate({
|
|||||||
settleWaiter('continue-local')
|
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 = () => {
|
const resetForRetry = () => {
|
||||||
// Reset paths are followed by a renderer reload / fresh startHermes() call.
|
// Reset paths are followed by a renderer reload / fresh startHermes() call.
|
||||||
// Settle the old boot explicitly so it cannot fall through into local
|
// Settle the old boot explicitly so it cannot fall through into local
|
||||||
@@ -140,6 +167,7 @@ export function createFirstRunSetupGate({
|
|||||||
isLocalBootstrapConfirmed,
|
isLocalBootstrapConfirmed,
|
||||||
resetForRepair,
|
resetForRepair,
|
||||||
resetForRetry,
|
resetForRetry,
|
||||||
|
runtimeAvailable,
|
||||||
shouldGate,
|
shouldGate,
|
||||||
wait
|
wait
|
||||||
}
|
}
|
||||||
|
|||||||
+267
-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 crypto from 'node:crypto'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import http from 'node:http'
|
import http from 'node:http'
|
||||||
@@ -9,6 +9,7 @@ import tls from 'node:tls'
|
|||||||
import { pathToFileURL, fileURLToPath } from 'node:url'
|
import { pathToFileURL, fileURLToPath } from 'node:url'
|
||||||
import { startChromeBridge } from './chrome-bridge.mjs'
|
import { startChromeBridge } from './chrome-bridge.mjs'
|
||||||
import { createMobileWindow } from './mobile-host.mjs'
|
import { createMobileWindow } from './mobile-host.mjs'
|
||||||
|
import { MobileVault } from './mobile-secrets.mjs'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
@@ -31,8 +32,21 @@ import {
|
|||||||
shell,
|
shell,
|
||||||
systemPreferences
|
systemPreferences
|
||||||
} from 'electron'
|
} from 'electron'
|
||||||
|
import { WebSocket as RelayWebSocket } from 'ws'
|
||||||
|
|
||||||
import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state'
|
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 { destroyKeepaliveAgents, downloadAgentFor, jsonAgentFor, withRetry } from './api-transport'
|
||||||
import { appIconCandidates, resolveAppIcon } from './app-icon'
|
import { appIconCandidates, resolveAppIcon } from './app-icon'
|
||||||
import { stopBackendChild as stopBackendChildImpl, stopBackendTreesForUpdate } from './backend-child'
|
import { stopBackendChild as stopBackendChildImpl, stopBackendTreesForUpdate } from './backend-child'
|
||||||
@@ -2063,6 +2077,10 @@ function hideFirstRunSetupChoice() {
|
|||||||
function getFirstRunSetupGate() {
|
function getFirstRunSetupGate() {
|
||||||
if (!firstRunSetupGate) {
|
if (!firstRunSetupGate) {
|
||||||
firstRunSetupGate = createFirstRunSetupGate({
|
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,
|
hideChoice: hideFirstRunSetupChoice,
|
||||||
log: rememberLog,
|
log: rememberLog,
|
||||||
onStuck: (_backend, stuckAfterMs) => {
|
onStuck: (_backend, stuckAfterMs) => {
|
||||||
@@ -5221,6 +5239,22 @@ async function ensureRuntime(backend) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Voice wheels (onnxruntime, ctranslate2) need msvcp140*.dll, which the
|
||||||
|
// managed CPython does not ship. Place the bundled Microsoft CRT beside the
|
||||||
|
// managed python.exe before the backend imports anything. Never blocks boot:
|
||||||
|
// only the voice features depend on it.
|
||||||
|
if (IS_WINDOWS) {
|
||||||
|
const vcRuntime = ensureWindowsVcRuntime({
|
||||||
|
platform: process.platform,
|
||||||
|
venvRoot: VENV_ROOT,
|
||||||
|
sourceDir: IS_PACKAGED
|
||||||
|
? path.join(process.resourcesPath, 'vc-runtime', 'x64')
|
||||||
|
: path.join(APP_ROOT, 'assets', 'windows', 'vc-runtime', 'x64')
|
||||||
|
})
|
||||||
|
|
||||||
|
rememberLog(`[vc-runtime] ${describeVcRuntimeResult(vcRuntime)}`)
|
||||||
|
}
|
||||||
|
|
||||||
backend.command = getVenvPython(VENV_ROOT)
|
backend.command = getVenvPython(VENV_ROOT)
|
||||||
backend.label = `Hermes at ${ACTIVE_HERMES_ROOT} (venv: ${VENV_ROOT})`
|
backend.label = `Hermes at ${ACTIVE_HERMES_ROOT} (venv: ${VENV_ROOT})`
|
||||||
updateBootProgress({
|
updateBootProgress({
|
||||||
@@ -5231,6 +5265,11 @@ async function ensureRuntime(backend) {
|
|||||||
error: null
|
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
|
return backend
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6767,7 +6806,7 @@ function buildApplicationMenu() {
|
|||||||
|
|
||||||
const checkForUpdatesItem = {
|
const checkForUpdatesItem = {
|
||||||
label: 'Check for Updates…',
|
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) {
|
if (IS_MAC) {
|
||||||
@@ -13053,6 +13092,9 @@ async function startHermes() {
|
|||||||
// accumulated count of the resolved episode.
|
// accumulated count of the resolved episode.
|
||||||
bootstrapRepairAttempt = 0
|
bootstrapRepairAttempt = 0
|
||||||
|
|
||||||
|
// The local backend is up, so the venv the Codex bridge runs on exists.
|
||||||
|
syncCodexLinkOnBoot()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
mode: 'local',
|
mode: 'local',
|
||||||
@@ -17144,6 +17186,72 @@ ipcMain.handle('hermes:quick-entry:settings:set', async (_event, patch) => {
|
|||||||
return applyQuickEntrySettings(next)
|
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
|
// Quick window → main → PRIMARY renderer. We never submit here: the renderer
|
||||||
// owns the one prompt-submit path, and forwarding keeps it that way. The
|
// owns the one prompt-submit path, and forwarding keeps it that way. The
|
||||||
// payload is `{ target, text }` — target routing (current chat / a picked
|
// payload is `{ target, text }` — target routing (current chat / a picked
|
||||||
@@ -17214,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) ─────────────────────────────────────────────
|
// ── Find-in-page (Ctrl/Cmd+F) ─────────────────────────────────────────────
|
||||||
// The desktop supports multiple BrowserWindows (one primary plus any
|
// The desktop supports multiple BrowserWindows (one primary plus any
|
||||||
// per-session secondary windows spawned via `hermes:window:openSession`).
|
// per-session secondary windows spawned via `hermes:window:openSession`).
|
||||||
@@ -17571,8 +17726,11 @@ async function getUninstallSummary() {
|
|||||||
async function runDesktopUninstall(_mode) {
|
async function runDesktopUninstall(_mode) {
|
||||||
// The upstream cleanup script removes Hermes-specific shortcuts, registry
|
// The upstream cleanup script removes Hermes-specific shortcuts, registry
|
||||||
// entries and global launchers. AITURK is owned by its OS package manager.
|
// entries and global launchers. AITURK is owned by its OS package manager.
|
||||||
return { ok: false, error: 'aiturk-os-uninstaller',
|
return {
|
||||||
message: 'AITURK IDE işletim sisteminin uygulama yöneticisinden kaldırılır.' }
|
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())
|
ipcMain.handle('hermes:uninstall:summary', async () => getUninstallSummary())
|
||||||
@@ -17753,19 +17911,110 @@ function openMobileConnectionWindow() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
mobileConnectionWindow = createMobileWindow({
|
mobileConnectionWindow = createMobileWindow({
|
||||||
directory: path.join(app.getPath('userData'), 'aiturk-mobile'),
|
assets: path.dirname(fileURLToPath(import.meta.url)),
|
||||||
assets: path.dirname(fileURLToPath(import.meta.url)),
|
link: getMobileLink()
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "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(() => {
|
app.whenReady().then(() => {
|
||||||
// Explicit development/beta entry point; never enables remote access on boot.
|
// Explicit development/beta entry point; never enables remote access on boot.
|
||||||
if (process.platform === 'win32' && process.argv.includes('--aiturk-mobile')) openMobileConnectionWindow()
|
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) {
|
if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
|
||||||
void startChromeBridge({
|
void startChromeBridge({
|
||||||
directory: path.join(process.env.LOCALAPPDATA, 'TurkServis', 'AITURK-IDE'),
|
directory: path.join(process.env.LOCALAPPDATA, 'TurkServis', 'AITURK-IDE'),
|
||||||
@@ -17776,7 +18025,12 @@ app.whenReady().then(() => {
|
|||||||
}
|
}
|
||||||
return ensureBackend(profile)
|
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.'))
|
.catch(() => rememberLog('[chrome] Native bridge could not start.'))
|
||||||
}
|
}
|
||||||
// Warm the login-shell PATH resolution immediately so it usually completes
|
// 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,60 +1,169 @@
|
|||||||
import { completeIce, rtcConfiguration, sendFrame } from './mobile/transport.mjs'
|
// "Telefon bağlantısı" window UI. Talks to electron/mobile-host.mjs over the
|
||||||
const host = window.mobileHost, $ = id => document.getElementById(id), peers = new Map()
|
// `mobileHost` bridge (mobile-preload.cjs); all logic lives in the main process.
|
||||||
const status = text => { $('status').textContent = text }
|
/* global window, document */
|
||||||
function action(id, work) { $(id).onclick = async () => { $(id).disabled = true; try { await work() } catch (error) { status(error.message) } finally { $(id).disabled = false } } }
|
import { drawQr } from './mobile/qr.mjs'
|
||||||
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.') })
|
const TEXT = {
|
||||||
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.') })
|
tr: {
|
||||||
action('save', async () => { const token = $('account').value; $('account').value = ''; await host.call('account.save', { token }); status('Hesap anahtarı Windows kasasına kaydedildi.') })
|
eyebrow: 'AITURK · BİLGİSAYAR', title: 'Telefon bağlantısı',
|
||||||
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}` })
|
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ı.',
|
||||||
action('approve', async () => { await host.call('phone.approve', { pairingId: $('pairing').value.trim(), code: $('phone-code').value.trim().toUpperCase() }); $('phone-code').value = ''; status('Telefon bu bilgisayarda onaylandı.') })
|
toggle: 'Telefon bağlantısını kullan',
|
||||||
action('refresh', async () => {
|
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.',
|
||||||
const data = await host.call('devices'); $('devices').replaceChildren()
|
relayLabel: 'İnternet üzerinden bağlantı', lanLabel: 'Aynı Wi-Fi’da doğrudan bağlantı',
|
||||||
for (const device of data.devices) {
|
relay: { connected: 'hazır', connecting: 'bağlanıyor…', failed: 'kurulamadı (yeniden denenecek)', idle: 'beklemede (telefon eklenince bağlanır)',
|
||||||
const row = document.createElement('p'), label = document.createElement('span'), button = document.createElement('button')
|
replaced: 'başka bir AITURK IDE aynı kaydı kullanıyor (30 sn sonra yeniden denenecek)', busy: 'servis dolu (yeniden denenecek)' },
|
||||||
label.textContent = `${device.name} · ${device.platform}${device.self ? ' · Bu bilgisayar' : ''}`
|
lanOpen: port => `açık (bağlantı noktası ${port})`, lanOff: 'kapalı', lanIdle: 'izin verildi (telefon eklenince açılır)',
|
||||||
button.textContent = 'Cihazı iptal et'
|
lanNoRule: 'Windows Güvenlik Duvarı izni yok', lanDenied: 'kapalı (Windows izin penceresi reddedildi)', lanFailed: 'kapalı (Güvenlik Duvarı kuralı eklenemedi)',
|
||||||
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 } }
|
lanAllow: 'Aynı Wi-Fi’da doğrudan bağlantıya da izin ver', allow: 'İzin ver', lanDisallow: 'Doğrudan bağlantıyı kapat',
|
||||||
row.append(label, button); $('devices').append(row)
|
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.',
|
||||||
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 }) }
|
orCode: 'ya da telefonda şu kodu yazın:',
|
||||||
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) })
|
codePending: 'Kodla bağlanma hazırlanıyor…', codeReady: 'Kodla bağlanma hazır.',
|
||||||
async function connect(offer) {
|
codeUnavailable: 'Kodla bağlanma şu an kullanılamıyor; QR kodunu okutun.', codeRetry: 'Yeniden dene',
|
||||||
const ice = await host.call('ice')
|
securityCode: code => `Güvenlik kodu: ${code} (telefonda da aynı kod görünmeli)`,
|
||||||
const pc = new RTCPeerConnection(rtcConfiguration(ice.iceServers)), entry = { pc }; peers.set(offer.id, entry)
|
anyNetwork: 'Telefon ve bilgisayar aynı ağda olmak zorunda değil.', sameWifi: 'Telefon ve bilgisayar aynı Wi-Fi ağında olmalı.',
|
||||||
pc.ondatachannel = event => {
|
countdown: (m, s) => `Kod ${m}:${s} sonra geçersiz olur`, expired: 'Kodun süresi doldu. Yeniden “Telefon ekle” seçin.',
|
||||||
const channel = event.channel; entry.channel = channel
|
devices: 'Bağlı telefonlar', noDevices: 'Henüz bağlı telefon yok.',
|
||||||
if (channel.label !== 'aiturk-mobile-v1') { close(offer.id); return }
|
added: 'Eklenme', lastSeen: 'Son görülme', online: 'şu an bağlı', remove: 'Kaldır',
|
||||||
let pending = 0
|
removed: name => `${name} kaldırıldı.`, paired: name => `${name} bağlandı.`,
|
||||||
channel.onmessage = async event => {
|
removeRule: 'Güvenlik Duvarı kuralını kaldır', ruleRemoved: 'Güvenlik Duvarı kuralı kaldırıldı.',
|
||||||
if (typeof event.data !== 'string' || event.data.length > 65000 || ++pending > 8) { close(offer.id); return }
|
renew: 'Röle kimliğini yenile', renewed: 'Röle kimliği yenilendi. Telefonları yeniden eşleştirin.',
|
||||||
let message
|
renewConfirm: 'Röle kimliği yenilenirse bağlı telefonların hepsi kaldırılır ve yeniden eşleştirilmeleri gerekir. Devam edilsin mi?',
|
||||||
try {
|
errors: {
|
||||||
message = JSON.parse(event.data)
|
remote_mode: 'IDE uzak ağ geçidine bağlıyken telefon bağlantısı açılamaz.',
|
||||||
const result = await host.call('channel.request', { channelId: offer.id, message })
|
vault_unavailable: 'Windows güvenli anahtar kasası kullanılamıyor; telefon bağlantısı açılamadı.',
|
||||||
if (channel.readyState === 'open') sendFrame(channel, { id: message.id, result })
|
not_enabled: 'Önce telefon bağlantısını açın.',
|
||||||
} catch (error) { if (message?.id && channel.readyState === 'open') channel.send(JSON.stringify({ id: message.id, error: error.message })) }
|
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ı.',
|
||||||
finally { pending-- }
|
relay_unavailable: 'İnternete ulaşılamıyor, yeniden deneyin.',
|
||||||
}
|
port_unavailable: 'Boş bağlantı noktası bulunamadı (47321–47340 kullanımda).',
|
||||||
channel.onclose = () => close(offer.id)
|
failed: 'İşlem tamamlanamadı. Yeniden deneyin.',
|
||||||
channel.onopen = () => status('Telefon şifreli veri kanalıyla bağlı.')
|
},
|
||||||
}
|
},
|
||||||
try {
|
en: {
|
||||||
await pc.setRemoteDescription({ type: 'offer', sdp: offer.sdp })
|
eyebrow: 'AITURK · COMPUTER', title: 'Phone connection',
|
||||||
await pc.setLocalDescription(await pc.createAnswer()); await completeIce(pc)
|
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.',
|
||||||
await host.call('channel.answer', { channelId: offer.id, sdp: pc.localDescription.sdp })
|
toggle: 'Use phone connection',
|
||||||
entry.timer = setInterval(() => { void host.call('channel.heartbeat', { channelId: offer.id }).catch(() => close(offer.id)) }, 10000)
|
remote: 'The IDE is connected to a remote gateway. Phone connection only works with the local IDE connection; choose the local connection in Settings.',
|
||||||
} catch (error) { close(offer.id); throw error }
|
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.',
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
let listening = false
|
const lang = (navigator.language || '').toLowerCase().startsWith('en') ? 'en' : 'tr'
|
||||||
action('listen', async () => {
|
const t = TEXT[lang], host = window.mobileHost, $ = id => document.getElementById(id)
|
||||||
if (listening) return
|
const when = value => new Date(value).toLocaleString(lang === 'tr' ? 'tr-TR' : 'en-GB', { dateStyle: 'medium', timeStyle: 'short' })
|
||||||
listening = true; status('Onaylı telefon bağlantısı bekleniyor.')
|
document.documentElement.lang = lang; document.title = `AITURK · ${t.title}`
|
||||||
while (listening) {
|
for (const [id, key] of [['t-eyebrow', 'eyebrow'], ['t-title', 'title'], ['t-intro', 'intro'], ['t-remote', 'remote'], ['t-scan', 'scan'],
|
||||||
try { const data = await host.call('inbox'); for (const offer of data.channels) if (!peers.has(offer.id)) await connect(offer) }
|
['t-or-code', 'orCode'], ['t-devices', 'devices'], ['devices-empty', 'noDevices'], ['t-enabled', 'toggle'], ['t-relay-label', 'relayLabel'],
|
||||||
catch (error) { status(error.message); listening = false; break }
|
['t-lan-label', 'lanLabel'], ['t-reissued', 'reissued'], ['lan-disallow', 'lanDisallow'], ['relay-renew', 'renew'],
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
['pair-begin', 'addPhone'], ['pair-cancel', 'cancel'], ['pair-republish', 'codeRetry'], ['firewall-remove', 'removeRule']]) $(id).textContent = t[key]
|
||||||
}
|
|
||||||
})
|
let current = null, drawnQr = '', busy = false
|
||||||
window.addEventListener('pagehide', () => { listening = false; for (const id of peers.keys()) close(id) })
|
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() }
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
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,19 +1,34 @@
|
|||||||
<!doctype html><html lang="tr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
<!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'">
|
<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 Mobil bağlantısı</title><link rel="stylesheet" href="mobile/style.css"></head><body><main>
|
<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">AITURK · BİLGİSAYAR</p><h1>Telefonunu bağla.</h1>
|
<p class="eyebrow" id="t-eyebrow">AITURK · BİLGİSAYAR</p><h1 id="t-title">Telefon bağlantısı</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="t-intro"></p>
|
||||||
<p id="status" role="status" aria-live="polite">Bağlantı kapalı.</p>
|
<label class="switch"><input id="enabled" type="checkbox"><span id="t-enabled"></span></label>
|
||||||
<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 id="notice" class="status" role="status" aria-live="polite" hidden></p>
|
||||||
<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>
|
<section id="remote-box" class="warning" hidden><p id="t-remote"></p></section>
|
||||||
<button id="logout">Mobil oturumdan çık ve kasayı temizle</button>
|
<section id="status-box" hidden>
|
||||||
<details><summary>Gelişmiş: hesap API anahtarı</summary>
|
<div class="status-row"><span id="t-relay-label"></span><strong id="relay-status" role="status" aria-live="polite"></strong></div>
|
||||||
<label>AITURK hesap API anahtarı<input id="account" type="password" autocomplete="off"></label>
|
<div class="status-row"><span id="t-lan-label"></span><strong id="lan-status" role="status" aria-live="polite"></strong></div>
|
||||||
<button id="save">Hesap anahtarını kaydet</button></details><button id="pair">Bu bilgisayarı hesabımla eşleştir</button>
|
<div class="status-actions"><button id="lan-allow" class="secondary"></button><button id="lan-disallow" class="link" hidden></button></div>
|
||||||
<p id="code"></p><hr>
|
</section>
|
||||||
<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>
|
<section id="reissued-box" class="warning" hidden><p id="t-reissued"></p></section>
|
||||||
<label>Eşleştirme kimliği<input id="pairing"></label><label>Telefondaki kod<input id="phone-code" maxlength="8" autocapitalize="characters"></label>
|
<section id="pair-section" hidden>
|
||||||
<button id="approve">Bu telefonu onayla</button><button id="listen">Mobil bağlantıyı aç</button>
|
<button id="pair-begin"></button>
|
||||||
<h2>Cihazlar</h2><button id="refresh">Cihaz listesini yenile</button><div id="devices"></div>
|
<div id="pair-live" hidden>
|
||||||
<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>
|
<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>
|
</main><script type="module" src="mobile-host-ui.mjs"></script></body></html>
|
||||||
|
|||||||
@@ -1,128 +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 { join } from 'node:path'
|
||||||
import { hostname } from 'node:os'
|
import { BrowserWindow, ipcMain } from 'electron'
|
||||||
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'
|
|
||||||
|
|
||||||
export function createMobileWindow({ directory, assets, resolveBackend, show = true }) {
|
const CODED = /^aiturk-mobile:[a-z_]+$/
|
||||||
const vault = new MobileVault(join(directory, 'mobile'), safeStorage)
|
|
||||||
const journal = new DeliveryJournal(join(directory, 'mobile', 'deliveries'))
|
export function createMobileWindow({ assets, link, show = true }) {
|
||||||
const issuer = new LiveTokenIssuer(vault)
|
const window = new BrowserWindow({ width: 560, height: 800, show, title: 'AITURK · Telefon bağlantısı',
|
||||||
const clients = new Map()
|
|
||||||
const window = new BrowserWindow({ width: 560, height: 800, show, title: 'AITURK Mobil bağlantısı',
|
|
||||||
webPreferences: { preload: join(assets, 'mobile-preload.cjs'), contextIsolation: true,
|
webPreferences: { preload: join(assets, 'mobile-preload.cjs'), contextIsolation: true,
|
||||||
nodeIntegration: false, sandbox: true, backgroundThrottling: false } })
|
nodeIntegration: false, sandbox: true } })
|
||||||
window.setMenu(null)
|
window.setMenu(null)
|
||||||
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||||
window.webContents.on('will-navigate', event => event.preventDefault())
|
window.webContents.on('will-navigate', event => event.preventDefault())
|
||||||
const api = async () => new AccountClient(await vault.load())
|
const actions = {
|
||||||
const call = async (path, body, method) => (await api()).call(path, body, method)
|
state: () => link.state(),
|
||||||
const send = frame => { if (!window.isDestroyed()) window.webContents.send('aiturk-mobile-event', frame) }
|
enable: () => link.enable(),
|
||||||
const handler = async (event, action, params = {}) => {
|
disable: () => link.disable(),
|
||||||
if (event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) throw new Error('Geçersiz mobil pencere.')
|
'pair.begin': () => link.pairBegin(),
|
||||||
if (action === 'setup') {
|
'pair.cancel': () => link.pairCancel(),
|
||||||
const secrets = await vault.load()
|
// Publishes the running pairing's rendezvous record again; the code and the QR stay as they are.
|
||||||
return { paired: Boolean(secrets.deviceId), hasAccount: Boolean(secrets.accountToken), hasGoogle: Boolean(secrets.googleKey) }
|
'pair.republish': () => link.pairRepublish(),
|
||||||
}
|
devices: () => link.devices(),
|
||||||
if (action === 'account.save') {
|
'device.revoke': params => link.deviceRevoke(params?.deviceId),
|
||||||
if (typeof params.token !== 'string' || params.token.length < 12 || params.token.length > 512) throw new Error('AITURK hesap anahtarı geçersiz.')
|
// The only actions that may raise a UAC prompt; both are explicit button presses.
|
||||||
if (clients.size) throw new Error('Hesap değiştirmeden önce mobil bağlantıyı kapatın.')
|
'lan.allow': () => link.lanAllow(),
|
||||||
const previous = await vault.load()
|
'lan.disallow': () => link.lanDisallow(),
|
||||||
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.')
|
'firewall.allow': () => link.lanAllow(),
|
||||||
await vault.save({ ...previous, accountToken: params.token })
|
'firewall.retry': () => link.lanAllow(),
|
||||||
return { saved: true }
|
'relay.renew': () => link.relayRenew(),
|
||||||
}
|
'firewall.remove': () => link.firewallRemove(),
|
||||||
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.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.')
|
|
||||||
return call(`/v1/aiturk/pairings/${params.pairingId}/approve`, { user_code: params.code })
|
|
||||||
}
|
|
||||||
if (action === 'devices') return call('/v1/aiturk/mobile/devices')
|
|
||||||
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') return call('/v1/aiturk/mobile/channels')
|
|
||||||
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.')
|
|
||||||
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 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)
|
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'))
|
void window.loadFile(join(assets, 'mobile-host.html'))
|
||||||
return window
|
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),
|
setDisableF12: blocked => ipcRenderer.send('hermes:devtools:disable-f12', blocked),
|
||||||
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
||||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
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: {
|
mcpOauth: {
|
||||||
// One-shot loopback listener for MCP OAuth against remote backends: bind
|
// One-shot loopback listener for MCP OAuth against remote backends: bind
|
||||||
// on this machine, hand redirectUri to mcp.servers.oauth.start, then wait
|
// 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.connectRemote.mock.calls.length, 0)
|
||||||
assert.equal(options.ensureLocalRuntime.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()
|
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) }
|
return { kind: 'local', backend: await ensureLocalRuntime(backend) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import { afterEach, test } from 'vitest'
|
||||||
|
|
||||||
|
import { ensureWindowsVcRuntime, loadVcRuntimeManifest, readPythonHome } from './windows-vc-runtime'
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const roots: string[] = []
|
||||||
|
|
||||||
|
function tempRoot() {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'aiturk-vc-runtime-'))
|
||||||
|
roots.push(root)
|
||||||
|
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(data: Buffer) {
|
||||||
|
return crypto.createHash('sha256').update(data).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fake install: venv/pyvenv.cfg -> python home, plus a bundled source dir
|
||||||
|
// whose manifest describes the given files.
|
||||||
|
function fixture(files: Record<string, { data: string; ownedByPython?: boolean }>) {
|
||||||
|
const root = tempRoot()
|
||||||
|
const home = path.join(root, 'python home')
|
||||||
|
const venv = path.join(root, 'venv')
|
||||||
|
const source = path.join(root, 'bundle')
|
||||||
|
|
||||||
|
for (const dir of [home, venv, source]) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(home, 'python.exe'), 'exe')
|
||||||
|
fs.writeFileSync(path.join(venv, 'pyvenv.cfg'), `home = ${home}\r\nversion_info = 3.11\r\n`)
|
||||||
|
|
||||||
|
const entries = Object.entries(files).map(([name, { data, ownedByPython }]) => {
|
||||||
|
const bytes = Buffer.from(data)
|
||||||
|
fs.writeFileSync(path.join(source, name), bytes)
|
||||||
|
|
||||||
|
return { name, size: bytes.length, sha256: sha256(bytes), ...(ownedByPython ? { ownedByPython } : {}) }
|
||||||
|
})
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(source, 'manifest.json'),
|
||||||
|
JSON.stringify({ schemaVersion: 1, version: 'test', architecture: 'x64', files: entries })
|
||||||
|
)
|
||||||
|
|
||||||
|
return { home, venv, source }
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of roots.splice(0)) {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('non-Windows platforms are left untouched', () => {
|
||||||
|
const { home, venv, source } = fixture({ 'msvcp140.dll': { data: 'crt' } })
|
||||||
|
|
||||||
|
assert.deepEqual(ensureWindowsVcRuntime({ platform: 'linux', venvRoot: venv, sourceDir: source }), {
|
||||||
|
status: 'skipped',
|
||||||
|
reason: 'not windows'
|
||||||
|
})
|
||||||
|
assert.equal(fs.existsSync(path.join(home, 'msvcp140.dll')), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('readPythonHome parses pyvenv.cfg home with spaces', () => {
|
||||||
|
const { home, venv } = fixture({ 'msvcp140.dll': { data: 'crt' } })
|
||||||
|
|
||||||
|
assert.equal(readPythonHome(venv), home)
|
||||||
|
assert.equal(readPythonHome(path.join(venv, 'missing')), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('missing CRT files are copied beside python.exe and verified', () => {
|
||||||
|
const { home, venv, source } = fixture({
|
||||||
|
'msvcp140.dll': { data: 'msvcp' },
|
||||||
|
'msvcp140_1.dll': { data: 'msvcp1' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source })
|
||||||
|
|
||||||
|
assert.deepEqual(result, { status: 'repaired', pythonHome: home, copied: ['msvcp140.dll', 'msvcp140_1.dll'] })
|
||||||
|
assert.equal(fs.readFileSync(path.join(home, 'msvcp140_1.dll'), 'utf8'), 'msvcp1')
|
||||||
|
assert.deepEqual(fs.readdirSync(home).filter(name => name.endsWith('.tmp')), [])
|
||||||
|
// Second launch: nothing to do.
|
||||||
|
assert.deepEqual(ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source }), {
|
||||||
|
status: 'ok',
|
||||||
|
pythonHome: home,
|
||||||
|
copied: []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a mismatching msvcp140 copy is replaced, a python-owned vcruntime is kept', () => {
|
||||||
|
const { home, venv, source } = fixture({
|
||||||
|
'msvcp140.dll': { data: 'good' },
|
||||||
|
'vcruntime140.dll': { data: 'bundled', ownedByPython: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(home, 'msvcp140.dll'), 'stale')
|
||||||
|
fs.writeFileSync(path.join(home, 'vcruntime140.dll'), 'interpreter-owned')
|
||||||
|
|
||||||
|
const result = ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source })
|
||||||
|
|
||||||
|
assert.deepEqual(result, { status: 'repaired', pythonHome: home, copied: ['msvcp140.dll'] })
|
||||||
|
assert.equal(fs.readFileSync(path.join(home, 'msvcp140.dll'), 'utf8'), 'good')
|
||||||
|
assert.equal(fs.readFileSync(path.join(home, 'vcruntime140.dll'), 'utf8'), 'interpreter-owned')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a tampered bundled file is refused and nothing is written', () => {
|
||||||
|
const { home, venv, source } = fixture({ 'msvcp140.dll': { data: 'genuine' } })
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(source, 'msvcp140.dll'), 'tampered')
|
||||||
|
|
||||||
|
const result = ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source })
|
||||||
|
|
||||||
|
assert.equal(result.status, 'failed')
|
||||||
|
assert.match((result as { error: string }).error, /does not match its manifest digest/)
|
||||||
|
assert.equal(fs.existsSync(path.join(home, 'msvcp140.dll')), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unresolvable python home fails without throwing', () => {
|
||||||
|
const { venv, source } = fixture({ 'msvcp140.dll': { data: 'crt' } })
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(venv, 'pyvenv.cfg'), 'home = C:\\does-not-exist\n')
|
||||||
|
|
||||||
|
assert.equal(ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source }).status, 'failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the shipped CRT set matches its manifest and covers the voice wheels', () => {
|
||||||
|
const sourceDir = path.join(here, '..', 'assets', 'windows', 'vc-runtime', 'x64')
|
||||||
|
const manifest = loadVcRuntimeManifest(sourceDir)
|
||||||
|
const names = manifest.files.map(file => file.name)
|
||||||
|
|
||||||
|
for (const required of ['msvcp140.dll', 'msvcp140_1.dll', 'vcruntime140.dll', 'vcruntime140_1.dll']) {
|
||||||
|
assert.ok(names.includes(required), `${required} must be bundled`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of manifest.files) {
|
||||||
|
const bytes = fs.readFileSync(path.join(sourceDir, file.name))
|
||||||
|
|
||||||
|
assert.equal(bytes.length, file.size, file.name)
|
||||||
|
assert.equal(sha256(bytes), file.sha256, file.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the Windows package ships the CRT set as an extra resource', () => {
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8'))
|
||||||
|
const resources = pkg.build.win.extraResources
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
resources.some(entry => entry.from === 'assets/windows/vc-runtime/x64' && entry.to === 'vc-runtime/x64'),
|
||||||
|
'win.extraResources must carry vc-runtime/x64'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ensureRuntime places the CRT before handing the venv python to the backend', () => {
|
||||||
|
const source = fs.readFileSync(path.join(here, 'main.ts'), 'utf8')
|
||||||
|
const start = source.indexOf('async function ensureRuntime(')
|
||||||
|
const body = source.slice(start, source.indexOf('\nfunction ', start + 1))
|
||||||
|
|
||||||
|
const ensureIdx = body.indexOf('ensureWindowsVcRuntime(')
|
||||||
|
const commandIdx = body.indexOf('backend.command = getVenvPython(VENV_ROOT)')
|
||||||
|
|
||||||
|
assert.notEqual(ensureIdx, -1, 'ensureRuntime must call ensureWindowsVcRuntime')
|
||||||
|
assert.ok(ensureIdx < commandIdx, 'CRT must be in place before the backend command is chosen')
|
||||||
|
})
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
// ── Windows VC++ runtime for the managed Python ─────────────────────────────
|
||||||
|
//
|
||||||
|
// Voice features load native wheels (onnxruntime for the wake word,
|
||||||
|
// ctranslate2 for local speech-to-text) that link against msvcp140.dll and
|
||||||
|
// msvcp140_1.dll. The managed CPython only ships vcruntime140*.dll, so on a
|
||||||
|
// Windows install without the system-wide VC++ Redistributable those imports
|
||||||
|
// fail with "DLL load failed / The specified module could not be found".
|
||||||
|
//
|
||||||
|
// The desktop package carries the Microsoft-signed VC143 CRT set and copies it
|
||||||
|
// next to the managed python.exe before every local backend start. That
|
||||||
|
// directory is the process image directory, which Windows searches first for
|
||||||
|
// extension-module dependencies, so a stale msvcp140.dll elsewhere on PATH can
|
||||||
|
// never win. Existing installs are healed on their next launch.
|
||||||
|
|
||||||
|
export interface VcRuntimeFile {
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
sha256: string
|
||||||
|
// Files the managed CPython already ships. Never overwrite an existing copy:
|
||||||
|
// the interpreter owns it and replacing a loaded DLL is not possible anyway.
|
||||||
|
ownedByPython?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VcRuntimeManifest {
|
||||||
|
schemaVersion: 1
|
||||||
|
version: string
|
||||||
|
architecture: string
|
||||||
|
files: VcRuntimeFile[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VcRuntimeResult =
|
||||||
|
| { status: 'skipped'; reason: string }
|
||||||
|
| { status: 'ok' | 'repaired'; pythonHome: string; copied: string[] }
|
||||||
|
| { status: 'failed'; pythonHome: string | null; copied: string[]; error: string }
|
||||||
|
|
||||||
|
const FILE_NAME_RE = /^[a-z0-9_]+\.dll$/i
|
||||||
|
const SHA256_RE = /^[0-9a-f]{64}$/
|
||||||
|
|
||||||
|
export function readPythonHome(venvRoot: string): string | null {
|
||||||
|
let text: string
|
||||||
|
|
||||||
|
try {
|
||||||
|
text = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
const match = /^\s*home\s*=\s*(.+?)\s*$/i.exec(line)
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
return match[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadVcRuntimeManifest(sourceDir: string): VcRuntimeManifest {
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(path.join(sourceDir, 'manifest.json'), 'utf8'))
|
||||||
|
|
||||||
|
if (manifest?.schemaVersion !== 1 || !Array.isArray(manifest.files) || manifest.files.length === 0) {
|
||||||
|
throw new Error('invalid VC++ runtime manifest')
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of manifest.files) {
|
||||||
|
if (!FILE_NAME_RE.test(file?.name) || !SHA256_RE.test(file?.sha256) || !Number.isInteger(file?.size)) {
|
||||||
|
throw new Error(`invalid VC++ runtime manifest entry: ${JSON.stringify(file?.name)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256File(filePath: string): string {
|
||||||
|
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(filePath: string, file: VcRuntimeFile): boolean {
|
||||||
|
try {
|
||||||
|
return fs.statSync(filePath).size === file.size && sha256File(filePath) === file.sha256
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installFile(sourceDir: string, pythonHome: string, file: VcRuntimeFile) {
|
||||||
|
const source = path.join(sourceDir, file.name)
|
||||||
|
|
||||||
|
if (!matches(source, file)) {
|
||||||
|
throw new Error(`bundled ${file.name} does not match its manifest digest`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = path.join(pythonHome, file.name)
|
||||||
|
const temp = `${target}.aiturk-${process.pid}.tmp`
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(source, temp)
|
||||||
|
|
||||||
|
if (!matches(temp, file)) {
|
||||||
|
throw new Error(`copied ${file.name} failed digest verification`)
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.renameSync(temp, target)
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(temp, { force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureWindowsVcRuntime({
|
||||||
|
platform,
|
||||||
|
venvRoot,
|
||||||
|
sourceDir
|
||||||
|
}: {
|
||||||
|
platform: NodeJS.Platform
|
||||||
|
venvRoot: string
|
||||||
|
sourceDir: string
|
||||||
|
}): VcRuntimeResult {
|
||||||
|
if (platform !== 'win32') {
|
||||||
|
return { status: 'skipped', reason: 'not windows' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const pythonHome = readPythonHome(venvRoot)
|
||||||
|
|
||||||
|
if (!pythonHome || !fs.existsSync(path.join(pythonHome, 'python.exe'))) {
|
||||||
|
return { status: 'failed', pythonHome, copied: [], error: 'managed python home not found' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const copied: string[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manifest = loadVcRuntimeManifest(sourceDir)
|
||||||
|
|
||||||
|
for (const file of manifest.files) {
|
||||||
|
const target = path.join(pythonHome, file.name)
|
||||||
|
|
||||||
|
if (file.ownedByPython ? fs.existsSync(target) : matches(target, file)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
installFile(sourceDir, pythonHome, file)
|
||||||
|
copied.push(file.name)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return { status: 'failed', pythonHome, copied, error: error instanceof Error ? error.message : String(error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: copied.length ? 'repaired' : 'ok', pythonHome, copied }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeVcRuntimeResult(result: VcRuntimeResult): string {
|
||||||
|
if (result.status === 'skipped') {
|
||||||
|
return `skipped (${result.reason})`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === 'failed') {
|
||||||
|
return `failed for ${result.pythonHome ?? 'unknown python home'}: ${result.error}` +
|
||||||
|
(result.copied.length ? ` (copied before failure: ${result.copied.join(', ')})` : '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.status === 'repaired'
|
||||||
|
? `installed ${result.copied.join(', ')} into ${result.pythonHome}`
|
||||||
|
: `verified in ${result.pythonHome}`
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "aiturk-ide",
|
"name": "aiturk-ide",
|
||||||
"productName": "AITURK IDE",
|
"productName": "AITURK IDE",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0-beta.10",
|
"version": "1.0.0-beta.27",
|
||||||
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
||||||
"author": "AITURK / TurkServis",
|
"author": "AITURK / TurkServis",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -290,7 +290,27 @@
|
|||||||
"nsis",
|
"nsis",
|
||||||
"msi"
|
"msi"
|
||||||
],
|
],
|
||||||
"signAndEditExecutable": false
|
"signAndEditExecutable": false,
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "assets/windows/vc-runtime/x64",
|
||||||
|
"to": "vc-runtime/x64",
|
||||||
|
"filter": [
|
||||||
|
"*.dll",
|
||||||
|
"manifest.json"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "assets/codex-bridge",
|
||||||
|
"to": "codex-bridge",
|
||||||
|
"filter": [
|
||||||
|
"**/*.py",
|
||||||
|
"**/*.pyd",
|
||||||
|
"**/LICENSE",
|
||||||
|
"!test_*.py"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"linux": {
|
"linux": {
|
||||||
"category": "Development",
|
"category": "Development",
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
// `electron` and `node-pty` are external (provided by the runtime / staged
|
// `electron` and `node-pty` are external (provided by the runtime / staged
|
||||||
// separately via stage-native-deps).
|
// separately via stage-native-deps).
|
||||||
import { build } from 'esbuild'
|
import { build } from 'esbuild'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
import { resolve, dirname } from 'node:path'
|
import { resolve, dirname } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { mkdirSync, cpSync } from 'node:fs'
|
import { mkdirSync, cpSync, existsSync } from 'node:fs'
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url))
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
const root = resolve(here, '..')
|
const root = resolve(here, '..')
|
||||||
@@ -63,10 +64,25 @@ await build({
|
|||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
})
|
})
|
||||||
console.log(`bundled ${preloadOut}${isDev ? ' (dev)' : ''}`)
|
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))
|
cpSync(resolve(root, 'electron', name), resolve(distDir, name))
|
||||||
}
|
}
|
||||||
mkdirSync(resolve(distDir, 'mobile'), { recursive: true })
|
mkdirSync(resolve(distDir, 'mobile'), { recursive: true })
|
||||||
for (const name of ['transport.mjs', 'style.css']) {
|
// 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))
|
cpSync(resolve(root, '../mobile/src', name), resolve(distDir, 'mobile', name))
|
||||||
}
|
}
|
||||||
|
// A clean `npm ci` hoists the mobile vendor packages to the workspace root, so
|
||||||
|
// look them up along Node's resolution path from apps/mobile instead of
|
||||||
|
// assuming apps/mobile/node_modules exists.
|
||||||
|
const mobileRequire = createRequire(resolve(root, '../mobile/package.json'))
|
||||||
|
function mobilePackageDir(name) {
|
||||||
|
for (const base of mobileRequire.resolve.paths(name) ?? []) {
|
||||||
|
const dir = resolve(base, name)
|
||||||
|
if (existsSync(resolve(dir, 'package.json'))) return dir
|
||||||
|
}
|
||||||
|
throw new Error(`${name} is not installed for apps/mobile; run npm ci at the repository root`)
|
||||||
|
}
|
||||||
|
mkdirSync(resolve(distDir, 'mobile/vendor'), { recursive: true })
|
||||||
|
cpSync(resolve(mobilePackageDir('qrcode-generator'), 'dist/qrcode.mjs'), resolve(distDir, 'mobile/vendor/qrcode.mjs'))
|
||||||
|
|||||||
@@ -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 { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { AiturkLogin } from '@/components/aiturk-login'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -80,7 +81,10 @@ function toPayload(form: EndpointForm, models?: string[]): CustomEndpointUpdate
|
|||||||
|
|
||||||
export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: CustomEndpointsSettingsProps) {
|
export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: CustomEndpointsSettingsProps) {
|
||||||
const { t } = useI18n()
|
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 [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [testing, setTesting] = 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() {
|
async function handleSave() {
|
||||||
try {
|
try {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
@@ -298,10 +327,13 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<Button className="mb-3" onClick={() => {
|
<div className="mb-3 flex flex-wrap items-start gap-2">
|
||||||
setForm({ ...EMPTY_FORM, name: 'TurkServis · AITURK', baseUrl: AITURK_API_BASE_URL })
|
<AiturkLogin label={loginLabel} onConnected={handleAiturkConnected} />
|
||||||
setDiscoveredModels([])
|
<Button onClick={() => {
|
||||||
}} variant="outline">{c.preset}</Button>
|
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} />
|
<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 rounded-md border border-border/50 p-3">
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { IconDeviceMobile } from '@tabler/icons-react'
|
||||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
import { useLocation, useNavigate } from 'react-router'
|
import { useLocation, useNavigate } from 'react-router'
|
||||||
|
|
||||||
@@ -7,6 +8,8 @@ import { KbdCombo } from '@/components/ui/kbd'
|
|||||||
import { Tip } from '@/components/ui/tooltip'
|
import { Tip } from '@/components/ui/tooltip'
|
||||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
|
import { useAiturkCopy } from '@/i18n/aiturk'
|
||||||
|
import { useCodexLinkCopy } from '@/i18n/codex-link'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
@@ -18,6 +21,7 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
|
Link,
|
||||||
Package,
|
Package,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
@@ -44,6 +48,7 @@ import { SKILLS_ROUTE } from '../routes'
|
|||||||
import { AboutSettings } from './about-settings'
|
import { AboutSettings } from './about-settings'
|
||||||
import { AppearanceSettings } from './appearance-settings'
|
import { AppearanceSettings } from './appearance-settings'
|
||||||
import { BillingSettings } from './billing'
|
import { BillingSettings } from './billing'
|
||||||
|
import { CodexLinkSettings } from './codex-link-settings'
|
||||||
import { ConfigSettings } from './config-settings'
|
import { ConfigSettings } from './config-settings'
|
||||||
import { SECTIONS } from './constants'
|
import { SECTIONS } from './constants'
|
||||||
import { GatewaySettings } from './gateway-settings'
|
import { GatewaySettings } from './gateway-settings'
|
||||||
@@ -66,6 +71,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
|||||||
'keys',
|
'keys',
|
||||||
'notifications',
|
'notifications',
|
||||||
'billing',
|
'billing',
|
||||||
|
'codex-link',
|
||||||
'plugins',
|
'plugins',
|
||||||
'sessions',
|
'sessions',
|
||||||
'about'
|
'about'
|
||||||
@@ -73,6 +79,8 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
|||||||
|
|
||||||
export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const aiturkCopy = useAiturkCopy()
|
||||||
|
const codexLinkCopy = useCodexLinkCopy()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { hash, pathname, search } = useLocation()
|
const { hash, pathname, search } = useLocation()
|
||||||
|
|
||||||
@@ -249,6 +257,34 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
|||||||
label: t.settings.nav.gateway,
|
label: t.settings.nav.gateway,
|
||||||
onSelect: () => setActiveView('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',
|
active: activeView === 'keybinds',
|
||||||
icon: Keyboard,
|
icon: Keyboard,
|
||||||
@@ -302,7 +338,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
|||||||
onSelect: () => setActiveView('about')
|
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
|
// Type-to-search: printable keystrokes on the Settings surface (outside any
|
||||||
@@ -421,6 +457,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
|||||||
<BillingSettings />
|
<BillingSettings />
|
||||||
) : activeView === 'plugins' ? (
|
) : activeView === 'plugins' ? (
|
||||||
<PluginsSettings />
|
<PluginsSettings />
|
||||||
|
) : activeView === 'codex-link' ? (
|
||||||
|
<CodexLinkSettings />
|
||||||
) : (
|
) : (
|
||||||
<SessionsSettings />
|
<SessionsSettings />
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { EnvVarInfo } from '@/types/hermes'
|
|||||||
export type SettingsView =
|
export type SettingsView =
|
||||||
| 'about'
|
| 'about'
|
||||||
| 'billing'
|
| 'billing'
|
||||||
|
| 'codex-link'
|
||||||
| 'connections'
|
| 'connections'
|
||||||
| 'gateway'
|
| 'gateway'
|
||||||
| 'keybinds'
|
| '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 { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { saveCustomEndpoint, validateCustomEndpoint } from '@/hermes'
|
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('@/hermes', () => ({ saveCustomEndpoint: vi.fn(), validateCustomEndpoint: vi.fn() }))
|
||||||
vi.mock('./flow', () => ({ DocsLink: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a> }))
|
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() {
|
function setup() {
|
||||||
const onConnected = vi.fn().mockResolvedValue(undefined)
|
const onConnected = vi.fn().mockResolvedValue(undefined)
|
||||||
@@ -17,6 +22,7 @@ function setup() {
|
|||||||
const input = screen.getByLabelText('TurkServis API anahtarı') as HTMLInputElement
|
const input = screen.getByLabelText('TurkServis API anahtarı') as HTMLInputElement
|
||||||
fireEvent.change(input, { target: { value: 'test-key-never-real' } })
|
fireEvent.change(input, { target: { value: 'test-key-never-real' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Bağlan' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Bağlan' }))
|
||||||
|
|
||||||
return { input, onConnected }
|
return { input, onConnected }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,3 +53,163 @@ describe('TurkServis provider setup', () => {
|
|||||||
expect(saveCustomEndpoint).not.toHaveBeenCalled()
|
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 { useState } from 'react'
|
||||||
|
|
||||||
|
import { AiturkLogin } from '@/components/aiturk-login'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { saveCustomEndpoint, validateCustomEndpoint } from '@/hermes'
|
import { saveCustomEndpoint, validateCustomEndpoint } from '@/hermes'
|
||||||
@@ -18,18 +19,22 @@ export function AiturkProvider({ onConnected }: { onConnected: () => Promise<voi
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
async function connect() {
|
async function connect() {
|
||||||
if (busy || !key.trim()) return
|
if (busy || !key.trim()) {return}
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Discovery goes through the connected backend's existing credential
|
// Discovery goes through the connected backend's existing credential
|
||||||
// boundary. Do not ship a shared key or hard-code a model catalog.
|
// boundary. Do not ship a shared key or hard-code a model catalog.
|
||||||
const checked = await validateCustomEndpoint(aiturkEndpoint(key))
|
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]
|
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))
|
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('')
|
setKey('')
|
||||||
await onConnected()
|
await onConnected()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -45,16 +50,18 @@ export function AiturkProvider({ onConnected }: { onConnected: () => Promise<voi
|
|||||||
<h3 className="font-semibold">TurkServis · AITURK</h3>
|
<h3 className="font-semibold">TurkServis · AITURK</h3>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{copy.pitch}</p>
|
<p className="mt-1 text-sm text-muted-foreground">{copy.pitch}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<AiturkLogin disabled={busy} onConnected={onConnected} />
|
||||||
|
<div className="text-xs text-muted-foreground">{copy.login.orPasteKey}</div>
|
||||||
<label className="grid gap-2 text-sm">
|
<label className="grid gap-2 text-sm">
|
||||||
{copy.key}
|
{copy.key}
|
||||||
<Input autoComplete="off" disabled={busy} onChange={e => setKey(e.target.value)}
|
<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} />
|
placeholder={copy.key} type="password" value={key} />
|
||||||
</label>
|
</label>
|
||||||
{error ? <p className="text-sm text-destructive" role="alert">{error}</p> : null}
|
{error ? <p className="text-sm text-destructive" role="alert">{error}</p> : null}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<DocsLink href={AITURK_ACCOUNT_URL}>{copy.getKey}</DocsLink>
|
<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}
|
{busy ? <Loader2 className="animate-spin" /> : null}{busy ? t.common.connecting : t.common.connect}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Vendored
+34
@@ -1,6 +1,7 @@
|
|||||||
import type { GatewayWsUrlResult } from '@hermes/shared'
|
import type { GatewayWsUrlResult } from '@hermes/shared'
|
||||||
import type { TranslucencyState } from '@hermes/shared/translucency'
|
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 { PoolLimits } from '../electron/pool-limits'
|
||||||
|
|
||||||
import type { WakeIndicatorState } from './lib/wake-indicator'
|
import type { WakeIndicatorState } from './lib/wake-indicator'
|
||||||
@@ -323,6 +324,25 @@ declare global {
|
|||||||
setDisableF12?: (blocked: boolean) => void
|
setDisableF12?: (blocked: boolean) => void
|
||||||
setPreviewShortcutActive?: (active: boolean) => void
|
setPreviewShortcutActive?: (active: boolean) => void
|
||||||
openExternal: (url: string) => Promise<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
|
/** One-shot loopback callback listener for MCP OAuth against remote
|
||||||
* backends (electron/mcp-oauth-callback-ipc.ts): bind on THIS machine,
|
* backends (electron/mcp-oauth-callback-ipc.ts): bind on THIS machine,
|
||||||
* pass redirectUri as client_redirect_uri to mcp.servers.oauth.start,
|
* pass redirectUri as client_redirect_uri to mcp.servers.oauth.start,
|
||||||
@@ -1212,6 +1232,20 @@ export type DesktopBootstrapEvent =
|
|||||||
docsUrl: string
|
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 {
|
export interface HermesApiRequest {
|
||||||
path: string
|
path: string
|
||||||
method?: string
|
method?: string
|
||||||
|
|||||||
@@ -30,6 +30,28 @@ const en = {
|
|||||||
getKey: 'My account and API keys',
|
getKey: 'My account and API keys',
|
||||||
noModels: 'No models are available for this key.',
|
noModels: 'No models are available for this key.',
|
||||||
failed: 'Could not connect to TurkServis.',
|
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: {
|
custom: {
|
||||||
title: 'Custom endpoints',
|
title: 'Custom endpoints',
|
||||||
active: 'Active',
|
active: 'Active',
|
||||||
@@ -64,6 +86,10 @@ const en = {
|
|||||||
found: (n: number) => `Endpoint is reachable. Found ${n} models.`,
|
found: (n: number) => `Endpoint is reachable. Found ${n} models.`,
|
||||||
confirmDelete: (name: string) => `Delete ${name}?`
|
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.',
|
about: 'TurkServis distribution based on Hermes Agent. Upstream: Nous Research, MIT license.',
|
||||||
downloads: 'Official downloads and updates',
|
downloads: 'Official downloads and updates',
|
||||||
source: 'Source code and license',
|
source: 'Source code and license',
|
||||||
@@ -79,6 +105,28 @@ const tr: typeof en = {
|
|||||||
getKey: 'Hesabım ve API anahtarlarım',
|
getKey: 'Hesabım ve API anahtarlarım',
|
||||||
noModels: 'Bu anahtarla kullanılabilecek model bulunamadı.',
|
noModels: 'Bu anahtarla kullanılabilecek model bulunamadı.',
|
||||||
failed: 'TurkServis bağlantısı kurulamadı.',
|
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: {
|
custom: {
|
||||||
title: 'Özel API bağlantıları',
|
title: 'Özel API bağlantıları',
|
||||||
active: 'Etkin',
|
active: 'Etkin',
|
||||||
@@ -113,6 +161,10 @@ const tr: typeof en = {
|
|||||||
found: n => `API adresine erişiliyor. ${n} model bulundu.`,
|
found: n => `API adresine erişiliyor. ${n} model bulundu.`,
|
||||||
confirmDelete: name => `${name} silinsin mi?`
|
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ı.',
|
about: 'Hermes Agent tabanlı TurkServis dağıtımı. Kaynak: Nous Research, MIT lisansı.',
|
||||||
downloads: 'Resmî indirmeler ve güncellemeler',
|
downloads: 'Resmî indirmeler ve güncellemeler',
|
||||||
source: 'Kaynak kodu ve lisans',
|
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'
|
import type { CustomEndpointUpdate } from '@/types/hermes'
|
||||||
|
|
||||||
export const AITURK_API_BASE_URL = 'https://ai.turkservis.online/v1'
|
export const AITURK_API_BASE_URL = 'https://ai.turkservis.online/v1'
|
||||||
export const AITURK_ACCOUNT_URL = 'https://turkservis.online/panel'
|
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 {
|
export function aiturkEndpoint(apiKey: string, model = '', models?: string[]): CustomEndpointUpdate {
|
||||||
return {
|
return {
|
||||||
id: 'turkservis',
|
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'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
+85
-7
@@ -1,4 +1,4 @@
|
|||||||
# AITURK Mobil — Android geliştirme sürümü 0.1.1
|
# AITURK Mobil — Android geliştirme sürümü 0.1.4
|
||||||
|
|
||||||
Bu bir **geliştirme paketi**; planın tamamlanmış ürün/kapalı beta kabulü değildir.
|
Bu bir **geliştirme paketi**; planın tamamlanmış ürün/kapalı beta kabulü değildir.
|
||||||
Android uygulaması, IDE adaptörü ve hesap doğrulamalı bağlantı servisi kodlandı.
|
Android uygulaması, IDE adaptörü ve hesap doğrulamalı bağlantı servisi kodlandı.
|
||||||
@@ -25,6 +25,14 @@ turkservis.online veya kurulu IDE bu kaynak değişiklikleriyle güncellenmedi.
|
|||||||
üretme kodu; doğrudan Google WebSocket, PCM mikrofon/oynatma ve kapatma kodu.
|
üretme kodu; doğrudan Google WebSocket, PCM mikrofon/oynatma ve kapatma kodu.
|
||||||
- Ses görüşmesini kapatma IDE'ye interrupt göndermez. Görünür görev gönder
|
- Ses görüşmesini kapatma IDE'ye interrupt göndermez. Görünür görev gönder
|
||||||
düğmesi ve ayrı IDE durdur düğmesi vardır.
|
düğmesi ve ayrı IDE durdur düğmesi vardır.
|
||||||
|
- Google kullanım bildirimleri ücretli liste tarifesiyle tahmin edilir; her
|
||||||
|
isteğin tekrar faturalandırılan geçmişi de sayılır. Ayrıntısı eksik tokenlar
|
||||||
|
için fiyat aralığı gösterilir. Bu fatura, kredi veya ücretsiz kota sorgusu değildir.
|
||||||
|
- Kullanıcı her görüşme için 1–10 dakika ve 0,01–10 USD tahmini durdurma sınırı
|
||||||
|
seçer (varsayılan 10 dakika / 0,25 USD). Üst tahmin sınıra ulaşırsa, süre
|
||||||
|
dolarsa veya 120 saniye kullanım bildirimi gelmezse yalnız Google bağlantısı
|
||||||
|
kapanır. Otomatik yeniden bağlanılmaz. Her yeni oturumda sayaç sıfırlanır;
|
||||||
|
bu günlük/aylık bütçe veya Google tarafında kesin harcama tavanı değildir.
|
||||||
|
|
||||||
## Geliştirme kurulumu
|
## Geliştirme kurulumu
|
||||||
|
|
||||||
@@ -55,8 +63,10 @@ Mobil pencere açılmadan uzaktan erişim etkinleşmez.
|
|||||||
1. Bilgisayardaki mobil pencerede “AITURK hesabımla giriş yap” seçin; tarayıcıda
|
1. Bilgisayardaki mobil pencerede “AITURK hesabımla giriş yap” seçin; tarayıcıda
|
||||||
hesabınıza girip uygulamanın kodunu onaylayın. “Girişi kontrol et” ile tamamlayın.
|
hesabınıza girip uygulamanın kodunu onaylayın. “Girişi kontrol et” ile tamamlayın.
|
||||||
2. Bilgisayarı hesapla eşleştirin; açılan mevcut portal sayfasında kodu onaylayın.
|
2. Bilgisayarı hesapla eşleştirin; açılan mevcut portal sayfasında kodu onaylayın.
|
||||||
3. Android'de aynı AITURK hesabıyla tarayıcı kod girişini tamamlayıp eşleştirme kodu oluşturun.
|
3. Android'de aynı AITURK hesabıyla tarayıcı kod girişini tamamlayın. Bilgisayarda
|
||||||
4. Telefondaki kimlik/kodu bilgisayardaki mobil pencerede açıkça onaylayın.
|
“Telefon için QR oluştur” seçip telefonda kamerayla veya QR resmiyle okuyun.
|
||||||
|
4. Bilgisayarda “QR ile gelen telefonu getir” seçin; telefonun gösterdiği kısa
|
||||||
|
kodu girip açıkça onaylayın. Uzun kimliği elle yazmak gerekmez.
|
||||||
5. Bilgisayarda “Mobil bağlantıyı aç”, telefonda “Bilgisayarları bul” ve “Bağlan”.
|
5. Bilgisayarda “Mobil bağlantıyı aç”, telefonda “Bilgisayarları bul” ve “Bağlan”.
|
||||||
6. Gerçek IDE konuşmasını seçin. Yazılı görevi görünür önizlemeden gönderin.
|
6. Gerçek IDE konuşmasını seçin. Yazılı görevi görünür önizlemeden gönderin.
|
||||||
7. Google anahtarını telefondaki ayrı güvenli girişten bilgisayara aktarın.
|
7. Google anahtarını telefondaki ayrı güvenli girişten bilgisayara aktarın.
|
||||||
@@ -68,12 +78,65 @@ deposunun `docs/aiturk-mobile-operations.md` dosyasında açıklanır.
|
|||||||
|
|
||||||
## Doğrulama ve sınırlar
|
## Doğrulama ve sınırlar
|
||||||
|
|
||||||
|
0.1.4-dev (Android versionCode 5), 12 Eylül 2026: 41 mobil test geçti;
|
||||||
|
tarayıcı uygulama testi, yerel sertifika/DTLS testi ve Electron IPC/Windows kasa
|
||||||
|
testi geçti. Telefon kimliği QR resminin onaysız içe alınması ve değiştirilmiş
|
||||||
|
cihaz kimliklerinin reddi test edildi. `assembleDebug lintDebug` başarılı,
|
||||||
|
uygulama lint sonucu 0 hata / 17 uyarı. Debug APK imzası doğrulandı ve kimlik,
|
||||||
|
taşıma, uygulama, davet, gizlilik ve ana sayfa dosyaları kaynakla karşılaştırıldı.
|
||||||
|
APK SHA-256: `94835E223B71BE36AFB26686D3EBC79D8976514FFDB4E7CDFC189ACE7E2F12A5`.
|
||||||
|
Bu APK geliştirici imzalıdır; üretim/gerçek Android kabulü değildir.
|
||||||
|
|
||||||
|
12 Eylül 2026 yerel doğrulaması: `npm test` 37/37 geçti. Ses başlangıcı
|
||||||
|
beklerken iptal ve kapatılmış oturumu yeniden açma regresyonları önce başarısız
|
||||||
|
testlerle gösterildi, sonra düzeltildi. `npm run android:sync` ve
|
||||||
|
`gradlew.bat assembleDebug lintDebug` başarılı; uygulama lint sonucu 0 hata,
|
||||||
|
17 uyarı. `scripts/browser-smoke.mjs` gerçek DTLS veri kanalında 240.000
|
||||||
|
karakter aktarımını ve sentetik Google kullanım sınırını doğruladı; Google'a
|
||||||
|
gerçek çağrı yapmadı. APK içindeki `voice.mjs` kaynakla birebir karşılaştırıldı.
|
||||||
|
Debug imzası `apksigner verify` ile doğrulandı; bu üretim imzası değildir.
|
||||||
|
APK SHA-256: `F5DB098DF0F1C59B92F274969FCAD926BCB2DD25AF823533A983085A18BFB966`.
|
||||||
|
Bu kontrolde `adb devices -l` bağlı cihaz göstermedi; fiziksel telefon kabulü
|
||||||
|
ve canlı yayın hâlâ tamamlanmadı.
|
||||||
|
|
||||||
`npm test`: davranış, tekrar gönderim, onay sınırı, ses temizliği ve token kısıtları.
|
`npm test`: davranış, tekrar gönderim, onay sınırı, ses temizliği ve token kısıtları.
|
||||||
`scripts/browser-smoke.mjs`: yeni test tarayıcısında gerçek DTLS veri kanalı,
|
`scripts/browser-smoke.mjs`: yeni test tarayıcısında gerçek DTLS veri kanalı,
|
||||||
240.000 karakter Unicode aktarımı, dar ekran taşması ve dokunma hedefi.
|
240.000 karakter Unicode aktarımı, dar ekran taşması ve dokunma hedefi.
|
||||||
`scripts/electron-smoke.mjs`: gerçek Electron IPC ve Windows safeStorage turu;
|
`scripts/electron-smoke.mjs`: gerçek Electron IPC ve Windows safeStorage turu;
|
||||||
yalnız sentetik bir test anahtarı kullanır.
|
yalnız sentetik bir test anahtarı kullanır.
|
||||||
|
|
||||||
|
`src/peer-identity.mjs` cihaz kimliğine göre kalıcı standart RTCCertificate
|
||||||
|
kaydını, atomik ilk oluşturmayı, kayıtlı parmak iziyle yüklemeyi ve SDP kimlik
|
||||||
|
kontrolünü uygular. Anahtar eksik veya geçersizse yükleme yeni sertifika üretmez.
|
||||||
|
Bilgisayar QR daveti sürüm 2 ile sertifika parmak izini taşır; telefon bunu
|
||||||
|
eşleştirme kaydına alır ve SDP uygulanmadan önce kontrol eder. Bilgisayar
|
||||||
|
bağlantıda kayıtlı sertifikasını kullanır. Eski sürüm 1 QR ve kimliği olmayan
|
||||||
|
elle eşleştirmeler veri bağlantısı açamaz; yeni QR ile yeniden eşleştirme gerekir.
|
||||||
|
Telefon da eşleştirmede kendi sertifikasını oluşturur ve bağlantıda aynı
|
||||||
|
sertifikayı yükler. Kullanıcı telefondaki kimlik belgesini bilgisayardaki
|
||||||
|
ilgili alana aktarır; `phone.approve` belgeyi eşleştirme kimliği ve sunucunun
|
||||||
|
onayladığı cihaz kimliğiyle karşılaştırıp Windows kasasına kaydeder. IDE hem
|
||||||
|
gelen bağlantı listesinde hem `channel.answer` yolunda kayıtlı telefon
|
||||||
|
parmak izini kontrol eder; renderer SDP'yi uygulamadan önce tekrar doğrular.
|
||||||
|
İlk kayıt için yerel açık onay gerekir; mevcut telefon kimliği sessizce
|
||||||
|
değiştirilemez. Telefon kimliğini QR olarak da gösterir. Kullanıcı ekran
|
||||||
|
görüntüsünü kendi bilgisayarına aktararak “Telefon kimliği QR resmi” alanından
|
||||||
|
okutabilir; eşleştirme kimliği ve belge doldurulur, fakat otomatik onay verilmez.
|
||||||
|
Kısa kod ve ayrı onay düğmesi gereklidir; elle belge aktarma yolu da korunur.
|
||||||
|
|
||||||
|
`node scripts/certificate-smoke.mjs`: 12 Eylül 2026'da izole Edge bağlamında
|
||||||
|
yerel WebRTC sertifikasının IndexedDB'ye yazılıp sayfa yenilendikten sonra
|
||||||
|
aynı parmak iziyle geri alınması, geri alınan sertifikayla gerçek DTLS veri
|
||||||
|
aktarımı ve yanlış SDP parmak izinde DTLS'nin `failed` durumuna geçmesi
|
||||||
|
doğrulandı. Test artık gerçek `peer-identity.mjs` modülünü kullanıyor; eşzamanlı
|
||||||
|
dört oluşturma çağrısının tek kimlikte birleşmesi, kayıp kaydın ve yanlış
|
||||||
|
parmak izinin reddi de geçti. Tarayıcı uygulama testi ayrıca QR ile eşlenen
|
||||||
|
bilgisayar yerine farklı sertifikalı SDP geldiğinde görünür kimlik hatası
|
||||||
|
verildiğini ve hiçbir IDE isteği gönderilmediğini doğruladı. Electron IPC testi
|
||||||
|
Windows kasasındaki parmak iziyle sertifikanın pencere yenilemesinden sonra
|
||||||
|
yüklenmesini ve sessiz kimlik değişiminin reddini doğruladı. Bu testlerde portal
|
||||||
|
ve Google gerçek servisleri kullanılmadı; canlı karşılıklı kimlik kabulü değildir.
|
||||||
|
|
||||||
Gerçek IDE yolunun testi, kök dizinden zorunlu test sarmalayıcısıyla çalışır:
|
Gerçek IDE yolunun testi, kök dizinden zorunlu test sarmalayıcısıyla çalışır:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -86,22 +149,37 @@ deterministik sağlayıcı kullanılır. Aynı görev ikinci defa gönderildiği
|
|||||||
geçmişinde yalnız bir kullanıcı görevi oluşur. Gerçek Google/iş modeli kalite
|
geçmişinde yalnız bir kullanıcı görevi oluşur. Gerçek Google/iş modeli kalite
|
||||||
ve maliyet testi olarak sayılmaz.
|
ve maliyet testi olarak sayılmaz.
|
||||||
|
|
||||||
|
12 Eylül 2026, 0.1.4 kaynak doğrulaması: zorunlu `scripts/run_tests.sh`
|
||||||
|
sarmalayıcısıyla `test_compute_host.py`, `test_compute_host_phase1.py` ve
|
||||||
|
`test_mobile_runtime_integration.py` birlikte çalıştırıldı. 3 dosyada 16 test
|
||||||
|
geçti, 0 başarısız; süre 32,9 saniye. Windows venv yönlendirici sürecinin
|
||||||
|
bildirdiği PID, gerçek süreç soy ağacı üzerinden doğrulanır. Bu koşu yalnız
|
||||||
|
yerel sentetik sağlayıcı kullanır, telefon donanımı veya Google kabulü değildir.
|
||||||
|
|
||||||
Henüz tamamlanmayanlar:
|
Henüz tamamlanmayanlar:
|
||||||
|
|
||||||
|
- Karşılıklı cihaz kimliğinin gerçek Android ve paketlenmiş IDE arasında,
|
||||||
|
yeniden başlatma/iptal/anahtar kaybı senaryolarıyla uçtan uca kabulü.
|
||||||
|
İki yönün kodu var; tarayıcı ve Electron testleri gerçek cihaz kabulünün
|
||||||
|
yerine geçmez. Telefon QR çıktısının pikselden geri okunması ve Electron'da
|
||||||
|
QR resminin açık onay vermeden alanlara aktarılması test edildi; gerçek
|
||||||
|
telefon ekran görüntüsü aktarımı henüz denenmedi.
|
||||||
- Fiziksel Android'de kurulum, mikrofon, kesme, Keystore ve arka plan testi.
|
- Fiziksel Android'de kurulum, mikrofon, kesme, Keystore ve arka plan testi.
|
||||||
- Kullanıcıya ait test anahtarıyla Google Live doğrulaması ve Türkçe konuşma
|
- Kullanıcıya ait test anahtarıyla Google Live doğrulaması ve Türkçe konuşma
|
||||||
senaryoları. Sıfır gerçek Google çağrısı yapıldı; ücret oluştuğu iddia edilmiyor.
|
senaryoları. Sıfır gerçek Google çağrısı yapıldı; ücret oluştuğu iddia edilmiyor.
|
||||||
- Canlı gateway/TURN kurulumu, ayrı mobil ve bilgisayar ağlarıyla tam akış.
|
- Canlı gateway/TURN kurulumu, ayrı mobil ve bilgisayar ağlarıyla tam akış.
|
||||||
- QR tarama. Tarayıcı kısa kod girişi kodlandı ve HTTP/DB ile sahte yanıtlı
|
- Giriş ve QR artık kodlandı: 9 HTTP/DB testi, 27 mobil testi, gerçek QR pikseli
|
||||||
tarayıcı arayüz testleri geçti; gerçek Android giriş kabulü henüz yapılmadı.
|
okuyan (hesap/claim yanıtları sahte) tarayıcı akışı geçti. Fiziksel Android
|
||||||
|
kamera/giriş kabulü ve farklı ağ bağlantısı henüz yapılmadı.
|
||||||
- Açık talimatla prepare_task/submit_task devri ve görev kimlikli terminal
|
- Açık talimatla prepare_task/submit_task devri ve görev kimlikli terminal
|
||||||
olayları kodlandı. 25 Node testi ve üç görevli gerçek yerel IDE bağlantı
|
olayları kodlandı. 25 Node testi ve üç görevli gerçek yerel IDE bağlantı
|
||||||
testi geçti. Canlı Google transkript sırası ve Türkçe kalite kabulü henüz yok.
|
testi geçti. Canlı Google transkript sırası ve Türkçe kalite kabulü henüz yok.
|
||||||
- Yeniden bağlantı bounded IDE olay tamponunu okuyarak kaçırılan görev
|
- Yeniden bağlantı bounded IDE olay tamponunu okuyarak kaçırılan görev
|
||||||
sonucunu alır. Tampon kaybı/backend yeniden başlaması durumunda sonuç
|
sonucunu alır. Tampon kaybı/backend yeniden başlaması durumunda sonuç
|
||||||
bilinmiyor kalabilir; iş otomatik tekrar çalıştırılmaz.
|
bilinmiyor kalabilir; iş otomatik tekrar çalıştırılmaz.
|
||||||
- Doğrulanmış maliyet tahmini/ayarlanabilir bütçe. Yerel görüşme süresi ve
|
- Maliyet tahmini/ayarlanabilir oturum sınırı uygulandı: 35 Node testi ve
|
||||||
10 dakikalık oturum sınırı Google kotası veya fatura üst sınırı değildir.
|
derlenmiş uygulamanın gerçek WebRTC, sentetik IDE/Google yanıtlı sınır akışı
|
||||||
|
geçti. Gerçek Google faturasına karşı karşılaştırma henüz yapılmadı.
|
||||||
- Bağımsız güvenlik incelemesi ve sinyalleşme sunucusuna karşı doğrulanmış
|
- Bağımsız güvenlik incelemesi ve sinyalleşme sunucusuna karşı doğrulanmış
|
||||||
kalıcı eş parmak izi. WebRTC standart şifrelemesi vardır; eş kimliğinin
|
kalıcı eş parmak izi. WebRTC standart şifrelemesi vardır; eş kimliğinin
|
||||||
sinyalleşme hizmetine güven sınırı gizlenmez.
|
sinyalleşme hizmetine güven sınırı gizlenmez.
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "online.turkservis.aiturk.mobile"
|
applicationId "online.turkservis.aiturk.mobile"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 2
|
versionCode 5
|
||||||
versionName "0.1.1-dev"
|
versionName "0.1.4-dev"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
|||||||
Generated
+17
-3
@@ -1,15 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "aiturk-mobile",
|
"name": "aiturk-mobile",
|
||||||
"version": "0.1.1",
|
"version": "0.1.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aiturk-mobile",
|
"name": "aiturk-mobile",
|
||||||
"version": "0.1.1",
|
"version": "0.1.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.5.1",
|
"@capacitor/android": "8.5.1",
|
||||||
"@capacitor/core": "8.5.1"
|
"@capacitor/core": "8.5.1",
|
||||||
|
"jsqr": "1.4.0",
|
||||||
|
"qrcode-generator": "2.0.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/cli": "8.5.1"
|
"@capacitor/cli": "8.5.1"
|
||||||
@@ -637,6 +639,12 @@
|
|||||||
"graceful-fs": "^4.1.6"
|
"graceful-fs": "^4.1.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsqr": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/kleur": {
|
"node_modules/kleur": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||||
@@ -827,6 +835,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode-generator": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/readable-stream": {
|
"node_modules/readable-stream": {
|
||||||
"version": "3.6.2",
|
"version": "3.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aiturk-mobile",
|
"name": "aiturk-mobile",
|
||||||
"version": "0.1.1",
|
"version": "0.1.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -8,6 +8,6 @@
|
|||||||
"test": "node --test tests/*.test.mjs",
|
"test": "node --test tests/*.test.mjs",
|
||||||
"android:sync": "npm run build && cap sync android"
|
"android:sync": "npm run build && cap sync android"
|
||||||
},
|
},
|
||||||
"dependencies": { "@capacitor/core": "8.5.1", "@capacitor/android": "8.5.1" },
|
"dependencies": { "@capacitor/core": "8.5.1", "@capacitor/android": "8.5.1", "jsqr": "1.4.0", "qrcode-generator": "2.0.4" },
|
||||||
"devDependencies": { "@capacitor/cli": "8.5.1" }
|
"devDependencies": { "@capacitor/cli": "8.5.1" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
|||||||
const browser = await chromium.launch({ channel: 'msedge', headless: true })
|
const browser = await chromium.launch({ channel: 'msedge', headless: true })
|
||||||
try {
|
try {
|
||||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 1 })
|
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 1 })
|
||||||
|
page.setDefaultTimeout(15000)
|
||||||
const errors = []; page.on('pageerror', error => errors.push(error.message))
|
const errors = []; page.on('pageerror', error => errors.push(error.message))
|
||||||
await page.goto(`http://127.0.0.1:${server.address().port}`)
|
await page.goto(`http://127.0.0.1:${server.address().port}`)
|
||||||
await page.getByRole('heading', { name: 'Fikrini konuş. Birlikte geliştir.' }).waitFor()
|
await page.getByRole('heading', { name: 'Fikrini konuş. Birlikte geliştir.' }).waitFor()
|
||||||
@@ -37,6 +38,111 @@ try {
|
|||||||
await page.getByText('Yalnız kendi hesabınızda bu kodu onaylayın: ABCDEFGH').waitFor()
|
await page.getByText('Yalnız kendi hesabınızda bu kodu onaylayın: ABCDEFGH').waitFor()
|
||||||
await page.getByRole('button', { name: 'Girişi kontrol et' }).click()
|
await page.getByRole('button', { name: 'Girişi kontrol et' }).click()
|
||||||
await page.getByText('Hesaba giriş yapıldı.', { exact: true }).waitFor()
|
await page.getByText('Hesaba giriş yapıldı.', { exact: true }).waitFor()
|
||||||
|
let claimed = false
|
||||||
|
await page.route('https://turkservis.online/v1/aiturk/mobile/invites/**/claim', async route => {
|
||||||
|
claimed = true
|
||||||
|
const body = route.request().postDataJSON(); assert.equal(body.secret, 'a'.repeat(43))
|
||||||
|
await route.fulfill({ status: 201, contentType: 'application/json', headers: { 'Access-Control-Allow-Origin': '*' },
|
||||||
|
body: JSON.stringify({ device_id: '33333333-3333-4333-8333-333333333333', device_token: 'fixture-device-secret', pairing_id: 'fixture-pair', user_code: 'QRSTUVWX' }) })
|
||||||
|
})
|
||||||
|
const qrImage = await page.evaluate(async () => {
|
||||||
|
const { drawQr } = await import('./qr.mjs'), canvas = document.createElement('canvas')
|
||||||
|
window.fixtureDesktopCertificate = await RTCPeerConnection.generateCertificate({ name: 'ECDSA', namedCurve: 'P-256' })
|
||||||
|
const desktopFingerprint = window.fixtureDesktopCertificate.getFingerprints().find(item => item.algorithm === 'sha-256').value
|
||||||
|
drawQr(canvas, JSON.stringify({ kind: 'aiturk-mobile-invite', version: 2, desktopFingerprint, inviteId: '11111111-1111-4111-8111-111111111111',
|
||||||
|
desktopId: '22222222-2222-4222-8222-222222222222', secret: 'a'.repeat(43), expiresAt: new Date(Date.now() + 600000).toISOString() }))
|
||||||
|
return canvas.toDataURL('image/png').split(',')[1]
|
||||||
|
})
|
||||||
|
await page.locator('#qr-file').setInputFiles({ name: 'invite.png', mimeType: 'image/png', buffer: Buffer.from(qrImage, 'base64') })
|
||||||
|
await page.getByText('Bilgisayarda bekleyen telefonu getirip şu kodu onaylayın: QRSTUVWX').waitFor()
|
||||||
|
assert.equal(claimed, true)
|
||||||
|
const phoneQr = await page.evaluate(() => {
|
||||||
|
const canvas = document.getElementById('phone-identity-qr')
|
||||||
|
const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height)
|
||||||
|
return { decoded: jsQR(pixels.data, pixels.width, pixels.height)?.data,
|
||||||
|
expected: document.getElementById('phone-identity').value, visible: !canvas.hidden }
|
||||||
|
})
|
||||||
|
assert.equal(phoneQr.visible, true)
|
||||||
|
assert.equal(phoneQr.decoded, phoneQr.expected)
|
||||||
|
// Run the actual app -> AccountClient -> WebRTC -> VoiceSession wiring.
|
||||||
|
// Portal, IDE responses and Google frames are synthetic; no paid/API traffic.
|
||||||
|
await page.route('https://turkservis.online/v1/aiturk/mobile/devices', route => route.fulfill({
|
||||||
|
json: { devices: [{ id: '22222222-2222-4222-8222-222222222222', name: 'Test bilgisayarı', platform: 'windows', self: false }] } }))
|
||||||
|
await page.route('https://turkservis.online/v1/aiturk/mobile/ice', route => route.fulfill({ json: { iceServers: [] } }))
|
||||||
|
await page.route('https://turkservis.online/v1/aiturk/mobile/channels**', async route => {
|
||||||
|
if (route.request().method() === 'POST' && !route.request().url().endsWith('/heartbeat')) {
|
||||||
|
const { sdp } = route.request().postDataJSON()
|
||||||
|
await page.evaluate(async sdp => {
|
||||||
|
const { completeIce, sendFrame } = await import('./transport.mjs')
|
||||||
|
const { assertRemoteIdentity } = await import('./peer-identity.mjs')
|
||||||
|
const trustedPhone = JSON.parse(document.getElementById('phone-identity').value)
|
||||||
|
assertRemoteIdentity(sdp, trustedPhone.fingerprint)
|
||||||
|
const pc = new RTCPeerConnection({ certificates: [window.fixtureDesktopCertificate] }); window.fixtureDesktop = pc; window.fixtureActions = []
|
||||||
|
pc.ondatachannel = event => { event.channel.onmessage = incoming => {
|
||||||
|
const request = JSON.parse(incoming.data); window.fixtureActions.push(request.action)
|
||||||
|
const results = {
|
||||||
|
'sessions.list': { sessions: [{ id: 'fixture-session', title: 'Test işi', model: 'fixture-model' }] },
|
||||||
|
'session.open': { model: 'fixture-model', messages: [], approval: null, running: false },
|
||||||
|
'voice.token': { token: 'fixture-only', model: 'gemini-3.1-flash-live-preview' },
|
||||||
|
}
|
||||||
|
sendFrame(event.channel, { id: request.id, result: results[request.action] || {} })
|
||||||
|
} }
|
||||||
|
await pc.setRemoteDescription({ type: 'offer', sdp })
|
||||||
|
await pc.setLocalDescription(await pc.createAnswer()); await completeIce(pc)
|
||||||
|
window.fixtureAnswer = pc.localDescription.sdp
|
||||||
|
}, sdp)
|
||||||
|
await route.fulfill({ json: {} })
|
||||||
|
} else await route.fulfill({ json: { sdp: await page.evaluate(() => window.fixtureAnswer) } })
|
||||||
|
})
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.fixtureVoiceSockets = []
|
||||||
|
window.WebSocket = class {
|
||||||
|
constructor(url) {
|
||||||
|
if (!url.startsWith('wss://generativelanguage.googleapis.com/')) throw new Error('Unexpected test socket')
|
||||||
|
this.readyState = 1; this.bufferedAmount = 0; window.fixtureVoiceSockets.push(this)
|
||||||
|
queueMicrotask(() => this.onopen?.())
|
||||||
|
}
|
||||||
|
send(raw) {
|
||||||
|
if (JSON.parse(raw).setup) queueMicrotask(() => this.onmessage?.({ data: JSON.stringify({ setupComplete: {} }) }))
|
||||||
|
}
|
||||||
|
close() { this.readyState = 3; this.onclose?.() }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await page.getByRole('button', { name: 'Onaydan sonra bilgisayarları bul' }).click()
|
||||||
|
await page.getByRole('button', { name: 'Bilgisayara bağlan', exact: true }).click()
|
||||||
|
await page.getByText('Bilgisayar bağlı. IDE konuşmasını seçin.', { exact: true }).waitFor()
|
||||||
|
await page.getByRole('button', { name: 'Konuşmaları yenile' }).click()
|
||||||
|
await page.getByRole('button', { name: 'Bu konuşmayı seç', exact: true }).click()
|
||||||
|
await page.getByText('IDE konuşması açıldı.', { exact: true }).waitFor()
|
||||||
|
await page.locator('#consent').check()
|
||||||
|
await page.locator('#voice-budget').fill('0.01')
|
||||||
|
await page.locator('#voice-minutes').fill('3')
|
||||||
|
await page.getByRole('button', { name: 'Asistanla yazış', exact: true }).click()
|
||||||
|
await page.getByText('Google asistanı bağlı.', { exact: true }).waitFor()
|
||||||
|
assert.match(await page.locator('#usage').textContent(), /henüz bilinmiyor/)
|
||||||
|
await page.evaluate(() => window.fixtureVoiceSockets[0].onmessage({ data: JSON.stringify({ usageMetadata: {
|
||||||
|
promptTokenCount: 1000, responseTokenCount: 1000, totalTokenCount: 2000,
|
||||||
|
promptTokensDetails: [{ modality: 'AUDIO', tokenCount: 1000 }],
|
||||||
|
responseTokensDetails: [{ modality: 'AUDIO', tokenCount: 1000 }],
|
||||||
|
} }) }))
|
||||||
|
await page.locator('#voice-status').filter({ hasText: 'maliyet sınırına ulaşıldı' }).waitFor()
|
||||||
|
assert.match(await page.locator('#usage').textContent(), /0\.0150–0\.0150 USD/)
|
||||||
|
assert.equal(await page.evaluate(() => window.fixtureVoiceSockets[0].readyState), 3)
|
||||||
|
assert.deepEqual(await page.evaluate(() => window.fixtureActions), ['sessions.list', 'session.open', 'voice.token'])
|
||||||
|
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('aiturk.voice-limits'))), { minutes: 3, usd: 0.01 })
|
||||||
|
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false)
|
||||||
|
await mkdir('test-results', { recursive: true })
|
||||||
|
await page.screenshot({ path: 'test-results/android-voice-limit.png', fullPage: true })
|
||||||
|
await page.evaluate(() => window.fixtureDesktop.close())
|
||||||
|
// A signaling service returning another machine's valid SDP must not replace
|
||||||
|
// the identity captured from the user's QR or receive any IDE requests.
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
window.fixtureDesktopCertificate = await RTCPeerConnection.generateCertificate({ name: 'ECDSA', namedCurve: 'P-256' })
|
||||||
|
})
|
||||||
|
await page.getByRole('button', { name: 'Bilgisayara bağlan', exact: true }).click()
|
||||||
|
await page.locator('#status').filter({ hasText: 'Cihaz kimliği doğrulanamadı' }).waitFor()
|
||||||
|
assert.deepEqual(await page.evaluate(() => window.fixtureActions), [])
|
||||||
|
await page.evaluate(() => window.fixtureDesktop.close())
|
||||||
await page.getByRole('button', { name: 'Bu uygulamadaki hesaptan çık' }).click()
|
await page.getByRole('button', { name: 'Bu uygulamadaki hesaptan çık' }).click()
|
||||||
await page.getByText('Bu uygulamanın oturumu kapatıldı.', { exact: false }).waitFor()
|
await page.getByText('Bu uygulamanın oturumu kapatıldı.', { exact: false }).waitFor()
|
||||||
assert.deepEqual(loginCalls, ['start', 'poll', 'logout'])
|
assert.deepEqual(loginCalls, ['start', 'poll', 'logout'])
|
||||||
@@ -67,5 +173,6 @@ try {
|
|||||||
})
|
})
|
||||||
assert.equal(result.length, result.expected); assert.equal(result.dtls, 'connected'); assert.equal(result.action, 'session.history')
|
assert.equal(result.length, result.expected); assert.equal(result.dtls, 'connected'); assert.equal(result.action, 'session.history')
|
||||||
assert.deepEqual(errors, [])
|
assert.deepEqual(errors, [])
|
||||||
console.log(JSON.stringify({ onboarding: 'passed', mockedLoginUi: 'passed', realWebRTC: result, errors }))
|
console.log(JSON.stringify({ onboarding: 'passed', mockedLoginUi: 'passed', realQrImageWithMockedClaim: 'passed',
|
||||||
|
appWebRTCWithSyntheticGoogleUsageLimit: 'passed', qrPinnedDesktopReplacement: 'rejected', realWebRTC: result, errors }))
|
||||||
} finally { await browser.close(); server.close() }
|
} finally { await browser.close(); server.close() }
|
||||||
|
|||||||
@@ -5,4 +5,8 @@ await mkdir(new URL('../dist', import.meta.url), { recursive: true })
|
|||||||
await cp(new URL('../src', import.meta.url), new URL('../dist', import.meta.url), { recursive: true })
|
await cp(new URL('../src', import.meta.url), new URL('../dist', import.meta.url), { recursive: true })
|
||||||
const core = dirname(createRequire(import.meta.url).resolve('@capacitor/core/package.json'))
|
const core = dirname(createRequire(import.meta.url).resolve('@capacitor/core/package.json'))
|
||||||
await cp(join(core, 'dist', 'capacitor.js'), new URL('../dist/capacitor.js', import.meta.url))
|
await cp(join(core, 'dist', 'capacitor.js'), new URL('../dist/capacitor.js', import.meta.url))
|
||||||
|
await mkdir(new URL('../dist/vendor', import.meta.url), { recursive: true })
|
||||||
|
await cp(new URL('../node_modules/qrcode-generator/dist/qrcode.mjs', import.meta.url), new URL('../dist/vendor/qrcode.mjs', import.meta.url))
|
||||||
|
await cp(new URL('../node_modules/jsqr/dist/jsQR.js', import.meta.url), new URL('../dist/vendor/jsQR.js', import.meta.url))
|
||||||
|
await cp(new URL('../node_modules/jsqr/LICENSE', import.meta.url), new URL('../dist/vendor/jsQR-LICENSE.txt', import.meta.url))
|
||||||
console.log('AITURK Android web assets built.')
|
console.log('AITURK Android web assets built.')
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Real identity-module + native DTLS test; full app pairing is tested separately.
|
||||||
|
// Synthetic local peers, isolated browser context, no account or Google requests.
|
||||||
|
import { chromium } from 'playwright'
|
||||||
|
import { createServer } from 'node:http'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
const server = createServer(async (request, response) => {
|
||||||
|
if (request.url === '/peer-identity.mjs') {
|
||||||
|
try {
|
||||||
|
const source = await readFile(new URL('../src/peer-identity.mjs', import.meta.url))
|
||||||
|
response.writeHead(200, { 'Content-Type': 'text/javascript' }); response.end(source)
|
||||||
|
} catch { response.writeHead(500); response.end() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.writeHead(200, { 'Content-Type': 'text/html' })
|
||||||
|
response.end('<!doctype html><title>Local certificate test</title>')
|
||||||
|
})
|
||||||
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||||
|
let browser
|
||||||
|
try {
|
||||||
|
browser = await chromium.launch({ channel: 'msedge', headless: true })
|
||||||
|
const page = await browser.newPage()
|
||||||
|
page.setDefaultTimeout(20000)
|
||||||
|
await page.goto(`http://127.0.0.1:${server.address().port}`)
|
||||||
|
const original = await page.evaluate(async () => {
|
||||||
|
const { createDeviceIdentity } = await import('/peer-identity.mjs')
|
||||||
|
const results = await Promise.all(Array.from({ length: 4 }, () => createDeviceIdentity('11111111-1111-4111-8111-111111111111')))
|
||||||
|
if (new Set(results.map(value => value.fingerprint)).size !== 1) throw new Error('Concurrent identity creation diverged')
|
||||||
|
const { certificate, fingerprint } = results[0]
|
||||||
|
return { fingerprints: certificate.getFingerprints(), fingerprint, expires: certificate.expires }
|
||||||
|
})
|
||||||
|
await page.reload()
|
||||||
|
const result = await page.evaluate(async expected => {
|
||||||
|
const { loadDeviceIdentity, assertRemoteIdentity } = await import('/peer-identity.mjs')
|
||||||
|
const { certificate } = await loadDeviceIdentity('11111111-1111-4111-8111-111111111111', expected)
|
||||||
|
let missingRejected = false, wrongPinRejected = false
|
||||||
|
try { await loadDeviceIdentity('22222222-2222-4222-8222-222222222222', expected) } catch { missingRejected = true }
|
||||||
|
try { await loadDeviceIdentity('11111111-1111-4111-8111-111111111111', Array(32).fill('00').join(':')) } catch { wrongPinRejected = true }
|
||||||
|
const peers = []
|
||||||
|
const complete = async pc => {
|
||||||
|
if (pc.iceGatheringState === 'complete') return
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => { cleanup(); reject(new Error('ICE timeout')) }, 10000)
|
||||||
|
const check = () => { if (pc.iceGatheringState === 'complete') { cleanup(); resolve() } }
|
||||||
|
const cleanup = () => { clearTimeout(timeout); pc.removeEventListener('icegatheringstatechange', check) }
|
||||||
|
pc.addEventListener('icegatheringstatechange', check); check()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const phone = new RTCPeerConnection({ iceServers: [] })
|
||||||
|
const desktop = new RTCPeerConnection({ iceServers: [], certificates: [certificate] })
|
||||||
|
peers.push(phone, desktop)
|
||||||
|
const channel = phone.createDataChannel('certificate-probe')
|
||||||
|
desktop.ondatachannel = event => { event.channel.onmessage = message => event.channel.send(message.data) }
|
||||||
|
await phone.setLocalDescription(await phone.createOffer()); await complete(phone)
|
||||||
|
await desktop.setRemoteDescription(phone.localDescription)
|
||||||
|
await desktop.setLocalDescription(await desktop.createAnswer()); await complete(desktop)
|
||||||
|
const advertised = desktop.localDescription.sdp.split(/\r?\n/).filter(line => line.startsWith('a=fingerprint:'))
|
||||||
|
assertRemoteIdentity(desktop.localDescription.sdp, expected)
|
||||||
|
await phone.setRemoteDescription(desktop.localDescription)
|
||||||
|
const echo = await new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => reject(new Error('DTLS echo timeout')), 15000)
|
||||||
|
channel.onmessage = event => { clearTimeout(timeout); resolve(event.data) }
|
||||||
|
channel.onopen = () => channel.send('Türkçe kimlik doğrulama 🟢')
|
||||||
|
if (channel.readyState === 'open') channel.onopen()
|
||||||
|
})
|
||||||
|
const goodDtls = phone.sctp.transport.state
|
||||||
|
const wrongCertificate = await RTCPeerConnection.generateCertificate({ name: 'ECDSA', namedCurve: 'P-256' })
|
||||||
|
const wrongFingerprint = wrongCertificate.getFingerprints().find(item => item.algorithm === 'sha-256').value.toUpperCase()
|
||||||
|
let untrustedPeerRejected = false
|
||||||
|
try { assertRemoteIdentity(desktop.localDescription.sdp, wrongFingerprint) } catch { untrustedPeerRejected = true }
|
||||||
|
const badPhone = new RTCPeerConnection({ iceServers: [] })
|
||||||
|
const badDesktop = new RTCPeerConnection({ iceServers: [], certificates: [certificate] })
|
||||||
|
peers.push(badPhone, badDesktop)
|
||||||
|
const badChannel = badPhone.createDataChannel('certificate-mismatch')
|
||||||
|
let mismatchOpened = false
|
||||||
|
badChannel.onopen = () => { mismatchOpened = true }
|
||||||
|
await badPhone.setLocalDescription(await badPhone.createOffer()); await complete(badPhone)
|
||||||
|
await badDesktop.setRemoteDescription(badPhone.localDescription)
|
||||||
|
await badDesktop.setLocalDescription(await badDesktop.createAnswer()); await complete(badDesktop)
|
||||||
|
await badPhone.setRemoteDescription({ type: 'answer', sdp: badDesktop.localDescription.sdp
|
||||||
|
.split(/\r?\n/).map(line => line.startsWith('a=fingerprint:sha-256 ')
|
||||||
|
? `a=fingerprint:sha-256 ${wrongFingerprint}` : line).join('\r\n') })
|
||||||
|
const mismatchState = await new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => { cleanup(); reject(new Error('No definitive certificate rejection')) }, 15000)
|
||||||
|
const check = () => {
|
||||||
|
const state = badPhone.sctp?.transport.state
|
||||||
|
if (state === 'failed' || state === 'connected') { cleanup(); resolve(state) }
|
||||||
|
}
|
||||||
|
const cleanup = () => { clearTimeout(timeout); badPhone.removeEventListener('connectionstatechange', check) }
|
||||||
|
badPhone.addEventListener('connectionstatechange', check); check()
|
||||||
|
})
|
||||||
|
return { fingerprints: certificate.getFingerprints(), expires: certificate.expires,
|
||||||
|
nativeCertificate: certificate instanceof RTCCertificate, advertised, echo,
|
||||||
|
dtls: goodDtls, mismatchState, mismatchOpened, missingRejected, wrongPinRejected, untrustedPeerRejected }
|
||||||
|
} finally { for (const peer of peers) peer.close() }
|
||||||
|
}, original.fingerprint)
|
||||||
|
assert.equal(result.nativeCertificate, true)
|
||||||
|
assert.deepEqual(result.fingerprints, original.fingerprints)
|
||||||
|
assert.equal(result.expires, original.expires)
|
||||||
|
assert.ok(result.expires > Date.now())
|
||||||
|
const sha256 = original.fingerprints.find(item => item.algorithm === 'sha-256')
|
||||||
|
assert.ok(sha256)
|
||||||
|
assert.ok(result.advertised.some(line => line.toLowerCase() === `a=fingerprint:sha-256 ${sha256.value}`.toLowerCase()))
|
||||||
|
assert.equal(result.echo, 'Türkçe kimlik doğrulama 🟢')
|
||||||
|
assert.equal(result.dtls, 'connected')
|
||||||
|
assert.equal(result.mismatchState, 'failed')
|
||||||
|
assert.equal(result.mismatchOpened, false)
|
||||||
|
assert.equal(result.missingRejected, true)
|
||||||
|
assert.equal(result.wrongPinRejected, true)
|
||||||
|
assert.equal(result.untrustedPeerRejected, true)
|
||||||
|
console.log(JSON.stringify({ nativeCertificatePersistence: 'passed', reload: 'passed',
|
||||||
|
restoredCertificateDtlsEcho: 'passed', nativeFingerprintMismatch: 'rejected',
|
||||||
|
concurrentCreation: 'passed', missingKeyAndWrongPin: 'rejected', scope: 'identity module and native DTLS; not full app pairing' }))
|
||||||
|
} finally {
|
||||||
|
await browser?.close()
|
||||||
|
server.close()
|
||||||
|
}
|
||||||
@@ -25,8 +25,74 @@ try {
|
|||||||
assert.ok(!blob.includes(Buffer.from('local-fake-account-for-encryption-test')))
|
assert.ok(!blob.includes(Buffer.from('local-fake-account-for-encryption-test')))
|
||||||
const vault = new MobileVault(join(directory, 'mobile'), safeStorage)
|
const vault = new MobileVault(join(directory, 'mobile'), safeStorage)
|
||||||
assert.equal((await vault.load()).accountToken, 'local-fake-account-for-encryption-test')
|
assert.equal((await vault.load()).accountToken, 'local-fake-account-for-encryption-test')
|
||||||
|
await vault.save({ ...await vault.load(), deviceId: '11111111-1111-4111-8111-111111111111' })
|
||||||
|
const fingerprint = await window.webContents.executeJavaScript(`(async () => {
|
||||||
|
const { createDeviceIdentity } = await import('./mobile/peer-identity.mjs');
|
||||||
|
const setup = await mobileHost.call('setup');
|
||||||
|
const identity = await createDeviceIdentity(setup.deviceId);
|
||||||
|
await mobileHost.call('identity.save', {deviceId:setup.deviceId, fingerprint:identity.fingerprint});
|
||||||
|
let rejected = false;
|
||||||
|
try { await mobileHost.call('identity.save', {deviceId:setup.deviceId, fingerprint:Array(32).fill('00').join(':')}); }
|
||||||
|
catch { rejected = true; }
|
||||||
|
if (!rejected) throw new Error('Changed identity silently accepted');
|
||||||
|
return identity.fingerprint;
|
||||||
|
})()`)
|
||||||
|
assert.equal((await vault.load()).localFingerprint, fingerprint)
|
||||||
|
const reloaded = new Promise(resolve => window.webContents.once('did-finish-load', resolve))
|
||||||
|
window.webContents.reload(); await reloaded
|
||||||
|
const restored = await window.webContents.executeJavaScript(`(async () => {
|
||||||
|
const { loadDeviceIdentity } = await import('./mobile/peer-identity.mjs');
|
||||||
|
const setup = await mobileHost.call('setup');
|
||||||
|
const identity = await loadDeviceIdentity(setup.deviceId, setup.localFingerprint);
|
||||||
|
return identity.fingerprint;
|
||||||
|
})()`)
|
||||||
|
assert.equal(restored, fingerprint)
|
||||||
|
const originalFetch = globalThis.fetch, phoneId = '33333333-3333-4333-8333-333333333333'
|
||||||
|
const pairingId = '44444444-4444-4444-8444-444444444444', phonePin = Array(32).fill('AB').join(':')
|
||||||
|
let advertisedPin = phonePin, approvals = 0
|
||||||
|
globalThis.fetch = async (url, options) => {
|
||||||
|
const path = new URL(url).pathname
|
||||||
|
if (path === `/v1/aiturk/pairings/${pairingId}/approve` && options.method === 'POST') {
|
||||||
|
approvals++
|
||||||
|
return Response.json({ device_id: phoneId, status: 'approved' })
|
||||||
|
}
|
||||||
|
if (path === '/v1/aiturk/mobile/channels') return Response.json({ channels: [{ id: 'fixture-channel',
|
||||||
|
phone_id: phoneId, sdp: `v=0\r\na=fingerprint:sha-256 ${advertisedPin}\r\n` }] })
|
||||||
|
throw new Error('Unexpected network request in isolated test')
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const proof = JSON.stringify({ kind: 'aiturk-phone-identity', version: 1, pairingId, deviceId: phoneId, fingerprint: phonePin })
|
||||||
|
const qrImport = await window.webContents.executeJavaScript(`(async () => {
|
||||||
|
const { drawQr } = await import('./mobile/qr.mjs');
|
||||||
|
const canvas = document.createElement('canvas'); drawQr(canvas, ${JSON.stringify(proof)});
|
||||||
|
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
|
||||||
|
const transfer = new DataTransfer(); transfer.items.add(new File([blob], 'phone-identity.png', {type:'image/png'}));
|
||||||
|
const input = document.getElementById('phone-identity-file'); input.files = transfer.files;
|
||||||
|
await input.onchange({target:input});
|
||||||
|
return {pairingId:document.getElementById('pairing').value, proof:document.getElementById('phone-identity').value,
|
||||||
|
status:document.getElementById('status').textContent};
|
||||||
|
})()`)
|
||||||
|
assert.equal(qrImport.pairingId, pairingId)
|
||||||
|
assert.equal(qrImport.proof, proof)
|
||||||
|
assert.match(qrImport.status, /henüz izin verilmedi/)
|
||||||
|
assert.equal(approvals, 0)
|
||||||
|
const accepted = await window.webContents.executeJavaScript(`mobileHost.call('phone.approve', ${JSON.stringify({ pairingId, code: 'QRSTUVWX', identity: proof })})`)
|
||||||
|
assert.equal(accepted.device_id, phoneId)
|
||||||
|
assert.equal((await vault.load()).phoneFingerprints[phoneId], phonePin)
|
||||||
|
const inbox = await window.webContents.executeJavaScript(`mobileHost.call('inbox')`)
|
||||||
|
assert.equal(inbox.channels[0].trustedPhoneFingerprint, phonePin)
|
||||||
|
advertisedPin = Array(32).fill('CD').join(':')
|
||||||
|
await assert.rejects(window.webContents.executeJavaScript(`mobileHost.call('inbox')`), /kimliği/)
|
||||||
|
await assert.rejects(window.webContents.executeJavaScript(`mobileHost.call('channel.answer', {channelId:'fixture-channel',sdp:'unused'})`), /kimliği/)
|
||||||
|
const changed = JSON.stringify({ kind: 'aiturk-phone-identity', version: 1, pairingId, deviceId: phoneId, fingerprint: advertisedPin })
|
||||||
|
await assert.rejects(window.webContents.executeJavaScript(`mobileHost.call('phone.approve', ${JSON.stringify({ pairingId, code: 'QRSTUVWX', identity: changed })})`), /kimliği/)
|
||||||
|
assert.equal(approvals, 1)
|
||||||
|
assert.equal((await vault.load()).phoneFingerprints[phoneId], phonePin)
|
||||||
|
} finally { globalThis.fetch = originalFetch }
|
||||||
await vault.save({})
|
await vault.save({})
|
||||||
const receipt = { realElectronIpc: 'passed', windowsSafeStorage: 'passed', heading: result.heading, checkedAt: new Date().toISOString() }
|
const receipt = { realElectronIpc: 'passed', windowsSafeStorage: 'passed', desktopIdentityReload: 'passed',
|
||||||
|
silentIdentityReplacement: 'rejected', locallyApprovedPhonePin: 'passed',
|
||||||
|
changedPhoneBeforeAnswer: 'rejected', identityQrImportWithoutApproval: 'passed', heading: result.heading, checkedAt: new Date().toISOString() }
|
||||||
await writeFile('apps/mobile/test-results/electron-smoke.json', JSON.stringify(receipt))
|
await writeFile('apps/mobile/test-results/electron-smoke.json', JSON.stringify(receipt))
|
||||||
console.log(JSON.stringify(receipt))
|
console.log(JSON.stringify(receipt))
|
||||||
window.destroy(); app.exit(0)
|
window.destroy(); app.exit(0)
|
||||||
|
|||||||
+53
-9
@@ -1,14 +1,45 @@
|
|||||||
import { AccountClient, openPhoneConnection } from './transport.mjs'
|
import { AccountClient, openPhoneConnection } from './transport.mjs'
|
||||||
import { VoiceSession } from './voice.mjs'
|
import { VoiceSession } from './voice.mjs'
|
||||||
|
import { DEFAULT_VOICE_LIMITS, voiceLimits, usageLabel } from './voice-usage.mjs'
|
||||||
import { taskState } from './behavior.mjs'
|
import { taskState } from './behavior.mjs'
|
||||||
|
import { parseInvite } from './invite.mjs'
|
||||||
|
import { InviteScanner, drawQr } from './qr.mjs'
|
||||||
|
import { createDeviceIdentity } from './peer-identity.mjs'
|
||||||
const $ = id => document.getElementById(id)
|
const $ = id => document.getElementById(id)
|
||||||
if (globalThis.Capacitor?.isNativePlatform()) globalThis.Capacitor.registerPlugin('MobileSecrets')
|
if (globalThis.Capacitor?.isNativePlatform()) globalThis.Capacitor.registerPlugin('MobileSecrets')
|
||||||
let credentials = {}, account, connection, selected, approval, voice, pendingTask, usageTimer, voiceGeneration = 0
|
let credentials = {}, account, connection, selected, approval, voice, pendingTask, voiceGeneration = 0
|
||||||
let connectedDesktopId = null
|
let connectedDesktopId = null
|
||||||
let connectionEpoch = 0, selectionEpoch = 0
|
let connectionEpoch = 0, selectionEpoch = 0
|
||||||
const status = text => { $('status').textContent = text }
|
const status = text => { $('status').textContent = text }
|
||||||
const native = () => window.Capacitor?.Plugins?.MobileSecrets
|
const native = () => window.Capacitor?.Plugins?.MobileSecrets
|
||||||
|
const scanner = new InviteScanner($('qr-video'), async raw => {
|
||||||
|
if (!account || credentials.deviceId) throw new Error('Önce giriş yapın; kayıtlı cihaz varsa önce mevcut eşleşmeyi kontrol edin.')
|
||||||
|
const targetAccount = account, epoch = connectionEpoch
|
||||||
|
const invite = parseInvite(raw)
|
||||||
|
const result = await account.call(`/v1/aiturk/mobile/invites/${invite.inviteId}/claim`, {
|
||||||
|
secret: invite.secret, display_name: $('device-name').value.trim() })
|
||||||
|
if (account !== targetAccount || epoch !== connectionEpoch) return
|
||||||
|
const identity = await createDeviceIdentity(result.device_id)
|
||||||
|
if (account !== targetAccount || epoch !== connectionEpoch) return
|
||||||
|
Object.assign(credentials, { deviceId: result.device_id, deviceToken: result.device_token,
|
||||||
|
pairingId: result.pairing_id, preferredDesktopId: invite.desktopId,
|
||||||
|
desktopFingerprints: { [invite.desktopId]: invite.desktopFingerprint }, localFingerprint: identity.fingerprint })
|
||||||
|
await saveCredentials()
|
||||||
|
showPhoneIdentity()
|
||||||
|
$('pair-code').textContent = `Bilgisayarda bekleyen telefonu getirip şu kodu onaylayın: ${result.user_code}`
|
||||||
|
status('QR okundu. Bilgisayardaki açık telefon onayı bekleniyor.')
|
||||||
|
})
|
||||||
|
scanner.onError = error => status(error.message)
|
||||||
|
action('scan-invite', async () => { if (!account || credentials.deviceId) throw new Error('Önce giriş yapın; bu telefon henüz eşleştirilmemiş olmalı.'); await scanner.start() })
|
||||||
|
action('stop-scan', async () => scanner.stop())
|
||||||
|
$('qr-file').onchange = async event => { try { await scanner.image(event.target.files[0]) } catch (error) { status(error.message) } finally { event.target.value = '' } }
|
||||||
async function saveCredentials() { if (native()) await native().save({ value: JSON.stringify(credentials) }) }
|
async function saveCredentials() { if (native()) await native().save({ value: JSON.stringify(credentials) }) }
|
||||||
|
function showPhoneIdentity() {
|
||||||
|
$('phone-identity').value = credentials.localFingerprint ? JSON.stringify({ kind: 'aiturk-phone-identity', version: 1,
|
||||||
|
deviceId: credentials.deviceId, pairingId: credentials.pairingId, fingerprint: credentials.localFingerprint }) : ''
|
||||||
|
$('phone-identity-qr').hidden = !$('phone-identity').value
|
||||||
|
if ($('phone-identity').value) drawQr($('phone-identity-qr'), $('phone-identity').value)
|
||||||
|
}
|
||||||
function addText(container, role, text) { const p = document.createElement('p'); p.textContent = `${role}: ${text}`; $(container).append(p); while ($(container).children.length > 150) $(container).firstChild.remove() }
|
function addText(container, role, text) { const p = document.createElement('p'); p.textContent = `${role}: ${text}`; $(container).append(p); while ($(container).children.length > 150) $(container).firstChild.remove() }
|
||||||
function action(id, work) { $(id).onclick = async () => { $(id).disabled = true; try { await work() } catch (error) { status(error.message) } finally { $(id).disabled = false } } }
|
function action(id, work) { $(id).onclick = async () => { $(id).disabled = true; try { await work() } catch (error) { status(error.message) } finally { $(id).disabled = false } } }
|
||||||
function rpc(action, params) { if (!connection) throw new Error('Önce bilgisayara bağlanın.'); return connection.requests.call(action, params) }
|
function rpc(action, params) { if (!connection) throw new Error('Önce bilgisayara bağlanın.'); return connection.requests.call(action, params) }
|
||||||
@@ -41,6 +72,7 @@ action('login-start', async () => {
|
|||||||
status('Tarayıcıda giriş yaptıktan sonra buraya dönüp girişi kontrol edin.')
|
status('Tarayıcıda giriş yaptıktan sonra buraya dönüp girişi kontrol edin.')
|
||||||
})
|
})
|
||||||
action('login-check', async () => {
|
action('login-check', async () => {
|
||||||
|
if (credentials.accountToken) throw new Error('Hesaba zaten giriş yapılmış; mevcut hesap değiştirilmeyecek.')
|
||||||
if (!credentials.pendingLogin) throw new Error('Önce giriş başlatın.')
|
if (!credentials.pendingLogin) throw new Error('Önce giriş başlatın.')
|
||||||
const result = await new AccountClient({ accountToken: credentials.pendingLogin.deviceCode }).call('/v1/aiturk/mobile-auth/poll', {})
|
const result = await new AccountClient({ accountToken: credentials.pendingLogin.deviceCode }).call('/v1/aiturk/mobile-auth/poll', {})
|
||||||
if (result.state !== 'approved') { status('Tarayıcıdaki kod onayı bekleniyor.'); return }
|
if (result.state !== 'approved') { status('Tarayıcıdaki kod onayı bekleniyor.'); return }
|
||||||
@@ -49,12 +81,14 @@ action('login-check', async () => {
|
|||||||
status('Hesap bağlı. Şimdi telefonun eşleştirme kodunu oluşturun.')
|
status('Hesap bağlı. Şimdi telefonun eşleştirme kodunu oluşturun.')
|
||||||
})
|
})
|
||||||
action('logout', async () => {
|
action('logout', async () => {
|
||||||
|
scanner.stop()
|
||||||
const token = credentials.accountToken || credentials.pendingLogin?.deviceCode
|
const token = credentials.accountToken || credentials.pendingLogin?.deviceCode
|
||||||
// Revoke the app-specific credential before dropping the local copy. A
|
// Revoke the app-specific credential before dropping the local copy. A
|
||||||
// network failure leaves it available for an explicit retry.
|
// network failure leaves it available for an explicit retry.
|
||||||
if (token?.startsWith('tsml_')) await new AccountClient({ accountToken: token }).call('/v1/aiturk/mobile-auth/logout', {})
|
if (token?.startsWith('tsml_')) await new AccountClient({ accountToken: token }).call('/v1/aiturk/mobile-auth/logout', {})
|
||||||
connectionEpoch++; stopVoice(); connection?.close(); connection = null; selected = null; account = null
|
connectionEpoch++; stopVoice(); connection?.close(); connection = null; selected = null; account = null
|
||||||
credentials = {}; pendingTask = null; await saveCredentials()
|
credentials = {}; pendingTask = null; await saveCredentials()
|
||||||
|
showPhoneIdentity()
|
||||||
$('work').hidden = true; $('login-link').hidden = true; $('login-code').textContent = ''; $('pair-code').textContent = ''
|
$('work').hidden = true; $('login-link').hidden = true; $('login-code').textContent = ''; $('pair-code').textContent = ''
|
||||||
status('Bu uygulamanın oturumu kapatıldı. IDE’de başlamış iş kendi çalışma kurallarıyla devam edebilir.')
|
status('Bu uygulamanın oturumu kapatıldı. IDE’de başlamış iş kendi çalışma kurallarıyla devam edebilir.')
|
||||||
})
|
})
|
||||||
@@ -66,8 +100,11 @@ action('pair', async () => {
|
|||||||
if (!credentials.accountToken) throw new Error('Önce AITURK hesabınıza giriş yapın.')
|
if (!credentials.accountToken) throw new Error('Önce AITURK hesabınıza giriş yapın.')
|
||||||
account = new AccountClient(credentials)
|
account = new AccountClient(credentials)
|
||||||
const result = await account.call('/v1/aiturk/pairings', { display_name: $('device-name').value.trim(), platform: 'android' })
|
const result = await account.call('/v1/aiturk/pairings', { display_name: $('device-name').value.trim(), platform: 'android' })
|
||||||
Object.assign(credentials, { deviceId: result.device_id, deviceToken: result.device_token, pairingId: result.pairing_id })
|
const identity = await createDeviceIdentity(result.device_id)
|
||||||
|
Object.assign(credentials, { deviceId: result.device_id, deviceToken: result.device_token, pairingId: result.pairing_id,
|
||||||
|
localFingerprint: identity.fingerprint })
|
||||||
await saveCredentials()
|
await saveCredentials()
|
||||||
|
showPhoneIdentity()
|
||||||
$('pair-code').textContent = `Eşleştirme kimliği:\n${result.pairing_id}\n\nKod: ${result.user_code}\nSon geçerlilik: ${result.expires_at}`
|
$('pair-code').textContent = `Eşleştirme kimliği:\n${result.pairing_id}\n\nKod: ${result.user_code}\nSon geçerlilik: ${result.expires_at}`
|
||||||
status('Bilgisayardaki açık onay bekleniyor.')
|
status('Bilgisayardaki açık onay bekleniyor.')
|
||||||
})
|
})
|
||||||
@@ -109,14 +146,18 @@ action('select-session', async () => {
|
|||||||
for (const [id, choice] of [['allow', 'once'], ['deny', 'deny']]) action(id, async () => { const target = requireSession(); await rpc('approval.respond', { sessionId: target.id, requestId: approval?.requestId, choice }); setApproval(null); status('Yanıt gerçek IDE onayına iletildi.') })
|
for (const [id, choice] of [['allow', 'once'], ['deny', 'deny']]) action(id, async () => { const target = requireSession(); await rpc('approval.respond', { sessionId: target.id, requestId: approval?.requestId, choice }); setApproval(null); status('Yanıt gerçek IDE onayına iletildi.') })
|
||||||
action('save-google', async () => { const key = $('google-key').value.trim(); $('google-key').value = ''; await rpc('google.key.save', { key }); status('Google anahtarı bilgisayarın güvenli kasasına kaydedildi.') })
|
action('save-google', async () => { const key = $('google-key').value.trim(); $('google-key').value = ''; await rpc('google.key.save', { key }); status('Google anahtarı bilgisayarın güvenli kasasına kaydedildi.') })
|
||||||
action('delete-google', async () => { stopVoice(); await rpc('google.key.delete'); status('Google anahtarı bilgisayar kasasından silindi.') })
|
action('delete-google', async () => { stopVoice(); await rpc('google.key.delete'); status('Google anahtarı bilgisayar kasasından silindi.') })
|
||||||
function stopVoice() { voiceGeneration++; voice?.stop(); voice = null; clearInterval(usageTimer); $('voice-status').textContent = 'Mikrofon kapalı. IDE işi devam edebilir.' }
|
function stopVoice() { voiceGeneration++; voice?.stop(); voice = null; $('voice-status').textContent = 'Mikrofon kapalı. IDE işi devam edebilir.' }
|
||||||
async function startVoice(microphone) {
|
async function startVoice(microphone) {
|
||||||
if (!$('consent').checked) throw new Error('Önce Google veri ve ücret bilgisini okuyup kabul edin.')
|
if (!$('consent').checked) throw new Error('Önce Google veri ve ücret bilgisini okuyup kabul edin.')
|
||||||
|
const limits = voiceLimits({ minutes: $('voice-minutes').value, usd: $('voice-budget').value })
|
||||||
|
// Only non-secret preferences; account and API credentials remain in the
|
||||||
|
// native vault. No usage history or transcripts are stored here.
|
||||||
|
try { localStorage.setItem('aiturk.voice-limits', JSON.stringify(limits)) } catch {}
|
||||||
const target = { ...requireSession() }, targetConnection = connection, desktopId = connectedDesktopId
|
const target = { ...requireSession() }, targetConnection = connection, desktopId = connectedDesktopId
|
||||||
stopVoice(); const generation = voiceGeneration; const token = await rpc('voice.token')
|
stopVoice(); const generation = voiceGeneration; const token = await rpc('voice.token')
|
||||||
if (generation !== voiceGeneration || document.hidden) return
|
if (generation !== voiceGeneration || document.hidden) return
|
||||||
const started = Date.now()
|
voice = new VoiceSession({ ...token, limits,
|
||||||
voice = new VoiceSession({ ...token,
|
onUsage: usage => { if (generation === voiceGeneration) $('usage').textContent = usageLabel(usage, usage.seconds) },
|
||||||
onText: (role, text) => addText('discussion', role, text),
|
onText: (role, text) => addText('discussion', role, text),
|
||||||
onDraft: (text, intent) => { $('task').value = text; $('intent').value = intent; updatePreview(); status('Asistan görev taslağını hazırladı. Henüz IDE’ye gönderilmedi.') },
|
onDraft: (text, intent) => { $('task').value = text; $('intent').value = intent; updatePreview(); status('Asistan görev taslağını hazırladı. Henüz IDE’ye gönderilmedi.') },
|
||||||
onSubmit: async (text, intent) => {
|
onSubmit: async (text, intent) => {
|
||||||
@@ -127,8 +168,7 @@ async function startVoice(microphone) {
|
|||||||
pendingTask = { taskId: crypto.randomUUID(), sessionId: target.id, model: target.model, desktopId, intent, text }
|
pendingTask = { taskId: crypto.randomUUID(), sessionId: target.id, model: target.model, desktopId, intent, text }
|
||||||
return deliver()
|
return deliver()
|
||||||
},
|
},
|
||||||
onError: status, onState: text => { $('voice-status').textContent = text; if (voice?.stopped) clearInterval(usageTimer) } })
|
onError: status, onState: text => { $('voice-status').textContent = text } })
|
||||||
usageTimer = setInterval(() => { $('usage').textContent = `Bu uygulamanın ölçtüğü görüşme süresi: ${Math.floor((Date.now() - started) / 1000)} sn. Google kotası/faturası değildir.` }, 1000)
|
|
||||||
await voice.start(microphone)
|
await voice.start(microphone)
|
||||||
}
|
}
|
||||||
action('voice-start', () => startVoice(true)); action('voice-text', () => startVoice(false))
|
action('voice-start', () => startVoice(true)); action('voice-text', () => startVoice(false))
|
||||||
@@ -166,6 +206,10 @@ action('history', async () => { const result = await rpc('session.history', { se
|
|||||||
action('clear-history', async () => { $('discussion').replaceChildren(); $('result').replaceChildren(); status('Telefondaki görünür metin temizlendi. Google bağlantı bağlamını da temizlemek için görüşmeyi kapatın.') })
|
action('clear-history', async () => { $('discussion').replaceChildren(); $('result').replaceChildren(); status('Telefondaki görünür metin temizlendi. Google bağlantı bağlamını da temizlemek için görüşmeyi kapatın.') })
|
||||||
action('disconnect', async () => { connectionEpoch++; stopVoice(); connection?.close(); connection = null; selected = null; $('work').hidden = true; status('Telefon bağlantısı kapalı. IDE işi devam edebilir.') })
|
action('disconnect', async () => { connectionEpoch++; stopVoice(); connection?.close(); connection = null; selected = null; $('work').hidden = true; status('Telefon bağlantısı kapalı. IDE işi devam edebilir.') })
|
||||||
action('revoke', async () => { const data = await account.call('/v1/aiturk/mobile/devices'); const self = data.devices.find(d => d.self); await account.call(`/v1/aiturk/devices/${self.id}/revoke`, { expected_version: self.version }); stopVoice(); connection?.close(); connection = null; credentials = {}; await saveCredentials(); account = null; $('work').hidden = true; status('Bu telefonun erişimi iptal edildi.') })
|
action('revoke', async () => { const data = await account.call('/v1/aiturk/mobile/devices'); const self = data.devices.find(d => d.self); await account.call(`/v1/aiturk/devices/${self.id}/revoke`, { expected_version: self.version }); stopVoice(); connection?.close(); connection = null; credentials = {}; await saveCredentials(); account = null; $('work').hidden = true; status('Bu telefonun erişimi iptal edildi.') })
|
||||||
document.addEventListener('visibilitychange', () => { if (document.hidden) stopVoice() })
|
document.addEventListener('visibilitychange', () => { if (document.hidden) { stopVoice(); scanner.stop() } })
|
||||||
window.addEventListener('pagehide', () => { stopVoice(); connection?.close() })
|
window.addEventListener('pagehide', () => { stopVoice(); scanner.stop(); connection?.close() })
|
||||||
|
let savedLimits = DEFAULT_VOICE_LIMITS
|
||||||
|
try { const raw = localStorage.getItem('aiturk.voice-limits'); if (raw) savedLimits = voiceLimits(JSON.parse(raw)) } catch {}
|
||||||
|
$('voice-minutes').value = savedLimits.minutes; $('voice-budget').value = savedLimits.usd
|
||||||
try { const saved = await native()?.load(); if (saved?.value) { credentials = JSON.parse(saved.value); if (credentials.accountToken) { account = new AccountClient(credentials); status('Kayıtlı cihaz bulundu. Bilgisayarları yenileyerek bağlanın.') } } } catch { status('Android güvenli kasası açılamadı. Hesabınızı yeniden eşleştirin.') }
|
try { const saved = await native()?.load(); if (saved?.value) { credentials = JSON.parse(saved.value); if (credentials.accountToken) { account = new AccountClient(credentials); status('Kayıtlı cihaz bulundu. Bilgisayarları yenileyerek bağlanın.') } } } catch { status('Android güvenli kasası açılamadı. Hesabınızı yeniden eşleştirin.') }
|
||||||
|
showPhoneIdentity()
|
||||||
|
|||||||
@@ -13,7 +13,15 @@
|
|||||||
<label>AITURK hesap API anahtarı<input id="account-token" type="password" autocomplete="off" autocapitalize="none" spellcheck="false"></label>
|
<label>AITURK hesap API anahtarı<input id="account-token" type="password" autocomplete="off" autocapitalize="none" spellcheck="false"></label>
|
||||||
</details>
|
</details>
|
||||||
<label>Telefon adı<input id="device-name" maxlength="80" value="Android telefonum"></label><button id="pair">Eşleştirme kodu oluştur</button>
|
<label>Telefon adı<input id="device-name" maxlength="80" value="Android telefonum"></label><button id="pair">Eşleştirme kodu oluştur</button>
|
||||||
|
<p>Bilgisayarda “Telefon için QR oluştur” seçin. Giriş yaptıktan sonra buradan tarayın. Görüntü sunucuya yüklenmez.</p>
|
||||||
|
<button id="scan-invite">Bilgisayarın QR kodunu tara</button><button id="stop-scan" class="secondary">Kamerayı kapat</button>
|
||||||
|
<video id="qr-video" hidden muted playsinline aria-label="QR kamera önizlemesi"></video>
|
||||||
|
<label>Alternatif: QR resmi seç<input id="qr-file" type="file" accept="image/*"></label>
|
||||||
<pre id="pair-code" aria-live="polite"></pre><p>Kodu bilgisayardaki AITURK Mobil bağlantı penceresinde onaylayın. Eşleştirme kimliğini kopyalayabilirsiniz.</p>
|
<pre id="pair-code" aria-live="polite"></pre><p>Kodu bilgisayardaki AITURK Mobil bağlantı penceresinde onaylayın. Eşleştirme kimliğini kopyalayabilirsiniz.</p>
|
||||||
|
<label>Bu telefonun kimlik belgesi<textarea id="phone-identity" readonly aria-describedby="phone-identity-help"></textarea></label>
|
||||||
|
<canvas id="phone-identity-qr" hidden aria-label="Telefonun sertifika kimliği QR kodu"></canvas>
|
||||||
|
<p>Kimlik QR kodunun ekran görüntüsünü kendi bilgisayarınıza aktarabilir ve bilgisayarda “Telefon kimliği QR resmi” alanından açabilirsiniz. Onayı ayrıca bilgisayarda verin.</p>
|
||||||
|
<p id="phone-identity-help">Bu belgenin tamamını kendi bilgisayarınızdaki “Telefondan alınan kimlik belgesi” alanına aktarın. Bu, özel anahtar değil; onaylayacağınız telefonun sertifika kimliğidir. Başkasının gönderdiği belgeyi onaylamayın.</p>
|
||||||
<button id="devices" class="secondary">Onaydan sonra bilgisayarları bul</button>
|
<button id="devices" class="secondary">Onaydan sonra bilgisayarları bul</button>
|
||||||
<label>Bilgisayar<select id="desktop"><option value="">Önce cihazları bulun</option></select></label><button id="connect">Bilgisayara bağlan</button>
|
<label>Bilgisayar<select id="desktop"><option value="">Önce cihazları bulun</option></select></label><button id="connect">Bilgisayara bağlan</button>
|
||||||
</section><section id="work" hidden><h2>2. IDE konuşması</h2><button id="sessions" class="secondary">Konuşmaları yenile</button>
|
</section><section id="work" hidden><h2>2. IDE konuşması</h2><button id="sessions" class="secondary">Konuşmaları yenile</button>
|
||||||
@@ -24,9 +32,15 @@
|
|||||||
<p><a href="https://aistudio.google.com/apikey" target="_blank" rel="noreferrer">Kendi Google AI Studio anahtarınızı oluşturun</a> · <a href="https://ai.google.dev/gemini-api/docs/pricing" target="_blank" rel="noreferrer">Güncel fiyat ve veri koşulları</a></p>
|
<p><a href="https://aistudio.google.com/apikey" target="_blank" rel="noreferrer">Kendi Google AI Studio anahtarınızı oluşturun</a> · <a href="https://ai.google.dev/gemini-api/docs/pricing" target="_blank" rel="noreferrer">Güncel fiyat ve veri koşulları</a></p>
|
||||||
<label>Google AI Studio API anahtarı<input id="google-key" type="password" autocomplete="off" autocapitalize="none" spellcheck="false"></label><button id="save-google">Bilgisayarın güvenli kasasına kaydet</button><button id="delete-google" class="secondary">Google anahtarını sil</button>
|
<label>Google AI Studio API anahtarı<input id="google-key" type="password" autocomplete="off" autocapitalize="none" spellcheck="false"></label><button id="save-google">Bilgisayarın güvenli kasasına kaydet</button><button id="delete-google" class="secondary">Google anahtarını sil</button>
|
||||||
<label><input id="consent" type="checkbox">Google'a veri aktarımını ve projemde ücret oluşabileceğini anladım.</label>
|
<label><input id="consent" type="checkbox">Google'a veri aktarımını ve projemde ücret oluşabileceğini anladım.</label>
|
||||||
|
<fieldset><legend>Her yeni ses / yazışma oturumu için durdurma sınırı</legend>
|
||||||
|
<label>En fazla dakika (1–10)<input id="voice-minutes" type="number" min="1" max="10" step="1" value="10" inputmode="numeric"></label>
|
||||||
|
<label>Tahmini maliyet sınırı (USD)<input id="voice-budget" type="number" min="0.01" max="10" step="0.01" value="0.25" inputmode="decimal"></label>
|
||||||
|
<p>Sınırlar bir sonraki bağlantıda uygulanır; her yeni bağlantıda sayaç sıfırlanır. Tahmin ücretli tarifeyi kullanır; ücretsiz kotanız ölçülmez. Görüşme geçmişi ve metin tokenları da ücret oluşturabilir. Bildirim gecikirse veya tarife değişirse gerçek ücret sınırı aşabilir. İki dakika kullanım bildirimi gelmezse bağlantı kapanır. Otomatik yeniden bağlanılmaz; IDE işi durdurulmaz.</p>
|
||||||
|
</fieldset>
|
||||||
<div class="row"><button id="voice-start">Sesle konuş</button><button id="voice-text" class="secondary">Asistanla yazış</button></div>
|
<div class="row"><button id="voice-start">Sesle konuş</button><button id="voice-text" class="secondary">Asistanla yazış</button></div>
|
||||||
<div class="row"><button id="voice-stop" class="secondary">Ses görüşmesini kapat</button><button id="silence" class="secondary">Asistanın sesini kes</button></div>
|
<div class="row"><button id="voice-stop" class="secondary">Ses görüşmesini kapat</button><button id="silence" class="secondary">Asistanın sesini kes</button></div>
|
||||||
<p id="voice-status" role="status">Mikrofon kapalı.</p><small id="usage">Yerel süre: 0 sn. Google kalan kotası ölçülmüyor.</small>
|
<p id="voice-status" role="status">Mikrofon kapalı.</p><small id="usage">Henüz kullanım yok. Google kalan kotası ve faturası ölçülmüyor.</small>
|
||||||
|
<p><small>Tahmin tarifesi: Gemini 3.1 Flash Live Preview · 8 Eylül 2026. Vergi, indirim ve bildirilmemiş kullanım dahil değildir.</small></p>
|
||||||
<div id="discussion" class="history" aria-label="Google asistanı görüşmesi"></div>
|
<div id="discussion" class="history" aria-label="Google asistanı görüşmesi"></div>
|
||||||
<label>Asistanla fikrini geliştir<textarea id="idea" placeholder="Bu uygulamayı nasıl geliştirebiliriz?"></textarea></label><button id="discuss">Asistana yaz</button>
|
<label>Asistanla fikrini geliştir<textarea id="idea" placeholder="Bu uygulamayı nasıl geliştirebiliriz?"></textarea></label><button id="discuss">Asistana yaz</button>
|
||||||
<h2>IDE'ye gönderilecek görev</h2><p>Fikir konuşması kendi başına iş başlatmaz. Aşağıdaki metnin tamamı seçili IDE konuşmasına gönderilir. Araştırma seçeneği değişiklik izni vermez.</p>
|
<h2>IDE'ye gönderilecek görev</h2><p>Fikir konuşması kendi başına iş başlatmaz. Aşağıdaki metnin tamamı seçili IDE konuşmasına gönderilir. Araştırma seçeneği değişiklik izni vermez.</p>
|
||||||
@@ -39,4 +53,4 @@
|
|||||||
<button id="revoke" class="danger">Bu telefonun erişimini iptal et</button>
|
<button id="revoke" class="danger">Bu telefonun erişimini iptal et</button>
|
||||||
</section><hr><p>Ses kaydı saklanmaz. Google görüşme metni yalnız bu ekranın belleğinde kalır. IDE'ye gönderilen görev IDE geçmişinde saklanır. Ekrandan çıkmak mikrofonu kapatır; IDE işini durdurmaz.</p>
|
</section><hr><p>Ses kaydı saklanmaz. Google görüşme metni yalnız bu ekranın belleğinde kalır. IDE'ye gönderilen görev IDE geçmişinde saklanır. Ekrandan çıkmak mikrofonu kapatır; IDE işini durdurmaz.</p>
|
||||||
<p><a href="privacy.html">Veri akışı ve geliştirme sürümünün sınırları</a></p>
|
<p><a href="privacy.html">Veri akışı ve geliştirme sürümünün sınırları</a></p>
|
||||||
</main><script src="capacitor.js"></script><script type="module" src="app.mjs"></script></body></html>
|
</main><script src="capacitor.js"></script><script src="vendor/jsQR.js"></script><script type="module" src="app.mjs"></script></body></html>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { normalizeFingerprint } from './peer-identity.mjs'
|
||||||
|
|
||||||
|
export function parseInvite(raw, now = Date.now()) {
|
||||||
|
if (typeof raw !== 'string' || raw.length > 800) throw new Error('Bu bir AITURK eşleştirme QR kodu değil.')
|
||||||
|
let data
|
||||||
|
try { data = JSON.parse(raw) } catch { throw new Error('Bu bir AITURK eşleştirme QR kodu değil.') }
|
||||||
|
if (!data || data.kind !== 'aiturk-mobile-invite' || data.version !== 2 ||
|
||||||
|
!/^[0-9a-f-]{36}$/.test(data.inviteId) || !/^[0-9a-f-]{36}$/.test(data.desktopId) ||
|
||||||
|
!/^[A-Za-z0-9_-]{43}$/.test(data.secret) || !Number.isFinite(Date.parse(data.expiresAt)) ||
|
||||||
|
Date.parse(data.expiresAt) <= now || Date.parse(data.expiresAt) > now + 11 * 60000)
|
||||||
|
throw new Error('QR geçersiz veya süresi dolmuş. Bilgisayarda yeni kod oluşturun.')
|
||||||
|
return { inviteId: data.inviteId, desktopId: data.desktopId, secret: data.secret,
|
||||||
|
desktopFingerprint: normalizeFingerprint(data.desktopFingerprint) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Standard WebRTC identities; private key material stays inside RTCCertificate.
|
||||||
|
// Pairing must persist the public fingerprint separately in its trusted record.
|
||||||
|
const DATABASE = 'aiturk-mobile-peer-identities-v1'
|
||||||
|
const STORE = 'certificates'
|
||||||
|
const failure = () => new Error('Cihaz kimliği doğrulanamadı. Mevcut eşleşmeyi kullanmayın; yeniden açık eşleştirme gerekli.')
|
||||||
|
|
||||||
|
function deviceKey(deviceId) {
|
||||||
|
if (typeof deviceId !== 'string' || !/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(deviceId)) throw failure()
|
||||||
|
return deviceId.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFingerprint(value) {
|
||||||
|
if (typeof value !== 'string' || !/^(?:[0-9a-f]{2}:){31}[0-9a-f]{2}$/i.test(value)) throw failure()
|
||||||
|
return value.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePhoneIdentity(raw, pairingId) {
|
||||||
|
if (typeof raw !== 'string' || raw.length > 700) throw failure()
|
||||||
|
let value
|
||||||
|
try { value = JSON.parse(raw) } catch { throw failure() }
|
||||||
|
if (value?.kind !== 'aiturk-phone-identity' || value.version !== 1 || value.pairingId !== pairingId) throw failure()
|
||||||
|
return { deviceId: deviceKey(value.deviceId), fingerprint: normalizeFingerprint(value.fingerprint) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function identity(certificate) {
|
||||||
|
if (!(certificate instanceof RTCCertificate) || !Number.isFinite(certificate.expires) || certificate.expires <= Date.now()) throw failure()
|
||||||
|
const fingerprints = certificate.getFingerprints().filter(item => item.algorithm === 'sha-256')
|
||||||
|
if (fingerprints.length !== 1) throw failure()
|
||||||
|
return { certificate, fingerprint: normalizeFingerprint(fingerprints[0].value), expires: certificate.expires }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function database() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let blocked = false
|
||||||
|
const request = indexedDB.open(DATABASE, 1)
|
||||||
|
request.onupgradeneeded = () => request.result.createObjectStore(STORE)
|
||||||
|
request.onerror = () => reject(failure())
|
||||||
|
request.onblocked = () => { blocked = true; reject(failure()) }
|
||||||
|
request.onsuccess = () => {
|
||||||
|
if (blocked) { request.result.close(); return }
|
||||||
|
request.result.onversionchange = () => request.result.close()
|
||||||
|
resolve(request.result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call only during an explicit NEW device pairing, never to repair a missing key.
|
||||||
|
// The read/write transaction chooses one winner across simultaneous windows.
|
||||||
|
export async function createDeviceIdentity(deviceId) {
|
||||||
|
const key = deviceKey(deviceId)
|
||||||
|
const candidate = await RTCPeerConnection.generateCertificate({ name: 'ECDSA', namedCurve: 'P-256' })
|
||||||
|
const db = await database()
|
||||||
|
try {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE, 'readwrite'), store = tx.objectStore(STORE)
|
||||||
|
let selected
|
||||||
|
const read = store.get(key)
|
||||||
|
read.onsuccess = () => {
|
||||||
|
try {
|
||||||
|
selected = identity(read.result === undefined ? candidate : read.result)
|
||||||
|
if (read.result === undefined) store.add(candidate, key)
|
||||||
|
} catch { tx.abort() }
|
||||||
|
}
|
||||||
|
tx.oncomplete = () => resolve(selected)
|
||||||
|
tx.onabort = tx.onerror = () => reject(failure())
|
||||||
|
})
|
||||||
|
} finally { db.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never generates or replaces a certificate. A lost key or changed public pin
|
||||||
|
// requires a new explicit pairing, not trust-on-first-use during reconnect.
|
||||||
|
export async function loadDeviceIdentity(deviceId, expectedFingerprint) {
|
||||||
|
const key = deviceKey(deviceId), expected = normalizeFingerprint(expectedFingerprint)
|
||||||
|
const db = await database()
|
||||||
|
try {
|
||||||
|
const certificate = await new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE), request = tx.objectStore(STORE).get(key)
|
||||||
|
tx.oncomplete = () => resolve(request.result)
|
||||||
|
tx.onabort = tx.onerror = () => reject(failure())
|
||||||
|
})
|
||||||
|
const result = identity(certificate)
|
||||||
|
if (result.fingerprint !== expected) throw failure()
|
||||||
|
return result
|
||||||
|
} finally { db.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Application data channels use a single bundled DTLS identity. Check every
|
||||||
|
// advertised fingerprint before setRemoteDescription; native DTLS then verifies
|
||||||
|
// that the actual peer possesses the matching private key.
|
||||||
|
export function assertRemoteIdentity(sdp, trustedFingerprint) {
|
||||||
|
const expected = normalizeFingerprint(trustedFingerprint)
|
||||||
|
if (typeof sdp !== 'string' || sdp.length > 131072) throw failure()
|
||||||
|
const lines = sdp.split(/\r?\n/).filter(line => line.startsWith('a=fingerprint:'))
|
||||||
|
if (!lines.length) throw failure()
|
||||||
|
for (const line of lines) {
|
||||||
|
const match = /^a=fingerprint:sha-256 ((?:[0-9a-f]{2}:){31}[0-9a-f]{2})$/i.exec(line)
|
||||||
|
if (!match || normalizeFingerprint(match[1]) !== expected) throw failure()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
<p class="eyebrow">AITURK MOBİL · 8 EYLÜL 2026</p><h1>Verilerinizin yolu.</h1>
|
<p class="eyebrow">AITURK MOBİL · 8 EYLÜL 2026</p><h1>Verilerinizin yolu.</h1>
|
||||||
<p>Bu geliştirme sürümü Android için hazırlanmıştır. Gerçek telefon, ayrı internet ağları ve Google ses görüşmesi kabul testleri tamamlanmadan genel kullanıma hazır sayılmaz.</p>
|
<p>Bu geliştirme sürümü Android için hazırlanmıştır. Gerçek telefon, ayrı internet ağları ve Google ses görüşmesi kabul testleri tamamlanmadan genel kullanıma hazır sayılmaz.</p>
|
||||||
<h2>Hesap ve cihazlar</h2><p>AITURK hesap API anahtarı ve iptal edilebilir cihaz sırrı telefonda Android Keystore ile, bilgisayarda Windows güvenli kasasıyla korunur. Eşleştirme kodları sürelidir; telefon bu bilgisayarda açıkça onaylanır. Cihaz iptali yeni işlemleri engeller; açık bağlantı da en geç bir sonraki yetki kontrolünde kapanır.</p>
|
<h2>Hesap ve cihazlar</h2><p>AITURK hesap API anahtarı ve iptal edilebilir cihaz sırrı telefonda Android Keystore ile, bilgisayarda Windows güvenli kasasıyla korunur. Eşleştirme kodları sürelidir; telefon bu bilgisayarda açıkça onaylanır. Cihaz iptali yeni işlemleri engeller; açık bağlantı da en geç bir sonraki yetki kontrolünde kapanır.</p>
|
||||||
<h2>Telefon ve bilgisayar</h2><p>Görev ve anahtar aktarımı WebRTC DTLS veri kanalından geçer. AITURK bağlantı hizmeti hesap/cihaz kimliklerini ve ağ bağlantı açıklamalarını (SDP, IP adresleri dahil) görür. Kayıtlar 90 saniye içinde yenilenmezse sona erer ve sonraki bağlantı sorgusunda temizlenir. Hizmet görev veya Google anahtarını saklamaz. Sinyalleşme hizmetine eş kimliğini doğru doğrulaması için güvenilir; kötü niyetli sinyalleşme sunucusuna karşı ayrıca doğrulanmış parmak izi koruması henüz yoktur.</p>
|
<h2>Telefon ve bilgisayar</h2><p>Görev ve anahtar aktarımı WebRTC DTLS veri kanalından geçer. AITURK bağlantı hizmeti hesap/cihaz kimliklerini ve ağ bağlantı açıklamalarını (SDP, IP adresleri dahil) görür. Kayıtlar 90 saniye içinde yenilenmezse sona erer ve sonraki bağlantı sorgusunda temizlenir. Hizmet görev veya Google anahtarını saklamaz. Cihaz sertifikaları yerel tarayıcı veritabanında; güvenilen karşı cihaz parmak izleri Android/Windows kasasında saklanır. Telefon bilgisayar kimliğini QR'dan, bilgisayar telefon kimliğini sizin aktarıp onayladığınız belgeden alır. Bağlantı bu kimliklerle karşılaştırılır; değişen veya kayıp kimlik yeniden açık eşleştirme gerektirir. Başkasının gönderdiği QR veya kimlik belgesini onaylamayın. Bu koruma ele geçirilmiş telefon/bilgisayarın güvenliğini sağlamaz; gerçek cihaz kabulü henüz tamamlanmadı.</p>
|
||||||
<h2>Google ses asistanı</h2><p>Kalıcı Google anahtarınız yalnız eşleştirdiğiniz bilgisayarda saklanır. Telefon kısa ömürlü erişimle doğrudan Google'a bağlanır. Ses ve Google görüşme metni Google tarafından işlenir; telefon-bilgisayar şifrelemesi Google'dan gizlilik sağlamaz. Ücretsiz katman verileri ürün geliştirme için kullanılabilir. Ücretli Google projesindeki çağrılar ücretlidir. Güncel koşullar için Google AI Studio ve Gemini API fiyat sayfasını okuyun.</p>
|
<h2>Google ses asistanı</h2><p>Kalıcı Google anahtarınız yalnız eşleştirdiğiniz bilgisayarda saklanır. Telefon kısa ömürlü erişimle doğrudan Google'a bağlanır. Ses ve Google görüşme metni Google tarafından işlenir; telefon-bilgisayar şifrelemesi Google'dan gizlilik sağlamaz. Ücretsiz katman verileri ürün geliştirme için kullanılabilir. Ücretli Google projesindeki çağrılar ücretlidir. Güncel koşullar için Google AI Studio ve Gemini API fiyat sayfasını okuyun.</p>
|
||||||
<h2>Geçmiş ve silme</h2><p>Ses kayıtları bu uygulama tarafından dosyaya yazılmaz. Görüşme metni ekranda geçici bellektedir. Temizleme düğmesi yerel görünümü temizler; aktif Google oturumunun bağlamı görüşme kapandığında bırakılır. IDE'ye gönderdiğiniz görev IDE'nin kendi geçmiş ve saklama kurallarına tabidir. Görev kimliği, içerik özeti ve gönderim durumu tekrar çalıştırmayı önlemek için bilgisayarda saklanır; görev metni bu kayıtta tutulmaz.</p>
|
<h2>Geçmiş ve silme</h2><p>Ses kayıtları bu uygulama tarafından dosyaya yazılmaz. Görüşme metni ekranda geçici bellektedir. Temizleme düğmesi yerel görünümü temizler; aktif Google oturumunun bağlamı görüşme kapandığında bırakılır. IDE'ye gönderdiğiniz görev IDE'nin kendi geçmiş ve saklama kurallarına tabidir. Görev kimliği, içerik özeti ve gönderim durumu tekrar çalıştırmayı önlemek için bilgisayarda saklanır; görev metni bu kayıtta tutulmaz.</p>
|
||||||
<h2>Bilinen sınırlar</h2><p>TURN hizmeti henüz sağlanmadığından bazı farklı ağlarda bağlantı kurulamaz. QR tarama, tek dokunuşla hesap girişi, doğrulanmış maliyet tahmini, otomatik sesli görev yürütme ve gerçek cihaz değerlendirmesi henüz tamamlanmamıştır. Ses aracı şu anda görünür görev taslağı üretir; kullanıcı gönder düğmesiyle IDE'ye iletir. Bu sayfa eksik özellikleri çalışıyor olarak sunmaz.</p>
|
<h2>Maliyet kontrolü</h2><p>Google'ın bildirdiği tokenlar ücretli liste tarifesiyle tahmin edilir. Geçmiş bağlamı tekrar ücretlenebilir. Eksik ayrıntılar fiyat aralığıyla gösterilir; bildirilmemiş kullanım, vergi ve indirimler dahil değildir. Seçtiğiniz süre veya tahmini sınır dolduğunda yalnız Google bağlantısı kapanır; IDE işi devam edebilir. İki dakika kullanım bildirimi gelmezse bağlantı kapatılır. Bildirim gecikmesi ve tarife değişiklikleri nedeniyle gerçek ücret sınırı aşabilir. Her yeni oturumda sayaç sıfırlanır. Yalnız süre ve sınır tercihi cihazda saklanır; burada fatura ya da kalan ücretsiz kota ölçülmez.</p>
|
||||||
|
<h2>Bilinen sınırlar</h2><p>QR ve tarayıcı üzerinden kısa kodla hesap girişi geliştirildi; canlı hizmete dağıtılmadı. Asistan görünür taslak hazırlayıp açık uygulama/araştırma talimatıyla seçili IDE konuşmasına görev iletebilir; işlem onaylarını kullanıcı verir. Bu davranış, maliyet sınırı ve bağlantı akışı sentetik yanıtlarla test edildi. Gerçek Android/Google kalite, fatura ve ayrı ağ kabulü henüz yapılmadı. TURN dağıtımı ve kalıcı eş kimliği doğrulaması tamamlanmadan genel kullanıma hazır değildir.</p>
|
||||||
<a href="index.html">Uygulamaya dön</a></main></body></html>
|
<a href="index.html">Uygulamaya dön</a></main></body></html>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user