Compare commits

..
Author SHA1 Message Date
yilsemandClaude Opus 5.5 a7ba300448 fix(codex-bridge): close the gaps an independent review found
Server CI and Windows package / verify (push) Successful in 3m23s
Server CI and Windows package / windows-package (push) Successful in 7m9s
- One decision per real turn: a turn starts with a user message after the
  previous final answer; tool steps, messages typed mid-turn, a lone "."
  and compaction keep the combo. Works without prompt_cache_key too.
- The virtual Otomatik name never reaches the gateway (stage off or error
  -> last choice or targets.zor); prefilter.target may not be it.
- finishCheck: at most once per turn; a broken, empty or refused retry
  delivers the already approved first answer.
- Layer window filter: only trusted windows, never the last layer.
- Correction channel only on always-pass targets while checks are on;
  Sesli-Hizli-Zeki stays always-pass by default; a >256 KB SSE frame
  passes unfiltered instead of breaking the stream.
- Turn-start questions wait in the local queue no longer than their
  timeout; decision-turn memory under a lock with refreshed order.
- Dify fetch refuses redirects (the app key never moves host); the
  rules file write retries a Windows sharing violation.
- An unreadable settings file no longer breaks every request.
- TypeScript accepts only https:// URLs the bridge accepts;
  finishCheck.timeoutSeconds (unused) removed.

Tests: 310 bridge tests, 36 electron tests; side-bridge live run for the
turn, prefilter (incl. decision timeout) and finish-check paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:14:38 +03:00
yilsemandClaude Opus 5.5 7c4ef18df0 feat(codex-bridge): decision-model stages, Dify rules and beta.39
Owner request, 14 October 2026: use the decision model (Jev / Celeris) at
several stages, let Dify supply the rules, code it without new bugs.
Every new stage is switched on in Settings > Kademeli Gecis and starts
disabled, so the upgrade changes no behaviour by itself.

- Settings schema 2 (Python and TypeScript validate the same rules); a
  schema 1 file is filled from the defaults. Default chain is now
  Katman-1 -> Katman-2 -> Katman-3 with Katman-3, Chat-Model and
  bekci-sesli-bekci always passing (owner decision of the same day).
- watchModels: the Bekci correction channel follows a settings list
  instead of the hard-coded Sesli-Hizli-Zeki.
- prefilter: a predicted refusal starts the step at the chosen layer.
- autoRoute: virtual "Otomatik" model; one decision per user turn picks
  the combo, tool steps of the same turn keep it.
- finishCheck: an answer that stops early goes back to the same model once.
- A layer whose window cannot hold the request is skipped.
- policy: stage rules from a Dify workflow (karar_politikasi.py), fetched
  in the background, validated, last good copy kept; turns never wait.
- karar_sor.py: verifierCheck for the dynamic-workflow acceptance step.
- Bridge version 19, IDE 1.0.0-beta.39.

Tests: 299 bridge tests, 36 electron tests; live run on a side bridge
(port 8797) with Celeris and the AITURK gateway for every stage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:48:33 +03:00
yilsem 8f53b090f7 feat(codex-link): persist direct subscription and AITURK connection modes
Server CI and Windows package / verify (push) Successful in 2m54s
Server CI and Windows package / windows-package (push) Successful in 7m17s
2026-10-13 19:56:40 +03:00
yilsem 049c4d1338 fix(installer): preserve healthy Python runtime and Codex connections
Server CI and Windows package / verify (push) Successful in 2m47s
Server CI and Windows package / windows-package (push) Successful in 6m58s
2026-10-13 17:22:28 +03:00
yilsem 4924781e2d fix(bridge): stop replaying explicit terminal provider failures 2026-10-13 17:18:38 +03:00
yilsem 4037c65204 ci: install ripgrep for real search regressions
Server CI and Windows package / verify (push) Successful in 2m51s
Server CI and Windows package / windows-package (push) Successful in 6m14s
2026-10-13 16:35:24 +03:00
yilsem e7d3080e0a fix: preserve native model switches and bound project searches
Server CI and Windows package / verify (push) Failing after 1m40s
Server CI and Windows package / windows-package (push) Skipped
2026-10-13 16:32:50 +03:00
yilsem db5e876f40 fix(bridge): preserve plaintext peer task payloads for external models
Server CI and Windows package / verify (push) Successful in 3m16s
Server CI and Windows package / windows-package (push) Successful in 8m18s
2026-10-13 08:05:00 +03:00
yilsem ab7a721fbc Ship beta34 with external agent tasks, terminal SSE and live token limits
Server CI and Windows package / verify (push) Successful in 3m13s
Server CI and Windows package / windows-package (push) Successful in 7m56s
2026-10-13 07:32:57 +03:00
yilsem 2cca6a66a6 Preserve namespaced Codex tools across external model calls
Server CI and Windows package / verify (push) Successful in 2m11s
Server CI and Windows package / windows-package (push) Successful in 10m44s
2026-10-13 04:54:52 +03:00
yilsem a68d3e2272 Restart stale Codex bridge when installing compression settings
Server CI and Windows package / verify (push) Successful in 3m11s
Server CI and Windows package / windows-package (push) Successful in 7m1s
2026-10-13 04:14:01 +03:00
yilsem dfa762bcd2 Include compression defaults in Windows bridge resources
Server CI and Windows package / verify (push) Successful in 3m0s
Server CI and Windows package / windows-package (push) Canceled after 2m58s
2026-10-13 04:07:54 +03:00
yilsem 912752bdd3 Cap merged Codex Link model catalog at 1000 entries
Server CI and Windows package / verify (push) Successful in 2m59s
Server CI and Windows package / windows-package (push) Successful in 9m6s
2026-10-13 03:53:29 +03:00
yilsem 91ecad8e5c Preserve native Codex history and add configurable cross-model compression
Server CI and Windows package / verify (push) Successful in 4m47s
Server CI and Windows package / windows-package (push) Successful in 8m47s
Separate encrypted reasoning from actual compaction, stop migration after native return, and retain full history and recent exchanges. Add typed settings, Gemini fallback, verified provider windows, bounded summaries and durable concurrent checkpoints.

Validation: 245 bridge tests plus focused concurrency tests; 71 settings/connection tests; 66 supplementary tests; TypeScript checks; renderer and Electron bundles; real primary/native/fallback summary probes and 700K input acceptance.
2026-10-13 03:36:42 +03:00
62 changed files with 4342 additions and 314 deletions

No files matched your search

+9 -2
View File
@@ -23,6 +23,9 @@ jobs:
run: |
set -eu
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
apt-get update
apt-get install -y --no-install-recommends ripgrep
rg --version
python3 -m venv "$RUNNER_TEMP/bootstrap"
"$RUNNER_TEMP/bootstrap/bin/pip" install uv
"$RUNNER_TEMP/bootstrap/bin/uv" venv --python 3.11 "$RUNNER_TEMP/ci-venv"
@@ -32,8 +35,12 @@ jobs:
"$RUNNER_TEMP/ci-venv/bin/python" -m pytest tests/tools/test_protected_instruction_unattended.py tests/tools/test_file_write_safety.py tests/tools/test_computer_use_approval_isolation.py tests/cli/test_cli_approval_ui.py tests/cli/test_single_query_session_finalize.py -q -p no:cacheprovider -rf
- name: Codex bridge regression tests
run: '"$RUNNER_TEMP/ci-venv/bin/python" -m unittest discover -s apps/desktop/assets/codex-bridge'
- name: Managed Python update preservation
run: '"$RUNNER_TEMP/ci-venv/bin/python" -m pytest tests/test_install_ps1_reuse_healthy_venv.py tests/test_install_ps1_python_fallback_venv.py -q -p no:cacheprovider -rf'
- name: Project scoped and bounded search regressions
run: '"$RUNNER_TEMP/ci-venv/bin/python" -m pytest tests/tools/test_search_project_scope.py tests/tools/test_search_files_cpu_windows.py tests/tools/test_search_zero_match_and_multipath.py tests/tools/test_search_giant_line_containment.py tests/tools/test_search_budget_truncation.py -q -p no:cacheprovider -rf'
- name: Native watch and speech mux pytest regressions
run: '"$RUNNER_TEMP/ci-venv/bin/python" -m pytest apps/desktop/assets/codex-bridge/test_bekci_watch.py apps/desktop/assets/codex-bridge/test_native_speech_mux.py -q -p no:cacheprovider -rf'
run: '"$RUNNER_TEMP/ci-venv/bin/python" -m pytest apps/desktop/assets/codex-bridge/test_bekci_watch.py apps/desktop/assets/codex-bridge/test_native_speech_mux.py apps/desktop/assets/codex-bridge/test_live_limits.py -q -p no:cacheprovider -rf'
- name: Mobile and desktop startup regression tests
run: |
npm install --prefix "$RUNNER_TEMP/mobile-tests" --no-save --ignore-scripts --no-audit --no-fund --legacy-peer-deps vitest@4.1.10 ws@8.21.1
@@ -66,7 +73,7 @@ jobs:
- name: Native dependency staging regression tests
run: npx vitest run --root apps/desktop scripts/stage-native-deps.test.mjs --maxWorkers=2
- name: Codex connection settings regression tests
run: npx vitest run --root apps/desktop electron/codex-link.test.ts electron/codex-link-config.test.ts electron/staged-transition.test.ts src/app/settings/codex-link-settings.test.tsx src/app/settings/staged-transition-settings.test.tsx --maxWorkers=2
run: npx vitest run --root apps/desktop electron/codex-link.test.ts electron/codex-link-config.test.ts electron/staged-transition.test.ts electron/compression-settings.test.ts src/app/settings/codex-link-settings.test.tsx src/app/settings/staged-transition-settings.test.tsx src/app/settings/compression-settings.test.tsx --maxWorkers=2
- name: Build and package Windows x64
run: |
set -eu
+21 -2
View File
@@ -18,6 +18,24 @@ TurkServis'in Hermes Agent tabanlı masaüstü geliştirme ortamı.
## Veri ve güncellemeler
### Codex bağlam ve sıkıştırma
Codex Bağlama ayarları, ek model içeren geçişlerde sıkıştırıcı/yedek model,
ayrıntı seviyesi, korunacak mesajlaşmalar, yüzde/token bütçesi ve otomatik eşik
seçimini sunar. Varsayılanlar: Gemini 3.8 Flash High, yedek Gemini 3 Flash,
10 mesajlaşma, geçişte %70, otomatik tetiklemede %70 ve sonrasında %50.
Kapasite bağlantıdan gelen model bilgisiyle hesaplanır; 272K genel tavan yoktur.
`/models` içindeki doğrulanmış sayısal bağlam bilgisi, model adından çıkarılan
yerel varsayılandan önce gelir; gözlenmiş daha düşük bir gerçek sınır korunur.
Girdi hesabı talimatlar ve araçları da içeren temkinli bir token tahminidir.
Abonelik modellerinin kendi aralarındaki istekler ve GPT-Live ses yolu özgün
Codex hattında kalır. Şifreli reasoning yalnız ek model için hazırlanan kopyada
görünür metnine indirgenir; gerçek şifreli konuşma özeti sahibi tarafından açılır.
Sohbet kimliği ve özgün konuşma kaydı değiştirilmez. Doğrulanmış özet sürümleri
köprü veri dizinindeki `compression-checkpoints.sqlite3` içinde saklanır.
Son mesajlaşmalar bütçeye sığmıyorsa sayı kendiliğinden azaltılmaz.
Windows ajan verisi `%LOCALAPPDATA%/TurkServis/AITURK-IDE/agent` altında tutulur.
`AITURK_IDE_HOME` açıkça başka bir konum seçmek için kullanılabilir.
Mevcut Hermes kurulumu ve `HERMES_HOME` dizini otomatik olarak devralınmaz.
@@ -42,8 +60,9 @@ npm run builder --workspace=apps/desktop -- --win nsis --x64 --publish never
Dağıtılacak derleme temiz, yayımlanmış Git commit'inden üretilmelidir. İlk açılış
ajan kurulumunu paketteki `install-stamp.json` commit'ine sabitler. Windows
imzası ve temiz kurulum doğrulanmadan bir paket doğrulanmış üretim sürümü diye
sunulmamalıdır. macOS ve Linux paketleri bu beta kapsamında doğrulanmamıştır.
paketleri imzasız EXE/ZIP olarak yayımlanır; SHA-256 ve gerçek Windows çalıştırma
kontrolü gereklidir. Kullanıcı açıkça istemeden imzalama yapılmaz. macOS ve Linux
paketleri bu beta kapsamında doğrulanmamıştır.
## Lisans ve kaynak
+60
View File
@@ -8,6 +8,66 @@ path; broad diagnostic probes should apply this policy to recursive walks.
from __future__ import annotations
from pathlib import Path
SEARCH_RG_THREADS = 2
SEARCH_MAX_CONCURRENT = 2
_PROJECT_MARKERS = ('.git', '.hg', 'pyproject.toml', 'package.json',
'Cargo.toml', 'go.mod', 'CMakeLists.txt')
def _project_root(path):
"""Prefer the enclosing repository over nested monorepo packages."""
package = None
for parent in (path, *path.parents):
if (parent / '.git').exists() or (parent / '.hg').exists():
return parent
if package is None and any((parent / marker).exists() for marker in _PROJECT_MARKERS):
package = parent
return package
def local_search_scope(path, cwd, home):
"""Resolve broad local roots without starting a recursive search process.
Returns (root, notice, error). Only one level of candidate directories is
inspected; no repository-wide discovery is needed to reject an umbrella.
Real paths prevent symlink aliases from bypassing broad-root detection.
"""
root = Path(path)
if not root.is_absolute():
root = Path(cwd) / root
root = root.resolve()
if not root.is_dir():
return path, None, None
home = Path(home).resolve()
project = _project_root(Path(cwd).resolve())
root_project = _project_root(root)
if root_project is not None:
return path, None, None
candidates = []
try:
# Bound metadata work even in directories containing many entries.
from itertools import islice
for child in islice(root.iterdir(), 256):
if child.name in SEARCH_PRUNE_DIR_NAMES or not child.is_dir():
continue
if any((child / marker).exists() for marker in _PROJECT_MARKERS):
candidates.append(str(child))
except OSError:
pass
broad = (root == Path(root.anchor) or root == home or root in home.parents
or len(candidates) > 1 or (project is not None and root in project.parents))
if not broad:
return path, None, None
if project is not None and root in project.parents:
chosen = str(project)
return chosen, f'Search scoped to current project: {chosen}. Set allow_broad=true for the requested umbrella.', None
choices = ', '.join(sorted(candidates)[:12]) or 'choose an explicit project directory'
return path, None, ('Search scope needed: requested root spans projects or a home/drive. '
f'Candidates: {choices}. Set allow_broad=true only for an intentional broad search.')
# Keep this policy conservative and name-based so it works for local and remote
# shell backends alike. The same set is used by context discovery and search
@@ -27,6 +27,45 @@ Run: `python bridge.py --home <data dir> --hermes-home <agent home>`. The IDE wr
Tests: `python -m unittest discover` in this directory.
## Version 19: decision stages (14 October 2026)
The decision model (Jev or another System One compatible service such as Celeris) can now be asked at
several stages. Every new stage is switched on in Settings → Kademeli Geçiş and starts disabled, so an
upgrade changes no behaviour by itself. The decision model never writes text; an unreachable or invalid
decision always keeps the current path.
- `jev-settings.json` schema 2 adds `watchModels`, `prefilter`, `autoRoute`, `finishCheck`,
`verifierCheck` and `policy`. A schema 1 file is filled from `jev-settings-defaults.json`.
The default chain is `Katman-1 → Katman-2 → Katman-3`; `Katman-3`, `Chat-Model`,
`bekci-sesli-bekci` and `Sesli-Hizli-Zeki` always pass.
- **Correction channel** (`watchModels`): the Bekçi watch sideband is no longer bound to the hard-coded
name `Sesli-Hizli-Zeki`; any exact target in the list owns it, but only while it always passes the check
(a checked answer may be replaced by a layer, and a receipt must not belong to an answer the user never saw).
A single SSE frame above 256 KB no longer breaks the stream; the rest of the stream passes unfiltered.
- **Refusal pre-check** (`prefilter`): at the start of a turn, "would a mainstream model refuse or water
this down?". If yes, the step starts at `prefilter.target` instead of collecting a refusal first.
- **Automatic model** (`autoRoute`): a virtual `ide/<model>` entry ("Otomatik") in the Codex list. A new turn
(a user message after the previous final answer) gets one decision call (hard? research?) and goes to
the configured combo. Tool steps, messages typed while the turn runs, a lone "." and compaction stay on
the same combo, because switching providers mid-turn breaks encrypted history. When the stage is off or
fails, the virtual name never reaches the gateway: the last choice or `targets.zor` is used. The user's
explicit model choice is never changed by this stage.
- Both turn-start questions go in one call; the wait, including the local queue, is bounded by
`timeoutSeconds` (default 3 s).
- **Layer window filter**: a layer whose trusted context window (pencere.bekci_calisir) cannot hold the
request is skipped. The last link of the chain is never skipped.
- **Early finish check** (`finishCheck`): asked in the same call as the refusal check on a final answer.
If the answer stopped early or claimed success without evidence, it goes back to the same model with
`finishCheck.note`, at most once per turn. If the retry breaks, comes back empty or fails the check, the
first (already approved) answer is delivered.
- **Rules from Dify** (`policy.source = dify`, `karar_politikasi.py`): the stage questions, thresholds and
combo choices may come from a Dify workflow's `kurallar` output. Fetched in the background every
`refreshMinutes`, validated against the local settings, last good copy kept in `jev-policy.json`.
The transition chain and the always-pass list stay local. A turn never waits for Dify, and a redirect
from the Dify endpoint is refused so the app key never travels to another host.
- **Workflow reviewer check** (`verifierCheck`, `karar_sor.py`): the Codex dynamic-workflow acceptance
step asks whether a reviewer approved without concrete evidence; if so acceptance is blocked.
## Version 11: isolated routes and cross-provider preparation
- Pure subscription chats and native voice retain their ChatGPT route. Added
+88 -11
View File
@@ -32,9 +32,10 @@ import time
import urllib.error
import urllib.request
import gecis_context
import terminal_stream
HERE = os.path.dirname(os.path.abspath(__file__))
SURUM = "12" # AITURK IDE'nin bekledigi kopru surumu (codex-link-config.ts CODEX_BRIDGE_VERSION)
SURUM = "19" # AITURK IDE'nin bekledigi kopru surumu (codex-link-config.ts CODEX_BRIDGE_VERSION)
try:
with open(os.path.abspath(__file__), "rb") as _f:
KOD = hashlib.sha256(_f.read()).hexdigest()[:8]
@@ -348,6 +349,36 @@ class H(http.server.BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(hata)
return True
observer = None
first_chunks = []
failure_key = getattr(self, "_failure_key", None)
if (hedef == KOPRU and failure_key is not None and durum == 200
and "text/event-stream" in yanit.headers.get("Content-Type", "")
and yanit.headers.get("Content-Encoding", "identity") == "identity"):
observer = terminal_stream.Observer()
if not started:
# Inspect only until the first SSE frame/comment. Keep normal
# streaming and heartbeats; an error-only response can still
# return a truthful HTTP failure before headers are committed.
try:
while True:
first = yanit.read1(4096)
if not first:
break
first_chunks.append(first)
observer.feed(first)
if observer.error and not observer.payload_seen:
yanit.close()
self._json(400, terminal_stream.error_body(observer.error))
return True
if observer.comment_seen or observer.payload_seen or sum(map(len, first_chunks)) >= observer.LIMIT:
break
except (OSError, http.client.HTTPException):
# A transport interruption without an explicit provider
# failure keeps the client's existing recovery behavior.
yanit.close()
self.close_connection = True
return True
if not started:
self.send_response(durum)
for k, v in yanit.headers.items():
@@ -356,7 +387,13 @@ class H(http.server.BaseHTTPRequestHandler):
self.send_header("Transfer-Encoding", "chunked")
self.end_headers()
toplam = 0
event_tail = b""
completed = False
try:
for first in first_chunks:
toplam += len(first)
self.wfile.write(b"%x\r\n" % len(first) + first + b"\r\n")
self.wfile.flush()
while True:
try:
parca = yanit.read1(4096) # gelen kadarini hemen verir; bekletmez
@@ -368,8 +405,23 @@ class H(http.server.BaseHTTPRequestHandler):
if not parca:
break
toplam += len(parca)
if observer is not None:
observer.feed(parca)
if getattr(self, "_native_return_scope", None):
event_tail += parca
while b"\n" in event_tail:
line, event_tail = event_tail.split(b"\n", 1)
if line.startswith(b"data:"):
try:
event = json.loads(line[5:])
completed = completed or event.get("type") == "response.completed"
except (ValueError, AttributeError):
pass
self.wfile.write(b"%x\r\n" % len(parca) + parca + b"\r\n")
self.wfile.flush()
if observer is not None and observer.error and not observer.completed:
self.server.failure_replays.remember(failure_key, observer.error)
log(f"{tag} terminal worker error; identical automatic replay will return HTTP 400")
self.wfile.write(b"0\r\n\r\n")
self.wfile.flush()
except (ConnectionAbortedError, BrokenPipeError, ConnectionResetError):
@@ -378,6 +430,8 @@ class H(http.server.BaseHTTPRequestHandler):
return True
if not sessiz:
log(f"{tag} bitti status={durum} {toplam} bayt")
if completed and getattr(self, "_native_return_scope", None):
remember_transfer(*self._native_return_scope)
return True
def _transfer_begin(self):
@@ -438,6 +492,8 @@ class H(http.server.BaseHTTPRequestHandler):
return result["value"], started
def _isle(self):
self._native_return_scope = None
self._failure_key = None
with kilit:
sira[0] += 1
n = sira[0]
@@ -464,30 +520,43 @@ class H(http.server.BaseHTTPRequestHandler):
req = {}
except Exception:
req = {}
if external:
self._failure_key = terminal_stream.request_key(req or {"raw": govde.hex()}, self.headers)
previous_failure = self.server.failure_replays.take(self._failure_key)
if previous_failure:
log(f"[{n} {model}] terminal worker error replay stopped before upstream request")
return self._json(400, terminal_stream.error_body(previous_failure))
thread = str(req.get("prompt_cache_key") or "")
scope = hashlib.sha256((str(self.headers.get("chatgpt-account-id") or "") + "|" + thread).encode()).hexdigest()
source = TRANSFERS.get(scope) if thread else None
mixed = bool(source) or gecis_context.foreign(req)
if external and thread:
remember_transfer(scope, model)
if external or mixed:
# A completed return to the subscription ends migration. Historical
# foreign IDs must not keep later native turns in the preparer.
mixed = ek_model_mu(source or "") or (source is None and gecis_context.foreign_history(req))
# Format repair is stateless: the saved transcript can still contain
# foreign wrappers after a successful native return (or a restart).
if external or mixed or gecis_context.foreign(req):
try:
normalized = gecis_context.normalize(req)
except gecis_context.TransferError as e:
self._transfer_begin()
return self._transfer_fail(str(e))
# Small returning requests need no summarizer or local worker.
if external or gecis_context.tokens(normalized) > int(32768 * 0.70):
# Native opaque state must remain on its owner route. Its byte
# size is not a semantic token budget and cannot trigger a
# foreign summarizer, including when transfer state is cold.
if external or (mixed and not gecis_context.native_opaque(normalized)
and gecis_context.tokens(normalized) > int(32768 * 0.70)):
prepared, started = self._prepare(normalized, source)
if prepared is None:
return
if "compaction" in prepared:
if "compaction" in prepared or "compaction_items" in prepared:
if not started:
self._transfer_begin()
item = prepared["compaction"]
for event in ({"type": "response.output_item.done", "output_index": 0, "item": item},
{"type": "response.completed", "response": {"id": "resp_aiturk_compact", "object": "response",
"status": "completed", "output": [item]}}):
items = prepared.get("compaction_items") or [prepared["compaction"]]
events = [{"type": "response.output_item.done", "output_index": index, "item": item} for index, item in enumerate(items)]
events.append({"type": "response.completed", "response": {"id": "resp_aiturk_compact", "object": "response",
"status": "completed", "output": items}})
for event in events:
self._transfer_chunk(b"event: " + event["type"].encode() + b"\ndata: " + json.dumps(event, ensure_ascii=False).encode() + b"\n\n")
self.wfile.write(b"0\r\n\r\n")
self.wfile.flush()
@@ -497,8 +566,12 @@ class H(http.server.BaseHTTPRequestHandler):
started = False
changed = normalized != req
body = json.dumps(normalized, ensure_ascii=False).encode() if changed else govde
if external and thread:
remember_transfer(scope, model)
self._native_return_scope = (scope, model) if thread and not external else None
return self._ilet(n, KOPRU if external else CHATGPT, yol, body,
f"{'KOPRU' if external else 'OZGUN'} {model}", rewritten=changed, started=started)
self._native_return_scope = (scope, model) if thread else None
return self._ilet(n, CHATGPT, yol, govde, f"OZGUN {model or '?'}")
if self.command == "GET" and "/models" in yol:
# Ek modeller listede gorunsun diye liste koprüden istenir; kopru cevap vermezse chatgpt.com'un listesi.
@@ -515,6 +588,10 @@ class H(http.server.BaseHTTPRequestHandler):
class Sunucu(http.server.ThreadingHTTPServer):
def __init__(self, *args, **kwargs):
self.failure_replays = terminal_stream.FailureReplays()
super().__init__(*args, **kwargs)
daemon_threads = True
# Shipped Python 3.11 defaults to only five pending connections. Model
# discovery, voice and simultaneous chats can arrive in the same burst.
@@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"primaryModel": "ide/agy-gemini-3.8-flash-high",
"fallbackModel": "ide/agy-gemini-3-flash",
"currentModelFallback": true,
"level": "orta",
"keepTurns": 10,
"budgetMode": "percent",
"transferPercent": 70,
"maxTokens": 64000,
"autoEnabled": true,
"triggerPercent": 70,
"targetPercent": 50,
"timeoutSeconds": 3600
}
@@ -0,0 +1,231 @@
"""Tool-free summarization with validated completion, fallback and checkpoints."""
import copy
import json
import time
import urllib.request
import compression_settings as settings_store
import gecis_context as gc
import ozet_kopru
def text_fragments(item, budget):
"""Split text without dropping metadata or non-text content blocks."""
if isinstance(item, str):
text, wrap = item, lambda value: value
elif isinstance(item, dict) and not item.get('call_id'):
content = item.get('content')
if isinstance(content, list):
for part in content:
if isinstance(part, dict) and isinstance(part.get('text'), str):
wrapper = dict(item, content=[dict(part, text='')])
size = max(1, (budget - gc.tokens([wrapper]) - 128) // 2)
for start in range(0, max(1, len(part['text'])), size):
fragment = dict(item, content=[dict(part, text=part['text'][start:start+size])])
if gc.tokens([fragment]) > budget:
raise gc.TransferError('Mesaj parçası sıkıştırıcıya sığmadı')
yield fragment
else:
fragment = dict(item, content=[part])
if gc.tokens([fragment]) > budget:
raise gc.TransferError('Metin dışı içerik sıkıştırıcı kapasitesine sığmıyor; geçmiş korundu.')
yield fragment
return
if not isinstance(content, str):
raise gc.TransferError('Bölünemeyen kayıt sıkıştırıcı kapasitesine sığmıyor; geçmiş korundu.')
text, wrap = content, lambda value: dict(item, content=value)
else:
raise gc.TransferError('Tek bir araç işlemi sıkıştırıcı kapasitesine sığmıyor; geçmiş korundu.')
# At most budget characters per piece is conservative for UTF-8 text.
size = max(1, (budget - gc.tokens([wrap('')]) - 128) // 2)
for start in range(0, max(1, len(text)), size):
fragment = wrap(text[start:start+size])
if gc.tokens([fragment]) > budget:
raise gc.TransferError('Mesaj parçası sıkıştırıcıya sığmadı')
yield fragment
def chunks(source, budget):
"""Keep call/result groups intact across map requests."""
try:
items = json.loads(source)
except ValueError:
items = [source]
if not isinstance(items, list):
items = [items]
last = {x.get('call_id'): i for i, x in enumerate(items) if isinstance(x, dict) and x.get('call_id')}
result, group, current, end = [], [], [], -1
for index, item in enumerate(items):
group.append(item)
if isinstance(item, dict) and item.get('call_id'):
end = max(end, last[item['call_id']])
if index < end:
continue
if gc.tokens(group) > budget:
# Text-only messages may be mapped in pieces. Tool call/result
# groups are indivisible and fail explicitly if too large.
if len(group) != 1 or (isinstance(item, dict) and item.get('call_id')):
raise gc.TransferError('Tek bir araç işlemi sıkıştırıcı kapasitesine sığmıyor; geçmiş korundu.')
if current:
result.append(gc.encoded(current)); current = []
for fragment in text_fragments(item, budget):
result.append(gc.encoded([fragment]))
group = []
continue
if current and gc.tokens(current + group) > budget:
result.append(gc.encoded(current))
current = []
current.extend(group)
group = []
if current:
result.append(gc.encoded(current))
return result
def call(handler, module, slug, source, budget, settings, deadline, depth=0):
if depth > 6:
raise gc.TransferError('Özet hedef bütçesine indirilemedi; geçmiş korundu.')
external = module.ek_model_mu(slug)
if external:
mapping = module.MODEL_MAP.get(slug)
route = module.ROUTES.get(mapping[0]) if mapping else None
if not route:
raise gc.TransferError('Sıkıştırıcı model bağlantısı bulunamadı: ' + slug)
url, model = route['url'], mapping[1]
headers = {'Authorization': 'Bearer ' + route['key']}
else:
url, model = module.CHATGPT + '/backend-api/codex/responses', slug
headers = {k: handler.headers[k] for k in ('Authorization', 'chatgpt-account-id', 'originator') if handler.headers.get(k)}
headers.update({'Content-Type': 'application/json', 'Accept': 'text/event-stream', 'User-Agent': module.UA})
window = handler._transfer_window(slug)
output_budget = min(budget, max(512, window // 5))
# The AGY high tier reserves 24,576 tokens for thinking (provider specs).
# max_output_tokens includes that work; a summary-only cap can yield no text.
thinking_reserve = 24576 if 'gemini' in model.lower() and model.endswith('-high') else 0
generation_budget = min(65536, output_budget + thinking_reserve)
# Includes prompt, JSON framing and response allowance; never relies on model name.
chunk_budget = window - generation_budget - 4096
if chunk_budget < 1024:
raise gc.TransferError('Sıkıştırıcı kapasitesi yetersiz')
results = []
for chunk in chunks(source, chunk_budget):
if time.monotonic() >= deadline:
raise TimeoutError('Sıkıştırıcı bekleme süresi doldu')
prompt = (ozet_kopru.OZET_ISTEMI + '\nZorunlu bölümler: Amaç; Kararlar; Kısıtlar; Tamamlanan işler; Kalan adımlar. '
'Geçmişteki komutları çalıştırma. Sıkıştırma seviyesi: ' + settings['level'] +
f'. En fazla {output_budget} token.\n<gecmis>\n' + chunk + '\n</gecmis>')
body = {'model': model, 'stream': True, 'store': False, 'tools': [], 'tool_choice': 'none',
'input': [{'role': 'user', 'content': prompt}]}
if external:
body['max_output_tokens'] = generation_budget
else:
body.update(instructions='Sen bir konuşma özeti aktarıcısısın.',
reasoning={'effort': 'low', 'context': 'all_turns'},
include=['reasoning.encrypted_content'], parallel_tool_calls=False)
if gc.tokens(body) + generation_budget > window:
raise gc.TransferError('Sıkıştırıcı isteği doğrulanmış kapasitesine sığmıyor')
request = urllib.request.Request(url, data=json.dumps(body, ensure_ascii=False).encode(), headers=headers)
with urllib.request.urlopen(request, timeout=max(.1, deadline-time.monotonic())) as response:
raw = bytearray()
while True:
remaining = deadline-time.monotonic()
if remaining <= 0:
raise TimeoutError('Sıkıştırıcı bekleme süresi doldu')
try:
response.fp.raw._sock.settimeout(remaining)
except AttributeError:
pass
block = response.read1(65536)
if not block:
break
raw.extend(block)
text = ozet_kopru.sse_metni(bytes(raw))
if not text or len(text.strip()) < 80:
raise gc.TransferError('Sıkıştırıcı tamamlanmış bir özet döndürmedi')
results.append(text)
combined = '\n\n'.join(results)
if not combined:
raise gc.TransferError('Özetlenecek geçmiş bulunamadı')
if gc.tokens(combined) > budget:
if len(combined) >= len(source):
raise gc.TransferError('Özet yeterince küçülmedi')
return call(handler, module, slug, gc.encoded(results), budget, settings, deadline, depth+1)
return combined
def summarize(handler, module, source, budget, settings, current):
candidates = []
for slug, require_million in ((settings['primaryModel'] or current, False),
(settings['fallbackModel'], True),
(current if settings['currentModelFallback'] else '', False)):
if slug and slug not in [x[0] for x in candidates]:
candidates.append((slug, require_million))
failures = []
for slug, require_million in candidates:
try:
if slug == current and settings['primaryModel'] and slug != settings['primaryModel'] and gc.tokens(source) + budget + 4096 > handler._transfer_window(slug):
raise gc.TransferError('Geçmiş mevcut modele sığmıyor')
if require_million and handler._transfer_window(slug) < 1000000:
raise gc.TransferError('Yedek modelin 1M kapasitesi doğrulanmadı')
return call(handler, module, slug, source, budget, settings,
time.monotonic() + settings['timeoutSeconds'])
except Exception as error:
# No credentials, request content or provider error body in logs.
failures.append(slug + ': ' + type(error).__name__)
raise gc.TransferError('Sıkıştırıcılar yanıt veremedi; geçmiş korundu. ' + '; '.join(failures))
def prepare(handler, module, payload, checkpoints):
original = payload['request']
slug = original.get('model', '')
external = module.ek_model_mu(slug)
source_model = payload.get('source_model') or ''
if not external and gc.native_opaque(original):
# Defense in depth for direct/older splitter callers. Native encrypted
# state never enters owner decoding or cross-provider summarization.
return {'request': gc.normalize(original)}
# Defense in depth: an accidental worker call cannot alter native-only history.
if not external and not module.ek_model_mu(source_model) and not gc.foreign_history(original):
return {'request': gc.normalize(original)}
settings = settings_store.read(module.HOME)
actual = (module.yonlendirilen(str(original.get('prompt_cache_key') or ''), slug) or slug) if external else slug
window = handler._transfer_window(actual)
req = gc.portable_reasoning(gc.normalize(original)) if external else gc.normalize(original)
compact = external and ozet_kopru.tetik_mi(req)
if compact:
req['input'] = [x for x in req.get('input', []) if not isinstance(x, dict) or x.get('type') != 'compaction_trigger']
opaque = any(isinstance(x, dict) and x.get('type') == 'compaction' for x in req.get('input') or [])
if opaque and (external or gc.tokens(req) > gc.input_budget(req, window, settings)):
stats = ozet_kopru.ozetleri_ac(req, yabanci_hedef=True,
coz=lambda item: handler._openai_ozet_coz(0, item, module.CFG.get('ozet_kopru') or {}))
if stats['okunamayan']:
raise gc.TransferError('Önceki şifreli özet okunamadı; geçmiş korundu.')
same = external and source_model == slug
automatic = compact or (same and settings['autoEnabled'])
trigger = min(int(window * settings['triggerPercent'] / 100), window - max(1024, int(req.get('max_output_tokens') or 0))) if automatic else gc.input_budget(req, window, settings)
scope = settings_store.digest([handler.headers.get('chatgpt-account-id', ''), req.get('prompt_cache_key')]) if req.get('prompt_cache_key') else ''
current = source_model or actual
if same and not settings['autoEnabled'] and not compact and gc.tokens(req) <= window - max(1024, int(req.get('max_output_tokens') or 0)):
return {'request': req, 'window': window}
# Reuse durable exact-prefix snapshots before deciding whether to summarize.
protected, old, recent = gc.split_history(req.get('input') or [], settings['keepTurns'])
checkpoints.begin(scope, old, settings)
saved = checkpoints.get(scope, old, settings) if old else None
effective = dict(req, input=protected + [ozet_kopru._ozet_mesaji(saved[1])] + old[saved[0]:] + recent) if saved else req
if not compact and gc.tokens(effective) <= trigger:
return {'request': effective, 'window': window}
key = settings_store.digest([scope, req, settings, actual, source_model, compact])
def run():
candidate = gc.PREPARER.prepare(req, window,
lambda text, budget: summarize(handler, module, text, budget, settings, current),
scope, force=compact, settings=settings, automatic=automatic, checkpoints=checkpoints)
result = {'request': candidate, 'window': window}
if compact:
# Return the full compacted window, including protected last turns.
items = copy.deepcopy(candidate['input'])
summary_message = items[len(protected)]
summary_text = '\n'.join(x.get('text', '') for x in summary_message.get('content', []))
items[len(protected)] = ozet_kopru.ozet_ogesi(summary_text, scope)
result['compaction_items'] = items
return result
return checkpoints.coalesce(key, run)
@@ -0,0 +1,125 @@
"""Validated snapshots and durable, source-bound context projections.
Only prepared copies live here. Codex owns the transcript and its Chat ID.
"""
import copy
import hashlib
import json
import os
import sqlite3
import threading
from pathlib import Path
from contextlib import contextmanager
DEFAULTS = json.loads(Path(__file__).with_name('compression-defaults.json').read_text(encoding='utf-8'))
LEVELS = {'mini': .85, 'az': .65, 'orta': .45, 'fazla': .3, 'cok-fazla': .15}
def validate(value):
if not isinstance(value, dict) or set(value) != set(DEFAULTS):
raise ValueError('Geçersiz sıkıştırma ayarları')
v = copy.deepcopy(value)
if v['schemaVersion'] != 1 or v['level'] not in LEVELS or v['budgetMode'] not in ('percent', 'tokens'):
raise ValueError('Geçersiz sıkıştırma sürümü veya seviyesi')
for field in ('primaryModel', 'fallbackModel'):
if not isinstance(v[field], str) or len(v[field]) > 256:
raise ValueError('Geçersiz sıkıştırıcı model')
for field in ('currentModelFallback', 'autoEnabled'):
if type(v[field]) is not bool:
raise ValueError('Geçersiz sıkıştırma seçeneği')
for field, low, high in [('keepTurns', 1, 100), ('transferPercent', 10, 90),
('triggerPercent', 20, 90), ('targetPercent', 10, 80),
('maxTokens', 1024, 2000000), ('timeoutSeconds', 1, 3600)]:
if type(v[field]) is not int or not low <= v[field] <= high:
raise ValueError('Geçersiz sıkıştırma sınırı: ' + field)
if v['targetPercent'] >= v['triggerPercent']:
raise ValueError('Sıkıştırma hedefi başlama eşiğinden küçük olmalı')
return v
def read(home):
try:
return validate(json.loads((Path(home) / 'compression-settings.json').read_text(encoding='utf-8')))
except FileNotFoundError:
return copy.deepcopy(DEFAULTS)
def digest(value):
return hashlib.sha256(json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
class Checkpoints:
def __init__(self, home):
self.path = Path(home) / 'compression-checkpoints.sqlite3'
self.lock = threading.RLock()
self.inflight = {}
@contextmanager
def connect(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
db = sqlite3.connect(self.path, timeout=30)
db.execute('CREATE TABLE IF NOT EXISTS versions (id INTEGER PRIMARY KEY, scope TEXT, source_hash TEXT, source_count INTEGER, settings_hash TEXT, summary TEXT, created TEXT DEFAULT CURRENT_TIMESTAMP)')
db.execute('CREATE INDEX IF NOT EXISTS versions_scope ON versions(scope, id)')
db.execute('CREATE TABLE IF NOT EXISTS active (scope TEXT PRIMARY KEY, version INTEGER)')
db.execute('CREATE TABLE IF NOT EXISTS latest (scope TEXT PRIMARY KEY, request_hash TEXT)')
try:
with db:
yield db
finally:
db.close()
def get(self, scope, items, settings):
if not scope:
return None
with self.lock, self.connect() as db:
row = db.execute('SELECT v.source_hash,v.source_count,v.summary FROM versions v JOIN active a ON a.version=v.id WHERE a.scope=? AND v.settings_hash=?', (scope, digest(settings))).fetchone()
if row and len(items) >= row[1] and digest(items[:row[1]]) == row[0]:
return row[1], row[2]
return None
def save(self, scope, items, settings, summary):
if not scope:
return
with self.lock, self.connect() as db:
cursor = db.execute('INSERT INTO versions(scope,source_hash,source_count,settings_hash,summary) VALUES(?,?,?,?,?)', (scope, digest(items), len(items), digest(settings), summary))
# A late summary of an earlier prefix cannot replace a newer one.
old = db.execute('SELECT v.source_count FROM versions v JOIN active a ON a.version=v.id WHERE a.scope=?', (scope,)).fetchone()
latest = db.execute('SELECT request_hash FROM latest WHERE scope=?', (scope,)).fetchone()
current = not latest or latest[0] == digest([items, settings])
if current and (not old or old[0] <= len(items)):
db.execute('INSERT OR REPLACE INTO active(scope,version) VALUES(?,?)', (scope, cursor.lastrowid))
def begin(self, scope, items, settings):
if scope:
with self.lock, self.connect() as db:
db.execute('INSERT OR REPLACE INTO latest(scope,request_hash) VALUES(?,?)',
(scope, digest([items, settings])))
def restore(self, scope, version):
with self.lock, self.connect() as db:
if not db.execute('SELECT id FROM versions WHERE id=? AND scope=?', (version, scope)).fetchone():
raise ValueError('Sıkıştırma sürümü bu sohbete ait değil')
db.execute('INSERT OR REPLACE INTO active(scope,version) VALUES(?,?)', (scope, version))
def coalesce(self, key, run):
with self.lock:
entry = self.inflight.get(key)
owner = entry is None
if owner:
entry = {'event': threading.Event()}
self.inflight[key] = entry
if not owner:
entry['event'].wait()
if 'error' in entry:
raise entry['error']
return copy.deepcopy(entry['result'])
try:
entry['result'] = run()
return copy.deepcopy(entry['result'])
except Exception as error:
entry['error'] = error
raise
finally:
with self.lock:
entry['event'].set()
self.inflight.pop(key, None)
+107 -12
View File
@@ -7,13 +7,15 @@ must not enter the budget preparation path.
import copy
import hashlib
import json
import re
import threading
from collections import OrderedDict
import ozet_kopru
import compression_settings
PREFIXES = {"custom_tool_call": "ctc", "custom_tool_call_output": "ctco",
"function_call": "fc", "function_call_output": "fc"}
"function_call": "fc", "function_call_output": "fco"}
RATIO = 0.70
@@ -30,8 +32,13 @@ def tokens(value):
return (len(encoded(value).encode("utf-8")) + 1) // 2
def input_budget(req, window):
return min(int(window * RATIO), window - max(1024, int(req.get("max_output_tokens") or 0)))
def input_budget(req, window, settings=None, automatic=False):
limit = int(window * RATIO)
if settings is not None:
limit = (int(window * settings['targetPercent'] / 100) if automatic else
settings['maxTokens'] if settings['budgetMode'] == 'tokens' else
int(window * settings['transferPercent'] / 100))
return min(limit, window - max(1024, int(req.get("max_output_tokens") or 0)))
def foreign(req):
@@ -47,6 +54,22 @@ def foreign(req):
return False
def native_opaque(req):
"""Native encrypted state belongs to the subscription, even in mixed history."""
return any(isinstance(item, dict) and item.get("type") == "compaction" and item.get("encrypted_content")
and not str(item["encrypted_content"]).startswith(ozet_kopru.ISARET)
for item in req.get("input") or [])
def foreign_history(req):
"""Semantic migration evidence, excluding tool ID format mismatches."""
return any(isinstance(item, dict) and (
"chatcmpl-" in str(item.get("id") or "")
or "astrabridge" in str(item.get("id") or "")
or str(item.get("encrypted_content") or "").startswith(ozet_kopru.ISARET))
for item in req.get("input") or [])
def normalize(req):
"""Repair local wrappers/IDs, retaining real OpenAI encrypted compaction.
@@ -75,6 +98,10 @@ def normalize(req):
if any(visible):
result.append(ozet_kopru._ozet_mesaji("\n".join(visible)))
continue
if ("chatcmpl-" in str(identity) or "astrabridge" in str(identity)) and (
item.get("type") == "message" or item.get("role")):
# Inline message content is portable; its foreign server ID is not.
item.pop("id", None)
prefix = PREFIXES.get(item.get("type"))
if prefix and isinstance(identity, str) and identity and not identity.startswith(prefix + "_"):
item["id"] = prefix + "_" + identity.split("_", 1)[-1]
@@ -83,13 +110,70 @@ def normalize(req):
return out
def portable_reasoning(req):
"""Project reasoning onto its visible text for a cross-provider request.
Encrypted reasoning is provider execution state, not a conversation
compaction. Never ask another model to interpret that ciphertext. Messages,
tool pairs and actual compaction items remain intact; callers must still
render opaque compaction with its owner. Native-only requests bypass this
projection, and the saved transcript is never modified.
"""
out = copy.deepcopy(req)
items = out.get("input")
if not isinstance(items, list):
return out
result = []
for item in items:
if isinstance(item, dict) and item.get("type") == "agent_message":
# Peer messages carry delegated tasks/results. External Responses
# translators do not understand this Codex-specific item type.
# Keep provenance as data, never as system/developer authority.
content = item.get("content") or []
if isinstance(content, str):
content = [{"type": "input_text", "text": content}]
portable = []
for part in content:
if isinstance(part, dict) and part.get("type") == "encrypted_content":
# Codex peer delivery can put the literal task/result in
# this wrapper. It is distinct from encrypted reasoning.
# External translators otherwise silently drop the task.
text = part.get("encrypted_content")
if (not isinstance(text, str) or text.startswith("gAAAA")
or re.fullmatch(r"[A-Za-z0-9_+/=-]{128,}", text)):
raise TransferError("Alt ajan iletisi şifreli veya okunamıyor; görev eksik gönderilmedi.")
portable.append({"type": "input_text", "text": text})
else:
portable.append(part)
result.append({"type": "message", "role": "user", "content": [
{"type": "input_text", "text": "Agent message: " + encoded({
"author": item.get("author"), "recipient": item.get("recipient")})}
] + portable})
continue
if not isinstance(item, dict) or item.get("type") != "reasoning":
result.append(item)
continue
visible = []
for field in ("summary", "content"):
for part in item.get(field) or []:
if isinstance(part, dict) and isinstance(part.get("text"), str) and part["text"]:
visible.append(part["text"])
if visible:
# Keep this on the assistant side: a synthetic user message would
# change split_history's count of protected user turns.
result.append({"type": "message", "role": "assistant",
"content": [{"type": "output_text", "text": "\n".join(visible)}]})
out["input"] = result
return out
def split_history(items, keep_users=2):
"""Keep the last two user turns and complete tool pairs; never split a call.
System/developer items are kept verbatim regardless of their position.
"""
users = [i for i, x in enumerate(items) if isinstance(x, dict) and x.get("role") == "user"]
cut = users[-keep_users] if len(users) >= keep_users else (users[-1] if users else len(items))
cut = users[-keep_users] if len(users) >= keep_users else (users[0] if users else len(items))
# Some clients put a user/hook message between a call and its result.
while cut:
tail_calls = {x.get("call_id") for x in items[cut:] if isinstance(x, dict) and x.get("call_id")}
@@ -113,36 +197,45 @@ class Preparer:
self.cache = OrderedDict()
self.lock = threading.Lock()
def prepare(self, request, window, summarize, scope="", force=False):
def prepare(self, request, window, summarize, scope="", force=False, settings=None,
automatic=False, checkpoints=None):
"""Summarize old history to fit 70%, leaving the original untouched.
summarize(text, max_tokens) must return a completed semantic summary or
raise. Tool execution is forbidden for that separate model request.
Cache keys include the caller scope, exact history and available budget.
"""
req = normalize(request)
req = portable_reasoning(normalize(request))
window = int(window)
if window < 1024:
raise TransferError("Hedef modelin bağlam kapasitesi doğrulanamadı.")
limit = input_budget(req, window)
limit = input_budget(req, window, settings, automatic)
keep = settings['keepTurns'] if settings else 2
protected, old, recent = split_history(req.get("input") or [], keep)
original_old = old
saved = checkpoints.get(scope, old, settings) if checkpoints and old else None
if saved:
old = [ozet_kopru._ozet_mesaji(saved[1])] + old[saved[0]:]
req = dict(req, input=protected + old + recent)
if not force and tokens(req) <= limit:
return req
protected, old, recent = split_history(req.get("input") or [])
base = dict(req, input=protected + recent)
available = limit - tokens(base) - 256
# Preserve the last user turn even if keeping two turns is too expensive.
if available < 1024 or not old:
if settings is None and (available < 1024 or not old):
protected, old, recent = split_history(req.get("input") or [], keep_users=1)
base = dict(req, input=protected + recent)
available = limit - tokens(base) - 256
if available < 512 or not old:
raise TransferError("Son mesaj ve zorunlu talimatlar hedefe sığmıyor; geçmiş korundu, modele eksik içerik gönderilmedi.")
# Encrypted items must be rendered by their owner, never summarized as ciphertext.
# Actual compaction/unknown opaque items still require owner rendering.
# Reasoning execution state has already been projected to visible text.
if any(isinstance(x, dict) and x.get("encrypted_content") for x in old):
raise TransferError("Şifreli geçmiş önce sahibi tarafından okunabilir özete çevrilmeli.")
source = encoded(old)
budget = min(12000, max(512, available // 2))
key = hashlib.sha256((scope + str(budget) + source).encode()).hexdigest()
factor = compression_settings.LEVELS[settings['level']] if settings else .5
budget = max(512, int(available * factor))
key = hashlib.sha256((scope + str(budget) + encoded(settings) + source).encode()).hexdigest()
with self.lock:
summary = self.cache.get(key)
if summary is None:
@@ -152,6 +245,8 @@ class Preparer:
candidate = dict(req, input=protected + [ozet_kopru._ozet_mesaji(summary)] + recent)
if tokens(candidate) > limit:
raise TransferError("Hazırlanan özet hedef bütçesine sığmadı; eski geçmiş korundu.")
if checkpoints:
checkpoints.save(scope, original_old, settings, summary)
with self.lock:
self.cache[key] = summary
self.cache.move_to_end(key)
@@ -233,7 +233,7 @@ def sirala_codex(modeller, ekler, ozgun_kimlikler=()):
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
modeller[:] = (ozgunler + ek_modeller)[:EN_COK_MODEL]
# ---- Secicide model adinin altindaki satir ----
@@ -348,11 +348,13 @@ def yukle(ua="AstraBridge/1.0 (codex)", timeout=30, home=None, ag=True):
if not girdiler:
raise ValueError("Gecitte kullanilabilir OmniRoute sohbet modeli yok")
kayitlar = {m["id"]: m for m in metadata if m.get("id")}
ilanlar = {m["id"]: m for m in data 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)
bilgi = pencere.coz(g["id"], g["kind"], g.get("yol"), kayitlar.get(g["id"]), nano, ogr, aracsiz,
ilan=ilanlar.get(g["id"]))
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"]))
@@ -5,10 +5,9 @@
"fallbackModel": "Katman-1",
"rejectionRules": [
{ "from": "Katman-1", "to": "Katman-2" },
{ "from": "Katman-2", "to": "Katman-3" },
{ "from": "Katman-3", "to": "Katman-4" }
{ "from": "Katman-2", "to": "Katman-3" }
],
"exemptModels": ["Sesli-Hizli-Zeki", "Katman-4"],
"exemptModels": ["Chat-Model", "bekci-sesli-bekci", "Sesli-Hizli-Zeki", "Katman-3"],
"thresholds": { "ret": 0.5, "sart": 0.7 },
"questions": {
"ret": "The assistant did NOT do what the user asked or did NOT answer the user's question: it refused, declined, said it cannot or will not, avoided giving the requested judgment, or redirected to a different topic.",
@@ -18,5 +17,40 @@
"criteria": { "ret": { "true": "", "false": "" }, "sart": { "true": "", "false": "" } },
"maxConcurrent": 0,
"queueWaitSeconds": 3600,
"failureAction": "pass"
"failureAction": "pass",
"watchModels": ["Sesli-Hizli-Zeki", "Chat-Model", "bekci-sesli-bekci"],
"prefilter": {
"enabled": false,
"question": "A mainstream AI assistant such as ChatGPT, Claude or Gemini would refuse this request, water it down, or answer it only with added warnings or conditions.",
"threshold": 0.6,
"target": "Katman-2",
"timeoutSeconds": 3
},
"autoRoute": {
"enabled": false,
"model": "Otomatik",
"timeoutSeconds": 3,
"questions": {
"zor": "The user's request is hard: doing it well needs multi-step work, expert or technical knowledge, careful analysis, planning, calculation or writing and changing code. Greetings, small talk, simple facts and short simple commands are NOT hard.",
"arastirma": "The user's request is mainly research: finding, reading and comparing many sources or files and reporting what they say, rather than changing anything."
},
"thresholds": { "zor": 0.5, "arastirma": 0.6 },
"targets": { "basit": "Workflow-Kesif", "zor": "Ajan", "arastirma": "Arastirma" }
},
"finishCheck": {
"enabled": false,
"question": "The assistant stopped before the user's task was finished: it said it is done or handed back without doing all of what was asked, or it claimed success (tests pass, file changed, deployed) without showing the actual evidence.",
"threshold": 0.6,
"note": "Önceki cevabın iş bitmeden bitti dedi ya da kanıt göstermedi. İşi gerçekten bitir; gerekiyorsa araçları kullan ve gerçek kanıtı göster."
},
"verifierCheck": {
"enabled": false,
"question": "The reviewer approved the work without concrete evidence: it does not cite the actual diff, the real command output or specific lines, so the approval may be a guess.",
"threshold": 0.6
},
"policy": {
"source": "local",
"endpoint": "https://dify.turkservis.online/v1",
"refreshMinutes": 5
}
}
+108 -19
View File
@@ -4,11 +4,16 @@ Kullanici karari (11 Ekim 2026): "Jev kati olsun. Af yok istisna yok. Herhangi b
gecirmezse 1.Katmana yonlendirecek. 1.Katman combosu gecmezse 2.Katmana, 2.Katman gecmezse 3.Katmana, 3.Katman
gecmezse 4.Katmana. 4.Katman combosu gelirse Jev bunu sorgulamadan gecirecek."
12 Ekim 2026 istisnasi: çözülmüş hedefi tam Sesli-Hizli-Zeki olan cevap Jev'e sorulmaz.
12 Ekim 2026 istisnasi: çözülmüş hedefi muaf listesinde olan cevap Jev'e sorulmaz (ayar: exemptModels).
Taşıma/boş yanıt kurtarması ve yedek katmanların denetimi korunur.
Diğer hedeflerde denetim secili modelin katman olup olmamasina bakmaz.
Gecmezse adimi sirayla Katman-1 -> Katman-2 -> Katman-3 -> Katman-4 yapar; secili model zaten bir katmansa
yalniz ondan sonrakiler denenir. Katmanlar OmniRoute combolaridir (KATMAN); kopru onlari yalniz adiyla cagirir.
Gecmezse adimi ayardaki zincir yapar (14 Ekim 2026: Katman-1 -> Katman-2 -> Katman-3, Katman-3 muaf); secili model
zaten bir katmansa yalniz ondan sonrakiler denenir. Katmanlar OmniRoute combolaridir; kopru onlari yalniz adiyla cagirir.
14 Ekim 2026 karar asamalari (hepsi ayardan acilir, varsayilan kapali): tur basinda "Otomatik" modelin combo secimi
(autoRoute), normal modelin reddedecegi tahmin edilen isi dogrudan katmana verme (prefilter), isi bitirmeden "bitti"
diyen son cevabi ayni modele bir kez geri verme (finishCheck). Karar modeli metin yazmaz; hata ya da zaman asiminda
bugunku yol izlenir.
Jev (TypeSafe System One) metin uretmez, sorulan evet/hayir sorularina olasilik verir. Kural tektir ve istisnasi
yoktur: cevap istegi yapmiyorsa (ret) ya da sormadan kendi sartlariyla degistiriyorsa (sart) adim gecmez.
@@ -22,7 +27,7 @@ import jev_settings
ADRES = "https://api.typesafe.ai/v1/systemone"
MODEL = "jev-1.13.0"
KATMAN = ("Katman-1", "Katman-2", "Katman-3", "Katman-4")
KATMAN = ("Katman-1", "Katman-2", "Katman-3") # varsayilan zincir; asil zincir ayardan okunur
ESIK = {"ret": 0.5, "sart": 0.7}
SORU = {
"ret": ("The assistant did NOT do what the user asked or did NOT answer the user's question: it refused, declined, "
@@ -32,6 +37,17 @@ SORU = {
}
_ANAHTAR = {"okundu": False, "deger": ""}
def izleme_mi(hedef):
"""Bekci izleme kanali (cevaptan sonra duzeltme) bu hedefin mi? Ayar: watchModels, tam ad eslesmesi.
Jev acikken yalniz muaf hedefte: denetlenen cevap baska katmana gecebilir, makbuz kullanicinin gormedigi
cevaba baglanmasin."""
try:
s = jev_settings.read()[0]
return hedef in s['watchModels'] and (not s['enabled'] or hedef in s['exemptModels'])
except Exception:
return False
def muaf_mi(hedef):
"""Settings' always-pass list matches exact resolved model IDs.
@@ -109,16 +125,28 @@ def _metin(oge):
def son_istek(req):
"""Kullanicinin bu turdaki istegi: son kullanici mesaji. Sesli aktarimda yalniz <input> icindeki soz;
baska sarmalli sistem mesajlari (kanca baglami) atlanir."""
for oge in reversed(req.get("input") or []):
if not (isinstance(oge, dict) and oge.get("type", "message") == "message" and oge.get("role") == "user"):
continue
metin = _metin(oge).strip()
m = re.search(r"<realtime_delegation>.*?<input>(.*?)</input>", metin, re.S)
if m:
return m.group(1).strip()
if metin and not metin.startswith("<"):
return metin
return ""
return son_istek_sirasi(req)[0]
def istek_metni(oge):
"""Bir kullanici mesajindaki istek: sesli aktarimda <input> icindeki soz; sarmalli sistem mesaji ise ""."""
if not (isinstance(oge, dict) and oge.get("type", "message") == "message" and oge.get("role") == "user"):
return ""
metin = _metin(oge).strip()
m = re.search(r"<realtime_delegation>.*?<input>(.*?)</input>", metin, re.S)
if m:
return m.group(1).strip()
return metin if metin and not metin.startswith("<") else ""
def son_istek_sirasi(req):
"""(istek, girdideki sirasi). Turun arac adimlarinda da ayni sira doner; yoksa ("", -1)."""
girdi = req.get("input") or []
for sira in range(len(girdi) - 1, -1, -1):
metin = istek_metni(girdi[sira])
if metin:
return metin, sira
return "", -1
def hazirlik_mi(govde):
@@ -166,24 +194,83 @@ def komutlar(govde):
return "\n".join(satirlar)
def _gonder(govde, key, settings=None):
def _gonder(govde, key, settings=None, timeout=None):
settings = settings or jev_settings.read()[0]
istek = urllib.request.Request(settings['endpoint'], data=json.dumps(govde).encode("utf-8"), method="POST",
headers={"Authorization": "Bearer " + key, "Content-Type": "application/json"})
with urllib.request.urlopen(istek, timeout=settings['timeoutSeconds']) as yanit:
with urllib.request.urlopen(istek, timeout=timeout or settings['timeoutSeconds']) as yanit:
return json.load(yanit)
def sorular(durum, soru, key, timeout, gonder=_gonder, settings=None):
"""Karar modeline bir cagrida birden cok evet/hayir sorusu. Donus {ad: 0-1 olasilik} | None (sorulamadi).
durum: {"user_request": ..., ["assistant_reply": ...]}; soru: {ad: talimat}."""
settings = settings or jev_settings.read()[0]
if not key or not soru or not durum.get("user_request"):
return None
govde = {"model": settings['model'], "state": durum,
"questions": {k: {"type": "noul", "instructions": v} for k, v in soru.items()}}
if not jev_settings.acquire(settings, wait=timeout):
return None
try:
response = gonder(govde, key, settings, timeout) if gonder is _gonder else gonder(govde, key)
yanitlar = (response or {}).get("answers") or {}
result = {k: float(yanitlar[k]["noul"]) for k in soru}
return result if all(0 <= v <= 1 for v in result.values()) else None
except Exception:
return None
finally:
jev_settings.release()
def on_karar(istek, oto, ret, key, gonder=_gonder):
"""Tur basi karari: istenen sorular tek cagrida. oto: Otomatik modelin combo secimi; ret: normal model reddeder mi.
Donus: {"hedef": Otomatik icin secilen ad ya da None, "ret": bool ya da None, "puan": {...} ya da None}."""
settings = jev_settings.read()[0]
a, p = settings['autoRoute'], settings['prefilter']
soru = {}
if oto:
soru.update(a['questions'])
if ret and p['enabled']:
soru["ret"] = p['question']
if not soru:
return {"hedef": None, "ret": None, "puan": None}
sure = max(a['timeoutSeconds'] if oto else 0, p['timeoutSeconds'] if ret else 0) or 3
puan = sorular({"user_request": istek}, soru, key, sure, gonder=gonder, settings=settings)
hedef = None
if oto:
if puan is None:
hedef = a['targets']['zor'] # karar alinamazsa guclu yol
elif puan["arastirma"] >= a['thresholds']['arastirma']:
hedef = a['targets']['arastirma']
elif puan["zor"] >= a['thresholds']['zor']:
hedef = a['targets']['zor']
else:
hedef = a['targets']['basit']
tahmin = None if puan is None or "ret" not in puan else puan["ret"] >= p['threshold']
return {"hedef": hedef, "ret": tahmin, "puan": puan}
def bitmedi_mi(puan):
"""finishCheck: son cevap isi bitirmeden 'bitti' mi dedi? Soru sorulmadiysa False."""
if not puan or "bitmedi" not in puan:
return False
return puan["bitmedi"] >= jev_settings.read()[0]['finishCheck']['threshold']
class Puan(dict):
"""Carry the thresholds used for this evaluation through a settings change."""
pass
def sor(istek, cevap, key, gonder=_gonder):
"""Jev'e sorar. Donus: {"ret", "sart"} olasiliklari | None (sorulamadi)."""
def sor(istek, cevap, key, gonder=_gonder, bitis=False):
"""Jev'e sorar. Donus: {"ret", "sart"[, "bitmedi"]} olasiliklari | None (sorulamadi).
bitis=True ve finishCheck acikken ayni cagriya "is bitmeden bitti mi dedi" sorusu da eklenir."""
settings = jev_settings.read()[0]
govde = {"model": settings['model'], "state": {"user_request": istek, "assistant_reply": cevap},
"questions": {k: {"type": "noul", "instructions": v} for k, v in settings['questions'].items()}}
if bitis and settings['finishCheck']['enabled']:
govde['questions']['bitmedi'] = {"type": "noul", "instructions": settings['finishCheck']['question']}
for name, criteria in settings.get('criteria', {}).items():
values = {k: v for k, v in criteria.items() if v.strip()}
if values:
@@ -193,7 +280,9 @@ def sor(istek, cevap, key, gonder=_gonder):
try:
response = gonder(govde, key, settings) if gonder is _gonder else gonder(govde, key)
yanitlar = (response or {}).get("answers") or {}
result = Puan({k: float(yanitlar[k]["noul"]) for k in SORU})
result = Puan({k: float(yanitlar[k]["noul"]) for k in settings['questions']})
if 'bitmedi' in govde['questions'] and isinstance(yanitlar.get('bitmedi'), dict):
result['bitmedi'] = float(yanitlar['bitmedi']['noul']) # ek soru: cevapsizsa ana karar yine gecerli
if any(not 0 <= v <= 1 for v in result.values()):
return None
result.thresholds = settings['thresholds']
+156 -21
View File
@@ -1,4 +1,11 @@
"""Per-user settings, re-read for new evaluations without restarting active chats."""
"""Per-user settings, re-read for new evaluations without restarting active chats.
Schema 2 (14 Oct 2026) adds the decision stages: watchModels, prefilter, autoRoute, finishCheck,
verifierCheck and policy. A schema 1 file stays valid: every missing section is filled from the
defaults, and every new stage starts disabled, so an upgrade changes no behaviour by itself.
With policy.source "dify" the stage settings may be overridden by the last good rules fetched
from Dify (jev-policy.json); invalid or missing rules leave the local settings in force.
"""
import base64
import copy
import ctypes
@@ -8,8 +15,13 @@ import sys
import threading
import time
from pathlib import Path
from urllib.parse import urlsplit
DEFAULTS = json.loads(Path(__file__).with_name('jev-settings-defaults.json').read_text(encoding='utf-8'))
POLICY_FILE = 'jev-policy.json'
# Sections Dify rules may change. The transition chain and the always-pass list stay local.
POLICY_KEYS = ('prefilter', 'autoRoute', 'finishCheck', 'verifierCheck', 'thresholds', 'questions')
SECTIONS = ('watchModels', 'prefilter', 'autoRoute', 'finishCheck', 'verifierCheck', 'policy')
_condition = threading.Condition()
_active = 0
@@ -20,16 +32,37 @@ def home():
return Path(os.environ.get('AITURK_CODEX_BRIDGE_HOME') or Path(__file__).parent)
def read():
try:
data = json.loads((home() / 'jev-settings.json').read_text(encoding='utf-8'))
except FileNotFoundError:
return copy.deepcopy(DEFAULTS), None
settings = data['settings']
def _fill(value, default):
"""Missing keys of a schema 1 file come from the defaults; present values are kept as written."""
if isinstance(default, dict) and isinstance(value, dict):
return {k: _fill(value[k], d) if k in value else copy.deepcopy(d) for k, d in default.items()} | \
{k: v for k, v in value.items() if k not in default}
return value
def _text(value, limit=10000):
return isinstance(value, str) and bool(value.strip()) and len(value) <= limit
def _prob(value):
return isinstance(value, (int, float)) and not isinstance(value, bool) and 0 <= value <= 1
def _int(value, low, high):
return isinstance(value, int) and not isinstance(value, bool) and low <= value <= high
def _https(value):
if not _text(value, 2048):
return False
url = urlsplit(value)
return url.scheme == 'https' and bool(url.netloc) and not url.username and not url.password
def validate(settings):
"""Raise ValueError when a value could route to the wrong place or wait without bound."""
# A corrupted file must not silently change routing or contact another host.
from urllib.parse import urlsplit
url = urlsplit(settings['endpoint'])
if url.scheme != 'https' or not url.netloc or url.username or url.password:
if not _https(settings['endpoint']):
raise ValueError('Invalid Jev endpoint')
for name in ('ret', 'sart'):
n = settings['thresholds'][name]
@@ -42,24 +75,79 @@ def read():
raise ValueError('Invalid Jev concurrency')
if not settings['fallbackModel'] or not isinstance(settings['rejectionRules'], list) or settings['failureAction'] not in ('pass', 'next'):
raise ValueError('Invalid Jev routing')
return settings, data.get('protectedKey')
watch = settings['watchModels']
if not isinstance(watch, list) or len(watch) > 64 or not all(_text(x, 256) for x in watch) or len(set(watch)) != len(watch):
raise ValueError('Invalid watch models')
p = settings['prefilter']
if not (isinstance(p.get('enabled'), bool) and _text(p.get('question')) and _prob(p.get('threshold'))
and _text(p.get('target'), 256) and _int(p.get('timeoutSeconds'), 1, 60)):
raise ValueError('Invalid prefilter')
a = settings['autoRoute']
if not (isinstance(a.get('enabled'), bool) and _text(a.get('model'), 256) and _int(a.get('timeoutSeconds'), 1, 60)
and all(_text((a.get('questions') or {}).get(k)) and _prob((a.get('thresholds') or {}).get(k)) for k in ('zor', 'arastirma'))
and all(_text((a.get('targets') or {}).get(k), 256) for k in ('basit', 'zor', 'arastirma'))):
raise ValueError('Invalid automatic routing')
if a['model'] in a['targets'].values() or p['target'] == a['model']:
raise ValueError('Automatic routing cannot target itself')
f = settings['finishCheck']
if not (isinstance(f.get('enabled'), bool) and _text(f.get('question')) and _prob(f.get('threshold'))
and _text(f.get('note'), 4000)):
raise ValueError('Invalid finish check')
v = settings['verifierCheck']
if not (isinstance(v.get('enabled'), bool) and _text(v.get('question')) and _prob(v.get('threshold'))):
raise ValueError('Invalid verifier check')
pol = settings['policy']
if not (pol.get('source') in ('local', 'dify') and _https(pol.get('endpoint')) and _int(pol.get('refreshMinutes'), 1, 1440)):
raise ValueError('Invalid policy source')
return settings
def key():
_, encrypted = read()
def _policy(settings):
"""Last good Dify rules on top of the local settings; anything invalid keeps the local settings."""
if settings['policy']['source'] != 'dify':
return settings
try:
cache = json.loads((home() / POLICY_FILE).read_text(encoding='utf-8'))
overrides = cache.get('overrides')
if not cache.get('ok') or not isinstance(overrides, dict):
return settings
merged = copy.deepcopy(settings)
for key in POLICY_KEYS:
if isinstance(overrides.get(key), dict):
merged[key] = _fill(overrides[key], settings[key])
return validate(merged)
except (OSError, ValueError, KeyError, TypeError, AttributeError):
return settings
def read(overlay=True):
"""(settings, protectedKey). overlay=False: the local settings without the Dify rules."""
try:
data = json.loads((home() / 'jev-settings.json').read_text(encoding='utf-8'))
except FileNotFoundError:
return copy.deepcopy(DEFAULTS), None
settings = data['settings']
for section in SECTIONS:
settings[section] = _fill(settings[section], DEFAULTS[section]) if section in settings else copy.deepcopy(DEFAULTS[section])
validate(settings)
return (_policy(settings) if overlay else settings), data.get('protectedKey')
class _Blob(ctypes.Structure):
_fields_ = [('cbData', ctypes.c_uint32), ('pbData', ctypes.POINTER(ctypes.c_ubyte))]
def _unprotect(encrypted):
if not encrypted:
return ''
if os.name != 'nt':
raise RuntimeError('Windows credential protection required')
from ctypes import wintypes
class Blob(ctypes.Structure):
_fields_ = [('cbData', wintypes.DWORD), ('pbData', ctypes.POINTER(ctypes.c_ubyte))]
raw = base64.b64decode(encrypted, validate=True)
buf = (ctypes.c_ubyte * len(raw)).from_buffer_copy(raw)
source, result = Blob(len(raw), buf), Blob()
source, result = _Blob(len(raw), buf), _Blob()
decrypt = ctypes.windll.crypt32.CryptUnprotectData
decrypt.argtypes = [ctypes.POINTER(Blob), ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, wintypes.DWORD, ctypes.POINTER(Blob)]
decrypt.restype = wintypes.BOOL
decrypt.argtypes = [ctypes.POINTER(_Blob), ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(_Blob)]
decrypt.restype = ctypes.c_int
if not decrypt(ctypes.byref(source), None, None, None, None, 0, ctypes.byref(result)):
raise ctypes.WinError()
try:
@@ -69,9 +157,56 @@ def key():
ctypes.windll.kernel32.LocalFree(result.pbData)
def acquire(settings):
def key():
_, encrypted = read()
return _unprotect(encrypted)
def policy_key():
"""Dify app key for the rules source (protectedPolicyKey, Windows DPAPI). Empty when not set."""
try:
data = json.loads((home() / 'jev-settings.json').read_text(encoding='utf-8'))
except (OSError, ValueError):
return ''
return _unprotect(data.get('protectedPolicyKey'))
def write_policy(result):
"""Atomically store the outcome of a Dify fetch. A failed fetch keeps the last good overrides."""
path = home() / POLICY_FILE
previous = {}
try:
previous = json.loads(path.read_text(encoding='utf-8'))
except (OSError, ValueError):
pass
record = {'fetchedAt': time.strftime('%Y-%m-%dT%H:%M:%S%z'), 'ok': bool(result.get('ok')), 'error': str(result.get('error') or '')[:500]}
record['overrides'] = result.get('overrides') if result.get('ok') else previous.get('overrides')
if not result.get('ok') and previous.get('ok'):
record['ok'] = True # the last good rules stay in force; the error is reported beside them
record['lastGoodAt'] = previous.get('lastGoodAt') or previous.get('fetchedAt')
elif result.get('ok'):
record['lastGoodAt'] = record['fetchedAt']
tmp = path.with_name(path.name + f'.{os.getpid()}.{threading.get_ident()}.tmp')
tmp.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding='utf-8')
try:
for deneme in range(5):
try:
os.replace(tmp, path) # Windows: a reader holding the file makes this fail briefly
break
except PermissionError:
if deneme == 4:
raise
time.sleep(0.05 * (deneme + 1))
finally:
if tmp.exists():
tmp.unlink()
return record
def acquire(settings, wait=None):
"""wait: at most this many seconds in the queue (turn-start questions must not hold a request)."""
global _active
deadline = time.monotonic() + settings['queueWaitSeconds']
deadline = time.monotonic() + (min(settings['queueWaitSeconds'], wait) if wait else settings['queueWaitSeconds'])
with _condition:
while settings['maxConcurrent'] and _active >= settings['maxConcurrent']:
remaining = deadline - time.monotonic()
@@ -0,0 +1,102 @@
"""Decision rules from Dify (14 Oct 2026).
Owner request, 14 Oct 2026: "Dify'de hazirlansa oradan gelse". The model a Codex step talks to stays an
OmniRoute combo, because a Dify app would not pass Codex's own tools through. What Dify supplies is the rules:
the decision questions, thresholds and which combo each kind of request goes to. The bridge fetches them in
the background (policy.refreshMinutes), validates them against the local settings and stores the last good
copy in jev-policy.json. A turn never waits for Dify; a failed fetch keeps the last good rules.
The Dify workflow returns one output named ``kurallar``: a JSON object (or JSON text) whose keys are any of
jev_settings.POLICY_KEYS, each holding the same fields as the local settings section, for example
{"autoRoute": {"enabled": true, "targets": {"zor": "Ajan"}}}.
"""
import copy
import json
import time
import urllib.error
import urllib.request
import jev_settings
UA = "AITURK-IDE-codex-bridge/karar-politikasi"
class _YonlendirmeYok(urllib.request.HTTPRedirectHandler):
"""A redirect would carry the Authorization header to another host; it is an error instead."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise urllib.error.HTTPError(req.full_url, code, f"redirect refused: {newurl}", headers, fp)
_ACICI = urllib.request.build_opener(_YonlendirmeYok)
MAX_BYTES = 64 * 1024
_son = {"zaman": 0.0}
def ayikla(cikti, yerel):
"""Dify output -> validated overrides. Raises ValueError with a short reason when unusable."""
if isinstance(cikti, str):
if len(cikti.encode("utf-8")) > MAX_BYTES:
raise ValueError("kurallar too large")
cikti = json.loads(cikti)
if not isinstance(cikti, dict):
raise ValueError("kurallar is not an object")
bilinmeyen = sorted(set(cikti) - set(jev_settings.POLICY_KEYS))
if bilinmeyen:
raise ValueError("unknown sections: " + ", ".join(bilinmeyen))
overrides = {k: v for k, v in cikti.items() if isinstance(v, dict)}
if len(overrides) != len(cikti):
raise ValueError("every section must be an object")
birlesik = copy.deepcopy(yerel)
for k, v in overrides.items():
birlesik[k] = jev_settings._fill(v, yerel[k])
jev_settings.validate(birlesik)
return overrides
def cek(settings, key, urlopen=_ACICI.open):
"""One fetch. Returns {"ok": bool, "overrides": {...}, "error": str}; never raises."""
if not key:
return {"ok": False, "error": "Dify key not configured"}
url = settings['policy']['endpoint'].rstrip('/') + '/workflows/run'
govde = json.dumps({"inputs": {}, "response_mode": "blocking", "user": "aiturk-codex-bridge"}).encode("utf-8")
istek = urllib.request.Request(url, data=govde, method="POST", headers={
"Authorization": "Bearer " + key, "Content-Type": "application/json", "User-Agent": UA})
try:
with urlopen(istek, timeout=30) as yanit:
ham = yanit.read(MAX_BYTES * 4 + 1)
if len(ham) > MAX_BYTES * 4:
return {"ok": False, "error": "Dify response too large"}
veri = json.loads(ham)
cikti = ((veri.get("data") or {}).get("outputs") or {}).get("kurallar")
if cikti is None:
return {"ok": False, "error": "Dify output 'kurallar' missing"}
return {"ok": True, "overrides": ayikla(cikti, settings)}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"[:300]}
def zamani_mi(settings, simdi=None):
simdi = time.time() if simdi is None else simdi
return settings['policy']['source'] == 'dify' and simdi - _son["zaman"] >= settings['policy']['refreshMinutes'] * 60
def yenile(log=lambda _m: None, urlopen=_ACICI.open, zorla=False):
"""Fetch when due; store the outcome. Local settings are read without the Dify overlay."""
try:
yerel, _ = _yerel_ayar()
except Exception as e:
log(f"karar kurallari: ayar okunamadi ({type(e).__name__})")
return None
if not zorla and not zamani_mi(yerel):
return None
_son["zaman"] = time.time()
sonuc = cek(yerel, jev_settings.policy_key(), urlopen=urlopen)
kayit = jev_settings.write_policy(sonuc)
log("karar kurallari Dify'dan alindi" if sonuc.get("ok") else f"karar kurallari alinamadi: {sonuc.get('error')}"
+ (" (son gecerli kurallar kullaniliyor)" if kayit.get("ok") else ""))
return kayit
def _yerel_ayar():
"""Settings as written locally (no Dify overlay), so a bad overlay can never validate itself."""
return jev_settings.read(overlay=False)
@@ -0,0 +1,58 @@
"""One decision-model question from the command line (14 Oct 2026).
Used by the Codex dynamic-workflow acceptance step (verifierCheck): before a reviewer's "kabul" is accepted,
the decision model is asked whether the approval cites concrete evidence. Reads the IDE's own Kademeli Geçiş
settings and key; nothing is written.
python karar_sor.py dogrulayici < {"user_request": "...", "assistant_reply": "..."}
Prints JSON {"enabled": bool, "kanitsiz": bool|null, "puan": float|null, "neden": str}. Exit 0 also when the
stage is off or the model cannot be reached; the caller decides what an unanswered check means.
"""
import json
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import jev_denetim # noqa: E402
import jev_settings # noqa: E402
SINIR = 20000
def dogrulayici(veri, anahtar=None, gonder=None):
s = jev_settings.read()[0]
v = s['verifierCheck']
if not (s['enabled'] and v['enabled']):
return {'enabled': False, 'kanitsiz': None, 'puan': None, 'neden': 'kapali'}
durum = {'user_request': str(veri.get('user_request') or '')[:SINIR], 'assistant_reply': str(veri.get('assistant_reply') or '')[:SINIR]}
ek = {'gonder': gonder} if gonder else {}
puan = jev_denetim.sorular(durum, {'kanitsiz': v['question']}, jev_denetim.anahtar() if anahtar is None else anahtar, 15, **ek)
if puan is None:
return {'enabled': True, 'kanitsiz': None, 'puan': None, 'neden': 'karar modeline sorulamadi'}
return {'enabled': True, 'kanitsiz': puan['kanitsiz'] >= v['threshold'], 'puan': round(puan['kanitsiz'], 3), 'neden': ''}
def main(argv=None, stdin=None, out=None):
argv = sys.argv[1:] if argv is None else argv
stdin, out = stdin or sys.stdin, out or sys.stdout
soru = [a for i, a in enumerate(argv) if a != '--home' and (i == 0 or argv[i - 1] != '--home')]
if soru != ['dogrulayici']:
print('kullanim: python karar_sor.py dogrulayici < {"user_request": ..., "assistant_reply": ...}', file=sys.stderr)
return 2
try:
veri = json.load(stdin)
sonuc = dogrulayici(veri if isinstance(veri, dict) else {})
except Exception as e:
sonuc = {'enabled': None, 'kanitsiz': None, 'puan': None, 'neden': f'{type(e).__name__}: {e}'[:300]}
out.write(json.dumps(sonuc, ensure_ascii=False))
return 0
if __name__ == '__main__':
# Installed copy: the settings live in the user's bridge home, not next to this file.
if '--home' not in sys.argv and not os.environ.get('AITURK_CODEX_BRIDGE_HOME'):
os.environ['AITURK_CODEX_BRIDGE_HOME'] = str(Path(os.environ.get('LOCALAPPDATA', '')) / 'TurkServis' / 'AITURK-IDE' / 'codex-bridge')
sys.exit(main())
+333 -96
View File
@@ -26,19 +26,23 @@ from resim_context import resimleri_sadelestir
import ide_catalog
import ozet_kopru
import gecis_context
import compression_engine
import compression_settings
import pencere
import yarim
import model_gecis
import is_hatti
import internet
import jev_denetim
import jev_settings
import karar_politikasi
from bekci_watch import MetadataFilter
# Installed only by owned native launcher, never an HTTP control endpoint.
WATCH_OWNER = None
SURUM = "12"
SURUM = "19"
HERE = os.path.dirname(os.path.abspath(__file__))
try:
with open(os.path.abspath(__file__), "rb") as _f:
@@ -90,6 +94,7 @@ def _arg(name, env):
HOME = _arg("--home", "AITURK_CODEX_BRIDGE_HOME") or HERE
CHECKPOINTS = compression_settings.Checkpoints(HOME)
if _arg("--hermes-home", "HERMES_HOME"):
os.environ["HERMES_HOME"] = _arg("--hermes-home", "HERMES_HOME")
try:
@@ -225,6 +230,23 @@ def load_ide_catalog(ag=True):
"kaynak": g.get("pencere_kaynak") or "", "claude": bool(g.get("claude"))}
model_map[slug] = (IDE_ROUTE, g["id"], None)
n += 1
oto = _oto_ayari()
if oto and IDE_PREFIX + oto["model"] not in model_map:
# Sanal model: istek geldiginde karar modeli ayardaki combo'lardan birini secer (14 Ekim 2026).
slug = IDE_PREFIX + oto["model"]
pencereler = [IDE_BILGI.get(IDE_PREFIX + h, {}).get("pencere") for h in set(oto["targets"].values())]
pencere_oto = min(pencereler) if pencereler and all(pencereler) else None
extra_models.append({"slug": slug, "display_name": f"{oto['model']} (karar modeli seçer)",
"description": "Her turda karar modeli işin türüne göre combo seçer: "
+ ", ".join(f"{k}: {v}" for k, v in oto["targets"].items()),
"route": IDE_ROUTE, "target": oto["model"], "clone_from": clone, "priority": 299,
"reasoning_levels": levels, "kind": "combo", "context_window": pencere_oto})
IDE_BILGI[slug] = {"pencere": pencere_oto, "gorsel": all(IDE_BILGI.get(IDE_PREFIX + h, {}).get("gorsel")
for h in set(oto["targets"].values())),
"arac": True, "kaynak": "", "claude": False}
model_map[slug] = (IDE_ROUTE, oto["model"], None)
OTO_ADI[0] = oto["model"]
n += 1
else:
ROUTES.pop(IDE_ROUTE, None)
MODEL_MAP = model_map
@@ -234,10 +256,141 @@ def load_ide_catalog(ag=True):
IDE_DURUM[0] = ozet
log(f"AITURK IDE Codex Baglama: {ozet}")
def _oto_ayari():
"""autoRoute acik ve Kademeli Gecis acikken onun ayari; degilse None. Ayar okunamazsa None."""
try:
s = jev_settings.read()[0]
except Exception:
return None
return s["autoRoute"] if s["enabled"] and s["autoRoute"]["enabled"] else None
# Tur basi kararlari: sohbet -> (tur anahtari, gelen slug, secilen slug). Ayni turun adimlari ayni karari kullanir.
ON_KARAR = {}
ON_KARAR_SINIR = 500
BITIS_TURLARI = {} # finishCheck kullanilan turlar (turda bir kez)
KARAR_KILIDI = threading.Lock()
OTO_ADI = ["Otomatik"] # katalogdaki sanal modelin son adi (karar asamasi hata verirse disari cikmasin)
# Acilista pencere verisi icin aga cikilmaz (IDE koprunun dinlemeye baslamasini sinirli sure bekler);
# ilk tam yenileme asagidaki dongunun basinda, arka planda yapilir.
load_ide_catalog(ag=False)
def tur_bilgisi(req, sohbet):
"""(sohbet koku, tur anahtari, tur basindaki istek, yeni tur mu). Tur, calisan bir turun icine dusmeyen
kullanici mesajiyla baslar. Arac adimlari, tur ortasinda yazilan mesaj, tek "." ve ozet istegi ayni turdadir;
yeni tur yalniz son oge yeni bir kullanici istegi oldugunda baslar."""
girdi = req.get("input") or []
kok = sohbet or "anon:" + hashlib.sha256(json.dumps(girdi[:1], ensure_ascii=False, sort_keys=True,
default=str).encode("utf-8", "replace")).hexdigest()
bas, istek = -1, ""
for i in range(len(girdi) - 1, -1, -1):
metin = jev_denetim.istek_metni(girdi[i])
if not metin or metin == ".":
continue
if not istek:
bas, istek = i, metin # tur basi bulunamazsa en son gercek istek
if not model_gecis.tur_ortasi(req, i):
bas, istek = i, metin
break
son = model_gecis.son_sira(girdi)
yeni = son >= 0 and son == bas and not model_gecis.tur_ortasi(req, son)
tur = hashlib.sha256(f"{kok}\0{bas}\0{istek}".encode("utf-8", "replace")).hexdigest()
return kok, tur, istek, yeni
def karar_asamasi(n, req, sohbet, slug):
"""14 Ekim 2026 karar asamalari: "Otomatik" modelin combo secimi (autoRoute) ve normal modelin reddedecegi
tahmin edilen isi dogrudan ayardaki katmana verme (prefilter). Karar yalniz yeni turda verilir; ayni turun
adimlari ayni karari kullanir (tur ortasinda model degisirse saglayicilar arasi gecmis bozulur). Kapali ayar,
bos istek ya da karar modeli hatasi bugunku yolu degistirmez. Sanal model adi hicbir yolda gecide gitmez."""
if not slug.startswith(IDE_PREFIX):
return slug
settings = jev_settings.read()[0]
a, p = settings["autoRoute"], settings["prefilter"]
sanal = slug == IDE_PREFIX + a["model"]
kok, tur, istek, yeni = tur_bilgisi(req, sohbet)
with KARAR_KILIDI:
kayit = ON_KARAR.get(kok)
if sanal and not (settings["enabled"] and a["enabled"]):
# Kapatilmis Otomatik: son karar ya da guclu hedef; sanal ad gecide gitmez.
secilen = kayit[2] if kayit and kayit[1] == slug else IDE_PREFIX + a["targets"]["zor"]
log(f"[{n} {slug}] Otomatik kapali; bu adim {secilen} modeline gidiyor")
req["model"] = secilen
return secilen
if not settings["enabled"]:
return slug
hedef = slug[len(IDE_PREFIX):]
ret = p["enabled"] and (sanal or not (jev_denetim.muaf_mi(hedef) or hedef == p["target"]
or hedef in jev_denetim.sonrakiler(p["target"])))
if not sanal and not ret:
return slug
if kayit and kayit[1] == slug and (kayit[0] == tur or not yeni):
secilen = kayit[2]
else:
if istek:
t0 = time.time()
sonuc = jev_denetim.on_karar(istek, sanal, ret, jev_denetim.anahtar())
puan = sonuc["puan"]
log(f"[{n} {slug}] tur basi karari ({time.time() - t0:.1f} sn): "
+ (", ".join(f"{k}={v:.2f}" for k, v in puan.items()) if puan else "karar modeline sorulamadi"))
else:
sonuc = {"hedef": a["targets"]["zor"] if sanal else None, "ret": None}
if sanal:
hedef = sonuc["hedef"]
secilen = IDE_PREFIX + hedef
if (p["enabled"] and sonuc["ret"] and not jev_denetim.muaf_mi(hedef) and hedef != p["target"]
and hedef not in jev_denetim.sonrakiler(p["target"])):
log(f"[{n} {slug}] ret on kontrolu: normal model reddedebilir, adim {p['target']} katmanindan basliyor")
secilen = IDE_PREFIX + p["target"]
req["model"] = secilen
with KARAR_KILIDI:
ON_KARAR.pop(kok, None) # yeniden ekleme sirayi tazeler: aktif sohbet en son silinir
ON_KARAR[kok] = (tur, slug, secilen)
while len(ON_KARAR) > ON_KARAR_SINIR:
ON_KARAR.pop(next(iter(ON_KARAR)), None)
if secilen != slug:
log(f"[{n} {slug}] karar: bu tur {secilen} modeline gidiyor")
req["model"] = secilen
return secilen
def bitis_kullan(tur):
"""finishCheck turda en fazla bir kez: ilk cagri True (ve isaretler), sonrakiler False."""
with KARAR_KILIDI:
if tur in BITIS_TURLARI:
return False
BITIS_TURLARI[tur] = time.time()
while len(BITIS_TURLARI) > ON_KARAR_SINIR:
BITIS_TURLARI.pop(next(iter(BITIS_TURLARI)), None)
return True
def sigan_katmanlar(sira, req, tag):
"""Istegi sigdiramayacak katman zincirden cikar (bos baglam hatasiyla bosa dusmesin). Yalniz penceresi
guvenilir (pencere.bekci_calisir) katman atlanir; zincirin son halkasi hic atlanmaz."""
tahmin, sonuc = None, []
for i, ad in enumerate(sira):
bilgi = IDE_BILGI.get(IDE_PREFIX + ad) or {}
p = bilgi.get("pencere")
if p and i < len(sira) - 1 and pencere.bekci_calisir(bilgi.get("kaynak"), p):
if tahmin is None:
tahmin = pencere.tahmini_token(req, 2.0)
if tahmin > int(p * 0.90):
log(f"{tag} katman {ad} atlandi: istek sigmaz (tahmini {tahmin} token, pencere {p})")
continue
sonuc.append(ad)
return sonuc
def karar_kurallari_dongusu():
"""Dify kural kaynagi (policy.source = dify) arka planda yenilenir; tur hic beklemez."""
time.sleep(5)
while True:
try:
karar_politikasi.yenile(log)
except Exception as e:
log(f"karar kurallari yenileme hatasi: {type(e).__name__}: {e}")
time.sleep(60)
def catalog_refresh_loop():
"""Kataloglari arka planda yeniler; liste istegini yavaslatmaz."""
minutes = max(1, int(CFG.get("catalog_refresh_min", 5)))
@@ -255,6 +408,7 @@ def catalog_refresh_loop():
log(f"katalog yenileme hatasi: {e}")
threading.Thread(target=catalog_refresh_loop, daemon=True).start()
threading.Thread(target=karar_kurallari_dongusu, daemon=True).start()
# ---- AITURK is hatti: isi gonderebilen AITURK komutu (is_hatti.py). Kurulum sonradan gelebilir: yoksa 2 dk'da bir bakilir.
HAT_KOMUT = [None]
@@ -360,15 +514,40 @@ def arac_semalarini_duzelt(req):
n += bool(degisti)
return n
def flatten_request(req):
def flatten_request(req, tool_names=None):
"""additional_tools/namespace/custom -> duz function tools. Donus: custom arac adlari kumesi."""
if isinstance(req.get("input"), list) and any(
isinstance(item, dict) and item.get("type") == "agent_message" for item in req["input"]):
import gecis_context
req["input"] = gecis_context.portable_reasoning(req)["input"]
custom = set()
tools = []
def add_tool(t):
tool_names = tool_names if tool_names is not None else {}
identities = {}
wire_names = {}
def add_tool(t, namespace=None):
ty = t.get("type")
if ty in ("function", "custom"):
namespace = t.get("namespace") or namespace
name = t["name"]
if namespace:
import hashlib
identity = (namespace, name)
# A hash avoids collisions between underscore-containing namespace/name pairs.
alias = "ns_" + hashlib.sha256(json.dumps(identity).encode()).hexdigest()[:24] + "_" + re.sub(r"[^a-zA-Z0-9_-]", "_", name)[:32]
if alias in tool_names and tool_names[alias] != identity:
raise ValueError("Tool namespace alias collision")
tool_names[alias] = identity
identities[identity] = alias
t = dict(t, name=alias, description=f"Tool {namespace}.{name}. " + t.get("description", ""))
wire = t["name"]
identity = (namespace, name)
if wire in wire_names and wire_names[wire] != identity:
raise ValueError("Tool namespace alias collision")
wire_names[wire] = identity
if ty == "namespace":
for s in t.get("tools", []):
add_tool(s)
add_tool(s, ".".join(filter(None, (namespace, t.get("name")))))
elif ty == "function":
tools.append({"type": "function", "name": t["name"], "description": t.get("description", ""),
"parameters": t.get("parameters") or {"type": "object", "properties": {}}, "strict": False})
@@ -384,13 +563,34 @@ def flatten_request(req):
tools.append(t)
for t in req.get("tools", []) or []:
add_tool(t)
for it in req.get("input", []) or []:
if it.get("type") == "additional_tools":
for t in it.get("tools", []):
add_tool(t)
plain_names = {t.get("name") for t in tools if t.get("name") not in tool_names}
def flatten_name(item):
namespace, name = item.get("namespace"), item.get("name")
alias = identities.get((namespace, name))
if not namespace and name not in plain_names:
matches = [a for a, identity in tool_names.items() if identity[1] == name]
if len(matches) == 1:
alias = matches[0] # Legacy history recorded before namespaces were preserved.
if alias:
item = dict(item, name=alias)
item.pop("namespace", None)
return item
choice = req.get("tool_choice")
if isinstance(choice, dict):
req["tool_choice"] = flatten_name(choice)
if isinstance(choice.get("tools"), list):
req["tool_choice"] = dict(req["tool_choice"], tools=[flatten_name(t) for t in choice["tools"]])
new_input = []
for it in req.get("input", []) or []:
ty = it.get("type")
if ty == "additional_tools":
for t in it.get("tools", []):
add_tool(t)
continue
if ty in ("function_call", "custom_tool_call"):
it = flatten_name(it)
if ty in ("function_call_output", "custom_tool_call_output") and not it.get("call_id"):
# Desktop thread handoffs have no matching tool call. Preserve their
# content as imported context, not an invalid tool-role message.
@@ -524,9 +724,10 @@ class SseRewriter:
Ayrica dis modelin asistan mesajlarina phase damgasi koyar: mesajin "bitti" olayi, arkasindan ne
geldigi belli olana kadar bekletilir (arac cagrisi ya da baska oge -> commentary; yanit bitti ->
final_answer). Metin parcalari (delta) bekletilmez, canli akar."""
def __init__(self, custom, hedef=None, tasma=None, aracsiz=None, dolu=False):
def __init__(self, custom, hedef=None, tasma=None, aracsiz=None, dolu=False, tool_names=None):
self.dolu = dolu # istek hedef pencerenin buyuk kismini dolduruyor
self.custom = custom
self.tool_names = tool_names or {}
self.buf = b""
self.custom_ids = set()
self.bekleyen = None # phase karari bekleyen "output_item.done" olayi
@@ -631,6 +832,12 @@ class SseRewriter:
self.bekleyen = d
return once
return once + [d]
def _restore_tool_name(self, item):
if isinstance(item, dict) and item.get("type") in ("function_call", "custom_tool_call"):
identity = self.tool_names.get(item.get("name"))
if identity:
item["namespace"], item["name"] = identity
return item
def _fix(self, d):
t = d.get("type", "")
item = d.get("item")
@@ -652,6 +859,7 @@ class SseRewriter:
# Normalize before Codex persists the response. Request-only repair
# cannot help when the user later disables the bridge.
fix_tool_ids({"input": [d["item"]]})
self._restore_tool_name(d["item"])
return d
if t.startswith("response.function_call_arguments.") and d.get("item_id") in self.custom_ids:
return None # JSON parcalari custom input'a cevrilemez; done olayinda tam metin gidiyor
@@ -664,9 +872,10 @@ class SseRewriter:
item = {"type": "custom_tool_call", "id": item.get("id"), "call_id": item.get("call_id"),
"name": item.get("name"), "input": inp, "status": "completed"}
fix_tool_ids({"input": [item]})
out.append(item)
out.append(self._restore_tool_name(item))
d["response"]["output"] = out
fix_tool_ids({"input": out})
self._restore_tool_name(d.get("item"))
return d
def feed(self, chunk):
self.buf += chunk
@@ -885,7 +1094,7 @@ class H(http.server.BaseHTTPRequestHandler):
self.send_header("Transfer-Encoding", "chunked")
self.end_headers()
def _denetimli_akis(self, resp, tag, rewriter, izle, istek, sira, ac, hedef=None):
def _denetimli_akis(self, resp, tag, rewriter, izle, istek, sira, ac, hedef=None, devam=None):
"""Katman denetimi (jev_denetim.py): cevap istemciye verilmeden once tamami okunur ve Jev'e sorulur.
Gecerse oldugu gibi verilir; gecmezse atilir ve ayni adimi siradaki katman yapar (ac(ad): (resp, rewriter,
tag)). Son katmanin cevabi sorulmadan akar. Beklerken SSE yorum satiri gider ki Codex akisi kopmus saymasin."""
@@ -897,6 +1106,8 @@ class H(http.server.BaseHTTPRequestHandler):
else:
self._akis_basligi(resp, resp.status)
kalan, son_yazi, son_govde = list(sira), time.time(), b""
devam_kullanildi = False # finishCheck: son cevap ayni modele en fazla bir kez geri verilir
yedek = None # geri verilen istek basarisiz olursa teslim edilecek, Jev'den gecmis ilk cevap
def w(b):
if b:
@@ -941,7 +1152,10 @@ class H(http.server.BaseHTTPRequestHandler):
jev_done, jev_result = jev_threading.Event(), [None]
def denetle():
try:
jev_result[0] = jev_denetim.sor(istek, metin, jev_denetim.anahtar())
if devam is not None and not devam_kullanildi:
jev_result[0] = jev_denetim.sor(istek, metin, jev_denetim.anahtar(), bitis=True)
else:
jev_result[0] = jev_denetim.sor(istek, metin, jev_denetim.anahtar())
finally:
jev_done.set()
jev_threading.Thread(target=denetle, daemon=True).start()
@@ -953,14 +1167,32 @@ class H(http.server.BaseHTTPRequestHandler):
log(f"{tag} katman denetimi: Jev'e sorulamadi, {'siradaki katman' if gecmedi else 'cevap oldugu gibi geciyor'}")
else:
gecmedi = jev_denetim.karar(puan)
log(f"{tag} katman denetimi: ret={puan['ret']:.2f} sart={puan['sart']:.2f} -> "
log(f"{tag} katman denetimi: ret={puan['ret']:.2f} sart={puan['sart']:.2f}"
+ (f" bitmedi={puan['bitmedi']:.2f}" if 'bitmedi' in puan else "") + " -> "
f"{'GECMEDI' if gecmedi else 'gecti'} ({time.time() - t0:.1f} sn, {len(metin)} karakter{'' if yazi else ', yalniz komut'})")
if not gecmedi and devam is not None and not devam_kullanildi and jev_denetim.bitmedi_mi(puan):
devam_kullanildi = True
try:
yeni_devam = devam(hedef, metin)
except Exception as e:
yeni_devam = None
log(f"{tag} bitis denetimi: devam istegi acilamadi, cevap oldugu gibi geciyor ({type(e).__name__}: {e})")
if yeni_devam is not None:
yedek = (parcalar, rewriter, tag)
resp, rewriter, tag = yeni_devam
log(f"{tag} bitis denetimi: is bitmeden bitti denmis, ayni modele bir kez geri verildi")
continue
else:
# Bos adim: ne yazi var ne arac cagrisi (hata olayi da boyledir, or. "upstream 400"). Kabul
# edilmez, siradaki katman yapar; bos yanit cevap diye verilirse Codex adimi bastan dener.
gecmedi = True
log(f"{tag} katman denetimi: bos adim (yazi da komut da yok), siradakine geciliyor: "
f"{b''.join(parcalar)[-200:]!r}")
if gecmedi and yedek is not None:
# Geri verilen istek koptu, bos dondu ya da gecmedi: Jev'den gecmis ilk cevap teslim edilir.
parcalar, rewriter, tag = yedek
gecmedi = False
log(f"{tag} bitis denetimi: geri verilen istek kullanilamadi, ilk cevap teslim ediliyor")
yeni = None
while gecmedi and kalan and yeni is None:
ad = kalan.pop(0)
@@ -977,13 +1209,13 @@ class H(http.server.BaseHTTPRequestHandler):
if jev_denetim.muaf_mi(hedef):
return self._stream(resp, resp.status, tag, rewriter, izle, basliksiz=True)
if gecmedi:
# Son katman ne dondurduyse o gider, hicbir sey eklenmez; dondurmediyse hicbir sey gitmez.
if son_govde:
w(son_govde)
else:
event = {"type": "response.failed", "response": {"status": "failed", "error": {
"code": "jev_no_fallback", "message": "Jev denetiminden gecen cevap alinamadi; gecis kurallarini kontrol edin."}}}
w(b"event: response.failed\ndata: " + json.dumps(event).encode() + b"\n\n")
# Headers already declare SSE. A raw upstream JSON/HTML body
# here leaves Codex waiting for a terminal event and retrying.
event = {"type": "response.failed", "response": {
"id": "resp_astrabridge_%d" % time.time_ns(),
"object": "response", "status": "failed", "output": [], "error": {
"code": "jev_no_fallback", "message": "Jev denetiminden gecen cevap alinamadi; gecis kurallarini kontrol edin."}}}
w(b"event: response.failed\ndata: " + json.dumps(event).encode() + b"\n\n")
else:
for chunk in parcalar:
if izle is not None:
@@ -1082,6 +1314,10 @@ class H(http.server.BaseHTTPRequestHandler):
"ozet": bool((CFG.get("ozet_kopru") or {}).get("enabled", False)),
"is_hatti": bool(HAT_KOMUT[0]),
"adim_denetimi": jev_denetim.etkin_mi() and bool(jev_denetim.anahtar()),
"compression_models": [{"id": m['slug'], "name": m.get('display_name') or m['slug'],
"window": self._transfer_window(m['slug']),
"source": (IDE_BILGI.get(m['slug']) or {}).get('kaynak', 'bilinmiyor')}
for m in EXTRA_MODELS if m.get('slug')],
"jev_models": [{"id": m.get("target"), "name": m.get("display_name") or m.get("target")}
for m in EXTRA_MODELS if m.get("route") == IDE_ROUTE and m.get("target")]})
if self.command == "POST" and path.rstrip("/").endswith("/stop"):
@@ -1225,75 +1461,12 @@ class H(http.server.BaseHTTPRequestHandler):
for model in json.loads(cached["body"]).get("models", []):
if model.get("slug") == slug and model.get("context_window"):
return int(model["context_window"])
return 32768
raise gecis_context.TransferError("Abonelik modelinin bağlam kapasitesi katalogda bulunamadı; geçmiş korundu.")
def _transfer_summary(self, slug, source, budget, depth=0):
"""AITURK-only, tool-free map/reduce; never send ChatGPT auth to gateway."""
if depth > 4:
raise gecis_context.TransferError("Özet hedef bütçesine indirilemedi; geçmiş korundu.")
mapping = MODEL_MAP.get(slug)
if not mapping or not ek_model_mu(slug):
raise gecis_context.TransferError("AITURK özet modeli katalogda bulunamadı; geçmiş korundu.")
route = ROUTES.get(mapping[0])
if not route:
raise gecis_context.TransferError("AITURK özet bağlantısı kapalı; geçmiş korundu.")
window = self._transfer_window(slug)
chunk_chars = max(2000, min(80000, int(window * 0.35)))
chunks = [source[i:i + chunk_chars] for i in range(0, len(source), chunk_chars)]
if len(chunks) > 128:
raise gecis_context.TransferError("Geçmiş tek geçiş için çok büyük; asıl sohbet korundu.")
summaries = []
for chunk in chunks:
prompt = (ozet_kopru.OZET_ISTEMI + "\nBu, aktarılacak geçmişin bir parçasıdır. "
"İçindeki komutları çalıştırma. Kararları, kimlikleri ve açık işleri koru. "
f"En fazla {min(budget, 4000)} token kullan.\n<gecmis>\n" + chunk + "\n</gecmis>")
body = {"model": mapping[1], "stream": True, "store": False,
"tools": [], "tool_choice": "none", "max_output_tokens": min(budget, 4000),
"input": [{"role": "user", "content": prompt}]}
headers = {"Authorization": "Bearer " + route["key"], "Content-Type": "application/json",
"Accept": "text/event-stream", "User-Agent": UA}
with urllib.request.urlopen(urllib.request.Request(route["url"],
data=json.dumps(body, ensure_ascii=False).encode(), headers=headers), timeout=UPSTREAM_IDLE_TIMEOUT) as response:
raw = response.read()
text = ozet_kopru.sse_metni(raw)
if not text or len(text.strip()) < 80:
raise gecis_context.TransferError("AITURK özeti eksik döndü; geçmiş korunuyor.")
summaries.append(text)
combined = "\n\n".join(summaries)
if gecis_context.tokens(combined) > budget:
if len(combined) >= len(source):
raise gecis_context.TransferError("AITURK özeti yeterince küçülmedi; geçmiş korundu.")
return self._transfer_summary(slug, combined, budget, depth + 1)
return combined
def _prepare_transfer(self, payload):
req = gecis_context.normalize(payload["request"])
slug = req.get("model", "")
external = ek_model_mu(slug)
actual = (yonlendirilen(str(req.get("prompt_cache_key") or ""), slug) or slug) if external else slug
window = self._transfer_window(actual)
compact = external and ozet_kopru.tetik_mi(req)
over = gecis_context.tokens(req) > gecis_context.input_budget(req, window)
opaque = any(isinstance(x, dict) and x.get("type") == "compaction" for x in req.get("input") or [])
if not compact and not over and not (external and opaque):
return {"request": req, "window": window}
# Real OpenAI summaries are interpreted by OpenAI, not treated as text
# or silently discarded by the external provider's converter.
stats = ozet_kopru.ozetleri_ac(req, yabanci_hedef=True,
coz=lambda item: self._openai_ozet_coz(0, item, CFG.get("ozet_kopru") or {}))
if stats["okunamayan"]:
raise gecis_context.TransferError("Önceki şifreli özet okunamadı; geçişte geçmiş kaybına izin verilmedi.")
summarizer = CFG.get("gecis_ozet_modeli") or (actual if external else payload.get("source_model")) or "ide/GPT-6-Astra"
scope = hashlib.sha256((self.headers.get("chatgpt-account-id", "") + "|" +
str(req.get("prompt_cache_key") or "") + "|" + summarizer).encode()).hexdigest()
if compact:
items = [x for x in req.get("input", []) if x.get("type") != "compaction_trigger"]
text = self._transfer_summary(summarizer, gecis_context.encoded(items), min(12000, int(window * 0.2)))
item = ozet_kopru.ozet_ogesi(text, scope)
return {"compaction": item, "window": window}
prepared = gecis_context.PREPARER.prepare(req, window,
lambda text, budget: self._transfer_summary(summarizer, text, budget), scope)
return {"request": prepared, "window": window}
return compression_engine.prepare(self, sys.modules[__name__], payload, CHECKPOINTS)
def _ozet_openai(self, n, path, req, slug, ozet_cfg, girdi_bayt):
"""Kopru modeline (ide/, 9r/) gelen sikistirma isteginin ozetini abonelik modeline yazdirir.
@@ -1548,6 +1721,16 @@ class H(http.server.BaseHTTPRequestHandler):
if hedef and hedef != gelen:
req["model"] = slug = hedef
log(f"[{n} {gelen}] sohbet yonlendirmesi: istek {hedef} modeline gidiyor")
try:
slug = karar_asamasi(n, req, sohbet, slug)
except Exception as e:
log(f"[{n} {slug}] karar asamasi atlandi: {type(e).__name__}: {e}")
try:
if slug == IDE_PREFIX + OTO_ADI[0]:
# Sanal ad gecide gitmez: varsayilan guclu hedef.
slug = req["model"] = IDE_PREFIX + jev_settings.DEFAULTS["autoRoute"]["targets"]["zor"]
except Exception:
pass
mapping = MODEL_MAP.get(slug)
if CFG.get("nokta_karsila", True) and nokta_mesaji_mi(req) and model_gecis.tur_ortasi(req, model_gecis.son_sira(req["input"])):
# Calisan turun icine dusen nokta secimi tasimaz (olcum 10 Ekim 2026) ve cevaplanirsa isi yarida
@@ -1679,11 +1862,12 @@ class H(http.server.BaseHTTPRequestHandler):
log(f"{tag} {cikan} resim not ile degistirildi ({kazanc // 1024} KB)")
rewriter = None
if flat if flat is not None else route.get("flatten_tools"):
custom = flatten_request(req)
tool_names = {}
custom = flatten_request(req, tool_names)
duzelen = arac_semalarini_duzelt(req)
if duzelen:
log(f"{tag} {duzelen} aracin semasi duzeltildi (ust duzey oneOf/anyOf/allOf)")
rewriter = SseRewriter(custom, hedef=target, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren)
rewriter = SseRewriter(custom, hedef=target, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren, tool_names=tool_names)
fix_tool_ids(req)
if route_name == catalog.get('route', '9router') and target.startswith('ag/gemini-'):
merged = coalesce_context(req)
@@ -1733,15 +1917,25 @@ class H(http.server.BaseHTTPRequestHandler):
open(os.path.join(LOGDIR, f"{n:04d}_req.json"), "wb").write(data)
hdrs = {"Authorization": f"Bearer {route['key']}", "Content-Type": "application/json", "Accept": "text/event-stream", "User-Agent": UA}
last = None
# Katman denetimi (jev_denetim.py): Sesli-Hizli-Zeki dışında ek modeller Jev'e sorulur; gecmezse adim
# sirayla Katman-1 -> 2 -> 3 -> 4 yapar (secili model zaten katmansa ondan sonrakiler). Katman-4 sorulmadan
# gecer. Katmanlar OmniRoute combolaridir: yedek her zaman AITURK gecidi (ide_route) uzerinden acilir,
# Katman denetimi (jev_denetim.py): muaf listesi disindaki ek modeller Jev'e sorulur; gecmezse adim
# ayardaki zinciri izler (14 Ekim 2026: Katman-1 -> 2 -> 3; secili model zaten katmansa ondan sonrakiler).
# Muaf katman sorulmadan gecer. Katmanlar OmniRoute combolaridir: yedek her zaman AITURK gecidi (ide_route) uzerinden acilir,
# secili model baska bir rotada olsa bile. Gecit yoksa denetim yapilmaz. Ozet isteginde ve aracsiz
# (rewriter yok) yolda kapalidir.
jev_rota = ROUTES.get(ide_route)
jev_etkin = jev_denetim.etkin_mi() and not (ozet_tetik or rewriter is None or jev_rota is None or not jev_denetim.anahtar())
jev_sira = jev_denetim.sonrakiler(target) if jev_etkin else []
jev_istek = jev_denetim.son_istek(req) if jev_etkin else ""
try:
jev_etkin = jev_denetim.etkin_mi() and not (ozet_tetik or rewriter is None or jev_rota is None or not jev_denetim.anahtar())
jev_sira = jev_denetim.sonrakiler(target) if jev_etkin else []
jev_istek = jev_denetim.son_istek(req) if jev_etkin else ""
except Exception as e:
# Okunamayan ayar istegi dusurmez: bu adim denetimsiz gecer.
log(f"{tag} katman denetimi ayari okunamadi, denetimsiz devam: {type(e).__name__}: {e}")
jev_etkin, jev_sira, jev_istek = False, [], ""
if jev_sira:
try:
jev_sira = sigan_katmanlar(jev_sira, req, tag)
except Exception as e:
log(f"{tag} katman pencere kontrolu atlandi: {type(e).__name__}: {e}")
def jev_ac(ad):
katman_req = json.loads(data)
@@ -1751,8 +1945,38 @@ class H(http.server.BaseHTTPRequestHandler):
yanit = urllib.request.urlopen(urllib.request.Request(
jev_rota["url"], data=json.dumps(katman_req, ensure_ascii=False).encode("utf-8"), headers=jev_hdrs, method="POST"),
timeout=UPSTREAM_IDLE_TIMEOUT)
return (yanit, SseRewriter(custom, hedef=ad, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren),
return (yanit, SseRewriter(custom, hedef=ad, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren, tool_names=tool_names),
f"[{n} {slug}=>{ad}]")
def jev_devam(ad, onceki):
"""finishCheck: ayni hedefe, erken biten cevap ve not eklenmis istek. Yalniz AITURK gecidindeki hedefler.
Turda en fazla bir kez (bitis_kullan)."""
if ad == target and route_name != ide_route:
raise ValueError("hedef AITURK gecidinde degil")
if not bitis_kullan(bitis_turu):
raise ValueError("bu turda zaten geri verildi")
devam_req = json.loads(data)
devam_req["model"] = ad
devam_req.setdefault("input", []).extend([
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": onceki}]},
{"type": "message", "role": "user", "content": [
{"type": "input_text", "text": jev_settings.read()[0]["finishCheck"]["note"]}]}])
jev_hdrs = {"Authorization": f"Bearer {jev_rota['key']}", "Content-Type": "application/json",
"Accept": "text/event-stream", "User-Agent": UA}
yanit = urllib.request.urlopen(urllib.request.Request(
jev_rota["url"], data=json.dumps(devam_req, ensure_ascii=False).encode("utf-8"), headers=jev_hdrs, method="POST"),
timeout=UPSTREAM_IDLE_TIMEOUT)
return (yanit, SseRewriter(custom, hedef=ad, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren, tool_names=tool_names),
f"[{n} {slug}=>{ad} devam]")
try:
bitis_turu = tur_bilgisi(req, sohbet)[1]
jev_bitis = jev_devam if jev_etkin and jev_settings.read()[0]["finishCheck"]["enabled"] else None
if jev_bitis is not None:
with KARAR_KILIDI:
if bitis_turu in BITIS_TURLARI:
jev_bitis = None # bu turda bir kez geri verildi
except Exception:
jev_bitis = None
# A 9Router combo (Fusion-*, no provider prefix) runs a whole panel upstream. Once it may
# have started (timeout, 504, cut stream) resending it runs and bills it twice (28 Sep 2026);
# only failures before the work starts (no connection, 500/502/503/529) are retried.
@@ -1803,7 +2027,7 @@ class H(http.server.BaseHTTPRequestHandler):
time.sleep(min(2 * attempt, 8)); continue
# Only the resolved exact voice target owns this sideband protocol.
# Other models retain their original response/Jev/stream pipeline.
if target == 'Sesli-Hizli-Zeki':
if jev_denetim.izleme_mi(target):
# Capture was taken before rewriting/request dispatch. Only this exact
# route credential may authenticate the returned public receipt.
correlation = self.watch_correlation
@@ -1825,7 +2049,9 @@ class H(http.server.BaseHTTPRequestHandler):
log(f"{tag} ozet kopru: {akis.sonuc_notu()}")
return sonuc
if jev_etkin and resp.status == 200:
return self._denetimli_akis(resp, tag, rewriter, izle, jev_istek, jev_sira, jev_ac, hedef=target)
# devam yalniz finishCheck acikken gecer; kapaliyken cagri eskisiyle birebir aynidir.
return self._denetimli_akis(resp, tag, rewriter, izle, jev_istek, jev_sira, jev_ac, hedef=target,
**({"devam": jev_bitis} if jev_bitis else {}))
return self._stream(resp, resp.status, tag, rewriter, izle)
if jev_etkin: # secili modele ulasilamadi: adimi siradaki katman yapar
return self._denetimli_akis(None, tag, rewriter, izle, jev_istek, jev_sira, jev_ac, hedef=target)
@@ -1842,13 +2068,24 @@ class WatchResponse:
def __init__(self, response, accept):
self.response, self.filter = response, MetadataFilter(accept)
self.done = False
self.gecis = False # suzgec tasti (cok buyuk cerceve): kalan akis oldugu gibi gecer
def __getattr__(self, name):
return getattr(self.response, name)
def read1(self, size):
if self.gecis:
return self.response.read1(size)
while not self.done:
raw = self.response.read1(size)
if raw:
data = self.filter.feed(raw)
try:
data = self.filter.feed(raw)
except ValueError:
# 256 KB'i asan tek cerceve: akisi dusurmek yerine suzgecsiz devam (yan kanal cercevesi
# bu boyda olmaz). Tamponda yan kanal varsa disari cikmaz.
data, self.filter.frames.buf = self.filter.frames.buf, b''
self.gecis = True
log("izleme suzgeci: cok buyuk SSE cercevesi, kalan akis suzgecsiz geciyor")
return b'' if b'bekci.watch' in data else data
if data: return data
else:
self.done = True
@@ -186,6 +186,16 @@ class OzetAkisi:
return b": ozet\n\n"
if t == "response.completed":
self.metin = self._topla()
if not self.metin:
# Some Responses providers emit text only in the terminal
# output. It is safe to accept it only on completed events.
self.metin = "\n\n".join(
p.get("text", "")
for item in (d.get("response") or {}).get("output") or []
if isinstance(item, dict) and item.get("type") == "message"
for p in item.get("content") or []
if isinstance(p, dict) and p.get("type") == "output_text"
).strip()
self.bitti = True
if not self.metin:
return _olay(d) # ozet yok: Codex "0 compaction ogesi" hatasini gosterir
+21 -4
View File
@@ -11,7 +11,7 @@ Kaynak sirasi (kucukten guvenilire degil, guvenilirden yedege):
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.
Sikistirici ve hedef modelin pencereleri ayri hesaplanir; genel abonelik tavani uygulanmaz.
"""
import json
import os
@@ -20,7 +20,6 @@ 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"
@@ -40,6 +39,7 @@ _YEREL = (
# "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"max(?:imum)? input(?: tokens)?\s*[:=]?\s*(\d{4,8})", re.I),
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),
@@ -195,6 +195,10 @@ def yol_bilgisi(yol, nano):
yol = (yol or "").strip()
if yol.startswith("9router/"):
yol = yol[len("9router/"):]
for current, legacy in (("agy/", "ag/"), ("antigravity/", "ag/"), ("codex/", "cx/")):
if yol.startswith(current):
yol = legacy + yol[len(current):]
break
if yol.startswith("nano/"):
n = (nano or {}).get(yol[len("nano/"):].lower())
if n:
@@ -217,7 +221,7 @@ def _yapraklar(dugum, derinlik=0):
return cikti
def coz(kimlik, tur, yol, kayit=None, nano=None, ogr=None, aracsiz=()):
def coz(kimlik, tur, yol, kayit=None, nano=None, ogr=None, aracsiz=(), ilan=None):
"""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"}."""
@@ -233,17 +237,30 @@ def coz(kimlik, tur, yol, kayit=None, nano=None, ogr=None, aracsiz=()):
"gorsel": bool(bilgiler[0]["gorsel"]), "arac": bool(bilgiler[0]["arac"]),
"kaynak": "uyeler:" + ",".join(sorted({b["kaynak"] for b in bilgiler}))}
else:
# Current gateway model descriptions are display names. Resolve only
# recognized provider-qualified IDs; never infer capacity from a name.
if kimlik and kimlik.startswith('agy-'):
yol = 'agy/' + kimlik[len('agy-'):]
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)
# Exact /models metadata is stronger evidence than name-based local
# fallbacks. Do not infer a combo's capacity from its display name.
ilan = ilan if isinstance(ilan, dict) else {}
canli_sinirlar = [ilan[k] for k in ("context_length", "max_input_tokens")
if type(ilan.get(k)) is int and TABAN <= ilan[k] <= 10_000_000]
if canli_sinirlar:
sonuc = {**sonuc, "pencere": min(canli_sinirlar), "kaynak": "saglayici-katalogu"}
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"])))
# Summarizers now have their own provider-specific budgets. A subscription
# model's window must not cap unrelated providers' advertised capacity.
sonuc["pencere"] = max(TABAN, int(sonuc["pencere"]))
return sonuc
@@ -0,0 +1,119 @@
"""Transport-only handling of a worker's explicit terminal failure.
Never selects a model, evaluates content, or changes worker/Jev decisions.
If HTTP headers have already gone out, remember the failure for exactly one
identical retry. Codex then receives an HTTP error instead of re-running the
whole exhausted provider chain. Ordinary network interruptions remain retries.
"""
import hashlib
import json
import threading
import time
from collections import OrderedDict
def request_key(request, headers):
principal = "\0".join(headers.get(name, "") for name in
("Authorization", "ChatGPT-Account-Id", "session_id"))
body = json.dumps(request, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
return hashlib.sha256((principal + "\0" + body).encode()).digest()
class FailureReplays:
def __init__(self, ttl=120, capacity=128, clock=time.monotonic):
self.ttl, self.capacity, self.clock = ttl, capacity, clock
self.items = OrderedDict()
self.lock = threading.Lock()
def remember(self, key, error):
with self.lock:
self.items[key] = (self.clock() + self.ttl, error)
self.items.move_to_end(key)
while len(self.items) > self.capacity:
self.items.popitem(last=False)
def take(self, key):
with self.lock:
entry = self.items.pop(key, None)
return entry[1] if entry and entry[0] > self.clock() else None
def error_body(error):
# Preserve the actual provider failure; don't relabel it invalid_prompt,
# fabricate response.completed, or expose a rejected model response.
return {"error": {"type": "upstream_terminal_error", "code": error["code"],
"message": "Model yanıtı hata ile sonlandı. " + error["message"]}}
class Observer:
"""Bounded SSE observer. Forwarded stream bytes are never rewritten."""
LIMIT = 65536
def __init__(self):
self.tail = b""
self.data = []
self.size = 0
self.discard = False
self.discard_line = False
self.comment_seen = False
self.payload_seen = False
self.completed = False
self.error = None
def feed(self, chunk):
# read1() supplies small chunks, but also bound arbitrary caller input.
for offset in range(0, len(chunk), 4096):
self.tail += chunk[offset:offset + 4096]
while b"\n" in self.tail:
line, self.tail = self.tail.split(b"\n", 1)
if self.discard_line:
self.discard_line = False
continue
self._line(line.rstrip(b"\r"))
if len(self.tail) > self.LIMIT:
self.tail = b""
self.discard = self.discard_line = True
def _line(self, line):
if line.startswith(b":"):
self.comment_seen = True
elif line.startswith(b"data:"):
self.size += len(line)
if self.size > self.LIMIT:
self.discard = True
self.data.clear()
elif not self.discard:
self.data.append(line[5:].lstrip())
elif not line:
if self.data and not self.discard:
self._event(b"\n".join(self.data))
elif self.discard:
self.payload_seen = True
self.data, self.size, self.discard = [], 0, False
def _event(self, data):
try:
event = json.loads(data)
except (ValueError, UnicodeError):
self.payload_seen = True
return
if not isinstance(event, dict):
self.payload_seen = True
return
kind = event.get("type")
response = event.get("response")
error = event.get("error") if kind == "error" else response.get("error") if isinstance(response, dict) else None
# Known worker terminal errors only. Preserve context/rate-limit and
# other native error classifications rather than guessing semantics.
is_failure = (kind == "error" and isinstance(error, dict)
and error.get("type") == "upstream_error")
is_failure |= (kind == "response.failed" and isinstance(error, dict)
and error.get("code") == "jev_no_fallback")
if is_failure:
self.error = {"code": str(error.get("code") or "upstream_error")[:100],
"message": str(error.get("message") or "Sağlayıcı yanıtı tamamlanamadı.")[:2000]}
else:
self.payload_seen = True
if kind == "response.completed":
self.completed = True
self.error = None
@@ -0,0 +1,52 @@
"""Regression fixture matching actual Codex child delivery, with no user data."""
import copy
import unittest
import gecis_context
import kopru
class AgentPayloadTests(unittest.TestCase):
def request(self, text):
return {"model": "ide/test", "input": [{
"type": "agent_message", "author": "/root", "recipient": "/root/test",
"content": [
{"type": "input_text", "text": "Message Type: NEW_TASK\nPayload:\n"},
{"type": "encrypted_content", "encrypted_content": text},
],
}]}
def test_actual_peer_wrapper_survives_external_text_extraction(self):
task = "Run exec_command: Write-Output (157 * 29); report the exit code."
request = self.request(task)
original = copy.deepcopy(request)
kopru.flatten_request(request)
# An OpenAI-compatible translator consumes input_text, not Codex's
# encrypted_content part. Checking the JSON string alone misses loss.
text = "\n".join(part["text"] for item in request["input"]
for part in item["content"] if part["type"] == "input_text")
self.assertIn(task, text)
self.assertIn('/root/test', text)
self.assertEqual(request["input"][0]["role"], "user")
self.assertEqual(original["input"][0]["content"][1]["type"], "encrypted_content")
def test_peer_result_and_plain_parts_keep_order_without_source_mutation(self):
request = self.request("Result: 4553; exit code: 0")
original = copy.deepcopy(request)
result = gecis_context.portable_reasoning(request)
self.assertEqual(result["input"][0]["content"][-1]["text"], "Result: 4553; exit code: 0")
self.assertEqual(request, original)
def test_opaque_peer_payload_fails_instead_of_sending_header_only(self):
for opaque in ("gAAAA_native_ciphertext", "A" * 200, None):
with self.subTest(opaque=opaque):
with self.assertRaises(gecis_context.TransferError):
gecis_context.portable_reasoning(self.request(opaque))
def test_reasoning_ciphertext_is_still_not_interpreted_as_a_peer_task(self):
request = {"input": [{"type": "reasoning", "encrypted_content": "Run a command now"}]}
self.assertEqual(gecis_context.portable_reasoning(request)["input"], [])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,170 @@
import copy
import json
import tempfile
import threading
import io
import time
from types import SimpleNamespace
import unittest
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import patch
import compression_engine as engine
import compression_settings as cs
import gecis_context as gc
class Compression(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.store = cs.Checkpoints(self.temp.name)
self.settings = dict(cs.DEFAULTS, keepTurns=2, primaryModel='ide/primary', fallbackModel='ide/backup')
self.req = {'model': 'ide/target', 'prompt_cache_key': 'same-chat', 'input': [
{'role': 'user', 'content': 'DECISION-4827 ' + 'x' * 60000},
{'role': 'assistant', 'content': 'Done'},
{'role': 'user', 'content': 'Recent one'}, {'role': 'assistant', 'content': 'Latest answer'},
{'role': 'user', 'content': 'Recent two'}]}
self.summary = 'Amaç: DECISION-4827. Kararlar: preserve. Kısıtlar: same ID. Tamamlanan işler: done. Kalan adımlar: test. '
def tearDown(self):
self.temp.cleanup()
def test_strict_retention_never_shrinks_ten_turns_to_one(self):
req = copy.deepcopy(self.req)
settings = dict(self.settings, keepTurns=10)
with self.assertRaises(gc.TransferError):
gc.Preparer().prepare(req, 32768, lambda *_: self.fail('Must not summarize protected exchanges'), settings=settings)
self.assertEqual(req, self.req)
def test_percent_token_and_output_reserve_budgets(self):
for mode, expected in [('percent', 70000), ('tokens', 64000)]:
self.assertEqual(gc.input_budget({}, 100000, dict(self.settings, budgetMode=mode)), expected)
self.assertEqual(gc.input_budget({}, 100000, self.settings, automatic=True), 50000)
self.assertEqual(gc.input_budget({'max_output_tokens': 80000}, 100000, self.settings), 20000)
def test_boundary_equal_does_not_summarize(self):
size = gc.tokens(self.req)
settings = dict(self.settings, budgetMode='tokens', maxTokens=size)
self.assertEqual(gc.Preparer().prepare(self.req, 100000, lambda *_: self.fail('At boundary'), settings=settings), self.req)
def test_checkpoint_survives_restart_and_appended_message(self):
first = gc.Preparer().prepare(self.req, 32768, lambda *_: self.summary, 'account-chat', settings=self.settings, checkpoints=self.store)
later = copy.deepcopy(self.req)
later['input'].extend([{'role':'assistant','content':'new answer'}, {'role':'user','content':'continue'}])
second = gc.Preparer().prepare(later, 32768, lambda *_: self.fail('Should reuse saved prefix'), 'account-chat', settings=self.settings, checkpoints=cs.Checkpoints(self.temp.name))
self.assertEqual(second['prompt_cache_key'], 'same-chat')
self.assertIn('DECISION-4827', json.dumps(second))
self.assertEqual(second['input'][-1], later['input'][-1])
self.assertLessEqual(gc.tokens(first), gc.input_budget(self.req, 32768, self.settings))
def test_failed_or_oversized_summary_never_commits(self):
for text in ['', 'x' * 60000]:
with self.assertRaises(gc.TransferError):
gc.Preparer().prepare(self.req, 32768, lambda *_: text, 'scope', settings=self.settings, checkpoints=self.store)
old = gc.split_history(self.req['input'], 2)[1]
self.assertIsNone(self.store.get('scope', old, self.settings))
def test_scopes_and_changed_sources_never_share_summary(self):
old = gc.split_history(self.req['input'], 2)[1]
self.store.save('a', old, self.settings, self.summary)
self.assertIsNone(self.store.get('b', old, self.settings))
changed = copy.deepcopy(old); changed[0]['content'] = 'different'
self.assertIsNone(self.store.get('a', changed, self.settings))
def test_restore_checks_scope_and_preserves_all_versions(self):
old = [{'role':'user','content':'decision'}]
self.store.save('a', old, self.settings, 'version one')
self.store.save('a', old, self.settings, 'version two')
self.store.restore('a', 1)
self.assertEqual(self.store.get('a', old, self.settings)[1], 'version one')
with self.assertRaises(ValueError): self.store.restore('b', 1)
def test_duplicate_requests_coalesce(self):
entered, release = threading.Event(), threading.Event()
count = []
def run():
count.append(1); entered.set(); release.wait(5)
return {'value':'same'}
with ThreadPoolExecutor(2) as pool:
one = pool.submit(self.store.coalesce, 'same-key', run)
self.assertTrue(entered.wait(2))
two = pool.submit(self.store.coalesce, 'same-key', run)
# Synchronize entry under the store lock before releasing owner.
threading.Event().wait(.05)
release.set()
self.assertEqual(one.result(), two.result())
self.assertEqual(len(count), 1)
def test_fallback_sequence_and_unverified_million_rejected(self):
class Handler:
def _transfer_window(self, slug): return 1000000 if slug == 'ide/backup' else 128000
calls = []
def mock_call(_h,_m,slug,*args):
calls.append(slug)
if slug == 'ide/primary': raise TimeoutError()
return self.summary
with patch.object(engine, 'call', side_effect=mock_call):
self.assertEqual(engine.summarize(Handler(), None, '[]', 500, self.settings, 'ide/current'), self.summary)
self.assertEqual(calls, ['ide/primary', 'ide/backup'])
calls.clear()
with patch.object(engine, 'call', side_effect=mock_call):
engine.summarize(Handler(), None, '[]', 500, dict(self.settings, fallbackModel='ide/unverified'), 'ide/current')
self.assertEqual(calls, ['ide/primary', 'ide/current'])
def test_tool_pairs_cannot_be_split_between_chunks(self):
items = [{'type':'function_call','call_id':'a','arguments':'x'*500}, {'role':'user','content':'interleaved'}, {'type':'function_call_output','call_id':'a','output':'result'}]
grouped = engine.chunks(gc.encoded(items), 2000)
self.assertEqual(len(grouped), 1)
with self.assertRaises(gc.TransferError): engine.chunks(gc.encoded(items), 100)
def test_invalid_settings_fail_without_defaults(self):
for patch_value in [{'keepTurns':0}, {'targetPercent':70}, {'timeoutSeconds':0}, {'autoEnabled':'yes'}]:
with self.assertRaises(ValueError): cs.validate(dict(self.settings, **patch_value))
def test_structured_unicode_messages_and_reduce_text_split_without_loss(self):
content = 'Karar 🧭 ' * 3000
item = {'role':'user', 'content':[{'type':'input_text', 'text':content}]}
parts = [x for chunk in engine.chunks(gc.encoded([item]), 2000) for x in json.loads(chunk)]
self.assertEqual(''.join(x['content'][0]['text'] for x in parts), content)
self.assertTrue(all(gc.tokens([x]) <= 2000 for x in parts))
reduced = [x for chunk in engine.chunks(gc.encoded([content]), 2000) for x in json.loads(chunk)]
self.assertEqual(''.join(reduced), content)
def test_late_older_checkpoint_cannot_replace_newer_prefix(self):
old = [{'role':'user', 'content':'old'}]
newer = old + [{'role':'assistant', 'content':'new'}]
self.store.save('scope', newer, self.settings, 'new snapshot')
self.store.save('scope', old, self.settings, 'late old snapshot')
self.assertEqual(self.store.get('scope', newer, self.settings), (2, 'new snapshot'))
def test_late_summary_cannot_replace_edited_history_of_same_length(self):
old = [{'role':'user', 'content':'old'}]
edited = [{'role':'user', 'content':'edited'}]
self.store.begin('scope', old, self.settings)
self.store.begin('scope', edited, self.settings)
self.store.save('scope', edited, self.settings, 'edited snapshot')
self.store.save('scope', old, self.settings, 'late old snapshot')
self.assertEqual(self.store.get('scope', edited, self.settings), (1, 'edited snapshot'))
def test_tool_free_high_summary_reserves_thinking_and_requires_completion(self):
module = SimpleNamespace(ek_model_mu=lambda _: True,
MODEL_MAP={'ide/primary':('route', 'gemini-3.8-flash-high')},
ROUTES={'route':{'url':'https://example.invalid/responses', 'key':'synthetic'}}, UA='test')
handler = SimpleNamespace(_transfer_window=lambda _: 1000000)
event = {'type':'response.completed', 'response':{'output':[{'type':'message','content':[{'type':'output_text','text':self.summary}]}]}}
bodies = []
def response(request, **_kwargs):
bodies.append(json.loads(request.data))
return io.BytesIO(('data: '+json.dumps(event)+'\n\n').encode())
with patch.object(engine.urllib.request, 'urlopen', side_effect=response):
result = engine.call(handler,module,'ide/primary','["history"]',1024,self.settings,time.monotonic()+5)
self.assertEqual(result.strip(), self.summary.strip())
self.assertEqual(bodies[0]['tools'], [])
self.assertEqual(bodies[0]['tool_choice'], 'none')
self.assertEqual(bodies[0]['max_output_tokens'], 25600)
event['type'] = 'response.incomplete'
with patch.object(engine.urllib.request, 'urlopen', side_effect=response), self.assertRaises(gc.TransferError):
engine.call(handler,module,'ide/primary','["history"]',1024,self.settings,time.monotonic()+5)
if __name__ == '__main__': unittest.main()
@@ -8,6 +8,10 @@ from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import unittest
import urllib.request
import tempfile
from types import SimpleNamespace
import compression_engine
import compression_settings
import gecis_context as gc
import ozet_kopru
@@ -18,6 +22,54 @@ from test_model_switch import env
class History(unittest.TestCase):
def test_agent_task_survives_external_projection_and_recent_history(self):
task = {"type": "agent_message", "id": "amsg_task", "author": "/root",
"recipient": "/root/child", "content": [
{"type": "input_text", "text": "Message Type: NEW_TASK\nRun CHILD_TOOL_410962"}]}
req = {"input": [{"role": "user", "content": "Old setup"}, task]}
before = copy.deepcopy(req)
self.assertEqual(gc.normalize(req), before)
projected = gc.portable_reasoning(req)
message = projected["input"][-1]
self.assertEqual(message["role"], "user")
self.assertEqual(message["content"][1:], task["content"])
self.assertIn('/root/child', message["content"][0]["text"])
self.assertNotIn("id", message)
self.assertEqual(gc.split_history(projected["input"], keep_users=1)[2], [message])
self.assertEqual(req, before)
self.assertEqual(gc.portable_reasoning(projected), projected)
direct = copy.deepcopy(req)
env["flatten_request"](direct)
self.assertEqual(direct["input"][-1], message)
def test_native_function_results_are_not_foreign_or_renamed(self):
req = {"input": [{"type": "function_call_output", "id": "fco_native",
"call_id": "call_native", "output": "kept"}]}
self.assertFalse(gc.foreign(req))
self.assertEqual(gc.normalize(req), req)
def test_encrypted_reasoning_does_not_block_visible_history_summary(self):
req = {"input": [{"role": "user", "content": "KARAR-4827 " + "a" * 60000},
{"type": "reasoning", "encrypted_content": "OPAQUE_OLD",
"summary": [{"type": "summary_text", "text": "VISIBLE_NOTE"}]},
{"role": "assistant", "content": "Work done"},
{"role": "user", "content": "Latest"},
{"type": "reasoning", "encrypted_content": "OPAQUE_RECENT"}]}
before = copy.deepcopy(req)
calls = []
def summarize(text, budget):
calls.append(text)
self.assertNotIn("OPAQUE_", text)
self.assertIn("VISIBLE_NOTE", text)
self.assertIn("KARAR-4827", text)
return "KARAR-4827: Visible decisions and work preserved. " * 4
result = gc.Preparer().prepare(req, 32768, summarize)
self.assertEqual(len(calls), 1)
self.assertNotIn("OPAQUE_", json.dumps(result))
self.assertEqual(req, before)
self.assertLessEqual(gc.tokens(result), gc.input_budget(req, 32768))
self.assertEqual(result["input"][-1], {"role": "user", "content": "Latest"})
def test_legacy_summary_and_tool_ids_repaired_without_mutating_source(self):
req = {"input": [ozet_kopru.ozet_ogesi("KARAR-4827 korunacak"),
{"type": "custom_tool_call", "id": "fc_call_173683", "call_id": "call_1", "input": "abc"},
@@ -112,6 +164,12 @@ class PreparationService(unittest.TestCase):
duz(200, raw, "text/event-stream")(h, body)
self.server = sunucu(upstream)
self.ns["ROUTES"] = {"aiturk": {"url": "http://127.0.0.1:%d/responses" % self.server.server_address[1], "key": "gateway-test"}}
self.temp = tempfile.TemporaryDirectory()
settings = dict(compression_settings.DEFAULTS, keepTurns=1, primaryModel='ide/test', timeoutSeconds=5)
Path(self.temp.name, 'compression-settings.json').write_text(json.dumps(settings), encoding='utf-8')
self.ns.update(HOME=self.temp.name, compression_engine=compression_engine,
CHECKPOINTS=compression_settings.Checkpoints(self.temp.name), __name__='offline')
self.ns['sys'] = SimpleNamespace(modules={'offline': SimpleNamespace(**self.ns)})
cls = ast.ClassDef(name="Service", bases=[], keywords=[], body=methods, decorator_list=[])
exec(compile(ast.fix_missing_locations(ast.Module(body=[cls], type_ignores=[])), "preparation", "exec"), self.ns)
self.service = self.ns["Service"]()
@@ -121,6 +179,7 @@ class PreparationService(unittest.TestCase):
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.temp.cleanup()
def test_large_external_preflight_uses_aiturk_only_and_fits(self):
req = {"model": "ide/test", "input": [{"role": "user", "content": "x" * 60000 + "KARAR-4827"},
@@ -148,14 +207,110 @@ class PreparationService(unittest.TestCase):
self.service._prepare_transfer({"request": req})
self.assertEqual(self.server.gelen, [])
def test_external_compaction_uses_external_summary_not_subscription(self):
req = {"model": "ide/test", "input": [{"role": "user", "content": "KARAR-4827"}, {"type": "compaction_trigger"}]}
def test_external_small_history_strips_only_opaque_reasoning(self):
req = {"model": "ide/test", "input": [
{"role": "user", "content": "KARAR-4827"},
{"type": "reasoning", "id": "rs_native", "encrypted_content": "OPAQUE_NATIVE",
"summary": [{"type": "summary_text", "text": "VISIBLE_NOTE"}]},
{"type": "function_call", "id": "fc_native", "call_id": "call_native", "name": "read", "arguments": "{}"},
{"type": "function_call_output", "id": "fco_native", "call_id": "call_native", "output": "TOOL_RESULT"},
{"role": "assistant", "content": "ANSWER"}]}
before = copy.deepcopy(req)
result = self.service._prepare_transfer({"request": req})["request"]
self.assertNotIn("OPAQUE_NATIVE", json.dumps(result))
self.assertIn("VISIBLE_NOTE", json.dumps(result))
for item in (req["input"][0], *req["input"][2:]):
self.assertIn(item, result["input"])
self.assertEqual(req, before)
self.assertEqual(self.server.gelen, [])
def test_external_compaction_never_sends_reasoning_ciphertext_to_summarizer(self):
req = {"model": "ide/test", "input": [
{"role": "assistant", "content": "KARAR-4827: previous decision"},
{"role": "user", "content": "KARAR-4827"},
{"type": "reasoning", "encrypted_content": "OPAQUE_NATIVE"},
{"type": "compaction_trigger"}]}
result = self.service._prepare_transfer({"request": req})
self.assertIn("KARAR-4827", ozet_kopru.ozet_metni(result["compaction"]))
self.assertIn("KARAR-4827", ozet_kopru.ozet_metni(result["compaction_items"][0]))
self.assertEqual(len(self.server.gelen), 1)
self.assertNotIn(b"OPAQUE_NATIVE", self.server.gelen[0]["govde"])
def test_native_small_history_keeps_reasoning_unchanged(self):
req = {"model": "gpt-6-astra", "input": [
{"type": "reasoning", "encrypted_content": "OPAQUE_NATIVE"},
{"role": "user", "content": "Latest"}]}
self.assertEqual(self.service._prepare_transfer({"request": req})["request"], req)
self.assertEqual(self.server.gelen, [])
def test_real_compaction_still_uses_owner_while_reasoning_is_portable(self):
compact = {"type": "compaction", "encrypted_content": "REAL_COMPACTION"}
calls = []
def decode(_n, item, _cfg):
calls.append(item)
return "COMPACTED_DECISION_4827"
self.service._openai_ozet_coz = decode
req = {"model": "ide/test", "input": [compact,
{"type": "reasoning", "encrypted_content": "OPAQUE_REASONING"},
{"role": "user", "content": "Latest"}]}
before = copy.deepcopy(req)
result = self.service._prepare_transfer({"request": req})["request"]
self.assertEqual(calls, [compact])
self.assertIn("COMPACTED_DECISION_4827", json.dumps(result))
self.assertNotIn("OPAQUE_REASONING", json.dumps(result))
self.assertEqual(req, before)
def test_large_native_return_summarizes_visible_history_without_ciphertext(self):
req = {"model": "gpt-6-astra", "input": [
{"role": "user", "content": "KARAR-4827 " + "x" * 190000},
{"type": "reasoning", "encrypted_content": "OPAQUE_NATIVE"},
{"role": "user", "content": "Latest"}]}
before = copy.deepcopy(req)
result = self.service._prepare_transfer({"request": req, "source_model": "ide/test"})["request"]
self.assertEqual(result["model"], "gpt-6-astra")
self.assertIn("KARAR-4827", json.dumps(result))
self.assertLessEqual(gc.tokens(result), gc.input_budget(req, 128000))
self.assertEqual(req, before)
self.assertTrue(self.server.gelen)
self.assertTrue(all(b"OPAQUE_NATIVE" not in call["govde"] for call in self.server.gelen))
def test_external_compaction_uses_external_summary_not_subscription(self):
req = {"model": "ide/test", "input": [{"role": "assistant", "content": "KARAR-4827"}, {"role": "user", "content": "KARAR-4827"}, {"type": "compaction_trigger"}]}
result = self.service._prepare_transfer({"request": req})
self.assertIn("KARAR-4827", ozet_kopru.ozet_metni(result["compaction_items"][0]))
self.assertEqual(len(self.server.gelen), 1)
class SocketMigration(fixtures.Ayirici):
def test_completed_return_ends_preparation_for_following_native_turns(self):
self.ozgun.cevap = duz(200, b'data: {"type":"response.completed"}\n\n', 'text/event-stream')
req = {"model": "ide/test", "prompt_cache_key": "return-once", "input": []}
self.iste("POST", "/backend-api/codex/responses", json.dumps(req).encode())
req.update(model="gpt-6-astra", input=[{"role": "user", "content": "x" * 100000}])
self.iste("POST", "/backend-api/codex/responses", json.dumps(req).encode())
count = len(self.kopru.gelen)
req.update(model="gpt-6-luna")
req["input"].append({"type": "reasoning", "encrypted_content": "NATIVE_OPAQUE"})
raw = zstd(json.dumps(req).encode())
self.iste("POST", "/backend-api/codex/responses", raw, {"Content-Encoding": "zstd"})
self.assertEqual(len(self.kopru.gelen), count)
self.assertEqual(self.ozgun.gelen[-1]["govde"], raw)
def test_native_large_tool_history_bypasses_transfer_byte_for_byte(self):
for model in ("gpt-6-astra", "gpt-6-luna"):
req = {"model": model, "prompt_cache_key": "native-only", "input": [
{"role": "user", "content": "x" * 100000},
{"type": "reasoning", "id": "rs_native", "encrypted_content": "OPAQUE_NATIVE"},
{"type": "function_call", "id": "fc_native", "call_id": "c", "name": "read", "arguments": "{}"},
{"type": "function_call_output", "id": "fco_native", "call_id": "c", "output": "result"},
{"role": "user", "content": "Latest"}]}
raw = json.dumps(req).encode()
for encoding, body in (("", raw), ("zstd", zstd(raw))):
headers = {"Content-Encoding": encoding} if encoding else {}
status, _, _ = self.iste("POST", "/backend-api/codex/responses", body, headers)
self.assertEqual(status, 200)
self.assertEqual(self.ozgun.gelen[-1]["govde"], body)
self.assertEqual(self.kopru.gelen, [])
def test_fifty_simultaneous_chats_keep_their_provider_routes(self):
start = threading.Barrier(50)
def call(index):
@@ -1,5 +1,7 @@
"""Layer check (11 Oct 2026): Jev passes or blocks each step of Katman-1..3; a blocked step is redone by the next
layer. Katman-4 is never asked. No exceptions."""
layer. The last layer is never asked. No exceptions.
14 Oct 2026 defaults: Katman-1 -> Katman-2 -> Katman-3; Katman-3, Chat-Model and bekci-sesli-bekci always pass."""
import ast
import io
import json
@@ -60,18 +62,19 @@ class Karar(unittest.TestCase):
class Katman(unittest.TestCase):
def test_a_non_layer_model_falls_through_every_layer_from_the_top(self):
self.assertEqual(jev_denetim.sonrakiler("Uncensored-Uygulama"),
["Katman-1", "Katman-2", "Katman-3", "Katman-4"])
self.assertEqual(jev_denetim.sonrakiler("ide/some-model"),
["Katman-1", "Katman-2", "Katman-3", "Katman-4"])
self.assertEqual(jev_denetim.sonrakiler("Uncensored-Uygulama"), ["Katman-1", "Katman-2", "Katman-3"])
self.assertEqual(jev_denetim.sonrakiler("ide/some-model"), ["Katman-1", "Katman-2", "Katman-3"])
def test_a_selected_layer_only_tries_the_ones_after_it(self):
self.assertEqual(jev_denetim.sonrakiler("Katman-1"), ["Katman-2", "Katman-3", "Katman-4"])
self.assertEqual(jev_denetim.sonrakiler("Katman-2"), ["Katman-3", "Katman-4"])
self.assertEqual(jev_denetim.sonrakiler("Katman-3"), ["Katman-4"])
self.assertEqual(jev_denetim.sonrakiler("Katman-1"), ["Katman-2", "Katman-3"])
self.assertEqual(jev_denetim.sonrakiler("Katman-2"), ["Katman-3"])
# The always-pass last layer only uses the general fallback on transport failure; no revisits.
self.assertEqual(jev_denetim.sonrakiler("Katman-3"), ["Katman-1", "Katman-2"])
def test_last_layer_is_never_asked(self):
self.assertTrue(jev_denetim.muaf_mi("Katman-4"))
self.assertTrue(jev_denetim.muaf_mi("Katman-3"))
self.assertTrue(jev_denetim.muaf_mi("Chat-Model"))
self.assertFalse(jev_denetim.muaf_mi("Katman-4"))
self.assertEqual(jev_denetim.sonrakiler(None), [])
@@ -139,7 +142,7 @@ class Akis(unittest.TestCase):
self.h.send_response = Mock()
self.h.send_header = Mock()
self.h.end_headers = Mock()
self.cevap = {"Katman-1": sse("bir"), "Katman-2": sse("iki"), "Katman-3": sse("uc"), "Katman-4": sse("dort")}
self.cevap = {"Katman-1": sse("bir"), "Katman-2": sse("iki"), "Katman-3": sse("uc")}
def sor(self, istek, metin, key):
self.asked.append(metin)
@@ -162,7 +165,7 @@ class Akis(unittest.TestCase):
raw = rest[n + 2:]
return out
def calistir(self, body, sira=("Katman-2", "Katman-3", "Katman-4")):
def calistir(self, body, sira=("Katman-2", "Katman-3")):
self.h._denetimli_akis(Yanit(body), "[1]", None, None, "yap", list(sira), self.ac)
def test_a_passed_step_is_delivered_unchanged(self):
@@ -176,7 +179,7 @@ class Akis(unittest.TestCase):
body = sse("Bunu yapamam.")
self.puanlar = [{"ret": 0.9, "sart": 0.0}]
self.h._denetimli_akis(Yanit(body), "[1]", None, None, "yap",
list(jev_denetim.KATMAN), self.ac, hedef="Sesli-Hizli-Zeki")
list(jev_denetim.KATMAN), self.ac, hedef="Chat-Model")
self.assertEqual(self.sent(), body)
self.assertEqual(self.asked, [])
self.assertEqual(self.opened, [])
@@ -184,14 +187,13 @@ class Akis(unittest.TestCase):
def test_voice_transport_failure_still_checks_the_fallback_model(self):
self.puanlar = [{"ret": 0.9, "sart": 0.0}]
self.h._denetimli_akis(None, "[1]", None, None, "yap",
list(jev_denetim.KATMAN), self.ac, hedef="Sesli-Hizli-Zeki")
list(jev_denetim.KATMAN), self.ac, hedef="Chat-Model")
self.assertEqual(self.opened, ["Katman-1", "Katman-2"])
self.assertEqual(self.asked, ["bir", "iki"])
self.assertEqual(self.sent(), self.cevap["Katman-2"])
def test_voice_lookalikes_are_still_checked(self):
for target in ("sesli-hizli-zeki", "Sesli-Hizli-Zeki-v2", "ide/Sesli-Hizli-Zeki",
"Sesli-Hizli-Zeki ", "Other-Combo"):
for target in ("chat-model", "Chat-Model-v2", "ide/Chat-Model", "Chat-Model ", "Other-Combo"):
with self.subTest(target=target):
self.setUp()
self.puanlar = [{"ret": 0.9, "sart": 0.0}]
@@ -202,7 +204,7 @@ class Akis(unittest.TestCase):
def test_a_blocked_non_layer_model_goes_to_layer_1_first(self):
self.puanlar = [{"ret": 0.9, "sart": 0.0}] # the selected (non-layer) model is blocked
self.calistir(sse("Bunu yapamam."), sira=("Katman-1", "Katman-2", "Katman-3", "Katman-4"))
self.calistir(sse("Bunu yapamam."), sira=("Katman-1", "Katman-2", "Katman-3"))
self.assertEqual(self.sent(), self.cevap["Katman-1"])
self.assertEqual(self.opened, ["Katman-1"])
self.assertEqual(self.asked, ["Bunu yapamam.", "bir"])
@@ -216,17 +218,17 @@ class Akis(unittest.TestCase):
self.assertEqual(self.asked, ["Bunu yapamam.", "iki"])
self.h.send_response.assert_called_once()
def test_every_layer_blocked_ends_at_layer_4_unasked(self):
self.puanlar = [{"ret": 0.9, "sart": 0.0}] * 3
def test_every_layer_blocked_ends_at_layer_3_unasked(self):
self.puanlar = [{"ret": 0.9, "sart": 0.0}] * 2
self.calistir(sse("hayir"))
self.assertEqual(self.sent(), self.cevap["Katman-4"])
self.assertEqual(self.opened, ["Katman-2", "Katman-3", "Katman-4"])
self.assertEqual(self.asked, ["hayir", "iki", "uc"]) # layer 4 is never asked
self.assertEqual(self.sent(), self.cevap["Katman-3"])
self.assertEqual(self.opened, ["Katman-2", "Katman-3"])
self.assertEqual(self.asked, ["hayir", "iki"]) # the always-pass last layer is never asked
def test_blocked_layer_3_goes_straight_to_layer_4(self):
def test_blocked_layer_2_goes_straight_to_layer_3(self):
self.puanlar = [{"ret": 0.0, "sart": 0.8}]
self.calistir(sse("kendi sartimla"), sira=("Katman-4",))
self.assertEqual(self.sent(), self.cevap["Katman-4"])
self.calistir(sse("kendi sartimla"), sira=("Katman-3",))
self.assertEqual(self.sent(), self.cevap["Katman-3"])
self.assertEqual(self.asked, ["kendi sartimla"])
def test_a_layer_that_cannot_be_opened_is_skipped(self):
@@ -329,13 +331,13 @@ class Akis(unittest.TestCase):
def test_when_no_layer_opens_the_blocked_answer_is_not_delivered(self):
self.puanlar = [{"ret": 0.9, "sart": 0.0}]
self.cevap.update({"Katman-2": None, "Katman-3": None, "Katman-4": None})
self.cevap.update({"Katman-2": None, "Katman-3": None})
self.calistir(sse("Bunu yapamam."))
self.assertEqual(self.opened, ["Katman-2", "Katman-3", "Katman-4"])
self.assertEqual(self.opened, ["Katman-2", "Katman-3"])
self.assertNotIn(b"yapamam", self.h.wfile.getvalue())
self.assertIn(b'response.failed', self.sent())
def test_the_last_layers_own_error_body_is_delivered_untouched(self):
def test_last_layer_http_error_terminates_sse_without_leaking_raw_body(self):
class Hata(Exception):
def read(self):
return b'{"error": {"message": "katman 4 hatasi"}}'
@@ -344,7 +346,14 @@ class Akis(unittest.TestCase):
def ac(ad):
raise Hata("HTTP 400")
self.h._denetimli_akis(Yanit(sse("Bunu yapamam.")), "[1]", None, None, "yap", ["Katman-4"], ac)
self.assertEqual(self.sent(), b'{"error": {"message": "katman 4 hatasi"}}')
raw = self.sent()
self.assertTrue(raw.startswith(b"event: response.failed\ndata: "))
event = json.loads(raw.split(b"data: ", 1)[1])
self.assertEqual(event["response"]["status"], "failed")
self.assertEqual(event["response"]["error"]["code"], "jev_no_fallback")
self.assertEqual(event["response"]["output"], [])
self.assertNotIn(b"katman 4 hatasi", raw)
self.assertNotIn(b"Bunu yapamam", raw)
def test_unreachable_jev_keeps_the_answer(self):
self.puanlar = [None]
@@ -0,0 +1,550 @@
"""Decision stages (14 Oct 2026): settings schema 2, turn-start decisions, layer window filter, finish check
and Dify rules. Every new stage is off by default; the tests turn them on explicitly."""
import ast
import copy
import hashlib
import io
import json
import threading
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
import jev_denetim
import jev_settings
import karar_politikasi
import model_gecis
import pencere
import yarim
from test_jev_denetim import Yanit, method, sse, user
SOURCE = Path(__file__).with_name("kopru.py").read_text(encoding="utf-8")
TREE = ast.parse(SOURCE)
def fonksiyon(name):
return next(n for n in TREE.body if isinstance(n, ast.FunctionDef) and n.name == name)
class Ev(unittest.TestCase):
"""A temporary bridge home with a settings file."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.home = Path(self.tmp.name)
p = patch.object(jev_settings, 'home', return_value=self.home)
p.start()
self.addCleanup(p.stop)
self.settings = copy.deepcopy(jev_settings.DEFAULTS)
def save(self, settings=None, **extra):
data = {'settings': settings if settings is not None else self.settings}
data.update(extra)
(self.home / 'jev-settings.json').write_text(json.dumps(data), encoding='utf-8')
class Ayarlar(Ev):
def test_a_schema_1_file_gets_every_new_stage_disabled(self):
eski = {k: v for k, v in self.settings.items() if k not in jev_settings.SECTIONS}
self.save(eski)
s, _ = jev_settings.read()
for bolum in ('prefilter', 'autoRoute', 'finishCheck', 'verifierCheck'):
self.assertFalse(s[bolum]['enabled'], bolum)
self.assertEqual(s['policy']['source'], 'local')
self.assertEqual(s['watchModels'], jev_settings.DEFAULTS['watchModels'])
def test_a_partial_section_keeps_written_values_and_fills_the_rest(self):
self.settings['prefilter'] = {'enabled': True, 'threshold': 0.8}
self.save()
p = jev_settings.read()[0]['prefilter']
self.assertEqual((p['enabled'], p['threshold']), (True, 0.8))
self.assertEqual(p['target'], jev_settings.DEFAULTS['prefilter']['target'])
def test_bad_values_are_rejected(self):
for bolum, alan, deger in (('prefilter', 'threshold', 1.5), ('prefilter', 'timeoutSeconds', 0),
('autoRoute', 'model', ''), ('finishCheck', 'note', ''),
('policy', 'endpoint', 'http://dify.example'), ('policy', 'source', 'web')):
with self.subTest(bolum=bolum, alan=alan):
s = copy.deepcopy(self.settings)
s[bolum][alan] = deger
self.save(s)
with self.assertRaises(ValueError):
jev_settings.read()
def test_automatic_model_cannot_target_itself(self):
self.settings['autoRoute']['targets']['zor'] = 'Otomatik'
self.save()
with self.assertRaises(ValueError):
jev_settings.read()
def test_watch_channel_is_an_exact_name_from_settings(self):
self.settings['watchModels'] = ['Chat-Model']
self.save()
self.assertTrue(jev_denetim.izleme_mi('Chat-Model'))
for ad in ('chat-model', 'Chat-Model ', 'ide/Chat-Model', 'Sesli-Hizli-Zeki'):
self.assertFalse(jev_denetim.izleme_mi(ad), ad)
class Politika(Ev):
def test_dify_rules_apply_only_when_the_source_is_dify_and_they_are_valid(self):
(self.home / jev_settings.POLICY_FILE).write_text(json.dumps(
{'ok': True, 'overrides': {'prefilter': {'enabled': True, 'target': 'Katman-3'}}}), encoding='utf-8')
self.save()
self.assertFalse(jev_settings.read()[0]['prefilter']['enabled']) # source local
self.settings['policy']['source'] = 'dify'
self.save()
p = jev_settings.read()[0]['prefilter']
self.assertEqual((p['enabled'], p['target']), (True, 'Katman-3'))
self.assertFalse(jev_settings.read(overlay=False)[0]['prefilter']['enabled'])
(self.home / jev_settings.POLICY_FILE).write_text(json.dumps(
{'ok': True, 'overrides': {'prefilter': {'threshold': 7}}}), encoding='utf-8')
self.assertFalse(jev_settings.read()[0]['prefilter']['enabled']) # invalid rules: local in force
def test_rules_cannot_change_the_chain_or_unknown_sections(self):
s = jev_settings.read(overlay=False)[0]
with self.assertRaises(ValueError):
karar_politikasi.ayikla({'exemptModels': {'x': 1}}, s)
with self.assertRaises(ValueError):
karar_politikasi.ayikla({'autoRoute': {'targets': {'zor': 'Otomatik'}}}, s)
with self.assertRaises(ValueError):
karar_politikasi.ayikla('[1, 2]', s)
self.assertEqual(karar_politikasi.ayikla('{"autoRoute": {"enabled": true}}', s), {'autoRoute': {'enabled': True}})
def test_fetch_reads_the_kurallar_output_and_never_raises(self):
s = jev_settings.read(overlay=False)[0]
def yanit(govde):
r = Mock()
r.__enter__ = lambda self_: self_
r.__exit__ = lambda *a: False
r.read = lambda n=-1: json.dumps(govde).encode()
return r
istekler = []
def urlopen(req, timeout):
istekler.append(req)
return yanit({'data': {'outputs': {'kurallar': {'finishCheck': {'enabled': True}}}}})
sonuc = karar_politikasi.cek(s, 'app-key', urlopen=urlopen)
self.assertEqual(sonuc, {'ok': True, 'overrides': {'finishCheck': {'enabled': True}}})
self.assertTrue(istekler[0].full_url.endswith('/workflows/run'))
self.assertEqual(istekler[0].get_header('Authorization'), 'Bearer app-key')
self.assertFalse(karar_politikasi.cek(s, '', urlopen=urlopen)['ok'])
self.assertFalse(karar_politikasi.cek(s, 'k', urlopen=Mock(side_effect=OSError('kapali')))['ok'])
bos = karar_politikasi.cek(s, 'k', urlopen=lambda req, timeout: yanit({'data': {'outputs': {}}}))
self.assertEqual(bos['ok'], False)
def test_a_failed_fetch_keeps_the_last_good_rules(self):
jev_settings.write_policy({'ok': True, 'overrides': {'finishCheck': {'enabled': True}}})
kayit = jev_settings.write_policy({'ok': False, 'error': 'Dify kapali'})
self.assertTrue(kayit['ok'])
self.assertEqual(kayit['overrides'], {'finishCheck': {'enabled': True}})
self.assertEqual(kayit['error'], 'Dify kapali')
self.settings['policy']['source'] = 'dify'
self.save()
self.assertTrue(jev_settings.read()[0]['finishCheck']['enabled'])
class OnKarar(Ev):
def gonder(self, cevap):
self.govdeler = []
def f(govde, key):
self.govdeler.append(govde)
if isinstance(cevap, Exception):
raise cevap
return {'answers': {k: {'noul': v} for k, v in cevap.items()}}
return f
def test_automatic_routing_picks_research_then_hard_then_simple(self):
self.settings['autoRoute']['enabled'] = True
self.save()
t = self.settings['autoRoute']['targets']
for puan, hedef in (({'zor': 0.9, 'arastirma': 0.8}, t['arastirma']), ({'zor': 0.9, 'arastirma': 0.1}, t['zor']),
({'zor': 0.1, 'arastirma': 0.1}, t['basit'])):
sonuc = jev_denetim.on_karar('istek', True, False, 'k', gonder=self.gonder(puan))
self.assertEqual(sonuc['hedef'], hedef)
self.assertEqual(set(self.govdeler[0]['questions']), {'zor', 'arastirma'})
self.assertNotIn('assistant_reply', self.govdeler[0]['state'])
def test_no_decision_falls_to_the_strong_target(self):
self.settings['autoRoute']['enabled'] = True
self.save()
sonuc = jev_denetim.on_karar('istek', True, False, 'k', gonder=self.gonder(OSError('yok')))
self.assertEqual((sonuc['hedef'], sonuc['ret']), (self.settings['autoRoute']['targets']['zor'], None))
def test_refusal_question_goes_in_the_same_call(self):
self.settings['autoRoute']['enabled'] = True
self.settings['prefilter']['enabled'] = True
self.save()
sonuc = jev_denetim.on_karar('istek', True, True, 'k', gonder=self.gonder({'zor': 0.1, 'arastirma': 0.1, 'ret': 0.9}))
self.assertEqual(len(self.govdeler), 1)
self.assertEqual(set(self.govdeler[0]['questions']), {'zor', 'arastirma', 'ret'})
self.assertTrue(sonuc['ret'])
def test_a_missing_answer_is_no_decision(self):
self.settings['prefilter']['enabled'] = True
self.save()
sonuc = jev_denetim.on_karar('istek', False, True, 'k', gonder=self.gonder({}))
self.assertIsNone(sonuc['ret'])
class TurBasi(Ev):
"""kopru.karar_asamasi through its own source."""
def setUp(self):
super().setUp()
self.kararlar = []
self.logs = []
sahte_denetim = Mock(wraps=jev_denetim)
sahte_denetim.anahtar = lambda: 'k'
sahte_denetim.on_karar = self.on_karar
sahte_denetim.muaf_mi = jev_denetim.muaf_mi
sahte_denetim.sonrakiler = jev_denetim.sonrakiler
sahte_denetim.son_istek = jev_denetim.son_istek
self.cevap = {'hedef': None, 'ret': None, 'puan': None}
sahte_denetim.son_istek_sirasi = jev_denetim.son_istek_sirasi
sahte_denetim.istek_metni = jev_denetim.istek_metni
self.env = {'IDE_PREFIX': 'ide/', 'ON_KARAR': {}, 'ON_KARAR_SINIR': 3, 'jev_settings': jev_settings,
'jev_denetim': sahte_denetim, 'model_gecis': model_gecis, 'hashlib': hashlib, 'time': time,
'json': json, 'KARAR_KILIDI': threading.Lock(), 'BITIS_TURLARI': {}, 'log': self.logs.append}
govde = [fonksiyon(a) for a in ('tur_bilgisi', 'karar_asamasi', 'bitis_kullan')]
exec(compile(ast.Module(body=govde, type_ignores=[]), 'kopru.py', 'exec'), self.env)
self.karar = self.env['karar_asamasi']
def on_karar(self, istek, oto, ret, key):
self.kararlar.append((istek, oto, ret))
return dict(self.cevap)
def istek(self, model, *girdi):
return {'model': model, 'prompt_cache_key': 'sohbet-1', 'input': list(girdi)}
def test_everything_off_changes_nothing_and_asks_nothing(self):
self.save()
for model in ('ide/Ajan', 'ide/codex-gpt-6-astra', 'ide/Chat-Model'):
req = self.istek(model, user('yap'))
self.assertEqual(self.karar(1, req, 'sohbet-1', model), model)
self.assertEqual(req['model'], model)
self.assertEqual(self.kararlar, [])
def test_automatic_choice_is_kept_for_the_tool_steps_of_the_same_turn(self):
self.settings['autoRoute']['enabled'] = True
self.save()
self.cevap = {'hedef': 'Arastirma', 'ret': None, 'puan': {'zor': 0.2, 'arastirma': 0.9}}
req = self.istek('ide/Otomatik', user('kaynaklari karsilastir'))
self.assertEqual(self.karar(1, req, 'sohbet-1', 'ide/Otomatik'), 'ide/Arastirma')
self.assertEqual(req['model'], 'ide/Arastirma')
arac = {'type': 'function_call_output', 'call_id': 'c1', 'output': 'ok'}
req2 = self.istek('ide/Otomatik', user('kaynaklari karsilastir'), {'type': 'function_call', 'call_id': 'c1'}, arac)
self.assertEqual(self.karar(2, req2, 'sohbet-1', 'ide/Otomatik'), 'ide/Arastirma')
self.assertEqual(len(self.kararlar), 1) # same turn: no second question
son_cevap = {'type': 'message', 'role': 'assistant', 'content': [{'type': 'output_text', 'text': 'Rapor hazir.'}]}
req3 = self.istek('ide/Otomatik', user('kaynaklari karsilastir'), arac, son_cevap, user('simdi kodu duzelt'))
self.cevap = {'hedef': 'Ajan', 'ret': None, 'puan': {'zor': 0.9, 'arastirma': 0.1}}
self.assertEqual(self.karar(3, req3, 'sohbet-1', 'ide/Otomatik'), 'ide/Ajan')
self.assertEqual(len(self.kararlar), 2) # new user message: new decision
def test_predicted_refusal_starts_at_the_configured_layer(self):
self.settings['prefilter']['enabled'] = True
self.save()
self.cevap = {'hedef': None, 'ret': True, 'puan': {'ret': 0.9}}
req = self.istek('ide/codex-gpt-6-astra', user('sert bir roast yaz'))
self.assertEqual(self.karar(1, req, 'sohbet-1', 'ide/codex-gpt-6-astra'), 'ide/Katman-2')
self.assertEqual(self.kararlar, [('sert bir roast yaz', False, True)])
def test_exempt_and_later_layers_are_not_asked(self):
self.settings['prefilter']['enabled'] = True
self.save()
for model in ('ide/Chat-Model', 'ide/Katman-2', 'ide/Katman-3', 'codex/gpt-6-astra'):
req = self.istek(model, user('yap'))
self.assertEqual(self.karar(1, req, 'sohbet-1', model), model)
self.assertEqual(self.kararlar, [])
def test_no_refusal_or_no_decision_keeps_the_model(self):
self.settings['prefilter']['enabled'] = True
self.save()
for cevap in ({'hedef': None, 'ret': False, 'puan': {'ret': 0.1}}, {'hedef': None, 'ret': None, 'puan': None}):
self.env['ON_KARAR'].clear()
self.cevap = cevap
req = self.istek('ide/Ajan', user('yap'))
self.assertEqual(self.karar(1, req, 'sohbet-1', 'ide/Ajan'), 'ide/Ajan')
self.assertEqual(req['model'], 'ide/Ajan')
def test_master_switch_off_disables_every_stage(self):
self.settings['enabled'] = False
self.settings['prefilter']['enabled'] = True
self.settings['autoRoute']['enabled'] = True
self.save()
req = self.istek('ide/Otomatik', user('yap'))
# The virtual name never leaves the bridge; nothing is asked while the main switch is off.
self.assertEqual(self.karar(1, req, 'sohbet-1', 'ide/Otomatik'), 'ide/' + self.settings['autoRoute']['targets']['zor'])
self.assertEqual(self.kararlar, [])
def test_a_message_typed_mid_turn_or_a_dot_keeps_the_choice(self):
self.settings['autoRoute']['enabled'] = True
self.save()
self.cevap = {'hedef': 'Arastirma', 'ret': None, 'puan': {'zor': 0.2, 'arastirma': 0.9}}
bas = user('kaynaklari karsilastir')
cagri = {'type': 'function_call', 'call_id': 'c1', 'name': 'shell', 'arguments': '{}'}
cikti = {'type': 'function_call_output', 'call_id': 'c1', 'output': 'ok'}
self.assertEqual(self.karar(1, self.istek('ide/Otomatik', bas), 'sohbet-1', 'ide/Otomatik'), 'ide/Arastirma')
self.cevap = {'hedef': 'Workflow-Kesif', 'ret': None, 'puan': {'zor': 0.1, 'arastirma': 0.1}}
for ek in (user('.'), user('bir de sunu ekle'), {'type': 'compaction_trigger'}):
req = self.istek('ide/Otomatik', bas, cagri, cikti, ek)
self.assertEqual(self.karar(2, req, 'sohbet-1', 'ide/Otomatik'), 'ide/Arastirma', ek)
self.assertEqual(len(self.kararlar), 1)
def test_a_disabled_automatic_model_never_reaches_the_gateway(self):
self.save() # autoRoute off
req = self.istek('ide/Otomatik', user('yap'))
secilen = self.karar(1, req, 'sohbet-1', 'ide/Otomatik')
self.assertEqual(secilen, 'ide/' + self.settings['autoRoute']['targets']['zor'])
self.assertEqual(req['model'], secilen)
self.assertEqual(self.kararlar, [])
self.settings['autoRoute']['enabled'] = True
self.settings['enabled'] = False # main switch off
self.save()
self.assertNotEqual(self.karar(2, self.istek('ide/Otomatik', user('yap')), 'sohbet-1', 'ide/Otomatik'), 'ide/Otomatik')
def test_without_a_conversation_key_the_tool_steps_still_keep_the_choice(self):
self.settings['autoRoute']['enabled'] = True
self.save()
self.cevap = {'hedef': 'Ajan', 'ret': None, 'puan': {'zor': 0.9, 'arastirma': 0.1}}
bas = user('kodu duzelt')
self.assertEqual(self.karar(1, {'model': 'ide/Otomatik', 'input': [bas]}, '', 'ide/Otomatik'), 'ide/Ajan')
cikti = {'type': 'function_call_output', 'call_id': 'c1', 'output': 'ok'}
req = {'model': 'ide/Otomatik', 'input': [bas, {'type': 'function_call', 'call_id': 'c1'}, cikti]}
self.assertEqual(self.karar(2, req, '', 'ide/Otomatik'), 'ide/Ajan')
self.assertEqual(len(self.kararlar), 1)
def test_the_finish_retry_is_used_once_per_turn(self):
self.assertTrue(self.env['bitis_kullan']('tur-1'))
self.assertFalse(self.env['bitis_kullan']('tur-1'))
self.assertTrue(self.env['bitis_kullan']('tur-2'))
def test_turn_memory_is_bounded(self):
self.settings['prefilter']['enabled'] = True
self.save()
self.cevap = {'hedef': None, 'ret': False, 'puan': {'ret': 0.1}}
for i in range(6):
self.karar(i, {'model': 'ide/Ajan', 'prompt_cache_key': f's{i}', 'input': [user('yap')]}, f's{i}', 'ide/Ajan')
self.assertLessEqual(len(self.env['ON_KARAR']), 3)
class KatmanPenceresi(unittest.TestCase):
def suzgec(self, bilgi):
logs = []
env = {'IDE_PREFIX': 'ide/', 'IDE_BILGI': bilgi, 'pencere': pencere, 'log': logs.append}
exec(compile(ast.Module(body=[fonksiyon('sigan_katmanlar')], type_ignores=[]), 'kopru.py', 'exec'), env)
return env['sigan_katmanlar'], logs
def test_a_layer_too_small_for_the_request_is_skipped(self):
f, logs = self.suzgec({'ide/Katman-2': {'pencere': 1000, 'kaynak': 'katalog'}, 'ide/Katman-3': {'pencere': 10 ** 6}})
req = {'input': [user('x' * 20000)]}
self.assertEqual(f(['Katman-2', 'Katman-3', 'Bilinmeyen'], req, '[1]'), ['Katman-3', 'Bilinmeyen'])
self.assertIn('Katman-2', logs[0])
def test_the_last_layer_and_untrusted_windows_are_never_skipped(self):
req = {'input': [user('x' * 20000)]}
f, _ = self.suzgec({'ide/Katman-2': {'pencere': 1000, 'kaynak': 'katalog'}, 'ide/Katman-3': {'pencere': 1000, 'kaynak': 'katalog'}})
self.assertEqual(f(['Katman-2', 'Katman-3'], req, '[1]'), ['Katman-3']) # last one stays
f, _ = self.suzgec({'ide/Katman-2': {'pencere': 1000, 'kaynak': 'varsayilan'}})
self.assertEqual(f(['Katman-2', 'Katman-3'], req, '[1]'), ['Katman-2', 'Katman-3']) # placeholder window
class BitisDenetimi(Ev):
"""_denetimli_akis with finishCheck: an answer that stops early goes back to the same model once."""
def setUp(self):
super().setUp()
self.settings['finishCheck']['enabled'] = True
self.save()
self.asked, self.logs, self.devamlar = [], [], []
self.puanlar = []
sahte = Mock()
sahte.anahtar = lambda: 'k'
sahte.sor = self.sor
sahte.karar = jev_denetim.karar
sahte.bitmedi_mi = jev_denetim.bitmedi_mi
sahte.hazirlik_mi = jev_denetim.hazirlik_mi
sahte.muaf_mi = jev_denetim.muaf_mi
sahte.hata_gecis = jev_denetim.hata_gecis
env = {'jev_denetim': sahte, 'yarim': yarim, 'time': time, 'http': __import__('http.client'),
'log': self.logs.append, 'RESP_HOP': set(), 'json': json}
adlar = ('_akis_basligi', '_denetimli_akis', '_stream')
exec(compile(ast.Module(body=[method(a) for a in adlar], type_ignores=[]), 'kopru.py', 'exec'), env)
self.h = type('H', (), {a: env[a] for a in adlar})()
self.h.wfile = io.BytesIO()
self.h.send_response = self.h.send_header = self.h.end_headers = Mock()
def sor(self, istek, metin, key, bitis=False):
self.asked.append((metin, bitis))
return self.puanlar.pop(0) if self.puanlar else {'ret': 0.0, 'sart': 0.0}
def devam(self, ad, onceki):
self.devamlar.append((ad, onceki))
return Yanit(sse('gercekten bitti, cikti: OK')), None, f'[{ad} devam]'
def calistir(self, body):
self.h._denetimli_akis(Yanit(body), '[1]', None, None, 'yap', ['Katman-1'], Mock(), hedef='Ajan', devam=self.devam)
return self.gonderilen()
def gonderilen(self):
raw, out = self.h.wfile.getvalue(), b''
while raw:
size, _, rest = raw.partition(b'\r\n')
n = int(size, 16)
if n == 0:
break
out += rest[:n]
raw = rest[n + 2:]
return out
def test_an_early_stop_goes_back_once_and_the_retry_is_delivered(self):
self.puanlar = [{'ret': 0.0, 'sart': 0.0, 'bitmedi': 0.9}, {'ret': 0.0, 'sart': 0.0, 'bitmedi': 0.9}]
out = self.calistir(sse('Bitti.'))
self.assertEqual(self.devamlar, [('Ajan', 'Bitti.')])
self.assertIn(b'gercekten bitti', out)
self.assertNotIn(b'"Bitti."', out)
self.assertEqual([b for _, b in self.asked], [True, False]) # the retry is not asked again
def test_a_finished_answer_is_delivered_unchanged(self):
self.puanlar = [{'ret': 0.0, 'sart': 0.0, 'bitmedi': 0.1}]
out = self.calistir(sse('Tamam, test ciktisi: 3 passed.'))
self.assertEqual(self.devamlar, [])
self.assertIn(b'3 passed', out)
def test_a_refusal_still_goes_down_the_chain_not_back(self):
self.puanlar = [{'ret': 0.9, 'sart': 0.0, 'bitmedi': 0.9}]
ac = Mock(return_value=(Yanit(sse('katman')), None, '[K1]'))
self.h._denetimli_akis(Yanit(sse('Yapamam.')), '[1]', None, None, 'yap', ['Katman-1'], ac, hedef='Ajan', devam=self.devam)
self.assertEqual(self.devamlar, [])
ac.assert_called_once_with('Katman-1')
def test_a_failed_retry_delivers_the_first_answer(self):
self.puanlar = [{'ret': 0.0, 'sart': 0.0, 'bitmedi': 0.9}]
def bozuk(ad, onceki):
raise OSError('acilamadi')
self.h._denetimli_akis(Yanit(sse('Bitti.')), '[1]', None, None, 'yap', [], Mock(), hedef='Ajan', devam=bozuk)
self.assertIn(b'Bitti.', self.gonderilen())
def test_a_broken_or_refused_retry_delivers_the_approved_first_answer(self):
for ikinci in (sse(), sse('Yapamam.')):
with self.subTest(ikinci=ikinci[:40]):
self.setUp()
self.puanlar = [{'ret': 0.0, 'sart': 0.0, 'bitmedi': 0.9}, {'ret': 0.9, 'sart': 0.0}]
ac = Mock(return_value=(Yanit(sse('katman')), None, '[K1]'))
self.h._denetimli_akis(Yanit(sse('Ilk cevap.')), '[1]', None, None, 'yap', ['Katman-1'], ac,
hedef='Ajan', devam=lambda ad, onceki: (Yanit(ikinci), None, '[devam]'))
self.assertIn(b'Ilk cevap.', self.gonderilen())
ac.assert_not_called()
def test_without_the_stage_the_finish_question_is_not_sent(self):
govdeler = []
def gonder(govde, key):
govdeler.append(govde)
return {'answers': {'ret': {'noul': 0.1}, 'sart': {'noul': 0.1}}}
puan = jev_denetim.sor('i', 'c', 'k', gonder=gonder)
self.assertNotIn('bitmedi', govdeler[0]['questions'])
self.assertNotIn('bitmedi', puan)
puan = jev_denetim.sor('i', 'c', 'k', gonder=gonder, bitis=True)
self.assertIn('bitmedi', govdeler[1]['questions'])
self.assertEqual(set(puan), {'ret', 'sart'}) # unanswered extra question: main verdict stands
self.assertFalse(jev_denetim.bitmedi_mi(puan))
class DogrulayiciElemesi(Ev):
def test_off_by_default_and_asked_only_when_enabled(self):
import io as _io
import karar_sor
self.save()
out = _io.StringIO()
self.assertEqual(karar_sor.main(['dogrulayici'], _io.StringIO('{"user_request": "x", "assistant_reply": "kabul"}'), out), 0)
self.assertEqual(json.loads(out.getvalue())['enabled'], False)
self.settings['verifierCheck']['enabled'] = True
self.save()
govdeler = []
def gonder(govde, key):
govdeler.append(govde)
return {'answers': {'kanitsiz': {'noul': 0.92}}}
sonuc = karar_sor.dogrulayici({'user_request': 'olcut', 'assistant_reply': '{"karar":"kabul","bulgular":[]}'}, 'k', gonder)
self.assertEqual((sonuc['enabled'], sonuc['kanitsiz'], sonuc['puan']), (True, True, 0.92))
self.assertEqual(set(govdeler[0]['questions']), {'kanitsiz'})
bozuk = karar_sor.dogrulayici({'user_request': 'o', 'assistant_reply': 'r'}, 'k', Mock(side_effect=OSError('yok')))
self.assertIsNone(bozuk['kanitsiz'])
def test_wrong_usage_exits_2(self):
import io as _io
import karar_sor
self.assertEqual(karar_sor.main(['baska'], _io.StringIO('{}'), _io.StringIO()), 2)
class Inceleme(Ev):
def test_the_correction_channel_needs_an_always_pass_target_while_checks_are_on(self):
self.settings['watchModels'] = ['Chat-Model', 'Ajan']
self.save()
self.assertTrue(jev_denetim.izleme_mi('Chat-Model'))
self.assertFalse(jev_denetim.izleme_mi('Ajan')) # checked target: no sideband
self.settings['enabled'] = False
self.save()
self.assertTrue(jev_denetim.izleme_mi('Ajan'))
def test_a_turn_start_question_does_not_wait_in_the_queue_beyond_its_timeout(self):
self.settings['maxConcurrent'] = 1
self.settings['queueWaitSeconds'] = 3600
self.save()
s = jev_settings.read()[0]
self.assertTrue(jev_settings.acquire(s)) # the only slot is taken
try:
t0 = time.monotonic()
self.assertIsNone(jev_denetim.sorular({'user_request': 'x'}, {'zor': 'q'}, 'k', 1, gonder=lambda g, k: {}))
self.assertLess(time.monotonic() - t0, 3)
finally:
jev_settings.release()
def test_the_prefilter_target_cannot_be_the_automatic_model(self):
self.settings['prefilter']['target'] = self.settings['autoRoute']['model']
self.save()
with self.assertRaises(ValueError):
jev_settings.read()
def test_dify_redirects_are_refused_so_the_key_never_moves(self):
import urllib.error
with self.assertRaises(urllib.error.HTTPError):
karar_politikasi._YonlendirmeYok().redirect_request(
Mock(full_url='https://dify.example/v1/workflows/run'), None, 302, 'Found', {}, 'http://evil.example/')
class IzlemeSuzgeci(unittest.TestCase):
def test_a_huge_frame_passes_through_instead_of_breaking_the_stream(self):
tree_sinif = next(n for n in TREE.body if isinstance(n, ast.ClassDef) and n.name == 'WatchResponse')
from bekci_watch import MetadataFilter
logs = []
env = {'MetadataFilter': MetadataFilter, 'log': logs.append}
exec(compile(ast.Module(body=[tree_sinif], type_ignores=[]), 'kopru.py', 'exec'), env)
buyuk = b'data: ' + b'x' * 300000
parcalar = [buyuk[:200000], buyuk[200000:], b'\n\n', b'']
class Ham:
def read1(self, n):
return parcalar.pop(0)
w = env['WatchResponse'](Ham(), lambda m: None)
cikti = b''
while True:
b = w.read1(65536)
if not b and w.done or not parcalar and not b:
break
cikti += b
self.assertIn(b'x' * 1000, cikti)
self.assertEqual(cikti.count(b'x'), 300000)
self.assertTrue(logs)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,18 @@
import pencere
def test_exact_combo_limits_override_unknown_and_local_defaults():
row = {"context_length": 872000, "max_input_tokens": 872000, "max_output_tokens": 128000}
result = pencere.coz("GPT-6-Astra", "combo", None, ilan=row)
assert result["pencere"] == 872000
assert result["kaynak"] == "saglayici-katalogu"
result = pencere.coz("codex-gpt-6-astra", "model", "codex/gpt-6-astra", ilan=row)
assert result["pencere"] == 872000
def test_learned_actual_limit_still_wins_and_invalid_metadata_is_ignored():
result = pencere.coz("combo", "combo", None, ilan={"context_length": 872000}, ogr={"combo": 500000})
assert result["pencere"] == 500000 and result["kaynak"] == "ogrenilen"
for value in (True, -1, "872000", 999999999):
result = pencere.coz("combo", "combo", None, ilan={"context_length": value})
assert result["pencere"] == pencere.VARSAYILAN
@@ -43,7 +43,12 @@ class ModelSwitch(unittest.TestCase):
{"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"})
names = {}
custom = env["flatten_request"](req, names)
alias = req["tools"][0]["name"]
self.assertEqual(custom, {alias})
self.assertEqual(names[alias], ("functions", "probe"))
self.assertEqual(req["input"][0]["name"], alias)
env["fix_tool_ids"](req)
self.assertEqual(json.loads(req["input"][0]["arguments"]), {"input": "remember-4827"})
self.assertEqual(req["input"][1]["output"], "result-4827")
@@ -0,0 +1,83 @@
"""Native switch regressions using the real HTTP splitter and offline peers."""
import copy
import gzip
import json
import unittest
import ast
from pathlib import Path
import bridge
import gecis_context as gc
import ozet_kopru
import test_ayirici as fixtures
class NativeSwitch(unittest.TestCase):
setUp = fixtures.Ayirici.setUp
tearDown = fixtures.Ayirici.tearDown
iste = fixtures.Ayirici.iste
def test_unknown_native_capacity_is_not_invented(self):
tree = ast.parse(Path(__file__).with_name('kopru.py').read_text(encoding='utf-8'))
method = next(node for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name == '_transfer_window')
ns = {'ek_model_mu': lambda _: False, 'LAST_MODELS': {}, 'gecis_context': gc, 'json': json}
exec(compile(ast.Module(body=[method], type_ignores=[]), 'window', 'exec'), ns)
from types import SimpleNamespace
with self.assertRaises(gc.TransferError):
ns['_transfer_window'](SimpleNamespace(headers={}), 'gpt-6-astra')
def test_opaque_native_switch_with_persisted_foreign_residue(self):
self.ozgun.cevap = fixtures.duz(200, b'data: {"type":"response.completed"}\n\n', 'text/event-stream')
compact = {'type': 'compaction', 'id': 'cmp_native',
'encrypted_content': 'gAAAAABp' + 'AqZ09_-jX' * 10000}
req = {'model': 'gpt-6.1-sol', 'prompt_cache_key': 'restart', 'input': [
ozet_kopru.ozet_ogesi('DECISION-4827 must survive'), compact,
{'type': 'custom_tool_call', 'id': 'fc_123', 'call_id': 'call_123',
'name': 'apply_patch', 'input': 'patch text'},
{'type': 'custom_tool_call_output', 'id': 'fco_123',
'call_id': 'call_123', 'output': 'complete'},
{'role': 'user', 'content': 'continue'}]}
before = copy.deepcopy(req)
for cold, model in ((True, 'gpt-6.1-sol'), (False, 'gpt-6-astra'), (True, 'gpt-6-astra')):
if cold:
bridge.TRANSFERS.clear()
req['model'] = model
body = gzip.compress(json.dumps(req).encode())
self.assertEqual(self.iste('POST', '/backend-api/codex/responses', body,
{'Content-Encoding': 'gzip'})[0], 200)
sent = json.loads(self.ozgun.gelen[-1]['govde'])
self.assertEqual(sent['input'][1], compact)
self.assertEqual(sent['input'][2]['id'], 'ctc_123')
self.assertEqual(sent['input'][3]['id'], 'ctco_123')
self.assertEqual(sent['input'][3]['call_id'], 'call_123')
self.assertIn('DECISION-4827', json.dumps(sent))
self.assertEqual(self.kopru.gelen, [])
self.assertEqual(req['input'], before['input'])
def test_clean_native_body_is_byte_identical_even_after_external_source(self):
req = {'model': 'ide/test', 'prompt_cache_key': 'return', 'input': []}
self.iste('POST', '/backend-api/codex/responses', json.dumps(req).encode())
self.kopru.gelen.clear()
req.update(model='gpt-6-astra', input=[{'type': 'compaction',
'encrypted_content': 'gAAAAABp' + 'X' * 100000}])
raw = gzip.compress(json.dumps(req, indent=3).encode())
self.iste('POST', '/backend-api/codex/responses', raw, {'Content-Encoding': 'gzip'})
self.assertEqual(self.ozgun.gelen[-1]['govde'], raw)
self.assertEqual(self.kopru.gelen, [])
def test_cold_native_tool_id_repair_does_not_require_semantic_budget(self):
req = {'model': 'gpt-6-astra', 'input': [
{'type': 'custom_tool_call', 'id': 'fc_old', 'call_id': 'call_old',
'name': 'apply_patch', 'input': 'x' * 100000},
{'type': 'custom_tool_call_output', 'id': 'fco_old',
'call_id': 'call_old', 'output': 'complete'}]}
self.iste('POST', '/backend-api/codex/responses', json.dumps(req).encode())
self.assertEqual(len(self.kopru.gelen), 0)
sent = json.loads(self.ozgun.gelen[-1]['govde'])
self.assertEqual(sent, gc.normalize(req))
self.assertEqual(req['input'][0]['id'], 'fc_old')
if __name__ == '__main__':
unittest.main()
@@ -43,11 +43,15 @@ def message(text, ident="msg_1"):
class Windows(unittest.TestCase):
def test_provider_data_local_table_and_cap(self):
def test_current_provider_aliases_keep_their_own_capacity(self):
self.assertEqual(pencere.coz('agy-gemini-3.8-flash-high', 'model', 'Gemini 3.8 Flash (High)')['pencere'], 1000000)
self.assertEqual(pencere.yol_bilgisi('codex/gpt-6-astra', {})['pencere'], 272000)
def test_provider_data_and_local_table_preserve_large_windows(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)
nano=NANO)["pencere"], 1048576)
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))
@@ -73,6 +77,7 @@ class Windows(unittest.TestCase):
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)
self.assertEqual(pencere.sinir_oku("Input exceeds maximum input tokens: estimated 1125036 input tokens, max input 1048576."), 1048576)
gateway = ("Your prompt exceeds the model's context length. Estimated input tokens: 171016. "
"Max context tokens: 65536.")
self.assertTrue(pencere.tasma_mi(gateway))
@@ -0,0 +1,143 @@
"""Real-socket regressions for terminal worker errors (no provider calls)."""
import json
import unittest
import test_ayirici as fixtures
import terminal_stream
ERROR = b'event: error\ndata: {"type":"error","error":{"type":"upstream_error","message":"upstream 503"}}\n\n'
COMPLETE = b'event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_ok","status":"completed","output":[]}}\n\n'
class TerminalStream(unittest.TestCase):
setUp = fixtures.Ayirici.setUp
tearDown = fixtures.Ayirici.tearDown
iste = fixtures.Ayirici.iste
def request(self, token="a", text="check"):
body = json.dumps({"model": "ide/test", "input": [{"role": "user", "content": text}]})
return self.iste("POST", "/backend-api/codex/responses", body,
{"Authorization": "Bearer " + token})
def worker(self, payload):
def respond(h, body):
if h.path.endswith("/prepare-transfer"):
return fixtures.duz(200, json.dumps({"request": json.loads(body)["request"]}).encode())(h, body)
return fixtures.duz(200, payload, "text/event-stream")(h, body)
self.kopru.cevap = respond
def inference_count(self):
return sum(x["yol"].endswith("/responses") for x in self.kopru.gelen)
def test_error_only_stream_is_an_honest_http_failure(self):
self.worker(ERROR)
status, body, _ = self.request()
self.assertEqual(status, 400)
self.assertIn("upstream 503", json.loads(body)["error"]["message"])
self.assertNotIn(b"response.completed", body)
self.assertEqual(self.inference_count(), 1)
def test_error_after_heartbeat_stops_automatic_replay_once(self):
def respond(h, body):
if h.path.endswith("/prepare-transfer"):
return fixtures.duz(200, json.dumps({"request": json.loads(body)["request"]}).encode())(h, body)
h.send_response(200)
h.send_header("Content-Type", "text/event-stream")
h.send_header("Transfer-Encoding", "chunked")
h.end_headers()
for chunk in (b": keepalive\n\n", ERROR):
h.wfile.write(b"%x\r\n" % len(chunk) + chunk + b"\r\n")
h.wfile.flush()
fixtures.time.sleep(.08)
h.wfile.write(b"0\r\n\r\n")
self.kopru.cevap = respond
status, body, _ = self.request()
self.assertEqual((status, body), (200, b": keepalive\n\n" + ERROR))
status, body, _ = self.request()
self.assertEqual(status, 400)
self.assertIn(b"upstream 503", body)
self.assertEqual(self.inference_count(), 1)
# A later explicit retry is allowed; the one-shot guard is consumed.
self.worker(COMPLETE)
self.assertEqual(self.request()[:2], (200, COMPLETE))
self.assertEqual(self.inference_count(), 2)
def test_native_error_stream_is_not_intercepted(self):
self.ozgun.cevap = fixtures.duz(200, ERROR, "text/event-stream")
body = json.dumps({"model": "gpt-6-astra", "input": []})
self.assertEqual(self.iste("POST", "/backend-api/codex/responses", body)[:2], (200, ERROR))
def test_success_stream_is_byte_identical(self):
self.worker(COMPLETE)
self.assertEqual(self.request()[:2], (200, COMPLETE))
self.assertEqual(self.request()[:2], (200, COMPLETE))
self.assertEqual(self.inference_count(), 2)
def test_eof_without_upstream_error_keeps_network_retry(self):
self.worker(b": keepalive\n\n")
self.assertEqual(self.request()[0], 200)
self.assertEqual(self.request()[0], 200)
self.assertEqual(self.inference_count(), 2)
def test_worker_exhausted_failure_is_terminal_without_changing_its_reason(self):
self.worker(b'event: response.failed\ndata: {"type":"response.failed","response":{"status":"failed","error":{"code":"jev_no_fallback","message":"No accepted response"}}}\n\n')
status, body, _ = self.request()
self.assertEqual(status, 400)
self.assertEqual(json.loads(body)["error"]["code"], "jev_no_fallback")
def test_existing_context_error_classification_is_preserved(self):
data = b'event: response.failed\ndata: {"type":"response.failed","response":{"error":{"code":"context_length_exceeded","message":"Context full"}}}\n\n'
self.worker(data)
self.assertEqual(self.request()[:2], (200, data))
class ObserverTests(unittest.TestCase):
def test_fragmented_crlf_and_multiline_json(self):
data = b'event: error\r\ndata: {"type":"error",\r\ndata: "error":{"type":"upstream_error","message":"503"}}\r\n\r\n'
observer = terminal_stream.Observer()
for byte in data:
observer.feed(bytes([byte]))
self.assertEqual(observer.error['message'], '503')
def test_large_and_malformed_frames_are_bounded_and_do_not_crash(self):
observer = terminal_stream.Observer()
observer.feed(b'data: ' + b'x'*200000)
self.assertLessEqual(len(observer.tail), observer.LIMIT)
observer.feed(b'\n\ndata: {"response":[]}\n\n' + ERROR)
self.assertEqual(observer.error['message'], 'upstream 503')
def test_completed_stream_does_not_poison_next_request(self):
observer = terminal_stream.Observer()
observer.feed(ERROR + COMPLETE)
self.assertIsNone(observer.error)
self.assertTrue(observer.completed)
def test_one_shot_expiry_and_capacity(self):
now = [10]
cache = terminal_stream.FailureReplays(ttl=5, capacity=2, clock=lambda: now[0])
cache.remember(b'a', {'code': 'a'})
self.assertEqual(cache.take(b'a'), {'code': 'a'})
self.assertIsNone(cache.take(b'a'))
cache.remember(b'a', {})
now[0] = 16
self.assertIsNone(cache.take(b'a'))
for key in (b'a', b'b', b'c'):
cache.remember(key, {'code': 'error'})
self.assertIsNone(cache.take(b'a'))
self.assertEqual(len(cache.items), 2)
def test_request_identity_separates_accounts_sessions_models_and_inputs(self):
base = {'model': 'ide/a', 'input': []}
headers = {'Authorization': 'Bearer a', 'session_id': 'one'}
key = terminal_stream.request_key(base, headers)
self.assertEqual(key, terminal_stream.request_key(dict(reversed(list(base.items()))), headers))
for request, other in [({'model': 'ide/b', 'input': []}, headers),
({'model': 'ide/a', 'input': ['next']}, headers),
(base, dict(headers, Authorization='Bearer b')),
(base, dict(headers, session_id='two'))]:
self.assertNotEqual(key, terminal_stream.request_key(request, other))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,66 @@
"""Namespace routing regressions: leaf-only calls cannot dispatch collaboration tools."""
import copy
import json
import unittest
from test_model_switch import env
class ToolNamespaces(unittest.TestCase):
def request(self):
return {"tools": [
{"type": "namespace", "name": "collaboration", "tools": [{"type": "function", "name": "spawn_agent", "parameters": {"type": "object", "properties": {}}}]},
{"type": "namespace", "name": "other", "tools": [{"type": "function", "name": "spawn_agent"}]},
{"type": "namespace", "name": "functions", "tools": [{"type": "custom", "name": "apply_patch"}]},
{"type": "function", "name": "plain"}],
"input": [{"type": "function_call", "namespace": "collaboration", "name": "spawn_agent", "id": "fc_a", "call_id": "call_a", "arguments": "{}"},
{"type": "function_call_output", "call_id": "call_a", "output": "child_123"}]}
def test_namespace_calls_round_trip_at_all_stream_stages(self):
req = self.request(); original = copy.deepcopy(req); names = {}
custom = env["flatten_request"](req, names)
alias = req["tools"][0]["name"]
self.assertNotEqual(alias, req["tools"][1]["name"])
self.assertLessEqual(len(alias), 64)
self.assertEqual(req["input"][0]["name"], alias)
self.assertNotIn("namespace", req["input"][0])
self.assertEqual(req["input"][1], original["input"][1])
adapter = env["SseRewriter"](custom, tool_names=names)
item = {"type": "function_call", "name": alias, "id": "fc_test", "call_id": "call_test", "arguments": "{}"}
for stage in ["response.output_item.added", "response.output_item.done"]:
restored = adapter._fix({"type": stage, "item": dict(item)})["item"]
self.assertEqual((restored["namespace"], restored["name"]), ("collaboration", "spawn_agent"))
self.assertEqual(restored["call_id"], "call_test")
restored = adapter._fix({"type": "response.completed", "response": {"output": [dict(item)]}})["response"]["output"][0]
self.assertEqual((restored["namespace"], restored["name"]), ("collaboration", "spawn_agent"))
def test_custom_tool_namespace_and_ids_survive_chunked_sse(self):
req = self.request(); names = {}; custom = env["flatten_request"](req, names)
alias = req["tools"][2]["name"]
item = {"type": "function_call", "name": alias, "id": "fc_patch", "call_id": "call_patch", "arguments": json.dumps({"input": "patch text"})}
raw = ("data: " + json.dumps({"type": "response.output_item.done", "item": item}) + "\n\n").encode()
a = env["SseRewriter"](custom, tool_names=names)
output = a.feed(raw[:31]) + a.feed(raw[31:]) + a.flush()
restored = json.loads(next(l[6:] for l in output.decode().splitlines() if l.startswith("data: ")))["item"]
self.assertEqual((restored["namespace"], restored["name"], restored["type"]), ("functions", "apply_patch", "custom_tool_call"))
self.assertEqual(restored["id"], "ctc_patch")
self.assertEqual(restored["input"], "patch text")
def test_plain_tools_stay_plain_and_history_not_mutated(self):
req = self.request(); original = copy.deepcopy(req); old_item = req["input"][0]
env["flatten_request"](req, {})
self.assertEqual(req["tools"][-1]["name"], "plain")
self.assertEqual(old_item, original["input"][0])
plain = {"type": "function_call", "name": "plain", "arguments": "{}"}
self.assertEqual(env["SseRewriter"](set())._fix({"item": plain})["item"], plain)
def test_additional_tools_long_names_and_tool_choice(self):
namespace = "provider_" * 20
req = {"tools": [], "input": [{"type": "additional_tools", "tools": [{"type": "namespace", "name": namespace, "tools": [{"type": "function", "name": "read"}]}]}], "tool_choice": {"type": "function", "namespace": namespace, "name": "read"}}
names = {}; env["flatten_request"](req, names)
alias = req["tools"][0]["name"]
self.assertLessEqual(len(alias), 64)
self.assertEqual(req["tool_choice"], {"type": "function", "name": alias})
self.assertEqual(req["input"], [])
self.assertEqual(names[alias], (namespace, "read"))
if __name__ == "__main__": unittest.main()
@@ -225,12 +225,17 @@ describe('preference file', () => {
})
it('writes the contract shape and keeps unknown fields', () => {
expect(JSON.parse(serializeCodexLinkSettings({}, false))).toEqual({ enabled: false, kapsam: 'ide' })
expect(JSON.parse(serializeCodexLinkSettings({}, false))).toEqual({
enabled: false,
mod: 'dogrudan',
kapsam: 'ide'
})
const { stored } = parseCodexLinkSettings('{"enabled": false, "kapsam": "hepsi", "gelecek": {"a": 1}}')
expect(JSON.parse(serializeCodexLinkSettings(stored, true))).toEqual({
enabled: true,
mod: 'dogrudan',
gelecek: { a: 1 },
kapsam: 'hepsi'
})
@@ -308,3 +313,17 @@ describe('bridge configuration and command', () => {
expect(codexAutostartPlan(`${deep}\\pythonw.exe`, launch, `${deep}\\baslat.pyw`)).toBeNull()
})
})
describe('mode preference', () => {
it('uses direct for missing, invalid and malformed modes', () => {
for (const raw of [null, '{', '{}', '{"mod":"bad"}', '{"mod":false}']) {
expect(parseCodexLinkSettings(raw).mod).toBe('dogrudan')
}
expect(parseCodexLinkSettings('{"mod":"aiturk"}').mod).toBe('aiturk')
})
it('preserves other fields when changing mode', () => {
const stored = { enabled: false, mod: 'aiturk', kapsam: 'hepsi', future: { a: 1 } }
expect(JSON.parse(serializeCodexLinkSettings(stored, false, 'dogrudan'))).toEqual({ ...stored, mod: 'dogrudan' })
expect(JSON.parse(serializeCodexLinkSettings(stored, true)).mod).toBe('aiturk')
})
})
+16 -7
View File
@@ -27,7 +27,7 @@ export const CODEX_LINK_REFRESH_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama
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 = '12'
export const CODEX_BRIDGE_VERSION = '19'
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'
@@ -300,6 +300,7 @@ export function removeCodexLinkLine(source: string): CodexConfigEdit {
export interface CodexLinkSettings {
enabled: boolean
mod: 'dogrudan' | 'aiturk'
/** Every field of the stored object, so a rewrite keeps what it did not set. */
stored: Record<string, unknown>
}
@@ -307,26 +308,34 @@ export interface CodexLinkSettings {
/** 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: {} }
return { enabled: true, mod: 'dogrudan', stored: {} }
}
try {
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { enabled: true, stored: {} }
return { enabled: true, mod: 'dogrudan', stored: {} }
}
return { enabled: parsed.enabled !== false, stored: parsed as Record<string, unknown> }
return {
enabled: parsed.enabled !== false,
mod: parsed.mod === 'aiturk' ? 'aiturk' : 'dogrudan',
stored: parsed as Record<string, unknown>
}
} catch {
return { enabled: true, stored: {} }
return { enabled: true, mod: 'dogrudan', stored: {} }
}
}
export function serializeCodexLinkSettings(stored: Record<string, unknown>, enabled: boolean): string {
export function serializeCodexLinkSettings(
stored: Record<string, unknown>,
enabled: boolean,
mod: 'dogrudan' | 'aiturk' = stored.mod === 'aiturk' ? 'aiturk' : 'dogrudan'
): string {
const kapsam = typeof stored.kapsam === 'string' && stored.kapsam ? stored.kapsam : 'ide'
return `${JSON.stringify({ ...stored, enabled, kapsam }, null, 2)}\n`
return `${JSON.stringify({ ...stored, enabled, mod, kapsam }, null, 2)}\n`
}
// ── Bridge configuration ────────────────────────────────────────────────────
@@ -5,6 +5,8 @@
* code into its program. See codex-link.ts for what the fields mean in action.
*/
export type CodexLinkMode = 'dogrudan' | 'aiturk'
export type CodexLinkIdeState = 'anahtar-yok' | 'kapali' | 'tamam'
/** Stable codes; the renderer owns the wording. Detail goes to the desktop log. */
@@ -18,6 +20,7 @@ export type CodexLinkError =
export interface CodexLinkStatus {
enabled: boolean
mod: CodexLinkMode
/** Codex's `config.toml` exists on this machine. */
codexFound: boolean
/** The IDE's own Python environment exists (first-run setup finished). */
@@ -34,6 +37,7 @@ export interface CodexLinkStatus {
export interface CodexLinkPatch {
enabled?: boolean
mod?: CodexLinkMode
/** "Sync now": re-run the apply without changing the preference. */
sync?: boolean
}
+52 -3
View File
@@ -52,6 +52,7 @@ function createWorld(patch: Partial<World> & { files?: Map<string, string> } = {
events: [],
files: new Map([
[PATHS.codexConfig, USER_CONFIG],
[PATHS.settingsFile, JSON.stringify({ enabled: true, mod: 'aiturk' })],
[PATHS.bridgeScript, '# bridge'],
[PATHS.pythonw, ''],
[PATHS.python, '']
@@ -192,6 +193,7 @@ describe('applyCodexLink', () => {
expect(status).toEqual({
enabled: true,
mod: 'aiturk',
codexFound: true,
venvReady: true,
foreign: false,
@@ -328,7 +330,11 @@ describe('applyCodexLink', () => {
'autostart clear',
'stop'
])
expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({ enabled: false, kapsam: 'ide' })
expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({
enabled: false,
mod: 'aiturk',
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()
@@ -344,7 +350,8 @@ describe('applyCodexLink', () => {
world.configWriteFails = true
expect(await link.apply({ enabled: false })).toMatchObject({
bridgeRunning: true, lastError: 'config-write-failed'
bridgeRunning: true,
lastError: 'config-write-failed'
})
expect(world.files.get(PATHS.codexConfig)).toBe(linkedConfig)
expect(world.autostart).toBe(autostart)
@@ -358,7 +365,7 @@ describe('applyCodexLink', () => {
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"}')
world.files.set(PATHS.settingsFile, '{"enabled": false, "mod": "aiturk", "kapsam": "ide", "not": "ours"}')
expect(await link.apply()).toMatchObject({ enabled: false, bridgeRunning: false })
expect(spawns(world)).toEqual([])
@@ -367,6 +374,7 @@ describe('applyCodexLink', () => {
expect(await link.apply({ enabled: true })).toMatchObject({ enabled: true, bridgeRunning: true })
expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({
enabled: true,
mod: 'aiturk',
kapsam: 'ide',
not: 'ours'
})
@@ -424,3 +432,44 @@ describe('getStatus', () => {
expect(effects(world)).toEqual([])
})
})
describe('connection mode', () => {
it('defaults to direct, starts bridge and autostart without writing a line', async () => {
const { link, world } = createWorld()
world.files.delete(PATHS.settingsFile)
expect(await link.apply()).toMatchObject({ mod: 'dogrudan', bridgeRunning: true })
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
expect(spawns(world)).toHaveLength(1)
expect(world.autostart).not.toBeNull()
})
it('removes the old line and preserves the current bridge in direct mode', async () => {
const { link, world } = createWorld({ listening: true })
world.files.set(PATHS.settingsFile, '{"mod":"dogrudan"}')
world.files.set(PATHS.codexConfig, `${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
await link.apply()
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
expect(spawns(world)).toHaveLength(0)
expect(world.events).not.toContain('stop')
expect(world.autostart).not.toBeNull()
})
it('switches both ways, persists mode and preserves unrelated preferences', async () => {
const { link, world } = createWorld({ listening: true })
world.files.set(PATHS.settingsFile, '{"mod":"dogrudan","custom":{"keep":1}}')
await link.apply({ mod: 'aiturk' })
expect(world.files.get(PATHS.codexConfig)).toContain(CODEX_LINK_LINE)
await link.apply({ mod: 'dogrudan' })
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
expect(JSON.parse(world.files.get(PATHS.settingsFile)!)).toMatchObject({ mod: 'dogrudan', custom: { keep: 1 } })
await link.apply()
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
expect(world.events).not.toContain('stop')
})
it.each(['dogrudan', 'aiturk'] as const)('preserves foreign URL in %s', async mod => {
const { link, world } = createWorld()
const foreign = 'openai_base_url = "https://other.example"\n'
world.files.set(PATHS.codexConfig, foreign)
await link.apply({ mod })
expect(world.files.get(PATHS.codexConfig)).toBe(foreign)
expect(spawns(world)).toHaveLength(0)
})
})
+12 -3
View File
@@ -186,6 +186,7 @@ export function createCodexLink(deps: CodexLinkDeps): CodexLink {
bridge: BridgeProbe
): CodexLinkStatus => ({
enabled,
mod: readSettings().mod,
codexFound: config.found,
venvReady: interpreter() !== null,
foreign: config.owner === 'foreign',
@@ -329,7 +330,8 @@ export function createCodexLink(deps: CodexLinkDeps): CodexLink {
return bridge
}
const edit = addCodexLinkLine(config.text ?? '')
const edit =
readSettings().mod === 'aiturk' ? addCodexLinkLine(config.text ?? '') : removeCodexLinkLine(config.text ?? '')
if (edit.changed) {
try {
@@ -364,9 +366,16 @@ export function createCodexLink(deps: CodexLinkDeps): CodexLink {
const run = async (patch?: CodexLinkPatch): Promise<CodexLinkStatus> => {
lastError = null
if (typeof patch?.enabled === 'boolean') {
if (typeof patch?.enabled === 'boolean' || patch?.mod === 'aiturk' || patch?.mod === 'dogrudan') {
try {
deps.writeTextAtomic(paths.settingsFile, serializeCodexLinkSettings(readSettings().stored, patch.enabled))
deps.writeTextAtomic(
paths.settingsFile,
serializeCodexLinkSettings(
readSettings().stored,
patch?.enabled ?? readSettings().enabled,
patch?.mod ?? readSettings().mod
)
)
} 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.
@@ -0,0 +1,21 @@
export interface CompressionSettings {
schemaVersion: 1
primaryModel: string
fallbackModel: string
currentModelFallback: boolean
level: 'mini' | 'az' | 'orta' | 'fazla' | 'cok-fazla'
keepTurns: number
budgetMode: 'percent' | 'tokens'
transferPercent: number
maxTokens: number
autoEnabled: boolean
triggerPercent: number
targetPercent: number
timeoutSeconds: number
}
export interface CompressionState {
settings: CompressionSettings
models: { id: string; name: string; window: number; source: string }[]
modelsAvailable: boolean
}
@@ -0,0 +1,31 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { createCompressionStore } from './compression-settings'
const dirs: string[] = []
afterEach(() => { for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }) })
it('persists complete settings and survives reopening', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'compression-store-'))
dirs.push(dir)
const store = createCompressionStore(dir)
const settings = { ...store.get(), keepTurns: 12, primaryModel: 'ide/primary', fallbackModel: 'ide/backup' }
store.save(settings)
expect(createCompressionStore(dir).get()).toEqual(settings)
expect(fs.readdirSync(dir)).toEqual(['compression-settings.json'])
})
it('rejects invalid thresholds without replacing saved settings', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'compression-store-'))
dirs.push(dir)
const store = createCompressionStore(dir)
const original = store.save(store.get())
for (const patch of [{ keepTurns: 0 }, { targetPercent: 70 }, { maxTokens: NaN }, { autoEnabled: 'yes' }]) {
expect(() => store.save({ ...original, ...patch })).toThrow()
expect(store.get()).toEqual(original)
}
})
@@ -0,0 +1,48 @@
import fs from 'node:fs'
import path from 'node:path'
import defaults from '../assets/codex-bridge/compression-defaults.json'
import type { CompressionSettings } from './compression-settings-types'
export function validateCompression(value: unknown): CompressionSettings {
if (!value || typeof value !== 'object') throw new Error('Invalid compression settings')
const v = value as CompressionSettings
if (Object.keys(v).sort().join() !== Object.keys(defaults).sort().join()) throw new Error('Invalid settings fields')
if (v.schemaVersion !== 1 || !['mini', 'az', 'orta', 'fazla', 'cok-fazla'].includes(v.level) || !['percent', 'tokens'].includes(v.budgetMode)) throw new Error('Invalid compression mode')
for (const key of ['primaryModel', 'fallbackModel'] as const) {
if (typeof v[key] !== 'string' || v[key].length > 256) throw new Error('Invalid model')
}
for (const key of ['autoEnabled', 'currentModelFallback'] as const) {
if (typeof v[key] !== 'boolean') throw new Error('Invalid option')
}
const limits: [keyof CompressionSettings, number, number][] = [
['keepTurns', 1, 100], ['transferPercent', 10, 90], ['triggerPercent', 20, 90],
['targetPercent', 10, 80], ['maxTokens', 1024, 2000000], ['timeoutSeconds', 1, 3600]
]
for (const [key, low, high] of limits) {
const n = v[key]
if (typeof n !== 'number' || !Number.isInteger(n) || n < low || n > high) throw new Error(`Invalid ${key}`)
}
if (v.targetPercent >= v.triggerPercent) throw new Error('Target must be below trigger')
return structuredClone(v)
}
export function createCompressionStore(directory: string) {
const file = path.join(directory, 'compression-settings.json')
return {
get: (): CompressionSettings => validateCompression(fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : defaults),
save(value: unknown): CompressionSettings {
const next = validateCompression(value)
fs.mkdirSync(directory, { recursive: true })
const tmp = `${file}.${process.pid}.tmp`
try {
fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 })
fs.renameSync(tmp, file)
} finally {
if (fs.existsSync(tmp)) fs.unlinkSync(tmp)
}
return next
}
}
}
+27
View File
@@ -379,6 +379,7 @@ import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstra
import { collectSshConfigHosts, parseSshGOutput } from './ssh-config'
import { createSshProbeConnection, pickLocalPort, redactSecrets, SshConnection } from './ssh-connection'
import { createStagedTransitionStore } from './staged-transition'
import { createCompressionStore } from './compression-settings'
import { createStreamThrottle } from './stream-throttle'
import { registerTerminalIpc } from './terminal-ipc'
import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width'
@@ -17193,6 +17194,7 @@ ipcMain.handle('hermes:quick-entry:settings:set', async (_event, patch) => {
// 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 compressionStore = createCompressionStore(path.join(AITURK_IDE_DATA_ROOT, 'codex-bridge'))
const stagedTransition = createStagedTransitionStore(
path.join(AITURK_IDE_DATA_ROOT, 'codex-bridge'),
path.join(app.getPath('home'), '.typesafe', 'typesafe.env'),
@@ -17281,6 +17283,30 @@ ipcMain.handle('aiturk:staged-transition:get', async event => {
return stagedTransitionState()
})
async function compressionState() {
const settings = compressionStore.get()
try {
const response = await fetch('http://127.0.0.1:8795/aiturk/codex-baglama/status', { signal: AbortSignal.timeout(5000) })
if (!response.ok) throw new Error('Bridge unavailable')
const body = await response.json()
const available = Array.isArray(body.compression_models)
return { settings, models: available ? body.compression_models : [], modelsAvailable: available }
} catch {
return { settings, models: [], modelsAvailable: false }
}
}
ipcMain.handle('aiturk:compression:get', async event => {
assertCodexLinkSender(event)
return compressionState()
})
ipcMain.handle('aiturk:compression:set', async (event, settings) => {
assertCodexLinkSender(event)
compressionStore.save(settings)
return compressionState()
})
ipcMain.handle('aiturk:staged-transition:set', async (event, patch) => {
assertCodexLinkSender(event)
stagedTransition.save(patch)
@@ -17292,6 +17318,7 @@ ipcMain.handle('aiturk:codex-link:set', async (event, patch) => {
return codexLink.apply({
enabled: typeof patch?.enabled === 'boolean' ? patch.enabled : undefined,
mod: patch?.mod === 'aiturk' || patch?.mod === 'dogrudan' ? patch.mod : undefined,
sync: patch?.sync === true
})
})
+3 -2
View File
@@ -103,11 +103,12 @@ it.skipIf(!process.env.AITURK_TEST_BACKEND_PORT)(
).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`, {
const tlsOptions: https.RequestOptions = {
ca: identity.cert,
checkServerIdentity,
headers: { Authorization: `Bearer ${deviceToken}` }
})
}
const ws = new WebSocket(`wss://127.0.0.1:${proxyPort}/api/ws?profile=default`, tlsOptions)
const timer = setTimeout(() => {
ws.terminate()
reject(new Error('Chat ping timed out'))
+2
View File
@@ -295,6 +295,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
...(process.platform === 'win32'
? {
codexLink: {
getCompression: () => ipcRenderer.invoke('aiturk:compression:get'),
setCompression: settings => ipcRenderer.invoke('aiturk:compression:set', settings),
getStagedTransition: () => ipcRenderer.invoke('aiturk:staged-transition:get'),
setStagedTransition: patch => ipcRenderer.invoke('aiturk:staged-transition:set', patch),
get: () => ipcRenderer.invoke('aiturk:codex-link:get'),
@@ -12,11 +12,35 @@ export interface StagedTransitionSettings {
maxConcurrent: number
queueWaitSeconds: number
failureAction: 'pass' | 'next'
// Schema 2 (14 Oct 2026). A schema 1 file gets these from the defaults; every stage starts disabled.
watchModels: string[]
prefilter: { enabled: boolean; question: string; threshold: number; target: string; timeoutSeconds: number }
autoRoute: {
enabled: boolean
model: string
timeoutSeconds: number
questions: { zor: string; arastirma: string }
thresholds: { zor: number; arastirma: number }
targets: { basit: string; zor: string; arastirma: string }
}
finishCheck: { enabled: boolean; question: string; threshold: number; note: string }
verifierCheck: { enabled: boolean; question: string; threshold: number }
policy: { source: 'local' | 'dify'; endpoint: string; refreshMinutes: number }
}
export interface StagedTransitionPolicyStatus {
fetchedAt?: string
lastGoodAt?: string
ok: boolean
error?: string
sections: string[]
}
export interface StagedTransitionState {
settings: StagedTransitionSettings
keyConfigured: boolean
policyKeyConfigured?: boolean
policyStatus?: StagedTransitionPolicyStatus | null
models?: { id: string; name: string }[]
modelsAvailable?: boolean
}
@@ -24,4 +48,5 @@ export interface StagedTransitionState {
export interface StagedTransitionPatch {
settings: StagedTransitionSettings
apiKey?: string
policyApiKey?: string
}
@@ -4,7 +4,12 @@ import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { createStagedTransitionStore, stagedTransitionDefaults, validateStagedTransition } from './staged-transition'
import {
createStagedTransitionStore,
readPolicyStatus,
stagedTransitionDefaults,
validateStagedTransition
} from './staged-transition'
const dirs: string[] = []
afterEach(() => {
@@ -50,4 +55,63 @@ describe('staged transition settings', () => {
expect(() => validateStagedTransition({ ...before.settings, thresholds: { ret: NaN, sart: 0.7 } })).toThrow()
expect(() => validateStagedTransition({ ...before.settings, endpoint: 'http://example.com' })).toThrow()
})
it('loads a schema 1 file with every new stage disabled and keeps unknown-free output', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jev-settings-test-'))
dirs.push(dir)
const old = structuredClone(stagedTransitionDefaults) as unknown as Record<string, unknown>
for (const key of ['watchModels', 'prefilter', 'autoRoute', 'finishCheck', 'verifierCheck', 'policy']) {
delete old[key]
}
fs.writeFileSync(path.join(dir, 'jev-settings.json'), JSON.stringify({ settings: old, protectedKey: 'k' }))
const store = createStagedTransitionStore(dir, path.join(dir, 'missing.env'), x => x)
const state = store.get()
expect(state.settings.prefilter.enabled).toBe(false)
expect(state.settings.autoRoute.enabled).toBe(false)
expect(state.settings.finishCheck.enabled).toBe(false)
expect(state.settings.verifierCheck.enabled).toBe(false)
expect(state.settings.policy.source).toBe('local')
expect(state.keyConfigured).toBe(true)
const partial = { ...structuredClone(stagedTransitionDefaults), prefilter: { enabled: true, threshold: 0.8 } }
const filled = validateStagedTransition(partial)
expect(filled.prefilter.enabled).toBe(true)
expect(filled.prefilter.threshold).toBe(0.8)
expect(filled.prefilter.target).toBe(stagedTransitionDefaults.prefilter.target)
})
it('keeps the Dify key protected and reports the last rules fetch', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jev-settings-test-'))
dirs.push(dir)
const store = createStagedTransitionStore(dir, path.join(dir, 'missing.env'), () => 'protected-value')
const settings = { ...structuredClone(stagedTransitionDefaults), policy: { ...stagedTransitionDefaults.policy, source: 'dify' as const } }
const saved = store.save({ settings, policyApiKey: 'app-dummy-secret' })
expect(saved.policyKeyConfigured).toBe(true)
expect(fs.readFileSync(path.join(dir, 'jev-settings.json'), 'utf8')).not.toContain('app-dummy-secret')
expect(store.save({ settings }).policyKeyConfigured).toBe(true)
expect(store.save({ settings, policyApiKey: '' }).policyKeyConfigured).toBe(false)
expect(readPolicyStatus(dir)).toBeNull()
fs.writeFileSync(
path.join(dir, 'jev-policy.json'),
JSON.stringify({ fetchedAt: 'now', ok: true, error: 'Dify down', overrides: { autoRoute: { enabled: true } } })
)
expect(store.get().policyStatus).toEqual({ fetchedAt: 'now', lastGoodAt: undefined, ok: true, error: 'Dify down', sections: ['autoRoute'] })
})
it('rejects invalid decision stage values', () => {
const base = structuredClone(stagedTransitionDefaults)
expect(() => validateStagedTransition({ ...base, prefilter: { ...base.prefilter, threshold: 2 } })).toThrow()
expect(() => validateStagedTransition({ ...base, prefilter: { ...base.prefilter, timeoutSeconds: 0 } })).toThrow()
expect(() =>
validateStagedTransition({ ...base, autoRoute: { ...base.autoRoute, targets: { ...base.autoRoute.targets, zor: 'Otomatik' } } })
).toThrow()
expect(() => validateStagedTransition({ ...base, finishCheck: { ...base.finishCheck, note: '' } })).toThrow()
expect(() => validateStagedTransition({ ...base, policy: { ...base.policy, endpoint: 'http://dify.example' } })).toThrow()
expect(() => validateStagedTransition({ ...base, policy: { ...base.policy, source: 'web' } })).toThrow()
expect(() => validateStagedTransition({ ...base, watchModels: ['a', 'a'] })).toThrow()
for (const endpoint of ['https:dify.example/v1', 'https:/dify.example', 'https:///dify.example', 'ftp://dify.example']) {
expect(() => validateStagedTransition({ ...base, policy: { ...base.policy, endpoint } })).toThrow()
expect(() => validateStagedTransition({ ...base, endpoint })).toThrow()
}
expect(() =>
validateStagedTransition({ ...base, prefilter: { ...base.prefilter, target: base.autoRoute.model } })
).toThrow()
})
})
+190 -35
View File
@@ -3,16 +3,78 @@ import path from 'node:path'
import defaults from '../assets/codex-bridge/jev-settings-defaults.json'
import type { StagedTransitionPatch, StagedTransitionSettings, StagedTransitionState } from './staged-transition-types'
import type {
StagedTransitionPatch,
StagedTransitionPolicyStatus,
StagedTransitionSettings,
StagedTransitionState
} from './staged-transition-types'
export const stagedTransitionDefaults = defaults as StagedTransitionSettings
export function validateStagedTransition(value: unknown): StagedTransitionSettings {
if (!value || typeof value !== 'object') {
// Sections added in schema 2 (14 Oct 2026). Mirrors codex-bridge/jev_settings.py: a missing section or
// field comes from the defaults, so a schema 1 file stays valid and every new stage starts disabled.
const SECTIONS = ['watchModels', 'prefilter', 'autoRoute', 'finishCheck', 'verifierCheck', 'policy'] as const
const POLICY_FILE = 'jev-policy.json'
type Plain = Record<string, unknown>
const isPlain = (value: unknown): value is Plain => Boolean(value) && typeof value === 'object' && !Array.isArray(value)
function fill(value: unknown, fallback: unknown): unknown {
if (!isPlain(fallback) || !isPlain(value)) {
return value
}
const out: Plain = { ...value }
for (const [key, def] of Object.entries(fallback)) {
out[key] = key in value ? fill(value[key], def) : structuredClone(def)
}
return out
}
export function withSchemaDefaults(value: unknown): unknown {
if (!isPlain(value)) {
return value
}
const out: Plain = { ...value }
const defs = stagedTransitionDefaults as unknown as Plain
for (const section of SECTIONS) {
out[section] = section in value ? fill(value[section], defs[section]) : structuredClone(defs[section])
}
return out
}
const text = (s: unknown, max = 10000): s is string => typeof s === 'string' && s.trim().length > 0 && s.length <= max
const probability = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n) && n >= 0 && n <= 1
const integer = (n: unknown, min: number, max: number): n is number =>
typeof n === 'number' && Number.isInteger(n) && n >= min && n <= max
// Same acceptance as the bridge (urllib.parse.urlsplit): the literal https:// prefix and a host. WHATWG URL alone
// also accepts "https:host" and "https:/host", which the bridge rejects.
function https(value: unknown): value is string {
if (!text(value, 2048) || !/^https:\/\/[^/?#\s]/i.test(value.trim())) {
return false
}
let url: URL
try {
url = new URL(value.trim())
} catch {
return false
}
return url.protocol === 'https:' && Boolean(url.hostname) && !url.username && !url.password && !url.hash
}
export function validateStagedTransition(input: unknown): StagedTransitionSettings {
if (!input || typeof input !== 'object') {
throw new Error('Invalid settings')
}
const v = value as StagedTransitionSettings
const text = (s: unknown, max = 10000): s is string => typeof s === 'string' && s.trim().length > 0 && s.length <= max
const v = withSchemaDefaults(input) as StagedTransitionSettings
const list = (s: unknown, allowEmpty: boolean) =>
Array.isArray(s) &&
@@ -21,15 +83,13 @@ export function validateStagedTransition(value: unknown): StagedTransitionSettin
s.every(x => text(x, 256)) &&
new Set(s.map(x => x.trim())).size === s.length
const seconds = (n: unknown) => typeof n === 'number' && Number.isInteger(n) && n >= 1 && n <= 3600
const seconds = (n: unknown) => integer(n, 1, 3600)
if (typeof v.enabled !== 'boolean' || !text(v.endpoint, 2048) || !text(v.model, 256)) {
throw new Error('Invalid connection settings')
}
const url = new URL(v.endpoint)
if (url.protocol !== 'https:' || url.username || url.password || url.hash) {
if (!https(v.endpoint)) {
throw new Error('HTTPS endpoint required')
}
@@ -60,13 +120,7 @@ export function validateStagedTransition(value: unknown): StagedTransitionSettin
}
for (const id of ['ret', 'sart'] as const) {
if (
!text(v.questions?.[id]) ||
typeof v.thresholds?.[id] !== 'number' ||
!Number.isFinite(v.thresholds[id]) ||
v.thresholds[id] < 0 ||
v.thresholds[id] > 1
) {
if (!text(v.questions?.[id]) || !probability(v.thresholds?.[id])) {
throw new Error('Invalid evaluation rule')
}
@@ -80,28 +134,117 @@ export function validateStagedTransition(value: unknown): StagedTransitionSettin
if (
!seconds(v.timeoutSeconds) ||
!seconds(v.queueWaitSeconds) ||
!Number.isInteger(v.maxConcurrent) ||
v.maxConcurrent < 0 ||
v.maxConcurrent > 512 ||
!integer(v.maxConcurrent, 0, 512) ||
!['pass', 'next'].includes(v.failureAction)
) {
throw new Error('Invalid request limits')
}
if (!list(v.watchModels, true)) {
throw new Error('Invalid watch models')
}
const p = v.prefilter
if (
typeof p?.enabled !== 'boolean' ||
!text(p.question) ||
!probability(p.threshold) ||
!text(p.target, 256) ||
!integer(p.timeoutSeconds, 1, 60)
) {
throw new Error('Invalid prefilter')
}
const a = v.autoRoute
if (
typeof a?.enabled !== 'boolean' ||
!text(a.model, 256) ||
!integer(a.timeoutSeconds, 1, 60) ||
(['zor', 'arastirma'] as const).some(k => !text(a.questions?.[k]) || !probability(a.thresholds?.[k])) ||
(['basit', 'zor', 'arastirma'] as const).some(k => !text(a.targets?.[k], 256))
) {
throw new Error('Invalid automatic routing')
}
if (Object.values(a.targets).some(t => t.trim() === a.model.trim()) || p.target.trim() === a.model.trim()) {
throw new Error('Automatic routing cannot target itself')
}
const f = v.finishCheck
if (
typeof f?.enabled !== 'boolean' ||
!text(f.question) ||
!probability(f.threshold) ||
!text(f.note, 4000)
) {
throw new Error('Invalid finish check')
}
const c = v.verifierCheck
if (typeof c?.enabled !== 'boolean' || !text(c.question) || !probability(c.threshold)) {
throw new Error('Invalid verifier check')
}
const pol = v.policy
if (!['local', 'dify'].includes(pol?.source) || !https(pol.endpoint) || !integer(pol.refreshMinutes, 1, 1440)) {
throw new Error('Invalid policy source')
}
const trimmed = (s: string) => s.trim()
return {
enabled: v.enabled,
endpoint: v.endpoint.trim(),
model: v.model.trim(),
fallbackModel: v.fallbackModel.trim(),
rejectionRules: v.rejectionRules.map(r => ({ from: r.from.trim(), to: r.to.trim() })),
exemptModels: v.exemptModels.map(x => x.trim()),
exemptModels: v.exemptModels.map(trimmed),
questions: { ...v.questions },
thresholds: { ...v.thresholds },
criteria: structuredClone(v.criteria),
timeoutSeconds: v.timeoutSeconds,
maxConcurrent: v.maxConcurrent,
queueWaitSeconds: v.queueWaitSeconds,
failureAction: v.failureAction
failureAction: v.failureAction,
watchModels: v.watchModels.map(trimmed),
prefilter: { ...p, target: p.target.trim() },
autoRoute: {
enabled: a.enabled,
model: a.model.trim(),
timeoutSeconds: a.timeoutSeconds,
questions: { zor: a.questions.zor, arastirma: a.questions.arastirma },
thresholds: { zor: a.thresholds.zor, arastirma: a.thresholds.arastirma },
targets: { basit: a.targets.basit.trim(), zor: a.targets.zor.trim(), arastirma: a.targets.arastirma.trim() }
},
finishCheck: { enabled: f.enabled, question: f.question, threshold: f.threshold, note: f.note },
verifierCheck: { enabled: c.enabled, question: c.question, threshold: c.threshold },
policy: { source: pol.source, endpoint: pol.endpoint.trim(), refreshMinutes: pol.refreshMinutes }
}
}
// The bridge writes jev-policy.json after each Dify fetch; only a summary reaches the renderer.
export function readPolicyStatus(directory: string): StagedTransitionPolicyStatus | null {
try {
const data = JSON.parse(fs.readFileSync(path.join(directory, POLICY_FILE), 'utf8'))
if (!isPlain(data)) {
return null
}
return {
fetchedAt: typeof data.fetchedAt === 'string' ? data.fetchedAt : undefined,
lastGoodAt: typeof data.lastGoodAt === 'string' ? data.lastGoodAt : undefined,
ok: data.ok === true,
error: typeof data.error === 'string' && data.error ? data.error.slice(0, 500) : undefined,
sections: isPlain(data.overrides) ? Object.keys(data.overrides) : []
}
} catch {
return null
}
}
@@ -112,13 +255,17 @@ export function createStagedTransitionStore(
) {
const file = path.join(directory, 'jev-settings.json')
const read = (): { settings: StagedTransitionSettings; protectedKey?: string } => {
const read = (): { settings: StagedTransitionSettings; protectedKey?: string; protectedPolicyKey?: string } => {
if (!fs.existsSync(file)) {
return { settings: structuredClone(stagedTransitionDefaults) }
}
const data = JSON.parse(fs.readFileSync(file, 'utf8'))
return { settings: validateStagedTransition(data.settings), protectedKey: data.protectedKey }
return {
settings: validateStagedTransition(data.settings),
protectedKey: data.protectedKey,
protectedPolicyKey: data.protectedPolicyKey
}
}
const state = (): StagedTransitionState => {
@@ -135,32 +282,40 @@ export function createStagedTransitionStore(
)
}
return { settings: current.settings, keyConfigured: Boolean(current.protectedKey) || legacy }
return {
settings: current.settings,
keyConfigured: Boolean(current.protectedKey) || legacy,
policyKeyConfigured: Boolean(current.protectedPolicyKey),
policyStatus: readPolicyStatus(directory)
}
}
const secret = (value: unknown, name: string) => {
if (value !== undefined && (typeof value !== 'string' || value.length > 8192)) {
throw new Error(`Invalid ${name}`)
}
}
return {
get: state,
save(patch: StagedTransitionPatch): StagedTransitionState {
const settings = validateStagedTransition(patch?.settings)
if (patch.apiKey !== undefined && (typeof patch.apiKey !== 'string' || patch.apiKey.length > 8192)) {
throw new Error('Invalid API key')
}
secret(patch.apiKey, 'API key')
secret(patch.policyApiKey, 'policy key')
const current = read()
const protectedKey =
patch.apiKey === undefined
? current.protectedKey
: patch.apiKey.trim()
? protect(patch.apiKey.trim())
: undefined
const update = (value: string | undefined, previous: string | undefined) =>
value === undefined ? previous : value.trim() ? protect(value.trim()) : undefined
const protectedKey = update(patch.apiKey, current.protectedKey)
const protectedPolicyKey = update(patch.policyApiKey, current.protectedPolicyKey)
fs.mkdirSync(directory, { recursive: true })
const tmp = `${file}.${process.pid}.tmp`
try {
fs.writeFileSync(tmp, JSON.stringify({ settings, protectedKey }, null, 2), { mode: 0o600 })
fs.writeFileSync(tmp, JSON.stringify({ settings, protectedKey, protectedPolicyKey }, null, 2), { mode: 0o600 })
fs.renameSync(tmp, file)
} finally {
if (fs.existsSync(tmp)) {
+5 -2
View File
@@ -2,7 +2,7 @@
"name": "aiturk-ide",
"productName": "AITURK IDE",
"private": true,
"version": "1.0.0-beta.31",
"version": "1.0.0-beta.39",
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
"author": "AITURK / TurkServis",
"repository": {
@@ -151,7 +151,8 @@
"unist-util-visit-parents": "6.0.2",
"use-stick-to-bottom": "1.1.6",
"vfile": "6.0.3",
"web-haptics": "0.0.6"
"web-haptics": "0.0.6",
"ws": "8.21.1"
},
"optionalDependencies": {
"get-windows": "9.3.0"
@@ -169,6 +170,7 @@
"@types/node": "22.20.1",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@types/ws": "8.18.2",
"@vitejs/plugin-react": "6.0.3",
"babel-plugin-react-compiler": "1.0.0",
"bippy": "0.5.43",
@@ -305,6 +307,7 @@
"to": "codex-bridge",
"filter": [
"jev-settings-defaults.json",
"compression-defaults.json",
"**/*.py",
"**/*.pyd",
"native-watch.exe",
@@ -12,6 +12,7 @@ vi.mock('@/store/notifications', () => ({ notifyError }))
const RUNNING: CodexLinkStatus = {
enabled: true,
mod: 'aiturk',
codexFound: true,
venvReady: true,
foreign: false,
@@ -198,3 +199,18 @@ describe('Codex Link settings', () => {
expect(get).not.toHaveBeenCalled()
})
})
describe('connection mode controls', () => {
it('sends the mode and renders the saved direct preference', async () => {
get.mockResolvedValue({ ...RUNNING, mod: 'dogrudan' })
set.mockResolvedValue({ ...RUNNING, mod: 'aiturk' })
renderPage()
const direct = await screen.findByRole('radio', { name: 'Doğrudan (önerilen): abonelik modelleri köprüden bağımsız' })
await waitFor(() => expect((direct as HTMLInputElement).checked).toBe(true))
fireEvent.click(screen.getByRole('radio', { name: 'AITURK: combolar Codex listesinde, tüm modeller köprüden' }))
await waitFor(() => expect(set).toHaveBeenCalledWith({ mod: 'aiturk' }))
await waitFor(() => expect((direct as HTMLInputElement).checked).toBe(false))
expect(screen.getByText("Codex'i yeniden başlatın")).toBeTruthy()
})
})
@@ -11,6 +11,7 @@ import { notifyError } from '@/store/notifications'
import type { CodexLinkPatch, CodexLinkStatus } from '../../../electron/codex-link-types'
import { EmptyState, ListRow, ListRowSkeleton, SettingsContent, SettingsSection } from './primitives'
import { CompressionSettingsView } from './compression-settings'
type Tone = 'muted' | 'ok' | 'warn'
@@ -56,7 +57,7 @@ export function codexLinkStatusLines(status: CodexLinkStatus, copy: CodexLinkCop
if (status.ideDurum === 'anahtar-yok') {
lines.push({ id: 'ide', text: copy.signIn, tone: 'warn' })
} else if (status.ideDurum === 'tamam' && status.ideModel !== null) {
} else if (status.mod === 'aiturk' && status.ideDurum === 'tamam' && status.ideModel !== null) {
lines.push({ id: 'ide', text: copy.models(status.ideModel), tone: 'ok' })
}
@@ -169,6 +170,27 @@ export function CodexLinkSettings() {
title={copy.title}
/>
<ListRow
title={copy.modeTitle}
below={
<fieldset disabled={!status || busy !== null} className="grid gap-2 py-2">
{(['dogrudan', 'aiturk'] as const).map(mod => (
<label key={mod} className="flex min-h-11 cursor-pointer items-center gap-2">
<input
type="radio"
name="codex-link-mode"
value={mod}
checked={(status?.mod ?? 'dogrudan') === mod}
onChange={() => void change({ mod }, 'toggle')}
/>
<span>{mod === 'dogrudan' ? copy.directMode : copy.aiturkMode}</span>
</label>
))}
<p className="text-(--ui-text-tertiary)">{copy.modeRestartNote}</p>
</fieldset>
}
/>
{!status && !loadFailed && <ListRowSkeleton wide />}
{loadFailed && !status && (
@@ -229,6 +251,7 @@ export function CodexLinkSettings() {
{copy.restartNote}
</p>
</SettingsSection>
<CompressionSettingsView />
</SettingsContent>
)
}
@@ -0,0 +1,37 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n/context'
import defaults from '../../../assets/codex-bridge/compression-defaults.json'
import { CompressionSettingsView } from './compression-settings'
afterEach(() => { cleanup(); delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop })
it('loads settings, excludes small fallback models, and saves the selected values', async () => {
const state = { settings: defaults, modelsAvailable: true, models: [
{ id: 'ide/small', name: 'Small', window: 32768, source: 'catalog' },
{ id: 'ide/large', name: 'Large', window: 1000000, source: 'catalog' }
] }
const save = vi.fn(async settings => ({ ...state, settings }))
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { codexLink: { getCompression: async () => state, setCompression: save } }
render(<I18nProvider configClient={null} initialLocale="tr"><CompressionSettingsView /></I18nProvider>)
const keep = await screen.findByRole('spinbutton', { name: 'Korunan son mesajlaşma sayısı' })
expect((keep as HTMLInputElement).value).toBe('10')
const fallback = screen.getByRole('combobox', { name: 'Yedek sıkıştırıcı (1M)' })
expect(fallback.textContent).not.toContain('Small')
fireEvent.change(keep, { target: { value: '12' } })
fireEvent.change(fallback, { target: { value: 'ide/large' } })
fireEvent.click(screen.getByRole('button', { name: 'Kaydet' }))
await waitFor(() => expect(save).toHaveBeenCalledWith(expect.objectContaining({ keepTurns: 12, fallbackModel: 'ide/large' })))
expect(await screen.findByText('Kaydedildi. Sonraki hazırlıkta uygulanacak.')).toBeTruthy()
})
it('shows a failed save without claiming success', async () => {
const save = vi.fn().mockRejectedValue(new Error('disk unavailable'))
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { codexLink: { getCompression: async () => ({ settings: defaults, models: [], modelsAvailable: false }), setCompression: save } }
render(<I18nProvider configClient={null} initialLocale="tr"><CompressionSettingsView /></I18nProvider>)
fireEvent.click(await screen.findByRole('button', { name: 'Kaydet' }))
expect((await screen.findByRole('alert')).textContent).toContain('Kaydedilemedi')
})
@@ -0,0 +1,79 @@
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { useI18n } from '@/i18n/context'
import { Link } from '@/lib/icons'
import type { CompressionSettings, CompressionState } from '../../../electron/compression-settings-types'
import { ListRow, SettingsSection } from './primitives'
export function CompressionSettingsView() {
const api = window.hermesDesktop?.codexLink
const { locale } = useI18n()
const tr = locale === 'tr'
const t = (a: string, b: string) => tr ? a : b
const [state, setState] = useState<CompressionState | null>(null)
const [draft, setDraft] = useState<CompressionSettings | null>(null)
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState('')
const [failed, setFailed] = useState(false)
useEffect(() => {
let active = true
api?.getCompression?.().then(next => {
if (active) { setState(next); setDraft(next.settings) }
}, () => {
if (active) { setFailed(true); setMessage(tr ? 'Sıkıştırma ayarları okunamadı.' : 'Could not load compression settings.') }
})
return () => { active = false }
}, [api, tr])
if (!api?.getCompression) return null
const change = <K extends keyof CompressionSettings>(key: K, value: CompressionSettings[K]) => {
if (draft) setDraft({ ...draft, [key]: value })
}
const numeric = (key: 'keepTurns' | 'transferPercent' | 'maxTokens' | 'triggerPercent' | 'targetPercent' | 'timeoutSeconds', label: string, min: number, max: number) => (
<ListRow key={key} title={label} action={<Input aria-label={label} className="min-h-11 w-32" type="number" min={min} max={max} step={1} disabled={busy} value={draft?.[key] ?? ''} onChange={e => change(key, e.target.valueAsNumber)} />} />
)
const modelSelect = (key: 'primaryModel' | 'fallbackModel', label: string) => {
const models = (state?.models ?? []).filter(m => key !== 'fallbackModel' || m.window >= 1000000)
const selected = draft?.[key] ?? ''
return <ListRow title={label} action={
<select aria-label={label} className="min-h-11 max-w-full rounded-md border bg-(--ui-bg) p-2" disabled={busy} value={selected} onChange={e => change(key, e.target.value)}>
<option value="">{key === 'primaryModel' ? t('Mevcut model (ayrı çağrı)', 'Current model (separate call)') : t('Yedek seçilmedi', 'No fallback selected')}</option>
{selected && !models.some(m => m.id === selected) && <option value={selected}>{selected} — {t('katalogda doğrulanmadı', 'not verified in catalog')}</option>}
{models.map(m => <option key={m.id} value={m.id}>{m.name} ({Math.round(m.window / 1000)}K)</option>)}
</select>
} />
}
return <SettingsSection icon={Link} title={t('Bağlam ve Sıkıştırma', 'Context and Compression')}>
<p className="text-sm text-(--ui-text-secondary)">{t('Ek modellerle geçişleri ve aynı ek modelde bağlamı yönetir. Sohbet kimliği ve tam geçmiş korunur. Abonelik modellerinin kendi aralarındaki geçişleri Codex yönetir.', 'Manages transfers involving added models and their ongoing context. Chat identity and full history are preserved. Codex manages transitions between subscription models.')}</p>
{draft && <>
{modelSelect('primaryModel', t('Sıkıştırmadan sorumlu model', 'Primary summarizer'))}
{modelSelect('fallbackModel', t('Yedek sıkıştırıcı (1M)', 'Fallback summarizer (1M)'))}
{state && !state.modelsAvailable && <p role="status">{t('Model kataloğuna ulaşılamadı.', 'Model catalog unavailable.')}</p>}
<ListRow title={t('Mevcut modele geri dönüş', 'Fall back to current model')} description={t('Sıkıştırıcılar başarısız olursa ve geçmiş mevcut modele sığıyorsa kullanılır.', 'Used if summarizers fail and the history fits the current model.')} action={<Switch aria-label={t('Mevcut modele geri dönüş', 'Fall back to current model')} checked={draft.currentModelFallback} disabled={busy} onCheckedChange={v => change('currentModelFallback', v)} />} />
<ListRow title={t('Sıkıştırma seviyesi', 'Compression level')} action={<select aria-label={t('Sıkıştırma seviyesi', 'Compression level')} className="min-h-11 rounded-md border p-2" value={draft.level} disabled={busy} onChange={e => change('level', e.target.value as CompressionSettings['level'])}>
{(['mini', 'az', 'orta', 'fazla', 'cok-fazla'] as const).map((value, i) => <option key={value} value={value}>{(tr ? ['Mini', 'Az', 'Orta', 'Fazla', 'Çok Fazla'] : ['Minimal', 'Light', 'Medium', 'High', 'Very high'])[i]}</option>)}
</select>} />
{numeric('keepTurns', t('Korunan son mesajlaşma sayısı', 'Recent exchanges to preserve'), 1, 100)}
<p className="text-sm text-(--ui-text-secondary)">{t('Bir mesajlaşma, kullanıcı mesajı ve ardından gelen cevap/araç işlemleridir. Korunan bölüm sığmazsa geçiş durur; bu sayı kendiliğinden azaltılmaz.', 'An exchange includes a user message and subsequent replies/tool operations. If the protected section cannot fit, the transfer stops without reducing this count.')}</p>
<ListRow title={t('Geçiş bütçesi', 'Transfer budget')} action={<select aria-label={t('Geçiş bütçesi', 'Transfer budget')} className="min-h-11 rounded-md border p-2" disabled={busy} value={draft.budgetMode} onChange={e => change('budgetMode', e.target.value as CompressionSettings['budgetMode'])}><option value="percent">{t('Yüzde', 'Percent')}</option><option value="tokens">{t('Maksimum token', 'Maximum tokens')}</option></select>} />
{draft.budgetMode === 'percent' ? numeric('transferPercent', t('Hedef model bağlamı (%)', 'Target model context (%)'), 10, 90) : numeric('maxTokens', t('Maksimum giriş tokenı', 'Maximum input tokens'), 1024, 2000000)}
<ListRow title={t('Otomatik sıkıştırma', 'Automatic compression')} action={<Switch aria-label={t('Otomatik sıkıştırma', 'Automatic compression')} disabled={busy} checked={draft.autoEnabled} onCheckedChange={v => change('autoEnabled', v)} />} />
{numeric('triggerPercent', t('Başlama eşiği (%)', 'Trigger threshold (%)'), 20, 90)}
{numeric('targetPercent', t('Sıkıştırma sonrası hedef (%)', 'Target after compression (%)'), 10, 80)}
{numeric('timeoutSeconds', t('Sıkıştırıcı bekleme süresi (saniye)', 'Summarizer timeout (seconds)'), 1, 3600)}
<Button disabled={busy || draft.targetPercent >= draft.triggerPercent} onClick={async () => {
setBusy(true); setMessage(''); setFailed(false)
try {
const next = await api.setCompression(draft)
setState(next); setDraft(next.settings); setMessage(t('Kaydedildi. Sonraki hazırlıkta uygulanacak.', 'Saved. Applies to the next preparation.'))
} catch { setFailed(true); setMessage(t('Kaydedilemedi. Değerleri ve hedefin eşikten küçük olduğunu kontrol edin.', 'Could not save. Check the values and ensure the target is below the trigger.')) }
finally { setBusy(false) }
}}>{busy ? t('Kaydediliyor…', 'Saving…') : t('Kaydet', 'Save')}</Button>
</>}
{message && <p role={failed ? 'alert' : 'status'}>{message}</p>}
</SettingsSection>
}
@@ -44,13 +44,13 @@ describe('staged transition settings UI', () => {
show()
await screen.findByText('Kademeli Geçiş')
fireEvent.click(screen.getByText('+ Geçiş kuralı ekle'))
expect(screen.getByLabelText('İtiraz edilen model 4')).toBeTruthy()
expect(screen.getByLabelText('Kullanılacak model 4')).toBeTruthy()
fireEvent.click(screen.getByLabelText('Geçiş kuralı 4 sil'))
expect(screen.queryByLabelText('İtiraz edilen model 4')).toBeNull()
expect(screen.getByLabelText('İtiraz edilen model 3')).toBeTruthy()
expect(screen.getByLabelText('Kullanılacak model 3')).toBeTruthy()
fireEvent.click(screen.getByLabelText('Geçiş kuralı 3 sil'))
expect(screen.queryByLabelText('İtiraz edilen model 3')).toBeNull()
fireEvent.click(screen.getByText('+ Her zaman geç kuralı ekle'))
expect(screen.getByLabelText('Her zaman geçen model 3')).toBeTruthy()
fireEvent.click(screen.getByLabelText('Her zaman geç kuralı 3 sil'))
expect(screen.getByLabelText('Her zaman geçen model 4')).toBeTruthy()
fireEvent.click(screen.getByLabelText('Her zaman geç kuralı 4 sil'))
fireEvent.change(screen.getByLabelText('İsteği yerine getirmeme denetim metni'), {
target: { value: 'custom prompt' }
})
@@ -58,6 +58,37 @@ describe('staged transition settings UI', () => {
await screen.findByText('Kaydedildi. Yeni değerlendirmelerde uygulanır.')
expect(set.mock.calls[0][0].settings.questions.ret).toBe('custom prompt')
expect(set.mock.calls[0][0]).not.toHaveProperty('apiKey')
expect(set.mock.calls[0][0]).not.toHaveProperty('policyApiKey')
})
it('edits the decision stages, the correction channel and the Dify rules source', async () => {
get.mockResolvedValue({
...state(),
policyKeyConfigured: false,
policyStatus: { ok: true, fetchedAt: 'dün', sections: ['autoRoute'], error: 'Dify kapalı' }
})
show()
await screen.findByText('Karar aşamaları')
fireEvent.click(screen.getByLabelText('Ret ön kontrolü'))
fireEvent.change(screen.getByLabelText('Ret ön kontrolü eşiği'), { target: { value: '0.75' } })
fireEvent.click(screen.getByLabelText('Otomatik model'))
fireEvent.change(screen.getByLabelText('Zor iş sorusu'), { target: { value: 'zor mu?' } })
fireEvent.click(screen.getByLabelText('Erken bitiş denetimi'))
fireEvent.change(screen.getByLabelText('Modele verilecek not'), { target: { value: 'işi bitir' } })
fireEvent.click(screen.getByLabelText('Workflow doğrulayıcı elemesi'))
fireEvent.click(screen.getByText('+ Düzeltme kanalı modeli ekle'))
expect(screen.getByLabelText('Düzeltme kanalı modeli 4')).toBeTruthy()
fireEvent.click(screen.getByLabelText('Düzeltme kanalı modeli 4 sil'))
fireEvent.change(screen.getByLabelText('Dify uygulama anahtarı'), { target: { value: 'app-key' } })
fireEvent.click(screen.getByText('Kaydet'))
await screen.findByText('Kaydedildi. Yeni değerlendirmelerde uygulanır.')
const saved = set.mock.calls[0][0]
expect(saved.settings.prefilter).toMatchObject({ enabled: true, threshold: 0.75 })
expect(saved.settings.autoRoute).toMatchObject({ enabled: true, questions: { zor: 'zor mu?' } })
expect(saved.settings.finishCheck).toMatchObject({ enabled: true, note: 'işi bitir' })
expect(saved.settings.verifierCheck.enabled).toBe(true)
expect(saved.settings.watchModels).toEqual(defaults.watchModels)
expect(saved.policyApiKey).toBe('app-key')
expect((screen.getByLabelText('Dify uygulama anahtarı') as HTMLInputElement).value).toBe('')
})
it('retains user edits if persistence fails', async () => {
set.mockRejectedValue(new Error('disk error'))
@@ -21,6 +21,7 @@ export function StagedTransitionSettingsView() {
const [state, setState] = useState<StagedTransitionState | null>(null)
const [draft, setDraft] = useState<StagedTransitionSettings | null>(null)
const [key, setKey] = useState('')
const [policyKey, setPolicyKey] = useState('')
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState(false)
@@ -80,7 +81,10 @@ export function StagedTransitionSettingsView() {
const storedModels = [
draft.fallbackModel,
...draft.exemptModels,
...draft.rejectionRules.flatMap(rule => [rule.from, rule.to])
...draft.rejectionRules.flatMap(rule => [rule.from, rule.to]),
...draft.watchModels,
draft.prefilter.target,
...Object.values(draft.autoRoute.targets)
]
for (const id of storedModels) {
@@ -127,14 +131,62 @@ export function StagedTransitionSettingsView() {
/>
)
// Decision stages (14 Oct 2026). Each stage has its own switch; all start disabled.
type StageName = 'prefilter' | 'autoRoute' | 'finishCheck' | 'verifierCheck'
const stage = <K extends StageName>(name: K, value: Partial<StagedTransitionSettings[K]>) =>
change(name, { ...draft[name], ...value } as StagedTransitionSettings[K])
const probabilityInput = (label: string, value: number, onChange: (value: number) => void) => (
<Input
aria-label={label}
max={1}
min={0}
onChange={e => onChange(Number(e.target.value))}
step={0.01}
type="number"
value={value}
/>
)
const secondsInput = (label: string, value: number, max: number, onChange: (value: number) => void) => (
<Input aria-label={label} max={max} min={1} onChange={e => onChange(Number(e.target.value))} type="number" value={value} />
)
const question = (label: string, value: string, onChange: (value: string) => void, rows = 3) => (
<label className="block py-2">
<span>{label}</span>
<Textarea aria-label={label} onChange={e => onChange(e.target.value)} rows={rows} value={value} />
</label>
)
const stageSwitch = (name: StageName, label: string, description: string) => (
<ListRow
action={
<Switch aria-label={label} checked={draft[name].enabled} onCheckedChange={value => stage(name, { enabled: value })} />
}
description={description}
title={label}
/>
)
const policy = state?.policyStatus
const save = async () => {
setBusy(true)
setMessage('')
setError(false)
try {
adopt(await api.setStagedTransition({ settings: draft, ...(key.trim() ? { apiKey: key.trim() } : {}) }))
adopt(
await api.setStagedTransition({
settings: draft,
...(key.trim() ? { apiKey: key.trim() } : {}),
...(policyKey.trim() ? { policyApiKey: policyKey.trim() } : {})
})
)
setKey('')
setPolicyKey('')
setMessage(text('Kaydedildi. Yeni değerlendirmelerde uygulanır.', 'Saved. Applies to new evaluations.'))
} catch (failure) {
setError(true)
@@ -404,6 +456,248 @@ export function StagedTransitionSettingsView() {
{text('+ Her zaman geç kuralı ekle', '+ Add always-pass rule')}
</Button>
</SettingsSection>
<SettingsSection icon={Link} title={text('Karar aşamaları', 'Decision stages')}>
<p>
{text(
'Karar modeli metin yazmaz; her aşamada evet/hayır olasılığı verir. Aşamalar yukarıdaki ana anahtar açıkken çalışır. Karar alınamazsa bugünkü yol izlenir.',
'The decision model writes no text; each stage gets a yes/no probability. Stages run while the main switch above is on. Without a decision the current path is kept.'
)}
</p>
{stageSwitch(
'prefilter',
text('Ret ön kontrolü', 'Refusal pre-check'),
text(
'Tur başında sorulur: normal bir model bu isteği reddeder ya da yumuşatır mı? Evetse adım beklemeden seçilen katmandan başlar.',
'Asked at the start of a turn: would a mainstream model refuse or water down this request? If so the step starts at the chosen layer.'
)
)}
<ListRow
action={modelSelect(text('Ret ön kontrolü hedefi', 'Refusal pre-check target'), draft.prefilter.target, value =>
stage('prefilter', { target: value })
)}
title={text('Reddedilecekse başlanacak model', 'Model to start with when a refusal is expected')}
/>
<ListRow
action={probabilityInput(text('Ret ön kontrolü eşiği', 'Refusal pre-check threshold'), draft.prefilter.threshold, value =>
stage('prefilter', { threshold: value })
)}
title={text('Eşik (0–1)', 'Threshold (0–1)')}
/>
{question(text('Ret ön kontrolü sorusu', 'Refusal pre-check question'), draft.prefilter.question, value =>
stage('prefilter', { question: value })
)}
{stageSwitch(
'autoRoute',
text('Otomatik model', 'Automatic model'),
text(
'Codex listesinde bir model olarak görünür. Seçildiğinde her yeni istekte karar modeli işin türüne göre combo seçer; aynı turun araç adımları aynı combo ile sürer. Açınca listede birkaç dakika içinde görünür.',
'Appears as a model in the Codex list. When selected, the decision model picks a combo for each new request; tool steps of the same turn keep it. It appears in the list within a few minutes after enabling.'
)
)}
<ListRow
action={
<Input
aria-label={text('Otomatik modelin adı', 'Automatic model name')}
onChange={e => stage('autoRoute', { model: e.target.value })}
value={draft.autoRoute.model}
/>
}
title={text('Listede görünecek ad', 'Name in the model list')}
/>
{(['basit', 'zor', 'arastirma'] as const).map(kind => {
const label =
kind === 'basit'
? text('Basit iş combo', 'Simple request combo')
: kind === 'zor'
? text('Zor iş combo', 'Hard request combo')
: text('Araştırma combo', 'Research combo')
return (
<ListRow
action={modelSelect(label, draft.autoRoute.targets[kind], value =>
stage('autoRoute', { targets: { ...draft.autoRoute.targets, [kind]: value } })
)}
key={kind}
title={label}
/>
)
})}
{(['zor', 'arastirma'] as const).map(kind => {
const label = kind === 'zor' ? text('Zor iş sorusu', 'Hard request question') : text('Araştırma sorusu', 'Research question')
return (
<div key={kind}>
<ListRow
action={probabilityInput(`${label} ${text('eşiği', 'threshold')}`, draft.autoRoute.thresholds[kind], value =>
stage('autoRoute', { thresholds: { ...draft.autoRoute.thresholds, [kind]: value } })
)}
title={`${label} ${text('eşiği', 'threshold')}`}
/>
{question(label, draft.autoRoute.questions[kind], value =>
stage('autoRoute', { questions: { ...draft.autoRoute.questions, [kind]: value } })
)}
</div>
)
})}
{stageSwitch(
'finishCheck',
text('Erken bitiş denetimi', 'Early finish check'),
text(
'Son cevapta sorulur: iş bitmeden “bitti” dendi mi ya da kanıt gösterilmedi mi? Evetse cevap aşağıdaki notla aynı modele bir kez geri verilir.',
'Asked on the final answer: did it say done before finishing or claim success without evidence? If so it goes back to the same model once with the note below.'
)
)}
<ListRow
action={probabilityInput(text('Erken bitiş eşiği', 'Early finish threshold'), draft.finishCheck.threshold, value =>
stage('finishCheck', { threshold: value })
)}
title={text('Eşik (0–1)', 'Threshold (0–1)')}
/>
{question(text('Erken bitiş sorusu', 'Early finish question'), draft.finishCheck.question, value =>
stage('finishCheck', { question: value })
)}
{question(
text('Modele verilecek not', 'Note given to the model'),
draft.finishCheck.note,
value => stage('finishCheck', { note: value }),
2
)}
{stageSwitch(
'verifierCheck',
text('Workflow doğrulayıcı elemesi', 'Workflow reviewer check'),
text(
'Dinamik workflow kabulünde sorulur: doğrulayıcı somut kanıt göstermeden mi onayladı? Evetse kabul engellenir, doğrulama yeniden yapılır.',
'Asked when a dynamic workflow is accepted: did the reviewer approve without concrete evidence? If so acceptance is blocked and verification is redone.'
)
)}
<ListRow
action={probabilityInput(text('Doğrulayıcı elemesi eşiği', 'Reviewer check threshold'), draft.verifierCheck.threshold, value =>
stage('verifierCheck', { threshold: value })
)}
title={text('Eşik (0–1)', 'Threshold (0–1)')}
/>
{question(text('Doğrulayıcı elemesi sorusu', 'Reviewer check question'), draft.verifierCheck.question, value =>
stage('verifierCheck', { question: value })
)}
<ListRow
action={secondsInput(text('Tur başı karar bekleme (saniye)', 'Turn-start decision wait (seconds)'), draft.prefilter.timeoutSeconds, 60, value => {
setDraft({
...draft,
prefilter: { ...draft.prefilter, timeoutSeconds: value },
autoRoute: { ...draft.autoRoute, timeoutSeconds: value }
})
})}
description={text(
'Bu sürede karar gelmezse istek bugünkü yoldan devam eder.',
'Without a decision within this time the request continues on the current path.'
)}
title={text('Tur başı karar bekleme (saniye)', 'Turn-start decision wait (seconds)')}
/>
</SettingsSection>
<SettingsSection icon={Link} title={text('Cevaptan sonra düzeltme kanalı', 'Correction channel after answers')}>
<p>
{text(
'Bekçi bu modellerin cevabından sonra düzeltme ya da ek bilgi gönderebilir. Ad tam eşleşmelidir.',
'Bekçi may send corrections or extra information after these models answer. Names must match exactly.'
)}
</p>
{draft.watchModels.map((model, index) => (
<div className="flex flex-wrap items-center gap-2 py-2" key={index}>
{modelSelect(text(`Düzeltme kanalı modeli ${index + 1}`, `Correction channel model ${index + 1}`), model, value =>
change(
'watchModels',
draft.watchModels.map((m, i) => (i === index ? value : m))
)
)}
<Button
aria-label={text(`Düzeltme kanalı modeli ${index + 1} sil`, `Remove correction channel model ${index + 1}`)}
onClick={() =>
change(
'watchModels',
draft.watchModels.filter((_, i) => i !== index)
)
}
variant="ghost"
>
{text('Sil', 'Remove')}
</Button>
</div>
))}
<Button onClick={() => change('watchModels', [...draft.watchModels, ''])} variant="outline">
{text('+ Düzeltme kanalı modeli ekle', '+ Add correction channel model')}
</Button>
</SettingsSection>
<SettingsSection icon={Link} title={text('Kural kaynağı', 'Rules source')}>
<p>
{text(
'Dify seçilirse karar aşamalarının soruları, eşikleri ve combo seçimleri Dify’daki iş akışından birkaç dakikada bir alınır. Codex’in konuştuğu model yine OmniRoute combo’sudur. Dify’a ulaşılamazsa son geçerli kurallar, o da yoksa bu sayfadaki ayarlar kullanılır.',
'With Dify, the stage questions, thresholds and combo choices are fetched from the Dify workflow every few minutes. Codex still talks to OmniRoute combos. If Dify is unreachable the last good rules, otherwise the settings on this page, are used.'
)}
</p>
<ListRow
action={
<Select
onValueChange={value => change('policy', { ...draft.policy, source: value as 'local' | 'dify' })}
value={draft.policy.source}
>
<SelectTrigger aria-label={text('Kural kaynağı', 'Rules source')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">{text('Bu sayfa', 'This page')}</SelectItem>
<SelectItem value="dify">Dify</SelectItem>
</SelectContent>
</Select>
}
title={text('Kural kaynağı', 'Rules source')}
/>
<ListRow
action={
<Input
aria-label={text('Dify API adresi', 'Dify API endpoint')}
onChange={e => change('policy', { ...draft.policy, endpoint: e.target.value })}
type="url"
value={draft.policy.endpoint}
/>
}
title={text('Dify API adresi', 'Dify API endpoint')}
/>
<ListRow
action={
<Input
aria-label={text('Dify uygulama anahtarı', 'Dify app key')}
autoComplete="new-password"
onChange={e => setPolicyKey(e.target.value)}
type="password"
value={policyKey}
/>
}
description={
state?.policyKeyConfigured
? text('Anahtar tanımlı. Değiştirmek için yeni anahtarı girin.', 'Key configured. Enter a new key to replace it.')
: text('Henüz anahtar tanımlı değil.', 'No key configured.')
}
title={text('Dify uygulama anahtarı', 'Dify app key')}
/>
<ListRow
action={secondsInput(text('Yenileme aralığı (dakika)', 'Refresh interval (minutes)'), draft.policy.refreshMinutes, 1440, value =>
change('policy', { ...draft.policy, refreshMinutes: value })
)}
title={text('Yenileme aralığı (dakika)', 'Refresh interval (minutes)')}
/>
{draft.policy.source === 'dify' && (
<p role="status">
{!policy
? text('Dify kuralları henüz alınmadı.', 'Dify rules not fetched yet.')
: policy.ok
? text(
`Son geçerli kurallar: ${policy.lastGoodAt ?? policy.fetchedAt ?? '-'} (${policy.sections.join(', ') || 'boş'})${policy.error ? ` · son deneme hatası: ${policy.error}` : ''}`,
`Last good rules: ${policy.lastGoodAt ?? policy.fetchedAt ?? '-'} (${policy.sections.join(', ') || 'empty'})${policy.error ? ` · last attempt failed: ${policy.error}` : ''}`
)
: text(`Kurallar alınamadı: ${policy.error ?? '-'}`, `Rules could not be fetched: ${policy.error ?? '-'}`)}
</p>
)}
</SettingsSection>
<SettingsSection icon={Link} title={text('Paralellik ve bekleme', 'Concurrency and waiting')}>
{number(
'maxConcurrent',
@@ -39,7 +39,7 @@ describe('ShareControls', () => {
const dialog = await screen.findByRole('dialog')
const input = within(dialog).getByRole('textbox')
fireEvent.change(input, { target: { value: 'not a valid code !!!' } })
fireEvent.click(within(dialog).getByRole('button', { name: 'Yükle', exact: true }))
fireEvent.click(within(dialog).getByRole('button', { name: 'Yükle' }))
expect(within(dialog).getByText('Bu metin bir harita koduna benzemiyor.')).toBeTruthy()
expect((input as HTMLTextAreaElement).value).toBe('not a valid code !!!')
})
+3
View File
@@ -2,6 +2,7 @@ import type { GatewayWsUrlResult } from '@hermes/shared'
import type { TranslucencyState } from '@hermes/shared/translucency'
import type { CodexLinkPatch, CodexLinkStatus } from '../electron/codex-link-types'
import type { CompressionSettings, CompressionState } from '../electron/compression-settings-types'
import type { PoolLimits } from '../electron/pool-limits'
import type { StagedTransitionPatch, StagedTransitionState } from '../electron/staged-transition-types'
@@ -341,6 +342,8 @@ declare global {
* use the IDE's models. `get` only reads; `set` applies and returns the
* state that actually resulted. Windows only. */
codexLink?: {
getCompression: () => Promise<CompressionState>
setCompression: (settings: CompressionSettings) => Promise<CompressionState>
getStagedTransition: () => Promise<StagedTransitionState>
setStagedTransition: (patch: StagedTransitionPatch) => Promise<StagedTransitionState>
get: () => Promise<CodexLinkStatus>
+44 -1
View File
@@ -6,6 +6,10 @@ import { useI18n } from './context'
// AITURK-distribution strings (see ./aiturk): Turkish for `tr`, English for
// every other locale.
const en = {
modeTitle: 'Connection mode',
directMode: 'Direct (recommended): subscription models independent of the bridge',
aiturkMode: 'AITURK: combos in the Codex list, all models through the bridge',
modeRestartNote: 'Restart Codex.',
nav: 'Codex Link',
title: 'Codex Link',
description:
@@ -39,6 +43,10 @@ const en = {
}
const tr: typeof en = {
modeTitle: 'Bağlantı modu',
directMode: 'Doğrudan (önerilen): abonelik modelleri köprüden bağımsız',
aiturkMode: 'AITURK: combolar Codex listesinde, tüm modeller köprüden',
modeRestartNote: "Codex'i yeniden başlatın",
nav: 'Codex Bağlama',
title: 'Codex Bağlama',
description:
@@ -71,10 +79,45 @@ const tr: typeof en = {
restartNote: "Codex'te yeni modelleri görmek için Codex'i yeniden açın."
}
const modeTranslations = {
zh: {
modeTitle: '连接模式',
directMode: '直接(推荐):订阅模型独立于桥接',
aiturkMode: 'AITURK:组合显示在 Codex 列表中,所有模型通过桥接',
modeRestartNote: '请重启 Codex。'
},
'zh-hant': {
modeTitle: '連線模式',
directMode: '直接(建議):訂閱模型獨立於橋接',
aiturkMode: 'AITURK:組合顯示在 Codex 清單中,所有模型透過橋接',
modeRestartNote: '請重新啟動 Codex。'
},
ja: {
modeTitle: '接続モード',
directMode: '直接(推奨):サブスクリプションモデルはブリッジから独立',
aiturkMode: 'AITURK:Codex の一覧にコンボを表示、すべてのモデルはブリッジ経由',
modeRestartNote: 'Codex を再起動してください。'
},
ar: {
modeTitle: 'وضع الاتصال',
directMode: 'مباشر (موصى به): نماذج الاشتراك مستقلة عن الجسر',
aiturkMode: 'AITURK: مجموعات النماذج في قائمة Codex، وجميع النماذج عبر الجسر',
modeRestartNote: 'أعد تشغيل Codex.'
},
ru: {
modeTitle: 'Режим подключения',
directMode: 'Напрямую (рекомендуется): модели подписки независимы от моста',
aiturkMode: 'AITURK: комбинации в списке Codex, все модели через мост',
modeRestartNote: 'Перезапустите Codex.'
}
}
export type CodexLinkCopy = typeof en
export function useCodexLinkCopy(): CodexLinkCopy {
const { locale } = useI18n()
return locale === 'tr' ? tr : en
if (locale === 'tr') return tr
if (locale === 'en') return en
return { ...en, ...modeTranslations[locale] }
}
+1
View File
@@ -19,6 +19,7 @@
"include": [
"electron",
"assets/codex-bridge/jev-settings-defaults.json",
"assets/codex-bridge/compression-defaults.json",
"../shared/src/data-url-read-max.ts",
"../shared/src/translucency.ts"
],
+41 -18
View File
@@ -65,7 +65,7 @@
},
"apps/desktop": {
"name": "aiturk-ide",
"version": "1.0.0-beta.31",
"version": "1.0.0-beta.39",
"license": "MIT",
"dependencies": {
"@assistant-ui/core": "0.2.23",
@@ -139,7 +139,8 @@
"unist-util-visit-parents": "6.0.2",
"use-stick-to-bottom": "1.1.6",
"vfile": "6.0.3",
"web-haptics": "0.0.6"
"web-haptics": "0.0.6",
"ws": "8.21.1"
},
"devDependencies": {
"@babel/core": "8.0.1",
@@ -154,6 +155,7 @@
"@types/node": "22.20.1",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@types/ws": "8.18.2",
"@vitejs/plugin-react": "6.0.3",
"babel-plugin-react-compiler": "1.0.0",
"bippy": "0.5.43",
@@ -466,6 +468,16 @@
}
}
},
"apps/desktop/node_modules/@types/ws": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
"integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"apps/desktop/node_modules/blobatar": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/blobatar/-/blobatar-2.0.0.tgz",
@@ -3923,6 +3935,19 @@
"react": ">=18.0.0"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.0.tgz",
"integrity": "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.3"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=23.5.0"
}
},
"node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
@@ -6856,22 +6881,15 @@
}
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.0.tgz",
"integrity": "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==",
"optional": true,
"license": "MIT",
"dependencies": {"@tybys/wasm-util": "^0.10.3"},
"engines": {"node": "^20.19.0 || ^22.13.0 || >=23.5.0"}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"optional": true,
"license": "MIT",
"dependencies": {"tslib": "^2.4.0"}
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
@@ -19515,11 +19533,17 @@
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
"cpu": ["x64"],
"os": ["win32"],
"optional": true,
"cpu": [
"x64"
],
"license": "MPL-2.0",
"engines": {"node": ">= 12.0.0"}
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/vite/node_modules/nanoid": {
"version": "3.3.18",
@@ -20032,7 +20056,6 @@
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},
+46
View File
@@ -2550,6 +2550,39 @@ function Install-Repository {
Write-Success "Repository ready"
}
function Test-ReusableManagedVenv {
param([string]$VenvPath, [string]$BasePython, [string]$Version)
$pythonExe = Join-Path $VenvPath "Scripts\python.exe"
if (-not (Test-Path -LiteralPath $pythonExe -PathType Leaf)) { return $false }
$process = New-Object System.Diagnostics.Process
try {
$info = New-Object System.Diagnostics.ProcessStartInfo
$info.FileName = $pythonExe
$info.Arguments = '-I -c "import json,sys,ssl,ctypes,venv,encodings; print(json.dumps(dict(version=str(sys.version_info.major)+''.''+str(sys.version_info.minor),prefix=sys.prefix,base=sys._base_executable)))"'
$info.UseShellExecute = $false
$info.CreateNoWindow = $true
$info.RedirectStandardOutput = $true
$info.RedirectStandardError = $true
$process.StartInfo = $info
if (-not $process.Start()) { return $false }
$stdout = $process.StandardOutput.ReadToEndAsync()
$stderr = $process.StandardError.ReadToEndAsync()
if (-not $process.WaitForExit(15000)) {
try { $process.Kill() } catch { }
return $false
}
if ($process.ExitCode -ne 0) { return $false }
$health = $stdout.Result | ConvertFrom-Json
return ($health.version -eq $Version -and
[string]::Equals([IO.Path]::GetFullPath($health.prefix), [IO.Path]::GetFullPath($VenvPath), [StringComparison]::OrdinalIgnoreCase) -and
[string]::Equals([IO.Path]::GetFullPath($health.base), [IO.Path]::GetFullPath($BasePython), [StringComparison]::OrdinalIgnoreCase))
} catch {
return $false
} finally {
$process.Dispose()
}
}
function Install-Venv {
if ($NoVenv) {
Write-Info "Skipping virtual environment (-NoVenv)"
@@ -2567,6 +2600,19 @@ function Install-Venv {
throw "Hermes-managed Python is unavailable. Run install.ps1 -Stage python first."
}
# Routine source updates do not require replacing a healthy interpreter.
# Recreating it kills detached Codex bridges sharing this venv and leaves
# every subscription model disconnected until bootstrap finishes. Verify
# version, provenance and stdlib first; the dependency stage still performs
# the locked sync and import checks. Broken/mismatched runtimes retain the
# transactional repair path below.
$existingVenvPath = Join-Path $InstallDir "venv"
if (Test-ReusableManagedVenv -VenvPath $existingVenvPath -BasePython $resolvedPython.Path -Version $resolvedPython.Version) {
$env:UV_PYTHON = Join-Path $existingVenvPath "Scripts\python.exe"
Write-Success "Reusing healthy managed virtual environment; running Codex bridges preserved"
return
}
Write-Info "Creating virtual environment with Python $($resolvedPython.Version)..."
Push-Location $InstallDir
@@ -0,0 +1,77 @@
"""Run the actual installer functions against a real disposable Python venv."""
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
SOURCE = Path(__file__).resolve().parents[1] / 'scripts/install.ps1'
PS = shutil.which('powershell') or shutil.which('pwsh')
pytestmark = pytest.mark.skipif(os.name != 'nt' or not PS, reason='Windows installer runtime test')
def function(source, name):
start = source.index('function ' + name + ' {')
opening = source.index('{', start)
depth = 0
for index in range(opening, len(source)):
if source[index] == '{': depth += 1
elif source[index] == '}': depth -= 1
if depth == 0: return source[start:index + 1]
raise AssertionError(name)
@pytest.fixture(scope='module')
def runtime(tmp_path_factory):
root = tmp_path_factory.mktemp('installer-reuse')
subprocess.run([sys.executable, '-m', 'venv', '--without-pip', str(root / 'venv')], check=True)
return root
def run(runtime, body):
text = SOURCE.read_text(encoding='utf-8-sig')
script = runtime / 'probe.ps1'
prefix = "$ErrorActionPreference = 'Stop'\n"
prefix += function(text, 'Test-ReusableManagedVenv') + '\n'
prefix += function(text, 'Install-Venv') + '\n'
prefix += "$InstallDir = '" + str(runtime).replace("'", "''") + "'\n"
prefix += "$BasePython = '" + sys._base_executable.replace("'", "''") + "'\n"
prefix += "$Version = '" + str(sys.version_info.major) + '.' + str(sys.version_info.minor) + "'\n"
script.write_text(prefix + body, encoding='utf-8-sig')
result = subprocess.run([PS, '-NoProfile', '-File', str(script)], capture_output=True, text=True, timeout=40)
assert result.returncode == 0, result.stdout + result.stderr
return result.stdout
def test_healthy_update_keeps_live_python_and_never_reaches_kill_sweep(runtime):
python = runtime / 'venv/Scripts/python.exe'
live = subprocess.Popen([str(python), '-c', 'import time;time.sleep(90)'])
try:
output = run(runtime, r'''
function Resolve-AvailablePythonVersion { return @{Path=$BasePython;Version=$Version} }
function Write-Success { param($Message); Write-Output $Message }
function Write-Info { throw 'Unexpected venv recreation/kill path' }
function taskkill { throw 'Live process must not be killed' }
Install-Venv
''')
assert 'Reusing healthy' in output
assert live.poll() is None
assert python.is_file()
finally:
live.terminate();live.wait(timeout=10)
def test_wrong_version_or_foreign_interpreter_does_not_reuse(runtime):
output = run(runtime, r'''
$venvPath = Join-Path $InstallDir 'venv'
$results = @(
(Test-ReusableManagedVenv $venvPath $BasePython '0.0'),
(Test-ReusableManagedVenv $venvPath (Join-Path $InstallDir 'other-python.exe') $Version),
(Test-ReusableManagedVenv (Join-Path $InstallDir 'missing') $BasePython $Version)
)
ConvertTo-Json -Compress -InputObject $results
''')
assert json.loads(output) == [False, False, False]
@@ -20,6 +20,7 @@ import subprocess
import pytest
from tools.file_operations import ShellFileOperations
from tools.environments.local import LocalEnvironment
# Big enough to prove containment, small enough to keep the test fast.
GIANT = 5 * 1024 * 1024 # 5MB single line
@@ -27,22 +28,20 @@ GIANT = 5 * 1024 * 1024 # 5MB single line
STDOUT_CEILING = 1 * 1024 * 1024
class RecordingEnv:
class RecordingEnv(LocalEnvironment):
"""Local bash executor that records the largest stdout it returned."""
def __init__(self, cwd):
self.cwd = cwd
super().__init__(cwd=cwd)
self.max_stdout = 0
def execute(self, command, timeout=60, **kwargs):
proc = subprocess.run(
["bash", "-c", command],
capture_output=True, text=True, errors="replace",
timeout=timeout + 30,
)
out = proc.stdout + (proc.stderr or "")
# Use the application's shell selection/path handling. On Windows a
# bare 'bash' may select WSL, whose filesystem is not the native one.
result = super().execute(command, timeout=timeout + 30, **kwargs)
out = result.get("output", "")
self.max_stdout = max(self.max_stdout, len(out))
return {"output": out, "returncode": proc.returncode}
return result
@pytest.fixture()
@@ -58,6 +57,8 @@ def _ops(giant_dir, engine):
env = RecordingEnv(str(giant_dir))
ops = ShellFileOperations(env)
ops._has_command = lambda cmd: cmd == engine
resolve = ops._resolve_command
ops._resolve_command = lambda cmd: None if cmd == "rg" and engine != "rg" else resolve(cmd)
return ops, env
@@ -96,5 +97,5 @@ def test_line_cap_skipped_for_path_and_count_modes(giant_dir, engine, output_mod
if output_mode == "files_only":
assert {os.path.basename(f) for f in result.files} == {"trace.json", "small.py"}
else:
assert {os.path.basename(k) for k in result.counts} == {"trace.json", "small.py"}
assert {os.path.basename(k) for k, count in result.counts.items() if count} == {"trace.json", "small.py"}
assert env.max_stdout < STDOUT_CEILING
+112
View File
@@ -0,0 +1,112 @@
"""Project scope and resource limits: real local paths, no broad subprocesses."""
from unittest.mock import Mock
from tools.file_operations import ShellFileOperations, SearchResult, ExecuteResult
from tools.environments.local import LocalEnvironment
def projects(tmp_path):
for name in ('one', 'two'):
project = tmp_path / name
project.mkdir()
(project / '.git').mkdir()
(project / 'source.py').write_text('needle\n')
return tmp_path / 'one'
def test_ambiguous_scope_starts_no_process(tmp_path):
projects(tmp_path)
ops = ShellFileOperations(LocalEnvironment(str(tmp_path)))
ops._exec = Mock(side_effect=AssertionError('must not start process'))
result = ops.search('needle', str(tmp_path))
assert 'scope' in result.error.lower()
assert 'one' in result.error and 'two' in result.error
ops._exec.assert_not_called()
def test_umbrella_uses_cwd_project_and_reports_it(tmp_path):
project = projects(tmp_path)
ops = ShellFileOperations(LocalEnvironment(str(project)))
ops._exec = Mock(return_value=ExecuteResult(stdout='exists', exit_code=0))
ops._search_content = Mock(return_value=SearchResult())
result = ops.search('needle', str(tmp_path))
assert ops._search_content.call_args.args[1] == str(project)
assert str(project) in result.warning
def test_broad_opt_in_and_explicit_file(tmp_path):
project = projects(tmp_path)
ops = ShellFileOperations(LocalEnvironment(str(project)))
ops._exec = Mock(return_value=ExecuteResult(stdout='exists', exit_code=0))
ops._search_content = Mock(return_value=SearchResult())
for root, broad in ((tmp_path, True), (project / 'source.py', False)):
assert not ops.search('needle', str(root), allow_broad=broad).error
assert ops._search_content.call_args.args[1] == str(root)
def test_rg_commands_bound_threads_and_prune(tmp_path):
project = projects(tmp_path)
ops = ShellFileOperations(LocalEnvironment(str(project)))
ops._exec = Mock(return_value=ExecuteResult(stdout='', exit_code=1))
ops._search_with_rg('needle', str(project), '*.py', 50, 0, 'content', 0, rg_executable='rg')
command = ops._exec.call_args.args[0]
assert '--threads 2' in command
assert 'node_modules' in command and '.git' in command
assert '*.py' in command
ops._search_files_rg('*.py', str(project), 50, 0, rg_executable='rg')
command = ops._exec.call_args.args[0]
assert '--threads 2' in command and 'node_modules' in command
def test_concurrent_searches_have_two_slots_and_release_after_failure(tmp_path):
import threading
from concurrent.futures import ThreadPoolExecutor
project = projects(tmp_path)
lock = threading.Lock()
release = threading.Event()
entered = threading.Event()
active = peak = 0
def run_search(*args):
nonlocal active, peak
with lock:
active += 1
peak = max(peak, active)
if active == 2:
entered.set()
try:
assert release.wait(5)
return SearchResult()
finally:
with lock:
active -= 1
with ThreadPoolExecutor(max_workers=6) as pool:
pending = []
for _ in range(6):
ops = ShellFileOperations(LocalEnvironment(str(project)))
ops._search_scoped = run_search
pending.append(pool.submit(ops.search, 'needle'))
try:
assert entered.wait(3)
assert peak == 2
finally:
release.set()
assert all(not task.result().error for task in pending)
assert peak == 2
def test_real_rg_project_content_and_nested_glob(tmp_path):
project = projects(tmp_path)
(project / 'src').mkdir()
(project / 'src' / 'nested.py').write_text('needle\n')
(project / 'node_modules').mkdir()
(project / 'node_modules' / 'noise.py').write_text('needle\n')
ops = ShellFileOperations(LocalEnvironment(str(project)))
result = ops.search('needle', str(tmp_path), file_glob='*.py')
assert not result.error
assert len(result.matches) == 2
assert all('node_modules' not in item.path for item in result.matches)
result = ops.search('src/*.py', str(project), target='files')
assert not result.error
assert len(result.files) == 1 and 'nested.py' in result.files[0]
+56 -12
View File
@@ -50,7 +50,8 @@ from agent.file_safety import (
get_write_denied_error,
is_write_denied as _shared_is_write_denied,
)
from agent.search_policy import SEARCH_PRUNE_DIR_NAMES
from agent.search_policy import (SEARCH_PRUNE_DIR_NAMES, SEARCH_RG_THREADS,
SEARCH_MAX_CONCURRENT, local_search_scope)
from tools import interrupt as tool_interrupt
logger = logging.getLogger(__name__)
@@ -73,6 +74,8 @@ _MACOS_TCC_PROTECTED_HOME_DIRS = (
)
_SEARCH_SLOTS = threading.BoundedSemaphore(SEARCH_MAX_CONCURRENT)
_FILENAME_SEARCH_ADMISSION = threading.Condition()
_ACTIVE_FILENAME_SEARCH_ROOTS: set[tuple[str, str, str]] = set()
_FILENAME_SEARCH_WAIT_SECONDS = 0.05
@@ -3432,7 +3435,31 @@ class ShellFileOperations(FileOperations):
def search(self, pattern: str, path: str = ".", target: str = "content",
file_glob: Optional[str] = None, limit: int = 50, offset: int = 0,
output_mode: str = "content", context: int = 0,
order: str = "discovery") -> SearchResult:
order: str = "discovery", allow_broad: bool = False) -> SearchResult:
# One admission slot covers discovery, traversal and zero-match probes.
# It is process-wide across file-ops instances, and interruptible.
while not _SEARCH_SLOTS.acquire(timeout=0.05):
if tool_interrupt.is_interrupted():
return SearchResult(error="Search interrupted while waiting for a search slot.")
try:
if tool_interrupt.is_interrupted():
return SearchResult(error="Search interrupted.")
return self._search_scoped(pattern, path, target, file_glob, limit,
offset, output_mode, context, order, allow_broad)
finally:
_SEARCH_SLOTS.release()
def _project_search_scope(self, path, allow_broad=False):
from tools.environments.local import LocalEnvironment, _msys_to_windows_path
if allow_broad or not isinstance(self.env, LocalEnvironment):
return path, None, None
cwd = getattr(self.env, "cwd", None) or self.cwd
return local_search_scope(_msys_to_windows_path(path), cwd, _HOME)
def _search_scoped(self, pattern: str, path: str = ".", target: str = "content",
file_glob: Optional[str] = None, limit: int = 50, offset: int = 0,
output_mode: str = "content", context: int = 0,
order: str = "discovery", allow_broad: bool = False) -> SearchResult:
"""
Search for content or files.
@@ -3460,6 +3487,9 @@ class ShellFileOperations(FileOperations):
# Expand ~ and other shell paths
path = self._expand_path(path)
path, scope_notice, scope_error = self._project_search_scope(path, allow_broad)
if scope_error:
return SearchResult(error=scope_error)
# Validate that the path exists before searching
check = self._exec(f"test -e {self._escape_shell_arg(path)} && echo exists || echo not_found")
@@ -3470,7 +3500,7 @@ class ShellFileOperations(FileOperations):
# merge the results, and report the skipped parts.
multi = self._try_multi_path_search(
pattern, path, target, file_glob, limit, offset, output_mode, context,
order,
order, allow_broad,
)
if multi is not None:
return multi
@@ -3518,6 +3548,8 @@ class ShellFileOperations(FileOperations):
f"an unattended privacy prompt: {skipped}. Search a protected "
"folder directly when access is intentional."
)
if scope_notice:
result.warning = " ".join(filter(None, [scope_notice, result.warning]))
return result
def _macos_search_exclusions(self, path: str) -> List[str]:
@@ -3587,7 +3619,7 @@ class ShellFileOperations(FileOperations):
def _try_multi_path_search(self, pattern: str, path: str, target: str,
file_glob: Optional[str], limit: int, offset: int,
output_mode: str, context: int,
order: str = "discovery") -> Optional[SearchResult]:
order: str = "discovery", allow_broad: bool = False) -> Optional[SearchResult]:
"""Recover a not-found ``path`` that is really several paths in one string.
Production trajectories show models passing "dir1 dir2 dir3" (or
@@ -3604,9 +3636,14 @@ class ShellFileOperations(FileOperations):
parts = path.split()
if len(parts) < 2:
return None
existing, missing = [], []
existing, missing, scope_notices = [], [], []
for p in parts:
expanded = self._expand_path(p)
expanded, notice, error = self._project_search_scope(expanded, allow_broad)
if error:
return SearchResult(error=error)
if notice:
scope_notices.append(notice)
chk = self._exec(
f"test -e {self._escape_shell_arg(expanded)} && echo exists || echo not_found"
)
@@ -3641,7 +3678,7 @@ class ShellFileOperations(FileOperations):
note += "; skipped missing: " + ", ".join(missing[:3])
if len(missing) > 3:
note += f" (+{len(missing) - 3} more)"
warning_parts = [note]
warning_parts = [note, *scope_notices]
if not merged.error:
protected_paths = [
absolute
@@ -3704,7 +3741,7 @@ class ShellFileOperations(FileOperations):
glob_expr = f" --glob {self._escape_shell_arg(file_glob)}" if file_glob else ""
probe = self._exec(
f"{rg} -i --count-matches{glob_expr} "
f"{rg} --threads {SEARCH_RG_THREADS} -i --count-matches{glob_expr} {self._search_prune_glob_args()} "
f"{self._escape_shell_arg(pattern)} {self._escape_native_tool_arg(path)} "
f"2>/dev/null | head -50",
timeout=30,
@@ -3723,7 +3760,7 @@ class ShellFileOperations(FileOperations):
# files remain diagnosable, but prune heavyweight trees before rg can
# recurse into them.
hidden = self._exec(
f"{rg} --hidden --no-ignore --count-matches{glob_expr}"
f"{rg} --threads {SEARCH_RG_THREADS} --hidden --no-ignore --count-matches{glob_expr}"
f" {self._search_prune_glob_args()} "
f"{self._escape_shell_arg(pattern)} {self._escape_native_tool_arg(path)} "
f"2>/dev/null | head -50",
@@ -3738,7 +3775,7 @@ class ShellFileOperations(FileOperations):
)
if re.search(r"[.\[\](){}?*+^$\\|]", pattern):
fixed = self._exec(
f"{rg} -F --count-matches{glob_expr} "
f"{rg} --threads {SEARCH_RG_THREADS} -F --count-matches{glob_expr} {self._search_prune_glob_args()} "
f"{self._escape_shell_arg(pattern)} {self._escape_native_tool_arg(path)} "
f"2>/dev/null | head -50",
timeout=30,
@@ -3813,7 +3850,7 @@ class ShellFileOperations(FileOperations):
))
try:
return self._search_files_rg(
search_pattern, path, limit, offset, order,
pattern, path, limit, offset, order,
rg_executable=rg_executable,
)
finally:
@@ -3848,6 +3885,9 @@ class ShellFileOperations(FileOperations):
hidden_prune = (
f" \\( -type d -name '.*'{root_exemptions} \\) -prune -o"
)
names = " -o ".join(f"-name {self._escape_shell_arg(name)}"
for name in sorted(SEARCH_PRUNE_DIR_NAMES))
hidden_prune += f" \\( -type d \\( {names} \\){root_exemptions} \\) -prune -o"
protected_paths = [
absolute
for _root, _relative, absolute
@@ -3986,7 +4026,7 @@ class ShellFileOperations(FileOperations):
f"cd {self._escape_shell_arg(scoped_common)} && " if scoped_common else ""
)
cmd = (
f"set -o pipefail; {cd_prefix}{rg} --files{sort_arg} -g {self._escape_shell_arg(glob_pattern)}"
f"set -o pipefail; {cd_prefix}{rg} --files{sort_arg} --threads {SEARCH_RG_THREADS} -g {self._escape_shell_arg(glob_pattern)} {self._search_prune_glob_args()}"
f"{exclusion_args} -- {root_args} 2>/dev/null | head -n {fetch_limit}"
)
result = self._exec(cmd, timeout=60)
@@ -4062,7 +4102,7 @@ class ShellFileOperations(FileOperations):
rg_executable = rg_executable or self._resolve_command("rg")
if not rg_executable:
return SearchResult(error="Content search requires ripgrep (rg).")
cmd_parts = [self._quote_executable(rg_executable), "--line-number", "--no-heading", "--with-filename"]
cmd_parts = [self._quote_executable(rg_executable), "--threads", str(SEARCH_RG_THREADS), "--line-number", "--no-heading", "--with-filename"]
# Giant-single-line containment (ported from cline/cline#13525): a
# match inside a serialized dump (multi-MB single-line JSON/minified
@@ -4100,6 +4140,8 @@ class ShellFileOperations(FileOperations):
if file_glob:
cmd_parts.extend(["--glob", self._escape_shell_arg(file_glob)])
cmd_parts.append(self._search_prune_glob_args())
# Output mode handling
if output_mode == "files_only":
cmd_parts.append("-l") # Files only
@@ -4230,6 +4272,8 @@ class ShellFileOperations(FileOperations):
# Exclude hidden directories (matching ripgrep's default behavior).
# This prevents searching inside .hub/index-cache/, .git/, etc.
cmd_parts.append("--exclude-dir='.*'")
cmd_parts.extend(f"--exclude-dir={self._escape_shell_arg(name)}"
for name in sorted(SEARCH_PRUNE_DIR_NAMES))
# Protected-dir pruning CANNOT use --exclude-dir here: grep matches
# exclude-dir globs against BASENAMES anywhere in the tree, so
+6 -2
View File
@@ -2653,7 +2653,7 @@ def search_tool(pattern: str, target: str = "content", path: str = ".",
file_glob: str = None, limit: int = 50, offset: int = 0,
output_mode: str = "content", context: int = 0,
order: str = "discovery",
task_id: str = "default") -> str:
task_id: str = "default", allow_broad: bool = False) -> str:
"""Search for content or files."""
try:
offset, limit = normalize_search_pagination(offset, limit)
@@ -2670,6 +2670,7 @@ def search_tool(pattern: str, target: str = "content", path: str = ".",
limit,
offset,
order,
allow_broad,
)
with _read_tracker_lock:
task_data = _read_tracker.setdefault(task_id, {
@@ -2717,6 +2718,7 @@ def search_tool(pattern: str, target: str = "content", path: str = ".",
pattern=pattern, path=path, target=target, file_glob=file_glob,
limit=limit, offset=offset, output_mode=output_mode, context=context,
order=order,
**({"allow_broad": True} if allow_broad else {}),
)
omitted = _filter_read_blocked_search_results(result, task_id)
if hasattr(result, 'matches'):
@@ -2912,6 +2914,7 @@ SEARCH_FILES_SCHEMA = {
"parameters": {
"type": "object",
"properties": {
"allow_broad": {"type": "boolean", "default": False, "description": "Deliberately search a home, drive or multi-project umbrella. Default scopes to the current project or asks for a scope. Does not remove pruning or CPU limits."},
"pattern": {"type": "string", "description": "Regex pattern for content search, or glob pattern (e.g., '*.py') for file search"},
"target": {"type": "string", "enum": ["content", "files"], "description": "'content' searches inside file contents, 'files' searches for files by name", "default": "content"},
"path": {"type": "string", "description": "Directory or file to search in (default: current working directory)", "default": "."},
@@ -2979,7 +2982,8 @@ def _handle_search_files(args, **kw):
pattern=args.get("pattern", ""), target=target, path=args.get("path", "."),
file_glob=args.get("file_glob"), limit=args.get("limit", 50), offset=args.get("offset", 0),
output_mode=args.get("output_mode", "content"), context=args.get("context", 0),
order=args.get("order", "discovery"), task_id=tid)
order=args.get("order", "discovery"), task_id=tid,
allow_broad=args.get("allow_broad") is True)
def _read_file_schema_overrides():