Compare commits
102
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f53b090f7 | ||
|
|
049c4d1338 | ||
|
|
4924781e2d | ||
|
|
4037c65204 | ||
|
|
e7d3080e0a | ||
|
|
db5e876f40 | ||
|
|
ab7a721fbc | ||
|
|
2cca6a66a6 | ||
|
|
a68d3e2272 | ||
|
|
dfa762bcd2 | ||
|
|
912752bdd3 | ||
|
|
91ecad8e5c | ||
|
|
1c6e3656b8 | ||
|
|
883b1dc824 | ||
|
|
369f6c2ca8 | ||
|
|
2718b460ed | ||
|
|
b4e8664115 | ||
|
|
b66d9164ad | ||
|
|
eeee2756c7 | ||
|
|
017c6629d2 | ||
|
|
8bd662ea92 | ||
|
|
b29cf1ad11 | ||
|
|
d0810c63dd | ||
|
|
e6cd197a2e | ||
|
|
02a5fa2080 | ||
|
|
4f0476e5f2 | ||
|
|
3726d2a553 | ||
|
|
8fbbe226db | ||
|
|
06039b904d | ||
|
|
7547de1852 | ||
|
|
ff53cafb5c | ||
|
|
77504b9aa4 | ||
|
|
11bbaddcc7 | ||
|
|
be0b33e89b | ||
|
|
1f4f2de428 | ||
|
|
27964e37b0 | ||
|
|
5556ad11af | ||
|
|
c8993d58cc | ||
|
|
23e8aa8191 | ||
|
|
c0eee2cc2a | ||
|
|
12eeec7b0f | ||
|
|
dd5f99c406 | ||
|
|
48103b9ad3 | ||
|
|
615fedde01 | ||
|
|
a46ff51019 | ||
|
|
8a612beba7 | ||
|
|
7c0dc467d5 | ||
|
|
e2793fa38f | ||
|
|
1e2b8e2b9c | ||
|
|
1445e204da | ||
|
|
d9a7661281 | ||
|
|
959f5f1459 | ||
|
|
acf88f8314 | ||
|
|
ac0fdcf355 | ||
|
|
9a54705ed5 | ||
|
|
9739222919 | ||
|
|
94e4aa2a74 | ||
|
|
adf73eeedd | ||
|
|
ce49d6d1cc | ||
|
|
8ee676d7c6 | ||
|
|
68a97b7489 | ||
|
|
6e91154f15 | ||
|
|
88e660768c | ||
|
|
bfd0ff196d | ||
|
|
1e0f2f9aea | ||
|
|
b560950f82 | ||
|
|
dea67fc153 | ||
|
|
1ce5ab82cf | ||
|
|
df63432c92 | ||
|
|
4220d35ebe | ||
|
|
01889d1383 | ||
|
|
1282de09ba | ||
|
|
0fa42332c1 | ||
|
|
21aa3c2247 | ||
|
|
55a1beaf9d | ||
|
|
1acb5dd90f | ||
|
|
61d765e2df | ||
|
|
b1db2870c1 | ||
|
|
7e4cc0915c | ||
|
|
58dbb4523b | ||
|
|
2ed9a6b2de | ||
|
|
03d3803dfc | ||
|
|
25d8c18c10 | ||
|
|
fe0a45a84f | ||
|
|
67a99be224 | ||
|
|
9240576937 | ||
|
|
233885d3a7 | ||
|
|
9a1d831d4b | ||
|
|
1fe367177c | ||
|
|
40c3c74282 | ||
|
|
ee73fa8cce | ||
|
|
f960716483 | ||
|
|
5f75dcc99a | ||
|
|
ac6687181e | ||
|
|
3f12c627ab | ||
|
|
4115f1b4c0 | ||
|
|
c1929957a2 | ||
|
|
b66daed07a | ||
|
|
9f9f3226a1 | ||
|
|
3332458708 | ||
|
|
ff2eb47302 | ||
|
|
fa63e03eb7 |
No files matched your search
@@ -0,0 +1,94 @@
|
||||
name: Server CI and Windows package
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [aiturk/main, fix/ide-windows-vcruntime]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 40
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=2560
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
github-server-url: https://gitea.turkservis.online
|
||||
- name: Exact source and Linux test environment
|
||||
run: |
|
||||
set -eu
|
||||
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
|
||||
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"
|
||||
"$RUNNER_TEMP/bootstrap/bin/uv" pip install --python "$RUNNER_TEMP/ci-venv/bin/python" -e . pytest zstandard
|
||||
- name: Approval gate and file safety on the server
|
||||
run: |
|
||||
"$RUNNER_TEMP/ci-venv/bin/python" -m pytest tests/tools/test_protected_instruction_unattended.py tests/tools/test_file_write_safety.py tests/tools/test_computer_use_approval_isolation.py tests/cli/test_cli_approval_ui.py tests/cli/test_single_query_session_finalize.py -q -p no:cacheprovider -rf
|
||||
- name: Codex bridge regression tests
|
||||
run: '"$RUNNER_TEMP/ci-venv/bin/python" -m unittest discover -s apps/desktop/assets/codex-bridge'
|
||||
- name: 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 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
|
||||
ln -s "$RUNNER_TEMP/mobile-tests/node_modules" node_modules
|
||||
node node_modules/vitest/vitest.mjs run --root apps/desktop --config vitest.mobile.config.ts --maxWorkers=2
|
||||
|
||||
windows-package:
|
||||
if: github.ref == 'refs/heads/fix/ide-windows-vcruntime'
|
||||
needs: verify
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
# Windows packaging runs on Linux/Wine in the new server. No laptop runner.
|
||||
container: electronuserland/builder@sha256:b226b27b1a7e95ad25eb3d6414ea90c56c04eb6d8329967f4d721dce50b989ff
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=2560
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
github-server-url: https://gitea.turkservis.online
|
||||
- name: Install locked source dependencies
|
||||
run: npm ci --no-audit --no-fund
|
||||
- name: Install Windows launcher cross compiler and framework reference
|
||||
run: |
|
||||
set -eu
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends mono-mcs=6.8.0.105+dfsg-3.2 libmono-system-web-extensions4.0-cil=6.8.0.105+dfsg-3.2
|
||||
- name: Compile Windows launcher from source and verify failure gate
|
||||
run: node --test apps/desktop/scripts/build-native-watch-launcher.test.mjs
|
||||
- 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 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
|
||||
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
|
||||
npm run build --workspace=apps/desktop
|
||||
npm run builder --workspace=apps/desktop -- --win nsis zip --x64 --publish never
|
||||
git diff --exit-code
|
||||
cd apps/desktop/release
|
||||
sha256sum *.exe *.zip > SHA256SUMS
|
||||
- name: Preserve exact-source Windows package
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: aiturk-ide-windows-${{ github.sha }}
|
||||
if-no-files-found: error
|
||||
path: |
|
||||
apps/desktop/release/*.exe
|
||||
apps/desktop/release/*.zip
|
||||
apps/desktop/release/SHA256SUMS
|
||||
+22
-3
@@ -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
|
||||
|
||||
@@ -54,5 +73,5 @@ Nous Research telif bildirimi ve MIT metni [LICENSE](LICENSE) dosyasında korunu
|
||||
paket ayrıca `assets/NOTICE.txt` ve `assets/LICENSE.txt` içerir.
|
||||
AITURK/TurkServis dağıtımı Nous Research tarafından onaylanmış bir ürün değildir.
|
||||
|
||||
[AITURK kaynak deposu](https://gitea.twinpay.one/yilsem/aiturk-hermes-ide)
|
||||
[AITURK kaynak deposu](https://gitea.turkservis.online/yilsem/aiturk-hermes-ide)
|
||||
· [TurkServis](https://turkservis.online)
|
||||
@@ -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
|
||||
|
||||
+15
-2
@@ -1,7 +1,7 @@
|
||||
# AITURK Chrome beta
|
||||
|
||||
Windows / Chrome MV3 side panel for existing local AITURK IDE conversations.
|
||||
Requires AITURK IDE 1.0.0-beta.7 or later; see `KURULUM.txt` for Turkish setup.
|
||||
Requires AITURK IDE 1.0.0-beta.9 or later; see `KURULUM.txt` for Turkish setup.
|
||||
|
||||
The extension talks only to `online.turkservis.aiturk` using Chrome native
|
||||
messaging. The per-user .NET Framework host authenticates to Electron's random
|
||||
@@ -12,7 +12,8 @@ runtime identity, then submits only the new prompt. No parallel agent backend,
|
||||
history reconstruction, model API key in the browser or telemetry is involved.
|
||||
|
||||
Only the active local profile is supported. The list searches titles among
|
||||
desktop sessions in the most recent 200 records. The transcript shows the last
|
||||
the most recent 200 desktop sessions (two API pages of at most 100, filtered
|
||||
server-side before pagination). The transcript shows the last
|
||||
40 records, with up to 8,000 characters per visible message; the model's existing
|
||||
context is not truncated by this display policy. Approvals and clarifications
|
||||
remain in the IDE. A backend event is stamped once and delivered to every
|
||||
@@ -40,6 +41,18 @@ npm ci --workspace apps/desktop --include-workspace-root
|
||||
node --test apps/chrome/tests/panel.test.mjs
|
||||
```
|
||||
|
||||
Exercise the production FastAPI session router and real temporary SQLite stores
|
||||
with the JS bridge (no model requests or user data):
|
||||
|
||||
```powershell
|
||||
$env:AITURK_TEST_PYTHON = 'C:/path/to/python-with-hermes-dependencies.exe'
|
||||
node --test apps/chrome/tests/sessions-api.test.mjs
|
||||
```
|
||||
|
||||
This contract test reproduces the original 422 for an oversized page, then
|
||||
verifies 200 desktop conversations across two valid pages, profile isolation,
|
||||
short/empty lists, archived/empty/cron exclusion and bounded pagination.
|
||||
|
||||
Run backend fan-out and replay tests with the canonical hermetic runner:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -78,11 +78,27 @@ test('page context is explicit appended reference data, never a replaced system
|
||||
assert.ok(prompt.includes('Ignore prior instructions'))
|
||||
})
|
||||
|
||||
test('40 long Unicode or escaped transcripts fit the native byte limit with an in-flight answer', () => {
|
||||
for (const text of ['Türkçe ığüşöç', '你好世界', '\u0000\u0001']) {
|
||||
const rows = Array.from({ length: 40 }, (_, id) => ({ id, role: 'assistant', content: text.repeat(8000) }))
|
||||
const response = { id: 'request', result: { messages: publicMessages(rows), inflight: '\u0000'.repeat(16000), model: 'fixture' } }
|
||||
assert.ok(Buffer.byteLength(JSON.stringify(response)) < MAX_FRAME)
|
||||
assert.equal(response.result.messages.length, 40)
|
||||
assert.ok(response.result.messages.every(row => row.text.length > 0))
|
||||
assert.equal(rows[0].content, text.repeat(8000))
|
||||
}
|
||||
})
|
||||
|
||||
async function fixture(t) {
|
||||
const calls = [], peers = []
|
||||
const http = createServer((request, response) => {
|
||||
assert.equal(request.headers['x-hermes-session-token'], 'fixture-backend-secret')
|
||||
response.setHeader('Content-Type', 'application/json')
|
||||
if (request.url.startsWith('/api/sessions?')) {
|
||||
const query = new URL(request.url, 'http://localhost').searchParams
|
||||
assert.ok(Number(query.get('limit')) <= 100)
|
||||
assert.equal(query.get('source'), 'desktop')
|
||||
}
|
||||
if (request.url.startsWith('/api/sessions?')) response.end(JSON.stringify({ sessions: [
|
||||
{ id: 'stored-1', title: 'Birinci iş', source: 'desktop', model: 'fixture-model' },
|
||||
{ id: 'stored-2', title: 'İkinci iş', source: 'desktop' },
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Real session router + disposable SQLite stores for the JS bridge contract.
|
||||
|
||||
Run by sessions-api.test.mjs. Only service dependencies are replaced; query
|
||||
validation, route handling, SQL filters and pagination are production code.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="aiturk-chrome-api-") as directory:
|
||||
os.environ["HERMES_HOME"] = directory
|
||||
from fastapi import FastAPI, HTTPException, WebSocket
|
||||
from fastapi.responses import JSONResponse
|
||||
import uvicorn
|
||||
from hermes_state import SessionDB
|
||||
from hermes_cli.web_routers import sessions
|
||||
|
||||
paths = {name: Path(directory) / f"{name}.db" for name in ("default", "small", "empty")}
|
||||
for profile, count in (("default", 230), ("small", 3), ("empty", 0)):
|
||||
db = SessionDB(db_path=paths[profile])
|
||||
for index in range(count):
|
||||
sid = f"{profile}-desktop-{index:03d}"
|
||||
db.create_session(sid, source="desktop")
|
||||
db.set_session_title(sid, sid)
|
||||
db.append_message(sid, role="user", content="Fixture context")
|
||||
if count:
|
||||
for index in range(240):
|
||||
sid = f"{profile}-cron-{index:03d}"
|
||||
db.create_session(sid, source="cron")
|
||||
db.set_session_title(sid, sid)
|
||||
db.append_message(sid, role="user", content="Not a desktop conversation")
|
||||
db.create_session(f"{profile}-blank", source="desktop")
|
||||
db.create_session(f"{profile}-archived", source="desktop")
|
||||
db.append_message(f"{profile}-archived", role="user", content="Archived")
|
||||
db._conn.execute("UPDATE sessions SET archived=1 WHERE id=?", (f"{profile}-archived",))
|
||||
db._conn.commit()
|
||||
db.close()
|
||||
|
||||
def profile_home(profile):
|
||||
if profile not in paths:
|
||||
raise HTTPException(status_code=404)
|
||||
return profile, Path(directory)
|
||||
|
||||
sessions._cron_profile_home = profile_home
|
||||
sessions._cron_default_profile = lambda: "default"
|
||||
sessions._maybe_auto_archive_for_profile = lambda profile: None
|
||||
sessions._open_session_db_for_profile = lambda profile, read_only=True: SessionDB(
|
||||
db_path=paths[profile or "default"], read_only=read_only
|
||||
)
|
||||
# The fixture stores no system/model secrets; list projection is unrelated
|
||||
# to this contract and must not import the full web-server startup path.
|
||||
sessions._strip_session_list_rows = lambda rows: None
|
||||
app = FastAPI()
|
||||
app.include_router(sessions.list_router)
|
||||
|
||||
@app.middleware("http")
|
||||
async def auth(request, call_next):
|
||||
if request.headers.get("X-Hermes-Session-Token") != "fixture-only":
|
||||
return JSONResponse({}, status_code=401)
|
||||
return await call_next(request)
|
||||
|
||||
@app.websocket("/api/ws")
|
||||
async def ws(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
server = uvicorn.Server(uvicorn.Config(app, log_level="critical"))
|
||||
thread = threading.Thread(target=server.run, kwargs={"sockets": [sock]}, daemon=True)
|
||||
thread.start()
|
||||
deadline = time.monotonic() + 20
|
||||
while not server.started:
|
||||
if not thread.is_alive() or time.monotonic() > deadline:
|
||||
raise RuntimeError("Fixture failed to start")
|
||||
time.sleep(0.01)
|
||||
print(f"READY {port}", flush=True)
|
||||
# EOF shuts down cleanly, including Windows venv launcher/worker pairs.
|
||||
sys.stdin.read()
|
||||
server.should_exit = True
|
||||
thread.join(timeout=15)
|
||||
sock.close()
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { once } from 'node:events'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { test } from 'node:test'
|
||||
import { ChromeGatewayClient } from '../../desktop/electron/chrome-bridge.mjs'
|
||||
|
||||
test('real FastAPI/SQLite: valid bounded desktop pages, profile scope and empty states', {
|
||||
skip: !process.env.AITURK_TEST_PYTHON && 'Set AITURK_TEST_PYTHON to run the real API contract',
|
||||
timeout: 90000,
|
||||
}, async t => {
|
||||
// No credentials/config inherited by the disposable Python service.
|
||||
const env = Object.fromEntries(['PATH', 'SYSTEMROOT', 'TEMP', 'TMP', 'USERPROFILE', 'LOCALAPPDATA', 'APPDATA']
|
||||
.filter(key => process.env[key]).map(key => [key, process.env[key]]))
|
||||
env.PYTHONUTF8 = '1'
|
||||
const child = spawn(process.env.AITURK_TEST_PYTHON,
|
||||
[fileURLToPath(new URL('./sessions-api-fixture.py', import.meta.url))], { env, windowsHide: true })
|
||||
const exited = once(child, 'exit')
|
||||
let stdout = '', stderr = ''
|
||||
child.stderr.on('data', chunk => { stderr += chunk })
|
||||
t.after(async () => { child.stdin.end(); await exited })
|
||||
const port = await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`Fixture startup timeout: ${stderr}`)), 45000)
|
||||
child.once('error', error => { clearTimeout(timer); reject(error) })
|
||||
child.once('exit', () => { clearTimeout(timer); reject(new Error(`Fixture exited: ${stderr}`)) })
|
||||
child.stdout.on('data', chunk => {
|
||||
stdout += chunk
|
||||
const match = stdout.match(/READY (\d+)/)
|
||||
if (match) { clearTimeout(timer); resolve(Number(match[1])) }
|
||||
})
|
||||
})
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
const old = await fetch(`${baseUrl}/api/sessions?limit=200`, { headers: { 'X-Hermes-Session-Token': 'fixture-only' } })
|
||||
assert.equal(old.status, 422, 'production validation reproduces the reported failure')
|
||||
assert.ok((await old.json()).detail.some(error => error.loc.includes('limit')))
|
||||
for (const [profile, expected, requests] of [['default', 200, 2], ['small', 3, 1], ['empty', 0, 1]]) {
|
||||
const client = new ChromeGatewayClient(async () => ({ baseUrl, wsUrl: `${baseUrl.replace('http:', 'ws:')}/api/ws`,
|
||||
token: 'fixture-only', mode: 'local', profile }), () => {})
|
||||
const paths = []
|
||||
const realRest = client.rest.bind(client)
|
||||
client.rest = path => { paths.push(path); return realRest(path) }
|
||||
try {
|
||||
const result = await client.handle({ id: 'contract', action: 'sessions.list' })
|
||||
assert.equal(result.sessions.length, expected)
|
||||
assert.equal(new Set(result.sessions.map(row => row.id)).size, expected)
|
||||
assert.ok(result.sessions.every(row => row.id.startsWith(`${profile}-desktop-`)))
|
||||
assert.equal(result.profile, profile)
|
||||
assert.equal(paths.length, requests)
|
||||
for (const [index, path] of paths.entries()) {
|
||||
const query = new URL(path, baseUrl).searchParams
|
||||
assert.equal(query.get('offset'), String(index * 100))
|
||||
assert.equal(query.get('source'), 'desktop')
|
||||
assert.equal(query.get('profile'), profile)
|
||||
}
|
||||
} finally { client.close() }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
# Generated by beforePack from NativeWatchBootstrap.cs; never commit opaque output.
|
||||
/native-watch.exe
|
||||
@@ -0,0 +1,136 @@
|
||||
# Native watch launcher — reviewable Windows candidate
|
||||
|
||||
This is an owned startup hook for the original Codex engine, not another model
|
||||
engine. Do not activate it until root has built a new packaged release and
|
||||
reviewed the evidence. No environment variable is set by build/provision tools.
|
||||
|
||||
## Build and provision
|
||||
|
||||
Normal Windows `beforePack` invokes `scripts/build-native-watch-launcher.mjs`.
|
||||
The resulting `native-watch.exe` is included in the existing codex-bridge
|
||||
extraResources directory along with Python modules. Windows hosts use the .NET Framework C# compiler. The existing Linux/Wine
|
||||
Windows-package job installs mono-mcs and libmono-system-web-extensions4.0-cil
|
||||
6.8.0.105+dfsg-3.2 from its Ubuntu 22.04 apt repository and compiles with mcs.
|
||||
Both hosts rebuild the same C# source; missing compilers or invalid source fail
|
||||
packaging and remove stale output. The generated EXE is ignored by git.
|
||||
This is a Windows artifact cross-build, not a Linux runtime launcher. The executable is a managed console bootstrap; use
|
||||
an absolute managed **python.exe**, not pythonw.exe. It checks pinned hashes of
|
||||
original binary, interpreter and launcher script. Original is checked again in
|
||||
Python immediately before use. Parent directory ACLs and the selected official
|
||||
binary hash are provisioning authority; this does not claim Authenticode trust.
|
||||
|
||||
After installing the new package, root provisions an owner-local candidate:
|
||||
|
||||
```powershell
|
||||
py -3 prepare_native_watch.py --source <packaged-codex-bridge> --destination <owned-candidate-directory> --original <official-absolute-codex.exe> --expected-sha256 <independently-verified-official-sha256> --python <managed-python.exe> --bridge-home <bridge-data-directory> --hermes-home <existing-agent-home>
|
||||
```
|
||||
|
||||
`prepare_native_watch.py` copies only the relevant resources and writes the
|
||||
adjacent native-watch.json (paths/hashes only, no keys). It never reads
|
||||
CODEX_CLI_PATH to locate the original and rejects its own executable as original.
|
||||
Root alone may later point Desktop's CODEX_CLI_PATH at this EXE. No Python argv
|
||||
are placed inside CODEX_CLI_PATH. No installed app.asar/binary modification.
|
||||
Rollback is root removing its override to restore original app startup.
|
||||
|
||||
Installed app read-only inspection (OpenAI.Codex_26.1007.2314.0):
|
||||
`.vite/build/application-network-startup-CfAQmSYg.js` tc()/Ks() constructs
|
||||
`-c` pairs before/after `app-server`, `--analytics-default-enabled`. Recognizer
|
||||
accepts that grammar plus explicit stdio forms, rejects daemon/websocket/unknown
|
||||
commands and passes those unchanged to the original. Approvals flow untouched.
|
||||
One original engine only; no extra version process at startup. Original cwd and
|
||||
args preserved except one per-child `-c openai_base_url` selecting the in-process
|
||||
bridge's ephemeral loopback `/backend-api/codex`. Normal original models and
|
||||
public member catalog reuse kopru.H. Native environment is restored after bridge
|
||||
import. Bridge logging uses stderr so stdout remains native JSONL only.
|
||||
|
||||
## Receipt, delivery and lifecycle
|
||||
|
||||
Before routing/rewrite, all three actual request values must agree:
|
||||
session-id, thread-id and prompt_cache_key. The active mux connection ID and
|
||||
realtime generation are captured then. Neither current tab nor notice data can
|
||||
create that mapping. thread/unsubscribe, realtime stop/start/closed/error and EOF
|
||||
invalidate an epoch. UI-only switches not observable in native protocol cannot
|
||||
be detected here; root must verify Desktop emits stop/unsubscribe for its switch.
|
||||
Concurrent active native threads have independent request mappings.
|
||||
|
||||
SSE `event: bekci.watch` and nonstream `_bekci_watch` are removed before native
|
||||
model text, history observers, response rewriting and response preview logging.
|
||||
Only `/v1/bekci/events` and the fixed `https://ai.turkservis.online` origin are
|
||||
accepted. The same primary route public member key authenticates POST; redirects
|
||||
are not followed. No backend keys, raw server tenant/session/history identifiers,
|
||||
or reconstructed history exist in this client.
|
||||
|
||||
Only authenticated SSE `ready` binds the receipt. Binding's legacy tenant_id,
|
||||
bekci_session_id, history_revision fields contain **local opaque scopes**, not
|
||||
literal server claims: member-key fingerprint, key+public-subscription digest,
|
||||
public-receipt revision digest. Server remains authoritative for hidden history.
|
||||
TLS plus same-key proxy ready is the receipt validity attestation. SQLite also
|
||||
locks receipt scope to its original connection/thread/generation across restarts.
|
||||
|
||||
SQLite WAL/FULL ledger commits `uncertain` before enqueue. Replays or a crash
|
||||
between commit and append are suppressed, including possible lost delivery.
|
||||
Cursor is persisted after durable admission/disposition; it is a local replay
|
||||
ack, not a server consumption API or exactly-once guarantee. Native RPC acceptance
|
||||
does not prove audio output. Replay IDs never cause automatic uncertain resend.
|
||||
Keepalives, bounded reconnect attempts, receipt expiry, lifecycle abort and
|
||||
simultaneous threads are handled. Receipt expiry invalidates its local binding.
|
||||
No raw notice text or credentials are persisted in the ledger.
|
||||
|
||||
## Evidence and exact remaining operations
|
||||
|
||||
- `.claude/sesli-client-all-tests.log`: 20 files, 203 passed, 0 failed.
|
||||
- `.claude/sesli-client-tests-red.log`: compiled Windows bootstrap lost stdout/
|
||||
stderr before fix; interactive flush subsequently exposed by real native probe
|
||||
and covered by `test_executable_flushes_interactive_stdin_before_eof`.
|
||||
- `.claude/native-launcher-proof.json`: compiled candidate, pinned official native
|
||||
initialize accepted, stdout JSON, EOF exit 0. No native voice call performed.
|
||||
- `.claude/native-launcher-candidate/native-watch.exe`: concrete provisioned
|
||||
candidate under worktree, not activated or installed.
|
||||
|
||||
Source now stamps desktop beta.30 and bridge 10. Root still must run
|
||||
normal full package CI/build/signing policy, inspect packaged resources, provision the
|
||||
new packaged candidate with trusted official SHA/managed runtime, activate only
|
||||
authorized Desktop startup, verify thread-switch notifications and exercise a
|
||||
real Sesli-Hizli-Zeki call through deployed server to audible appendSpeech. The bootstrap assigns itself to a non-inheritable KILL_ON_JOB_CLOSE Windows
|
||||
Job Object before Python starts. Its descendants inherit membership; abrupt
|
||||
bootstrap termination closes the handle and kills only that owned tree. Owned
|
||||
fake and pinned original initialize/kill tests passed for both Windows-built and
|
||||
Linux-built EXEs running on Windows. Actual Desktop UI termination remains a
|
||||
manual acceptance step. Atomic generation enforcement inside native appendSpeech
|
||||
is still unavailable (the native RPC has no expected-generation field).
|
||||
No commit, push, deployment, installed runtime change or AITURK delegation.
|
||||
|
||||
## 2026-10-12 source verification update
|
||||
|
||||
- `.claude/native-watch-linux-build.log`: exact CI image digest
|
||||
`sha256:b226b27b1a7e95ad25eb3d6414ea90c56c04eb6d8329967f4d721dce50b989ff`,
|
||||
real apt dependencies, two compiler regressions passed, Windows PE produced.
|
||||
- `.claude/native-watch-windows-build.log`: same two tests passed with Framework csc.
|
||||
- `.claude/native-watch-linux-artifact-windows-tests.log`: 25 tests passed with
|
||||
the cross-built artifact, including pinned original initialize and abrupt kill.
|
||||
- `.claude/sesli-client-release-all-tests.log`: 220 passed, 21 files, zero failures.
|
||||
- `.claude/electron-beta30-tests.log`: 47 passed including Python/Electron version alignment.
|
||||
|
||||
Receipt validation accepts up to 24 hours plus 60 seconds clock skew. Actual
|
||||
expiry remains authoritative; expired, boolean, nonnumeric, NaN, infinite and
|
||||
over-bound receipts are rejected. Long research has no local 15-minute limit.
|
||||
|
||||
Provision the exact rebuilt candidate with the explicit original SHA-256
|
||||
`d13914ced6c7af174d8d638db938284231227312104b06a25e9608c632a7daec` only
|
||||
when that is the selected independently verified official original. SHA-1 is
|
||||
not the executable integrity pin; a Git source identity and an executable
|
||||
SHA-256 have different roles. Fixed-origin TLS plus the same public member key
|
||||
and authenticated ready event attest the receipt; none claims Authenticode.
|
||||
No full installer build, signing, activation, or audible acceptance is claimed.
|
||||
|
||||
CI verify explicitly runs test_bekci_watch.py and test_native_speech_mux.py using
|
||||
ci-venv pytest in addition to the unchanged unittest suite. Both focused files
|
||||
pass on Linux without skips. Tests that execute native-watch.exe reside in the
|
||||
explicitly Windows-only test_native_watch_cleanup.py; their owned-process proof
|
||||
is Windows evidence, not Linux CI runtime coverage.
|
||||
|
||||
Sideband JSON extraction and SSE filtering apply only to the resolved exact
|
||||
Sesli-Hizli-Zeki target, including when no watch owner is active. Other models
|
||||
retain their original Jev/response pipeline without the sideband frame buffer.
|
||||
After the final scope correction, rebuild/provision from final source; the older
|
||||
local candidate hash proof does not include this last kopru.py change.
|
||||
@@ -0,0 +1,89 @@
|
||||
// Thin Windows CLI_PATH executable. Runtime and original are owner-provisioned,
|
||||
// absolute, SHA-pinned; never consult PATH or CODEX_CLI_PATH.
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Web.Script.Serialization;
|
||||
class NativeWatchBootstrap {
|
||||
// Assign this bootstrap BEFORE creating Python. Descendants inherit job
|
||||
// membership atomically; the non-inheritable handle belongs only to us.
|
||||
// Windows closes it even on TerminateProcess, killing Python and native.
|
||||
static IntPtr ownedJob;
|
||||
[StructLayout(LayoutKind.Sequential)] struct BasicLimits {
|
||||
public long ProcessTime, JobTime;
|
||||
public uint Flags;
|
||||
public UIntPtr MinWorkingSet, MaxWorkingSet;
|
||||
public uint ActiveProcesses;
|
||||
public UIntPtr Affinity;
|
||||
public uint Priority, Scheduling;
|
||||
}
|
||||
[StructLayout(LayoutKind.Sequential)] struct IoCounters {
|
||||
public ulong ReadOps, WriteOps, OtherOps, ReadBytes, WriteBytes, OtherBytes;
|
||||
}
|
||||
[StructLayout(LayoutKind.Sequential)] struct ExtendedLimits {
|
||||
public BasicLimits Basic;
|
||||
public IoCounters Io;
|
||||
public UIntPtr ProcessMemory, JobMemory, PeakProcessMemory, PeakJobMemory;
|
||||
}
|
||||
[DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
|
||||
static extern IntPtr CreateJobObject(IntPtr attributes, string name);
|
||||
[DllImport("kernel32.dll", SetLastError=true)]
|
||||
static extern bool SetInformationJobObject(IntPtr job, int kind, ref ExtendedLimits limits, uint size);
|
||||
[DllImport("kernel32.dll", SetLastError=true)]
|
||||
static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
|
||||
static void OwnProcessTree() {
|
||||
ownedJob=CreateJobObject(IntPtr.Zero,null);
|
||||
var limits=new ExtendedLimits(); limits.Basic.Flags=0x2000; // KILL_ON_JOB_CLOSE
|
||||
if(ownedJob==IntPtr.Zero || !SetInformationJobObject(ownedJob,9,ref limits,(uint)Marshal.SizeOf(typeof(ExtendedLimits)))
|
||||
|| !AssignProcessToJobObject(ownedJob,Process.GetCurrentProcess().Handle)) throw new Exception();
|
||||
// Do not close here or in finally: the bootstrap is itself a member.
|
||||
// The OS closes this handle at exit, preserving our native exit code.
|
||||
}
|
||||
static string Quote(string s) {
|
||||
var b=new StringBuilder("\""); int slashes=0;
|
||||
foreach(char c in s) {
|
||||
if(c=='\\') {slashes++; continue;}
|
||||
if(c=='"') { b.Append('\\',slashes*2+1); b.Append(c); }
|
||||
else {b.Append('\\',slashes); b.Append(c);}
|
||||
slashes=0;
|
||||
}
|
||||
b.Append('\\',slashes*2); b.Append('"'); return b.ToString();
|
||||
}
|
||||
static string Pin(Dictionary<string,object> c,string field) {
|
||||
string p=(string)c[field], expected=(string)c[field+"_sha256"];
|
||||
if(!Path.IsPathRooted(p) || String.Equals(Path.GetFullPath(p),Process.GetCurrentProcess().MainModule.FileName,StringComparison.OrdinalIgnoreCase)) throw new Exception();
|
||||
using(var f=File.OpenRead(p)) using(var sha=SHA256.Create()) {
|
||||
string actual=BitConverter.ToString(sha.ComputeHash(f)).Replace("-","").ToLowerInvariant();
|
||||
if(actual!=expected.ToLowerInvariant()) throw new Exception();
|
||||
}
|
||||
return p;
|
||||
}
|
||||
static void Pump(Stream source, Stream target) {
|
||||
byte[] buffer=new byte[8192]; int count;
|
||||
while((count=source.Read(buffer,0,buffer.Length))>0) { target.Write(buffer,0,count); target.Flush(); }
|
||||
}
|
||||
static int Main(string[] args) {
|
||||
try {
|
||||
string manifest=Path.ChangeExtension(Process.GetCurrentProcess().MainModule.FileName,".json");
|
||||
var c=new JavaScriptSerializer().Deserialize<Dictionary<string,object>>(File.ReadAllText(manifest));
|
||||
string original=Pin(c,"original");
|
||||
string python=Pin(c,"python");
|
||||
string script=Pin(c,"script");
|
||||
OwnProcessTree();
|
||||
var argv=new List<string>{"-u",script,manifest}; argv.AddRange(args);
|
||||
var quoted=new List<string>(); foreach(var a in argv) quoted.Add(Quote(a));
|
||||
var p=Process.Start(new ProcessStartInfo(python,String.Join(" ",quoted.ToArray())) { UseShellExecute=false, CreateNoWindow=true, RedirectStandardInput=true, RedirectStandardOutput=true, RedirectStandardError=true });
|
||||
var input=new Thread(()=> { try { Pump(Console.OpenStandardInput(),p.StandardInput.BaseStream); p.StandardInput.Close(); } catch(IOException) {} });
|
||||
input.IsBackground=true; input.Start();
|
||||
var output=new Thread(()=>Pump(p.StandardOutput.BaseStream,Console.OpenStandardOutput()));
|
||||
var error=new Thread(()=>Pump(p.StandardError.BaseStream,Console.OpenStandardError()));
|
||||
output.Start(); error.Start();
|
||||
p.WaitForExit(); output.Join(); error.Join(); return p.ExitCode;
|
||||
} catch { Console.Error.WriteLine("Owned native launcher integrity/configuration failure."); return 78; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
# AITURK IDE Codex bridge
|
||||
|
||||
Local HTTP proxy that AITURK IDE's **Settings → Codex Bağlama** runs for OpenAI Codex
|
||||
(desktop or CLI) on the same computer. Standard-library Python only; it runs on the
|
||||
agent venv's interpreter and keeps running after the IDE window closes, because
|
||||
Codex depends on it while `openai_base_url` points here.
|
||||
|
||||
- `bridge.py`: the proxy. Subscription models go to chatgpt.com unchanged; `ide/<id>`
|
||||
models go to the AITURK gateway configured in the agent's `config.yaml`, with the
|
||||
key read from the agent's `.env` on every catalog refresh (never written elsewhere).
|
||||
- `ide_catalog.py`: which IDE models and combos are added to Codex's model list and
|
||||
how they are named (`provider/model`, combos by bare name).
|
||||
The public `/models` catalog supplies every OmniRoute chat model; a member does
|
||||
not need a private local model allowlist. `/router/catalog` supplies only the
|
||||
`kind` metadata used for sorting, never additional model IDs. Direct 9Router
|
||||
entries and internal 9Router combos are excluded. Codex's own subscription models
|
||||
stay first, in their own order and under their own names; added entries follow
|
||||
them: combos A–Z, then individual models by provider and name. A saved model
|
||||
selection remains unchanged. A failed or empty refresh keeps the last successful catalog.
|
||||
- `ozet_kopru.py`: conversation summaries are produced as plain text, so a summary made
|
||||
by one provider stays readable after the user switches to another.
|
||||
- `gemini_context.py`, `resim_context.py`: request fixes for routed models.
|
||||
|
||||
Run: `python bridge.py --home <data dir> --hermes-home <agent home>`. The IDE writes
|
||||
`<data dir>/bridge.json` and reads `GET /aiturk/codex-baglama/status`;
|
||||
`POST /aiturk/codex-baglama/stop` ends the process.
|
||||
|
||||
Tests: `python -m unittest discover` in this directory.
|
||||
|
||||
## Version 11: isolated routes and cross-provider preparation
|
||||
|
||||
- Pure subscription chats and native voice retain their ChatGPT route. Added
|
||||
models use the separate AITURK worker; model selection never globally changes
|
||||
the provider for another chat.
|
||||
- Cross-provider history repairs custom tool IDs and unwraps old AITURK summaries
|
||||
before sending them to OpenAI. Real OpenAI encrypted compaction is interpreted
|
||||
by OpenAI before transfer; unreadable context stops the transfer instead of
|
||||
silently dropping it. The saved transcript is not rewritten.
|
||||
- Old context above 70% of the target window is summarized through AITURK, with
|
||||
instructions, tools, recent messages and complete tool pairs protected. Output
|
||||
allowance is reserved. Unknown windows use a conservative guard. Summaries are
|
||||
approximate; if protected content alone cannot fit, a visible error keeps the
|
||||
source history intact. No middle-of-message truncation is used by this path.
|
||||
- Preparation emits SSE keep-alive comments while it waits. The status version
|
||||
matches the IDE, avoiding needless healthy-process restarts on reconnect.
|
||||
- Both HTTP listeners accept a burst queue of 128 instead of Python 3.11's five;
|
||||
disk logging and transfer records no longer share the request-counter lock.
|
||||
This is local capacity, not a change to any provider's rate or account limits.
|
||||
- The catalog cap is 1000; native entries retain their order, followed by combos
|
||||
A–Z and provider/model A–Z. The official desktop picker owns submenus: this
|
||||
bridge does not patch the Codex app or promise provider folders.
|
||||
- Turning Codex Link off restores its owned config line. If that write fails,
|
||||
the working bridge remains alive and the UI does not claim restoration.
|
||||
|
||||
Acceptance covers socket routing (including native voice endpoints), 50 concurrent
|
||||
synthetic requests, tool-ID regression, context budgets and summary failures.
|
||||
Microphone/audio and public installer acceptance are separate release checks.
|
||||
|
||||
## Version 8 (IDE beta.27): changing the model by saying so
|
||||
|
||||
Measured on a live voice chat (11 Oct 2026): a model picked on the phone reaches this machine only with a typed
|
||||
message that STARTS a turn; a message that joins a running turn (including the lone `.`) does not carry it.
|
||||
`model_gecis.py` therefore handles the change in the bridge. A short user message such as "modeli gemini flash low
|
||||
yap" (spoken through the voice assistant or typed) is matched against the model list; the chat's requests are then
|
||||
routed to that model (`model-yonlendirme.json`, keyed by `prompt_cache_key`) until Codex sends a different model
|
||||
for the chat, i.e. until the user picks another one in the list.
|
||||
|
||||
- Idle chat: the bridge answers itself ("Sohbetin modeli artık X ..."), which also tells the voice assistant.
|
||||
- Running turn: the command is replaced by a handover note and the SAME turn continues on the new model.
|
||||
- "seçili model hangisi" / "ana model kim" is answered by the bridge with the model actually in use.
|
||||
- An ambiguous name changes nothing and lists the closest names; a name matching no model is not a command.
|
||||
- A lone `.` that joins a running turn is dropped instead of answered (answering it cut the work short).
|
||||
- Handled commands left in the history are turned into notes so a later model does not answer them again.
|
||||
|
||||
Setting: `sesle_model` (default on).
|
||||
|
||||
Audit follow-up (11 Oct 2026, same version number; `GET .../status` now also returns `kod`, the first 8 hex digits
|
||||
of `bridge.py`'s SHA-256, to tell builds apart):
|
||||
|
||||
- Numbers said as words ("opus beş beş", "üç nokta sekiz") and a trailing "ve devam et" no longer drop the command.
|
||||
- Picking the same model again in the list does not clear a spoken route (Codex sends the same name), so the
|
||||
bridge's answer says how to go back. A route that is in use no longer expires after 7 days.
|
||||
- A command is turned into a "model was changed" note only if it took effect; a mid-turn note also reaches a
|
||||
subscription model when hook messages follow it.
|
||||
- Combos: a Claude member selects the stricter token estimate; one unknown member no longer switches the overflow
|
||||
guard off for a known small member; a combo dropped by the peer after the request was sent is not resent.
|
||||
- An old `9r/…` name without a route gets a local 503 instead of going to chatgpt.com; upstream error bodies
|
||||
reach Codex whole, with `Retry-After`.
|
||||
- The last model list is also served when chatgpt.com cannot be reached at all, and is cached when the answer
|
||||
was gzip-compressed. The old `display_map` setting is no longer read (subscription models keep their names).
|
||||
- Log lines carry the date; the access-token tail is no longer logged.
|
||||
|
||||
### Work handoff instruction (prepared, off by default)
|
||||
|
||||
`is_hatti.py`: when `aiturk_hat` is enabled and has a `komut`, the bridge prepends a developer message ("small work
|
||||
yourself; larger work goes to AITURK via this command") to requests whose `originator` header is `Codex Desktop`.
|
||||
The Codex CLI (`codex-tui`, `codex_exec`) never receives it and nothing is written to any file it reads. Each new
|
||||
`originator` value is logged once (`istemci goruldu`).
|
||||
|
||||
## Version 7 (IDE beta.26): external models behave like subscription models
|
||||
|
||||
Measured on 10 Oct 2026 with the unmodified Codex binary (`codex exec`, same conversation, model switched per turn).
|
||||
|
||||
| Area | Before | Now |
|
||||
| --- | --- | --- |
|
||||
| Context window | every added model declared 272 000 | `pencere.py`: NanoGPT's own list for `nano/*`, a local table for `cc`/`cx`/`ag`, a combo declares its smallest member, unknown = 128 000, nothing above 272 000 (the summarizer's window). Codex then compacts by itself when switching to a smaller model. |
|
||||
| Overflow | the target answered with an empty stream; Codex retried five times | `pencere.sigdir` trims the middle of oversized old parts in the outgoing copy; if it still does not fit Codex gets `context_length_exceeded`. A limit reported by the target is learned (`pencere-ogrenilen.json`). |
|
||||
| Tools | clones inherited `code_mode_only` + Responses Lite; GLM and Qwen failed 0/3 on simple tasks (empty answers, reasoning leaked into the answer) | `dis_arac_modu: "direct"` (default): classic tools. GLM 3/3, Qwen 2/3; Claude, `cx` and Gemini unchanged (6/6). `"kalip"` restores the old behaviour. |
|
||||
| Intermediate messages | external assistant messages had no `phase`; Codex treats those as final answers | `SseRewriter` stamps `commentary` / `final_answer`. Deltas are never delayed; only the item's `done` event waits for what follows. |
|
||||
| Interrupted answer | text written before a stop or an app exit was lost | `yarim.py` keeps it (memory only) when the *client* closes the stream and adds it as a note to the next user message of the same conversation, once. |
|
||||
| Images | removed from every old message for all external models | kept for vision-capable targets (user messages, newest first, 1 MB total). |
|
||||
| Summary brake | the 4th summary in 10 minutes failed the turn | three summaries in a row with no normal request in between, or eight in 10 minutes. |
|
||||
| Gateway errors | `event: error` (e.g. `upstream 402`) looked like a cut stream | permanent ones become `response.failed` with a readable message; short upstream bodies are logged. |
|
||||
| Start-up | the model list was fetched before listening; a slow gateway made the IDE give up after 10 s | window data comes from the cache at start and is refreshed in the background; the IDE waits 45 s. |
|
||||
|
||||
Not changed: subscription models, their order and names, and everything on the voice path (`/realtime/*` passes through untouched).
|
||||
|
||||
Settings in `bridge.json` (all optional): `dis_arac_modu`, `tasma_bekcisi`, `yarim_cevap`, `ozet_kopru.dongu_siniri`, `ozet_kopru.dongu_art_arda`.
|
||||
|
||||
A lone `.` sent as the user's message (used to apply a model change during a voice session) is answered by the bridge itself with "<model> seçildi." (the target is not probed, so it does not say "ready") and never reaches a model (`nokta_karsila`, default on). Targets that reject tool calls are remembered (`aracsiz-modeller.json`) and marked "araçsız" in the picker.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Codex sesli entegrasyon incelemesi — 12 Ekim 2026
|
||||
|
||||
## Uygulanan değişiklik ve doğrulama
|
||||
|
||||
Çözülmüş hedef tam `Sesli-Hizli-Zeki` ise nihai metin Jev'e gönderilmez. Büyük/küçük harf, boşluk, `ide/` öneki veya sonek normalizasyonu yapılmaz. `ide/` kullanıcı slug'ı önce mevcut mapping ile hedefe çözülür. Taşıma hatası, boş yanıt ve kopan akışın katman kurtarması korunur; yedek katmana geçince muafiyet taşınmaz. Araç/commentary hazırlık bypass'ı korunur. İstisna yanıt tamponlamasını değiştirmez.
|
||||
|
||||
`test_jev_denetim.py` exact hedefi, benzer isimleri ve fallback katmanının tekrar denetlenmesini çalıştırır. `test_denetim_ek.py` gerçek `_responses` metodu üzerinden başarılı yanıt, bağlantı kopması ve HTTP 400 yollarında çözülmüş hedef aktarımını, `instructions` ve developer mesajının korunmasını doğrular. Bunlar mevcut handler test düzeneğini kullanır; canlı sağlayıcı testi değildir.
|
||||
|
||||
Paket README'sindeki komutla `py -3 -m unittest discover`: **185 test, 25.305 saniye, OK**. Mevcut testlerde ResourceWarning ve beklenen yerel bağlantı reddi çıktısı vardı. İlk yeni test eski imzada hata verdi. Ayrıca muafiyet fonksiyonu geçici mock ile False yapıldığında yeni test gerçek davranış farkıyla başarısız oldu: özgün cevap yerine Katman-1 cevabı geldi (`MUTATION_DETECTED=True`).
|
||||
|
||||
## Gerçek Codex ses yolu
|
||||
|
||||
İncelenen istemci: WindowsApps `OpenAI.Codex_26.1007.2314.0_x64__2p2nqsd0c76g0`, `app/resources/app.asar`, salt okunur. CLI: `codex-cli 0.155.1`. `codex app-server generate-ts --experimental --out <geçici-dizin>` ile yerel binary protokolü üretildi.
|
||||
|
||||
Protokolün `ClientRequest.ts` dosyasında gerçek yöntemler:
|
||||
|
||||
- `thread/realtime/appendText`: `{threadId, text, role}`; role = user/developer/assistant. Girdi ekler; tek başına ses üretimini garanti ettiği iddia edilmez.
|
||||
- `thread/realtime/appendSpeech`: `{threadId, text}`; açıklaması “append speakable text to thread realtime”.
|
||||
- `thread/realtime/start`: thread kapsamlı oturum; websocket, webrtc veya existingCall taşıması. `clientManagedHandoffs` varsayılan false; automatic Codex response handoff seçenekleri var. Başka oturumun existingCall bağlantısı kullanılmadı.
|
||||
- `turn/start`, `turn/steer`, `thread/queue/add`, `thread/queue/start` var. `turn/steer` aktif `expectedTurnId` ister; bitmiş tura uygulanacak genel uyandırma yolu değildir. `codex queue --thread ... --message ...` mevcut CLI yüzeyidir; kuyruğa koymanın ses çıkışıyla aynı şey olduğu doğrulanmadı.
|
||||
|
||||
İstemcinin `webview/assets/app-initial-b8e1c8014bb4.js` dosyasında `cra` ve `lra`, sırasıyla appendText/appendSpeech RPC'sini `manager.sendRequest` ile çağırıyor. Realtime runtime aynı yöntemleri dışarı sunuyor; durdurulmuş durumda no-op. **Somut arka plan tüketicisi:** kullanım limiti yaklaşma bildirimi geldiğinde istemci `appendSpeech` çağırıp uyarıyı sesli söylüyor (`[Composer] failed to speak realtime usage warning` hata kolu). Bu, kullanıcı yeni mesaj yazmadan konuşma istemenin mevcut istemci mekanizması olduğunu kanıtlar. Bekçi düzeltmesinin canlı ses çıkışı test edilmiş değildir.
|
||||
|
||||
`CompilerIntrinsics-87ea46a69911.js` içinde WebRTC data channel üzerinden `conversation.item.create` gönderimi de var. Aynı aramada `response.created` olay işleyicisi görüldü; bunu `response.create` gönderim kanıtı saymıyoruz. Ham Realtime protokolü icat etmek yerine mevcut thread realtime RPC'si entegrasyon adayıdır.
|
||||
|
||||
## Köprüdeki somut eksik
|
||||
|
||||
`bridge.py:H._isle` yalnız `/responses` ve model listesini alt köprüye yollar; diğer yollar, `/realtime/calls` dahil, doğrudan ChatGPT'ye gider. WebSocket upgrade 426 ile reddedilir. Dolayısıyla `kopru.py:H._passthrough` içindeki isteğe bağlı `voice_prep.baslat(Location, headers, ...)` kancası standart ayırıcı topolojisinde çağrı oluşturmayı göremez.
|
||||
|
||||
`voice_prep.py` ve `ses_tuneli.py` kaynak worktree ve kurulu AITURK resources/codex-bridge içinde yok. Yerel aramada `Temp/oldb/voice_prep.py` yedek kopyası bulundu ve yalnız okundu: `Hazirlayici` aynı call'a `wss://api.openai.com/v1/live/` sideband açıyor; `session.context.append` ile bağlam ekliyor. Bu eski dosyada response.create/conversation.item.create gönderimi bulunmadı. Yedek dosya kurulu/aktif bileşen kanıtı değildir; çalıştırılmadı veya kopyalanmadı. `ses_tuneli` bulunamadı.
|
||||
|
||||
## Bekçi entegrasyonunda kalanlar
|
||||
|
||||
Diğer ajanın auth + session + cursor notices SSE sözleşmesi henüz bu çalışmaya ulaşmadı. Sözleşme sonrası bildirimin doğrulanmış session kimliğini doğru Codex host/thread ve canlı realtime ömrüne bağlamak gerekiyor. SSE yeniden bağlanma/cursor tekrarlarında aynı düzeltmeyi iki kez seslendirmeme, bitmiş ses oturumuna göndermeme ve yeni kullanıcı konuşmasıyla çakışmayı ele alma davranışları kabul testine dahil edilmeli. Bunlar uygulanmış sözleşme değildir.
|
||||
|
||||
Yalnız bize ait deney oturumunda, ilk ses cevabı bittikten sonra bir notice üretilip mevcut app-server yetkili bağlantısından `thread/realtime/appendSpeech` ile düzeltme konuşmasının başlaması; başka thread'in etkilenmemesi; yeni kullanıcı mesajı gerekmemesi gözlenmeli. Bu canlı test yapılmadan “entegrasyon tamamlandı” denemez. Hermes masaüstü TTS/voice hook'ları bu Codex kanalının yerine kullanılmadı. İmzalı istemciye API eklenmedi.
|
||||
|
||||
## Kokla / Codex alt ajan yolu
|
||||
|
||||
Mevcut yerel `C:/Users/maste/.codex/skills/codex-kokla-ajan/SKILL.md` açıkça `ide/GPT-6-Astra` CLI slug'ını, `GPT-6-Astra` OmniRoute combo kimliğini belirtiyor. `gpt-6-astra` tek modelini combo yerine kullanmayı yasaklıyor. Burada katalog yeniden sorgulanmadı; bilgi skill'in kaydıdır.
|
||||
|
||||
Mevcut CLI yolu: `codex exec --model 'ide/GPT-6-Astra' --color never -C '<ayrı-worktree>' -o '<sonuç>' '<görev>'`. `--json` süreç olaylarını, `exec resume` düzeltme devamını destekliyor. Skill ana ajana süreç kimliği, hedef, kalanlar, sonuç dosyası kaydı; çıktı takibi; bağımsız diff/test kontrolü; sıkıştırma sonrası kayıt ve kanıt okuma sorumluluğu veriyor. Bu görevde yeni bir CLI alt ajanı başlatılmadı. Runtime'ın sunulan `spawn_agent` aracı çağrısı `unsupported call: spawn_agent` döndü; bu nedenle kullanılabilir canlı alt ajan oluşturduğu iddia edilmez.
|
||||
|
||||
`C:/Users/maste/.codex/hooks/codex_continuity.py` save işlemi session/cwd kapsamlı; PreCompact yedekliyor, SessionStart/UserPromptSubmit geri yüklüyor, Stop aktif kayıtta devam uyarısı veriyor. Tamamlanma için kalan iş olmaması ve kanıt dosyası şartı var. Bu kayıt bağımsız test yerine geçmez. Runtime plugin CLI add/list/remove sağlar; istemcinin özel JS fonksiyonlarına genel plugin erişimi kanıtlanmadı. AITURK IDE devri yapılmadı.
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Internet aramasi ve sayfa okuma: Codex'e sonradan eklenen modeller icin (11 Ekim 2026).
|
||||
|
||||
Codex'in yerlesik web aramasi OpenAI sunucusunda calisir ve yalniz Codex'in kendi modellerine verilir; kopru onu
|
||||
ek modele giden arac listesinden atar. Ek model aramayi bu betigi kabuk araciyla calistirarak yapar (internet.py
|
||||
modele komutu soyler). Anahtar gerekmez; yalniz standart kitaplik.
|
||||
|
||||
python ara.py ara "sorgu" [--adet 8]
|
||||
python ara.py oku <adres> [--en-cok 8000]
|
||||
"""
|
||||
import argparse
|
||||
import html
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"
|
||||
ARAMA = "https://html.duckduckgo.com/html/?"
|
||||
SURE = 25
|
||||
EN_COK_BAYT = 3_000_000
|
||||
|
||||
|
||||
def indir(adres, sure=SURE):
|
||||
adres = urllib.parse.quote(adres, safe=":/?#[]@!$&'()*+,;=%~") # Turkce harfli adres ("/wiki/Türkiye")
|
||||
istek = urllib.request.Request(adres, headers={"User-Agent": UA, "Accept-Language": "tr,en;q=0.8"})
|
||||
with urllib.request.urlopen(istek, timeout=sure) as yanit:
|
||||
ham = yanit.read(EN_COK_BAYT)
|
||||
kodlama = yanit.headers.get_content_charset() or "utf-8"
|
||||
tur = yanit.headers.get_content_type()
|
||||
try:
|
||||
return ham.decode(kodlama, "replace"), tur
|
||||
except LookupError:
|
||||
return ham.decode("utf-8", "replace"), tur
|
||||
|
||||
|
||||
def _duz(parca):
|
||||
return re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", "", parca))).strip()
|
||||
|
||||
|
||||
def sonuclari_coz(sayfa, adet=8):
|
||||
"""Arama sayfasindan [(baslik, adres, ozet)]."""
|
||||
cikti = []
|
||||
for blok in re.split(r'<div[^>]+class="[^"]*\bresult\b[^"]*"', sayfa)[1:]:
|
||||
bag = re.search(r'<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>(.*?)</a>', blok, re.S)
|
||||
if not bag:
|
||||
continue
|
||||
adres = html.unescape(bag.group(1))
|
||||
ic = re.search(r"[?&]uddg=([^&]+)", adres)
|
||||
if ic:
|
||||
adres = urllib.parse.unquote(ic.group(1))
|
||||
if adres.startswith("//"):
|
||||
adres = "https:" + adres
|
||||
if "duckduckgo.com/y.js" in adres: # reklam
|
||||
continue
|
||||
ozet = re.search(r'class="result__snippet"[^>]*>(.*?)</a>', blok, re.S)
|
||||
cikti.append((_duz(bag.group(2)), adres, _duz(ozet.group(1)) if ozet else ""))
|
||||
if len(cikti) >= adet:
|
||||
break
|
||||
return cikti
|
||||
|
||||
|
||||
def metne_cevir(sayfa):
|
||||
"""HTML -> okunur duz metin (betik, bicim ve gezinme kisimlari atilir)."""
|
||||
sayfa = re.sub(r"(?is)<(script|style|noscript|svg|nav|footer|header|form|iframe)\b.*?</\1>", " ", sayfa)
|
||||
sayfa = re.sub(r"(?is)<!--.*?-->", " ", sayfa)
|
||||
sayfa = re.sub(r"(?i)<(br|/p|/div|/li|/h[1-6]|/tr)\b[^>]*>", "\n", sayfa)
|
||||
metin = html.unescape(re.sub(r"<[^>]+>", " ", sayfa))
|
||||
satirlar = [re.sub(r"[ \t\r\f\v]+", " ", s).strip() for s in metin.split("\n")]
|
||||
return "\n".join(s for s in satirlar if s)
|
||||
|
||||
|
||||
def ara(sorgu, adet=8, indirici=indir):
|
||||
sayfa, _ = indirici(ARAMA + urllib.parse.urlencode({"q": sorgu}))
|
||||
sonuc = sonuclari_coz(sayfa, adet)
|
||||
if not sonuc:
|
||||
if 'no-results' in sayfa.lower() or 'no results found' in sayfa.lower():
|
||||
return "Sonuç bulunamadı. En fazla bir farklı sorgu deneyebilirsin."
|
||||
# DDG bot kontrolünü/bozuk HTML'yi sonuç yok diye göstermeyiz. Tek yedek istek.
|
||||
try:
|
||||
rss, _ = indirici('https://www.bing.com/search?' + urllib.parse.urlencode({'format': 'rss', 'q': sorgu}))
|
||||
kok = ET.fromstring(rss)
|
||||
if kok.tag != 'rss' or kok.find('channel') is None:
|
||||
raise ValueError('RSS arama yanıtı değil')
|
||||
sonuc = [(i.findtext('title') or '', i.findtext('link') or '', _duz(i.findtext('description') or ''))
|
||||
for i in kok.findall('./channel/item')
|
||||
if (i.findtext('link') or '').startswith(('https://', 'http://'))][:adet]
|
||||
except (ValueError, ET.ParseError, OSError) as hata:
|
||||
raise RuntimeError('Arama hizmeti engellendi veya yanıtı okunamadı; sorguları değiştirerek tekrar etme.') from hata
|
||||
if not sonuc:
|
||||
return "Sonuç bulunamadı. En fazla bir farklı sorgu deneyebilirsin."
|
||||
return "\n\n".join(f"{i}. {b}\n {a}\n {o}" for i, (b, a, o) in enumerate(sonuc, 1))
|
||||
|
||||
|
||||
def oku(adres, en_cok=8000, indirici=indir):
|
||||
if not re.match(r"https?://", adres):
|
||||
adres = "https://" + adres
|
||||
sayfa, tur = indirici(adres)
|
||||
metin = metne_cevir(sayfa) if "html" in tur or "<html" in sayfa[:2000].lower() else sayfa
|
||||
if len(metin) > en_cok:
|
||||
metin = metin[:en_cok] + f"\n[... kesildi: {len(metin)} karakterin ilk {en_cok} karakteri]"
|
||||
return metin or "Sayfada okunur metin bulunamadı."
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
p = argparse.ArgumentParser(description="Internet aramasi ve sayfa okuma")
|
||||
alt = p.add_subparsers(dest="is_", required=True)
|
||||
a = alt.add_parser("ara"); a.add_argument("sorgu", nargs="+"); a.add_argument("--adet", type=int, default=8)
|
||||
o = alt.add_parser("oku"); o.add_argument("adres"); o.add_argument("--en-cok", type=int, default=8000)
|
||||
s = p.parse_args(argv)
|
||||
try:
|
||||
metin = ara(" ".join(s.sorgu), max(1, min(s.adet, 20))) if s.is_ == "ara" else oku(s.adres, max(500, s.en_cok))
|
||||
kod = 0
|
||||
except Exception as e:
|
||||
metin, kod = f"Hata: {type(e).__name__}: {e}", 1
|
||||
sys.stdout.buffer.write((metin + "\n").encode("utf-8", "replace"))
|
||||
return kod
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Public Omni receipts. All scopes here are LOCAL opaque identities, never backend IDs."""
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import socket
|
||||
import sqlite3
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
from native_speech_mux import Binding, WatchReceiptAdapter
|
||||
|
||||
EVENTS_PATH = '/v1/bekci/events'
|
||||
TRUSTED_ORIGIN = 'https://ai.turkservis.online'
|
||||
# Public protocol lifetime, with bounded server/client clock skew. The server's
|
||||
# expiry remains the watch deadline; there is no shorter research timeout.
|
||||
MAX_RECEIPT_TTL = 24 * 60 * 60
|
||||
RECEIPT_CLOCK_SKEW = 60
|
||||
|
||||
def opaque(*parts):
|
||||
return hashlib.sha256('\0'.join(parts).encode()).hexdigest()
|
||||
|
||||
class Frames:
|
||||
def __init__(self):
|
||||
self.buf = b''
|
||||
def feed(self, chunk):
|
||||
self.buf += chunk
|
||||
if len(self.buf) > 262144:
|
||||
raise ValueError('SSE frame too large')
|
||||
while True:
|
||||
# Normalize only complete CRLF, including a CR split across reads.
|
||||
self.buf = self.buf.replace(b'\r\n', b'\n')
|
||||
if b'\n\n' not in self.buf:
|
||||
return
|
||||
frame, self.buf = self.buf.split(b'\n\n', 1)
|
||||
event, data, cursor = '', [], None
|
||||
for line in frame.decode('utf-8').split('\n'):
|
||||
field, _, value = line.partition(':')
|
||||
value = value.removeprefix(' ')
|
||||
if field == 'event': event = value
|
||||
elif field == 'data': data.append(value)
|
||||
elif field == 'id': cursor = value
|
||||
yield event, '\n'.join(data), cursor, frame + b'\n\n'
|
||||
|
||||
class MetadataFilter:
|
||||
"""Strip sideband before rewrites, logging or transcript observers."""
|
||||
def __init__(self, accept):
|
||||
self.frames, self.accept = Frames(), accept
|
||||
def feed(self, chunk):
|
||||
out = []
|
||||
for event, data, _, raw in self.frames.feed(chunk):
|
||||
if event == 'bekci.watch':
|
||||
try: self.accept(json.loads(data))
|
||||
except (ValueError, TypeError): pass
|
||||
else: out.append(raw)
|
||||
return b''.join(out)
|
||||
def flush(self):
|
||||
# An incomplete sideband frame must never escape into model text.
|
||||
raw, self.frames.buf = self.frames.buf, b''
|
||||
return b'' if b'bekci.watch' in raw else raw
|
||||
|
||||
class Ledger:
|
||||
"""Commit uncertain BEFORE enqueue. Crash ambiguity sacrifices delivery, never resends."""
|
||||
def __init__(self, path):
|
||||
self.db = sqlite3.connect(path, check_same_thread=False)
|
||||
self.lock = threading.Lock()
|
||||
self.db.execute('PRAGMA journal_mode=WAL')
|
||||
self.db.execute('PRAGMA synchronous=FULL')
|
||||
self.db.executescript('CREATE TABLE IF NOT EXISTS delivery(scope TEXT,id TEXT,status TEXT,PRIMARY KEY(scope,id)); CREATE TABLE IF NOT EXISTS cursors(scope TEXT PRIMARY KEY,cursor TEXT); CREATE TABLE IF NOT EXISTS bindings(scope TEXT PRIMARY KEY,epoch TEXT);')
|
||||
def bind_epoch(self, scope, correlation):
|
||||
epoch = opaque(correlation.connection_id,correlation.thread_id,str(correlation.generation))
|
||||
with self.lock, self.db:
|
||||
self.db.execute('INSERT OR IGNORE INTO bindings VALUES(?,?)',(scope,epoch))
|
||||
return self.db.execute('SELECT epoch FROM bindings WHERE scope=?',(scope,)).fetchone()[0] == epoch
|
||||
def claim(self, scope, nid):
|
||||
with self.lock, self.db:
|
||||
return self.db.execute('INSERT OR IGNORE INTO delivery VALUES(?,?,?)', (scope,nid,'uncertain')).rowcount == 1
|
||||
def ack(self, scope, nid, cursor, status):
|
||||
with self.lock, self.db:
|
||||
self.db.execute('UPDATE delivery SET status=? WHERE scope=? AND id=?', (status,scope,nid))
|
||||
self.db.execute('INSERT OR REPLACE INTO cursors VALUES(?,?)', (scope,cursor))
|
||||
def cursor(self, scope):
|
||||
with self.lock:
|
||||
row = self.db.execute('SELECT cursor FROM cursors WHERE scope=?',(scope,)).fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
class WatchOwner:
|
||||
def __init__(self, mux, ledger, *, origin=TRUSTED_ORIGIN, test_loopback=False):
|
||||
u = urlsplit(origin)
|
||||
if origin != TRUSTED_ORIGIN and not (test_loopback and u.scheme == 'http' and u.hostname == '127.0.0.1'):
|
||||
raise ValueError('untrusted Omni origin')
|
||||
self.mux, self.ledger, self.origin = mux, ledger, u
|
||||
self.adapter = WatchReceiptAdapter(mux)
|
||||
self.lock, self.watches = threading.Lock(), {}
|
||||
def capture(self, headers, body):
|
||||
return self.adapter.capture(headers, body)
|
||||
def live(self, c):
|
||||
s = self.mux.snapshot(); t = s['threads'].get(c.thread_id, {})
|
||||
return (not s['closed'] and not s['injection_disabled'] and s['connection_id'] == c.connection_id
|
||||
and t.get('active') and t.get('generation') == c.generation)
|
||||
def accept(self, payload, correlation, key, route_url):
|
||||
if not correlation or not self.live(correlation): return 'stale_generation'
|
||||
route = urlsplit(route_url)
|
||||
if (route.scheme,route.netloc) != (self.origin.scheme,self.origin.netloc) or not key: return 'untrusted_route'
|
||||
if (not isinstance(payload,dict) or set(payload) != {'subscription','events_url','expires_at'}
|
||||
or payload['events_url'] != EVENTS_PATH or not isinstance(payload['subscription'],str)
|
||||
or not 20 <= len(payload['subscription']) <= 128 or type(payload['expires_at']) not in (int,float)
|
||||
or not time.time()*1000 < payload['expires_at'] <= (time.time()+MAX_RECEIPT_TTL+RECEIPT_CLOCK_SKEW)*1000): return 'invalid_receipt'
|
||||
scope = opaque('public-watch',key,payload['subscription'])
|
||||
if not self.ledger.bind_epoch(scope,correlation): return 'receipt_replay_mismatch'
|
||||
with self.lock:
|
||||
if scope in self.watches: return 'already_watching'
|
||||
if len(self.watches) >= 256: return 'capacity_exhausted'
|
||||
stop = threading.Event()
|
||||
self.watches[scope] = stop
|
||||
threading.Thread(target=self._watch,args=(dict(payload),correlation,key,scope,stop),daemon=True).start()
|
||||
return 'subscribing'
|
||||
def close(self):
|
||||
with self.lock:
|
||||
for stop in self.watches.values(): stop.set()
|
||||
def _watch(self, p, c, key, scope, stop):
|
||||
# These are local labels only. TLS+same-key authenticated ready attests
|
||||
# receipt validity; server owns literal tenant/session/history mapping.
|
||||
binding = Binding(opaque('member',key),scope,opaque('history-receipt',p['subscription']),c.connection_id,c.thread_id,c.generation)
|
||||
deadline = p['expires_at']/1000
|
||||
try:
|
||||
backoff = 1
|
||||
while not stop.is_set() and time.time() < deadline and self.live(c):
|
||||
conn = (http.client.HTTPSConnection(self.origin.hostname,self.origin.port,context=ssl.create_default_context(),timeout=10)
|
||||
if self.origin.scheme == 'https' else http.client.HTTPConnection(self.origin.hostname,self.origin.port,timeout=10))
|
||||
finished = threading.Event()
|
||||
transport, resp, guard_thread = None, None, None
|
||||
def interrupt_transport():
|
||||
# getresponse may detach conn.sock for HTTP/1.0 or
|
||||
# Connection: close. Keep our actual socket until cleanup.
|
||||
if transport is not None:
|
||||
try: transport.shutdown(socket.SHUT_RDWR)
|
||||
except OSError: pass
|
||||
def guard():
|
||||
while not finished.wait(.05):
|
||||
if stop.is_set() or time.time() >= deadline or not self.live(c):
|
||||
stop.set()
|
||||
interrupt_transport()
|
||||
return
|
||||
try:
|
||||
# Bound TCP/TLS connection establishment separately from
|
||||
# headers/stream reads: quiet research has no idle deadline.
|
||||
conn.connect()
|
||||
transport = conn.sock
|
||||
transport.settimeout(None)
|
||||
if stop.is_set() or time.time() >= deadline or not self.live(c): break
|
||||
guard_thread = threading.Thread(target=guard,daemon=True)
|
||||
guard_thread.start()
|
||||
body = {'subscription':p['subscription']}
|
||||
cursor = self.ledger.cursor(scope)
|
||||
if cursor: body['cursor'] = cursor
|
||||
conn.request('POST', EVENTS_PATH, json.dumps(body), {'Authorization':'Bearer '+key,'Content-Type':'application/json'})
|
||||
resp = conn.getresponse()
|
||||
if resp.status != 200:
|
||||
if resp.status not in (408,429) and not 500 <= resp.status < 600: break
|
||||
elif 'text/event-stream' not in resp.getheader('Content-Type',''):
|
||||
break
|
||||
else:
|
||||
parser, ready = Frames(), False
|
||||
while self.live(c) and not stop.is_set() and time.time() < deadline:
|
||||
chunk = resp.read1(4096)
|
||||
if not chunk: break
|
||||
for event,data,event_id,_ in parser.feed(chunk):
|
||||
if stop.is_set() or time.time() >= deadline or not self.live(c): break
|
||||
if not data: continue # keepalive
|
||||
value = json.loads(data)
|
||||
if event == 'ready':
|
||||
if not isinstance(value,dict) or not isinstance(value.get('cursor'),str): raise ValueError('invalid ready')
|
||||
ready = self.live(c) and self.mux.bind(binding)
|
||||
if not ready: return
|
||||
elif event == 'notice':
|
||||
if not ready: raise ValueError('notice before ready')
|
||||
self._notice(binding,scope,value,event_id,deadline,c)
|
||||
except (OSError,ValueError,http.client.HTTPException):
|
||||
pass # no credential-bearing exceptions in logs
|
||||
finally:
|
||||
finished.set()
|
||||
interrupt_transport()
|
||||
if guard_thread is not None: guard_thread.join()
|
||||
if resp is not None: resp.close()
|
||||
conn.close()
|
||||
if transport is not None: transport.close()
|
||||
if stop.is_set() or time.time() >= deadline or not self.live(c): break
|
||||
if stop.wait(min(backoff,max(0,deadline-time.time()))): break
|
||||
backoff = min(backoff*2,15)
|
||||
finally:
|
||||
with self.mux._lock:
|
||||
k = (binding.tenant_id,binding.bekci_session_id)
|
||||
if self.mux._bindings.get(k) == binding: self.mux._bindings.pop(k,None)
|
||||
with self.lock: self.watches.pop(scope,None)
|
||||
def _notice(self,b,scope,value,event_id,deadline,c):
|
||||
if not self.live(c) or not isinstance(value,dict): return
|
||||
n, cursor = value.get('notice'), value.get('cursor')
|
||||
if (not isinstance(n,dict) or not isinstance(cursor,str) or not 0 < len(cursor) <= 512
|
||||
or event_id != cursor or not isinstance(n.get('id'),str) or not 0 < len(n['id']) <= 256
|
||||
or n.get('type') not in ('duzeltme','arastirma') or not isinstance(n.get('metin'),str)
|
||||
or not 0 < len(n['metin']) <= 8000): return
|
||||
if not self.ledger.claim(scope,n['id']):
|
||||
self.ledger.ack(scope,n['id'],cursor,'replay_suppressed'); return
|
||||
now = time.time()
|
||||
notice = dict(notice_id=n['id'],tenant_id=b.tenant_id,bekci_session_id=b.bekci_session_id,
|
||||
history_revision=b.history_revision,connection_id=b.connection_id,thread_id=b.thread_id,
|
||||
expected_generation=b.generation,issued_at=now,expires_at=min(now+60,deadline),text=n['metin'])
|
||||
result = self.mux.submit(notice)
|
||||
self.ledger.ack(scope,n['id'],cursor,result['status'])
|
||||
@@ -0,0 +1,610 @@
|
||||
"""AITURK IDE "Codex Baglama" ayiricisi.
|
||||
|
||||
13 Ekim 2026 aciklamasi: abonelik ve ek modellerin saglayici yollari ayri
|
||||
kalir; gecis gecmisinin hazirlanmasi yasak degildir. Ses Codex'in yerlisik
|
||||
ses hattinda kalir. Saf abonelik sohbeti degistirilmez.
|
||||
|
||||
Codex'te tek adres ayari (`openai_base_url`) oldugu icin butun istekler buraya gelir. Bu dosya YALNIZ ayirir:
|
||||
|
||||
* Codex'in kendi modeli -> chatgpt.com'a; govde bayt bayt ayni gider, cevap geldigi gibi doner.
|
||||
* Bizim ekledigimiz model -> ayri bir surecte calisan kopruye (kopru.py). Codex'in kendi modelinin istegi
|
||||
(ide/..., 9r/..., model_map) o surece HIC gitmez.
|
||||
* Model listesi -> kopru ayaktaysa ek modelleri eklenmis liste; degilse chatgpt.com'un listesi.
|
||||
* Geri kalan her sey -> chatgpt.com'a, oldugu gibi.
|
||||
|
||||
Karisik gecmiste yerel aktarim hazirligi inference'dan ayridir. Abonelik
|
||||
inference'i her zaman CHATGPT'ye; ek model inference'i KOPRU'ye gider.
|
||||
Saf abonelik sohbetleri ve ses, ozet servisinin durumundan etkilenmez.
|
||||
|
||||
Veri klasoru (bridge.json, log/): --home <klasor> ya da AITURK_CODEX_BRIDGE_HOME. Ajan klasoru: --hermes-home.
|
||||
Yerel denetim: GET /aiturk/codex-baglama/status, POST .../stop, POST .../refresh, POST .../kopru-yenile.
|
||||
"""
|
||||
import gzip
|
||||
import hashlib
|
||||
import http.client
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import gecis_context
|
||||
import terminal_stream
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SURUM = "18" # AITURK IDE'nin bekledigi kopru surumu (codex-link-config.ts CODEX_BRIDGE_VERSION)
|
||||
try:
|
||||
with open(os.path.abspath(__file__), "rb") as _f:
|
||||
KOD = hashlib.sha256(_f.read()).hexdigest()[:8]
|
||||
except OSError:
|
||||
KOD = ""
|
||||
|
||||
|
||||
def _arg(name, env):
|
||||
"""--name <deger> komut satirindan, yoksa ortam degiskeninden."""
|
||||
if name in sys.argv[1:]:
|
||||
k = sys.argv.index(name)
|
||||
if k + 1 < len(sys.argv):
|
||||
return sys.argv[k + 1]
|
||||
return os.environ.get(env) or ""
|
||||
|
||||
|
||||
HOME = _arg("--home", "AITURK_CODEX_BRIDGE_HOME") or HERE
|
||||
HERMES = _arg("--hermes-home", "HERMES_HOME")
|
||||
try:
|
||||
with open(os.path.join(HOME, "bridge.json"), encoding="utf-8") as _f:
|
||||
CFG = json.load(_f)
|
||||
except (OSError, ValueError):
|
||||
CFG = {}
|
||||
PORT = int(CFG.get("port", 8790))
|
||||
KOPRU_PORT = int(_arg("--kopru-port", "AITURK_CODEX_KOPRU_PORT") or PORT + 1)
|
||||
CHATGPT = CFG.get("chatgpt_backend", "https://chatgpt.com").rstrip("/")
|
||||
KOPRU = f"http://127.0.0.1:{KOPRU_PORT}"
|
||||
BEKLEME = float(CFG.get("upstream_idle_timeout", 3600)) # okuma basina; uzun cevap kesilmez (28 Eylul 2026 karari)
|
||||
LOGDIR = os.path.join(HOME, "log")
|
||||
|
||||
# Codex istek govdesini zstd ile sikistirir. Model adini okumak icin bir KOPYASI acilir; giden govde degismez.
|
||||
_VENDOR = os.path.join(HERE, "vendor", "cp%d%d-win_amd64" % sys.version_info[:2])
|
||||
if os.name == "nt" and os.path.isdir(_VENDOR):
|
||||
sys.path.insert(0, _VENDOR)
|
||||
|
||||
|
||||
def _zstd_ac(govde):
|
||||
try:
|
||||
from compression import zstd as _z
|
||||
return _z.decompress(govde)
|
||||
except ImportError:
|
||||
import zstandard
|
||||
return zstandard.ZstdDecompressor().decompressobj().decompress(govde)
|
||||
|
||||
|
||||
def _zstd_hazir():
|
||||
try:
|
||||
try:
|
||||
from compression import zstd as _z
|
||||
ornek = _z.compress(b"aiturk")
|
||||
except ImportError:
|
||||
import zstandard
|
||||
ornek = zstandard.ZstdCompressor().compress(b"aiturk")
|
||||
return _zstd_ac(ornek) == b"aiturk"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# zstd yoksa model adi okunamaz ve ek modeller ayrilamaz; durum ucu ok=false der, IDE Codex'i buraya yoneltmez.
|
||||
ZSTD_OK = _zstd_hazir()
|
||||
|
||||
kilit = threading.Lock()
|
||||
log_kilit = threading.Lock()
|
||||
transfer_kilit = threading.Lock()
|
||||
sira = [0]
|
||||
|
||||
# Store only hashed conversation keys and the last external model, not messages
|
||||
# or credentials. Survives restarts so returning long chats keep their guard.
|
||||
TRANSFER_PATH = os.path.join(HOME, "gecis-sohbetler.json")
|
||||
try:
|
||||
with open(TRANSFER_PATH, encoding="utf-8") as f:
|
||||
TRANSFERS = json.load(f)
|
||||
if not isinstance(TRANSFERS, dict):
|
||||
TRANSFERS = {}
|
||||
except (OSError, ValueError):
|
||||
TRANSFERS = {}
|
||||
|
||||
|
||||
def remember_transfer(scope, model):
|
||||
with transfer_kilit:
|
||||
if TRANSFERS.get(scope) == model:
|
||||
return
|
||||
TRANSFERS[scope] = model
|
||||
while len(TRANSFERS) > 2048:
|
||||
del TRANSFERS[next(iter(TRANSFERS))]
|
||||
try:
|
||||
os.makedirs(HOME, exist_ok=True)
|
||||
with open(TRANSFER_PATH + ".tmp", "w", encoding="utf-8") as f:
|
||||
json.dump(TRANSFERS, f)
|
||||
os.replace(TRANSFER_PATH + ".tmp", TRANSFER_PATH)
|
||||
except OSError:
|
||||
pass # in-memory guard remains active
|
||||
|
||||
|
||||
def log(msg):
|
||||
satir = time.strftime("%Y-%m-%d %H:%M:%S ") + msg
|
||||
try:
|
||||
os.makedirs(LOGDIR, exist_ok=True)
|
||||
with log_kilit:
|
||||
with open(os.path.join(LOGDIR, "ayirici.log"), "a", encoding="utf-8") as f:
|
||||
f.write(satir + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def ek_model_mu(slug):
|
||||
"""Bizim ekledigimiz model mi? Degilse Codex'in kendi modelidir."""
|
||||
slug = str(slug or "")
|
||||
onek = (CFG.get("nine_router_catalog") or {}).get("prefix", "9r/")
|
||||
return bool(slug) and (slug.startswith("ide/") or bool(onek and slug.startswith(onek))
|
||||
or slug in (CFG.get("model_map") or {}))
|
||||
|
||||
|
||||
def model_oku(govde, kodlama):
|
||||
"""Istekteki model adi; okunamazsa None (o zaman istek Codex'in kendi yoluna gider). Govdeye dokunmaz."""
|
||||
try:
|
||||
kodlama = (kodlama or "").strip().lower()
|
||||
ham = _zstd_ac(govde) if kodlama == "zstd" else gzip.decompress(govde) if kodlama == "gzip" else govde
|
||||
veri = json.loads(ham)
|
||||
return veri.get("model") if isinstance(veri, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
ISTEK_ATLA = {"host", "content-length", "accept-encoding", "transfer-encoding", "connection"}
|
||||
YANIT_ATLA = {"host", "content-length", "accept-encoding", "transfer-encoding", "connection", "content-encoding"}
|
||||
|
||||
|
||||
def kopru_sor(yol, yontem="GET", sure=5.0, govde=None):
|
||||
"""Kopru surecinin denetim ucuna sorar; (durum kodu, JSON) ya da ulasilamazsa (None, None)."""
|
||||
try:
|
||||
istek = urllib.request.Request(KOPRU + yol, data=govde, method=yontem)
|
||||
with urllib.request.urlopen(istek, timeout=sure) as yanit:
|
||||
return yanit.status, json.loads(yanit.read() or b"{}")
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
|
||||
class Kopru:
|
||||
"""Bizim kopruyu (kopru.py) ayri surec olarak calistirir; duserse yeniden baslatir."""
|
||||
|
||||
def __init__(self):
|
||||
self.surec = None
|
||||
self.duruyor = False
|
||||
self.baslangic = 0.0
|
||||
|
||||
def komut(self):
|
||||
k = [sys.executable, os.path.join(HERE, "kopru.py"), "--home", HOME, "--port", str(KOPRU_PORT)]
|
||||
return k + (["--hermes-home", HERMES] if HERMES else [])
|
||||
|
||||
def baslat(self):
|
||||
self.baslangic = time.time()
|
||||
self.surec = subprocess.Popen(self.komut(), cwd=HERE, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
||||
log(f"kopru baslatildi (surec {self.surec.pid}, kapi {KOPRU_PORT})")
|
||||
|
||||
def bekci(self):
|
||||
"""Kopru duserse yeniden baslatir: hemen cokuyorsa araligi acar (2, 4, ... en cok 30 sn)."""
|
||||
kopru_sor("/aiturk/codex-baglama/stop", "POST", 2.0, b"") # onceki ayiricidan kalmis sahipsiz kopru
|
||||
time.sleep(0.5)
|
||||
ara = 2.0
|
||||
while not self.duruyor:
|
||||
try:
|
||||
self.baslat()
|
||||
kod = self.surec.wait()
|
||||
except Exception as e:
|
||||
kod = f"{type(e).__name__}: {e}"
|
||||
if self.duruyor:
|
||||
break
|
||||
omur = time.time() - self.baslangic
|
||||
ara = 2.0 if omur > 60 else min(ara * 2, 30.0)
|
||||
log(f"kopru durdu (cikis {kod}, {int(omur)} sn calisti); {int(ara)} sn sonra yeniden baslatilacak")
|
||||
time.sleep(ara)
|
||||
|
||||
def yenile(self):
|
||||
"""Yalniz kopruyu yeniden baslatir; Codex'in kendi modellerinin trafigi kesilmez."""
|
||||
if kopru_sor("/aiturk/codex-baglama/stop", "POST", 3.0, b"")[0] is None and self.surec is not None:
|
||||
try:
|
||||
self.surec.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def durdur(self):
|
||||
self.duruyor = True
|
||||
kopru_sor("/aiturk/codex-baglama/stop", "POST", 3.0, b"")
|
||||
try:
|
||||
if self.surec is not None:
|
||||
self.surec.wait(timeout=3)
|
||||
except Exception:
|
||||
try:
|
||||
self.surec.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
KOPRU_SUREC = Kopru()
|
||||
|
||||
|
||||
class H(http.server.BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def _oku(self):
|
||||
if "chunked" in (self.headers.get("Transfer-Encoding") or "").lower():
|
||||
out = bytearray()
|
||||
while True:
|
||||
ham = self.rfile.readline()
|
||||
if not ham:
|
||||
break
|
||||
satir = ham.strip()
|
||||
if not satir:
|
||||
continue
|
||||
boyut = int(satir.split(b";")[0], 16)
|
||||
if boyut == 0:
|
||||
while self.rfile.readline().strip():
|
||||
pass
|
||||
break
|
||||
out += self.rfile.read(boyut)
|
||||
self.rfile.readline()
|
||||
return bytes(out)
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
return self.rfile.read(n) if n else b""
|
||||
|
||||
def _json(self, durum, veri):
|
||||
b = json.dumps(veri).encode()
|
||||
self.send_response(durum)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(b)))
|
||||
self.end_headers()
|
||||
self.wfile.write(b)
|
||||
|
||||
def _denetim(self, yol, govde):
|
||||
son = yol.split("?", 1)[0].rstrip("/")
|
||||
if self.command == "POST" and son.endswith("/stop"):
|
||||
self._json(200, {"ok": True})
|
||||
log("AITURK IDE istegiyle kapatiliyor")
|
||||
threading.Thread(target=lambda: (KOPRU_SUREC.durdur(), time.sleep(0.2), os._exit(0)), daemon=True).start()
|
||||
return
|
||||
if self.command == "POST" and son.endswith("/kopru-yenile"):
|
||||
log("kopru yeniden baslatiliyor (istek uzerine); Codex'in kendi modelleri etkilenmez")
|
||||
KOPRU_SUREC.yenile()
|
||||
return self._json(200, {"ok": True})
|
||||
if son.endswith("/status") or (self.command == "POST" and son.endswith("/refresh")):
|
||||
kod, veri = kopru_sor(yol, self.command, 40.0 if son.endswith("/refresh") else 5.0,
|
||||
govde if self.command == "POST" else None)
|
||||
if kod == 200 and isinstance(veri, dict):
|
||||
veri.update({"ayirici": True, "ayirici_kod": KOD, "kopru": "calisiyor"})
|
||||
# IDE compares the splitter, not the older worker's version.
|
||||
veri["surum"] = SURUM
|
||||
veri["ok"] = bool(veri.get("ok")) and ZSTD_OK
|
||||
return self._json(200, veri)
|
||||
return self._json(200, {"ok": ZSTD_OK, "surum": SURUM, "kod": KOD, "zstd": ZSTD_OK, "ide_durum": "kapali",
|
||||
"ide_model": 0, "ozet": False, "is_hatti": False, "ayirici": True,
|
||||
"ayirici_kod": KOD, "kopru": "kapali"})
|
||||
return self._json(404, {"error": {"message": "unknown control path"}})
|
||||
|
||||
def _ilet(self, n, hedef, yol, govde, etiket, yedekli=False, sessiz=False, rewritten=False, started=False):
|
||||
"""Istegi hedefe OLDUGU GIBI iletir, cevabi geldigi gibi (akarak) geri verir.
|
||||
yedekli=True: cevap basliklari gelmeden bir sorun olursa hicbir sey gonderilmez ve False doner (cagiran baska
|
||||
hedefi dener). Diger durumlarda True doner."""
|
||||
tag = f"[{n} {etiket} {self.command} {yol[:70]}]"
|
||||
basliklar = {k: v for k, v in self.headers.items() if k.lower() not in ISTEK_ATLA}
|
||||
if rewritten:
|
||||
basliklar = {k: v for k, v in basliklar.items() if k.lower() != "content-encoding"}
|
||||
if self.command == "GET" and "/models" in yol:
|
||||
# Liste her seferinde tam gelsin: istemcinin elindeki onbellek damgasi iki tarafin listesini karistirir.
|
||||
basliklar = {k: v for k, v in basliklar.items() if k.lower() not in ("if-none-match", "if-modified-since")}
|
||||
istek = urllib.request.Request(hedef + yol, data=govde or None, headers=basliklar, method=self.command)
|
||||
try:
|
||||
yanit = urllib.request.urlopen(istek, timeout=20.0 if yedekli else BEKLEME)
|
||||
durum = yanit.status
|
||||
except urllib.error.HTTPError as e:
|
||||
yanit, durum = e, e.code
|
||||
except Exception as e:
|
||||
if yedekli:
|
||||
return False
|
||||
log(f"{tag} HATA {type(e).__name__}: {e}")
|
||||
if started:
|
||||
self._transfer_fail("Model bağlantısı kurulamadı; sohbet geçmişi korundu.")
|
||||
return True
|
||||
if hedef == KOPRU:
|
||||
self._json(503, {"error": {"message": "AITURK köprüsü şu an çalışmıyor; birkaç saniye sonra yeniden deneyin "
|
||||
"ya da ekrandaki seçiciden Codex'in kendi modelini seçin."}})
|
||||
else:
|
||||
self._json(502, {"error": {"message": f"baglanti kurulamadi: {e}"}})
|
||||
return True
|
||||
if yedekli and durum != 200:
|
||||
try:
|
||||
yanit.read()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
if durum >= 400:
|
||||
hata = yanit.read()
|
||||
log(f"{tag} {durum}: {hata[:300]!r}")
|
||||
if started:
|
||||
try:
|
||||
message = json.loads(hata).get("error", {}).get("message", "Model isteği reddedildi.")
|
||||
except (ValueError, AttributeError):
|
||||
message = "Model isteği reddedildi (HTTP %d)." % durum
|
||||
self._transfer_fail(message)
|
||||
return True
|
||||
self.send_response(durum)
|
||||
for k, v in yanit.headers.items():
|
||||
if k.lower() not in YANIT_ATLA:
|
||||
self.send_header(k, v)
|
||||
self.send_header("Content-Length", str(len(hata)))
|
||||
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():
|
||||
if k.lower() not in YANIT_ATLA:
|
||||
self.send_header(k, v)
|
||||
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
|
||||
except (OSError, http.client.HTTPException) as e:
|
||||
# Hedef koptu: baglanti kapatilir ki Codex yarim akisi tam sanmasin.
|
||||
log(f"{tag} hedef koptu ({toplam} bayt): {type(e).__name__}: {e}")
|
||||
self.close_connection = True
|
||||
return True
|
||||
if not parca:
|
||||
break
|
||||
toplam += len(parca)
|
||||
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):
|
||||
log(f"{tag} istemci baglantiyi kapatti ({toplam} bayt)")
|
||||
self.close_connection = True
|
||||
return True
|
||||
if not sessiz:
|
||||
log(f"{tag} bitti status={durum} {toplam} bayt")
|
||||
if completed and getattr(self, "_native_return_scope", None):
|
||||
remember_transfer(*self._native_return_scope)
|
||||
return True
|
||||
|
||||
def _transfer_begin(self):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Transfer-Encoding", "chunked")
|
||||
self.end_headers()
|
||||
|
||||
def _transfer_chunk(self, body):
|
||||
self.wfile.write(b"%x\r\n" % len(body) + body + b"\r\n")
|
||||
self.wfile.flush()
|
||||
|
||||
def _transfer_fail(self, message):
|
||||
event = {"type": "response.failed", "response": {"id": "resp_aiturk_transfer", "status": "failed",
|
||||
"output": [], "error": {"code": "aiturk_transfer_failed", "message": message}}}
|
||||
self._transfer_chunk(b"event: response.failed\ndata: " + json.dumps(event, ensure_ascii=False).encode() + b"\n\n")
|
||||
self.wfile.write(b"0\r\n\r\n")
|
||||
self.wfile.flush()
|
||||
|
||||
def _prepare(self, req, source_model):
|
||||
"""Preparation is separate from inference. ChatGPT auth never goes to AITURK upstream.
|
||||
|
||||
The local worker needs subscription auth only to render opaque OpenAI
|
||||
summaries through their owner. It returns history, never a task answer.
|
||||
"""
|
||||
done, result = threading.Event(), {}
|
||||
def run():
|
||||
try:
|
||||
headers = {"Content-Type": "application/json", "X-AITURK-Transfer": "1"}
|
||||
for key in ("Authorization", "chatgpt-account-id", "originator"):
|
||||
if self.headers.get(key):
|
||||
headers[key] = self.headers[key]
|
||||
data = json.dumps({"request": req, "source_model": source_model}, ensure_ascii=False).encode()
|
||||
request = urllib.request.Request(KOPRU + "/aiturk/codex-baglama/prepare-transfer", data=data, headers=headers)
|
||||
with urllib.request.urlopen(request, timeout=BEKLEME) as response:
|
||||
result["value"] = json.load(response)
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
result["error"] = json.loads(e.read())["error"]["message"]
|
||||
except Exception:
|
||||
result["error"] = "Geçiş hazırlığı başarısız; geçmiş korundu."
|
||||
except Exception:
|
||||
result["error"] = "AITURK geçiş hazırlığına ulaşılamadı; geçmiş korundu."
|
||||
finally:
|
||||
done.set()
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
started = not done.wait(0.2)
|
||||
if started:
|
||||
self._transfer_begin()
|
||||
self._transfer_chunk(b": AITURK context preparation\n\n")
|
||||
while not done.wait(10):
|
||||
self._transfer_chunk(b": AITURK context preparation\n\n")
|
||||
if "error" in result:
|
||||
if not started:
|
||||
self._transfer_begin()
|
||||
self._transfer_fail(result["error"])
|
||||
return None, True
|
||||
return result["value"], started
|
||||
|
||||
def _isle(self):
|
||||
self._native_return_scope = None
|
||||
self._failure_key = None
|
||||
with kilit:
|
||||
sira[0] += 1
|
||||
n = sira[0]
|
||||
if "websocket" in (self.headers.get("Upgrade") or "").lower():
|
||||
# Bugunku calisan durum: surekli acik baglanti reddedilir, Codex olagan yola duser.
|
||||
self.send_response(426)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
return
|
||||
govde = self._oku()
|
||||
yol = self.path
|
||||
if yol.startswith("/aiturk/codex-baglama/"):
|
||||
return self._denetim(yol, govde)
|
||||
son = yol.split("?", 1)[0].rstrip("/")
|
||||
if self.command == "POST" and son.endswith("/responses"):
|
||||
model = model_oku(govde, self.headers.get("Content-Encoding"))
|
||||
external = ek_model_mu(model)
|
||||
try:
|
||||
encoding = (self.headers.get("Content-Encoding") or "").lower()
|
||||
raw = _zstd_ac(govde) if encoding == "zstd" else gzip.decompress(govde) if encoding == "gzip" else govde
|
||||
req = json.loads(raw)
|
||||
if not isinstance(req, dict):
|
||||
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
|
||||
# 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.
|
||||
# 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 or "compaction_items" in prepared:
|
||||
if not started:
|
||||
self._transfer_begin()
|
||||
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()
|
||||
return
|
||||
normalized = prepared["request"]
|
||||
else:
|
||||
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.
|
||||
if self._ilet(n, KOPRU, yol, govde, "LISTE kopru", yedekli=True, sessiz=True):
|
||||
return
|
||||
return self._ilet(n, CHATGPT, yol, govde, "LISTE ozgun", sessiz=True)
|
||||
return self._ilet(n, CHATGPT, yol, govde, "OZGUN diger", sessiz=True)
|
||||
|
||||
do_POST = _isle
|
||||
do_GET = _isle
|
||||
do_PUT = _isle
|
||||
do_DELETE = _isle
|
||||
do_PATCH = _isle
|
||||
|
||||
|
||||
class Sunucu(http.server.ThreadingHTTPServer):
|
||||
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.
|
||||
request_queue_size = 128
|
||||
allow_reuse_address = False # kapi doluysa sessizce yan yana dinlemek yerine acikca hata
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sunucu = Sunucu(("127.0.0.1", PORT), H)
|
||||
except OSError as e:
|
||||
log(f"HATA: 127.0.0.1:{PORT} acilamadi ({e}); baska bir surec bu kapiyi kullaniyor olabilir.")
|
||||
sys.exit(1)
|
||||
log(f"ayirici :{PORT} Codex'in kendi modelleri -> {CHATGPT} ek modeller -> kopru :{KOPRU_PORT} (kod {KOD})")
|
||||
threading.Thread(target=KOPRU_SUREC.bekci, daemon=True).start()
|
||||
sunucu.serve_forever()
|
||||
@@ -0,0 +1,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)
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Provider-neutral history preparation. Never edits the saved Codex transcript.
|
||||
|
||||
Inference routing and voice are not responsibilities of this module. A caller
|
||||
supplies the target window and an AITURK summarizer; subscription-only requests
|
||||
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": "fco"}
|
||||
RATIO = 0.70
|
||||
|
||||
|
||||
class TransferError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def encoded(value):
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def tokens(value):
|
||||
# Deliberately more conservative than the old 3.2 bytes/token estimate.
|
||||
return (len(encoded(value).encode("utf-8")) + 1) // 2
|
||||
|
||||
|
||||
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):
|
||||
for item in req.get("input") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
identity = str(item.get("id") or "")
|
||||
want = PREFIXES.get(item.get("type"))
|
||||
if ("chatcmpl-" in identity or "astrabridge" in identity
|
||||
or str(item.get("encrypted_content") or "").startswith(ozet_kopru.ISARET)
|
||||
or (want and identity and not identity.startswith(want + "_"))):
|
||||
return True
|
||||
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.
|
||||
|
||||
Only foreign server references (not inline tool results) are removed. Their
|
||||
visible summary/text, when supplied, is retained as ordinary context.
|
||||
"""
|
||||
out = copy.deepcopy(req)
|
||||
items = out.get("input")
|
||||
if not isinstance(items, list):
|
||||
return out
|
||||
result = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
result.append(item)
|
||||
continue
|
||||
encrypted = item.get("encrypted_content")
|
||||
if item.get("type") == "compaction" and isinstance(encrypted, str) and encrypted.startswith(ozet_kopru.ISARET):
|
||||
text = ozet_kopru.ozet_metni(item)
|
||||
if not text:
|
||||
raise TransferError("Eski AITURK özeti okunamadı; geçmiş değiştirilmedi.")
|
||||
result.append(ozet_kopru._ozet_mesaji(text))
|
||||
continue
|
||||
identity = item.get("id")
|
||||
if "chatcmpl-" in str(identity) and item.get("type") in ("reasoning", "item_reference"):
|
||||
visible = [p.get("text", "") for p in item.get("summary", []) if isinstance(p, dict)]
|
||||
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]
|
||||
result.append(item)
|
||||
out["input"] = result
|
||||
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[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")}
|
||||
earlier = [i for i, x in enumerate(items[:cut]) if isinstance(x, dict) and x.get("call_id") in tail_calls]
|
||||
if not earlier:
|
||||
break
|
||||
cut = min(earlier)
|
||||
protected, old, recent = [], [], []
|
||||
for i, item in enumerate(items):
|
||||
if isinstance(item, dict) and item.get("role") in ("system", "developer"):
|
||||
protected.append(item)
|
||||
elif i < cut:
|
||||
old.append(item)
|
||||
else:
|
||||
recent.append(item)
|
||||
return protected, old, recent
|
||||
|
||||
|
||||
class Preparer:
|
||||
def __init__(self):
|
||||
self.cache = OrderedDict()
|
||||
self.lock = threading.Lock()
|
||||
|
||||
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 = portable_reasoning(normalize(request))
|
||||
window = int(window)
|
||||
if window < 1024:
|
||||
raise TransferError("Hedef modelin bağlam kapasitesi doğrulanamadı.")
|
||||
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
|
||||
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 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.")
|
||||
# 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)
|
||||
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:
|
||||
summary = summarize(source, budget)
|
||||
if not isinstance(summary, str) or len(summary.strip()) < 80:
|
||||
raise TransferError("AITURK özeti tamamlanamadı; eski geçmiş korundu.")
|
||||
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)
|
||||
while len(self.cache) > 32:
|
||||
self.cache.popitem(last=False)
|
||||
return candidate
|
||||
|
||||
|
||||
PREPARER = Preparer()
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Compatibility for Gemini translators that retain only the last system turn.
|
||||
|
||||
Only instruction-bearing text messages are folded; user/tool content is never
|
||||
promoted. Caller must scope this to the affected Gemini route. No network or IO.
|
||||
"""
|
||||
|
||||
|
||||
def coalesce_context(request):
|
||||
items = request.get('input')
|
||||
base = request.get('instructions', '')
|
||||
if not isinstance(items, list) or not isinstance(base, str):
|
||||
return 0
|
||||
chunks = ['[BASE SYSTEM INSTRUCTIONS]\n' + base] if base else []
|
||||
remaining = []
|
||||
count = 0
|
||||
for item in items:
|
||||
if not isinstance(item, dict) or item.get('type', 'message') != 'message' or item.get('role') not in ('system', 'developer'):
|
||||
remaining.append(item)
|
||||
continue
|
||||
content = item.get('content', '')
|
||||
if isinstance(content, str):
|
||||
text = content
|
||||
elif isinstance(content, list) and all(isinstance(p, dict) and p.get('type') in ('input_text','text') and isinstance(p.get('text'), str) for p in content):
|
||||
text = '\n'.join(p['text'] for p in content)
|
||||
else:
|
||||
# Unknown multimodal instruction block: do not discard or rewrite.
|
||||
return 0
|
||||
chunks.append('[' + item['role'].upper() + ' CONTEXT ' + str(count+1) + ']\n' + text)
|
||||
count += 1
|
||||
if count:
|
||||
request['instructions'] = '\n\n'.join(chunks)
|
||||
request['input'] = remaining
|
||||
return count
|
||||
@@ -0,0 +1,362 @@
|
||||
"""AITURK IDE "Codex Baglama": IDE'nin model ve combolarini Codex listesine ekler.
|
||||
|
||||
Kaynak, IDE'nin kullandigi gecittir (config.yaml -> providers.turkservis.base_url);
|
||||
anahtar IDE'nin kendi .env dosyasindan her yenilemede okunur, bridge.json'a yazilmaz.
|
||||
|
||||
Ac/kapat: %LOCALAPPDATA%\\TurkServis\\AITURK-IDE\\codex-baglama.json
|
||||
{"enabled": true, "kapsam": "ide"}
|
||||
Dosya yoksa ACIK sayilir (kullanici karari, 9 Ekim 2026: varsayilan acik).
|
||||
kapsam "ide" ve "hepsi": gecidin uye katalogundaki OmniRoute sohbet modelleri.
|
||||
Yerel config'teki model adlari uye katalogunu sinirlamaz.
|
||||
"""
|
||||
import json, os, re, urllib.request
|
||||
|
||||
import pencere
|
||||
|
||||
# Agent home: HERMES_HOME when the IDE passes it, else the default install location.
|
||||
AGENT = os.environ.get("HERMES_HOME") or os.path.join(os.environ.get("LOCALAPPDATA", ""), "TurkServis", "AITURK-IDE", "agent")
|
||||
IDE_DIR = os.path.dirname(os.path.normpath(AGENT))
|
||||
AYAR = os.path.join(IDE_DIR, "codex-baglama.json")
|
||||
KEY_NAME = "HERMES_CUSTOM_TURKSERVIS_API_KEY"
|
||||
DEFAULT_BASE = "https://ai.turkservis.online/v1"
|
||||
|
||||
|
||||
def ayar_oku(yol=AYAR):
|
||||
"""Dosya yoksa ya da bozuksa varsayilan: acik, kapsam 'ide'."""
|
||||
ayar = {"enabled": True, "kapsam": "ide"}
|
||||
try:
|
||||
with open(yol, encoding="utf-8") as f:
|
||||
v = json.load(f)
|
||||
if isinstance(v, dict):
|
||||
if isinstance(v.get("enabled"), bool):
|
||||
ayar["enabled"] = v["enabled"]
|
||||
if v.get("kapsam") in ("ide", "hepsi"):
|
||||
ayar["kapsam"] = v["kapsam"]
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return ayar
|
||||
|
||||
|
||||
def anahtar_oku(agent=AGENT):
|
||||
try:
|
||||
with open(os.path.join(agent, ".env"), encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
if line.startswith(KEY_NAME + "="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
except OSError:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def ide_yapilandirma(agent=AGENT):
|
||||
"""config.yaml'dan gecit adresi, varsayilan model ve tanimli model adlari.
|
||||
Kopru bagimliliksiz calistigi icin YAML'in yalniz bu bolumu satir satir okunur."""
|
||||
base, varsayilan, adlar = DEFAULT_BASE, "", []
|
||||
try:
|
||||
with open(os.path.join(agent, "config.yaml"), encoding="utf-8", errors="replace") as f:
|
||||
lines = f.read().splitlines()
|
||||
except OSError:
|
||||
return base, varsayilan, adlar
|
||||
bolum = saglayici = None
|
||||
modellerde = False
|
||||
for line in lines:
|
||||
if not line.strip() or line.lstrip().startswith("#"):
|
||||
continue
|
||||
girinti = len(line) - len(line.lstrip(" "))
|
||||
metin = line.strip()
|
||||
if girinti == 0:
|
||||
bolum = metin.rstrip(":"); saglayici = None; modellerde = False
|
||||
continue
|
||||
if bolum == "model" and girinti == 2:
|
||||
if metin.startswith("default:"):
|
||||
varsayilan = metin.split(":", 1)[1].strip().strip("'\"")
|
||||
continue
|
||||
if bolum != "providers":
|
||||
continue
|
||||
if girinti == 2:
|
||||
saglayici = metin.rstrip(":"); modellerde = False
|
||||
elif saglayici == "turkservis" and girinti == 4:
|
||||
modellerde = metin == "models:"
|
||||
if metin.startswith("base_url:"):
|
||||
base = metin.split(":", 1)[1].strip().strip("'\"") or base
|
||||
elif saglayici == "turkservis" and modellerde and girinti == 6:
|
||||
ad = re.split(r":\s*(\{|$)", metin)[0].strip().strip("'\"")
|
||||
if ad:
|
||||
adlar.append(ad)
|
||||
return base.rstrip("/"), varsayilan, adlar
|
||||
|
||||
|
||||
EN_COK_MODEL = 1000
|
||||
AITURK_EKI = " (AITURK)"
|
||||
# Gecidin kisa saglayici kodu -> kullanicinin gormek istedigi kod (10 Ekim 2026: "agy/gemini").
|
||||
_SAGLAYICI_KISA = {"ag": "agy"}
|
||||
# Saglayici kodu zaten soyledigi icin model adinin basinda tekrar edilmeyen uretici adi ("cc/opus-5-5").
|
||||
_TEKRAR = ("claude-",)
|
||||
DUSUNMESIZ = "no-think-"
|
||||
|
||||
|
||||
def kisa_ad(ad):
|
||||
"""Gecit onekleri ("OmniRoute · ", "9Router · ", "9R · ") ve aradaki saglayici halkalari atilir:
|
||||
"nano/qwen/qwen3.8-27b" -> "nano/qwen3.8-27b", "9R · ag/gemini-3.8-flash" -> "ag/gemini-3.8-flash",
|
||||
combo adi oldugu gibi kalir. Kullanici karari (10 Ekim 2026): "9R" yazilmaz."""
|
||||
ad = (ad or "").split(" · ")[-1].strip()
|
||||
parcalar = [p for p in ad.split("/") if p]
|
||||
if len(parcalar) <= 2:
|
||||
return "/".join(parcalar) or ad
|
||||
return parcalar[0] + "/" + parcalar[-1]
|
||||
|
||||
|
||||
def gorunen_ad(kimlik, ad, tur="model"):
|
||||
"""Secicide gorunen ad (kullanici karari, 10 Ekim 2026): combo yalniz kendi adiyla; tek model
|
||||
"saglayici/model" kalibinda, kisa ve surumlu: "9R · cc/claude-opus-5-5" -> "cc/opus-5-5",
|
||||
"9R · ag/gemini-3.8-flash-high" -> "agy/gemini-3.8-flash-high", "Agnes 2.0 Flash" (agnes-agnes-2.0-flash)
|
||||
-> "agnes/agnes-2.0-flash", "kurnaz" (bekci-kurnaz) -> "bekci/kurnaz". Dusunmesiz kopya asil
|
||||
saglayicisiyla gorunur: "no-think/claude-opus-5-5" -> "cc/opus-5-5 (no-think)"."""
|
||||
yalin = kisa_ad(ad)
|
||||
if tur == "combo":
|
||||
return yalin
|
||||
kim, ek = kimlik or "", ""
|
||||
if kim.startswith(DUSUNMESIZ):
|
||||
kim, ek = kim[len(DUSUNMESIZ):], " (no-think)"
|
||||
if kim.startswith("9router-"):
|
||||
kim = kim[len("9router-"):]
|
||||
saglayici, _, model = yalin.partition("/")
|
||||
if not model or ek:
|
||||
kod, _, kalan = kim.partition("-")
|
||||
if not kod or not kalan:
|
||||
return yalin
|
||||
saglayici, model = kod, model or kalan
|
||||
model = model.replace(" ", "-")
|
||||
for tekrar in _TEKRAR:
|
||||
if model.lower().startswith(tekrar) and len(model) > len(tekrar):
|
||||
model = model[len(tekrar):]
|
||||
return f"{_SAGLAYICI_KISA.get(saglayici.lower(), saglayici)}/{model}{ek}"
|
||||
|
||||
|
||||
def adlandir(girdiler, dolu=(), turler=None):
|
||||
"""[(kimlik, tam_ad)] -> {kimlik: gorunen_ad}. Kisa adlar cakisirsa aradaki halkalar geri konur;
|
||||
yine cakisirsa kimligin sonu eklenir. `dolu`: baska kaynaktan gelen, zaten kullanilan adlar.
|
||||
`turler` ({kimlik: "combo"|"model"}) verilirse adlar gorunen_ad kuraliyla yazilir."""
|
||||
if turler is None:
|
||||
kisa = {k: kisa_ad(a) for k, a in girdiler}
|
||||
else:
|
||||
kisa = {k: gorunen_ad(k, a, turler.get(k, "model")) for k, a in girdiler}
|
||||
tam = {k: (a or "").split(" · ")[-1].strip() for k, a in girdiler}
|
||||
sayac = {}
|
||||
for v in list(kisa.values()) + list(dolu):
|
||||
sayac[v.lower()] = sayac.get(v.lower(), 0) + 1
|
||||
sonuc, kullanilan = {}, {d.lower() for d in dolu}
|
||||
for k, _ in girdiler:
|
||||
ad = kisa[k] if sayac[kisa[k].lower()] == 1 else tam[k]
|
||||
if ad.lower() in kullanilan:
|
||||
taban = ad
|
||||
for uzunluk in range(8, len(k) + 8, 4): # son 8 karakter de cakisirsa ek uzatilir: iki satir ayni adi tasimaz
|
||||
ad = f"{taban} [{k[-uzunluk:]}]"
|
||||
if ad.lower() not in kullanilan:
|
||||
break
|
||||
kullanilan.add(ad.lower())
|
||||
sonuc[k] = ad
|
||||
return sonuc
|
||||
|
||||
|
||||
def sec(data, tanimli=(), kapsam="ide", turler=None):
|
||||
"""Gecidin uye katalogundan girdiler: [{'id','ad','kind'}], sirali ve tekrarsiz.
|
||||
Yalniz sohbet modelleri; resim/ses/video/embedding uretenler eklenmez."""
|
||||
turler = turler or {}
|
||||
secilen, gorulen = [], set()
|
||||
for m in data:
|
||||
mid = m.get("id") or ""
|
||||
ts = m.get("turkservis") or {}
|
||||
if not mid or mid in gorulen or "chat" not in (ts.get("capabilities") or ["chat"]):
|
||||
continue
|
||||
gorunen = ts.get("display_name") or mid
|
||||
kind = turler.get(mid) or ts.get("kind") or "model"
|
||||
# Tek uye yolu: 9Router -> OmniRoute -> TurkServis -> Codex.
|
||||
# Dogrudan 9Router satirlari ayni modelin ikinci kopyasi olarak eklenmez.
|
||||
if ts.get("source") != "omniroute":
|
||||
continue
|
||||
# Combo adi biciminden tahmin edilmez; duz adli Agnes/Bekci modelleri de vardir.
|
||||
if kind == "combo" and mid.lower().startswith("9router-"):
|
||||
continue
|
||||
gorulen.add(mid)
|
||||
secilen.append({"id": mid, "ad": gorunen, "kind": kind, "sira": ts.get("order") or 0,
|
||||
"yol": str(m.get("description") or "").split(" · ")[-1].strip()})
|
||||
secilen.sort(key=lambda s: (s["sira"], s["id"]))
|
||||
# Gecit ayni adin ikinci yoluna "ad (2)" der; birincisi listede yoksa sayac anlamsizdir.
|
||||
adlar = {s["ad"].split(" · ")[-1].strip().lower() for s in secilen}
|
||||
for s in secilen:
|
||||
taban = re.sub(r" \(\d+\)$", "", s["ad"])
|
||||
if taban != s["ad"] and taban.split(" · ")[-1].strip().lower() not in adlar:
|
||||
adlar.add(taban.split(" · ")[-1].strip().lower())
|
||||
s["ad"] = taban
|
||||
return [{"id": s["id"], "ad": s["ad"], "kind": s["kind"], "yol": s["yol"]} for s in secilen[:EN_COK_MODEL]]
|
||||
|
||||
|
||||
def sirala_codex(modeller, ekler, ozgun_kimlikler=()):
|
||||
"""Ozgun abonelik modelleri oldugu gibi kalir: sirasi, priority degeri ve adi degismez,
|
||||
listenin basinda durur. Ek modeller onlarin ARKASINA eklenir: combo A-Z, sonra
|
||||
saglayici A-Z / model A-Z. Ozgun bir modelle ayni adi tasiyan ek model " (AITURK)"
|
||||
ekiyle ayirt edilir; secicide ozgunun yerine gecmis gibi gorunmez.
|
||||
Kimlik ve yetenek alanlari korunur; kayitli model secimine dokunulmaz.
|
||||
ozgun_kimlikler: ust listeden (chatgpt.com) gelen kimlikler. Ayarda ayni kimlik ek model diye yazilmis
|
||||
olsa da o model ozgundur; yerinden ve sirasindan oynamaz."""
|
||||
bilgiler = {m["slug"]: m for m in ekler if m["slug"] not in ozgun_kimlikler}
|
||||
ozgunler = [m for m in modeller if m.get("slug", "") not in bilgiler]
|
||||
ek_modeller = [m for m in modeller if m.get("slug", "") in bilgiler]
|
||||
def yalin(ad): # "GPT-6-1-Sol" ile "GPT-6.1-Sol" ayni ad sayilir
|
||||
return re.sub(r"[^0-9a-z]+", "", (ad or "").casefold())
|
||||
|
||||
ozgun_adlar = {yalin(m.get("display_name") or m.get("slug", "")) for m in ozgunler}
|
||||
|
||||
def anahtar(m):
|
||||
slug = m.get("slug", "")
|
||||
bilgi = bilgiler.get(slug, {})
|
||||
ad = kisa_ad(m.get("display_name") or slug)
|
||||
if bilgi.get("kind") == "combo":
|
||||
return (0, ad.casefold(), "", slug)
|
||||
if slug.startswith("ide/"):
|
||||
mid = slug[len("ide/"):]
|
||||
if mid.startswith("9router-"):
|
||||
mid = mid[len("9router-"):]
|
||||
saglayici, _, model = ad.partition("/")
|
||||
if not model:
|
||||
saglayici, _, _ = mid.partition("-")
|
||||
model = ad
|
||||
else:
|
||||
saglayici, model = "OpenAI", ad
|
||||
return (1, saglayici.casefold(), model.casefold(), slug)
|
||||
|
||||
ek_modeller.sort(key=anahtar)
|
||||
taban = max([m.get("priority") or 0 for m in ozgunler], default=-1) + 1
|
||||
for n, model in enumerate(ek_modeller):
|
||||
model["priority"] = taban + n
|
||||
ad = model.get("display_name") or model.get("slug", "")
|
||||
if yalin(ad) in ozgun_adlar:
|
||||
model["display_name"] = f"{ad}{AITURK_EKI}"
|
||||
modeller[:] = (ozgunler + ek_modeller)[:EN_COK_MODEL]
|
||||
|
||||
|
||||
# ---- Secicide model adinin altindaki satir ----
|
||||
# Telefonda bu satir tek satirdir ve yaklasik 30 karakterde kesilir (kullanici ekran goruntusu,
|
||||
# 10 Ekim 2026); en ayirt edici bilgi basa yazilir, ad tekrar edilmez.
|
||||
ACIKLAMA_SINIRI = 30
|
||||
KUCUK_PENCERE = 100_000 # bunun altindaki pencere alt satirda yazilir
|
||||
_AILE = (("opus", "opus"), ("sonnet", "sonnet"), ("haiku", "haiku"), ("astra", "astra"), ("terra", "terra"),
|
||||
("luna", "luna"), ("sol", "sol"), ("gemini", "gemini"), ("qwen", "qwen"), ("glm", "glm"),
|
||||
("abliterated", "abliterated"), ("deepseek", "deepseek"), ("llama", "llama"), ("gemma", "gemma"))
|
||||
_SAGLAYICI = {"cc": "Claude", "cx": "Codex", "ag": "Antigravity", "agy": "Antigravity", "nano": "NanoGPT", "hf": "HuggingFace",
|
||||
"huggingface": "HuggingFace", "agnes": "Agnes", "bekci": "Bekçi"}
|
||||
_SANSURSUZ = re.compile(r"uncensored|abliterat|obliterat|derestrict|heretic|unshackled", re.I)
|
||||
|
||||
|
||||
def kisa_model(ad):
|
||||
""""cc/claude-opus-5-5" -> "opus", "nano/z-ai/glm-5.3-flash-uncensored" -> "glm"; bilinmeyen ad "?"."""
|
||||
if not ad:
|
||||
return "?"
|
||||
son = ad.split("/")[-1].split(":")[0].lower()
|
||||
parcalar = [p for p in re.split(r"[^a-z]+", son) if p]
|
||||
for anahtar, kisa in _AILE:
|
||||
if anahtar in parcalar:
|
||||
return kisa
|
||||
return (parcalar[0] if parcalar else son) or "?"
|
||||
|
||||
|
||||
def _yapraklar(dugum, derinlik=0):
|
||||
"""Sirali (priority) zinciri duz model listesine acar; ic takim kendi uyeleriyle yer alir."""
|
||||
if not isinstance(dugum, dict) or derinlik > 6:
|
||||
return ["?"]
|
||||
if dugum.get("kind") != "combo":
|
||||
return [kisa_model(dugum.get("model"))]
|
||||
cikti = []
|
||||
for uye in dugum.get("members") or []:
|
||||
cikti.extend(_yapraklar(uye, derinlik + 1))
|
||||
return cikti or ["?"]
|
||||
|
||||
|
||||
def _sigdir(onek, adlar, ayrac):
|
||||
"""Sinira sigdigi kadar ad yazar, kalani "+N" ile sayar."""
|
||||
tekil = [a for i, a in enumerate(adlar) if i == 0 or a != adlar[i - 1]]
|
||||
for adet in range(len(tekil), 0, -1):
|
||||
kalan = len(tekil) - adet
|
||||
metin = onek + ayrac.join(tekil[:adet]) + (f" +{kalan}" if kalan else "")
|
||||
if len(metin) <= ACIKLAMA_SINIRI or adet == 1:
|
||||
return metin
|
||||
return onek.rstrip(": ")
|
||||
|
||||
|
||||
def aciklama(kimlik, ad, tur, kayit=None, bilgi=None):
|
||||
"""Model adinin altinda gorunecek kisa satir. kayit: gecidin /router/catalog girdisi (yoksa None).
|
||||
Takim -> uyeleri; sirali yedek -> sirasi; tek model -> saglayicisi ve adindan okunan ozellikleri.
|
||||
bilgi (pencere.coz sonucu): Codex'te isi etkileyen iki uyari one yazilir: kucuk baglam ("32K")
|
||||
ve arac cagiramayan model ("araçsız")."""
|
||||
if tur == "combo":
|
||||
topoloji = (kayit or {}).get("topology") or {}
|
||||
uyeler = topoloji.get("members") or []
|
||||
if topoloji.get("strategy") == "round-robin":
|
||||
return f"Dönüşümlü: {len(uyeler)} üye"
|
||||
takim = topoloji if topoloji.get("strategy") == "fusion" else None
|
||||
if takim is None and uyeler and isinstance(uyeler[0], dict) and uyeler[0].get("strategy") == "fusion":
|
||||
takim = uyeler[0]
|
||||
if takim is not None:
|
||||
return _sigdir("Takım: ", [kisa_model(u.get("model")) for u in takim.get("members") or []], "+")
|
||||
if uyeler:
|
||||
return _sigdir("Sıralı: ", _yapraklar(topoloji), ">")
|
||||
return "Takım"
|
||||
kisa = gorunen_ad(kimlik, ad, "model")
|
||||
kod = kisa.split("/")[0].lower() if "/" in kisa else (kimlik or "").split("-")[0].lower()
|
||||
parcalar = [_SAGLAYICI.get(kod, kod) or "Tek model"]
|
||||
if bilgi and bilgi.get("pencere") and bilgi["pencere"] < KUCUK_PENCERE:
|
||||
parcalar.append(pencere.kisa(bilgi["pencere"]))
|
||||
if bilgi and bilgi.get("arac") is False:
|
||||
parcalar.append("araçsız")
|
||||
if _SANSURSUZ.search(kisa) or _SANSURSUZ.search(kimlik or ""):
|
||||
parcalar.append("sansürsüz")
|
||||
if "thinking" in (kisa + " " + (kimlik or "")).lower():
|
||||
parcalar.append("düşünen")
|
||||
if len(parcalar) == 1:
|
||||
parcalar.insert(0, "Tek model")
|
||||
while len(parcalar) > 1 and len(" · ".join(parcalar)) > ACIKLAMA_SINIRI:
|
||||
parcalar.pop() # sona yazilan en az onemli ozellik duser; kelime ortasindan kesilmez
|
||||
return " · ".join(parcalar)[:ACIKLAMA_SINIRI]
|
||||
|
||||
|
||||
def yukle(ua="AstraBridge/1.0 (codex)", timeout=30, home=None, ag=True):
|
||||
"""Donus: (durum, base_url, anahtar, girdiler). durum: 'kapali' | 'anahtar-yok' | 'tamam'.
|
||||
Ag hatasinda istisna atar; cagiran son listeyi korur. home: koprunun veri klasoru (pencere onbellegi).
|
||||
Her girdi "pencere" (ilan edilecek baglam penceresi), "gorsel" ve "arac" alanlarini tasir."""
|
||||
ayar = ayar_oku()
|
||||
if not ayar["enabled"]:
|
||||
return "kapali", "", "", []
|
||||
anahtar = anahtar_oku()
|
||||
if not anahtar:
|
||||
return "anahtar-yok", "", "", []
|
||||
base, varsayilan, adlar = ide_yapilandirma()
|
||||
req = urllib.request.Request(base + "/models", headers={"Authorization": f"Bearer {anahtar}", "User-Agent": ua})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
data = json.load(resp).get("data")
|
||||
if not isinstance(data, list) or not data:
|
||||
raise ValueError("Gecit bos veya gecersiz model katalogu dondurdu")
|
||||
req = urllib.request.Request(base + "/router/catalog", headers={"Authorization": f"Bearer {anahtar}", "User-Agent": ua})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
metadata = json.load(resp).get("models")
|
||||
if not isinstance(metadata, list) or not metadata:
|
||||
raise ValueError("Gecit model turlerini dondurmedi")
|
||||
# Bu ucta uye listesinde olmayan ic kayitlar da bulunur. Yalniz tur bilgisi alinir;
|
||||
# modele erisim listesi her zaman /models yanitidir.
|
||||
turler = {m["id"]: m["kind"] for m in metadata if m.get("id") and m.get("kind") in ("combo", "model")}
|
||||
girdiler = sec(data, adlar + [varsayilan], ayar["kapsam"], turler)
|
||||
if not girdiler:
|
||||
raise ValueError("Gecitte kullanilabilir OmniRoute sohbet modeli yok")
|
||||
kayitlar = {m["id"]: m for m in metadata if m.get("id")}
|
||||
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,
|
||||
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"]))
|
||||
g["aciklama"] = aciklama(g["id"], g["ad"], g["kind"], kayitlar.get(g["id"]), bilgi)
|
||||
return "tamam", base, anahtar, girdiler
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Ek modellere internet aramasi (11 Ekim 2026).
|
||||
|
||||
Kullanici karari (11 Ekim 2026, sesli): "Arastirma yapabilmesini istiyorum. Ayni Codex'te yapabildikleri gibi olmasini
|
||||
istiyorum." Codex'in yerlesik web aramasi OpenAI sunucusunda calistigi icin ek modele verilemez (kopru onu arac
|
||||
listesinden atar); olcum: ek model arama araci bulamayinca turu bos bitirdi. Kopru bunun yerine ek modele giden
|
||||
istege, aramayi kabuk araciyla yapan hazir komutu (ara.py) soyleyen kisa bir not ekler. Codex'in kendi modelleri bu
|
||||
notu hic gormez: onlarin istegi kopruye ugramaz.
|
||||
|
||||
Ayar (bridge.json, istege bagli): "internet": {"enabled": false} kapatir; {"talimat": "..."} metni degistirir.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
ISARET = "[İnternet araması]"
|
||||
VARSAYILAN = (
|
||||
ISARET + " Bu modelde yerleşik web araması yok; internetten bilgi gerektiğinde (güncel olay, araştırma, kaynak, "
|
||||
"fiyat, \"araştır\" isteği) kabuk aracınla şu iki komutu kullan:\n"
|
||||
"Arama: {ara} \"sorgu\"\n"
|
||||
"Sayfa okuma: {oku} <adres>\n"
|
||||
"Önce ara, sonra işe yarayan iki üç sonucu oku, cevabında kaynak adreslerini belirt. Başka bir arama aracı "
|
||||
"arama (tool_search ile bulunmaz). Arama hizmeti engeli veya ayrıştırma hatasında sorguları tekrar tekrar "
|
||||
"değiştirme; aramanın yapılamadığını açıkça söyle. Gerçek sonuç yoksa en fazla bir farklı sorgu dene. Bilgi uydurma."
|
||||
)
|
||||
|
||||
|
||||
def python_yolu(calisan=None):
|
||||
"""Modelin calistiracagi yorumlayici. Kopru penceresiz (pythonw) calisiyorsa cikti veren python secilir."""
|
||||
calisan = calisan or sys.executable or "python"
|
||||
klasor, ad = os.path.split(calisan)
|
||||
if ad.lower() == "pythonw.exe":
|
||||
aday = os.path.join(klasor, "python.exe")
|
||||
if os.path.isfile(aday):
|
||||
return aday
|
||||
return calisan
|
||||
|
||||
|
||||
def talimat(ayar, betik, python=None):
|
||||
"""Eklenecek metin; kapaliysa ya da betik yoksa None."""
|
||||
ayar = ayar if isinstance(ayar, dict) else {}
|
||||
if ayar.get("enabled") is False or not betik or not os.path.isfile(betik):
|
||||
return None
|
||||
on = f'& "{python or python_yolu()}" "{betik}"' if os.name == 'nt' else f'"{python or python_yolu()}" "{betik}"'
|
||||
return str(ayar.get("talimat") or VARSAYILAN).replace("{ara}", on + " ara").replace("{oku}", on + " oku")
|
||||
|
||||
|
||||
def ekle(req, metin):
|
||||
"""Notu istegin basina developer mesaji olarak koyar. True: eklendi (zaten varsa eklenmez)."""
|
||||
girdi = req.get("input")
|
||||
if not metin or not isinstance(girdi, list):
|
||||
return False
|
||||
for oge in girdi[:4]:
|
||||
icerik = oge.get("content") if isinstance(oge, dict) else None
|
||||
yazi = icerik if isinstance(icerik, str) else " ".join(str(p.get("text") or "") for p in icerik or [] if isinstance(p, dict))
|
||||
if ISARET in yazi:
|
||||
return False
|
||||
girdi.insert(0, {"type": "message", "role": "developer", "content": [{"type": "input_text", "text": metin}]})
|
||||
return True
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Codex IDE'ye "is kucuk degilse AITURK yapacak" talimati (11 Ekim 2026).
|
||||
|
||||
Kullanici karari: Codex Baglama acikken talimat Codex IDE'nin (masaustu uygulamasi) gordugu sistem talimatinda durur;
|
||||
Codex CLI talimati hic gormez, varligindan haberi olmaz. Karari secili model verir; kucuk modellerin yanilmasi kabul
|
||||
edilir, ayri bir yazilimla onune gecilmez.
|
||||
|
||||
Bu yuzden talimat hicbir dosyaya yazilmaz (AGENTS.md'yi CLI da okur): kopru, yalniz masaustu uygulamasindan gelen
|
||||
isteklere, istek gecerken ekler. Istemci `originator` basligindan ayirt edilir (olcum 11 Ekim 2026: masaustu
|
||||
"Codex Desktop", etkilesimli CLI "codex-tui", `codex exec` "codex_exec").
|
||||
|
||||
Hat, Codex Baglama'nin parcasidir: ayri bir dugmesi yoktur, bu bilgisayarda isi gonderebilen bir AITURK komutu
|
||||
(`aiturk is`) bulundugu surece aciktir. Komut yoksa talimat eklenmez: gonderme yolu olmayan bir "AITURK'e gonder"
|
||||
talimati modeli yaniltir. Ayar (bridge.json, istege bagli): "aiturk_hat": {"enabled": false} kapatir;
|
||||
{"komut": "..."} komutu elle verir; {"talimat": "..."} metni degistirir.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
MASAUSTU = {"codex desktop"}
|
||||
ISARET = "[AITURK iş hattı]"
|
||||
VARSAYILAN = (
|
||||
ISARET + " Bu Codex, AITURK'e bağlı. Şu kurala göre çalış:\n"
|
||||
"1. Basit soruyu, bilgi isteğini ya da tek küçük düzeltmeyi kendin yap.\n"
|
||||
"2. Kullanıcı \"sen yap\" derse işi kendin yap.\n"
|
||||
"3. İş küçük değilse (birden çok dosya, birkaç adım, yeni özellik, araştırma gerektiren iş) kendin yapma. "
|
||||
"İşin tam metnini (kullanıcının isteği ve gereken bağlam) UTF-8 bir dosyaya yaz, işi şu komutla AITURK'e gönder, "
|
||||
"bitmesini bekle ve sonucu kullanıcıya kısaca aktar:\n{komut}\n"
|
||||
"Komut \"işi sen yap\" derse ya da hata verirse işi kendin yap. Emin değilsen işi küçük say ve kendin yap."
|
||||
)
|
||||
|
||||
|
||||
def masaustu_mu(originator):
|
||||
return str(originator or "").strip().lower() in MASAUSTU
|
||||
|
||||
|
||||
def adaylar(ortam=None):
|
||||
"""Isi gonderebilecek AITURK komutu adaylari, oncelik sirasiyla (kurulu AITURK, PATH, hat deneme kurulumu)."""
|
||||
ortam = os.environ if ortam is None else ortam
|
||||
yerel = ortam.get("LOCALAPPDATA") or ""
|
||||
liste = []
|
||||
if yerel:
|
||||
liste.append(os.path.join(yerel, "TurkServis", "AITURK", "program", "bin", "aiturk.cmd"))
|
||||
bulunan = shutil.which("aiturk")
|
||||
if bulunan:
|
||||
liste.append(bulunan)
|
||||
if yerel:
|
||||
liste.append(os.path.join(yerel, "TurkServis", "AITURK-is-hatti", "Scripts", "aiturk.exe"))
|
||||
return [y for i, y in enumerate(liste) if y and y not in liste[:i]]
|
||||
|
||||
|
||||
def destekliyor(yol, calistir=subprocess.run):
|
||||
"""Bu AITURK `is` komutunu taniyor mu? (Eski surumler tanimaz: "No such command 'is'.")"""
|
||||
if not os.path.isfile(yol):
|
||||
return False
|
||||
try:
|
||||
sonuc = calistir([yol, "is", "--help"], capture_output=True, timeout=40, cwd=os.path.dirname(yol),
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
||||
return sonuc.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def komut_bul(ortam=None, destek=destekliyor):
|
||||
"""Isi gonderebilen ilk AITURK komutunun yolu; yoksa None."""
|
||||
for yol in adaylar(ortam):
|
||||
if destek(yol):
|
||||
return yol
|
||||
return None
|
||||
|
||||
|
||||
def komut_satiri(yol, sohbet=""):
|
||||
"""Modelin calistiracagi hazir satir. Sohbet kimligi kopruden gelir (sohbetler karismasin); klasor verilmez:
|
||||
komut Codex'in o anki klasorunde calisir, AITURK de isi ayni klasorde yapar."""
|
||||
sohbet = "".join(c for c in str(sohbet or "") if c.isalnum() or c in "-_")
|
||||
return f'"{yol}" is' + (f" --sohbet {sohbet}" if sohbet else "") + ' --dosya "<iş metnini yazdığın dosya>"'
|
||||
|
||||
|
||||
def talimat(ayar, bulunan=None, sohbet=""):
|
||||
"""Eklenecek metin; hat kapaliysa ya da gonderme komutu yoksa None.
|
||||
bulunan: bu bilgisayarda bulunan AITURK komutunun yolu (komut_bul); ayardaki "komut" onun onune gecer."""
|
||||
ayar = ayar if isinstance(ayar, dict) else {}
|
||||
if ayar.get("enabled") is False:
|
||||
return None
|
||||
komut = str(ayar.get("komut") or "").strip() or (komut_satiri(bulunan, sohbet) if bulunan else "")
|
||||
if not komut:
|
||||
return None
|
||||
return str(ayar.get("talimat") or VARSAYILAN).replace("{komut}", komut)
|
||||
|
||||
|
||||
def ekle(req, metin):
|
||||
"""Talimati istegin basina developer mesaji olarak koyar. True: eklendi (zaten varsa eklenmez)."""
|
||||
girdi = req.get("input")
|
||||
if not metin or not isinstance(girdi, list):
|
||||
return False
|
||||
for oge in girdi[:3]:
|
||||
icerik = oge.get("content") if isinstance(oge, dict) else None
|
||||
yazi = icerik if isinstance(icerik, str) else " ".join(str(p.get("text") or "") for p in icerik or [] if isinstance(p, dict))
|
||||
if ISARET in yazi:
|
||||
return False
|
||||
girdi.insert(0, {"type": "message", "role": "developer", "content": [{"type": "input_text", "text": metin}]})
|
||||
return True
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"endpoint": "https://api.typesafe.ai/v1/systemone",
|
||||
"model": "jev-1.13.0",
|
||||
"fallbackModel": "Katman-1",
|
||||
"rejectionRules": [
|
||||
{ "from": "Katman-1", "to": "Katman-2" },
|
||||
{ "from": "Katman-2", "to": "Katman-3" },
|
||||
{ "from": "Katman-3", "to": "Katman-4" }
|
||||
],
|
||||
"exemptModels": ["Sesli-Hizli-Zeki", "Katman-4"],
|
||||
"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.",
|
||||
"sart": "The assistant changed the request on its own: it added its own conditions, softened or relabeled what was asked, or left part of it out, instead of doing exactly what the user asked."
|
||||
},
|
||||
"timeoutSeconds": 3600,
|
||||
"criteria": { "ret": { "true": "", "false": "" }, "sart": { "true": "", "false": "" } },
|
||||
"maxConcurrent": 0,
|
||||
"queueWaitSeconds": 3600,
|
||||
"failureAction": "pass"
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Katman denetimi: Jev, katman combosunun her adimdaki cevabini gecirir ya da gecirmez (11 Ekim 2026).
|
||||
|
||||
Kullanici karari (11 Ekim 2026): "Jev kati olsun. Af yok istisna yok. Herhangi bir model Jev'e gelirse Jev bunu
|
||||
gecirmezse 1.Katmana yonlendirecek. 1.Katman combosu gecmezse 2.Katmana, 2.Katman gecmezse 3.Katmana, 3.Katman
|
||||
gecmezse 4.Katmana. 4.Katman combosu gelirse Jev bunu sorgulamadan gecirecek."
|
||||
|
||||
12 Ekim 2026 istisnasi: çözülmüş hedefi tam Sesli-Hizli-Zeki olan cevap Jev'e sorulmaz.
|
||||
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.
|
||||
|
||||
Jev (TypeSafe System One) metin uretmez, sorulan evet/hayir sorularina olasilik verir. Kural tektir ve istisnasi
|
||||
yoktur: cevap istegi yapmiyorsa (ret) ya da sormadan kendi sartlariyla degistiriyorsa (sart) adim gecmez.
|
||||
Metni olmayan (yalniz arac cagiran) adimda sorulacak cevap yoktur. Jev'e ulasilamazsa cevap oldugu gibi verilir.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.request
|
||||
import jev_settings
|
||||
|
||||
ADRES = "https://api.typesafe.ai/v1/systemone"
|
||||
MODEL = "jev-1.13.0"
|
||||
KATMAN = ("Katman-1", "Katman-2", "Katman-3", "Katman-4")
|
||||
ESIK = {"ret": 0.5, "sart": 0.7}
|
||||
SORU = {
|
||||
"ret": ("The assistant did NOT do what the user asked or did NOT answer the user's question: it refused, declined, "
|
||||
"said it cannot or will not, avoided giving the requested judgment, or redirected to a different topic."),
|
||||
"sart": ("The assistant changed the request on its own: it added its own conditions, softened or relabeled what was "
|
||||
"asked, or left part of it out, instead of doing exactly what the user asked."),
|
||||
}
|
||||
_ANAHTAR = {"okundu": False, "deger": ""}
|
||||
|
||||
def muaf_mi(hedef):
|
||||
"""Settings' always-pass list matches exact resolved model IDs.
|
||||
|
||||
Taşıma hatalarının katman sırasını değiştirmez; yedek model kendi adına denetlenir.
|
||||
"""
|
||||
return hedef in jev_settings.read()[0]['exemptModels']
|
||||
|
||||
|
||||
def sonrakiler(hedef):
|
||||
"""Model-specific rejection edges take precedence over the general fallback.
|
||||
|
||||
Stop at an always-pass model or before revisiting any target.
|
||||
"""
|
||||
settings = jev_settings.read()[0]
|
||||
ad = str(hedef or "")
|
||||
if not settings['enabled'] or not ad:
|
||||
return []
|
||||
routes = {r['from']: r['to'] for r in settings['rejectionRules']}
|
||||
result, seen = [], {ad}
|
||||
# An exempt model can still use the general fallback on transport failure.
|
||||
# Repeated targets stop the walk even if a file was edited outside Settings.
|
||||
while len(result) < 65:
|
||||
target = routes.get(ad, settings['fallbackModel'])
|
||||
if target in seen:
|
||||
break
|
||||
seen.add(target)
|
||||
result.append(target)
|
||||
if target in settings['exemptModels']:
|
||||
break
|
||||
ad = target
|
||||
return result
|
||||
|
||||
|
||||
def anahtar(ortam=None, dosyalar=None):
|
||||
"""TypeSafe anahtari: ortam degiskeni, sonra ~/.typesafe/typesafe.env. Yoksa ""."""
|
||||
if ortam is None and dosyalar is None:
|
||||
saved = jev_settings.key()
|
||||
if saved:
|
||||
return saved
|
||||
ortam = os.environ if ortam is None else ortam
|
||||
deger = str(ortam.get("TYPESAFE_API_KEY") or "").strip()
|
||||
if deger:
|
||||
return deger
|
||||
if dosyalar is None:
|
||||
if _ANAHTAR["okundu"]:
|
||||
return _ANAHTAR["deger"]
|
||||
dosyalar = [os.path.join(os.path.expanduser("~"), ".typesafe", "typesafe.env")]
|
||||
onbellek = True
|
||||
else:
|
||||
onbellek = False
|
||||
for yol in dosyalar:
|
||||
try:
|
||||
with open(yol, encoding="utf-8", errors="replace") as f:
|
||||
for satir in f:
|
||||
m = re.match(r"^\s*TYPESAFE_API_KEY\s*=\s*(.*)$", satir)
|
||||
if m:
|
||||
deger = m.group(1).strip().strip('"').strip("'")
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
if deger:
|
||||
break
|
||||
if onbellek:
|
||||
_ANAHTAR.update(okundu=True, deger=deger)
|
||||
return deger
|
||||
|
||||
|
||||
def _metin(oge):
|
||||
icerik = oge.get("content")
|
||||
if isinstance(icerik, str):
|
||||
return icerik
|
||||
return "".join(str(p.get("text") or "") for p in icerik or [] if isinstance(p, dict))
|
||||
|
||||
|
||||
def son_istek(req):
|
||||
"""Kullanicinin bu turdaki istegi: son kullanici mesaji. Sesli aktarimda yalniz <input> icindeki soz;
|
||||
baska sarmalli sistem mesajlari (kanca baglami) atlanir."""
|
||||
for oge in reversed(req.get("input") or []):
|
||||
if not (isinstance(oge, dict) and oge.get("type", "message") == "message" and oge.get("role") == "user"):
|
||||
continue
|
||||
metin = _metin(oge).strip()
|
||||
m = re.search(r"<realtime_delegation>.*?<input>(.*?)</input>", metin, re.S)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
if metin and not metin.startswith("<"):
|
||||
return metin
|
||||
return ""
|
||||
|
||||
|
||||
def hazirlik_mi(govde):
|
||||
"""Araç çağrısı veya commentary görüldüyse adım nihai cevap değildir.
|
||||
|
||||
Tam SSE satırları üzerinden bakılır; parçalı olay bir sonraki parçada tamamlanır.
|
||||
Araç argümanında geçen olay adı hazırlık sayılmaz.
|
||||
"""
|
||||
for satir in govde.splitlines():
|
||||
if not satir.startswith(b'data:'):
|
||||
continue
|
||||
try:
|
||||
olay = json.loads(satir[5:])
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
continue
|
||||
if not isinstance(olay, dict):
|
||||
continue
|
||||
tip = str(olay.get('type') or '')
|
||||
oge = olay.get('item') or {}
|
||||
if tip in ('response.output_item.added', 'response.output_item.done') and isinstance(oge, dict):
|
||||
if str(oge.get('type') or '').endswith('_call') or oge.get('phase') == 'commentary':
|
||||
return True
|
||||
if tip in ('response.function_call_arguments.delta', 'response.function_call_arguments.done',
|
||||
'response.custom_tool_call_input.delta', 'response.custom_tool_call_input.done'):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def komutlar(govde):
|
||||
"""Yazisi olmayan adimda Jev'e giden metin: modelin bu adimda cagirdigi araclar (ad ve arguman).
|
||||
govde: ust kaynagin SSE yaniti (bayt). Arac cagrisi yoksa ""."""
|
||||
satirlar = []
|
||||
for satir in govde.splitlines():
|
||||
if not satir.startswith(b"data:") or b"response.output_item.done" not in satir:
|
||||
continue
|
||||
try:
|
||||
oge = json.loads(satir[5:]).get("item") or {}
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
if isinstance(oge, dict) and str(oge.get("type") or "").endswith("_call"):
|
||||
arguman = oge.get("arguments") or oge.get("input") or oge.get("action") or ""
|
||||
if not isinstance(arguman, str):
|
||||
arguman = json.dumps(arguman, ensure_ascii=False)
|
||||
satirlar.append(f"[komut] {oge.get('name') or oge.get('type')}: {arguman}")
|
||||
return "\n".join(satirlar)
|
||||
|
||||
|
||||
def _gonder(govde, key, settings=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:
|
||||
return json.load(yanit)
|
||||
|
||||
|
||||
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)."""
|
||||
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()}}
|
||||
for name, criteria in settings.get('criteria', {}).items():
|
||||
values = {k: v for k, v in criteria.items() if v.strip()}
|
||||
if values:
|
||||
govde['questions'][name]['criteria'] = values
|
||||
if not jev_settings.acquire(settings):
|
||||
return None
|
||||
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})
|
||||
if any(not 0 <= v <= 1 for v in result.values()):
|
||||
return None
|
||||
result.thresholds = settings['thresholds']
|
||||
return result
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
jev_settings.release()
|
||||
|
||||
|
||||
def karar(puan):
|
||||
"""True: adim gecmez, sonraki katman yapar. Istisna yok."""
|
||||
thresholds = getattr(puan, 'thresholds', None) or jev_settings.read()[0]['thresholds']
|
||||
return puan["ret"] >= thresholds["ret"] or puan["sart"] >= thresholds["sart"]
|
||||
|
||||
|
||||
def hata_gecis():
|
||||
return jev_settings.read()[0]['failureAction'] == 'next'
|
||||
|
||||
|
||||
def etkin_mi():
|
||||
return bool(jev_settings.read()[0]['enabled'])
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Per-user settings, re-read for new evaluations without restarting active chats."""
|
||||
import base64
|
||||
import copy
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULTS = json.loads(Path(__file__).with_name('jev-settings-defaults.json').read_text(encoding='utf-8'))
|
||||
_condition = threading.Condition()
|
||||
_active = 0
|
||||
|
||||
|
||||
def home():
|
||||
if '--home' in sys.argv:
|
||||
return Path(sys.argv[sys.argv.index('--home') + 1])
|
||||
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']
|
||||
# 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:
|
||||
raise ValueError('Invalid Jev endpoint')
|
||||
for name in ('ret', 'sart'):
|
||||
n = settings['thresholds'][name]
|
||||
if not isinstance(n, (int, float)) or not 0 <= n <= 1 or not settings['questions'][name]:
|
||||
raise ValueError('Invalid Jev rule')
|
||||
for name in ('timeoutSeconds', 'queueWaitSeconds'):
|
||||
if not isinstance(settings[name], int) or not 1 <= settings[name] <= 3600:
|
||||
raise ValueError('Invalid Jev timeout')
|
||||
if not isinstance(settings['maxConcurrent'], int) or not 0 <= settings['maxConcurrent'] <= 512:
|
||||
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')
|
||||
|
||||
|
||||
def key():
|
||||
_, encrypted = read()
|
||||
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()
|
||||
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
|
||||
if not decrypt(ctypes.byref(source), None, None, None, None, 0, ctypes.byref(result)):
|
||||
raise ctypes.WinError()
|
||||
try:
|
||||
return ctypes.string_at(result.pbData, result.cbData).decode('utf-8')
|
||||
finally:
|
||||
ctypes.windll.kernel32.LocalFree.argtypes = [ctypes.c_void_p]
|
||||
ctypes.windll.kernel32.LocalFree(result.pbData)
|
||||
|
||||
|
||||
def acquire(settings):
|
||||
global _active
|
||||
deadline = time.monotonic() + settings['queueWaitSeconds']
|
||||
with _condition:
|
||||
while settings['maxConcurrent'] and _active >= settings['maxConcurrent']:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return False
|
||||
_condition.wait(remaining)
|
||||
_active += 1
|
||||
return True
|
||||
|
||||
|
||||
def release():
|
||||
global _active
|
||||
with _condition:
|
||||
_active -= 1
|
||||
_condition.notify_all()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,393 @@
|
||||
"""Sesle ya da yaziyla "modeli sunu yap" komutu ve sohbet basina model yonlendirmesi (10 Ekim 2026).
|
||||
|
||||
Olcum (sesli sohbet 01a12780): telefonda secilen model bilgisayara yalnizca YENI TUR baslatan yazili mesajla
|
||||
ulasiyor. Calisan tura eklenen mesaj (nokta dahil) secimi tasimiyor; dort denemenin ucunde secim kayboldu.
|
||||
Codex'in pencereler arasi kanali da sohbet bir pencerede acik degilse istegi kabul etmiyor. Bu yuzden degisiklik
|
||||
koprude yapilir: kullanici "modeli gemini flash low yap" der (sesli asistan bunu her soz gibi ana modele iletir),
|
||||
kopru cumleyi tanir, o sohbetin isteklerini secilen modele yonlendirir ve cevabi kendisi verir.
|
||||
|
||||
- Model bostayken: kopru yerel cevap verir ("Sohbetin modeli artik ...").
|
||||
- Model calisirken (tur ortasi): komut mesaji kisa bir devir notuna cevrilir ve AYNI tur yeni modelle surer;
|
||||
yarim kalan is kesilmez, yeni model kaldigi yerden devam eder.
|
||||
- Kullanici sonradan listeden baska model secerse (Codex'in gonderdigi ad degisir) yonlendirme kendiliginden kalkar.
|
||||
|
||||
Ad bulunamazsa mesaj komut sayilmaz, modele oldugu gibi gider. Birden cok aday varsa degisiklik yapilmaz, adaylar sorulur.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import collections
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
DOSYA = "model-yonlendirme.json"
|
||||
OMUR_SN = 7 * 24 * 3600
|
||||
YENILE_SN = 24 * 3600
|
||||
EN_COK_KAYIT = 200
|
||||
EN_COK_KELIME = 14
|
||||
_KILIT = threading.Lock()
|
||||
|
||||
_TR = str.maketrans("çğıöşüâîû", "cgiosuaiu")
|
||||
# Yalniz olumlu emir/istek bicimleri: "yapma", "gecme", "yapay", "secili" gibi sozler komut fiili degildir.
|
||||
FIILLER = {"gec", "gecir", "gecelim", "gecirelim", "gecer", "gecirir", "gecsene", "degis", "degistir", "degistirelim",
|
||||
"degistirir", "yap", "yapalim", "yapar", "yapsana", "sec", "secelim", "secer", "kullan", "kullanalim",
|
||||
"cevir", "cevirelim", "olsun", "ayarla", "ayarlayalim"}
|
||||
OLUMSUZ = ("yapma", "gecme", "gecirme", "degistirme", "secme", "kullanma", "cevirme", "olmasin", "ayarlama", "degisme")
|
||||
# "Hangi model daha ucuz?" genel sorudur; yalniz bu sohbetin modelini soran soz kopruden cevaplanir.
|
||||
SORU_ISARETI = {"secili", "aktif", "anki", "suanki", "sohbet", "sohbetin", "sohbette", "chat", "chatte", "chatin", "ana",
|
||||
"modelsin", "modeldesin", "modeldeyiz", "modeldeyim", "kullaniyorsun", "kullaniliyor", "calisiyor"}
|
||||
SORU = ("hangi", "hangisi", "kim", "kimdir")
|
||||
ATLA = {"model", "modeli", "modelini", "modele", "modeline", "modelin", "modelle", "modeller", "su", "bu", "o", "bir",
|
||||
"olarak", "ile", "lutfen", "artik", "ana", "sohbet", "sohbetin", "chat", "chatin", "chatte", "simdi", "hadi",
|
||||
"bana", "icin", "de", "da", "ve", "mi", "misin", "musun", "mu", "yeni", "diger", "baska", "olan", "codex",
|
||||
"ya", "e", "a", "i", "u", "yi", "ye", "na", "ne", "nu", "seni", "sen", "biz", "onu", "ona", "nokta"}
|
||||
# Yaziyla soylenen surum rakamlari ("opus bes bes", "uc nokta sekiz"). "bir" belirtec de olabildigi icin
|
||||
# ("bir model sec") yalniz baska bir rakamin ya da "nokta"nin yaninda rakam sayilir.
|
||||
SAYI = {"sifir": "0", "bir": "1", "iki": "2", "uc": "3", "dort": "4", "bes": "5", "alti": "6", "yedi": "7",
|
||||
"sekiz": "8", "dokuz": "9"}
|
||||
# Komutun ardindan gelen ve is tasimayan ek ("... yap ve devam et"): komutu dusurmez, atilir.
|
||||
DEVAM_EKI = {"ve", "sonra", "devam", "et", "edelim", "edin", "lutfen"}
|
||||
# Konusmadan yaziya ceviride sik gorulen yazimlar -> model adindaki parca
|
||||
TAKMA = {"cloud": "claude", "klod": "claude", "klaud": "claude", "claud": "claude", "opus": "opus", "opüs": "opus",
|
||||
"cemini": "gemini", "jemini": "gemini", "cemin": "gemini", "flas": "flash", "fles": "flash",
|
||||
"kuven": "qwen", "kiven": "qwen", "kuen": "qwen", "kven": "qwen", "kodeks": "codex",
|
||||
"dusuk": "low", "yuksek": "high", "orta": "medium", "sonet": "sonnet", "haiku": "haiku",
|
||||
"sansursuz": "uncensored", "ansansird": "uncensored", "dusunen": "thinking", "dusunmeli": "thinking",
|
||||
"ciptii": "gpt", "cipiti": "gpt", "gipiti": "gpt"}
|
||||
|
||||
|
||||
# Listede kisa yazilan saglayici -> konusurken soylenen ad
|
||||
SAGLAYICI = {"cc": ("claude",), "agy": ("gemini", "antigravity"), "ag": ("gemini", "antigravity"), "cx": ("codex", "gpt")}
|
||||
|
||||
|
||||
def duz(metin):
|
||||
return str(metin or "").lower().translate(_TR)
|
||||
|
||||
|
||||
def parcala(metin):
|
||||
"""Harf ve rakam obeklerine ayirir: "agy/gemini-3.8-flash-low" -> agy gemini 3 8 flash low."""
|
||||
return [TAKMA.get(p, p) for p in re.findall(r"[a-z]+|\d+", duz(metin))]
|
||||
|
||||
|
||||
def son_sira(girdi):
|
||||
"""Son kullanici mesajinin sirasi. Arkasindaki talimat (developer/system) mesajlari atlanir: kancalar
|
||||
kullanici mesajinin ardina baglam ekler (olcum 10 Ekim 2026). Baska bir oge varsa -1."""
|
||||
if not isinstance(girdi, list):
|
||||
return -1
|
||||
for i in range(len(girdi) - 1, -1, -1):
|
||||
oge = girdi[i]
|
||||
if isinstance(oge, dict) and oge.get("type", "message") == "message":
|
||||
if oge.get("role") == "user":
|
||||
return i
|
||||
if oge.get("role") in ("developer", "system"):
|
||||
continue
|
||||
return -1
|
||||
return -1
|
||||
|
||||
|
||||
def son_kullanici_metni(req):
|
||||
"""(metin, sira, sesli). Son girdi kullanici metin mesaji degilse (None, -1, False).
|
||||
Sesli aktarimda (<realtime_delegation>) yalnizca <input> icindeki soz alinir."""
|
||||
girdi = req.get("input")
|
||||
sira = son_sira(girdi)
|
||||
if sira < 0:
|
||||
return None, -1, False
|
||||
son = girdi[sira]
|
||||
icerik = son.get("content")
|
||||
if isinstance(icerik, str):
|
||||
metin = icerik
|
||||
elif isinstance(icerik, list) and icerik and all(isinstance(p, dict) and p.get("type") in ("input_text", "text")
|
||||
for p in icerik):
|
||||
metin = "".join(str(p.get("text") or "") for p in icerik)
|
||||
else:
|
||||
return None, -1, False
|
||||
m = re.search(r"<realtime_delegation>.*?<input>(.*?)</input>", metin, re.S)
|
||||
if m:
|
||||
return m.group(1).strip(), sira, True
|
||||
if "<" in metin and ">" in metin:
|
||||
return None, -1, False # baska bir sarmalli sistem mesaji; komut aranmaz
|
||||
return metin.strip(), sira, False
|
||||
|
||||
|
||||
def _sayilari_cevir(kelimeler):
|
||||
cikti = []
|
||||
for i, k in enumerate(kelimeler):
|
||||
if k in SAYI:
|
||||
komsu = [kelimeler[j] for j in (i - 1, i + 1) if 0 <= j < len(kelimeler)]
|
||||
if k != "bir" or any(x == "nokta" or x.isdigit() or (x in SAYI and x != "bir") for x in komsu):
|
||||
cikti.append(SAYI[k])
|
||||
continue
|
||||
cikti.append(k)
|
||||
return cikti
|
||||
|
||||
|
||||
DOLGU = {"evet", "tamam", "hadi", "peki", "tabii", "hayir", "yani", "sey", "ee", "eee", "hmm", "olur", "simdi"}
|
||||
|
||||
|
||||
def _harfleri_birlestir(kelimeler):
|
||||
"""Harf harf soylenen ad tek sozcuk olur: "s e e d iki nokta bir turbo" -> "seed iki nokta bir turbo"."""
|
||||
cikti, harfler = [], []
|
||||
for k in kelimeler + [""]:
|
||||
if len(k) == 1 and k.isalpha():
|
||||
harfler.append(k)
|
||||
continue
|
||||
if len(harfler) >= 3:
|
||||
cikti.append("".join(harfler))
|
||||
else:
|
||||
cikti.extend(harfler)
|
||||
harfler = []
|
||||
if k:
|
||||
cikti.append(k)
|
||||
# Ad hem heceleyerek hem duz soylendiyse ("seed s e e d ...") tekrar atilir.
|
||||
return [k for i, k in enumerate(cikti) if i == 0 or k != cikti[i - 1]]
|
||||
|
||||
|
||||
def komut_coz(metin):
|
||||
"""None | {"tur": "gec", "ad": [parcalar]} | {"tur": "sor"}."""
|
||||
kelimeler = _harfleri_birlestir(re.findall(r"[a-z0-9]+", duz(metin)))
|
||||
if not kelimeler or len(kelimeler) > EN_COK_KELIME:
|
||||
return None
|
||||
if not any(k.startswith("model") for k in kelimeler):
|
||||
# "modeli" denmeden soylenen komut ("Seed iki nokta bir turbo yap", "gemini flash low'a gec"): yalniz cumle
|
||||
# bir komut fiiliyle bitiyorsa ve ad bir modele TAM uyuyorsa komut sayilir (kesin=False: cagiran, ad
|
||||
# bulunamazsa mesaji komut saymaz, modele birakir).
|
||||
if any(k.startswith(OLUMSUZ) for k in kelimeler) or kelimeler[-1] not in FIILLER or len(kelimeler) > 9:
|
||||
return None
|
||||
kelimeler = _sayilari_cevir(kelimeler)
|
||||
ad = [TAKMA.get(k, k) for k in kelimeler[:-1] if k not in ATLA and k not in DOLGU]
|
||||
ad = [p for k in ad for p in re.findall(r"[a-z]+|\d+", k)]
|
||||
return {"tur": "gec", "ad": ad, "kesin": False} if len(ad) >= 2 else None
|
||||
if len(kelimeler) <= 8 and any(k in SORU for k in kelimeler):
|
||||
return {"tur": "sor"} if any(k in SORU_ISARETI for k in kelimeler) else None
|
||||
if any(k.startswith(OLUMSUZ) for k in kelimeler):
|
||||
return None
|
||||
fiil = [k for k in kelimeler if k in FIILLER]
|
||||
if fiil:
|
||||
son_fiil = max(i for i, k in enumerate(kelimeler) if k in FIILLER)
|
||||
if all(k in DEVAM_EKI for k in kelimeler[son_fiil + 1:]):
|
||||
kelimeler = kelimeler[:son_fiil + 1]
|
||||
kelimeler = _sayilari_cevir(kelimeler)
|
||||
ad = [TAKMA.get(k, k) for k in kelimeler if k not in ATLA and k not in fiil and k not in DOLGU]
|
||||
ad = [p for k in ad for p in re.findall(r"[a-z]+|\d+", k)]
|
||||
# Ad soylenmemis ("modeli değiştir", "başka modele geç"): degisiklik yok, nasil soylenecegi anlatilir.
|
||||
return {"tur": "gec", "ad": ad, "kesin": True} if ad else {"tur": "nasil"}
|
||||
return None
|
||||
|
||||
|
||||
def _uyar(sorgu, parca):
|
||||
"""Turkce ek almis soz ("geminiye", "opusa") model parcasiyla baslar."""
|
||||
if sorgu == parca:
|
||||
return True
|
||||
if len(parca) >= 3 and sorgu.startswith(parca) and len(sorgu) - len(parca) <= 4 and not sorgu.isdigit():
|
||||
return True
|
||||
# Yarim soylenen ya da yarim yaziya dokulen soz adin basidir: "think" -> thinking, "obli" -> obliterated
|
||||
# (olcum 11 Ekim 2026: "qwen 3.8 27b obli think" uc kez bulunamadi).
|
||||
return len(sorgu) >= 4 and sorgu.isalpha() and parca.startswith(sorgu)
|
||||
|
||||
|
||||
def eslestir(ad, modeller):
|
||||
"""ad: sorgu parcalari; modeller: [(slug, gorunen ad)].
|
||||
Doner: {"slug", "ad"} (tek aday) | {"adaylar": [gorunen adlar]} (belirsiz) | None (ad bir modele benzemiyor)."""
|
||||
if not ad:
|
||||
return None
|
||||
puanli = []
|
||||
for slug, gorunen in modeller:
|
||||
parcalar = parcala(gorunen) or parcala(slug)
|
||||
kume = set(parcalar) | set(parcala(slug))
|
||||
for kisa, uzunlar in SAGLAYICI.items():
|
||||
if kisa in kume:
|
||||
kume.update(uzunlar)
|
||||
tutan = sum(1 for s in ad if any(_uyar(s, p) for p in kume))
|
||||
harf = sum(1 for s in ad if not s.isdigit() and any(_uyar(s, p) for p in kume))
|
||||
if harf: # yalniz rakam tutmasi ("3", "5") esleme sayilmaz
|
||||
kendi = [p for p in parcalar if p not in SAGLAYICI and p not in ("nano", "ide", "9router")]
|
||||
hepsi = len(kendi) if kendi and all(any(_uyar(s, p) for s in ad) for p in kendi) else 0
|
||||
puanli.append((tutan, -len(parcalar), slug, gorunen, harf, hepsi))
|
||||
if not puanli:
|
||||
return None
|
||||
en = max(p[0] for p in puanli)
|
||||
harfler = [s for s in ad if not s.isdigit()]
|
||||
en_harf = max(p[4] for p in puanli)
|
||||
if en_harf < len(harfler):
|
||||
return None # adin bir sozcugu hicbir modelde yok: komut degil
|
||||
iyi = sorted((p for p in puanli if p[0] == en and p[4] == len(harfler)), key=lambda p: (-p[1], p[3]))
|
||||
if not iyi:
|
||||
return None
|
||||
if en < len(ad): # sozcukler tuttu ama surum rakami uymadi: sor, tahmin etme
|
||||
return {"adaylar": [p[3] for p in iyi[:4]]}
|
||||
if len(iyi) == 1:
|
||||
return {"slug": iyi[0][2], "ad": iyi[0][3]}
|
||||
tam = [p for p in iyi if p[5]] # adinin butun parcalari soylenen model
|
||||
if tam:
|
||||
# Ayni adi tasiyanlar arasinda sira: Codex'in kendi abonelik modeli, sonra adi en uzun (en ozgul) olan,
|
||||
# sonra saglayici/model bicimindeki tekil model. Hala esitse sorulur.
|
||||
def oncelik(p):
|
||||
return (p[2].startswith("ide/"), -p[5], "/" not in p[3])
|
||||
tam.sort(key=oncelik)
|
||||
if len(tam) == 1 or oncelik(tam[0]) != oncelik(tam[1]):
|
||||
return {"slug": tam[0][2], "ad": tam[0][3]}
|
||||
return {"adaylar": [p[3] for p in tam[:4]]}
|
||||
# Adaylardan biri digerlerinin "fazladan sozcuklu" hali degilse sorulur. Biri digerlerinin govdesiyse
|
||||
# ("...-obliterated" ile "...-obliterated:thinking") soylenmeyen eki tasimayan secilir: kullanici "thinking"
|
||||
# demediyse dusunmeyeni istiyordur (olcum 11 Ekim 2026). Fark surum rakamiysa tahmin edilmez, sorulur.
|
||||
sayim = [(collections.Counter(parcala(p[3]) or parcala(p[2])), p) for p in iyi]
|
||||
sayim.sort(key=lambda c: sum(c[0].values()))
|
||||
govde, secilen = sayim[0]
|
||||
if all(not (govde - c) and (c - govde) and not any(k.isdigit() for k in (c - govde)) for c, _ in sayim[1:]):
|
||||
return {"slug": secilen[2], "ad": secilen[3]}
|
||||
return {"adaylar": [p[3] for p in iyi[:4]]}
|
||||
|
||||
|
||||
_ARAC = {"function_call", "function_call_output", "custom_tool_call", "custom_tool_call_output", "reasoning",
|
||||
"local_shell_call", "local_shell_call_output", "tool_search_call", "tool_search_output"}
|
||||
|
||||
|
||||
def tur_ortasi(req, sira):
|
||||
"""Mesaj calisan bir turun icine mi dustu? Geriye dogru, kullanici/talimat mesajlarini atlayarak bakilir:
|
||||
once arac ogesi ya da ara (commentary) mesaj gelirse tur suruyordur; once son cevap gelirse tur yeni baslamistir."""
|
||||
girdi = req.get("input") or []
|
||||
for oge in reversed(girdi[:max(sira, 0)]):
|
||||
if not isinstance(oge, dict):
|
||||
continue
|
||||
tur = oge.get("type", "message")
|
||||
if tur in _ARAC:
|
||||
return True
|
||||
if tur == "message":
|
||||
if oge.get("role") == "assistant":
|
||||
return oge.get("phase") == "commentary"
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def metni_degistir(req, sira, yeni):
|
||||
"""sira'daki kullanici mesajinin metnini yeni ile degistirir (sesli aktarimda yalnizca <input> icini)."""
|
||||
oge = req["input"][sira]
|
||||
icerik = oge.get("content")
|
||||
eski = icerik if isinstance(icerik, str) else "".join(str(p.get("text") or "") for p in icerik)
|
||||
if "<realtime_delegation>" in eski:
|
||||
metin = re.sub(r"(<input>).*?(</input>)", lambda m: m.group(1) + yeni + m.group(2), eski, count=1, flags=re.S)
|
||||
else:
|
||||
metin = yeni
|
||||
oge["content"] = metin if isinstance(icerik, str) else [{"type": icerik[0].get("type", "input_text"), "text": metin}]
|
||||
|
||||
|
||||
def gecmisi_temizle(req, modeller, etkin=None):
|
||||
"""Gecmisteki (son mesaj disindaki) islenmis "modeli X yap" komutlarini kisa nota cevirir; yoksa sonraki
|
||||
model ayni cumleyi yeniden gorup "modeli ben degistiremem" diye cevap veriyor (olcum 11 Ekim 2026).
|
||||
etkin: sohbetin su an gercekten kullandigi model. Verilirse, en son kullanici mesajindaki komut (ardindan
|
||||
arac ciktisi ya da sikistirma tetigi geldigi icin "gecmis" gorunen) yalniz bu modele gecirdiyse nota cevrilir;
|
||||
uygulanmamis komut ("baglanti kapali" denen gibi) "degistirildi" diye anlatilmaz.
|
||||
Doner: degistirilen mesaj sayisi."""
|
||||
girdi = req.get("input")
|
||||
if not isinstance(girdi, list):
|
||||
return 0
|
||||
son = son_sira(girdi)
|
||||
son_kullanici = max((i for i, oge in enumerate(girdi) if isinstance(oge, dict)
|
||||
and oge.get("type", "message") == "message" and oge.get("role") == "user"), default=-1)
|
||||
n = 0
|
||||
for i, oge in enumerate(girdi):
|
||||
if i == son or not (isinstance(oge, dict) and oge.get("type", "message") == "message" and oge.get("role") == "user"):
|
||||
continue
|
||||
metin, _, _ = son_kullanici_metni({"input": [oge]})
|
||||
if not metin or "model" not in duz(metin) or metin.startswith("[Köprü notu"):
|
||||
continue
|
||||
komut = komut_coz(metin)
|
||||
sonuc = eslestir(komut["ad"], modeller) if komut and komut["tur"] == "gec" else None
|
||||
if sonuc and "slug" in sonuc:
|
||||
if etkin is not None and i == son_kullanici and sonuc["slug"] != etkin:
|
||||
continue
|
||||
metni_degistir(req, i, f"[Köprü notu: kullanıcı burada sohbetin modelini {sonuc['ad']} olarak değiştirdi; "
|
||||
"değişiklik köprü tarafından yapıldı, senin bir şey yapman gerekmiyor.]")
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
BILGI_ISARETI = "[Model değiştirme]"
|
||||
BILGI = (BILGI_ISARETI + " Bu sohbetin modeli söylenerek değiştirilebilir: kullanıcı \"modeli <ad> yap\" dediğinde "
|
||||
"(örnek: \"modeli gemini flash low yap\", \"modeli opus beş beş yap\") değişikliği aradaki köprü yapar; senin bir "
|
||||
"araç çağırman ya da ayar değiştirmen gerekmez. Kullanıcı modeli değiştirip değiştiremeyeceğini sorarsa "
|
||||
"\"değiştiremem\" deme; bu cümleyi söylemesinin yeterli olduğunu kısaca anlat. Hangi modelde olduğunu öğrenmek "
|
||||
"için \"seçili model hangisi\" demesi yeter. Codex'in kendi modellerine (listenin başındaki abonelik modelleri) "
|
||||
"sesle geçilmez: kullanıcı onlardan birine geçmek isterse ekrandaki model seçiciden seçip tek başına \".\" "
|
||||
"yazması gerektiğini söyle.")
|
||||
NASIL = ("Hangi modele geçeyim? Adını söylemen yeterli, örneğin: \"modeli gemini flash low yap\" ya da "
|
||||
"\"modeli opus beş beş yap\". Şu anki modeli öğrenmek için \"seçili model hangisi\" de. Codex'in kendi "
|
||||
"modellerine sesle geçilmez; onlar için ekrandaki model seçiciden seçip tek başına \".\" yaz.")
|
||||
|
||||
|
||||
def bilgi_ekle(req):
|
||||
"""Modele, sohbetin modelinin soylenerek degistirilebildigini bildirir (olcum 11 Ekim 2026: Opus "bu sohbetin
|
||||
modelini buradan değiştiremem" dedi). Istegin basina tek developer mesaji; zaten varsa eklenmez. True: eklendi."""
|
||||
girdi = req.get("input")
|
||||
if not isinstance(girdi, list) or not girdi:
|
||||
return False
|
||||
for oge in girdi[:4]:
|
||||
icerik = oge.get("content") if isinstance(oge, dict) else None
|
||||
yazi = icerik if isinstance(icerik, str) else " ".join(str(p.get("text") or "") for p in icerik or [] if isinstance(p, dict))
|
||||
if BILGI_ISARETI in yazi:
|
||||
return False
|
||||
girdi.insert(0, {"type": "message", "role": "developer", "content": [{"type": "input_text", "text": BILGI}]})
|
||||
return True
|
||||
|
||||
|
||||
def devir_notu(yeni_ad, eski_ad):
|
||||
return (f"[Köprü notu: kullanıcı bu sohbetin modelini değiştirdi. Önceki model {eski_ad} idi; şu andan itibaren "
|
||||
f"cevap veren model sensin ({yeni_ad}). Yarım kalan işi kaldığı yerden sürdür, baştan başlama. "
|
||||
f"Cevabının ilk cümlesinde kısaca \"{yeni_ad} olarak devam ediyorum\" de.]")
|
||||
|
||||
|
||||
class Yonlendirme:
|
||||
"""sohbet anahtari -> {"gorulen": Codex'in gonderdigi ad, "hedef": yonlenen ad, "zaman"}; diske yazilir."""
|
||||
|
||||
def __init__(self, home):
|
||||
self.yol = os.path.join(home, DOSYA) if home else None
|
||||
self.veri = {}
|
||||
if self.yol:
|
||||
try:
|
||||
with open(self.yol, encoding="utf-8") as f:
|
||||
okunan = json.load(f)
|
||||
if isinstance(okunan, dict):
|
||||
self.veri = {k: v for k, v in okunan.items() if isinstance(v, dict) and v.get("hedef")}
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
def _yaz(self):
|
||||
if not self.yol:
|
||||
return
|
||||
try:
|
||||
gecici = self.yol + ".tmp"
|
||||
with open(gecici, "w", encoding="utf-8") as f:
|
||||
json.dump(self.veri, f, ensure_ascii=False)
|
||||
os.replace(gecici, self.yol)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def al(self, sohbet, gelen, simdi=None):
|
||||
"""Bu sohbet icin yonlenen ad; yoksa None. Codex baska bir ad gonderiyorsa (kullanici listeden yeni
|
||||
model secti) ya da kayit eskidiyse yonlendirme silinir."""
|
||||
simdi = simdi or time.time()
|
||||
with _KILIT:
|
||||
k = self.veri.get(sohbet)
|
||||
if not k:
|
||||
return None
|
||||
if k.get("gorulen") != gelen or simdi - float(k.get("zaman") or 0) > OMUR_SN:
|
||||
del self.veri[sohbet]
|
||||
self._yaz()
|
||||
return None
|
||||
if simdi - float(k.get("zaman") or 0) > YENILE_SN:
|
||||
# Kullanilan yonlendirme eskimez: omur son kullanimdan sayilir (gunde en cok bir yazma).
|
||||
k["zaman"] = simdi
|
||||
self._yaz()
|
||||
return k["hedef"]
|
||||
|
||||
def koy(self, sohbet, gelen, hedef, simdi=None):
|
||||
simdi = simdi or time.time()
|
||||
with _KILIT:
|
||||
if hedef == gelen:
|
||||
self.veri.pop(sohbet, None)
|
||||
else:
|
||||
self.veri[sohbet] = {"gorulen": gelen, "hedef": hedef, "zaman": simdi}
|
||||
if len(self.veri) > EN_COK_KAYIT:
|
||||
for eski in sorted(self.veri, key=lambda a: self.veri[a].get("zaman") or 0)[:-EN_COK_KAYIT]:
|
||||
del self.veri[eski]
|
||||
self._yaz()
|
||||
@@ -0,0 +1,573 @@
|
||||
"""Owned native Codex stdio adapter; inert until explicitly launched by an owner.
|
||||
|
||||
Only a trusted in-process root adapter may install Binding objects. There is no
|
||||
HTTP bind/RPC endpoint, tenant inference, or activated SSE subscription.
|
||||
appendSpeech acceptance is not proof of audible speech or history persistence.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import hashlib
|
||||
import json
|
||||
import queue
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from typing import BinaryIO, TypedDict, Callable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WatchReceipt:
|
||||
"""INTERNAL adapter contract, not a claim about backend wire field names.
|
||||
|
||||
Backend resolver authenticates an opaque handle using the member credential
|
||||
against a trusted, configured Omni endpoint. Backend secret stays server-side.
|
||||
The resolver must verify tenant, source request thread and history together.
|
||||
No URL or identity supplied by a notice is trusted as subscription authority.
|
||||
"""
|
||||
opaque_handle: str
|
||||
tenant_id: str
|
||||
bekci_session_id: str
|
||||
history_revision: str
|
||||
thread_id: str
|
||||
expires_at: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestCorrelation:
|
||||
"""Captured by the owned HTTP bridge from the actual incoming request.
|
||||
|
||||
Must be captured before metadata arrives, not reconstructed from its body.
|
||||
generation is the mux epoch observed at request capture, never the latest tab.
|
||||
"""
|
||||
connection_id: str
|
||||
thread_id: str
|
||||
generation: int
|
||||
|
||||
|
||||
class WatchReceiptAdapter:
|
||||
"""One consumer; root chooses transport/decoder after backend agreement.
|
||||
|
||||
decode extracts only the opaque handle from bekci.watch SSE data or the
|
||||
nonstream _bekci_watch value. resolve performs authenticated backend lookup.
|
||||
Missing agreement is fail-closed, not fabricated ready metadata.
|
||||
"""
|
||||
def __init__(self, mux, *, decode: Callable[[object], str] | None = None,
|
||||
resolve: Callable[[str], WatchReceipt] | None = None):
|
||||
self.mux, self.decode, self.resolve = mux, decode, resolve
|
||||
self._accepted = {}
|
||||
|
||||
def capture(self, headers: dict, body: dict) -> RequestCorrelation | None:
|
||||
normalized = {k.lower(): v for k, v in headers.items()}
|
||||
values = [normalized.get('session-id'), normalized.get('thread-id'), body.get('prompt_cache_key')]
|
||||
if not all(isinstance(v, str) and v for v in values) or len(set(values)) != 1:
|
||||
return None
|
||||
snap = self.mux.snapshot()
|
||||
state = snap['threads'].get(values[0], {})
|
||||
if snap['closed'] or not state.get('active'):
|
||||
return None
|
||||
return RequestCorrelation(snap['connection_id'], values[0], state['generation'])
|
||||
|
||||
def accept(self, transport: str, payload: object, correlation: RequestCorrelation | None) -> str:
|
||||
if transport not in ('bekci.watch', '_bekci_watch'):
|
||||
return 'unsupported_metadata_transport'
|
||||
if self.decode is None or self.resolve is None:
|
||||
return 'backend_contract_not_established'
|
||||
if correlation is None or correlation.connection_id != self.mux.connection_id:
|
||||
return 'request_correlation_missing'
|
||||
try:
|
||||
handle = self.decode(payload)
|
||||
if not isinstance(handle, str) or not 1 <= len(handle) <= 4096:
|
||||
return 'invalid_receipt'
|
||||
receipt = self.resolve(handle)
|
||||
except Exception:
|
||||
return 'receipt_verification_failed' # never expose credential-bearing exception text
|
||||
if not isinstance(receipt, WatchReceipt) or receipt.opaque_handle != handle:
|
||||
return 'invalid_receipt'
|
||||
if (receipt.thread_id != correlation.thread_id or type(receipt.expires_at) not in (int, float)
|
||||
or not time.time() < receipt.expires_at <= time.time() + 3600
|
||||
or not all(isinstance(v, str) and 0 < len(v) <= 256 for v in
|
||||
(receipt.tenant_id, receipt.bekci_session_id, receipt.history_revision, receipt.thread_id))):
|
||||
return 'receipt_mismatch_or_expired'
|
||||
binding = Binding(receipt.tenant_id, receipt.bekci_session_id, receipt.history_revision,
|
||||
correlation.connection_id, receipt.thread_id, correlation.generation)
|
||||
previous = self._accepted.get(handle)
|
||||
if previous is not None and previous != (receipt, binding):
|
||||
return 'receipt_replay_mismatch'
|
||||
if previous is None and len(self._accepted) >= 4096:
|
||||
return 'capacity_exhausted'
|
||||
if not self.mux.bind(binding):
|
||||
return 'stale_generation'
|
||||
self._accepted[handle] = (receipt, binding)
|
||||
return 'bound'
|
||||
|
||||
def notice(self, handle: str, notice: Notice) -> dict:
|
||||
accepted = self._accepted.get(handle)
|
||||
if accepted is None:
|
||||
return {'status': 'binding_not_established'}
|
||||
receipt, binding = accepted
|
||||
if time.time() >= receipt.expires_at:
|
||||
return {'status': 'receipt_expired'}
|
||||
expected = dict(tenant_id=binding.tenant_id, bekci_session_id=binding.bekci_session_id,
|
||||
history_revision=binding.history_revision, thread_id=binding.thread_id,
|
||||
connection_id=binding.connection_id, expected_generation=binding.generation)
|
||||
if not isinstance(notice, dict) or any(notice.get(k) != v for k, v in expected.items()):
|
||||
return {'status': 'binding_mismatch'}
|
||||
return self.mux.submit(notice)
|
||||
|
||||
|
||||
def verified_original(path: str, expected_sha256: str) -> str:
|
||||
"""Owner-pinned absolute original; no PATH/override discovery or extra engine."""
|
||||
candidate = Path(path)
|
||||
if not candidate.is_absolute() or not candidate.is_file() or candidate.resolve() == Path(__file__).resolve():
|
||||
raise ValueError('original executable must be an explicitly pinned absolute file')
|
||||
if len(expected_sha256) != 64:
|
||||
raise ValueError('original executable hash required')
|
||||
with candidate.open('rb') as stream:
|
||||
actual = hashlib.file_digest(stream, 'sha256').hexdigest()
|
||||
if not hmac.compare_digest(actual, expected_sha256.lower()):
|
||||
raise ValueError('original executable hash mismatch')
|
||||
return str(candidate.resolve())
|
||||
|
||||
|
||||
def stdio_invocation(args: list[str]) -> bool:
|
||||
"""Installed Desktop tc()/Ks() grammar, preserving every original argument."""
|
||||
seen, i = False, 0
|
||||
while i < len(args):
|
||||
arg = args[i]
|
||||
if arg in ('-c', '--config') and i + 1 < len(args):
|
||||
i += 2
|
||||
continue
|
||||
if arg == 'app-server' and not seen:
|
||||
seen = True
|
||||
elif seen and arg in ('--stdio', '--analytics-default-enabled'):
|
||||
pass
|
||||
elif seen and arg == '--listen' and args[i+1:i+2] == ['stdio://']:
|
||||
i += 1
|
||||
else:
|
||||
return False
|
||||
i += 1
|
||||
return seen
|
||||
|
||||
def launch_original(path: str, expected_sha256: str, args: list[str], *,
|
||||
attach: Callable[[WatchReceiptAdapter], None] | None = None) -> int:
|
||||
"""Source launcher entry for a future normal bridge module copy.
|
||||
|
||||
No activation/config mutation. Unknown invocation grammar passes native stdio
|
||||
directly. Owner callback wires only this consumer after backend agreement.
|
||||
No second engine, binary patch, token file or discovery of current UI tab.
|
||||
"""
|
||||
original = verified_original(path, expected_sha256)
|
||||
observer = stdio_invocation(args)
|
||||
mux = NativeSpeechMux([original, *args], observer=observer)
|
||||
if observer and attach is not None:
|
||||
try:
|
||||
attach(WatchReceiptAdapter(mux))
|
||||
except Exception:
|
||||
# Optional adapter failure cannot take down native stdio. Invalidate
|
||||
# any partial setup so no correction can escape a failed attachment.
|
||||
mux._disabled = True
|
||||
mux._bindings.clear()
|
||||
return mux.run()
|
||||
|
||||
|
||||
class Notice(TypedDict):
|
||||
notice_id: str
|
||||
tenant_id: str
|
||||
bekci_session_id: str
|
||||
history_revision: str
|
||||
connection_id: str
|
||||
thread_id: str
|
||||
expected_generation: int
|
||||
issued_at: float
|
||||
expires_at: float
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Binding:
|
||||
"""Local scope labels (legacy field names), NOT literal server identity claims.
|
||||
|
||||
Trusted owner attests this opaque receipt scope belongs to this native epoch.
|
||||
|
||||
Install only from independently verified mapping, NEVER from notice fields.
|
||||
Rebind explicitly for a new history revision or realtime generation.
|
||||
"""
|
||||
tenant_id: str
|
||||
bekci_session_id: str
|
||||
history_revision: str
|
||||
connection_id: str
|
||||
thread_id: str
|
||||
generation: int
|
||||
|
||||
|
||||
def _object(raw: bytes) -> dict:
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
return value if isinstance(value, dict) else {}
|
||||
except (ValueError, UnicodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _id(value):
|
||||
return (type(value), value) if type(value) in (str, int) else None
|
||||
|
||||
|
||||
class NativeSpeechMux:
|
||||
"""One original process, bounded FIFO, one writer in each direction.
|
||||
|
||||
argv is the original executable plus its original arguments. observer=True
|
||||
must only be selected for a verified stdio app-server invocation. The CLI
|
||||
deliberately recognizes only the exact ['app-server'] invocation; all other
|
||||
invocations inherit the original stdio directly. No environment/cwd rewrite.
|
||||
"""
|
||||
def __init__(self, argv: list[str], *, observer: bool = False):
|
||||
self.argv = list(argv)
|
||||
self.observer = observer
|
||||
self.connection_id = secrets.token_hex(24)
|
||||
self._prefix = 'bekci-speech-' + secrets.token_hex(24) + ':'
|
||||
self._token = secrets.token_urlsafe(32)
|
||||
self._lock = threading.RLock()
|
||||
self._queue = queue.Queue(maxsize=32)
|
||||
self._ended = threading.Event()
|
||||
self._eof = False
|
||||
self._disabled = False
|
||||
self._threads = {}
|
||||
self._starts = set()
|
||||
self._bindings = {}
|
||||
self._receipts = {}
|
||||
self._pending = {}
|
||||
self._counter = 0
|
||||
self._server = None
|
||||
self._process = None
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
with self._lock:
|
||||
return {'connection_id': self.connection_id,
|
||||
'binding_status': 'established' if self._bindings else 'binding_not_established',
|
||||
'threads': {k: dict(v) for k, v in self._threads.items()},
|
||||
'closed': self._eof or self._ended.is_set(),
|
||||
'injection_disabled': self._disabled}
|
||||
|
||||
def bind(self, binding: Binding) -> bool:
|
||||
"""Trusted local API only. No untrusted endpoint can invoke this."""
|
||||
with self._lock:
|
||||
st = self._threads.get(binding.thread_id, {})
|
||||
if (binding.connection_id != self.connection_id or not st.get('active')
|
||||
or st.get('generation') != binding.generation or self._eof
|
||||
or self._ended.is_set()):
|
||||
return False
|
||||
self._bindings[(binding.tenant_id, binding.bekci_session_id)] = binding
|
||||
return True
|
||||
|
||||
def _invalidate(self, thread):
|
||||
if not isinstance(thread, str):
|
||||
return
|
||||
if thread in self._threads:
|
||||
self._threads[thread]['active'] = False
|
||||
self._bindings = {k: b for k, b in self._bindings.items() if b.thread_id != thread}
|
||||
|
||||
def _observe_client(self, obj):
|
||||
with self._lock:
|
||||
rid = _id(obj.get('id'))
|
||||
# Responses to server requests may legitimately reuse our ID.
|
||||
if 'method' in obj and isinstance(obj.get('id'), str) and obj['id'].startswith(self._prefix):
|
||||
self._disabled = True
|
||||
self._pending.pop(obj['id'], None)
|
||||
if obj.get('method') in ('thread/start', 'thread/resume', 'thread/fork') and rid:
|
||||
if len(self._starts) < 4096:
|
||||
self._starts.add(rid)
|
||||
else:
|
||||
self._disabled = True
|
||||
if obj.get('method') in ('thread/realtime/stop', 'thread/realtime/start', 'thread/unsubscribe'):
|
||||
params = obj.get('params')
|
||||
if isinstance(params, dict):
|
||||
self._invalidate(params.get('threadId'))
|
||||
|
||||
def _observe_server(self, obj) -> bool:
|
||||
with self._lock:
|
||||
rid = obj.get('id')
|
||||
response = 'method' not in obj and (('result' in obj) != ('error' in obj))
|
||||
if response and isinstance(rid, str) and rid in self._pending:
|
||||
key = self._pending.pop(rid)
|
||||
self._receipts[key]['status'] = 'rpc_error' if 'error' in obj else 'rpc_accepted'
|
||||
return True
|
||||
if response and _id(rid) in self._starts:
|
||||
self._starts.discard(_id(rid))
|
||||
result = obj.get('result')
|
||||
thread = result.get('thread', {}) if isinstance(result, dict) else {}
|
||||
tid = thread.get('id') if isinstance(thread, dict) else None
|
||||
if isinstance(tid, str):
|
||||
self._threads.setdefault(tid, {'generation': 0, 'active': False, 'realtime_session_id': None})
|
||||
params = obj.get('params')
|
||||
if not isinstance(params, dict) or 'id' in obj:
|
||||
return False
|
||||
tid = params.get('threadId')
|
||||
if not isinstance(tid, str):
|
||||
return False
|
||||
method = obj.get('method')
|
||||
if method == 'thread/realtime/started':
|
||||
self._invalidate(tid)
|
||||
previous = self._threads.get(tid, {}).get('generation', 0)
|
||||
self._threads[tid] = {'generation': previous + 1, 'active': True,
|
||||
'realtime_session_id': params.get('realtimeSessionId')}
|
||||
elif method in ('thread/realtime/closed', 'thread/realtime/error'):
|
||||
self._invalidate(tid)
|
||||
return False
|
||||
|
||||
def _validate(self, notice):
|
||||
if not isinstance(notice, dict) or set(notice) != set(Notice.__annotations__):
|
||||
return 'invalid_schema'
|
||||
for key in Notice.__annotations__:
|
||||
if key not in ('expected_generation', 'issued_at', 'expires_at'):
|
||||
if not isinstance(notice[key], str) or not notice[key].strip() or len(notice[key]) > (8000 if key == 'text' else 256):
|
||||
return 'invalid_schema'
|
||||
if type(notice['expected_generation']) is not int or notice['expected_generation'] < 1:
|
||||
return 'invalid_schema'
|
||||
if any(type(notice[k]) not in (int, float) for k in ('issued_at', 'expires_at')):
|
||||
return 'invalid_schema'
|
||||
now = time.time()
|
||||
if not (now - 60 <= notice['issued_at'] <= now + 2 and now < notice['expires_at'] <= notice['issued_at'] + 60):
|
||||
return 'expired_or_invalid_ttl'
|
||||
if self._eof or self._ended.is_set():
|
||||
return 'closed'
|
||||
if self._disabled or not self.observer:
|
||||
return 'injection_disabled'
|
||||
binding = self._bindings.get((notice['tenant_id'], notice['bekci_session_id']))
|
||||
if binding is None:
|
||||
return 'binding_not_established'
|
||||
if binding != Binding(notice['tenant_id'], notice['bekci_session_id'], notice['history_revision'],
|
||||
notice['connection_id'], notice['thread_id'], notice['expected_generation']):
|
||||
return 'binding_mismatch'
|
||||
st = self._threads.get(binding.thread_id, {})
|
||||
if not st.get('active') or st.get('generation') != binding.generation:
|
||||
return 'stale_generation'
|
||||
return None
|
||||
|
||||
def submit(self, notice: Notice) -> dict:
|
||||
with self._lock:
|
||||
error = self._validate(notice)
|
||||
if error:
|
||||
return {'status': error}
|
||||
key = (notice['tenant_id'], notice['bekci_session_id'], notice['notice_id'])
|
||||
if key in self._receipts:
|
||||
return dict(self._receipts[key])
|
||||
# Never evict dedupe during this process lifetime or retry uncertain sends.
|
||||
if len(self._receipts) >= 4096:
|
||||
return {'status': 'capacity_exhausted'}
|
||||
receipt = {'status': 'queued'}
|
||||
self._receipts[key] = receipt
|
||||
try:
|
||||
self._queue.put_nowait(('notice', (dict(notice), key)))
|
||||
except queue.Full:
|
||||
del self._receipts[key]
|
||||
return {'status': 'busy'}
|
||||
return dict(receipt)
|
||||
|
||||
def start_inlet(self) -> tuple[str, str]:
|
||||
"""Return loopback URL and secret to trusted owner IN MEMORY; never log.
|
||||
|
||||
One bounded request at a time; 2s timeout applies only to inlet HTTP reads,
|
||||
never the original engine. Owner must arrange private credential delivery.
|
||||
"""
|
||||
if not self.observer or self._server is not None:
|
||||
raise ValueError('inlet requires an unused app-server observer')
|
||||
mux = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def setup(self):
|
||||
super().setup()
|
||||
self.connection.settimeout(2)
|
||||
|
||||
def do_POST(self):
|
||||
try:
|
||||
auth = self.headers.get('Authorization', '')
|
||||
if not hmac.compare_digest(auth.encode(), ('Bearer ' + mux._token).encode()):
|
||||
self.send_error(401)
|
||||
return
|
||||
if self.headers.get('Origin') or self.headers.get('Transfer-Encoding'):
|
||||
self.send_error(400)
|
||||
return
|
||||
lengths = self.headers.get_all('Content-Length', [])
|
||||
if len(lengths) != 1 or not lengths[0].isdigit() or not 0 < int(lengths[0]) <= 65536:
|
||||
self.send_error(400)
|
||||
return
|
||||
raw = self.rfile.read(int(lengths[0]))
|
||||
if len(raw) != int(lengths[0]):
|
||||
self.send_error(400)
|
||||
return
|
||||
if self.path == '/notice':
|
||||
result = mux.submit(_object(raw))
|
||||
elif self.path == '/status' and _object(raw) == {}:
|
||||
result = mux.snapshot()
|
||||
else:
|
||||
self.send_error(404)
|
||||
return
|
||||
body = json.dumps(result).encode()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
|
||||
self._server = HTTPServer(('127.0.0.1', 0), Handler)
|
||||
threading.Thread(target=self._server.serve_forever, daemon=True).start()
|
||||
return f'http://127.0.0.1:{self._server.server_port}', self._token
|
||||
|
||||
def _put(self, item):
|
||||
while not self._ended.is_set():
|
||||
try:
|
||||
self._queue.put(item, timeout=.1)
|
||||
return True
|
||||
except queue.Full:
|
||||
pass
|
||||
return False
|
||||
|
||||
def run(self, source: BinaryIO | None = None, target: BinaryIO | None = None) -> int:
|
||||
if not self.observer:
|
||||
return subprocess.call(self.argv) # exact inherited stdin/stdout/stderr/env/cwd
|
||||
source = source if source is not None else sys.stdin.buffer
|
||||
target = target if target is not None else sys.stdout.buffer
|
||||
process = self._process = subprocess.Popen(self.argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
|
||||
|
||||
def read_client():
|
||||
fragment = False
|
||||
while not self._ended.is_set():
|
||||
raw = source.readline(65536)
|
||||
if not raw:
|
||||
with self._lock:
|
||||
self._eof = True
|
||||
self._put(('eof', None))
|
||||
return
|
||||
complete = raw.endswith(b'\n')
|
||||
if not fragment and complete:
|
||||
self._observe_client(_object(raw))
|
||||
elif not complete:
|
||||
# Oversized records still forward byte-for-byte, but cannot be
|
||||
# safely observed. Fail closed for injection for this connection.
|
||||
with self._lock:
|
||||
self._disabled = True
|
||||
fragment = not complete
|
||||
if not self._put(('raw', raw)):
|
||||
return
|
||||
|
||||
def send_notice(value):
|
||||
notice, key = value
|
||||
with self._lock:
|
||||
error = self._validate(notice)
|
||||
if error:
|
||||
self._receipts[key]['status'] = error
|
||||
return
|
||||
self._counter += 1
|
||||
rid = self._prefix + str(self._counter)
|
||||
self._pending[rid] = key
|
||||
self._receipts[key]['status'] = 'sent_unknown'
|
||||
raw = json.dumps({'id': rid, 'method': 'thread/realtime/appendSpeech',
|
||||
'params': {'threadId': notice['thread_id'], 'text': notice['text']}},
|
||||
ensure_ascii=True).encode() + b'\n'
|
||||
# No lock during blocking pipe I/O: observer must drain server output.
|
||||
# Protocol has no server-side generation precondition; this race remains.
|
||||
process.stdin.write(raw)
|
||||
process.stdin.flush()
|
||||
|
||||
def write_server():
|
||||
partial = False
|
||||
try:
|
||||
while not self._ended.is_set():
|
||||
try:
|
||||
kind, value = self._queue.get(timeout=.1)
|
||||
except queue.Empty:
|
||||
continue
|
||||
if kind == 'eof':
|
||||
return
|
||||
if kind == 'notice':
|
||||
if partial:
|
||||
# An oversized frame has disabled injection; reject now.
|
||||
with self._lock:
|
||||
self._receipts[value[1]]['status'] = 'injection_disabled'
|
||||
else:
|
||||
send_notice(value)
|
||||
else:
|
||||
# Re-apply lifecycle invalidation at the actual write boundary
|
||||
# too: a prior server start may have arrived while queued.
|
||||
if not partial and value.endswith(b'\n'):
|
||||
obj = _object(value)
|
||||
if obj.get('method') in ('thread/realtime/start', 'thread/realtime/stop'):
|
||||
params = obj.get('params')
|
||||
if isinstance(params, dict):
|
||||
with self._lock:
|
||||
self._invalidate(params.get('threadId'))
|
||||
process.stdin.write(value)
|
||||
process.stdin.flush()
|
||||
partial = not value.endswith(b'\n')
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
process.stdin.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
reader = threading.Thread(target=read_client, daemon=True)
|
||||
writer = threading.Thread(target=write_server, daemon=True)
|
||||
reader.start()
|
||||
writer.start()
|
||||
fragment = False
|
||||
try:
|
||||
while True:
|
||||
raw = process.stdout.readline(65536)
|
||||
if not raw:
|
||||
break
|
||||
complete = raw.endswith(b'\n')
|
||||
consumed = not fragment and complete and self._observe_server(_object(raw))
|
||||
if not complete:
|
||||
with self._lock:
|
||||
self._disabled = True
|
||||
fragment = not complete
|
||||
if not consumed:
|
||||
target.write(raw)
|
||||
target.flush()
|
||||
finally:
|
||||
self._ended.set()
|
||||
process.stdout.close()
|
||||
with self._lock:
|
||||
self._bindings.clear()
|
||||
for st in self._threads.values():
|
||||
st['active'] = False
|
||||
for receipt in self._receipts.values():
|
||||
if receipt['status'] == 'queued':
|
||||
receipt['status'] = 'closed'
|
||||
if self._server:
|
||||
self._server.shutdown()
|
||||
self._server.server_close()
|
||||
return process.wait() # no deadline on legitimate native work
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# Future launcher must pin/verify the original executable, never resolve the
|
||||
# override recursively. No CODEX_CLI_PATH configuration is performed here.
|
||||
if len(sys.argv) >= 5 and sys.argv[1] == '--pinned-original' and sys.argv[4] == '--':
|
||||
try:
|
||||
return launch_original(sys.argv[2], sys.argv[3], sys.argv[5:])
|
||||
except ValueError:
|
||||
print('original executable verification failed', file=sys.stderr)
|
||||
return 2
|
||||
if len(sys.argv) < 3 or sys.argv[2] != '--':
|
||||
print('usage: native_speech_mux.py --pinned-original ORIGINAL_EXECUTABLE SHA256 -- ORIGINAL_ARGS...', file=sys.stderr)
|
||||
return 2
|
||||
argv = [sys.argv[1], *sys.argv[3:]]
|
||||
return NativeSpeechMux(argv, observer=argv[1:] == ['app-server']).run()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Owned startup entry. No user environment/config changes or override discovery."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from native_speech_mux import NativeSpeechMux, stdio_invocation, verified_original
|
||||
from bekci_watch import Ledger, WatchOwner
|
||||
|
||||
def main(manifest, args):
|
||||
cfg = json.loads(Path(manifest).read_text(encoding='utf-8-sig'))
|
||||
original = verified_original(cfg['original'], cfg['original_sha256'])
|
||||
if not stdio_invocation(args):
|
||||
return subprocess.call([original,*args])
|
||||
# Existing public catalog and original model passthrough handler is reused.
|
||||
# A per-child CLI setting selects the ephemeral owned bridge; no global file edit.
|
||||
saved_env = dict(os.environ)
|
||||
os.environ['AITURK_CODEX_BRIDGE_HOME'] = cfg['bridge_home']
|
||||
os.environ['HERMES_HOME'] = cfg['hermes_home']
|
||||
try:
|
||||
import kopru
|
||||
finally:
|
||||
os.environ.clear(); os.environ.update(saved_env)
|
||||
from http.server import ThreadingHTTPServer
|
||||
server = ThreadingHTTPServer(('127.0.0.1',0), kopru.H)
|
||||
mux = NativeSpeechMux([original,*args,'-c',f'openai_base_url="http://127.0.0.1:{server.server_port}/backend-api/codex"'],observer=True)
|
||||
Path(cfg['bridge_home']).mkdir(parents=True,exist_ok=True)
|
||||
owner = WatchOwner(mux,Ledger(str(Path(cfg['bridge_home'])/'native-watch-ledger.sqlite3')))
|
||||
kopru.WATCH_OWNER = owner
|
||||
threading.Thread(target=server.serve_forever,daemon=True).start()
|
||||
try:
|
||||
return mux.run()
|
||||
finally:
|
||||
owner.close(); server.shutdown(); server.server_close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
raise SystemExit(main(sys.argv[1],sys.argv[2:]))
|
||||
except (OSError,ValueError,KeyError):
|
||||
sys.stderr.write('Owned native launcher configuration/integrity failure.\n')
|
||||
raise SystemExit(78)
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Sikistirma (ozet) bagdastiricisi (6 Ekim 2026, kart a-grubu-bakim-20261006-0417).
|
||||
|
||||
Codex 0.160.0 "remote compaction v2" kullanir: girdinin sonuna {"type": "compaction_trigger"} koyar ve yanitta tam bir
|
||||
{"type": "compaction", "encrypted_content": ...} ogesi bekler. Gercek olcum (raporlar/sesli-kopru-kanit-20261006/sik1, sik2):
|
||||
* 9Router modelleri bu ogeyi uretemez -> "remote compaction v2 expected exactly one compaction output item, got 0",
|
||||
tur hata verir (kucuk modele geciste ve 9Router modelinde esik asilinca).
|
||||
* OpenAI'nin urettigi sifreli ozet 9Router modelince okunamaz -> kararlar/gerekceler kaybolur.
|
||||
|
||||
Bu modul:
|
||||
1. 9Router'a giden sikistirma isteginde ozeti duz metin olarak ayni (ya da bridge.json ozet_kopru.model ile secilen)
|
||||
modele yazdirir ve Codex'in bekledigi "compaction" ogesine sarar (encrypted_content = ISARET + base64(metin)).
|
||||
2. Koprunun urettigi ozet ogelerini her giden istekte duz metin kullanici mesajina acar (hangi saglayiciya giderse).
|
||||
3. OpenAI'nin sifreli ozeti 9Router modeline gidecekse, OpenAI'ye bir kez "bu ozeti duz metne dok" diye sorar,
|
||||
sonucu diske onbellekler ve onu gonderir; olmazsa durust bir not koyar (sessiz kayip yok).
|
||||
OpenAI -> OpenAI sikistirmasi oldugu gibi (yerlesik) kalir.
|
||||
Kapatmak: bridge.json "ozet_kopru": {"enabled": false}
|
||||
"""
|
||||
import base64, hashlib, json, os, re
|
||||
|
||||
ISARET = "ASTRABRIDGE-OZET-1:"
|
||||
OZET_BASLIK = "<onceki_konusma_ozeti>"
|
||||
OZET_BITIS = "</onceki_konusma_ozeti>"
|
||||
|
||||
OZET_ISTEMI = """BAGLAM KONTROL NOKTASI (bu mesaj kullanicidan degil, kopru altyapisindan): Bu konusma baglam sinirina yaklastigi icin ozetlenip baska bir modele devredilecek. Simdi arac CAGIRMA; yalniz asagidaki ozeti yaz. Ozeti okuyacak model bu konusmayi goremeyecek, yalniz senin yazdigini gorecek; bu yuzden kisa tutmak icin bilgi atma. Bu ozet istegini kullanicinin istegi gibi ozetleme.
|
||||
|
||||
Kurallar:
|
||||
- Kullanicinin ve konusmanin kullandigi etiket, kimlik ve adlari (ornek: "KARAR-1", kart/is numaralari, dosya adlari, model adlari) HARFI HARFINE koru ve her bilgiyi kendi etiketiyle yaz.
|
||||
- Sayilari, tarihleri, yollari, komutlari ve onemli ozgun cumleleri degistirmeden aktar.
|
||||
- Uydurma; emin olmadigin seyi "dogrulanmadi" diye isaretle. Sifre, anahtar ve gizli degerleri yazma.
|
||||
|
||||
Su basliklarla, madde madde ve somut yaz:
|
||||
1. KULLANICININ ISTEGI VE HEDEF: kullanicinin kendi sozleriyle ne istedigi, kabul olcutleri, kisitlar.
|
||||
2. KARARLAR VE GEREKCELERI: verilen her karar ve neden verildigi (etiketleriyle); reddedilen secenekler ve nedeni.
|
||||
3. YAPILANLAR VE ONEMLI SONUCLAR: degisen dosyalar, calistirilan komutlar ve gercek ciktilari, test/olcum sonuclari, dogrulanan ve dogrulanamayan seyler.
|
||||
4. ACIK ISLER VE SIRADAKI ADIM: bitmemis isler, bekleyen sorular/onaylar, bir sonraki somut adim.
|
||||
5. KULLANICI TERCIHLERI VE UYARILAR: kalici talimatlar, yapilmamasi istenenler, bilinen tuzaklar ve hatalar.
|
||||
6. GEREKLI BASVURULAR: yollar, adresler, kart/is numaralari, model ve ayar adlari."""
|
||||
|
||||
COZ_ISTEMI = ("Yukaridaki sikistirilmis konusma ozetinin icerigini, bu konusmayi hic gormemis baska bir modelin isi "
|
||||
"surdurebilmesi icin eksiksiz ve ayrintili DUZ METIN olarak yaz: kullanicinin istegi, kararlar ve gerekceleri, "
|
||||
"yapilanlar ve onemli sonuclar, acik isler ve siradaki adim, kullanici tercihleri, gerekli yollar/kimlikler. "
|
||||
"Arac cagirma, yorum ekleme, uydurma. Sifre ve anahtar yazma.")
|
||||
|
||||
OKUNAMADI_NOTU = ("(Kopru notu: Bu noktadan onceki konusma, onceki modelin sifreli ozetinde duruyor ve bu modele "
|
||||
"aktarilamadi. Onceki kararlari bilmiyorsan uydurma; kullaniciya sor.)")
|
||||
|
||||
|
||||
KISA_MESAJI = ("AstraBridge: Sohbet ozeti cok kisa cikti (%d karakter, en az %d bekleniyordu) ve gecmisin yerine "
|
||||
"konmadi; mesajlariniz sohbette duruyor. Tekrar deneyin ya da daha guclu bir model secin.")
|
||||
|
||||
|
||||
def en_az_karakter(girdi_bayt, ayar=None):
|
||||
"""Bu buyuklukteki bir konusmanin ozeti en az kac karakter olmali.
|
||||
Yalniz bozuk/bos sayilacak kadar kisa ozeti yakalar; ozetin yeterliligini boyuttan cikarmaz.
|
||||
Olcum (10 Ekim 2026, 646.955 baytlik gunluk + bes olgu): guclu modelin dogru ozeti 1500-1700
|
||||
karakterdi; boyuta oranli yuksek esik (2587) bu dogru ozeti reddedip sohbeti kilitledi.
|
||||
Hafiza kaybinin asil onlemi ozeti guclu modele yazdirmaktir (bridge.py _ozet_openai)."""
|
||||
ayar = ayar or {}
|
||||
tavan = int(ayar.get("en_az_tavan", 300))
|
||||
oran = max(1, int(ayar.get("en_az_oran", 2000))) # her 2000 bayt girdi icin 1 karakter
|
||||
return max(0, min(tavan, int(girdi_bayt) // oran))
|
||||
|
||||
|
||||
def tetik_mi(req):
|
||||
"""Istek bir sikistirma istegi mi (girdinin son ogesi compaction_trigger)?"""
|
||||
ogeler = req.get("input")
|
||||
return (isinstance(ogeler, list) and bool(ogeler) and isinstance(ogeler[-1], dict)
|
||||
and ogeler[-1].get("type") == "compaction_trigger")
|
||||
|
||||
|
||||
def ozet_ogesi(metin, kimlik_tohumu=""):
|
||||
"""Duz metin ozeti Codex'in bekledigi compaction ogesine sarar."""
|
||||
kimlik = "cmp_astrabridge_" + hashlib.sha256((kimlik_tohumu + metin).encode("utf-8")).hexdigest()[:32]
|
||||
return {"type": "compaction", "id": kimlik,
|
||||
"encrypted_content": ISARET + base64.b64encode(metin.encode("utf-8")).decode("ascii")}
|
||||
|
||||
|
||||
def ozet_metni(oge):
|
||||
"""Koprunun urettigi compaction ogesinden duz metni cikarir; kopru ogesi degilse None."""
|
||||
icerik = oge.get("encrypted_content") if isinstance(oge, dict) else None
|
||||
if not isinstance(icerik, str) or not icerik.startswith(ISARET):
|
||||
return None
|
||||
try:
|
||||
return base64.b64decode(icerik[len(ISARET):]).decode("utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _ozet_mesaji(metin):
|
||||
return {"type": "message", "role": "user",
|
||||
"content": [{"type": "input_text", "text": OZET_BASLIK + "\n" + metin.strip() + "\n" + OZET_BITIS}]}
|
||||
|
||||
|
||||
def ozetleri_ac(req, yabanci_hedef=False, coz=None):
|
||||
"""Giden istekteki compaction ogelerini hedefin okuyabilecegi bicime getirir (yerinde).
|
||||
- Koprunun urettigi ozet -> duz metin kullanici mesaji (her hedefte).
|
||||
- Baska saglayicinin (OpenAI) sifreli ozeti: hedef OpenAI ise dokunulmaz; hedef yabanciysa (9Router) coz(oge)
|
||||
ile duz metne cevrilir, olmazsa OKUNAMADI_NOTU konur.
|
||||
Doner: {'acilan': n, 'cozulen': n, 'okunamayan': n}"""
|
||||
bilgi = {"acilan": 0, "cozulen": 0, "okunamayan": 0}
|
||||
ogeler = req.get("input")
|
||||
if not isinstance(ogeler, list):
|
||||
return bilgi
|
||||
yeni = []
|
||||
for oge in ogeler:
|
||||
if isinstance(oge, dict) and oge.get("type") == "compaction":
|
||||
metin = ozet_metni(oge)
|
||||
if metin is not None:
|
||||
yeni.append(_ozet_mesaji(metin)); bilgi["acilan"] += 1
|
||||
continue
|
||||
if yabanci_hedef:
|
||||
cozulmus = None
|
||||
if coz is not None:
|
||||
try:
|
||||
cozulmus = coz(oge)
|
||||
except Exception:
|
||||
cozulmus = None
|
||||
if cozulmus:
|
||||
yeni.append(_ozet_mesaji(cozulmus)); bilgi["cozulen"] += 1
|
||||
else:
|
||||
yeni.append({"type": "message", "role": "user",
|
||||
"content": [{"type": "input_text", "text": OKUNAMADI_NOTU}]}); bilgi["okunamayan"] += 1
|
||||
continue
|
||||
yeni.append(oge)
|
||||
if bilgi["acilan"] or bilgi["cozulen"] or bilgi["okunamayan"]:
|
||||
req["input"] = yeni
|
||||
return bilgi
|
||||
|
||||
|
||||
def ozet_istegi_hazirla(req, istem=None):
|
||||
"""Sikistirma istegini (sonunda compaction_trigger) duz metin ozet istegine cevirir (yerinde)."""
|
||||
ogeler = [o for o in req.get("input") or [] if not (isinstance(o, dict) and o.get("type") == "compaction_trigger")]
|
||||
ogeler.append({"type": "message", "role": "user", "content": [{"type": "input_text", "text": istem or OZET_ISTEMI}]})
|
||||
req["input"] = ogeler
|
||||
req["tool_choice"] = "none"
|
||||
req["stream"] = True
|
||||
return req
|
||||
|
||||
|
||||
def _olay(d):
|
||||
return b"event: " + d["type"].encode() + b"\ndata: " + json.dumps(d, ensure_ascii=False).encode("utf-8") + b"\n\n"
|
||||
|
||||
|
||||
class OzetAkisi:
|
||||
"""Ozet isteginin SSE yanitini Codex'in bekledigi tek 'compaction' ogesine cevirir.
|
||||
ic: once calisan cevirici (SseRewriter). Metin gelmezse compaction ogesi uretmez (Codex hatayi gosterir)."""
|
||||
|
||||
def __init__(self, ic=None, tohum="", en_az=0):
|
||||
self.ic = ic
|
||||
self.tohum = tohum
|
||||
self.en_az = en_az # bundan kisa ozet kabul edilmez (bkz. en_az_karakter)
|
||||
self.kisa = False
|
||||
self.buf = b""
|
||||
self.parcalar = {} # item_id -> delta metni
|
||||
self.bitenler = [] # tamamlanan mesaj metinleri
|
||||
self.metin = None # sonuc (gunluk/test icin)
|
||||
self.bitti = False
|
||||
|
||||
def _topla(self):
|
||||
metin = "\n\n".join(t for t in self.bitenler if t.strip())
|
||||
if not metin.strip():
|
||||
metin = "\n\n".join(t for t in self.parcalar.values() if t.strip())
|
||||
return metin.strip()
|
||||
|
||||
def _blok(self, blok):
|
||||
veri = [s[5:].strip() for s in re.split(rb"\r?\n", blok) if s.startswith(b"data:")]
|
||||
if not veri:
|
||||
return b""
|
||||
try:
|
||||
d = json.loads(b"\n".join(veri))
|
||||
except Exception:
|
||||
return b""
|
||||
if not isinstance(d, dict):
|
||||
return b""
|
||||
t = d.get("type", "")
|
||||
if t == "response.created":
|
||||
return _olay(d)
|
||||
if t == "response.output_text.delta":
|
||||
self.parcalar[d.get("item_id")] = self.parcalar.get(d.get("item_id"), "") + (d.get("delta") or "")
|
||||
return b": ozet\n\n" # baglantiyi canli tutar; Codex yorum satirini yok sayar
|
||||
if t == "response.output_item.done":
|
||||
oge = d.get("item") or {}
|
||||
if oge.get("type") == "message":
|
||||
self.bitenler.append("".join(p.get("text", "") for p in oge.get("content") or []
|
||||
if isinstance(p, dict) and p.get("type") == "output_text"))
|
||||
return b": ozet\n\n"
|
||||
if t == "response.completed":
|
||||
self.metin = self._topla()
|
||||
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
|
||||
if len(self.metin) < self.en_az:
|
||||
# Anormal kisa ozet gecmisin yerine konmaz: Codex hatayi gosterir, sohbet oldugu gibi kalir.
|
||||
self.kisa = True
|
||||
yanit = dict(d.get("response") or {})
|
||||
yanit["status"] = "failed"; yanit["output"] = []
|
||||
yanit["error"] = {"code": "astrabridge_ozet_kisa", "message": KISA_MESAJI % (len(self.metin), self.en_az)}
|
||||
return _olay({"type": "response.failed", "response": yanit})
|
||||
oge = ozet_ogesi(self.metin, self.tohum)
|
||||
yanit = dict(d.get("response") or {})
|
||||
yanit["output"] = [oge]
|
||||
return (_olay({"type": "response.output_item.done", "output_index": 0, "item": oge})
|
||||
+ _olay({"type": "response.completed", "response": yanit}))
|
||||
if t in ("response.failed", "response.incomplete", "error"):
|
||||
return _olay(d)
|
||||
return b""
|
||||
|
||||
def sonuc_notu(self):
|
||||
"""Gunluk satiri icin sonuc (icerik yazilmaz)."""
|
||||
if not self.metin:
|
||||
return "ozet URETILEMEDI"
|
||||
if self.kisa:
|
||||
return f"ozet REDDEDILDI: {len(self.metin)} karakter, en az {self.en_az} bekleniyordu; gecmis korunuyor"
|
||||
return f"ozet uretildi, {len(self.metin)} karakter"
|
||||
|
||||
def feed(self, chunk):
|
||||
if self.ic:
|
||||
chunk = self.ic.feed(chunk)
|
||||
self.buf += chunk
|
||||
out = b""
|
||||
while True:
|
||||
m = re.search(rb"\r?\n\r?\n", self.buf)
|
||||
if not m:
|
||||
break
|
||||
blok, self.buf = self.buf[:m.start()], self.buf[m.end():]
|
||||
out += self._blok(blok)
|
||||
return out
|
||||
|
||||
def flush(self):
|
||||
if self.ic:
|
||||
self.buf += self.ic.flush()
|
||||
self.buf = b""
|
||||
return b""
|
||||
|
||||
|
||||
# ---- OpenAI'nin sifreli ozetini duz metne cevirme (onbellekli) ----
|
||||
def onbellek_yolu(klasor, oge):
|
||||
kimlik = str(oge.get("id") or hashlib.sha256(str(oge.get("encrypted_content")).encode()).hexdigest()[:32])
|
||||
return os.path.join(klasor, re.sub(r"[^A-Za-z0-9_.-]", "_", kimlik)[:80] + ".txt")
|
||||
|
||||
|
||||
def onbellekten(klasor, oge):
|
||||
try:
|
||||
with open(onbellek_yolu(klasor, oge), encoding="utf-8") as f:
|
||||
return f.read().strip() or None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def onbellege(klasor, oge, metin):
|
||||
os.makedirs(klasor, exist_ok=True)
|
||||
yol = onbellek_yolu(klasor, oge)
|
||||
with open(yol + ".tmp", "w", encoding="utf-8") as f:
|
||||
f.write(metin)
|
||||
os.replace(yol + ".tmp", yol)
|
||||
|
||||
|
||||
def coz_istegi(oge, model, talimat="Sen bir konusma ozeti aktaricisisin."):
|
||||
"""Sifreli ozeti duz metne dokturmek icin OpenAI'ye gidecek istek govdesi."""
|
||||
temiz = {k: v for k, v in oge.items() if k in ("type", "id", "encrypted_content")}
|
||||
return {"model": model, "instructions": talimat, "store": False, "stream": True, "tools": [],
|
||||
"tool_choice": "none", "parallel_tool_calls": False,
|
||||
"reasoning": {"effort": "low", "context": "all_turns"}, # chatgpt.com arka ucu bunu sart kosuyor
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"input": [temiz, {"type": "message", "role": "user", "content": [{"type": "input_text", "text": COZ_ISTEMI}]}]}
|
||||
|
||||
|
||||
def sse_metni(govde):
|
||||
"""SSE govdesinden (bayt) asistan metnini toplar. Yanit tamamlanmadiysa (akis yarida kesildi) None doner:
|
||||
yarim metin tam ozet diye kalici onbellege yazilmasin."""
|
||||
a = OzetAkisi()
|
||||
a.feed(govde if govde.endswith(b"\n\n") else govde + b"\n\n")
|
||||
return a.metin if a.bitti else None
|
||||
|
||||
|
||||
# ---- Dongu freni ----
|
||||
# Ozetten sonra da baglam hedefin esigini asiyorsa (Codex son kullanici mesajlarini aynen tuttugu icin olabilir) Codex
|
||||
# ayni turda tekrar tekrar sikistirma ister; her biri basarili oldugu icin dongu kendiliginden bitmez (olcum: sik6).
|
||||
DONGU_MESAJI = ("AstraBridge: Bu sohbetin baglami, ozetlendikten sonra da secili modelin sinirina sigmiyor "
|
||||
"(kisa surede %d ozet denendi). Dongu durduruldu. Daha buyuk baglam pencereli bir model secin ya da "
|
||||
"yeni sohbet acin; mesajlariniz sohbette duruyor.")
|
||||
|
||||
|
||||
class DonguFreni:
|
||||
"""Ozet dongusunu durdurur, mesru sik sikistirmayi durdurmaz (10 Ekim 2026: kucuk esikli sohbette
|
||||
10 dakikadaki 4. ozet reddedilip tur dusuyordu). Iki ayri sinir:
|
||||
- art_arda: arada normal (ozet olmayan) istek gecmeden ust uste gelen ozet sayisi; gercek dongu budur.
|
||||
- sinir: pencere_sn icindeki toplam ozet sayisi; her ozetin arasina normal istek giren yavas donguyu keser."""
|
||||
|
||||
def __init__(self, sinir=8, pencere_sn=600, art_arda=3):
|
||||
self.sinir = sinir
|
||||
self.pencere = pencere_sn
|
||||
self.art_arda = art_arda
|
||||
self.kayit = {}
|
||||
self.ust_uste = {}
|
||||
|
||||
def normal(self, anahtar):
|
||||
"""Bu sohbette ozet olmayan bir istek gecti: art arda sayaci sifirlanir."""
|
||||
self.ust_uste.pop(anahtar, None)
|
||||
|
||||
def izin(self, anahtar, simdi):
|
||||
"""Bu ozet istegine izin var mi? Varsa kaydeder."""
|
||||
zamanlar = [t for t in self.kayit.get(anahtar, []) if simdi - t < self.pencere]
|
||||
if not zamanlar:
|
||||
self.ust_uste.pop(anahtar, None) # pencere suresi doldu: eski art arda sayaci sohbeti kilitlemesin
|
||||
if len(zamanlar) >= self.sinir or self.ust_uste.get(anahtar, 0) >= self.art_arda:
|
||||
self.kayit[anahtar] = zamanlar
|
||||
return False
|
||||
zamanlar.append(simdi)
|
||||
self.kayit[anahtar] = zamanlar
|
||||
self.ust_uste[anahtar] = self.ust_uste.get(anahtar, 0) + 1
|
||||
if len(self.kayit) > 500: # bellek sismesin
|
||||
for k in sorted(self.kayit, key=lambda k: (self.kayit[k] or [0])[-1])[:250]:
|
||||
self.kayit.pop(k, None)
|
||||
self.ust_uste.pop(k, None)
|
||||
return True
|
||||
@@ -0,0 +1,385 @@
|
||||
"""Eklenen modellerin gercek baglam penceresi ve yetenekleri (10 Ekim 2026).
|
||||
|
||||
Neden: gecit model basina pencere vermiyor; kopru her modele abonelik kalibinin penceresini (272000)
|
||||
kopyaliyordu. Codex, kucuk pencereli modele geciste sohbeti kendisi sikistirir, ama bunu ilan edilen
|
||||
pencereye gore yapar (core/src/session/turn.rs maybe_run_previous_model_inline_compact). Pencere dogru
|
||||
ilan edilince o yol kendiliginden calisir; yanlis ilan edilince istek hedefe sigmaz.
|
||||
|
||||
Kaynak sirasi (kucukten guvenilire degil, guvenilirden yedege):
|
||||
1. Hedefin kendi hatasindan ogrenilen sinir (ogren / ogrenilen): en guvenilir, diske yazilir.
|
||||
2. NanoGPT'nin acik model listesi (nano/... modelleri icin istegi tasiyan saglayicinin kendi degeri).
|
||||
3. Saglayici kisaltmasina gore yerel tablo (cc, cx, ag).
|
||||
4. Bilinmeyen: VARSAYILAN (temkinli) ve bir kez gunluk satiri.
|
||||
Combo: yapraklarinin en kucugu (yedege dusulurse de sigsin).
|
||||
Sikistirici ve hedef modelin pencereleri ayri hesaplanir; genel abonelik tavani uygulanmaz.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
VARSAYILAN = 128_000 # hakkinda hicbir sey bilinmeyen model
|
||||
TABAN = 8_000 # saglayici verisi bozuksa (0, eksi) kullanilmaz
|
||||
NANO_URL = "https://nano-gpt.com/api/v1/models?detailed=true"
|
||||
NANO_OMUR_SN = 24 * 3600
|
||||
NANO_DOSYA = "pencere-nano.json"
|
||||
OGRENILEN_DOSYA = "pencere-ogrenilen.json"
|
||||
ARACSIZ_DOSYA = "aracsiz-modeller.json"
|
||||
# Saglayici kisaltmasi -> (pencere, gorsel girdi, arac cagrisi). cc = Claude Code aboneligi, cx = Codex
|
||||
# aboneligi, ag = Antigravity. Degerler abonelik yollarinin temkinli sinirlaridir.
|
||||
_YEREL = (
|
||||
(re.compile(r"^cx/"), (272_000, True, True)),
|
||||
(re.compile(r"^cc/"), (200_000, True, True)),
|
||||
(re.compile(r"^ag/claude"), (200_000, True, True)),
|
||||
(re.compile(r"^ag/gemini"), (1_000_000, True, True)),
|
||||
(re.compile(r"^ag/gpt-oss"), (131_072, False, True)),
|
||||
)
|
||||
# "maximum context length is 131072 tokens", "prompt is too long: 3040251 tokens > 1000000 maximum",
|
||||
# "context window of 32768 tokens", "exceeds the limit of 65536" ...
|
||||
_SINIR = (
|
||||
re.compile(r"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),
|
||||
re.compile(r"(?:limit|maximum) of (\d{4,8}) tokens", re.I),
|
||||
re.compile(r"max(?:imum)? context tokens\D{0,6}(\d{4,8})", re.I),
|
||||
)
|
||||
_TASMA = re.compile(r"context[_ ](?:length|window)[_ ]exceeded|maximum context length|prompt is too long|"
|
||||
r"context window|too many tokens|input is too long|exceeds? the (?:maximum |model.s )?context|max(?:imum)? context tokens", re.I)
|
||||
|
||||
|
||||
def _oku(yol):
|
||||
try:
|
||||
with open(yol, encoding="utf-8") as f:
|
||||
v = json.load(f)
|
||||
return v if isinstance(v, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _yaz(yol, veri):
|
||||
gecici = "%s.%d.tmp" % (yol, threading.get_ident()) # es zamanli iki yazma ayni gecici dosyada carpismasin
|
||||
with open(gecici, "w", encoding="utf-8") as f:
|
||||
json.dump(veri, f, ensure_ascii=False)
|
||||
os.replace(gecici, yol)
|
||||
|
||||
|
||||
def nano_sadelestir(ham):
|
||||
"""NanoGPT yaniti -> {model kimligi (kucuk harf): {"pencere", "gorsel", "arac"}}."""
|
||||
cikti = {}
|
||||
for m in (ham or {}).get("data") or []:
|
||||
kimlik = str(m.get("id") or "").lower()
|
||||
pencere = m.get("context_length")
|
||||
if not kimlik or not isinstance(pencere, int) or pencere < TABAN:
|
||||
continue
|
||||
yetenek = m.get("capabilities") or {}
|
||||
cikti[kimlik] = {"pencere": pencere, "gorsel": bool(yetenek.get("vision")),
|
||||
"arac": bool(yetenek.get("tool_calling"))}
|
||||
return cikti
|
||||
|
||||
|
||||
def nano_yukle(home, ua="AstraBridge/1.0 (codex)", timeout=20, simdi=None, getir=None, ag=True):
|
||||
"""Onbellek tazeyse onu, degilse agdan yenisini dondurur. Ag hatasinda eski onbellek (yasina bakilmadan)
|
||||
kullanilir; o da yoksa bos sozluk doner ve cagiran yerel tabloya duser. Hicbir durumda istisna atmaz.
|
||||
ag=False: aga cikilmaz, eldeki onbellek doner (koprunun acilisi beklemesin diye)."""
|
||||
simdi = time.time() if simdi is None else simdi
|
||||
yol = os.path.join(home, NANO_DOSYA) if home else ""
|
||||
onbellek = _oku(yol) if yol else {}
|
||||
try:
|
||||
taze = simdi - float(onbellek.get("zaman") or 0) < NANO_OMUR_SN
|
||||
except (TypeError, ValueError):
|
||||
taze = False # bozuk "zaman" alani yenilemeyi engellemez
|
||||
if onbellek.get("modeller") and taze:
|
||||
return onbellek["modeller"]
|
||||
if not ag:
|
||||
return onbellek.get("modeller") or {}
|
||||
try:
|
||||
if getir is None:
|
||||
istek = urllib.request.Request(NANO_URL, headers={"User-Agent": ua, "Accept": "application/json"})
|
||||
with urllib.request.urlopen(istek, timeout=timeout) as resp:
|
||||
ham = json.load(resp)
|
||||
else:
|
||||
ham = getir()
|
||||
modeller = nano_sadelestir(ham)
|
||||
if modeller:
|
||||
if yol:
|
||||
try:
|
||||
_yaz(yol, {"zaman": simdi, "modeller": modeller})
|
||||
except OSError:
|
||||
pass
|
||||
return modeller
|
||||
except Exception: # noqa: BLE001 - ag, JSON, sertifika: hepsi "yerel tabloya dus" demek
|
||||
pass
|
||||
return onbellek.get("modeller") or {}
|
||||
|
||||
|
||||
def ogrenilen(home):
|
||||
return {k: int(v) for k, v in _oku(os.path.join(home, OGRENILEN_DOSYA)).items()
|
||||
if isinstance(v, (int, float)) and v >= TABAN} if home else {}
|
||||
|
||||
|
||||
def aracsizlar(home):
|
||||
"""Hedefin "arac cagrisi desteklenmiyor" dedigi modeller (kimlik kumesi)."""
|
||||
return {k for k, v in _oku(os.path.join(home, ARACSIZ_DOSYA)).items() if v} if home else set()
|
||||
|
||||
|
||||
def aracsiz_mi(metin):
|
||||
return bool(re.search(r"does not support tool|tool_choice_unsupported|tools? (?:are|is) not supported|"
|
||||
r"function calling is not supported", metin or "", re.I))
|
||||
|
||||
|
||||
def ogren_aracsiz(home, kimlik):
|
||||
"""True: kayit yeni eklendi."""
|
||||
if not home or not kimlik:
|
||||
return False
|
||||
yol = os.path.join(home, ARACSIZ_DOSYA)
|
||||
veri = _oku(yol)
|
||||
if veri.get(kimlik):
|
||||
return False
|
||||
veri[kimlik] = True
|
||||
try:
|
||||
_yaz(yol, veri)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def kapali_hata_mi(metin):
|
||||
"""Gecit asil nedeni gizleyip yalniz "upstream 400" derse (olcum 11 Ekim 2026)."""
|
||||
return bool(re.fullmatch(r"\s*upstream 4(?:00|13)\s*", metin or "", re.I))
|
||||
|
||||
|
||||
_HIZ_SINIRI = re.compile(r"per minute|rate.?limit|tokens? per|\bTPM\b|\b429\b|too many requests", re.I)
|
||||
|
||||
|
||||
def hiz_siniri_mi(metin):
|
||||
"""Dakikalik hiz siniri hatasi baglam tasmasi degildir ("rate limit of 32768 tokens per minute")."""
|
||||
return bool(_HIZ_SINIRI.search(metin or ""))
|
||||
|
||||
|
||||
def tasma_mi(metin):
|
||||
return bool(_TASMA.search(metin or "")) and not hiz_siniri_mi(metin)
|
||||
|
||||
|
||||
def sinir_oku(metin):
|
||||
"""Hedefin tasma hatasindaki gercek sinir; yoksa None."""
|
||||
if hiz_siniri_mi(metin):
|
||||
return None
|
||||
for kalip in _SINIR:
|
||||
m = kalip.search(metin or "")
|
||||
if m and int(m.group(1)) >= TABAN:
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def ogren(home, kimlik, sinir):
|
||||
"""Hedefin bildirdigi siniri kaydeder (yalniz kucultur). True: kayit degisti."""
|
||||
if not home or not kimlik or not sinir or sinir < TABAN:
|
||||
return False
|
||||
yol = os.path.join(home, OGRENILEN_DOSYA)
|
||||
veri = _oku(yol)
|
||||
if isinstance(veri.get(kimlik), (int, float)) and veri[kimlik] <= sinir:
|
||||
return False
|
||||
veri[kimlik] = int(sinir)
|
||||
try:
|
||||
_yaz(yol, veri)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def yol_bilgisi(yol, nano):
|
||||
""""nano/z-ai/glm-5.3" ya da "cc/claude-opus-5-5" -> {"pencere","gorsel","arac","kaynak"} ya da None."""
|
||||
yol = (yol or "").strip()
|
||||
if yol.startswith("9router/"):
|
||||
yol = yol[len("9router/"):]
|
||||
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:
|
||||
return {**n, "kaynak": "nanogpt"}
|
||||
return None
|
||||
for kalip, (pencere, gorsel, arac) in _YEREL:
|
||||
if kalip.match(yol):
|
||||
return {"pencere": pencere, "gorsel": gorsel, "arac": arac, "kaynak": "yerel"}
|
||||
return None
|
||||
|
||||
|
||||
def _yapraklar(dugum, derinlik=0):
|
||||
if not isinstance(dugum, dict) or derinlik > 6:
|
||||
return []
|
||||
if dugum.get("kind") != "combo":
|
||||
return [dugum.get("model")] if dugum.get("model") else []
|
||||
cikti = []
|
||||
for uye in dugum.get("members") or []:
|
||||
cikti.extend(_yapraklar(uye, derinlik + 1))
|
||||
return cikti
|
||||
|
||||
|
||||
def coz(kimlik, tur, yol, kayit=None, nano=None, ogr=None, aracsiz=(), 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"}."""
|
||||
bilinmeyen = {"pencere": VARSAYILAN, "gorsel": False, "arac": True, "kaynak": "varsayilan"}
|
||||
if tur == "combo":
|
||||
yapraklar = _yapraklar((kayit or {}).get("topology"))
|
||||
bilgiler = [yol_bilgisi(y, nano) or dict(bilinmeyen) for y in yapraklar]
|
||||
if not bilgiler:
|
||||
sonuc = dict(bilinmeyen)
|
||||
else:
|
||||
sonuc = {"pencere": min(b["pencere"] for b in bilgiler),
|
||||
# Bir takim uyesi resmi goremese de digerleri gorur; sirali yedekte ilk uye belirler.
|
||||
"gorsel": bool(bilgiler[0]["gorsel"]), "arac": bool(bilgiler[0]["arac"]),
|
||||
"kaynak": "uyeler:" + ",".join(sorted({b["kaynak"] for b in bilgiler}))}
|
||||
else:
|
||||
# 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"}
|
||||
# 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
|
||||
|
||||
|
||||
def claude_var(tur, yol, kayit=None):
|
||||
"""Istek Claude'a gidebilir mi? Tek modelde yolundan, comboda yapraklarindan okunur (Opus-5 gibi combolarin
|
||||
adinda "claude" gecmez; token tahmini yine Claude oraniyla yapilmalidir)."""
|
||||
yollar = _yapraklar((kayit or {}).get("topology")) if tur == "combo" else [yol]
|
||||
for y in yollar:
|
||||
y = str(y or "").lower()
|
||||
if y.startswith("9router/"):
|
||||
y = y[len("9router/"):]
|
||||
if "claude" in y or y.startswith("cc/"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def bekci_calisir(kaynak, pencere_token):
|
||||
"""Tasma bekcisi bu pencereye guvenebilir mi? Hakkinda hicbir sey bilinmeyen modelde calismaz (tahmine
|
||||
dayanarak gecmis kirpilmaz). Comboda tek bilinmeyen uye, bilinen KUCUK uyenin sinirini gecersiz kilmaz:
|
||||
ilan edilen pencere VARSAYILAN'dan kucukse o deger bilinen bir uyeden gelmistir."""
|
||||
kaynak = kaynak or "varsayilan"
|
||||
if "varsayilan" not in kaynak:
|
||||
return True
|
||||
return kaynak.startswith("uyeler:") and kaynak != "uyeler:varsayilan" and int(pencere_token or 0) < VARSAYILAN
|
||||
|
||||
|
||||
def kisa(pencere):
|
||||
"""8192 -> "8K", 131072 -> "128K", 272000 -> "272K"."""
|
||||
return f"{round(pencere / 1024) if pencere < 262_144 and pencere % 1024 == 0 else round(pencere / 1000)}K"
|
||||
|
||||
|
||||
# ---- Istek oncesi tasma bekcisi ----
|
||||
# Codex sikistirmadan sonra kullanici mesajlarini AYNEN tutar; cok buyuk bir yapistirma (ya da dev arac
|
||||
# ciktisi) kucuk pencereli hedefe yine sigmaz ve hedef bos akisla doner (10 Ekim 2026 olcumu: 390 KB istek,
|
||||
# 64K pencere, Codex 5 kez yeniden deneyip "stream closed" ile dustu). Bekci, hedefe giden KOPYADA en buyuk
|
||||
# eski metin parcalarinin ortasini cikarir; yine sigmazsa istegi gondermeden anlasilir hata doner.
|
||||
BAYT_BOLU_TOKEN = 3.2 # olcum (10 Ekim 2026): 540 KB duz Turkce metin = 171016 token (3,16 bayt/token)
|
||||
DOLULUK = 0.97 # pencerenin bu oranini asan istek kirpilir / gonderilmez
|
||||
KIRP_ESIK = 6000 # bundan kisa metin parcasi kirpilmaz
|
||||
KIRP_BAS = KIRP_SON = 1500
|
||||
|
||||
|
||||
# Claude ayni metni daha cok tokena boler. Olcum (11 Ekim 2026): 607 KB'lik gercek Codex istegi 3,2 oraniyla
|
||||
# 189776 token tahmin edildi (pencere 200000) ve Claude tarafindan reddedildi; yani gercek oran 3,03'un altinda.
|
||||
CLAUDE_BAYT_BOLU_TOKEN = 2.6
|
||||
|
||||
|
||||
def oran_sec(hedef, claude=False):
|
||||
"""claude=True: hedefin adinda gecmese de (combo) istek Claude'a gidebilir (bkz. claude_var)."""
|
||||
return CLAUDE_BAYT_BOLU_TOKEN if (claude or "claude" in str(hedef or "").lower()) else BAYT_BOLU_TOKEN
|
||||
|
||||
|
||||
def tahmini_token(req, oran=None):
|
||||
return int(len(json.dumps(req, ensure_ascii=False).encode("utf-8")) / (oran or BAYT_BOLU_TOKEN))
|
||||
|
||||
|
||||
KOPRU_DEVAM_BASI = "(Önceki cevabın yarıda kaldı" # bridge.py claude_sonu_duzelt'in ekledigi mesajin basi
|
||||
|
||||
|
||||
def _kopru_mesaji(oge):
|
||||
icerik = oge.get("content")
|
||||
if isinstance(icerik, list):
|
||||
icerik = "".join(str(p.get("text") or "") for p in icerik if isinstance(p, dict))
|
||||
return isinstance(icerik, str) and icerik.startswith(KOPRU_DEVAM_BASI)
|
||||
|
||||
|
||||
def _son_arac_ciktisi(req):
|
||||
"""En yeni arac ciktisinin metin sahipleri (kimlik kumesi): model bir sonraki adimi buna gore atar,
|
||||
bu yuzden ancak baska hicbir sey yetmezse kirpilir."""
|
||||
for it in reversed(req.get("input") or []):
|
||||
if isinstance(it, dict) and "output" in it and str(it.get("type") or "").endswith("_output"):
|
||||
deger = it.get("output")
|
||||
return {id(it)} if isinstance(deger, str) else {id(p) for p in deger or [] if isinstance(p, dict)}
|
||||
return set()
|
||||
|
||||
|
||||
def _metin_parcalari(req):
|
||||
"""Kirpilabilir (sahip, anahtar) ciftleri: arac ciktilari ile eski kullanici/asistan mesajlari.
|
||||
Son kullanici mesajina ve talimat tasiyan developer/system mesajlarina (Codex kurallari, skill
|
||||
yonergeleri, kanca baglami) dokunulmaz."""
|
||||
girdi = req.get("input")
|
||||
if not isinstance(girdi, list):
|
||||
return []
|
||||
# Koprunun sona ekledigi "devam et" mesaji kullanicinin son mesaji degildir; korunan, ondan onceki gercek mesajdir.
|
||||
son_kullanici = max((i for i, it in enumerate(girdi) if isinstance(it, dict) and it.get("role") == "user"
|
||||
and it.get("type", "message") == "message" and not _kopru_mesaji(it)), default=-1)
|
||||
parcalar = []
|
||||
for i, it in enumerate(girdi):
|
||||
if not isinstance(it, dict) or i == son_kullanici or it.get("role") in ("developer", "system"):
|
||||
continue
|
||||
for alan in ("content", "output"):
|
||||
deger = it.get(alan)
|
||||
if isinstance(deger, str):
|
||||
parcalar.append((it, alan))
|
||||
elif isinstance(deger, list):
|
||||
for p in deger:
|
||||
if isinstance(p, dict) and isinstance(p.get("text"), str):
|
||||
parcalar.append((p, "text"))
|
||||
return parcalar
|
||||
|
||||
|
||||
def sigdir(req, pencere_token, oran=None):
|
||||
"""Istegi hedef pencereye sigdirir. Donus: (kirpilan parca sayisi, sigdi mi, tahmini token)."""
|
||||
sinir = int(pencere_token * DOLULUK)
|
||||
tahmin = tahmini_token(req, oran)
|
||||
if not pencere_token or tahmin <= sinir:
|
||||
return 0, True, tahmin
|
||||
kirpilan = 0
|
||||
en_yeni = _son_arac_ciktisi(req)
|
||||
# Buyukten kucuge; en yeni arac ciktisi en sona kalir (yalniz digerleri yetmezse kirpilir).
|
||||
parcalar = sorted(_metin_parcalari(req), key=lambda sa: (id(sa[0]) in en_yeni, -len(sa[0][sa[1]])))
|
||||
for sahip, anahtar in parcalar:
|
||||
metin = sahip[anahtar]
|
||||
if len(metin) < KIRP_ESIK:
|
||||
continue
|
||||
sahip[anahtar] = (metin[:KIRP_BAS] + f"\n[... {len(metin) - KIRP_BAS - KIRP_SON} karakter çıkarıldı (köprü): "
|
||||
f"seçili modelin bağlam penceresine sığmıyordu ...]\n" + metin[-KIRP_SON:])
|
||||
kirpilan += 1
|
||||
tahmin = tahmini_token(req, oran)
|
||||
if tahmin <= sinir:
|
||||
return kirpilan, True, tahmin
|
||||
return kirpilan, tahmin <= sinir, tahmin
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Provision a reviewable launcher candidate. Never sets CODEX_CLI_PATH or restarts apps."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
def prepare(source, destination, original, expected_sha256, python, bridge_home, hermes_home):
|
||||
source, destination = Path(source).resolve(), Path(destination).resolve()
|
||||
original, python = Path(original).resolve(), Path(python).resolve()
|
||||
def digest(p):
|
||||
with p.open('rb') as f: return hashlib.file_digest(f,'sha256').hexdigest()
|
||||
if digest(original) != expected_sha256.lower(): raise ValueError('chosen official original hash mismatch')
|
||||
if original == destination/'native-watch.exe': raise ValueError('recursive original')
|
||||
if source == destination: raise ValueError('candidate must use separate destination')
|
||||
destination.mkdir(parents=True,exist_ok=True)
|
||||
for p in source.rglob('*'):
|
||||
if p.is_file() and (p.suffix in ('.py','.pyd') or p.name in ('native-watch.exe','LICENSE')) and not p.name.startswith('test_'):
|
||||
target=destination/p.relative_to(source);target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(p,target)
|
||||
script=destination/'native_watch_launcher.py'
|
||||
manifest=dict(original=str(original),original_sha256=expected_sha256.lower(),python=str(python),python_sha256=digest(python),script=str(script),script_sha256=digest(script),bridge_home=str(Path(bridge_home).resolve()),hermes_home=str(Path(hermes_home).resolve()))
|
||||
(destination/'native-watch.json').write_text(json.dumps(manifest,indent=2),encoding='utf-8')
|
||||
return destination/'native-watch.exe'
|
||||
|
||||
if __name__=='__main__':
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
for name in ('source','destination','original','expected-sha256','python','bridge-home','hermes-home'): p.add_argument('--'+name,required=True)
|
||||
args=vars(p.parse_args());print(prepare(**args))
|
||||
@@ -0,0 +1,112 @@
|
||||
"""9Router'a giden isteklerde resim, video, ses, PDF ve dev içerikleri sadeleştirir (24 Eylül 2026, kullanıcı onayı).
|
||||
|
||||
Sorun: Codex, image_gen aracının ürettiği resmi (≈3 MB base64 PNG) araç çıktısı olarak sohbete geri koyuyor.
|
||||
9Router'ın Claude çevirisi bunu düz yazı sayıyor: istek 3 milyon token'a çıktı ("prompt is too long:
|
||||
3040251 tokens > 1000000 maximum") ve sohbetteki sonraki HER tur 400 ile düştü; sohbet kurtarılamadı.
|
||||
Aynısı video, ses, PDF/dosya ve çok büyük araç çıktılarıyla da olabilir (kullanıcı isteği: "tekrar böyle sorun olmasın").
|
||||
|
||||
Kural:
|
||||
- Medya parçaları (resim, dosya/PDF, ses, video): araç çıktılarında ve eski mesajlarda kısa notla değiştirilir.
|
||||
Kullanıcının SON mesajındaki medya toplamı TUT_BAYT'ı aşmıyorsa korunur (yeni eklenen dosyayı model görebilsin).
|
||||
- Tür ne olursa olsun tek başına DEV_BAYT'tan büyük her parça (son mesajdaki korunanlar hariç) notla değiştirilir.
|
||||
- Düz yazı araç çıktısı DEV_BAYT'tan büyükse başı ve sonu kalır, ortası notla kısaltılır.
|
||||
Codex'in kendi kaydına dokunulmaz; yalnız 9Router'a giden kopya değişir.
|
||||
"""
|
||||
import json
|
||||
|
||||
TUT_BAYT = 1_000_000
|
||||
DEV_BAYT = 1_000_000
|
||||
RESIM_TUT_BAYT = TUT_BAYT # eski ad
|
||||
MEDYA_TURLERI = {'input_image': 'resim', 'image': 'resim', 'image_url': 'resim',
|
||||
'input_file': 'dosya/PDF', 'file': 'dosya/PDF',
|
||||
'input_audio': 'ses', 'audio': 'ses',
|
||||
'input_video': 'video', 'video': 'video'}
|
||||
|
||||
|
||||
def _boyut(p):
|
||||
try:
|
||||
return len(json.dumps(p, ensure_ascii=False))
|
||||
except (TypeError, ValueError):
|
||||
return len(str(p))
|
||||
|
||||
|
||||
def _medya_mi(p):
|
||||
return isinstance(p, dict) and p.get('type') in MEDYA_TURLERI
|
||||
|
||||
|
||||
def _not(p):
|
||||
tur = MEDYA_TURLERI.get(p.get('type'), p.get('type') or 'içerik') if isinstance(p, dict) else 'içerik'
|
||||
ad = p.get('filename') or p.get('file_path') or p.get('path') or '' if isinstance(p, dict) else ''
|
||||
ek = f' ({ad})' if ad else ''
|
||||
return {'type': 'input_text',
|
||||
'text': f'[{tur}{ek} çıkarıldı (AstraBridge): bu yoldan gönderilemiyor, ~{_boyut(p) // 1024} KB; '
|
||||
'gerekirse dosyayı yolundan aç]'}
|
||||
|
||||
|
||||
def _kisalt(metin):
|
||||
yarim = DEV_BAYT // 4
|
||||
return (metin[:yarim] + f'\n[... {len(metin) - 2 * yarim} karakter çıkarıldı (AstraBridge): çıktı çok büyüktü ...]\n'
|
||||
+ metin[-yarim:])
|
||||
|
||||
|
||||
def resimleri_sadelestir(req, tut_bayt=TUT_BAYT, gorsel=False):
|
||||
"""(değiştirilen parça sayısı, kazanılan bayt). req['input'] yerinde değişir.
|
||||
gorsel=True (hedef model resim görebiliyor): eski KULLANICI mesajlarındaki resimler de, en yeniden eskiye
|
||||
toplam tut_bayt'ı aşmadıkça korunur (10 Ekim 2026: resim görebilen modele geçince "göremiyorum" diyordu).
|
||||
Araç çıktılarındaki medya her durumda notla değiştirilir."""
|
||||
inp = req.get('input')
|
||||
if not isinstance(inp, list):
|
||||
return 0, 0
|
||||
son_kullanici = None
|
||||
for i, it in enumerate(inp):
|
||||
# 'type' alani olmayan mesaj da mesajdir (Responses girdisinde varsayilan tur).
|
||||
if isinstance(it, dict) and it.get('type', 'message') == 'message' and it.get('role') == 'user':
|
||||
son_kullanici = i
|
||||
koru = set()
|
||||
if son_kullanici is not None:
|
||||
icerik = inp[son_kullanici].get('content')
|
||||
if isinstance(icerik, list):
|
||||
medya = [p for p in icerik if _medya_mi(p)]
|
||||
if medya and sum(_boyut(p) for p in medya) <= tut_bayt:
|
||||
koru.add(son_kullanici)
|
||||
if gorsel:
|
||||
kalan = tut_bayt - sum(_boyut(p) for i in koru for p in inp[i].get('content') or [] if _medya_mi(p))
|
||||
for i in range(len(inp) - 1, -1, -1):
|
||||
it = inp[i]
|
||||
if i in koru or not (isinstance(it, dict) and it.get('type', 'message') == 'message' and it.get('role') == 'user'):
|
||||
continue
|
||||
icerik = it.get('content')
|
||||
resimler = [p for p in icerik if _medya_mi(p) and MEDYA_TURLERI.get(p.get('type')) == 'resim'] \
|
||||
if isinstance(icerik, list) else []
|
||||
diger = [p for p in icerik if _medya_mi(p) and p not in resimler] if isinstance(icerik, list) else []
|
||||
boyut = sum(_boyut(p) for p in resimler)
|
||||
if resimler and not diger and boyut <= kalan:
|
||||
koru.add(i)
|
||||
kalan -= boyut
|
||||
sayi = kazanc = 0
|
||||
for i, it in enumerate(inp):
|
||||
if not isinstance(it, dict) or i in koru:
|
||||
continue
|
||||
for alan in ('content', 'output'):
|
||||
deger = it.get(alan)
|
||||
if isinstance(deger, str) and len(deger) > DEV_BAYT:
|
||||
yeni = _kisalt(deger)
|
||||
kazanc += len(deger) - len(yeni)
|
||||
sayi += 1
|
||||
it[alan] = yeni
|
||||
continue
|
||||
if not isinstance(deger, list):
|
||||
continue
|
||||
degisti = False
|
||||
yeni_liste = []
|
||||
for p in deger:
|
||||
if _medya_mi(p) or _boyut(p) > DEV_BAYT:
|
||||
kazanc += _boyut(p)
|
||||
sayi += 1
|
||||
degisti = True
|
||||
yeni_liste.append(_not(p))
|
||||
else:
|
||||
yeni_liste.append(p)
|
||||
if degisti:
|
||||
it[alan] = yeni_liste
|
||||
return sayi, kazanc
|
||||
@@ -0,0 +1,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,87 @@
|
||||
"""Regression coverage for Codex -> external API access_programs compatibility."""
|
||||
import ast
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
import urllib.request
|
||||
|
||||
|
||||
# Load only the actual adapter and routing method: no catalog/network/thread
|
||||
# startup or credential loading is needed to exercise the production code.
|
||||
source = ast.parse(Path(__file__).with_name("kopru.py").read_text(encoding="utf-8"))
|
||||
flatten = next(n for n in source.body if isinstance(n, ast.FunctionDef) and n.name == "flatten_request")
|
||||
claude_sonu = next(n for n in source.body if isinstance(n, ast.FunctionDef) and n.name == "claude_sonu_duzelt")
|
||||
handler = next(n for n in source.body if isinstance(n, ast.ClassDef) and n.name == "H")
|
||||
responses = next(n for n in handler.body if isinstance(n, ast.FunctionDef) and n.name == "_responses")
|
||||
ozgun = next(n for n in handler.body if isinstance(n, ast.FunctionDef) and n.name == "_ozgun")
|
||||
taraf = [n for n in source.body if isinstance(n, ast.FunctionDef) and n.name in ("ek_model_mu", "yonlendirilen")]
|
||||
|
||||
|
||||
class AccessPrograms(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.upstream = Mock(return_value=Mock(status=200))
|
||||
self.env = {
|
||||
"WATCH_OWNER": None, "WatchResponse": lambda response, accept: response, "json": json,
|
||||
"urllib": Mock(request=Mock(Request=urllib.request.Request, urlopen=self.upstream)),
|
||||
"decompress": lambda body, encoding: body,
|
||||
"MODEL_MAP": {},
|
||||
"CFG": {"nine_router_catalog": {"enabled": True, "route": "9router", "prefix": "9r/"},
|
||||
"resim_sadelestir": False, "pano_kanca_user": False},
|
||||
"ROUTES": {"9router": {"url": "https://example.invalid/v1/responses", "key": "test-only", "flatten_tools": True}},
|
||||
"fix_tool_ids": lambda req: 0,
|
||||
"drop_foreign_items": lambda req: 0,
|
||||
"SseRewriter": Mock(),
|
||||
"log": lambda message: None, "pencere": __import__("pencere"), "IDE_BILGI": {}, "yarim": __import__("yarim"), "jev_denetim": __import__("jev_denetim"), "internet": __import__("internet"), "is_hatti": __import__("is_hatti"), "GORULEN_ISTEMCI": set(), "arac_semalarini_duzelt": lambda req: 0, "nokta_mesaji_mi": lambda req: False, "EXTRA_MODELS": [],
|
||||
"DEBUG": False, "UA": "test", "RETRIES": 1, "UPSTREAM_IDLE_TIMEOUT": 3900,
|
||||
}
|
||||
exec(compile(ast.Module(body=taraf + [flatten, claude_sonu, responses, ozgun], type_ignores=[]), "bridge.py", "exec"), self.env)
|
||||
|
||||
def request(self, model):
|
||||
return {"model": model, "access_programs": {"cyber": "standard"},
|
||||
"metadata": {"access_programs": "preserve nested data"},
|
||||
"input": [{"role": "user", "content": [{"type": "input_text", "text": "hello"}]}],
|
||||
"tools": [{"type": "function", "name": "echo", "parameters": {"type": "object", "properties": {}}}]}
|
||||
|
||||
def test_adapter_removes_only_top_level_field(self):
|
||||
req = self.request("meta/muse-spark-1.3")
|
||||
original = copy.deepcopy(req)
|
||||
self.env["flatten_request"](req)
|
||||
self.assertNotIn("access_programs", req)
|
||||
self.assertEqual(req["metadata"], original["metadata"])
|
||||
self.assertEqual(req["input"], original["input"])
|
||||
self.assertEqual(req["tools"][0]["name"], "echo")
|
||||
|
||||
def test_absent_null_and_empty_values(self):
|
||||
for value in (None, {}, {"cyber": "standard"}):
|
||||
with self.subTest(value=value):
|
||||
req = {"input": [], "access_programs": value}
|
||||
self.env["flatten_request"](req)
|
||||
self.assertNotIn("access_programs", req)
|
||||
self.env["flatten_request"]({"input": []})
|
||||
|
||||
def test_model_and_combo_external_request_strips_field(self):
|
||||
for model in ("9r/meta/muse-spark-1.3-contributor", "9r/Fusion-Hizli"):
|
||||
with self.subTest(model=model):
|
||||
body = json.dumps(self.request(model)).encode()
|
||||
fake = Mock(headers={})
|
||||
self.env["_responses"](fake, 1, "/responses", body)
|
||||
sent = json.loads(self.upstream.call_args.args[0].data)
|
||||
self.assertNotIn("access_programs", sent)
|
||||
self.assertEqual(sent["model"], model.removeprefix("9r/"))
|
||||
self.assertIn("access_programs", json.loads(body))
|
||||
fake._stream.assert_called_once()
|
||||
|
||||
def test_chatgpt_passthrough_preserves_original_body(self):
|
||||
body = json.dumps(self.request("gpt-6-astra")).encode()
|
||||
fake = Mock(headers={})
|
||||
fake._ozgun = lambda *a: self.env["_ozgun"](fake, *a)
|
||||
self.env["_responses"](fake, 1, "/responses", body)
|
||||
self.assertEqual(fake._passthrough.call_args.args[2], body)
|
||||
self.assertFalse(fake._passthrough.call_args.kwargs["rewritten"])
|
||||
self.upstream.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,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,224 @@
|
||||
"""The splitter (bridge.py): Codex's own models go to chatgpt.com byte for byte, added models to the bridge process.
|
||||
|
||||
Real sockets, no network: a fake chatgpt.com and a fake bridge process record what reaches them."""
|
||||
import gzip
|
||||
import hashlib
|
||||
import http.client
|
||||
import http.server
|
||||
import json
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import bridge
|
||||
|
||||
|
||||
class Kayit(http.server.BaseHTTPRequestHandler):
|
||||
"""Records every request; answers from the server's `cevap` function."""
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def _her(self):
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
govde = self.rfile.read(n) if n else b""
|
||||
self.server.gelen.append({"yontem": self.command, "yol": self.path, "baslik": dict(self.headers), "govde": govde})
|
||||
self.server.cevap(self, govde)
|
||||
|
||||
do_GET = do_POST = _her
|
||||
|
||||
|
||||
def sunucu(cevap):
|
||||
s = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Kayit)
|
||||
s.daemon_threads = True
|
||||
s.gelen, s.cevap = [], cevap
|
||||
threading.Thread(target=s.serve_forever, daemon=True).start()
|
||||
return s
|
||||
|
||||
|
||||
def duz(durum, govde, tur="application/json"):
|
||||
def f(h, _):
|
||||
h.send_response(durum)
|
||||
h.send_header("Content-Type", tur)
|
||||
h.send_header("Content-Length", str(len(govde)))
|
||||
h.end_headers()
|
||||
h.wfile.write(govde)
|
||||
return f
|
||||
|
||||
|
||||
def zstd(veri):
|
||||
try:
|
||||
from compression import zstd as z
|
||||
return z.compress(veri)
|
||||
except ImportError:
|
||||
import zstandard
|
||||
return zstandard.ZstdCompressor().compress(veri)
|
||||
|
||||
|
||||
class Ayirici(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.ozgun = sunucu(duz(200, b'event: response.completed\ndata: {"ok":1}\n\n', "text/event-stream"))
|
||||
def worker(h, body):
|
||||
if h.path.endswith("/prepare-transfer"):
|
||||
return duz(200, json.dumps({"request": json.loads(body)["request"]}).encode())(h, body)
|
||||
return duz(200, b'event: response.completed\ndata: {"kopru":1}\n\n', "text/event-stream")(h, body)
|
||||
self.kopru = sunucu(worker)
|
||||
self.on = bridge.Sunucu(("127.0.0.1", 0), bridge.H)
|
||||
threading.Thread(target=self.on.serve_forever, daemon=True).start()
|
||||
self.eski = (bridge.CHATGPT, bridge.KOPRU, bridge.LOGDIR, dict(bridge.CFG))
|
||||
self.gecici = tempfile.TemporaryDirectory()
|
||||
self.transfer_old = bridge.TRANSFER_PATH, dict(bridge.TRANSFERS)
|
||||
bridge.TRANSFER_PATH = self.gecici.name + "/transfers.json"
|
||||
bridge.TRANSFERS.clear()
|
||||
bridge.CHATGPT = f"http://127.0.0.1:{self.ozgun.server_address[1]}"
|
||||
bridge.KOPRU = f"http://127.0.0.1:{self.kopru.server_address[1]}"
|
||||
bridge.LOGDIR = self.gecici.name
|
||||
bridge.CFG.clear()
|
||||
|
||||
def tearDown(self):
|
||||
for s in (self.on, self.ozgun, self.kopru):
|
||||
s.shutdown()
|
||||
s.server_close()
|
||||
bridge.CHATGPT, bridge.KOPRU, bridge.LOGDIR, cfg = self.eski
|
||||
bridge.CFG.clear()
|
||||
bridge.CFG.update(cfg)
|
||||
bridge.TRANSFER_PATH, transfers = self.transfer_old
|
||||
bridge.TRANSFERS.clear()
|
||||
bridge.TRANSFERS.update(transfers)
|
||||
self.gecici.cleanup()
|
||||
|
||||
def iste(self, yontem, yol, govde=None, baslik=None):
|
||||
b = http.client.HTTPConnection("127.0.0.1", self.on.server_address[1], timeout=10)
|
||||
b.request(yontem, yol, body=govde, headers=baslik or {})
|
||||
y = b.getresponse()
|
||||
veri = y.read()
|
||||
b.close()
|
||||
return y.status, veri, dict(y.getheaders())
|
||||
|
||||
def test_codex_own_model_reaches_chatgpt_byte_for_byte(self):
|
||||
"""Compressed or not, with a command, a lone dot or a compaction trigger in it: nothing is changed."""
|
||||
istekler = [{"model": "gpt-6-astra", "input": [{"role": "user", "content": "modeli opus 5 5 yap"}]},
|
||||
{"model": "gpt-6-luna", "input": [{"role": "user", "content": "."}]},
|
||||
{"model": "gpt-6-astra", "prompt_cache_key": "s", "input": [{"type": "compaction_trigger"}]},
|
||||
{"model": "gpt-6-astra", "input": [{"type": "compaction", "encrypted_content": "gAAAA_native"},
|
||||
{"type": "reasoning", "id": "rs_native"}]}]
|
||||
for veri in istekler:
|
||||
ham = json.dumps(veri, ensure_ascii=False).encode("utf-8")
|
||||
for kodlama, govde in (("zstd", zstd(ham)), ("gzip", gzip.compress(ham)), ("", ham)):
|
||||
with self.subTest(model=veri["model"], kodlama=kodlama):
|
||||
baslik = {"Authorization": "Bearer sahte", "originator": "Codex Desktop", "chatgpt-account-id": "h1"}
|
||||
if kodlama:
|
||||
baslik["Content-Encoding"] = kodlama
|
||||
durum, cevap, _ = self.iste("POST", "/backend-api/codex/responses", govde, baslik)
|
||||
giden = self.ozgun.gelen[-1]
|
||||
self.assertEqual(hashlib.sha256(giden["govde"]).hexdigest(), hashlib.sha256(govde).hexdigest())
|
||||
basliklar = {k.lower(): v for k, v in giden["baslik"].items()}
|
||||
self.assertEqual(basliklar.get("content-encoding", ""), kodlama)
|
||||
self.assertEqual((basliklar["authorization"], basliklar["originator"], basliklar["chatgpt-account-id"]),
|
||||
("Bearer sahte", "Codex Desktop", "h1"))
|
||||
self.assertEqual(giden["yol"], "/backend-api/codex/responses")
|
||||
self.assertEqual((durum, cevap), (200, b'event: response.completed\ndata: {"ok":1}\n\n'))
|
||||
self.assertEqual(self.kopru.gelen, []) # the bridge process never saw any of them
|
||||
|
||||
def test_added_models_go_to_the_bridge_process_and_only_there(self):
|
||||
for model in ("ide/Opus-5", "9r/cc/claude-opus-5-5"):
|
||||
govde = zstd(json.dumps({"model": model, "input": []}).encode())
|
||||
durum, cevap, _ = self.iste("POST", "/backend-api/codex/responses", govde,
|
||||
{"Content-Encoding": "zstd", "originator": "Codex Desktop"})
|
||||
self.assertEqual((durum, cevap), (200, b'event: response.completed\ndata: {"kopru":1}\n\n'))
|
||||
self.assertEqual(self.kopru.gelen[-1]["govde"], govde)
|
||||
basliklar = {k.lower(): v for k, v in self.kopru.gelen[-1]["baslik"].items()}
|
||||
self.assertEqual((basliklar["originator"], basliklar["content-encoding"]), ("Codex Desktop", "zstd"))
|
||||
bridge.CFG["model_map"] = {"ozel-model": ["rota", "hedef"]}
|
||||
self.iste("POST", "/backend-api/codex/responses", json.dumps({"model": "ozel-model"}).encode())
|
||||
self.assertEqual(len([x for x in self.kopru.gelen if x["yol"].endswith("/responses")]), 3)
|
||||
self.assertEqual(self.ozgun.gelen, [])
|
||||
|
||||
def test_a_body_that_cannot_be_read_takes_codex_own_path_untouched(self):
|
||||
for govde, baslik in ((b"\x00\x01bozuk", {"Content-Encoding": "zstd"}), (b"json degil", {}), (b"[1,2]", {})):
|
||||
self.iste("POST", "/backend-api/codex/responses", govde, baslik)
|
||||
self.assertEqual(self.ozgun.gelen[-1]["govde"], govde)
|
||||
self.assertEqual(self.kopru.gelen, [])
|
||||
|
||||
def test_codex_own_model_keeps_working_while_the_bridge_process_is_down(self):
|
||||
self.kopru.shutdown()
|
||||
self.kopru.server_close()
|
||||
govde = json.dumps({"model": "gpt-6-astra", "input": []}).encode()
|
||||
self.assertEqual(self.iste("POST", "/backend-api/codex/responses", govde)[0], 200)
|
||||
# The model list falls back to chatgpt.com's own list, unchanged.
|
||||
self.ozgun.cevap = duz(200, b'{"models":[{"slug":"gpt-6-astra"}]}')
|
||||
durum, liste, _ = self.iste("GET", "/backend-api/codex/models?client_version=1", None, {"If-None-Match": "x"})
|
||||
self.assertEqual((durum, liste), (200, b'{"models":[{"slug":"gpt-6-astra"}]}'))
|
||||
# An added model says so instead of hanging or going to chatgpt.com under another name.
|
||||
durum, cevap, _ = self.iste("POST", "/backend-api/codex/responses", json.dumps({"model": "ide/Opus-5"}).encode())
|
||||
self.assertEqual(durum, 200)
|
||||
self.assertIn(b"response.failed", cevap)
|
||||
self.assertEqual([g["yol"].split("?")[0] for g in self.ozgun.gelen],
|
||||
["/backend-api/codex/responses", "/backend-api/codex/models"])
|
||||
# Status still answers in the shape AITURK IDE expects.
|
||||
durum, cevap, _ = self.iste("GET", "/aiturk/codex-baglama/status")
|
||||
d = json.loads(cevap)
|
||||
self.assertEqual((durum, d["surum"], d["kopru"], d["ayirici"]), (200, bridge.SURUM, "kapali", True))
|
||||
|
||||
def test_model_list_comes_from_the_bridge_and_falls_back_when_it_fails(self):
|
||||
self.kopru.cevap = duz(200, b'{"models":[{"slug":"gpt-6-astra"},{"slug":"ide/Opus-5"}]}')
|
||||
self.assertIn(b"ide/Opus-5", self.iste("GET", "/backend-api/codex/models")[1])
|
||||
self.assertEqual(self.ozgun.gelen, [])
|
||||
self.kopru.cevap = duz(500, b'{"error":"x"}')
|
||||
self.ozgun.cevap = duz(200, b'{"models":[{"slug":"gpt-6-astra"}]}')
|
||||
self.assertEqual(self.iste("GET", "/backend-api/codex/models")[1], b'{"models":[{"slug":"gpt-6-astra"}]}')
|
||||
|
||||
def test_everything_else_and_errors_pass_through_as_they_are(self):
|
||||
self.ozgun.cevap = duz(200, b'{"sonuc":1}')
|
||||
self.assertEqual(self.iste("POST", "/backend-api/codex/alpha/search", b'{"q":"x"}')[1], b'{"sonuc":1}')
|
||||
self.assertEqual(self.ozgun.gelen[-1]["govde"], b'{"q":"x"}')
|
||||
self.ozgun.cevap = duz(400, b'{"error":{"message":"gecersiz"}}')
|
||||
durum, cevap, _ = self.iste("POST", "/backend-api/codex/responses", json.dumps({"model": "gpt-6-astra"}).encode())
|
||||
self.assertEqual((durum, cevap), (400, b'{"error":{"message":"gecersiz"}}'))
|
||||
self.assertEqual(self.kopru.gelen, [])
|
||||
|
||||
def test_a_long_answer_streams_piece_by_piece_without_waiting_for_the_end(self):
|
||||
"""This morning's failure was an idle stream: Codex got no bytes for five minutes. Bytes must flow as they come."""
|
||||
def yavas(h, _):
|
||||
h.send_response(200)
|
||||
h.send_header("Content-Type", "text/event-stream")
|
||||
h.send_header("Transfer-Encoding", "chunked")
|
||||
h.end_headers()
|
||||
for parca in (b"data: bir\n\n", b"data: iki\n\n"):
|
||||
h.wfile.write(b"%x\r\n" % len(parca) + parca + b"\r\n")
|
||||
h.wfile.flush()
|
||||
time.sleep(1.0)
|
||||
h.wfile.write(b"0\r\n\r\n")
|
||||
self.ozgun.cevap = yavas
|
||||
b = http.client.HTTPConnection("127.0.0.1", self.on.server_address[1], timeout=10)
|
||||
bas = time.time()
|
||||
b.request("POST", "/backend-api/codex/responses", body=json.dumps({"model": "gpt-6-astra"}).encode())
|
||||
y = b.getresponse()
|
||||
ilk = y.read1(64)
|
||||
ilk_sure = time.time() - bas
|
||||
kalan = y.read()
|
||||
b.close()
|
||||
self.assertEqual(ilk, b"data: bir\n\n")
|
||||
self.assertLess(ilk_sure, 0.8) # the first piece arrived before the answer ended
|
||||
self.assertEqual(kalan, b"data: iki\n\n")
|
||||
|
||||
def test_status_is_the_bridge_status_plus_the_splitter_fields(self):
|
||||
self.kopru.cevap = duz(200, b'{"ok": true, "surum": "8", "kod": "abc", "ide_durum": "tamam", "ide_model": 147}')
|
||||
d = json.loads(self.iste("GET", "/aiturk/codex-baglama/status")[1])
|
||||
self.assertEqual((d["surum"], d["ide_model"], d["kopru"], d["ayirici"]), (bridge.SURUM, 147, "calisiyor", True))
|
||||
|
||||
def test_a_persistent_connection_is_refused_the_way_it_is_today(self):
|
||||
durum, _, _ = self.iste("GET", "/backend-api/codex/responses", None, {"Upgrade": "websocket", "Connection": "Upgrade"})
|
||||
self.assertEqual(durum, 426)
|
||||
self.assertEqual(self.ozgun.gelen + self.kopru.gelen, [])
|
||||
|
||||
def test_which_side_a_model_is_on(self):
|
||||
for slug, ek in (("ide/Opus-5", True), ("9r/ag/gemini", True), ("gpt-6-astra", False), ("gpt-6.1-sol", False),
|
||||
("", False), (None, False), ("idea", False)):
|
||||
self.assertEqual(bridge.ek_model_mu(slug), ek, slug)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,357 @@
|
||||
import concurrent.futures
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import pytest
|
||||
from bekci_watch import Frames, Ledger, MetadataFilter, WatchOwner, opaque
|
||||
from native_speech_mux import NativeSpeechMux, stdio_invocation
|
||||
from test_native_speech_mux import Harness
|
||||
|
||||
def wait(predicate):
|
||||
until=time.time()+5
|
||||
while time.time()<until:
|
||||
if predicate(): return
|
||||
time.sleep(.01)
|
||||
assert predicate()
|
||||
|
||||
class Server:
|
||||
def __init__(self, frames, status=200):
|
||||
self.requests=[];self.frames=frames;self.status=status;self.closed=threading.Event()
|
||||
owner=self
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def log_message(self,*args): pass
|
||||
def do_POST(self):
|
||||
owner.requests.append((self.path,self.headers.get('Authorization'),json.loads(self.rfile.read(int(self.headers['Content-Length'])))))
|
||||
self.send_response(owner.status);self.send_header('Content-Type','text/event-stream');self.end_headers()
|
||||
try:
|
||||
for frame in owner.frames:
|
||||
self.wfile.write(frame);self.wfile.flush()
|
||||
while not owner.closed.wait(.05):
|
||||
self.wfile.write(b': keepalive\n\n');self.wfile.flush()
|
||||
except OSError: pass
|
||||
self.server=ThreadingHTTPServer(('127.0.0.1',0),H);self.server.daemon_threads=True
|
||||
self.origin='http://127.0.0.1:'+str(self.server.server_port)
|
||||
threading.Thread(target=self.server.serve_forever,daemon=True).start()
|
||||
def close(self): self.closed.set();self.server.shutdown();self.server.server_close()
|
||||
|
||||
def frame(event,value,cursor=None):
|
||||
return ((f'id: {cursor}\n' if cursor else '')+f'event: {event}\ndata: '+json.dumps(value)+'\n\n').encode()
|
||||
|
||||
def started(mux,tid):
|
||||
mux._observe_server({'method':'thread/realtime/started','params':{'threadId':tid,'realtimeSessionId':'rt'}})
|
||||
|
||||
def meta(): return dict(subscription='s'*40,events_url='/v1/bekci/events',expires_at=(time.time()+60)*1000)
|
||||
def capture(owner,tid): return owner.capture({'session-id':tid,'thread-id':tid},{'prompt_cache_key':tid})
|
||||
def notice(i='one'): return dict(notice=dict(id=i,type='arastirma',metin='correction',at=time.time()),cursor='cursor-'+i)
|
||||
|
||||
def test_stream_metadata_isolation_split_crlf_and_json():
|
||||
seen=[];f=MetadataFilter(seen.append);wire=frame('response.output_text.delta',{'delta':'answer'})+frame('bekci.watch',meta())+b'data: [DONE]\n\n'
|
||||
wire=wire.replace(b'\n',b'\r\n');out=b''
|
||||
for byte in wire: out+=f.feed(bytes([byte]))
|
||||
out+=f.flush()
|
||||
assert len(seen)==1 and b'subscription' not in out and b'answer' in out and b'[DONE]' in out
|
||||
|
||||
def test_ready_auth_durable_replay_and_epoch_abort(tmp_path):
|
||||
n=notice();server=Server([frame('ready',{'cursor':'start'}),frame('notice',n,n['cursor']),frame('notice',n,n['cursor'])])
|
||||
mux=NativeSpeechMux(['unused'],observer=True);started(mux,'A')
|
||||
ledger=Ledger(tmp_path/'ledger.db');owner=WatchOwner(mux,ledger,origin=server.origin,test_loopback=True)
|
||||
c=capture(owner,'A');p=meta()
|
||||
try:
|
||||
assert owner.accept(p,c,'member',server.origin+'/v1/responses')=='subscribing'
|
||||
wait(lambda:mux._queue.qsize()==1)
|
||||
scope=opaque('public-watch','member',p['subscription'])
|
||||
wait(lambda:ledger.cursor(scope)==n['cursor'])
|
||||
assert server.requests[0]==('/v1/bekci/events','Bearer member',{'subscription':p['subscription']})
|
||||
reopened=Ledger(tmp_path/'ledger.db');assert not reopened.claim(scope,'one')
|
||||
mux._observe_client({'method':'thread/realtime/stop','params':{'threadId':'A'}})
|
||||
wait(lambda:not owner.watches)
|
||||
assert owner.accept(p,c,'member',server.origin+'/v1/responses')=='stale_generation'
|
||||
finally: owner.close();server.close()
|
||||
|
||||
def test_no_ready_wrong_key_untrusted_url_and_two_threads(tmp_path):
|
||||
n=notice();server=Server([frame('notice',n,n['cursor'])]);mux=NativeSpeechMux(['unused'],observer=True)
|
||||
for t in ('A','B'): started(mux,t)
|
||||
owner=WatchOwner(mux,Ledger(tmp_path/'db'),origin=server.origin,test_loopback=True)
|
||||
try:
|
||||
c=capture(owner,'A')
|
||||
assert owner.accept(meta(),c,'member','https://evil.invalid/responses')=='untrusted_route'
|
||||
assert owner.accept(meta(),None,'member',server.origin)=='stale_generation'
|
||||
p=meta();p['events_url']='https://evil.invalid';assert owner.accept(p,c,'member',server.origin)=='invalid_receipt'
|
||||
owner.accept(meta(),c,'member',server.origin);wait(lambda:len(server.requests)>0)
|
||||
time.sleep(.1);assert mux._queue.empty() and not mux._bindings
|
||||
finally:owner.close();server.close()
|
||||
server=Server([frame('ready',{'cursor':'start'}),frame('notice',n,n['cursor'])]);owner=WatchOwner(mux,Ledger(tmp_path/'db2'),origin=server.origin,test_loopback=True)
|
||||
try:
|
||||
for t in ('A','B'):
|
||||
p=meta();p['subscription']=t*40;owner.accept(p,capture(owner,t),'member',server.origin)
|
||||
wait(lambda:mux._queue.qsize()==2)
|
||||
assert {mux._queue.get()[1][0]['thread_id'] for _ in range(2)}=={'A','B'}
|
||||
finally:owner.close();server.close()
|
||||
|
||||
def test_launcher_stdio_grammar(tmp_path):
|
||||
assert stdio_invocation(['-c','x=true','app-server','--analytics-default-enabled','-c','y="z"'])
|
||||
assert not stdio_invocation(['exec','app-server'])
|
||||
assert not stdio_invocation(['app-server','--listen','ws://127.0.0.1'])
|
||||
assert not stdio_invocation(['app-server','daemon','start'])
|
||||
|
||||
@pytest.mark.parametrize('active_owner', [True, False])
|
||||
def test_http_receipt_through_real_bridge_handler_to_real_pipe(tmp_path, active_owner):
|
||||
"""Real imported H, real HTTP primary/events, real subprocess append/approval."""
|
||||
import os
|
||||
import http.client
|
||||
import urllib.request
|
||||
bridge=tmp_path/'bridge';bridge.mkdir()
|
||||
(bridge/'bridge.json').write_text(json.dumps({'aiturk_hat':{'enabled':False},'sesle_model':False,'nokta_karsila':False,'yarim_cevap':False,'ozet_kopru':{'enabled':False},'tasma_bekcisi':False}),encoding='utf-8')
|
||||
agent=tmp_path/'agent';agent.mkdir()
|
||||
old=dict(os.environ);os.environ['AITURK_CODEX_BRIDGE_HOME']=str(bridge);os.environ['HERMES_HOME']=str(agent)
|
||||
try:
|
||||
import kopru
|
||||
finally: os.environ.clear();os.environ.update(old)
|
||||
h=Harness(tmp_path);h.start()
|
||||
p=meta();n=notice();requests=[]
|
||||
class Primary(BaseHTTPRequestHandler):
|
||||
def log_message(self,*args): pass
|
||||
def do_POST(self):
|
||||
body=json.loads(self.rfile.read(int(self.headers['Content-Length'])))
|
||||
requests.append((self.path,self.headers.get('Authorization'),body))
|
||||
if self.path=='/v1/bekci/events':
|
||||
wire=frame('ready',{'cursor':'start'})+frame('notice',n,n['cursor'])
|
||||
kind='text/event-stream'
|
||||
elif body.get('stream',True):
|
||||
wire=frame('response.output_text.delta',{'type':'response.output_text.delta','delta':'answer'})+frame('bekci.watch',p)+b'data: [DONE]\n\n';kind='text/event-stream'
|
||||
else:
|
||||
wire=json.dumps({'output':[],'_bekci_watch':p}).encode();kind='application/json'
|
||||
self.send_response(200);self.send_header('Content-Type',kind);self.send_header('Content-Length',str(len(wire)));self.end_headers();self.wfile.write(wire)
|
||||
upstream=ThreadingHTTPServer(('127.0.0.1',0),Primary);upstream.daemon_threads=True
|
||||
threading.Thread(target=upstream.serve_forever,daemon=True).start();origin='http://127.0.0.1:'+str(upstream.server_port)
|
||||
owner=WatchOwner(h.mux,Ledger(tmp_path/'http-ledger'),origin=origin,test_loopback=True)
|
||||
kopru.WATCH_OWNER=owner if active_owner else None;kopru.MODEL_MAP['ide/test-watch']=('test-route','Sesli-Hizli-Zeki',False)
|
||||
kopru.ROUTES['test-route']={'url':origin+'/v1/responses','key':'public-member','flatten_tools':False}
|
||||
server=kopru.TS(('127.0.0.1',0),kopru.H);threading.Thread(target=server.serve_forever,daemon=True).start()
|
||||
try:
|
||||
for stream in (True,False):
|
||||
conn=http.client.HTTPConnection('127.0.0.1',server.server_port,timeout=5)
|
||||
conn.request('POST','/v1/responses',json.dumps({'model':'ide/test-watch','stream':stream,'prompt_cache_key':'thread-A','input':[]}),{'Content-Type':'application/json','session-id':'thread-A','thread-id':'thread-A'})
|
||||
resp=conn.getresponse();data=resp.read();conn.close()
|
||||
assert resp.status==200 and b'subscription' not in data and b'_bekci_watch' not in data
|
||||
if active_owner:
|
||||
approval=json.loads(h.recv());speech=json.loads(h.recv())
|
||||
assert approval['method']=='item/commandExecution/requestApproval'
|
||||
assert speech['method']=='seen-speech' and speech['params']=={'threadId':'thread-A','text':'correction'}
|
||||
assert any(path=='/v1/bekci/events' and auth=='Bearer public-member' for path,auth,_ in requests)
|
||||
else:
|
||||
assert not any(path=='/v1/bekci/events' for path,_,_ in requests)
|
||||
finally:
|
||||
owner.close();server.shutdown();server.server_close();upstream.shutdown();upstream.server_close();kopru.WATCH_OWNER=None;h.close()
|
||||
|
||||
def test_http_403_expiry_and_crash_claim_never_resend(tmp_path):
|
||||
server=Server([],status=403);mux=NativeSpeechMux(['unused'],observer=True);started(mux,'A')
|
||||
owner=WatchOwner(mux,Ledger(tmp_path/'db'),origin=server.origin,test_loopback=True)
|
||||
try:
|
||||
owner.accept(meta(),capture(owner,'A'),'wrong-key',server.origin)
|
||||
wait(lambda:len(server.requests)==1);wait(lambda:not owner.watches)
|
||||
assert not mux._bindings and mux._queue.empty()
|
||||
finally:owner.close();server.close()
|
||||
n=notice();server=Server([frame('ready',{'cursor':'start'}),frame('notice',n,n['cursor'])])
|
||||
ledger=Ledger(tmp_path/'crash');p=meta();scope=opaque('public-watch','member',p['subscription']);assert ledger.claim(scope,'one')
|
||||
owner=WatchOwner(mux,Ledger(tmp_path/'crash'),origin=server.origin,test_loopback=True)
|
||||
try:
|
||||
p['expires_at']=(time.time()+.4)*1000
|
||||
owner.accept(p,capture(owner,'A'),'member',server.origin)
|
||||
wait(lambda:owner.ledger.cursor(scope)==n['cursor']);wait(lambda:not owner.watches)
|
||||
assert mux._queue.empty()
|
||||
finally:owner.close();server.close()
|
||||
|
||||
def test_receipt_cannot_move_to_other_thread_or_generation(tmp_path):
|
||||
server=Server([frame('ready',{'cursor':'start'})]);mux=NativeSpeechMux(['unused'],observer=True)
|
||||
started(mux,'A');started(mux,'B');owner=WatchOwner(mux,Ledger(tmp_path/'db'),origin=server.origin,test_loopback=True);p=meta()
|
||||
try:
|
||||
assert owner.accept(p,capture(owner,'A'),'member',server.origin)=='subscribing'
|
||||
assert owner.accept(p,capture(owner,'B'),'member',server.origin)=='receipt_replay_mismatch'
|
||||
mux._observe_client({'method':'thread/unsubscribe','params':{'threadId':'A'}})
|
||||
wait(lambda:not owner.watches);started(mux,'A')
|
||||
assert owner.accept(p,capture(owner,'A'),'member',server.origin)=='receipt_replay_mismatch'
|
||||
finally:owner.close();server.close()
|
||||
|
||||
@pytest.mark.parametrize('seconds', [16*60, 60*60, 24*60*60, 24*60*60+30])
|
||||
def test_protocol_lifetime_receipt_admitted(tmp_path, seconds):
|
||||
server=Server([frame('ready', {'cursor':'start'})])
|
||||
mux=NativeSpeechMux(['unused'],observer=True);started(mux,'A')
|
||||
owner=WatchOwner(mux,Ledger(tmp_path/'db'),origin=server.origin,test_loopback=True)
|
||||
try:
|
||||
p=meta();p['expires_at']=(time.time()+seconds)*1000
|
||||
assert owner.accept(p,capture(owner,'A'),'member',server.origin)=='subscribing'
|
||||
wait(lambda:len(server.requests)==1)
|
||||
wait(lambda:bool(mux._bindings))
|
||||
assert owner.watches
|
||||
finally: owner.close();server.close()
|
||||
|
||||
@pytest.mark.parametrize('expiry', [0, -1, float('inf'), float('-inf'), float('nan'), True, False, 'infinite', None, 10**100])
|
||||
def test_invalid_or_unbounded_expiry_rejected(tmp_path, expiry):
|
||||
mux=NativeSpeechMux(['unused'],observer=True);started(mux,'A')
|
||||
owner=WatchOwner(mux,Ledger(tmp_path/'db'))
|
||||
p=meta();p['expires_at']=expiry
|
||||
assert owner.accept(p,capture(owner,'A'),'member','https://ai.turkservis.online/v1/responses')=='invalid_receipt'
|
||||
assert not owner.watches
|
||||
|
||||
def test_receipt_beyond_protocol_and_skew_rejected(tmp_path):
|
||||
mux=NativeSpeechMux(['unused'],observer=True);started(mux,'A')
|
||||
owner=WatchOwner(mux,Ledger(tmp_path/'db'))
|
||||
p=meta();p['expires_at']=(time.time()+24*60*60+120)*1000
|
||||
assert owner.accept(p,capture(owner,'A'),'member','https://ai.turkservis.online/v1/responses')=='invalid_receipt'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('target', ['Other-Model', 'sesli-hizli-zeki', 'Sesli-Hizli-Zeki-extra'])
|
||||
@pytest.mark.parametrize('json_response', [False, True])
|
||||
@pytest.mark.parametrize('jev', [False, True])
|
||||
def test_actual_handler_nonvoice_preserves_response_pipeline(tmp_path, monkeypatch, target, json_response, jev):
|
||||
import http.client
|
||||
import kopru
|
||||
from unittest.mock import Mock
|
||||
# Exceed the sideband frame cap: other models must never enter its buffer.
|
||||
wire=(json.dumps({'output':[], 'payload':'x'*270000}).encode() if json_response
|
||||
else frame('response.output_text.delta', {'delta':'x'*270000}))
|
||||
class Primary(BaseHTTPRequestHandler):
|
||||
def log_message(self,*args): pass
|
||||
def do_POST(self):
|
||||
self.rfile.read(int(self.headers['Content-Length']))
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type','application/json' if json_response else 'text/event-stream')
|
||||
self.send_header('Content-Length',str(len(wire)));self.end_headers();self.wfile.write(wire)
|
||||
upstream=ThreadingHTTPServer(('127.0.0.1',0),Primary)
|
||||
threading.Thread(target=upstream.serve_forever,daemon=True).start()
|
||||
owner=Mock();monkeypatch.setattr(kopru,'WATCH_OWNER',owner)
|
||||
monkeypatch.setitem(kopru.MODEL_MAP,'ide/scope-proof',('aiturk-ide',target,None))
|
||||
monkeypatch.setitem(kopru.ROUTES,'aiturk-ide',{'url':f'http://127.0.0.1:{upstream.server_port}/v1/responses','key':'scope-member','flatten_tools':jev})
|
||||
monkeypatch.setattr(kopru.jev_denetim,'anahtar',lambda:'test-key' if jev else '')
|
||||
wrapped=Mock(side_effect=AssertionError('nonvoice entered watch wrapper'))
|
||||
monkeypatch.setattr(kopru,'WatchResponse',wrapped)
|
||||
shortcut=Mock(side_effect=AssertionError('nonvoice entered JSON shortcut'))
|
||||
monkeypatch.setattr(kopru.H,'_send_json',shortcut)
|
||||
calls=[]
|
||||
original_stream=kopru.H._stream
|
||||
def stream(self,resp,*args,**kwargs):
|
||||
calls.append('stream')
|
||||
return original_stream(self,resp,*args,**kwargs)
|
||||
def denetim(self,resp,tag,rewriter,izle,istek,sira,ac,*,hedef):
|
||||
# Verify original upstream object/body reaches Jev, not a watch wrapper.
|
||||
calls.append('jev');assert hedef==target and sira
|
||||
data=resp.read();resp.close();assert data==wire
|
||||
self.send_response(200);self.send_header('Content-Length',str(len(data)))
|
||||
self.end_headers();self.wfile.write(data)
|
||||
monkeypatch.setattr(kopru.H,'_stream',stream)
|
||||
monkeypatch.setattr(kopru.H,'_denetimli_akis',denetim)
|
||||
server=kopru.TS(('127.0.0.1',0),kopru.H)
|
||||
threading.Thread(target=server.serve_forever,daemon=True).start()
|
||||
try:
|
||||
conn=http.client.HTTPConnection('127.0.0.1',server.server_port,timeout=5)
|
||||
conn.request('POST','/v1/responses',json.dumps({'model':'ide/scope-proof','stream':not json_response,'input':[]}),{'Content-Type':'application/json'})
|
||||
response=conn.getresponse();data=response.read();conn.close()
|
||||
assert response.status==200 and data==wire
|
||||
assert calls==(['jev'] if jev else ['stream'])
|
||||
wrapped.assert_not_called();shortcut.assert_not_called();owner.accept.assert_not_called()
|
||||
finally:
|
||||
server.shutdown();server.server_close();upstream.shutdown();upstream.server_close()
|
||||
|
||||
|
||||
class QuietWatchServer:
|
||||
"""Real sockets, with no heartbeat; observes client shutdown at the peer."""
|
||||
def __init__(self, protocol='HTTP/1.0', delay=0, headers_delay=False, reconnects=False, delayed_headers=False):
|
||||
self.requests=[];self.closed=threading.Event();self.peer_closed=threading.Event()
|
||||
self.ready=threading.Event();owner=self
|
||||
class H(BaseHTTPRequestHandler):
|
||||
protocol_version='HTTP/1.1' if protocol=='HTTP/1.1-close' else protocol
|
||||
def log_message(self,*args): pass
|
||||
def do_POST(self):
|
||||
body=json.loads(self.rfile.read(int(self.headers['Content-Length'])))
|
||||
owner.requests.append(body);number=len(owner.requests)
|
||||
try:
|
||||
if headers_delay:
|
||||
owner.ready.set()
|
||||
self.connection.settimeout(5)
|
||||
if self.connection.recv(1)==b'': owner.peer_closed.set()
|
||||
return
|
||||
if delayed_headers and owner.closed.wait(delay): return
|
||||
self.send_response(200);self.send_header('Content-Type','text/event-stream')
|
||||
if protocol=='HTTP/1.1-close': self.send_header('Connection','close')
|
||||
self.end_headers()
|
||||
self.wfile.write(frame('ready',{'cursor':'start'}));self.wfile.flush();owner.ready.set()
|
||||
if reconnects:
|
||||
n=notice('first' if number<9 else 'ninth')
|
||||
self.wfile.write(frame('notice',n,n['cursor']));self.wfile.flush()
|
||||
if number<9:
|
||||
self.close_connection=True
|
||||
return
|
||||
elif delay and number==1:
|
||||
if not delayed_headers and owner.closed.wait(delay): return
|
||||
n=notice('delayed');self.wfile.write(frame('notice',n,n['cursor']));self.wfile.flush()
|
||||
self.connection.settimeout(5 if not delay else 20)
|
||||
if self.connection.recv(1)==b'': owner.peer_closed.set()
|
||||
except OSError: pass
|
||||
self.server=ThreadingHTTPServer(('127.0.0.1',0),H);self.server.daemon_threads=True
|
||||
self.origin='http://127.0.0.1:'+str(self.server.server_port)
|
||||
threading.Thread(target=self.server.serve_forever,daemon=True).start()
|
||||
def close(self):
|
||||
self.closed.set();self.server.shutdown();self.server.server_close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('delayed_headers',[False,True])
|
||||
def test_quiet_watch_delivers_after_more_than_ten_seconds(tmp_path,delayed_headers):
|
||||
server=QuietWatchServer(delay=11.2,delayed_headers=delayed_headers)
|
||||
mux=NativeSpeechMux(['unused'],observer=True);started(mux,'A')
|
||||
owner=WatchOwner(mux,Ledger(tmp_path/'quiet'),origin=server.origin,test_loopback=True)
|
||||
try:
|
||||
assert owner.accept(meta(),capture(owner,'A'),'member',server.origin)=='subscribing'
|
||||
assert server.ready.wait(14)
|
||||
until=time.monotonic()+14
|
||||
while mux._queue.empty() and time.monotonic()<until: time.sleep(.02)
|
||||
assert not mux._queue.empty(), 'valid quiet stream lost delayed notice'
|
||||
assert len(server.requests)==1, 'quiet channel spuriously reconnected'
|
||||
finally:owner.close();server.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('protocol',['HTTP/1.0','HTTP/1.1','HTTP/1.1-close'])
|
||||
@pytest.mark.parametrize('cancel',['close','expiry','generation'])
|
||||
@pytest.mark.parametrize('headers_delay',[False,True])
|
||||
def test_quiet_owned_socket_is_interrupted(tmp_path,protocol,cancel,headers_delay):
|
||||
server=QuietWatchServer(protocol=protocol,headers_delay=headers_delay)
|
||||
mux=NativeSpeechMux(['unused'],observer=True);started(mux,'A')
|
||||
owner=WatchOwner(mux,Ledger(tmp_path/'cancel'),origin=server.origin,test_loopback=True)
|
||||
try:
|
||||
p=meta()
|
||||
if cancel=='expiry':p['expires_at']=(time.time()+.7)*1000
|
||||
owner.accept(p,capture(owner,'A'),'member',server.origin)
|
||||
assert server.ready.wait(3)
|
||||
if not headers_delay:wait(lambda:bool(mux._bindings))
|
||||
if cancel=='close':owner.close()
|
||||
elif cancel=='generation':mux._observe_client({'method':'thread/realtime/stop','params':{'threadId':'A'}})
|
||||
assert server.peer_closed.wait(2), 'owned response/header socket remained open'
|
||||
wait(lambda:not owner.watches)
|
||||
assert not mux._bindings and mux._queue.empty()
|
||||
finally:owner.close();server.close()
|
||||
|
||||
|
||||
def test_ninth_reconnect_delivers_and_preserves_cursor_dedup(tmp_path):
|
||||
class FastBackoff:
|
||||
def __init__(self):self.event=threading.Event()
|
||||
def is_set(self):return self.event.is_set()
|
||||
def set(self):self.event.set()
|
||||
def wait(self,seconds):return self.event.wait(min(seconds,.01))
|
||||
server=QuietWatchServer(reconnects=True)
|
||||
mux=NativeSpeechMux(['unused'],observer=True);started(mux,'A')
|
||||
owner=WatchOwner(mux,Ledger(tmp_path/'reconnect'),origin=server.origin,test_loopback=True)
|
||||
p=meta();c=capture(owner,'A');scope=opaque('public-watch','member',p['subscription']);stop=FastBackoff()
|
||||
owner.watches[scope]=stop
|
||||
worker=threading.Thread(target=owner._watch,args=(p,c,'member',scope,stop),daemon=True);worker.start()
|
||||
try:
|
||||
wait(lambda:len(server.requests)>=9)
|
||||
wait(lambda:owner.ledger.cursor(scope)=='cursor-ninth')
|
||||
assert mux._queue.qsize()==2
|
||||
assert all(req.get('cursor')=='cursor-first' for req in server.requests[1:9])
|
||||
finally:
|
||||
owner.close();worker.join(timeout=3);server.close()
|
||||
assert not worker.is_alive()
|
||||
@@ -0,0 +1,45 @@
|
||||
"""claude_sonu_duzelt testleri (9Router Claude prefill 400)."""
|
||||
import ast
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
tree = ast.parse(Path(__file__).with_name("kopru.py").read_text(encoding="utf-8"))
|
||||
env = {}
|
||||
exec(compile(ast.Module(body=[n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == "claude_sonu_duzelt"], type_ignores=[]), "bridge.py", "exec"), env)
|
||||
f = env["claude_sonu_duzelt"]
|
||||
|
||||
|
||||
def msg(role, text="x"):
|
||||
return {"type": "message", "role": role, "content": [{"type": "input_text", "text": text}]}
|
||||
|
||||
|
||||
class ClaudeSonu(unittest.TestCase):
|
||||
def test_asistanla_biten_duzelir(self):
|
||||
req = {"input": [msg("user"), msg("assistant")]}
|
||||
self.assertTrue(f(req)[0])
|
||||
self.assertEqual(req["input"][-1]["role"], "user")
|
||||
|
||||
def test_asistan_sonra_developer_duzelir(self):
|
||||
# Sesli tur kapaninca: ... assistant, developer (9Router developer'i dusurur -> asistanla biter)
|
||||
req = {"input": [msg("user"), msg("assistant"), msg("developer")]}
|
||||
self.assertTrue(f(req)[0])
|
||||
self.assertEqual(req["input"][-1]["role"], "user")
|
||||
|
||||
def test_reasoning_ile_biten_duzelir(self):
|
||||
req = {"input": [msg("user"), {"type": "reasoning", "id": "rs_1"}]}
|
||||
self.assertTrue(f(req)[0])
|
||||
|
||||
def test_kullaniciyla_biten_degismez(self):
|
||||
req = {"input": [msg("assistant"), msg("user"), msg("developer")]}
|
||||
self.assertFalse(f(req)[0])
|
||||
self.assertEqual(len(req["input"]), 3)
|
||||
|
||||
def test_arac_sonucuyla_biten_degismez(self):
|
||||
req = {"input": [msg("user"), {"type": "custom_tool_call", "call_id": "c"}, {"type": "custom_tool_call_output", "call_id": "c"}]}
|
||||
self.assertFalse(f(req)[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""A 9Router combo (Fusion-*) must not be sent twice once it may be running upstream.
|
||||
|
||||
22 Sep 2026: after a Cloudflare 524 the bridge resent 9r/Fusion-Orta three times and the whole
|
||||
Fusion ran (and billed) again. Failures before the work starts may still be retried.
|
||||
"""
|
||||
import ast
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
source = ast.parse(Path(__file__).with_name("kopru.py").read_text(encoding="utf-8"))
|
||||
pick = lambda name: next(n for n in source.body if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
handler = next(n for n in source.body if isinstance(n, ast.ClassDef) and n.name == "H")
|
||||
responses = next(n for n in handler.body if isinstance(n, ast.FunctionDef) and n.name == "_responses")
|
||||
|
||||
|
||||
def http_error(code, body=b"{}"):
|
||||
return urllib.error.HTTPError("https://example.invalid", code, "x", {}, io.BytesIO(body))
|
||||
|
||||
|
||||
class ComboNotResent(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.upstream = Mock()
|
||||
self.env = {
|
||||
"WATCH_OWNER": None, "WatchResponse": lambda response, accept: response, "json": json, "re": __import__("re"), "os": __import__("os"), "http": __import__("http.client"),
|
||||
"time": types.SimpleNamespace(sleep=lambda s: None),
|
||||
"urllib": types.SimpleNamespace(
|
||||
request=types.SimpleNamespace(Request=urllib.request.Request, urlopen=self.upstream),
|
||||
error=urllib.error),
|
||||
"decompress": lambda body, encoding: body,
|
||||
"MODEL_MAP": {},
|
||||
"CFG": {"nine_router_catalog": {"enabled": True, "route": "9router", "prefix": "9r/"},
|
||||
"resim_sadelestir": False, "pano_kanca_user": False},
|
||||
"ROUTES": {"9router": {"url": "https://example.invalid/v1/responses", "key": "test-only", "flatten_tools": False}},
|
||||
"fix_tool_ids": lambda req: 0, "drop_foreign_items": lambda req: 0,
|
||||
"SseRewriter": Mock(), "log": lambda message: None,
|
||||
"DEBUG": False, "UA": "test", "RETRIES": 6, "UPSTREAM_IDLE_TIMEOUT": 3600,
|
||||
"NO_RETRY_PATTERNS": [], "pencere": __import__("pencere"), "IDE_BILGI": {}, "yarim": __import__("yarim"), "jev_denetim": __import__("jev_denetim"), "internet": __import__("internet"), "is_hatti": __import__("is_hatti"), "GORULEN_ISTEMCI": set(), "arac_semalarini_duzelt": lambda req: 0, "nokta_mesaji_mi": lambda req: False, "EXTRA_MODELS": [],
|
||||
}
|
||||
exec(compile(ast.Module(body=[pick("ek_model_mu"), pick("yonlendirilen"), pick("flatten_request"), pick("claude_sonu_duzelt"),
|
||||
pick("is_timeout"), pick("kopma_mi"), responses],
|
||||
type_ignores=[]), "bridge.py", "exec"), self.env)
|
||||
|
||||
def send(self, model):
|
||||
body = json.dumps({"model": model, "input": [{"role": "user", "content": [{"type": "input_text", "text": "8 vezir"}]}]}).encode()
|
||||
fake = Mock(headers={})
|
||||
self.env["_responses"](fake, 1, "/responses", body)
|
||||
return fake
|
||||
|
||||
def test_combo_timeout_is_not_resent(self):
|
||||
self.upstream.side_effect = urllib.error.URLError(TimeoutError("timed out"))
|
||||
fake = self.send("9r/Fusion-Orta")
|
||||
self.assertEqual(self.upstream.call_count, 1)
|
||||
fake._stream.assert_not_called()
|
||||
|
||||
def test_combo_504_and_524_are_not_resent(self):
|
||||
for code in (504, 524):
|
||||
with self.subTest(code=code):
|
||||
self.upstream.reset_mock()
|
||||
self.upstream.side_effect = http_error(code)
|
||||
self.send("9r/Fusion-Agir")
|
||||
self.assertEqual(self.upstream.call_count, 1)
|
||||
|
||||
def test_combo_cut_mid_stream_is_not_resent(self):
|
||||
self.upstream.side_effect = None
|
||||
self.upstream.return_value = Mock(status=200)
|
||||
fake = self.send("9r/Fusion-Orta")
|
||||
fake._stream.side_effect = TimeoutError("read timed out")
|
||||
self.upstream.reset_mock()
|
||||
with self.assertRaises(TimeoutError):
|
||||
self.send_with(fake, "9r/Fusion-Orta")
|
||||
self.assertEqual(self.upstream.call_count, 1)
|
||||
|
||||
def send_with(self, fake, model):
|
||||
body = json.dumps({"model": model, "input": [{"role": "user", "content": [{"type": "input_text", "text": "8 vezir"}]}]}).encode()
|
||||
self.env["_responses"](fake, 1, "/responses", body)
|
||||
|
||||
def test_combo_failure_before_work_starts_is_retried(self):
|
||||
self.upstream.side_effect = [http_error(503), urllib.error.URLError(ConnectionRefusedError("refused")), Mock(status=200)]
|
||||
fake = self.send("9r/Fusion-Orta")
|
||||
self.assertEqual(self.upstream.call_count, 3)
|
||||
fake._stream.assert_called_once()
|
||||
|
||||
def test_combo_closed_by_the_peer_before_headers_is_not_resent(self):
|
||||
import http.client
|
||||
for error in (http.client.RemoteDisconnected("closed"), urllib.error.URLError(ConnectionResetError("reset"))):
|
||||
with self.subTest(error=error):
|
||||
self.upstream.reset_mock()
|
||||
self.upstream.side_effect = error
|
||||
fake = self.send("9r/Fusion-Orta")
|
||||
self.assertEqual(self.upstream.call_count, 1)
|
||||
fake._stream.assert_not_called()
|
||||
|
||||
def test_single_model_timeout_is_still_retried(self):
|
||||
self.upstream.side_effect = [urllib.error.URLError(TimeoutError("timed out")), Mock(status=200)]
|
||||
fake = self.send("9r/cc/claude-opus-5-5")
|
||||
self.assertEqual(self.upstream.call_count, 2)
|
||||
fake._stream.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,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()
|
||||
@@ -0,0 +1,519 @@
|
||||
"""Audit of 11 Oct 2026, remaining findings: each fix has a test here (handler methods run through a fake handler)."""
|
||||
import ast
|
||||
import copy
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import time
|
||||
import types
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import ide_catalog
|
||||
import is_hatti
|
||||
import model_gecis
|
||||
import ozet_kopru
|
||||
import pencere
|
||||
import resim_context
|
||||
import yarim
|
||||
|
||||
SOURCE = Path(__file__).with_name("kopru.py").read_text(encoding="utf-8")
|
||||
tree = ast.parse(SOURCE)
|
||||
handler = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "H")
|
||||
|
||||
|
||||
def top(*names):
|
||||
nodes = [n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.ClassDef)) and n.name in names]
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id in names for t in node.targets):
|
||||
nodes.insert(0, node)
|
||||
return nodes
|
||||
|
||||
|
||||
def method(name):
|
||||
return next(n for n in handler.body if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
|
||||
|
||||
def build(nodes, env):
|
||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), "bridge.py", "exec"), env)
|
||||
return env
|
||||
|
||||
|
||||
def user(text):
|
||||
return {"type": "message", "role": "user", "content": [{"type": "input_text", "text": text}]}
|
||||
|
||||
|
||||
def developer(text):
|
||||
return {"type": "message", "role": "developer", "content": [{"type": "input_text", "text": text}]}
|
||||
|
||||
|
||||
MODELS = [("gpt-6-sol", "GPT-6-Sol"), ("gpt-6-astra", "GPT-6-Astra"), ("gpt-6.1-sol", "GPT-6.1-Sol"),
|
||||
("ide/9router-ag-gemini-3.8-flash-low", "agy/gemini-3.8-flash-low"),
|
||||
("ide/9router-cc-claude-opus-5-5", "cc/opus-5-5")]
|
||||
CALL = {"type": "function_call", "call_id": "c", "name": "shell", "arguments": "{}"}
|
||||
OUT = {"type": "function_call_output", "call_id": "c", "output": "x"}
|
||||
|
||||
|
||||
class SesliKomut(unittest.TestCase):
|
||||
def pick(self, text):
|
||||
command = model_gecis.komut_coz(text)
|
||||
return model_gecis.eslestir(command["ad"], MODELS) if command and command["tur"] == "gec" else None
|
||||
|
||||
def test_numbers_said_as_words_and_a_continue_tail_still_switch(self):
|
||||
for text, slug in [("modeli opus beş beş yap", "ide/9router-cc-claude-opus-5-5"),
|
||||
("modeli gemini üç nokta sekiz flash low yap", "ide/9router-ag-gemini-3.8-flash-low"),
|
||||
("modeli gpt altı nokta bir sol yap", "gpt-6.1-sol"),
|
||||
("modeli gpt altı astra yap ve devam et", "gpt-6-astra"),
|
||||
("modeli opus 5 5 yap sonra devam et", "ide/9router-cc-claude-opus-5-5")]:
|
||||
self.assertEqual((self.pick(text) or {}).get("slug"), slug, text)
|
||||
|
||||
def test_a_task_after_the_command_is_not_swallowed(self):
|
||||
for text in ("modeli opus 5 5 yap ve şu dosyayı düzelt", "bir model seç", "modeli iki kere değiştir"):
|
||||
self.assertIsNone(self.pick(text), text)
|
||||
|
||||
def test_last_command_is_noted_only_when_it_really_took_effect(self):
|
||||
opus = "ide/9router-cc-claude-opus-5-5"
|
||||
refused = {"input": [user("modeli opus 5 5 yap"), {"type": "compaction_trigger"}]}
|
||||
self.assertEqual(model_gecis.gecmisi_temizle(refused, MODELS, "gpt-6-sol"), 0) # e.g. "bağlantı kapalı"
|
||||
self.assertEqual(refused["input"][0]["content"][0]["text"], "modeli opus 5 5 yap")
|
||||
applied = {"input": [user("modeli opus 5 5 yap"), CALL, OUT]}
|
||||
self.assertEqual(model_gecis.gecmisi_temizle(applied, MODELS, opus), 1) # mid-turn handover
|
||||
self.assertIn("cc/opus-5-5 olarak değiştirdi", applied["input"][0]["content"][0]["text"])
|
||||
|
||||
def test_a_route_in_use_does_not_expire(self):
|
||||
y = model_gecis.Yonlendirme(None)
|
||||
y.koy("chat", "gpt-6-sol", "ide/x", simdi=1000)
|
||||
day = 24 * 3600
|
||||
for n in range(1, 12): # used every 1.5 days for 16 days
|
||||
self.assertEqual(y.al("chat", "gpt-6-sol", simdi=1000 + n * 1.5 * day), "ide/x", n)
|
||||
self.assertIsNone(y.al("chat", "gpt-6-sol", simdi=1000 + 16.5 * day + model_gecis.OMUR_SN + 1)) # unused for a week
|
||||
|
||||
|
||||
class Pencere(unittest.TestCase):
|
||||
def test_guard_runs_for_a_combo_whose_small_member_is_known(self):
|
||||
self.assertFalse(pencere.bekci_calisir("varsayilan", 128_000))
|
||||
self.assertFalse(pencere.bekci_calisir("", 128_000))
|
||||
self.assertFalse(pencere.bekci_calisir("uyeler:varsayilan", 128_000))
|
||||
self.assertTrue(pencere.bekci_calisir("uyeler:nanogpt,varsayilan", 8192)) # the 8K member is real
|
||||
self.assertFalse(pencere.bekci_calisir("uyeler:nanogpt,varsayilan", 128_000)) # the minimum is the guess
|
||||
for known in ("yerel", "nanogpt", "ogrenilen", "uyeler:nanogpt,yerel"):
|
||||
self.assertTrue(pencere.bekci_calisir(known, 200_000), known)
|
||||
|
||||
def test_combo_with_a_claude_member_uses_the_claude_ratio(self):
|
||||
combo = {"topology": {"kind": "combo", "strategy": "priority", "members": [
|
||||
{"kind": "model", "model": "cx/gpt-6-astra"},
|
||||
{"kind": "combo", "strategy": "priority", "members": [{"kind": "model", "model": "cc/claude-opus-5-5"}]}]}}
|
||||
plain = {"topology": {"kind": "combo", "members": [{"kind": "model", "model": "ag/gemini-3.8-flash"}]}}
|
||||
self.assertTrue(pencere.claude_var("combo", "Opus-5", combo))
|
||||
self.assertFalse(pencere.claude_var("combo", "Hizli", plain))
|
||||
self.assertTrue(pencere.claude_var("model", "no-think/claude-opus-5-5"))
|
||||
self.assertFalse(pencere.claude_var("model", "nano/z-ai/glm-5.3"))
|
||||
self.assertEqual(pencere.oran_sec("Opus-5", True), pencere.CLAUDE_BAYT_BOLU_TOKEN)
|
||||
self.assertEqual(pencere.oran_sec("Opus-5"), pencere.BAYT_BOLU_TOKEN)
|
||||
|
||||
def test_a_corrupt_nano_cache_does_not_block_the_refresh(self):
|
||||
home = tempfile.mkdtemp()
|
||||
Path(home, pencere.NANO_DOSYA).write_text(json.dumps({"zaman": "dun", "modeller": {"eski": {"pencere": 9000}}}),
|
||||
encoding="utf-8")
|
||||
fresh = {"data": [{"id": "Yeni/M", "context_length": 65536, "capabilities": {}}]}
|
||||
self.assertIn("yeni/m", pencere.nano_yukle(home, simdi=1000.0, getir=lambda: fresh))
|
||||
|
||||
def test_trimming_keeps_the_real_last_user_message_and_the_newest_tool_output(self):
|
||||
marker = "(Önceki cevabın yarıda kaldı ya da sesli konuşma kapandı; bağlama göre devam et.)"
|
||||
self.assertIn(marker, SOURCE) # the text bridge.py appends
|
||||
self.assertTrue(marker.startswith(pencere.KOPRU_DEVAM_BASI))
|
||||
req = {"input": [{"type": "function_call_output", "call_id": "a", "output": "eski " * 30000},
|
||||
user("TALIMAT " * 5000),
|
||||
{"type": "function_call_output", "call_id": "b", "output": "yeni " * 4000},
|
||||
user(marker)]}
|
||||
trimmed, fits, _ = pencere.sigdir(req, 30_000)
|
||||
self.assertEqual((trimmed, fits), (1, True))
|
||||
self.assertIn("çıkarıldı (köprü)", req["input"][0]["output"])
|
||||
self.assertEqual(req["input"][1]["content"][0]["text"], "TALIMAT " * 5000) # the user's real last message
|
||||
self.assertEqual(req["input"][2]["output"], "yeni " * 4000) # newest tool result untouched
|
||||
# When nothing else is left, the newest tool output is trimmed rather than the request refused.
|
||||
only = {"input": [user("soru"), {"type": "function_call_output", "call_id": "b", "output": "yeni " * 40000}]}
|
||||
self.assertEqual(pencere.sigdir(only, 30_000)[:2], (1, True))
|
||||
|
||||
|
||||
class Ozet(unittest.TestCase):
|
||||
def test_a_cut_stream_is_not_returned_as_a_whole_summary(self):
|
||||
delta = b'data: {"type":"response.output_text.delta","item_id":"m","delta":"yarim oz"}\n\n'
|
||||
self.assertIsNone(ozet_kopru.sse_metni(delta))
|
||||
done = delta + b'data: {"type":"response.completed","response":{}}\n\n'
|
||||
self.assertEqual(ozet_kopru.sse_metni(done), "yarim oz")
|
||||
|
||||
def test_the_in_a_row_brake_lifts_after_the_window(self):
|
||||
f = ozet_kopru.DonguFreni(sinir=8, pencere_sn=600, art_arda=3)
|
||||
self.assertEqual([f.izin("s", t) for t in (0, 10, 20, 30)], [True, True, True, False])
|
||||
self.assertFalse(f.izin("s", 500)) # still inside the window
|
||||
self.assertTrue(f.izin("s", 24 * 3600)) # a day later the chat is not locked any more
|
||||
|
||||
def test_cleanup_survives_an_empty_record(self):
|
||||
f = ozet_kopru.DonguFreni()
|
||||
f.kayit = {"k%d" % i: [float(i)] for i in range(500)}
|
||||
f.kayit["bos"] = []
|
||||
self.assertTrue(f.izin("yeni", 1000.0))
|
||||
self.assertLess(len(f.kayit), 300)
|
||||
|
||||
|
||||
class Akis(unittest.TestCase):
|
||||
env = build(top("fix_tool_ids", "SseRewriter", "phase_damgala", "asistan_mesaji_mi", "ID_PREFIX", "ARAC_OGELERI"),
|
||||
{"WATCH_OWNER": None, "WatchResponse": lambda response, accept: response, "json": json, "re": __import__("re"), "time": time, "log": lambda m: None, "pencere": pencere})
|
||||
|
||||
def events(self, raw):
|
||||
return [json.loads(line[6:]) for line in raw.decode().splitlines() if line.startswith("data: ")]
|
||||
|
||||
def test_crlf_separated_events_are_translated(self):
|
||||
flat = {"type": "error", "code": "bad_request", "message": "upstream 402"}
|
||||
raw = b"event: error\r\ndata: " + json.dumps(flat).encode() + b"\r\n\r\n"
|
||||
adapter = self.env["SseRewriter"](set())
|
||||
out = self.events(adapter.feed(raw[:20]) + adapter.feed(raw[20:]) + adapter.flush())
|
||||
self.assertEqual(out[-1]["type"], "response.completed")
|
||||
self.assertIn("kredi ya da kota", out[1]["delta"])
|
||||
watcher = yarim.Izleyici("k", "m")
|
||||
watcher.feed(b'data: {"type":"response.output_text.delta","delta":"merhaba"}\r\n\r\n'
|
||||
b'data: {"type":"response.completed"}\r\n\r\n')
|
||||
self.assertEqual((watcher.metin, watcher.bitti), ("merhaba", True))
|
||||
|
||||
def test_a_non_object_event_body_passes_through(self):
|
||||
adapter = self.env["SseRewriter"](set())
|
||||
self.assertEqual(adapter.feed(b"data: null\n\ndata: [1]\n\n"), b"data: null\n\ndata: [1]\n\n")
|
||||
|
||||
def test_a_waiting_message_is_released_before_the_error_answer(self):
|
||||
message = {"type": "response.output_item.done", "output_index": 0,
|
||||
"item": {"id": "m1", "type": "message", "role": "assistant", "content": []}}
|
||||
adapter = self.env["SseRewriter"](set(), dolu=True)
|
||||
first = adapter.feed(b"data: " + json.dumps(message).encode() + b"\n\n")
|
||||
self.assertEqual(first, b"") # held until what follows is known
|
||||
out = self.events(adapter.feed(b'data: {"type":"error","message":"upstream 400"}\n\n') + adapter.flush())
|
||||
self.assertEqual([e["type"] for e in out], ["response.output_item.done", "response.failed"])
|
||||
self.assertEqual(out[0]["item"]["id"], "m1")
|
||||
self.assertIn("nedenini bildirmedi", out[1]["response"]["error"]["message"]) # no false "did not fit"
|
||||
self.assertIn("olabilir", out[1]["response"]["error"]["message"])
|
||||
|
||||
|
||||
class Resim(unittest.TestCase):
|
||||
def test_a_new_image_in_a_message_without_type_is_kept(self):
|
||||
img = {"type": "input_image", "image_url": "data:image/png;base64," + "A" * 2000}
|
||||
req = {"input": [{"role": "user", "content": [{"type": "input_text", "text": "bak"}, dict(img)]}]}
|
||||
self.assertEqual(resim_context.resimleri_sadelestir(req), (0, 0))
|
||||
self.assertEqual(req["input"][0]["content"][1]["type"], "input_image")
|
||||
|
||||
|
||||
class Katalog(unittest.TestCase):
|
||||
def test_names_stay_distinct_when_the_id_tail_collides_too(self):
|
||||
names = ide_catalog.adlandir([("a-ortakson", "Ad"), ("b-ortakson", "Ad"), ("c-ortakson", "Ad")], dolu={"ad"})
|
||||
self.assertEqual(len({v.lower() for v in names.values()}), 3)
|
||||
|
||||
def test_an_original_listed_as_extra_keeps_its_place(self):
|
||||
models = [{"slug": "gpt-6-sol", "display_name": "GPT-6-Sol", "priority": 1},
|
||||
{"slug": "gpt-6-astra", "display_name": "GPT-6-Astra", "priority": 2},
|
||||
{"slug": "ide/a", "display_name": "Aaa", "priority": 0}]
|
||||
extras = [{"slug": "gpt-6-astra"}, {"slug": "ide/a", "kind": "combo"}]
|
||||
ide_catalog.sirala_codex(models, extras, {"gpt-6-sol", "gpt-6-astra"})
|
||||
self.assertEqual([(m["slug"], m["priority"]) for m in models],
|
||||
[("gpt-6-sol", 1), ("gpt-6-astra", 2), ("ide/a", 3)])
|
||||
|
||||
|
||||
class Sahte:
|
||||
"""A handler stand-in: real bridge methods, recorded output."""
|
||||
|
||||
def __init__(self, headers=None, command="POST"):
|
||||
self.headers = headers or {}
|
||||
self.command = command
|
||||
self.path = "/backend-api/codex/responses"
|
||||
self.wfile = io.BytesIO()
|
||||
self.status = None
|
||||
self.sent = []
|
||||
self._passthrough = Mock()
|
||||
self._yerel_yanit = Mock()
|
||||
self._send_json = Mock()
|
||||
self._stream = Mock()
|
||||
self._tasma_ogren = Mock()
|
||||
self._aracsiz_ogren = Mock()
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, k, v):
|
||||
self.sent.append((k, v))
|
||||
|
||||
def end_headers(self):
|
||||
pass
|
||||
|
||||
|
||||
class Zincir(unittest.TestCase):
|
||||
"""_responses + _model_komutu with a conversation key, the way Codex sends it."""
|
||||
|
||||
def setUp(self):
|
||||
self.env = build(top("nokta_mesaji_mi", "NOKTA_ISARETLERI", "is_timeout", "kopma_mi", "flatten_request",
|
||||
"claude_sonu_duzelt", "ek_model_mu", "yonlendirilen")
|
||||
+ [method("_responses"), method("_model_komutu"), method("_ozgun")], {
|
||||
"gecit_modelleri": lambda: [],
|
||||
"WATCH_OWNER": None, "WatchResponse": lambda response, accept: response, "json": json, "re": __import__("re"), "os": __import__("os"), "copy": copy, "http": __import__("http.client"),
|
||||
"time": types.SimpleNamespace(sleep=lambda s: None, time=time.time),
|
||||
"urllib": types.SimpleNamespace(request=types.SimpleNamespace(Request=urllib.request.Request, urlopen=Mock()),
|
||||
error=urllib.error),
|
||||
"decompress": lambda body, enc: body, "MODEL_MAP": {}, "ROUTES": {}, "CFG": {"model_bilgi": False}, "log": lambda m: None,
|
||||
"model_gecis": model_gecis, "YONLENDIRME": model_gecis.Yonlendirme(None), "bilinen_modeller": lambda: MODELS,
|
||||
"model_adi": lambda slug: dict(MODELS).get(slug, slug), "is_hatti": is_hatti, "GORULEN_ISTEMCI": set(),
|
||||
"yarim": yarim, "jev_denetim": __import__("jev_denetim"), "internet": __import__("internet"), "ozet_kopru": ozet_kopru, "OZET_FRENI": ozet_kopru.DonguFreni(), "pencere": pencere,
|
||||
"fix_tool_ids": lambda req: 0, "drop_foreign_items": lambda req: 0, "IDE_BILGI": {}, "EXTRA_MODELS": [],
|
||||
"arac_semalarini_duzelt": lambda req: 0, "SseRewriter": Mock(), "DEBUG": False, "UA": "test", "RETRIES": 6,
|
||||
"UPSTREAM_IDLE_TIMEOUT": 1, "NO_RETRY_PATTERNS": [], "pano_blogunu_ulastir": lambda req: (0, ""),
|
||||
"resimleri_sadelestir": lambda req, gorsel=False: (0, 0),
|
||||
})
|
||||
Sahte._responses = self.env["_responses"]
|
||||
Sahte._model_komutu = self.env["_model_komutu"]
|
||||
Sahte._ozgun = self.env["_ozgun"]
|
||||
|
||||
def send(self, model, items, key="sohbet-1", path="/responses"):
|
||||
fake = Sahte()
|
||||
body = json.dumps({"model": model, "prompt_cache_key": key, "input": items}).encode()
|
||||
fake._responses(1, path, body)
|
||||
return fake, body
|
||||
|
||||
def test_a_request_for_codex_own_model_is_passed_through_untouched(self):
|
||||
"""User decision, 11 Oct 2026: Codex's own models are one side, the added models the other; the bridge
|
||||
does not touch a request for Codex's own model (no command, dot, note, work line or compaction handling)."""
|
||||
self.env["CFG"].update({"nokta_karsila": True, "model_bilgi": True, "yarim_cevap": True,
|
||||
"ozet_kopru": {"enabled": True, "kapsam": "hepsi"},
|
||||
"aiturk_hat": {"enabled": True, "komut": "aiturk is"}})
|
||||
mid_turn = [user("işe başla"), CALL, OUT, user("modeli opus 5 5 yap"), developer("k1")]
|
||||
for items in ([user("modeli opus 5 5 yap")], [user(".")], [user("seçili model hangisi")], mid_turn,
|
||||
[user("uzun iş"), {"type": "compaction_trigger"}]):
|
||||
with self.subTest(items=items):
|
||||
fake = Sahte(headers={"originator": "Codex Desktop"})
|
||||
body = json.dumps({"model": "gpt-6-astra", "prompt_cache_key": "sohbet-1", "input": items}).encode()
|
||||
fake._responses(1, "/responses", body)
|
||||
fake._yerel_yanit.assert_not_called()
|
||||
args, kwargs = fake._passthrough.call_args
|
||||
self.assertEqual(args[2], body)
|
||||
self.assertFalse(kwargs["rewritten"])
|
||||
self.assertEqual(self.env["YONLENDIRME"].veri, {})
|
||||
|
||||
def test_the_bridge_does_not_even_help_codex_own_model(self):
|
||||
"""'If you pick Codex and write to Codex, our bridge cannot be beside it even to help.' Traces an added
|
||||
model or the bridge left earlier in the chat are not cleaned either: the body goes out byte for byte."""
|
||||
self.env["CFG"]["ozet_kopru"] = {"enabled": True, "kapsam": "hepsi"}
|
||||
self.env["fix_tool_ids"] = Mock(return_value=2)
|
||||
self.env["drop_foreign_items"] = Mock(return_value=3)
|
||||
summary = ozet_kopru.ozet_ogesi("eski köprü özeti", "t")
|
||||
fake, body = self.send("gpt-6-astra", [summary, {"type": "reasoning", "id": "rs_chatcmpl-x"}, user("devam")])
|
||||
args, kwargs = fake._passthrough.call_args
|
||||
self.assertEqual(args[2], body)
|
||||
self.assertFalse(kwargs["rewritten"])
|
||||
self.env["fix_tool_ids"].assert_not_called()
|
||||
self.env["drop_foreign_items"].assert_not_called()
|
||||
fake._yerel_yanit.assert_not_called()
|
||||
|
||||
def test_voice_switch_stays_among_the_added_models(self):
|
||||
opus, flash = "ide/9router-cc-claude-opus-5-5", "ide/9router-ag-gemini-3.8-flash-low"
|
||||
self.env["ROUTES"]["aiturk-ide"] = {"url": "https://example.invalid/v1/responses", "key": "k"}
|
||||
fake = Sahte()
|
||||
answer = fake._model_komutu(1, {"input": [user("modeli gemini flash low yap")]}, "sohbet-1", opus)
|
||||
self.assertIn("Sohbetin modeli artık agy/gemini-3.8-flash-low", answer)
|
||||
self.assertEqual(self.env["yonlendirilen"]("sohbet-1", opus), flash)
|
||||
# Codex's own model is not a voice target, from an added model either.
|
||||
answer = fake._model_komutu(1, {"input": [user("modeli gpt 6 astra yap")]}, "sohbet-1", opus)
|
||||
self.assertIn("GPT-6-Astra Codex'in kendi modeli", answer)
|
||||
self.assertIn("sesle geçilmiyor", answer)
|
||||
self.assertIn('"."', answer)
|
||||
self.assertEqual(self.env["yonlendirilen"]("sohbet-1", opus), flash)
|
||||
# In the middle of a turn the running model is told to pass that on; the work is not cut.
|
||||
req = {"input": [user("işe başla"), CALL, OUT, user("modeli gpt 6 astra yap")]}
|
||||
self.assertIsNone(fake._model_komutu(1, req, "sohbet-1", opus))
|
||||
self.assertIn("sesle yapılamıyor", req["input"][3]["content"][0]["text"])
|
||||
# A record left from before the rule that points at Codex's own model is ignored.
|
||||
self.env["YONLENDIRME"].koy("sohbet-2", opus, "gpt-6-astra")
|
||||
self.assertIsNone(self.env["yonlendirilen"]("sohbet-2", opus))
|
||||
|
||||
def test_a_refused_switch_is_not_told_as_done_after_compaction(self):
|
||||
items = [user("modeli opus 5 5 yap"), {"type": "compaction_trigger"}] # the ide/ route is closed here
|
||||
fake, body = self.send("gpt-6-sol", items)
|
||||
self.assertEqual(fake._passthrough.call_args.args[2], body)
|
||||
|
||||
def test_lone_dot_is_answered_for_an_added_model_and_left_alone_for_codex_own(self):
|
||||
self.env["CFG"]["nokta_karsila"] = True
|
||||
fake, _ = self.send("ide/9router-cc-claude-opus-5-5", [user(".")])
|
||||
self.assertIn("cc/opus-5-5 seçildi", fake._yerel_yanit.call_args.args[1])
|
||||
self.assertNotIn("hazır", fake._yerel_yanit.call_args.args[1])
|
||||
fake, body = self.send("gpt-6-sol", [user(".")])
|
||||
fake._yerel_yanit.assert_not_called()
|
||||
self.assertEqual(fake._passthrough.call_args.args[2], body)
|
||||
|
||||
def test_old_router_name_without_a_route_is_refused_locally(self):
|
||||
fake, _ = self.send("9r/cc/claude-opus-5-5", [user("selam")])
|
||||
self.assertEqual(fake._send_json.call_args.args[0], 503)
|
||||
fake._passthrough.assert_not_called()
|
||||
|
||||
def test_a_route_removed_while_the_request_is_prepared_gives_503(self):
|
||||
self.env["MODEL_MAP"]["ide/x"] = ("aiturk-ide", "x", None)
|
||||
fake, _ = self.send("ide/x", [user("selam")])
|
||||
self.assertEqual(fake._send_json.call_args.args[0], 503)
|
||||
self.env["urllib"].request.urlopen.assert_not_called()
|
||||
|
||||
def external(self, error):
|
||||
self.env["ROUTES"]["r"] = {"url": "https://example.invalid/v1/responses", "key": "test-only"}
|
||||
self.env["MODEL_MAP"].update({"x/combo": ("r", "Takim", None), "x/tek": ("r", "cc/claude", None)})
|
||||
self.env["urllib"].request.urlopen.side_effect = error
|
||||
|
||||
def test_resolved_voice_target_and_codex_instructions_reach_the_stream_guard(self):
|
||||
self.env["ROUTES"]["aiturk-ide"] = {
|
||||
"url": "https://example.invalid/v1/responses", "key": "test-only", "flatten_tools": True}
|
||||
self.env["MODEL_MAP"]["ide/voice-alias"] = ("aiturk-ide", "Sesli-Hizli-Zeki", None)
|
||||
req = {"model": "ide/voice-alias", "instructions": "Codex system instructions unchanged",
|
||||
"input": [developer("Codex developer instructions unchanged"), user("selam")]}
|
||||
for failure in (None, urllib.error.URLError(ConnectionResetError("reset")),
|
||||
urllib.error.HTTPError("https://example.invalid", 400, "bad", {}, io.BytesIO(b"bad"))):
|
||||
with self.subTest(failure=type(failure).__name__):
|
||||
opener = self.env["urllib"].request.urlopen
|
||||
opener.reset_mock()
|
||||
opener.side_effect = failure
|
||||
opener.return_value = Mock(status=200)
|
||||
fake = Sahte()
|
||||
fake._denetimli_akis = Mock()
|
||||
with patch.object(self.env["jev_denetim"], "anahtar", return_value="test-only"):
|
||||
fake._responses(1, "/responses", json.dumps(req).encode())
|
||||
fake._denetimli_akis.assert_called_once()
|
||||
self.assertEqual(fake._denetimli_akis.call_args.kwargs, {"hedef": "Sesli-Hizli-Zeki"})
|
||||
self.assertEqual(fake._denetimli_akis.call_args.args[5], list(self.env["jev_denetim"].KATMAN))
|
||||
sent = json.loads(opener.call_args.args[0].data)
|
||||
self.assertEqual(sent["model"], "Sesli-Hizli-Zeki")
|
||||
self.assertEqual(sent["instructions"], req["instructions"])
|
||||
self.assertEqual(sent["input"][0], req["input"][0])
|
||||
|
||||
def test_nonvoice_json_still_reaches_original_jev_with_upstream_identity(self):
|
||||
self.env["ROUTES"]["aiturk-ide"] = {
|
||||
"url": "https://example.invalid/v1/responses", "key": "test-only", "flatten_tools": True}
|
||||
self.env["MODEL_MAP"]["ide/scope"] = ("aiturk-ide", "Other-Model", None)
|
||||
response = Mock(status=200, headers={"Content-Type": "application/json"})
|
||||
self.env["urllib"].request.urlopen.return_value = response
|
||||
watch = self.env["WatchResponse"] = Mock(side_effect=AssertionError("watch scope escaped"))
|
||||
fake = Sahte(); fake._denetimli_akis = Mock()
|
||||
with patch.object(self.env["jev_denetim"], "anahtar", return_value="test-only"):
|
||||
fake._responses(1, "/responses", json.dumps({"model":"ide/scope", "input":[user("task")]}).encode())
|
||||
fake._denetimli_akis.assert_called_once()
|
||||
self.assertIs(fake._denetimli_akis.call_args.args[0], response)
|
||||
self.assertEqual(fake._denetimli_akis.call_args.kwargs, {"hedef":"Other-Model"})
|
||||
response.read.assert_not_called()
|
||||
watch.assert_not_called(); fake._send_json.assert_not_called(); fake._stream.assert_not_called()
|
||||
|
||||
def test_combo_dropped_after_sending_is_not_resent_but_a_single_model_is(self):
|
||||
import http.client
|
||||
self.external(http.client.RemoteDisconnected("closed"))
|
||||
self.send("x/combo", [user("selam")])
|
||||
self.assertEqual(self.env["urllib"].request.urlopen.call_count, 1)
|
||||
self.env["urllib"].request.urlopen.reset_mock()
|
||||
self.external([urllib.error.URLError(ConnectionResetError("reset")), Mock(status=200)])
|
||||
fake, _ = self.send("x/tek", [user("selam")])
|
||||
self.assertEqual(self.env["urllib"].request.urlopen.call_count, 2)
|
||||
fake._stream.assert_called_once()
|
||||
self.env["urllib"].request.urlopen.reset_mock()
|
||||
self.external([urllib.error.URLError(ConnectionRefusedError("refused")), Mock(status=200)])
|
||||
self.send("x/combo", [user("selam")]) # never connected: safe to retry
|
||||
self.assertEqual(self.env["urllib"].request.urlopen.call_count, 2)
|
||||
|
||||
def test_error_body_reaches_the_client_whole_with_retry_after(self):
|
||||
whole = json.dumps({"error": {"message": "limit " + "x" * 900, "code": "rate_limit_exceeded"}}).encode()
|
||||
self.external(urllib.error.HTTPError("https://example.invalid", 429, "x", {"Retry-After": "17"}, io.BytesIO(whole)))
|
||||
fake, _ = self.send("x/tek", [user("selam")])
|
||||
self.assertEqual(fake.status, 429)
|
||||
self.assertEqual(fake.wfile.getvalue(), whole)
|
||||
self.assertIn(("Retry-After", "17"), fake.sent)
|
||||
self.assertIn(("Content-Length", str(len(whole))), fake.sent)
|
||||
|
||||
def test_image_in_an_orphan_tool_output_is_removed_before_flattening(self):
|
||||
self.env["resimleri_sadelestir"] = resim_context.resimleri_sadelestir
|
||||
self.env["CFG"]["nine_router_catalog"] = {"enabled": True, "route": "r", "prefix": "9r/"}
|
||||
self.external(None)
|
||||
self.env["ROUTES"]["r"]["flatten_tools"] = True
|
||||
self.env["urllib"].request.urlopen.return_value = Mock(status=200)
|
||||
picture = {"type": "input_image", "image_url": "data:image/png;base64," + "QUJD" * 500}
|
||||
self.send("x/tek", [{"type": "function_call_output", "name": "image_gen", "output": [picture]}, user("devam")])
|
||||
sent = self.env["urllib"].request.urlopen.call_args.args[0].data.decode()
|
||||
self.assertNotIn("QUJDQUJD", sent)
|
||||
self.assertIn("resim", sent)
|
||||
|
||||
|
||||
class Govde(unittest.TestCase):
|
||||
read = build([method("_read")], {})["_read"]
|
||||
|
||||
def test_chunked_upload_cut_short_ends_instead_of_spinning(self):
|
||||
fake = types.SimpleNamespace(headers={"Transfer-Encoding": "chunked"}, rfile=io.BytesIO(b"5\r\nhello\r\n"))
|
||||
self.assertEqual(Govde.read(fake), b"hello")
|
||||
fake = types.SimpleNamespace(headers={"Transfer-Encoding": "chunked"}, rfile=io.BytesIO(b"5\r\nhello\r\n0\r\n\r\n"))
|
||||
self.assertEqual(Govde.read(fake), b"hello")
|
||||
|
||||
|
||||
class Liste(unittest.TestCase):
|
||||
def env(self, **extra):
|
||||
env = {"WATCH_OWNER": None, "WatchResponse": lambda response, accept: response, "json": json, "copy": copy, "hashlib": hashlib, "time": time, "log": lambda m: None, "CFG": {},
|
||||
"decompress": lambda raw, enc: raw, "EXTRA_MODELS": [], "IDE_PREFIX": "ide/", "SLIM_CLONES": False,
|
||||
"SLIM_FIELDS": [], "ide_catalog": ide_catalog, "LAST_MODELS": {}, "save_last_models": Mock(),
|
||||
"RESP_HOP": {"content-length", "content-encoding"}, "REQ_HOP": {"host"}, "CHATGPT": "https://example.invalid",
|
||||
"UPSTREAM_IDLE_TIMEOUT": 1,
|
||||
"urllib": types.SimpleNamespace(request=types.SimpleNamespace(Request=urllib.request.Request, urlopen=Mock()),
|
||||
error=urllib.error)}
|
||||
env.update(extra)
|
||||
return build([method("_models"), method("_passthrough"), method("_son_listeyi_ver")], env)
|
||||
|
||||
UPSTREAM = {"models": [{"slug": "gpt-6-sol", "display_name": "GPT-6-Sol", "priority": 1},
|
||||
{"slug": "gpt-6-astra", "display_name": "GPT-6-Astra", "priority": 2}]}
|
||||
|
||||
def test_gzip_answer_is_still_cached_plain_and_originals_keep_name_and_place(self):
|
||||
extras = [{"slug": "gpt-6-astra", "display_name": "Baska Ad"}, {"slug": "ide/a", "display_name": "Aaa", "kind": "combo"}]
|
||||
env = self.env(EXTRA_MODELS=extras, CFG={"display_map": {"gpt-6-sol": "Yeni Ad"}})
|
||||
fake = Sahte({"Accept-Encoding": "gzip", "chatgpt-account-id": "hesap"}, "GET")
|
||||
resp = types.SimpleNamespace(read=lambda: json.dumps(self.UPSTREAM).encode(), headers={"ETag": "x"})
|
||||
env["_models"](fake, resp, "[t]")
|
||||
listed = json.loads(gzip.decompress(fake.wfile.getvalue()))["models"]
|
||||
self.assertEqual([(m["slug"], m["display_name"]) for m in listed],
|
||||
[("gpt-6-sol", "GPT-6-Sol"), ("gpt-6-astra", "GPT-6-Astra"), ("ide/a", "Aaa")])
|
||||
cached = env["LAST_MODELS"]["hesap"]
|
||||
self.assertEqual(json.loads(cached["body"])["models"], listed) # plain JSON, usable as a fallback
|
||||
self.assertNotIn("Content-Encoding", dict(cached["headers"]))
|
||||
self.assertIn(("Content-Encoding", "gzip"), fake.sent)
|
||||
env["save_last_models"].assert_called_once()
|
||||
|
||||
def test_no_network_still_serves_the_last_list(self):
|
||||
cached = {"headers": [("Content-Type", "application/json")], "body": b'{"models": []}', "at": "x"}
|
||||
env = self.env(LAST_MODELS={"hesap": cached})
|
||||
env["urllib"].request.urlopen.side_effect = urllib.error.URLError(OSError("no network"))
|
||||
fake = Sahte({"chatgpt-account-id": "hesap"}, "GET")
|
||||
fake._son_listeyi_ver = types.MethodType(env["_son_listeyi_ver"], fake)
|
||||
env["_passthrough"](fake, 1, "/backend-api/codex/models?client_version=1", b"")
|
||||
self.assertEqual((fake.status, fake.wfile.getvalue()), (200, b'{"models": []}'))
|
||||
other = Sahte({"chatgpt-account-id": "baska"}, "GET")
|
||||
other._son_listeyi_ver = types.MethodType(env["_son_listeyi_ver"], other)
|
||||
env["_passthrough"](other, 1, "/backend-api/codex/models", b"")
|
||||
self.assertEqual(other._send_json.call_args.args[0], 502)
|
||||
|
||||
|
||||
class Kucukler(unittest.TestCase):
|
||||
def test_log_line_has_the_date_and_no_token_tail_and_status_reports_the_code(self):
|
||||
self.assertIn('time.strftime("%Y-%m-%d %H:%M:%S ") + msg', SOURCE)
|
||||
self.assertNotIn("token=..", SOURCE)
|
||||
self.assertNotIn("DISPLAY_MAP", SOURCE)
|
||||
self.assertIn('"kod": KOD', SOURCE)
|
||||
|
||||
def test_responses_path_with_a_query_is_still_routed(self):
|
||||
env = build([method("_handle")], {"lock": __import__("threading").Lock(), "seq": [0], "DEBUG": False,
|
||||
"log": lambda m: None})
|
||||
fake = Sahte({})
|
||||
fake.path = "/backend-api/codex/responses?stream=true"
|
||||
fake._read = lambda: b"{}"
|
||||
fake._responses = Mock()
|
||||
env["_handle"](fake)
|
||||
fake._responses.assert_called_once()
|
||||
fake._passthrough.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,407 @@
|
||||
"""Offline regressions for cross-provider context migration and native voice."""
|
||||
import ast
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
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
|
||||
import bridge
|
||||
import test_ayirici as fixtures
|
||||
from test_ayirici import duz, zstd, sunucu
|
||||
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"},
|
||||
{"type": "custom_tool_call_output", "id": "fc_out", "call_id": "call_1", "output": "result"},
|
||||
{"type": "compaction", "encrypted_content": "gAAAA_native"}]}
|
||||
before = copy.deepcopy(req)
|
||||
result = gc.normalize(req)
|
||||
self.assertEqual(req, before)
|
||||
self.assertIn("KARAR-4827", result["input"][0]["content"][0]["text"])
|
||||
self.assertEqual(result["input"][1]["id"], "ctc_call_173683")
|
||||
self.assertEqual(result["input"][2]["id"], "ctco_out")
|
||||
self.assertEqual(result["input"][3], req["input"][3])
|
||||
self.assertEqual(result["input"][1]["call_id"], result["input"][2]["call_id"])
|
||||
|
||||
def test_already_custom_stream_ids_and_deltas(self):
|
||||
adapter = env["SseRewriter"]({"apply_patch"})
|
||||
item = {"type": "custom_tool_call", "id": "fc_call_173683", "call_id": "call_1", "name": "apply_patch", "input": "abc"}
|
||||
for event in ("response.output_item.added", "response.output_item.done"):
|
||||
self.assertEqual(adapter._fix({"type": event, "item": dict(item)})["item"]["id"], "ctc_call_173683")
|
||||
self.assertEqual(adapter._fix({"type": "response.custom_tool_call_input.delta", "item_id": item["id"], "delta": "a"})["item_id"], "ctc_call_173683")
|
||||
final = adapter._fix({"type": "response.completed", "response": {"output": [dict(item)]}})
|
||||
self.assertEqual(final["response"]["output"][0]["id"], "ctc_call_173683")
|
||||
|
||||
def test_big_history_summarizes_middle_instead_of_cutting_it(self):
|
||||
req = {"model": "ide/test", "instructions": "keep instructions", "tools": [], "input": [
|
||||
{"role": "user", "content": "A" * 60000 + "KARAR-4827" + "B" * 60000},
|
||||
{"role": "assistant", "content": "work complete"},
|
||||
{"role": "user", "content": "Son karari uygula"}]}
|
||||
before = copy.deepcopy(req)
|
||||
calls = []
|
||||
def summarize(text, budget):
|
||||
self.assertIn("KARAR-4827", text)
|
||||
calls.append(budget)
|
||||
return "KARAR-4827: Korunacak karar ve acik is. " * 5
|
||||
prep = gc.Preparer()
|
||||
result = prep.prepare(req, 32768, summarize, "account/thread")
|
||||
self.assertEqual(req, before)
|
||||
self.assertLessEqual(gc.tokens(result), int(32768 * .7))
|
||||
self.assertEqual(result["instructions"], req["instructions"])
|
||||
self.assertEqual(result["input"][-1], req["input"][-1])
|
||||
self.assertIn("KARAR-4827", json.dumps(result))
|
||||
self.assertEqual(prep.prepare(req, 32768, summarize, "account/thread"), result)
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
def test_threshold_includes_tools_and_instructions(self):
|
||||
req = {"instructions": "x" * 100000, "tools": [{"type": "function", "name": "p", "description": "y" * 30000}],
|
||||
"input": [{"role": "user", "content": "hello"}]}
|
||||
with self.assertRaises(gc.TransferError):
|
||||
gc.Preparer().prepare(req, 32768, lambda *_: self.fail("must not summarize protected instructions"))
|
||||
|
||||
def test_failed_summary_never_replaces_history(self):
|
||||
req = {"input": [{"role": "user", "content": "a" * 90000}, {"role": "user", "content": "latest"}]}
|
||||
before = copy.deepcopy(req)
|
||||
for response in ("", "too short", "x" * 100000):
|
||||
with self.assertRaises(gc.TransferError):
|
||||
gc.Preparer().prepare(req, 32768, lambda *_: response)
|
||||
self.assertEqual(req, before)
|
||||
|
||||
def test_recent_tool_pairs_stay_together(self):
|
||||
call = {"type": "custom_tool_call", "call_id": "p", "input": "abc"}
|
||||
output = {"type": "custom_tool_call_output", "call_id": "p", "output": "result"}
|
||||
protected, old, recent = gc.split_history([
|
||||
{"role": "user", "content": "old"}, call, {"role": "user", "content": "latest"}, output], keep_users=1)
|
||||
self.assertIn(call, recent)
|
||||
self.assertIn(output, recent)
|
||||
|
||||
|
||||
class PreparationService(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Extract just the real preparation methods: importing kopru starts its
|
||||
# catalog and background threads, which must not happen in offline tests.
|
||||
tree = ast.parse(Path(__file__).with_name("kopru.py").read_text(encoding="utf-8"))
|
||||
owner = next(x for x in tree.body if isinstance(x, ast.ClassDef) and any(isinstance(m, ast.FunctionDef) and m.name == "_prepare_transfer" for m in x.body))
|
||||
methods = [x for x in owner.body if isinstance(x, ast.FunctionDef) and x.name in
|
||||
{"_transfer_window", "_transfer_summary", "_prepare_transfer"}]
|
||||
self.ns = {"json": json, "hashlib": hashlib, "urllib": __import__("urllib"), "gecis_context": gc,
|
||||
"ozet_kopru": ozet_kopru, "pencere": __import__("pencere"), "CFG": {}, "UA": "test",
|
||||
"UPSTREAM_IDLE_TIMEOUT": 5, "MODEL_MAP": {"ide/test": ("aiturk", "test", True)},
|
||||
"IDE_BILGI": {"ide/test": {"pencere": 32768, "kaynak": "yerel"}},
|
||||
"LAST_MODELS": {"acct": {"body": json.dumps({"models": [{"slug": "gpt-6-astra", "context_window": 128000}]})}},
|
||||
"ek_model_mu": lambda slug: slug.startswith("ide/"), "yonlendirilen": lambda *_: None}
|
||||
def upstream(h, body):
|
||||
data = json.loads(body)
|
||||
self.assertEqual(data["model"], "test")
|
||||
self.assertEqual(data["tools"], [])
|
||||
self.assertEqual(h.headers["Authorization"], "Bearer gateway-test")
|
||||
self.assertNotIn("chatgpt-account-id", h.headers)
|
||||
text = "KARAR-4827: Dosya yolu ve acik is korunacak. " * 3
|
||||
events = [{"type": "response.output_item.done", "item": {"type": "message", "content": [{"type": "output_text", "text": text}]}},
|
||||
{"type": "response.completed", "response": {"status": "completed", "output": []}}]
|
||||
raw = b"".join(b"data: " + json.dumps(e).encode() + b"\n\n" for e in events)
|
||||
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"]()
|
||||
self.service.headers = {"Authorization": "Bearer native-test", "chatgpt-account-id": "acct"}
|
||||
self.service._openai_ozet_coz = lambda *_: None
|
||||
|
||||
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"},
|
||||
{"role": "user", "content": "latest"}]}
|
||||
result = self.service._prepare_transfer({"request": req})["request"]
|
||||
self.assertLessEqual(gc.tokens(result), int(32768 * .7))
|
||||
self.assertIn("KARAR-4827", json.dumps(result))
|
||||
self.assertGreater(len(self.server.gelen), 1) # bounded chunks, not oversized summarizer request
|
||||
|
||||
def test_subscription_window_is_read_from_its_own_catalog(self):
|
||||
self.assertEqual(self.service._transfer_window("gpt-6-astra"), 128000)
|
||||
self.assertEqual(self.service._transfer_window("ide/unknown"), 32768)
|
||||
|
||||
def test_output_reserve_can_require_preparation_below_seventy_percent(self):
|
||||
req = {"model": "ide/test", "max_output_tokens": 24000,
|
||||
"input": [{"role": "user", "content": "x" * 20000}, {"role": "user", "content": "latest"}]}
|
||||
self.assertLess(gc.tokens(req), int(32768 * .7))
|
||||
result = self.service._prepare_transfer({"request": req})["request"]
|
||||
self.assertLessEqual(gc.tokens(result), 32768 - 24000)
|
||||
self.assertTrue(self.server.gelen)
|
||||
|
||||
def test_unreadable_native_compaction_never_drops_context(self):
|
||||
req = {"model": "ide/test", "input": [{"type": "compaction", "encrypted_content": "gAAAA_unreadable"}]}
|
||||
with self.assertRaises(gc.TransferError):
|
||||
self.service._prepare_transfer({"request": req})
|
||||
self.assertEqual(self.server.gelen, [])
|
||||
|
||||
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_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):
|
||||
external = index % 2 == 0
|
||||
req = {"model": "ide/test" if external else "gpt-6-astra",
|
||||
"prompt_cache_key": f"parallel-{index}",
|
||||
"input": [{"role": "user", "content": f"marker-{index}"}]}
|
||||
start.wait(timeout=5)
|
||||
status, body, _ = self.iste('POST', '/backend-api/codex/responses', json.dumps(req).encode())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn(b'"kopru":1' if external else b'"ok":1', body)
|
||||
with ThreadPoolExecutor(max_workers=50) as pool:
|
||||
list(pool.map(call, range(50)))
|
||||
native = [json.loads(x['govde']) for x in self.ozgun.gelen]
|
||||
added = [json.loads(x['govde']) for x in self.kopru.gelen if x['yol'].endswith('/responses')]
|
||||
self.assertEqual(len(native), 25)
|
||||
self.assertEqual(len(added), 25)
|
||||
self.assertTrue(all(x['model'] == 'gpt-6-astra' for x in native))
|
||||
self.assertTrue(all(x['model'] == 'ide/test' for x in added))
|
||||
self.assertEqual(len({x['prompt_cache_key'] for x in native + added}), 50)
|
||||
self.assertEqual(bridge.Sunucu.request_queue_size, 128)
|
||||
|
||||
def test_concurrent_native_chat_is_not_blocked_by_external_preparation(self):
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
original_worker = self.kopru.cevap
|
||||
|
||||
def worker(h, body):
|
||||
if h.path.endswith('/prepare-transfer'):
|
||||
entered.set()
|
||||
if not release.wait(5):
|
||||
return duz(422, b'{"error":{"message":"test wait expired"}}')(h, body)
|
||||
return original_worker(h, body)
|
||||
|
||||
self.kopru.cevap = worker
|
||||
external = {"model": "ide/test", "prompt_cache_key": "external-thread", "input": []}
|
||||
native = {"model": "gpt-6-astra", "prompt_cache_key": "native-thread", "input": []}
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
pending = pool.submit(self.iste, 'POST', '/backend-api/codex/responses', json.dumps(external).encode())
|
||||
try:
|
||||
self.assertTrue(entered.wait(2))
|
||||
result = pool.submit(self.iste, 'POST', '/backend-api/codex/responses', json.dumps(native).encode()).result(timeout=2)
|
||||
self.assertEqual(result[0], 200)
|
||||
self.assertEqual(json.loads(self.ozgun.gelen[-1]['govde']), native)
|
||||
self.assertEqual(len(self.kopru.gelen), 1)
|
||||
finally:
|
||||
release.set()
|
||||
self.assertEqual(pending.result(timeout=3)[0], 200)
|
||||
self.assertEqual(len(self.ozgun.gelen), 1)
|
||||
|
||||
def test_native_voice_never_enters_added_model_or_preparation_path(self):
|
||||
for path in ("/backend-api/codex/realtime/calls", "/wham/realtime/calls?intent=quicksilver&architecture=avas"):
|
||||
for model in ("ide/GPT-6-Astra", "ide/agy-gemini-3.8-flash-high", "gpt-6-astra"):
|
||||
body = json.dumps({"model": model}).encode()
|
||||
self.assertEqual(self.iste("POST", path, body, {"Authorization": "Bearer voice-test"})[0], 200)
|
||||
self.assertEqual(self.ozgun.gelen[-1]["govde"], body)
|
||||
self.assertEqual(self.ozgun.gelen[-1]["baslik"]["Authorization"], "Bearer voice-test")
|
||||
self.assertEqual(self.kopru.gelen, [])
|
||||
|
||||
def test_returning_compressed_history_repaired_but_inference_stays_native(self):
|
||||
req = {"model": "gpt-6-astra", "input": [ozet_kopru.ozet_ogesi("KARAR-4827"),
|
||||
{"type": "custom_tool_call", "id": "fc_call_173683", "call_id": "p", "name": "probe", "input": "x"}]}
|
||||
body = zstd(json.dumps(req).encode())
|
||||
self.iste("POST", "/backend-api/codex/responses", body, {"Content-Encoding": "zstd", "Authorization": "Bearer native-test"})
|
||||
sent = self.ozgun.gelen[-1]
|
||||
parsed = json.loads(sent["govde"])
|
||||
self.assertIn("KARAR-4827", json.dumps(parsed))
|
||||
self.assertEqual(parsed["input"][1]["id"], "ctc_call_173683")
|
||||
self.assertNotIn("Content-Encoding", sent["baslik"])
|
||||
self.assertEqual(sent["baslik"]["Authorization"], "Bearer native-test")
|
||||
self.assertEqual(self.kopru.gelen, [])
|
||||
|
||||
def test_large_return_uses_preparation_only_not_aiturk_inference(self):
|
||||
req = {"model": "gpt-6-astra", "input": [{"role": "assistant", "id": "msg_chatcmpl-foreign", "content": "x" * 100000},
|
||||
{"role": "user", "content": "latest"}]}
|
||||
def worker(h, body):
|
||||
payload = json.loads(body)
|
||||
self.assertEqual(h.path, "/aiturk/codex-baglama/prepare-transfer")
|
||||
clean = dict(payload["request"], input=[{"role": "user", "content": "prepared summary"}, req["input"][-1]])
|
||||
duz(200, json.dumps({"request": clean}).encode())(h, body)
|
||||
self.kopru.cevap = worker
|
||||
self.iste("POST", "/backend-api/codex/responses", json.dumps(req).encode())
|
||||
self.assertEqual(len(self.kopru.gelen), 1)
|
||||
self.assertEqual(json.loads(self.ozgun.gelen[-1]["govde"])["model"], "gpt-6-astra")
|
||||
|
||||
def test_preparation_failure_never_sends_truncated_request(self):
|
||||
self.kopru.cevap = duz(422, b'{"error":{"message":"summary unavailable"}}')
|
||||
_, body, _ = self.iste("POST", "/backend-api/codex/responses", b'{"model":"ide/test","input":[]}')
|
||||
self.assertIn(b"response.failed", body)
|
||||
self.assertEqual(self.ozgun.gelen, [])
|
||||
self.assertEqual(len(self.kopru.gelen), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,113 @@
|
||||
import json, os, tempfile, unittest
|
||||
import ide_catalog as c
|
||||
|
||||
|
||||
def girdi(mid, ad, yetenek=("chat",), sira=0, kaynak="omniroute", kind="model"):
|
||||
return {"id": mid, "turkservis": {"display_name": ad, "capabilities": list(yetenek), "order": sira, "source": kaynak, "kind": kind}}
|
||||
|
||||
|
||||
KATALOG = [
|
||||
girdi("Ajan-Arac", "OmniRoute · Ajan-Arac", sira=5),
|
||||
girdi("9router-cc-opus", "OmniRoute · cc/claude-opus-5-5", sira=2),
|
||||
girdi("9router-nano-x", "OmniRoute · nano/x", sira=1),
|
||||
girdi("gorsel", "OmniRoute · gpt-image-1", ("image",)),
|
||||
girdi("9router-Takim-Agir", "OmniRoute · Takim-Agir", sira=9, kind="combo"),
|
||||
girdi("9router-Takim-Agir-6e65dba5", "OmniRoute · Takim-Agir", sira=8, kind="combo"),
|
||||
girdi("Ajan-Arac", "OmniRoute · Ajan-Arac (tekrar)"),
|
||||
]
|
||||
|
||||
|
||||
class Ayar(unittest.TestCase):
|
||||
def test_dosya_yoksa_varsayilan_acik(self):
|
||||
self.assertEqual(c.ayar_oku(os.path.join(tempfile.gettempdir(), "yok-boyle-dosya.json")), {"enabled": True, "kapsam": "ide"})
|
||||
|
||||
def test_kapatma_ve_bozuk_dosya(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
yol = os.path.join(d, "a.json")
|
||||
for icerik, beklenen in [('{"enabled": false}', {"enabled": False, "kapsam": "ide"}),
|
||||
('{"enabled": true, "kapsam": "hepsi"}', {"enabled": True, "kapsam": "hepsi"}),
|
||||
('{"enabled": "hayir", "kapsam": "x"}', {"enabled": True, "kapsam": "ide"}),
|
||||
('bozuk', {"enabled": True, "kapsam": "ide"})]:
|
||||
open(yol, "w", encoding="utf-8").write(icerik)
|
||||
self.assertEqual(c.ayar_oku(yol), beklenen, icerik)
|
||||
|
||||
|
||||
class Yapilandirma(unittest.TestCase):
|
||||
def test_config_yaml_okuma(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
open(os.path.join(d, "config.yaml"), "w", encoding="utf-8").write(
|
||||
"model:\n default: Ajan-Arac\n provider: turkservis\nproviders:\n baska:\n models:\n Yabanci: {}\n"
|
||||
" turkservis:\n base_url: https://ornek.test/v1/\n # yorum\n models:\n Hermes-Master: {}\n"
|
||||
" 'cc/claude-opus-5-5': {}\n timeout: 3\nmcp_servers:\n x:\n models:\n Degil: {}\n")
|
||||
open(os.path.join(d, ".env"), "w", encoding="utf-8").write("A=1\nHERMES_CUSTOM_TURKSERVIS_API_KEY='sahte-anahtar'\n")
|
||||
self.assertEqual(c.ide_yapilandirma(d), ("https://ornek.test/v1", "Ajan-Arac", ["Hermes-Master", "cc/claude-opus-5-5"]))
|
||||
self.assertEqual(c.anahtar_oku(d), "sahte-anahtar")
|
||||
|
||||
def test_dosyalar_yoksa(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
self.assertEqual(c.ide_yapilandirma(d), (c.DEFAULT_BASE, "", []))
|
||||
self.assertEqual(c.anahtar_oku(d), "")
|
||||
|
||||
|
||||
class Secim(unittest.TestCase):
|
||||
def test_full_catalog_up_to_one_thousand_models(self):
|
||||
catalog = [girdi(f"provider-{n:04}", f"provider/model-{n:04}") for n in range(1100)]
|
||||
selected = c.sec(catalog)
|
||||
self.assertEqual(len(selected), 1000)
|
||||
self.assertEqual(selected[-1]["id"], "provider-0999")
|
||||
|
||||
def test_ide_kapsami(self):
|
||||
s = c.sec(KATALOG, ["cc/claude-opus-5-5"], "ide")
|
||||
# Uye katalogu yerel config'teki adlarla sinirlanmaz.
|
||||
self.assertEqual([x["id"] for x in s], ["9router-nano-x", "9router-cc-opus", "Ajan-Arac"])
|
||||
|
||||
def test_9router_combolari_gosterilmez(self):
|
||||
katalog = [girdi("9router-Takim-Agir-6e65dba5", "Takim-Agir", kaynak="9router", sira=1),
|
||||
girdi("9router-Takim-Agir", "Takim-Agir (2)", sira=2, kind="combo"),
|
||||
girdi("Takim-Agir", "Takim-Agir (3)", sira=3),
|
||||
girdi("Ajan-Arac", "Ajan-Arac", sira=4),
|
||||
girdi("9router-nano-x", "nano/x", kaynak="9router", sira=5)]
|
||||
s = c.sec(katalog, ["nano/x"], "ide")
|
||||
# 9Router combosu ve gecidin 9router saglayicisindaki kopyasi yok; OmniRoute'un kendi combosu
|
||||
# sayacsiz adiyla kalir. Dogrudan 9Router tekilleri de eklenmez.
|
||||
self.assertEqual([(x["id"], x["ad"]) for x in s],
|
||||
[("Takim-Agir", "Takim-Agir"), ("Ajan-Arac", "Ajan-Arac")])
|
||||
|
||||
def test_9router_modelleri_yalniz_omniroute_uzerinden_eklenir(self):
|
||||
katalog = [girdi("9router-nano-x-aaaa1111", "nano/x", kaynak="9router", sira=1),
|
||||
girdi("9router-nano-x", "nano/x (2)", sira=2),
|
||||
girdi("9router-ocg-y-bbbb2222", "ocg/y", kaynak="9router", sira=3),
|
||||
girdi("vag-z", "vag/z", sira=4),
|
||||
girdi("9router-ses", "nano/ses", ("tts",), kaynak="9router", sira=5),
|
||||
girdi("9router-Takim-Agir-6e65dba5", "Takim-Agir", kaynak="9router", sira=6)]
|
||||
s = c.sec(katalog, [], "ide")
|
||||
# Yerel model tanimi gerekmez; iki kaynak yerine yalniz OmniRoute yolu gelir.
|
||||
self.assertEqual([(x["id"], x["ad"]) for x in s],
|
||||
[("9router-nano-x", "nano/x"), ("vag-z", "vag/z")])
|
||||
|
||||
def test_gorunen_ad_yalniz_saglayici_ve_model(self):
|
||||
for tam, beklenen in [("OmniRoute · ag/gemini-3.8-flash", "ag/gemini-3.8-flash"),
|
||||
("nano/qwen/qwen3.8-27b-obliterated:thinking", "nano/qwen3.8-27b-obliterated:thinking"),
|
||||
("9Router · Nous-Research/inclusionai/ling-3.1-flash", "Nous-Research/ling-3.1-flash"),
|
||||
("cf/@cf/qwen/qwq-32b", "cf/qwq-32b"),
|
||||
("9R · nano/qwen/qwen3.8-27b", "nano/qwen3.8-27b"), ("9R · ag/gemini", "ag/gemini"),
|
||||
("OmniRoute · Ajan-Arac", "Ajan-Arac"), ("cc/claude-opus-5-5", "cc/claude-opus-5-5")]:
|
||||
self.assertEqual(c.kisa_ad(tam), beklenen, tam)
|
||||
|
||||
def test_cakisan_adlar_ayirt_edilir(self):
|
||||
adlar = c.adlandir([("a", "nano/z-ai/glm-5.3"), ("b", "nano/baska/glm-5.3"), ("d", "cc/opus"), ("e-12345678", "OmniRoute · Takim-Agir")],
|
||||
dolu={"takim-agir"})
|
||||
self.assertEqual(adlar["a"], "nano/z-ai/glm-5.3") # kisa adi cakisan: aradaki halka geri gelir
|
||||
self.assertEqual(adlar["b"], "nano/baska/glm-5.3")
|
||||
self.assertEqual(adlar["d"], "cc/opus")
|
||||
self.assertEqual(adlar["e-12345678"], "Takim-Agir [12345678]")
|
||||
self.assertEqual(len(set(adlar.values())), 4)
|
||||
|
||||
def test_hepsi_kapsami_yalniz_sohbet(self):
|
||||
s = c.sec(KATALOG, [], "hepsi")
|
||||
self.assertEqual(len(s), 3) # iki 9Router combosu hicbir kapsamda gosterilmez
|
||||
self.assertNotIn("gorsel", [x["id"] for x in s])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,81 @@
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import ara
|
||||
import internet
|
||||
|
||||
SAYFA = '''
|
||||
<div class="result results_links results_links_deep web-result ">
|
||||
<h2 class="result__title"><a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fornek.com%2Fbir%3Fa%3D1&rut=x">Bir <b>başlık</b></a></h2>
|
||||
<a class="result__snippet" href="//x">Kısa & öz <b>özet</b></a>
|
||||
</div>
|
||||
<div class="result results_links web-result ">
|
||||
<a class="result__a" href="https://duckduckgo.com/y.js?ad=1">Reklam</a>
|
||||
</div>
|
||||
<div class="result results_links web-result ">
|
||||
<a class="result__a" href="https://ornek.org/iki">İkinci</a>
|
||||
</div>
|
||||
'''
|
||||
|
||||
|
||||
class AraTest(unittest.TestCase):
|
||||
def test_results_are_parsed_and_ads_skipped(self):
|
||||
self.assertEqual(ara.sonuclari_coz(SAYFA), [("Bir başlık", "https://ornek.com/bir?a=1", "Kısa & öz özet"),
|
||||
("İkinci", "https://ornek.org/iki", "")])
|
||||
self.assertEqual(len(ara.sonuclari_coz(SAYFA, adet=1)), 1)
|
||||
|
||||
def test_search_output_and_empty_result(self):
|
||||
metin = ara.ara("x", indirici=lambda adres: (SAYFA, "text/html"))
|
||||
self.assertIn("1. Bir başlık\n https://ornek.com/bir?a=1", metin)
|
||||
self.assertIn("Sonuç bulunamadı", ara.ara("x", indirici=lambda adres: ('<div class="no-results">no results</div>', "text/html")))
|
||||
|
||||
def test_bot_challenge_uses_one_rss_backup_not_false_no_results(self):
|
||||
calls = []
|
||||
def indirici(adres):
|
||||
calls.append(adres)
|
||||
return (('<form id="challenge-form">Bot challenge</form>' if len(calls) == 1 else
|
||||
'<rss><channel><item><title>OmniRoute</title><link>https://ornek.com/</link></item></channel></rss>'), 'text/html')
|
||||
self.assertIn('https://ornek.com/', ara.ara('OmniRoute', indirici=indirici))
|
||||
self.assertEqual(len(calls), 2)
|
||||
|
||||
def test_two_invalid_services_report_failure_not_no_results(self):
|
||||
with self.assertRaisesRegex(RuntimeError, 'Arama hizmeti'):
|
||||
ara.ara('x', indirici=lambda adres: ('<html>challenge</html>', 'text/html'))
|
||||
|
||||
def test_page_is_turned_into_text_and_cut(self):
|
||||
sayfa = "<html><head><style>a{}</style><script>x()</script></head><body><nav>menü</nav><p>Bir & iki</p><div>üç</div></body></html>"
|
||||
self.assertEqual(ara.oku("ornek.com", indirici=lambda adres: (sayfa, "text/html")), "Bir & iki\nüç")
|
||||
uzun = ara.oku("https://ornek.com", en_cok=10, indirici=lambda adres: ("<p>" + "a" * 50 + "</p>", "text/html"))
|
||||
self.assertTrue(uzun.startswith("a" * 10 + "\n[... kesildi: 50"))
|
||||
|
||||
def test_main_reports_errors_without_raising(self):
|
||||
self.assertEqual(ara.main(["oku", "http://127.0.0.1:9/yok"]), 1)
|
||||
|
||||
|
||||
class InternetTest(unittest.TestCase):
|
||||
def test_note_names_both_commands_and_is_added_once(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
betik = os.path.join(d, "ara.py")
|
||||
open(betik, "w").close()
|
||||
metin = internet.talimat(None, betik, python="py.exe")
|
||||
on = '& ' if os.name == 'nt' else ''
|
||||
self.assertIn(f'{on}"py.exe" "{betik}" ara "sorgu"', metin)
|
||||
self.assertIn(f'"py.exe" "{betik}" oku <adres>', metin)
|
||||
req = {"input": [{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "araştır"}]}]}
|
||||
self.assertTrue(internet.ekle(req, metin))
|
||||
self.assertFalse(internet.ekle(req, metin))
|
||||
self.assertEqual(req["input"][0]["role"], "developer")
|
||||
self.assertEqual(len(req["input"]), 2)
|
||||
self.assertIsNone(internet.talimat({"enabled": False}, betik))
|
||||
self.assertIsNone(internet.talimat(None, os.path.join(d, "ara.py"))) # betik yoksa not eklenmez
|
||||
|
||||
def test_windowless_python_is_swapped_for_the_console_one(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
open(os.path.join(d, "python.exe"), "w").close()
|
||||
self.assertEqual(internet.python_yolu(os.path.join(d, "pythonw.exe")), os.path.join(d, "python.exe"))
|
||||
self.assertEqual(internet.python_yolu(os.path.join("yok", "pythonw.exe")), os.path.join("yok", "pythonw.exe"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""The "send larger work to AITURK" instruction reaches only Codex Desktop, and only when a send command exists."""
|
||||
import unittest
|
||||
|
||||
import is_hatti as h
|
||||
|
||||
|
||||
class Hat(unittest.TestCase):
|
||||
def test_only_the_desktop_app_is_recognised(self):
|
||||
self.assertTrue(h.masaustu_mu("Codex Desktop"))
|
||||
for other in ("codex-tui", "codex_exec", "codex_cli_rs", "", None, "Codex Desktop Helper"):
|
||||
self.assertFalse(h.masaustu_mu(other), other)
|
||||
|
||||
def test_no_instruction_when_switched_off_or_without_a_send_command(self):
|
||||
self.assertIsNone(h.talimat(None))
|
||||
self.assertIsNone(h.talimat({}))
|
||||
self.assertIsNone(h.talimat({"enabled": True, "komut": " "}))
|
||||
self.assertIsNone(h.talimat({"enabled": False, "komut": "aiturk gonder"}))
|
||||
self.assertIsNone(h.talimat({"enabled": False}, "C:/a/aiturk.exe", "s1"))
|
||||
text = h.talimat({"enabled": True, "komut": "aiturk gonder --dosya is.md"})
|
||||
self.assertIn("aiturk gonder --dosya is.md", text)
|
||||
self.assertIn("sen yap", text)
|
||||
self.assertEqual(h.talimat({"enabled": True, "komut": "x", "talimat": "ozel {komut}"}), "ozel x")
|
||||
|
||||
def test_on_by_default_once_a_send_command_exists_on_this_computer(self):
|
||||
text = h.talimat(None, "C:/Program/aiturk.exe", "019a-bc_d")
|
||||
self.assertIn('"C:/Program/aiturk.exe" is --sohbet 019a-bc_d --dosya', text)
|
||||
self.assertIn('"C:/Program/aiturk.exe" is --dosya', h.talimat({}, "C:/Program/aiturk.exe", ""))
|
||||
# Sohbet kimligi komut satirina yazilir: kabuk karakteri tasiyamaz.
|
||||
self.assertIn("--sohbet abrm-rf ", h.komut_satiri("x", 'a"b; rm -rf /'))
|
||||
# Ayardaki komut bulunanin onune gecer.
|
||||
self.assertIn("elle", h.talimat({"komut": "elle"}, "C:/Program/aiturk.exe"))
|
||||
|
||||
def test_the_first_candidate_that_knows_the_command_is_used(self):
|
||||
import os
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
eski = os.path.join(d, "TurkServis", "AITURK", "program", "bin", "aiturk.cmd")
|
||||
yeni = os.path.join(d, "TurkServis", "AITURK-is-hatti", "Scripts", "aiturk.exe")
|
||||
for yol in (eski, yeni):
|
||||
os.makedirs(os.path.dirname(yol))
|
||||
open(yol, "w").close()
|
||||
ortam = {"LOCALAPPDATA": d}
|
||||
self.assertEqual(h.adaylar(ortam)[0], eski)
|
||||
self.assertEqual(h.komut_bul(ortam, destek=lambda y: y == yeni), yeni)
|
||||
self.assertEqual(h.komut_bul(ortam, destek=lambda y: True), eski)
|
||||
self.assertIsNone(h.komut_bul(ortam, destek=lambda y: False))
|
||||
|
||||
class Sonuc:
|
||||
returncode = 2
|
||||
self.assertFalse(h.destekliyor(eski, calistir=lambda *a, **k: Sonuc()))
|
||||
Sonuc.returncode = 0
|
||||
self.assertTrue(h.destekliyor(eski, calistir=lambda *a, **k: Sonuc()))
|
||||
self.assertFalse(h.destekliyor(os.path.join(d, "yok.exe"), calistir=lambda *a, **k: Sonuc()))
|
||||
|
||||
def patlar(*a, **k):
|
||||
raise OSError("x")
|
||||
self.assertFalse(h.destekliyor(eski, calistir=patlar))
|
||||
|
||||
def test_instruction_is_put_first_once_and_never_repeated(self):
|
||||
req = {"input": [{"type": "message", "role": "user", "content": "selam"}]}
|
||||
text = h.talimat({"enabled": True, "komut": "aiturk gonder"})
|
||||
self.assertTrue(h.ekle(req, text))
|
||||
self.assertEqual((req["input"][0]["role"], len(req["input"])), ("developer", 2))
|
||||
self.assertFalse(h.ekle(req, text))
|
||||
self.assertEqual(len(req["input"]), 2)
|
||||
self.assertFalse(h.ekle({"input": "duz metin"}, text))
|
||||
self.assertFalse(h.ekle(req, None))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,365 @@
|
||||
"""Layer check (11 Oct 2026): Jev passes or blocks each step of Katman-1..3; a blocked step is redone by the next
|
||||
layer. Katman-4 is never asked. No exceptions."""
|
||||
import ast
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
import jev_denetim
|
||||
import yarim
|
||||
|
||||
SOURCE = Path(__file__).with_name("kopru.py").read_text(encoding="utf-8")
|
||||
tree = ast.parse(SOURCE)
|
||||
handler = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "H")
|
||||
|
||||
|
||||
def method(name):
|
||||
return next(n for n in handler.body if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
|
||||
|
||||
def user(text):
|
||||
return {"type": "message", "role": "user", "content": [{"type": "input_text", "text": text}]}
|
||||
|
||||
|
||||
def sse(*deltas):
|
||||
out = b""
|
||||
for d in deltas:
|
||||
out += b"event: response.output_text.delta\ndata: " + json.dumps(
|
||||
{"type": "response.output_text.delta", "delta": d}).encode() + b"\n\n"
|
||||
return out + b'event: response.completed\ndata: {"type": "response.completed"}\n\n'
|
||||
|
||||
|
||||
class Yanit:
|
||||
status = 200
|
||||
|
||||
def __init__(self, body):
|
||||
self.parts = [body[:len(body) // 2], body[len(body) // 2:]]
|
||||
self.headers = {"Content-Type": "text/event-stream"}
|
||||
|
||||
def read1(self, n):
|
||||
return self.parts.pop(0) if self.parts else b""
|
||||
|
||||
|
||||
class Karar(unittest.TestCase):
|
||||
def test_refusal_or_own_conditions_block(self):
|
||||
self.assertTrue(jev_denetim.karar({"ret": 0.84, "sart": 0.1}))
|
||||
self.assertTrue(jev_denetim.karar({"ret": 0.41, "sart": 0.95}))
|
||||
self.assertTrue(jev_denetim.karar({"ret": 0.5, "sart": 0.0}))
|
||||
|
||||
def test_normal_answer_passes(self):
|
||||
self.assertFalse(jev_denetim.karar({"ret": 0.05, "sart": 0.3}))
|
||||
|
||||
def test_no_exception_questions_exist(self):
|
||||
self.assertEqual(set(jev_denetim.SORU), {"ret", "sart"})
|
||||
|
||||
|
||||
class Katman(unittest.TestCase):
|
||||
def test_a_non_layer_model_falls_through_every_layer_from_the_top(self):
|
||||
self.assertEqual(jev_denetim.sonrakiler("Uncensored-Uygulama"),
|
||||
["Katman-1", "Katman-2", "Katman-3", "Katman-4"])
|
||||
self.assertEqual(jev_denetim.sonrakiler("ide/some-model"),
|
||||
["Katman-1", "Katman-2", "Katman-3", "Katman-4"])
|
||||
|
||||
def test_a_selected_layer_only_tries_the_ones_after_it(self):
|
||||
self.assertEqual(jev_denetim.sonrakiler("Katman-1"), ["Katman-2", "Katman-3", "Katman-4"])
|
||||
self.assertEqual(jev_denetim.sonrakiler("Katman-2"), ["Katman-3", "Katman-4"])
|
||||
self.assertEqual(jev_denetim.sonrakiler("Katman-3"), ["Katman-4"])
|
||||
|
||||
def test_last_layer_is_never_asked(self):
|
||||
self.assertTrue(jev_denetim.muaf_mi("Katman-4"))
|
||||
self.assertEqual(jev_denetim.sonrakiler(None), [])
|
||||
|
||||
|
||||
class Istek(unittest.TestCase):
|
||||
def test_last_user_request_is_taken_and_wrappers_are_skipped(self):
|
||||
voice = user("<realtime_delegation>\n <input>siteyi yap</input>\n</realtime_delegation>")
|
||||
self.assertEqual(jev_denetim.son_istek({"input": [user("eski"), voice]}), "siteyi yap")
|
||||
hook = user("<hook_context>pano</hook_context>")
|
||||
call = {"type": "function_call", "call_id": "c", "name": "shell", "arguments": "{}"}
|
||||
self.assertEqual(jev_denetim.son_istek({"input": [user("README'yi düzelt"), call, hook]}), "README'yi düzelt")
|
||||
self.assertEqual(jev_denetim.son_istek({"input": [call]}), "")
|
||||
|
||||
|
||||
class Sor(unittest.TestCase):
|
||||
def test_request_shape_and_scores(self):
|
||||
seen = {}
|
||||
|
||||
def gonder(govde, key):
|
||||
seen.update(govde=govde, key=key)
|
||||
return {"answers": {k: {"type": "noul", "noul": 0.25} for k in jev_denetim.SORU}}
|
||||
self.assertEqual(jev_denetim.sor("istek", "c" * 9000, "anahtar", gonder=gonder), {"ret": 0.25, "sart": 0.25})
|
||||
self.assertEqual(seen["key"], "anahtar")
|
||||
self.assertEqual(seen["govde"]["state"]["user_request"], "istek")
|
||||
self.assertEqual(seen["govde"]["state"]["assistant_reply"], "c" * 9000)
|
||||
|
||||
def test_failures_return_none_every_time(self):
|
||||
calls = []
|
||||
|
||||
def bozuk(govde, key):
|
||||
calls.append(1)
|
||||
raise OSError("ulasilamadi")
|
||||
for _ in range(5):
|
||||
self.assertIsNone(jev_denetim.sor("i", "c", "k", gonder=bozuk))
|
||||
self.assertEqual(len(calls), 5)
|
||||
|
||||
def test_key_is_read_from_env_or_file(self):
|
||||
self.assertEqual(jev_denetim.anahtar({"TYPESAFE_API_KEY": " a "}), "a")
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
yol = os.path.join(d, "typesafe.env")
|
||||
Path(yol).write_text('TYPESAFE_API_KEY="b"\n', encoding="utf-8")
|
||||
self.assertEqual(jev_denetim.anahtar({}, [yol]), "b")
|
||||
self.assertEqual(jev_denetim.anahtar({}, [os.path.join(d, "yok.env")]), "")
|
||||
|
||||
|
||||
class Akis(unittest.TestCase):
|
||||
"""_denetimli_akis through a fake handler: what reaches the client."""
|
||||
|
||||
def setUp(self):
|
||||
self.logs, self.asked, self.opened = [], [], []
|
||||
self.puanlar = [] # one verdict per asked answer; empty -> pass
|
||||
sahte = Mock()
|
||||
sahte.anahtar = lambda: "k"
|
||||
sahte.sor = self.sor
|
||||
sahte.karar = jev_denetim.karar
|
||||
sahte.komutlar = jev_denetim.komutlar
|
||||
sahte.hazirlik_mi = jev_denetim.hazirlik_mi
|
||||
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 = Mock()
|
||||
self.h.send_header = Mock()
|
||||
self.h.end_headers = Mock()
|
||||
self.cevap = {"Katman-1": sse("bir"), "Katman-2": sse("iki"), "Katman-3": sse("uc"), "Katman-4": sse("dort")}
|
||||
|
||||
def sor(self, istek, metin, key):
|
||||
self.asked.append(metin)
|
||||
return self.puanlar.pop(0) if self.puanlar else {"ret": 0.0, "sart": 0.0}
|
||||
|
||||
def ac(self, ad):
|
||||
self.opened.append(ad)
|
||||
if self.cevap[ad] is None:
|
||||
raise OSError("acilamadi")
|
||||
return Yanit(self.cevap[ad]), None, f"[{ad}]"
|
||||
|
||||
def sent(self):
|
||||
raw, out = self.h.wfile.getvalue(), b""
|
||||
while raw:
|
||||
size, _, rest = raw.partition(b"\r\n")
|
||||
n = int(size, 16)
|
||||
if n == 0:
|
||||
break
|
||||
out += rest[:n]
|
||||
raw = rest[n + 2:]
|
||||
return out
|
||||
|
||||
def calistir(self, body, sira=("Katman-2", "Katman-3", "Katman-4")):
|
||||
self.h._denetimli_akis(Yanit(body), "[1]", None, None, "yap", list(sira), self.ac)
|
||||
|
||||
def test_a_passed_step_is_delivered_unchanged(self):
|
||||
body = sse("Fenerbahçe'nin ", "13 puanı var.")
|
||||
self.calistir(body)
|
||||
self.assertEqual(self.sent(), body)
|
||||
self.assertEqual(self.asked, ["Fenerbahçe'nin 13 puanı var."])
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
def test_exact_voice_target_skips_jev_but_not_transport_fallback(self):
|
||||
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")
|
||||
self.assertEqual(self.sent(), body)
|
||||
self.assertEqual(self.asked, [])
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
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")
|
||||
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"):
|
||||
with self.subTest(target=target):
|
||||
self.setUp()
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}]
|
||||
self.h._denetimli_akis(Yanit(sse("hayir")), "[1]", None, None, "yap",
|
||||
list(jev_denetim.KATMAN), self.ac, hedef=target)
|
||||
self.assertEqual(self.asked, ["hayir", "bir"])
|
||||
self.assertEqual(self.opened, ["Katman-1"])
|
||||
|
||||
def test_a_blocked_non_layer_model_goes_to_layer_1_first(self):
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}] # the selected (non-layer) model is blocked
|
||||
self.calistir(sse("Bunu yapamam."), sira=("Katman-1", "Katman-2", "Katman-3", "Katman-4"))
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-1"])
|
||||
self.assertEqual(self.opened, ["Katman-1"])
|
||||
self.assertEqual(self.asked, ["Bunu yapamam.", "bir"])
|
||||
|
||||
def test_blocked_layer_1_goes_to_layer_2_which_is_checked_too(self):
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}]
|
||||
self.calistir(sse("Bunu yapamam."))
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-2"])
|
||||
self.assertNotIn(b"yapamam", self.h.wfile.getvalue())
|
||||
self.assertEqual(self.opened, ["Katman-2"])
|
||||
self.assertEqual(self.asked, ["Bunu yapamam.", "iki"])
|
||||
self.h.send_response.assert_called_once()
|
||||
|
||||
def test_every_layer_blocked_ends_at_layer_4_unasked(self):
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}] * 3
|
||||
self.calistir(sse("hayir"))
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-4"])
|
||||
self.assertEqual(self.opened, ["Katman-2", "Katman-3", "Katman-4"])
|
||||
self.assertEqual(self.asked, ["hayir", "iki", "uc"]) # layer 4 is never asked
|
||||
|
||||
def test_blocked_layer_3_goes_straight_to_layer_4(self):
|
||||
self.puanlar = [{"ret": 0.0, "sart": 0.8}]
|
||||
self.calistir(sse("kendi sartimla"), sira=("Katman-4",))
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-4"])
|
||||
self.assertEqual(self.asked, ["kendi sartimla"])
|
||||
|
||||
def test_a_layer_that_cannot_be_opened_is_skipped(self):
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}]
|
||||
self.cevap["Katman-2"] = None
|
||||
self.calistir(sse("hayir"))
|
||||
self.assertEqual(self.opened, ["Katman-2", "Katman-3"])
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-3"])
|
||||
|
||||
def test_a_layer_that_answers_with_an_error_is_skipped(self):
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}]
|
||||
self.cevap["Katman-2"] = b'event: error\ndata: {"type":"error","error":{"type":"upstream_error","message":"upstream 400"}}\n\n'
|
||||
self.calistir(sse("hayir"))
|
||||
self.assertEqual(self.opened, ["Katman-2", "Katman-3"])
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-3"])
|
||||
self.assertNotIn(b"upstream 400", self.h.wfile.getvalue())
|
||||
|
||||
KOMUT = (b'event: response.output_item.done\ndata: {"type": "response.output_item.done", "item": '
|
||||
b'{"type": "function_call", "name": "shell", "arguments": "{\\"cmd\\": \\"dir\\"}"}}\n\n')
|
||||
|
||||
def test_a_step_without_text_passes_without_jev(self):
|
||||
body = self.KOMUT + sse()
|
||||
self.calistir(body)
|
||||
self.assertEqual(self.sent(), body)
|
||||
self.assertEqual(self.asked, [])
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
def test_tool_step_cannot_be_replaced_even_if_jev_would_refuse(self):
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}]
|
||||
body = self.KOMUT + sse()
|
||||
self.calistir(body)
|
||||
self.assertEqual(self.opened, [])
|
||||
self.assertEqual(self.asked, [])
|
||||
self.assertEqual(self.sent(), body)
|
||||
|
||||
def test_tool_event_reaches_client_before_upstream_finishes(self):
|
||||
first = (b'data: {"type":"response.output_item.added","item":'
|
||||
b'{"type":"function_call","name":"shell","arguments":""}}\n\n')
|
||||
owner = self
|
||||
|
||||
class Streaming(Yanit):
|
||||
def __init__(self):
|
||||
super().__init__(b'')
|
||||
self.parts = [first, owner.KOMUT + sse()]
|
||||
self.reads = 0
|
||||
|
||||
def read1(self, n):
|
||||
self.reads += 1
|
||||
if self.reads == 2:
|
||||
owner.assertIn(first, owner.h.wfile.getvalue())
|
||||
owner.assertEqual(owner.asked, [])
|
||||
owner.assertEqual(owner.opened, [])
|
||||
return super().read1(n)
|
||||
|
||||
self.h._denetimli_akis(Streaming(), '[1]', None, None, 'yap', ['Katman-1'], self.ac)
|
||||
self.assertEqual(self.sent(), first + self.KOMUT + sse())
|
||||
|
||||
def test_text_with_a_tool_call_is_preparation_not_a_final_refusal(self):
|
||||
body = sse('Önce dosyaları inceleyeceğim.') + self.KOMUT
|
||||
self.calistir(body)
|
||||
self.assertEqual(self.sent(), body)
|
||||
self.assertEqual(self.asked, [])
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
def test_explicit_commentary_passes_without_jev(self):
|
||||
body = (b'data: {"type":"response.output_item.added","item":'
|
||||
b'{"type":"message","role":"assistant","phase":"commentary"}}\n\n' + sse('Araştırıyorum.'))
|
||||
self.calistir(body)
|
||||
self.assertEqual(self.sent(), body)
|
||||
self.assertEqual(self.asked, [])
|
||||
|
||||
def test_an_empty_step_of_the_selected_model_goes_to_the_next_layer(self):
|
||||
self.calistir(sse()) # no text, no tool call
|
||||
self.assertEqual(self.asked, ["iki"]) # nothing to ask about the empty step itself
|
||||
self.assertEqual(self.opened, ["Katman-2"])
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-2"])
|
||||
|
||||
def test_an_empty_layer_goes_on_to_the_one_after_it(self):
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}]
|
||||
self.cevap["Katman-2"] = sse()
|
||||
self.calistir(sse("hayir"))
|
||||
self.assertEqual(self.opened, ["Katman-2", "Katman-3"])
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-3"])
|
||||
|
||||
def test_an_error_of_the_selected_model_goes_to_the_next_layer(self):
|
||||
self.h._denetimli_akis(None, "[1]", None, None, "yap", ["Katman-1", "Katman-2", "Katman-3", "Katman-4"], self.ac)
|
||||
self.assertEqual(self.opened, ["Katman-1"])
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-1"])
|
||||
|
||||
def test_an_answer_cut_midway_goes_to_the_next_layer(self):
|
||||
class Kopan(Yanit):
|
||||
def read1(self, n):
|
||||
if not self.parts:
|
||||
raise OSError("koptu")
|
||||
return self.parts.pop(0)
|
||||
self.h._denetimli_akis(Kopan(sse("yarim")), "[1]", None, None, "yap", ["Katman-2", "Katman-3", "Katman-4"], self.ac)
|
||||
self.assertEqual(self.opened, ["Katman-2"])
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-2"])
|
||||
self.assertNotIn(b"yarim", self.h.wfile.getvalue())
|
||||
|
||||
def test_when_no_layer_opens_the_blocked_answer_is_not_delivered(self):
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}]
|
||||
self.cevap.update({"Katman-2": None, "Katman-3": None, "Katman-4": None})
|
||||
self.calistir(sse("Bunu yapamam."))
|
||||
self.assertEqual(self.opened, ["Katman-2", "Katman-3", "Katman-4"])
|
||||
self.assertNotIn(b"yapamam", self.h.wfile.getvalue())
|
||||
self.assertIn(b'response.failed', self.sent())
|
||||
|
||||
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"}}'
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}]
|
||||
|
||||
def ac(ad):
|
||||
raise Hata("HTTP 400")
|
||||
self.h._denetimli_akis(Yanit(sse("Bunu yapamam.")), "[1]", None, None, "yap", ["Katman-4"], ac)
|
||||
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]
|
||||
body = sse("Bunu yapamam.")
|
||||
self.calistir(body)
|
||||
self.assertEqual(self.sent(), body)
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,111 @@
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import jev_denetim
|
||||
import jev_settings
|
||||
|
||||
|
||||
class Settings(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.home = Path(self.tmp.name)
|
||||
self.home_patch = patch.object(jev_settings, 'home', return_value=self.home)
|
||||
self.home_patch.start()
|
||||
self.addCleanup(self.home_patch.stop)
|
||||
self.settings = copy.deepcopy(jev_settings.DEFAULTS)
|
||||
|
||||
def save(self, **changes):
|
||||
self.settings.update(changes)
|
||||
(self.home / 'jev-settings.json').write_text(json.dumps({'settings': self.settings}), encoding='utf-8')
|
||||
|
||||
def test_live_rules_general_override_and_always_pass(self):
|
||||
self.save(fallbackModel='B', rejectionRules=[{'from': 'A', 'to': 'C'}, {'from': 'C', 'to': 'D'}], exemptModels=['D'])
|
||||
self.assertEqual(jev_denetim.sonrakiler('A'), ['C', 'D'])
|
||||
self.assertEqual(jev_denetim.sonrakiler('Other'), ['B'])
|
||||
self.assertTrue(jev_denetim.muaf_mi('D'))
|
||||
self.assertFalse(jev_denetim.muaf_mi('A'))
|
||||
self.save(enabled=False)
|
||||
self.assertFalse(jev_denetim.etkin_mi())
|
||||
self.assertEqual(jev_denetim.sonrakiler('A'), [])
|
||||
|
||||
def test_cycle_cannot_spin_even_if_file_manually_edited(self):
|
||||
self.save(rejectionRules=[{'from': 'A', 'to': 'B'}, {'from': 'B', 'to': 'A'}])
|
||||
self.assertEqual(jev_denetim.sonrakiler('A'), ['B'])
|
||||
|
||||
def test_prompts_model_criteria_and_thresholds_are_applied(self):
|
||||
self.save(model='jev-custom', questions={'ret': 'question A', 'sart': 'question B'}, thresholds={'ret': .9, 'sart': .8}, criteria={'ret': {'true': 'bad', 'false': 'good'}, 'sart': {'true': '', 'false': ''}})
|
||||
captured = []
|
||||
def send(body, key):
|
||||
captured.append(body)
|
||||
return {'answers': {'ret': {'noul': .6}, 'sart': {'noul': .2}}}
|
||||
result = jev_denetim.sor('request', 'answer', 'test', gonder=send)
|
||||
self.assertFalse(jev_denetim.karar(result))
|
||||
self.assertEqual(captured[0]['model'], 'jev-custom')
|
||||
self.assertEqual(captured[0]['questions']['ret']['instructions'], 'question A')
|
||||
self.assertEqual(captured[0]['questions']['ret']['criteria'], {'true': 'bad', 'false': 'good'})
|
||||
self.assertNotIn('criteria', captured[0]['questions']['sart'])
|
||||
self.save(thresholds={'ret': .1, 'sart': .1})
|
||||
self.assertFalse(jev_denetim.karar(result)) # in-flight result retains its own thresholds
|
||||
|
||||
def test_concurrency_is_limited_without_serializing_all_requests(self):
|
||||
self.save(maxConcurrent=2)
|
||||
count, peak = 0, 0
|
||||
lock = threading.Lock()
|
||||
def send(body, key):
|
||||
nonlocal count, peak
|
||||
with lock:
|
||||
count += 1
|
||||
peak = max(peak, count)
|
||||
time.sleep(.04)
|
||||
with lock:
|
||||
count -= 1
|
||||
return {'answers': {'ret': {'noul': 0}, 'sart': {'noul': 0}}}
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
answers = list(pool.map(lambda _: jev_denetim.sor('r', 'a', 'test', gonder=send), range(8)))
|
||||
self.assertEqual(peak, 2)
|
||||
self.assertTrue(all(a is not None for a in answers))
|
||||
|
||||
def test_timeout_endpoint_and_failure_behavior(self):
|
||||
self.save(endpoint='https://example.test/jev', timeoutSeconds=42, failureAction='next')
|
||||
with patch('urllib.request.urlopen', side_effect=TimeoutError) as opening:
|
||||
self.assertIsNone(jev_denetim.sor('r', 'a', 'test'))
|
||||
self.assertEqual(opening.call_args.kwargs['timeout'], 42)
|
||||
self.assertEqual(opening.call_args.args[0].full_url, 'https://example.test/jev')
|
||||
self.assertTrue(jev_denetim.hata_gecis())
|
||||
self.assertEqual(jev_settings._active, 0)
|
||||
|
||||
def test_queue_wait_expires_and_slot_count_is_preserved(self):
|
||||
self.save(maxConcurrent=1, queueWaitSeconds=1)
|
||||
settings = jev_settings.read()[0]
|
||||
self.assertTrue(jev_settings.acquire(settings))
|
||||
try:
|
||||
with patch.object(jev_denetim, '_gonder') as send:
|
||||
self.assertIsNone(jev_denetim.sor('r', 'a', 'test', gonder=send))
|
||||
send.assert_not_called()
|
||||
self.assertEqual(jev_settings._active, 1)
|
||||
finally:
|
||||
jev_settings.release()
|
||||
|
||||
@unittest.skipUnless(os.name == 'nt', 'Windows DPAPI')
|
||||
def test_encrypted_key_roundtrip_without_plaintext_on_disk(self):
|
||||
secret = 'dummy-key-for-local-test'
|
||||
code = 'Add-Type -AssemblyName System.Security; $b=[Text.Encoding]::UTF8.GetBytes([Console]::In.ReadToEnd()); [Console]::Out.Write([Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect($b,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser)))'
|
||||
encrypted = subprocess.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', code], input=secret, capture_output=True, text=True, check=True, creationflags=subprocess.CREATE_NO_WINDOW).stdout.strip()
|
||||
text = json.dumps({'settings': self.settings, 'protectedKey': encrypted})
|
||||
(self.home / 'jev-settings.json').write_text(text, encoding='utf-8')
|
||||
self.assertNotIn(secret, text)
|
||||
self.assertEqual(jev_settings.key(), secret)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Bridge 7: tool-less models are learned, flat gateway errors are translated, instructions are never trimmed."""
|
||||
import ast
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import pencere
|
||||
|
||||
tree = ast.parse(Path(__file__).with_name("kopru.py").read_text(encoding="utf-8"))
|
||||
names = {"fix_tool_ids", "SseRewriter", "phase_damgala", "asistan_mesaji_mi", "nokta_mesaji_mi", "hazir_yaniti",
|
||||
"sema_duzelt", "arac_semalarini_duzelt"}
|
||||
nodes = [n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.ClassDef)) and n.name in names]
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id in
|
||||
{"ID_PREFIX", "FOREIGN_ID_MARK", "REFERENCE_TYPES", "INLINE_TYPES", "ARAC_OGELERI", "NOKTA_ISARETLERI"} for t in node.targets):
|
||||
nodes.insert(0, node)
|
||||
env = {"json": json, "re": __import__("re"), "time": __import__("time"), "log": lambda message: None, "pencere": pencere}
|
||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), "bridge.py", "exec"), env)
|
||||
|
||||
|
||||
def feed(event, **kw):
|
||||
adapter = env["SseRewriter"](set(), **kw)
|
||||
raw = b"data: " + json.dumps(event).encode() + b"\n\n"
|
||||
return [json.loads(line[6:]) for line in (adapter.feed(raw) + adapter.flush()).decode().splitlines()
|
||||
if line.startswith("data: ")]
|
||||
|
||||
|
||||
class Ek(unittest.TestCase):
|
||||
def test_model_without_tool_calls_is_reported_and_remembered(self):
|
||||
seen = []
|
||||
flat = {"type": "error", "code": "bad_request", "status_code": 400, "message":
|
||||
'[400]: {"error":{"message":"This model does not support tool calls.","code":"tool_choice_unsupported"}}'}
|
||||
out = feed(flat, hedef="m", aracsiz=seen.append)
|
||||
# A permanent error ends the turn with a visible answer instead of a silent retry.
|
||||
self.assertEqual([e["type"] for e in out], ["response.output_item.added", "response.output_text.delta",
|
||||
"response.output_item.done", "response.completed"])
|
||||
self.assertIn("araç çağrısını desteklemiyor", out[1]["delta"])
|
||||
self.assertEqual(out[2]["item"]["phase"], "final_answer")
|
||||
self.assertEqual(seen, ["m"])
|
||||
home = tempfile.mkdtemp()
|
||||
self.assertTrue(pencere.ogren_aracsiz(home, "m"))
|
||||
self.assertFalse(pencere.ogren_aracsiz(home, "m"))
|
||||
self.assertFalse(pencere.coz("m", "model", "cc/claude-opus-5-5", aracsiz=pencere.aracsizlar(home))["arac"])
|
||||
self.assertTrue(pencere.coz("n", "model", "cc/claude-opus-5-5", aracsiz=pencere.aracsizlar(home))["arac"])
|
||||
|
||||
def test_flat_overflow_error_is_translated_and_learned(self):
|
||||
seen = []
|
||||
flat = {"type": "error", "code": "bad_request", "message":
|
||||
"Your prompt exceeds the model's context length. Max context tokens: 65536."}
|
||||
out = feed(flat, hedef="m", tasma=lambda hedef, metin: seen.append(pencere.sinir_oku(metin)))
|
||||
self.assertEqual(out[0]["response"]["error"]["code"], "context_length_exceeded")
|
||||
self.assertEqual(seen, [65536])
|
||||
self.assertEqual(feed({"type": "error", "code": "server_error", "message": "upstream 503"})[0]["type"], "error")
|
||||
|
||||
def test_guard_never_trims_instruction_messages(self):
|
||||
big = "KURAL " * 40000
|
||||
req = {"input": [
|
||||
{"type": "message", "role": "developer", "content": [{"type": "input_text", "text": big}]},
|
||||
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "eski " * 40000}]},
|
||||
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "soru"}]}]}
|
||||
pencere.sigdir(req, 65536)
|
||||
self.assertEqual(req["input"][0]["content"][0]["text"], big)
|
||||
self.assertIn("çıkarıldı (köprü)", req["input"][1]["content"][0]["text"])
|
||||
|
||||
|
||||
class Sema(unittest.TestCase):
|
||||
def test_top_level_union_becomes_one_object_schema(self):
|
||||
req = {"tools": [
|
||||
{"type": "function", "name": "a", "parameters": {"oneOf": [
|
||||
{"type": "object", "properties": {"x": {"type": "string"}, "k": {"type": "string"}}, "required": ["x", "k"]},
|
||||
{"type": "object", "properties": {"y": {"type": "integer"}, "k": {"type": "string"}}, "required": ["y", "k"]}]}},
|
||||
{"type": "function", "name": "b", "parameters": {"type": "object", "properties": {"z": {"anyOf": [{"type": "string"}, {"type": "null"}]}}}},
|
||||
{"type": "function", "name": "c", "parameters": {"allOf": [
|
||||
{"type": "object", "properties": {"p": {"type": "string"}}, "required": ["p"]},
|
||||
{"type": "object", "properties": {"q": {"type": "string"}}, "required": ["q"]}]}}]}
|
||||
self.assertEqual(env["arac_semalarini_duzelt"](req), 2)
|
||||
a, b, c = (t["parameters"] for t in req["tools"])
|
||||
self.assertEqual((a["type"], sorted(a["properties"]), a["required"]), ("object", ["k", "x", "y"], ["k"]))
|
||||
self.assertNotIn("oneOf", a)
|
||||
self.assertIn("anyOf", b["properties"]["z"]) # nested unions are left alone
|
||||
self.assertEqual((sorted(c["properties"]), c["required"]), (["p", "q"], ["p", "q"]))
|
||||
self.assertEqual(env["arac_semalarini_duzelt"](req), 0)
|
||||
|
||||
|
||||
class Nokta(unittest.TestCase):
|
||||
def user(self, content):
|
||||
return {"type": "message", "role": "user", "content": content}
|
||||
|
||||
def test_only_a_lone_dot_from_the_user_is_answered_locally(self):
|
||||
is_dot = env["nokta_mesaji_mi"]
|
||||
self.assertTrue(is_dot({"input": [self.user([{"type": "input_text", "text": " . "}])]}))
|
||||
self.assertTrue(is_dot({"input": [self.user("x"), self.user(".")]}))
|
||||
for other in ("..", "?", "Selam", "", "1."):
|
||||
self.assertFalse(is_dot({"input": [self.user([{"type": "input_text", "text": other}])]}), other)
|
||||
self.assertFalse(is_dot({"input": [self.user("."), {"type": "function_call_output", "call_id": "c", "output": "x"}]}))
|
||||
self.assertFalse(is_dot({"input": [self.user([{"type": "input_text", "text": "."}, {"type": "input_image", "image_url": "x"}])]}))
|
||||
self.assertFalse(is_dot({"input": [{"type": "message", "role": "assistant", "content": "."}]}))
|
||||
self.assertFalse(is_dot({"input": []}))
|
||||
|
||||
def test_local_answer_is_a_complete_response_with_a_final_answer(self):
|
||||
raw = env["hazir_yaniti"]("ide/x", "cc/opus-5-5 hazır.").decode()
|
||||
events = [json.loads(line[6:]) for line in raw.splitlines() if line.startswith("data: ")]
|
||||
self.assertEqual([e["type"] for e in events], ["response.created", "response.output_item.added",
|
||||
"response.output_text.delta", "response.output_item.done", "response.completed"])
|
||||
done = events[-1]["response"]
|
||||
self.assertEqual(done["status"], "completed")
|
||||
self.assertEqual(done["output"][0]["phase"], "final_answer")
|
||||
self.assertEqual(done["output"][0]["content"][0]["text"], "cc/opus-5-5 hazır.")
|
||||
self.assertEqual(done["usage"]["total_tokens"], 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,141 @@
|
||||
"""The shipped IDE must use the same public OmniRoute catalog as every member."""
|
||||
import io
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import ide_catalog as c
|
||||
|
||||
|
||||
def row(mid, name, source="omniroute", capabilities=("chat",)):
|
||||
return {"id": mid, "turkservis": {"display_name": name, "source": source,
|
||||
"capabilities": list(capabilities)}}
|
||||
|
||||
|
||||
class MemberCatalog(unittest.TestCase):
|
||||
def test_metadata_classifies_only_public_models(self):
|
||||
public = [row("plain", "Plain"), row("team", "Team")]
|
||||
metadata = [{"id": "plain", "kind": "model"}, {"id": "team", "kind": "combo"},
|
||||
{"id": "internal", "kind": "combo"}]
|
||||
replies = [io.BytesIO(json.dumps({"data": public}).encode()),
|
||||
io.BytesIO(json.dumps({"models": metadata}).encode())]
|
||||
with patch.object(c, "ayar_oku", return_value={"enabled": True, "kapsam": "ide"}), \
|
||||
patch.object(c, "anahtar_oku", return_value="test-key"), \
|
||||
patch.object(c, "ide_yapilandirma", return_value=("https://example.test/v1", "", [])), \
|
||||
patch.object(c.urllib.request, "urlopen", side_effect=replies):
|
||||
status, _, _, selected = c.yukle()
|
||||
self.assertEqual(status, "tamam")
|
||||
self.assertEqual({m["id"]: m["kind"] for m in selected}, {"plain": "model", "team": "combo"})
|
||||
|
||||
def test_empty_catalog_is_a_refresh_failure(self):
|
||||
with patch.object(c, "ayar_oku", return_value={"enabled": True, "kapsam": "ide"}), \
|
||||
patch.object(c, "anahtar_oku", return_value="test-key"), \
|
||||
patch.object(c, "ide_yapilandirma", return_value=("https://example.test/v1", "", [])), \
|
||||
patch.object(c.urllib.request, "urlopen", return_value=io.BytesIO(b'{"data": []}')):
|
||||
with self.assertRaises(ValueError):
|
||||
c.yukle()
|
||||
|
||||
def test_public_omniroute_models_do_not_need_private_local_configuration(self):
|
||||
rows = [row("9router-nano-x", "9R · nano/x"), row("agnes-flat", "Agnes Flat"),
|
||||
row("Ajan-Arac", "Ajan-Arac"), row("image", "image", capabilities=("image",))]
|
||||
self.assertEqual({m["id"] for m in c.sec(rows, [])},
|
||||
{"9router-nano-x", "agnes-flat", "Ajan-Arac"})
|
||||
|
||||
def test_direct_route_is_never_added_beside_omniroute(self):
|
||||
rows = [row("9router-nano-x-direct", "9R · nano/x", "9router"),
|
||||
row("9router-nano-x", "9R · nano/x")]
|
||||
self.assertEqual([m["id"] for m in c.sec(rows, ["nano/x"], "hepsi")], ["9router-nano-x"])
|
||||
|
||||
def test_model_order_uses_combo_metadata_and_preserves_every_model(self):
|
||||
models = [{"slug": "gpt-z", "display_name": "GPT Z", "priority": 1},
|
||||
{"slug": "ide/bekci-kurnaz", "display_name": "kurnaz", "priority": 2},
|
||||
{"slug": "ide/Z-Team", "display_name": "Z-Team", "priority": 5},
|
||||
{"slug": "ide/9router-cc-opus", "display_name": "9R · cc/opus", "priority": 3},
|
||||
{"slug": "ide/A-Team", "display_name": "A-Team", "priority": 4}]
|
||||
extras = [{"slug": m["slug"], "kind": "combo" if m["slug"].endswith("Team") else "model"}
|
||||
for m in models if m["slug"].startswith("ide/")]
|
||||
c.sirala_codex(models, extras)
|
||||
self.assertEqual([m["slug"] for m in models],
|
||||
["gpt-z", "ide/A-Team", "ide/Z-Team", "ide/bekci-kurnaz", "ide/9router-cc-opus"])
|
||||
self.assertEqual([m["priority"] for m in models], [1, 2, 3, 4, 5])
|
||||
|
||||
def test_subscription_models_keep_their_own_order_priority_and_name(self):
|
||||
models = [{"slug": "ide/GPT-6-Astra", "display_name": "GPT-6-Astra", "priority": 3},
|
||||
{"slug": "gpt-6-astra", "display_name": "GPT-6-Astra", "priority": 2},
|
||||
{"slug": "ide/A-Team", "display_name": "A-Team", "priority": 0},
|
||||
{"slug": "gpt-6.1-sol", "display_name": "GPT-6.1-Sol", "priority": 1},
|
||||
{"slug": "codex-auto-review", "display_name": "Codex Auto Review", "priority": 43}]
|
||||
models.append({"slug": "ide/GPT-6-1-Sol", "display_name": "GPT-6-1-Sol", "priority": 2})
|
||||
extras = [{"slug": "ide/GPT-6-Astra", "kind": "combo"}, {"slug": "ide/A-Team", "kind": "combo"},
|
||||
{"slug": "ide/GPT-6-1-Sol", "kind": "combo"}]
|
||||
originals = [dict(m) for m in models if not m["slug"].startswith("ide/")]
|
||||
c.sirala_codex(models, extras)
|
||||
self.assertEqual([m for m in models if not m["slug"].startswith("ide/")], originals)
|
||||
self.assertEqual([m["slug"] for m in models],
|
||||
["gpt-6-astra", "gpt-6.1-sol", "codex-auto-review", "ide/A-Team", "ide/GPT-6-1-Sol",
|
||||
"ide/GPT-6-Astra"])
|
||||
self.assertEqual([m["priority"] for m in models[3:]], [44, 45, 46])
|
||||
self.assertEqual([m["display_name"] for m in models[4:]], ["GPT-6-1-Sol (AITURK)", "GPT-6-Astra (AITURK)"])
|
||||
|
||||
def test_description_line_tells_what_the_entry_is_and_fits_a_phone_row(self):
|
||||
yedek = {"kind": "combo", "model": "Yedek", "strategy": "priority", "members": [
|
||||
{"kind": "model", "model": "nano/z-ai/glm-5.3-flash-uncensored"},
|
||||
{"kind": "model", "model": "nano/abliteration-ai/abliterated-model-large-v2"}]}
|
||||
takim = {"topology": {"kind": "combo", "strategy": "priority", "members": [
|
||||
{"kind": "combo", "model": "Takim-Hizli", "strategy": "fusion", "members": [
|
||||
{"kind": "model", "model": "ag/gemini-3.8-flash-high"}, {"kind": "model", "model": "cc/claude-sonnet-5-5"}]},
|
||||
yedek]}}
|
||||
self.assertEqual(c.aciklama("Takim-Hizli", "Takim-Hizli", "combo", takim), "Takım: gemini+sonnet")
|
||||
sirali = {"topology": {"kind": "combo", "strategy": "priority", "members": [
|
||||
{"kind": "model", "model": "cx/gpt-6-astra"}, {"kind": "model", "model": "cc/claude-opus-5-5"},
|
||||
{"kind": "model", "model": "cc/claude-sonnet-5-5"}, {"kind": "model", "model": "ag/gemini-3.8-flash-high"}]}}
|
||||
self.assertEqual(c.aciklama("Ajan-Arac", "Ajan-Arac", "combo", sirali), "Sıralı: astra>opus>sonnet +1")
|
||||
# An unresolved first member stays visible instead of being hidden behind the fallback.
|
||||
eksik = {"topology": {"kind": "combo", "strategy": "priority", "members": [{"kind": "combo", "model": None}, yedek]}}
|
||||
self.assertEqual(c.aciklama("GPT-6-Sol", "GPT-6-Sol", "combo", eksik), "Sıralı: ?>glm>abliterated")
|
||||
donen = {"topology": {"kind": "combo", "strategy": "round-robin", "members": [yedek, yedek, yedek]}}
|
||||
self.assertEqual(c.aciklama("Takim-Sira", "Takim-Sira", "combo", donen), "Dönüşümlü: 3 üye")
|
||||
self.assertEqual(c.aciklama("X", "X", "combo", None), "Takım")
|
||||
self.assertEqual(c.aciklama("9router-cc-claude-opus-5-5", "9R · cc/claude-opus-5-5", "model"), "Tek model · Claude")
|
||||
self.assertEqual(c.aciklama("9router-nano-qwen-qwen3.8-27b-uncensored-thinking",
|
||||
"9R · nano/qwen3.8-27b-uncensored:thinking", "model"),
|
||||
"NanoGPT · sansürsüz · düşünen")
|
||||
self.assertEqual(c.aciklama("bekci-kurnaz", "kurnaz", "model"), "Tek model · Bekçi")
|
||||
# 9Router aliases: cc = Claude Code, cx = OpenAI Codex, ag = Antigravity.
|
||||
self.assertEqual(c.aciklama("9router-cx-gpt-6-astra", "9R · cx/gpt-6-astra", "model"), "Tek model · Codex")
|
||||
self.assertEqual(c.aciklama("9router-ag-gemini-3.8-flash-high", "9R · ag/gemini-3.8-flash-high", "model"), "Tek model · Antigravity")
|
||||
uzun = {"topology": {"kind": "combo", "strategy": "priority", "members": [
|
||||
{"kind": "model", "model": f"x/model{chr(97 + i) * 9}"} for i in range(8)]}}
|
||||
self.assertLessEqual(len(c.aciklama("U", "U", "combo", uzun)), c.ACIKLAMA_SINIRI)
|
||||
for ad in ("Takim-Hizli", "9R · cc/claude-opus-5-5"):
|
||||
self.assertNotIn(ad, c.aciklama(ad, ad, "combo" if "Takim" in ad else "model", takim))
|
||||
|
||||
def test_picker_names_are_combo_name_or_short_provider_slash_model(self):
|
||||
# User decision, 10 Oct 2026: no "9R" mark; combos by name; single models as provider/model.
|
||||
rows = [("Takim-Hizli", "Takim-Hizli", "combo"),
|
||||
("9router-cc-claude-opus-5-5", "9R · cc/claude-opus-5-5", "model"),
|
||||
("9router-cc-claude-opus-4-8", "9R · cc/claude-opus-4-8", "model"),
|
||||
("9router-cx-gpt-6-astra", "9R · cx/gpt-6-astra", "model"),
|
||||
("9router-ag-gemini-3.8-flash-high", "9R · ag/gemini-3.8-flash-high", "model"),
|
||||
("9router-nano-z-ai-glm-5.3-flash-uncensored", "9R · nano/glm-5.3-flash-uncensored", "model"),
|
||||
("agnes-agnes-2.0-flash", "Agnes 2.0 Flash", "model"),
|
||||
("agnes-agnes-2.0-flash-high", "agnes-2.0-flash-high", "model"),
|
||||
("bekci-bekci", "bekci", "model"), ("bekci-kurnaz", "kurnaz", "model"),
|
||||
("hf-deepseek-ai-DeepSeek-V3", "hf/DeepSeek V3", "model"),
|
||||
("no-think-9router-cc-claude-opus-5-5", "no-think/claude-opus-5-5", "model"),
|
||||
("no-think-9router-ag-claude-opus-4-6-thinking", "no-think/claude-opus-4-6-thinking", "model")]
|
||||
adlar = c.adlandir([(k, a) for k, a, _ in rows], turler={k: t for k, _, t in rows})
|
||||
self.assertEqual([adlar[k] for k, _, _ in rows],
|
||||
["Takim-Hizli", "cc/opus-5-5", "cc/opus-4-8", "cx/gpt-6-astra", "agy/gemini-3.8-flash-high",
|
||||
"nano/glm-5.3-flash-uncensored", "agnes/agnes-2.0-flash", "agnes/agnes-2.0-flash-high",
|
||||
"bekci/bekci", "bekci/kurnaz", "hf/DeepSeek-V3", "cc/opus-5-5 (no-think)",
|
||||
"agy/opus-4-6-thinking (no-think)"])
|
||||
self.assertFalse([a for a in adlar.values() if "9R" in a])
|
||||
# Added models sort after the combos by provider then model.
|
||||
models = [{"slug": "ide/" + k, "display_name": adlar[k], "priority": 0} for k, _, _ in rows]
|
||||
c.sirala_codex(models, [{"slug": "ide/" + k, "kind": t} for k, _, t in rows])
|
||||
gorunen = [m["display_name"] for m in models]
|
||||
self.assertEqual(gorunen[0], "Takim-Hizli")
|
||||
self.assertEqual(gorunen[1:], sorted(gorunen[1:], key=lambda a: tuple(p.casefold() for p in a.partition("/")[::2])))
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Bridge 8: "modeli X yap" is handled by the bridge; a per-chat route survives until the user picks another model."""
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import model_gecis as g
|
||||
|
||||
MODELS = [("gpt-6-sol", "GPT-6-Sol"), ("gpt-6-astra", "GPT-6-Astra"), ("gpt-6.1-sol", "GPT-6.1-Sol"),
|
||||
("ide/9router-ag-gemini-3.8-flash-low", "agy/gemini-3.8-flash-low"),
|
||||
("ide/9router-ag-gemini-3.8-flash-high", "agy/gemini-3.8-flash-high"),
|
||||
("ide/9router-cc-claude-opus-5-5", "cc/opus-5-5"), ("ide/9router-cc-claude-opus-5-5-nt", "cc/opus-5-5 (no-think)"),
|
||||
("ide/9router-nano-z-ai-glm-5.3-flash-uncensored", "nano/glm-5.3-flash-uncensored"),
|
||||
("ide/agnes-agnes-2.0-flash", "agnes/agnes-2.0-flash"), ("ide/agnes-agnes-2.5-flash", "agnes/agnes-2.5-flash")]
|
||||
|
||||
|
||||
def user(text):
|
||||
return {"type": "message", "role": "user", "content": [{"type": "input_text", "text": text}]}
|
||||
|
||||
|
||||
def voice(text):
|
||||
return user(f"<realtime_delegation>\n <input>{text}</input>\n <transcript_delta>user: x</transcript_delta>\n</realtime_delegation>")
|
||||
|
||||
|
||||
def pick(text):
|
||||
command = g.komut_coz(text)
|
||||
return g.eslestir(command["ad"], MODELS) if command and command["tur"] == "gec" else command
|
||||
|
||||
|
||||
class Komut(unittest.TestCase):
|
||||
def test_spoken_names_resolve_to_one_model(self):
|
||||
for text, slug in [("modeli gemini flash low yap", "ide/9router-ag-gemini-3.8-flash-low"),
|
||||
("Modeli Gemini 3.8 Flash High'a geçir", "ide/9router-ag-gemini-3.8-flash-high"),
|
||||
("modeli klod opus 5 5 yap", "ide/9router-cc-claude-opus-5-5"),
|
||||
("modeli opus 5 5'e geç", "ide/9router-cc-claude-opus-5-5"),
|
||||
("modeli gpt 6 astra yap", "gpt-6-astra"), ("model geç gpt 6.1 sol", "gpt-6.1-sol"),
|
||||
("modeli glm flaş sansürsüz yap", "ide/9router-nano-z-ai-glm-5.3-flash-uncensored")]:
|
||||
self.assertEqual((pick(text) or {}).get("slug"), slug, text)
|
||||
|
||||
def test_half_spoken_words_and_the_plain_variant(self):
|
||||
"""Measured 11 Oct 2026 in a voice session: "obli think" was not found three times, and "Qwen 3.8
|
||||
Obliterated" asked which one although the user had not said "thinking"."""
|
||||
qwen = MODELS + [("ide/9router-nano-qwen-qwen3.8-27b-obliterated", "nano/qwen3.8-27b-obliterated"),
|
||||
("ide/9router-nano-qwen-qwen3.8-27b-obliterated-thinking", "nano/qwen3.8-27b-obliterated:thinking"),
|
||||
("ide/9router-nano-qwen-qwen3.8-27b-uncensored", "nano/qwen3.8-27b-uncensored")]
|
||||
|
||||
def sec(text):
|
||||
return (g.eslestir(g.komut_coz(text)["ad"], qwen) or {}).get("slug")
|
||||
thinking = "ide/9router-nano-qwen-qwen3.8-27b-obliterated-thinking"
|
||||
self.assertEqual(sec("Modeli 3.8 Obliterated Think yap"), thinking)
|
||||
self.assertEqual(sec("Modeli Qwen... modeli Qwen 3.8 .27 B... Obli... Think yap"), thinking)
|
||||
self.assertEqual(sec("Modeli Qwen ... 3.8 Obliterated yap"), "ide/9router-nano-qwen-qwen3.8-27b-obliterated")
|
||||
self.assertEqual(sec("modeli klod opus yap"), "ide/9router-cc-claude-opus-5-5") # not the "(no-think)" one
|
||||
self.assertIn("adaylar", g.eslestir(g.komut_coz("modeli qwen 3.8 yap")["ad"], qwen)) # obliterated or uncensored: ask
|
||||
|
||||
def test_ambiguous_name_lists_candidates_and_changes_nothing(self):
|
||||
self.assertEqual(pick("modeli agnes yap"), {"adaylar": ["agnes/agnes-2.0-flash", "agnes/agnes-2.5-flash"]})
|
||||
self.assertIn("adaylar", pick("modeli flash yap")) # several unrelated Flash models: ask
|
||||
self.assertIn("adaylar", pick("modeli sol yap")) # GPT-6-Sol or GPT-6.1-Sol: ask
|
||||
self.assertIn("adaylar", pick("modeli gpt 999 astra yap")) # words fit, version does not: ask
|
||||
|
||||
def test_negations_and_general_questions_are_not_commands(self):
|
||||
for text in ("modeli gemini flash low yapma", "modeli değiştirme opus kalsın", "GPT 6 Astra modeli için test yap",
|
||||
"hangi model daha ucuz", "en iyi model hangisi", "modeli seçili dosyaya göre gemini flash low yap"):
|
||||
result = pick(text)
|
||||
self.assertFalse(result and ("slug" in result or result.get("tur") == "sor"), text)
|
||||
self.assertEqual(g.komut_coz("hangi modelsin"), {"tur": "sor"})
|
||||
|
||||
def test_ordinary_sentences_are_not_commands(self):
|
||||
for text in ("Masaüstünde kaç klasör var", "bu modeli kullanarak bir site yap", "yapay zeka modeli nedir",
|
||||
"hangi modeli önerirsin bu iş için bana söyle bakalım lütfen",
|
||||
"model " + "çok uzun bir cümle " * 5 + "yap"):
|
||||
self.assertFalse(pick(text) and "slug" in pick(text), text)
|
||||
self.assertEqual(g.komut_coz("seçili model hangisi"), {"tur": "sor"})
|
||||
self.assertEqual(g.komut_coz("bu chatte ana model kim"), {"tur": "sor"})
|
||||
|
||||
def test_voice_delegation_yields_only_the_spoken_input(self):
|
||||
text, index, spoken = g.son_kullanici_metni({"input": [user("a"), voice("modeli opus yap")]})
|
||||
self.assertEqual((text, index, spoken), ("modeli opus yap", 1, True))
|
||||
self.assertEqual(g.son_kullanici_metni({"input": [user("<skill>x</skill>")]})[0], None)
|
||||
self.assertEqual(g.son_kullanici_metni({"input": [{"type": "function_call_output", "output": "x"}]})[0], None)
|
||||
|
||||
|
||||
class TurOrtasi(unittest.TestCase):
|
||||
def test_message_after_tool_items_is_mid_turn_and_after_final_answer_is_not(self):
|
||||
final = {"type": "message", "role": "assistant", "phase": "final_answer", "content": "ok"}
|
||||
call = {"type": "function_call", "call_id": "c", "name": "shell", "arguments": "{}"}
|
||||
out = {"type": "function_call_output", "call_id": "c", "output": "x"}
|
||||
self.assertFalse(g.tur_ortasi({"input": [user("a"), final, user(".")]}, 2))
|
||||
self.assertTrue(g.tur_ortasi({"input": [user("a"), call, out, user(".")]}, 3))
|
||||
self.assertTrue(g.tur_ortasi({"input": [user("a"), call, out, voice("bekliyorum"), user(".")]}, 4))
|
||||
self.assertTrue(g.tur_ortasi({"input": [user("a"), {"type": "message", "role": "assistant", "phase": "commentary",
|
||||
"content": "bakıyorum"}, user(".")]}, 2))
|
||||
self.assertFalse(g.tur_ortasi({"input": [user(".")]}, 0))
|
||||
|
||||
def test_handover_note_replaces_only_the_spoken_input(self):
|
||||
req = {"input": [voice("modeli opus yap")]}
|
||||
g.metni_degistir(req, 0, g.devir_notu("cc/opus-5-5", "GPT-6-Sol"))
|
||||
text = req["input"][0]["content"][0]["text"]
|
||||
self.assertIn("<transcript_delta>", text)
|
||||
self.assertIn("cc/opus-5-5 olarak devam ediyorum", text)
|
||||
self.assertNotIn("modeli opus yap</input>", text)
|
||||
|
||||
|
||||
class Gecmis(unittest.TestCase):
|
||||
def test_handled_commands_in_history_become_notes_but_the_last_message_is_left_alone(self):
|
||||
req = {"input": [user("modeli gemini flash low yap"), user("Masaüstünde kaç dosya var"),
|
||||
voice("modeli opus 5 5 yap"), user("modeli gpt 6 astra yap")]}
|
||||
self.assertEqual(g.gecmisi_temizle(req, MODELS), 2)
|
||||
self.assertIn("agy/gemini-3.8-flash-low olarak değiştirdi", req["input"][0]["content"][0]["text"])
|
||||
self.assertEqual(req["input"][1]["content"][0]["text"], "Masaüstünde kaç dosya var")
|
||||
self.assertIn("<transcript_delta>", req["input"][2]["content"][0]["text"])
|
||||
self.assertEqual(req["input"][3]["content"][0]["text"], "modeli gpt 6 astra yap")
|
||||
self.assertEqual(g.gecmisi_temizle(req, MODELS), 0)
|
||||
|
||||
|
||||
class Bilgi(unittest.TestCase):
|
||||
def test_model_is_told_once_that_the_chat_model_changes_by_saying_so(self):
|
||||
req = {"input": [user("selam")]}
|
||||
self.assertTrue(g.bilgi_ekle(req))
|
||||
self.assertEqual(req["input"][0]["role"], "developer")
|
||||
self.assertIn("modeli <ad> yap", req["input"][0]["content"][0]["text"])
|
||||
self.assertFalse(g.bilgi_ekle(req))
|
||||
self.assertEqual(len(req["input"]), 2)
|
||||
self.assertFalse(g.bilgi_ekle({"input": []}))
|
||||
|
||||
def test_a_command_without_a_name_asks_which_model(self):
|
||||
for text in ("modeli değiştir", "başka modele geç", "modeli değiştirir misin"):
|
||||
self.assertEqual(g.komut_coz(text), {"tur": "nasil"}, text)
|
||||
self.assertIsNone(g.komut_coz("modeli değiştirme"))
|
||||
|
||||
|
||||
class Dogal(unittest.TestCase):
|
||||
GECIT = [("ide/9router/ModelARK/dola-seed-2-1-turbo-260628", "9router/ModelARK/dola-seed-2-1-turbo-260628"),
|
||||
("ide/9router/ModelARK/seed-2-0-pro-260328", "9router/ModelARK/seed-2-0-pro-260328")]
|
||||
|
||||
def test_command_without_the_word_model_and_a_spelled_out_name(self):
|
||||
for text in ("Seed iki nokta bir turbo yap", "Evet. Seed, S E E D iki nokta bir turbo yap"):
|
||||
command = g.komut_coz(text)
|
||||
self.assertEqual((command["ad"], command["kesin"]), (["seed", "2", "1", "turbo"], False), text)
|
||||
self.assertEqual(g.eslestir(command["ad"], self.GECIT)["slug"], "ide/9router/ModelARK/dola-seed-2-1-turbo-260628")
|
||||
self.assertTrue(g.komut_coz("Modeli Seed iki nokta bir turbo yap")["kesin"])
|
||||
self.assertEqual(g.eslestir(g.komut_coz("gemini flash low'a geç")["ad"], MODELS)["slug"],
|
||||
"ide/9router-ag-gemini-3.8-flash-low")
|
||||
|
||||
def test_ordinary_requests_ending_in_a_verb_are_not_commands(self):
|
||||
for text in ("test yap", "kahve yap", "yapma"):
|
||||
self.assertIsNone(g.komut_coz(text), text)
|
||||
for text in ("şu dosyayı düzelt ve commit yap", "rapor hazırla gemini flash low ile karşılaştırma yap"):
|
||||
command = g.komut_coz(text)
|
||||
self.assertFalse(command["kesin"])
|
||||
self.assertFalse((g.eslestir(command["ad"], MODELS) or {}).get("slug"), text)
|
||||
|
||||
|
||||
class Yon(unittest.TestCase):
|
||||
def test_route_lasts_until_the_user_picks_another_model_in_the_list(self):
|
||||
with tempfile.TemporaryDirectory() as home:
|
||||
y = g.Yonlendirme(home)
|
||||
y.koy("chat", "gpt-6-sol", "ide/x", simdi=1000)
|
||||
self.assertEqual(g.Yonlendirme(home).al("chat", "gpt-6-sol", simdi=1001), "ide/x") # survives a restart
|
||||
self.assertIsNone(y.al("other", "gpt-6-sol", simdi=1001))
|
||||
self.assertIsNone(y.al("chat", "gpt-6-astra", simdi=1002)) # user picked another
|
||||
self.assertIsNone(y.al("chat", "gpt-6-sol", simdi=1003)) # and it stays cleared
|
||||
y.koy("chat", "gpt-6-sol", "ide/x", simdi=1000)
|
||||
y.koy("chat", "gpt-6-sol", "gpt-6-sol", simdi=1001) # back to the picked one
|
||||
self.assertIsNone(y.al("chat", "gpt-6-sol", simdi=1002))
|
||||
y.koy("old", "a", "b", simdi=1000)
|
||||
self.assertIsNone(y.al("old", "a", simdi=1000 + g.OMUR_SN + 1))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Provider-switch history regression tests, without startup or network access."""
|
||||
import ast
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
tree = ast.parse(Path(__file__).with_name("kopru.py").read_text(encoding="utf-8"))
|
||||
names = {"flatten_request", "fix_tool_ids", "drop_foreign_items", "SseRewriter", "phase_damgala",
|
||||
"asistan_mesaji_mi"}
|
||||
nodes = [n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.ClassDef)) and n.name in names]
|
||||
env = {"json": json, "re": __import__("re"), "log": lambda message: None, "pencere": __import__("pencere")}
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id in
|
||||
{"ID_PREFIX", "FOREIGN_ID_MARK", "REFERENCE_TYPES", "INLINE_TYPES", "ARAC_OGELERI"} for t in node.targets):
|
||||
nodes.insert(0, node)
|
||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), "bridge.py", "exec"), env)
|
||||
|
||||
|
||||
class ModelSwitch(unittest.TestCase):
|
||||
def test_external_to_chatgpt_preserves_messages_and_paired_tool_calls(self):
|
||||
req = {"input": [
|
||||
{"type": "reasoning", "id": "rs_native", "encrypted_content": "native-reasoning"},
|
||||
{"type": "reasoning", "id": "rs_chatcmpl-foreign"},
|
||||
{"type": "item_reference", "id": "chatcmpl-foreign"},
|
||||
{"type": "message", "id": "msg_chatcmpl-foreign", "role": "assistant", "content": "remember-4827"},
|
||||
{"type": "custom_tool_call", "id": "fc_chatcmpl-call", "call_id": "call_4827", "name": "probe", "input": "hello"},
|
||||
{"type": "custom_tool_call_output", "id": "fc_chatcmpl-output", "call_id": "call_4827", "output": "result-4827"},
|
||||
]}
|
||||
self.assertEqual(env["fix_tool_ids"](req), 2)
|
||||
self.assertEqual(env["drop_foreign_items"](req), 2)
|
||||
self.assertEqual(req["input"][0]["encrypted_content"], "native-reasoning")
|
||||
self.assertEqual(req["input"][1]["content"], "remember-4827")
|
||||
self.assertEqual(req["input"][2]["id"], "ctc_chatcmpl-call")
|
||||
self.assertEqual(req["input"][3]["id"], "ctco_chatcmpl-output")
|
||||
self.assertEqual(req["input"][2]["call_id"], req["input"][3]["call_id"])
|
||||
self.assertEqual(req["input"][3]["output"], "result-4827")
|
||||
|
||||
def test_chatgpt_to_external_preserves_custom_tool_history(self):
|
||||
original = {"access_programs": {"cyber": "standard"}, "input": [
|
||||
{"type": "custom_tool_call", "id": "ctc_4827", "call_id": "call_4827", "name": "probe", "input": "remember-4827"},
|
||||
{"type": "custom_tool_call_output", "id": "ctco_4827", "call_id": "call_4827", "output": "result-4827"},
|
||||
], "tools": [{"type": "namespace", "name": "functions", "tools": [{"type": "custom", "name": "probe"}]}]}
|
||||
req = copy.deepcopy(original)
|
||||
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")
|
||||
self.assertEqual(req["input"][0]["call_id"], req["input"][1]["call_id"])
|
||||
self.assertEqual(req["input"][0]["type"], "function_call")
|
||||
self.assertEqual(req["input"][1]["type"], "function_call_output")
|
||||
self.assertIn("access_programs", original)
|
||||
|
||||
def test_streamed_external_custom_tool_can_return_to_codex(self):
|
||||
event = {"type": "response.output_item.done", "item": {
|
||||
"type": "function_call", "id": "fc_chatcmpl-probe", "call_id": "call_4827",
|
||||
"name": "probe", "arguments": json.dumps({"input": "remember-4827"})}}
|
||||
raw = ("event: response.output_item.done\ndata: " + json.dumps(event) + "\n\n").encode()
|
||||
adapter = env["SseRewriter"]({"probe"})
|
||||
out = adapter.feed(raw[:25]) + adapter.feed(raw[25:]) + adapter.flush()
|
||||
rewritten = json.loads(next(line[6:] for line in out.decode().splitlines() if line.startswith("data: ")))
|
||||
self.assertEqual(rewritten["item"]["type"], "custom_tool_call")
|
||||
self.assertEqual(rewritten["item"]["input"], "remember-4827")
|
||||
self.assertEqual(rewritten["item"]["call_id"], "call_4827")
|
||||
self.assertEqual(rewritten["item"]["id"], "ctc_chatcmpl-probe")
|
||||
|
||||
def test_custom_stream_ids_are_valid_before_history_is_saved(self):
|
||||
# Real failure after disabling the bridge and returning to OpenAI:
|
||||
# input[32].id fc_call_... was saved as a custom_tool_call by Codex.
|
||||
item = {"type": "function_call", "id": "fc_call_3f427ecc539d4c779c1252077701216f",
|
||||
"call_id": "call_original", "name": "probe",
|
||||
"arguments": json.dumps({"input": "keep this exact tool input"})}
|
||||
adapter = env["SseRewriter"]({"probe"})
|
||||
converted = []
|
||||
for kind in ("response.output_item.added", "response.output_item.done"):
|
||||
converted.append(adapter._fix({"type": kind, "item": dict(item)})["item"])
|
||||
final = adapter._fix({"type": "response.completed", "response": {"output": [dict(item)]}})
|
||||
converted.append(final["response"]["output"][0])
|
||||
for result in converted:
|
||||
self.assertEqual(result["type"], "custom_tool_call")
|
||||
self.assertEqual(result["id"], "ctc_call_3f427ecc539d4c779c1252077701216f")
|
||||
self.assertEqual(result["call_id"], "call_original")
|
||||
self.assertEqual(result["input"], "keep this exact tool input")
|
||||
history = {"input": converted}
|
||||
self.assertEqual(env["fix_tool_ids"](history), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,357 @@
|
||||
"""Real pipes/processes and real loopback HTTP; no native runtime activation."""
|
||||
import concurrent.futures
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import native_speech_mux as muxmod
|
||||
|
||||
|
||||
# Fake engine is a separate process speaking complete JSONL, not mocked Popen.
|
||||
ENGINE = r'''
|
||||
import json, os, sys, time
|
||||
if '--probe' in sys.argv:
|
||||
print(json.dumps({'argv':sys.argv[1:], 'cwd':os.getcwd(), 'marker':os.getenv('MUX_TEST_MARKER')}))
|
||||
sys.stderr.write('original-stderr\n')
|
||||
sys.exit(23)
|
||||
for raw in sys.stdin.buffer:
|
||||
try: m=json.loads(raw)
|
||||
except ValueError:
|
||||
sys.stdout.buffer.write(raw); sys.stdout.buffer.flush(); continue
|
||||
method=m.get('method')
|
||||
def emit(v):
|
||||
sys.stdout.buffer.write(json.dumps(v).encode()+b'\n'); sys.stdout.buffer.flush()
|
||||
if method=='thread/start':
|
||||
emit({'id':m['id'],'result':{'thread':{'id':'thread-A'}}})
|
||||
elif method=='thread/realtime/start':
|
||||
emit({'method':'thread/realtime/started','params':{'threadId':'thread-A','realtimeSessionId':'rt'}})
|
||||
elif method=='thread/realtime/stop':
|
||||
emit({'method':'thread/realtime/closed','params':{'threadId':'thread-A','reason':None}})
|
||||
elif method=='error-event':
|
||||
emit({'method':'thread/realtime/error','params':{'threadId':'thread-A','message':'failed'}})
|
||||
elif method=='thread/realtime/appendSpeech':
|
||||
emit({'method':'item/commandExecution/requestApproval','id':m['id'],'params':{'command':'test'}})
|
||||
emit({'method':'seen-speech','params':m['params']})
|
||||
emit({'id':m['id'], 'error':{'code':-1,'message':'no'}} if m['params']['text']=='rpc-error' else {'id':m['id'],'result':{}})
|
||||
elif method=='delay':
|
||||
time.sleep(.25)
|
||||
sys.stdout.buffer.write(raw); sys.stdout.buffer.flush()
|
||||
elif method=='pause':
|
||||
emit({'method':'paused'})
|
||||
while not os.path.exists(m['params']['release']): time.sleep(.01)
|
||||
emit({'method':'released'})
|
||||
elif method=='exit':
|
||||
sys.exit(17)
|
||||
else:
|
||||
sys.stdout.buffer.write(raw); sys.stdout.buffer.flush()
|
||||
sys.stdout.buffer.write(b'{"method":"saw-eof"}\n'); sys.stdout.buffer.flush()
|
||||
sys.stderr.write('engine-eof\n')
|
||||
sys.exit(19)
|
||||
'''
|
||||
|
||||
|
||||
class Harness:
|
||||
def __init__(self, tmp_path):
|
||||
engine = tmp_path / 'engine.py'
|
||||
engine.write_text(ENGINE)
|
||||
self.mux = muxmod.NativeSpeechMux([sys.executable, '-u', str(engine), 'app-server'], observer=True)
|
||||
ir, iw = os.pipe()
|
||||
outr, outw = os.pipe()
|
||||
self.source = os.fdopen(ir, 'rb', buffering=0)
|
||||
self.input = os.fdopen(iw, 'wb', buffering=0)
|
||||
self.target = os.fdopen(outw, 'wb', buffering=0)
|
||||
self.output = os.fdopen(outr, 'rb')
|
||||
self.lines = queue.Queue()
|
||||
self.code = None
|
||||
self.error = None
|
||||
self.url, self.token = self.mux.start_inlet()
|
||||
def run():
|
||||
try:
|
||||
self.code = self.mux.run(self.source, self.target)
|
||||
except BaseException as e:
|
||||
self.error = e
|
||||
finally:
|
||||
self.target.close()
|
||||
self.thread = threading.Thread(target=run, daemon=True)
|
||||
self.thread.start()
|
||||
def collect():
|
||||
for raw in self.output:
|
||||
self.lines.put(raw)
|
||||
self.collector = threading.Thread(target=collect, daemon=True)
|
||||
self.collector.start()
|
||||
|
||||
def send(self, value):
|
||||
raw = value if isinstance(value, bytes) else json.dumps(value).encode()+b'\n'
|
||||
self.input.write(raw)
|
||||
|
||||
def recv(self):
|
||||
return self.lines.get(timeout=8)
|
||||
|
||||
def http(self, path, body, token=None):
|
||||
conn = http.client.HTTPConnection('127.0.0.1', int(self.url.rsplit(':', 1)[1]), timeout=5)
|
||||
conn.request('POST', path, json.dumps(body), {'Authorization':'Bearer '+(token if token is not None else self.token)})
|
||||
response = conn.getresponse()
|
||||
data = response.read()
|
||||
conn.close()
|
||||
return response.status, json.loads(data) if response.status == 200 else data
|
||||
|
||||
def start(self):
|
||||
self.send({'id':1, 'method':'thread/start','params':{}})
|
||||
assert json.loads(self.recv())['result']['thread']['id'] == 'thread-A'
|
||||
assert self.mux.snapshot()['threads']['thread-A']['active'] is False
|
||||
self.send({'id':2,'method':'thread/realtime/start','params':{'threadId':'thread-A'}})
|
||||
assert json.loads(self.recv())['method'] == 'thread/realtime/started'
|
||||
return self.bind()
|
||||
|
||||
def bind(self):
|
||||
generation = self.mux.snapshot()['threads']['thread-A']['generation']
|
||||
b = muxmod.Binding('tenant','bekci','rev',self.mux.connection_id,'thread-A',generation)
|
||||
assert self.mux.bind(b)
|
||||
return self.notice(generation)
|
||||
|
||||
def notice(self, generation=1, **changes):
|
||||
now = time.time()
|
||||
return dict(notice_id='notice-1',tenant_id='tenant',bekci_session_id='bekci',history_revision='rev',
|
||||
connection_id=self.mux.connection_id,thread_id='thread-A',expected_generation=generation,
|
||||
issued_at=now,expires_at=now+30,text='düzeltme', **changes)
|
||||
|
||||
def close(self):
|
||||
self.input.close()
|
||||
self.thread.join(10)
|
||||
if self.thread.is_alive():
|
||||
self.mux._process.kill()
|
||||
self.thread.join(5)
|
||||
raise AssertionError('mux failed to exit after EOF')
|
||||
self.collector.join(3)
|
||||
self.source.close()
|
||||
self.output.close()
|
||||
assert self.error is None
|
||||
|
||||
|
||||
def test_native_notice_rpc_and_approval_are_separate(tmp_path):
|
||||
h = Harness(tmp_path)
|
||||
try:
|
||||
notice = h.start()
|
||||
assert h.http('/notice', notice, 'wrong')[0] == 401
|
||||
assert h.http('/notice', notice)[1]['status'] == 'queued'
|
||||
approval = json.loads(h.recv())
|
||||
assert approval['method'] == 'item/commandExecution/requestApproval'
|
||||
assert approval['id'].startswith('bekci-speech-')
|
||||
assert json.loads(h.recv()) == {'method':'seen-speech','params':{'threadId':'thread-A','text':'düzeltme'}}
|
||||
answer = (json.dumps({'id':approval['id'],'result':{'decision':'decline'}}, separators=(', ', ': '))+'\r\n').encode()
|
||||
h.send(answer)
|
||||
assert h.recv() == answer
|
||||
assert h.http('/notice', notice)[1]['status'] == 'rpc_accepted'
|
||||
assert h.lines.empty() # native own-result never leaked to desktop
|
||||
assert h.http('/rpc', {'method':'turn/start'})[0] == 404
|
||||
assert h.http('/notice', {**notice,'notice_id':'err','text':'rpc-error'})[1]['status'] == 'queued'
|
||||
h.recv(); h.recv()
|
||||
h.send(b'{"method":"barrier"}\n')
|
||||
assert h.recv() == b'{"method":"barrier"}\n'
|
||||
assert h.http('/notice', {**notice,'notice_id':'err','text':'rpc-error'})[1]['status'] == 'rpc_error'
|
||||
finally:
|
||||
h.close()
|
||||
assert h.code == 19
|
||||
assert json.loads(h.recv())['method'] == 'saw-eof'
|
||||
|
||||
|
||||
def test_unbound_schema_ttl_tenant_generation_and_restart(tmp_path):
|
||||
h = Harness(tmp_path)
|
||||
try:
|
||||
n = h.notice()
|
||||
assert h.http('/notice', n)[1]['status'] == 'binding_not_established'
|
||||
n = h.start()
|
||||
for delta, status in [({'method':'turn/start'},'invalid_schema'),
|
||||
({'expected_generation':True},'invalid_schema'),
|
||||
({'expires_at':time.time()-1},'expired_or_invalid_ttl'),
|
||||
({'issued_at':float('nan')},'expired_or_invalid_ttl'),
|
||||
({'tenant_id':'other'},'binding_not_established'),
|
||||
({'history_revision':'other'},'binding_mismatch'),
|
||||
({'expected_generation':2},'binding_mismatch'),
|
||||
({'connection_id':'other'},'binding_mismatch')]:
|
||||
assert h.http('/notice', {**n, **delta})[1]['status'] == status
|
||||
h.send({'method':'thread/realtime/stop','params':{'threadId':'thread-A'}})
|
||||
assert json.loads(h.recv())['method'] == 'thread/realtime/closed'
|
||||
assert h.http('/notice', n)[1]['status'] == 'binding_not_established'
|
||||
h.send({'method':'thread/realtime/start','params':{'threadId':'thread-A'}})
|
||||
h.recv()
|
||||
fresh = h.bind()
|
||||
assert fresh['expected_generation'] == n['expected_generation']+1
|
||||
assert h.http('/notice', n)[1]['status'] == 'binding_mismatch'
|
||||
h.send({'method':'error-event'})
|
||||
h.recv()
|
||||
assert h.http('/notice', fresh)[1]['status'] == 'binding_not_established'
|
||||
finally:
|
||||
h.close()
|
||||
|
||||
|
||||
def test_concurrent_notices_and_raw_backpressure_order(tmp_path):
|
||||
h = Harness(tmp_path)
|
||||
try:
|
||||
n = h.start()
|
||||
h.send({'method':'delay'})
|
||||
records = [(json.dumps({'method':'echo','i':i,'padding':'x'*16000})+'\r\n').encode() for i in range(90)]
|
||||
producer = threading.Thread(target=lambda: [h.send(r) for r in records], daemon=True)
|
||||
producer.start()
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
|
||||
results = list(pool.map(lambda i: h.http('/notice',{**n,'notice_id':str(i)})[1]['status'], range(12)))
|
||||
assert set(results) <= {'queued','busy'}
|
||||
accepted = results.count('queued')
|
||||
echoes = []
|
||||
speech = 0
|
||||
approvals = 0
|
||||
for _ in range(1 + len(records) + accepted*2):
|
||||
raw = h.recv()
|
||||
method = json.loads(raw).get('method')
|
||||
if method == 'echo': echoes.append(raw)
|
||||
elif method == 'seen-speech': speech += 1
|
||||
elif method == 'item/commandExecution/requestApproval': approvals += 1
|
||||
producer.join(5)
|
||||
assert not producer.is_alive()
|
||||
assert echoes == records
|
||||
assert speech == approvals == accepted
|
||||
assert h.mux._queue.maxsize == 32
|
||||
finally:
|
||||
h.close()
|
||||
|
||||
|
||||
def test_fragmented_oversized_jsonl_and_partial_eof_preserved(tmp_path):
|
||||
h = Harness(tmp_path)
|
||||
try:
|
||||
h.start()
|
||||
raw = b'{ "method":"huge", "value":"' + b'x'*200000 + b'"}\r\n'
|
||||
producer = threading.Thread(target=lambda: h.send(raw),daemon=True)
|
||||
producer.start()
|
||||
assert h.recv() == raw
|
||||
producer.join(5)
|
||||
assert h.mux.snapshot()['injection_disabled']
|
||||
h.send(b'{"partial":true}')
|
||||
finally:
|
||||
h.close()
|
||||
# Engine echoes an unterminated record, then its EOF notification: byte exact.
|
||||
assert h.recv() == b'{"partial":true}{"method":"saw-eof"}\n'
|
||||
assert h.code == 19
|
||||
|
||||
|
||||
def test_non_appserver_passthrough_argv_env_cwd_stderr_exit(tmp_path, monkeypatch):
|
||||
engine = tmp_path / 'engine.py'
|
||||
engine.write_text(ENGINE)
|
||||
module = Path(muxmod.__file__).resolve()
|
||||
monkeypatch.setenv('MUX_TEST_MARKER','inherited')
|
||||
args = [sys.executable,str(module),sys.executable,'--','-u',str(engine),'--probe','space arg','app-server']
|
||||
p = subprocess.run(args,cwd=tmp_path,capture_output=True,timeout=10)
|
||||
assert p.returncode == 23
|
||||
result = json.loads(p.stdout)
|
||||
assert result == {'argv':['--probe','space arg','app-server'],'cwd':str(tmp_path),'marker':'inherited'}
|
||||
direct = subprocess.run([sys.executable, '-u', str(engine), '--probe', 'space arg', 'app-server'],
|
||||
cwd=tmp_path, capture_output=True, timeout=10)
|
||||
assert (p.stdout, p.stderr, p.returncode) == (direct.stdout, direct.stderr, direct.returncode)
|
||||
|
||||
|
||||
def test_engine_exit_without_desktop_eof(tmp_path):
|
||||
h = Harness(tmp_path)
|
||||
try:
|
||||
h.send({'method':'exit'})
|
||||
h.thread.join(5)
|
||||
assert not h.thread.is_alive()
|
||||
assert h.code == 17
|
||||
assert h.mux.snapshot()['closed']
|
||||
finally:
|
||||
h.close()
|
||||
|
||||
|
||||
def test_owned_namespace_collision_disables_injection_but_forwards(tmp_path):
|
||||
h = Harness(tmp_path)
|
||||
try:
|
||||
n = h.start()
|
||||
raw = json.dumps({'id':h.mux._prefix+'collision','method':'echo'}).encode()+b'\n'
|
||||
h.send(raw)
|
||||
assert h.recv() == raw
|
||||
assert h.http('/notice', n)[1]['status'] == 'injection_disabled'
|
||||
finally:
|
||||
h.close()
|
||||
|
||||
|
||||
def test_queued_notice_revalidated_after_observed_stop(tmp_path):
|
||||
h = Harness(tmp_path)
|
||||
release = tmp_path / 'release'
|
||||
try:
|
||||
n = h.start()
|
||||
h.send({'method':'pause','params':{'release':str(release)}})
|
||||
assert json.loads(h.recv())['method'] == 'paused'
|
||||
# Fill the OS pipe while the real engine is paused; leave bounded queue
|
||||
# capacity for the notice and stop record behind it.
|
||||
for i in range(8):
|
||||
h.send({'method':'echo','i':i,'padding':'x'*32000})
|
||||
assert h.http('/notice', n)[1]['status'] == 'queued'
|
||||
h.send({'method':'thread/realtime/stop','params':{'threadId':'thread-A'}})
|
||||
deadline = time.monotonic()+5
|
||||
while h.mux.snapshot()['threads']['thread-A']['active'] and time.monotonic() < deadline:
|
||||
time.sleep(.01)
|
||||
assert not h.mux.snapshot()['threads']['thread-A']['active']
|
||||
release.touch()
|
||||
methods = [json.loads(h.recv())['method'] for _ in range(10)]
|
||||
assert methods == ['released'] + ['echo']*8 + ['thread/realtime/closed']
|
||||
assert h.mux._receipts[('tenant','bekci','notice-1')]['status'] == 'binding_not_established'
|
||||
assert h.lines.empty()
|
||||
finally:
|
||||
release.touch()
|
||||
h.close()
|
||||
|
||||
def test_real_request_correlation_and_opaque_receipt_contract(tmp_path):
|
||||
h = Harness(tmp_path)
|
||||
try:
|
||||
n = h.start()
|
||||
adapter = muxmod.WatchReceiptAdapter(h.mux)
|
||||
headers = {'Session-Id':'thread-A','Thread-Id':'thread-A'}
|
||||
correlation = adapter.capture(headers, {'prompt_cache_key':'thread-A'})
|
||||
assert correlation.thread_id == 'thread-A'
|
||||
assert adapter.capture({**headers,'Thread-Id':'other'}, {'prompt_cache_key':'thread-A'}) is None
|
||||
assert adapter.capture(headers, {}) is None
|
||||
assert adapter.accept('bekci.watch', {}, correlation) == 'backend_contract_not_established'
|
||||
# Internal contract fixture, explicitly NOT a claim about backend wire format.
|
||||
receipt = muxmod.WatchReceipt('opaque','tenant','bekci','rev','thread-A',time.time()+60)
|
||||
adapter.decode = lambda payload: payload
|
||||
adapter.resolve = lambda handle: receipt
|
||||
assert adapter.accept('bekci.watch','opaque',correlation) == 'bound'
|
||||
assert adapter.accept('_bekci_watch','opaque',correlation) == 'bound'
|
||||
assert adapter.accept('other','opaque',correlation) == 'unsupported_metadata_transport'
|
||||
assert adapter.notice('unbound',n)['status'] == 'binding_not_established'
|
||||
assert adapter.notice('opaque',{**n,'tenant_id':'other'})['status'] == 'binding_mismatch'
|
||||
assert adapter.notice('opaque',n)['status'] == 'queued'
|
||||
assert json.loads(h.recv())['method'] == 'item/commandExecution/requestApproval'
|
||||
assert json.loads(h.recv())['method'] == 'seen-speech'
|
||||
h.send({'method':'thread/realtime/stop','params':{'threadId':'thread-A'}}); h.recv()
|
||||
h.send({'method':'thread/realtime/start','params':{'threadId':'thread-A'}}); h.recv()
|
||||
assert adapter.accept('bekci.watch','opaque',correlation) == 'stale_generation'
|
||||
fresh = adapter.capture(headers, {'prompt_cache_key':'thread-A'})
|
||||
adapter.resolve = lambda handle: muxmod.WatchReceipt('opaque','tenant','bekci','rev','wrong',time.time()+60)
|
||||
assert adapter.accept('bekci.watch','opaque',fresh) == 'receipt_mismatch_or_expired'
|
||||
adapter.resolve = lambda handle: muxmod.WatchReceipt('opaque','tenant','bekci','rev','thread-A',time.time()-1)
|
||||
assert adapter.accept('bekci.watch','opaque',fresh) == 'receipt_mismatch_or_expired'
|
||||
finally:
|
||||
h.close()
|
||||
|
||||
|
||||
def test_verified_original_launcher_fallback_actual_process(tmp_path):
|
||||
import hashlib
|
||||
executable = str(Path(sys.executable).resolve())
|
||||
with open(executable,'rb') as f:
|
||||
digest = hashlib.file_digest(f,'sha256').hexdigest()
|
||||
assert muxmod.verified_original(executable,digest) == executable
|
||||
import pytest
|
||||
with pytest.raises(ValueError): muxmod.verified_original(executable,'0'*64)
|
||||
with pytest.raises(ValueError): muxmod.verified_original('python.exe',digest)
|
||||
# A value that happens to say app-server must not trigger observer/attachment.
|
||||
marker = tmp_path/'original-ran'
|
||||
code = 'from pathlib import Path; import sys; Path(sys.argv[1]).write_text("ok"); sys.exit(21)'
|
||||
def forbidden(adapter):
|
||||
raise AssertionError('not an app-server invocation')
|
||||
assert muxmod.launch_original(executable,digest,['-c',code,str(marker),'app-server'],attach=forbidden) == 21
|
||||
assert marker.read_text() == 'ok'
|
||||
@@ -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()
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Windows owned-process lifetime tests; no installed process is terminated."""
|
||||
import ctypes
|
||||
from ctypes import wintypes as w
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import pytest
|
||||
from prepare_native_watch import prepare
|
||||
|
||||
pytestmark = pytest.mark.skipif(sys.platform != 'win32', reason='Windows executable lifecycle')
|
||||
|
||||
def descendants(parent):
|
||||
class Entry(ctypes.Structure):
|
||||
_fields_=[('size',w.DWORD),('usage',w.DWORD),('pid',w.DWORD),('heap',ctypes.c_size_t),('module',w.DWORD),('threads',w.DWORD),('parent',w.DWORD),('priority',w.LONG),('flags',w.DWORD),('exe',w.WCHAR*260)]
|
||||
k=ctypes.WinDLL('kernel32',use_last_error=True)
|
||||
k.CreateToolhelp32Snapshot.restype=w.HANDLE
|
||||
k.Process32FirstW.argtypes=[w.HANDLE,ctypes.POINTER(Entry)]
|
||||
k.Process32NextW.argtypes=[w.HANDLE,ctypes.POINTER(Entry)]
|
||||
k.CloseHandle.argtypes=[w.HANDLE]
|
||||
snap=k.CreateToolhelp32Snapshot(2,0);entry=Entry();entry.size=ctypes.sizeof(entry);rows=[]
|
||||
try:
|
||||
more=k.Process32FirstW(snap,ctypes.byref(entry))
|
||||
while more:
|
||||
rows.append((entry.pid,entry.parent));more=k.Process32NextW(snap,ctypes.byref(entry))
|
||||
finally:k.CloseHandle(snap)
|
||||
owned={parent}
|
||||
while True:
|
||||
children={pid for pid,ppid in rows if ppid in owned}
|
||||
if children <= owned: return owned-{parent}
|
||||
owned |= children
|
||||
|
||||
|
||||
def assert_abrupt_cleanup(p, expected_min):
|
||||
k=ctypes.WinDLL('kernel32',use_last_error=True)
|
||||
k.OpenProcess.argtypes=[w.DWORD,w.BOOL,w.DWORD];k.OpenProcess.restype=w.HANDLE
|
||||
k.WaitForSingleObject.argtypes=[w.HANDLE,w.DWORD]
|
||||
k.CloseHandle.argtypes=[w.HANDLE]
|
||||
ids=descendants(p.pid)
|
||||
assert len(ids)>=expected_min, ids
|
||||
handles=[k.OpenProcess(0x100000,False,pid) for pid in ids]
|
||||
assert all(handles)
|
||||
try:
|
||||
p.kill();p.wait(timeout=5)
|
||||
assert all(k.WaitForSingleObject(h,5000)==0 for h in handles), ids
|
||||
finally:
|
||||
for h in handles:k.CloseHandle(h)
|
||||
return len(ids)
|
||||
|
||||
|
||||
def line(p):
|
||||
q=queue.Queue();threading.Thread(target=lambda:q.put(p.stdout.readline()),daemon=True).start()
|
||||
return q.get(timeout=20)
|
||||
|
||||
|
||||
def candidate(tmp_path, original, digest):
|
||||
return prepare(Path(__file__).parent,tmp_path/'candidate',original,digest,Path(sys.executable),tmp_path/'bridge',tmp_path/'agent')
|
||||
|
||||
|
||||
def test_abrupt_bootstrap_exit_kills_owned_python_and_grandchild(tmp_path):
|
||||
exe=Path(sys.executable)
|
||||
launcher=candidate(tmp_path,exe,hashlib.sha256(exe.read_bytes()).hexdigest())
|
||||
script='import subprocess,sys,time; p=subprocess.Popen([sys.executable,"-c","import time; time.sleep(120)"]); print(p.pid,flush=True); time.sleep(120)'
|
||||
p=subprocess.Popen([str(launcher),'-u','-c',script],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
|
||||
try:
|
||||
assert int(line(p))>0
|
||||
assert_abrupt_cleanup(p,3)
|
||||
finally:
|
||||
if p.poll() is None:p.kill();p.wait(timeout=5)
|
||||
p.stdin.close();p.stdout.close();p.stderr.close()
|
||||
|
||||
|
||||
def test_pinned_original_initialize_and_abrupt_cleanup(tmp_path):
|
||||
# Explicit provisioned evidence, never discovers or kills an existing app.
|
||||
manifest_path=os.environ.get('NATIVE_WATCH_TEST_MANIFEST')
|
||||
if not manifest_path: pytest.skip('requires explicit pinned original candidate')
|
||||
manifest=json.loads(Path(manifest_path).read_text(encoding='utf-8-sig'))
|
||||
launcher=candidate(tmp_path,Path(manifest['original']),manifest['original_sha256'])
|
||||
p=subprocess.Popen([str(launcher),'-c','model_provider="openai"','app-server','--analytics-default-enabled'],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
|
||||
try:
|
||||
p.stdin.write(json.dumps({'id':1,'method':'initialize','params':{'clientInfo':{'name':'owned-cleanup-proof','version':'1.0'},'capabilities':{'experimentalApi':True}}}).encode()+b'\n');p.stdin.flush()
|
||||
response=json.loads(line(p));assert response.get('id')==1 and 'result' in response,response
|
||||
assert_abrupt_cleanup(p,2)
|
||||
finally:
|
||||
if p.poll() is None:p.kill();p.wait(timeout=5)
|
||||
p.stdin.close();p.stdout.close();p.stderr.close()
|
||||
|
||||
def test_compiled_launcher_fallback(tmp_path):
|
||||
source=Path(__file__).parent
|
||||
import hashlib
|
||||
exe=Path(sys.executable);digest=hashlib.sha256(exe.read_bytes()).hexdigest()
|
||||
launcher=prepare(source,tmp_path/'candidate',exe,digest,exe,tmp_path/'bridge',tmp_path/'agent')
|
||||
args=['-c','import json,sys,os; print(json.dumps([sys.argv[1:],os.getcwd()])); sys.stderr.write("stderr\\n"); sys.exit(23)','space arg','quote"arg','trailing\\']
|
||||
direct=subprocess.run([str(exe),*args],cwd=tmp_path,capture_output=True)
|
||||
wrapped=subprocess.run([str(launcher),*args],cwd=tmp_path,capture_output=True)
|
||||
assert (wrapped.returncode,wrapped.stdout,wrapped.stderr)==(direct.returncode,direct.stdout,direct.stderr)
|
||||
manifest=launcher.with_suffix('.json');data=json.loads(manifest.read_text(encoding='utf-8'));data['original_sha256']='0'*64;manifest.write_text(json.dumps(data),encoding='utf-8')
|
||||
assert subprocess.run([str(launcher),'--version'],capture_output=True).returncode==78
|
||||
|
||||
def test_executable_flushes_interactive_stdin_before_eof(tmp_path):
|
||||
import hashlib,queue
|
||||
exe=Path(sys.executable);digest=hashlib.sha256(exe.read_bytes()).hexdigest()
|
||||
launcher=prepare(Path(__file__).parent,tmp_path/'candidate',exe,digest,exe,tmp_path/'bridge',tmp_path/'agent')
|
||||
p=subprocess.Popen([str(launcher),'-u','-c','import sys; print(sys.stdin.readline().strip(),flush=True); sys.stdin.read()'],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
|
||||
q=queue.Queue();threading.Thread(target=lambda:q.put(p.stdout.readline()),daemon=True).start()
|
||||
try:
|
||||
p.stdin.write(b'live-before-eof\n');p.stdin.flush()
|
||||
assert q.get(timeout=5).strip()==b'live-before-eof'
|
||||
finally:
|
||||
p.stdin.close();p.wait(timeout=5)
|
||||
@@ -0,0 +1,185 @@
|
||||
"""ozet_kopru testleri: 9Router icin sikistirma bagdastiricisi ve saglayicilar arasi ozet aktarimi."""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import ozet_kopru as o
|
||||
|
||||
|
||||
def msg(rol, metin):
|
||||
return {"type": "message", "role": rol, "content": [{"type": "input_text", "text": metin}]}
|
||||
|
||||
|
||||
def sse(*olaylar):
|
||||
return b"".join(b"event: " + e["type"].encode() + b"\ndata: " + json.dumps(e, ensure_ascii=False).encode() + b"\n\n" for e in olaylar)
|
||||
|
||||
|
||||
def coz(veri):
|
||||
return [json.loads(s[5:]) for blok in veri.split(b"\n\n") for s in blok.split(b"\n") if s.startswith(b"data:")]
|
||||
|
||||
|
||||
OPENAI_OZETI = {"type": "compaction", "id": "cmp_0123", "encrypted_content": "gAAAAABsahte", "internal_chat_message_metadata_passthrough": {"turn_id": "t"}}
|
||||
|
||||
|
||||
class Istek(unittest.TestCase):
|
||||
def test_tetik_algilanir_ve_ozet_istegine_cevrilir(self):
|
||||
req = {"model": "m", "tools": [{"type": "function", "name": "x"}], "input": [msg("user", "KARAR-1: 14 Kasim"), {"type": "compaction_trigger"}]}
|
||||
self.assertTrue(o.tetik_mi(req))
|
||||
self.assertFalse(o.tetik_mi({"input": [msg("user", "normal")]}))
|
||||
self.assertFalse(o.tetik_mi({"input": []}))
|
||||
o.ozet_istegi_hazirla(req)
|
||||
self.assertNotIn("compaction_trigger", json.dumps(req))
|
||||
self.assertEqual(req["input"][0]["content"][0]["text"], "KARAR-1: 14 Kasim") # gecmis aynen kalir
|
||||
self.assertEqual(req["input"][-1]["role"], "user")
|
||||
istem = req["input"][-1]["content"][0]["text"]
|
||||
for baslik in ("KARARLAR VE GEREKCELERI", "ACIK ISLER", "ONEMLI SONUCLAR", "KULLANICININ ISTEGI"):
|
||||
self.assertIn(baslik, istem)
|
||||
self.assertEqual(req["tool_choice"], "none")
|
||||
|
||||
def test_kopru_ozeti_her_hedefte_duz_metne_acilir(self):
|
||||
oge = o.ozet_ogesi("KARAR-1: teslim 14 Kasim\nACIK-IS-1: fatura onayi")
|
||||
self.assertTrue(oge["id"].startswith("cmp_astrabridge_"))
|
||||
self.assertNotIn("KARAR-1", json.dumps(oge)) # ogede duz metin gorunmez (base64)
|
||||
for yabanci in (False, True):
|
||||
req = {"input": [msg("user", "once"), dict(oge), msg("user", "soru")]}
|
||||
b = o.ozetleri_ac(req, yabanci_hedef=yabanci)
|
||||
self.assertEqual(b["acilan"], 1)
|
||||
metin = req["input"][1]["content"][0]["text"]
|
||||
self.assertEqual(req["input"][1]["role"], "user")
|
||||
self.assertIn("KARAR-1: teslim 14 Kasim", metin)
|
||||
self.assertTrue(metin.startswith(o.OZET_BASLIK))
|
||||
self.assertNotIn('"compaction"', json.dumps(req))
|
||||
|
||||
def test_openai_ozeti_openai_hedefinde_dokunulmaz(self):
|
||||
req = {"input": [dict(OPENAI_OZETI), msg("user", "soru")]}
|
||||
once = json.dumps(req, sort_keys=True)
|
||||
b = o.ozetleri_ac(req, yabanci_hedef=False)
|
||||
self.assertEqual(json.dumps(req, sort_keys=True), once)
|
||||
self.assertEqual(b, {"acilan": 0, "cozulen": 0, "okunamayan": 0})
|
||||
|
||||
def test_openai_ozeti_yabanci_hedefte_duz_metne_cevrilir_ya_da_not_dusulur(self):
|
||||
req = {"input": [dict(OPENAI_OZETI), msg("user", "soru")]}
|
||||
b = o.ozetleri_ac(req, yabanci_hedef=True, coz=lambda oge: "KARAR-2: saglayici degismeyecek")
|
||||
self.assertEqual(b["cozulen"], 1)
|
||||
self.assertIn("KARAR-2", req["input"][0]["content"][0]["text"])
|
||||
for bozuk in (None, lambda oge: None, lambda oge: 1 / 0):
|
||||
req = {"input": [dict(OPENAI_OZETI), msg("user", "soru")]}
|
||||
b = o.ozetleri_ac(req, yabanci_hedef=True, coz=bozuk)
|
||||
self.assertEqual(b["okunamayan"], 1)
|
||||
self.assertEqual(req["input"][0]["content"][0]["text"], o.OKUNAMADI_NOTU) # sessiz kayip yok
|
||||
self.assertNotIn("gAAAAAB", json.dumps(req))
|
||||
|
||||
def test_coz_istegi_yalniz_gerekli_alanlari_tasir(self):
|
||||
g = o.coz_istegi(OPENAI_OZETI, "gpt-6-luna")
|
||||
self.assertEqual(g["input"][0], {"type": "compaction", "id": "cmp_0123", "encrypted_content": "gAAAAABsahte"})
|
||||
self.assertEqual(g["model"], "gpt-6-luna")
|
||||
self.assertFalse(g["store"])
|
||||
|
||||
|
||||
class Akis(unittest.TestCase):
|
||||
def _mesaj(self, metin, kimlik="msg_1"):
|
||||
return {"id": kimlik, "type": "message", "role": "assistant", "content": [{"type": "output_text", "text": metin}]}
|
||||
|
||||
def test_metin_tek_compaction_ogesine_sarilir(self):
|
||||
ozet = "1. KULLANICININ ISTEGI: x\n2. KARARLAR: KARAR-1 14 Kasim"
|
||||
ham = sse({"type": "response.created", "response": {"id": "r1"}},
|
||||
{"type": "response.output_item.added", "item": {"id": "msg_1", "type": "message"}},
|
||||
{"type": "response.output_text.delta", "item_id": "msg_1", "delta": ozet[:10]},
|
||||
{"type": "response.output_text.delta", "item_id": "msg_1", "delta": ozet[10:]},
|
||||
{"type": "response.output_item.done", "item": self._mesaj(ozet)},
|
||||
{"type": "response.completed", "response": {"id": "r1", "output": [self._mesaj(ozet)], "usage": {"input_tokens": 5}}})
|
||||
a = o.OzetAkisi(tohum="7")
|
||||
out = b"".join(a.feed(ham[i:i + 53]) for i in range(0, len(ham), 53)) + a.flush()
|
||||
olaylar = coz(out)
|
||||
self.assertEqual([e["type"] for e in olaylar], ["response.created", "response.output_item.done", "response.completed"])
|
||||
oge = olaylar[1]["item"]
|
||||
self.assertEqual(oge["type"], "compaction")
|
||||
self.assertEqual(o.ozet_metni(oge), ozet)
|
||||
self.assertEqual(olaylar[2]["response"]["output"], [oge]) # Codex tam bir compaction ogesi gorur
|
||||
self.assertEqual(olaylar[2]["response"]["usage"], {"input_tokens": 5})
|
||||
self.assertEqual(a.metin, ozet)
|
||||
self.assertNotIn("KARAR-1", out.decode()) # ozet metni akista duz gorunmez
|
||||
|
||||
def test_yalniz_delta_gelirse_de_toplanir(self):
|
||||
ham = sse({"type": "response.created", "response": {}},
|
||||
{"type": "response.output_text.delta", "item_id": "a", "delta": "ozet "},
|
||||
{"type": "response.output_text.delta", "item_id": "a", "delta": "metni"},
|
||||
{"type": "response.completed", "response": {}})
|
||||
a = o.OzetAkisi()
|
||||
self.assertEqual(o.ozet_metni(coz(a.feed(ham))[1]["item"]), "ozet metni")
|
||||
|
||||
def test_anormal_kisa_ozet_gecmisin_yerine_konmaz(self):
|
||||
# Esik yalniz bozuk sayilacak kadar kisa ozeti yakalar. 10 Ekim 2026 olcumu: 646.955 baytlik
|
||||
# sohbetin dogru ozeti 1501-1701 karakterdi ve boyuta oranli eski esik onu reddedip sohbeti kilitledi.
|
||||
self.assertEqual(o.en_az_karakter(1018151), 300)
|
||||
self.assertEqual(o.en_az_karakter(646955), 300)
|
||||
self.assertEqual(o.en_az_karakter(100000), 50)
|
||||
self.assertEqual(o.en_az_karakter(0), 0)
|
||||
self.assertEqual(o.en_az_karakter(1018151, {"en_az_tavan": 1500, "en_az_oran": 250}), 1500)
|
||||
for gercek_ozet in (1501, 1648, 1701):
|
||||
self.assertGreater(gercek_ozet, o.en_az_karakter(646955))
|
||||
ham = sse({"type": "response.created", "response": {"id": "r1"}},
|
||||
{"type": "response.output_item.done", "item": self._mesaj("k" * 120)},
|
||||
{"type": "response.completed", "response": {"id": "r1", "status": "completed"}})
|
||||
a = o.OzetAkisi(tohum="7", en_az=o.en_az_karakter(1018151))
|
||||
olaylar = coz(a.feed(ham) + a.flush())
|
||||
self.assertEqual([e["type"] for e in olaylar], ["response.created", "response.failed"])
|
||||
self.assertNotIn("compaction", json.dumps(olaylar))
|
||||
self.assertEqual(olaylar[1]["response"]["error"]["code"], "astrabridge_ozet_kisa")
|
||||
self.assertTrue(a.kisa)
|
||||
self.assertIn("REDDEDILDI", a.sonuc_notu())
|
||||
# Yeterli uzunlukta ozet ayni esikle kabul edilir; kucuk sohbetin kisa ozeti de reddedilmez.
|
||||
for metin, esik in (("u" * 1501, o.en_az_karakter(646955)), ("kisa ama dogru ozet", o.en_az_karakter(3000))):
|
||||
b = o.OzetAkisi(tohum="8", en_az=esik)
|
||||
ham2 = sse({"type": "response.output_item.done", "item": self._mesaj(metin)},
|
||||
{"type": "response.completed", "response": {"id": "r2"}})
|
||||
cikti = coz(b.feed(ham2))
|
||||
self.assertEqual(o.ozet_metni(cikti[0]["item"]), metin)
|
||||
self.assertFalse(b.kisa)
|
||||
|
||||
def test_metin_yoksa_compaction_uretilmez_hata_gorunur(self):
|
||||
ham = sse({"type": "response.created", "response": {}},
|
||||
{"type": "response.output_item.done", "item": {"type": "function_call", "name": "x"}},
|
||||
{"type": "response.completed", "response": {"output": []}})
|
||||
a = o.OzetAkisi()
|
||||
olaylar = coz(a.feed(ham))
|
||||
self.assertNotIn("compaction", json.dumps(olaylar))
|
||||
self.assertFalse(a.metin)
|
||||
hata = sse({"type": "response.created", "response": {}}, {"type": "response.failed", "response": {"error": {"message": "x"}}})
|
||||
self.assertIn("response.failed", o.OzetAkisi().feed(hata).decode())
|
||||
|
||||
def test_onbellek(self):
|
||||
klasor = os.path.join(tempfile.mkdtemp(), "c")
|
||||
self.assertIsNone(o.onbellekten(klasor, OPENAI_OZETI))
|
||||
o.onbellege(klasor, OPENAI_OZETI, "duz metin ozet")
|
||||
self.assertEqual(o.onbellekten(klasor, OPENAI_OZETI), "duz metin ozet")
|
||||
|
||||
def test_sse_metni(self):
|
||||
ham = sse({"type": "response.created", "response": {}}, {"type": "response.output_item.done", "item": self._mesaj("cozulmus ozet")},
|
||||
{"type": "response.completed", "response": {}})
|
||||
self.assertEqual(o.sse_metni(ham), "cozulmus ozet")
|
||||
|
||||
|
||||
class Fren(unittest.TestCase):
|
||||
def test_tight_loop_is_stopped_but_spaced_summaries_are_not(self):
|
||||
f = o.DonguFreni(sinir=8, pencere_sn=600, art_arda=3)
|
||||
# Summary after summary with no normal request in between: a real loop.
|
||||
self.assertEqual([f.izin("sohbet-a", t) for t in (0, 10, 20, 30, 40)], [True, True, True, False, False])
|
||||
self.assertTrue(f.izin("sohbet-b", 40)) # another conversation is unaffected
|
||||
# A normal request between summaries (small-window model compacting every turn) keeps working.
|
||||
g = o.DonguFreni(sinir=8, pencere_sn=600, art_arda=3)
|
||||
izinler = []
|
||||
for t in range(0, 70, 10):
|
||||
izinler.append(g.izin("sohbet-c", t))
|
||||
g.normal("sohbet-c")
|
||||
self.assertEqual(izinler, [True] * 7)
|
||||
self.assertTrue(g.izin("sohbet-c", 70)) # the 8th in the window
|
||||
g.normal("sohbet-c")
|
||||
self.assertFalse(g.izin("sohbet-c", 80)) # the total cap still ends a slow loop
|
||||
self.assertTrue(g.izin("sohbet-c", 700)) # and lifts once the window passes
|
||||
self.assertIn("Dongu durduruldu", o.DONGU_MESAJI % 3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Bridge version 7: real context windows, phase stamping, overflow errors, images for vision models."""
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import ide_catalog as c
|
||||
import pencere
|
||||
import resim_context
|
||||
|
||||
tree = ast.parse(Path(__file__).with_name("kopru.py").read_text(encoding="utf-8"))
|
||||
names = {"fix_tool_ids", "SseRewriter", "phase_damgala", "asistan_mesaji_mi"}
|
||||
nodes = [n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.ClassDef)) and n.name in names]
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id in
|
||||
{"ID_PREFIX", "FOREIGN_ID_MARK", "REFERENCE_TYPES", "INLINE_TYPES", "ARAC_OGELERI"} for t in node.targets):
|
||||
nodes.insert(0, node)
|
||||
env = {"json": json, "re": __import__("re"), "time": __import__("time"), "log": lambda message: None, "pencere": pencere}
|
||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), "bridge.py", "exec"), env)
|
||||
|
||||
NANO = pencere.nano_sadelestir({"data": [
|
||||
{"id": "z-ai/glm-5.3-flash-uncensored", "context_length": 1048576,
|
||||
"capabilities": {"vision": True, "tool_calling": True}},
|
||||
{"id": "mlabonne/NeuralDaredevil-8B-abliterated", "context_length": 8192,
|
||||
"capabilities": {"vision": False, "tool_calling": False}},
|
||||
{"id": "nousresearch/hermes-3-llama-3.1-70b", "context_length": 65536,
|
||||
"capabilities": {"vision": False, "tool_calling": True}},
|
||||
{"id": "broken", "context_length": 0}]})
|
||||
|
||||
|
||||
def sse(*events):
|
||||
return b"".join(b"event: " + e["type"].encode() + b"\ndata: " + json.dumps(e).encode() + b"\n\n" for e in events)
|
||||
|
||||
|
||||
def parse(raw):
|
||||
return [json.loads(line[6:]) for line in raw.decode().splitlines() if line.startswith("data: ")]
|
||||
|
||||
|
||||
def message(text, ident="msg_1"):
|
||||
return {"type": "message", "id": ident, "role": "assistant", "content": [{"type": "output_text", "text": text}]}
|
||||
|
||||
|
||||
class Windows(unittest.TestCase):
|
||||
def test_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"], 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))
|
||||
self.assertEqual(coz("9router-cc-claude-opus-5-5", "model", "cc/claude-opus-5-5")["pencere"], 200000)
|
||||
self.assertEqual(coz("9router-cx-gpt-6-astra", "model", "cx/gpt-6-astra")["pencere"], 272000)
|
||||
self.assertEqual(coz("no-think-9router-cc-claude-opus-5-5", "model", "no-think/claude-opus-5-5")["pencere"], 200000)
|
||||
unknown = coz("agnes-agnes-2.5-flash", "model", "Agnes 2.5 Flash", nano=NANO)
|
||||
self.assertEqual((unknown["pencere"], unknown["kaynak"]), (pencere.VARSAYILAN, "varsayilan"))
|
||||
self.assertNotIn("broken", NANO)
|
||||
|
||||
def test_combo_declares_its_smallest_member(self):
|
||||
combo = {"topology": {"kind": "combo", "strategy": "priority", "members": [
|
||||
{"kind": "model", "model": "cx/gpt-6-astra"},
|
||||
{"kind": "combo", "strategy": "priority", "members": [
|
||||
{"kind": "model", "model": "nano/z-ai/glm-5.3-flash-uncensored"},
|
||||
{"kind": "model", "model": "nano/nousresearch/hermes-3-llama-3.1-70b"}]}]}}
|
||||
self.assertEqual(pencere.coz("X", "combo", "X", combo, NANO)["pencere"], 65536)
|
||||
self.assertEqual(pencere.coz("X", "combo", "X", None, NANO)["pencere"], pencere.VARSAYILAN)
|
||||
|
||||
def test_limit_learned_from_the_target_error_only_shrinks(self):
|
||||
home = tempfile.mkdtemp()
|
||||
text = "This model's maximum context length is 32768 tokens. However, you requested 51234 tokens"
|
||||
self.assertTrue(pencere.tasma_mi(text))
|
||||
self.assertEqual(pencere.sinir_oku(text), 32768)
|
||||
self.assertEqual(pencere.sinir_oku("prompt is too long: 3040251 tokens > 1000000 maximum"), 1000000)
|
||||
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))
|
||||
self.assertEqual(pencere.sinir_oku(gateway), 65536)
|
||||
self.assertIsNone(pencere.sinir_oku("rate limit exceeded"))
|
||||
self.assertFalse(pencere.tasma_mi("rate limit exceeded"))
|
||||
self.assertTrue(pencere.ogren(home, "m", 32768))
|
||||
self.assertFalse(pencere.ogren(home, "m", 65536)) # a larger number never overrides
|
||||
self.assertEqual(pencere.ogrenilen(home), {"m": 32768})
|
||||
self.assertEqual(pencere.coz("m", "model", "cc/claude-opus-5-5", ogr=pencere.ogrenilen(home))["pencere"], 32768)
|
||||
|
||||
def test_nano_cache_survives_a_network_failure(self):
|
||||
home = tempfile.mkdtemp()
|
||||
fresh = {"data": [{"id": "A/b", "context_length": 16384, "capabilities": {}}]}
|
||||
self.assertEqual(pencere.nano_yukle(home, simdi=1000, getir=lambda: fresh)["a/b"]["pencere"], 16384)
|
||||
|
||||
def fail():
|
||||
raise OSError("offline")
|
||||
later = 1000 + pencere.NANO_OMUR_SN + 5
|
||||
self.assertEqual(pencere.nano_yukle(home, simdi=later, getir=fail)["a/b"]["pencere"], 16384)
|
||||
self.assertEqual(pencere.nano_yukle(tempfile.mkdtemp(), simdi=later, getir=fail), {})
|
||||
self.assertEqual((pencere.kisa(8192), pencere.kisa(131072), pencere.kisa(200000)), ("8K", "128K", "200K"))
|
||||
|
||||
def test_description_line_warns_about_small_window_and_no_tools(self):
|
||||
bilgi = {"pencere": 8192, "arac": False, "gorsel": False}
|
||||
line = c.aciklama("9router-nano-mlabonne-NeuralDaredevil-8B-abliterated", "9R · nano/NeuralDaredevil-8B-abliterated",
|
||||
"model", None, bilgi)
|
||||
self.assertEqual(line, "NanoGPT · 8K · araçsız") # warnings first; the rest no longer fits in 30
|
||||
self.assertLessEqual(len(line), c.ACIKLAMA_SINIRI)
|
||||
self.assertEqual(c.aciklama("9router-cc-claude-opus-5-5", "9R · cc/claude-opus-5-5", "model", None,
|
||||
{"pencere": 200000, "arac": True, "gorsel": True}), "Tek model · Claude")
|
||||
|
||||
|
||||
class Guard(unittest.TestCase):
|
||||
def req(self, old, last="son soru"):
|
||||
return {"instructions": "talimat", "input": [
|
||||
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": old}]},
|
||||
{"type": "function_call_output", "call_id": "c", "output": "cikti " * 4000},
|
||||
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": last}]}]}
|
||||
|
||||
def test_request_that_fits_is_untouched(self):
|
||||
req = self.req("kisa")
|
||||
before = json.dumps(req)
|
||||
self.assertEqual(pencere.sigdir(req, 272000)[:2], (0, True))
|
||||
self.assertEqual(json.dumps(req), before)
|
||||
|
||||
def test_oversized_old_parts_lose_their_middle_and_the_last_user_message_never_does(self):
|
||||
req = self.req("BAS " + "dolgu " * 60000 + " SON", last="yeni " * 3000)
|
||||
kirpilan, sigdi, tahmin = pencere.sigdir(req, 65536)
|
||||
self.assertTrue(sigdi)
|
||||
self.assertGreaterEqual(kirpilan, 1)
|
||||
old = req["input"][0]["content"][0]["text"]
|
||||
self.assertTrue(old.startswith("BAS ") and old.endswith(" SON"))
|
||||
self.assertIn("çıkarıldı (köprü)", old)
|
||||
self.assertEqual(req["input"][2]["content"][0]["text"], "yeni " * 3000) # the new message is intact
|
||||
self.assertLessEqual(tahmin, int(65536 * pencere.DOLULUK))
|
||||
|
||||
def test_a_last_message_larger_than_the_window_is_reported_not_sent(self):
|
||||
req = self.req("kisa", last="x" * 400000)
|
||||
self.assertFalse(pencere.sigdir(req, 32768)[1])
|
||||
|
||||
|
||||
class Phase(unittest.TestCase):
|
||||
def run_stream(self, *events, chunk=37):
|
||||
adapter = env["SseRewriter"](set())
|
||||
raw = sse(*events)
|
||||
out = b"".join(adapter.feed(raw[i:i + chunk]) for i in range(0, len(raw), chunk)) + adapter.flush()
|
||||
return parse(out)
|
||||
|
||||
def test_message_before_a_tool_call_is_commentary(self):
|
||||
call = {"type": "function_call", "id": "fc_1", "call_id": "call_1", "name": "shell", "arguments": "{}"}
|
||||
out = self.run_stream(
|
||||
{"type": "response.output_item.added", "item": message("")},
|
||||
{"type": "response.output_text.delta", "item_id": "msg_1", "delta": "bakiyorum"},
|
||||
{"type": "response.output_item.done", "item": message("bakiyorum")},
|
||||
{"type": "response.output_item.added", "item": call},
|
||||
{"type": "response.output_item.done", "item": call},
|
||||
{"type": "response.completed", "response": {"output": [message("bakiyorum"), call]}})
|
||||
self.assertEqual([e["type"] for e in out], [
|
||||
"response.output_item.added", "response.output_text.delta", "response.output_item.done",
|
||||
"response.output_item.added", "response.output_item.done", "response.completed"]) # order kept
|
||||
self.assertEqual(out[2]["item"]["phase"], "commentary")
|
||||
self.assertEqual(out[5]["response"]["output"][0]["phase"], "commentary")
|
||||
self.assertNotIn("phase", out[5]["response"]["output"][1])
|
||||
|
||||
def test_last_message_without_tool_calls_is_the_final_answer(self):
|
||||
out = self.run_stream(
|
||||
{"type": "response.output_item.done", "item": message("ara", "msg_a")},
|
||||
{"type": "response.output_item.added", "item": message("", "msg_b")},
|
||||
{"type": "response.output_item.done", "item": message("son", "msg_b")},
|
||||
{"type": "response.completed", "response": {"output": [message("ara", "msg_a"), message("son", "msg_b")]}})
|
||||
done = [e for e in out if e["type"] == "response.output_item.done"]
|
||||
self.assertEqual([e["item"]["phase"] for e in done], ["commentary", "final_answer"])
|
||||
self.assertEqual([o["phase"] for o in out[-1]["response"]["output"]], ["commentary", "final_answer"])
|
||||
self.assertEqual(out[-1]["type"], "response.completed") # done comes first
|
||||
|
||||
def test_existing_phase_and_cut_streams_are_left_alone(self):
|
||||
native = dict(message("x"), phase="final_answer")
|
||||
out = self.run_stream({"type": "response.output_item.done", "item": native},
|
||||
{"type": "response.completed", "response": {"output": [dict(native)]}})
|
||||
self.assertEqual(out[0]["item"]["phase"], "final_answer")
|
||||
# No terminal event: the held message is still delivered, unmarked rather than guessed.
|
||||
cut = self.run_stream({"type": "response.output_item.done", "item": message("yarim")})
|
||||
self.assertEqual(len(cut), 1)
|
||||
self.assertNotIn("phase", cut[0]["item"])
|
||||
|
||||
def test_overflow_failure_gets_the_code_codex_understands(self):
|
||||
seen = []
|
||||
adapter = env["SseRewriter"](set(), hedef="model-x", tasma=lambda hedef, metin: seen.append((hedef, metin)))
|
||||
failed = {"type": "response.failed", "response": {"error": {
|
||||
"code": "server_error", "message": "maximum context length is 32768 tokens"}}}
|
||||
out = parse(adapter.feed(sse(failed)) + adapter.flush())
|
||||
self.assertEqual(out[0]["response"]["error"]["code"], "context_length_exceeded")
|
||||
self.assertEqual(seen[0][0], "model-x")
|
||||
other = {"type": "response.failed", "response": {"error": {"code": "rate_limit", "message": "slow down"}}}
|
||||
self.assertEqual(parse(adapter.feed(sse(other)))[0]["response"]["error"]["code"], "rate_limit")
|
||||
|
||||
|
||||
class GatewayErrors(unittest.TestCase):
|
||||
def test_permanent_gateway_error_becomes_a_failure_codex_shows_at_once(self):
|
||||
adapter = env["SseRewriter"](set())
|
||||
raw = sse({"type": "error", "error": {"type": "upstream_error", "message": "upstream 402"}})
|
||||
out = parse(adapter.feed(raw) + adapter.flush())
|
||||
self.assertEqual(out[-1]["type"], "response.completed") # visible answer, turn ends normally
|
||||
self.assertIn("kredi ya da kota", out[1]["delta"])
|
||||
self.assertIn("upstream 402", out[1]["delta"])
|
||||
raw = sse({"type": "error", "error": {"type": "upstream_error", "message": "upstream 400"}})
|
||||
out = parse(env["SseRewriter"](set()).feed(raw))
|
||||
self.assertIn("nedenini bildirmedi", out[1]["delta"])
|
||||
self.assertEqual(out[-1]["type"], "response.completed")
|
||||
# A transient error is left alone so that Codex retries it.
|
||||
raw = sse({"type": "error", "error": {"type": "upstream_error", "message": "upstream 503"}})
|
||||
self.assertEqual(parse(env["SseRewriter"](set()).feed(raw))[0]["type"], "error")
|
||||
|
||||
|
||||
class Images(unittest.TestCase):
|
||||
def req(self):
|
||||
img = {"type": "input_image", "image_url": "data:image/png;base64," + "A" * 2000}
|
||||
return {"input": [
|
||||
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "bak"}, dict(img)]},
|
||||
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "OKUDUM"}]},
|
||||
{"type": "function_call_output", "call_id": "c", "output": [dict(img)]},
|
||||
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "ne yaziyordu"}]}]}
|
||||
|
||||
def test_text_model_gets_notes_vision_model_keeps_user_images(self):
|
||||
text_only = self.req()
|
||||
self.assertEqual(resim_context.resimleri_sadelestir(text_only)[0], 2)
|
||||
self.assertNotIn("input_image", json.dumps(text_only))
|
||||
vision = self.req()
|
||||
self.assertEqual(resim_context.resimleri_sadelestir(vision, gorsel=True)[0], 1) # only the tool output
|
||||
self.assertEqual(vision["input"][0]["content"][1]["type"], "input_image")
|
||||
self.assertNotIn("input_image", json.dumps(vision["input"][2]))
|
||||
big = self.req()
|
||||
self.assertEqual(resim_context.resimleri_sadelestir(big, tut_bayt=500, gorsel=True)[0], 2) # over budget
|
||||
|
||||
|
||||
|
||||
class ClaudeOrani(unittest.TestCase):
|
||||
def test_claude_targets_are_estimated_more_strictly_than_others(self):
|
||||
import pencere as p
|
||||
req = {"input": [{"type": "function_call_output", "call_id": "c", "output": "x" * 600_000},
|
||||
{"type": "message", "role": "user", "content": "son"}]}
|
||||
self.assertEqual(p.oran_sec("9router-cc-claude-opus-5-5"), p.CLAUDE_BAYT_BOLU_TOKEN)
|
||||
self.assertEqual(p.oran_sec("9router-ag-gemini-3.8-flash-low"), p.BAYT_BOLU_TOKEN)
|
||||
self.assertEqual(p.sigdir({"input": [dict(i) for i in req["input"]]}, 200_000)[0], 0) # 187500: fits
|
||||
trimmed, fits, estimate = p.sigdir(req, 200_000, p.oran_sec("cc-claude-opus")) # 230769: trimmed
|
||||
self.assertEqual((trimmed, fits), (1, True))
|
||||
self.assertLess(estimate, 194_000)
|
||||
self.assertTrue(p.kapali_hata_mi("upstream 400"))
|
||||
# A per-minute rate limit is neither an overflow nor a window size to learn.
|
||||
rate = "Rate limit reached: limit of 32768 tokens per minute, context window ok"
|
||||
self.assertFalse(p.tasma_mi(rate))
|
||||
self.assertIsNone(p.sinir_oku(rate))
|
||||
self.assertTrue(p.tasma_mi("maximum context length is 65536 tokens"))
|
||||
self.assertFalse(p.kapali_hata_mi("upstream 400: invalid tool schema"))
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""py -3 -m unittest test_resim_context (astra-bridge klasöründe)"""
|
||||
import unittest
|
||||
|
||||
from resim_context import resimleri_sadelestir
|
||||
|
||||
|
||||
def resim(bayt):
|
||||
return {'type': 'input_image', 'image_url': 'data:image/png;base64,' + 'A' * bayt}
|
||||
|
||||
|
||||
class Resim(unittest.TestCase):
|
||||
def test_generated_image_in_tool_output_is_replaced(self):
|
||||
req = {'input': [
|
||||
{'type': 'message', 'role': 'user', 'content': [{'type': 'input_text', 'text': 'foto'}, resim(150_000)]},
|
||||
{'type': 'custom_tool_call_output', 'call_id': 'x',
|
||||
'output': [{'type': 'input_text', 'text': 'tamam'}, resim(3_000_000)]},
|
||||
]}
|
||||
sayi, kazanc = resimleri_sadelestir(req)
|
||||
self.assertEqual(sayi, 1) # son user mesajındaki küçük foto korunur, araç çıktısındaki resim çıkar
|
||||
self.assertGreater(kazanc, 3_000_000)
|
||||
self.assertEqual(req['input'][1]['output'][1]['type'], 'input_text')
|
||||
|
||||
def test_latest_user_photos_kept_when_small(self):
|
||||
req = {'input': [
|
||||
{'type': 'message', 'role': 'user', 'content': [resim(10_000)]},
|
||||
{'type': 'message', 'role': 'assistant', 'content': [{'type': 'output_text', 'text': 'ok'}]},
|
||||
{'type': 'message', 'role': 'user', 'content': [{'type': 'input_text', 'text': 'yeni'}, resim(200_000)]},
|
||||
]}
|
||||
sayi, _ = resimleri_sadelestir(req)
|
||||
self.assertEqual(sayi, 1) # yalnız eski mesajdaki resim
|
||||
self.assertEqual(req['input'][2]['content'][1]['type'], 'input_image')
|
||||
self.assertEqual(req['input'][0]['content'][0]['type'], 'input_text')
|
||||
|
||||
def test_latest_user_photos_dropped_when_too_big(self):
|
||||
req = {'input': [{'type': 'message', 'role': 'user', 'content': [resim(1_200_000)]}]}
|
||||
self.assertEqual(resimleri_sadelestir(req)[0], 1)
|
||||
|
||||
def test_pdf_audio_video_in_old_messages_and_tool_output_are_replaced(self):
|
||||
pdf = {'type': 'input_file', 'filename': 'rapor.pdf', 'file_data': 'data:application/pdf;base64,' + 'P' * 500_000}
|
||||
ses = {'type': 'input_audio', 'input_audio': {'data': 'S' * 400_000, 'format': 'wav'}}
|
||||
video = {'type': 'input_video', 'video_url': 'data:video/mp4;base64,' + 'V' * 300_000}
|
||||
req = {'input': [
|
||||
{'type': 'message', 'role': 'user', 'content': [pdf, ses]},
|
||||
{'type': 'function_call_output', 'call_id': 'y', 'output': [video]},
|
||||
{'type': 'message', 'role': 'user', 'content': [{'type': 'input_text', 'text': 'devam'}]},
|
||||
]}
|
||||
sayi, _ = resimleri_sadelestir(req)
|
||||
self.assertEqual(sayi, 3)
|
||||
self.assertIn('rapor.pdf', req['input'][0]['content'][0]['text'])
|
||||
self.assertIn('ses', req['input'][0]['content'][1]['text'])
|
||||
self.assertIn('video', req['input'][1]['output'][0]['text'])
|
||||
|
||||
def test_huge_text_tool_output_is_shortened(self):
|
||||
req = {'input': [{'type': 'function_call_output', 'call_id': 'z', 'output': 'x' * 3_000_000}]}
|
||||
sayi, kazanc = resimleri_sadelestir(req)
|
||||
self.assertEqual(sayi, 1)
|
||||
self.assertLess(len(req['input'][0]['output']), 600_000)
|
||||
self.assertIn('çıkarıldı', req['input'][0]['output'])
|
||||
|
||||
def test_unknown_huge_part_is_replaced(self):
|
||||
req = {'input': [{'type': 'custom_tool_call_output', 'call_id': 'q',
|
||||
'output': [{'type': 'yeni_bir_tur', 'veri': 'Z' * 1_500_000}]}]}
|
||||
self.assertEqual(resimleri_sadelestir(req)[0], 1)
|
||||
|
||||
def test_request_without_images_untouched(self):
|
||||
req = {'input': [{'type': 'message', 'role': 'user', 'content': [{'type': 'input_text', 'text': 'x'}]}]}
|
||||
self.assertEqual(resimleri_sadelestir(req), (0, 0))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,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()
|
||||
@@ -0,0 +1,74 @@
|
||||
"""An answer cut by the client is carried to the next turn of the same conversation, once."""
|
||||
import json
|
||||
import unittest
|
||||
|
||||
import yarim
|
||||
|
||||
|
||||
def delta(text):
|
||||
return (b"event: response.output_text.delta\ndata: "
|
||||
+ json.dumps({"type": "response.output_text.delta", "delta": text}).encode() + b"\n\n")
|
||||
|
||||
|
||||
def user(text):
|
||||
return {"type": "message", "role": "user", "content": [{"type": "input_text", "text": text}]}
|
||||
|
||||
|
||||
class Yarim(unittest.TestCase):
|
||||
def setUp(self):
|
||||
yarim._KAYIT.clear()
|
||||
|
||||
def cut(self, key="sohbet-1", text="1. bir sayidir ve ilk siradadir. 2. iki sayidir ve cifttir. 3. uc"):
|
||||
izle = yarim.Izleyici(key, "ide/x")
|
||||
raw = delta(text[:20]) + delta(text[20:])
|
||||
for i in range(0, len(raw), 17):
|
||||
izle.feed(raw[i:i + 17])
|
||||
return izle
|
||||
|
||||
def test_cut_answer_reaches_the_next_user_message_once(self):
|
||||
izle = self.cut()
|
||||
self.assertTrue(yarim.kaydet(izle, simdi=100))
|
||||
req = {"input": [user("liste yaz"), user("kaldigin yerden devam et")]}
|
||||
self.assertGreater(yarim.uygula(req, "sohbet-1", simdi=200), 0)
|
||||
note = req["input"][-1]["content"][-1]["text"]
|
||||
self.assertIn("3. uc", note)
|
||||
self.assertIn('model="ide/x"', note)
|
||||
self.assertEqual(req["input"][-1]["content"][0]["text"], "kaldigin yerden devam et") # the user's words stay first
|
||||
again = {"input": [user("bir daha")]}
|
||||
self.assertEqual(yarim.uygula(again, "sohbet-1", simdi=300), 0) # used once
|
||||
self.assertEqual(yarim.uygula({"input": [user("x")]}, "baska-sohbet", simdi=300), 0)
|
||||
|
||||
def test_finished_short_or_keyless_answers_are_not_kept(self):
|
||||
done = self.cut()
|
||||
done.feed(b'event: response.completed\ndata: {"type": "response.completed"}\n\n')
|
||||
self.assertFalse(yarim.kaydet(done))
|
||||
self.assertFalse(yarim.kaydet(self.cut(text="kisa")))
|
||||
self.assertFalse(yarim.kaydet(self.cut(key="")))
|
||||
|
||||
def test_note_waits_for_a_real_user_turn_and_skips_text_codex_already_saved(self):
|
||||
yarim.kaydet(self.cut(), simdi=100)
|
||||
tool_turn = {"input": [user("x"), {"type": "function_call_output", "call_id": "c", "output": "ok"}]}
|
||||
self.assertEqual(yarim.uygula(tool_turn, "sohbet-1", simdi=110), 0)
|
||||
compaction = {"input": [user("x"), {"type": "compaction_trigger"}]}
|
||||
self.assertEqual(yarim.uygula(compaction, "sohbet-1", simdi=110), 0)
|
||||
self.assertIn("sohbet-1", yarim._KAYIT) # still waiting
|
||||
saved = {"input": [{"type": "message", "role": "assistant", "content": [
|
||||
{"type": "output_text", "text": "1. bir sayidir ve ilk siradadir. 2. iki sayidir ve cifttir. 3. uc"}]}, user("devam")]}
|
||||
self.assertEqual(yarim.uygula(saved, "sohbet-1", simdi=120), 0)
|
||||
self.assertNotIn("sohbet-1", yarim._KAYIT)
|
||||
yarim.kaydet(self.cut(), simdi=100)
|
||||
self.assertEqual(yarim.uygula({"input": [user("gec")]}, "sohbet-1", simdi=100 + yarim.OMUR_SN + 1), 0) # expired
|
||||
|
||||
def test_long_text_keeps_head_and_tail(self):
|
||||
yarim.kaydet(self.cut(text="BAS" + "x" * 40000 + "SON"), simdi=1)
|
||||
req = {"input": [user("devam")]}
|
||||
yarim.uygula(req, "sohbet-1", simdi=2)
|
||||
note = req["input"][-1]["content"][-1]["text"]
|
||||
self.assertLess(len(note), yarim.EN_COK_KARAKTER + 600)
|
||||
self.assertIn("BAS", note)
|
||||
self.assertTrue(note.rstrip().endswith("</yarim_cevap>"))
|
||||
self.assertIn("SON\n</yarim_cevap>", note)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Codex compresses request bodies with zstd; the bridge must be able to read them."""
|
||||
import ast
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
tree = ast.parse(Path(__file__).with_name("kopru.py").read_text(encoding="utf-8"))
|
||||
wanted = {"_zstd_decompress", "_zstd_ready", "decompress"}
|
||||
env = {}
|
||||
exec(compile(ast.Module(body=[n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in wanted],
|
||||
type_ignores=[]), "bridge.py", "exec"), env)
|
||||
|
||||
|
||||
class Zstd(unittest.TestCase):
|
||||
def test_self_check_matches_reality(self):
|
||||
try:
|
||||
try:
|
||||
from compression import zstd as z
|
||||
packed = z.compress(b"govde" * 50)
|
||||
except ImportError:
|
||||
import zstandard
|
||||
packed = zstandard.ZstdCompressor().compress(b"govde" * 50)
|
||||
except ImportError:
|
||||
self.assertFalse(env["_zstd_ready"]()) # no zstd here: the bridge must say so
|
||||
return
|
||||
self.assertTrue(env["_zstd_ready"]())
|
||||
self.assertEqual(env["decompress"](packed, "zstd"), b"govde" * 50)
|
||||
|
||||
def test_other_encodings(self):
|
||||
import gzip
|
||||
self.assertEqual(env["decompress"](gzip.compress(b"x"), "gzip"), b"x")
|
||||
self.assertEqual(env["decompress"](b"duz", None), b"duz")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
Copyright (c) 2016, Gregory Szorc
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
# Copyright (c) 2017-present, Gregory Szorc
|
||||
# All rights reserved.
|
||||
#
|
||||
# This software may be modified and distributed under the terms
|
||||
# of the BSD license. See the LICENSE file for details.
|
||||
|
||||
# ruff: noqa: F403, F405
|
||||
|
||||
"""Python interface to the Zstandard (zstd) compression library."""
|
||||
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
# This module serves 2 roles:
|
||||
#
|
||||
# 1) Export the C or CFFI "backend" through a central module.
|
||||
# 2) Implement additional functionality built on top of C or CFFI backend.
|
||||
import builtins
|
||||
import io
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from collections.abc import Buffer
|
||||
else:
|
||||
from typing import ByteString as Buffer
|
||||
|
||||
# Some Python implementations don't support C extensions. That's why we have
|
||||
# a CFFI implementation in the first place. The code here import one of our
|
||||
# "backends" then re-exports the symbols from this module. For convenience,
|
||||
# we support falling back to the CFFI backend if the C extension can't be
|
||||
# imported. But for performance reasons, we only do this on unknown Python
|
||||
# implementation. Notably, for CPython we require the C extension by default.
|
||||
# Because someone will inevitably want special behavior, the behavior is
|
||||
# configurable via an environment variable. A potentially better way to handle
|
||||
# this is to import a special ``__importpolicy__`` module or something
|
||||
# defining a variable and `setup.py` could write the file with whatever
|
||||
# policy was specified at build time. Until someone needs it, we go with
|
||||
# the hacky but simple environment variable approach.
|
||||
_module_policy = os.environ.get(
|
||||
"PYTHON_ZSTANDARD_IMPORT_POLICY", "default"
|
||||
).strip()
|
||||
|
||||
if _module_policy == "default":
|
||||
if platform.python_implementation() in ("CPython",):
|
||||
from .backend_c import * # type: ignore
|
||||
|
||||
backend = "cext"
|
||||
elif platform.python_implementation() in ("PyPy",):
|
||||
from .backend_cffi import * # type: ignore
|
||||
|
||||
backend = "cffi"
|
||||
else:
|
||||
try:
|
||||
from .backend_c import *
|
||||
|
||||
backend = "cext"
|
||||
except ImportError:
|
||||
from .backend_cffi import *
|
||||
|
||||
backend = "cffi"
|
||||
elif _module_policy == "cffi_fallback":
|
||||
try:
|
||||
from .backend_c import *
|
||||
|
||||
backend = "cext"
|
||||
except ImportError:
|
||||
from .backend_cffi import *
|
||||
|
||||
backend = "cffi"
|
||||
elif _module_policy == "rust":
|
||||
from .backend_rust import * # type: ignore
|
||||
|
||||
backend = "rust"
|
||||
elif _module_policy == "cext":
|
||||
from .backend_c import *
|
||||
|
||||
backend = "cext"
|
||||
elif _module_policy == "cffi":
|
||||
from .backend_cffi import *
|
||||
|
||||
backend = "cffi"
|
||||
else:
|
||||
raise ImportError(
|
||||
"unknown module import policy: %s; use default, cffi_fallback, "
|
||||
"cext, or cffi" % _module_policy
|
||||
)
|
||||
|
||||
# Keep this in sync with python-zstandard.h, rust-ext/src/lib.rs, and debian/changelog.
|
||||
__version__ = "0.25.0"
|
||||
|
||||
_MODE_CLOSED = 0
|
||||
_MODE_READ = 1
|
||||
_MODE_WRITE = 2
|
||||
|
||||
|
||||
def open(
|
||||
filename,
|
||||
mode="rb",
|
||||
cctx=None,
|
||||
dctx=None,
|
||||
encoding=None,
|
||||
errors=None,
|
||||
newline=None,
|
||||
closefd=None,
|
||||
):
|
||||
"""Create a file object with zstd (de)compression.
|
||||
|
||||
The object returned from this function will be a
|
||||
:py:class:`ZstdDecompressionReader` if opened for reading in binary mode,
|
||||
a :py:class:`ZstdCompressionWriter` if opened for writing in binary mode,
|
||||
or an ``io.TextIOWrapper`` if opened for reading or writing in text mode.
|
||||
|
||||
:param filename:
|
||||
``bytes``, ``str``, or ``os.PathLike`` defining a file to open or a
|
||||
file object (with a ``read()`` or ``write()`` method).
|
||||
:param mode:
|
||||
``str`` File open mode. Accepts any of the open modes recognized by
|
||||
``open()``.
|
||||
:param cctx:
|
||||
``ZstdCompressor`` to use for compression. If not specified and file
|
||||
is opened for writing, the default ``ZstdCompressor`` will be used.
|
||||
:param dctx:
|
||||
``ZstdDecompressor`` to use for decompression. If not specified and file
|
||||
is opened for reading, the default ``ZstdDecompressor`` will be used.
|
||||
:param encoding:
|
||||
``str`` that defines text encoding to use when file is opened in text
|
||||
mode.
|
||||
:param errors:
|
||||
``str`` defining text encoding error handling mode.
|
||||
:param newline:
|
||||
``str`` defining newline to use in text mode.
|
||||
:param closefd:
|
||||
``bool`` whether to close the file when the returned object is closed.
|
||||
Only used if a file object is passed. If a filename is specified, the
|
||||
opened file is always closed when the returned object is closed.
|
||||
"""
|
||||
normalized_mode = mode.replace("t", "")
|
||||
|
||||
if normalized_mode in ("r", "rb"):
|
||||
dctx = dctx or ZstdDecompressor()
|
||||
open_mode = "r"
|
||||
raw_open_mode = "rb"
|
||||
elif normalized_mode in ("w", "wb", "a", "ab", "x", "xb"):
|
||||
cctx = cctx or ZstdCompressor()
|
||||
open_mode = "w"
|
||||
raw_open_mode = normalized_mode
|
||||
if not raw_open_mode.endswith("b"):
|
||||
raw_open_mode = raw_open_mode + "b"
|
||||
else:
|
||||
raise ValueError("Invalid mode: {!r}".format(mode))
|
||||
|
||||
if hasattr(os, "PathLike"):
|
||||
types = (str, bytes, os.PathLike)
|
||||
else:
|
||||
types = (str, bytes)
|
||||
|
||||
if isinstance(filename, types): # type: ignore
|
||||
inner_fh = builtins.open(filename, raw_open_mode)
|
||||
closefd = True
|
||||
elif hasattr(filename, "read") or hasattr(filename, "write"):
|
||||
inner_fh = filename
|
||||
closefd = bool(closefd)
|
||||
else:
|
||||
raise TypeError(
|
||||
"filename must be a str, bytes, file or PathLike object"
|
||||
)
|
||||
|
||||
if open_mode == "r":
|
||||
fh = dctx.stream_reader(inner_fh, closefd=closefd)
|
||||
elif open_mode == "w":
|
||||
fh = cctx.stream_writer(inner_fh, closefd=closefd)
|
||||
else:
|
||||
raise RuntimeError("logic error in zstandard.open() handling open mode")
|
||||
|
||||
if "b" not in normalized_mode:
|
||||
return io.TextIOWrapper(
|
||||
fh, encoding=encoding, errors=errors, newline=newline
|
||||
)
|
||||
else:
|
||||
return fh
|
||||
|
||||
|
||||
def compress(data: Buffer, level: int = 3) -> bytes:
|
||||
"""Compress source data using the zstd compression format.
|
||||
|
||||
This performs one-shot compression using basic/default compression
|
||||
settings.
|
||||
|
||||
This method is provided for convenience and is equivalent to calling
|
||||
``ZstdCompressor(level=level).compress(data)``.
|
||||
|
||||
If you find yourself calling this function in a tight loop,
|
||||
performance will be greater if you construct a single ``ZstdCompressor``
|
||||
and repeatedly call ``compress()`` on it.
|
||||
"""
|
||||
cctx = ZstdCompressor(level=level)
|
||||
|
||||
return cctx.compress(data)
|
||||
|
||||
|
||||
def decompress(data: Buffer, max_output_size: int = 0) -> bytes:
|
||||
"""Decompress a zstd frame into its original data.
|
||||
|
||||
This performs one-shot decompression using basic/default compression
|
||||
settings.
|
||||
|
||||
This method is provided for convenience and is equivalent to calling
|
||||
``ZstdDecompressor().decompress(data, max_output_size=max_output_size)``.
|
||||
|
||||
If you find yourself calling this function in a tight loop, performance
|
||||
will be greater if you construct a single ``ZstdDecompressor`` and
|
||||
repeatedly call ``decompress()`` on it.
|
||||
"""
|
||||
dctx = ZstdDecompressor()
|
||||
|
||||
return dctx.decompress(data, max_output_size=max_output_size)
|
||||
+481
@@ -0,0 +1,481 @@
|
||||
# Copyright (c) 2016-present, Gregory Szorc
|
||||
# All rights reserved.
|
||||
#
|
||||
# This software may be modified and distributed under the terms
|
||||
# of the BSD license. See the LICENSE file for details.
|
||||
|
||||
import os
|
||||
from typing import (
|
||||
IO,
|
||||
BinaryIO,
|
||||
ByteString,
|
||||
Generator,
|
||||
Iterable,
|
||||
List,
|
||||
Optional,
|
||||
Set,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
|
||||
FLUSH_BLOCK: int
|
||||
FLUSH_FRAME: int
|
||||
|
||||
COMPRESSOBJ_FLUSH_FINISH: int
|
||||
COMPRESSOBJ_FLUSH_BLOCK: int
|
||||
|
||||
CONTENTSIZE_UNKNOWN: int
|
||||
CONTENTSIZE_ERROR: int
|
||||
|
||||
MAX_COMPRESSION_LEVEL: int
|
||||
|
||||
COMPRESSION_RECOMMENDED_INPUT_SIZE: int
|
||||
COMPRESSION_RECOMMENDED_OUTPUT_SIZE: int
|
||||
|
||||
DECOMPRESSION_RECOMMENDED_INPUT_SIZE: int
|
||||
DECOMPRESSION_RECOMMENDED_OUTPUT_SIZE: int
|
||||
|
||||
BLOCKSIZELOG_MAX: int
|
||||
BLOCKSIZE_MAX: int
|
||||
|
||||
WINDOWLOG_MIN: int
|
||||
WINDOWLOG_MAX: int
|
||||
|
||||
CHAINLOG_MIN: int
|
||||
CHAINLOG_MAX: int
|
||||
HASHLOG_MIN: int
|
||||
HASHLOG_MAX: int
|
||||
MINMATCH_MIN: int
|
||||
MINMATCH_MAX: int
|
||||
SEARCHLOG_MIN: int
|
||||
SEARCHLOG_MAX: int
|
||||
SEARCHLENGTH_MIN: int
|
||||
SEARCHLENGTH_MAX: int
|
||||
TARGETLENGTH_MIN: int
|
||||
TARGETLENGTH_MAX: int
|
||||
LDM_MINMATCH_MIN: int
|
||||
LDM_MINMATCH_MAX: int
|
||||
LDM_BUCKETSIZELOG_MAX: int
|
||||
|
||||
STRATEGY_FAST: int
|
||||
STRATEGY_DFAST: int
|
||||
STRATEGY_GREEDY: int
|
||||
STRATEGY_LAZY: int
|
||||
STRATEGY_LAZY2: int
|
||||
STRATEGY_BTLAZY2: int
|
||||
STRATEGY_BTOPT: int
|
||||
STRATEGY_BTULTRA: int
|
||||
STRATEGY_BTULTRA2: int
|
||||
|
||||
DICT_TYPE_AUTO: int
|
||||
DICT_TYPE_RAWCONTENT: int
|
||||
DICT_TYPE_FULLDICT: int
|
||||
|
||||
FORMAT_ZSTD1: int
|
||||
FORMAT_ZSTD1_MAGICLESS: int
|
||||
|
||||
ZSTD_VERSION: Tuple[int, int, int]
|
||||
FRAME_HEADER: bytes
|
||||
MAGIC_NUMBER: int
|
||||
|
||||
backend: str
|
||||
backend_features: Set[str]
|
||||
__version__: str
|
||||
|
||||
class ZstdError(Exception): ...
|
||||
|
||||
class BufferSegment(object):
|
||||
offset: int
|
||||
def __len__(self) -> int: ...
|
||||
def tobytes(self) -> bytes: ...
|
||||
|
||||
class BufferSegments(object):
|
||||
def __len__(self) -> int: ...
|
||||
def __getitem__(self, i: int) -> BufferSegment: ...
|
||||
|
||||
class BufferWithSegments(object):
|
||||
size: int
|
||||
def __init__(self, data: ByteString, segments: ByteString): ...
|
||||
def __len__(self) -> int: ...
|
||||
def __getitem__(self, i: int) -> BufferSegment: ...
|
||||
def segments(self): ...
|
||||
def tobytes(self) -> bytes: ...
|
||||
|
||||
class BufferWithSegmentsCollection(object):
|
||||
def __init__(self, *args): ...
|
||||
def __len__(self) -> int: ...
|
||||
def __getitem__(self, i: int) -> BufferSegment: ...
|
||||
def size(self) -> int: ...
|
||||
|
||||
class ZstdCompressionParameters(object):
|
||||
@staticmethod
|
||||
def from_level(
|
||||
level: int, source_size: int = ..., dict_size: int = ..., **kwargs
|
||||
) -> "ZstdCompressionParameters": ...
|
||||
def __init__(
|
||||
self,
|
||||
format: int = ...,
|
||||
compression_level: int = ...,
|
||||
window_log: int = ...,
|
||||
hash_log: int = ...,
|
||||
chain_log: int = ...,
|
||||
search_log: int = ...,
|
||||
min_match: int = ...,
|
||||
target_length: int = ...,
|
||||
strategy: int = ...,
|
||||
write_content_size: int = ...,
|
||||
write_checksum: int = ...,
|
||||
write_dict_id: int = ...,
|
||||
job_size: int = ...,
|
||||
overlap_log: int = ...,
|
||||
force_max_window: int = ...,
|
||||
enable_ldm: int = ...,
|
||||
ldm_hash_log: int = ...,
|
||||
ldm_min_match: int = ...,
|
||||
ldm_bucket_size_log: int = ...,
|
||||
ldm_hash_rate_log: int = ...,
|
||||
threads: int = ...,
|
||||
): ...
|
||||
@property
|
||||
def format(self) -> int: ...
|
||||
@property
|
||||
def compression_level(self) -> int: ...
|
||||
@property
|
||||
def window_log(self) -> int: ...
|
||||
@property
|
||||
def hash_log(self) -> int: ...
|
||||
@property
|
||||
def chain_log(self) -> int: ...
|
||||
@property
|
||||
def search_log(self) -> int: ...
|
||||
@property
|
||||
def min_match(self) -> int: ...
|
||||
@property
|
||||
def target_length(self) -> int: ...
|
||||
@property
|
||||
def strategy(self) -> int: ...
|
||||
@property
|
||||
def write_content_size(self) -> int: ...
|
||||
@property
|
||||
def write_checksum(self) -> int: ...
|
||||
@property
|
||||
def write_dict_id(self) -> int: ...
|
||||
@property
|
||||
def job_size(self) -> int: ...
|
||||
@property
|
||||
def overlap_log(self) -> int: ...
|
||||
@property
|
||||
def force_max_window(self) -> int: ...
|
||||
@property
|
||||
def enable_ldm(self) -> int: ...
|
||||
@property
|
||||
def ldm_hash_log(self) -> int: ...
|
||||
@property
|
||||
def ldm_min_match(self) -> int: ...
|
||||
@property
|
||||
def ldm_bucket_size_log(self) -> int: ...
|
||||
@property
|
||||
def ldm_hash_rate_log(self) -> int: ...
|
||||
@property
|
||||
def threads(self) -> int: ...
|
||||
def estimated_compression_context_size(self) -> int: ...
|
||||
|
||||
class CompressionParameters(ZstdCompressionParameters): ...
|
||||
|
||||
class ZstdCompressionDict(object):
|
||||
k: int
|
||||
d: int
|
||||
def __init__(
|
||||
self,
|
||||
data: ByteString,
|
||||
dict_type: int = ...,
|
||||
k: int = ...,
|
||||
d: int = ...,
|
||||
): ...
|
||||
def __len__(self) -> int: ...
|
||||
def dict_id(self) -> int: ...
|
||||
def as_bytes(self) -> bytes: ...
|
||||
def precompute_compress(
|
||||
self,
|
||||
level: int = ...,
|
||||
compression_params: ZstdCompressionParameters = ...,
|
||||
): ...
|
||||
|
||||
class ZstdCompressionObj(object):
|
||||
def compress(self, data: ByteString) -> bytes: ...
|
||||
def flush(self, flush_mode: int = ...) -> bytes: ...
|
||||
|
||||
class ZstdCompressionChunker(object):
|
||||
def compress(self, data: ByteString): ...
|
||||
def flush(self): ...
|
||||
def finish(self): ...
|
||||
|
||||
class ZstdCompressionReader(BinaryIO):
|
||||
def __enter__(self) -> "ZstdCompressionReader": ...
|
||||
def __exit__(self, exc_type, exc_value, exc_tb): ...
|
||||
def readable(self) -> bool: ...
|
||||
def writable(self) -> bool: ...
|
||||
def seekable(self) -> bool: ...
|
||||
def readline(self, limit: int = ...) -> bytes: ...
|
||||
def readlines(self, hint: int = ...) -> List[bytes]: ...
|
||||
def write(self, data: ByteString): ...
|
||||
def writelines(self, data: Iterable[bytes]): ...
|
||||
def isatty(self) -> bool: ...
|
||||
def flush(self): ...
|
||||
def close(self): ...
|
||||
@property
|
||||
def closed(self) -> bool: ...
|
||||
def tell(self) -> int: ...
|
||||
def readall(self) -> bytes: ...
|
||||
def __iter__(self): ...
|
||||
def __next__(self): ...
|
||||
def next(self): ...
|
||||
def read(self, size: int = ...) -> bytes: ...
|
||||
def read1(self, size: int = ...) -> bytes: ...
|
||||
def readinto(self, b) -> int: ...
|
||||
def readinto1(self, b) -> int: ...
|
||||
|
||||
class ZstdCompressionWriter(BinaryIO):
|
||||
def __enter__(self) -> "ZstdCompressionWriter": ...
|
||||
def __exit__(self, exc_type, exc_value, exc_tb): ...
|
||||
def memory_size(self) -> int: ...
|
||||
def fileno(self) -> int: ...
|
||||
def close(self): ...
|
||||
@property
|
||||
def closed(self) -> bool: ...
|
||||
def isatty(self) -> bool: ...
|
||||
def readable(self) -> bool: ...
|
||||
def readline(self, size: int = ...) -> bytes: ...
|
||||
def readlines(self, hint: int = ...) -> List[bytes]: ...
|
||||
def seek(self, offset: int, whence: int = ...): ...
|
||||
def seekable(self) -> bool: ...
|
||||
def truncate(self, size: int = ...): ...
|
||||
def writable(self) -> bool: ...
|
||||
def writelines(self, lines: Iterable[bytes]): ...
|
||||
def read(self, size: int = ...) -> bytes: ...
|
||||
def readall(self) -> bytes: ...
|
||||
def readinto(self, b): ...
|
||||
def write(self, data: ByteString) -> int: ...
|
||||
def flush(self, flush_mode: int = ...) -> int: ...
|
||||
def tell(self) -> int: ...
|
||||
|
||||
class ZstdCompressor(object):
|
||||
def __init__(
|
||||
self,
|
||||
level: int = ...,
|
||||
dict_data: Optional[ZstdCompressionDict] = ...,
|
||||
compression_params: Optional[ZstdCompressionParameters] = ...,
|
||||
write_checksum: Optional[bool] = ...,
|
||||
write_content_size: Optional[bool] = ...,
|
||||
write_dict_id: Optional[bool] = ...,
|
||||
threads: int = ...,
|
||||
): ...
|
||||
def memory_size(self) -> int: ...
|
||||
def compress(self, data: ByteString) -> bytes: ...
|
||||
def compressobj(self, size: int = ...) -> ZstdCompressionObj: ...
|
||||
def chunker(
|
||||
self, size: int = ..., chunk_size: int = ...
|
||||
) -> ZstdCompressionChunker: ...
|
||||
def copy_stream(
|
||||
self,
|
||||
ifh: IO[bytes],
|
||||
ofh: IO[bytes],
|
||||
size: int = ...,
|
||||
read_size: int = ...,
|
||||
write_size: int = ...,
|
||||
) -> Tuple[int, int]: ...
|
||||
def stream_reader(
|
||||
self,
|
||||
source: Union[IO[bytes], ByteString],
|
||||
size: int = ...,
|
||||
read_size: int = ...,
|
||||
*,
|
||||
closefd: bool = ...,
|
||||
) -> ZstdCompressionReader: ...
|
||||
def stream_writer(
|
||||
self,
|
||||
writer: IO[bytes],
|
||||
size: int = ...,
|
||||
write_size: int = ...,
|
||||
write_return_read: bool = ...,
|
||||
*,
|
||||
closefd: bool = ...,
|
||||
) -> ZstdCompressionWriter: ...
|
||||
def read_to_iter(
|
||||
self,
|
||||
reader: Union[IO[bytes], ByteString],
|
||||
size: int = ...,
|
||||
read_size: int = ...,
|
||||
write_size: int = ...,
|
||||
) -> Generator[bytes, None, None]: ...
|
||||
def frame_progression(self) -> Tuple[int, int, int]: ...
|
||||
def multi_compress_to_buffer(
|
||||
self,
|
||||
data: Union[
|
||||
BufferWithSegments,
|
||||
BufferWithSegmentsCollection,
|
||||
List[ByteString],
|
||||
],
|
||||
threads: int = ...,
|
||||
) -> BufferWithSegmentsCollection: ...
|
||||
|
||||
class ZstdDecompressionObj(object):
|
||||
def decompress(self, data: ByteString) -> bytes: ...
|
||||
def flush(self, length: int = ...) -> bytes: ...
|
||||
@property
|
||||
def unused_data(self) -> bytes: ...
|
||||
@property
|
||||
def unconsumed_tail(self) -> bytes: ...
|
||||
@property
|
||||
def eof(self) -> bool: ...
|
||||
|
||||
class ZstdDecompressionReader(BinaryIO):
|
||||
def __enter__(self) -> "ZstdDecompressionReader": ...
|
||||
def __exit__(self, exc_type, exc_value, exc_tb): ...
|
||||
def readable(self) -> bool: ...
|
||||
def writable(self) -> bool: ...
|
||||
def seekable(self) -> bool: ...
|
||||
def readline(self, size: int = ...): ...
|
||||
def readlines(self, hint: int = ...): ...
|
||||
def write(self, data: ByteString): ...
|
||||
def writelines(self, lines: Iterable[bytes]): ...
|
||||
def isatty(self) -> bool: ...
|
||||
def flush(self): ...
|
||||
def close(self): ...
|
||||
@property
|
||||
def closed(self) -> bool: ...
|
||||
def tell(self) -> int: ...
|
||||
def readall(self) -> bytes: ...
|
||||
def __iter__(self): ...
|
||||
def __next__(self): ...
|
||||
def next(self): ...
|
||||
def read(self, size: int = ...) -> bytes: ...
|
||||
def readinto(self, b) -> int: ...
|
||||
def read1(self, size: int = ...) -> bytes: ...
|
||||
def readinto1(self, b) -> int: ...
|
||||
def seek(self, pos: int, whence: int = ...) -> int: ...
|
||||
|
||||
class ZstdDecompressionWriter(BinaryIO):
|
||||
def __enter__(self) -> "ZstdDecompressionWriter": ...
|
||||
def __exit__(self, exc_type, exc_value, exc_tb): ...
|
||||
def memory_size(self) -> int: ...
|
||||
def close(self): ...
|
||||
@property
|
||||
def closed(self) -> bool: ...
|
||||
def fileno(self) -> int: ...
|
||||
def flush(self): ...
|
||||
def isatty(self) -> bool: ...
|
||||
def readable(self) -> bool: ...
|
||||
def readline(self, size: int = ...): ...
|
||||
def readlines(self, hint: int = ...): ...
|
||||
def seek(self, offset: int, whence: int = ...): ...
|
||||
def seekable(self) -> bool: ...
|
||||
def tell(self): ...
|
||||
def truncate(self, size: int = ...): ...
|
||||
def writable(self) -> bool: ...
|
||||
def writelines(self, lines: Iterable[bytes]): ...
|
||||
def read(self, size: int = ...): ...
|
||||
def readall(self): ...
|
||||
def readinto(self, b): ...
|
||||
def write(self, data: ByteString) -> int: ...
|
||||
|
||||
class ZstdDecompressor(object):
|
||||
def __init__(
|
||||
self,
|
||||
dict_data: Optional[ZstdCompressionDict] = ...,
|
||||
max_window_size: int = ...,
|
||||
format: int = ...,
|
||||
): ...
|
||||
def memory_size(self) -> int: ...
|
||||
def decompress(
|
||||
self,
|
||||
data: ByteString,
|
||||
max_output_size: int = ...,
|
||||
read_across_frames: bool = ...,
|
||||
allow_extra_data: bool = ...,
|
||||
) -> bytes: ...
|
||||
def stream_reader(
|
||||
self,
|
||||
source: Union[IO[bytes], ByteString],
|
||||
read_size: int = ...,
|
||||
read_across_frames: bool = ...,
|
||||
*,
|
||||
closefd=False,
|
||||
) -> ZstdDecompressionReader: ...
|
||||
def decompressobj(
|
||||
self, write_size: int = ..., read_across_frames: bool = False
|
||||
) -> ZstdDecompressionObj: ...
|
||||
def read_to_iter(
|
||||
self,
|
||||
reader: Union[IO[bytes], ByteString],
|
||||
read_size: int = ...,
|
||||
write_size: int = ...,
|
||||
skip_bytes: int = ...,
|
||||
) -> Generator[bytes, None, None]: ...
|
||||
def stream_writer(
|
||||
self,
|
||||
writer: IO[bytes],
|
||||
write_size: int = ...,
|
||||
write_return_read: bool = ...,
|
||||
*,
|
||||
closefd: bool = ...,
|
||||
) -> ZstdDecompressionWriter: ...
|
||||
def copy_stream(
|
||||
self,
|
||||
ifh: IO[bytes],
|
||||
ofh: IO[bytes],
|
||||
read_size: int = ...,
|
||||
write_size: int = ...,
|
||||
) -> Tuple[int, int]: ...
|
||||
def decompress_content_dict_chain(
|
||||
self, frames: list[ByteString]
|
||||
) -> bytes: ...
|
||||
def multi_decompress_to_buffer(
|
||||
self,
|
||||
frames: Union[
|
||||
BufferWithSegments,
|
||||
BufferWithSegmentsCollection,
|
||||
List[ByteString],
|
||||
],
|
||||
decompressed_sizes: ByteString = ...,
|
||||
threads: int = ...,
|
||||
) -> BufferWithSegmentsCollection: ...
|
||||
|
||||
class FrameParameters(object):
|
||||
content_size: int
|
||||
window_size: int
|
||||
dict_id: int
|
||||
has_checksum: bool
|
||||
|
||||
def estimate_decompression_context_size() -> int: ...
|
||||
def frame_content_size(data: ByteString) -> int: ...
|
||||
def frame_header_size(data: ByteString) -> int: ...
|
||||
def get_frame_parameters(
|
||||
data: ByteString, format: Optional[int] = None
|
||||
) -> FrameParameters: ...
|
||||
def train_dictionary(
|
||||
dict_size: int,
|
||||
samples: list[ByteString],
|
||||
k: int = ...,
|
||||
d: int = ...,
|
||||
f: int = ...,
|
||||
split_point: float = ...,
|
||||
accel: int = ...,
|
||||
notifications: int = ...,
|
||||
dict_id: int = ...,
|
||||
level: int = ...,
|
||||
steps: int = ...,
|
||||
threads: int = ...,
|
||||
) -> ZstdCompressionDict: ...
|
||||
def open(
|
||||
filename: Union[bytes, str, os.PathLike, BinaryIO],
|
||||
mode: str = ...,
|
||||
cctx: Optional[ZstdCompressor] = ...,
|
||||
dctx: Optional[ZstdDecompressor] = ...,
|
||||
encoding: Optional[str] = ...,
|
||||
errors: Optional[str] = ...,
|
||||
newline: Optional[str] = ...,
|
||||
closefd: bool = ...,
|
||||
): ...
|
||||
def compress(data: ByteString, level: int = ...) -> bytes: ...
|
||||
def decompress(data: ByteString, max_output_size: int = ...) -> bytes: ...
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
+4478
File diff suppressed because it is too large.
Load diff
Whitespace-only changes.
@@ -0,0 +1,127 @@
|
||||
"""Yarida kesilen cevabi sonraki modele tasir (10 Ekim 2026).
|
||||
|
||||
Olcum: Codex kapanir ya da kullanici cevabi durdurursa o ana kadar yazilan metin sohbet gecmisine girmiyor;
|
||||
sonraki tur (ayni ya da baska modelle) "onceki cevabi goremiyorum" diyor. Kopru akisi zaten goruyor:
|
||||
ISTEMCI baglantiyi kapattiginda (yanit bitmeden) o ana kadarki metni bellekte tutar ve ayni sohbetin
|
||||
sonraki isteginde, kullanicinin yeni mesajina kisa bir not olarak ekler. Bir kez kullanilir.
|
||||
|
||||
Bilerek yapilmayanlar:
|
||||
- Ust kaynak kopmasinda (istemci hala bagli) kayit yok: Codex o istegi kendisi yeniden gonderir; not eklenirse
|
||||
model bastan yazmak yerine ortadan devam eder ve cevabin basi kaybolur.
|
||||
- Yarim metin asistan mesaji olarak eklenmez: Claude "asistan mesajiyla biten istek"i reddeder ve abonelik
|
||||
yolunda kimliksiz asistan ogesi risklidir. Kullanici mesajina eklenen not her saglayicida gecerlidir.
|
||||
- Diske yazilmaz; kopru yeniden baslarsa kayit gider.
|
||||
"""
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
OMUR_SN = 6 * 3600
|
||||
EN_COK_KAYIT = 50
|
||||
EN_COK_KARAKTER = 12_000 # nota konan yarim metnin ust siniri (basi ve sonu korunur)
|
||||
EN_AZ_KARAKTER = 40 # bundan kisa yarim metin tasinmaya degmez
|
||||
NOT_BASI = "[Köprü notu: bir önceki cevabın yarıda kesildi ve sohbet geçmişine kaydedilmedi."
|
||||
_KILIT = threading.Lock()
|
||||
_KAYIT = {} # sohbet anahtari -> {"zaman", "metin", "model"}
|
||||
|
||||
|
||||
class Izleyici:
|
||||
"""Akistan gecen parcalardan asistan metnini toplar; yanitin bitip bitmedigini izler."""
|
||||
|
||||
def __init__(self, anahtar, model):
|
||||
self.anahtar, self.model = anahtar, model
|
||||
self.buf = b""
|
||||
self.parcalar = []
|
||||
self.bitti = False
|
||||
|
||||
def feed(self, chunk):
|
||||
self.buf += chunk
|
||||
if b"\r" in self.buf:
|
||||
self.buf = self.buf.replace(b"\r\n", b"\n") # CRLF ile ayrilan SSE de izlenir
|
||||
while b"\n\n" in self.buf:
|
||||
blok, self.buf = self.buf.split(b"\n\n", 1)
|
||||
if b"response.output_text.delta" in blok:
|
||||
for satir in blok.split(b"\n"):
|
||||
if satir.startswith(b"data:"):
|
||||
try:
|
||||
d = json.loads(satir[5:])
|
||||
except ValueError:
|
||||
continue
|
||||
if d.get("type") == "response.output_text.delta" and isinstance(d.get("delta"), str):
|
||||
self.parcalar.append(d["delta"])
|
||||
elif b"response.completed" in blok or b"response.failed" in blok or b"response.incomplete" in blok:
|
||||
# Bitis, olayin TURUNDEN anlasilir; arac argumaninda gecen "response.completed" sozu bitis degildir.
|
||||
for satir in blok.split(b"\n"):
|
||||
if satir.startswith(b"data:"):
|
||||
try:
|
||||
tur = json.loads(satir[5:]).get("type")
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
if tur in ("response.completed", "response.failed", "response.incomplete"):
|
||||
self.bitti = True
|
||||
|
||||
@property
|
||||
def metin(self):
|
||||
return "".join(self.parcalar)
|
||||
|
||||
|
||||
def kaydet(izleyici, simdi=None):
|
||||
"""Istemci kapatti ve yanit bitmemisti: yarim metni sakla. True: saklandi."""
|
||||
metin = izleyici.metin.strip()
|
||||
if not izleyici.anahtar or izleyici.bitti or len(metin) < EN_AZ_KARAKTER:
|
||||
return False
|
||||
simdi = time.time() if simdi is None else simdi
|
||||
with _KILIT:
|
||||
_KAYIT[izleyici.anahtar] = {"zaman": simdi, "metin": metin, "model": izleyici.model}
|
||||
for eski in sorted(_KAYIT, key=lambda k: _KAYIT[k]["zaman"])[:max(0, len(_KAYIT) - EN_COK_KAYIT)]:
|
||||
del _KAYIT[eski]
|
||||
return True
|
||||
|
||||
|
||||
def _kisalt(metin):
|
||||
if len(metin) <= EN_COK_KARAKTER:
|
||||
return metin
|
||||
bas, son = EN_COK_KARAKTER // 4, EN_COK_KARAKTER - EN_COK_KARAKTER // 4
|
||||
return metin[:bas] + f"\n[... ortadan {len(metin) - bas - son} karakter çıkarıldı ...]\n" + metin[-son:]
|
||||
|
||||
|
||||
def uygula(req, anahtar, simdi=None):
|
||||
"""Ayni sohbetin sonraki isteginde yarim metni kullanicinin son mesajina not olarak ekler.
|
||||
Donus: eklenen karakter sayisi (0: eklenmedi). Kayit yalniz basariyla eklenince ya da eskiyince silinir."""
|
||||
if not anahtar:
|
||||
return 0
|
||||
simdi = time.time() if simdi is None else simdi
|
||||
with _KILIT:
|
||||
kayit = _KAYIT.get(anahtar)
|
||||
if kayit and simdi - kayit["zaman"] > OMUR_SN:
|
||||
del _KAYIT[anahtar]
|
||||
kayit = None
|
||||
girdi = req.get("input")
|
||||
if not kayit or not isinstance(girdi, list) or not girdi:
|
||||
return 0
|
||||
son = None
|
||||
for oge in reversed(girdi): # kancalarin kullanici mesajinin ardina ekledigi talimat mesajlari atlanir
|
||||
if isinstance(oge, dict) and oge.get("type", "message") == "message" and oge.get("role") in ("developer", "system"):
|
||||
continue
|
||||
son = oge
|
||||
break
|
||||
if not (isinstance(son, dict) and son.get("type", "message") == "message" and son.get("role") == "user"):
|
||||
return 0 # sikistirma istegi, arac sonucu turu vb.: yeni kullanici mesajini bekle
|
||||
metin = kayit["metin"]
|
||||
ham = json.dumps(girdi, ensure_ascii=False)
|
||||
if json.dumps(metin[:200], ensure_ascii=False)[1:-1] in ham:
|
||||
with _KILIT: # Codex bu metni gecmise kendisi yazmis: ikinci kez eklenmez
|
||||
_KAYIT.pop(anahtar, None)
|
||||
return 0
|
||||
notu = (f"{NOT_BASI} Kesilmeden önce yazdığın metin aşağıda. Kullanıcı devam etmeni isterse baştan yazma, "
|
||||
f"kaldığın yerden sürdür.]\n<yarim_cevap model=\"{kayit['model']}\">\n{_kisalt(metin)}\n</yarim_cevap>")
|
||||
icerik = son.get("content")
|
||||
if isinstance(icerik, str):
|
||||
son["content"] = [{"type": "input_text", "text": icerik}, {"type": "input_text", "text": notu}]
|
||||
elif isinstance(icerik, list):
|
||||
icerik.append({"type": "input_text", "text": notu})
|
||||
else:
|
||||
return 0
|
||||
with _KILIT:
|
||||
_KAYIT.pop(anahtar, None)
|
||||
return len(notu)
|
||||
Binary file not shown.
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"product": "Microsoft Visual C++ 2015-2022 Redistributable (VC143 CRT)",
|
||||
"version": "14.44.35211.0",
|
||||
"architecture": "x64",
|
||||
"publisher": "Microsoft Corporation (Authenticode doğrulandı)",
|
||||
"files": [
|
||||
{
|
||||
"name": "msvcp140.dll",
|
||||
"size": 557728,
|
||||
"sha256": "0f885b509a685d2bbfa652fed26b5fb31d88fbdab0a978c641d1c7b8aa460aa9"
|
||||
},
|
||||
{
|
||||
"name": "msvcp140_1.dll",
|
||||
"size": 35952,
|
||||
"sha256": "bfad5aef4c63a669e3c140655cdfdf395b6c979b400a447bd5dcb65ed8826c3d"
|
||||
},
|
||||
{
|
||||
"name": "msvcp140_2.dll",
|
||||
"size": 280200,
|
||||
"sha256": "3ea06f0ee098b4823cb79599df3780e7f23cce52c19aac31d2a0d47efe33a5e9"
|
||||
},
|
||||
{
|
||||
"name": "msvcp140_atomic_wait.dll",
|
||||
"size": 50304,
|
||||
"sha256": "640b2aefced484d0368eea5bdd06addd0658a3a70a49256e560d6923b404a479"
|
||||
},
|
||||
{
|
||||
"name": "msvcp140_codecvt_ids.dll",
|
||||
"size": 31872,
|
||||
"sha256": "f2069a52880ec885ee7f0511186100eb7fada0411a2b4948fafea7735b878a18"
|
||||
},
|
||||
{
|
||||
"name": "concrt140.dll",
|
||||
"size": 324208,
|
||||
"sha256": "2405355f0a58067b258f8df33c327e3a3d716eaac5a3a5aebb757842d85bd376"
|
||||
},
|
||||
{
|
||||
"name": "vcruntime140.dll",
|
||||
"size": 124544,
|
||||
"sha256": "d5e4d9a3e835fa679450145d6a7d94e36573a509317111904d9b3712c30d9066",
|
||||
"ownedByPython": true
|
||||
},
|
||||
{
|
||||
"name": "vcruntime140_1.dll",
|
||||
"size": 49792,
|
||||
"sha256": "1f2d41c4aa5db0bc33ebf7b66d72943a817d7ce6cbe880502a9403823633093f",
|
||||
"ownedByPython": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,32 @@
|
||||
# Mobile connection repair — beta.19
|
||||
|
||||
The paired phone previously resolved the desktop's active profile. If that
|
||||
profile's startup promise was waiting for first-run setup, every phone API
|
||||
request waited too, even while the default backend was already serving.
|
||||
|
||||
REST and WebSocket requests now resolve the phone's explicit `profile` query
|
||||
(default when absent). Aggregate `profile=all` reaches the default process
|
||||
without rewriting the downstream query. Invalid profile identifiers, remote
|
||||
backends and a missing local runtime are refused; phone requests do not install
|
||||
software or silently fall back to a different profile. Resolution has an eight
|
||||
second deadline; this is not an agent, tool or scheduled-job execution deadline.
|
||||
|
||||
Shared-runtime completion wakes only a waiting setup for the same runtime root.
|
||||
Primary startup then resolves its own profile again. This does not grant
|
||||
installation consent or substitute a sibling profile's backend connection.
|
||||
|
||||
## Evidence
|
||||
|
||||
- 94 mobile transport, certificate, relay, profile and startup regression tests.
|
||||
- Electron TypeScript type check.
|
||||
- Opt-in `mobile-live.test.ts`, with `AITURK_TEST_BACKEND_PORT`: real local
|
||||
backend behind the production TLS proxy, pinned certificate, screen reads,
|
||||
cron create/update/pause/resume/delete and chat WebSocket ping. Test-created
|
||||
job is deleted; existing jobs are untouched. This test does not trigger an
|
||||
agent job or claim physical-phone/cellular acceptance.
|
||||
- Public relay hello was separately exercised using Android's production
|
||||
OkHttp/Conscrypt stack. A successful hello alone does not prove API readiness.
|
||||
|
||||
Publication, installed beta.19 and physical-phone acceptance are separate gates.
|
||||
Existing phone security exclusions are unchanged. Unsupported methods and
|
||||
features are not claimed to be implemented by this connection repair.
|
||||
@@ -0,0 +1,656 @@
|
||||
/**
|
||||
* Tests for electron/aiturk-login.ts — the one-click TurkServis sign-in.
|
||||
* Everything is injected (fake fetch, fake backend, fake CLI, fake clock), so
|
||||
* no network, browser or real CLI is touched. The fixture "keys" below are
|
||||
* obviously fake placeholders in the right shape.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
AITURK_CLI_KEY_HOLD_MS,
|
||||
AITURK_LOGIN_START_RETRY_DELAY_MS,
|
||||
type AiturkCliExit,
|
||||
AiturkLoginError,
|
||||
createAiturkCliRunner,
|
||||
createAiturkLogin,
|
||||
resolveAiturkCliPath
|
||||
} from './aiturk-login'
|
||||
|
||||
const DEVICE_CODE = `tsdl_${'d'.repeat(43)}`
|
||||
const IDE_KEY = 'tsk_FAKE-ide-key-for-tests'
|
||||
const CLI_KEY = 'tsk_FAKE-cli-key-for-tests'
|
||||
const URI = 'https://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e'
|
||||
|
||||
const startBody = (over: Record<string, unknown> = {}) => ({
|
||||
loginId: '0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||
deviceCode: DEVICE_CODE,
|
||||
userCode: 'ABCD2345',
|
||||
expiresIn: 600,
|
||||
interval: 3,
|
||||
verificationUri: URI,
|
||||
...over
|
||||
})
|
||||
|
||||
const approved = { state: 'approved', key: IDE_KEY, cliKey: CLI_KEY, account: 'acct_test' }
|
||||
|
||||
function harness(
|
||||
options: {
|
||||
start?: { status: number; body?: unknown }
|
||||
/** Thrown by successive `device-auth/start` calls before any answer is given. */
|
||||
startErrors?: Error[]
|
||||
polls?: ({ status: number; body?: unknown } | Error)[]
|
||||
cliExits?: AiturkCliExit[]
|
||||
validate?: unknown
|
||||
save?: unknown
|
||||
openExternal?: () => boolean
|
||||
} = {}
|
||||
) {
|
||||
const calls: { url: string; init: any }[] = []
|
||||
const polls = [...(options.polls ?? [])]
|
||||
const startErrors = [...(options.startErrors ?? [])]
|
||||
const cliExits = [...(options.cliExits ?? [0])]
|
||||
const clock = { now: 1_000_000 }
|
||||
const timers: { fn: () => void; ms: number; cleared: boolean }[] = []
|
||||
|
||||
const fetch = vi.fn(async (url: string, init: any) => {
|
||||
calls.push({ url, init })
|
||||
|
||||
if (url.endsWith('/start')) {
|
||||
const failure = startErrors.shift()
|
||||
|
||||
if (failure) {
|
||||
throw failure
|
||||
}
|
||||
|
||||
const r = options.start ?? { status: 201, body: startBody() }
|
||||
|
||||
return { status: r.status, json: async () => r.body }
|
||||
}
|
||||
|
||||
if (url.endsWith('/poll')) {
|
||||
const r = polls.shift() ?? { status: 200, body: { state: 'pending' } }
|
||||
|
||||
if (r instanceof Error) {
|
||||
throw r
|
||||
}
|
||||
|
||||
return { status: r.status, json: async () => r.body }
|
||||
}
|
||||
|
||||
return { status: 200, json: async () => ({}) }
|
||||
})
|
||||
|
||||
const backendRequest = vi.fn(async (request: any) => {
|
||||
if (request.path.endsWith('/validate')) {
|
||||
return options.validate ?? { ok: true, reachable: true, models: ['account/model-a', 'account/model-b'], message: '' }
|
||||
}
|
||||
|
||||
return options.save ?? { ok: true, id: 'turkservis', endpoints: [] }
|
||||
})
|
||||
|
||||
const runCli = vi.fn(async (_key: string, _options: { keepOtherAccount: boolean }) =>
|
||||
cliExits.length ? (cliExits.shift() as AiturkCliExit) : 0
|
||||
)
|
||||
|
||||
const openExternal = vi.fn(options.openExternal ?? (() => true))
|
||||
|
||||
const login = createAiturkLogin({
|
||||
fetch,
|
||||
openExternal,
|
||||
hostname: () => 'TEST-PC',
|
||||
backendRequest,
|
||||
runCli,
|
||||
now: () => clock.now,
|
||||
setTimer: (fn, ms) => {
|
||||
const timer = { fn, ms, cleared: false }
|
||||
timers.push(timer)
|
||||
|
||||
return timer
|
||||
},
|
||||
clearTimer: handle => {
|
||||
;(handle as { cleared: boolean }).cleared = true
|
||||
},
|
||||
timeoutSignal: () => undefined
|
||||
})
|
||||
|
||||
return { login, fetch, calls, backendRequest, runCli, openExternal, clock, timers }
|
||||
}
|
||||
|
||||
const leaks = (value: unknown) => /tsk_|tsdl_/.test(typeof value === 'string' ? value : JSON.stringify(value) ?? '')
|
||||
|
||||
describe('aiturk login: start', () => {
|
||||
it('asks the server for a code, opens the approval page and returns no secret', async () => {
|
||||
const h = harness()
|
||||
const started = await h.login.start({ profile: 'work' })
|
||||
|
||||
expect(started).toEqual({ userCode: 'ABCD2345', verificationUri: URI, expiresAt: 1_000_000 + 600_000 })
|
||||
expect(leaks(started)).toBe(false)
|
||||
expect(h.openExternal).toHaveBeenCalledExactlyOnceWith(URI)
|
||||
|
||||
const { url, init } = h.calls[0]
|
||||
expect(url).toBe('https://turkservis.online/v1/aiturk/device-auth/start')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.redirect).toBe('error')
|
||||
expect(JSON.parse(init.body)).toEqual({ client: 'ide', device_name: 'TEST-PC', also_cli: true })
|
||||
expect(init.headers.Authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://turkservis.online.evil.example/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||
'http://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||
'https://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e?next=//evil',
|
||||
'https://turkservis.online/account/aiturk/device-login/../../x',
|
||||
'https://evil.example/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||
''
|
||||
])('refuses a verification URL that is not exactly the approval page: %s', async verificationUri => {
|
||||
const h = harness({ start: { status: 201, body: startBody({ verificationUri }) } })
|
||||
|
||||
const error = await h.login.start().catch(e => e)
|
||||
expect(error).toBeInstanceOf(AiturkLoginError)
|
||||
expect(error.message).toBe('aiturk-login:invalid-response')
|
||||
expect(leaks(error.message)).toBe(false)
|
||||
expect(h.openExternal).not.toHaveBeenCalled()
|
||||
// The issued code is revoked instead of being left approvable.
|
||||
expect(h.calls.at(-1)?.url).toBe('https://turkservis.online/v1/aiturk/device-auth/cancel')
|
||||
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
||||
})
|
||||
|
||||
it('maps 429 to rate-limited and other failures to unavailable', async () => {
|
||||
await expect(harness({ start: { status: 429 } }).login.start()).rejects.toThrow('aiturk-login:rate-limited')
|
||||
await expect(harness({ start: { status: 422 } }).login.start()).rejects.toThrow('aiturk-login:unavailable')
|
||||
await expect(harness({ start: { status: 500 } }).login.start()).rejects.toThrow('aiturk-login:unavailable')
|
||||
})
|
||||
|
||||
it('tries start once more after a thrown network error (first request of a long-idle process)', async () => {
|
||||
const h = harness({ startErrors: [new TypeError('fetch failed')] })
|
||||
const pending = h.login.start()
|
||||
|
||||
await vi.waitFor(() => expect(h.timers.some(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toBe(true))
|
||||
expect(AITURK_LOGIN_START_RETRY_DELAY_MS).toBe(500)
|
||||
expect(h.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
|
||||
expect(h.openExternal).not.toHaveBeenCalled()
|
||||
h.timers.find(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)!.fn()
|
||||
|
||||
expect(await pending).toEqual({ userCode: 'ABCD2345', verificationUri: URI, expiresAt: 1_000_000 + 600_000 })
|
||||
expect(h.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(2)
|
||||
// One code only: the approval page is opened once.
|
||||
expect(h.openExternal).toHaveBeenCalledExactlyOnceWith(URI)
|
||||
})
|
||||
|
||||
it('gives up after the second network error, and never retries an HTTP answer or a timeout', async () => {
|
||||
const twice = harness({ startErrors: [new TypeError('fetch failed'), new TypeError('fetch failed')] })
|
||||
const failing = twice.login.start().catch(e => e)
|
||||
|
||||
await vi.waitFor(() => expect(twice.timers.some(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toBe(true))
|
||||
twice.timers.find(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)!.fn()
|
||||
expect((await failing).message).toBe('aiturk-login:unavailable')
|
||||
expect(twice.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(2)
|
||||
expect(twice.timers.filter(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toHaveLength(1)
|
||||
|
||||
for (const status of [500, 503, 429, 422]) {
|
||||
const answered = harness({ start: { status } })
|
||||
|
||||
await expect(answered.login.start()).rejects.toThrow(/^aiturk-login:(?:unavailable|rate-limited)$/)
|
||||
expect(answered.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
|
||||
expect(answered.timers).toHaveLength(0)
|
||||
}
|
||||
|
||||
const timedOut = harness({ startErrors: [new DOMException('The operation was aborted due to timeout', 'TimeoutError')] })
|
||||
|
||||
await expect(timedOut.login.start()).rejects.toThrow('aiturk-login:unavailable')
|
||||
expect(timedOut.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
|
||||
expect(timedOut.timers).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects malformed codes', async () => {
|
||||
await expect(
|
||||
harness({ start: { status: 201, body: startBody({ userCode: 'abcd2345' }) } }).login.start()
|
||||
).rejects.toThrow('aiturk-login:invalid-response')
|
||||
await expect(
|
||||
harness({ start: { status: 201, body: startBody({ deviceCode: 'nope' }) } }).login.start()
|
||||
).rejects.toThrow('aiturk-login:invalid-response')
|
||||
})
|
||||
|
||||
it('keeps a single login: a second start revokes the first code', async () => {
|
||||
const h = harness()
|
||||
await h.login.start()
|
||||
await h.login.start()
|
||||
|
||||
const urls = h.calls.map(c => c.url.split('/').pop())
|
||||
expect(urls).toEqual(['start', 'cancel', 'start'])
|
||||
expect(h.calls[1].init.headers.Authorization).toBe(`Bearer ${DEVICE_CODE}`)
|
||||
})
|
||||
|
||||
it('cancels the code when the browser cannot be opened', async () => {
|
||||
const h = harness({ openExternal: () => false })
|
||||
|
||||
await expect(h.login.start()).rejects.toThrow('aiturk-login:browser-failed')
|
||||
expect(h.calls.at(-1)?.url).toMatch(/\/cancel$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiturk login: poll', () => {
|
||||
it('reports pending while the user has not approved', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: { state: 'pending' } }] })
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'pending' })
|
||||
expect(h.calls[1].url).toBe('https://turkservis.online/v1/aiturk/device-auth/poll')
|
||||
expect(h.calls[1].init.headers.Authorization).toBe(`Bearer ${DEVICE_CODE}`)
|
||||
expect(h.calls[1].init.redirect).toBe('error')
|
||||
expect(h.backendRequest).not.toHaveBeenCalled()
|
||||
expect(h.runCli).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps waiting through a transient network error', async () => {
|
||||
const h = harness({ polls: [new Error(`boom ${DEVICE_CODE}`), { status: 503 }] })
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'pending' })
|
||||
expect(await h.login.poll()).toEqual({ state: 'pending' })
|
||||
})
|
||||
|
||||
it('reports expired on 401 and stops asking the server', async () => {
|
||||
const h = harness({ polls: [{ status: 401, body: { detail: 'expired' } }] })
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
||||
const count = h.fetch.mock.calls.length
|
||||
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
||||
expect(h.fetch.mock.calls.length).toBe(count)
|
||||
})
|
||||
|
||||
it('expires by the clock without a request', async () => {
|
||||
const h = harness()
|
||||
await h.login.start()
|
||||
h.clock.now += 600_000
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
||||
expect(h.calls.filter(c => c.url.endsWith('/poll'))).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('on approval saves the provider and pipes ONLY the CLI key to the CLI', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||
await h.login.start({ profile: 'work', connectionId: 'conn-1' })
|
||||
|
||||
const result = await h.login.poll()
|
||||
expect(result).toEqual({ state: 'connected', cli: 'ok' })
|
||||
expect(leaks(result)).toBe(false)
|
||||
|
||||
expect(h.backendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(h.backendRequest.mock.calls[0][0]).toEqual({
|
||||
connectionId: 'conn-1',
|
||||
path: '/api/providers/custom-endpoints/validate',
|
||||
method: 'POST',
|
||||
body: {
|
||||
id: 'turkservis',
|
||||
name: 'TurkServis · AITURK',
|
||||
base_url: 'https://ai.turkservis.online/v1',
|
||||
api_key: IDE_KEY,
|
||||
model: '',
|
||||
discover_models: true,
|
||||
make_default: true
|
||||
}
|
||||
})
|
||||
expect(h.backendRequest.mock.calls[1][0]).toEqual({
|
||||
profile: 'work',
|
||||
connectionId: 'conn-1',
|
||||
path: '/api/providers/custom-endpoints',
|
||||
method: 'POST',
|
||||
body: {
|
||||
id: 'turkservis',
|
||||
name: 'TurkServis · AITURK',
|
||||
base_url: 'https://ai.turkservis.online/v1',
|
||||
api_key: IDE_KEY,
|
||||
model: 'account/model-a',
|
||||
models: ['account/model-a', 'account/model-b'],
|
||||
discover_models: true,
|
||||
make_default: true
|
||||
}
|
||||
})
|
||||
|
||||
expect(h.runCli).toHaveBeenCalledExactlyOnceWith(CLI_KEY, { keepOtherAccount: true })
|
||||
// No key ever rides a URL.
|
||||
expect(h.calls.some(c => leaks(c.url))).toBe(false)
|
||||
})
|
||||
|
||||
it('does not ask for the one-time keys again on later polls', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||
await h.login.start()
|
||||
|
||||
const first = await h.login.poll()
|
||||
const fetches = h.fetch.mock.calls.length
|
||||
const second = await h.login.poll()
|
||||
|
||||
expect(second).toEqual(first)
|
||||
expect(h.fetch.mock.calls.length).toBe(fetches)
|
||||
expect(h.backendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(h.runCli).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shares one completion between overlapping polls', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||
await h.login.start()
|
||||
|
||||
const [a, b] = await Promise.all([h.login.poll(), h.login.poll()])
|
||||
expect(a).toEqual({ state: 'connected', cli: 'ok' })
|
||||
expect(b.state === 'connected' || b.state === 'pending').toBe(true)
|
||||
expect(h.runCli).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[0, 'ok'],
|
||||
[2, 'outdated'],
|
||||
[3, 'other-account'],
|
||||
[4, 'unknown-account'],
|
||||
[1, 'failed'],
|
||||
[null, 'failed'],
|
||||
['missing', 'missing']
|
||||
] as const)('maps CLI exit %s to cli=%s', async (exit, cli) => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }], cliExits: [exit] })
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'connected', cli })
|
||||
})
|
||||
|
||||
it('treats a throwing CLI runner as failed without leaking', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||
h.runCli.mockRejectedValueOnce(new Error(`spawn failed ${CLI_KEY}`))
|
||||
await h.login.start()
|
||||
|
||||
const result = await h.login.poll()
|
||||
expect(result).toEqual({ state: 'connected', cli: 'failed' })
|
||||
})
|
||||
|
||||
it('does not save or touch the CLI when the key is rejected by the backend', async () => {
|
||||
const h = harness({
|
||||
polls: [{ status: 200, body: approved }],
|
||||
validate: { ok: false, reachable: true, models: [], message: `bad key ${IDE_KEY}` }
|
||||
})
|
||||
|
||||
await h.login.start()
|
||||
|
||||
const result = await h.login.poll()
|
||||
expect(result).toEqual({ state: 'error', error: 'provider-failed' })
|
||||
expect(leaks(result)).toBe(false)
|
||||
expect(h.backendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(h.runCli).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not invent a model when the catalog is empty', async () => {
|
||||
const h = harness({
|
||||
polls: [{ status: 200, body: approved }],
|
||||
validate: { ok: true, reachable: true, models: [], message: '' }
|
||||
})
|
||||
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'error', error: 'no-models' })
|
||||
expect(h.backendRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('never surfaces backend error text (which could echo the key)', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||
h.backendRequest.mockRejectedValueOnce(new Error(`500: echoed ${IDE_KEY}`))
|
||||
await h.login.start()
|
||||
|
||||
const result = await h.login.poll()
|
||||
expect(result).toEqual({ state: 'error', error: 'provider-failed' })
|
||||
expect(leaks(result)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an approval whose key is malformed', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: { state: 'approved', key: 'not a key', cliKey: CLI_KEY } }] })
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.poll()).toEqual({ state: 'error', error: 'invalid-response' })
|
||||
expect(h.backendRequest).not.toHaveBeenCalled()
|
||||
expect(h.runCli).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiturk login: other CLI account', () => {
|
||||
async function otherAccount(overwriteExit: AiturkCliExit = 0) {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }], cliExits: [3, overwriteExit] })
|
||||
await h.login.start()
|
||||
expect(await h.login.poll()).toEqual({ state: 'connected', cli: 'other-account' })
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
it('overwrites only after the user confirms, without the keep flag', async () => {
|
||||
const h = await otherAccount()
|
||||
|
||||
const result = await h.login.overwriteCli()
|
||||
expect(result).toEqual({ cli: 'ok' })
|
||||
expect(leaks(result)).toBe(false)
|
||||
expect(h.runCli).toHaveBeenLastCalledWith(CLI_KEY, { keepOtherAccount: false })
|
||||
expect(await h.login.poll()).toEqual({ state: 'connected', cli: 'ok' })
|
||||
|
||||
// Single use: the held key is gone.
|
||||
await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite')
|
||||
expect(h.runCli).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reports an overwrite that still fails', async () => {
|
||||
const h = await otherAccount(4)
|
||||
|
||||
expect(await h.login.overwriteCli()).toEqual({ cli: 'unknown-account' })
|
||||
})
|
||||
|
||||
it('drops the held CLI key after five minutes', async () => {
|
||||
const h = await otherAccount()
|
||||
const hold = h.timers.find(t => t.ms === AITURK_CLI_KEY_HOLD_MS)
|
||||
expect(hold).toBeDefined()
|
||||
|
||||
h.clock.now += AITURK_CLI_KEY_HOLD_MS
|
||||
hold?.fn()
|
||||
|
||||
await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite')
|
||||
expect(h.runCli).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('refuses a stale held key even if the timer never fired', async () => {
|
||||
const h = await otherAccount()
|
||||
h.clock.now += AITURK_CLI_KEY_HOLD_MS
|
||||
|
||||
await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite')
|
||||
expect(h.runCli).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('drops the held CLI key on cancel', async () => {
|
||||
const h = await otherAccount()
|
||||
await h.login.cancel()
|
||||
|
||||
const error = await h.login.overwriteCli().catch(e => e)
|
||||
expect(error.message).toBe('aiturk-login:nothing-to-overwrite')
|
||||
expect(leaks(error.message)).toBe(false)
|
||||
expect(h.runCli).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('has nothing to overwrite when the CLI simply succeeded', async () => {
|
||||
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||
await h.login.start()
|
||||
await h.login.poll()
|
||||
|
||||
await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite')
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiturk login: cancel', () => {
|
||||
it('revokes the code on the server and clears the session', async () => {
|
||||
const h = harness()
|
||||
await h.login.start()
|
||||
|
||||
expect(await h.login.cancel()).toEqual({ ok: true })
|
||||
const last = h.calls.at(-1)
|
||||
expect(last?.url).toBe('https://turkservis.online/v1/aiturk/device-auth/cancel')
|
||||
expect(last?.init.headers.Authorization).toBe(`Bearer ${DEVICE_CODE}`)
|
||||
|
||||
const count = h.fetch.mock.calls.length
|
||||
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
||||
expect(h.fetch.mock.calls.length).toBe(count)
|
||||
})
|
||||
|
||||
it('is a no-op without a login and survives a failing server', async () => {
|
||||
const h = harness()
|
||||
expect(await h.login.cancel()).toEqual({ ok: true })
|
||||
expect(h.fetch).not.toHaveBeenCalled()
|
||||
|
||||
await h.login.start()
|
||||
h.fetch.mockRejectedValueOnce(new Error('offline'))
|
||||
expect(await h.login.cancel()).toEqual({ ok: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiturk CLI runner', () => {
|
||||
const CLI_PATH = 'C:\\Users\\Test User\\AppData\\Local\\TurkServis\\AITURK\\program\\bin\\aiturk.cmd'
|
||||
const env = { LOCALAPPDATA: 'C:\\Users\\Test User\\AppData\\Local', SystemRoot: 'C:\\Windows' }
|
||||
|
||||
function fakeSpawn(behaviour: (child: any) => void = child => child.emit('close', 0)) {
|
||||
const state: any = { calls: [], stdin: [] as string[], killed: false }
|
||||
|
||||
const spawn = vi.fn((command: string, args: string[], options: any) => {
|
||||
const child: any = new EventEmitter()
|
||||
child.stdin = new EventEmitter()
|
||||
|
||||
child.stdin.end = (chunk: string) => {
|
||||
state.stdin.push(chunk)
|
||||
queueMicrotask(() => behaviour(child))
|
||||
}
|
||||
|
||||
child.kill = () => {
|
||||
state.killed = true
|
||||
}
|
||||
|
||||
state.calls.push({ command, args, options })
|
||||
|
||||
return child
|
||||
})
|
||||
|
||||
return { spawn, state }
|
||||
}
|
||||
|
||||
it('finds the CLI only at the fixed install path', () => {
|
||||
expect(resolveAiturkCliPath({ env, existsSync: p => p === CLI_PATH, platform: 'win32' })).toBe(CLI_PATH)
|
||||
expect(resolveAiturkCliPath({ env, existsSync: () => false, platform: 'win32' })).toBeNull()
|
||||
expect(resolveAiturkCliPath({ env: {}, existsSync: () => true, platform: 'win32' })).toBeNull()
|
||||
expect(resolveAiturkCliPath({ env: { LOCALAPPDATA: 'relative' }, existsSync: () => true, platform: 'win32' })).toBeNull()
|
||||
expect(resolveAiturkCliPath({ env, existsSync: () => true, platform: 'linux' })).toBeNull()
|
||||
})
|
||||
|
||||
it('reports missing without spawning anything', async () => {
|
||||
const { spawn } = fakeSpawn()
|
||||
const run = createAiturkCliRunner({ spawn, existsSync: () => false, env, platform: 'win32' })
|
||||
|
||||
expect(await run(CLI_KEY, { keepOtherAccount: true })).toBe('missing')
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends the key on stdin only, through an absolute cmd.exe, without a shell', async () => {
|
||||
const { spawn, state } = fakeSpawn()
|
||||
const run = createAiturkCliRunner({ spawn, existsSync: () => true, env, platform: 'win32' })
|
||||
|
||||
expect(await run(CLI_KEY, { keepOtherAccount: true })).toBe(0)
|
||||
|
||||
const { command, args, options } = state.calls[0]
|
||||
expect(command).toBe('C:\\Windows\\System32\\cmd.exe')
|
||||
expect(args).toEqual(['/d', '/s', '/c', `""${CLI_PATH}" login --key-stdin --keep-other-account"`])
|
||||
expect(options).toEqual({
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
windowsVerbatimArguments: true,
|
||||
stdio: ['pipe', 'ignore', 'ignore']
|
||||
})
|
||||
expect(leaks([command, args, options])).toBe(false)
|
||||
expect(state.stdin).toEqual([`${CLI_KEY}\n`])
|
||||
})
|
||||
|
||||
it('drops the keep flag for a confirmed overwrite', async () => {
|
||||
const { spawn, state } = fakeSpawn()
|
||||
const run = createAiturkCliRunner({ spawn, existsSync: () => true, env, platform: 'win32' })
|
||||
|
||||
await run(CLI_KEY, { keepOtherAccount: false })
|
||||
expect(state.calls[0].args[3]).toBe(`""${CLI_PATH}" login --key-stdin"`)
|
||||
})
|
||||
|
||||
it.each([0, 1, 2, 3, 4])('returns exit code %s', async code => {
|
||||
const { spawn } = fakeSpawn(child => child.emit('close', code))
|
||||
const run = createAiturkCliRunner({ spawn, existsSync: () => true, env, platform: 'win32' })
|
||||
|
||||
expect(await run(CLI_KEY, { keepOtherAccount: true })).toBe(code)
|
||||
})
|
||||
|
||||
it('returns null on spawn error, thrown spawn and stdin EPIPE', async () => {
|
||||
const errored = fakeSpawn(child => child.emit('error', new Error('ENOENT')))
|
||||
expect(
|
||||
await createAiturkCliRunner({ spawn: errored.spawn, existsSync: () => true, env, platform: 'win32' })(CLI_KEY, {
|
||||
keepOtherAccount: true
|
||||
})
|
||||
).toBeNull()
|
||||
|
||||
const thrower = vi.fn(() => {
|
||||
throw new Error('spawn EINVAL')
|
||||
})
|
||||
|
||||
expect(
|
||||
await createAiturkCliRunner({ spawn: thrower, existsSync: () => true, env, platform: 'win32' })(CLI_KEY, {
|
||||
keepOtherAccount: true
|
||||
})
|
||||
).toBeNull()
|
||||
|
||||
const epipe = fakeSpawn(child => {
|
||||
child.stdin.emit('error', new Error('EPIPE'))
|
||||
child.emit('close', 2)
|
||||
})
|
||||
|
||||
expect(
|
||||
await createAiturkCliRunner({ spawn: epipe.spawn, existsSync: () => true, env, platform: 'win32' })(CLI_KEY, {
|
||||
keepOtherAccount: true
|
||||
})
|
||||
).toBe(2)
|
||||
})
|
||||
|
||||
it('kills a CLI that hangs past the timeout', async () => {
|
||||
const { spawn, state } = fakeSpawn(() => {})
|
||||
|
||||
let fire = () => {}
|
||||
|
||||
const run = createAiturkCliRunner({
|
||||
spawn,
|
||||
existsSync: () => true,
|
||||
env,
|
||||
platform: 'win32',
|
||||
setTimer: (fn, ms) => {
|
||||
expect(ms).toBe(60_000)
|
||||
fire = fn
|
||||
|
||||
return 1
|
||||
},
|
||||
clearTimer: () => {}
|
||||
})
|
||||
|
||||
const pending = run(CLI_KEY, { keepOtherAccount: true })
|
||||
fire()
|
||||
|
||||
expect(await pending).toBeNull()
|
||||
expect(state.killed).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses an install path cmd.exe could reinterpret', async () => {
|
||||
const { spawn } = fakeSpawn()
|
||||
|
||||
const run = createAiturkCliRunner({
|
||||
spawn,
|
||||
existsSync: () => true,
|
||||
env: { LOCALAPPDATA: 'C:\\Users\\a%PATH%b\\AppData\\Local', SystemRoot: 'C:\\Windows' },
|
||||
platform: 'win32'
|
||||
})
|
||||
|
||||
expect(await run(CLI_KEY, { keepOtherAccount: true })).toBeNull()
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,668 @@
|
||||
/**
|
||||
* aiturk-login.ts
|
||||
*
|
||||
* One-click TurkServis sign-in for AITURK IDE (device-code flow).
|
||||
*
|
||||
* The IDE shows a short code, the browser opens the turkservis.online approval
|
||||
* page, and once the user approves, the server hands out two API keys exactly
|
||||
* once: one for the IDE's TurkServis provider and one for the AITURK CLI.
|
||||
*
|
||||
* Security contract (do not relax):
|
||||
* - Keys (`tsk_…`) and the device code (`tsdl_…`) never leave the main
|
||||
* process: they are not returned to the renderer, not logged, not put into
|
||||
* error messages, process arguments or URLs.
|
||||
* - The CLI receives its key on stdin only, is launched from an absolute
|
||||
* path without a shell, with a fixed argument list.
|
||||
* - Network calls go only to https://turkservis.online, refuse redirects and
|
||||
* time out after 20 s.
|
||||
*
|
||||
* All I/O is injected so the logic is unit-testable without Electron, a
|
||||
* network or a real CLI (see aiturk-login.test.ts).
|
||||
*/
|
||||
|
||||
export const AITURK_LOGIN_ORIGIN = 'https://turkservis.online'
|
||||
export const AITURK_LOGIN_FETCH_TIMEOUT_MS = 20_000
|
||||
/** Wait before the single second try of `device-auth/start` after a thrown network error. */
|
||||
export const AITURK_LOGIN_START_RETRY_DELAY_MS = 500
|
||||
export const AITURK_CLI_TIMEOUT_MS = 60_000
|
||||
export const AITURK_CLI_KEY_HOLD_MS = 5 * 60_000
|
||||
|
||||
const AITURK_PROVIDER_ID = 'turkservis'
|
||||
const AITURK_PROVIDER_NAME = 'TurkServis · AITURK'
|
||||
const AITURK_PROVIDER_BASE_URL = 'https://ai.turkservis.online/v1'
|
||||
|
||||
const VERIFICATION_URI_RE = /^https:\/\/turkservis\.online\/account\/aiturk\/device-login\/[0-9a-f-]{36}$/
|
||||
const DEVICE_CODE_RE = /^tsdl_[A-Za-z0-9_-]{43}$/
|
||||
const USER_CODE_RE = /^[A-HJ-NP-Z2-9]{8}$/
|
||||
const API_KEY_RE = /^tsk_[A-Za-z0-9._-]{8,500}$/
|
||||
const MAX_EXPIRES_IN_S = 3600
|
||||
|
||||
export type AiturkCliResult = 'ok' | 'missing' | 'outdated' | 'other-account' | 'unknown-account' | 'failed'
|
||||
|
||||
export type AiturkLoginErrorCode =
|
||||
| 'busy'
|
||||
| 'rate-limited'
|
||||
| 'unavailable'
|
||||
| 'invalid-response'
|
||||
| 'browser-failed'
|
||||
| 'provider-failed'
|
||||
| 'no-models'
|
||||
| 'nothing-to-overwrite'
|
||||
|
||||
/** Error whose message is ONLY a fixed code — never upstream text, so a key
|
||||
* echoed by a server or backend cannot ride an error into the renderer/log. */
|
||||
export class AiturkLoginError extends Error {
|
||||
code: AiturkLoginErrorCode
|
||||
|
||||
constructor(code: AiturkLoginErrorCode) {
|
||||
super(`aiturk-login:${code}`)
|
||||
this.name = 'AiturkLoginError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export interface AiturkLoginScope {
|
||||
profile?: null | string
|
||||
connectionId?: null | string
|
||||
}
|
||||
|
||||
export interface AiturkLoginStartResult {
|
||||
userCode: string
|
||||
verificationUri: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
export type AiturkLoginPollResult =
|
||||
| { state: 'pending' }
|
||||
| { state: 'expired' }
|
||||
| { state: 'connected'; cli: AiturkCliResult }
|
||||
| { state: 'error'; error: AiturkLoginErrorCode }
|
||||
|
||||
export interface AiturkFetchResponse {
|
||||
status: number
|
||||
json: () => Promise<unknown>
|
||||
}
|
||||
|
||||
export type AiturkFetch = (
|
||||
url: string,
|
||||
init: { method: string; headers: Record<string, string>; body?: string; redirect: 'error'; signal?: AbortSignal }
|
||||
) => Promise<AiturkFetchResponse>
|
||||
|
||||
export interface AiturkBackendRequest {
|
||||
path: string
|
||||
method: string
|
||||
body: unknown
|
||||
profile?: string
|
||||
connectionId?: string
|
||||
}
|
||||
|
||||
/** Outcome of one CLI launch: 'missing' when the CLI is not installed, the
|
||||
* numeric exit code otherwise, null when it could not run / timed out. */
|
||||
export type AiturkCliExit = 'missing' | null | number
|
||||
|
||||
export interface AiturkLoginDeps {
|
||||
fetch: AiturkFetch
|
||||
/** Open a URL in the system browser. `false` means it could not be opened. */
|
||||
openExternal: (url: string) => boolean | Promise<boolean | void> | void
|
||||
hostname: () => string
|
||||
/** Same route the renderer's hermesApi uses (hermes:api handler internals). */
|
||||
backendRequest: (request: AiturkBackendRequest) => Promise<unknown>
|
||||
/** Run `aiturk login --key-stdin [--keep-other-account]`, key on stdin. */
|
||||
runCli: (cliKey: string, options: { keepOtherAccount: boolean }) => Promise<AiturkCliExit>
|
||||
now: () => number
|
||||
setTimer?: (fn: () => void, ms: number) => unknown
|
||||
clearTimer?: (handle: unknown) => void
|
||||
timeoutSignal?: (ms: number) => AbortSignal | undefined
|
||||
}
|
||||
|
||||
interface Session {
|
||||
deviceCode: string
|
||||
userCode: string
|
||||
verificationUri: string
|
||||
expiresAt: number
|
||||
scope: { profile?: string; connectionId?: string }
|
||||
}
|
||||
|
||||
function deviceName(raw: unknown): string {
|
||||
// Printable, 1–60 chars. A hostname is not a secret, but keep it tidy.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const cleaned = String(raw ?? '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 60)
|
||||
|
||||
return cleaned || 'AITURK IDE'
|
||||
}
|
||||
|
||||
function cleanScope(scope: unknown): { profile?: string; connectionId?: string } {
|
||||
const source = (scope && typeof scope === 'object' ? scope : {}) as AiturkLoginScope
|
||||
const profile = typeof source.profile === 'string' ? source.profile.trim() : ''
|
||||
const connectionId = typeof source.connectionId === 'string' ? source.connectionId.trim() : ''
|
||||
|
||||
return {
|
||||
...(profile ? { profile } : {}),
|
||||
...(connectionId ? { connectionId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function cliResultFromExit(exit: AiturkCliExit, keepOtherAccount: boolean): AiturkCliResult {
|
||||
if (exit === 'missing') {
|
||||
return 'missing'
|
||||
}
|
||||
|
||||
if (exit === 0) {
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
if (exit === 2) {
|
||||
return 'outdated'
|
||||
}
|
||||
|
||||
if (exit === 3 && keepOtherAccount) {
|
||||
return 'other-account'
|
||||
}
|
||||
|
||||
if (exit === 4) {
|
||||
return 'unknown-account'
|
||||
}
|
||||
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
export function createAiturkLogin(deps: AiturkLoginDeps) {
|
||||
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
|
||||
const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as ReturnType<typeof setTimeout>))
|
||||
|
||||
const timeoutSignal =
|
||||
deps.timeoutSignal ?? (ms => (typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(ms) : undefined))
|
||||
|
||||
let session: null | Session = null
|
||||
// In-flight "approved → save provider → hand key to CLI" work. Concurrent
|
||||
// polls share it so the one-time key is fetched and consumed exactly once.
|
||||
let finishing: null | Promise<AiturkLoginPollResult> = null
|
||||
// Final outcome of the last login, replayed to later polls without a fetch.
|
||||
let settled: null | AiturkLoginPollResult = null
|
||||
// CLI key kept ONLY while the user decides whether to replace another
|
||||
// account's CLI login. Main-process memory, dropped after 5 minutes.
|
||||
let heldCli: null | { key: string; expiresAt: number; timer: unknown } = null
|
||||
|
||||
function dropHeldCli() {
|
||||
if (heldCli) {
|
||||
clearTimer(heldCli.timer)
|
||||
heldCli = null
|
||||
}
|
||||
}
|
||||
|
||||
function holdCli(key: string) {
|
||||
dropHeldCli()
|
||||
|
||||
const timer = setTimer(() => {
|
||||
heldCli = null
|
||||
}, AITURK_CLI_KEY_HOLD_MS)
|
||||
|
||||
// Never keep the app alive just for this timer.
|
||||
;(timer as { unref?: () => void } | null)?.unref?.()
|
||||
heldCli = { key, expiresAt: deps.now() + AITURK_CLI_KEY_HOLD_MS, timer }
|
||||
}
|
||||
|
||||
async function post(path: string, bearer: null | string, body?: unknown): Promise<AiturkFetchResponse> {
|
||||
return deps.fetch(`${AITURK_LOGIN_ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(bearer ? { Authorization: `Bearer ${bearer}` } : {})
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
redirect: 'error',
|
||||
signal: timeoutSignal(AITURK_LOGIN_FETCH_TIMEOUT_MS)
|
||||
})
|
||||
}
|
||||
|
||||
async function cancelOnServer(deviceCode: string) {
|
||||
try {
|
||||
await post('/v1/aiturk/device-auth/cancel', deviceCode)
|
||||
} catch {
|
||||
// Best effort: the code expires on its own within 10 minutes.
|
||||
}
|
||||
}
|
||||
|
||||
async function start(scope?: AiturkLoginScope): Promise<AiturkLoginStartResult> {
|
||||
if (finishing) {
|
||||
throw new AiturkLoginError('busy')
|
||||
}
|
||||
|
||||
// One login at a time: a new start replaces (and revokes) the previous one.
|
||||
const previous = session
|
||||
session = null
|
||||
settled = null
|
||||
dropHeldCli()
|
||||
|
||||
if (previous) {
|
||||
await cancelOnServer(previous.deviceCode)
|
||||
}
|
||||
|
||||
let response: AiturkFetchResponse | undefined
|
||||
|
||||
// A long-idle process can fail its first request on a dead pooled
|
||||
// connection. Only a THROWN network error (no HTTP answer, so no code was
|
||||
// handed to us) is tried once more; an HTTP answer is never retried, so
|
||||
// one start cannot knowingly leave two codes behind. A timeout already
|
||||
// used its whole budget and is not retried.
|
||||
for (let attempt = 1; response === undefined; attempt++) {
|
||||
try {
|
||||
response = await post('/v1/aiturk/device-auth/start', null, {
|
||||
client: 'ide',
|
||||
device_name: deviceName(deps.hostname()),
|
||||
also_cli: true
|
||||
})
|
||||
} catch (error) {
|
||||
const name = (error as null | { name?: unknown })?.name
|
||||
|
||||
if (attempt >= 2 || name === 'TimeoutError' || name === 'AbortError') {
|
||||
throw new AiturkLoginError('unavailable')
|
||||
}
|
||||
|
||||
await new Promise<void>(resolve => void setTimer(resolve, AITURK_LOGIN_START_RETRY_DELAY_MS))
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
throw new AiturkLoginError('rate-limited')
|
||||
}
|
||||
|
||||
if (response.status !== 201 && response.status !== 200) {
|
||||
throw new AiturkLoginError('unavailable')
|
||||
}
|
||||
|
||||
let data: any
|
||||
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch {
|
||||
throw new AiturkLoginError('invalid-response')
|
||||
}
|
||||
|
||||
const deviceCode = typeof data?.deviceCode === 'string' ? data.deviceCode : ''
|
||||
const userCode = typeof data?.userCode === 'string' ? data.userCode : ''
|
||||
const verificationUri = typeof data?.verificationUri === 'string' ? data.verificationUri : ''
|
||||
const expiresIn = Number(data?.expiresIn)
|
||||
const codeOk = DEVICE_CODE_RE.test(deviceCode)
|
||||
|
||||
if (
|
||||
!codeOk ||
|
||||
!USER_CODE_RE.test(userCode) ||
|
||||
!VERIFICATION_URI_RE.test(verificationUri) ||
|
||||
!Number.isFinite(expiresIn) ||
|
||||
expiresIn <= 0
|
||||
) {
|
||||
// Never open a URL that is not exactly our approval page.
|
||||
if (codeOk) {
|
||||
await cancelOnServer(deviceCode)
|
||||
}
|
||||
|
||||
throw new AiturkLoginError('invalid-response')
|
||||
}
|
||||
|
||||
if (session || finishing) {
|
||||
// A concurrent start won the race while we were waiting on the network.
|
||||
await cancelOnServer(deviceCode)
|
||||
|
||||
throw new AiturkLoginError('busy')
|
||||
}
|
||||
|
||||
const expiresAt = deps.now() + Math.min(expiresIn, MAX_EXPIRES_IN_S) * 1000
|
||||
session = { deviceCode, userCode, verificationUri, expiresAt, scope: cleanScope(scope) }
|
||||
|
||||
let opened: unknown
|
||||
|
||||
try {
|
||||
opened = await deps.openExternal(verificationUri)
|
||||
} catch {
|
||||
opened = false
|
||||
}
|
||||
|
||||
if (opened === false) {
|
||||
session = null
|
||||
await cancelOnServer(deviceCode)
|
||||
|
||||
throw new AiturkLoginError('browser-failed')
|
||||
}
|
||||
|
||||
return { userCode, verificationUri, expiresAt }
|
||||
}
|
||||
|
||||
async function saveProvider(apiKey: string, scope: Session['scope']) {
|
||||
const endpoint = {
|
||||
id: AITURK_PROVIDER_ID,
|
||||
name: AITURK_PROVIDER_NAME,
|
||||
base_url: AITURK_PROVIDER_BASE_URL,
|
||||
api_key: apiKey,
|
||||
discover_models: true,
|
||||
make_default: true
|
||||
}
|
||||
|
||||
let checked: any
|
||||
|
||||
try {
|
||||
// Mirrors the renderer's validateCustomEndpoint (no profile scope) and
|
||||
// saveCustomEndpoint (profile-scoped) calls.
|
||||
checked = await deps.backendRequest({
|
||||
...(scope.connectionId ? { connectionId: scope.connectionId } : {}),
|
||||
path: '/api/providers/custom-endpoints/validate',
|
||||
method: 'POST',
|
||||
body: { ...endpoint, model: '' }
|
||||
})
|
||||
} catch {
|
||||
throw new AiturkLoginError('provider-failed')
|
||||
}
|
||||
|
||||
if (!checked?.ok) {
|
||||
throw new AiturkLoginError('provider-failed')
|
||||
}
|
||||
|
||||
const models = Array.isArray(checked.models) ? checked.models.filter((m: unknown) => typeof m === 'string' && m) : []
|
||||
|
||||
if (!models.length) {
|
||||
throw new AiturkLoginError('no-models')
|
||||
}
|
||||
|
||||
let saved: any
|
||||
|
||||
try {
|
||||
saved = await deps.backendRequest({
|
||||
...scope,
|
||||
path: '/api/providers/custom-endpoints',
|
||||
method: 'POST',
|
||||
body: { ...endpoint, model: models[0], models }
|
||||
})
|
||||
} catch {
|
||||
throw new AiturkLoginError('provider-failed')
|
||||
}
|
||||
|
||||
if (saved?.ok === false) {
|
||||
throw new AiturkLoginError('provider-failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function handCliKey(cliKey: string, keepOtherAccount: boolean): Promise<AiturkCliResult> {
|
||||
let exit: AiturkCliExit
|
||||
|
||||
try {
|
||||
exit = await deps.runCli(cliKey, { keepOtherAccount })
|
||||
} catch {
|
||||
exit = null
|
||||
}
|
||||
|
||||
return cliResultFromExit(exit, keepOtherAccount)
|
||||
}
|
||||
|
||||
async function finish(key: string, cliKey: string, scope: Session['scope']): Promise<AiturkLoginPollResult> {
|
||||
try {
|
||||
await saveProvider(key, scope)
|
||||
} catch (error) {
|
||||
return { state: 'error', error: error instanceof AiturkLoginError ? error.code : 'provider-failed' }
|
||||
}
|
||||
|
||||
let cli: AiturkCliResult = 'failed'
|
||||
|
||||
if (cliKey) {
|
||||
cli = await handCliKey(cliKey, true)
|
||||
|
||||
if (cli === 'other-account') {
|
||||
holdCli(cliKey)
|
||||
}
|
||||
}
|
||||
|
||||
return { state: 'connected', cli }
|
||||
}
|
||||
|
||||
async function poll(): Promise<AiturkLoginPollResult> {
|
||||
if (finishing) {
|
||||
return finishing
|
||||
}
|
||||
|
||||
if (settled) {
|
||||
return settled
|
||||
}
|
||||
|
||||
const current = session
|
||||
|
||||
if (!current) {
|
||||
return { state: 'expired' }
|
||||
}
|
||||
|
||||
if (deps.now() >= current.expiresAt) {
|
||||
session = null
|
||||
|
||||
return { state: 'expired' }
|
||||
}
|
||||
|
||||
let response: AiturkFetchResponse
|
||||
|
||||
try {
|
||||
response = await post('/v1/aiturk/device-auth/poll', current.deviceCode)
|
||||
} catch {
|
||||
// Transient network trouble: keep waiting until the code expires.
|
||||
return { state: 'pending' }
|
||||
}
|
||||
|
||||
if (session !== current) {
|
||||
// Cancelled or replaced while the request was in flight.
|
||||
return settled ?? (finishing ? finishing : { state: 'expired' })
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
session = null
|
||||
|
||||
return { state: 'expired' }
|
||||
}
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { state: 'pending' }
|
||||
}
|
||||
|
||||
let data: any
|
||||
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch {
|
||||
return { state: 'pending' }
|
||||
}
|
||||
|
||||
if (session !== current) {
|
||||
return settled ?? (finishing ? finishing : { state: 'expired' })
|
||||
}
|
||||
|
||||
if (data?.state !== 'approved') {
|
||||
return { state: 'pending' }
|
||||
}
|
||||
|
||||
// The server returns the keys exactly once; the device code is spent.
|
||||
session = null
|
||||
|
||||
const key = typeof data.key === 'string' && API_KEY_RE.test(data.key) ? data.key : ''
|
||||
const cliKey = typeof data.cliKey === 'string' && API_KEY_RE.test(data.cliKey) ? data.cliKey : ''
|
||||
|
||||
if (!key) {
|
||||
settled = { state: 'error', error: 'invalid-response' }
|
||||
|
||||
return settled
|
||||
}
|
||||
|
||||
finishing = finish(key, cliKey, current.scope)
|
||||
.catch((): AiturkLoginPollResult => ({ state: 'error', error: 'provider-failed' }))
|
||||
.then(result => {
|
||||
settled = result
|
||||
finishing = null
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
return finishing
|
||||
}
|
||||
|
||||
async function cancel(): Promise<{ ok: true }> {
|
||||
const current = session
|
||||
session = null
|
||||
dropHeldCli()
|
||||
|
||||
if (!finishing) {
|
||||
settled = null
|
||||
}
|
||||
|
||||
if (current) {
|
||||
await cancelOnServer(current.deviceCode)
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/** User confirmed: replace the other account stored in the AITURK CLI. */
|
||||
async function overwriteCli(): Promise<{ cli: AiturkCliResult }> {
|
||||
const held = heldCli
|
||||
|
||||
if (!held || deps.now() >= held.expiresAt) {
|
||||
dropHeldCli()
|
||||
|
||||
throw new AiturkLoginError('nothing-to-overwrite')
|
||||
}
|
||||
|
||||
// Single use: drop it before launching so it cannot be replayed.
|
||||
dropHeldCli()
|
||||
|
||||
const cli = await handCliKey(held.key, false)
|
||||
|
||||
if (settled?.state === 'connected') {
|
||||
settled = { state: 'connected', cli }
|
||||
}
|
||||
|
||||
return { cli }
|
||||
}
|
||||
|
||||
return { start, poll, cancel, overwriteCli }
|
||||
}
|
||||
|
||||
// ── CLI runner ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AiturkCliRunnerDeps {
|
||||
/** child_process.spawn (injected for tests). */
|
||||
spawn: (command: string, args: string[], options: Record<string, unknown>) => any
|
||||
existsSync: (path: string) => boolean
|
||||
env: Record<string, string | undefined>
|
||||
platform?: string
|
||||
timeoutMs?: number
|
||||
setTimer?: (fn: () => void, ms: number) => unknown
|
||||
clearTimer?: (handle: unknown) => void
|
||||
}
|
||||
|
||||
// cmd.exe expands %VAR% and treats " specially even inside quotes; a path that
|
||||
// carries either (or a control char) is refused rather than risk reparsing.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const UNSAFE_CMD_PATH_RE = /["%^!\u0000-\u001f]/
|
||||
const WINDOWS_ABSOLUTE_RE = /^[A-Za-z]:\\/
|
||||
|
||||
/** `%LOCALAPPDATA%\TurkServis\AITURK\program\bin\aiturk.cmd`, or null. */
|
||||
export function resolveAiturkCliPath(deps: Pick<AiturkCliRunnerDeps, 'env' | 'existsSync' | 'platform'>): null | string {
|
||||
if ((deps.platform ?? process.platform) !== 'win32') {
|
||||
return null
|
||||
}
|
||||
|
||||
const localAppData = String(deps.env.LOCALAPPDATA || '').replace(/[\\/]+$/, '')
|
||||
|
||||
if (!WINDOWS_ABSOLUTE_RE.test(localAppData)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidate = `${localAppData}\\TurkServis\\AITURK\\program\\bin\\aiturk.cmd`
|
||||
|
||||
try {
|
||||
return deps.existsSync(candidate) ? candidate : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the CLI runner used by createAiturkLogin. The key goes to the child's
|
||||
* stdin and nowhere else: the argument list is a fixed array with no user
|
||||
* input, there is no `shell: true`, and stdout/stderr are discarded.
|
||||
*/
|
||||
export function createAiturkCliRunner(deps: AiturkCliRunnerDeps) {
|
||||
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
|
||||
const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as ReturnType<typeof setTimeout>))
|
||||
const timeoutMs = deps.timeoutMs ?? AITURK_CLI_TIMEOUT_MS
|
||||
|
||||
return function runCli(cliKey: string, options: { keepOtherAccount: boolean }): Promise<AiturkCliExit> {
|
||||
const cliPath = resolveAiturkCliPath(deps)
|
||||
|
||||
if (!cliPath) {
|
||||
return Promise.resolve('missing')
|
||||
}
|
||||
|
||||
const systemRoot = String(deps.env.SystemRoot || deps.env.SYSTEMROOT || 'C:\\Windows').replace(/[\\/]+$/, '')
|
||||
|
||||
if (UNSAFE_CMD_PATH_RE.test(cliPath) || !WINDOWS_ABSOLUTE_RE.test(systemRoot) || UNSAFE_CMD_PATH_RE.test(systemRoot)) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
const flags = options.keepOtherAccount ? 'login --key-stdin --keep-other-account' : 'login --key-stdin'
|
||||
|
||||
// A .cmd cannot be spawned directly without a shell; run it through an
|
||||
// absolute cmd.exe. With /s, cmd strips the outer quote pair and runs the
|
||||
// rest verbatim, so the (validated, quoted) path survives spaces.
|
||||
const args = ['/d', '/s', '/c', `""${cliPath}" ${flags}"`]
|
||||
|
||||
return new Promise(resolve => {
|
||||
let done = false
|
||||
let timer: unknown = null
|
||||
let child: any
|
||||
|
||||
const settle = (value: AiturkCliExit) => {
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
|
||||
done = true
|
||||
|
||||
if (timer !== null) {
|
||||
clearTimer(timer)
|
||||
}
|
||||
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
try {
|
||||
child = deps.spawn(`${systemRoot}\\System32\\cmd.exe`, args, {
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
windowsVerbatimArguments: true,
|
||||
stdio: ['pipe', 'ignore', 'ignore']
|
||||
})
|
||||
} catch {
|
||||
settle(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
timer = setTimer(() => {
|
||||
try {
|
||||
child.kill()
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
|
||||
settle(null)
|
||||
}, timeoutMs)
|
||||
|
||||
child.once('error', () => settle(null))
|
||||
child.once('close', (code: null | number) => settle(typeof code === 'number' ? code : null))
|
||||
|
||||
try {
|
||||
// A CLI that exits before reading stdin raises EPIPE here; ignore it,
|
||||
// the exit code is the answer.
|
||||
child.stdin.on('error', () => {})
|
||||
child.stdin.end(`${cliKey}\n`)
|
||||
} catch {
|
||||
// Same as above.
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export const AITURK_PRODUCT = Object.freeze({
|
||||
protocol: 'aiturk-ide',
|
||||
website: 'https://turkservis.online',
|
||||
downloads: 'https://turkservis.online/ide',
|
||||
repository: 'https://gitea.twinpay.one/yilsem/aiturk-hermes-ide',
|
||||
repository: 'https://gitea.turkservis.online/yilsem/aiturk-hermes-ide',
|
||||
apiBaseUrl: 'https://ai.turkservis.online/v1'
|
||||
})
|
||||
|
||||
|
||||
@@ -234,7 +234,7 @@ function downloadInstallScript(ref, destPath) {
|
||||
// placeholder is a real GitHub commit.
|
||||
const scriptName = installScriptName()
|
||||
const refType = isPinnedCommit(ref) ? 'commit' : 'branch'
|
||||
const url = `https://gitea.twinpay.one/yilsem/aiturk-hermes-ide/raw/${refType}/${encodeURIComponent(ref)}/scripts/${scriptName}`
|
||||
const url = `https://gitea.turkservis.online/yilsem/aiturk-hermes-ide/raw/${refType}/${encodeURIComponent(ref)}/scripts/${scriptName}`
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
||||
|
||||
@@ -12,13 +12,23 @@ const contentText = value => typeof value === 'string' ? value : Array.isArray(v
|
||||
? value.filter(part => part?.type === 'text').map(part => part.text || '').join('\n') : ''
|
||||
|
||||
export function publicMessages(rows) {
|
||||
return (Array.isArray(rows) ? rows : []).filter(row =>
|
||||
const messages = (Array.isArray(rows) ? rows : []).filter(row =>
|
||||
['user', 'assistant'].includes(row.role) && row.display_kind !== 'hidden'
|
||||
).slice(-40).map(row => ({
|
||||
id: row.id ?? row.row_id,
|
||||
id: typeof (row.id ?? row.row_id) === 'number' ? (row.id ?? row.row_id) : undefined,
|
||||
role: row.role,
|
||||
text: clip(contentText(row.display_content ?? row.content ?? row.text)),
|
||||
}))
|
||||
// The Chrome native limit counts UTF-8 JSON bytes, not JS characters.
|
||||
// Reserve room for the opening response's in-flight text and metadata;
|
||||
// escaped controls and CJK text must not disconnect a large transcript.
|
||||
const messageBudget = Math.floor((MAX_FRAME - 128000) / Math.max(1, messages.length))
|
||||
for (const message of messages) {
|
||||
while (Buffer.byteLength(JSON.stringify(message)) > messageBudget) {
|
||||
message.text = message.text.slice(0, Math.floor(message.text.length * 0.8))
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
export function validateRequest(message) {
|
||||
@@ -149,10 +159,23 @@ export class ChromeGatewayClient {
|
||||
case 'status':
|
||||
return { product: 'AITURK IDE', protocol: 1, profile: this.backend.profile || 'default', local: true }
|
||||
case 'sessions.list': {
|
||||
const query = new URLSearchParams({ ...this.scope(), limit: '200', offset: '0', min_messages: '1', archived: 'exclude', order: 'recent' })
|
||||
const data = await this.rest(`/api/sessions?${query}`)
|
||||
const rows = Array.isArray(data) ? data : data.sessions || []
|
||||
return { sessions: rows.slice(0, 200).filter(row => row.source === 'desktop').map(row => ({
|
||||
const rows = [], seen = new Set()
|
||||
// The real sessions API validates limit <= 100. Filter before paging:
|
||||
// cron/CLI traffic must not crowd IDE conversations out of the list.
|
||||
for (let offset = 0; offset < 200; offset += 100) {
|
||||
const query = new URLSearchParams({ ...this.scope(), limit: '100', offset: String(offset),
|
||||
source: 'desktop', min_messages: '1', archived: 'exclude', order: 'recent' })
|
||||
const data = await this.rest(`/api/sessions?${query}`)
|
||||
const page = Array.isArray(data) ? data : data.sessions || []
|
||||
for (const row of page) {
|
||||
const id = row.id || row.session_id
|
||||
if (row.source !== 'desktop' || !id || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
rows.push(row)
|
||||
}
|
||||
if (page.length < 100) break
|
||||
}
|
||||
return { sessions: rows.slice(0, 200).map(row => ({
|
||||
id: row.id || row.session_id, title: clip(row.title || row.preview || 'İsimsiz konuşma', 180),
|
||||
model: clip(row.model, 160), updatedAt: row.last_active || row.started_at,
|
||||
})), profile: this.backend.profile || 'default', limited: true }
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
addCodexLinkLine,
|
||||
CODEX_BRIDGE_VERSION,
|
||||
CODEX_LINK_LINE,
|
||||
CODEX_LINK_RUN_COMMAND_MAX,
|
||||
codexAutostartPlan,
|
||||
codexBridgeArgs,
|
||||
codexBridgeCommandLine,
|
||||
codexBridgeConfig,
|
||||
codexBridgeLauncherSource,
|
||||
inspectCodexConfig,
|
||||
parseCodexLinkSettings,
|
||||
removeCodexLinkLine,
|
||||
serializeCodexLinkSettings
|
||||
} from './codex-link-config'
|
||||
|
||||
const FOREIGN = 'openai_base_url = "http://127.0.0.1:8799/backend-api/codex"'
|
||||
|
||||
// Add then remove must hand the user's file back exactly as it was.
|
||||
function expectRoundTrip(original: string) {
|
||||
const added = addCodexLinkLine(original)
|
||||
|
||||
expect(added.changed).toBe(true)
|
||||
expect(added.owner).toBe('ours')
|
||||
expect(inspectCodexConfig(added.text)).toBe('ours')
|
||||
|
||||
const removed = removeCodexLinkLine(added.text)
|
||||
|
||||
expect(removed.changed).toBe(true)
|
||||
expect(removed.text).toBe(original)
|
||||
|
||||
return added.text
|
||||
}
|
||||
|
||||
describe('the contract line', () => {
|
||||
it('expects the version exported by the bundled Python bridge', () => {
|
||||
const home = mkdtempSync(path.join(tmpdir(), 'codex-bridge-version-'))
|
||||
try {
|
||||
const python = process.platform === 'win32' ? 'py' : 'python3'
|
||||
const args = process.platform === 'win32' ? ['-3'] : []
|
||||
const version = execFileSync(python, [...args, '-c', 'import bridge; print(bridge.SURUM)'], {
|
||||
cwd: fileURLToPath(new URL('../assets/codex-bridge/', import.meta.url)),
|
||||
env: { ...process.env, AITURK_CODEX_BRIDGE_HOME: home, HERMES_HOME: home },
|
||||
encoding: 'utf8',
|
||||
windowsHide: true
|
||||
}).trim()
|
||||
expect(CODEX_BRIDGE_VERSION).toBe(version)
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('is the exact text Codex and the bridge agree on', () => {
|
||||
expect(CODEX_LINK_LINE).toBe(
|
||||
'openai_base_url = "http://127.0.0.1:8795/backend-api/codex" # AITURK IDE Codex Baglama'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addCodexLinkLine', () => {
|
||||
it('writes the line into an empty file', () => {
|
||||
expect(expectRoundTrip('')).toBe(`${CODEX_LINK_LINE}\n`)
|
||||
})
|
||||
|
||||
it('puts the line before the first table when the file only has tables', () => {
|
||||
const original = '[projects."C:\\\\work"]\ntrust_level = "trusted"\n\n[mcp_servers.x]\ncommand = "node"\n'
|
||||
const text = expectRoundTrip(original)
|
||||
|
||||
expect(text.indexOf(CODEX_LINK_LINE)).toBeLessThan(text.indexOf('[projects.'))
|
||||
expect(text.endsWith(original)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps existing top-level keys and tables intact', () => {
|
||||
const original = '# my config\nmodel = "gpt-6"\napproval_policy = "never"\n\n[features]\nweb = true\n'
|
||||
const text = expectRoundTrip(original)
|
||||
|
||||
expect(text.indexOf(CODEX_LINK_LINE)).toBeLessThan(text.indexOf('[features]'))
|
||||
expect(text).toContain('model = "gpt-6"\napproval_policy = "never"\n\n[features]\nweb = true\n')
|
||||
})
|
||||
|
||||
it('preserves a CRLF file and writes its own line with CRLF', () => {
|
||||
const original = 'model = "gpt-6"\r\n\r\n[features]\r\nweb = true\r\n'
|
||||
const text = expectRoundTrip(original)
|
||||
|
||||
expect(text).toBe(`${CODEX_LINK_LINE}\r\n${original}`)
|
||||
expect(text.replace(/\r\n/g, '')).not.toMatch(/[\r\n]/)
|
||||
})
|
||||
|
||||
it('keeps a byte-order mark at the very start', () => {
|
||||
const text = expectRoundTrip('\uFEFFmodel = "gpt-6"\n')
|
||||
|
||||
expect(text.startsWith(`\uFEFF${CODEX_LINK_LINE}\n`)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a file without a trailing newline exactly as it was', () => {
|
||||
expectRoundTrip('model = "gpt-6"\n[features]\nweb = true')
|
||||
})
|
||||
|
||||
it('is idempotent once our line is there', () => {
|
||||
const once = addCodexLinkLine('model = "gpt-6"\n[features]\n').text
|
||||
const twice = addCodexLinkLine(once)
|
||||
|
||||
expect(twice.changed).toBe(false)
|
||||
expect(twice.owner).toBe('ours')
|
||||
expect(twice.text).toBe(once)
|
||||
})
|
||||
|
||||
it('refreshes a stale copy of our own line in place', () => {
|
||||
const stale = 'model = "gpt-6"\nopenai_base_url = "http://127.0.0.1:1/x" # AITURK IDE Codex Baglama\n[features]\n'
|
||||
const edit = addCodexLinkLine(stale)
|
||||
|
||||
expect(edit.changed).toBe(true)
|
||||
expect(edit.text).toBe(`model = "gpt-6"\n${CODEX_LINK_LINE}\n[features]\n`)
|
||||
})
|
||||
|
||||
it('still owns its line after Codex rewrote the config and dropped the comment', () => {
|
||||
const rewritten = 'openai_base_url = "http://127.0.0.1:8795/backend-api/codex"\nmodel = "x"\n'
|
||||
|
||||
expect(inspectCodexConfig(rewritten)).toBe('ours')
|
||||
expect(removeCodexLinkLine(rewritten).text).toBe('model = "x"\n')
|
||||
// Another local port is somebody else's bridge.
|
||||
expect(inspectCodexConfig('openai_base_url = "http://127.0.0.1:8799/backend-api/codex"\n')).toBe('foreign')
|
||||
})
|
||||
|
||||
it('does not touch a foreign openai_base_url and reports it', () => {
|
||||
for (const original of [
|
||||
`${FOREIGN}\n[features]\n`,
|
||||
`model = "gpt-6"\r\n ${FOREIGN} # my own bridge\r\n`,
|
||||
'"openai_base_url" = "https://example.test/v1"\n',
|
||||
"'openai_base_url'='https://example.test/v1'\n"
|
||||
]) {
|
||||
const edit = addCodexLinkLine(original)
|
||||
|
||||
expect(edit).toEqual({ changed: false, owner: 'foreign', text: original })
|
||||
expect(inspectCodexConfig(original)).toBe('foreign')
|
||||
}
|
||||
})
|
||||
|
||||
it('does not treat the same key under a table as top-level', () => {
|
||||
const original = 'model = "gpt-6"\n\n[model_providers.mine]\nopenai_base_url = "https://example.test/v1"\n'
|
||||
|
||||
expect(inspectCodexConfig(original)).toBe('absent')
|
||||
|
||||
const text = expectRoundTrip(original)
|
||||
|
||||
expect(text.indexOf(CODEX_LINK_LINE)).toBeLessThan(text.indexOf('[model_providers.mine]'))
|
||||
expect(text).toContain('[model_providers.mine]\nopenai_base_url = "https://example.test/v1"\n')
|
||||
})
|
||||
|
||||
it('ignores the key inside a comment', () => {
|
||||
const original = '# openai_base_url = "https://example.test/v1"\n #openai_base_url = "x"\nmodel = "gpt-6"\n'
|
||||
|
||||
expect(inspectCodexConfig(original)).toBe('absent')
|
||||
expectRoundTrip(original)
|
||||
})
|
||||
|
||||
it('ignores look-alikes inside multi-line values', () => {
|
||||
// Neither the "key" inside the string nor the "[table]" inside it is real,
|
||||
// so the genuine foreign key after it must still be found.
|
||||
const inString = 'notes = """\nopenai_base_url = "nope"\n[not.a.table]\n"""\nmodel = "gpt-6"\n'
|
||||
|
||||
expect(inspectCodexConfig(inString)).toBe('absent')
|
||||
expect(inspectCodexConfig(`${inString}${FOREIGN}\n`)).toBe('foreign')
|
||||
|
||||
const inArray = 'notify = [\n "a",\n ["b"]\n]\n'
|
||||
|
||||
expect(inspectCodexConfig(`${inArray}${FOREIGN}\n`)).toBe('foreign')
|
||||
expect(inspectCodexConfig(`literal = '''\n[x]\n'''\n${FOREIGN}\n`)).toBe('foreign')
|
||||
})
|
||||
|
||||
it('is not fooled by a bracket or hash inside a quoted value', () => {
|
||||
const original = `title = "a [b] # c"\n${FOREIGN}\n`
|
||||
|
||||
expect(inspectCodexConfig(original)).toBe('foreign')
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeCodexLinkLine', () => {
|
||||
it('removes only our line, wherever the user moved it', () => {
|
||||
const text = `model = "gpt-6"\r\n${CODEX_LINK_LINE}\r\napproval_policy = "never"\r\n[features]\r\n`
|
||||
const edit = removeCodexLinkLine(text)
|
||||
|
||||
expect(edit).toEqual({
|
||||
changed: true,
|
||||
owner: 'absent',
|
||||
text: 'model = "gpt-6"\r\napproval_policy = "never"\r\n[features]\r\n'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves a file without our line untouched', () => {
|
||||
for (const original of ['', 'model = "gpt-6"\n', `${FOREIGN}\n[features]\n`]) {
|
||||
expect(removeCodexLinkLine(original).changed).toBe(false)
|
||||
expect(removeCodexLinkLine(original).text).toBe(original)
|
||||
}
|
||||
})
|
||||
|
||||
it('never removes a foreign line', () => {
|
||||
expect(removeCodexLinkLine(`${FOREIGN}\n`)).toEqual({ changed: false, owner: 'foreign', text: `${FOREIGN}\n` })
|
||||
})
|
||||
|
||||
it('leaves our marker alone when it sits under a table', () => {
|
||||
const original = `[model_providers.mine]\n${CODEX_LINK_LINE}\n`
|
||||
|
||||
expect(removeCodexLinkLine(original)).toEqual({ changed: false, owner: 'absent', text: original })
|
||||
})
|
||||
})
|
||||
|
||||
describe('preference file', () => {
|
||||
it('is on by default when the file is missing or malformed', () => {
|
||||
for (const raw of [null, undefined, '', '{', 'null', '[]', '"off"', '{"kapsam":"ide"}', '{"enabled":"false"}']) {
|
||||
expect(parseCodexLinkSettings(raw).enabled).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('is off only when explicitly disabled', () => {
|
||||
expect(parseCodexLinkSettings('{"enabled": false, "kapsam": "ide"}').enabled).toBe(false)
|
||||
expect(parseCodexLinkSettings('{"enabled": true}').enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('writes the contract shape and keeps unknown fields', () => {
|
||||
expect(JSON.parse(serializeCodexLinkSettings({}, false))).toEqual({
|
||||
enabled: false,
|
||||
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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('bridge configuration and command', () => {
|
||||
const launch = {
|
||||
bridgeHome: 'C:\\Users\\a b\\AppData\\Local\\TurkServis\\AITURK-IDE\\codex-bridge',
|
||||
bridgeScript: 'C:\\Program Files\\AITURK IDE\\resources\\codex-bridge\\bridge.py',
|
||||
hermesHome: 'C:\\Users\\a b\\AppData\\Local\\TurkServis\\AITURK-IDE\\agent'
|
||||
}
|
||||
|
||||
const python = `${launch.hermesHome}\\hermes-agent\\venv\\Scripts\\pythonw.exe`
|
||||
|
||||
it('writes the agreed bridge.json', () => {
|
||||
expect(codexBridgeConfig()).toEqual({
|
||||
port: 8795,
|
||||
chatgpt_backend: 'https://chatgpt.com',
|
||||
routes: {},
|
||||
nine_router_catalog: { enabled: false },
|
||||
pano_kanca_user: false,
|
||||
ozet_kopru: { enabled: true, kapsam: 'hepsi', openai_modeli: 'gpt-6-luna', dongu_siniri: 8, dongu_art_arda: 3 }
|
||||
})
|
||||
})
|
||||
|
||||
it('passes the same arguments to the spawn and the login item', () => {
|
||||
expect(codexBridgeArgs(launch)).toEqual([
|
||||
launch.bridgeScript,
|
||||
'--home',
|
||||
launch.bridgeHome,
|
||||
'--hermes-home',
|
||||
launch.hermesHome
|
||||
])
|
||||
expect(codexBridgeCommandLine(python, launch)).toBe(
|
||||
`"${python}" "${launch.bridgeScript}" --home "${launch.bridgeHome}" --hermes-home "${launch.hermesHome}"`
|
||||
)
|
||||
})
|
||||
|
||||
it('drops a trailing separator that would escape the closing quote', () => {
|
||||
expect(
|
||||
codexBridgeCommandLine('C:\\py\\pythonw.exe', {
|
||||
bridgeHome: 'C:\\d\\',
|
||||
bridgeScript: 'C:\\b.py',
|
||||
hermesHome: 'C:\\h\\'
|
||||
})
|
||||
).toBe('"C:\\py\\pythonw.exe" "C:\\b.py" --home "C:\\d" --hermes-home "C:\\h"')
|
||||
})
|
||||
|
||||
it('uses the direct command for the login item when it fits a Run value', () => {
|
||||
const short = { bridgeHome: 'C:\\d', bridgeScript: 'C:\\b\\bridge.py', hermesHome: 'C:\\h' }
|
||||
|
||||
expect(codexAutostartPlan('C:\\py\\pythonw.exe', short, 'C:\\d\\baslat.pyw')).toEqual({
|
||||
command: '"C:\\py\\pythonw.exe" "C:\\b\\bridge.py" --home "C:\\d" --hermes-home "C:\\h"',
|
||||
launcher: null
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a launcher when the direct command is too long for a Run value', () => {
|
||||
expect(codexBridgeCommandLine(python, launch).length).toBeGreaterThan(CODEX_LINK_RUN_COMMAND_MAX)
|
||||
|
||||
const launcherPath = `${launch.bridgeHome}\\baslat.pyw`
|
||||
const plan = codexAutostartPlan(python, launch, launcherPath)
|
||||
|
||||
expect(plan?.command).toBe(`"${python}" "${launcherPath}"`)
|
||||
expect(plan?.command.length).toBeLessThanOrEqual(CODEX_LINK_RUN_COMMAND_MAX)
|
||||
expect(plan?.launcher).toEqual({ path: launcherPath, source: codexBridgeLauncherSource(launch) })
|
||||
// The launcher carries the very same argv as the direct command.
|
||||
expect(plan?.launcher?.source).toContain(`ARGV = ${JSON.stringify(codexBridgeArgs(launch))}`)
|
||||
})
|
||||
|
||||
it('registers nothing when not even the launcher command fits', () => {
|
||||
const deep = `C:\\${'x'.repeat(300)}`
|
||||
|
||||
expect(codexAutostartPlan(`${deep}\\pythonw.exe`, launch, `${deep}\\baslat.pyw`)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Codex Link ("Codex Bağlama") — the pure half.
|
||||
*
|
||||
* AITURK IDE ships a small local Python bridge that lets the OpenAI Codex
|
||||
* installed on this machine use the IDE's models and combos. Turning the
|
||||
* feature on means editing a file the IDE does not own (Codex's `config.toml`)
|
||||
* and registering a login item, so every text transformation lives here as a
|
||||
* dependency-free, byte-preserving function that can be proven without
|
||||
* Electron, a filesystem, or a registry. `codex-link.ts` owns the decisions and
|
||||
* the side effects.
|
||||
*
|
||||
* Invariants:
|
||||
* - Only the line carrying OUR trailing comment is ever written or removed.
|
||||
* - An `openai_base_url` that is not ours means someone else's bridge is in
|
||||
* charge: report `foreign` and change nothing.
|
||||
* - Everything else in the file — including its line endings and BOM — comes
|
||||
* back byte for byte.
|
||||
*/
|
||||
|
||||
export const CODEX_LINK_PORT = 8795
|
||||
export const CODEX_LINK_ORIGIN = `http://127.0.0.1:${CODEX_LINK_PORT}`
|
||||
export const CODEX_LINK_STATUS_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama/status`
|
||||
export const CODEX_LINK_STOP_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama/stop`
|
||||
export const CODEX_LINK_REFRESH_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama/refresh`
|
||||
|
||||
/** The trailing comment that marks the line as ours. */
|
||||
export const CODEX_LINK_MARKER = '# AITURK IDE Codex Baglama'
|
||||
// Must equal SURUM in assets/codex-bridge/bridge.py; a running bridge that reports
|
||||
// another version is restarted so an updated IDE never keeps an old bridge alive.
|
||||
export const CODEX_BRIDGE_VERSION = '18'
|
||||
export const CODEX_LINK_LINE = `openai_base_url = "${CODEX_LINK_ORIGIN}/backend-api/codex" ${CODEX_LINK_MARKER}`
|
||||
|
||||
export const CODEX_LINK_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'
|
||||
export const CODEX_LINK_RUN_VALUE = 'AITURK-IDE-Codex-Baglama'
|
||||
export const CODEX_LINK_LAUNCHER_NAME = 'baslat.pyw'
|
||||
|
||||
// Windows documents Run-key command lines as "no longer than 260 characters".
|
||||
// A longer value is not reliably started at sign-in, so the direct command is
|
||||
// only used when it fits (see autostartPlan).
|
||||
export const CODEX_LINK_RUN_COMMAND_MAX = 260
|
||||
|
||||
/** Who owns the top-level `openai_base_url` in a Codex config. */
|
||||
export type CodexConfigOwner = 'absent' | 'foreign' | 'ours'
|
||||
|
||||
interface Line {
|
||||
eol: string
|
||||
text: string
|
||||
}
|
||||
|
||||
const BOM = '\uFEFF'
|
||||
const KEY_PATTERN = /^[ \t]*(?:openai_base_url|"openai_base_url"|'openai_base_url')[ \t]*=/
|
||||
|
||||
// Split into lines WITHOUT losing anything: joining `text + eol` of every entry
|
||||
// reproduces the input exactly, whatever mix of \n, \r\n and \r it used.
|
||||
function splitLines(source: string): Line[] {
|
||||
const lines: Line[] = []
|
||||
let start = 0
|
||||
|
||||
while (start < source.length) {
|
||||
let end = start
|
||||
|
||||
while (end < source.length && source[end] !== '\n' && source[end] !== '\r') {
|
||||
end += 1
|
||||
}
|
||||
|
||||
let eol = ''
|
||||
|
||||
if (end < source.length) {
|
||||
eol = source[end] === '\r' && source[end + 1] === '\n' ? '\r\n' : source[end]
|
||||
}
|
||||
|
||||
lines.push({ eol, text: source.slice(start, end) })
|
||||
start = end + eol.length
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
function joinLines(lines: Line[]): string {
|
||||
return lines.map(line => line.text + line.eol).join('')
|
||||
}
|
||||
|
||||
function splitBom(source: string): { body: string; bom: string } {
|
||||
return source.startsWith(BOM) ? { body: source.slice(1), bom: BOM } : { body: source, bom: '' }
|
||||
}
|
||||
|
||||
type MultilineQuote = "'''" | '"""' | null
|
||||
|
||||
interface ValueState {
|
||||
depth: number
|
||||
multiline: MultilineQuote
|
||||
}
|
||||
|
||||
// Index just past the `=` of a `key = value` line, or the line length when the
|
||||
// line holds no value (blank, comment, malformed). Quoted keys may contain `=`.
|
||||
function valueStart(text: string): number {
|
||||
let pos = 0
|
||||
|
||||
while (pos < text.length) {
|
||||
const char = text[pos]
|
||||
|
||||
if (char === '#') {
|
||||
return text.length
|
||||
}
|
||||
|
||||
if (char === '=') {
|
||||
return pos + 1
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
pos += 1
|
||||
|
||||
while (pos < text.length && text[pos] !== char) {
|
||||
pos += char === '"' && text[pos] === '\\' ? 2 : 1
|
||||
}
|
||||
}
|
||||
|
||||
pos += 1
|
||||
}
|
||||
|
||||
return text.length
|
||||
}
|
||||
|
||||
// Walk a value just far enough to know whether the NEXT line is still inside it
|
||||
// (multi-line string or array). That is the only TOML the top-level scan needs:
|
||||
// without it, text inside a multi-line value could pass for a key or a table
|
||||
// header.
|
||||
function scanValue(text: string, from: number, state: ValueState): ValueState {
|
||||
let { depth, multiline } = state
|
||||
let pos = from
|
||||
|
||||
while (pos < text.length) {
|
||||
if (multiline) {
|
||||
if (multiline === '"""' && text[pos] === '\\') {
|
||||
pos += 2
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (text.startsWith(multiline, pos)) {
|
||||
pos += 3
|
||||
|
||||
// Up to two quotes may sit directly before the closing delimiter.
|
||||
while (text[pos] === multiline[0]) {
|
||||
pos += 1
|
||||
}
|
||||
|
||||
multiline = null
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
pos += 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const char = text[pos]
|
||||
|
||||
if (char === '#') {
|
||||
break
|
||||
}
|
||||
|
||||
if (text.startsWith('"""', pos) || text.startsWith("'''", pos)) {
|
||||
multiline = text.slice(pos, pos + 3) as MultilineQuote
|
||||
pos += 3
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
pos += 1
|
||||
|
||||
while (pos < text.length && text[pos] !== char) {
|
||||
pos += char === '"' && text[pos] === '\\' ? 2 : 1
|
||||
}
|
||||
|
||||
pos += 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '[' || char === '{') {
|
||||
depth += 1
|
||||
} else if (char === ']' || char === '}') {
|
||||
depth = Math.max(0, depth - 1)
|
||||
}
|
||||
|
||||
pos += 1
|
||||
}
|
||||
|
||||
return { depth, multiline }
|
||||
}
|
||||
|
||||
// Indices of the top-level `openai_base_url` lines: those before the first
|
||||
// `[table]` header, outside comments and multi-line values.
|
||||
function topLevelEntries(lines: Line[]): { index: number; ours: boolean }[] {
|
||||
const entries: { index: number; ours: boolean }[] = []
|
||||
let state: ValueState = { depth: 0, multiline: null }
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const { text } = lines[index]
|
||||
let from = 0
|
||||
|
||||
if (!state.multiline && state.depth === 0) {
|
||||
if (text.trimStart().startsWith('[')) {
|
||||
break
|
||||
}
|
||||
|
||||
if (KEY_PATTERN.test(text)) {
|
||||
// Ours by marker or by value: Codex may rewrite its config and drop the comment.
|
||||
entries.push({
|
||||
index,
|
||||
ours: text.includes(CODEX_LINK_MARKER) || text.includes(`"${CODEX_LINK_ORIGIN}/backend-api/codex"`)
|
||||
})
|
||||
}
|
||||
|
||||
from = valueStart(text)
|
||||
}
|
||||
|
||||
state = scanValue(text, from, state)
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
function ownerOf(entries: { ours: boolean }[]): CodexConfigOwner {
|
||||
if (entries.some(entry => !entry.ours)) {
|
||||
return 'foreign'
|
||||
}
|
||||
|
||||
return entries.length > 0 ? 'ours' : 'absent'
|
||||
}
|
||||
|
||||
/** Who owns the top-level `openai_base_url` of this config text. */
|
||||
export function inspectCodexConfig(source: string): CodexConfigOwner {
|
||||
return ownerOf(topLevelEntries(splitLines(splitBom(source).body)))
|
||||
}
|
||||
|
||||
export interface CodexConfigEdit {
|
||||
changed: boolean
|
||||
owner: CodexConfigOwner
|
||||
text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Point Codex at the bridge. Idempotent; a foreign `openai_base_url` leaves the
|
||||
* text untouched and reports `foreign`.
|
||||
*
|
||||
* The line goes on the very first line: top-level keys must precede the first
|
||||
* table header, and the top of the file is the one position that is always
|
||||
* top-level without having to understand the values below it.
|
||||
*/
|
||||
export function addCodexLinkLine(source: string): CodexConfigEdit {
|
||||
const { body, bom } = splitBom(source)
|
||||
const lines = splitLines(body)
|
||||
const entries = topLevelEntries(lines)
|
||||
const owner = ownerOf(entries)
|
||||
|
||||
if (owner === 'foreign') {
|
||||
return { changed: false, owner, text: source }
|
||||
}
|
||||
|
||||
if (owner === 'ours') {
|
||||
const [first, ...duplicates] = entries
|
||||
|
||||
if (duplicates.length === 0 && lines[first.index].text === CODEX_LINK_LINE) {
|
||||
return { changed: false, owner, text: source }
|
||||
}
|
||||
|
||||
// A stale copy of our line (older address) or an accidental duplicate:
|
||||
// normalize to exactly one current line, in place.
|
||||
const drop = new Set(duplicates.map(entry => entry.index))
|
||||
lines[first.index] = { ...lines[first.index], text: CODEX_LINK_LINE }
|
||||
|
||||
return { changed: true, owner, text: bom + joinLines(lines.filter((_line, index) => !drop.has(index))) }
|
||||
}
|
||||
|
||||
const eol = body.includes('\r\n') ? '\r\n' : '\n'
|
||||
|
||||
return { changed: true, owner: 'ours', text: `${bom}${CODEX_LINK_LINE}${eol}${body}` }
|
||||
}
|
||||
|
||||
/** Remove our line (and only ours). A foreign key is left exactly as it was. */
|
||||
export function removeCodexLinkLine(source: string): CodexConfigEdit {
|
||||
const { body, bom } = splitBom(source)
|
||||
const lines = splitLines(body)
|
||||
const entries = topLevelEntries(lines)
|
||||
const drop = new Set(entries.filter(entry => entry.ours).map(entry => entry.index))
|
||||
const remaining = ownerOf(entries.filter(entry => !entry.ours))
|
||||
|
||||
if (drop.size === 0) {
|
||||
return { changed: false, owner: remaining, text: source }
|
||||
}
|
||||
|
||||
return { changed: true, owner: remaining, text: bom + joinLines(lines.filter((_line, index) => !drop.has(index))) }
|
||||
}
|
||||
|
||||
// ── Preference file ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface CodexLinkSettings {
|
||||
enabled: boolean
|
||||
mod: 'dogrudan' | 'aiturk'
|
||||
/** Every field of the stored object, so a rewrite keeps what it did not set. */
|
||||
stored: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Missing, unreadable or malformed means ON: the feature is on by default. */
|
||||
export function parseCodexLinkSettings(raw: null | string | undefined): CodexLinkSettings {
|
||||
if (typeof raw !== 'string') {
|
||||
return { enabled: true, mod: 'dogrudan', stored: {} }
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return { enabled: true, mod: 'dogrudan', stored: {} }
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: parsed.enabled !== false,
|
||||
mod: parsed.mod === 'aiturk' ? 'aiturk' : 'dogrudan',
|
||||
stored: parsed as Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
return { enabled: true, mod: 'dogrudan', stored: {} }
|
||||
}
|
||||
}
|
||||
|
||||
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, mod, kapsam }, null, 2)}\n`
|
||||
}
|
||||
|
||||
// ── Bridge configuration ────────────────────────────────────────────────────
|
||||
|
||||
/** `bridge.json`, rewritten on every apply. */
|
||||
export function codexBridgeConfig() {
|
||||
return {
|
||||
port: CODEX_LINK_PORT,
|
||||
chatgpt_backend: 'https://chatgpt.com',
|
||||
routes: {},
|
||||
nine_router_catalog: { enabled: false },
|
||||
pano_kanca_user: false,
|
||||
ozet_kopru: { enabled: true, kapsam: 'hepsi', openai_modeli: 'gpt-6-luna', dongu_siniri: 8, dongu_art_arda: 3 }
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeCodexBridgeConfig(): string {
|
||||
return `${JSON.stringify(codexBridgeConfig(), null, 2)}\n`
|
||||
}
|
||||
|
||||
// ── Bridge command line ─────────────────────────────────────────────────────
|
||||
|
||||
export interface CodexBridgeLaunch {
|
||||
bridgeHome: string
|
||||
bridgeScript: string
|
||||
hermesHome: string
|
||||
}
|
||||
|
||||
/** Arguments after the interpreter — identical for the spawn and the login item. */
|
||||
export function codexBridgeArgs({ bridgeHome, bridgeScript, hermesHome }: CodexBridgeLaunch): string[] {
|
||||
return [bridgeScript, '--home', bridgeHome, '--hermes-home', hermesHome]
|
||||
}
|
||||
|
||||
// A trailing backslash would escape the closing quote (`"C:\dir\"`).
|
||||
function quoted(value: string): string {
|
||||
return `"${value.replace(/[\\/]+$/, '')}"`
|
||||
}
|
||||
|
||||
/** `"<pythonw>" "<bridge.py>" --home "<data>" --hermes-home "<agent>"` */
|
||||
export function codexBridgeCommandLine(python: string, launch: CodexBridgeLaunch): string {
|
||||
const [script, homeFlag, home, hermesFlag, hermes] = codexBridgeArgs(launch)
|
||||
|
||||
return `${quoted(python)} ${quoted(script)} ${homeFlag} ${quoted(home)} ${hermesFlag} ${quoted(hermes)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* A tiny windowless launcher for the login item, used when the direct command
|
||||
* would not fit a Run value. It runs the bridge exactly as the direct command
|
||||
* would: same script, same arguments, the script's folder first on sys.path so
|
||||
* its helper modules import.
|
||||
*/
|
||||
export function codexBridgeLauncherSource(launch: CodexBridgeLaunch): string {
|
||||
const argv = JSON.stringify(codexBridgeArgs(launch))
|
||||
|
||||
return [
|
||||
'# Written by AITURK IDE (Settings > Codex Baglama). Regenerated on every sync; do not edit.',
|
||||
'import os',
|
||||
'import runpy',
|
||||
'import sys',
|
||||
'',
|
||||
`ARGV = ${argv}`,
|
||||
'',
|
||||
'sys.argv = list(ARGV)',
|
||||
'sys.path.insert(0, os.path.dirname(ARGV[0]))',
|
||||
"runpy.run_path(ARGV[0], run_name='__main__')",
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export interface CodexAutostartPlan {
|
||||
command: string
|
||||
/** Launcher file to write first; null when the direct command is used. */
|
||||
launcher: { path: string; source: string } | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The login-item command. Prefer the direct bridge command; fall back to the
|
||||
* launcher when that exceeds the Run-value limit. Returns null when not even
|
||||
* the launcher form fits (nothing is registered rather than something that
|
||||
* silently never starts).
|
||||
*/
|
||||
export function codexAutostartPlan(
|
||||
python: string,
|
||||
launch: CodexBridgeLaunch,
|
||||
launcherPath: string
|
||||
): CodexAutostartPlan | null {
|
||||
const direct = codexBridgeCommandLine(python, launch)
|
||||
|
||||
if (direct.length <= CODEX_LINK_RUN_COMMAND_MAX) {
|
||||
return { command: direct, launcher: null }
|
||||
}
|
||||
|
||||
const viaLauncher = `${quoted(python)} ${quoted(launcherPath)}`
|
||||
|
||||
if (viaLauncher.length > CODEX_LINK_RUN_COMMAND_MAX) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { command: viaLauncher, launcher: { path: launcherPath, source: codexBridgeLauncherSource(launch) } }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Codex Link ("Codex Bağlama") — the shape that crosses the IPC bridge.
|
||||
*
|
||||
* Types only, so the renderer can import them without pulling main-process
|
||||
* code into its program. See codex-link.ts for what the fields mean in action.
|
||||
*/
|
||||
|
||||
export type CodexLinkMode = 'dogrudan' | 'aiturk'
|
||||
|
||||
export type CodexLinkIdeState = 'anahtar-yok' | 'kapali' | 'tamam'
|
||||
|
||||
/** Stable codes; the renderer owns the wording. Detail goes to the desktop log. */
|
||||
export type CodexLinkError =
|
||||
| 'autostart-failed'
|
||||
| 'bridge-config-failed'
|
||||
| 'bridge-missing'
|
||||
| 'bridge-start-failed'
|
||||
| 'config-write-failed'
|
||||
| 'settings-write-failed'
|
||||
|
||||
export interface CodexLinkStatus {
|
||||
enabled: boolean
|
||||
mod: CodexLinkMode
|
||||
/** Codex's `config.toml` exists on this machine. */
|
||||
codexFound: boolean
|
||||
/** The IDE's own Python environment exists (first-run setup finished). */
|
||||
venvReady: boolean
|
||||
/** Codex already uses another bridge; nothing of its setup is touched. */
|
||||
foreign: boolean
|
||||
bridgeRunning: boolean
|
||||
/** From the bridge's status endpoint; null while it is not answering. */
|
||||
ideDurum: CodexLinkIdeState | null
|
||||
ideModel: null | number
|
||||
ozet: boolean | null
|
||||
lastError: CodexLinkError | null
|
||||
}
|
||||
|
||||
export interface CodexLinkPatch {
|
||||
enabled?: boolean
|
||||
mod?: CodexLinkMode
|
||||
/** "Sync now": re-run the apply without changing the preference. */
|
||||
sync?: boolean
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import path from 'node:path'
|
||||
|
||||
import { CODEX_BRIDGE_START_BUDGET_MS, type CodexLinkDeps, type CodexLinkPaths, createCodexLink } from './codex-link'
|
||||
import {
|
||||
CODEX_BRIDGE_VERSION,
|
||||
CODEX_LINK_LINE,
|
||||
CODEX_LINK_REFRESH_URL,
|
||||
CODEX_LINK_STATUS_URL,
|
||||
CODEX_LINK_STOP_URL,
|
||||
codexBridgeArgs,
|
||||
codexBridgeConfig
|
||||
} from './codex-link-config'
|
||||
|
||||
const PATHS: CodexLinkPaths = {
|
||||
settingsFile: 'D:\\ide\\codex-baglama.json',
|
||||
bridgeHome: 'D:\\ide\\codex-bridge',
|
||||
hermesHome: 'D:\\ide\\agent',
|
||||
bridgeScript: 'D:\\app\\codex-bridge\\bridge.py',
|
||||
pythonw: 'D:\\ide\\agent\\venv\\Scripts\\pythonw.exe',
|
||||
python: 'D:\\ide\\agent\\venv\\Scripts\\python.exe',
|
||||
codexConfig: 'D:\\home\\.codex\\config.toml'
|
||||
}
|
||||
|
||||
const BRIDGE_JSON = path.join(PATHS.bridgeHome, 'bridge.json')
|
||||
const STATUS_OK = { ok: true, surum: CODEX_BRIDGE_VERSION, ide_durum: 'tamam', ide_model: 7, ozet: true }
|
||||
const USER_CONFIG = 'model = "gpt-6"\r\n\r\n[features]\r\nweb = true\r\n'
|
||||
|
||||
interface World {
|
||||
/** What the machine looks like; mutated by the fake effects. */
|
||||
files: Map<string, string>
|
||||
/** Is something answering on the bridge port? */
|
||||
listening: boolean
|
||||
/** Version the listening bridge reports before any spawn; a spawn brings the current one. */
|
||||
staleVersion: null | string
|
||||
/** Does a spawn bring the bridge up? `false` = it never answers. */
|
||||
spawnWorks: boolean
|
||||
/** Spawned bridge only starts answering after this many status probes. */
|
||||
probesUntilUp: number
|
||||
autostart: null | string
|
||||
regFails: boolean
|
||||
configWriteFails: boolean
|
||||
/** Every observable effect, in order. */
|
||||
events: string[]
|
||||
clock: number
|
||||
}
|
||||
|
||||
function createWorld(patch: Partial<World> & { files?: Map<string, string> } = {}) {
|
||||
const world: World = {
|
||||
autostart: null,
|
||||
clock: 0,
|
||||
events: [],
|
||||
files: new Map([
|
||||
[PATHS.codexConfig, USER_CONFIG],
|
||||
[PATHS.settingsFile, JSON.stringify({ enabled: true, mod: 'aiturk' })],
|
||||
[PATHS.bridgeScript, '# bridge'],
|
||||
[PATHS.pythonw, ''],
|
||||
[PATHS.python, '']
|
||||
]),
|
||||
listening: false,
|
||||
staleVersion: null,
|
||||
probesUntilUp: 0,
|
||||
regFails: false,
|
||||
configWriteFails: false,
|
||||
spawnWorks: true,
|
||||
...patch
|
||||
}
|
||||
|
||||
let spawned = false
|
||||
let probesSinceSpawn = 0
|
||||
|
||||
const deps: CodexLinkDeps = {
|
||||
paths: PATHS,
|
||||
exists: file => world.files.has(file),
|
||||
readText: file => world.files.get(file) ?? null,
|
||||
writeTextAtomic: (file, text) => {
|
||||
if (file === PATHS.codexConfig && world.configWriteFails) throw new Error('config locked')
|
||||
world.events.push(`write ${file}`)
|
||||
world.files.set(file, text)
|
||||
},
|
||||
spawnBridge: (command, args, env) => {
|
||||
world.events.push(`spawn ${command} ${JSON.stringify(args)} ${JSON.stringify(env)}`)
|
||||
spawned = true
|
||||
probesSinceSpawn = 0
|
||||
world.staleVersion = null
|
||||
},
|
||||
request: async (method, url) => {
|
||||
if (method === 'GET' && url === CODEX_LINK_STATUS_URL) {
|
||||
if (spawned && world.spawnWorks && !world.listening) {
|
||||
probesSinceSpawn += 1
|
||||
|
||||
if (probesSinceSpawn > world.probesUntilUp) {
|
||||
world.listening = true
|
||||
}
|
||||
}
|
||||
|
||||
if (world.listening) {
|
||||
world.events.push('status ok')
|
||||
|
||||
return world.staleVersion ? { ...STATUS_OK, surum: world.staleVersion } : STATUS_OK
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (method === 'POST' && url === CODEX_LINK_REFRESH_URL) {
|
||||
world.events.push('refresh')
|
||||
|
||||
return world.listening ? STATUS_OK : null
|
||||
}
|
||||
|
||||
if (method === 'POST' && url === CODEX_LINK_STOP_URL) {
|
||||
world.events.push('stop')
|
||||
|
||||
if (!world.listening) {
|
||||
return null
|
||||
}
|
||||
|
||||
world.listening = false
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
throw new Error(`unexpected request ${method} ${url}`)
|
||||
},
|
||||
setAutostart: async command => {
|
||||
world.events.push('autostart set')
|
||||
|
||||
if (world.regFails) {
|
||||
throw new Error('reg add exited 1')
|
||||
}
|
||||
|
||||
world.autostart = command
|
||||
},
|
||||
clearAutostart: async () => {
|
||||
world.events.push('autostart clear')
|
||||
world.autostart = null
|
||||
},
|
||||
sleep: async ms => {
|
||||
world.clock += ms
|
||||
},
|
||||
now: () => world.clock,
|
||||
log: () => undefined
|
||||
}
|
||||
|
||||
return { link: createCodexLink(deps), world }
|
||||
}
|
||||
|
||||
const writes = (world: World) => world.events.filter(event => event.startsWith('write '))
|
||||
const spawns = (world: World) => world.events.filter(event => event.startsWith('spawn '))
|
||||
// Effects that change the machine (a status probe does not).
|
||||
const effects = (world: World) => world.events.filter(event => event !== 'status ok')
|
||||
|
||||
describe('applyCodexLink', () => {
|
||||
it('touches nothing when Codex is not installed', async () => {
|
||||
const { link, world } = createWorld()
|
||||
world.files.delete(PATHS.codexConfig)
|
||||
|
||||
const status = await link.apply()
|
||||
|
||||
expect(status).toMatchObject({ enabled: true, codexFound: false, bridgeRunning: false, lastError: null })
|
||||
expect(effects(world)).toEqual([])
|
||||
expect(world.autostart).toBeNull()
|
||||
})
|
||||
|
||||
it('touches nothing when Codex already uses another bridge', async () => {
|
||||
const foreign = 'openai_base_url = "http://127.0.0.1:8799/backend-api/codex"\n[features]\n'
|
||||
const { link, world } = createWorld({ listening: true })
|
||||
world.files.set(PATHS.codexConfig, foreign)
|
||||
|
||||
const status = await link.apply({ sync: true })
|
||||
|
||||
expect(status).toMatchObject({ enabled: true, codexFound: true, foreign: true, bridgeRunning: true })
|
||||
expect(effects(world)).toEqual([])
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(foreign)
|
||||
})
|
||||
|
||||
it('waits for the IDE setup: no venv, no changes', async () => {
|
||||
const { link, world } = createWorld()
|
||||
world.files.delete(PATHS.pythonw)
|
||||
world.files.delete(PATHS.python)
|
||||
|
||||
const status = await link.apply()
|
||||
|
||||
expect(status).toMatchObject({ enabled: true, codexFound: true, venvReady: false, foreign: false })
|
||||
expect(effects(world)).toEqual([])
|
||||
})
|
||||
|
||||
it('brings everything up in the safe order', async () => {
|
||||
const { link, world } = createWorld({ probesUntilUp: 2 })
|
||||
|
||||
const status = await link.apply()
|
||||
|
||||
expect(status).toEqual({
|
||||
enabled: true,
|
||||
mod: 'aiturk',
|
||||
codexFound: true,
|
||||
venvReady: true,
|
||||
foreign: false,
|
||||
bridgeRunning: true,
|
||||
ideDurum: 'tamam',
|
||||
ideModel: 7,
|
||||
ozet: true,
|
||||
lastError: null
|
||||
})
|
||||
|
||||
const env = { AITURK_CODEX_BRIDGE_HOME: PATHS.bridgeHome, HERMES_HOME: PATHS.hermesHome }
|
||||
|
||||
const args = codexBridgeArgs({
|
||||
bridgeHome: PATHS.bridgeHome,
|
||||
bridgeScript: PATHS.bridgeScript,
|
||||
hermesHome: PATHS.hermesHome
|
||||
})
|
||||
|
||||
// bridge.json → start → verified answer → config.toml → login item
|
||||
expect(world.events).toEqual([
|
||||
`write ${BRIDGE_JSON}`,
|
||||
`spawn ${PATHS.pythonw} ${JSON.stringify(args)} ${JSON.stringify(env)}`,
|
||||
'status ok',
|
||||
`write ${PATHS.codexConfig}`,
|
||||
'autostart set'
|
||||
])
|
||||
|
||||
expect(JSON.parse(world.files.get(BRIDGE_JSON) ?? '')).toEqual(codexBridgeConfig())
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(`${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
|
||||
expect(world.autostart).toBe(
|
||||
`"${PATHS.pythonw}" "${PATHS.bridgeScript}" --home "${PATHS.bridgeHome}" --hermes-home "${PATHS.hermesHome}"`
|
||||
)
|
||||
})
|
||||
|
||||
it('never points Codex at a bridge that did not come up', async () => {
|
||||
const { link, world } = createWorld({ spawnWorks: false })
|
||||
|
||||
const status = await link.apply()
|
||||
|
||||
expect(status).toMatchObject({ enabled: true, bridgeRunning: false, lastError: 'bridge-start-failed' })
|
||||
expect(spawns(world)).toHaveLength(1)
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
|
||||
expect(world.autostart).toBeNull()
|
||||
expect(writes(world)).toEqual([`write ${BRIDGE_JSON}`])
|
||||
// The start poll is bounded by its one budget.
|
||||
expect(world.clock).toBe(CODEX_BRIDGE_START_BUDGET_MS)
|
||||
})
|
||||
|
||||
it('takes its own line back out when the bridge can no longer be started', async () => {
|
||||
const { link, world } = createWorld({ spawnWorks: false })
|
||||
world.files.set(PATHS.codexConfig, `${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
|
||||
|
||||
const status = await link.apply()
|
||||
|
||||
expect(status.lastError).toBe('bridge-start-failed')
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
|
||||
})
|
||||
|
||||
it('does not start a second bridge when one is already listening', async () => {
|
||||
const { link, world } = createWorld({ listening: true })
|
||||
|
||||
await link.apply()
|
||||
await link.apply({ sync: true })
|
||||
|
||||
expect(spawns(world)).toEqual([])
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(`${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
|
||||
// The config is written once; the login item is refreshed on every apply.
|
||||
expect(writes(world).filter(event => event === `write ${PATHS.codexConfig}`)).toHaveLength(1)
|
||||
expect(world.events.filter(event => event === 'autostart set')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('replaces a bridge left running by an older IDE build', async () => {
|
||||
const { link, world } = createWorld({ listening: true, staleVersion: '0' })
|
||||
|
||||
const status = await link.apply()
|
||||
|
||||
expect(world.events.indexOf('stop')).toBeGreaterThanOrEqual(0)
|
||||
expect(spawns(world)).toHaveLength(1)
|
||||
expect(world.events.indexOf('stop')).toBeLessThan(world.events.findIndex(event => event.startsWith('spawn')))
|
||||
expect(status.bridgeRunning).toBe(true)
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(`${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
|
||||
})
|
||||
|
||||
it('asks a running bridge to re-read the IDE session on every apply', async () => {
|
||||
const { link, world } = createWorld({ listening: true })
|
||||
|
||||
await link.apply({ sync: true })
|
||||
|
||||
expect(world.events).toContain('refresh')
|
||||
expect(spawns(world)).toEqual([])
|
||||
})
|
||||
|
||||
it('falls back to python.exe when pythonw.exe is missing', async () => {
|
||||
const { link, world } = createWorld()
|
||||
world.files.delete(PATHS.pythonw)
|
||||
|
||||
await link.apply()
|
||||
|
||||
expect(spawns(world)[0]).toContain(`spawn ${PATHS.python} `)
|
||||
expect(world.autostart?.startsWith(`"${PATHS.python}" `)).toBe(true)
|
||||
})
|
||||
|
||||
it('reports a missing bridge script instead of starting anything', async () => {
|
||||
const { link, world } = createWorld()
|
||||
world.files.delete(PATHS.bridgeScript)
|
||||
|
||||
const status = await link.apply()
|
||||
|
||||
expect(status.lastError).toBe('bridge-missing')
|
||||
expect(effects(world)).toEqual([])
|
||||
})
|
||||
|
||||
it('surfaces a registry failure but keeps the working link', async () => {
|
||||
const { link, world } = createWorld({ listening: true, regFails: true })
|
||||
|
||||
const status = await link.apply()
|
||||
|
||||
expect(status).toMatchObject({ bridgeRunning: true, lastError: 'autostart-failed' })
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(`${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
|
||||
})
|
||||
|
||||
it('undoes everything when turned off', async () => {
|
||||
const { link, world } = createWorld({ listening: true })
|
||||
|
||||
await link.apply()
|
||||
world.events.length = 0
|
||||
|
||||
const status = await link.apply({ enabled: false })
|
||||
|
||||
expect(status).toMatchObject({ enabled: false, codexFound: true, bridgeRunning: false, lastError: null })
|
||||
expect(world.events).toEqual([
|
||||
`write ${PATHS.settingsFile}`,
|
||||
`write ${PATHS.codexConfig}`,
|
||||
'autostart clear',
|
||||
'stop'
|
||||
])
|
||||
expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({
|
||||
enabled: false,
|
||||
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()
|
||||
expect(world.listening).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the bridge and login item if disconnect cannot restore the config', async () => {
|
||||
const { link, world } = createWorld({ listening: true })
|
||||
await link.apply()
|
||||
const linkedConfig = world.files.get(PATHS.codexConfig)
|
||||
const autostart = world.autostart
|
||||
world.events.length = 0
|
||||
world.configWriteFails = true
|
||||
|
||||
expect(await link.apply({ enabled: false })).toMatchObject({
|
||||
bridgeRunning: true,
|
||||
lastError: 'config-write-failed'
|
||||
})
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(linkedConfig)
|
||||
expect(world.autostart).toBe(autostart)
|
||||
expect(world.events).not.toContain('stop')
|
||||
expect(world.events).not.toContain('autostart clear')
|
||||
|
||||
world.configWriteFails = false
|
||||
expect(await link.apply({ enabled: false })).toMatchObject({ bridgeRunning: false, lastError: null })
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
|
||||
})
|
||||
|
||||
it('stays off across applies and comes back when turned on', async () => {
|
||||
const { link, world } = createWorld()
|
||||
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([])
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
|
||||
|
||||
expect(await link.apply({ enabled: true })).toMatchObject({ enabled: true, bridgeRunning: true })
|
||||
expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({
|
||||
enabled: true,
|
||||
mod: 'aiturk',
|
||||
kapsam: 'ide',
|
||||
not: 'ours'
|
||||
})
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(`${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
|
||||
})
|
||||
|
||||
it('does not stop a bridge that serves a foreign config when turned off', async () => {
|
||||
const foreign = 'openai_base_url = "http://127.0.0.1:8799/backend-api/codex"\n'
|
||||
const { link, world } = createWorld({ listening: true })
|
||||
world.files.set(PATHS.codexConfig, foreign)
|
||||
|
||||
const status = await link.apply({ enabled: false })
|
||||
|
||||
expect(status).toMatchObject({ enabled: false, foreign: true, bridgeRunning: true })
|
||||
expect(world.events).not.toContain('stop')
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(foreign)
|
||||
expect(world.listening).toBe(true)
|
||||
})
|
||||
|
||||
it('runs concurrent applies one after another', async () => {
|
||||
const { link, world } = createWorld({ probesUntilUp: 3 })
|
||||
|
||||
const [first, second] = await Promise.all([link.apply(), link.apply({ enabled: false })])
|
||||
|
||||
expect(first.enabled).toBe(true)
|
||||
expect(second).toMatchObject({ enabled: false, bridgeRunning: false })
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
|
||||
expect(world.autostart).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('reads without changing anything', async () => {
|
||||
const { link, world } = createWorld({ listening: true })
|
||||
|
||||
const status = await link.getStatus()
|
||||
|
||||
expect(status).toMatchObject({
|
||||
enabled: true,
|
||||
codexFound: true,
|
||||
venvReady: true,
|
||||
foreign: false,
|
||||
bridgeRunning: true,
|
||||
ideDurum: 'tamam',
|
||||
ideModel: 7,
|
||||
ozet: true
|
||||
})
|
||||
expect(effects(world)).toEqual([])
|
||||
})
|
||||
|
||||
it('reports a bridge that is not answering', async () => {
|
||||
const { link, world } = createWorld()
|
||||
|
||||
expect(await link.getStatus()).toMatchObject({ bridgeRunning: false, ideDurum: null, ideModel: null, ozet: null })
|
||||
expect(effects(world)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,538 @@
|
||||
/**
|
||||
* Codex Link ("Codex Bağlama") — decisions and side effects.
|
||||
*
|
||||
* When the feature is on and OpenAI Codex is installed on this machine, the IDE
|
||||
* runs a small local Python bridge, points Codex's `config.toml` at it and
|
||||
* registers the bridge as a login item, so Codex keeps working with the IDE's
|
||||
* models while the IDE itself is closed. Turning it off undoes all three.
|
||||
*
|
||||
* Ordering is the safety property: Codex cannot work at all while its config
|
||||
* points at a dead address, so the config line is written only AFTER the bridge
|
||||
* has answered, and it is taken back out if the bridge cannot be brought up.
|
||||
*
|
||||
* Electron-free on purpose. `createCodexLink` takes every effect as a
|
||||
* dependency (files, spawn, HTTP, registry) so the ordering above is provable
|
||||
* in a unit test; `createNodeCodexLinkDeps` is the real wiring main.ts uses.
|
||||
* The text transformations live in `codex-link-config.ts`.
|
||||
*/
|
||||
|
||||
import { execFile, spawn } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
addCodexLinkLine,
|
||||
CODEX_BRIDGE_VERSION,
|
||||
CODEX_LINK_LAUNCHER_NAME,
|
||||
CODEX_LINK_REFRESH_URL,
|
||||
CODEX_LINK_RUN_KEY,
|
||||
CODEX_LINK_RUN_VALUE,
|
||||
CODEX_LINK_STATUS_URL,
|
||||
CODEX_LINK_STOP_URL,
|
||||
codexAutostartPlan,
|
||||
codexBridgeArgs,
|
||||
type CodexBridgeLaunch,
|
||||
inspectCodexConfig,
|
||||
parseCodexLinkSettings,
|
||||
removeCodexLinkLine,
|
||||
serializeCodexBridgeConfig,
|
||||
serializeCodexLinkSettings
|
||||
} from './codex-link-config'
|
||||
import type { CodexLinkError, CodexLinkIdeState, CodexLinkPatch, CodexLinkStatus } from './codex-link-types'
|
||||
import { hiddenWindowsChildOptions } from './windows-child-options'
|
||||
|
||||
export type { CodexLinkError, CodexLinkIdeState, CodexLinkPatch, CodexLinkStatus } from './codex-link-types'
|
||||
|
||||
export interface CodexLinkPaths {
|
||||
/** `<data root>/codex-baglama.json` */
|
||||
settingsFile: string
|
||||
/** `<data root>/codex-bridge` — the bridge's data folder. */
|
||||
bridgeHome: string
|
||||
hermesHome: string
|
||||
/** `<resources>/codex-bridge/bridge.py` */
|
||||
bridgeScript: string
|
||||
/** Windowless interpreter, preferred. */
|
||||
pythonw: string
|
||||
/** Console interpreter, used hidden when pythonw is absent. */
|
||||
python: string
|
||||
/** `<CODEX_HOME or ~/.codex>/config.toml` */
|
||||
codexConfig: string
|
||||
}
|
||||
|
||||
export interface CodexLinkDeps {
|
||||
paths: CodexLinkPaths
|
||||
exists: (file: string) => boolean
|
||||
/** null when the file is missing; throws when it cannot be read losslessly. */
|
||||
readText: (file: string) => null | string
|
||||
/** tmp + rename; creates the parent folder. */
|
||||
writeTextAtomic: (file: string, text: string) => void
|
||||
/** Start the bridge detached and hidden; it must outlive the IDE. */
|
||||
spawnBridge: (command: string, args: string[], env: Record<string, string>) => void
|
||||
/** Parsed JSON body, or null when nothing answered within `budgetMs`. */
|
||||
request: (method: 'GET' | 'POST', url: string, budgetMs: number) => Promise<unknown>
|
||||
setAutostart: (command: string) => Promise<void>
|
||||
clearAutostart: () => Promise<void>
|
||||
sleep: (ms: number) => Promise<void>
|
||||
now: () => number
|
||||
log: (message: string) => void
|
||||
}
|
||||
|
||||
// The single wait this feature owns: how long a freshly spawned bridge gets to
|
||||
// answer its status endpoint before the apply gives up.
|
||||
export const CODEX_BRIDGE_START_BUDGET_MS = 45_000
|
||||
const START_POLL_INTERVAL_MS = 500
|
||||
// A probe of an already-running (or absent) local bridge. Loopback answers or
|
||||
// refuses at once; the bound only keeps Settings from hanging on a wedged port.
|
||||
const PROBE_BUDGET_MS = 2_000
|
||||
|
||||
interface BridgeProbe {
|
||||
ideDurum: CodexLinkIdeState | null
|
||||
ideModel: null | number
|
||||
ozet: boolean | null
|
||||
running: boolean
|
||||
surum: null | string
|
||||
}
|
||||
|
||||
const NOT_RUNNING: BridgeProbe = { ideDurum: null, ideModel: null, ozet: null, running: false, surum: null }
|
||||
|
||||
function readProbe(body: unknown): BridgeProbe {
|
||||
if (!body || typeof body !== 'object' || (body as { ok?: unknown }).ok !== true) {
|
||||
return NOT_RUNNING
|
||||
}
|
||||
|
||||
const reply = body as { ide_durum?: unknown; ide_model?: unknown; ozet?: unknown; surum?: unknown }
|
||||
const state = reply.ide_durum
|
||||
|
||||
return {
|
||||
ideDurum: state === 'tamam' || state === 'kapali' || state === 'anahtar-yok' ? state : null,
|
||||
ideModel: typeof reply.ide_model === 'number' && Number.isFinite(reply.ide_model) ? reply.ide_model : null,
|
||||
ozet: typeof reply.ozet === 'boolean' ? reply.ozet : null,
|
||||
running: true,
|
||||
surum: typeof reply.surum === 'string' ? reply.surum : null
|
||||
}
|
||||
}
|
||||
|
||||
function describe(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
export interface CodexLink {
|
||||
/** Side-effect free: reads the preference, the files and the status endpoint. */
|
||||
getStatus: () => Promise<CodexLinkStatus>
|
||||
/** Persist `patch.enabled` (if given), make the machine match, report. */
|
||||
apply: (patch?: CodexLinkPatch) => Promise<CodexLinkStatus>
|
||||
}
|
||||
|
||||
export function createCodexLink(deps: CodexLinkDeps): CodexLink {
|
||||
const { paths } = deps
|
||||
let lastError: CodexLinkError | null = null
|
||||
let queue: Promise<unknown> = Promise.resolve()
|
||||
|
||||
const launch: CodexBridgeLaunch = {
|
||||
bridgeHome: paths.bridgeHome,
|
||||
bridgeScript: paths.bridgeScript,
|
||||
hermesHome: paths.hermesHome
|
||||
}
|
||||
|
||||
const fail = (code: CodexLinkError, error?: unknown) => {
|
||||
lastError = code
|
||||
deps.log(`[codex-link] ${code}${error === undefined ? '' : `: ${describe(error)}`}`)
|
||||
}
|
||||
|
||||
const readSettings = () => {
|
||||
try {
|
||||
return parseCodexLinkSettings(deps.readText(paths.settingsFile))
|
||||
} catch {
|
||||
return parseCodexLinkSettings(null)
|
||||
}
|
||||
}
|
||||
|
||||
// The interpreter to run the bridge with, or null while the IDE's own
|
||||
// first-run setup has not produced the venv yet.
|
||||
const interpreter = () =>
|
||||
deps.exists(paths.pythonw) ? paths.pythonw : deps.exists(paths.python) ? paths.python : null
|
||||
|
||||
// The owner of Codex's base URL. An unreadable config counts as foreign for
|
||||
// the purpose of "do not touch": we only edit what we could read back intact.
|
||||
const readCodexConfig = (): { found: boolean; owner: 'absent' | 'foreign' | 'ours'; text: null | string } => {
|
||||
if (!deps.exists(paths.codexConfig)) {
|
||||
return { found: false, owner: 'absent', text: null }
|
||||
}
|
||||
|
||||
try {
|
||||
const text = deps.readText(paths.codexConfig)
|
||||
|
||||
return text === null
|
||||
? { found: false, owner: 'absent', text: null }
|
||||
: { found: true, owner: inspectCodexConfig(text), text }
|
||||
} catch (error) {
|
||||
deps.log(`[codex-link] config.toml could not be read: ${describe(error)}`)
|
||||
|
||||
return { found: true, owner: 'foreign', text: null }
|
||||
}
|
||||
}
|
||||
|
||||
const probe = async (budgetMs = PROBE_BUDGET_MS): Promise<BridgeProbe> => {
|
||||
try {
|
||||
return readProbe(await deps.request('GET', CODEX_LINK_STATUS_URL, budgetMs))
|
||||
} catch {
|
||||
return NOT_RUNNING
|
||||
}
|
||||
}
|
||||
|
||||
const statusFrom = (
|
||||
enabled: boolean,
|
||||
config: { found: boolean; owner: string },
|
||||
bridge: BridgeProbe
|
||||
): CodexLinkStatus => ({
|
||||
enabled,
|
||||
mod: readSettings().mod,
|
||||
codexFound: config.found,
|
||||
venvReady: interpreter() !== null,
|
||||
foreign: config.owner === 'foreign',
|
||||
bridgeRunning: bridge.running,
|
||||
ideDurum: bridge.ideDurum,
|
||||
ideModel: bridge.ideModel,
|
||||
ozet: bridge.ozet,
|
||||
lastError
|
||||
})
|
||||
|
||||
const removeOurLine = (text: null | string): boolean => {
|
||||
if (text === null) {
|
||||
return true
|
||||
}
|
||||
|
||||
const edit = removeCodexLinkLine(text)
|
||||
|
||||
if (!edit.changed) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
deps.writeTextAtomic(paths.codexConfig, edit.text)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
fail('config-write-failed', error)
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the owned URL before stopping its server. A failed config write
|
||||
// must leave the working bridge alive so Codex is not stranded offline.
|
||||
const tearDown = async (config: ReturnType<typeof readCodexConfig>): Promise<BridgeProbe> => {
|
||||
if (!removeOurLine(config.text)) {
|
||||
return probe()
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.clearAutostart()
|
||||
} catch (error) {
|
||||
fail('autostart-failed', error)
|
||||
}
|
||||
|
||||
// A foreign config means whatever listens on the port serves someone
|
||||
// else's setup; stopping it would break a bridge we do not own.
|
||||
if (config.owner === 'foreign') {
|
||||
return probe()
|
||||
}
|
||||
|
||||
try {
|
||||
// No answer means nothing of ours is listening; leave it alone.
|
||||
return (await deps.request('POST', CODEX_LINK_STOP_URL, PROBE_BUDGET_MS)) === null ? probe() : NOT_RUNNING
|
||||
} catch {
|
||||
return probe()
|
||||
}
|
||||
}
|
||||
|
||||
const startBridge = async (python: string): Promise<BridgeProbe> => {
|
||||
try {
|
||||
deps.spawnBridge(python, codexBridgeArgs(launch), {
|
||||
AITURK_CODEX_BRIDGE_HOME: paths.bridgeHome,
|
||||
HERMES_HOME: paths.hermesHome
|
||||
})
|
||||
} catch (error) {
|
||||
deps.log(`[codex-link] bridge spawn failed: ${describe(error)}`)
|
||||
|
||||
return NOT_RUNNING
|
||||
}
|
||||
|
||||
const deadline = deps.now() + CODEX_BRIDGE_START_BUDGET_MS
|
||||
|
||||
for (;;) {
|
||||
const remaining = deadline - deps.now()
|
||||
|
||||
if (remaining <= 0) {
|
||||
return NOT_RUNNING
|
||||
}
|
||||
|
||||
const result = await probe(Math.min(PROBE_BUDGET_MS, remaining))
|
||||
|
||||
if (result.running) {
|
||||
return result
|
||||
}
|
||||
|
||||
await deps.sleep(Math.min(START_POLL_INTERVAL_MS, Math.max(0, deadline - deps.now())))
|
||||
}
|
||||
}
|
||||
|
||||
// On: bridge.json → bridge up (verified) → config.toml → login item.
|
||||
const setUp = async (python: string, config: ReturnType<typeof readCodexConfig>): Promise<BridgeProbe> => {
|
||||
if (!deps.exists(paths.bridgeScript)) {
|
||||
fail('bridge-missing', paths.bridgeScript)
|
||||
|
||||
return probe()
|
||||
}
|
||||
|
||||
try {
|
||||
deps.writeTextAtomic(path.join(paths.bridgeHome, 'bridge.json'), serializeCodexBridgeConfig())
|
||||
} catch (error) {
|
||||
fail('bridge-config-failed', error)
|
||||
|
||||
return probe()
|
||||
}
|
||||
|
||||
let bridge = await probe()
|
||||
|
||||
if (bridge.running && bridge.surum !== CODEX_BRIDGE_VERSION) {
|
||||
// A bridge from an older IDE build is still listening: replace it.
|
||||
try {
|
||||
await deps.request('POST', CODEX_LINK_STOP_URL, PROBE_BUDGET_MS)
|
||||
} catch {
|
||||
// It stops answering once it exits; the start below verifies the new one.
|
||||
}
|
||||
|
||||
await deps.sleep(START_POLL_INTERVAL_MS * 2)
|
||||
bridge = NOT_RUNNING
|
||||
}
|
||||
|
||||
if (bridge.running) {
|
||||
// Already up: have it re-read the IDE session and model list now (a sign-in
|
||||
// since its last periodic refresh would otherwise show up minutes later).
|
||||
try {
|
||||
const refreshed = readProbe(await deps.request('POST', CODEX_LINK_REFRESH_URL, CODEX_BRIDGE_START_BUDGET_MS))
|
||||
|
||||
bridge = refreshed.running ? refreshed : bridge
|
||||
} catch {
|
||||
// The status it already gave stays valid.
|
||||
}
|
||||
} else {
|
||||
bridge = await startBridge(python)
|
||||
}
|
||||
|
||||
if (!bridge.running) {
|
||||
fail('bridge-start-failed')
|
||||
// Fail open: a line left over from an earlier run would now point Codex
|
||||
// at a dead address. Without it Codex simply talks to OpenAI directly.
|
||||
removeOurLine(config.text)
|
||||
|
||||
return bridge
|
||||
}
|
||||
|
||||
const edit =
|
||||
readSettings().mod === 'aiturk' ? addCodexLinkLine(config.text ?? '') : removeCodexLinkLine(config.text ?? '')
|
||||
|
||||
if (edit.changed) {
|
||||
try {
|
||||
deps.writeTextAtomic(paths.codexConfig, edit.text)
|
||||
} catch (error) {
|
||||
fail('config-write-failed', error)
|
||||
|
||||
return bridge
|
||||
}
|
||||
}
|
||||
|
||||
// Rewritten on every apply: an IDE update can move the bridge script.
|
||||
try {
|
||||
const plan = codexAutostartPlan(python, launch, path.join(paths.bridgeHome, CODEX_LINK_LAUNCHER_NAME))
|
||||
|
||||
if (!plan) {
|
||||
throw new Error('the bridge command does not fit a Run value')
|
||||
}
|
||||
|
||||
if (plan.launcher) {
|
||||
deps.writeTextAtomic(plan.launcher.path, plan.launcher.source)
|
||||
}
|
||||
|
||||
await deps.setAutostart(plan.command)
|
||||
} catch (error) {
|
||||
fail('autostart-failed', error)
|
||||
}
|
||||
|
||||
return bridge
|
||||
}
|
||||
|
||||
const run = async (patch?: CodexLinkPatch): Promise<CodexLinkStatus> => {
|
||||
lastError = null
|
||||
|
||||
if (typeof patch?.enabled === 'boolean' || patch?.mod === 'aiturk' || patch?.mod === 'dogrudan') {
|
||||
try {
|
||||
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.
|
||||
fail('settings-write-failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
const { enabled } = readSettings()
|
||||
const config = readCodexConfig()
|
||||
|
||||
if (!enabled) {
|
||||
return statusFrom(enabled, config, await tearDown(config))
|
||||
}
|
||||
|
||||
const python = interpreter()
|
||||
|
||||
if (!config.found || !python || config.owner === 'foreign') {
|
||||
return statusFrom(enabled, config, await probe())
|
||||
}
|
||||
|
||||
return statusFrom(enabled, config, await setUp(python, config))
|
||||
}
|
||||
|
||||
return {
|
||||
getStatus: async () => statusFrom(readSettings().enabled, readCodexConfig(), await probe()),
|
||||
// One apply at a time: two interleaved runs could write the config line
|
||||
// from one while the other is tearing the bridge down.
|
||||
apply: patch => {
|
||||
const next = queue.then(() => run(patch))
|
||||
queue = next.catch(() => undefined)
|
||||
|
||||
return next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Real dependencies ───────────────────────────────────────────────────────
|
||||
|
||||
function writeTextAtomic(file: string, text: string): void {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
const tmp = `${file}.${process.pid}.tmp`
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tmp, text, 'utf8')
|
||||
fs.renameSync(tmp, file)
|
||||
} catch (error) {
|
||||
fs.rmSync(tmp, { force: true })
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Decode as UTF-8 and insist the bytes survive the round trip. A config in
|
||||
// another encoding would be corrupted by a rewrite, so refuse to return it.
|
||||
function readTextLossless(file: string): null | string {
|
||||
let bytes: Buffer
|
||||
|
||||
try {
|
||||
bytes = fs.readFileSync(file)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
const text = bytes.toString('utf8')
|
||||
|
||||
if (!Buffer.from(text, 'utf8').equals(bytes)) {
|
||||
throw new Error('file is not valid UTF-8')
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
function runReg(args: string[]): Promise<{ code: number; stderr: string }> {
|
||||
const reg = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'reg.exe')
|
||||
|
||||
return new Promise(resolve => {
|
||||
execFile(reg, args, hiddenWindowsChildOptions({ encoding: 'utf8' }), (error, _stdout, stderr) => {
|
||||
const exit = error ? (typeof (error as { code?: unknown }).code === 'number' ? (error as any).code : 1) : 0
|
||||
resolve({ code: exit, stderr: String(stderr || (error ? error.message : '')).trim() })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function requestJson(method: 'GET' | 'POST', url: string, budgetMs: number): Promise<unknown> {
|
||||
try {
|
||||
const response = await fetch(url, { method, signal: AbortSignal.timeout(Math.max(1, budgetMs)) })
|
||||
|
||||
return response.ok ? await response.json() : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function createNodeCodexLinkDeps(paths: CodexLinkPaths, log: (message: string) => void): CodexLinkDeps {
|
||||
return {
|
||||
paths,
|
||||
exists: file => fs.existsSync(file),
|
||||
readText: readTextLossless,
|
||||
writeTextAtomic,
|
||||
spawnBridge: (command, args, env) => {
|
||||
fs.mkdirSync(paths.bridgeHome, { recursive: true })
|
||||
|
||||
// Detached with no stdio and unref'd: the bridge is deliberately NOT a
|
||||
// child the IDE cleans up on quit — Codex depends on it staying alive.
|
||||
const child = spawn(
|
||||
command,
|
||||
args,
|
||||
hiddenWindowsChildOptions({
|
||||
cwd: paths.bridgeHome,
|
||||
detached: true,
|
||||
env: { ...process.env, ...env },
|
||||
stdio: 'ignore'
|
||||
}) as any
|
||||
)
|
||||
|
||||
child.on('error', error => log(`[codex-link] bridge process error: ${error.message}`))
|
||||
child.unref()
|
||||
},
|
||||
request: requestJson,
|
||||
setAutostart: async command => {
|
||||
const result = await runReg([
|
||||
'add',
|
||||
CODEX_LINK_RUN_KEY,
|
||||
'/v',
|
||||
CODEX_LINK_RUN_VALUE,
|
||||
'/t',
|
||||
'REG_SZ',
|
||||
'/d',
|
||||
command,
|
||||
'/f'
|
||||
])
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`reg add exited ${result.code}: ${result.stderr}`)
|
||||
}
|
||||
},
|
||||
clearAutostart: async () => {
|
||||
// `reg delete` fails on a value that is not there; absent is already the
|
||||
// state we want, so look first.
|
||||
const present = await runReg(['query', CODEX_LINK_RUN_KEY, '/v', CODEX_LINK_RUN_VALUE])
|
||||
|
||||
if (present.code !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await runReg(['delete', CODEX_LINK_RUN_KEY, '/v', CODEX_LINK_RUN_VALUE, '/f'])
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`reg delete exited ${result.code}: ${result.stderr}`)
|
||||
}
|
||||
},
|
||||
sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
|
||||
now: () => Date.now(),
|
||||
log
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,3 +131,37 @@ test('remote apply without a waiter has no first-run side effects', async () =>
|
||||
assert.equal(hidden, 0)
|
||||
assert.equal(gate.isLocalBootstrapConfirmed(), true)
|
||||
})
|
||||
|
||||
test('a completed shared runtime wakes only its own pending setup, without granting installation consent', async () => {
|
||||
let hidden = 0
|
||||
const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 })
|
||||
const pending = gate.wait(bootstrapBackend)
|
||||
assert.equal(gate.runtimeAvailable('/tmp/unrelated-runtime'), false)
|
||||
assert.equal(await settledState(pending), 'pending')
|
||||
assert.equal(gate.runtimeAvailable(bootstrapBackend.activeRoot), true)
|
||||
assert.equal(await pending, 'runtime-ready')
|
||||
assert.equal(gate.isLocalBootstrapConfirmed(), false)
|
||||
assert.equal(hidden, 1)
|
||||
assert.equal(gate.runtimeAvailable(bootstrapBackend.activeRoot), false)
|
||||
})
|
||||
|
||||
test('late runtime readiness does not revive a reset or remotely resolved waiter', async () => {
|
||||
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
|
||||
const pending = gate.wait(bootstrapBackend)
|
||||
gate.resetForRetry()
|
||||
assert.equal(await pending, 'reset')
|
||||
assert.equal(gate.runtimeAvailable(bootstrapBackend.activeRoot), false)
|
||||
})
|
||||
|
||||
test('runtime completed before wait does not leave a stale setup prompt pending', async () => {
|
||||
let prompts = 0
|
||||
const gate = createFirstRunSetupGate({
|
||||
isRuntimeAvailable: backend => backend.activeRoot === bootstrapBackend.activeRoot,
|
||||
promptChoice: () => prompts++,
|
||||
stuckAfterMs: 0
|
||||
})
|
||||
assert.equal(await gate.wait(bootstrapBackend), 'runtime-ready')
|
||||
assert.equal(gate.hasWaiter(), false)
|
||||
assert.equal(gate.isLocalBootstrapConfirmed(), false)
|
||||
assert.equal(prompts, 0)
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import path from 'node:path'
|
||||
|
||||
interface FirstRunSetupBackend {
|
||||
activeRoot?: string
|
||||
kind?: string
|
||||
@@ -5,6 +7,7 @@ interface FirstRunSetupBackend {
|
||||
}
|
||||
|
||||
interface FirstRunSetupGateOptions {
|
||||
isRuntimeAvailable?: (backend: FirstRunSetupBackend) => boolean
|
||||
hideChoice?: () => void
|
||||
log?: (message: string) => void
|
||||
onStuck?: (backend: FirstRunSetupBackend, stuckAfterMs: number) => void
|
||||
@@ -12,9 +15,10 @@ interface FirstRunSetupGateOptions {
|
||||
stuckAfterMs?: number
|
||||
}
|
||||
|
||||
export type FirstRunSetupDecision = 'continue-local' | 'remote-applied' | 'reset'
|
||||
export type FirstRunSetupDecision = 'continue-local' | 'remote-applied' | 'reset' | 'runtime-ready'
|
||||
|
||||
export function createFirstRunSetupGate({
|
||||
isRuntimeAvailable,
|
||||
hideChoice,
|
||||
log,
|
||||
onStuck,
|
||||
@@ -24,6 +28,7 @@ export function createFirstRunSetupGate({
|
||||
let localBootstrapConfirmed = false
|
||||
|
||||
let waiter: {
|
||||
activeRoot?: string
|
||||
promise: Promise<FirstRunSetupDecision>
|
||||
resolve: (decision: FirstRunSetupDecision) => void
|
||||
} | null = null
|
||||
@@ -65,6 +70,13 @@ export function createFirstRunSetupGate({
|
||||
return 'continue-local' as const
|
||||
}
|
||||
|
||||
// Recheck synchronously before registering the waiter: another profile
|
||||
// may have completed installation before this stale descriptor arrived.
|
||||
if (backend && isRuntimeAvailable?.(backend)) {
|
||||
if (backend.activeRoot) runtimeAvailable(backend.activeRoot)
|
||||
return 'runtime-ready' as const
|
||||
}
|
||||
|
||||
if (waiter) {
|
||||
return waiter.promise
|
||||
}
|
||||
@@ -78,7 +90,7 @@ export function createFirstRunSetupGate({
|
||||
resolveWaiter = resolve
|
||||
})
|
||||
|
||||
waiter = { promise, resolve: resolveWaiter }
|
||||
waiter = { activeRoot: backend?.activeRoot, promise, resolve: resolveWaiter }
|
||||
|
||||
return promise
|
||||
}
|
||||
@@ -102,6 +114,21 @@ export function createFirstRunSetupGate({
|
||||
settleWaiter('continue-local')
|
||||
}
|
||||
|
||||
// A sibling profile may finish installing the SAME shared runtime while the
|
||||
// primary boot is awaiting a setup choice. Re-resolve it; do not interpret
|
||||
// this as permission for another installation, or reuse the sibling's
|
||||
// profile/backend descriptor.
|
||||
const runtimeAvailable = (root: string) => {
|
||||
const canonical = (value: string) => {
|
||||
const absolute = path.resolve(value)
|
||||
return process.platform === 'win32' ? absolute.toLowerCase() : absolute
|
||||
}
|
||||
if (!waiter?.activeRoot || canonical(waiter.activeRoot) !== canonical(root)) return false
|
||||
settleWaiter('runtime-ready')
|
||||
hideChoice?.()
|
||||
return true
|
||||
}
|
||||
|
||||
const resetForRetry = () => {
|
||||
// Reset paths are followed by a renderer reload / fresh startHermes() call.
|
||||
// Settle the old boot explicitly so it cannot fall through into local
|
||||
@@ -140,6 +167,7 @@ export function createFirstRunSetupGate({
|
||||
isLocalBootstrapConfirmed,
|
||||
resetForRepair,
|
||||
resetForRetry,
|
||||
runtimeAvailable,
|
||||
shouldGate,
|
||||
wait
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { execFile, execFileSync, spawn } from 'node:child_process'
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import http from 'node:http'
|
||||
@@ -6,8 +6,10 @@ import https from 'node:https'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import tls from 'node:tls'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url'
|
||||
import { startChromeBridge } from './chrome-bridge.mjs'
|
||||
import { createMobileWindow } from './mobile-host.mjs'
|
||||
import { MobileVault } from './mobile-secrets.mjs'
|
||||
|
||||
import {
|
||||
app,
|
||||
@@ -30,8 +32,21 @@ import {
|
||||
shell,
|
||||
systemPreferences
|
||||
} from 'electron'
|
||||
import { WebSocket as RelayWebSocket } from 'ws'
|
||||
|
||||
import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state'
|
||||
import { createAiturkCliRunner, createAiturkLogin } from './aiturk-login'
|
||||
import { createCodexLink, createNodeCodexLinkDeps } from './codex-link'
|
||||
import { createMobileBackendResolver } from './mobile-backend'
|
||||
import {
|
||||
createMobileLink,
|
||||
createMobileLinkFileStore,
|
||||
createWindowsFirewall,
|
||||
defaultRouteAddress,
|
||||
type MobileLink,
|
||||
selectLanAddresses
|
||||
} from './mobile-lan-proxy'
|
||||
import { describeVcRuntimeResult, ensureWindowsVcRuntime } from './windows-vc-runtime'
|
||||
import { destroyKeepaliveAgents, downloadAgentFor, jsonAgentFor, withRetry } from './api-transport'
|
||||
import { appIconCandidates, resolveAppIcon } from './app-icon'
|
||||
import { stopBackendChild as stopBackendChildImpl, stopBackendTreesForUpdate } from './backend-child'
|
||||
@@ -363,6 +378,8 @@ import { ensureLoginShellPath } from './shell-path'
|
||||
import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator'
|
||||
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'
|
||||
@@ -2062,6 +2079,10 @@ function hideFirstRunSetupChoice() {
|
||||
function getFirstRunSetupGate() {
|
||||
if (!firstRunSetupGate) {
|
||||
firstRunSetupGate = createFirstRunSetupGate({
|
||||
isRuntimeAvailable: backend =>
|
||||
backend.activeRoot === ACTIVE_HERMES_ROOT &&
|
||||
resolveHermesBackend(['--profile', primaryProfileKey(), 'serve', '--host', '127.0.0.1', '--port', '0']).kind !==
|
||||
'bootstrap-needed',
|
||||
hideChoice: hideFirstRunSetupChoice,
|
||||
log: rememberLog,
|
||||
onStuck: (_backend, stuckAfterMs) => {
|
||||
@@ -5220,6 +5241,22 @@ async function ensureRuntime(backend) {
|
||||
)
|
||||
}
|
||||
|
||||
// Voice wheels (onnxruntime, ctranslate2) need msvcp140*.dll, which the
|
||||
// managed CPython does not ship. Place the bundled Microsoft CRT beside the
|
||||
// managed python.exe before the backend imports anything. Never blocks boot:
|
||||
// only the voice features depend on it.
|
||||
if (IS_WINDOWS) {
|
||||
const vcRuntime = ensureWindowsVcRuntime({
|
||||
platform: process.platform,
|
||||
venvRoot: VENV_ROOT,
|
||||
sourceDir: IS_PACKAGED
|
||||
? path.join(process.resourcesPath, 'vc-runtime', 'x64')
|
||||
: path.join(APP_ROOT, 'assets', 'windows', 'vc-runtime', 'x64')
|
||||
})
|
||||
|
||||
rememberLog(`[vc-runtime] ${describeVcRuntimeResult(vcRuntime)}`)
|
||||
}
|
||||
|
||||
backend.command = getVenvPython(VENV_ROOT)
|
||||
backend.label = `Hermes at ${ACTIVE_HERMES_ROOT} (venv: ${VENV_ROOT})`
|
||||
updateBootProgress({
|
||||
@@ -5230,6 +5267,11 @@ async function ensureRuntime(backend) {
|
||||
error: null
|
||||
})
|
||||
|
||||
// Runtime installation is shared; profile processes and their data are not.
|
||||
// Wake only a setup waiter for this exact installed root, then let primary
|
||||
// startup resolve its own profile again (never adopt a pool connection).
|
||||
firstRunSetupGate?.runtimeAvailable(ACTIVE_HERMES_ROOT)
|
||||
|
||||
return backend
|
||||
}
|
||||
|
||||
@@ -6766,7 +6808,7 @@ function buildApplicationMenu() {
|
||||
|
||||
const checkForUpdatesItem = {
|
||||
label: 'Check for Updates…',
|
||||
click: () => IS_PACKAGED ? void shell.openExternal(AITURK_PRODUCT.downloads) : sendOpenUpdatesRequested()
|
||||
click: () => (IS_PACKAGED ? void shell.openExternal(AITURK_PRODUCT.downloads) : sendOpenUpdatesRequested())
|
||||
}
|
||||
|
||||
if (IS_MAC) {
|
||||
@@ -13052,6 +13094,9 @@ async function startHermes() {
|
||||
// accumulated count of the resolved episode.
|
||||
bootstrapRepairAttempt = 0
|
||||
|
||||
// The local backend is up, so the venv the Codex bridge runs on exists.
|
||||
syncCodexLinkOnBoot()
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'local',
|
||||
@@ -17143,6 +17188,141 @@ ipcMain.handle('hermes:quick-entry:settings:set', async (_event, patch) => {
|
||||
return applyQuickEntrySettings(next)
|
||||
})
|
||||
|
||||
// Codex Link ("Codex Bağlama"): lets the OpenAI Codex on this machine use the
|
||||
// IDE's models through a small local bridge. Main owns the preference file, the
|
||||
// bridge process, Codex's config line and the login item; Settings reads the
|
||||
// resulting state and asks for changes over IPC. Windows only (the login item is
|
||||
// a Run-key value). All decisions live in electron/codex-link.ts.
|
||||
const AITURK_IDE_DATA_ROOT = path.dirname(HERMES_HOME)
|
||||
const 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'),
|
||||
value =>
|
||||
execFileSync(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
'Add-Type -AssemblyName System.Security; $bytes=[Text.Encoding]::UTF8.GetBytes([Console]::In.ReadToEnd()); [Console]::Out.Write([Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect($bytes,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser)))'
|
||||
],
|
||||
{ input: value, encoding: 'utf8', windowsHide: true, timeout: 15000 }
|
||||
).trim()
|
||||
)
|
||||
|
||||
const codexLink = createCodexLink(
|
||||
createNodeCodexLinkDeps(
|
||||
{
|
||||
settingsFile: path.join(AITURK_IDE_DATA_ROOT, 'codex-baglama.json'),
|
||||
bridgeHome: path.join(AITURK_IDE_DATA_ROOT, 'codex-bridge'),
|
||||
hermesHome: HERMES_HOME,
|
||||
bridgeScript: IS_PACKAGED
|
||||
? path.join(process.resourcesPath, 'codex-bridge', 'bridge.py')
|
||||
: path.join(APP_ROOT, 'assets', 'codex-bridge', 'bridge.py'),
|
||||
pythonw: path.join(VENV_ROOT, 'Scripts', 'pythonw.exe'),
|
||||
python: getVenvPython(VENV_ROOT),
|
||||
codexConfig: path.join(process.env.CODEX_HOME || path.join(app.getPath('home'), '.codex'), 'config.toml')
|
||||
},
|
||||
rememberLog
|
||||
)
|
||||
)
|
||||
|
||||
let codexLinkBootSynced = false
|
||||
|
||||
// Once per launch, after the local backend is ready. Never allowed to affect
|
||||
// startup: it is not awaited and every failure ends in the log.
|
||||
function syncCodexLinkOnBoot() {
|
||||
if (!IS_WINDOWS || codexLinkBootSynced) {
|
||||
return
|
||||
}
|
||||
|
||||
codexLinkBootSynced = true
|
||||
void codexLink.apply().catch(error => rememberLog(`[codex-link] boot sync failed: ${error?.message ?? error}`))
|
||||
}
|
||||
|
||||
// Only the primary window's top-level frame may read or change the link.
|
||||
function assertCodexLinkSender(event) {
|
||||
const contents = mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null
|
||||
|
||||
if (
|
||||
!IS_WINDOWS ||
|
||||
!contents ||
|
||||
event.sender !== contents ||
|
||||
(event.senderFrame && event.senderFrame !== contents.mainFrame)
|
||||
) {
|
||||
throw new Error('codex-link:unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('aiturk:codex-link:get', async event => {
|
||||
assertCodexLinkSender(event)
|
||||
|
||||
return codexLink.getStatus()
|
||||
})
|
||||
|
||||
async function stagedTransitionState() {
|
||||
const state = stagedTransition.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 models = Array.isArray(body.jev_models)
|
||||
? body.jev_models.filter(m => typeof m?.id === 'string' && typeof m?.name === 'string')
|
||||
: []
|
||||
return { ...state, models, modelsAvailable: Array.isArray(body.jev_models) }
|
||||
} catch {
|
||||
return { ...state, models: [], modelsAvailable: false }
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('aiturk:staged-transition:get', async event => {
|
||||
assertCodexLinkSender(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)
|
||||
return stagedTransitionState()
|
||||
})
|
||||
|
||||
ipcMain.handle('aiturk:codex-link:set', async (event, patch) => {
|
||||
assertCodexLinkSender(event)
|
||||
|
||||
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
|
||||
})
|
||||
})
|
||||
|
||||
// Quick window → main → PRIMARY renderer. We never submit here: the renderer
|
||||
// owns the one prompt-submit path, and forwarding keeps it that way. The
|
||||
// payload is `{ target, text }` — target routing (current chat / a picked
|
||||
@@ -17213,6 +17393,53 @@ ipcMain.handle('hermes:openExternal', (_event, url) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── AITURK one-click sign-in (device code) ───────────────────────────────
|
||||
// The API keys returned by turkservis.online stay in THIS process: the
|
||||
// provider is saved through the same backend route hermes:api uses and the
|
||||
// CLI key is piped to `aiturk login --key-stdin`. The renderer only ever sees
|
||||
// the short user code, the approval URL and a status. See aiturk-login.ts.
|
||||
const aiturkLogin = createAiturkLogin({
|
||||
fetch: (url, init) => fetch(url, init),
|
||||
openExternal: url => openExternalUrl(url),
|
||||
hostname: () => os.hostname(),
|
||||
backendRequest: request => handleHermesApiRequest(request),
|
||||
runCli: createAiturkCliRunner({ spawn, existsSync: fs.existsSync, env: process.env }),
|
||||
now: () => Date.now()
|
||||
})
|
||||
|
||||
// Only the primary window's top-level frame may drive a sign-in.
|
||||
function assertAiturkLoginSender(event) {
|
||||
const contents = mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null
|
||||
|
||||
if (!contents || event.sender !== contents || (event.senderFrame && event.senderFrame !== contents.mainFrame)) {
|
||||
throw new Error('aiturk-login:unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('aiturk:login:start', async (event, scope) => {
|
||||
assertAiturkLoginSender(event)
|
||||
|
||||
return aiturkLogin.start(scope)
|
||||
})
|
||||
|
||||
ipcMain.handle('aiturk:login:poll', async event => {
|
||||
assertAiturkLoginSender(event)
|
||||
|
||||
return aiturkLogin.poll()
|
||||
})
|
||||
|
||||
ipcMain.handle('aiturk:login:cancel', async event => {
|
||||
assertAiturkLoginSender(event)
|
||||
|
||||
return aiturkLogin.cancel()
|
||||
})
|
||||
|
||||
ipcMain.handle('aiturk:login:overwrite-cli', async event => {
|
||||
assertAiturkLoginSender(event)
|
||||
|
||||
return aiturkLogin.overwriteCli()
|
||||
})
|
||||
|
||||
// ── Find-in-page (Ctrl/Cmd+F) ─────────────────────────────────────────────
|
||||
// The desktop supports multiple BrowserWindows (one primary plus any
|
||||
// per-session secondary windows spawned via `hermes:window:openSession`).
|
||||
@@ -17570,8 +17797,11 @@ async function getUninstallSummary() {
|
||||
async function runDesktopUninstall(_mode) {
|
||||
// The upstream cleanup script removes Hermes-specific shortcuts, registry
|
||||
// entries and global launchers. AITURK is owned by its OS package manager.
|
||||
return { ok: false, error: 'aiturk-os-uninstaller',
|
||||
message: 'AITURK IDE işletim sisteminin uygulama yöneticisinden kaldırılır.' }
|
||||
return {
|
||||
ok: false,
|
||||
error: 'aiturk-os-uninstaller',
|
||||
message: 'AITURK IDE işletim sisteminin uygulama yöneticisinden kaldırılır.'
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:uninstall:summary', async () => getUninstallSummary())
|
||||
@@ -17717,6 +17947,10 @@ if (!isPrimaryInstance) {
|
||||
app.exit(0)
|
||||
} else {
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
if (process.platform === 'win32' && argv.includes('--aiturk-mobile')) {
|
||||
openMobileConnectionWindow()
|
||||
return
|
||||
}
|
||||
const url = _extractDeepLink(argv)
|
||||
|
||||
if (url) {
|
||||
@@ -17740,7 +17974,118 @@ app.on('open-url', (event, url) => {
|
||||
handleDeepLink(url)
|
||||
})
|
||||
|
||||
let mobileConnectionWindow: BrowserWindow | null = null
|
||||
function openMobileConnectionWindow() {
|
||||
if (mobileConnectionWindow && !mobileConnectionWindow.isDestroyed()) {
|
||||
mobileConnectionWindow.show()
|
||||
mobileConnectionWindow.focus()
|
||||
return
|
||||
}
|
||||
mobileConnectionWindow = createMobileWindow({
|
||||
assets: path.dirname(fileURLToPath(import.meta.url)),
|
||||
link: getMobileLink()
|
||||
})
|
||||
}
|
||||
|
||||
// "Telefon bağlantısı" (electron/mobile-lan-proxy.ts): TLS LAN proxy in front of
|
||||
// the loopback backend + pairing. One controller for the whole app run; it only
|
||||
// listens while the feature is on and a phone is paired or being paired.
|
||||
let mobileLink: MobileLink | null = null
|
||||
|
||||
function mobileLinkRemoteMode() {
|
||||
return globalRemoteActive() || profileHasRemoteOverride(primaryProfileKey())
|
||||
}
|
||||
|
||||
function getMobileLink(): MobileLink {
|
||||
if (mobileLink) {
|
||||
return mobileLink
|
||||
}
|
||||
|
||||
const directory = path.join(app.getPath('userData'), 'aiturk-mobile', 'mobile')
|
||||
|
||||
mobileLink = createMobileLink({
|
||||
// Same vault file as before (mobile-secrets.bin, safeStorage); older fields are kept.
|
||||
vault: new MobileVault(directory, safeStorage),
|
||||
store: createMobileLinkFileStore(directory, path.join(directory, 'lan-link.json'), fs.promises),
|
||||
resolveBackend: createMobileBackendResolver({
|
||||
isRemote: profile => mobileLinkRemoteMode() || profileHasRemoteOverride(profile),
|
||||
runtimeNeedsInstall: profile =>
|
||||
resolveHermesBackend(['--profile', profile, 'serve', '--host', '127.0.0.1', '--port', '0']).kind ===
|
||||
'bootstrap-needed',
|
||||
ensureBackend
|
||||
}),
|
||||
isRemoteMode: mobileLinkRemoteMode,
|
||||
firewall: createWindowsFirewall({
|
||||
spawn,
|
||||
platform: process.platform,
|
||||
env: process.env,
|
||||
// Read-only rule query (fixed arguments, no shell, no elevation). netsh answers in the
|
||||
// console code page; latin1 keeps every byte and the parser needs no translated word.
|
||||
run: (file, args) =>
|
||||
new Promise(resolve => {
|
||||
execFile(
|
||||
file,
|
||||
args,
|
||||
{ windowsHide: true, timeout: 8000, encoding: 'latin1', maxBuffer: 256 * 1024 },
|
||||
(error, stdout) => {
|
||||
const code = error
|
||||
? typeof (error as { code?: unknown }).code === 'number'
|
||||
? ((error as { code?: unknown }).code as number)
|
||||
: null
|
||||
: 0
|
||||
|
||||
resolve({ code, stdout: String(stdout ?? '') })
|
||||
}
|
||||
)
|
||||
})
|
||||
}),
|
||||
fetch: (url, init) => fetch(url, init as RequestInit),
|
||||
// Outbound relay socket (turkservis.online) and the pinned loopback sockets to our own proxy.
|
||||
WebSocket: RelayWebSocket,
|
||||
addresses: async () => selectLanAddresses(os.networkInterfaces(), await defaultRouteAddress()),
|
||||
version: app.getVersion(),
|
||||
pcName: os.hostname(),
|
||||
platform: process.platform,
|
||||
// Method + path + status only (no token, code, query or body). Successful
|
||||
// API traffic is not logged; pairing and refusals are, for diagnosis. Relay
|
||||
// lines are status changes only ("relay connected", "relay failed:<code>").
|
||||
log: line => {
|
||||
if (/^(?:relay|pair) /.test(line) || / \/aiturk\//.test(line) || / [45]\d\d$/.test(line)) {
|
||||
rememberLog(`[mobile-lan] ${line}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return mobileLink
|
||||
}
|
||||
|
||||
// Only the primary window's top-level frame may open the phone window.
|
||||
ipcMain.handle('aiturk:mobile:open', async event => {
|
||||
assertAiturkLoginSender(event)
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error('aiturk-mobile:unsupported')
|
||||
}
|
||||
|
||||
openMobileConnectionWindow()
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
app.on('will-quit', () => {
|
||||
void mobileLink?.stop().catch(() => undefined)
|
||||
})
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// Explicit development/beta entry point; never enables remote access on boot.
|
||||
if (process.platform === 'win32' && process.argv.includes('--aiturk-mobile')) openMobileConnectionWindow()
|
||||
// Resume the phone link only when the user turned it on earlier AND a phone
|
||||
// is paired; otherwise nothing listens (and no vault / UAC prompt is touched).
|
||||
if (process.platform === 'win32') {
|
||||
void getMobileLink()
|
||||
.start()
|
||||
.catch(error => rememberLog(`[mobile-lan] start failed: ${error instanceof Error ? error.message : 'error'}`))
|
||||
}
|
||||
if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
|
||||
void startChromeBridge({
|
||||
directory: path.join(process.env.LOCALAPPDATA, 'TurkServis', 'AITURK-IDE'),
|
||||
@@ -17751,7 +18096,12 @@ app.whenReady().then(() => {
|
||||
}
|
||||
return ensureBackend(profile)
|
||||
}
|
||||
}).then(stop => app.once('will-quit', () => { void stop() }))
|
||||
})
|
||||
.then(stop =>
|
||||
app.once('will-quit', () => {
|
||||
void stop()
|
||||
})
|
||||
)
|
||||
.catch(() => rememberLog('[chrome] Native bridge could not start.'))
|
||||
}
|
||||
// Warm the login-shell PATH resolution immediately so it usually completes
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { ChromeGatewayClient } from './chrome-bridge.mjs'
|
||||
|
||||
const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
||||
const identifier = value => typeof value === 'string' && /^[a-zA-Z0-9_.:-]{1,160}$/.test(value)
|
||||
|
||||
// Reserved BEFORE the RPC: a crash/timeout is indeterminate, never safe to resend.
|
||||
// Journal stores only hashes and delivery state, no prompt, token, or result.
|
||||
export class DeliveryJournal {
|
||||
constructor(directory) { this.directory = directory; this.writes = Promise.resolve() }
|
||||
async reserve(deviceId, taskId, envelope) {
|
||||
if (!identifier(deviceId) || !identifier(taskId)) throw new Error('Geçersiz görev kimliği.')
|
||||
await mkdir(this.directory, { recursive: true })
|
||||
const file = join(this.directory, `${digest([deviceId, taskId])}.json`)
|
||||
const hash = digest(envelope)
|
||||
try {
|
||||
await writeFile(file, JSON.stringify({ hash, state: 'unknown' }), { flag: 'wx', mode: 0o600, flush: true })
|
||||
return { file, hash, fresh: true }
|
||||
} catch (error) {
|
||||
if (error.code !== 'EEXIST') throw error
|
||||
let previous
|
||||
try { previous = JSON.parse(await readFile(file, 'utf8')) }
|
||||
catch { return { file, hash, fresh: false, state: 'unknown' } }
|
||||
if (previous.hash !== hash) throw new Error('Bu görev kimliği farklı bir içerikle kullanılmış.')
|
||||
return { file, hash, fresh: false, state: previous.state }
|
||||
}
|
||||
}
|
||||
async accepted(reservation) {
|
||||
return this.update(reservation, 'accepted')
|
||||
}
|
||||
update(reservation, state) {
|
||||
const operation = this.writes.then(async () => {
|
||||
const previous = JSON.parse(await readFile(reservation.file, 'utf8'))
|
||||
if (['complete', 'error', 'interrupted'].includes(previous.state)) return
|
||||
const temporary = `${reservation.file}.tmp`
|
||||
await writeFile(temporary, JSON.stringify({ hash: reservation.hash, state }), { mode: 0o600, flush: true })
|
||||
await rename(temporary, reservation.file)
|
||||
})
|
||||
this.writes = operation.catch(() => {})
|
||||
return operation
|
||||
}
|
||||
}
|
||||
|
||||
export class MobileGatewayClient extends ChromeGatewayClient {
|
||||
constructor(resolveBackend, emit, { deviceId, journal, authorize, webSocket } = {}) {
|
||||
super(resolveBackend, emit, webSocket)
|
||||
this.deviceId = deviceId
|
||||
this.journal = journal
|
||||
this.authorize = authorize
|
||||
this.approval = null
|
||||
this.model = null
|
||||
this.chain = Promise.resolve()
|
||||
this.tasks = new Map()
|
||||
}
|
||||
|
||||
onEvent(event) {
|
||||
if (['task.start', 'task.complete'].includes(event?.type)) {
|
||||
const task = this.tasks.get(event.payload?.task_id)
|
||||
if (!task || task.runtimeId !== event.session_id || task.terminal) return
|
||||
const state = event.type === 'task.start' ? 'running' : event.payload.status
|
||||
if (!['running', 'complete', 'error', 'interrupted'].includes(state)) return
|
||||
task.terminal = state !== 'running'
|
||||
task.state = state
|
||||
if (task.terminal) this.journal.update(task.reservation, state).catch(() => {})
|
||||
this.emit({ event: event.type, taskId: task.taskId, sessionId: task.sessionId, status: state,
|
||||
text: typeof event.payload.text === 'string' ? event.payload.text.slice(0, 100000) : '' })
|
||||
return
|
||||
}
|
||||
if (!this.selected || event?.session_id !== this.selected.runtimeId) return
|
||||
if (event.type === 'approval.request') {
|
||||
this.approval = this.approvalView(event.payload)
|
||||
this.emit({ event: 'approval.request', sessionId: this.selected.storedId, approval: this.approval })
|
||||
return
|
||||
}
|
||||
if (event.type === 'message.complete') this.approval = null
|
||||
super.onEvent(event)
|
||||
}
|
||||
|
||||
approvalView(payload) {
|
||||
if (!identifier(payload?.request_id) || typeof payload?.command !== 'string') return null
|
||||
// Retain full backend-redacted operation; never approve a clipped preview.
|
||||
if (payload.command.length > 48000) return null
|
||||
return { requestId: payload.request_id, command: payload.command,
|
||||
reason: typeof payload.reason === 'string' ? payload.reason : '',
|
||||
choices: (payload.choices || []).filter(x => ['once', 'deny'].includes(x)) }
|
||||
}
|
||||
|
||||
handle(message) {
|
||||
const result = this.chain.then(() => this.perform(message))
|
||||
this.chain = result.catch(() => {})
|
||||
return result
|
||||
}
|
||||
|
||||
async perform(message) {
|
||||
if (!message || !identifier(message.id)) throw new Error('Geçersiz istek.')
|
||||
await this.authorize()
|
||||
const params = message.params || {}
|
||||
if (message.action === 'session.open') {
|
||||
const list = await super.handle({ id: message.id, action: 'sessions.list' })
|
||||
if (!list.sessions.some(row => row.id === params.sessionId)) throw new Error('Bu IDE konuşması erişilebilir değil.')
|
||||
const result = await super.handle(message)
|
||||
this.model = result.model
|
||||
const resumed = await this.rpc('session.resume', { session_id: params.sessionId, cols: 96,
|
||||
source: 'desktop', defer_history: true, omit_messages: true, ...this.scope() })
|
||||
this.approval = this.approvalView(resumed.pending_approval)
|
||||
return { ...result, approval: this.approval }
|
||||
}
|
||||
if (message.action === 'task.submit') {
|
||||
const capabilities = await this.rpc('gateway.capabilities', {})
|
||||
if (!capabilities.client_task_tracking) throw new Error('IDE sürümü mobil görev takibini desteklemiyor. IDE güncellemesi gerekli.')
|
||||
if (this.selected?.storedId !== params.sessionId || !params.model || params.model !== this.model)
|
||||
throw new Error('Konuşma veya iş modeli değişti. Hedefi tekrar seçin.')
|
||||
if (typeof params.text !== 'string' || !params.text.trim() || params.text.length > 48000)
|
||||
throw new Error('Görev boş olamaz ve 48.000 karakteri aşamaz.')
|
||||
if (!['research', 'execute'].includes(params.intent)) throw new Error('Görev türü gerekli.')
|
||||
// A model change by the desktop between selection and delivery fails closed.
|
||||
const info = await this.rpc('session.resume', { session_id: params.sessionId, cols: 96,
|
||||
source: 'desktop', defer_history: true, omit_messages: true, ...this.scope() })
|
||||
if (info.info?.model !== params.model || info.session_id !== this.selected.runtimeId)
|
||||
throw new Error('IDE hedefi değişti. Konuşmayı yeniden seçin.')
|
||||
const envelope = { sessionId: params.sessionId, profile: this.backend.profile || 'default',
|
||||
model: params.model, text: params.text, intent: params.intent }
|
||||
const reservation = await this.journal.reserve(this.deviceId, params.taskId, envelope)
|
||||
const correlation = digest([this.deviceId, params.taskId])
|
||||
if (!this.tasks.has(correlation)) this.tasks.set(correlation, { taskId: params.taskId, sessionId: params.sessionId,
|
||||
runtimeId: this.selected.runtimeId, reservation, terminal: false, state: reservation.state || 'unknown' })
|
||||
if (!reservation.fresh) {
|
||||
// Recover a terminal event missed while the phone/companion was closed.
|
||||
// Never resubmit even if the bounded replay window no longer has it.
|
||||
const replay = await this.rpc('session.events.since', { session_id: this.selected.runtimeId, last_seen: 0 })
|
||||
for (const frame of replay.events || []) if (frame.params?.payload?.task_id === correlation) this.onEvent(frame.params)
|
||||
await this.journal.writes
|
||||
const state = this.tasks.get(correlation).state
|
||||
return { taskId: params.taskId, state, duplicate: true, historyRequired: !['complete', 'error', 'interrupted'].includes(state) }
|
||||
}
|
||||
await this.authorize()
|
||||
await this.rpc('prompt.submit', { session_id: this.selected.runtimeId, text: params.text, queued: true,
|
||||
client_task_id: correlation })
|
||||
await this.journal.accepted(reservation)
|
||||
if (!this.tasks.get(correlation).terminal) this.tasks.get(correlation).state = 'accepted'
|
||||
return { taskId: params.taskId, state: 'accepted' }
|
||||
}
|
||||
if (message.action === 'approval.respond') {
|
||||
if (this.selected?.storedId !== params.sessionId || !this.approval ||
|
||||
params.requestId !== this.approval.requestId || !['once', 'deny'].includes(params.choice) ||
|
||||
!this.approval.choices.includes(params.choice)) throw new Error('Onay artık geçerli değil. IDE durumunu yenileyin.')
|
||||
const result = await this.rpc('approval.respond', { session_id: this.selected.runtimeId,
|
||||
request_id: params.requestId, choice: params.choice, all: false })
|
||||
if (!result.resolved) throw new Error('İşlem onayı daha önce yanıtlanmış veya sona ermiş.')
|
||||
this.approval = null
|
||||
return { resolved: true }
|
||||
}
|
||||
if (!['status', 'sessions.list', 'session.history', 'message.stop'].includes(message.action))
|
||||
throw new Error('Bu mobil işlem desteklenmiyor.')
|
||||
if (message.action === 'session.history' && this.selected?.storedId !== params.sessionId)
|
||||
throw new Error('Önce konuşmayı seçin.')
|
||||
return super.handle(message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
|
||||
import { createMobileBackendResolver } from './mobile-backend'
|
||||
|
||||
const local = { mode: 'local', baseUrl: 'http://127.0.0.1:12345', token: 'test-session' }
|
||||
|
||||
it('uses the phone profile even while another desktop profile is awaiting setup', async () => {
|
||||
const ensureBackend = vi.fn(async (profile: string) => {
|
||||
if (profile === 'fusion-frontier') return new Promise<typeof local>(() => {})
|
||||
return local
|
||||
})
|
||||
const resolve = createMobileBackendResolver({
|
||||
isRemote: () => false,
|
||||
runtimeNeedsInstall: () => false,
|
||||
ensureBackend
|
||||
})
|
||||
expect(await resolve('default')).toEqual({ baseUrl: local.baseUrl, token: local.token })
|
||||
expect(ensureBackend).toHaveBeenCalledExactlyOnceWith('default')
|
||||
})
|
||||
|
||||
it('never bootstraps an absent runtime or silently replaces a remote profile with default', async () => {
|
||||
const ensureBackend = vi.fn(async () => local)
|
||||
const resolve = createMobileBackendResolver({
|
||||
isRemote: profile => profile === 'remote',
|
||||
runtimeNeedsInstall: () => true,
|
||||
ensureBackend
|
||||
})
|
||||
await expect(resolve('default')).rejects.toThrow('runtime_not_ready')
|
||||
await expect(resolve('remote')).rejects.toThrow('remote_mode')
|
||||
expect(ensureBackend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves an explicitly selected local profile and rejects remote-mode changes during resolution', async () => {
|
||||
let remote = false
|
||||
const ensureBackend = vi.fn(async () => local)
|
||||
const resolve = createMobileBackendResolver({
|
||||
isRemote: () => remote,
|
||||
runtimeNeedsInstall: () => false,
|
||||
ensureBackend
|
||||
})
|
||||
await resolve('kodlama')
|
||||
expect(ensureBackend).toHaveBeenCalledExactlyOnceWith('kodlama')
|
||||
ensureBackend.mockImplementationOnce(async () => {
|
||||
remote = true
|
||||
return local
|
||||
})
|
||||
await expect(resolve('kodlama')).rejects.toThrow('remote_mode')
|
||||
})
|
||||
Loaded 100 of 304 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user