Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86a6621936 | ||
|
|
5c76c24c9c | ||
|
|
81ee2ca646 | ||
|
|
557e619499 | ||
|
|
a7ba300448 | ||
|
|
7c4ef18df0 | ||
|
|
8f53b090f7 | ||
|
|
049c4d1338 | ||
|
|
4924781e2d | ||
|
|
4037c65204 | ||
|
|
e7d3080e0a | ||
|
|
db5e876f40 | ||
|
|
ab7a721fbc | ||
|
|
2cca6a66a6 | ||
|
|
a68d3e2272 | ||
|
|
dfa762bcd2 | ||
|
|
912752bdd3 | ||
|
|
91ecad8e5c | ||
|
|
1c6e3656b8 | ||
|
|
883b1dc824 | ||
|
|
369f6c2ca8 | ||
|
|
2718b460ed | ||
|
|
b4e8664115 | ||
|
|
b66d9164ad | ||
|
|
eeee2756c7 | ||
|
|
017c6629d2 |
No files matched your search
@@ -23,6 +23,9 @@ jobs:
|
||||
run: |
|
||||
set -eu
|
||||
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends ripgrep
|
||||
rg --version
|
||||
python3 -m venv "$RUNNER_TEMP/bootstrap"
|
||||
"$RUNNER_TEMP/bootstrap/bin/pip" install uv
|
||||
"$RUNNER_TEMP/bootstrap/bin/uv" venv --python 3.11 "$RUNNER_TEMP/ci-venv"
|
||||
@@ -32,6 +35,14 @@ jobs:
|
||||
"$RUNNER_TEMP/ci-venv/bin/python" -m pytest tests/tools/test_protected_instruction_unattended.py tests/tools/test_file_write_safety.py tests/tools/test_computer_use_approval_isolation.py tests/cli/test_cli_approval_ui.py tests/cli/test_single_query_session_finalize.py -q -p no:cacheprovider -rf
|
||||
- name: Codex bridge regression tests
|
||||
run: '"$RUNNER_TEMP/ci-venv/bin/python" -m unittest discover -s apps/desktop/assets/codex-bridge'
|
||||
- name: Managed Python update preservation
|
||||
run: '"$RUNNER_TEMP/ci-venv/bin/python" -m pytest tests/test_install_ps1_reuse_healthy_venv.py tests/test_install_ps1_python_fallback_venv.py -q -p no:cacheprovider -rf'
|
||||
- name: Project scoped and bounded search regressions
|
||||
run: '"$RUNNER_TEMP/ci-venv/bin/python" -m pytest tests/tools/test_search_project_scope.py tests/tools/test_search_files_cpu_windows.py tests/tools/test_search_zero_match_and_multipath.py tests/tools/test_search_giant_line_containment.py tests/tools/test_search_budget_truncation.py -q -p no:cacheprovider -rf'
|
||||
- name: Desktop browser control lane (AITURK Chrome)
|
||||
run: '"$RUNNER_TEMP/ci-venv/bin/python" -m pytest tests/gateway/test_browser_control_local_lane.py tests/gateway/test_browser_control_broker.py tests/gateway/test_browser_control_broker_hardening.py tests/gateway/test_browser_control_cloud.py tests/tools/test_browser_extension_router.py tests/tools/test_browser_extension_router_wiring.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
|
||||
@@ -54,8 +65,19 @@ jobs:
|
||||
github-server-url: https://gitea.turkservis.online
|
||||
- name: Install locked source dependencies
|
||||
run: npm ci --no-audit --no-fund
|
||||
- name: Chrome extension, agent control and bridge tests
|
||||
run: node --test apps/chrome/tests/agent.test.mjs apps/chrome/tests/bridge.test.mjs apps/chrome/tests/panel.test.mjs
|
||||
- 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
|
||||
|
||||
+21
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+25
-3
@@ -1,7 +1,8 @@
|
||||
AITURK Chrome — Windows beta 0.1.0
|
||||
AITURK Chrome — Windows beta 0.2.0
|
||||
|
||||
GEREKSİNİMLER
|
||||
Windows 10/11, Chrome 116 veya üzeri, AITURK IDE 1.0.0-beta.7 veya üzeri.
|
||||
Windows 10/11, Chrome 116 veya üzeri, AITURK IDE 1.0.0-beta.42 veya üzeri
|
||||
(ajanın Chrome'u kullanması için; konuşma paneli eski IDE'lerde de çalışır).
|
||||
Eski IDE sürümleri bu eklentiye bağlanamaz. Yerel IDE açık olmalıdır.
|
||||
İlk beta yalnızca IDE'nin etkin yerel profilini destekler; uzak sunucu/bulut
|
||||
konuşmaları ve başka bilgisayardaki konuşmalar bu sürümde desteklenmez.
|
||||
@@ -27,6 +28,26 @@ Görüntülenen uzun mesajlar kısaltılabilir. Modelin mevcut bağlamı değiş
|
||||
"Yenile" konuşma listesini ve seçili geçmişi tekrar okur.
|
||||
Ctrl+Enter mesaj gönderir. Enter yeni satır ekler.
|
||||
|
||||
AJAN BU CHROME'U KULLANSIN (YENİ)
|
||||
Paneldeki "Ajan bu Chrome'u kullanabilsin" anahtarı açıkken (varsayılan açık)
|
||||
AITURK IDE ajanı tarayıcı işlerini bu Chrome'da yapar: sayfa açar, okur,
|
||||
tıklar, yazar, kaydırır, geri gider, tuşa basar, görselleri listeler ve ekran
|
||||
görüntüsüne bakar. Panel kapalı olsa da çalışır; IDE açık olmalıdır.
|
||||
- Ajan yalnız kendi sekmelerinde çalışır; bunlar "AITURK" sekme grubunda,
|
||||
arka planda açılır. Kendi sekmenizi vermek için panelde "Bu sekmeyi ajana
|
||||
ver"e ya da sağ tıkta "Bu sekmeyi AITURK ajanına ver"e basın.
|
||||
- Ajan çalışırken Chrome üstte "hata ayıklama" uyarısı gösterir. Uyarıdaki
|
||||
İptal, paneldeki Durdur ya da anahtarı kapatmak ajanı hemen durdurur.
|
||||
- Ajan şifre ve kart numarası alanlarına yazmaz; bu adımları size bırakır ve
|
||||
bildirim gösterir. chrome://, eklenti, dosya ve Web Mağazası sayfalarına girmez.
|
||||
- Anahtar kapalıyken ya da Chrome kapalıyken ajan kendi tarayıcısını kullanır.
|
||||
- Panelde "Ajanın son işlemleri" listesi ajanın ne yaptığını gösterir.
|
||||
|
||||
KISAYOL VE SAĞ TIK
|
||||
Alt+Shift+A paneli açar (chrome://extensions/shortcuts üzerinden değişir).
|
||||
Sağ tık: "AITURK'e gönder: seçili metin" / "bu sayfa" paneli açar ve metni
|
||||
mesajınıza ek olarak hazırlar; konuşma seçince gönderebilirsiniz.
|
||||
|
||||
SAYFA İÇERİĞİ VE GİZLİLİK
|
||||
Eklenti gezinme geçmişini, çerezleri, form değerlerini veya parolaları toplamaz.
|
||||
Sayfa/seçim yalnızca "Sayfayı / seçimi ekle" tıklandığında okunur; siz Gönder'e
|
||||
@@ -41,7 +62,8 @@ SORUN GİDERME
|
||||
"Native messaging host not found": bağlantı kurulumunu yeniden çalıştırın.
|
||||
"IDE bağlantısı bulunamadı": Chrome destekli IDE sürümü açık ve yerel bağlantıda
|
||||
olmalıdır. IDE'yi yeniden başlatıp eklentide Yeniden bağlan'a basın.
|
||||
"Cannot access contents": ilgili sekmede AITURK araç çubuğu simgesine tıklayın.
|
||||
"Cannot access contents": eklentinin site erişimi kapalı olabilir; chrome://extensions
|
||||
üzerinden AITURK için "Tüm sitelerde" erişimi açın.
|
||||
chrome://, Chrome Web Store, PDF görüntüleyici gibi korumalı sayfalar okunamaz.
|
||||
Zaman aşımında mesaj otomatik tekrar gönderilmez. Çifte gönderimi önlemek için
|
||||
önce IDE geçmişini kontrol edin. Taslağınız panel kapanana kadar korunur.
|
||||
|
||||
+16
-1
@@ -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.9 or later; see `KURULUM.txt` for Turkish setup.
|
||||
Requires AITURK IDE 1.0.0-beta.9 or later (agent browser control: 1.0.0-beta.42); 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
|
||||
@@ -19,6 +19,20 @@ 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
|
||||
attached viewer so the desktop can still show and answer these requests.
|
||||
|
||||
## Agent browser control
|
||||
|
||||
The service worker keeps its own native connection and registers this Chrome
|
||||
with the IDE backend as the local desktop browser controller
|
||||
(`browser.controller.local.*`, see the API server docs). The IDE agent's
|
||||
existing `browser_*` tools (navigate, snapshot, click, type, scroll, back,
|
||||
press, get_images, vision) then run here, in agent tabs grouped as "AITURK"
|
||||
or a tab the user hands over; when the switch is off or Chrome is closed the
|
||||
agent keeps its normal browser. Clicks and key presses use trusted
|
||||
`chrome.debugger` input; password and payment-card fields are refused;
|
||||
chrome://, extension, file and Web Store pages are refused. Cancelling
|
||||
Chrome's debugging bar, the panel's Stop button or the switch turns control
|
||||
off and cancels pending commands.
|
||||
|
||||
## Build and verification
|
||||
|
||||
From the repository root, on Windows:
|
||||
@@ -39,6 +53,7 @@ UI tests require the existing desktop workspace's jsdom dependency:
|
||||
```powershell
|
||||
npm ci --workspace apps/desktop --include-workspace-root
|
||||
node --test apps/chrome/tests/panel.test.mjs
|
||||
node --test apps/chrome/tests/agent.test.mjs
|
||||
```
|
||||
|
||||
Exercise the production FastAPI session router and real temporary SQLite stores
|
||||
|
||||
@@ -1 +1,42 @@
|
||||
import { createController } from './controller.js'
|
||||
|
||||
const controller = createController({ chrome })
|
||||
|
||||
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
|
||||
|
||||
// The side panel talks to the controller; nothing else may.
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
const type = String(message?.type || '')
|
||||
if (sender.id !== chrome.runtime.id || !type.startsWith('control.') || type === 'control.state') return false
|
||||
controller.onMessage(message, sender).then(sendResponse, error => sendResponse({ failure: String(error?.message || error) }))
|
||||
return true
|
||||
})
|
||||
chrome.debugger.onDetach.addListener((source, reason) => controller.onDebuggerDetach(source, reason))
|
||||
chrome.tabs.onRemoved.addListener(tabId => { void controller.onTabRemoved(tabId) })
|
||||
chrome.alarms.onAlarm.addListener(alarm => controller.onAlarm(alarm.name))
|
||||
|
||||
const MENU = [
|
||||
{ id: 'aiturk-selection', title: 'AITURK’e gönder: seçili metin', contexts: ['selection'] },
|
||||
{ id: 'aiturk-page', title: 'AITURK’e gönder: bu sayfa', contexts: ['page'] },
|
||||
{ id: 'aiturk-share', title: 'Bu sekmeyi AITURK ajanına ver', contexts: ['page'] },
|
||||
]
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
chrome.contextMenus.removeAll(() => { for (const item of MENU) chrome.contextMenus.create(item) })
|
||||
})
|
||||
chrome.contextMenus.onClicked.addListener((info, tab) => {
|
||||
if (!tab?.windowId) return
|
||||
// Opening the panel must happen inside the click's user gesture, before any await.
|
||||
chrome.sidePanel.open({ windowId: tab.windowId }).catch(() => {})
|
||||
if (info.menuItemId === 'aiturk-share') {
|
||||
void chrome.storage.local.get('selectedSession')
|
||||
.then(saved => controller.shareTab(tab, typeof saved.selectedSession === 'string' ? saved.selectedSession : null))
|
||||
.catch(error => chrome.storage.session.set({ pendingNotice: String(error?.message || error) }))
|
||||
return
|
||||
}
|
||||
void chrome.storage.session.set({ pendingContext: {
|
||||
kind: info.menuItemId === 'aiturk-selection' ? 'selection' : 'page', tabId: tab.id,
|
||||
text: info.selectionText ? info.selectionText.slice(0, 24000) : '', at: Date.now(),
|
||||
} })
|
||||
})
|
||||
|
||||
void controller.connect()
|
||||
@@ -0,0 +1,646 @@
|
||||
// Lets the AITURK IDE agent use this Chrome, the way the ChatGPT extension
|
||||
// lets its agent work in the user's browser. Runs in the service worker and
|
||||
// keeps its own native connection, so it works with the side panel closed.
|
||||
//
|
||||
// Boundaries: the agent works only in its own tabs (grouped as "AITURK") or a
|
||||
// tab the user explicitly hands over; never on chrome://, extension, file or
|
||||
// Web Store pages; never types into password or payment-card fields. Chrome
|
||||
// shows its own "debugging" bar while the agent is in control; cancelling it,
|
||||
// the panel's Stop button or the switch turns control off at once.
|
||||
|
||||
import {
|
||||
annotateRefs, clickWithEvents, listImages, locateRef, prepareTyping, readValue, scrollPage, snapshotPage, typeWithEvents,
|
||||
} from './page-agent.js'
|
||||
|
||||
export const HOST = 'online.turkservis.aiturk'
|
||||
export const CAPABILITIES = Object.freeze(['browser_navigate', 'browser_snapshot', 'browser_click', 'browser_type',
|
||||
'browser_scroll', 'browser_back', 'browser_press', 'browser_get_images', 'browser_vision'])
|
||||
const COMMAND_BUDGET_MS = 27000
|
||||
const LOAD_WAIT_MS = 20000
|
||||
const DEBUGGER_IDLE_MS = 45000
|
||||
const SCREENSHOT_LIMIT = 380000
|
||||
const LOG_SIZE = 30
|
||||
|
||||
export class ToolError extends Error {}
|
||||
|
||||
const KEY_ALIASES = { esc: 'Escape', return: 'Enter', up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight',
|
||||
space: ' ', spacebar: ' ', del: 'Delete', pgup: 'PageUp', pgdn: 'PageDown', ctrl: 'Control', cmd: 'Meta', command: 'Meta', option: 'Alt' }
|
||||
const NAMED_KEYS = { Enter: [13, 'Enter', '\r'], Tab: [9, 'Tab'], Escape: [27, 'Escape'], Backspace: [8, 'Backspace'],
|
||||
Delete: [46, 'Delete'], ArrowUp: [38, 'ArrowUp'], ArrowDown: [40, 'ArrowDown'], ArrowLeft: [37, 'ArrowLeft'],
|
||||
ArrowRight: [39, 'ArrowRight'], Home: [36, 'Home'], End: [35, 'End'], PageUp: [33, 'PageUp'], PageDown: [34, 'PageDown'],
|
||||
' ': [32, 'Space', ' '], F5: [116, 'F5'] }
|
||||
const MODIFIERS = { Alt: 1, Control: 2, Meta: 4, Shift: 8 }
|
||||
|
||||
// "Control+a" → one CDP key description; unknown names fail before any input.
|
||||
export function parseKey(value) {
|
||||
const parts = String(value || '').split('+').map(part => part.trim()).filter(Boolean)
|
||||
if (!parts.length || parts.length > 4) throw new ToolError('Tuş adı geçersiz. Örnek: Enter, Tab, Escape, Control+a')
|
||||
let modifiers = 0
|
||||
const keyName = parts.pop()
|
||||
for (const raw of parts) {
|
||||
const name = KEY_ALIASES[raw.toLowerCase()] || raw[0].toUpperCase() + raw.slice(1).toLowerCase()
|
||||
if (!MODIFIERS[name]) throw new ToolError(`Bilinmeyen değiştirici tuş: ${raw}`)
|
||||
modifiers |= MODIFIERS[name]
|
||||
}
|
||||
const alias = KEY_ALIASES[keyName.toLowerCase()]
|
||||
const name = alias || (keyName.length === 1 ? keyName : keyName[0].toUpperCase() + keyName.slice(1))
|
||||
const named = NAMED_KEYS[name] || NAMED_KEYS[Object.keys(NAMED_KEYS).find(key => key.toLowerCase() === name.toLowerCase())]
|
||||
if (named) {
|
||||
const [code, keyCode, text] = named
|
||||
return { key: name === ' ' ? ' ' : keyCode, code: keyCode === ' ' ? 'Space' : keyCode, windowsVirtualKeyCode: code,
|
||||
text: modifiers & ~MODIFIERS.Shift ? undefined : text, modifiers }
|
||||
}
|
||||
if (name.length !== 1) throw new ToolError(`Bilinmeyen tuş: ${keyName}`)
|
||||
const upper = name.toUpperCase()
|
||||
const letter = /[A-Z]/.test(upper), digit = /[0-9]/.test(name)
|
||||
return { key: name, code: letter ? `Key${upper}` : digit ? `Digit${name}` : '',
|
||||
windowsVirtualKeyCode: letter || digit ? upper.charCodeAt(0) : name.charCodeAt(0),
|
||||
text: modifiers & ~MODIFIERS.Shift ? undefined : name, modifiers }
|
||||
}
|
||||
|
||||
// Only ordinary web pages. Local development servers are allowed; browser,
|
||||
// extension, file and Web Store pages are not.
|
||||
export function checkUrl(value, { allowBlank = false } = {}) {
|
||||
let text = String(value || '').trim()
|
||||
if (!text) throw new ToolError('Adres boş.')
|
||||
if (!/^[a-z][a-z0-9+.-]*:/i.test(text)) text = `https://${text}`
|
||||
let url
|
||||
try { url = new URL(text) } catch { throw new ToolError(`Geçersiz adres: ${String(value).slice(0, 200)}`) }
|
||||
if (allowBlank && url.href === 'about:blank') return url.href
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
throw new ToolError(`Ajan bu tür sayfada çalışamaz (${url.protocol}). Yalnız http/https sayfaları açılabilir.`)
|
||||
}
|
||||
if (/^(chrome\.google\.com|chromewebstore\.google\.com)$/i.test(url.hostname) &&
|
||||
(url.hostname.startsWith('chromewebstore') || url.pathname.startsWith('/webstore'))) {
|
||||
throw new ToolError('Chrome Web Mağazası sayfalarında ajan çalışamaz.')
|
||||
}
|
||||
if (url.username || url.password) throw new ToolError('Adreste kullanıcı adı/şifre olamaz.')
|
||||
return url.href
|
||||
}
|
||||
|
||||
const shortHost = value => { try { return new URL(value).host } catch { return '' } }
|
||||
|
||||
function describeCommand(action, args) {
|
||||
switch (action) {
|
||||
case 'browser_navigate': return `Sayfa açılıyor: ${shortHost(args.url) || String(args.url || '').slice(0, 60)}`
|
||||
case 'browser_snapshot': return 'Sayfa okunuyor'
|
||||
case 'browser_click': return `Tıklanıyor: ${String(args.ref || '').slice(0, 20)}`
|
||||
case 'browser_type': return `Yazılıyor: ${String(args.ref || '').slice(0, 20)}`
|
||||
case 'browser_scroll': return args.direction === 'up' ? 'Yukarı kaydırılıyor' : 'Aşağı kaydırılıyor'
|
||||
case 'browser_back': return 'Geri gidiliyor'
|
||||
case 'browser_press': return `Tuşa basılıyor: ${String(args.key || '').slice(0, 20)}`
|
||||
case 'browser_get_images': return 'Görseller listeleniyor'
|
||||
case 'browser_vision': return 'Ekran görüntüsü alınıyor'
|
||||
default: return action
|
||||
}
|
||||
}
|
||||
|
||||
export function createController({ chrome, setTimer = setTimeout, clearTimer = clearTimeout, now = () => Date.now(), random = () => crypto.randomUUID() }) {
|
||||
let port = null, connected = false, registered = false, lastError = '', profile = ''
|
||||
let enabled = true, instanceId = '', settingsLoaded = null, reconnectTimer = null, backoff = 2000
|
||||
let nextId = 0, titles = new Map(), titlesAt = 0
|
||||
const pending = new Map()
|
||||
const active = new Map()
|
||||
const queues = new Map()
|
||||
const debuggers = new Map()
|
||||
let state = null
|
||||
|
||||
// ── persistent pieces ────────────────────────────────────────────────
|
||||
async function settings() {
|
||||
settingsLoaded ||= (async () => {
|
||||
const saved = await chrome.storage.local.get(['controlEnabled', 'controllerInstance'])
|
||||
enabled = saved.controlEnabled !== false
|
||||
instanceId = saved.controllerInstance || random()
|
||||
if (!saved.controllerInstance) await chrome.storage.local.set({ controllerInstance: instanceId })
|
||||
})()
|
||||
return settingsLoaded
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (state) return state
|
||||
const saved = await chrome.storage.session.get('controlState')
|
||||
state = { tabs: {}, groups: {}, shared: null, log: [], ...(saved.controlState || {}) }
|
||||
return state
|
||||
}
|
||||
|
||||
async function save() { await chrome.storage.session.set({ controlState: state }) }
|
||||
|
||||
// ── native connection ────────────────────────────────────────────────
|
||||
function request(action, params = {}) {
|
||||
if (!port) return Promise.reject(new Error('IDE bağlantısı yok.'))
|
||||
const id = `c${++nextId}`
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimer(() => { pending.delete(id); reject(new Error('IDE yanıt vermedi.')) }, 30000)
|
||||
pending.set(id, { resolve, reject, timer })
|
||||
try { port.postMessage({ id, action, params }) }
|
||||
catch (error) { clearTimer(timer); pending.delete(id); reject(error) }
|
||||
})
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (!enabled || reconnectTimer) return
|
||||
reconnectTimer = setTimer(() => { reconnectTimer = null; void connect() }, backoff)
|
||||
backoff = Math.min(backoff * 2, 60000)
|
||||
chrome.alarms?.create('aiturk-reconnect', { periodInMinutes: 1 })
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
await settings()
|
||||
if (!enabled || port) return
|
||||
const current = chrome.runtime.connectNative(HOST)
|
||||
port = current
|
||||
current.onMessage.addListener(frame => onFrame(current, frame))
|
||||
current.onDisconnect.addListener(() => {
|
||||
if (port !== current) return
|
||||
port = null; connected = false; registered = false
|
||||
for (const item of pending.values()) { clearTimer(item.timer); item.reject(new Error('IDE bağlantısı kapandı.')) }
|
||||
pending.clear()
|
||||
abortAll('IDE bağlantısı kapandı.')
|
||||
lastError ||= 'AITURK IDE kapalı ya da Chrome bağlantısı kurulmamış.'
|
||||
broadcast()
|
||||
scheduleReconnect()
|
||||
})
|
||||
try {
|
||||
const status = await request('status')
|
||||
profile = status?.profile || ''
|
||||
connected = true; lastError = ''
|
||||
await register()
|
||||
backoff = 2000
|
||||
chrome.alarms?.clear('aiturk-reconnect')
|
||||
} catch (error) {
|
||||
lastError = error.message
|
||||
}
|
||||
broadcast()
|
||||
}
|
||||
|
||||
async function register() {
|
||||
if (!port || !enabled) return
|
||||
const result = await request('browser.register', { enabled: true, capabilities: [...CAPABILITIES],
|
||||
controllerId: `aiturk-chrome-${instanceId}`, browserProfileId: instanceId })
|
||||
registered = Boolean(result?.registered)
|
||||
}
|
||||
|
||||
function onFrame(source, frame) {
|
||||
if (source !== port || !frame || typeof frame !== 'object') return
|
||||
if (frame.id !== undefined) {
|
||||
const item = pending.get(frame.id)
|
||||
if (!item) return
|
||||
pending.delete(frame.id); clearTimer(item.timer)
|
||||
frame.error ? item.reject(new Error(frame.error)) : item.resolve(frame.result)
|
||||
return
|
||||
}
|
||||
if (frame.event === 'browser.command') void runCommand(frame)
|
||||
else if (frame.event === 'browser.cancel') cancelCommand(frame.commandId, 'Ajan bu adımı iptal etti.')
|
||||
else if (frame.event === 'disconnected') {
|
||||
// The IDE restarted its backend; the native host is still here.
|
||||
registered = false; connected = false
|
||||
abortAll('IDE yeniden bağlanıyor.')
|
||||
broadcast()
|
||||
setTimer(() => { void reconnectBackend() }, 2000)
|
||||
} else if (frame.event === 'error') { lastError = String(frame.message || '').slice(0, 300); broadcast() }
|
||||
}
|
||||
|
||||
async function reconnectBackend() {
|
||||
if (!port || !enabled) return
|
||||
try {
|
||||
const status = await request('status')
|
||||
profile = status?.profile || ''
|
||||
connected = true; lastError = ''
|
||||
await register()
|
||||
} catch (error) { lastError = error.message; setTimer(() => { void reconnectBackend() }, 10000) }
|
||||
broadcast()
|
||||
}
|
||||
|
||||
// ── commands ─────────────────────────────────────────────────────────
|
||||
async function runCommand(frame) {
|
||||
const { commandId, action, sessionId } = frame
|
||||
const args = frame.arguments && typeof frame.arguments === 'object' ? frame.arguments : {}
|
||||
const record = { commandId, cancelled: false, reason: '' }
|
||||
active.set(commandId, record)
|
||||
const entry = await logEntry({ at: now(), sessionId, action, text: describeCommand(action, args), status: 'running' })
|
||||
let reply
|
||||
try {
|
||||
if (!CAPABILITIES.includes(action)) throw new ToolError('Chrome bu işlemi desteklemiyor.')
|
||||
const result = await withBudget(execute(action, args, sessionId, record), COMMAND_BUDGET_MS)
|
||||
reply = { commandId, ok: true, result }
|
||||
entry.status = result?.success === false ? 'failed' : 'ok'
|
||||
if (result?.success === false) entry.note = String(result.error || '').slice(0, 200)
|
||||
} catch (error) {
|
||||
const message = String(error?.message || error).slice(0, 2000)
|
||||
// Expected refusals reach the model as a normal tool result it can act on.
|
||||
reply = error instanceof ToolError ? { commandId, ok: true, result: { success: false, error: message } }
|
||||
: { commandId, ok: false, error: message }
|
||||
entry.status = 'failed'; entry.note = message.slice(0, 200)
|
||||
}
|
||||
if (record.cancelled) { entry.status = 'cancelled'; entry.note = record.reason }
|
||||
active.delete(commandId)
|
||||
await save().catch(() => {})
|
||||
broadcast()
|
||||
if (record.cancelled) return
|
||||
try { await request('browser.result', reply) } catch {}
|
||||
}
|
||||
|
||||
function cancelCommand(commandId, reason) {
|
||||
const record = active.get(commandId)
|
||||
if (record) { record.cancelled = true; record.reason = reason }
|
||||
}
|
||||
|
||||
function abortAll(reason) { for (const record of active.values()) { record.cancelled = true; record.reason = reason } }
|
||||
|
||||
function guard(record) { if (record?.cancelled) throw new ToolError(record.reason || 'Durduruldu.') }
|
||||
|
||||
async function withBudget(promise, ms) {
|
||||
let timer
|
||||
const timeout = new Promise((_, reject) => { timer = setTimer(() => reject(new ToolError('Tarayıcı adımı zaman aşımına uğradı.')), ms) })
|
||||
try { return await Promise.race([promise, timeout]) } finally { clearTimer(timer) }
|
||||
}
|
||||
|
||||
// One step at a time per tab. The stored tail never rejects: the caller
|
||||
// handles the step's own error, the queue only orders the steps.
|
||||
function serial(tabId, task) {
|
||||
const next = (queues.get(tabId) || Promise.resolve()).then(task)
|
||||
const tail = next.catch(() => {}).then(() => { if (queues.get(tabId) === tail) queues.delete(tabId) })
|
||||
queues.set(tabId, tail)
|
||||
return next
|
||||
}
|
||||
|
||||
async function execute(action, args, sessionId, record) {
|
||||
if (action === 'browser_navigate') {
|
||||
const url = checkUrl(args.url)
|
||||
const tab = await agentTab(sessionId, true)
|
||||
return serial(tab.id, async () => {
|
||||
guard(record)
|
||||
await chrome.tabs.update(tab.id, { url })
|
||||
const loaded = await waitForLoad(tab.id, LOAD_WAIT_MS)
|
||||
guard(record)
|
||||
const snap = await inject(tab.id, snapshotPage, [{ full: false }])
|
||||
return { success: true, url: snap.url, title: snap.title, snapshot: snap.snapshot, element_count: snap.element_count,
|
||||
...(loaded ? {} : { note: 'Sayfa yüklenmeye devam ediyor; gerekirse browser_snapshot ile tekrar bakın.' }) }
|
||||
})
|
||||
}
|
||||
const tab = await agentTab(sessionId, false)
|
||||
return serial(tab.id, async () => {
|
||||
guard(record)
|
||||
const current = await chrome.tabs.get(tab.id)
|
||||
const address = current.pendingUrl || current.url || ''
|
||||
if (address === 'about:blank' || !address) {
|
||||
if (action === 'browser_snapshot') return { success: true, snapshot: 'Boş sekme. Önce browser_navigate ile bir sayfa açın.', element_count: 0, url: 'about:blank', title: '' }
|
||||
throw new ToolError('Sekme boş. Önce browser_navigate ile bir sayfa açın.')
|
||||
}
|
||||
checkUrl(address)
|
||||
switch (action) {
|
||||
case 'browser_snapshot': {
|
||||
const snap = await inject(tab.id, snapshotPage, [{ full: args.full === true }])
|
||||
return { success: true, ...snap }
|
||||
}
|
||||
case 'browser_click': return click(tab, args.ref, record)
|
||||
case 'browser_type': return type(tab, args.ref, typeof args.text === 'string' ? args.text : String(args.text ?? ''), record)
|
||||
case 'browser_scroll': {
|
||||
const result = await inject(tab.id, scrollPage, [args.direction === 'up' ? 'up' : 'down'])
|
||||
return { success: true, direction: args.direction === 'up' ? 'up' : 'down', ...result }
|
||||
}
|
||||
case 'browser_back': {
|
||||
try { await chrome.tabs.goBack(tab.id) } catch { throw new ToolError('Geri gidilecek sayfa yok.') }
|
||||
await waitForLoad(tab.id, LOAD_WAIT_MS)
|
||||
const after = await chrome.tabs.get(tab.id)
|
||||
return { success: true, url: after.url, title: after.title }
|
||||
}
|
||||
case 'browser_press': return press(tab, args.key)
|
||||
case 'browser_get_images': return { success: true, ...(await inject(tab.id, listImages, [])) }
|
||||
case 'browser_vision': return vision(tab, args.annotate === true)
|
||||
}
|
||||
throw new ToolError('Chrome bu işlemi desteklemiyor.')
|
||||
})
|
||||
}
|
||||
|
||||
async function click(tab, ref, record) {
|
||||
const located = await inject(tab.id, locateRef, [ref])
|
||||
if (located.error) throw new ToolError(located.error)
|
||||
if (located.disabled) throw new ToolError(`${ref} şu an devre dışı.`)
|
||||
guard(record)
|
||||
let trusted = true
|
||||
try {
|
||||
const send = await debuggerFor(tab.id)
|
||||
// No mouseMoved first: on a background tab Chrome holds a hover move
|
||||
// for 5 s waiting for a frame, while press/release go straight through.
|
||||
for (const type of ['mousePressed', 'mouseReleased']) {
|
||||
await send('Input.dispatchMouseEvent', { type, x: located.x, y: located.y, button: 'left', clickCount: 1 })
|
||||
}
|
||||
} catch {
|
||||
trusted = false
|
||||
const result = await inject(tab.id, clickWithEvents, [ref])
|
||||
if (result.error) throw new ToolError(result.error)
|
||||
}
|
||||
await settle(tab.id)
|
||||
const after = await chrome.tabs.get(tab.id)
|
||||
return { success: true, clicked: ref, element: located.name, url: after.url, title: after.title,
|
||||
...(located.covered ? { warning: `Tıklanan noktada başka bir öğe vardı: "${located.covered}". Gerekirse önce onu kapatın.` } : {}),
|
||||
...(trusted ? {} : { note: 'Gerçek fare tıklaması yerine sayfa olayı kullanıldı.' }) }
|
||||
}
|
||||
|
||||
async function type(tab, ref, text, record) {
|
||||
const prepared = await inject(tab.id, prepareTyping, [ref])
|
||||
if (prepared.error) {
|
||||
if (prepared.handoff) notify('AITURK senin girmeni bekliyor', prepared.error)
|
||||
throw new ToolError(prepared.error)
|
||||
}
|
||||
guard(record)
|
||||
if (prepared.select) {
|
||||
const result = await inject(tab.id, typeWithEvents, [ref, text])
|
||||
if (result.error) throw new ToolError(result.error)
|
||||
return { success: true, typed: ref, value: result.value }
|
||||
}
|
||||
try {
|
||||
const send = await debuggerFor(tab.id)
|
||||
if (text) await send('Input.insertText', { text })
|
||||
else for (const type of ['rawKeyDown', 'keyUp']) await send('Input.dispatchKeyEvent', { type, key: 'Delete', code: 'Delete', windowsVirtualKeyCode: 46 })
|
||||
} catch {
|
||||
const result = await inject(tab.id, typeWithEvents, [ref, text])
|
||||
if (result.error) throw new ToolError(result.error)
|
||||
}
|
||||
const value = await inject(tab.id, readValue, [ref])
|
||||
return { success: true, typed: ref, value: value.value ?? '' }
|
||||
}
|
||||
|
||||
async function press(tab, key) {
|
||||
const description = parseKey(key)
|
||||
try {
|
||||
const send = await debuggerFor(tab.id)
|
||||
await send('Input.dispatchKeyEvent', { type: description.text ? 'keyDown' : 'rawKeyDown', ...description,
|
||||
unmodifiedText: description.text })
|
||||
await send('Input.dispatchKeyEvent', { type: 'keyUp', key: description.key, code: description.code,
|
||||
windowsVirtualKeyCode: description.windowsVirtualKeyCode, modifiers: description.modifiers })
|
||||
} catch {
|
||||
throw new ToolError('Tuşa basılamadı: Chrome kontrol izni alınamadı (başka bir geliştirici aracı açık olabilir).')
|
||||
}
|
||||
await settle(tab.id)
|
||||
const after = await chrome.tabs.get(tab.id)
|
||||
return { success: true, pressed: String(key), url: after.url, title: after.title }
|
||||
}
|
||||
|
||||
async function vision(tab, annotate) {
|
||||
let annotations = []
|
||||
if (annotate) {
|
||||
await inject(tab.id, snapshotPage, [{ full: false }])
|
||||
annotations = (await inject(tab.id, annotateRefs, [true])).annotations || []
|
||||
}
|
||||
try {
|
||||
let data = ''
|
||||
try {
|
||||
const send = await debuggerFor(tab.id)
|
||||
// A background tab paints on demand for the capture (about 1-3 s).
|
||||
for (const options of [{ quality: 65, budget: 15000 }, { quality: 40, budget: 5000 }, { quality: 40, scale: 0.6, budget: 5000 }]) {
|
||||
let clip
|
||||
if (options.scale) {
|
||||
const metrics = await send('Page.getLayoutMetrics', {})
|
||||
const view = metrics.cssVisualViewport || metrics.layoutViewport
|
||||
clip = { x: view.pageX || 0, y: view.pageY || 0, width: view.clientWidth, height: view.clientHeight, scale: options.scale }
|
||||
}
|
||||
const shot = await withBudget(send('Page.captureScreenshot', { format: 'jpeg', quality: options.quality, ...(clip ? { clip } : {}) }), options.budget)
|
||||
data = shot?.data || ''
|
||||
if (data && data.length <= SCREENSHOT_LIMIT) break
|
||||
}
|
||||
} catch {
|
||||
const current = await chrome.tabs.get(tab.id)
|
||||
if (!current.active) throw new ToolError('Ekran görüntüsü alınamadı: ajanın sekmesi görünür değil ve Chrome kontrol izni alınamadı.')
|
||||
const url = await chrome.tabs.captureVisibleTab(current.windowId, { format: 'jpeg', quality: 55 })
|
||||
data = String(url || '').replace(/^data:image\/jpeg;base64,/, '')
|
||||
}
|
||||
if (!data || data.length > SCREENSHOT_LIMIT) throw new ToolError('Ekran görüntüsü çok büyük ya da boş geldi.')
|
||||
const after = await chrome.tabs.get(tab.id)
|
||||
return { success: true, screenshot: data, mime: 'image/jpeg', url: after.url, title: after.title,
|
||||
...(annotate ? { annotations } : {}) }
|
||||
} finally {
|
||||
if (annotate) await inject(tab.id, annotateRefs, [false]).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// ── tabs ─────────────────────────────────────────────────────────────
|
||||
async function agentTab(sessionId, create) {
|
||||
const current = await load()
|
||||
const key = sessionId || 'default'
|
||||
const known = current.tabs[key]
|
||||
if (known) {
|
||||
try { return await chrome.tabs.get(known) } catch { delete current.tabs[key] }
|
||||
}
|
||||
if (current.shared && (!current.shared.sessionId || current.shared.sessionId === key)) {
|
||||
try {
|
||||
const tab = await chrome.tabs.get(current.shared.tabId)
|
||||
current.tabs[key] = tab.id
|
||||
current.shared = null
|
||||
await save()
|
||||
return tab
|
||||
} catch { current.shared = null }
|
||||
}
|
||||
if (!create) throw new ToolError('Bu konuşma için açık bir tarayıcı sekmesi yok. Önce browser_navigate ile bir sayfa açın.')
|
||||
const window = await chrome.windows.getLastFocused({ windowTypes: ['normal'] }).catch(() => null)
|
||||
const tab = await chrome.tabs.create({ url: 'about:blank', active: false, ...(window?.id ? { windowId: window.id } : {}) })
|
||||
current.tabs[key] = tab.id
|
||||
await save()
|
||||
await groupTab(tab, key).catch(() => {})
|
||||
return tab
|
||||
}
|
||||
|
||||
async function sessionTitle(sessionId) {
|
||||
if (now() - titlesAt > 60000 && port) {
|
||||
titlesAt = now()
|
||||
try {
|
||||
const data = await request('sessions.list')
|
||||
titles = new Map((data?.sessions || []).map(row => [row.id, row.title]))
|
||||
} catch {}
|
||||
}
|
||||
return titles.get(sessionId) || ''
|
||||
}
|
||||
|
||||
async function groupTab(tab, key) {
|
||||
const current = await load()
|
||||
let groupId = current.groups[key]
|
||||
if (groupId !== undefined) {
|
||||
try {
|
||||
const group = await chrome.tabGroups.get(groupId)
|
||||
if (group.windowId !== tab.windowId) groupId = undefined
|
||||
} catch { groupId = undefined }
|
||||
}
|
||||
groupId = await chrome.tabs.group({ tabIds: [tab.id], ...(groupId === undefined ? { createProperties: { windowId: tab.windowId } } : { groupId }) })
|
||||
current.groups[key] = groupId
|
||||
await save()
|
||||
const title = await sessionTitle(key)
|
||||
await chrome.tabGroups.update(groupId, { title: `AITURK${title ? ` · ${title.slice(0, 28)}` : ''}`, color: 'cyan' })
|
||||
}
|
||||
|
||||
function waitForLoad(tabId, ms) {
|
||||
return new Promise(resolve => {
|
||||
let done = false
|
||||
const finish = value => {
|
||||
if (done) return
|
||||
done = true; clearTimer(timer)
|
||||
chrome.tabs.onUpdated.removeListener(listener)
|
||||
resolve(value)
|
||||
}
|
||||
const listener = (id, change) => { if (id === tabId && change.status === 'complete') finish(true) }
|
||||
const timer = setTimer(() => finish(false), ms)
|
||||
chrome.tabs.onUpdated.addListener(listener)
|
||||
// A page that finished before the listener existed.
|
||||
// pendingUrl is set until the new document commits, so an old page's
|
||||
// "complete" is never mistaken for the new one.
|
||||
setTimer(() => { chrome.tabs.get(tabId).then(tab => { if (tab.status === 'complete' && !tab.pendingUrl) finish(true) }).catch(() => finish(false)) }, 250)
|
||||
})
|
||||
}
|
||||
|
||||
async function settle(tabId) {
|
||||
await new Promise(resolve => setTimer(resolve, 400))
|
||||
const tab = await chrome.tabs.get(tabId).catch(() => null)
|
||||
if (tab?.status === 'loading') await waitForLoad(tabId, 8000)
|
||||
}
|
||||
|
||||
async function inject(tabId, func, args) {
|
||||
let results
|
||||
try {
|
||||
results = await chrome.scripting.executeScript({ target: { tabId }, func, args })
|
||||
} catch (error) {
|
||||
throw new ToolError(`Sayfaya erişilemedi: ${String(error?.message || error).slice(0, 200)}`)
|
||||
}
|
||||
const value = results?.[0]?.result
|
||||
if (value === undefined || value === null) throw new ToolError('Sayfa yanıt vermedi; yükleniyor olabilir.')
|
||||
return value
|
||||
}
|
||||
|
||||
async function debuggerFor(tabId) {
|
||||
let entry = debuggers.get(tabId)
|
||||
if (!entry) {
|
||||
await chrome.debugger.attach({ tabId }, '1.3')
|
||||
entry = { timer: null }
|
||||
debuggers.set(tabId, entry)
|
||||
}
|
||||
clearTimer(entry.timer)
|
||||
entry.timer = setTimer(() => { void detachDebugger(tabId) }, DEBUGGER_IDLE_MS)
|
||||
return (method, params) => chrome.debugger.sendCommand({ tabId }, method, params)
|
||||
}
|
||||
|
||||
async function detachDebugger(tabId) {
|
||||
const entry = debuggers.get(tabId)
|
||||
if (!entry) return
|
||||
clearTimer(entry.timer)
|
||||
debuggers.delete(tabId)
|
||||
await chrome.debugger.detach({ tabId }).catch(() => {})
|
||||
}
|
||||
|
||||
// ── state for the panel ─────────────────────────────────────────────
|
||||
async function logEntry(entry) {
|
||||
const current = await load()
|
||||
current.log.unshift(entry)
|
||||
current.log.length = Math.min(current.log.length, LOG_SIZE)
|
||||
await save().catch(() => {})
|
||||
broadcast()
|
||||
return entry
|
||||
}
|
||||
|
||||
function notify(title, message) {
|
||||
chrome.notifications?.create({ type: 'basic', iconUrl: 'icon128.png', title, message: String(message).slice(0, 200), priority: 1 })
|
||||
?.catch?.(() => {})
|
||||
}
|
||||
|
||||
async function snapshotState() {
|
||||
const current = await load()
|
||||
const tabs = []
|
||||
for (const [sessionId, tabId] of Object.entries(current.tabs)) {
|
||||
try {
|
||||
const tab = await chrome.tabs.get(tabId)
|
||||
tabs.push({ sessionId, tabId, title: (tab.title || '').slice(0, 120), url: shortHost(tab.url) })
|
||||
} catch { delete current.tabs[sessionId] }
|
||||
}
|
||||
return { enabled, connected, registered, error: lastError, profile, running: active.size,
|
||||
log: current.log.slice(0, 15), tabs, shared: current.shared }
|
||||
}
|
||||
|
||||
function broadcast() {
|
||||
chrome.action?.setBadgeText?.({ text: active.size ? '●' : '' })?.catch?.(() => {})
|
||||
void snapshotState().then(view => chrome.runtime.sendMessage({ type: 'control.state', state: view })).catch(() => {})
|
||||
}
|
||||
|
||||
async function setEnabled(value) {
|
||||
await settings()
|
||||
enabled = value === true
|
||||
await chrome.storage.local.set({ controlEnabled: enabled })
|
||||
if (enabled) {
|
||||
backoff = 2000
|
||||
if (port && !registered) { try { await register() } catch (error) { lastError = error.message } }
|
||||
else await connect()
|
||||
} else {
|
||||
abortAll('Kullanıcı ajanın tarayıcı kullanımını kapattı.')
|
||||
for (const tabId of [...debuggers.keys()]) await detachDebugger(tabId)
|
||||
if (port) { try { await request('browser.register', { enabled: false }) } catch {} }
|
||||
registered = false
|
||||
const previous = port
|
||||
port = null; connected = false
|
||||
previous?.disconnect()
|
||||
chrome.alarms?.clear('aiturk-reconnect')
|
||||
}
|
||||
broadcast()
|
||||
return snapshotState()
|
||||
}
|
||||
|
||||
async function shareTab(tab, sessionId) {
|
||||
if (!tab?.id) throw new ToolError('Paylaşılacak sekme bulunamadı.')
|
||||
checkUrl(tab.url || '')
|
||||
const current = await load()
|
||||
if (sessionId) current.tabs[sessionId] = tab.id
|
||||
else current.shared = { tabId: tab.id, sessionId: null, title: (tab.title || '').slice(0, 120) }
|
||||
await save()
|
||||
broadcast()
|
||||
return snapshotState()
|
||||
}
|
||||
|
||||
async function releaseTab(sessionId) {
|
||||
const current = await load()
|
||||
const ids = sessionId ? [current.tabs[sessionId]] : Object.values(current.tabs)
|
||||
if (sessionId) delete current.tabs[sessionId]
|
||||
else { current.tabs = {}; current.shared = null }
|
||||
for (const tabId of ids) if (tabId) await detachDebugger(tabId)
|
||||
await save()
|
||||
broadcast()
|
||||
return snapshotState()
|
||||
}
|
||||
|
||||
async function onMessage(message, sender) {
|
||||
switch (message?.type) {
|
||||
case 'control.get':
|
||||
await settings()
|
||||
if (enabled && !port) void connect()
|
||||
return snapshotState()
|
||||
case 'control.set': return setEnabled(message.enabled === true)
|
||||
case 'control.stop': return setEnabled(false)
|
||||
case 'control.share': {
|
||||
const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true })
|
||||
return shareTab(tab, typeof message.sessionId === 'string' ? message.sessionId : null)
|
||||
}
|
||||
case 'control.release': return releaseTab(typeof message.sessionId === 'string' ? message.sessionId : null)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function onDebuggerDetach(source, reason) {
|
||||
const entry = debuggers.get(source?.tabId)
|
||||
if (!entry) return
|
||||
clearTimer(entry.timer)
|
||||
debuggers.delete(source.tabId)
|
||||
if (reason === 'canceled_by_user') {
|
||||
notify('AITURK durduruldu', 'Chrome uyarısındaki İptal ile ajanın tarayıcı kullanımı kapatıldı. Panelden tekrar açabilirsiniz.')
|
||||
void setEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function onTabRemoved(tabId) {
|
||||
const current = await load()
|
||||
let changed = false
|
||||
for (const [key, value] of Object.entries(current.tabs)) if (value === tabId) { delete current.tabs[key]; changed = true }
|
||||
if (current.shared?.tabId === tabId) { current.shared = null; changed = true }
|
||||
debuggers.delete(tabId)
|
||||
if (changed) { await save(); broadcast() }
|
||||
}
|
||||
|
||||
return { connect, onMessage, onDebuggerDetach, onTabRemoved, setEnabled, shareTab, releaseTab, snapshotState,
|
||||
onAlarm: name => { if (name === 'aiturk-reconnect') void connect() },
|
||||
// exposed for tests
|
||||
_execute: execute, _runCommand: runCommand, _state: () => ({ port, connected, registered, enabled, active }) }
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 589 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "AITURK — IDE Yan Paneli",
|
||||
"version": "0.1.0",
|
||||
"description": "AITURK IDE konuşmalarınızı Chrome yan panelinde seçin ve aynı bağlamla devam edin. Windows beta.",
|
||||
"version": "0.2.0",
|
||||
"description": "AITURK IDE konuşmalarınızı Chrome yan panelinde sürdürün; isterseniz AITURK ajanı bu Chrome'da sayfa açıp tıklayabilsin. Windows beta.",
|
||||
"minimum_chrome_version": "116",
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsqPEObthL/kikWFLJrsJWfxxEqn50wJa3kbDGdZM644GW035J6lpkb932DBsHUtMdcDfRsQNWtjrIfLjxJP3xJv+vWLoO5QpcxHmqteyVS05SQw/RV0mV+pEQpuDnTlxUwAvfPk8bpyj7sD4zVHqLy0Q+BbcqAdkPUQL7x9wJDZxaM6fmrUyrTQMkFRjd6+2Stdg+rfrIgCNym55UaM0dHCtMPxpyn79eqyRT3CwDEmqAz99YCDwKbVu+MCEjQiVVeYSgJmfwcL1vV8Zsue1gOsC5mdQEtGGyHiHV8XIc8SL8k8AkGJu6v8srGEtjLRKvb/G66wzq10FxCis9M0f6QIDAQAB",
|
||||
"permissions": ["sidePanel", "nativeMessaging", "storage", "activeTab", "scripting"],
|
||||
"background": { "service_worker": "background.js" },
|
||||
"action": { "default_title": "AITURK yan panelini aç" },
|
||||
"permissions": ["sidePanel", "nativeMessaging", "storage", "activeTab", "scripting", "tabs", "tabGroups", "debugger", "contextMenus", "notifications", "alarms"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": { "service_worker": "background.js", "type": "module" },
|
||||
"action": { "default_title": "AITURK yan panelini aç", "default_icon": { "16": "icon16.png", "32": "icon32.png", "48": "icon48.png" } },
|
||||
"icons": { "16": "icon16.png", "32": "icon32.png", "48": "icon48.png", "128": "icon128.png" },
|
||||
"commands": { "_execute_action": { "suggested_key": { "default": "Alt+Shift+A" }, "description": "AITURK yan panelini aç" } },
|
||||
"side_panel": { "default_path": "panel.html" },
|
||||
"content_security_policy": { "extension_pages": "script-src 'self'; object-src 'none'; connect-src 'none'; base-uri 'none'" },
|
||||
"homepage_url": "https://turkservis.online/indir"
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
// Functions injected into a page the IDE agent controls. Each one runs through
|
||||
// chrome.scripting.executeScript in Chrome's isolated world, so every function
|
||||
// must be self-contained (no imports, no outer variables). They share one
|
||||
// global per document: the element map of the latest snapshot, which turns the
|
||||
// model's "@e5" back into the element it saw.
|
||||
|
||||
// Accessibility-style outline of the page with refs for interactive elements.
|
||||
// full=false lists interactive elements and headings; full=true adds visible
|
||||
// text in reading order. Same-origin iframes and open shadow roots are walked.
|
||||
export function snapshotPage(options) {
|
||||
const full = Boolean(options && options.full)
|
||||
const limit = Math.max(2000, Math.min(Number(options && options.limit) || 15000, 40000))
|
||||
const refs = new Map()
|
||||
const lines = []
|
||||
let counter = 0, length = 0, omitted = 0
|
||||
const ROLE_BY_TAG = { a: 'link', button: 'button', select: 'combobox', textarea: 'textbox', summary: 'button',
|
||||
h1: 'heading', h2: 'heading', h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading', img: 'img', iframe: 'iframe' }
|
||||
const INTERACTIVE = new Set(['button', 'link', 'textbox', 'searchbox', 'combobox', 'checkbox', 'radio', 'switch', 'tab',
|
||||
'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'slider', 'spinbutton', 'treeitem', 'listbox'])
|
||||
const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'HEAD', 'META', 'LINK'])
|
||||
const clean = (value, max = 100) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, max)
|
||||
const quote = value => JSON.stringify(value)
|
||||
|
||||
function visible(element) {
|
||||
const rect = element.getBoundingClientRect()
|
||||
if (rect.width < 1 || rect.height < 1) return false
|
||||
const style = element.ownerDocument.defaultView.getComputedStyle(element)
|
||||
return style.visibility !== 'hidden' && style.display !== 'none' && style.opacity !== '0'
|
||||
}
|
||||
|
||||
function inputRole(element) {
|
||||
const type = (element.getAttribute('type') || 'text').toLowerCase()
|
||||
if (['button', 'submit', 'reset', 'image'].includes(type)) return 'button'
|
||||
if (type === 'checkbox') return 'checkbox'
|
||||
if (type === 'radio') return 'radio'
|
||||
if (type === 'range') return 'slider'
|
||||
if (type === 'number') return 'spinbutton'
|
||||
if (type === 'search') return 'searchbox'
|
||||
if (type === 'hidden') return ''
|
||||
return 'textbox'
|
||||
}
|
||||
|
||||
function roleOf(element) {
|
||||
const explicit = clean(element.getAttribute('role'), 40).split(' ')[0]
|
||||
if (explicit) return explicit
|
||||
const tag = element.tagName.toLowerCase()
|
||||
if (tag === 'input') return inputRole(element)
|
||||
if (tag === 'a') return element.hasAttribute('href') ? 'link' : ''
|
||||
if (element.isContentEditable && element.getAttribute('contenteditable') !== null) return 'textbox'
|
||||
return ROLE_BY_TAG[tag] || ''
|
||||
}
|
||||
|
||||
function nameOf(element, role) {
|
||||
const doc = element.ownerDocument
|
||||
const labelled = element.getAttribute('aria-labelledby')
|
||||
if (labelled) {
|
||||
const text = labelled.split(/\s+/).map(id => doc.getElementById(id)?.textContent || '').join(' ')
|
||||
if (clean(text)) return clean(text)
|
||||
}
|
||||
const aria = element.getAttribute('aria-label')
|
||||
if (clean(aria)) return clean(aria)
|
||||
if (element.id && ['textbox', 'searchbox', 'combobox', 'checkbox', 'radio', 'slider', 'spinbutton', 'switch'].includes(role)) {
|
||||
const label = Array.from(doc.getElementsByTagName('label')).find(candidate => candidate.htmlFor === element.id)
|
||||
if (label && clean(label.textContent)) return clean(label.textContent)
|
||||
}
|
||||
const wrapping = element.closest('label')
|
||||
if (wrapping && wrapping !== element && clean(wrapping.textContent)) return clean(wrapping.textContent)
|
||||
if (element.tagName === 'IMG') return clean(element.getAttribute('alt'))
|
||||
if (element.tagName === 'INPUT' && ['button', 'submit', 'reset'].includes((element.type || '').toLowerCase())) {
|
||||
return clean(element.value || element.type)
|
||||
}
|
||||
const text = role === 'textbox' || role === 'searchbox' || role === 'combobox' ? '' : clean(element.innerText || element.textContent)
|
||||
return text || clean(element.getAttribute('title')) || clean(element.getAttribute('placeholder'))
|
||||
}
|
||||
|
||||
function states(element, role) {
|
||||
const out = []
|
||||
if (role === 'heading') out.push(`level=${element.tagName.match(/\d/)?.[0] || element.getAttribute('aria-level') || 2}`)
|
||||
if (element.disabled || element.getAttribute('aria-disabled') === 'true') out.push('disabled')
|
||||
if (element.checked || element.getAttribute('aria-checked') === 'true') out.push('checked')
|
||||
if (element.getAttribute('aria-expanded') === 'true') out.push('expanded')
|
||||
if (element.getAttribute('aria-selected') === 'true') out.push('selected')
|
||||
if (element.required) out.push('required')
|
||||
return out
|
||||
}
|
||||
|
||||
function valueOf(element, role) {
|
||||
if (!['textbox', 'searchbox', 'combobox', 'spinbutton', 'slider'].includes(role)) return ''
|
||||
const type = (element.getAttribute('type') || '').toLowerCase()
|
||||
if (type === 'password') return element.value ? '••••' : ''
|
||||
if (element.tagName === 'SELECT') return clean(element.selectedOptions?.[0]?.textContent, 80)
|
||||
if (element.isContentEditable) return clean(element.innerText, 120)
|
||||
return clean(element.value, 120)
|
||||
}
|
||||
|
||||
function push(line) {
|
||||
if (length + line.length + 1 > limit) { omitted++; return }
|
||||
lines.push(line)
|
||||
length += line.length + 1
|
||||
}
|
||||
|
||||
function describe(element, depth) {
|
||||
const role = roleOf(element)
|
||||
if (!role) return false
|
||||
const interactive = INTERACTIVE.has(role) || (element.tagName === 'INPUT' && role)
|
||||
if (!interactive && role !== 'heading' && !(full && (role === 'img' || role === 'iframe'))) return false
|
||||
if (!visible(element)) return false
|
||||
const name = nameOf(element, role)
|
||||
const parts = [`${' '.repeat(depth)}- ${role}`]
|
||||
if (name) parts.push(quote(name))
|
||||
const flags = states(element, role)
|
||||
let ref = ''
|
||||
if (interactive) {
|
||||
ref = `e${++counter}`
|
||||
refs.set(ref, new WeakRef(element))
|
||||
flags.push(`ref=${ref}`)
|
||||
}
|
||||
if (flags.length) parts.push(`[${flags.join('] [')}]`)
|
||||
const value = valueOf(element, role)
|
||||
if (value) parts.push(`(değer: ${quote(value)})`)
|
||||
if ((element.getAttribute('type') || '').toLowerCase() === 'password') parts.push('(şifre alanı: ajan yazamaz)')
|
||||
if (role === 'link' && full) {
|
||||
try { parts.push(`→ ${new URL(element.getAttribute('href'), element.baseURI).href.slice(0, 160)}`) } catch {}
|
||||
}
|
||||
push(parts.join(' '))
|
||||
if (full && element.tagName === 'SELECT') {
|
||||
Array.from(element.options).slice(0, 20).forEach(option => push(`${' '.repeat(depth + 1)}- option ${quote(clean(option.textContent))}${option.selected ? ' [selected]' : ''}`))
|
||||
}
|
||||
return interactive
|
||||
}
|
||||
|
||||
function directText(element) {
|
||||
let text = ''
|
||||
for (const node of element.childNodes) if (node.nodeType === 3) text += node.textContent
|
||||
return clean(text, 300)
|
||||
}
|
||||
|
||||
function walk(root, depth) {
|
||||
for (const element of root.children || []) {
|
||||
if (SKIP.has(element.tagName.toUpperCase())) continue
|
||||
if (element.getAttribute('aria-hidden') === 'true' || element.hidden) continue
|
||||
if (element.tagName === 'IFRAME') {
|
||||
let inner = null
|
||||
try { inner = element.contentDocument } catch {}
|
||||
if (inner?.body && visible(element)) { push(`${' '.repeat(depth)}- iframe ${quote(clean(element.title || element.name || ''))}`); walk(inner.body, depth + 1) }
|
||||
else if (visible(element)) push(`${' '.repeat(depth)}- iframe ${quote(clean(element.title || element.src, 80))} (farklı alan adı: içerik okunamaz)`)
|
||||
continue
|
||||
}
|
||||
const interactive = describe(element, depth)
|
||||
if (!interactive && full && visible(element)) {
|
||||
const text = directText(element)
|
||||
if (text && !roleOf(element)) push(`${' '.repeat(depth)}- text: ${text}`)
|
||||
}
|
||||
if (element.shadowRoot) walk(element.shadowRoot, depth)
|
||||
// A described control's own children rarely add meaning (button labels).
|
||||
if (!interactive || element.tagName === 'SELECT' || element.getAttribute('role') === 'listbox') walk(element, depth)
|
||||
}
|
||||
}
|
||||
|
||||
walk(document.body || document.documentElement, 0)
|
||||
globalThis.__aiturkRefs = refs
|
||||
const header = [`Sayfa: ${clean(document.title, 200) || '(başlıksız)'}`, `Adres: ${location.href.slice(0, 300)}`]
|
||||
if (!lines.length) lines.push('(Görünür etkileşimli öğe yok. Sayfa yükleniyor olabilir; browser_snapshot ile tekrar bakın ya da full=true deneyin.)')
|
||||
if (omitted) lines.push(`… (${omitted} satır daha var; kısaltıldı. Sayfayı kaydırıp tekrar bakın.)`)
|
||||
return { snapshot: header.concat(lines).join('\n'), element_count: counter, url: location.href, title: document.title, truncated: omitted > 0 }
|
||||
}
|
||||
|
||||
// Where to click/type for a ref, after scrolling it into view. Coordinates are
|
||||
// top-level viewport CSS pixels (same-origin iframe offsets added).
|
||||
export function locateRef(ref) {
|
||||
const key = String(ref || '').trim().replace(/^@/, '').replace(/^ref=/, '').replace(/^\[|\]$/g, '')
|
||||
const element = globalThis.__aiturkRefs?.get(key)?.deref()
|
||||
if (!element || !element.isConnected) {
|
||||
return { error: `Öğe bulunamadı (@${key}). Sayfa değişmiş olabilir; browser_snapshot ile yeni numaraları alın.` }
|
||||
}
|
||||
element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' })
|
||||
const rect = element.getBoundingClientRect()
|
||||
let x = rect.left + rect.width / 2, y = rect.top + rect.height / 2
|
||||
let view = element.ownerDocument.defaultView
|
||||
while (view && view !== window && view.frameElement) {
|
||||
const frame = view.frameElement.getBoundingClientRect()
|
||||
x += frame.left; y += frame.top
|
||||
view = view.parent
|
||||
}
|
||||
const type = (element.getAttribute('type') || '').toLowerCase()
|
||||
const autocomplete = (element.getAttribute('autocomplete') || '').toLowerCase()
|
||||
let covered = ''
|
||||
if (element.ownerDocument === document) {
|
||||
const top = document.elementFromPoint(x, y)
|
||||
if (top && top !== element && !element.contains(top) && !top.contains(element)) {
|
||||
covered = String(top.innerText || top.getAttribute('aria-label') || top.tagName).replace(/\s+/g, ' ').trim().slice(0, 80)
|
||||
}
|
||||
}
|
||||
return {
|
||||
x: Math.round(x), y: Math.round(y),
|
||||
tag: element.tagName.toLowerCase(),
|
||||
name: String(element.getAttribute('aria-label') || element.innerText || element.value || element.getAttribute('title') || '').replace(/\s+/g, ' ').trim().slice(0, 80),
|
||||
editable: element.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(element.tagName),
|
||||
password: type === 'password',
|
||||
payment: /^cc-|^card|card-?number|cvc|cvv/.test(autocomplete) || /cvc|cvv|card.?number/i.test(element.name || ''),
|
||||
disabled: Boolean(element.disabled || element.getAttribute('aria-disabled') === 'true'),
|
||||
covered,
|
||||
}
|
||||
}
|
||||
|
||||
// Focus a ref and select its current content so typed text replaces it.
|
||||
// Refuses password and payment-card fields: those stay with the user.
|
||||
export function prepareTyping(ref) {
|
||||
const key = String(ref || '').trim().replace(/^@/, '').replace(/^ref=/, '').replace(/^[|]$/g, '')
|
||||
const element = globalThis.__aiturkRefs?.get(key)?.deref()
|
||||
if (!element || !element.isConnected) {
|
||||
return { error: `Öğe bulunamadı (@${key}). Sayfa değişmiş olabilir; browser_snapshot ile yeni numaraları alın.` }
|
||||
}
|
||||
const type = (element.getAttribute('type') || '').toLowerCase()
|
||||
const autocomplete = (element.getAttribute('autocomplete') || '').toLowerCase()
|
||||
if (type === 'password' || autocomplete.includes('password')) {
|
||||
return { error: 'Şifre alanına ajan yazamaz. Bu adımı kullanıcıya bırakın.', handoff: true }
|
||||
}
|
||||
if (/^cc-|card-?number|cvc|cvv/.test(autocomplete) || /cvc|cvv|card.?number/i.test(element.name || '')) {
|
||||
return { error: 'Kart bilgisi alanına ajan yazamaz. Bu adımı kullanıcıya bırakın.', handoff: true }
|
||||
}
|
||||
const editable = element.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(element.tagName)
|
||||
if (!editable) return { error: `@${key} bir yazı alanı değil (${element.tagName.toLowerCase()}).` }
|
||||
if (element.disabled || element.readOnly || element.getAttribute('aria-disabled') === 'true') {
|
||||
return { error: `@${key} şu an devre dışı ya da salt okunur.` }
|
||||
}
|
||||
element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' })
|
||||
if (element.tagName === 'SELECT') return { select: true }
|
||||
element.focus()
|
||||
if (element.isContentEditable) {
|
||||
const range = element.ownerDocument.createRange()
|
||||
range.selectNodeContents(element)
|
||||
const selection = element.ownerDocument.getSelection()
|
||||
selection.removeAllRanges(); selection.addRange(range)
|
||||
} else if (typeof element.select === 'function' && element.tagName !== 'SELECT') {
|
||||
element.select()
|
||||
}
|
||||
return { focused: element.ownerDocument.activeElement === element }
|
||||
}
|
||||
|
||||
// Script fallback when trusted input is unavailable: replace the value and fire
|
||||
// the events frameworks listen to.
|
||||
export function typeWithEvents(ref, text) {
|
||||
const key = String(ref || '').trim().replace(/^@/, '').replace(/^ref=/, '')
|
||||
const element = globalThis.__aiturkRefs?.get(key)?.deref()
|
||||
if (!element) return { error: `Öğe bulunamadı (@${key}).` }
|
||||
if (element.tagName === 'SELECT') {
|
||||
const wanted = String(text).trim().toLocaleLowerCase()
|
||||
const option = Array.from(element.options).find(item => item.textContent.trim().toLocaleLowerCase() === wanted ||
|
||||
item.value.toLocaleLowerCase() === wanted) || Array.from(element.options).find(item => item.textContent.trim().toLocaleLowerCase().includes(wanted))
|
||||
if (!option) return { error: `Listede "${String(text).slice(0, 80)}" seçeneği yok.` }
|
||||
element.value = option.value
|
||||
} else if (element.isContentEditable) {
|
||||
element.textContent = text
|
||||
} else {
|
||||
const proto = element.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set
|
||||
setter ? setter.call(element, text) : (element.value = text)
|
||||
}
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
return { value: element.isContentEditable ? element.textContent : element.value }
|
||||
}
|
||||
|
||||
// Read back what a field holds after typing (password fields never echoed).
|
||||
export function readValue(ref) {
|
||||
const key = String(ref || '').trim().replace(/^@/, '').replace(/^ref=/, '')
|
||||
const element = globalThis.__aiturkRefs?.get(key)?.deref()
|
||||
if (!element) return { error: `Öğe bulunamadı (@${key}).` }
|
||||
return { value: String(element.isContentEditable ? element.innerText : element.value ?? '').slice(0, 2000) }
|
||||
}
|
||||
|
||||
export function clickWithEvents(ref) {
|
||||
const key = String(ref || '').trim().replace(/^@/, '').replace(/^ref=/, '')
|
||||
const element = globalThis.__aiturkRefs?.get(key)?.deref()
|
||||
if (!element) return { error: `Öğe bulunamadı (@${key}).` }
|
||||
element.click()
|
||||
return { clicked: true }
|
||||
}
|
||||
|
||||
// Scroll the page, or the scrollable area under the viewport centre when the
|
||||
// window itself does not move (single-page apps).
|
||||
export function scrollPage(direction) {
|
||||
const sign = direction === 'up' ? -1 : 1
|
||||
const amount = Math.round(window.innerHeight * 0.8) * sign
|
||||
const before = window.scrollY
|
||||
window.scrollBy({ top: amount, behavior: 'instant' })
|
||||
if (window.scrollY !== before) {
|
||||
return { scrolled: true, atEnd: sign > 0 ? window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2 : window.scrollY <= 0 }
|
||||
}
|
||||
let element = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2)
|
||||
while (element && element !== document.body) {
|
||||
const style = getComputedStyle(element)
|
||||
if (/(auto|scroll)/.test(style.overflowY) && element.scrollHeight > element.clientHeight) {
|
||||
const previous = element.scrollTop
|
||||
element.scrollBy({ top: amount, behavior: 'instant' })
|
||||
if (element.scrollTop !== previous) {
|
||||
return { scrolled: true, atEnd: sign > 0 ? element.scrollTop + element.clientHeight >= element.scrollHeight - 2 : element.scrollTop <= 0 }
|
||||
}
|
||||
}
|
||||
element = element.parentElement
|
||||
}
|
||||
return { scrolled: false, atEnd: true }
|
||||
}
|
||||
|
||||
export function listImages() {
|
||||
const images = []
|
||||
for (const image of document.images) {
|
||||
if (images.length >= 100) break
|
||||
const rect = image.getBoundingClientRect()
|
||||
if (rect.width < 8 || rect.height < 8) continue
|
||||
const src = image.currentSrc || image.src || ''
|
||||
images.push({ src: src.startsWith('data:') ? `${src.slice(0, 60)}…` : src.slice(0, 500),
|
||||
alt: String(image.alt || '').slice(0, 200), width: Math.round(rect.width), height: Math.round(rect.height) })
|
||||
}
|
||||
return { images, count: images.length, url: location.href, title: document.title }
|
||||
}
|
||||
|
||||
// Numbered labels over the refs of the latest snapshot, for annotated
|
||||
// screenshots; call again with show=false to remove them.
|
||||
export function annotateRefs(show) {
|
||||
document.getElementById('__aiturk_labels')?.remove()
|
||||
if (!show) return { removed: true }
|
||||
const layer = document.createElement('div')
|
||||
layer.id = '__aiturk_labels'
|
||||
layer.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:2147483647'
|
||||
const annotations = []
|
||||
for (const [ref, weak] of globalThis.__aiturkRefs || []) {
|
||||
const element = weak.deref()
|
||||
if (!element || element.ownerDocument !== document) continue
|
||||
const rect = element.getBoundingClientRect()
|
||||
if (rect.bottom < 0 || rect.top > innerHeight || rect.right < 0 || rect.left > innerWidth) continue
|
||||
const label = document.createElement('span')
|
||||
label.textContent = ref.slice(1)
|
||||
label.style.cssText = `position:fixed;left:${Math.max(0, rect.left)}px;top:${Math.max(0, rect.top)}px;background:#007f79;color:#fff;font:600 11px/14px system-ui;padding:0 3px;border-radius:3px`
|
||||
layer.append(label)
|
||||
annotations.push({ ref: `@${ref}`, label: `[${ref.slice(1)}]` })
|
||||
}
|
||||
document.documentElement.append(layer)
|
||||
return { annotations }
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
:root{color-scheme:light dark;--bg:#f8faf9;--surface:#fff;--ink:#172a2a;--muted:#617675;--line:#dce5e2;--accent:#007f79;--soft:#e7f4ef;--error:#a43d24;font:14px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
|
||||
*{box-sizing:border-box}body{margin:0;min-width:300px;height:100dvh;display:flex;flex-direction:column;background:var(--bg);color:var(--ink)}header{padding:18px 16px 10px;display:flex;align-items:center;justify-content:space-between;gap:6px}.brand{display:flex;align-items:center;gap:8px;letter-spacing:.02em}.mark{background:var(--accent);color:#fff;border-radius:8px;display:grid;place-items:center;width:28px;height:28px;font-size:19px;font-weight:750}.badge{color:var(--muted);font-size:9px;letter-spacing:.1em}button,input,textarea,select{font:inherit;color:inherit}button{cursor:pointer;background:var(--accent);color:#fff;border:0;border-radius:7px;padding:8px 12px;font-weight:600}button:disabled{opacity:.4;cursor:default}.quiet{background:transparent;color:var(--muted);font-size:12px;padding:6px}.quiet:hover:enabled{background:var(--soft);color:var(--accent)}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.connection{padding:0 16px 14px;display:flex;align-items:center;gap:7px;color:var(--muted);font-size:12px}.dot{height:7px;width:7px;border-radius:50%;background:var(--muted);flex-shrink:0}.dot.online{background:var(--accent)}.picker{padding:8px 16px 10px;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.row{display:flex;align-items:center;justify-content:space-between;gap:8px}label{font-weight:650;font-size:12px}input,select,textarea{display:block;width:100%;border:1px solid var(--line);background:var(--surface);border-radius:7px;padding:10px}input{margin:5px 0 8px}select{font-weight:600;text-overflow:ellipsis}.hint{font-size:11px;color:var(--muted);margin:7px 0 0}.picker .hint{margin-top:6px}#notice{margin:10px 16px 0;padding:10px;background:var(--soft);border-left:3px solid var(--error);font-size:12px;overflow-wrap:anywhere}main{flex:1;min-height:100px;overflow-y:auto;padding:12px 16px;scrollbar-width:thin}.empty{padding:30px 6px;color:var(--muted)}.empty-mark{font-size:42px;color:var(--accent);font-weight:800}h1{font-size:27px;line-height:1.2;letter-spacing:-.04em;color:var(--ink);font-weight:600}.message{margin:8px 0 22px;overflow-wrap:anywhere}.message .role{font-weight:700;color:var(--muted);font-size:11px;margin-bottom:6px;letter-spacing:.05em}.message .text{white-space:pre-wrap;tab-size:2}.message.user .text{background:var(--soft);border-radius:9px;padding:10px 12px}.message.streaming .role{color:var(--accent)}.activity{margin:0;padding:3px 16px;color:var(--accent);font-size:12px;min-height:24px}form{padding:8px 16px 10px}textarea{resize:vertical;max-height:220px;min-height:76px}form>.row{margin-top:6px}#attachment{font-size:11px;display:flex;align-items:center;justify-content:space-between;gap:5px;background:var(--soft);border-radius:6px;padding:5px 8px;margin-bottom:6px}#attachment-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}footer{font-size:10px;display:flex;justify-content:space-between;padding:8px 16px 12px;color:var(--muted)}a{color:var(--muted);text-decoration:none}a:hover{text-decoration:underline}[hidden]{display:none!important}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(prefers-color-scheme:dark){:root{--bg:#121c1c;--surface:#192525;--ink:#e1ede9;--muted:#94aaa5;--line:#2e4140;--accent:#43cabb;--soft:#203a34;--error:#e9997e}button{color:#10201d}}@media(max-width:340px){.badge{display:none}}
|
||||
*{box-sizing:border-box}body{margin:0;min-width:300px;height:100dvh;display:flex;flex-direction:column;background:var(--bg);color:var(--ink)}header{padding:18px 16px 10px;display:flex;align-items:center;justify-content:space-between;gap:6px}.brand{display:flex;align-items:center;gap:8px;letter-spacing:.02em}.mark{background:var(--accent);color:#fff;border-radius:8px;display:grid;place-items:center;width:28px;height:28px;font-size:19px;font-weight:750}.badge{color:var(--muted);font-size:9px;letter-spacing:.1em}button,input,textarea,select{font:inherit;color:inherit}button{cursor:pointer;background:var(--accent);color:#fff;border:0;border-radius:7px;padding:8px 12px;font-weight:600}button:disabled{opacity:.4;cursor:default}.quiet{background:transparent;color:var(--muted);font-size:12px;padding:6px}.quiet:hover:enabled{background:var(--soft);color:var(--accent)}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.connection{padding:0 16px 14px;display:flex;align-items:center;gap:7px;color:var(--muted);font-size:12px}.dot{height:7px;width:7px;border-radius:50%;background:var(--muted);flex-shrink:0}.dot.online{background:var(--accent)}.picker{padding:8px 16px 10px;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.row{display:flex;align-items:center;justify-content:space-between;gap:8px}label{font-weight:650;font-size:12px}input,select,textarea{display:block;width:100%;border:1px solid var(--line);background:var(--surface);border-radius:7px;padding:10px}input{margin:5px 0 8px}select{font-weight:600;text-overflow:ellipsis}.hint{font-size:11px;color:var(--muted);margin:7px 0 0}.picker .hint{margin-top:6px}#notice{margin:10px 16px 0;padding:10px;background:var(--soft);border-left:3px solid var(--error);font-size:12px;overflow-wrap:anywhere}main{flex:1;min-height:100px;overflow-y:auto;padding:12px 16px;scrollbar-width:thin}.empty{padding:30px 6px;color:var(--muted)}.empty-mark{font-size:42px;color:var(--accent);font-weight:800}h1{font-size:27px;line-height:1.2;letter-spacing:-.04em;color:var(--ink);font-weight:600}.message{margin:8px 0 22px;overflow-wrap:anywhere}.message .role{font-weight:700;color:var(--muted);font-size:11px;margin-bottom:6px;letter-spacing:.05em}.message .text{white-space:pre-wrap;tab-size:2}.message.user .text{background:var(--soft);border-radius:9px;padding:10px 12px}.message.streaming .role{color:var(--accent)}.activity{margin:0;padding:3px 16px;color:var(--accent);font-size:12px;min-height:24px}form{padding:8px 16px 10px}textarea{resize:vertical;max-height:220px;min-height:76px}form>.row{margin-top:6px}#attachment{font-size:11px;display:flex;align-items:center;justify-content:space-between;gap:5px;background:var(--soft);border-radius:6px;padding:5px 8px;margin-bottom:6px}#attachment-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}footer{font-size:10px;display:flex;justify-content:space-between;padding:8px 16px 12px;color:var(--muted)}a{color:var(--muted);text-decoration:none}a:hover{text-decoration:underline}[hidden]{display:none!important}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}.control{margin:0 16px 10px;padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:var(--surface)}.control .hint{margin-top:6px}.control .actions{justify-content:flex-start;gap:4px;margin-top:4px}.toggle{display:flex;align-items:center;gap:8px;font-weight:650;font-size:12px;cursor:pointer;min-height:32px}.toggle input{width:18px;height:18px;margin:0;accent-color:var(--accent)}.stop{background:var(--error);color:#fff;padding:6px 12px}#control-details{margin-top:6px;font-size:11px;color:var(--muted)}#control-details summary{cursor:pointer;min-height:24px}#control-log{margin:6px 0 0;padding-left:18px;max-height:160px;overflow-y:auto}#control-log li{margin:3px 0;overflow-wrap:anywhere}#control-log li.failed{color:var(--error)}#control-log li.running{color:var(--accent)}@media(prefers-color-scheme:dark){:root{--bg:#121c1c;--surface:#192525;--ink:#e1ede9;--muted:#94aaa5;--line:#2e4140;--accent:#43cabb;--soft:#203a34;--error:#e9997e}button{color:#10201d}}@media(max-width:340px){.badge{display:none}}
|
||||
@@ -4,6 +4,12 @@
|
||||
<body>
|
||||
<header><div class="brand"><span class="mark">A</span><strong>AITURK</strong><span class="badge">CHROME BETA</span></div><button id="reconnect" class="quiet">Yeniden bağlan</button></header>
|
||||
<div class="connection"><span id="dot" class="dot"></span><span id="connection" role="status">IDE bağlantısı kuruluyor…</span></div>
|
||||
<section id="control" class="control" aria-label="Ajanın bu Chrome’u kullanması">
|
||||
<div class="row"><label class="toggle"><input id="control-enabled" type="checkbox"><span>Ajan bu Chrome’u kullanabilsin</span></label><button id="control-stop" type="button" class="stop" hidden>Durdur</button></div>
|
||||
<p id="control-status" class="hint" role="status">Durum okunuyor…</p>
|
||||
<div class="row actions"><button id="control-share" type="button" class="quiet">+ Bu sekmeyi ajana ver</button><button id="control-release" type="button" class="quiet" hidden>Sekmeleri geri al</button></div>
|
||||
<details id="control-details" hidden><summary>Ajanın son işlemleri</summary><ol id="control-log"></ol></details>
|
||||
</section>
|
||||
<section class="picker" aria-label="IDE konuşmaları">
|
||||
<div class="row"><label for="search">IDE konuşmaların</label><button id="refresh" class="quiet" disabled>Yenile</button></div>
|
||||
<input id="search" type="search" placeholder="Başlığa göre ara…" autocomplete="off" disabled>
|
||||
@@ -19,6 +25,6 @@
|
||||
<div class="row"><button id="add-page" type="button" class="quiet" disabled>+ Sayfayı / seçimi ekle</button><div><button id="stop" type="button" class="quiet" hidden>Durdur</button><button id="send" type="submit" disabled>Gönder ↑</button></div></div>
|
||||
<p class="hint">Sayfa yalnızca siz eklediğinizde okunur. Gönderilen mesajlar IDE’nin araçlarını çalıştırabilir. Onaylar IDE’de yanıtlanır.</p>
|
||||
</form>
|
||||
<footer><a href="https://turkservis.online/indir" target="_blank" rel="noopener noreferrer">Kurulum ve yardım ↗</a><span>0.1.0 · Windows</span></footer>
|
||||
<footer><a href="https://turkservis.online/indir" target="_blank" rel="noopener noreferrer">Kurulum ve yardım ↗</a><span>0.2.0 · Windows · Alt+Shift+A</span></footer>
|
||||
<script src="panel.js" type="module"></script>
|
||||
</body></html>
|
||||
@@ -112,9 +112,11 @@ function paintHistory(messages) {
|
||||
|
||||
async function openSession(id) {
|
||||
const intent = ++generation
|
||||
// A page sent from the context menu before any conversation was chosen
|
||||
// waits for the first choice; switching conversations still clears it.
|
||||
if (selected) { attachment = null; $('attachment').hidden = true }
|
||||
selected = id; opening = Boolean(id); stream = null
|
||||
bufferedEvents = []
|
||||
attachment = null; $('attachment').hidden = true
|
||||
$('message').value = ''; $('stop').hidden = true
|
||||
notice(''); controls()
|
||||
if (!id) { opening = false; $('transcript').replaceChildren(); activity('Bir konuşma seçin.'); return }
|
||||
@@ -203,20 +205,92 @@ $('stop').addEventListener('click', async () => {
|
||||
try { await request('message.stop', { sessionId: selected }); activity('Durdurma isteği IDE’ye iletildi.') }
|
||||
catch (error) { notice(error.message) }
|
||||
})
|
||||
function setAttachment(context) {
|
||||
if (!context?.text) throw new Error('Bu sayfadan okunabilir metin alınamadı.')
|
||||
attachment = context
|
||||
$('attachment-label').textContent = `${context.selection ? 'Seçim' : 'Sayfa'}: ${context.title} · ${context.text.length.toLocaleString('tr')} karakter`
|
||||
$('attachment').hidden = false
|
||||
notice(selected ? 'Sayfa içeriği mesajınızla birlikte IDE’ye ve seçili model sağlayıcısına gönderilecek. Hassas bilgi içermediğinden emin olun.'
|
||||
: 'Ek hazır. Göndermek için yukarıdan bir IDE konuşması seçin.')
|
||||
}
|
||||
|
||||
async function capture(tabId) {
|
||||
const results = await chrome.scripting.executeScript({ target: { tabId }, func: capturePageContext })
|
||||
return results[0]?.result
|
||||
}
|
||||
|
||||
$('add-page').addEventListener('click', async () => {
|
||||
const intent = generation
|
||||
try {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
|
||||
if (!tab?.id || !/^https?:\/\//.test(tab.url || '')) throw new Error('Yalnızca normal HTTP/HTTPS sayfaları eklenebilir. Önce AITURK araç çubuğu simgesine tıklayarak bu sekmeye erişim verin.')
|
||||
const results = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: capturePageContext })
|
||||
if (!tab?.id || !/^https?:\/\//.test(tab.url || '')) throw new Error('Yalnızca normal HTTP/HTTPS sayfaları eklenebilir.')
|
||||
const context = await capture(tab.id)
|
||||
if (intent !== generation) return
|
||||
const context = results[0]?.result
|
||||
if (!context?.text) throw new Error('Bu sayfadan okunabilir metin alınamadı.')
|
||||
attachment = context
|
||||
$('attachment-label').textContent = `${context.selection ? 'Seçim' : 'Sayfa'}: ${context.title} · ${context.text.length.toLocaleString('tr')} karakter`
|
||||
$('attachment').hidden = false
|
||||
notice('Sayfa içeriği mesajınızla birlikte IDE’ye ve seçili model sağlayıcısına gönderilecek. Hassas bilgi içermediğinden emin olun.')
|
||||
setAttachment(context)
|
||||
} catch (error) { notice(error.message) }
|
||||
})
|
||||
|
||||
// Text or a page sent from the right-click menu while the panel opens.
|
||||
async function takePendingContext() {
|
||||
const saved = await chrome.storage.session.get(['pendingContext', 'pendingNotice'])
|
||||
if (saved.pendingNotice) { notice(saved.pendingNotice); await chrome.storage.session.remove('pendingNotice') }
|
||||
const pending = saved.pendingContext
|
||||
if (!pending) return
|
||||
await chrome.storage.session.remove('pendingContext')
|
||||
if (Date.now() - pending.at > 120000) return
|
||||
try {
|
||||
if (pending.kind === 'selection' && pending.text) {
|
||||
const tab = await chrome.tabs.get(pending.tabId).catch(() => null)
|
||||
let address = ''
|
||||
try { const url = new URL(tab?.url || ''); address = url.origin + url.pathname } catch {}
|
||||
setAttachment({ title: (tab?.title || '').slice(0, 200), url: address, text: pending.text.slice(0, 24000), selection: true })
|
||||
} else setAttachment(await capture(pending.tabId))
|
||||
} catch (error) { notice(error.message) }
|
||||
}
|
||||
|
||||
// ── The agent using this Chrome ───────────────────────────────────────
|
||||
const STATUS_MARK = { ok: '✓', failed: '✕', cancelled: '■', running: '…' }
|
||||
|
||||
function renderControl(state) {
|
||||
if (!state || typeof state !== 'object') return
|
||||
$('control-enabled').checked = Boolean(state.enabled)
|
||||
$('control-stop').hidden = !(state.enabled && state.running)
|
||||
$('control-release').hidden = !(state.tabs?.length || state.shared)
|
||||
let text
|
||||
if (!state.enabled) text = 'Kapalı. Açarsan AITURK ajanı tarayıcı işlerini bu Chrome’da, kendi “AITURK” sekmelerinde yapar.'
|
||||
else if (!state.registered) text = `IDE bekleniyor… AITURK IDE açık olmalı.${state.error ? ' (' + state.error + ')' : ''}`
|
||||
else if (state.running) text = 'Ajan şu an bu Chrome’da çalışıyor. Durdurmak için “Durdur”a bas.'
|
||||
else text = 'Hazır: ajan tarayıcı işlerini bu Chrome’da, “AITURK” sekme grubunda yapar. Şifre ve kart alanlarına yazmaz.'
|
||||
if (state.shared) text += ` Paylaşılan sekme: “${state.shared.title || 'sekme'}”; ajan bir sonraki tarayıcı işinde bunu kullanır.`
|
||||
if (state.tabs?.length) text += ` Ajanın sekmeleri: ${state.tabs.map(tab => tab.url || tab.title).filter(Boolean).join(', ')}.`
|
||||
$('control-status').textContent = text
|
||||
const items = (state.log || []).map(entry => {
|
||||
const item = document.createElement('li')
|
||||
item.className = entry.status
|
||||
item.textContent = `${STATUS_MARK[entry.status] || ''} ${entry.text}${entry.note ? ' — ' + entry.note : ''}`
|
||||
return item
|
||||
})
|
||||
$('control-log').replaceChildren(...items)
|
||||
$('control-details').hidden = !items.length
|
||||
}
|
||||
|
||||
async function control(type, extra = {}) {
|
||||
try {
|
||||
const reply = await chrome.runtime.sendMessage({ type, ...extra })
|
||||
if (reply?.failure) notice(reply.failure)
|
||||
else renderControl(reply)
|
||||
} catch (error) { notice(`Eklenti arka planına ulaşılamadı: ${error.message}`) }
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener(message => { if (message?.type === 'control.state') renderControl(message.state) })
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area === 'session' && (changes.pendingContext?.newValue || changes.pendingNotice?.newValue)) void takePendingContext()
|
||||
})
|
||||
$('control-enabled').addEventListener('change', () => void control('control.set', { enabled: $('control-enabled').checked }))
|
||||
$('control-stop').addEventListener('click', () => void control('control.stop'))
|
||||
$('control-share').addEventListener('click', () => void control('control.share', { sessionId: selected || null }))
|
||||
$('control-release').addEventListener('click', () => void control('control.release'))
|
||||
$('remove-attachment').addEventListener('click', () => { attachment = null; $('attachment').hidden = true; notice('') })
|
||||
void connect()
|
||||
void control('control.get')
|
||||
void takePendingContext()
|
||||
@@ -0,0 +1,339 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createRequire } from 'node:module'
|
||||
import { test } from 'node:test'
|
||||
import * as page from '../extension/page-agent.js'
|
||||
import { CAPABILITIES, checkUrl, createController, parseKey, ToolError } from '../extension/controller.js'
|
||||
|
||||
const { JSDOM } = createRequire(import.meta.url)('jsdom')
|
||||
const GLOBALS = ['window', 'document', 'location', 'getComputedStyle', 'HTMLInputElement', 'HTMLTextAreaElement', 'Event', 'innerHeight', 'innerWidth']
|
||||
|
||||
function pageFixture(t, html) {
|
||||
const dom = new JSDOM(`<!doctype html><html><head><title>Sipariş formu</title></head><body>${html}</body></html>`,
|
||||
{ url: 'https://shop.example.test/checkout?session=gizli' })
|
||||
const { window } = dom
|
||||
window.HTMLElement.prototype.getBoundingClientRect = function () {
|
||||
if (this.dataset.size === '0') return { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }
|
||||
return { left: 10, top: 20, right: 110, bottom: 40, width: 100, height: 20 }
|
||||
}
|
||||
window.HTMLElement.prototype.scrollIntoView = () => {}
|
||||
window.document.elementFromPoint = () => null
|
||||
window.scrollBy = () => {}
|
||||
for (const key of GLOBALS) globalThis[key] = key === 'innerHeight' ? 800 : key === 'innerWidth' ? 1200 : window[key]
|
||||
t.after(() => { delete globalThis.__aiturkRefs; window.close(); for (const key of GLOBALS) delete globalThis[key] })
|
||||
return window
|
||||
}
|
||||
|
||||
const FORM = `
|
||||
<h1>Ödeme</h1>
|
||||
<nav aria-hidden="true"><a href="/gizli">Gizli menü</a></nav>
|
||||
<a href="/docs">Belgeler</a>
|
||||
<label for="mail">E-posta</label><input id="mail" type="email" value="ali@example.test">
|
||||
<input type="password" aria-label="Şifre" value="cok-gizli">
|
||||
<input autocomplete="cc-number" aria-label="Kart numarası">
|
||||
<button style="display:none">Görünmez</button>
|
||||
<button data-size="0">Sıfır boyut</button>
|
||||
<select aria-label="Şehir"><option>Ankara</option><option selected>İzmir</option></select>
|
||||
<label><input type="checkbox" checked> Koşulları kabul ediyorum</label>
|
||||
<p>Toplam tutar: 120 TL</p>
|
||||
<button>Siparişi tamamla</button>
|
||||
<img src="data:image/png;base64,${'A'.repeat(200)}" alt="Ürün">`
|
||||
|
||||
test('snapshot lists visible controls with refs, masks passwords and skips hidden parts', t => {
|
||||
pageFixture(t, FORM)
|
||||
const result = page.snapshotPage({ full: false })
|
||||
const lines = result.snapshot.split('\n')
|
||||
assert.equal(lines[0], 'Sayfa: Sipariş formu')
|
||||
assert.ok(lines.includes('- heading "Ödeme" [level=1]'))
|
||||
assert.ok(lines.includes('- link "Belgeler" [ref=e1]'))
|
||||
assert.ok(lines.includes('- textbox "E-posta" [ref=e2] (değer: "ali@example.test")'))
|
||||
const password = lines.find(line => line.includes('"Şifre"'))
|
||||
assert.ok(password.includes('(değer: "••••")') && password.includes('şifre alanı: ajan yazamaz'))
|
||||
assert.ok(!result.snapshot.includes('cok-gizli'))
|
||||
assert.ok(!result.snapshot.includes('Gizli menü'))
|
||||
assert.ok(!result.snapshot.includes('Görünmez') && !result.snapshot.includes('Sıfır boyut'))
|
||||
assert.ok(lines.some(line => line.startsWith('- combobox "Şehir"') && line.includes('(değer: "İzmir")')))
|
||||
assert.ok(lines.some(line => line.startsWith('- checkbox "Koşulları kabul ediyorum"') && line.includes('[checked]')))
|
||||
assert.ok(!result.snapshot.includes('Toplam tutar'), 'compact view leaves plain text out')
|
||||
assert.equal(result.element_count, 7)
|
||||
assert.equal(globalThis.__aiturkRefs.size, 7)
|
||||
|
||||
const full = page.snapshotPage({ full: true })
|
||||
assert.ok(full.snapshot.includes('- text: Toplam tutar: 120 TL'))
|
||||
assert.ok(full.snapshot.includes('→ https://shop.example.test/docs'))
|
||||
assert.ok(full.snapshot.includes('- option "Ankara"'))
|
||||
|
||||
const short = page.snapshotPage({ limit: 2000, full: true })
|
||||
assert.ok(short.snapshot.length <= 2300)
|
||||
})
|
||||
|
||||
test('a long page is cut with an explicit note instead of silently', t => {
|
||||
pageFixture(t, Array.from({ length: 400 }, (_, index) => `<button>Düğme numarası ${index}</button>`).join(''))
|
||||
const result = page.snapshotPage({ limit: 2000 })
|
||||
assert.equal(result.truncated, true)
|
||||
assert.match(result.snapshot, /satır daha var; kısaltıldı/)
|
||||
})
|
||||
|
||||
test('refs resolve to coordinates; stale refs fail with a recoverable message', t => {
|
||||
const window = pageFixture(t, FORM)
|
||||
page.snapshotPage({})
|
||||
const located = page.locateRef('@e2')
|
||||
assert.deepEqual([located.x, located.y, located.tag, located.password], [60, 30, 'input', false])
|
||||
window.document.getElementById('mail').remove()
|
||||
assert.match(page.locateRef('@e2').error, /browser_snapshot/)
|
||||
assert.match(page.locateRef('e99').error, /bulunamadı/)
|
||||
})
|
||||
|
||||
test('typing refuses password and card fields, focuses text fields and fires framework events', t => {
|
||||
const window = pageFixture(t, FORM)
|
||||
page.snapshotPage({})
|
||||
const refs = [...globalThis.__aiturkRefs.keys()]
|
||||
const refOf = predicate => `@${refs.find(ref => predicate(globalThis.__aiturkRefs.get(ref).deref()))}`
|
||||
const passwordRef = refOf(element => element.type === 'password')
|
||||
const cardRef = refOf(element => element.getAttribute('autocomplete') === 'cc-number')
|
||||
assert.equal(page.prepareTyping(passwordRef).handoff, true)
|
||||
assert.match(page.prepareTyping(cardRef).error, /Kart/)
|
||||
assert.match(page.prepareTyping('@e1').error, /yazı alanı değil/)
|
||||
|
||||
const mail = window.document.getElementById('mail')
|
||||
let inputs = 0
|
||||
mail.addEventListener('input', () => inputs++)
|
||||
assert.equal(page.prepareTyping('@e2').focused, true)
|
||||
assert.deepEqual(page.typeWithEvents('@e2', 'veli@example.test'), { value: 'veli@example.test' })
|
||||
assert.equal(inputs, 1)
|
||||
assert.deepEqual(page.readValue('@e2'), { value: 'veli@example.test' })
|
||||
|
||||
const selectRef = refOf(element => element.tagName === 'SELECT')
|
||||
assert.deepEqual(page.prepareTyping(selectRef), { select: true })
|
||||
assert.deepEqual(page.typeWithEvents(selectRef, 'ankara'), { value: 'Ankara' })
|
||||
assert.match(page.typeWithEvents(selectRef, 'Paris').error, /seçeneği yok/)
|
||||
})
|
||||
|
||||
test('images are listed with data URLs shortened; annotation labels come and go', t => {
|
||||
const window = pageFixture(t, FORM)
|
||||
const images = page.listImages()
|
||||
assert.equal(images.count, 1)
|
||||
assert.ok(images.images[0].src.length < 70 && images.images[0].alt === 'Ürün')
|
||||
page.snapshotPage({})
|
||||
const labels = page.annotateRefs(true)
|
||||
assert.equal(labels.annotations.length, 7)
|
||||
assert.ok(window.document.getElementById('__aiturk_labels'))
|
||||
page.annotateRefs(false)
|
||||
assert.equal(window.document.getElementById('__aiturk_labels'), null)
|
||||
})
|
||||
|
||||
test('keys and addresses are validated before anything touches the page', () => {
|
||||
assert.deepEqual(parseKey('Enter'), { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, text: '\r', modifiers: 0 })
|
||||
assert.deepEqual(parseKey('ctrl+a'), { key: 'a', code: 'KeyA', windowsVirtualKeyCode: 65, text: undefined, modifiers: 2 })
|
||||
assert.equal(parseKey('Shift+Tab').modifiers, 8)
|
||||
assert.equal(parseKey('esc').key, 'Escape')
|
||||
assert.throws(() => parseKey('Hyper+x'), ToolError)
|
||||
assert.throws(() => parseKey('NotAKey'), ToolError)
|
||||
assert.equal(checkUrl('example.test/a?b=1'), 'https://example.test/a?b=1')
|
||||
assert.equal(checkUrl('http://localhost:5173/'), 'http://localhost:5173/')
|
||||
for (const blocked of ['chrome://settings', 'file:///C:/Windows/win.ini', 'javascript:alert(1)', 'chrome-extension://abc/panel.html',
|
||||
'https://chromewebstore.google.com/detail/x', 'https://user:pass@example.test/']) {
|
||||
assert.throws(() => checkUrl(blocked), ToolError, blocked)
|
||||
}
|
||||
})
|
||||
|
||||
// ── controller with a fake Chrome ──────────────────────────────────────
|
||||
const hub = () => ({ listeners: new Set(), addListener(fn) { this.listeners.add(fn) }, removeListener(fn) { this.listeners.delete(fn) },
|
||||
emit(...args) { for (const fn of [...this.listeners]) fn(...args) } })
|
||||
const pick = (store, keys) => Object.fromEntries([keys].flat().filter(key => key in store).map(key => [key, store[key]]))
|
||||
const until = async (condition, label) => {
|
||||
for (let index = 0; index < 400; index++) { if (condition()) return; await new Promise(resolve => setTimeout(resolve, 5)) }
|
||||
assert.fail(`zaman aşımı: ${label}`)
|
||||
}
|
||||
|
||||
function fakeChrome({ pageScript = {}, tabs: initialTabs = [] } = {}) {
|
||||
const local = {}, session = {}, native = [], debuggerCalls = [], notifications = [], groups = new Map()
|
||||
const tabs = new Map(initialTabs.map(tab => [tab.id, { status: 'complete', windowId: 1, active: true, ...tab }]))
|
||||
let nextTab = 100, nextGroup = 1, port = null
|
||||
const onUpdated = hub()
|
||||
const respond = message => {
|
||||
const result = message.action === 'status' ? { profile: 'default', local: true }
|
||||
: message.action === 'browser.register' ? { registered: message.params.enabled }
|
||||
: message.action === 'sessions.list' ? { sessions: [{ id: 'durable-1', title: 'Rapor işi' }] }
|
||||
: { accepted: true }
|
||||
port.onMessage.emit({ id: message.id, result })
|
||||
}
|
||||
const chrome = {
|
||||
runtime: {
|
||||
connectNative: () => {
|
||||
port = { onMessage: hub(), onDisconnect: hub(), disconnected: false, disconnect() { this.disconnected = true },
|
||||
postMessage: message => { native.push(message); queueMicrotask(() => respond(message)) } }
|
||||
return port
|
||||
},
|
||||
sendMessage: async () => {},
|
||||
},
|
||||
storage: {
|
||||
local: { get: async keys => pick(local, keys), set: async value => Object.assign(local, value) },
|
||||
session: { get: async keys => structuredClone(pick(session, keys)), set: async value => Object.assign(session, structuredClone(value)) },
|
||||
},
|
||||
windows: { getLastFocused: async () => ({ id: 1 }) },
|
||||
tabs: {
|
||||
onUpdated,
|
||||
create: async ({ url, active, windowId }) => {
|
||||
const tab = { id: nextTab++, url, active, windowId, status: 'complete', title: '' }
|
||||
tabs.set(tab.id, tab)
|
||||
return { ...tab }
|
||||
},
|
||||
get: async id => { if (!tabs.has(id)) throw new Error(`No tab with id: ${id}`); return { ...tabs.get(id) } },
|
||||
update: async (id, { url }) => {
|
||||
const tab = tabs.get(id)
|
||||
Object.assign(tab, { pendingUrl: url, status: 'loading' })
|
||||
setTimeout(() => { Object.assign(tab, { url, pendingUrl: undefined, status: 'complete', title: 'Açılan sayfa' }); onUpdated.emit(id, { status: 'complete' }) }, 20)
|
||||
return { ...tab }
|
||||
},
|
||||
goBack: async () => {},
|
||||
query: async () => [...tabs.values()].filter(tab => tab.active).map(tab => ({ ...tab })),
|
||||
group: async ({ tabIds, groupId }) => { const id = groupId ?? nextGroup++; groups.set(id, { windowId: tabs.get(tabIds[0]).windowId }); return id },
|
||||
captureVisibleTab: async () => 'data:image/jpeg;base64,QUJD',
|
||||
},
|
||||
tabGroups: { get: async id => ({ id, ...groups.get(id) }), update: async (id, value) => Object.assign(groups.get(id), value) },
|
||||
scripting: {
|
||||
executeScript: async ({ target, func, args }) => {
|
||||
const handler = pageScript[func.name]
|
||||
if (!handler) throw new Error(`script not expected: ${func.name}`)
|
||||
return [{ result: await handler(...args, target.tabId) }]
|
||||
},
|
||||
},
|
||||
debugger: {
|
||||
attach: async () => {}, detach: async () => {},
|
||||
sendCommand: async (target, method, params) => { debuggerCalls.push([method, params]); return method === 'Page.captureScreenshot' ? { data: 'QUJD' } : {} },
|
||||
},
|
||||
notifications: { create: async (...args) => { notifications.push(args) } },
|
||||
alarms: { create() {}, clear() {} },
|
||||
action: { setBadgeText: async () => {} },
|
||||
}
|
||||
return { chrome, native, debuggerCalls, notifications, tabs, groups, local, session, get port() { return port } }
|
||||
}
|
||||
|
||||
const SNAPSHOT = { snapshot: 'Sayfa: Açılan sayfa\n- button "Gönder" [ref=e1]', element_count: 1, url: 'https://example.test/', title: 'Açılan sayfa' }
|
||||
|
||||
async function connected(fake) {
|
||||
const controller = createController({ chrome: fake.chrome, random: () => 'instance-1' })
|
||||
await controller.connect()
|
||||
await until(() => controller._state().registered, 'registration')
|
||||
return controller
|
||||
}
|
||||
|
||||
function command(fake, action, args, commandId = 'ab'.repeat(16)) {
|
||||
fake.port.onMessage.emit({ event: 'browser.command', commandId, action, arguments: args, sessionId: 'durable-1' })
|
||||
}
|
||||
const resultFor = (fake, commandId = 'ab'.repeat(16)) => fake.native.find(message => message.action === 'browser.result' && message.params.commandId === commandId)
|
||||
|
||||
test('controller registers this Chrome for the IDE agent with a stable identity', async () => {
|
||||
const fake = fakeChrome()
|
||||
await connected(fake)
|
||||
const register = fake.native.find(message => message.action === 'browser.register')
|
||||
assert.deepEqual(register.params, { enabled: true, capabilities: [...CAPABILITIES], controllerId: 'aiturk-chrome-instance-1', browserProfileId: 'instance-1' })
|
||||
assert.equal(fake.local.controllerInstance, 'instance-1')
|
||||
})
|
||||
|
||||
test('navigate opens the agent its own background tab in an AITURK group and returns a snapshot', async () => {
|
||||
const fake = fakeChrome({ pageScript: { snapshotPage: () => SNAPSHOT } })
|
||||
await connected(fake)
|
||||
command(fake, 'browser_navigate', { url: 'example.test' })
|
||||
await until(() => resultFor(fake), 'navigate result')
|
||||
const { params } = resultFor(fake)
|
||||
assert.equal(params.ok, true)
|
||||
assert.equal(params.result.success, true)
|
||||
assert.equal(params.result.snapshot, SNAPSHOT.snapshot)
|
||||
const tab = fake.tabs.get(100)
|
||||
assert.equal(tab.active, false)
|
||||
assert.equal(tab.url, 'https://example.test/')
|
||||
assert.equal(fake.groups.get(1).title, 'AITURK · Rapor işi')
|
||||
assert.equal(fake.groups.get(1).color, 'cyan')
|
||||
})
|
||||
|
||||
test('blocked pages and missing tabs come back as tool errors the agent can read', async () => {
|
||||
const fake = fakeChrome()
|
||||
await connected(fake)
|
||||
command(fake, 'browser_navigate', { url: 'chrome://settings/passwords' }, '01'.repeat(16))
|
||||
command(fake, 'browser_click', { ref: '@e1' }, '02'.repeat(16))
|
||||
await until(() => resultFor(fake, '01'.repeat(16)) && resultFor(fake, '02'.repeat(16)), 'refusals')
|
||||
assert.deepEqual(resultFor(fake, '01'.repeat(16)).params.result.success, false)
|
||||
assert.match(resultFor(fake, '01'.repeat(16)).params.result.error, /çalışamaz/)
|
||||
assert.match(resultFor(fake, '02'.repeat(16)).params.result.error, /browser_navigate/)
|
||||
assert.equal(fake.tabs.size, 0, 'a refused navigation never opens a tab')
|
||||
})
|
||||
|
||||
test('click is a real mouse click at the element; a covering element is reported', async () => {
|
||||
const fake = fakeChrome({ tabs: [{ id: 7, url: 'https://example.test/', title: 'Örnek' }],
|
||||
pageScript: { locateRef: () => ({ x: 60, y: 30, name: 'Gönder', covered: 'Çerez bildirimi' }) } })
|
||||
const controller = await connected(fake)
|
||||
await controller.shareTab(await fake.chrome.tabs.get(7), 'durable-1')
|
||||
command(fake, 'browser_click', { ref: '@e1' })
|
||||
await until(() => resultFor(fake), 'click result')
|
||||
const { result } = resultFor(fake).params
|
||||
assert.equal(result.success, true)
|
||||
assert.match(result.warning, /Çerez bildirimi/)
|
||||
const mouse = fake.debuggerCalls.filter(([method]) => method === 'Input.dispatchMouseEvent').map(([, params]) => [params.type, params.x, params.y])
|
||||
assert.deepEqual(mouse, [['mousePressed', 60, 30], ['mouseReleased', 60, 30]])
|
||||
})
|
||||
|
||||
test('password fields are handed to the user: nothing is typed and a notification is shown', async () => {
|
||||
const fake = fakeChrome({ tabs: [{ id: 7, url: 'https://example.test/login' }],
|
||||
pageScript: { prepareTyping: () => ({ error: 'Şifre alanına ajan yazamaz. Bu adımı kullanıcıya bırakın.', handoff: true }) } })
|
||||
const controller = await connected(fake)
|
||||
await controller.shareTab(await fake.chrome.tabs.get(7), 'durable-1')
|
||||
command(fake, 'browser_type', { ref: '@e2', text: 'parola' })
|
||||
await until(() => resultFor(fake), 'type result')
|
||||
assert.equal(resultFor(fake).params.result.success, false)
|
||||
assert.ok(!fake.debuggerCalls.some(([method]) => method === 'Input.insertText'))
|
||||
assert.equal(fake.notifications.length, 1)
|
||||
})
|
||||
|
||||
test('typing inserts trusted text and reports what the field now holds', async () => {
|
||||
const fake = fakeChrome({ tabs: [{ id: 7, url: 'https://example.test/' }],
|
||||
pageScript: { prepareTyping: () => ({ focused: true }), readValue: () => ({ value: 'İstanbul' }) } })
|
||||
const controller = await connected(fake)
|
||||
await controller.shareTab(await fake.chrome.tabs.get(7), 'durable-1')
|
||||
command(fake, 'browser_type', { ref: '@e3', text: 'İstanbul' })
|
||||
await until(() => resultFor(fake), 'type result')
|
||||
assert.deepEqual(resultFor(fake).params.result, { success: true, typed: '@e3', value: 'İstanbul' })
|
||||
assert.deepEqual(fake.debuggerCalls.find(([method]) => method === 'Input.insertText')[1], { text: 'İstanbul' })
|
||||
})
|
||||
|
||||
test('vision returns a JPEG capture of the agent tab', async () => {
|
||||
const fake = fakeChrome({ tabs: [{ id: 7, url: 'https://example.test/', title: 'Örnek' }] })
|
||||
const controller = await connected(fake)
|
||||
await controller.shareTab(await fake.chrome.tabs.get(7), 'durable-1')
|
||||
command(fake, 'browser_vision', { question: 'Ne var?' })
|
||||
await until(() => resultFor(fake), 'vision result')
|
||||
assert.deepEqual(resultFor(fake).params.result, { success: true, screenshot: 'QUJD', mime: 'image/jpeg', url: 'https://example.test/', title: 'Örnek' })
|
||||
})
|
||||
|
||||
test('a cancelled command never answers late; stopping turns control off at the IDE', async () => {
|
||||
let release
|
||||
const fake = fakeChrome({ tabs: [{ id: 7, url: 'https://example.test/' }],
|
||||
pageScript: { snapshotPage: () => new Promise(resolve => { release = () => resolve(SNAPSHOT) }) } })
|
||||
const controller = await connected(fake)
|
||||
await controller.shareTab(await fake.chrome.tabs.get(7), 'durable-1')
|
||||
command(fake, 'browser_snapshot', {})
|
||||
await until(() => release, 'snapshot started')
|
||||
fake.port.onMessage.emit({ event: 'browser.cancel', commandId: 'ab'.repeat(16) })
|
||||
release()
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
assert.equal(resultFor(fake), undefined)
|
||||
|
||||
const port = fake.port
|
||||
const state = await controller.onMessage({ type: 'control.stop' })
|
||||
assert.equal(state.enabled, false)
|
||||
assert.deepEqual(fake.native.filter(message => message.action === 'browser.register').at(-1).params, { enabled: false })
|
||||
assert.equal(port.disconnected, true)
|
||||
assert.equal(fake.local.controlEnabled, false)
|
||||
})
|
||||
|
||||
test('cancelling Chrome’s debugging bar is treated as the user stopping the agent', async () => {
|
||||
const fake = fakeChrome({ tabs: [{ id: 7, url: 'https://example.test/' }],
|
||||
pageScript: { locateRef: () => ({ x: 1, y: 1, name: 'x' }) } })
|
||||
const controller = await connected(fake)
|
||||
await controller.shareTab(await fake.chrome.tabs.get(7), 'durable-1')
|
||||
command(fake, 'browser_click', { ref: '@e1' })
|
||||
await until(() => resultFor(fake), 'click result')
|
||||
controller.onDebuggerDetach({ tabId: 7 }, 'canceled_by_user')
|
||||
await until(() => fake.local.controlEnabled === false, 'disabled')
|
||||
assert.equal(fake.notifications.length, 1)
|
||||
})
|
||||
@@ -151,6 +151,55 @@ test('real HTTP+WebSocket: same stored/runtime session, queue sends and preserve
|
||||
assert.ok(!JSON.stringify(events).includes('secret never'))
|
||||
})
|
||||
|
||||
test('browser control: only a registered connection receives agent commands; results and refusals go back', async t => {
|
||||
const f = await fixture(t), events = []
|
||||
const client = new ChromeGatewayClient(f.resolve, event => events.push(event))
|
||||
t.after(() => client.close())
|
||||
const call = (action, params = {}) => client.handle({ id: 'test', action, params })
|
||||
const command = (action, args = {}) => client.onEvent({ type: 'browser.controller.command', session_id: 'durable-1',
|
||||
payload: { command_id: 'ab'.repeat(16), action, arguments: args, tool_call_id: 'call-1' } })
|
||||
|
||||
assert.throws(() => validateRequest({ id: 'a', action: 'browser.register', params: { enabled: true, capabilities: ['browser_cdp'], controllerId: 'c', browserProfileId: 'p' } }))
|
||||
assert.throws(() => validateRequest({ id: 'a', action: 'browser.result', params: { commandId: '../x', ok: true } }))
|
||||
assert.throws(() => validateRequest({ id: 'a', action: 'browser.result', params: { commandId: 'ab'.repeat(16), ok: false } }))
|
||||
|
||||
await call('status')
|
||||
command('browser_navigate', { url: 'https://example.test/' })
|
||||
assert.equal(events.length, 0, 'commands are never forwarded before the extension registers')
|
||||
|
||||
await call('browser.register', { enabled: true, capabilities: ['browser_navigate', 'browser_vision'], controllerId: 'chrome-ext', browserProfileId: 'profile-1' })
|
||||
assert.deepEqual(f.calls.at(-1).params, { protocol_version: 1, controller_id: 'chrome-ext', browser_profile_id: 'profile-1',
|
||||
capabilities: ['browser_navigate', 'browser_vision'], profile: 'default' })
|
||||
assert.equal(f.calls.at(-1).method, 'browser.controller.local.register')
|
||||
|
||||
command('browser_navigate', { url: 'https://example.test/' })
|
||||
assert.deepEqual(events.at(-1), { event: 'browser.command', commandId: 'ab'.repeat(16), action: 'browser_navigate',
|
||||
arguments: { url: 'https://example.test/' }, sessionId: 'durable-1' })
|
||||
|
||||
const before = f.calls.length
|
||||
command('browser_evaluate', { expression: 'document.cookie' })
|
||||
command('browser_type', { ref: '@e1', text: 'x'.repeat(300 * 1024) })
|
||||
assert.equal(events.at(-1).action, 'browser_navigate', 'refused commands never reach the extension')
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
const refusals = f.calls.slice(before)
|
||||
assert.equal(refusals.length, 2)
|
||||
assert.ok(refusals.every(frame => frame.method === 'browser.controller.local.result' && frame.params.ok === false))
|
||||
|
||||
client.onEvent({ type: 'browser.controller.cancel', payload: { command_id: 'cd'.repeat(16) } })
|
||||
assert.deepEqual(events.at(-1), { event: 'browser.cancel', commandId: 'cd'.repeat(16) })
|
||||
|
||||
await call('browser.result', { commandId: 'ab'.repeat(16), ok: true, result: { success: true, url: 'https://example.test/' } })
|
||||
assert.deepEqual(f.calls.at(-1).params, { command_id: 'ab'.repeat(16), ok: true, result: { success: true, url: 'https://example.test/' } })
|
||||
await call('browser.result', { commandId: 'ab'.repeat(16), ok: false, error: 'Sekme kapandı' })
|
||||
assert.deepEqual(f.calls.at(-1).params, { command_id: 'ab'.repeat(16), ok: false, error: 'Sekme kapandı' })
|
||||
|
||||
await call('browser.register', { enabled: false })
|
||||
assert.equal(f.calls.at(-1).method, 'browser.controller.local.detach')
|
||||
const count = events.length
|
||||
command('browser_navigate', { url: 'https://example.test/' })
|
||||
assert.equal(events.length, count, 'switching control off stops forwarding')
|
||||
})
|
||||
|
||||
test('remote and non-loopback descriptors are rejected before opening a socket', async () => {
|
||||
const client = new ChromeGatewayClient(async () => ({ baseUrl: 'https://example.test', wsUrl: 'wss://example.test/api/ws', token: 'x', mode: 'remote' }), () => {})
|
||||
await assert.rejects(client.handle({ id: '1', action: 'status' }), /yalnızca/)
|
||||
|
||||
@@ -12,21 +12,33 @@ async function settle(condition) {
|
||||
assert.ok(condition(), 'UI did not settle')
|
||||
}
|
||||
|
||||
async function panel(t, respond) {
|
||||
const CONTROL_READY = { enabled: true, connected: true, registered: true, running: 0, tabs: [], shared: null,
|
||||
log: [{ status: 'ok', text: 'Sayfa açılıyor: example.test' }, { status: 'failed', text: '<b>Yazılıyor</b>', note: 'Şifre alanına ajan yazamaz.' }] }
|
||||
|
||||
async function panel(t, respond, { controlState = CONTROL_READY, session = {} } = {}) {
|
||||
const html = await readFile(new URL('../extension/panel.html', import.meta.url), 'utf8')
|
||||
const dom = new JSDOM(html, { url: 'https://extension-fixture.test/panel.html' })
|
||||
const calls = []
|
||||
const calls = [], controlCalls = []
|
||||
const port = { onMessage: eventHub(), onDisconnect: eventHub(), disconnect() {}, postMessage(message) {
|
||||
calls.push(message)
|
||||
queueMicrotask(() => respond(message, frame => port.onMessage.emit(frame), port))
|
||||
} }
|
||||
const runtimeMessages = eventHub()
|
||||
const sessionStore = { ...session }
|
||||
Object.assign(globalThis, { window: dom.window, document: dom.window.document, Option: dom.window.Option,
|
||||
chrome: { runtime: { connectNative: () => port }, storage: { local: { get: async () => ({}), set: async () => {} } } } })
|
||||
chrome: {
|
||||
runtime: { connectNative: () => port, onMessage: runtimeMessages,
|
||||
sendMessage: async message => { controlCalls.push(message); return message.type === 'control.set' ? { ...controlState, enabled: message.enabled } : controlState } },
|
||||
storage: { local: { get: async () => ({}), set: async () => {} }, onChanged: eventHub(),
|
||||
session: { get: async keys => Object.fromEntries([keys].flat().filter(key => key in sessionStore).map(key => [key, sessionStore[key]])),
|
||||
remove: async key => { delete sessionStore[key] }, set: async value => Object.assign(sessionStore, value) } },
|
||||
tabs: { get: async id => ({ id, title: 'Kaynak sayfa', url: 'https://example.test/yazi?token=gizli#x' }) },
|
||||
} })
|
||||
await import(`../extension/panel.js?test=${Math.random()}`)
|
||||
t.after(() => { dom.window.close(); for (const key of ['window', 'document', 'Option', 'chrome']) delete globalThis[key] })
|
||||
const get = id => dom.window.document.getElementById(id)
|
||||
await settle(() => !get('sessions').disabled)
|
||||
return { dom, calls, port, get, async select(id) {
|
||||
return { dom, calls, controlCalls, runtimeMessages, port, get, async select(id) {
|
||||
get('sessions').value = id
|
||||
get('sessions').dispatchEvent(new dom.window.Event('change'))
|
||||
await settle(() => !get('sessions').disabled)
|
||||
@@ -101,3 +113,44 @@ test('explicit page capture excludes form values and strips URL credentials in q
|
||||
assert.equal(context.url, 'https://example.test/page')
|
||||
assert.equal(context.selection, false)
|
||||
})
|
||||
|
||||
test('agent control section shows the live state, never renders log text as HTML, and drives the background', async t => {
|
||||
const p = await panel(t, fixtureResponse)
|
||||
await settle(() => p.get('control-status').textContent.startsWith('Hazır'))
|
||||
assert.equal(p.get('control-enabled').checked, true)
|
||||
assert.equal(p.get('control-stop').hidden, true)
|
||||
assert.equal(p.get('control-log').querySelector('b'), null)
|
||||
assert.ok(p.get('control-log').textContent.includes('<b>Yazılıyor</b>'))
|
||||
assert.ok(p.get('control-log').textContent.includes('Şifre alanına ajan yazamaz.'))
|
||||
|
||||
p.runtimeMessages.emit({ type: 'control.state', state: { ...CONTROL_READY, running: 1 } })
|
||||
assert.equal(p.get('control-stop').hidden, false)
|
||||
assert.ok(p.get('control-status').textContent.includes('çalışıyor'))
|
||||
|
||||
await p.select('one')
|
||||
p.get('control-share').dispatchEvent(new p.dom.window.Event('click'))
|
||||
p.get('control-stop').dispatchEvent(new p.dom.window.Event('click'))
|
||||
p.get('control-enabled').checked = false
|
||||
p.get('control-enabled').dispatchEvent(new p.dom.window.Event('change'))
|
||||
await settle(() => p.controlCalls.some(call => call.type === 'control.set'))
|
||||
assert.deepEqual(p.controlCalls.find(call => call.type === 'control.share'), { type: 'control.share', sessionId: 'one' })
|
||||
assert.ok(p.controlCalls.some(call => call.type === 'control.stop'))
|
||||
assert.deepEqual(p.controlCalls.find(call => call.type === 'control.set'), { type: 'control.set', enabled: false })
|
||||
await settle(() => p.get('control-status').textContent.startsWith('Kapalı'))
|
||||
})
|
||||
|
||||
test('a selection sent from the right-click menu waits for the first conversation and drops URL secrets', async t => {
|
||||
const p = await panel(t, fixtureResponse, { session: { pendingContext: { kind: 'selection', tabId: 7, text: 'Seçilen paragraf', at: Date.now() } } })
|
||||
await settle(() => !p.get('attachment').hidden)
|
||||
assert.ok(p.get('attachment-label').textContent.includes('Seçim: Kaynak sayfa'))
|
||||
assert.ok(p.get('notice').textContent.includes('konuşması seçin'))
|
||||
await p.select('one')
|
||||
assert.equal(p.get('attachment').hidden, false, 'the first choice keeps the pending attachment')
|
||||
p.get('message').value = 'Bunu özetle'
|
||||
p.get('composer').dispatchEvent(new p.dom.window.Event('submit', { cancelable: true }))
|
||||
await settle(() => p.calls.some(call => call.action === 'message.send'))
|
||||
const sent = p.calls.find(call => call.action === 'message.send').params.text
|
||||
assert.ok(sent.includes('Seçilen paragraf'))
|
||||
assert.ok(sent.includes('https://example.test/yazi'))
|
||||
assert.ok(!sent.includes('token=gizli'))
|
||||
})
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,74 @@ Run: `python bridge.py --home <data dir> --hermes-home <agent home>`. The IDE wr
|
||||
|
||||
Tests: `python -m unittest discover` in this directory.
|
||||
|
||||
## Version 19: decision stages (14 October 2026)
|
||||
|
||||
The decision model (Jev or another System One compatible service such as Celeris) can now be asked at
|
||||
several stages. Every new stage is switched on in Settings → Kademeli Geçiş and starts disabled, so an
|
||||
upgrade changes no behaviour by itself. The decision model never writes text; an unreachable or invalid
|
||||
decision always keeps the current path.
|
||||
|
||||
- `jev-settings.json` schema 2 adds `watchModels`, `prefilter`, `autoRoute`, `finishCheck`,
|
||||
`verifierCheck` and `policy`. A schema 1 file is filled from `jev-settings-defaults.json`.
|
||||
The default chain is `Katman-1 → Katman-2 → Katman-3`; `Katman-3`, `Chat-Model`,
|
||||
`bekci-sesli-bekci` and `Sesli-Hizli-Zeki` always pass.
|
||||
- **Correction channel** (`watchModels`): the Bekçi watch sideband is no longer bound to the hard-coded
|
||||
name `Sesli-Hizli-Zeki`; any exact target in the list owns it, but only while it always passes the check
|
||||
(a checked answer may be replaced by a layer, and a receipt must not belong to an answer the user never saw).
|
||||
A single SSE frame above 256 KB no longer breaks the stream; the rest of the stream passes unfiltered.
|
||||
- **Refusal pre-check** (`prefilter`): at the start of a turn, "would a mainstream model refuse or water
|
||||
this down?". If yes, the step starts at `prefilter.target` instead of collecting a refusal first.
|
||||
- **Automatic model** (`autoRoute`): a virtual `ide/<model>` entry ("Otomatik") in the Codex list. A new turn
|
||||
(a user message after the previous final answer) gets one decision call (hard? research?) and goes to
|
||||
the configured combo. Tool steps, messages typed while the turn runs, a lone "." and compaction stay on
|
||||
the same combo, because switching providers mid-turn breaks encrypted history. When the stage is off or
|
||||
fails, the virtual name never reaches the gateway: the last choice or `targets.zor` is used. The user's
|
||||
explicit model choice is never changed by this stage.
|
||||
- Both turn-start questions go in one call; the wait, including the local queue, is bounded by
|
||||
`timeoutSeconds` (default 3 s).
|
||||
- **Layer window filter**: a layer whose trusted context window (pencere.bekci_calisir) cannot hold the
|
||||
request is skipped. The last link of the chain is never skipped.
|
||||
- **Early finish check** (`finishCheck`): asked in the same call as the refusal check on a final answer.
|
||||
If the answer stopped early or claimed success without evidence, it goes back to the same model with
|
||||
`finishCheck.note`, at most once per turn. If the retry breaks, comes back empty or fails the check, the
|
||||
first (already approved) answer is delivered.
|
||||
- **Rules from Dify** (`policy.source = dify`, `karar_politikasi.py`): the stage questions, thresholds and
|
||||
combo choices may come from a Dify workflow's `kurallar` output. Fetched in the background every
|
||||
`refreshMinutes`, validated against the local settings, last good copy kept in `jev-policy.json`.
|
||||
The transition chain and the always-pass list stay local. A turn never waits for Dify, and a redirect
|
||||
from the Dify endpoint is refused so the app key never travels to another host.
|
||||
- **Workflow reviewer check** (`verifierCheck`, `karar_sor.py`): the Codex dynamic-workflow acceptance
|
||||
step asks whether a reviewer approved without concrete evidence; if so acceptance is blocked.
|
||||
|
||||
## Version 11: isolated routes and cross-provider preparation
|
||||
|
||||
- Pure subscription chats and native voice retain their ChatGPT route. Added
|
||||
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
|
||||
|
||||
@@ -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,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'])
|
||||
@@ -1,8 +1,8 @@
|
||||
"""AITURK IDE "Codex Baglama" ayiricisi.
|
||||
|
||||
Kullanici karari (11 Ekim 2026): "Codex'in modelleri bir tarafta. Bizim sonradan eklediklerimiz diger tarafta.
|
||||
Bizim sonradan eklediklerimiz kopru kullaniyor. Codex'in modelleri kopru kullanmiyor." ve "Codex'i secip Codex'e
|
||||
yaziyorsan bizim kopru yardim amacli bile yaninda olamaz."
|
||||
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:
|
||||
|
||||
@@ -12,8 +12,9 @@ Codex'te tek adres ayari (`openai_base_url`) oldugu icin butun istekler buraya g
|
||||
* Model listesi -> kopru ayaktaysa ek modelleri eklenmis liste; degilse chatgpt.com'un listesi.
|
||||
* Geri kalan her sey -> chatgpt.com'a, oldugu gibi.
|
||||
|
||||
BU DOSYAYA OZELLIK EKLENMEZ. Sikistirma, not, talimat, model komutu, gecmis duzeltme: hepsi kopru.py'dedir ve yalniz
|
||||
ek modeller icindir. Kopru cokerse, guncellenirse ya da yeniden baslatilirsa Codex'in kendi modelleri etkilenmez.
|
||||
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.
|
||||
@@ -30,9 +31,11 @@ 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 = "8" # AITURK IDE'nin bekledigi kopru surumu (codex-link-config.ts CODEX_BRIDGE_VERSION)
|
||||
SURUM = "21" # 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]
|
||||
@@ -95,14 +98,43 @@ def _zstd_hazir():
|
||||
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 kilit:
|
||||
with log_kilit:
|
||||
with open(os.path.join(LOGDIR, "ayirici.log"), "a", encoding="utf-8") as f:
|
||||
f.write(satir + "\n")
|
||||
except OSError:
|
||||
@@ -254,6 +286,8 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
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",
|
||||
@@ -261,12 +295,14 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
"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):
|
||||
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")}
|
||||
@@ -280,6 +316,9 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
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."}})
|
||||
@@ -295,6 +334,13 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
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:
|
||||
@@ -303,14 +349,51 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(hata)
|
||||
return True
|
||||
self.send_response(durum)
|
||||
for k, v in yanit.headers.items():
|
||||
if k.lower() not in YANIT_ATLA:
|
||||
self.send_header(k, v)
|
||||
self.send_header("Transfer-Encoding", "chunked")
|
||||
self.end_headers()
|
||||
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
|
||||
@@ -322,8 +405,23 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
if not parca:
|
||||
break
|
||||
toplam += len(parca)
|
||||
if observer is not None:
|
||||
observer.feed(parca)
|
||||
if getattr(self, "_native_return_scope", None):
|
||||
event_tail += parca
|
||||
while b"\n" in event_tail:
|
||||
line, event_tail = event_tail.split(b"\n", 1)
|
||||
if line.startswith(b"data:"):
|
||||
try:
|
||||
event = json.loads(line[5:])
|
||||
completed = completed or event.get("type") == "response.completed"
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
self.wfile.write(b"%x\r\n" % len(parca) + parca + b"\r\n")
|
||||
self.wfile.flush()
|
||||
if observer is not None and observer.error and not observer.completed:
|
||||
self.server.failure_replays.remember(failure_key, observer.error)
|
||||
log(f"{tag} terminal worker error; identical automatic replay will return HTTP 400")
|
||||
self.wfile.write(b"0\r\n\r\n")
|
||||
self.wfile.flush()
|
||||
except (ConnectionAbortedError, BrokenPipeError, ConnectionResetError):
|
||||
@@ -332,9 +430,70 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
return True
|
||||
if not sessiz:
|
||||
log(f"{tag} bitti status={durum} {toplam} bayt")
|
||||
if completed and getattr(self, "_native_return_scope", None):
|
||||
remember_transfer(*self._native_return_scope)
|
||||
return True
|
||||
|
||||
def _transfer_begin(self):
|
||||
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]
|
||||
@@ -352,8 +511,67 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
son = yol.split("?", 1)[0].rstrip("/")
|
||||
if self.command == "POST" and son.endswith("/responses"):
|
||||
model = model_oku(govde, self.headers.get("Content-Encoding"))
|
||||
if ek_model_mu(model):
|
||||
return self._ilet(n, KOPRU, yol, govde, f"KOPRU {model}", sessiz=True)
|
||||
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.
|
||||
@@ -370,7 +588,14 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
|
||||
class Sunucu(http.server.ThreadingHTTPServer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.failure_replays = terminal_stream.FailureReplays()
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
daemon_threads = True
|
||||
# Shipped Python 3.11 defaults to only five pending connections. Model
|
||||
# discovery, voice and simultaneous chats can arrive in the same burst.
|
||||
request_queue_size = 128
|
||||
allow_reuse_address = False # kapi doluysa sessizce yan yana dinlemek yerine acikca hata
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -86,7 +86,7 @@ def ide_yapilandirma(agent=AGENT):
|
||||
return base.rstrip("/"), varsayilan, adlar
|
||||
|
||||
|
||||
EN_COK_MODEL = 300
|
||||
EN_COK_MODEL = 1000
|
||||
AITURK_EKI = " (AITURK)"
|
||||
# Gecidin kisa saglayici kodu -> kullanicinin gormek istedigi kod (10 Ekim 2026: "agy/gemini").
|
||||
_SAGLAYICI_KISA = {"ag": "agy"}
|
||||
@@ -192,7 +192,41 @@ def sec(data, tanimli=(), kapsam="ide", turler=None):
|
||||
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=()):
|
||||
# ---- Codex secicisinde gorunen liste (15 Ekim 2026) ----
|
||||
# Codex masaustu uygulamasinin model secicisi listeyi tek sayfada, en cok 100 modelle ister (uygulamanin kendi kodunda
|
||||
# sabit; sonraki sayfayi istemez). Kullanici o 100'e hangi modellerin girecegini secer (bridge.json "codex_liste"):
|
||||
# "one_al" ozgun modellerin hemen arkasina verdigi sirayla alinir, "gizli" cikarilir; resim/ses/video modelleri ve
|
||||
# ana modeli listede olan dusuk-orta-yuksek kopyalari istege bagli gizlenir (varsayilan: gizli).
|
||||
CODEX_SECICI_SINIRI = 100
|
||||
_MEDYA = re.compile(r"(?:^|[-_/. ])(?:tts|image|imagen|veo|video|lyria|embedding|transcribe|whisper|flux|kokoro|orpheus|"
|
||||
r"chatterbox|seedance|seedream|nano-banana|aqa|robotics|computer-use|realtime|music|audio|speech)"
|
||||
r"(?=$|[-_/. ])", re.I)
|
||||
_EFOR = re.compile(r"-(?:none|minimal|low|medium|high|xhigh|max)$", re.I)
|
||||
|
||||
|
||||
def liste_ayari(ham):
|
||||
"""bridge.json "codex_liste" -> {medya_gizle, efor_gizle, one_al, gizli}; eksik ya da bozuk alan varsayilandir."""
|
||||
a = ham if isinstance(ham, dict) else {}
|
||||
|
||||
def liste(anahtar):
|
||||
deger = a.get(anahtar)
|
||||
return [str(s) for s in deger if isinstance(s, str) and s.strip()][:2000] if isinstance(deger, list) else []
|
||||
return {"medya_gizle": a.get("medya_gizle") is not False, "efor_gizle": a.get("efor_gizle") is not False,
|
||||
"one_al": list(dict.fromkeys(liste("one_al"))), "gizli": liste("gizli")}
|
||||
|
||||
|
||||
def medya_mi(slug, ad=""):
|
||||
"""Codex sohbetinde calismayan resim, ses, video, gomme modeli mi (kimlikten ya da adindan)."""
|
||||
return bool(_MEDYA.search(slug or "") or _MEDYA.search(ad or ""))
|
||||
|
||||
|
||||
def efor_kopyasi_mi(slug, kimlikler):
|
||||
""""...-high" gibi bir kopya ve ana modeli de listede mi; Codex'in kendi dusunme seviyesi secimi ana modelde var."""
|
||||
taban = _EFOR.sub("", slug or "")
|
||||
return taban != slug and taban in kimlikler
|
||||
|
||||
|
||||
def sirala_codex(modeller, ekler, ozgun_kimlikler=(), ayar=None):
|
||||
"""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)"
|
||||
@@ -227,13 +261,41 @@ def sirala_codex(modeller, ekler, ozgun_kimlikler=()):
|
||||
return (1, saglayici.casefold(), model.casefold(), slug)
|
||||
|
||||
ek_modeller.sort(key=anahtar)
|
||||
adaylar = None
|
||||
if ayar is not None:
|
||||
# Kullanicinin secimi: once kimin gizlenecegi, sonra one alinanlar (verdigi sirayla); gerisi yukaridaki sirada.
|
||||
ayar = liste_ayari(ayar)
|
||||
kimlikler = {m.get("slug", "") for m in ek_modeller}
|
||||
one_sira = {s: i for i, s in enumerate(s for s in ayar["one_al"] if s in kimlikler)}
|
||||
gizli = set(ayar["gizli"])
|
||||
adaylar, kalan = [], []
|
||||
for m in ek_modeller:
|
||||
slug = m.get("slug", "")
|
||||
medya, efor = medya_mi(slug, m.get("display_name")), efor_kopyasi_mi(slug, kimlikler)
|
||||
one = slug in one_sira
|
||||
neden = ("gizli" if slug in gizli else "medya" if medya and ayar["medya_gizle"] and not one
|
||||
else "efor" if efor and ayar["efor_gizle"] and not one else "")
|
||||
adaylar.append({"slug": slug, "ad": m.get("display_name") or slug, "kind": bilgiler.get(slug, {}).get("kind") or "model",
|
||||
"medya": medya, "efor": efor, "neden": neden})
|
||||
if not neden:
|
||||
kalan.append(m)
|
||||
kalan.sort(key=lambda m: (0, one_sira[m.get("slug", "")]) if m.get("slug", "") in one_sira else (1, 0))
|
||||
ek_modeller = kalan
|
||||
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
|
||||
modeller[:] = (ozgunler + ek_modeller)[:EN_COK_MODEL]
|
||||
if adaylar is None:
|
||||
return None
|
||||
# Secicide gorunecek yer: ozgun modeller once sayilir; Codex ilk 100 girdiyi gosterir.
|
||||
yer = {m.get("slug", ""): i for i, m in enumerate(modeller)}
|
||||
for a in adaylar:
|
||||
a["sira"] = yer.get(a["slug"]) if not a["neden"] else None
|
||||
a["menude"] = a["sira"] is not None and a["sira"] < CODEX_SECICI_SINIRI
|
||||
return {"sinir": CODEX_SECICI_SINIRI, "ozgun_sayisi": len(ozgunler), "adaylar": adaylar}
|
||||
|
||||
|
||||
# ---- Secicide model adinin altindaki satir ----
|
||||
@@ -348,11 +410,13 @@ def yukle(ua="AstraBridge/1.0 (codex)", timeout=30, home=None, ag=True):
|
||||
if not girdiler:
|
||||
raise ValueError("Gecitte kullanilabilir OmniRoute sohbet modeli yok")
|
||||
kayitlar = {m["id"]: m for m in metadata if m.get("id")}
|
||||
ilanlar = {m["id"]: m for m in data if m.get("id")}
|
||||
nano = pencere.nano_yukle(home, ua, ag=ag) # ag=False: acilista yalniz onbellek
|
||||
ogr = pencere.ogrenilen(home)
|
||||
aracsiz = pencere.aracsizlar(home)
|
||||
for g in girdiler:
|
||||
bilgi = pencere.coz(g["id"], g["kind"], g.get("yol"), kayitlar.get(g["id"]), nano, ogr, aracsiz)
|
||||
bilgi = pencere.coz(g["id"], g["kind"], g.get("yol"), kayitlar.get(g["id"]), nano, ogr, aracsiz,
|
||||
ilan=ilanlar.get(g["id"]))
|
||||
g["pencere"], g["gorsel"], g["arac"], g["pencere_kaynak"] = (bilgi["pencere"], bilgi["gorsel"],
|
||||
bilgi["arac"], bilgi["kaynak"])
|
||||
g["claude"] = pencere.claude_var(g["kind"], g.get("yol"), kayitlar.get(g["id"]))
|
||||
|
||||
@@ -11,7 +11,9 @@ isteklere, istek gecerken ekler. Istemci `originator` basligindan ayirt edilir (
|
||||
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.
|
||||
{"komut": "..."} komutu elle verir; {"talimat": "..."} metni degistirir ({komut}, {zamanla} yer tutuculari);
|
||||
{"secim": "onerilen"|"buyuk"|"kapali"|"ozel"} AITURK IDE ayarlar ekranindan yazilir. Kopru ayari her istekte
|
||||
dosyadan tazeler (kopru.py hat_ayari), yeniden baslatma gerekmez.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
@@ -19,7 +21,7 @@ import subprocess
|
||||
|
||||
MASAUSTU = {"codex desktop"}
|
||||
ISARET = "[AITURK iş hattı]"
|
||||
VARSAYILAN = (
|
||||
ESKI = (
|
||||
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"
|
||||
@@ -28,6 +30,30 @@ VARSAYILAN = (
|
||||
"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."
|
||||
)
|
||||
# Kullanici karari, 15 Ekim 2026: tekrarli/zamanli isler AITURK IDE botuna, kodlama Codex'te kalir.
|
||||
VARSAYILAN = (
|
||||
ISARET + " Bu Codex, AITURK'e bağlı. İş dağıtımı kuralı:\n"
|
||||
"1. Tekrarlı, zamanlanmış ya da sürekli açık kalacak işleri (her gün şu saatte, saat başı, haftada bir, "
|
||||
"'7 gün her gün baksın' gibi) AITURK IDE'deki bota ver. İşin tam metnini UTF-8 bir dosyaya yaz ve şu komutla "
|
||||
"gönder, sonucu kullanıcıya kısaca aktar:\n{komut}\n"
|
||||
"2. Kodlama ve diğer işleri AITURK'e verme. Ana model işi kendisi yapmaz: kolay ya da tek parça işi tek bir alt "
|
||||
"ajana, zor ya da çok parçalı işi alt ajan ekibine veya dinamik workflow'a ver. Ana model kullanıcıyla konuşur, "
|
||||
"işi dağıtır, sonucu doğrulayıp aktarır.\n"
|
||||
"3. Kullanıcı 'Codex' derse işi devretme. Komut hata verirse işi alt ajanla yap."
|
||||
)
|
||||
# Ayarlar ekranindaki hazir secenekler (bridge.json "aiturk_hat": {"secim": ...}).
|
||||
SECIMLER = {"onerilen": VARSAYILAN, "buyuk": ESKI}
|
||||
|
||||
|
||||
def zamanla_satiri(ortam=None):
|
||||
"""AITURK IDE botuna zamanlanmis is veren hazir satir; IDE ajan klasoru yoksa ''."""
|
||||
ortam = os.environ if ortam is None else ortam
|
||||
ev = str(ortam.get("HERMES_HOME") or "").strip()
|
||||
if not ev:
|
||||
return ""
|
||||
exe = os.path.join(ev, "bin", "hermes.exe")
|
||||
return (f"$env:HERMES_HOME='{ev}'; & '{exe}' cron create --name '<kısa ad>' --workdir '<işin klasörü>' "
|
||||
"'<zamanlama: 30m, every 2h ya da 0 9 * * *>' '<işin tam ve kendi başına anlaşılır talimatı>'")
|
||||
|
||||
|
||||
def masaustu_mu(originator):
|
||||
@@ -76,16 +102,25 @@ def komut_satiri(yol, sohbet=""):
|
||||
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."""
|
||||
def talimat(ayar, bulunan=None, sohbet="", zamanla=None):
|
||||
"""Eklenecek metin; hat kapaliysa ya da metnin istedigi gonderme yolu yoksa None.
|
||||
bulunan: bu bilgisayarda bulunan AITURK komutunun yolu (komut_bul); ayardaki "komut" onun onune gecer.
|
||||
zamanla: AITURK IDE botuna zamanlanmis is veren satir (zamanla_satiri); None ise ortamdan bulunur.
|
||||
Secim (ayarlar ekrani): "onerilen" (varsayilan), "buyuk" (eski davranis), "kapali", "ozel" ("talimat" metni).
|
||||
Secimsiz eski ayarda "talimat" doluysa ozel sayilir."""
|
||||
ayar = ayar if isinstance(ayar, dict) else {}
|
||||
if ayar.get("enabled") is False:
|
||||
secim = str(ayar.get("secim") or "").strip()
|
||||
if ayar.get("enabled") is False or secim == "kapali":
|
||||
return None
|
||||
if secim in SECIMLER:
|
||||
metin = SECIMLER[secim]
|
||||
else:
|
||||
metin = str(ayar.get("talimat") or "").strip() or VARSAYILAN
|
||||
komut = str(ayar.get("komut") or "").strip() or (komut_satiri(bulunan, sohbet) if bulunan else "")
|
||||
if not komut:
|
||||
zamanla = zamanla_satiri() if zamanla is None else zamanla
|
||||
if ("{komut}" in metin and not komut) or ("{zamanla}" in metin and not zamanla):
|
||||
return None
|
||||
return str(ayar.get("talimat") or VARSAYILAN).replace("{komut}", komut)
|
||||
return metin.replace("{komut}", komut).replace("{zamanla}", zamanla)
|
||||
|
||||
|
||||
def ekle(req, metin):
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"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" }
|
||||
],
|
||||
"exemptModels": ["Chat-Model", "bekci-sesli-bekci", "Sesli-Hizli-Zeki", "Katman-3"],
|
||||
"thresholds": { "ret": 0.5, "sart": 0.7 },
|
||||
"questions": {
|
||||
"ret": "The assistant did NOT do what the user asked or did NOT answer the user's question: it refused, declined, said it cannot or will not, avoided giving the requested judgment, or redirected to a different topic.",
|
||||
"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",
|
||||
"watchModels": ["Sesli-Hizli-Zeki", "Chat-Model", "bekci-sesli-bekci"],
|
||||
"prefilter": {
|
||||
"enabled": false,
|
||||
"question": "A mainstream AI assistant such as ChatGPT, Claude or Gemini would refuse this request, water it down, or answer it only with added warnings or conditions.",
|
||||
"threshold": 0.6,
|
||||
"target": "Katman-2",
|
||||
"timeoutSeconds": 3
|
||||
},
|
||||
"autoRoute": {
|
||||
"enabled": false,
|
||||
"model": "Otomatik",
|
||||
"timeoutSeconds": 3,
|
||||
"questions": {
|
||||
"zor": "The user's request is hard: doing it well needs multi-step work, expert or technical knowledge, careful analysis, planning, calculation or writing and changing code. Greetings, small talk, simple facts and short simple commands are NOT hard.",
|
||||
"arastirma": "The user's request is mainly research: finding, reading and comparing many sources or files and reporting what they say, rather than changing anything."
|
||||
},
|
||||
"thresholds": { "zor": 0.5, "arastirma": 0.6 },
|
||||
"targets": { "basit": "Workflow-Kesif", "zor": "Ajan", "arastirma": "Arastirma" }
|
||||
},
|
||||
"finishCheck": {
|
||||
"enabled": false,
|
||||
"question": "The assistant stopped before the user's task was finished: it said it is done or handed back without doing all of what was asked, or it claimed success (tests pass, file changed, deployed) without showing the actual evidence.",
|
||||
"threshold": 0.6,
|
||||
"note": "Önceki cevabın iş bitmeden bitti dedi ya da kanıt göstermedi. İşi gerçekten bitir; gerekiyorsa araçları kullan ve gerçek kanıtı göster."
|
||||
},
|
||||
"verifierCheck": {
|
||||
"enabled": false,
|
||||
"question": "The reviewer approved the work without concrete evidence: it does not cite the actual diff, the real command output or specific lines, so the approval may be a guess.",
|
||||
"threshold": 0.6
|
||||
},
|
||||
"policy": {
|
||||
"source": "local",
|
||||
"endpoint": "https://dify.turkservis.online/v1",
|
||||
"refreshMinutes": 5
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,16 @@ Kullanici karari (11 Ekim 2026): "Jev kati olsun. Af yok istisna yok. Herhangi b
|
||||
gecirmezse 1.Katmana yonlendirecek. 1.Katman combosu gecmezse 2.Katmana, 2.Katman gecmezse 3.Katmana, 3.Katman
|
||||
gecmezse 4.Katmana. 4.Katman combosu gelirse Jev bunu sorgulamadan gecirecek."
|
||||
|
||||
Yani denetim secili modelin katman olup olmamasina bakmaz: kopruye ugrayan her ek modelin cevabi denetlenir.
|
||||
Gecmezse adimi sirayla Katman-1 -> Katman-2 -> Katman-3 -> Katman-4 yapar; secili model zaten bir katmansa
|
||||
yalniz ondan sonrakiler denenir. Katmanlar OmniRoute combolaridir (KATMAN); kopru onlari yalniz adiyla cagirir.
|
||||
12 Ekim 2026 istisnasi: çözülmüş hedefi muaf listesinde olan cevap Jev'e sorulmaz (ayar: exemptModels).
|
||||
Taşıma/boş yanıt kurtarması ve yedek katmanların denetimi korunur.
|
||||
Diğer hedeflerde denetim secili modelin katman olup olmamasina bakmaz.
|
||||
Gecmezse adimi ayardaki zincir yapar (14 Ekim 2026: Katman-1 -> Katman-2 -> Katman-3, Katman-3 muaf); secili model
|
||||
zaten bir katmansa yalniz ondan sonrakiler denenir. Katmanlar OmniRoute combolaridir; kopru onlari yalniz adiyla cagirir.
|
||||
|
||||
14 Ekim 2026 karar asamalari (hepsi ayardan acilir, varsayilan kapali): tur basinda "Otomatik" modelin combo secimi
|
||||
(autoRoute), normal modelin reddedecegi tahmin edilen isi dogrudan katmana verme (prefilter), isi bitirmeden "bitti"
|
||||
diyen son cevabi ayni modele bir kez geri verme (finishCheck). Karar modeli metin yazmaz; hata ya da zaman asiminda
|
||||
bugunku yol izlenir.
|
||||
|
||||
Jev (TypeSafe System One) metin uretmez, sorulan evet/hayir sorularina olasilik verir. Kural tektir ve istisnasi
|
||||
yoktur: cevap istegi yapmiyorsa (ret) ya da sormadan kendi sartlariyla degistiriyorsa (sart) adim gecmez.
|
||||
@@ -16,10 +23,11 @@ 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")
|
||||
KATMAN = ("Katman-1", "Katman-2", "Katman-3") # varsayilan zincir; asil zincir ayardan okunur
|
||||
ESIK = {"ret": 0.5, "sart": 0.7}
|
||||
SORU = {
|
||||
"ret": ("The assistant did NOT do what the user asked or did NOT answer the user's question: it refused, declined, "
|
||||
@@ -29,20 +37,56 @@ SORU = {
|
||||
}
|
||||
_ANAHTAR = {"okundu": False, "deger": ""}
|
||||
|
||||
def izleme_mi(hedef):
|
||||
"""Bekci izleme kanali (cevaptan sonra duzeltme) bu hedefin mi? Ayar: watchModels, tam ad eslesmesi.
|
||||
Jev acikken yalniz muaf hedefte: denetlenen cevap baska katmana gecebilir, makbuz kullanicinin gormedigi
|
||||
cevaba baglanmasin."""
|
||||
try:
|
||||
s = jev_settings.read()[0]
|
||||
return hedef in s['watchModels'] and (not s['enabled'] or hedef in s['exemptModels'])
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def muaf_mi(hedef):
|
||||
"""Settings' always-pass list matches exact resolved model IDs.
|
||||
|
||||
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):
|
||||
"""Jev gecirmezse sirayla denenecek katmanlar. Secili model bir katmansa ondan sonrakiler; katman degilse
|
||||
butun katmanlar bastan (once Katman-1). Son katman (Katman-4) kendisi gelince bos doner: sorgulanmadan gecer."""
|
||||
"""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 ad or ad == KATMAN[-1]:
|
||||
if not settings['enabled'] or not ad:
|
||||
return []
|
||||
if ad in KATMAN:
|
||||
return list(KATMAN[KATMAN.index(ad) + 1:])
|
||||
return list(KATMAN)
|
||||
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:
|
||||
@@ -81,16 +125,28 @@ def _metin(oge):
|
||||
def son_istek(req):
|
||||
"""Kullanicinin bu turdaki istegi: son kullanici mesaji. Sesli aktarimda yalniz <input> icindeki soz;
|
||||
baska sarmalli sistem mesajlari (kanca baglami) atlanir."""
|
||||
for oge in reversed(req.get("input") or []):
|
||||
if not (isinstance(oge, dict) and oge.get("type", "message") == "message" and oge.get("role") == "user"):
|
||||
continue
|
||||
metin = _metin(oge).strip()
|
||||
m = re.search(r"<realtime_delegation>.*?<input>(.*?)</input>", metin, re.S)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
if metin and not metin.startswith("<"):
|
||||
return metin
|
||||
return ""
|
||||
return son_istek_sirasi(req)[0]
|
||||
|
||||
|
||||
def istek_metni(oge):
|
||||
"""Bir kullanici mesajindaki istek: sesli aktarimda <input> icindeki soz; sarmalli sistem mesaji ise ""."""
|
||||
if not (isinstance(oge, dict) and oge.get("type", "message") == "message" and oge.get("role") == "user"):
|
||||
return ""
|
||||
metin = _metin(oge).strip()
|
||||
m = re.search(r"<realtime_delegation>.*?<input>(.*?)</input>", metin, re.S)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
return metin if metin and not metin.startswith("<") else ""
|
||||
|
||||
|
||||
def son_istek_sirasi(req):
|
||||
"""(istek, girdideki sirasi). Turun arac adimlarinda da ayni sira doner; yoksa ("", -1)."""
|
||||
girdi = req.get("input") or []
|
||||
for sira in range(len(girdi) - 1, -1, -1):
|
||||
metin = istek_metni(girdi[sira])
|
||||
if metin:
|
||||
return metin, sira
|
||||
return "", -1
|
||||
|
||||
|
||||
def hazirlik_mi(govde):
|
||||
@@ -138,24 +194,114 @@ def komutlar(govde):
|
||||
return "\n".join(satirlar)
|
||||
|
||||
|
||||
def _gonder(govde, key):
|
||||
istek = urllib.request.Request(ADRES, data=json.dumps(govde).encode("utf-8"), method="POST",
|
||||
def _gonder(govde, key, settings=None, timeout=None):
|
||||
settings = settings or jev_settings.read()[0]
|
||||
istek = urllib.request.Request(settings['endpoint'], data=json.dumps(govde).encode("utf-8"), method="POST",
|
||||
headers={"Authorization": "Bearer " + key, "Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(istek) as yanit:
|
||||
with urllib.request.urlopen(istek, timeout=timeout or settings['timeoutSeconds']) as yanit:
|
||||
return json.load(yanit)
|
||||
|
||||
|
||||
def sor(istek, cevap, key, gonder=_gonder):
|
||||
"""Jev'e sorar. Donus: {"ret", "sart"} olasiliklari | None (sorulamadi)."""
|
||||
govde = {"model": MODEL, "state": {"user_request": istek, "assistant_reply": cevap},
|
||||
"questions": {k: {"type": "noul", "instructions": v} for k, v in SORU.items()}}
|
||||
def sorular(durum, soru, key, timeout, gonder=_gonder, settings=None):
|
||||
"""Karar modeline bir cagrida birden cok evet/hayir sorusu. Donus {ad: 0-1 olasilik} | None (sorulamadi).
|
||||
durum: {"user_request": ..., ["assistant_reply": ...]}; soru: {ad: talimat}."""
|
||||
settings = settings or jev_settings.read()[0]
|
||||
if not key or not soru or not durum.get("user_request"):
|
||||
return None
|
||||
govde = {"model": settings['model'], "state": durum,
|
||||
"questions": {k: {"type": "noul", "instructions": v} for k, v in soru.items()}}
|
||||
if not jev_settings.acquire(settings, wait=timeout):
|
||||
return None
|
||||
try:
|
||||
yanitlar = (gonder(govde, key) or {}).get("answers") or {}
|
||||
return {k: float(yanitlar[k]["noul"]) for k in SORU}
|
||||
response = gonder(govde, key, settings, timeout) if gonder is _gonder else gonder(govde, key)
|
||||
yanitlar = (response or {}).get("answers") or {}
|
||||
result = {k: float(yanitlar[k]["noul"]) for k in soru}
|
||||
return result if all(0 <= v <= 1 for v in result.values()) else None
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
jev_settings.release()
|
||||
|
||||
|
||||
def on_karar(istek, oto, ret, key, gonder=_gonder):
|
||||
"""Tur basi karari: istenen sorular tek cagrida. oto: Otomatik modelin combo secimi; ret: normal model reddeder mi.
|
||||
Donus: {"hedef": Otomatik icin secilen ad ya da None, "ret": bool ya da None, "puan": {...} ya da None}."""
|
||||
settings = jev_settings.read()[0]
|
||||
a, p = settings['autoRoute'], settings['prefilter']
|
||||
soru = {}
|
||||
if oto:
|
||||
soru.update(a['questions'])
|
||||
if ret and p['enabled']:
|
||||
soru["ret"] = p['question']
|
||||
if not soru:
|
||||
return {"hedef": None, "ret": None, "puan": None}
|
||||
sure = max(a['timeoutSeconds'] if oto else 0, p['timeoutSeconds'] if ret else 0) or 3
|
||||
puan = sorular({"user_request": istek}, soru, key, sure, gonder=gonder, settings=settings)
|
||||
hedef = None
|
||||
if oto:
|
||||
if puan is None:
|
||||
hedef = a['targets']['zor'] # karar alinamazsa guclu yol
|
||||
elif puan["arastirma"] >= a['thresholds']['arastirma']:
|
||||
hedef = a['targets']['arastirma']
|
||||
elif puan["zor"] >= a['thresholds']['zor']:
|
||||
hedef = a['targets']['zor']
|
||||
else:
|
||||
hedef = a['targets']['basit']
|
||||
tahmin = None if puan is None or "ret" not in puan else puan["ret"] >= p['threshold']
|
||||
return {"hedef": hedef, "ret": tahmin, "puan": puan}
|
||||
|
||||
|
||||
def bitmedi_mi(puan):
|
||||
"""finishCheck: son cevap isi bitirmeden 'bitti' mi dedi? Soru sorulmadiysa False."""
|
||||
if not puan or "bitmedi" not in puan:
|
||||
return False
|
||||
return puan["bitmedi"] >= jev_settings.read()[0]['finishCheck']['threshold']
|
||||
|
||||
|
||||
class Puan(dict):
|
||||
"""Carry the thresholds used for this evaluation through a settings change."""
|
||||
pass
|
||||
|
||||
|
||||
def sor(istek, cevap, key, gonder=_gonder, bitis=False):
|
||||
"""Jev'e sorar. Donus: {"ret", "sart"[, "bitmedi"]} olasiliklari | None (sorulamadi).
|
||||
bitis=True ve finishCheck acikken ayni cagriya "is bitmeden bitti mi dedi" sorusu da eklenir."""
|
||||
settings = jev_settings.read()[0]
|
||||
govde = {"model": settings['model'], "state": {"user_request": istek, "assistant_reply": cevap},
|
||||
"questions": {k: {"type": "noul", "instructions": v} for k, v in settings['questions'].items()}}
|
||||
if bitis and settings['finishCheck']['enabled']:
|
||||
govde['questions']['bitmedi'] = {"type": "noul", "instructions": settings['finishCheck']['question']}
|
||||
for name, criteria in settings.get('criteria', {}).items():
|
||||
values = {k: v for k, v in criteria.items() if v.strip()}
|
||||
if values:
|
||||
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 settings['questions']})
|
||||
if 'bitmedi' in govde['questions'] and isinstance(yanitlar.get('bitmedi'), dict):
|
||||
result['bitmedi'] = float(yanitlar['bitmedi']['noul']) # ek soru: cevapsizsa ana karar yine gecerli
|
||||
if any(not 0 <= v <= 1 for v in result.values()):
|
||||
return None
|
||||
result.thresholds = settings['thresholds']
|
||||
return result
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
jev_settings.release()
|
||||
|
||||
|
||||
def karar(puan):
|
||||
"""True: adim gecmez, sonraki katman yapar. Istisna yok."""
|
||||
return puan["ret"] >= ESIK["ret"] or puan["sart"] >= ESIK["sart"]
|
||||
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,224 @@
|
||||
"""Per-user settings, re-read for new evaluations without restarting active chats.
|
||||
|
||||
Schema 2 (14 Oct 2026) adds the decision stages: watchModels, prefilter, autoRoute, finishCheck,
|
||||
verifierCheck and policy. A schema 1 file stays valid: every missing section is filled from the
|
||||
defaults, and every new stage starts disabled, so an upgrade changes no behaviour by itself.
|
||||
With policy.source "dify" the stage settings may be overridden by the last good rules fetched
|
||||
from Dify (jev-policy.json); invalid or missing rules leave the local settings in force.
|
||||
"""
|
||||
import base64
|
||||
import copy
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
DEFAULTS = json.loads(Path(__file__).with_name('jev-settings-defaults.json').read_text(encoding='utf-8'))
|
||||
POLICY_FILE = 'jev-policy.json'
|
||||
# Sections Dify rules may change. The transition chain and the always-pass list stay local.
|
||||
POLICY_KEYS = ('prefilter', 'autoRoute', 'finishCheck', 'verifierCheck', 'thresholds', 'questions')
|
||||
SECTIONS = ('watchModels', 'prefilter', 'autoRoute', 'finishCheck', 'verifierCheck', 'policy')
|
||||
_condition = threading.Condition()
|
||||
_active = 0
|
||||
|
||||
|
||||
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 _fill(value, default):
|
||||
"""Missing keys of a schema 1 file come from the defaults; present values are kept as written."""
|
||||
if isinstance(default, dict) and isinstance(value, dict):
|
||||
return {k: _fill(value[k], d) if k in value else copy.deepcopy(d) for k, d in default.items()} | \
|
||||
{k: v for k, v in value.items() if k not in default}
|
||||
return value
|
||||
|
||||
|
||||
def _text(value, limit=10000):
|
||||
return isinstance(value, str) and bool(value.strip()) and len(value) <= limit
|
||||
|
||||
|
||||
def _prob(value):
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool) and 0 <= value <= 1
|
||||
|
||||
|
||||
def _int(value, low, high):
|
||||
return isinstance(value, int) and not isinstance(value, bool) and low <= value <= high
|
||||
|
||||
|
||||
def _https(value):
|
||||
if not _text(value, 2048):
|
||||
return False
|
||||
url = urlsplit(value)
|
||||
return url.scheme == 'https' and bool(url.netloc) and not url.username and not url.password
|
||||
|
||||
|
||||
def validate(settings):
|
||||
"""Raise ValueError when a value could route to the wrong place or wait without bound."""
|
||||
# A corrupted file must not silently change routing or contact another host.
|
||||
if not _https(settings['endpoint']):
|
||||
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')
|
||||
watch = settings['watchModels']
|
||||
if not isinstance(watch, list) or len(watch) > 64 or not all(_text(x, 256) for x in watch) or len(set(watch)) != len(watch):
|
||||
raise ValueError('Invalid watch models')
|
||||
p = settings['prefilter']
|
||||
if not (isinstance(p.get('enabled'), bool) and _text(p.get('question')) and _prob(p.get('threshold'))
|
||||
and _text(p.get('target'), 256) and _int(p.get('timeoutSeconds'), 1, 60)):
|
||||
raise ValueError('Invalid prefilter')
|
||||
a = settings['autoRoute']
|
||||
if not (isinstance(a.get('enabled'), bool) and _text(a.get('model'), 256) and _int(a.get('timeoutSeconds'), 1, 60)
|
||||
and all(_text((a.get('questions') or {}).get(k)) and _prob((a.get('thresholds') or {}).get(k)) for k in ('zor', 'arastirma'))
|
||||
and all(_text((a.get('targets') or {}).get(k), 256) for k in ('basit', 'zor', 'arastirma'))):
|
||||
raise ValueError('Invalid automatic routing')
|
||||
if a['model'] in a['targets'].values() or p['target'] == a['model']:
|
||||
raise ValueError('Automatic routing cannot target itself')
|
||||
f = settings['finishCheck']
|
||||
if not (isinstance(f.get('enabled'), bool) and _text(f.get('question')) and _prob(f.get('threshold'))
|
||||
and _text(f.get('note'), 4000)):
|
||||
raise ValueError('Invalid finish check')
|
||||
v = settings['verifierCheck']
|
||||
if not (isinstance(v.get('enabled'), bool) and _text(v.get('question')) and _prob(v.get('threshold'))):
|
||||
raise ValueError('Invalid verifier check')
|
||||
pol = settings['policy']
|
||||
if not (pol.get('source') in ('local', 'dify') and _https(pol.get('endpoint')) and _int(pol.get('refreshMinutes'), 1, 1440)):
|
||||
raise ValueError('Invalid policy source')
|
||||
return settings
|
||||
|
||||
|
||||
def _policy(settings):
|
||||
"""Last good Dify rules on top of the local settings; anything invalid keeps the local settings."""
|
||||
if settings['policy']['source'] != 'dify':
|
||||
return settings
|
||||
try:
|
||||
cache = json.loads((home() / POLICY_FILE).read_text(encoding='utf-8'))
|
||||
overrides = cache.get('overrides')
|
||||
if not cache.get('ok') or not isinstance(overrides, dict):
|
||||
return settings
|
||||
merged = copy.deepcopy(settings)
|
||||
for key in POLICY_KEYS:
|
||||
if isinstance(overrides.get(key), dict):
|
||||
merged[key] = _fill(overrides[key], settings[key])
|
||||
return validate(merged)
|
||||
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
||||
return settings
|
||||
|
||||
|
||||
def read(overlay=True):
|
||||
"""(settings, protectedKey). overlay=False: the local settings without the Dify rules."""
|
||||
try:
|
||||
data = json.loads((home() / 'jev-settings.json').read_text(encoding='utf-8'))
|
||||
except FileNotFoundError:
|
||||
return copy.deepcopy(DEFAULTS), None
|
||||
settings = data['settings']
|
||||
for section in SECTIONS:
|
||||
settings[section] = _fill(settings[section], DEFAULTS[section]) if section in settings else copy.deepcopy(DEFAULTS[section])
|
||||
validate(settings)
|
||||
return (_policy(settings) if overlay else settings), data.get('protectedKey')
|
||||
|
||||
|
||||
class _Blob(ctypes.Structure):
|
||||
_fields_ = [('cbData', ctypes.c_uint32), ('pbData', ctypes.POINTER(ctypes.c_ubyte))]
|
||||
|
||||
|
||||
def _unprotect(encrypted):
|
||||
if not encrypted:
|
||||
return ''
|
||||
if os.name != 'nt':
|
||||
raise RuntimeError('Windows credential protection required')
|
||||
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, ctypes.c_uint32, ctypes.POINTER(_Blob)]
|
||||
decrypt.restype = ctypes.c_int
|
||||
if not decrypt(ctypes.byref(source), None, None, None, None, 0, ctypes.byref(result)):
|
||||
raise ctypes.WinError()
|
||||
try:
|
||||
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 key():
|
||||
_, encrypted = read()
|
||||
return _unprotect(encrypted)
|
||||
|
||||
|
||||
def policy_key():
|
||||
"""Dify app key for the rules source (protectedPolicyKey, Windows DPAPI). Empty when not set."""
|
||||
try:
|
||||
data = json.loads((home() / 'jev-settings.json').read_text(encoding='utf-8'))
|
||||
except (OSError, ValueError):
|
||||
return ''
|
||||
return _unprotect(data.get('protectedPolicyKey'))
|
||||
|
||||
|
||||
def write_policy(result):
|
||||
"""Atomically store the outcome of a Dify fetch. A failed fetch keeps the last good overrides."""
|
||||
path = home() / POLICY_FILE
|
||||
previous = {}
|
||||
try:
|
||||
previous = json.loads(path.read_text(encoding='utf-8'))
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
record = {'fetchedAt': time.strftime('%Y-%m-%dT%H:%M:%S%z'), 'ok': bool(result.get('ok')), 'error': str(result.get('error') or '')[:500]}
|
||||
record['overrides'] = result.get('overrides') if result.get('ok') else previous.get('overrides')
|
||||
if not result.get('ok') and previous.get('ok'):
|
||||
record['ok'] = True # the last good rules stay in force; the error is reported beside them
|
||||
record['lastGoodAt'] = previous.get('lastGoodAt') or previous.get('fetchedAt')
|
||||
elif result.get('ok'):
|
||||
record['lastGoodAt'] = record['fetchedAt']
|
||||
tmp = path.with_name(path.name + f'.{os.getpid()}.{threading.get_ident()}.tmp')
|
||||
tmp.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
try:
|
||||
for deneme in range(5):
|
||||
try:
|
||||
os.replace(tmp, path) # Windows: a reader holding the file makes this fail briefly
|
||||
break
|
||||
except PermissionError:
|
||||
if deneme == 4:
|
||||
raise
|
||||
time.sleep(0.05 * (deneme + 1))
|
||||
finally:
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
return record
|
||||
|
||||
|
||||
def acquire(settings, wait=None):
|
||||
"""wait: at most this many seconds in the queue (turn-start questions must not hold a request)."""
|
||||
global _active
|
||||
deadline = time.monotonic() + (min(settings['queueWaitSeconds'], wait) if wait else 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()
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Decision rules from Dify (14 Oct 2026).
|
||||
|
||||
Owner request, 14 Oct 2026: "Dify'de hazirlansa oradan gelse". The model a Codex step talks to stays an
|
||||
OmniRoute combo, because a Dify app would not pass Codex's own tools through. What Dify supplies is the rules:
|
||||
the decision questions, thresholds and which combo each kind of request goes to. The bridge fetches them in
|
||||
the background (policy.refreshMinutes), validates them against the local settings and stores the last good
|
||||
copy in jev-policy.json. A turn never waits for Dify; a failed fetch keeps the last good rules.
|
||||
|
||||
The Dify workflow returns one output named ``kurallar``: a JSON object (or JSON text) whose keys are any of
|
||||
jev_settings.POLICY_KEYS, each holding the same fields as the local settings section, for example
|
||||
{"autoRoute": {"enabled": true, "targets": {"zor": "Ajan"}}}.
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import jev_settings
|
||||
|
||||
UA = "AITURK-IDE-codex-bridge/karar-politikasi"
|
||||
|
||||
|
||||
class _YonlendirmeYok(urllib.request.HTTPRedirectHandler):
|
||||
"""A redirect would carry the Authorization header to another host; it is an error instead."""
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
raise urllib.error.HTTPError(req.full_url, code, f"redirect refused: {newurl}", headers, fp)
|
||||
|
||||
|
||||
_ACICI = urllib.request.build_opener(_YonlendirmeYok)
|
||||
MAX_BYTES = 64 * 1024
|
||||
_son = {"zaman": 0.0}
|
||||
|
||||
|
||||
def ayikla(cikti, yerel):
|
||||
"""Dify output -> validated overrides. Raises ValueError with a short reason when unusable."""
|
||||
if isinstance(cikti, str):
|
||||
if len(cikti.encode("utf-8")) > MAX_BYTES:
|
||||
raise ValueError("kurallar too large")
|
||||
cikti = json.loads(cikti)
|
||||
if not isinstance(cikti, dict):
|
||||
raise ValueError("kurallar is not an object")
|
||||
bilinmeyen = sorted(set(cikti) - set(jev_settings.POLICY_KEYS))
|
||||
if bilinmeyen:
|
||||
raise ValueError("unknown sections: " + ", ".join(bilinmeyen))
|
||||
overrides = {k: v for k, v in cikti.items() if isinstance(v, dict)}
|
||||
if len(overrides) != len(cikti):
|
||||
raise ValueError("every section must be an object")
|
||||
birlesik = copy.deepcopy(yerel)
|
||||
for k, v in overrides.items():
|
||||
birlesik[k] = jev_settings._fill(v, yerel[k])
|
||||
jev_settings.validate(birlesik)
|
||||
return overrides
|
||||
|
||||
|
||||
def cek(settings, key, urlopen=_ACICI.open):
|
||||
"""One fetch. Returns {"ok": bool, "overrides": {...}, "error": str}; never raises."""
|
||||
if not key:
|
||||
return {"ok": False, "error": "Dify key not configured"}
|
||||
url = settings['policy']['endpoint'].rstrip('/') + '/workflows/run'
|
||||
govde = json.dumps({"inputs": {}, "response_mode": "blocking", "user": "aiturk-codex-bridge"}).encode("utf-8")
|
||||
istek = urllib.request.Request(url, data=govde, method="POST", headers={
|
||||
"Authorization": "Bearer " + key, "Content-Type": "application/json", "User-Agent": UA})
|
||||
try:
|
||||
with urlopen(istek, timeout=30) as yanit:
|
||||
ham = yanit.read(MAX_BYTES * 4 + 1)
|
||||
if len(ham) > MAX_BYTES * 4:
|
||||
return {"ok": False, "error": "Dify response too large"}
|
||||
veri = json.loads(ham)
|
||||
cikti = ((veri.get("data") or {}).get("outputs") or {}).get("kurallar")
|
||||
if cikti is None:
|
||||
return {"ok": False, "error": "Dify output 'kurallar' missing"}
|
||||
return {"ok": True, "overrides": ayikla(cikti, settings)}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"{type(e).__name__}: {e}"[:300]}
|
||||
|
||||
|
||||
def zamani_mi(settings, simdi=None):
|
||||
simdi = time.time() if simdi is None else simdi
|
||||
return settings['policy']['source'] == 'dify' and simdi - _son["zaman"] >= settings['policy']['refreshMinutes'] * 60
|
||||
|
||||
|
||||
def yenile(log=lambda _m: None, urlopen=_ACICI.open, zorla=False):
|
||||
"""Fetch when due; store the outcome. Local settings are read without the Dify overlay."""
|
||||
try:
|
||||
yerel, _ = _yerel_ayar()
|
||||
except Exception as e:
|
||||
log(f"karar kurallari: ayar okunamadi ({type(e).__name__})")
|
||||
return None
|
||||
if not zorla and not zamani_mi(yerel):
|
||||
return None
|
||||
_son["zaman"] = time.time()
|
||||
sonuc = cek(yerel, jev_settings.policy_key(), urlopen=urlopen)
|
||||
kayit = jev_settings.write_policy(sonuc)
|
||||
log("karar kurallari Dify'dan alindi" if sonuc.get("ok") else f"karar kurallari alinamadi: {sonuc.get('error')}"
|
||||
+ (" (son gecerli kurallar kullaniliyor)" if kayit.get("ok") else ""))
|
||||
return kayit
|
||||
|
||||
|
||||
def _yerel_ayar():
|
||||
"""Settings as written locally (no Dify overlay), so a bad overlay can never validate itself."""
|
||||
return jev_settings.read(overlay=False)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""One decision-model question from the command line (14 Oct 2026).
|
||||
|
||||
Used by the Codex dynamic-workflow acceptance step (verifierCheck): before a reviewer's "kabul" is accepted,
|
||||
the decision model is asked whether the approval cites concrete evidence. Reads the IDE's own Kademeli Geçiş
|
||||
settings and key; nothing is written.
|
||||
|
||||
python karar_sor.py dogrulayici < {"user_request": "...", "assistant_reply": "..."}
|
||||
|
||||
Prints JSON {"enabled": bool, "kanitsiz": bool|null, "puan": float|null, "neden": str}. Exit 0 also when the
|
||||
stage is off or the model cannot be reached; the caller decides what an unanswered check means.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import jev_denetim # noqa: E402
|
||||
import jev_settings # noqa: E402
|
||||
|
||||
SINIR = 20000
|
||||
|
||||
|
||||
def dogrulayici(veri, anahtar=None, gonder=None):
|
||||
s = jev_settings.read()[0]
|
||||
v = s['verifierCheck']
|
||||
if not (s['enabled'] and v['enabled']):
|
||||
return {'enabled': False, 'kanitsiz': None, 'puan': None, 'neden': 'kapali'}
|
||||
durum = {'user_request': str(veri.get('user_request') or '')[:SINIR], 'assistant_reply': str(veri.get('assistant_reply') or '')[:SINIR]}
|
||||
ek = {'gonder': gonder} if gonder else {}
|
||||
puan = jev_denetim.sorular(durum, {'kanitsiz': v['question']}, jev_denetim.anahtar() if anahtar is None else anahtar, 15, **ek)
|
||||
if puan is None:
|
||||
return {'enabled': True, 'kanitsiz': None, 'puan': None, 'neden': 'karar modeline sorulamadi'}
|
||||
return {'enabled': True, 'kanitsiz': puan['kanitsiz'] >= v['threshold'], 'puan': round(puan['kanitsiz'], 3), 'neden': ''}
|
||||
|
||||
|
||||
def main(argv=None, stdin=None, out=None):
|
||||
argv = sys.argv[1:] if argv is None else argv
|
||||
stdin, out = stdin or sys.stdin, out or sys.stdout
|
||||
soru = [a for i, a in enumerate(argv) if a != '--home' and (i == 0 or argv[i - 1] != '--home')]
|
||||
if soru != ['dogrulayici']:
|
||||
print('kullanim: python karar_sor.py dogrulayici < {"user_request": ..., "assistant_reply": ...}', file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
veri = json.load(stdin)
|
||||
sonuc = dogrulayici(veri if isinstance(veri, dict) else {})
|
||||
except Exception as e:
|
||||
sonuc = {'enabled': None, 'kanitsiz': None, 'puan': None, 'neden': f'{type(e).__name__}: {e}'[:300]}
|
||||
out.write(json.dumps(sonuc, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Installed copy: the settings live in the user's bridge home, not next to this file.
|
||||
if '--home' not in sys.argv and not os.environ.get('AITURK_CODEX_BRIDGE_HOME'):
|
||||
os.environ['AITURK_CODEX_BRIDGE_HOME'] = str(Path(os.environ.get('LOCALAPPDATA', '')) / 'TurkServis' / 'AITURK-IDE' / 'codex-bridge')
|
||||
sys.exit(main())
|
||||
@@ -25,14 +25,24 @@ except ImportError:
|
||||
from resim_context import resimleri_sadelestir
|
||||
import ide_catalog
|
||||
import ozet_kopru
|
||||
import gecis_context
|
||||
import compression_engine
|
||||
import compression_settings
|
||||
import pencere
|
||||
import yarim
|
||||
import model_gecis
|
||||
import is_hatti
|
||||
import internet
|
||||
import jev_denetim
|
||||
import jev_settings
|
||||
import karar_politikasi
|
||||
|
||||
SURUM = "8"
|
||||
from bekci_watch import MetadataFilter
|
||||
|
||||
# Installed only by owned native launcher, never an HTTP control endpoint.
|
||||
WATCH_OWNER = None
|
||||
|
||||
SURUM = "20"
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
try:
|
||||
with open(os.path.abspath(__file__), "rb") as _f:
|
||||
@@ -84,6 +94,7 @@ def _arg(name, env):
|
||||
|
||||
|
||||
HOME = _arg("--home", "AITURK_CODEX_BRIDGE_HOME") or HERE
|
||||
CHECKPOINTS = compression_settings.Checkpoints(HOME)
|
||||
if _arg("--hermes-home", "HERMES_HOME"):
|
||||
os.environ["HERMES_HOME"] = _arg("--hermes-home", "HERMES_HOME")
|
||||
try:
|
||||
@@ -118,12 +129,13 @@ NO_RETRY_PATTERNS = CFG.get("no_retry_patterns", ["All fusion panel models faile
|
||||
LOGDIR = os.path.join(HOME, "log")
|
||||
os.makedirs(LOGDIR, exist_ok=True)
|
||||
lock = threading.Lock()
|
||||
log_lock = threading.Lock()
|
||||
seq = [0]
|
||||
|
||||
def log(msg):
|
||||
line = time.strftime("%Y-%m-%d %H:%M:%S ") + msg
|
||||
print(line, flush=True)
|
||||
with lock:
|
||||
print(line, file=sys.stderr, flush=True)
|
||||
with log_lock:
|
||||
with open(os.path.join(LOGDIR, "bridge.log"), "a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
@@ -218,6 +230,23 @@ def load_ide_catalog(ag=True):
|
||||
"kaynak": g.get("pencere_kaynak") or "", "claude": bool(g.get("claude"))}
|
||||
model_map[slug] = (IDE_ROUTE, g["id"], None)
|
||||
n += 1
|
||||
oto = _oto_ayari()
|
||||
if oto and IDE_PREFIX + oto["model"] not in model_map:
|
||||
# Sanal model: istek geldiginde karar modeli ayardaki combo'lardan birini secer (14 Ekim 2026).
|
||||
slug = IDE_PREFIX + oto["model"]
|
||||
pencereler = [IDE_BILGI.get(IDE_PREFIX + h, {}).get("pencere") for h in set(oto["targets"].values())]
|
||||
pencere_oto = min(pencereler) if pencereler and all(pencereler) else None
|
||||
extra_models.append({"slug": slug, "display_name": f"{oto['model']} (karar modeli seçer)",
|
||||
"description": "Her turda karar modeli işin türüne göre combo seçer: "
|
||||
+ ", ".join(f"{k}: {v}" for k, v in oto["targets"].items()),
|
||||
"route": IDE_ROUTE, "target": oto["model"], "clone_from": clone, "priority": 299,
|
||||
"reasoning_levels": levels, "kind": "combo", "context_window": pencere_oto})
|
||||
IDE_BILGI[slug] = {"pencere": pencere_oto, "gorsel": all(IDE_BILGI.get(IDE_PREFIX + h, {}).get("gorsel")
|
||||
for h in set(oto["targets"].values())),
|
||||
"arac": True, "kaynak": "", "claude": False}
|
||||
model_map[slug] = (IDE_ROUTE, oto["model"], None)
|
||||
OTO_ADI[0] = oto["model"]
|
||||
n += 1
|
||||
else:
|
||||
ROUTES.pop(IDE_ROUTE, None)
|
||||
MODEL_MAP = model_map
|
||||
@@ -227,10 +256,141 @@ def load_ide_catalog(ag=True):
|
||||
IDE_DURUM[0] = ozet
|
||||
log(f"AITURK IDE Codex Baglama: {ozet}")
|
||||
|
||||
def _oto_ayari():
|
||||
"""autoRoute acik ve Kademeli Gecis acikken onun ayari; degilse None. Ayar okunamazsa None."""
|
||||
try:
|
||||
s = jev_settings.read()[0]
|
||||
except Exception:
|
||||
return None
|
||||
return s["autoRoute"] if s["enabled"] and s["autoRoute"]["enabled"] else None
|
||||
|
||||
# Tur basi kararlari: sohbet -> (tur anahtari, gelen slug, secilen slug). Ayni turun adimlari ayni karari kullanir.
|
||||
ON_KARAR = {}
|
||||
ON_KARAR_SINIR = 500
|
||||
BITIS_TURLARI = {} # finishCheck kullanilan turlar (turda bir kez)
|
||||
KARAR_KILIDI = threading.Lock()
|
||||
OTO_ADI = ["Otomatik"] # katalogdaki sanal modelin son adi (karar asamasi hata verirse disari cikmasin)
|
||||
|
||||
# Acilista pencere verisi icin aga cikilmaz (IDE koprunun dinlemeye baslamasini sinirli sure bekler);
|
||||
# ilk tam yenileme asagidaki dongunun basinda, arka planda yapilir.
|
||||
load_ide_catalog(ag=False)
|
||||
|
||||
def tur_bilgisi(req, sohbet):
|
||||
"""(sohbet koku, tur anahtari, tur basindaki istek, yeni tur mu). Tur, calisan bir turun icine dusmeyen
|
||||
kullanici mesajiyla baslar. Arac adimlari, tur ortasinda yazilan mesaj, tek "." ve ozet istegi ayni turdadir;
|
||||
yeni tur yalniz son oge yeni bir kullanici istegi oldugunda baslar."""
|
||||
girdi = req.get("input") or []
|
||||
kok = sohbet or "anon:" + hashlib.sha256(json.dumps(girdi[:1], ensure_ascii=False, sort_keys=True,
|
||||
default=str).encode("utf-8", "replace")).hexdigest()
|
||||
bas, istek = -1, ""
|
||||
for i in range(len(girdi) - 1, -1, -1):
|
||||
metin = jev_denetim.istek_metni(girdi[i])
|
||||
if not metin or metin == ".":
|
||||
continue
|
||||
if not istek:
|
||||
bas, istek = i, metin # tur basi bulunamazsa en son gercek istek
|
||||
if not model_gecis.tur_ortasi(req, i):
|
||||
bas, istek = i, metin
|
||||
break
|
||||
son = model_gecis.son_sira(girdi)
|
||||
yeni = son >= 0 and son == bas and not model_gecis.tur_ortasi(req, son)
|
||||
tur = hashlib.sha256(f"{kok}\0{bas}\0{istek}".encode("utf-8", "replace")).hexdigest()
|
||||
return kok, tur, istek, yeni
|
||||
|
||||
|
||||
def karar_asamasi(n, req, sohbet, slug):
|
||||
"""14 Ekim 2026 karar asamalari: "Otomatik" modelin combo secimi (autoRoute) ve normal modelin reddedecegi
|
||||
tahmin edilen isi dogrudan ayardaki katmana verme (prefilter). Karar yalniz yeni turda verilir; ayni turun
|
||||
adimlari ayni karari kullanir (tur ortasinda model degisirse saglayicilar arasi gecmis bozulur). Kapali ayar,
|
||||
bos istek ya da karar modeli hatasi bugunku yolu degistirmez. Sanal model adi hicbir yolda gecide gitmez."""
|
||||
if not slug.startswith(IDE_PREFIX):
|
||||
return slug
|
||||
settings = jev_settings.read()[0]
|
||||
a, p = settings["autoRoute"], settings["prefilter"]
|
||||
sanal = slug == IDE_PREFIX + a["model"]
|
||||
kok, tur, istek, yeni = tur_bilgisi(req, sohbet)
|
||||
with KARAR_KILIDI:
|
||||
kayit = ON_KARAR.get(kok)
|
||||
if sanal and not (settings["enabled"] and a["enabled"]):
|
||||
# Kapatilmis Otomatik: son karar ya da guclu hedef; sanal ad gecide gitmez.
|
||||
secilen = kayit[2] if kayit and kayit[1] == slug else IDE_PREFIX + a["targets"]["zor"]
|
||||
log(f"[{n} {slug}] Otomatik kapali; bu adim {secilen} modeline gidiyor")
|
||||
req["model"] = secilen
|
||||
return secilen
|
||||
if not settings["enabled"]:
|
||||
return slug
|
||||
hedef = slug[len(IDE_PREFIX):]
|
||||
ret = p["enabled"] and (sanal or not (jev_denetim.muaf_mi(hedef) or hedef == p["target"]
|
||||
or hedef in jev_denetim.sonrakiler(p["target"])))
|
||||
if not sanal and not ret:
|
||||
return slug
|
||||
if kayit and kayit[1] == slug and (kayit[0] == tur or not yeni):
|
||||
secilen = kayit[2]
|
||||
else:
|
||||
if istek:
|
||||
t0 = time.time()
|
||||
sonuc = jev_denetim.on_karar(istek, sanal, ret, jev_denetim.anahtar())
|
||||
puan = sonuc["puan"]
|
||||
log(f"[{n} {slug}] tur basi karari ({time.time() - t0:.1f} sn): "
|
||||
+ (", ".join(f"{k}={v:.2f}" for k, v in puan.items()) if puan else "karar modeline sorulamadi"))
|
||||
else:
|
||||
sonuc = {"hedef": a["targets"]["zor"] if sanal else None, "ret": None}
|
||||
if sanal:
|
||||
hedef = sonuc["hedef"]
|
||||
secilen = IDE_PREFIX + hedef
|
||||
if (p["enabled"] and sonuc["ret"] and not jev_denetim.muaf_mi(hedef) and hedef != p["target"]
|
||||
and hedef not in jev_denetim.sonrakiler(p["target"])):
|
||||
log(f"[{n} {slug}] ret on kontrolu: normal model reddedebilir, adim {p['target']} katmanindan basliyor")
|
||||
secilen = IDE_PREFIX + p["target"]
|
||||
req["model"] = secilen
|
||||
with KARAR_KILIDI:
|
||||
ON_KARAR.pop(kok, None) # yeniden ekleme sirayi tazeler: aktif sohbet en son silinir
|
||||
ON_KARAR[kok] = (tur, slug, secilen)
|
||||
while len(ON_KARAR) > ON_KARAR_SINIR:
|
||||
ON_KARAR.pop(next(iter(ON_KARAR)), None)
|
||||
if secilen != slug:
|
||||
log(f"[{n} {slug}] karar: bu tur {secilen} modeline gidiyor")
|
||||
req["model"] = secilen
|
||||
return secilen
|
||||
|
||||
|
||||
def bitis_kullan(tur):
|
||||
"""finishCheck turda en fazla bir kez: ilk cagri True (ve isaretler), sonrakiler False."""
|
||||
with KARAR_KILIDI:
|
||||
if tur in BITIS_TURLARI:
|
||||
return False
|
||||
BITIS_TURLARI[tur] = time.time()
|
||||
while len(BITIS_TURLARI) > ON_KARAR_SINIR:
|
||||
BITIS_TURLARI.pop(next(iter(BITIS_TURLARI)), None)
|
||||
return True
|
||||
|
||||
|
||||
def sigan_katmanlar(sira, req, tag):
|
||||
"""Istegi sigdiramayacak katman zincirden cikar (bos baglam hatasiyla bosa dusmesin). Yalniz penceresi
|
||||
guvenilir (pencere.bekci_calisir) katman atlanir; zincirin son halkasi hic atlanmaz."""
|
||||
tahmin, sonuc = None, []
|
||||
for i, ad in enumerate(sira):
|
||||
bilgi = IDE_BILGI.get(IDE_PREFIX + ad) or {}
|
||||
p = bilgi.get("pencere")
|
||||
if p and i < len(sira) - 1 and pencere.bekci_calisir(bilgi.get("kaynak"), p):
|
||||
if tahmin is None:
|
||||
tahmin = pencere.tahmini_token(req, 2.0)
|
||||
if tahmin > int(p * 0.90):
|
||||
log(f"{tag} katman {ad} atlandi: istek sigmaz (tahmini {tahmin} token, pencere {p})")
|
||||
continue
|
||||
sonuc.append(ad)
|
||||
return sonuc
|
||||
|
||||
def karar_kurallari_dongusu():
|
||||
"""Dify kural kaynagi (policy.source = dify) arka planda yenilenir; tur hic beklemez."""
|
||||
time.sleep(5)
|
||||
while True:
|
||||
try:
|
||||
karar_politikasi.yenile(log)
|
||||
except Exception as e:
|
||||
log(f"karar kurallari yenileme hatasi: {type(e).__name__}: {e}")
|
||||
time.sleep(60)
|
||||
|
||||
def catalog_refresh_loop():
|
||||
"""Kataloglari arka planda yeniler; liste istegini yavaslatmaz."""
|
||||
minutes = max(1, int(CFG.get("catalog_refresh_min", 5)))
|
||||
@@ -248,6 +408,7 @@ def catalog_refresh_loop():
|
||||
log(f"katalog yenileme hatasi: {e}")
|
||||
|
||||
threading.Thread(target=catalog_refresh_loop, daemon=True).start()
|
||||
threading.Thread(target=karar_kurallari_dongusu, daemon=True).start()
|
||||
|
||||
# ---- AITURK is hatti: isi gonderebilen AITURK komutu (is_hatti.py). Kurulum sonradan gelebilir: yoksa 2 dk'da bir bakilir.
|
||||
HAT_KOMUT = [None]
|
||||
@@ -255,7 +416,7 @@ HAT_KOMUT = [None]
|
||||
def hat_komut_loop():
|
||||
while True:
|
||||
try:
|
||||
if (CFG.get("aiturk_hat") or {}).get("enabled") is not False:
|
||||
if True: # komut her zaman aranir; secim ayarlar ekranindan sonradan acilabilir
|
||||
bulunan = is_hatti.komut_bul()
|
||||
if bulunan != HAT_KOMUT[0]:
|
||||
HAT_KOMUT[0] = bulunan
|
||||
@@ -266,6 +427,57 @@ def hat_komut_loop():
|
||||
|
||||
threading.Thread(target=hat_komut_loop, daemon=True).start()
|
||||
|
||||
_HAT_AYAR = {"mtime": None, "deger": None}
|
||||
|
||||
def hat_ayari():
|
||||
"""bridge.json "aiturk_hat" alani; ayarlar ekranindan yapilan degisiklik yeniden baslatmadan uygulanir."""
|
||||
yol = os.path.join(HOME, "bridge.json")
|
||||
try:
|
||||
mt = os.path.getmtime(yol)
|
||||
if mt != _HAT_AYAR["mtime"]:
|
||||
with open(yol, encoding="utf-8") as f:
|
||||
_HAT_AYAR["deger"] = (json.load(f) or {}).get("aiturk_hat")
|
||||
_HAT_AYAR["mtime"] = mt
|
||||
return _HAT_AYAR["deger"]
|
||||
except Exception:
|
||||
return CFG.get("aiturk_hat")
|
||||
|
||||
_LISTE_AYAR = {"mtime": None, "deger": None}
|
||||
|
||||
def liste_ayari():
|
||||
"""bridge.json "codex_liste": Codex secicisinde gorunecek modeller (ayarlar ekrani yazar, yeniden baslatmadan uygulanir)."""
|
||||
yol = os.path.join(HOME, "bridge.json")
|
||||
try:
|
||||
mt = os.path.getmtime(yol)
|
||||
if mt != _LISTE_AYAR["mtime"]:
|
||||
with open(yol, encoding="utf-8") as f:
|
||||
_LISTE_AYAR["deger"] = (json.load(f) or {}).get("codex_liste")
|
||||
_LISTE_AYAR["mtime"] = mt
|
||||
return _LISTE_AYAR["deger"] or {}
|
||||
except Exception:
|
||||
return CFG.get("codex_liste") or {}
|
||||
|
||||
_ADAY_IZ = {"iz": None}
|
||||
|
||||
def aday_kaydet(sonuc):
|
||||
"""Ayarlar ekrani icin aday listesi (log/son-liste/codex-adaylar.json); icerik degismediyse yazilmaz."""
|
||||
if not sonuc:
|
||||
return
|
||||
try:
|
||||
govde = json.dumps(sonuc, ensure_ascii=False, sort_keys=True)
|
||||
iz = hashlib.sha256(govde.encode("utf-8")).hexdigest()
|
||||
if iz == _ADAY_IZ["iz"]:
|
||||
return
|
||||
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||
yol = os.path.join(CACHE_DIR, "codex-adaylar.json")
|
||||
gecici = "%s.%d.tmp" % (yol, threading.get_ident())
|
||||
with open(gecici, "w", encoding="utf-8") as f:
|
||||
json.dump({**sonuc, "at": time.strftime("%Y-%m-%d %H:%M:%S")}, f, ensure_ascii=False)
|
||||
os.replace(gecici, yol)
|
||||
_ADAY_IZ["iz"] = iz
|
||||
except Exception as e:
|
||||
log(f"Codex aday listesi yazilamadi: {e}")
|
||||
|
||||
LAST_MODELS = {} # hesap kimligi -> son basarili model listesi (chatgpt.com hata verirse bu verilir)
|
||||
CACHE_DIR = os.path.join(LOGDIR, "son-liste")
|
||||
|
||||
@@ -353,15 +565,40 @@ def arac_semalarini_duzelt(req):
|
||||
n += bool(degisti)
|
||||
return n
|
||||
|
||||
def flatten_request(req):
|
||||
def flatten_request(req, tool_names=None):
|
||||
"""additional_tools/namespace/custom -> duz function tools. Donus: custom arac adlari kumesi."""
|
||||
if isinstance(req.get("input"), list) and any(
|
||||
isinstance(item, dict) and item.get("type") == "agent_message" for item in req["input"]):
|
||||
import gecis_context
|
||||
req["input"] = gecis_context.portable_reasoning(req)["input"]
|
||||
custom = set()
|
||||
tools = []
|
||||
def add_tool(t):
|
||||
tool_names = tool_names if tool_names is not None else {}
|
||||
identities = {}
|
||||
wire_names = {}
|
||||
def add_tool(t, namespace=None):
|
||||
ty = t.get("type")
|
||||
if ty in ("function", "custom"):
|
||||
namespace = t.get("namespace") or namespace
|
||||
name = t["name"]
|
||||
if namespace:
|
||||
import hashlib
|
||||
identity = (namespace, name)
|
||||
# A hash avoids collisions between underscore-containing namespace/name pairs.
|
||||
alias = "ns_" + hashlib.sha256(json.dumps(identity).encode()).hexdigest()[:24] + "_" + re.sub(r"[^a-zA-Z0-9_-]", "_", name)[:32]
|
||||
if alias in tool_names and tool_names[alias] != identity:
|
||||
raise ValueError("Tool namespace alias collision")
|
||||
tool_names[alias] = identity
|
||||
identities[identity] = alias
|
||||
t = dict(t, name=alias, description=f"Tool {namespace}.{name}. " + t.get("description", ""))
|
||||
wire = t["name"]
|
||||
identity = (namespace, name)
|
||||
if wire in wire_names and wire_names[wire] != identity:
|
||||
raise ValueError("Tool namespace alias collision")
|
||||
wire_names[wire] = identity
|
||||
if ty == "namespace":
|
||||
for s in t.get("tools", []):
|
||||
add_tool(s)
|
||||
add_tool(s, ".".join(filter(None, (namespace, t.get("name")))))
|
||||
elif ty == "function":
|
||||
tools.append({"type": "function", "name": t["name"], "description": t.get("description", ""),
|
||||
"parameters": t.get("parameters") or {"type": "object", "properties": {}}, "strict": False})
|
||||
@@ -377,13 +614,34 @@ def flatten_request(req):
|
||||
tools.append(t)
|
||||
for t in req.get("tools", []) or []:
|
||||
add_tool(t)
|
||||
for it in req.get("input", []) or []:
|
||||
if it.get("type") == "additional_tools":
|
||||
for t in it.get("tools", []):
|
||||
add_tool(t)
|
||||
plain_names = {t.get("name") for t in tools if t.get("name") not in tool_names}
|
||||
def flatten_name(item):
|
||||
namespace, name = item.get("namespace"), item.get("name")
|
||||
alias = identities.get((namespace, name))
|
||||
if not namespace and name not in plain_names:
|
||||
matches = [a for a, identity in tool_names.items() if identity[1] == name]
|
||||
if len(matches) == 1:
|
||||
alias = matches[0] # Legacy history recorded before namespaces were preserved.
|
||||
if alias:
|
||||
item = dict(item, name=alias)
|
||||
item.pop("namespace", None)
|
||||
return item
|
||||
choice = req.get("tool_choice")
|
||||
if isinstance(choice, dict):
|
||||
req["tool_choice"] = flatten_name(choice)
|
||||
if isinstance(choice.get("tools"), list):
|
||||
req["tool_choice"] = dict(req["tool_choice"], tools=[flatten_name(t) for t in choice["tools"]])
|
||||
new_input = []
|
||||
for it in req.get("input", []) or []:
|
||||
ty = it.get("type")
|
||||
if ty == "additional_tools":
|
||||
for t in it.get("tools", []):
|
||||
add_tool(t)
|
||||
continue
|
||||
if ty in ("function_call", "custom_tool_call"):
|
||||
it = flatten_name(it)
|
||||
if ty in ("function_call_output", "custom_tool_call_output") and not it.get("call_id"):
|
||||
# Desktop thread handoffs have no matching tool call. Preserve their
|
||||
# content as imported context, not an invalid tool-role message.
|
||||
@@ -517,9 +775,10 @@ class SseRewriter:
|
||||
Ayrica dis modelin asistan mesajlarina phase damgasi koyar: mesajin "bitti" olayi, arkasindan ne
|
||||
geldigi belli olana kadar bekletilir (arac cagrisi ya da baska oge -> commentary; yanit bitti ->
|
||||
final_answer). Metin parcalari (delta) bekletilmez, canli akar."""
|
||||
def __init__(self, custom, hedef=None, tasma=None, aracsiz=None, dolu=False):
|
||||
def __init__(self, custom, hedef=None, tasma=None, aracsiz=None, dolu=False, tool_names=None):
|
||||
self.dolu = dolu # istek hedef pencerenin buyuk kismini dolduruyor
|
||||
self.custom = custom
|
||||
self.tool_names = tool_names or {}
|
||||
self.buf = b""
|
||||
self.custom_ids = set()
|
||||
self.bekleyen = None # phase karari bekleyen "output_item.done" olayi
|
||||
@@ -624,9 +883,21 @@ class SseRewriter:
|
||||
self.bekleyen = d
|
||||
return once
|
||||
return once + [d]
|
||||
def _restore_tool_name(self, item):
|
||||
if isinstance(item, dict) and item.get("type") in ("function_call", "custom_tool_call"):
|
||||
identity = self.tool_names.get(item.get("name"))
|
||||
if identity:
|
||||
item["namespace"], item["name"] = identity
|
||||
return item
|
||||
def _fix(self, d):
|
||||
t = d.get("type", "")
|
||||
item = d.get("item")
|
||||
if isinstance(item, dict) and item.get("type") == "custom_tool_call":
|
||||
fix_tool_ids({"input": [item]})
|
||||
if t.startswith("response.custom_tool_call_input.") and isinstance(d.get("item_id"), str):
|
||||
identity = {"type": "custom_tool_call", "id": d["item_id"]}
|
||||
fix_tool_ids({"input": [identity]})
|
||||
d["item_id"] = identity["id"]
|
||||
if item and item.get("type") == "function_call" and item.get("name") in self.custom:
|
||||
self.custom_ids.add(item.get("id"))
|
||||
args = item.get("arguments") or ""
|
||||
@@ -639,6 +910,7 @@ class SseRewriter:
|
||||
# Normalize before Codex persists the response. Request-only repair
|
||||
# cannot help when the user later disables the bridge.
|
||||
fix_tool_ids({"input": [d["item"]]})
|
||||
self._restore_tool_name(d["item"])
|
||||
return d
|
||||
if t.startswith("response.function_call_arguments.") and d.get("item_id") in self.custom_ids:
|
||||
return None # JSON parcalari custom input'a cevrilemez; done olayinda tam metin gidiyor
|
||||
@@ -651,8 +923,10 @@ class SseRewriter:
|
||||
item = {"type": "custom_tool_call", "id": item.get("id"), "call_id": item.get("call_id"),
|
||||
"name": item.get("name"), "input": inp, "status": "completed"}
|
||||
fix_tool_ids({"input": [item]})
|
||||
out.append(item)
|
||||
out.append(self._restore_tool_name(item))
|
||||
d["response"]["output"] = out
|
||||
fix_tool_ids({"input": out})
|
||||
self._restore_tool_name(d.get("item"))
|
||||
return d
|
||||
def feed(self, chunk):
|
||||
self.buf += chunk
|
||||
@@ -871,7 +1145,7 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
self.send_header("Transfer-Encoding", "chunked")
|
||||
self.end_headers()
|
||||
|
||||
def _denetimli_akis(self, resp, tag, rewriter, izle, istek, sira, ac):
|
||||
def _denetimli_akis(self, resp, tag, rewriter, izle, istek, sira, ac, hedef=None, devam=None):
|
||||
"""Katman denetimi (jev_denetim.py): cevap istemciye verilmeden once tamami okunur ve Jev'e sorulur.
|
||||
Gecerse oldugu gibi verilir; gecmezse atilir ve ayni adimi siradaki katman yapar (ac(ad): (resp, rewriter,
|
||||
tag)). Son katmanin cevabi sorulmadan akar. Beklerken SSE yorum satiri gider ki Codex akisi kopmus saymasin."""
|
||||
@@ -883,6 +1157,8 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
else:
|
||||
self._akis_basligi(resp, resp.status)
|
||||
kalan, son_yazi, son_govde = list(sira), time.time(), b""
|
||||
devam_kullanildi = False # finishCheck: son cevap ayni modele en fazla bir kez geri verilir
|
||||
yedek = None # geri verilen istek basarisiz olursa teslim edilecek, Jev'den gecmis ilk cevap
|
||||
|
||||
def w(b):
|
||||
if b:
|
||||
@@ -918,21 +1194,56 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
gecmedi = False
|
||||
if hata:
|
||||
gecmedi = True
|
||||
elif metin and jev_denetim.muaf_mi(hedef):
|
||||
log(f"{tag} Jev atlandi: her zaman gec ({hedef})")
|
||||
elif metin:
|
||||
t0 = time.time()
|
||||
puan = jev_denetim.sor(istek, metin, jev_denetim.anahtar())
|
||||
# Jev may wait for a slot or a remote response. Keep this chat alive.
|
||||
import threading as jev_threading
|
||||
jev_done, jev_result = jev_threading.Event(), [None]
|
||||
def denetle():
|
||||
try:
|
||||
if devam is not None and not devam_kullanildi:
|
||||
jev_result[0] = jev_denetim.sor(istek, metin, jev_denetim.anahtar(), bitis=True)
|
||||
else:
|
||||
jev_result[0] = jev_denetim.sor(istek, metin, jev_denetim.anahtar())
|
||||
finally:
|
||||
jev_done.set()
|
||||
jev_threading.Thread(target=denetle, daemon=True).start()
|
||||
while not jev_done.wait(10):
|
||||
w(b": jev-denetim\n\n")
|
||||
puan = jev_result[0]
|
||||
if puan is None:
|
||||
log(f"{tag} katman denetimi: Jev'e sorulamadi, cevap oldugu gibi geciyor")
|
||||
gecmedi = jev_denetim.hata_gecis()
|
||||
log(f"{tag} katman denetimi: Jev'e sorulamadi, {'siradaki katman' if gecmedi else 'cevap oldugu gibi geciyor'}")
|
||||
else:
|
||||
gecmedi = jev_denetim.karar(puan)
|
||||
log(f"{tag} katman denetimi: ret={puan['ret']:.2f} sart={puan['sart']:.2f} -> "
|
||||
log(f"{tag} katman denetimi: ret={puan['ret']:.2f} sart={puan['sart']:.2f}"
|
||||
+ (f" bitmedi={puan['bitmedi']:.2f}" if 'bitmedi' in puan else "") + " -> "
|
||||
f"{'GECMEDI' if gecmedi else 'gecti'} ({time.time() - t0:.1f} sn, {len(metin)} karakter{'' if yazi else ', yalniz komut'})")
|
||||
if not gecmedi and devam is not None and not devam_kullanildi and jev_denetim.bitmedi_mi(puan):
|
||||
devam_kullanildi = True
|
||||
try:
|
||||
yeni_devam = devam(hedef, metin)
|
||||
except Exception as e:
|
||||
yeni_devam = None
|
||||
log(f"{tag} bitis denetimi: devam istegi acilamadi, cevap oldugu gibi geciyor ({type(e).__name__}: {e})")
|
||||
if yeni_devam is not None:
|
||||
yedek = (parcalar, rewriter, tag)
|
||||
resp, rewriter, tag = yeni_devam
|
||||
log(f"{tag} bitis denetimi: is bitmeden bitti denmis, ayni modele bir kez geri verildi")
|
||||
continue
|
||||
else:
|
||||
# Bos adim: ne yazi var ne arac cagrisi (hata olayi da boyledir, or. "upstream 400"). Kabul
|
||||
# edilmez, siradaki katman yapar; bos yanit cevap diye verilirse Codex adimi bastan dener.
|
||||
gecmedi = True
|
||||
log(f"{tag} katman denetimi: bos adim (yazi da komut da yok), siradakine geciliyor: "
|
||||
f"{b''.join(parcalar)[-200:]!r}")
|
||||
if gecmedi and yedek is not None:
|
||||
# Geri verilen istek koptu, bos dondu ya da gecmedi: Jev'den gecmis ilk cevap teslim edilir.
|
||||
parcalar, rewriter, tag = yedek
|
||||
gecmedi = False
|
||||
log(f"{tag} bitis denetimi: geri verilen istek kullanilamadi, ilk cevap teslim ediliyor")
|
||||
yeni = None
|
||||
while gecmedi and kalan and yeni is None:
|
||||
ad = kalan.pop(0)
|
||||
@@ -944,12 +1255,18 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
if yeni is None:
|
||||
break
|
||||
resp, rewriter, tag = yeni
|
||||
hedef = ad # Muafiyet yalnız ilk exact hedefe ait; yedek katmana taşınmaz.
|
||||
log(f"{tag} katman denetimi: adim bu katmana verildi")
|
||||
if not kalan:
|
||||
if jev_denetim.muaf_mi(hedef):
|
||||
return self._stream(resp, resp.status, tag, rewriter, izle, basliksiz=True)
|
||||
if gecmedi:
|
||||
# Son katman ne dondurduyse o gider, hicbir sey eklenmez; dondurmediyse hicbir sey gitmez.
|
||||
w(son_govde)
|
||||
# Headers already declare SSE. A raw upstream JSON/HTML body
|
||||
# here leaves Codex waiting for a terminal event and retrying.
|
||||
event = {"type": "response.failed", "response": {
|
||||
"id": "resp_astrabridge_%d" % time.time_ns(),
|
||||
"object": "response", "status": "failed", "output": [], "error": {
|
||||
"code": "jev_no_fallback", "message": "Jev denetiminden gecen cevap alinamadi; gecis kurallarini kontrol edin."}}}
|
||||
w(b"event: response.failed\ndata: " + json.dumps(event).encode() + b"\n\n")
|
||||
else:
|
||||
for chunk in parcalar:
|
||||
if izle is not None:
|
||||
@@ -1047,7 +1364,13 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
"ide_model": int((sayi.split() or ["0"])[0]),
|
||||
"ozet": bool((CFG.get("ozet_kopru") or {}).get("enabled", False)),
|
||||
"is_hatti": bool(HAT_KOMUT[0]),
|
||||
"adim_denetimi": bool(jev_denetim.anahtar())})
|
||||
"adim_denetimi": jev_denetim.etkin_mi() and bool(jev_denetim.anahtar()),
|
||||
"compression_models": [{"id": m['slug'], "name": m.get('display_name') or m['slug'],
|
||||
"window": self._transfer_window(m['slug']),
|
||||
"source": (IDE_BILGI.get(m['slug']) or {}).get('kaynak', 'bilinmiyor')}
|
||||
for m in EXTRA_MODELS if m.get('slug')],
|
||||
"jev_models": [{"id": m.get("target"), "name": m.get("display_name") or m.get("target")}
|
||||
for m in EXTRA_MODELS if m.get("route") == IDE_ROUTE and m.get("target")]})
|
||||
if self.command == "POST" and path.rstrip("/").endswith("/stop"):
|
||||
self._send_json(200, {"ok": True})
|
||||
log("AITURK IDE istegiyle kapatiliyor")
|
||||
@@ -1067,7 +1390,20 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
return
|
||||
body = self._read()
|
||||
path = self.path
|
||||
if self.command == "POST" and path == "/aiturk/codex-baglama/prepare-transfer":
|
||||
# Non-simple header prevents browser-origin requests to this local service.
|
||||
if self.headers.get("Origin") or self.headers.get("X-AITURK-Transfer") != "1":
|
||||
return self._send_json(403, {"error": {"message": "local transfer only"}})
|
||||
try:
|
||||
result = self._prepare_transfer(json.loads(body))
|
||||
return self._send_json(200, result)
|
||||
except Exception as e:
|
||||
log(f"gecis hazirligi basarisiz: {type(e).__name__}")
|
||||
message = str(e) if isinstance(e, gecis_context.TransferError) else "AITURK geçiş özeti hazırlanamadı; geçmiş korundu."
|
||||
return self._send_json(422, {"error": {"message": message}})
|
||||
if path.startswith("/aiturk/codex-baglama/"):
|
||||
if WATCH_OWNER is not None:
|
||||
return self._send_json(404, {"error": "not available on native owner"})
|
||||
return self._control(path)
|
||||
if DEBUG:
|
||||
log(f"[{n} giris {self.command} {path[:70]}] cl={self.headers.get('Content-Length')} enc={self.headers.get('Content-Encoding')} govde={len(body)}")
|
||||
@@ -1148,7 +1484,7 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
govde = ozet_kopru.coz_istegi(oge, ozet_cfg.get("openai_modeli", "gpt-6-luna"))
|
||||
hdrs = {k: v for k, v in self.headers.items() if k.lower() not in REQ_HOP and k.lower() != "content-encoding"}
|
||||
hdrs["Content-Type"] = "application/json"; hdrs["Accept"] = "text/event-stream"
|
||||
r = urllib.request.Request(CHATGPT + self.path, data=json.dumps(govde, ensure_ascii=False).encode("utf-8"), headers=hdrs, method="POST")
|
||||
r = urllib.request.Request(CHATGPT + "/backend-api/codex/responses", data=json.dumps(govde, ensure_ascii=False).encode("utf-8"), headers=hdrs, method="POST")
|
||||
try:
|
||||
resp = urllib.request.urlopen(r, timeout=float(ozet_cfg.get("coz_zaman_asimi", 300)))
|
||||
metin = ozet_kopru.sse_metni(resp.read())
|
||||
@@ -1163,6 +1499,26 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
log(f"[{n} ozet kopru] OpenAI ozeti duz metne cevrildi ({len(metin)} karakter, onbellege yazildi)")
|
||||
return metin or None
|
||||
|
||||
def _transfer_window(self, slug):
|
||||
if ek_model_mu(slug):
|
||||
info = IDE_BILGI.get(slug) or {}
|
||||
value = info.get("pencere")
|
||||
# Unknown is not an excuse to disable the guard. Use a conservative
|
||||
# fallback until the provider reports its real capacity.
|
||||
return int(value) if value and pencere.bekci_calisir(info.get("kaynak"), value) else 32768
|
||||
account = self.headers.get("chatgpt-account-id") or "-"
|
||||
cached = LAST_MODELS.get(account)
|
||||
if cached:
|
||||
for model in json.loads(cached["body"]).get("models", []):
|
||||
if model.get("slug") == slug and model.get("context_window"):
|
||||
return int(model["context_window"])
|
||||
raise gecis_context.TransferError("Abonelik modelinin bağlam kapasitesi katalogda bulunamadı; geçmiş korundu.")
|
||||
|
||||
|
||||
def _prepare_transfer(self, payload):
|
||||
return compression_engine.prepare(self, sys.modules[__name__], payload, CHECKPOINTS)
|
||||
|
||||
|
||||
def _ozet_openai(self, n, path, req, slug, ozet_cfg, girdi_bayt):
|
||||
"""Kopru modeline (ide/, 9r/) gelen sikistirma isteginin ozetini abonelik modeline yazdirir.
|
||||
Donus: True = yanit istemciye gitti; False = abonelik istegi baslamadan basarisiz oldu
|
||||
@@ -1249,7 +1605,7 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
m.update(saved)
|
||||
models.append(m); changed += 1
|
||||
if ekler:
|
||||
ide_catalog.sirala_codex(models, ekler, ozgun_kimlikler)
|
||||
aday_kaydet(ide_catalog.sirala_codex(models, ekler, ozgun_kimlikler, liste_ayari()))
|
||||
out = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||
except Exception as e:
|
||||
log(f"{tag} model listesi degistirilemedi: {e}")
|
||||
@@ -1388,6 +1744,7 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
except Exception as e:
|
||||
log(f"[{n} responses] govde cozulemedi: {e}")
|
||||
return self._send_json(400, {"error": {"message": f"bridge: govde cozulemedi: {e}"}})
|
||||
self.watch_correlation = WATCH_OWNER.capture(dict(self.headers), req) if WATCH_OWNER else None
|
||||
slug = req.get("model", "")
|
||||
gelen = slug # Codex'in bu sohbet icin gonderdigi ad (listeden secilen model)
|
||||
if not ek_model_mu(gelen):
|
||||
@@ -1415,6 +1772,16 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
if hedef and hedef != gelen:
|
||||
req["model"] = slug = hedef
|
||||
log(f"[{n} {gelen}] sohbet yonlendirmesi: istek {hedef} modeline gidiyor")
|
||||
try:
|
||||
slug = karar_asamasi(n, req, sohbet, slug)
|
||||
except Exception as e:
|
||||
log(f"[{n} {slug}] karar asamasi atlandi: {type(e).__name__}: {e}")
|
||||
try:
|
||||
if slug == IDE_PREFIX + OTO_ADI[0]:
|
||||
# Sanal ad gecide gitmez: varsayilan guclu hedef.
|
||||
slug = req["model"] = IDE_PREFIX + jev_settings.DEFAULTS["autoRoute"]["targets"]["zor"]
|
||||
except Exception:
|
||||
pass
|
||||
mapping = MODEL_MAP.get(slug)
|
||||
if CFG.get("nokta_karsila", True) and nokta_mesaji_mi(req) and model_gecis.tur_ortasi(req, model_gecis.son_sira(req["input"])):
|
||||
# Calisan turun icine dusen nokta secimi tasimaz (olcum 10 Ekim 2026) ve cevaplanirsa isi yarida
|
||||
@@ -1439,7 +1806,7 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
if CFG.get("sesle_model", True) and CFG.get("model_bilgi", True) and sohbet and model_gecis.bilgi_ekle(req):
|
||||
govde_degisti = True
|
||||
if is_hatti.masaustu_mu(kaynak) and is_hatti.ekle(
|
||||
req, is_hatti.talimat(CFG.get("aiturk_hat"), HAT_KOMUT[0], sohbet)):
|
||||
req, is_hatti.talimat(hat_ayari(), HAT_KOMUT[0], sohbet)):
|
||||
govde_degisti = True
|
||||
# Ek modelde yerlesik web aramasi yok (flatten_request atar): aramayi kabukla yapan komut soylenir.
|
||||
if internet.ekle(req, internet.talimat(CFG.get("internet"), os.path.join(HERE, "ara.py"))):
|
||||
@@ -1546,11 +1913,12 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
log(f"{tag} {cikan} resim not ile degistirildi ({kazanc // 1024} KB)")
|
||||
rewriter = None
|
||||
if flat if flat is not None else route.get("flatten_tools"):
|
||||
custom = flatten_request(req)
|
||||
tool_names = {}
|
||||
custom = flatten_request(req, tool_names)
|
||||
duzelen = arac_semalarini_duzelt(req)
|
||||
if duzelen:
|
||||
log(f"{tag} {duzelen} aracin semasi duzeltildi (ust duzey oneOf/anyOf/allOf)")
|
||||
rewriter = SseRewriter(custom, hedef=target, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren)
|
||||
rewriter = SseRewriter(custom, hedef=target, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren, tool_names=tool_names)
|
||||
fix_tool_ids(req)
|
||||
if route_name == catalog.get('route', '9router') and target.startswith('ag/gemini-'):
|
||||
merged = coalesce_context(req)
|
||||
@@ -1568,15 +1936,17 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
if eklendi:
|
||||
log(f"{tag} girdi kullanici mesajiyla bitmiyordu ({sonlar}), devam mesaji eklendi")
|
||||
hedef_pencere = (IDE_BILGI.get(slug) or {}).get("pencere")
|
||||
# Penceresi bilinmeyen (varsayilan degerli) modelde bekci calismaz: tahmine dayanarak gecmis kirpilmaz.
|
||||
# Comboda tek bilinmeyen uye, bilinen kucuk uyenin sinirini gecersiz kilmaz (pencere.bekci_calisir).
|
||||
pencere_biliniyor = bool(hedef_pencere) and pencere.bekci_calisir((IDE_BILGI.get(slug) or {}).get("kaynak"), hedef_pencere)
|
||||
if hedef_pencere and pencere_biliniyor and CFG.get("tasma_bekcisi", True) and not ozet_tetik:
|
||||
kirpilan, sigdi, tahmin = pencere.sigdir(req, hedef_pencere, pencere.oran_sec(target, bool((IDE_BILGI.get(slug) or {}).get("claude"))))
|
||||
# Final check includes instructions added after transfer preparation.
|
||||
# Never cut the middle of historical text: semantic preparation happens
|
||||
# before inference, while the disk transcript remains untouched.
|
||||
pencere_kaynagi = (IDE_BILGI.get(slug) or {}).get("kaynak")
|
||||
if not hedef_pencere or not pencere.bekci_calisir(pencere_kaynagi, hedef_pencere):
|
||||
hedef_pencere = 32768
|
||||
if CFG.get("tasma_bekcisi", True) and not ozet_tetik:
|
||||
tahmin = pencere.tahmini_token(req, 2.0)
|
||||
sigdi = tahmin <= int(hedef_pencere * 0.90)
|
||||
if rewriter is not None:
|
||||
rewriter.dolu = tahmin >= hedef_pencere * 0.7
|
||||
if kirpilan:
|
||||
log(f"{tag} tasma bekcisi: {kirpilan} buyuk eski parca kisaltildi (tahmini {tahmin} token, pencere {hedef_pencere})")
|
||||
if not sigdi:
|
||||
log(f"{tag} tasma bekcisi: istek gonderilmedi (tahmini {tahmin} token > pencere {hedef_pencere})")
|
||||
return self._send_sse_failed("context_length_exceeded",
|
||||
@@ -1598,15 +1968,25 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
open(os.path.join(LOGDIR, f"{n:04d}_req.json"), "wb").write(data)
|
||||
hdrs = {"Authorization": f"Bearer {route['key']}", "Content-Type": "application/json", "Accept": "text/event-stream", "User-Agent": UA}
|
||||
last = None
|
||||
# Katman denetimi (jev_denetim.py): kopruye ugrayan HER ek modelin cevabi Jev'e sorulur; gecmezse adim
|
||||
# sirayla Katman-1 -> 2 -> 3 -> 4 yapar (secili model zaten katmansa ondan sonrakiler). Katman-4 sorulmadan
|
||||
# gecer. Katmanlar OmniRoute combolaridir: yedek her zaman AITURK gecidi (ide_route) uzerinden acilir,
|
||||
# Katman denetimi (jev_denetim.py): muaf listesi disindaki ek modeller Jev'e sorulur; gecmezse adim
|
||||
# ayardaki zinciri izler (14 Ekim 2026: Katman-1 -> 2 -> 3; secili model zaten katmansa ondan sonrakiler).
|
||||
# Muaf katman sorulmadan gecer. Katmanlar OmniRoute combolaridir: yedek her zaman AITURK gecidi (ide_route) uzerinden acilir,
|
||||
# secili model baska bir rotada olsa bile. Gecit yoksa denetim yapilmaz. Ozet isteginde ve aracsiz
|
||||
# (rewriter yok) yolda kapalidir.
|
||||
jev_rota = ROUTES.get(ide_route)
|
||||
jev_sira = ([] if ozet_tetik or rewriter is None or jev_rota is None or not jev_denetim.anahtar()
|
||||
else jev_denetim.sonrakiler(target))
|
||||
jev_istek = jev_denetim.son_istek(req) if jev_sira else ""
|
||||
try:
|
||||
jev_etkin = jev_denetim.etkin_mi() and not (ozet_tetik or rewriter is None or jev_rota is None or not jev_denetim.anahtar())
|
||||
jev_sira = jev_denetim.sonrakiler(target) if jev_etkin else []
|
||||
jev_istek = jev_denetim.son_istek(req) if jev_etkin else ""
|
||||
except Exception as e:
|
||||
# Okunamayan ayar istegi dusurmez: bu adim denetimsiz gecer.
|
||||
log(f"{tag} katman denetimi ayari okunamadi, denetimsiz devam: {type(e).__name__}: {e}")
|
||||
jev_etkin, jev_sira, jev_istek = False, [], ""
|
||||
if jev_sira:
|
||||
try:
|
||||
jev_sira = sigan_katmanlar(jev_sira, req, tag)
|
||||
except Exception as e:
|
||||
log(f"{tag} katman pencere kontrolu atlandi: {type(e).__name__}: {e}")
|
||||
|
||||
def jev_ac(ad):
|
||||
katman_req = json.loads(data)
|
||||
@@ -1616,8 +1996,38 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
yanit = urllib.request.urlopen(urllib.request.Request(
|
||||
jev_rota["url"], data=json.dumps(katman_req, ensure_ascii=False).encode("utf-8"), headers=jev_hdrs, method="POST"),
|
||||
timeout=UPSTREAM_IDLE_TIMEOUT)
|
||||
return (yanit, SseRewriter(custom, hedef=ad, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren),
|
||||
return (yanit, SseRewriter(custom, hedef=ad, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren, tool_names=tool_names),
|
||||
f"[{n} {slug}=>{ad}]")
|
||||
|
||||
def jev_devam(ad, onceki):
|
||||
"""finishCheck: ayni hedefe, erken biten cevap ve not eklenmis istek. Yalniz AITURK gecidindeki hedefler.
|
||||
Turda en fazla bir kez (bitis_kullan)."""
|
||||
if ad == target and route_name != ide_route:
|
||||
raise ValueError("hedef AITURK gecidinde degil")
|
||||
if not bitis_kullan(bitis_turu):
|
||||
raise ValueError("bu turda zaten geri verildi")
|
||||
devam_req = json.loads(data)
|
||||
devam_req["model"] = ad
|
||||
devam_req.setdefault("input", []).extend([
|
||||
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": onceki}]},
|
||||
{"type": "message", "role": "user", "content": [
|
||||
{"type": "input_text", "text": jev_settings.read()[0]["finishCheck"]["note"]}]}])
|
||||
jev_hdrs = {"Authorization": f"Bearer {jev_rota['key']}", "Content-Type": "application/json",
|
||||
"Accept": "text/event-stream", "User-Agent": UA}
|
||||
yanit = urllib.request.urlopen(urllib.request.Request(
|
||||
jev_rota["url"], data=json.dumps(devam_req, ensure_ascii=False).encode("utf-8"), headers=jev_hdrs, method="POST"),
|
||||
timeout=UPSTREAM_IDLE_TIMEOUT)
|
||||
return (yanit, SseRewriter(custom, hedef=ad, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren, tool_names=tool_names),
|
||||
f"[{n} {slug}=>{ad} devam]")
|
||||
try:
|
||||
bitis_turu = tur_bilgisi(req, sohbet)[1]
|
||||
jev_bitis = jev_devam if jev_etkin and jev_settings.read()[0]["finishCheck"]["enabled"] else None
|
||||
if jev_bitis is not None:
|
||||
with KARAR_KILIDI:
|
||||
if bitis_turu in BITIS_TURLARI:
|
||||
jev_bitis = None # bu turda bir kez geri verildi
|
||||
except Exception:
|
||||
jev_bitis = None
|
||||
# A 9Router combo (Fusion-*, no provider prefix) runs a whole panel upstream. Once it may
|
||||
# have started (timeout, 504, cut stream) resending it runs and bills it twice (28 Sep 2026);
|
||||
# only failures before the work starts (no connection, 500/502/503/529) are retried.
|
||||
@@ -1642,8 +2052,8 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
log(f"{tag} deneme {attempt}: HTTP {e.code} {errbody[:100]!r} -> tekrar")
|
||||
time.sleep(min(2 * attempt, 8)); continue
|
||||
log(f"{tag} HTTP {e.code} {errbody[:200]!r}")
|
||||
if jev_sira: # secili model hata verdi: adimi siradaki katman yapar
|
||||
return self._denetimli_akis(None, tag, rewriter, izle, jev_istek, jev_sira, jev_ac)
|
||||
if jev_etkin: # secili model hata verdi: adimi siradaki katman yapar
|
||||
return self._denetimli_akis(None, tag, rewriter, izle, jev_istek, jev_sira, jev_ac, hedef=target)
|
||||
if pencere.tasma_mi(etxt):
|
||||
# Hedef "baglam sigmiyor" dedi: Codex'in tanidigi tasma hatasi olarak iletilir ve hedefin
|
||||
# bildirdigi gercek sinir ogrenilir (sonraki model listesinde pencere kuculur).
|
||||
@@ -1666,17 +2076,36 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
break
|
||||
log(f"{tag} deneme {attempt}: {e}")
|
||||
time.sleep(min(2 * attempt, 8)); continue
|
||||
# Only the resolved exact voice target owns this sideband protocol.
|
||||
# Other models retain their original response/Jev/stream pipeline.
|
||||
if jev_denetim.izleme_mi(target):
|
||||
# Capture was taken before rewriting/request dispatch. Only this exact
|
||||
# route credential may authenticate the returned public receipt.
|
||||
correlation = self.watch_correlation
|
||||
accept_watch = lambda metadata: WATCH_OWNER.accept(metadata, correlation, route['key'], route['url']) if WATCH_OWNER else None
|
||||
if 'application/json' in str(resp.headers.get('Content-Type', '')):
|
||||
try:
|
||||
reply = json.loads(resp.read())
|
||||
if isinstance(reply, dict):
|
||||
metadata = reply.pop('_bekci_watch', None)
|
||||
if metadata is not None: accept_watch(metadata)
|
||||
return self._send_json(resp.status, reply)
|
||||
finally:
|
||||
resp.close()
|
||||
resp = WatchResponse(resp, accept_watch)
|
||||
# Headers go to the client from here on: a cut stream is never resent.
|
||||
if ozet_tetik:
|
||||
akis = ozet_kopru.OzetAkisi(rewriter, tohum=str(n), en_az=ozet_kopru.en_az_karakter(girdi_bayt, ozet_cfg))
|
||||
sonuc = self._stream(resp, resp.status, tag, akis)
|
||||
log(f"{tag} ozet kopru: {akis.sonuc_notu()}")
|
||||
return sonuc
|
||||
if jev_sira and resp.status == 200:
|
||||
return self._denetimli_akis(resp, tag, rewriter, izle, jev_istek, jev_sira, jev_ac)
|
||||
if jev_etkin and resp.status == 200:
|
||||
# devam yalniz finishCheck acikken gecer; kapaliyken cagri eskisiyle birebir aynidir.
|
||||
return self._denetimli_akis(resp, tag, rewriter, izle, jev_istek, jev_sira, jev_ac, hedef=target,
|
||||
**({"devam": jev_bitis} if jev_bitis else {}))
|
||||
return self._stream(resp, resp.status, tag, rewriter, izle)
|
||||
if jev_sira: # secili modele ulasilamadi: adimi siradaki katman yapar
|
||||
return self._denetimli_akis(None, tag, rewriter, izle, jev_istek, jev_sira, jev_ac)
|
||||
if jev_etkin: # secili modele ulasilamadi: adimi siradaki katman yapar
|
||||
return self._denetimli_akis(None, tag, rewriter, izle, jev_istek, jev_sira, jev_ac, hedef=target)
|
||||
self._send_json(503, {"error": {"message": f"bridge: hedef ulasilamadi {last}"}})
|
||||
|
||||
do_POST = _handle
|
||||
@@ -1685,8 +2114,38 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
do_DELETE = _handle
|
||||
do_PATCH = _handle
|
||||
|
||||
class WatchResponse:
|
||||
"""Remove metadata before any response rewriter, log or history observer."""
|
||||
def __init__(self, response, accept):
|
||||
self.response, self.filter = response, MetadataFilter(accept)
|
||||
self.done = False
|
||||
self.gecis = False # suzgec tasti (cok buyuk cerceve): kalan akis oldugu gibi gecer
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.response, name)
|
||||
def read1(self, size):
|
||||
if self.gecis:
|
||||
return self.response.read1(size)
|
||||
while not self.done:
|
||||
raw = self.response.read1(size)
|
||||
if raw:
|
||||
try:
|
||||
data = self.filter.feed(raw)
|
||||
except ValueError:
|
||||
# 256 KB'i asan tek cerceve: akisi dusurmek yerine suzgecsiz devam (yan kanal cercevesi
|
||||
# bu boyda olmaz). Tamponda yan kanal varsa disari cikmaz.
|
||||
data, self.filter.frames.buf = self.filter.frames.buf, b''
|
||||
self.gecis = True
|
||||
log("izleme suzgeci: cok buyuk SSE cercevesi, kalan akis suzgecsiz geciyor")
|
||||
return b'' if b'bekci.watch' in data else data
|
||||
if data: return data
|
||||
else:
|
||||
self.done = True
|
||||
return self.filter.flush()
|
||||
return b''
|
||||
|
||||
class TS(http.server.ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
request_queue_size = 128
|
||||
# Windows'ta SO_REUSEADDR baska bir surecin ayni portu "calmasina" izin verir.
|
||||
# Kapali: port doluysa sessizce yan yana dinlemek yerine acikca hata verir.
|
||||
allow_reuse_address = False
|
||||
|
||||
@@ -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)
|
||||
@@ -186,6 +186,16 @@ class OzetAkisi:
|
||||
return b": ozet\n\n"
|
||||
if t == "response.completed":
|
||||
self.metin = self._topla()
|
||||
if not self.metin:
|
||||
# Some Responses providers emit text only in the terminal
|
||||
# output. It is safe to accept it only on completed events.
|
||||
self.metin = "\n\n".join(
|
||||
p.get("text", "")
|
||||
for item in (d.get("response") or {}).get("output") or []
|
||||
if isinstance(item, dict) and item.get("type") == "message"
|
||||
for p in item.get("content") or []
|
||||
if isinstance(p, dict) and p.get("type") == "output_text"
|
||||
).strip()
|
||||
self.bitti = True
|
||||
if not self.metin:
|
||||
return _olay(d) # ozet yok: Codex "0 compaction ogesi" hatasini gosterir
|
||||
|
||||
@@ -11,7 +11,7 @@ Kaynak sirasi (kucukten guvenilire degil, guvenilirden yedege):
|
||||
3. Saglayici kisaltmasina gore yerel tablo (cc, cx, ag).
|
||||
4. Bilinmeyen: VARSAYILAN (temkinli) ve bir kez gunluk satiri.
|
||||
Combo: yapraklarinin en kucugu (yedege dusulurse de sigsin).
|
||||
Ust sinir TAVAN: ozeti abonelik modeli yazdigi icin gecmis onun penceresini asmamali.
|
||||
Sikistirici ve hedef modelin pencereleri ayri hesaplanir; genel abonelik tavani uygulanmaz.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
@@ -20,7 +20,6 @@ import threading
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
TAVAN = 272_000 # abonelik modellerinin penceresi; ozetleyen model bundan fazlasini okuyamaz
|
||||
VARSAYILAN = 128_000 # hakkinda hicbir sey bilinmeyen model
|
||||
TABAN = 8_000 # saglayici verisi bozuksa (0, eksi) kullanilmaz
|
||||
NANO_URL = "https://nano-gpt.com/api/v1/models?detailed=true"
|
||||
@@ -40,6 +39,7 @@ _YEREL = (
|
||||
# "maximum context length is 131072 tokens", "prompt is too long: 3040251 tokens > 1000000 maximum",
|
||||
# "context window of 32768 tokens", "exceeds the limit of 65536" ...
|
||||
_SINIR = (
|
||||
re.compile(r"max(?:imum)? input(?: tokens)?\s*[:=]?\s*(\d{4,8})", re.I),
|
||||
re.compile(r"maximum context length (?:is|of) (\d{4,8})", re.I),
|
||||
re.compile(r"\d{4,9} tokens? > (\d{4,8}) maximum", re.I),
|
||||
re.compile(r"context (?:window|length|size)(?: limit)? (?:is|of) (\d{4,8})", re.I),
|
||||
@@ -195,6 +195,10 @@ def yol_bilgisi(yol, nano):
|
||||
yol = (yol or "").strip()
|
||||
if yol.startswith("9router/"):
|
||||
yol = yol[len("9router/"):]
|
||||
for current, legacy in (("agy/", "ag/"), ("antigravity/", "ag/"), ("codex/", "cx/")):
|
||||
if yol.startswith(current):
|
||||
yol = legacy + yol[len(current):]
|
||||
break
|
||||
if yol.startswith("nano/"):
|
||||
n = (nano or {}).get(yol[len("nano/"):].lower())
|
||||
if n:
|
||||
@@ -217,7 +221,7 @@ def _yapraklar(dugum, derinlik=0):
|
||||
return cikti
|
||||
|
||||
|
||||
def coz(kimlik, tur, yol, kayit=None, nano=None, ogr=None, aracsiz=()):
|
||||
def coz(kimlik, tur, yol, kayit=None, nano=None, ogr=None, aracsiz=(), ilan=None):
|
||||
"""Bir katalog girdisinin ilan edilecek penceresi ve yetenekleri.
|
||||
kimlik: gecit kimligi; tur: "combo" | "model"; yol: gecidin aciklamasindaki "saglayici/model" yolu;
|
||||
kayit: /router/catalog girdisi (combo topolojisi icin). Donus: {"pencere","gorsel","arac","kaynak"}."""
|
||||
@@ -233,17 +237,30 @@ def coz(kimlik, tur, yol, kayit=None, nano=None, ogr=None, aracsiz=()):
|
||||
"gorsel": bool(bilgiler[0]["gorsel"]), "arac": bool(bilgiler[0]["arac"]),
|
||||
"kaynak": "uyeler:" + ",".join(sorted({b["kaynak"] for b in bilgiler}))}
|
||||
else:
|
||||
# Current gateway model descriptions are display names. Resolve only
|
||||
# recognized provider-qualified IDs; never infer capacity from a name.
|
||||
if kimlik and kimlik.startswith('agy-'):
|
||||
yol = 'agy/' + kimlik[len('agy-'):]
|
||||
if (yol or "").startswith("no-think/"):
|
||||
# Dusunmesiz kopya: asil saglayici kimlikten okunur ("no-think-9router-cc-claude-..." -> cc).
|
||||
m = re.match(r"no-think-(?:9router-)?([a-z0-9]+)-", kimlik or "")
|
||||
yol = (m.group(1) + "/" + yol[len("no-think/"):]) if m else yol
|
||||
sonuc = yol_bilgisi(yol, nano) or dict(bilinmeyen)
|
||||
# Exact /models metadata is stronger evidence than name-based local
|
||||
# fallbacks. Do not infer a combo's capacity from its display name.
|
||||
ilan = ilan if isinstance(ilan, dict) else {}
|
||||
canli_sinirlar = [ilan[k] for k in ("context_length", "max_input_tokens")
|
||||
if type(ilan.get(k)) is int and TABAN <= ilan[k] <= 10_000_000]
|
||||
if canli_sinirlar:
|
||||
sonuc = {**sonuc, "pencere": min(canli_sinirlar), "kaynak": "saglayici-katalogu"}
|
||||
if kimlik in aracsiz:
|
||||
sonuc = {**sonuc, "arac": False}
|
||||
sinir = (ogr or {}).get(kimlik)
|
||||
if sinir and sinir < sonuc["pencere"]:
|
||||
sonuc = {**sonuc, "pencere": int(sinir), "kaynak": "ogrenilen"}
|
||||
sonuc["pencere"] = max(TABAN, min(TAVAN, int(sonuc["pencere"])))
|
||||
# Summarizers now have their own provider-specific budgets. A subscription
|
||||
# model's window must not cap unrelated providers' advertised capacity.
|
||||
sonuc["pencere"] = max(TABAN, int(sonuc["pencere"]))
|
||||
return sonuc
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,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,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
|
||||
@@ -23,7 +23,7 @@ class AccessPrograms(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.upstream = Mock(return_value=Mock(status=200))
|
||||
self.env = {
|
||||
"json": json,
|
||||
"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": {},
|
||||
|
||||
@@ -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()
|
||||
@@ -60,11 +60,18 @@ def zstd(veri):
|
||||
class Ayirici(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.ozgun = sunucu(duz(200, b'event: response.completed\ndata: {"ok":1}\n\n', "text/event-stream"))
|
||||
self.kopru = sunucu(duz(200, b'event: response.completed\ndata: {"kopru":1}\n\n', "text/event-stream"))
|
||||
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
|
||||
@@ -77,6 +84,9 @@ class Ayirici(unittest.TestCase):
|
||||
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):
|
||||
@@ -92,8 +102,8 @@ class Ayirici(unittest.TestCase):
|
||||
istekler = [{"model": "gpt-6-astra", "input": [{"role": "user", "content": "modeli opus 5 5 yap"}]},
|
||||
{"model": "gpt-6-luna", "input": [{"role": "user", "content": "."}]},
|
||||
{"model": "gpt-6-astra", "prompt_cache_key": "s", "input": [{"type": "compaction_trigger"}]},
|
||||
{"model": "gpt-6-astra", "input": [{"type": "compaction", "encrypted_content": "ASTRABRIDGE-OZET-1:eA=="},
|
||||
{"type": "reasoning", "id": "rs_chatcmpl-x"}]}]
|
||||
{"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)):
|
||||
@@ -123,7 +133,7 @@ class Ayirici(unittest.TestCase):
|
||||
self.assertEqual((basliklar["originator"], basliklar["content-encoding"]), ("Codex Desktop", "zstd"))
|
||||
bridge.CFG["model_map"] = {"ozel-model": ["rota", "hedef"]}
|
||||
self.iste("POST", "/backend-api/codex/responses", json.dumps({"model": "ozel-model"}).encode())
|
||||
self.assertEqual(len(self.kopru.gelen), 3)
|
||||
self.assertEqual(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):
|
||||
@@ -143,14 +153,14 @@ class Ayirici(unittest.TestCase):
|
||||
self.assertEqual((durum, liste), (200, b'{"models":[{"slug":"gpt-6-astra"}]}'))
|
||||
# An added model says so instead of hanging or going to chatgpt.com under another name.
|
||||
durum, cevap, _ = self.iste("POST", "/backend-api/codex/responses", json.dumps({"model": "ide/Opus-5"}).encode())
|
||||
self.assertEqual(durum, 503)
|
||||
self.assertIn("köprüsü şu an çalışmıyor", json.loads(cevap)["error"]["message"])
|
||||
self.assertEqual(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, "8", "kapali", True))
|
||||
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"}]}')
|
||||
@@ -197,7 +207,7 @@ class Ayirici(unittest.TestCase):
|
||||
def test_status_is_the_bridge_status_plus_the_splitter_fields(self):
|
||||
self.kopru.cevap = duz(200, b'{"ok": true, "surum": "8", "kod": "abc", "ide_durum": "tamam", "ide_model": 147}')
|
||||
d = json.loads(self.iste("GET", "/aiturk/codex-baglama/status")[1])
|
||||
self.assertEqual((d["surum"], d["ide_model"], d["kopru"], d["ayirici"]), ("8", 147, "calisiyor", True))
|
||||
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"})
|
||||
|
||||
@@ -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,83 @@
|
||||
"""Codex secicisinde gorunen liste: kullanicinin one aldigi, gizledigi modeller ve 100'luk secici siniri."""
|
||||
import unittest
|
||||
|
||||
import ide_catalog as c
|
||||
|
||||
|
||||
def liste(*slugs, ozgun=("gpt-6-sol", "gpt-6-astra")):
|
||||
models = [{"slug": s, "display_name": s, "priority": i} for i, s in enumerate(ozgun)]
|
||||
models += [{"slug": "ide/" + s, "display_name": s, "priority": 0} for s in slugs]
|
||||
extras = [{"slug": "ide/" + s, "kind": "combo" if s.startswith("Takim") else "model"} for s in slugs]
|
||||
return models, extras
|
||||
|
||||
|
||||
class CodexListe(unittest.TestCase):
|
||||
def test_no_setting_keeps_the_old_behaviour(self):
|
||||
models, extras = liste("agnes-agnes-2.0-flash", "agnes-agnes-2.0-flash-high", "gemini-gemini-2.5-flash-preview-tts")
|
||||
self.assertIsNone(c.sirala_codex(models, extras))
|
||||
self.assertEqual(len(models), 5)
|
||||
|
||||
def test_default_hides_media_and_effort_copies_whose_base_is_listed(self):
|
||||
models, extras = liste("agnes-agnes-2.0-flash", "agnes-agnes-2.0-flash-high", "agy-gemini-3.8-flash-high",
|
||||
"gemini-gemini-2.5-flash-preview-tts", "gemini-gemini-3.1-flash-image", "gemini-aqa",
|
||||
"claude-claude-sonnet-5-5")
|
||||
sonuc = c.sirala_codex(models, extras, (), {})
|
||||
slugs = [m["slug"] for m in models]
|
||||
self.assertNotIn("ide/agnes-agnes-2.0-flash-high", slugs) # ana model listede: kopya gizlenir
|
||||
self.assertIn("ide/agy-gemini-3.8-flash-high", slugs) # ana modeli yok: kalir
|
||||
self.assertFalse([s for s in slugs if "tts" in s or "image" in s or "aqa" in s])
|
||||
nedenler = {a["slug"]: a["neden"] for a in sonuc["adaylar"]}
|
||||
self.assertEqual(nedenler["ide/agnes-agnes-2.0-flash-high"], "efor")
|
||||
self.assertEqual(nedenler["ide/gemini-aqa"], "medya")
|
||||
self.assertEqual(nedenler["ide/claude-claude-sonnet-5-5"], "")
|
||||
self.assertEqual(sonuc["ozgun_sayisi"], 2)
|
||||
|
||||
def test_switches_off_show_everything(self):
|
||||
models, extras = liste("agnes-agnes-2.0-flash", "agnes-agnes-2.0-flash-high", "gemini-gemini-2.5-flash-preview-tts")
|
||||
c.sirala_codex(models, extras, (), {"medya_gizle": False, "efor_gizle": False})
|
||||
self.assertEqual(len(models), 5)
|
||||
|
||||
def test_pinned_models_follow_the_subscription_models_in_the_given_order(self):
|
||||
models, extras = liste("agnes-a", "claude-claude-opus-5-5", "Takim-Hizli", "nanogpt-glm-uncensored", "zeta-x")
|
||||
originals = [dict(m) for m in models if not m["slug"].startswith("ide/")]
|
||||
sonuc = c.sirala_codex(models, extras, (), {"one_al": ["ide/nanogpt-glm-uncensored", "ide/claude-claude-opus-5-5", "ide/yok"]})
|
||||
self.assertEqual([m["slug"] for m in models[:4]],
|
||||
["gpt-6-sol", "gpt-6-astra", "ide/nanogpt-glm-uncensored", "ide/claude-claude-opus-5-5"])
|
||||
self.assertEqual([m for m in models if not m["slug"].startswith("ide/")], originals)
|
||||
self.assertEqual([m["priority"] for m in models[2:]], list(range(2, 2 + len(models) - 2)))
|
||||
sira = {a["slug"]: a["sira"] for a in sonuc["adaylar"]}
|
||||
self.assertEqual(sira["ide/nanogpt-glm-uncensored"], 2)
|
||||
|
||||
def test_hidden_models_leave_and_a_pin_overrides_the_automatic_filters(self):
|
||||
models, extras = liste("agnes-a", "agnes-a-high", "gemini-veo-3", "claude-x")
|
||||
c.sirala_codex(models, extras, (), {"gizli": ["ide/claude-x"], "one_al": ["ide/agnes-a-high", "ide/gemini-veo-3"]})
|
||||
slugs = [m["slug"] for m in models]
|
||||
self.assertNotIn("ide/claude-x", slugs)
|
||||
self.assertEqual(slugs[2:4], ["ide/agnes-a-high", "ide/gemini-veo-3"])
|
||||
|
||||
def test_menu_limit_counts_the_subscription_models_first(self):
|
||||
slugs = ["model-%03d" % i for i in range(120)]
|
||||
models, extras = liste(*slugs)
|
||||
sonuc = c.sirala_codex(models, extras, (), {"one_al": ["ide/model-119"]})
|
||||
aday = {a["slug"]: a for a in sonuc["adaylar"]}
|
||||
self.assertTrue(aday["ide/model-119"]["menude"]) # one alindi: 3. sirada
|
||||
self.assertEqual(aday["ide/model-119"]["sira"], 2)
|
||||
menude = [a for a in sonuc["adaylar"] if a["menude"]]
|
||||
self.assertEqual(len(menude), 100 - 2) # 2 ozgun + 98 ek model = 100
|
||||
self.assertFalse(aday["ide/model-118"]["menude"])
|
||||
|
||||
def test_broken_setting_falls_back_to_defaults(self):
|
||||
self.assertEqual(c.liste_ayari("bozuk"), {"medya_gizle": True, "efor_gizle": True, "one_al": [], "gizli": []})
|
||||
self.assertEqual(c.liste_ayari({"one_al": ["a", "a", 3, " "], "gizli": "x"})["one_al"], ["a"])
|
||||
|
||||
def test_media_detection_does_not_catch_chat_models(self):
|
||||
for sohbet in ("ide/claude-claude-opus-5-5", "ide/agnes-agnes-2.5-flash", "ide/gemini-gemini-3.8-flash",
|
||||
"ide/nanogpt-z-ai-glm-5.3-flash-uncensored", "ide/bekci-sesli-bekci", "ide/Chat-Model"):
|
||||
self.assertFalse(c.medya_mi(sohbet), sohbet)
|
||||
for medya in ("ide/agnes-agnes-video-2.5", "ide/gemini-lyria-3-clip-preview", "ide/gemini-gemini-embedding-001",
|
||||
"ide/gemini-gemini-3.5-transcribe", "ide/gemini-nano-banana-pro-preview", "ide/agnes-agnes-image-2.5-flash"):
|
||||
self.assertTrue(c.medya_mi(medya), medya)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -27,7 +27,7 @@ class ComboNotResent(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.upstream = Mock()
|
||||
self.env = {
|
||||
"json": json, "re": __import__("re"), "os": __import__("os"), "http": __import__("http.client"),
|
||||
"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),
|
||||
|
||||
@@ -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()
|
||||
@@ -12,7 +12,7 @@ import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import ide_catalog
|
||||
import is_hatti
|
||||
@@ -164,7 +164,7 @@ class Ozet(unittest.TestCase):
|
||||
|
||||
class Akis(unittest.TestCase):
|
||||
env = build(top("fix_tool_ids", "SseRewriter", "phase_damgala", "asistan_mesaji_mi", "ID_PREFIX", "ARAC_OGELERI"),
|
||||
{"json": json, "re": __import__("re"), "time": time, "log": lambda m: None, "pencere": pencere})
|
||||
{"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: ")]
|
||||
@@ -256,7 +256,7 @@ class Zincir(unittest.TestCase):
|
||||
"claude_sonu_duzelt", "ek_model_mu", "yonlendirilen")
|
||||
+ [method("_responses"), method("_model_komutu"), method("_ozgun")], {
|
||||
"gecit_modelleri": lambda: [],
|
||||
"json": json, "re": __import__("re"), "os": __import__("os"), "copy": copy, "http": __import__("http.client"),
|
||||
"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),
|
||||
@@ -364,6 +364,47 @@ class Zincir(unittest.TestCase):
|
||||
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"))
|
||||
@@ -413,9 +454,10 @@ class Govde(unittest.TestCase):
|
||||
|
||||
class Liste(unittest.TestCase):
|
||||
def env(self, **extra):
|
||||
env = {"json": json, "copy": copy, "hashlib": hashlib, "time": time, "log": lambda m: None, "CFG": {},
|
||||
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(),
|
||||
"liste_ayari": lambda: {}, "aday_kaydet": 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()),
|
||||
|
||||
@@ -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()
|
||||
@@ -50,6 +50,12 @@ class Yapilandirma(unittest.TestCase):
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -11,24 +11,41 @@ class Hat(unittest.TestCase):
|
||||
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({"secim": "buyuk"}, zamanla=""))
|
||||
self.assertIsNone(h.talimat({"secim": "buyuk", "komut": " "}, zamanla="z"))
|
||||
self.assertIsNone(h.talimat({"secim": "kapali", "komut": "x"}, "C:/a.exe", zamanla="z"))
|
||||
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"})
|
||||
text = h.talimat({"secim": "buyuk", "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")
|
||||
text = h.talimat({"secim": "buyuk"}, "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", ""))
|
||||
self.assertIn('"C:/Program/aiturk.exe" is --dosya', h.talimat({"talimat": h.ESKI}, "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"))
|
||||
self.assertIn("elle", h.talimat({"secim": "buyuk", "komut": "elle"}, "C:/Program/aiturk.exe"))
|
||||
|
||||
def test_default_sends_repeating_work_to_aiturk_and_delegates_the_rest(self):
|
||||
z = h.zamanla_satiri({"HERMES_HOME": "C:/ide/agent"})
|
||||
self.assertIn("cron create", z)
|
||||
self.assertIn("$env:HERMES_HOME='C:/ide/agent'", z)
|
||||
self.assertEqual(h.zamanla_satiri({}), "")
|
||||
for ayar in (None, {}, {"secim": "onerilen"}, {"secim": "bilinmeyen"}):
|
||||
text = h.talimat(ayar, "C:/a.exe", "s1", zamanla="")
|
||||
self.assertIn('"C:/a.exe" is --sohbet s1', text)
|
||||
self.assertIn("Kodlama ve diğer işleri AITURK'e verme", text)
|
||||
self.assertIn("alt ajan", text)
|
||||
self.assertNotIn("kendin yap", text)
|
||||
self.assertNotIn("{", text)
|
||||
self.assertIsNone(h.talimat(None, None, zamanla=z))
|
||||
self.assertEqual(h.talimat({"secim": "ozel", "talimat": "kendi kuralim"}, zamanla=""), "kendi kuralim")
|
||||
self.assertIn("ozel z", h.talimat({"secim": "ozel", "talimat": "ozel {zamanla}"}, zamanla="z"))
|
||||
self.assertIn("alt ajan", h.talimat({"secim": "ozel", "talimat": " "}, "C:/a.exe"))
|
||||
|
||||
def test_the_first_candidate_that_knows_the_command_is_used(self):
|
||||
import os
|
||||
@@ -58,7 +75,7 @@ class Hat(unittest.TestCase):
|
||||
|
||||
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"})
|
||||
text = h.talimat({"secim": "buyuk", "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))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Layer check (11 Oct 2026): Jev passes or blocks each step of Katman-1..3; a blocked step is redone by the next
|
||||
layer. Katman-4 is never asked. No exceptions."""
|
||||
layer. The last layer is never asked. No exceptions.
|
||||
|
||||
14 Oct 2026 defaults: Katman-1 -> Katman-2 -> Katman-3; Katman-3, Chat-Model and bekci-sesli-bekci always pass."""
|
||||
import ast
|
||||
import io
|
||||
import json
|
||||
@@ -60,18 +62,19 @@ class Karar(unittest.TestCase):
|
||||
|
||||
class Katman(unittest.TestCase):
|
||||
def test_a_non_layer_model_falls_through_every_layer_from_the_top(self):
|
||||
self.assertEqual(jev_denetim.sonrakiler("Uncensored-Uygulama"),
|
||||
["Katman-1", "Katman-2", "Katman-3", "Katman-4"])
|
||||
self.assertEqual(jev_denetim.sonrakiler("ide/some-model"),
|
||||
["Katman-1", "Katman-2", "Katman-3", "Katman-4"])
|
||||
self.assertEqual(jev_denetim.sonrakiler("Uncensored-Uygulama"), ["Katman-1", "Katman-2", "Katman-3"])
|
||||
self.assertEqual(jev_denetim.sonrakiler("ide/some-model"), ["Katman-1", "Katman-2", "Katman-3"])
|
||||
|
||||
def test_a_selected_layer_only_tries_the_ones_after_it(self):
|
||||
self.assertEqual(jev_denetim.sonrakiler("Katman-1"), ["Katman-2", "Katman-3", "Katman-4"])
|
||||
self.assertEqual(jev_denetim.sonrakiler("Katman-2"), ["Katman-3", "Katman-4"])
|
||||
self.assertEqual(jev_denetim.sonrakiler("Katman-3"), ["Katman-4"])
|
||||
self.assertEqual(jev_denetim.sonrakiler("Katman-1"), ["Katman-2", "Katman-3"])
|
||||
self.assertEqual(jev_denetim.sonrakiler("Katman-2"), ["Katman-3"])
|
||||
# The always-pass last layer only uses the general fallback on transport failure; no revisits.
|
||||
self.assertEqual(jev_denetim.sonrakiler("Katman-3"), ["Katman-1", "Katman-2"])
|
||||
|
||||
def test_last_layer_is_never_asked(self):
|
||||
self.assertEqual(jev_denetim.sonrakiler("Katman-4"), [])
|
||||
self.assertTrue(jev_denetim.muaf_mi("Katman-3"))
|
||||
self.assertTrue(jev_denetim.muaf_mi("Chat-Model"))
|
||||
self.assertFalse(jev_denetim.muaf_mi("Katman-4"))
|
||||
self.assertEqual(jev_denetim.sonrakiler(None), [])
|
||||
|
||||
|
||||
@@ -128,6 +131,8 @@ class Akis(unittest.TestCase):
|
||||
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")
|
||||
@@ -137,7 +142,7 @@ class Akis(unittest.TestCase):
|
||||
self.h.send_response = Mock()
|
||||
self.h.send_header = Mock()
|
||||
self.h.end_headers = Mock()
|
||||
self.cevap = {"Katman-1": sse("bir"), "Katman-2": sse("iki"), "Katman-3": sse("uc"), "Katman-4": sse("dort")}
|
||||
self.cevap = {"Katman-1": sse("bir"), "Katman-2": sse("iki"), "Katman-3": sse("uc")}
|
||||
|
||||
def sor(self, istek, metin, key):
|
||||
self.asked.append(metin)
|
||||
@@ -160,7 +165,7 @@ class Akis(unittest.TestCase):
|
||||
raw = rest[n + 2:]
|
||||
return out
|
||||
|
||||
def calistir(self, body, sira=("Katman-2", "Katman-3", "Katman-4")):
|
||||
def calistir(self, body, sira=("Katman-2", "Katman-3")):
|
||||
self.h._denetimli_akis(Yanit(body), "[1]", None, None, "yap", list(sira), self.ac)
|
||||
|
||||
def test_a_passed_step_is_delivered_unchanged(self):
|
||||
@@ -170,9 +175,36 @@ class Akis(unittest.TestCase):
|
||||
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="Chat-Model")
|
||||
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="Chat-Model")
|
||||
self.assertEqual(self.opened, ["Katman-1", "Katman-2"])
|
||||
self.assertEqual(self.asked, ["bir", "iki"])
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-2"])
|
||||
|
||||
def test_voice_lookalikes_are_still_checked(self):
|
||||
for target in ("chat-model", "Chat-Model-v2", "ide/Chat-Model", "Chat-Model ", "Other-Combo"):
|
||||
with self.subTest(target=target):
|
||||
self.setUp()
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}]
|
||||
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.calistir(sse("Bunu yapamam."), sira=("Katman-1", "Katman-2", "Katman-3"))
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-1"])
|
||||
self.assertEqual(self.opened, ["Katman-1"])
|
||||
self.assertEqual(self.asked, ["Bunu yapamam.", "bir"])
|
||||
@@ -186,17 +218,17 @@ class Akis(unittest.TestCase):
|
||||
self.assertEqual(self.asked, ["Bunu yapamam.", "iki"])
|
||||
self.h.send_response.assert_called_once()
|
||||
|
||||
def test_every_layer_blocked_ends_at_layer_4_unasked(self):
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}] * 3
|
||||
def test_every_layer_blocked_ends_at_layer_3_unasked(self):
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}] * 2
|
||||
self.calistir(sse("hayir"))
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-4"])
|
||||
self.assertEqual(self.opened, ["Katman-2", "Katman-3", "Katman-4"])
|
||||
self.assertEqual(self.asked, ["hayir", "iki", "uc"]) # layer 4 is never asked
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-3"])
|
||||
self.assertEqual(self.opened, ["Katman-2", "Katman-3"])
|
||||
self.assertEqual(self.asked, ["hayir", "iki"]) # the always-pass last layer is never asked
|
||||
|
||||
def test_blocked_layer_3_goes_straight_to_layer_4(self):
|
||||
def test_blocked_layer_2_goes_straight_to_layer_3(self):
|
||||
self.puanlar = [{"ret": 0.0, "sart": 0.8}]
|
||||
self.calistir(sse("kendi sartimla"), sira=("Katman-4",))
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-4"])
|
||||
self.calistir(sse("kendi sartimla"), sira=("Katman-3",))
|
||||
self.assertEqual(self.sent(), self.cevap["Katman-3"])
|
||||
self.assertEqual(self.asked, ["kendi sartimla"])
|
||||
|
||||
def test_a_layer_that_cannot_be_opened_is_skipped(self):
|
||||
@@ -299,13 +331,13 @@ class Akis(unittest.TestCase):
|
||||
|
||||
def test_when_no_layer_opens_the_blocked_answer_is_not_delivered(self):
|
||||
self.puanlar = [{"ret": 0.9, "sart": 0.0}]
|
||||
self.cevap.update({"Katman-2": None, "Katman-3": None, "Katman-4": None})
|
||||
self.cevap.update({"Katman-2": None, "Katman-3": None})
|
||||
self.calistir(sse("Bunu yapamam."))
|
||||
self.assertEqual(self.opened, ["Katman-2", "Katman-3", "Katman-4"])
|
||||
self.assertEqual(self.opened, ["Katman-2", "Katman-3"])
|
||||
self.assertNotIn(b"yapamam", self.h.wfile.getvalue())
|
||||
self.assertEqual(self.sent(), b"") # the last layer said nothing: nothing is added
|
||||
self.assertIn(b'response.failed', self.sent())
|
||||
|
||||
def test_the_last_layers_own_error_body_is_delivered_untouched(self):
|
||||
def test_last_layer_http_error_terminates_sse_without_leaking_raw_body(self):
|
||||
class Hata(Exception):
|
||||
def read(self):
|
||||
return b'{"error": {"message": "katman 4 hatasi"}}'
|
||||
@@ -314,7 +346,14 @@ class Akis(unittest.TestCase):
|
||||
def ac(ad):
|
||||
raise Hata("HTTP 400")
|
||||
self.h._denetimli_akis(Yanit(sse("Bunu yapamam.")), "[1]", None, None, "yap", ["Katman-4"], ac)
|
||||
self.assertEqual(self.sent(), b'{"error": {"message": "katman 4 hatasi"}}')
|
||||
raw = self.sent()
|
||||
self.assertTrue(raw.startswith(b"event: response.failed\ndata: "))
|
||||
event = json.loads(raw.split(b"data: ", 1)[1])
|
||||
self.assertEqual(event["response"]["status"], "failed")
|
||||
self.assertEqual(event["response"]["error"]["code"], "jev_no_fallback")
|
||||
self.assertEqual(event["response"]["output"], [])
|
||||
self.assertNotIn(b"katman 4 hatasi", raw)
|
||||
self.assertNotIn(b"Bunu yapamam", raw)
|
||||
|
||||
def test_unreachable_jev_keeps_the_answer(self):
|
||||
self.puanlar = [None]
|
||||
|
||||
@@ -0,0 +1,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,550 @@
|
||||
"""Decision stages (14 Oct 2026): settings schema 2, turn-start decisions, layer window filter, finish check
|
||||
and Dify rules. Every new stage is off by default; the tests turn them on explicitly."""
|
||||
import ast
|
||||
import copy
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import threading
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import jev_denetim
|
||||
import jev_settings
|
||||
import karar_politikasi
|
||||
import model_gecis
|
||||
import pencere
|
||||
import yarim
|
||||
from test_jev_denetim import Yanit, method, sse, user
|
||||
|
||||
SOURCE = Path(__file__).with_name("kopru.py").read_text(encoding="utf-8")
|
||||
TREE = ast.parse(SOURCE)
|
||||
|
||||
|
||||
def fonksiyon(name):
|
||||
return next(n for n in TREE.body if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
|
||||
|
||||
class Ev(unittest.TestCase):
|
||||
"""A temporary bridge home with a settings file."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.home = Path(self.tmp.name)
|
||||
p = patch.object(jev_settings, 'home', return_value=self.home)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
self.settings = copy.deepcopy(jev_settings.DEFAULTS)
|
||||
|
||||
def save(self, settings=None, **extra):
|
||||
data = {'settings': settings if settings is not None else self.settings}
|
||||
data.update(extra)
|
||||
(self.home / 'jev-settings.json').write_text(json.dumps(data), encoding='utf-8')
|
||||
|
||||
|
||||
class Ayarlar(Ev):
|
||||
def test_a_schema_1_file_gets_every_new_stage_disabled(self):
|
||||
eski = {k: v for k, v in self.settings.items() if k not in jev_settings.SECTIONS}
|
||||
self.save(eski)
|
||||
s, _ = jev_settings.read()
|
||||
for bolum in ('prefilter', 'autoRoute', 'finishCheck', 'verifierCheck'):
|
||||
self.assertFalse(s[bolum]['enabled'], bolum)
|
||||
self.assertEqual(s['policy']['source'], 'local')
|
||||
self.assertEqual(s['watchModels'], jev_settings.DEFAULTS['watchModels'])
|
||||
|
||||
def test_a_partial_section_keeps_written_values_and_fills_the_rest(self):
|
||||
self.settings['prefilter'] = {'enabled': True, 'threshold': 0.8}
|
||||
self.save()
|
||||
p = jev_settings.read()[0]['prefilter']
|
||||
self.assertEqual((p['enabled'], p['threshold']), (True, 0.8))
|
||||
self.assertEqual(p['target'], jev_settings.DEFAULTS['prefilter']['target'])
|
||||
|
||||
def test_bad_values_are_rejected(self):
|
||||
for bolum, alan, deger in (('prefilter', 'threshold', 1.5), ('prefilter', 'timeoutSeconds', 0),
|
||||
('autoRoute', 'model', ''), ('finishCheck', 'note', ''),
|
||||
('policy', 'endpoint', 'http://dify.example'), ('policy', 'source', 'web')):
|
||||
with self.subTest(bolum=bolum, alan=alan):
|
||||
s = copy.deepcopy(self.settings)
|
||||
s[bolum][alan] = deger
|
||||
self.save(s)
|
||||
with self.assertRaises(ValueError):
|
||||
jev_settings.read()
|
||||
|
||||
def test_automatic_model_cannot_target_itself(self):
|
||||
self.settings['autoRoute']['targets']['zor'] = 'Otomatik'
|
||||
self.save()
|
||||
with self.assertRaises(ValueError):
|
||||
jev_settings.read()
|
||||
|
||||
def test_watch_channel_is_an_exact_name_from_settings(self):
|
||||
self.settings['watchModels'] = ['Chat-Model']
|
||||
self.save()
|
||||
self.assertTrue(jev_denetim.izleme_mi('Chat-Model'))
|
||||
for ad in ('chat-model', 'Chat-Model ', 'ide/Chat-Model', 'Sesli-Hizli-Zeki'):
|
||||
self.assertFalse(jev_denetim.izleme_mi(ad), ad)
|
||||
|
||||
|
||||
class Politika(Ev):
|
||||
def test_dify_rules_apply_only_when_the_source_is_dify_and_they_are_valid(self):
|
||||
(self.home / jev_settings.POLICY_FILE).write_text(json.dumps(
|
||||
{'ok': True, 'overrides': {'prefilter': {'enabled': True, 'target': 'Katman-3'}}}), encoding='utf-8')
|
||||
self.save()
|
||||
self.assertFalse(jev_settings.read()[0]['prefilter']['enabled']) # source local
|
||||
self.settings['policy']['source'] = 'dify'
|
||||
self.save()
|
||||
p = jev_settings.read()[0]['prefilter']
|
||||
self.assertEqual((p['enabled'], p['target']), (True, 'Katman-3'))
|
||||
self.assertFalse(jev_settings.read(overlay=False)[0]['prefilter']['enabled'])
|
||||
(self.home / jev_settings.POLICY_FILE).write_text(json.dumps(
|
||||
{'ok': True, 'overrides': {'prefilter': {'threshold': 7}}}), encoding='utf-8')
|
||||
self.assertFalse(jev_settings.read()[0]['prefilter']['enabled']) # invalid rules: local in force
|
||||
|
||||
def test_rules_cannot_change_the_chain_or_unknown_sections(self):
|
||||
s = jev_settings.read(overlay=False)[0]
|
||||
with self.assertRaises(ValueError):
|
||||
karar_politikasi.ayikla({'exemptModels': {'x': 1}}, s)
|
||||
with self.assertRaises(ValueError):
|
||||
karar_politikasi.ayikla({'autoRoute': {'targets': {'zor': 'Otomatik'}}}, s)
|
||||
with self.assertRaises(ValueError):
|
||||
karar_politikasi.ayikla('[1, 2]', s)
|
||||
self.assertEqual(karar_politikasi.ayikla('{"autoRoute": {"enabled": true}}', s), {'autoRoute': {'enabled': True}})
|
||||
|
||||
def test_fetch_reads_the_kurallar_output_and_never_raises(self):
|
||||
s = jev_settings.read(overlay=False)[0]
|
||||
|
||||
def yanit(govde):
|
||||
r = Mock()
|
||||
r.__enter__ = lambda self_: self_
|
||||
r.__exit__ = lambda *a: False
|
||||
r.read = lambda n=-1: json.dumps(govde).encode()
|
||||
return r
|
||||
istekler = []
|
||||
|
||||
def urlopen(req, timeout):
|
||||
istekler.append(req)
|
||||
return yanit({'data': {'outputs': {'kurallar': {'finishCheck': {'enabled': True}}}}})
|
||||
sonuc = karar_politikasi.cek(s, 'app-key', urlopen=urlopen)
|
||||
self.assertEqual(sonuc, {'ok': True, 'overrides': {'finishCheck': {'enabled': True}}})
|
||||
self.assertTrue(istekler[0].full_url.endswith('/workflows/run'))
|
||||
self.assertEqual(istekler[0].get_header('Authorization'), 'Bearer app-key')
|
||||
self.assertFalse(karar_politikasi.cek(s, '', urlopen=urlopen)['ok'])
|
||||
self.assertFalse(karar_politikasi.cek(s, 'k', urlopen=Mock(side_effect=OSError('kapali')))['ok'])
|
||||
bos = karar_politikasi.cek(s, 'k', urlopen=lambda req, timeout: yanit({'data': {'outputs': {}}}))
|
||||
self.assertEqual(bos['ok'], False)
|
||||
|
||||
def test_a_failed_fetch_keeps_the_last_good_rules(self):
|
||||
jev_settings.write_policy({'ok': True, 'overrides': {'finishCheck': {'enabled': True}}})
|
||||
kayit = jev_settings.write_policy({'ok': False, 'error': 'Dify kapali'})
|
||||
self.assertTrue(kayit['ok'])
|
||||
self.assertEqual(kayit['overrides'], {'finishCheck': {'enabled': True}})
|
||||
self.assertEqual(kayit['error'], 'Dify kapali')
|
||||
self.settings['policy']['source'] = 'dify'
|
||||
self.save()
|
||||
self.assertTrue(jev_settings.read()[0]['finishCheck']['enabled'])
|
||||
|
||||
|
||||
class OnKarar(Ev):
|
||||
def gonder(self, cevap):
|
||||
self.govdeler = []
|
||||
|
||||
def f(govde, key):
|
||||
self.govdeler.append(govde)
|
||||
if isinstance(cevap, Exception):
|
||||
raise cevap
|
||||
return {'answers': {k: {'noul': v} for k, v in cevap.items()}}
|
||||
return f
|
||||
|
||||
def test_automatic_routing_picks_research_then_hard_then_simple(self):
|
||||
self.settings['autoRoute']['enabled'] = True
|
||||
self.save()
|
||||
t = self.settings['autoRoute']['targets']
|
||||
for puan, hedef in (({'zor': 0.9, 'arastirma': 0.8}, t['arastirma']), ({'zor': 0.9, 'arastirma': 0.1}, t['zor']),
|
||||
({'zor': 0.1, 'arastirma': 0.1}, t['basit'])):
|
||||
sonuc = jev_denetim.on_karar('istek', True, False, 'k', gonder=self.gonder(puan))
|
||||
self.assertEqual(sonuc['hedef'], hedef)
|
||||
self.assertEqual(set(self.govdeler[0]['questions']), {'zor', 'arastirma'})
|
||||
self.assertNotIn('assistant_reply', self.govdeler[0]['state'])
|
||||
|
||||
def test_no_decision_falls_to_the_strong_target(self):
|
||||
self.settings['autoRoute']['enabled'] = True
|
||||
self.save()
|
||||
sonuc = jev_denetim.on_karar('istek', True, False, 'k', gonder=self.gonder(OSError('yok')))
|
||||
self.assertEqual((sonuc['hedef'], sonuc['ret']), (self.settings['autoRoute']['targets']['zor'], None))
|
||||
|
||||
def test_refusal_question_goes_in_the_same_call(self):
|
||||
self.settings['autoRoute']['enabled'] = True
|
||||
self.settings['prefilter']['enabled'] = True
|
||||
self.save()
|
||||
sonuc = jev_denetim.on_karar('istek', True, True, 'k', gonder=self.gonder({'zor': 0.1, 'arastirma': 0.1, 'ret': 0.9}))
|
||||
self.assertEqual(len(self.govdeler), 1)
|
||||
self.assertEqual(set(self.govdeler[0]['questions']), {'zor', 'arastirma', 'ret'})
|
||||
self.assertTrue(sonuc['ret'])
|
||||
|
||||
def test_a_missing_answer_is_no_decision(self):
|
||||
self.settings['prefilter']['enabled'] = True
|
||||
self.save()
|
||||
sonuc = jev_denetim.on_karar('istek', False, True, 'k', gonder=self.gonder({}))
|
||||
self.assertIsNone(sonuc['ret'])
|
||||
|
||||
|
||||
class TurBasi(Ev):
|
||||
"""kopru.karar_asamasi through its own source."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.kararlar = []
|
||||
self.logs = []
|
||||
sahte_denetim = Mock(wraps=jev_denetim)
|
||||
sahte_denetim.anahtar = lambda: 'k'
|
||||
sahte_denetim.on_karar = self.on_karar
|
||||
sahte_denetim.muaf_mi = jev_denetim.muaf_mi
|
||||
sahte_denetim.sonrakiler = jev_denetim.sonrakiler
|
||||
sahte_denetim.son_istek = jev_denetim.son_istek
|
||||
self.cevap = {'hedef': None, 'ret': None, 'puan': None}
|
||||
sahte_denetim.son_istek_sirasi = jev_denetim.son_istek_sirasi
|
||||
sahte_denetim.istek_metni = jev_denetim.istek_metni
|
||||
self.env = {'IDE_PREFIX': 'ide/', 'ON_KARAR': {}, 'ON_KARAR_SINIR': 3, 'jev_settings': jev_settings,
|
||||
'jev_denetim': sahte_denetim, 'model_gecis': model_gecis, 'hashlib': hashlib, 'time': time,
|
||||
'json': json, 'KARAR_KILIDI': threading.Lock(), 'BITIS_TURLARI': {}, 'log': self.logs.append}
|
||||
govde = [fonksiyon(a) for a in ('tur_bilgisi', 'karar_asamasi', 'bitis_kullan')]
|
||||
exec(compile(ast.Module(body=govde, type_ignores=[]), 'kopru.py', 'exec'), self.env)
|
||||
self.karar = self.env['karar_asamasi']
|
||||
|
||||
def on_karar(self, istek, oto, ret, key):
|
||||
self.kararlar.append((istek, oto, ret))
|
||||
return dict(self.cevap)
|
||||
|
||||
def istek(self, model, *girdi):
|
||||
return {'model': model, 'prompt_cache_key': 'sohbet-1', 'input': list(girdi)}
|
||||
|
||||
def test_everything_off_changes_nothing_and_asks_nothing(self):
|
||||
self.save()
|
||||
for model in ('ide/Ajan', 'ide/codex-gpt-6-astra', 'ide/Chat-Model'):
|
||||
req = self.istek(model, user('yap'))
|
||||
self.assertEqual(self.karar(1, req, 'sohbet-1', model), model)
|
||||
self.assertEqual(req['model'], model)
|
||||
self.assertEqual(self.kararlar, [])
|
||||
|
||||
def test_automatic_choice_is_kept_for_the_tool_steps_of_the_same_turn(self):
|
||||
self.settings['autoRoute']['enabled'] = True
|
||||
self.save()
|
||||
self.cevap = {'hedef': 'Arastirma', 'ret': None, 'puan': {'zor': 0.2, 'arastirma': 0.9}}
|
||||
req = self.istek('ide/Otomatik', user('kaynaklari karsilastir'))
|
||||
self.assertEqual(self.karar(1, req, 'sohbet-1', 'ide/Otomatik'), 'ide/Arastirma')
|
||||
self.assertEqual(req['model'], 'ide/Arastirma')
|
||||
arac = {'type': 'function_call_output', 'call_id': 'c1', 'output': 'ok'}
|
||||
req2 = self.istek('ide/Otomatik', user('kaynaklari karsilastir'), {'type': 'function_call', 'call_id': 'c1'}, arac)
|
||||
self.assertEqual(self.karar(2, req2, 'sohbet-1', 'ide/Otomatik'), 'ide/Arastirma')
|
||||
self.assertEqual(len(self.kararlar), 1) # same turn: no second question
|
||||
son_cevap = {'type': 'message', 'role': 'assistant', 'content': [{'type': 'output_text', 'text': 'Rapor hazir.'}]}
|
||||
req3 = self.istek('ide/Otomatik', user('kaynaklari karsilastir'), arac, son_cevap, user('simdi kodu duzelt'))
|
||||
self.cevap = {'hedef': 'Ajan', 'ret': None, 'puan': {'zor': 0.9, 'arastirma': 0.1}}
|
||||
self.assertEqual(self.karar(3, req3, 'sohbet-1', 'ide/Otomatik'), 'ide/Ajan')
|
||||
self.assertEqual(len(self.kararlar), 2) # new user message: new decision
|
||||
|
||||
def test_predicted_refusal_starts_at_the_configured_layer(self):
|
||||
self.settings['prefilter']['enabled'] = True
|
||||
self.save()
|
||||
self.cevap = {'hedef': None, 'ret': True, 'puan': {'ret': 0.9}}
|
||||
req = self.istek('ide/codex-gpt-6-astra', user('sert bir roast yaz'))
|
||||
self.assertEqual(self.karar(1, req, 'sohbet-1', 'ide/codex-gpt-6-astra'), 'ide/Katman-2')
|
||||
self.assertEqual(self.kararlar, [('sert bir roast yaz', False, True)])
|
||||
|
||||
def test_exempt_and_later_layers_are_not_asked(self):
|
||||
self.settings['prefilter']['enabled'] = True
|
||||
self.save()
|
||||
for model in ('ide/Chat-Model', 'ide/Katman-2', 'ide/Katman-3', 'codex/gpt-6-astra'):
|
||||
req = self.istek(model, user('yap'))
|
||||
self.assertEqual(self.karar(1, req, 'sohbet-1', model), model)
|
||||
self.assertEqual(self.kararlar, [])
|
||||
|
||||
def test_no_refusal_or_no_decision_keeps_the_model(self):
|
||||
self.settings['prefilter']['enabled'] = True
|
||||
self.save()
|
||||
for cevap in ({'hedef': None, 'ret': False, 'puan': {'ret': 0.1}}, {'hedef': None, 'ret': None, 'puan': None}):
|
||||
self.env['ON_KARAR'].clear()
|
||||
self.cevap = cevap
|
||||
req = self.istek('ide/Ajan', user('yap'))
|
||||
self.assertEqual(self.karar(1, req, 'sohbet-1', 'ide/Ajan'), 'ide/Ajan')
|
||||
self.assertEqual(req['model'], 'ide/Ajan')
|
||||
|
||||
def test_master_switch_off_disables_every_stage(self):
|
||||
self.settings['enabled'] = False
|
||||
self.settings['prefilter']['enabled'] = True
|
||||
self.settings['autoRoute']['enabled'] = True
|
||||
self.save()
|
||||
req = self.istek('ide/Otomatik', user('yap'))
|
||||
# The virtual name never leaves the bridge; nothing is asked while the main switch is off.
|
||||
self.assertEqual(self.karar(1, req, 'sohbet-1', 'ide/Otomatik'), 'ide/' + self.settings['autoRoute']['targets']['zor'])
|
||||
self.assertEqual(self.kararlar, [])
|
||||
|
||||
def test_a_message_typed_mid_turn_or_a_dot_keeps_the_choice(self):
|
||||
self.settings['autoRoute']['enabled'] = True
|
||||
self.save()
|
||||
self.cevap = {'hedef': 'Arastirma', 'ret': None, 'puan': {'zor': 0.2, 'arastirma': 0.9}}
|
||||
bas = user('kaynaklari karsilastir')
|
||||
cagri = {'type': 'function_call', 'call_id': 'c1', 'name': 'shell', 'arguments': '{}'}
|
||||
cikti = {'type': 'function_call_output', 'call_id': 'c1', 'output': 'ok'}
|
||||
self.assertEqual(self.karar(1, self.istek('ide/Otomatik', bas), 'sohbet-1', 'ide/Otomatik'), 'ide/Arastirma')
|
||||
self.cevap = {'hedef': 'Workflow-Kesif', 'ret': None, 'puan': {'zor': 0.1, 'arastirma': 0.1}}
|
||||
for ek in (user('.'), user('bir de sunu ekle'), {'type': 'compaction_trigger'}):
|
||||
req = self.istek('ide/Otomatik', bas, cagri, cikti, ek)
|
||||
self.assertEqual(self.karar(2, req, 'sohbet-1', 'ide/Otomatik'), 'ide/Arastirma', ek)
|
||||
self.assertEqual(len(self.kararlar), 1)
|
||||
|
||||
def test_a_disabled_automatic_model_never_reaches_the_gateway(self):
|
||||
self.save() # autoRoute off
|
||||
req = self.istek('ide/Otomatik', user('yap'))
|
||||
secilen = self.karar(1, req, 'sohbet-1', 'ide/Otomatik')
|
||||
self.assertEqual(secilen, 'ide/' + self.settings['autoRoute']['targets']['zor'])
|
||||
self.assertEqual(req['model'], secilen)
|
||||
self.assertEqual(self.kararlar, [])
|
||||
self.settings['autoRoute']['enabled'] = True
|
||||
self.settings['enabled'] = False # main switch off
|
||||
self.save()
|
||||
self.assertNotEqual(self.karar(2, self.istek('ide/Otomatik', user('yap')), 'sohbet-1', 'ide/Otomatik'), 'ide/Otomatik')
|
||||
|
||||
def test_without_a_conversation_key_the_tool_steps_still_keep_the_choice(self):
|
||||
self.settings['autoRoute']['enabled'] = True
|
||||
self.save()
|
||||
self.cevap = {'hedef': 'Ajan', 'ret': None, 'puan': {'zor': 0.9, 'arastirma': 0.1}}
|
||||
bas = user('kodu duzelt')
|
||||
self.assertEqual(self.karar(1, {'model': 'ide/Otomatik', 'input': [bas]}, '', 'ide/Otomatik'), 'ide/Ajan')
|
||||
cikti = {'type': 'function_call_output', 'call_id': 'c1', 'output': 'ok'}
|
||||
req = {'model': 'ide/Otomatik', 'input': [bas, {'type': 'function_call', 'call_id': 'c1'}, cikti]}
|
||||
self.assertEqual(self.karar(2, req, '', 'ide/Otomatik'), 'ide/Ajan')
|
||||
self.assertEqual(len(self.kararlar), 1)
|
||||
|
||||
def test_the_finish_retry_is_used_once_per_turn(self):
|
||||
self.assertTrue(self.env['bitis_kullan']('tur-1'))
|
||||
self.assertFalse(self.env['bitis_kullan']('tur-1'))
|
||||
self.assertTrue(self.env['bitis_kullan']('tur-2'))
|
||||
|
||||
def test_turn_memory_is_bounded(self):
|
||||
self.settings['prefilter']['enabled'] = True
|
||||
self.save()
|
||||
self.cevap = {'hedef': None, 'ret': False, 'puan': {'ret': 0.1}}
|
||||
for i in range(6):
|
||||
self.karar(i, {'model': 'ide/Ajan', 'prompt_cache_key': f's{i}', 'input': [user('yap')]}, f's{i}', 'ide/Ajan')
|
||||
self.assertLessEqual(len(self.env['ON_KARAR']), 3)
|
||||
|
||||
|
||||
class KatmanPenceresi(unittest.TestCase):
|
||||
def suzgec(self, bilgi):
|
||||
logs = []
|
||||
env = {'IDE_PREFIX': 'ide/', 'IDE_BILGI': bilgi, 'pencere': pencere, 'log': logs.append}
|
||||
exec(compile(ast.Module(body=[fonksiyon('sigan_katmanlar')], type_ignores=[]), 'kopru.py', 'exec'), env)
|
||||
return env['sigan_katmanlar'], logs
|
||||
|
||||
def test_a_layer_too_small_for_the_request_is_skipped(self):
|
||||
f, logs = self.suzgec({'ide/Katman-2': {'pencere': 1000, 'kaynak': 'katalog'}, 'ide/Katman-3': {'pencere': 10 ** 6}})
|
||||
req = {'input': [user('x' * 20000)]}
|
||||
self.assertEqual(f(['Katman-2', 'Katman-3', 'Bilinmeyen'], req, '[1]'), ['Katman-3', 'Bilinmeyen'])
|
||||
self.assertIn('Katman-2', logs[0])
|
||||
|
||||
def test_the_last_layer_and_untrusted_windows_are_never_skipped(self):
|
||||
req = {'input': [user('x' * 20000)]}
|
||||
f, _ = self.suzgec({'ide/Katman-2': {'pencere': 1000, 'kaynak': 'katalog'}, 'ide/Katman-3': {'pencere': 1000, 'kaynak': 'katalog'}})
|
||||
self.assertEqual(f(['Katman-2', 'Katman-3'], req, '[1]'), ['Katman-3']) # last one stays
|
||||
f, _ = self.suzgec({'ide/Katman-2': {'pencere': 1000, 'kaynak': 'varsayilan'}})
|
||||
self.assertEqual(f(['Katman-2', 'Katman-3'], req, '[1]'), ['Katman-2', 'Katman-3']) # placeholder window
|
||||
|
||||
|
||||
class BitisDenetimi(Ev):
|
||||
"""_denetimli_akis with finishCheck: an answer that stops early goes back to the same model once."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.settings['finishCheck']['enabled'] = True
|
||||
self.save()
|
||||
self.asked, self.logs, self.devamlar = [], [], []
|
||||
self.puanlar = []
|
||||
sahte = Mock()
|
||||
sahte.anahtar = lambda: 'k'
|
||||
sahte.sor = self.sor
|
||||
sahte.karar = jev_denetim.karar
|
||||
sahte.bitmedi_mi = jev_denetim.bitmedi_mi
|
||||
sahte.hazirlik_mi = jev_denetim.hazirlik_mi
|
||||
sahte.muaf_mi = jev_denetim.muaf_mi
|
||||
sahte.hata_gecis = jev_denetim.hata_gecis
|
||||
env = {'jev_denetim': sahte, 'yarim': yarim, 'time': time, 'http': __import__('http.client'),
|
||||
'log': self.logs.append, 'RESP_HOP': set(), 'json': json}
|
||||
adlar = ('_akis_basligi', '_denetimli_akis', '_stream')
|
||||
exec(compile(ast.Module(body=[method(a) for a in adlar], type_ignores=[]), 'kopru.py', 'exec'), env)
|
||||
self.h = type('H', (), {a: env[a] for a in adlar})()
|
||||
self.h.wfile = io.BytesIO()
|
||||
self.h.send_response = self.h.send_header = self.h.end_headers = Mock()
|
||||
|
||||
def sor(self, istek, metin, key, bitis=False):
|
||||
self.asked.append((metin, bitis))
|
||||
return self.puanlar.pop(0) if self.puanlar else {'ret': 0.0, 'sart': 0.0}
|
||||
|
||||
def devam(self, ad, onceki):
|
||||
self.devamlar.append((ad, onceki))
|
||||
return Yanit(sse('gercekten bitti, cikti: OK')), None, f'[{ad} devam]'
|
||||
|
||||
def calistir(self, body):
|
||||
self.h._denetimli_akis(Yanit(body), '[1]', None, None, 'yap', ['Katman-1'], Mock(), hedef='Ajan', devam=self.devam)
|
||||
return self.gonderilen()
|
||||
|
||||
def gonderilen(self):
|
||||
raw, out = self.h.wfile.getvalue(), b''
|
||||
while raw:
|
||||
size, _, rest = raw.partition(b'\r\n')
|
||||
n = int(size, 16)
|
||||
if n == 0:
|
||||
break
|
||||
out += rest[:n]
|
||||
raw = rest[n + 2:]
|
||||
return out
|
||||
|
||||
def test_an_early_stop_goes_back_once_and_the_retry_is_delivered(self):
|
||||
self.puanlar = [{'ret': 0.0, 'sart': 0.0, 'bitmedi': 0.9}, {'ret': 0.0, 'sart': 0.0, 'bitmedi': 0.9}]
|
||||
out = self.calistir(sse('Bitti.'))
|
||||
self.assertEqual(self.devamlar, [('Ajan', 'Bitti.')])
|
||||
self.assertIn(b'gercekten bitti', out)
|
||||
self.assertNotIn(b'"Bitti."', out)
|
||||
self.assertEqual([b for _, b in self.asked], [True, False]) # the retry is not asked again
|
||||
|
||||
def test_a_finished_answer_is_delivered_unchanged(self):
|
||||
self.puanlar = [{'ret': 0.0, 'sart': 0.0, 'bitmedi': 0.1}]
|
||||
out = self.calistir(sse('Tamam, test ciktisi: 3 passed.'))
|
||||
self.assertEqual(self.devamlar, [])
|
||||
self.assertIn(b'3 passed', out)
|
||||
|
||||
def test_a_refusal_still_goes_down_the_chain_not_back(self):
|
||||
self.puanlar = [{'ret': 0.9, 'sart': 0.0, 'bitmedi': 0.9}]
|
||||
ac = Mock(return_value=(Yanit(sse('katman')), None, '[K1]'))
|
||||
self.h._denetimli_akis(Yanit(sse('Yapamam.')), '[1]', None, None, 'yap', ['Katman-1'], ac, hedef='Ajan', devam=self.devam)
|
||||
self.assertEqual(self.devamlar, [])
|
||||
ac.assert_called_once_with('Katman-1')
|
||||
|
||||
def test_a_failed_retry_delivers_the_first_answer(self):
|
||||
self.puanlar = [{'ret': 0.0, 'sart': 0.0, 'bitmedi': 0.9}]
|
||||
|
||||
def bozuk(ad, onceki):
|
||||
raise OSError('acilamadi')
|
||||
self.h._denetimli_akis(Yanit(sse('Bitti.')), '[1]', None, None, 'yap', [], Mock(), hedef='Ajan', devam=bozuk)
|
||||
self.assertIn(b'Bitti.', self.gonderilen())
|
||||
|
||||
def test_a_broken_or_refused_retry_delivers_the_approved_first_answer(self):
|
||||
for ikinci in (sse(), sse('Yapamam.')):
|
||||
with self.subTest(ikinci=ikinci[:40]):
|
||||
self.setUp()
|
||||
self.puanlar = [{'ret': 0.0, 'sart': 0.0, 'bitmedi': 0.9}, {'ret': 0.9, 'sart': 0.0}]
|
||||
ac = Mock(return_value=(Yanit(sse('katman')), None, '[K1]'))
|
||||
self.h._denetimli_akis(Yanit(sse('Ilk cevap.')), '[1]', None, None, 'yap', ['Katman-1'], ac,
|
||||
hedef='Ajan', devam=lambda ad, onceki: (Yanit(ikinci), None, '[devam]'))
|
||||
self.assertIn(b'Ilk cevap.', self.gonderilen())
|
||||
ac.assert_not_called()
|
||||
|
||||
def test_without_the_stage_the_finish_question_is_not_sent(self):
|
||||
govdeler = []
|
||||
|
||||
def gonder(govde, key):
|
||||
govdeler.append(govde)
|
||||
return {'answers': {'ret': {'noul': 0.1}, 'sart': {'noul': 0.1}}}
|
||||
puan = jev_denetim.sor('i', 'c', 'k', gonder=gonder)
|
||||
self.assertNotIn('bitmedi', govdeler[0]['questions'])
|
||||
self.assertNotIn('bitmedi', puan)
|
||||
puan = jev_denetim.sor('i', 'c', 'k', gonder=gonder, bitis=True)
|
||||
self.assertIn('bitmedi', govdeler[1]['questions'])
|
||||
self.assertEqual(set(puan), {'ret', 'sart'}) # unanswered extra question: main verdict stands
|
||||
self.assertFalse(jev_denetim.bitmedi_mi(puan))
|
||||
|
||||
|
||||
|
||||
class DogrulayiciElemesi(Ev):
|
||||
def test_off_by_default_and_asked_only_when_enabled(self):
|
||||
import io as _io
|
||||
import karar_sor
|
||||
self.save()
|
||||
out = _io.StringIO()
|
||||
self.assertEqual(karar_sor.main(['dogrulayici'], _io.StringIO('{"user_request": "x", "assistant_reply": "kabul"}'), out), 0)
|
||||
self.assertEqual(json.loads(out.getvalue())['enabled'], False)
|
||||
self.settings['verifierCheck']['enabled'] = True
|
||||
self.save()
|
||||
govdeler = []
|
||||
|
||||
def gonder(govde, key):
|
||||
govdeler.append(govde)
|
||||
return {'answers': {'kanitsiz': {'noul': 0.92}}}
|
||||
sonuc = karar_sor.dogrulayici({'user_request': 'olcut', 'assistant_reply': '{"karar":"kabul","bulgular":[]}'}, 'k', gonder)
|
||||
self.assertEqual((sonuc['enabled'], sonuc['kanitsiz'], sonuc['puan']), (True, True, 0.92))
|
||||
self.assertEqual(set(govdeler[0]['questions']), {'kanitsiz'})
|
||||
bozuk = karar_sor.dogrulayici({'user_request': 'o', 'assistant_reply': 'r'}, 'k', Mock(side_effect=OSError('yok')))
|
||||
self.assertIsNone(bozuk['kanitsiz'])
|
||||
|
||||
def test_wrong_usage_exits_2(self):
|
||||
import io as _io
|
||||
import karar_sor
|
||||
self.assertEqual(karar_sor.main(['baska'], _io.StringIO('{}'), _io.StringIO()), 2)
|
||||
|
||||
|
||||
class Inceleme(Ev):
|
||||
def test_the_correction_channel_needs_an_always_pass_target_while_checks_are_on(self):
|
||||
self.settings['watchModels'] = ['Chat-Model', 'Ajan']
|
||||
self.save()
|
||||
self.assertTrue(jev_denetim.izleme_mi('Chat-Model'))
|
||||
self.assertFalse(jev_denetim.izleme_mi('Ajan')) # checked target: no sideband
|
||||
self.settings['enabled'] = False
|
||||
self.save()
|
||||
self.assertTrue(jev_denetim.izleme_mi('Ajan'))
|
||||
|
||||
def test_a_turn_start_question_does_not_wait_in_the_queue_beyond_its_timeout(self):
|
||||
self.settings['maxConcurrent'] = 1
|
||||
self.settings['queueWaitSeconds'] = 3600
|
||||
self.save()
|
||||
s = jev_settings.read()[0]
|
||||
self.assertTrue(jev_settings.acquire(s)) # the only slot is taken
|
||||
try:
|
||||
t0 = time.monotonic()
|
||||
self.assertIsNone(jev_denetim.sorular({'user_request': 'x'}, {'zor': 'q'}, 'k', 1, gonder=lambda g, k: {}))
|
||||
self.assertLess(time.monotonic() - t0, 3)
|
||||
finally:
|
||||
jev_settings.release()
|
||||
|
||||
def test_the_prefilter_target_cannot_be_the_automatic_model(self):
|
||||
self.settings['prefilter']['target'] = self.settings['autoRoute']['model']
|
||||
self.save()
|
||||
with self.assertRaises(ValueError):
|
||||
jev_settings.read()
|
||||
|
||||
def test_dify_redirects_are_refused_so_the_key_never_moves(self):
|
||||
import urllib.error
|
||||
with self.assertRaises(urllib.error.HTTPError):
|
||||
karar_politikasi._YonlendirmeYok().redirect_request(
|
||||
Mock(full_url='https://dify.example/v1/workflows/run'), None, 302, 'Found', {}, 'http://evil.example/')
|
||||
|
||||
|
||||
class IzlemeSuzgeci(unittest.TestCase):
|
||||
def test_a_huge_frame_passes_through_instead_of_breaking_the_stream(self):
|
||||
tree_sinif = next(n for n in TREE.body if isinstance(n, ast.ClassDef) and n.name == 'WatchResponse')
|
||||
from bekci_watch import MetadataFilter
|
||||
logs = []
|
||||
env = {'MetadataFilter': MetadataFilter, 'log': logs.append}
|
||||
exec(compile(ast.Module(body=[tree_sinif], type_ignores=[]), 'kopru.py', 'exec'), env)
|
||||
buyuk = b'data: ' + b'x' * 300000
|
||||
parcalar = [buyuk[:200000], buyuk[200000:], b'\n\n', b'']
|
||||
|
||||
class Ham:
|
||||
def read1(self, n):
|
||||
return parcalar.pop(0)
|
||||
w = env['WatchResponse'](Ham(), lambda m: None)
|
||||
cikti = b''
|
||||
while True:
|
||||
b = w.read1(65536)
|
||||
if not b and w.done or not parcalar and not b:
|
||||
break
|
||||
cikti += b
|
||||
self.assertIn(b'x' * 1000, cikti)
|
||||
self.assertEqual(cikti.count(b'x'), 300000)
|
||||
self.assertTrue(logs)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,18 @@
|
||||
import pencere
|
||||
|
||||
|
||||
def test_exact_combo_limits_override_unknown_and_local_defaults():
|
||||
row = {"context_length": 872000, "max_input_tokens": 872000, "max_output_tokens": 128000}
|
||||
result = pencere.coz("GPT-6-Astra", "combo", None, ilan=row)
|
||||
assert result["pencere"] == 872000
|
||||
assert result["kaynak"] == "saglayici-katalogu"
|
||||
result = pencere.coz("codex-gpt-6-astra", "model", "codex/gpt-6-astra", ilan=row)
|
||||
assert result["pencere"] == 872000
|
||||
|
||||
|
||||
def test_learned_actual_limit_still_wins_and_invalid_metadata_is_ignored():
|
||||
result = pencere.coz("combo", "combo", None, ilan={"context_length": 872000}, ogr={"combo": 500000})
|
||||
assert result["pencere"] == 500000 and result["kaynak"] == "ogrenilen"
|
||||
for value in (True, -1, "872000", 999999999):
|
||||
result = pencere.coz("combo", "combo", None, ilan={"context_length": value})
|
||||
assert result["pencere"] == pencere.VARSAYILAN
|
||||
@@ -43,7 +43,12 @@ class ModelSwitch(unittest.TestCase):
|
||||
{"type": "custom_tool_call_output", "id": "ctco_4827", "call_id": "call_4827", "output": "result-4827"},
|
||||
], "tools": [{"type": "namespace", "name": "functions", "tools": [{"type": "custom", "name": "probe"}]}]}
|
||||
req = copy.deepcopy(original)
|
||||
self.assertEqual(env["flatten_request"](req), {"probe"})
|
||||
names = {}
|
||||
custom = env["flatten_request"](req, names)
|
||||
alias = req["tools"][0]["name"]
|
||||
self.assertEqual(custom, {alias})
|
||||
self.assertEqual(names[alias], ("functions", "probe"))
|
||||
self.assertEqual(req["input"][0]["name"], alias)
|
||||
env["fix_tool_ids"](req)
|
||||
self.assertEqual(json.loads(req["input"][0]["arguments"]), {"input": "remember-4827"})
|
||||
self.assertEqual(req["input"][1]["output"], "result-4827")
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
@@ -43,11 +43,15 @@ def message(text, ident="msg_1"):
|
||||
|
||||
|
||||
class Windows(unittest.TestCase):
|
||||
def test_provider_data_local_table_and_cap(self):
|
||||
def test_current_provider_aliases_keep_their_own_capacity(self):
|
||||
self.assertEqual(pencere.coz('agy-gemini-3.8-flash-high', 'model', 'Gemini 3.8 Flash (High)')['pencere'], 1000000)
|
||||
self.assertEqual(pencere.yol_bilgisi('codex/gpt-6-astra', {})['pencere'], 272000)
|
||||
|
||||
def test_provider_data_and_local_table_preserve_large_windows(self):
|
||||
coz = pencere.coz
|
||||
# The provider that carries the request decides; nothing is declared above the summarizer's window.
|
||||
self.assertEqual(coz("9router-nano-z-ai-glm-5.3-flash-uncensored", "model", "nano/z-ai/glm-5.3-flash-uncensored",
|
||||
nano=NANO)["pencere"], pencere.TAVAN)
|
||||
nano=NANO)["pencere"], 1048576)
|
||||
small = coz("9router-nano-mlabonne-NeuralDaredevil-8B-abliterated", "model",
|
||||
"nano/mlabonne/NeuralDaredevil-8B-abliterated", nano=NANO)
|
||||
self.assertEqual((small["pencere"], small["arac"], small["gorsel"]), (8192, False, False))
|
||||
@@ -73,6 +77,7 @@ class Windows(unittest.TestCase):
|
||||
self.assertTrue(pencere.tasma_mi(text))
|
||||
self.assertEqual(pencere.sinir_oku(text), 32768)
|
||||
self.assertEqual(pencere.sinir_oku("prompt is too long: 3040251 tokens > 1000000 maximum"), 1000000)
|
||||
self.assertEqual(pencere.sinir_oku("Input exceeds maximum input tokens: estimated 1125036 input tokens, max input 1048576."), 1048576)
|
||||
gateway = ("Your prompt exceeds the model's context length. Estimated input tokens: 171016. "
|
||||
"Max context tokens: 65536.")
|
||||
self.assertTrue(pencere.tasma_mi(gateway))
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Real-socket regressions for terminal worker errors (no provider calls)."""
|
||||
import json
|
||||
import unittest
|
||||
|
||||
import test_ayirici as fixtures
|
||||
import terminal_stream
|
||||
|
||||
|
||||
ERROR = b'event: error\ndata: {"type":"error","error":{"type":"upstream_error","message":"upstream 503"}}\n\n'
|
||||
COMPLETE = b'event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_ok","status":"completed","output":[]}}\n\n'
|
||||
|
||||
|
||||
class TerminalStream(unittest.TestCase):
|
||||
setUp = fixtures.Ayirici.setUp
|
||||
tearDown = fixtures.Ayirici.tearDown
|
||||
iste = fixtures.Ayirici.iste
|
||||
|
||||
def request(self, token="a", text="check"):
|
||||
body = json.dumps({"model": "ide/test", "input": [{"role": "user", "content": text}]})
|
||||
return self.iste("POST", "/backend-api/codex/responses", body,
|
||||
{"Authorization": "Bearer " + token})
|
||||
|
||||
def worker(self, payload):
|
||||
def respond(h, body):
|
||||
if h.path.endswith("/prepare-transfer"):
|
||||
return fixtures.duz(200, json.dumps({"request": json.loads(body)["request"]}).encode())(h, body)
|
||||
return fixtures.duz(200, payload, "text/event-stream")(h, body)
|
||||
self.kopru.cevap = respond
|
||||
|
||||
def inference_count(self):
|
||||
return sum(x["yol"].endswith("/responses") for x in self.kopru.gelen)
|
||||
|
||||
def test_error_only_stream_is_an_honest_http_failure(self):
|
||||
self.worker(ERROR)
|
||||
status, body, _ = self.request()
|
||||
self.assertEqual(status, 400)
|
||||
self.assertIn("upstream 503", json.loads(body)["error"]["message"])
|
||||
self.assertNotIn(b"response.completed", body)
|
||||
self.assertEqual(self.inference_count(), 1)
|
||||
|
||||
def test_error_after_heartbeat_stops_automatic_replay_once(self):
|
||||
def respond(h, body):
|
||||
if h.path.endswith("/prepare-transfer"):
|
||||
return fixtures.duz(200, json.dumps({"request": json.loads(body)["request"]}).encode())(h, body)
|
||||
h.send_response(200)
|
||||
h.send_header("Content-Type", "text/event-stream")
|
||||
h.send_header("Transfer-Encoding", "chunked")
|
||||
h.end_headers()
|
||||
for chunk in (b": keepalive\n\n", ERROR):
|
||||
h.wfile.write(b"%x\r\n" % len(chunk) + chunk + b"\r\n")
|
||||
h.wfile.flush()
|
||||
fixtures.time.sleep(.08)
|
||||
h.wfile.write(b"0\r\n\r\n")
|
||||
self.kopru.cevap = respond
|
||||
status, body, _ = self.request()
|
||||
self.assertEqual((status, body), (200, b": keepalive\n\n" + ERROR))
|
||||
status, body, _ = self.request()
|
||||
self.assertEqual(status, 400)
|
||||
self.assertIn(b"upstream 503", body)
|
||||
self.assertEqual(self.inference_count(), 1)
|
||||
# A later explicit retry is allowed; the one-shot guard is consumed.
|
||||
self.worker(COMPLETE)
|
||||
self.assertEqual(self.request()[:2], (200, COMPLETE))
|
||||
self.assertEqual(self.inference_count(), 2)
|
||||
|
||||
def test_native_error_stream_is_not_intercepted(self):
|
||||
self.ozgun.cevap = fixtures.duz(200, ERROR, "text/event-stream")
|
||||
body = json.dumps({"model": "gpt-6-astra", "input": []})
|
||||
self.assertEqual(self.iste("POST", "/backend-api/codex/responses", body)[:2], (200, ERROR))
|
||||
|
||||
def test_success_stream_is_byte_identical(self):
|
||||
self.worker(COMPLETE)
|
||||
self.assertEqual(self.request()[:2], (200, COMPLETE))
|
||||
self.assertEqual(self.request()[:2], (200, COMPLETE))
|
||||
self.assertEqual(self.inference_count(), 2)
|
||||
|
||||
def test_eof_without_upstream_error_keeps_network_retry(self):
|
||||
self.worker(b": keepalive\n\n")
|
||||
self.assertEqual(self.request()[0], 200)
|
||||
self.assertEqual(self.request()[0], 200)
|
||||
self.assertEqual(self.inference_count(), 2)
|
||||
|
||||
def test_worker_exhausted_failure_is_terminal_without_changing_its_reason(self):
|
||||
self.worker(b'event: response.failed\ndata: {"type":"response.failed","response":{"status":"failed","error":{"code":"jev_no_fallback","message":"No accepted response"}}}\n\n')
|
||||
status, body, _ = self.request()
|
||||
self.assertEqual(status, 400)
|
||||
self.assertEqual(json.loads(body)["error"]["code"], "jev_no_fallback")
|
||||
|
||||
def test_existing_context_error_classification_is_preserved(self):
|
||||
data = b'event: response.failed\ndata: {"type":"response.failed","response":{"error":{"code":"context_length_exceeded","message":"Context full"}}}\n\n'
|
||||
self.worker(data)
|
||||
self.assertEqual(self.request()[:2], (200, data))
|
||||
|
||||
|
||||
class ObserverTests(unittest.TestCase):
|
||||
def test_fragmented_crlf_and_multiline_json(self):
|
||||
data = b'event: error\r\ndata: {"type":"error",\r\ndata: "error":{"type":"upstream_error","message":"503"}}\r\n\r\n'
|
||||
observer = terminal_stream.Observer()
|
||||
for byte in data:
|
||||
observer.feed(bytes([byte]))
|
||||
self.assertEqual(observer.error['message'], '503')
|
||||
|
||||
def test_large_and_malformed_frames_are_bounded_and_do_not_crash(self):
|
||||
observer = terminal_stream.Observer()
|
||||
observer.feed(b'data: ' + b'x'*200000)
|
||||
self.assertLessEqual(len(observer.tail), observer.LIMIT)
|
||||
observer.feed(b'\n\ndata: {"response":[]}\n\n' + ERROR)
|
||||
self.assertEqual(observer.error['message'], 'upstream 503')
|
||||
|
||||
def test_completed_stream_does_not_poison_next_request(self):
|
||||
observer = terminal_stream.Observer()
|
||||
observer.feed(ERROR + COMPLETE)
|
||||
self.assertIsNone(observer.error)
|
||||
self.assertTrue(observer.completed)
|
||||
|
||||
def test_one_shot_expiry_and_capacity(self):
|
||||
now = [10]
|
||||
cache = terminal_stream.FailureReplays(ttl=5, capacity=2, clock=lambda: now[0])
|
||||
cache.remember(b'a', {'code': 'a'})
|
||||
self.assertEqual(cache.take(b'a'), {'code': 'a'})
|
||||
self.assertIsNone(cache.take(b'a'))
|
||||
cache.remember(b'a', {})
|
||||
now[0] = 16
|
||||
self.assertIsNone(cache.take(b'a'))
|
||||
for key in (b'a', b'b', b'c'):
|
||||
cache.remember(key, {'code': 'error'})
|
||||
self.assertIsNone(cache.take(b'a'))
|
||||
self.assertEqual(len(cache.items), 2)
|
||||
|
||||
def test_request_identity_separates_accounts_sessions_models_and_inputs(self):
|
||||
base = {'model': 'ide/a', 'input': []}
|
||||
headers = {'Authorization': 'Bearer a', 'session_id': 'one'}
|
||||
key = terminal_stream.request_key(base, headers)
|
||||
self.assertEqual(key, terminal_stream.request_key(dict(reversed(list(base.items()))), headers))
|
||||
for request, other in [({'model': 'ide/b', 'input': []}, headers),
|
||||
({'model': 'ide/a', 'input': ['next']}, headers),
|
||||
(base, dict(headers, Authorization='Bearer b')),
|
||||
(base, dict(headers, session_id='two'))]:
|
||||
self.assertNotEqual(key, terminal_stream.request_key(request, other))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Namespace routing regressions: leaf-only calls cannot dispatch collaboration tools."""
|
||||
import copy
|
||||
import json
|
||||
import unittest
|
||||
from test_model_switch import env
|
||||
|
||||
|
||||
class ToolNamespaces(unittest.TestCase):
|
||||
def request(self):
|
||||
return {"tools": [
|
||||
{"type": "namespace", "name": "collaboration", "tools": [{"type": "function", "name": "spawn_agent", "parameters": {"type": "object", "properties": {}}}]},
|
||||
{"type": "namespace", "name": "other", "tools": [{"type": "function", "name": "spawn_agent"}]},
|
||||
{"type": "namespace", "name": "functions", "tools": [{"type": "custom", "name": "apply_patch"}]},
|
||||
{"type": "function", "name": "plain"}],
|
||||
"input": [{"type": "function_call", "namespace": "collaboration", "name": "spawn_agent", "id": "fc_a", "call_id": "call_a", "arguments": "{}"},
|
||||
{"type": "function_call_output", "call_id": "call_a", "output": "child_123"}]}
|
||||
|
||||
def test_namespace_calls_round_trip_at_all_stream_stages(self):
|
||||
req = self.request(); original = copy.deepcopy(req); names = {}
|
||||
custom = env["flatten_request"](req, names)
|
||||
alias = req["tools"][0]["name"]
|
||||
self.assertNotEqual(alias, req["tools"][1]["name"])
|
||||
self.assertLessEqual(len(alias), 64)
|
||||
self.assertEqual(req["input"][0]["name"], alias)
|
||||
self.assertNotIn("namespace", req["input"][0])
|
||||
self.assertEqual(req["input"][1], original["input"][1])
|
||||
adapter = env["SseRewriter"](custom, tool_names=names)
|
||||
item = {"type": "function_call", "name": alias, "id": "fc_test", "call_id": "call_test", "arguments": "{}"}
|
||||
for stage in ["response.output_item.added", "response.output_item.done"]:
|
||||
restored = adapter._fix({"type": stage, "item": dict(item)})["item"]
|
||||
self.assertEqual((restored["namespace"], restored["name"]), ("collaboration", "spawn_agent"))
|
||||
self.assertEqual(restored["call_id"], "call_test")
|
||||
restored = adapter._fix({"type": "response.completed", "response": {"output": [dict(item)]}})["response"]["output"][0]
|
||||
self.assertEqual((restored["namespace"], restored["name"]), ("collaboration", "spawn_agent"))
|
||||
|
||||
def test_custom_tool_namespace_and_ids_survive_chunked_sse(self):
|
||||
req = self.request(); names = {}; custom = env["flatten_request"](req, names)
|
||||
alias = req["tools"][2]["name"]
|
||||
item = {"type": "function_call", "name": alias, "id": "fc_patch", "call_id": "call_patch", "arguments": json.dumps({"input": "patch text"})}
|
||||
raw = ("data: " + json.dumps({"type": "response.output_item.done", "item": item}) + "\n\n").encode()
|
||||
a = env["SseRewriter"](custom, tool_names=names)
|
||||
output = a.feed(raw[:31]) + a.feed(raw[31:]) + a.flush()
|
||||
restored = json.loads(next(l[6:] for l in output.decode().splitlines() if l.startswith("data: ")))["item"]
|
||||
self.assertEqual((restored["namespace"], restored["name"], restored["type"]), ("functions", "apply_patch", "custom_tool_call"))
|
||||
self.assertEqual(restored["id"], "ctc_patch")
|
||||
self.assertEqual(restored["input"], "patch text")
|
||||
|
||||
def test_plain_tools_stay_plain_and_history_not_mutated(self):
|
||||
req = self.request(); original = copy.deepcopy(req); old_item = req["input"][0]
|
||||
env["flatten_request"](req, {})
|
||||
self.assertEqual(req["tools"][-1]["name"], "plain")
|
||||
self.assertEqual(old_item, original["input"][0])
|
||||
plain = {"type": "function_call", "name": "plain", "arguments": "{}"}
|
||||
self.assertEqual(env["SseRewriter"](set())._fix({"item": plain})["item"], plain)
|
||||
|
||||
def test_additional_tools_long_names_and_tool_choice(self):
|
||||
namespace = "provider_" * 20
|
||||
req = {"tools": [], "input": [{"type": "additional_tools", "tools": [{"type": "namespace", "name": namespace, "tools": [{"type": "function", "name": "read"}]}]}], "tool_choice": {"type": "function", "namespace": namespace, "name": "read"}}
|
||||
names = {}; env["flatten_request"](req, names)
|
||||
alias = req["tools"][0]["name"]
|
||||
self.assertLessEqual(len(alias), 64)
|
||||
self.assertEqual(req["tool_choice"], {"type": "function", "name": alias})
|
||||
self.assertEqual(req["input"], [])
|
||||
self.assertEqual(names[alias], (namespace, "read"))
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
@@ -31,12 +31,33 @@ export function publicMessages(rows) {
|
||||
return messages
|
||||
}
|
||||
|
||||
// Browser actions the extension may offer the IDE agent. The backend applies
|
||||
// its own allowlist again; this one keeps unknown names off the wire.
|
||||
export const BROWSER_CAPABILITIES = Object.freeze(['browser_navigate', 'browser_snapshot', 'browser_click',
|
||||
'browser_type', 'browser_scroll', 'browser_back', 'browser_press', 'browser_get_images', 'browser_vision'])
|
||||
const COMMAND_ID = /^[a-f0-9]{16,64}$/
|
||||
|
||||
export function validateRequest(message) {
|
||||
if (!message || typeof message.id !== 'string' || message.id.length > 80 ||
|
||||
!['status', 'sessions.list', 'session.open', 'session.history', 'message.send', 'message.stop'].includes(message.action)) {
|
||||
!['status', 'sessions.list', 'session.open', 'session.history', 'message.send', 'message.stop',
|
||||
'browser.register', 'browser.result'].includes(message.action)) {
|
||||
throw new Error('Desteklenmeyen eklenti isteği.')
|
||||
}
|
||||
const params = message.params || {}
|
||||
if (message.action === 'browser.register') {
|
||||
if (typeof params.enabled !== 'boolean') throw new Error('Tarayıcı denetimi ayarı geçersiz.')
|
||||
if (params.enabled && (!Array.isArray(params.capabilities) || !params.capabilities.length ||
|
||||
params.capabilities.some(name => !BROWSER_CAPABILITIES.includes(name)) ||
|
||||
!/^[a-zA-Z0-9_.:-]{1,120}$/.test(params.controllerId || '') ||
|
||||
!/^[a-zA-Z0-9_.:-]{1,120}$/.test(params.browserProfileId || ''))) {
|
||||
throw new Error('Tarayıcı denetimi kaydı geçersiz.')
|
||||
}
|
||||
}
|
||||
if (message.action === 'browser.result' &&
|
||||
(typeof params.commandId !== 'string' || !COMMAND_ID.test(params.commandId) || typeof params.ok !== 'boolean' ||
|
||||
(!params.ok && (typeof params.error !== 'string' || params.error.length > 4000)))) {
|
||||
throw new Error('Tarayıcı komut sonucu geçersiz.')
|
||||
}
|
||||
if (['session.open', 'session.history', 'message.send', 'message.stop'].includes(message.action) &&
|
||||
(typeof params.sessionId !== 'string' || !/^[a-zA-Z0-9_.:-]{1,160}$/.test(params.sessionId))) {
|
||||
throw new Error('Geçersiz konuşma kimliği.')
|
||||
@@ -57,6 +78,8 @@ export class ChromeGatewayClient {
|
||||
this.nextId = 0
|
||||
this.selected = null
|
||||
this.closed = false
|
||||
// True once this connection offered the user's browser to the IDE agent.
|
||||
this.controller = false
|
||||
}
|
||||
|
||||
async connect() {
|
||||
@@ -92,6 +115,8 @@ export class ChromeGatewayClient {
|
||||
}
|
||||
this.pending.clear()
|
||||
this.selected = null
|
||||
// The backend drops a local browser controller with its transport.
|
||||
this.controller = false
|
||||
if (!this.closed) this.emit({ event: 'disconnected', message: 'IDE bağlantısı kesildi. Yeniden bağlanın.' })
|
||||
})
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -135,6 +160,10 @@ export class ChromeGatewayClient {
|
||||
}
|
||||
|
||||
onEvent(event) {
|
||||
if (this.controller && ['browser.controller.command', 'browser.controller.cancel'].includes(event?.type)) {
|
||||
this.onControllerEvent(event)
|
||||
return
|
||||
}
|
||||
const selected = this.selected
|
||||
if (!selected || event?.session_id !== selected.runtimeId) return
|
||||
const payload = event.payload || {}
|
||||
@@ -152,6 +181,26 @@ export class ChromeGatewayClient {
|
||||
}
|
||||
}
|
||||
|
||||
onControllerEvent(event) {
|
||||
const payload = event.payload || {}
|
||||
const commandId = payload.command_id
|
||||
if (typeof commandId !== 'string' || !COMMAND_ID.test(commandId)) return
|
||||
if (event.type === 'browser.controller.cancel') {
|
||||
this.emit({ event: 'browser.cancel', commandId })
|
||||
return
|
||||
}
|
||||
const refuse = error => void this.rpc('browser.controller.local.result',
|
||||
{ command_id: commandId, ok: false, error }).catch(() => {})
|
||||
if (!BROWSER_CAPABILITIES.includes(payload.action)) return refuse('Chrome bu tarayıcı işlemini desteklemiyor.')
|
||||
const args = payload.arguments && typeof payload.arguments === 'object' && !Array.isArray(payload.arguments)
|
||||
? payload.arguments : {}
|
||||
// A frame over the native limit would drop the whole connection; refuse
|
||||
// the one oversized command instead.
|
||||
if (Buffer.byteLength(JSON.stringify(args)) > 256 * 1024) return refuse('Tarayıcı komutu çok büyük.')
|
||||
this.emit({ event: 'browser.command', commandId, action: payload.action, arguments: args,
|
||||
sessionId: typeof event.session_id === 'string' ? event.session_id.slice(0, 160) : '' })
|
||||
}
|
||||
|
||||
async handle(message) {
|
||||
const params = validateRequest(message)
|
||||
await this.connect()
|
||||
@@ -193,6 +242,24 @@ export class ChromeGatewayClient {
|
||||
}
|
||||
case 'session.history':
|
||||
return this.history(params.sessionId)
|
||||
case 'browser.register': {
|
||||
if (!params.enabled) {
|
||||
this.controller = false
|
||||
const result = await this.rpc('browser.controller.local.detach', {})
|
||||
return { registered: false, detached: Boolean(result?.detached) }
|
||||
}
|
||||
const result = await this.rpc('browser.controller.local.register', { protocol_version: 1,
|
||||
controller_id: params.controllerId, browser_profile_id: params.browserProfileId,
|
||||
capabilities: params.capabilities, profile: this.backend.profile || 'default' })
|
||||
this.controller = true
|
||||
return { registered: true, capabilities: Array.isArray(result?.capabilities) ? result.capabilities : [] }
|
||||
}
|
||||
case 'browser.result': {
|
||||
const result = await this.rpc('browser.controller.local.result', params.ok
|
||||
? { command_id: params.commandId, ok: true, result: params.result ?? null }
|
||||
: { command_id: params.commandId, ok: false, error: params.error })
|
||||
return { accepted: Boolean(result?.accepted) }
|
||||
}
|
||||
case 'message.send':
|
||||
case 'message.stop': {
|
||||
if (this.selected?.storedId !== params.sessionId) throw new Error('Önce bu konuşmayı seçin.')
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
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,
|
||||
@@ -34,6 +40,23 @@ function expectRoundTrip(original: string) {
|
||||
}
|
||||
|
||||
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'
|
||||
@@ -202,12 +225,17 @@ describe('preference file', () => {
|
||||
})
|
||||
|
||||
it('writes the contract shape and keeps unknown fields', () => {
|
||||
expect(JSON.parse(serializeCodexLinkSettings({}, false))).toEqual({ enabled: false, kapsam: 'ide' })
|
||||
expect(JSON.parse(serializeCodexLinkSettings({}, false))).toEqual({
|
||||
enabled: false,
|
||||
mod: 'dogrudan',
|
||||
kapsam: 'ide'
|
||||
})
|
||||
|
||||
const { stored } = parseCodexLinkSettings('{"enabled": false, "kapsam": "hepsi", "gelecek": {"a": 1}}')
|
||||
|
||||
expect(JSON.parse(serializeCodexLinkSettings(stored, true))).toEqual({
|
||||
enabled: true,
|
||||
mod: 'dogrudan',
|
||||
gelecek: { a: 1 },
|
||||
kapsam: 'hepsi'
|
||||
})
|
||||
@@ -285,3 +313,17 @@ describe('bridge configuration and command', () => {
|
||||
expect(codexAutostartPlan(`${deep}\\pythonw.exe`, launch, `${deep}\\baslat.pyw`)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mode preference', () => {
|
||||
it('uses direct for missing, invalid and malformed modes', () => {
|
||||
for (const raw of [null, '{', '{}', '{"mod":"bad"}', '{"mod":false}']) {
|
||||
expect(parseCodexLinkSettings(raw).mod).toBe('dogrudan')
|
||||
}
|
||||
expect(parseCodexLinkSettings('{"mod":"aiturk"}').mod).toBe('aiturk')
|
||||
})
|
||||
it('preserves other fields when changing mode', () => {
|
||||
const stored = { enabled: false, mod: 'aiturk', kapsam: 'hepsi', future: { a: 1 } }
|
||||
expect(JSON.parse(serializeCodexLinkSettings(stored, false, 'dogrudan'))).toEqual({ ...stored, mod: 'dogrudan' })
|
||||
expect(JSON.parse(serializeCodexLinkSettings(stored, true)).mod).toBe('aiturk')
|
||||
})
|
||||
})
|
||||
@@ -27,7 +27,7 @@ export const CODEX_LINK_REFRESH_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama
|
||||
export const CODEX_LINK_MARKER = '# AITURK IDE Codex Baglama'
|
||||
// Must equal SURUM in assets/codex-bridge/bridge.py; a running bridge that reports
|
||||
// another version is restarted so an updated IDE never keeps an old bridge alive.
|
||||
export const CODEX_BRIDGE_VERSION = '8'
|
||||
export const CODEX_BRIDGE_VERSION = '21'
|
||||
export const CODEX_LINK_LINE = `openai_base_url = "${CODEX_LINK_ORIGIN}/backend-api/codex" ${CODEX_LINK_MARKER}`
|
||||
|
||||
export const CODEX_LINK_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'
|
||||
@@ -300,6 +300,7 @@ export function removeCodexLinkLine(source: string): CodexConfigEdit {
|
||||
|
||||
export interface CodexLinkSettings {
|
||||
enabled: boolean
|
||||
mod: 'dogrudan' | 'aiturk'
|
||||
/** Every field of the stored object, so a rewrite keeps what it did not set. */
|
||||
stored: Record<string, unknown>
|
||||
}
|
||||
@@ -307,26 +308,34 @@ export interface CodexLinkSettings {
|
||||
/** Missing, unreadable or malformed means ON: the feature is on by default. */
|
||||
export function parseCodexLinkSettings(raw: null | string | undefined): CodexLinkSettings {
|
||||
if (typeof raw !== 'string') {
|
||||
return { enabled: true, stored: {} }
|
||||
return { enabled: true, mod: 'dogrudan', stored: {} }
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return { enabled: true, stored: {} }
|
||||
return { enabled: true, mod: 'dogrudan', stored: {} }
|
||||
}
|
||||
|
||||
return { enabled: parsed.enabled !== false, stored: parsed as Record<string, unknown> }
|
||||
return {
|
||||
enabled: parsed.enabled !== false,
|
||||
mod: parsed.mod === 'aiturk' ? 'aiturk' : 'dogrudan',
|
||||
stored: parsed as Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
return { enabled: true, stored: {} }
|
||||
return { enabled: true, mod: 'dogrudan', stored: {} }
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeCodexLinkSettings(stored: Record<string, unknown>, enabled: boolean): string {
|
||||
export function serializeCodexLinkSettings(
|
||||
stored: Record<string, unknown>,
|
||||
enabled: boolean,
|
||||
mod: 'dogrudan' | 'aiturk' = stored.mod === 'aiturk' ? 'aiturk' : 'dogrudan'
|
||||
): string {
|
||||
const kapsam = typeof stored.kapsam === 'string' && stored.kapsam ? stored.kapsam : 'ide'
|
||||
|
||||
return `${JSON.stringify({ ...stored, enabled, kapsam }, null, 2)}\n`
|
||||
return `${JSON.stringify({ ...stored, enabled, mod, kapsam }, null, 2)}\n`
|
||||
}
|
||||
|
||||
// ── Bridge configuration ────────────────────────────────────────────────────
|
||||
@@ -343,8 +352,24 @@ export function codexBridgeConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeCodexBridgeConfig(): string {
|
||||
return `${JSON.stringify(codexBridgeConfig(), null, 2)}\n`
|
||||
/** Keeps the user's own choices (Settings → AITURK iş hattı) across the rewrite. */
|
||||
export function serializeCodexBridgeConfig(previous?: null | string): string {
|
||||
const config: Record<string, unknown> = codexBridgeConfig()
|
||||
|
||||
try {
|
||||
const old = previous ? JSON.parse(previous) : null
|
||||
|
||||
// The user's own choices from Settings → Codex Bağlama survive the IDE rewriting the file.
|
||||
for (const key of ['aiturk_hat', 'codex_liste']) {
|
||||
if (old && typeof old === 'object' && old[key] && typeof old[key] === 'object') {
|
||||
config[key] = old[key]
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// An unreadable file has nothing worth keeping.
|
||||
}
|
||||
|
||||
return `${JSON.stringify(config, null, 2)}\n`
|
||||
}
|
||||
|
||||
// ── Bridge command line ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* code into its program. See codex-link.ts for what the fields mean in action.
|
||||
*/
|
||||
|
||||
export type CodexLinkMode = 'dogrudan' | 'aiturk'
|
||||
|
||||
export type CodexLinkIdeState = 'anahtar-yok' | 'kapali' | 'tamam'
|
||||
|
||||
/** Stable codes; the renderer owns the wording. Detail goes to the desktop log. */
|
||||
@@ -18,6 +20,7 @@ export type CodexLinkError =
|
||||
|
||||
export interface CodexLinkStatus {
|
||||
enabled: boolean
|
||||
mod: CodexLinkMode
|
||||
/** Codex's `config.toml` exists on this machine. */
|
||||
codexFound: boolean
|
||||
/** The IDE's own Python environment exists (first-run setup finished). */
|
||||
@@ -34,6 +37,7 @@ export interface CodexLinkStatus {
|
||||
|
||||
export interface CodexLinkPatch {
|
||||
enabled?: boolean
|
||||
mod?: CodexLinkMode
|
||||
/** "Sync now": re-run the apply without changing the preference. */
|
||||
sync?: boolean
|
||||
}
|
||||
@@ -39,6 +39,7 @@ interface World {
|
||||
probesUntilUp: number
|
||||
autostart: null | string
|
||||
regFails: boolean
|
||||
configWriteFails: boolean
|
||||
/** Every observable effect, in order. */
|
||||
events: string[]
|
||||
clock: number
|
||||
@@ -51,6 +52,7 @@ function createWorld(patch: Partial<World> & { files?: Map<string, string> } = {
|
||||
events: [],
|
||||
files: new Map([
|
||||
[PATHS.codexConfig, USER_CONFIG],
|
||||
[PATHS.settingsFile, JSON.stringify({ enabled: true, mod: 'aiturk' })],
|
||||
[PATHS.bridgeScript, '# bridge'],
|
||||
[PATHS.pythonw, ''],
|
||||
[PATHS.python, '']
|
||||
@@ -59,6 +61,7 @@ function createWorld(patch: Partial<World> & { files?: Map<string, string> } = {
|
||||
staleVersion: null,
|
||||
probesUntilUp: 0,
|
||||
regFails: false,
|
||||
configWriteFails: false,
|
||||
spawnWorks: true,
|
||||
...patch
|
||||
}
|
||||
@@ -71,6 +74,7 @@ function createWorld(patch: Partial<World> & { files?: Map<string, string> } = {
|
||||
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)
|
||||
},
|
||||
@@ -189,6 +193,7 @@ describe('applyCodexLink', () => {
|
||||
|
||||
expect(status).toEqual({
|
||||
enabled: true,
|
||||
mod: 'aiturk',
|
||||
codexFound: true,
|
||||
venvReady: true,
|
||||
foreign: false,
|
||||
@@ -325,16 +330,42 @@ describe('applyCodexLink', () => {
|
||||
'autostart clear',
|
||||
'stop'
|
||||
])
|
||||
expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({ enabled: false, kapsam: 'ide' })
|
||||
expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({
|
||||
enabled: false,
|
||||
mod: 'aiturk',
|
||||
kapsam: 'ide'
|
||||
})
|
||||
// Byte for byte what the user had before the feature touched it.
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
|
||||
expect(world.autostart).toBeNull()
|
||||
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, "kapsam": "ide", "not": "ours"}')
|
||||
world.files.set(PATHS.settingsFile, '{"enabled": false, "mod": "aiturk", "kapsam": "ide", "not": "ours"}')
|
||||
|
||||
expect(await link.apply()).toMatchObject({ enabled: false, bridgeRunning: false })
|
||||
expect(spawns(world)).toEqual([])
|
||||
@@ -343,6 +374,7 @@ describe('applyCodexLink', () => {
|
||||
expect(await link.apply({ enabled: true })).toMatchObject({ enabled: true, bridgeRunning: true })
|
||||
expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({
|
||||
enabled: true,
|
||||
mod: 'aiturk',
|
||||
kapsam: 'ide',
|
||||
not: 'ours'
|
||||
})
|
||||
@@ -400,3 +432,44 @@ describe('getStatus', () => {
|
||||
expect(effects(world)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('connection mode', () => {
|
||||
it('defaults to direct, starts bridge and autostart without writing a line', async () => {
|
||||
const { link, world } = createWorld()
|
||||
world.files.delete(PATHS.settingsFile)
|
||||
expect(await link.apply()).toMatchObject({ mod: 'dogrudan', bridgeRunning: true })
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
|
||||
expect(spawns(world)).toHaveLength(1)
|
||||
expect(world.autostart).not.toBeNull()
|
||||
})
|
||||
it('removes the old line and preserves the current bridge in direct mode', async () => {
|
||||
const { link, world } = createWorld({ listening: true })
|
||||
world.files.set(PATHS.settingsFile, '{"mod":"dogrudan"}')
|
||||
world.files.set(PATHS.codexConfig, `${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
|
||||
await link.apply()
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
|
||||
expect(spawns(world)).toHaveLength(0)
|
||||
expect(world.events).not.toContain('stop')
|
||||
expect(world.autostart).not.toBeNull()
|
||||
})
|
||||
it('switches both ways, persists mode and preserves unrelated preferences', async () => {
|
||||
const { link, world } = createWorld({ listening: true })
|
||||
world.files.set(PATHS.settingsFile, '{"mod":"dogrudan","custom":{"keep":1}}')
|
||||
await link.apply({ mod: 'aiturk' })
|
||||
expect(world.files.get(PATHS.codexConfig)).toContain(CODEX_LINK_LINE)
|
||||
await link.apply({ mod: 'dogrudan' })
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
|
||||
expect(JSON.parse(world.files.get(PATHS.settingsFile)!)).toMatchObject({ mod: 'dogrudan', custom: { keep: 1 } })
|
||||
await link.apply()
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
|
||||
expect(world.events).not.toContain('stop')
|
||||
})
|
||||
it.each(['dogrudan', 'aiturk'] as const)('preserves foreign URL in %s', async mod => {
|
||||
const { link, world } = createWorld()
|
||||
const foreign = 'openai_base_url = "https://other.example"\n'
|
||||
world.files.set(PATHS.codexConfig, foreign)
|
||||
await link.apply({ mod })
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(foreign)
|
||||
expect(spawns(world)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -186,6 +186,7 @@ export function createCodexLink(deps: CodexLinkDeps): CodexLink {
|
||||
bridge: BridgeProbe
|
||||
): CodexLinkStatus => ({
|
||||
enabled,
|
||||
mod: readSettings().mod,
|
||||
codexFound: config.found,
|
||||
venvReady: interpreter() !== null,
|
||||
foreign: config.owner === 'foreign',
|
||||
@@ -218,10 +219,12 @@ export function createCodexLink(deps: CodexLinkDeps): CodexLink {
|
||||
}
|
||||
}
|
||||
|
||||
// Off: take back everything we put there. Each step is independent — a
|
||||
// failure in one must not leave the others in place.
|
||||
// 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> => {
|
||||
removeOurLine(config.text)
|
||||
if (!removeOurLine(config.text)) {
|
||||
return probe()
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.clearAutostart()
|
||||
@@ -283,7 +286,16 @@ export function createCodexLink(deps: CodexLinkDeps): CodexLink {
|
||||
}
|
||||
|
||||
try {
|
||||
deps.writeTextAtomic(path.join(paths.bridgeHome, 'bridge.json'), serializeCodexBridgeConfig())
|
||||
const bridgeJson = path.join(paths.bridgeHome, 'bridge.json')
|
||||
let previous: null | string = null
|
||||
|
||||
try {
|
||||
previous = deps.readText(bridgeJson)
|
||||
} catch {
|
||||
previous = null
|
||||
}
|
||||
|
||||
deps.writeTextAtomic(bridgeJson, serializeCodexBridgeConfig(previous))
|
||||
} catch (error) {
|
||||
fail('bridge-config-failed', error)
|
||||
|
||||
@@ -327,7 +339,8 @@ export function createCodexLink(deps: CodexLinkDeps): CodexLink {
|
||||
return bridge
|
||||
}
|
||||
|
||||
const edit = addCodexLinkLine(config.text ?? '')
|
||||
const edit =
|
||||
readSettings().mod === 'aiturk' ? addCodexLinkLine(config.text ?? '') : removeCodexLinkLine(config.text ?? '')
|
||||
|
||||
if (edit.changed) {
|
||||
try {
|
||||
@@ -362,9 +375,16 @@ export function createCodexLink(deps: CodexLinkDeps): CodexLink {
|
||||
const run = async (patch?: CodexLinkPatch): Promise<CodexLinkStatus> => {
|
||||
lastError = null
|
||||
|
||||
if (typeof patch?.enabled === 'boolean') {
|
||||
if (typeof patch?.enabled === 'boolean' || patch?.mod === 'aiturk' || patch?.mod === 'dogrudan') {
|
||||
try {
|
||||
deps.writeTextAtomic(paths.settingsFile, serializeCodexLinkSettings(readSettings().stored, patch.enabled))
|
||||
deps.writeTextAtomic(
|
||||
paths.settingsFile,
|
||||
serializeCodexLinkSettings(
|
||||
readSettings().stored,
|
||||
patch?.enabled ?? readSettings().enabled,
|
||||
patch?.mod ?? readSettings().mod
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
// The preference did not stick, so do not act on it either: what the
|
||||
// machine does must match what the file says after a restart.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { serializeCodexBridgeConfig } from './codex-link-config'
|
||||
import { onizle, readAdaylar, readCodexListe, writeCodexListe } from './codex-liste-settings'
|
||||
|
||||
const aday = (slug: string, extra: Record<string, unknown> = {}) => ({ slug, ad: slug, kind: 'model', medya: false, efor: false, neden: '', sira: null, menude: false, ...extra })
|
||||
|
||||
describe('Codex model picker list setting', () => {
|
||||
it('defaults to hiding media models and effort copies, with nothing pinned or hidden', () => {
|
||||
expect(readCodexListe(null)).toEqual({ medya_gizle: true, efor_gizle: true, one_al: [], gizli: [] })
|
||||
expect(readCodexListe('bozuk').medya_gizle).toBe(true)
|
||||
expect(readCodexListe('{"codex_liste":{"efor_gizle":false,"one_al":["a","a",3]}}')).toEqual({ medya_gizle: true, efor_gizle: false, one_al: ['a'], gizli: [] })
|
||||
})
|
||||
|
||||
it('writes only codex_liste, keeps the rest of bridge.json, and a pin wins over hiding', () => {
|
||||
const raw = '{"port":8795,"aiturk_hat":{"secim":"onerilen"}}'
|
||||
const out = JSON.parse(writeCodexListe(raw, { medya_gizle: false, efor_gizle: true, one_al: ['x'], gizli: ['x', 'y'] }))
|
||||
expect(out).toEqual({ port: 8795, aiturk_hat: { secim: 'onerilen' }, codex_liste: { medya_gizle: false, efor_gizle: true, one_al: ['x'], gizli: ['y'] } })
|
||||
expect(() => writeCodexListe('{bozuk', { one_al: [], gizli: [] })).toThrow()
|
||||
expect(() => writeCodexListe(null, { one_al: 'x', gizli: [] })).toThrow()
|
||||
})
|
||||
|
||||
it('reads the candidate list the bridge wrote and survives a missing or broken file', () => {
|
||||
expect(readAdaylar(null)).toEqual({ adaylar: [], ozgunSayisi: 0, sinir: 100, at: null })
|
||||
const st = readAdaylar(JSON.stringify({ sinir: 100, ozgun_sayisi: 9, at: 't', adaylar: [aday('ide/a', { neden: 'medya', medya: true }), { yanlis: 1 }] }))
|
||||
expect(st.ozgunSayisi).toBe(9)
|
||||
expect(st.adaylar).toHaveLength(1)
|
||||
expect(st.adaylar[0].neden).toBe('medya')
|
||||
})
|
||||
|
||||
it('previews the menu like the bridge: pins first, filters, and the 100 limit counts subscription models', () => {
|
||||
const adaylar = [aday('ide/a'), aday('ide/b', { efor: true }), aday('ide/c', { medya: true }), ...Array.from({ length: 100 }, (_, i) => aday(`ide/m${i}`))]
|
||||
const sonuc = onizle({ adaylar, ozgunSayisi: 2, sinir: 100 }, { medya_gizle: true, efor_gizle: true, one_al: ['ide/m99', 'ide/c'], gizli: ['ide/a'] })
|
||||
const bul = (s: string) => sonuc.find(a => a.slug === s)!
|
||||
expect(bul('ide/m99')).toMatchObject({ sira: 2, menude: true, neden: '' })
|
||||
expect(bul('ide/c')).toMatchObject({ sira: 3, neden: '' }) // pinned media model is shown
|
||||
expect(bul('ide/a').neden).toBe('gizli')
|
||||
expect(bul('ide/b').neden).toBe('efor')
|
||||
expect(sonuc.filter(a => a.menude)).toHaveLength(98)
|
||||
expect(bul('ide/m98').menude).toBe(false)
|
||||
})
|
||||
|
||||
it('survives the IDE rewriting bridge.json together with the work routing choice', () => {
|
||||
const previous = JSON.stringify({ port: 1, aiturk_hat: { secim: 'kapali' }, codex_liste: { medya_gizle: false, efor_gizle: true, one_al: ['ide/x'], gizli: [] } })
|
||||
const out = JSON.parse(serializeCodexBridgeConfig(previous))
|
||||
expect(out.aiturk_hat).toEqual({ secim: 'kapali' })
|
||||
expect(out.codex_liste).toEqual({ medya_gizle: false, efor_gizle: true, one_al: ['ide/x'], gizli: [] })
|
||||
expect(JSON.parse(serializeCodexBridgeConfig('bozuk')).codex_liste).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { type CodexAday, CODEX_LISTE_VARSAYILAN, CODEX_SECICI_SINIRI, type CodexListeSettings, type CodexListeState } from './codex-liste-types'
|
||||
|
||||
export type { CodexAday, CodexListeSettings, CodexListeState } from './codex-liste-types'
|
||||
export { onizle } from './codex-liste-types'
|
||||
|
||||
const SINIR = CODEX_SECICI_SINIRI
|
||||
const MAX_SLUGS = 2000
|
||||
const VARSAYILAN: CodexListeSettings = CODEX_LISTE_VARSAYILAN
|
||||
|
||||
function slugs(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
return [...new Set(value.filter((s): s is string => typeof s === 'string' && s.trim() !== ''))].slice(0, MAX_SLUGS)
|
||||
}
|
||||
|
||||
export function readCodexListe(raw: null | string): CodexListeSettings {
|
||||
try {
|
||||
const liste = raw ? JSON.parse(raw)?.codex_liste : null
|
||||
|
||||
if (!liste || typeof liste !== 'object') return { ...VARSAYILAN, one_al: [], gizli: [] }
|
||||
|
||||
return {
|
||||
medya_gizle: liste.medya_gizle !== false,
|
||||
efor_gizle: liste.efor_gizle !== false,
|
||||
one_al: slugs(liste.one_al),
|
||||
gizli: slugs(liste.gizli)
|
||||
}
|
||||
} catch {
|
||||
return { ...VARSAYILAN, one_al: [], gizli: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCodexListe(raw: null | string, value: unknown): string {
|
||||
const v = value as Partial<CodexListeSettings> | null
|
||||
|
||||
if (!v || typeof v !== 'object') throw new Error('Invalid setting')
|
||||
if (!Array.isArray(v.one_al) || !Array.isArray(v.gizli)) throw new Error('Invalid model list')
|
||||
let config: Record<string, unknown> = {}
|
||||
|
||||
try {
|
||||
const parsed = raw ? JSON.parse(raw) : {}
|
||||
config = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||
} catch {
|
||||
throw new Error('bridge.json is not valid JSON')
|
||||
}
|
||||
|
||||
const liste: CodexListeSettings = {
|
||||
medya_gizle: v.medya_gizle !== false,
|
||||
efor_gizle: v.efor_gizle !== false,
|
||||
one_al: slugs(v.one_al),
|
||||
gizli: slugs(v.gizli).filter(s => !slugs(v.one_al).includes(s))
|
||||
}
|
||||
|
||||
return `${JSON.stringify({ ...config, codex_liste: liste }, null, 2)}\n`
|
||||
}
|
||||
|
||||
export function readAdaylar(raw: null | string): Omit<CodexListeState, 'ayar'> {
|
||||
const bos = { adaylar: [], ozgunSayisi: 0, sinir: SINIR, at: null }
|
||||
|
||||
try {
|
||||
const veri = raw ? JSON.parse(raw) : null
|
||||
|
||||
if (!veri || !Array.isArray(veri.adaylar)) return bos
|
||||
|
||||
const adaylar: CodexAday[] = veri.adaylar
|
||||
.filter((a: unknown): a is Record<string, unknown> => Boolean(a) && typeof (a as { slug?: unknown }).slug === 'string')
|
||||
.map((a: Record<string, unknown>) => ({
|
||||
slug: String(a.slug),
|
||||
ad: typeof a.ad === 'string' ? a.ad : String(a.slug),
|
||||
kind: typeof a.kind === 'string' ? a.kind : 'model',
|
||||
medya: a.medya === true,
|
||||
efor: a.efor === true,
|
||||
neden: (['efor', 'gizli', 'medya'].includes(String(a.neden)) ? a.neden : '') as CodexAday['neden'],
|
||||
sira: typeof a.sira === 'number' ? a.sira : null,
|
||||
menude: a.menude === true
|
||||
}))
|
||||
|
||||
return {
|
||||
adaylar,
|
||||
ozgunSayisi: typeof veri.ozgun_sayisi === 'number' ? veri.ozgun_sayisi : 0,
|
||||
sinir: typeof veri.sinir === 'number' ? veri.sinir : SINIR,
|
||||
at: typeof veri.at === 'string' ? veri.at : null
|
||||
}
|
||||
} catch {
|
||||
return bos
|
||||
}
|
||||
}
|
||||
|
||||
export function createCodexListeStore(directory: string) {
|
||||
const file = path.join(directory, 'bridge.json')
|
||||
const adayFile = path.join(directory, 'log', 'son-liste', 'codex-adaylar.json')
|
||||
const read = (f: string) => (fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : null)
|
||||
const state = (raw: null | string): CodexListeState => ({ ayar: readCodexListe(raw), ...readAdaylar(read(adayFile)) })
|
||||
|
||||
return {
|
||||
get: (): CodexListeState => state(read(file)),
|
||||
save(value: unknown): CodexListeState {
|
||||
const text = writeCodexListe(read(file), value)
|
||||
fs.mkdirSync(directory, { recursive: true })
|
||||
const tmp = `${file}.${process.pid}.tmp`
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tmp, text, 'utf8')
|
||||
fs.renameSync(tmp, file)
|
||||
} finally {
|
||||
if (fs.existsSync(tmp)) fs.unlinkSync(tmp)
|
||||
}
|
||||
|
||||
return state(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/** Pure part of the Codex model-list setting (no Node imports), shared by the main process and the
|
||||
* settings screen. */
|
||||
/** Which added models Codex's model picker shows. The Codex desktop app asks for one page of at most
|
||||
* 100 models and never asks for the next page, so the user chooses which models fill it. Stored in the
|
||||
* Codex bridge's `bridge.json` under `codex_liste`; the bridge re-reads it when the file changes and writes
|
||||
* the candidate list it built to `log/son-liste/codex-adaylar.json`. */
|
||||
export interface CodexListeSettings {
|
||||
medya_gizle: boolean
|
||||
efor_gizle: boolean
|
||||
one_al: string[]
|
||||
gizli: string[]
|
||||
}
|
||||
|
||||
export interface CodexAday {
|
||||
slug: string
|
||||
ad: string
|
||||
kind: string
|
||||
medya: boolean
|
||||
efor: boolean
|
||||
neden: '' | 'efor' | 'gizli' | 'medya'
|
||||
sira: null | number
|
||||
menude: boolean
|
||||
}
|
||||
|
||||
export interface CodexListeState {
|
||||
ayar: CodexListeSettings
|
||||
adaylar: CodexAday[]
|
||||
ozgunSayisi: number
|
||||
sinir: number
|
||||
at: null | string
|
||||
}
|
||||
|
||||
export const CODEX_SECICI_SINIRI = 100
|
||||
|
||||
export const CODEX_LISTE_VARSAYILAN: CodexListeSettings = { medya_gizle: true, efor_gizle: true, one_al: [], gizli: [] }
|
||||
|
||||
/** The menu the setting produces, computed the way the bridge does it, so the screen can show the effect
|
||||
* before the bridge rebuilds the list. Candidates arrive in the bridge's base order. */
|
||||
export function onizle(state: Pick<CodexListeState, 'adaylar' | 'ozgunSayisi' | 'sinir'>, ayar: CodexListeSettings) {
|
||||
const one = ayar.one_al.filter(s => state.adaylar.some(a => a.slug === s))
|
||||
const gizli = new Set(ayar.gizli)
|
||||
|
||||
const neden = (a: CodexAday): CodexAday['neden'] => {
|
||||
if (gizli.has(a.slug)) return 'gizli'
|
||||
if (one.includes(a.slug)) return ''
|
||||
if (a.medya && ayar.medya_gizle) return 'medya'
|
||||
if (a.efor && ayar.efor_gizle) return 'efor'
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const kalan = state.adaylar.filter(a => !neden(a))
|
||||
const sirali = [...one.map(s => kalan.find(a => a.slug === s)).filter((a): a is CodexAday => Boolean(a)), ...kalan.filter(a => !one.includes(a.slug))]
|
||||
const yer = new Map(sirali.map((a, i) => [a.slug, state.ozgunSayisi + i]))
|
||||
|
||||
return state.adaylar.map(a => {
|
||||
const n = neden(a)
|
||||
const sira = n ? null : (yer.get(a.slug) ?? null)
|
||||
|
||||
return { ...a, neden: n, sira, menude: sira !== null && sira < state.sinir }
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { readIsHatti, writeIsHatti } from './is-hatti-settings'
|
||||
|
||||
describe('AITURK iş hattı settings', () => {
|
||||
it('defaults to the recommended rule and reads older files the way the bridge does', () => {
|
||||
expect(readIsHatti(null)).toEqual({ secim: 'onerilen', talimat: '' })
|
||||
expect(readIsHatti('{"aiturk_hat":{"enabled":false}}').secim).toBe('kapali')
|
||||
expect(readIsHatti('{"aiturk_hat":{"talimat":"x"}}')).toEqual({ secim: 'ozel', talimat: 'x' })
|
||||
expect(readIsHatti('bozuk').secim).toBe('onerilen')
|
||||
})
|
||||
|
||||
it('writes only aiturk_hat and keeps everything else', () => {
|
||||
const raw = '{"port":8795,"aiturk_hat":{"enabled":false,"komut":"k"}}'
|
||||
const out = JSON.parse(writeIsHatti(raw, { secim: 'buyuk', talimat: '' }))
|
||||
expect(out).toEqual({ port: 8795, aiturk_hat: { komut: 'k', secim: 'buyuk', talimat: '' } })
|
||||
})
|
||||
|
||||
it('rejects an empty custom text and unknown choices', () => {
|
||||
expect(() => writeIsHatti(null, { secim: 'ozel', talimat: ' ' })).toThrow()
|
||||
expect(() => writeIsHatti(null, { secim: 'x', talimat: '' })).toThrow()
|
||||
expect(() => writeIsHatti('{bozuk', { secim: 'onerilen', talimat: '' })).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/** "AITURK iş hattı" choice, stored in the Codex bridge's `bridge.json` under `aiturk_hat`.
|
||||
* The bridge re-reads the field when the file changes, so no restart is needed. */
|
||||
export type IsHattiSecim = 'onerilen' | 'buyuk' | 'kapali' | 'ozel'
|
||||
|
||||
export interface IsHattiSettings {
|
||||
secim: IsHattiSecim
|
||||
talimat: string
|
||||
}
|
||||
|
||||
const SECIMLER: IsHattiSecim[] = ['onerilen', 'buyuk', 'kapali', 'ozel']
|
||||
const MAX_TALIMAT = 20000
|
||||
|
||||
export function readIsHatti(raw: null | string): IsHattiSettings {
|
||||
try {
|
||||
const hat = raw ? JSON.parse(raw)?.aiturk_hat : null
|
||||
|
||||
if (!hat || typeof hat !== 'object') {
|
||||
return { secim: 'onerilen', talimat: '' }
|
||||
}
|
||||
|
||||
const talimat = typeof hat.talimat === 'string' ? hat.talimat : ''
|
||||
|
||||
if (SECIMLER.includes(hat.secim)) {
|
||||
return { secim: hat.secim, talimat }
|
||||
}
|
||||
|
||||
// Written before this screen existed: same meaning the bridge gives it.
|
||||
return { secim: hat.enabled === false ? 'kapali' : talimat.trim() ? 'ozel' : 'onerilen', talimat }
|
||||
} catch {
|
||||
return { secim: 'onerilen', talimat: '' }
|
||||
}
|
||||
}
|
||||
|
||||
export function writeIsHatti(raw: null | string, value: unknown): string {
|
||||
const v = value as Partial<IsHattiSettings> | null
|
||||
|
||||
if (!v || !SECIMLER.includes(v.secim as IsHattiSecim)) throw new Error('Invalid choice')
|
||||
const talimat = typeof v.talimat === 'string' ? v.talimat : ''
|
||||
|
||||
if (talimat.length > MAX_TALIMAT) throw new Error('Instruction too long')
|
||||
if (v.secim === 'ozel' && !talimat.trim()) throw new Error('Custom instruction is empty')
|
||||
let config: Record<string, unknown> = {}
|
||||
|
||||
try {
|
||||
const parsed = raw ? JSON.parse(raw) : {}
|
||||
config = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||
} catch {
|
||||
throw new Error('bridge.json is not valid JSON')
|
||||
}
|
||||
|
||||
const old = config.aiturk_hat && typeof config.aiturk_hat === 'object' ? (config.aiturk_hat as Record<string, unknown>) : {}
|
||||
const hat: Record<string, unknown> = { ...old, secim: v.secim, talimat }
|
||||
delete hat.enabled
|
||||
|
||||
return `${JSON.stringify({ ...config, aiturk_hat: hat }, null, 2)}\n`
|
||||
}
|
||||
|
||||
export function createIsHattiStore(directory: string) {
|
||||
const file = path.join(directory, 'bridge.json')
|
||||
const read = () => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null)
|
||||
|
||||
return {
|
||||
get: (): IsHattiSettings => readIsHatti(read()),
|
||||
save(value: unknown): IsHattiSettings {
|
||||
const text = writeIsHatti(read(), value)
|
||||
fs.mkdirSync(directory, { recursive: true })
|
||||
const tmp = `${file}.${process.pid}.tmp`
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tmp, text, 'utf8')
|
||||
fs.renameSync(tmp, file)
|
||||
} finally {
|
||||
if (fs.existsSync(tmp)) fs.unlinkSync(tmp)
|
||||
}
|
||||
|
||||
return readIsHatti(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -378,6 +378,10 @@ 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 { createCodexListeStore } from './codex-liste-settings'
|
||||
import { createIsHattiStore } from './is-hatti-settings'
|
||||
import { createStreamThrottle } from './stream-throttle'
|
||||
import { registerTerminalIpc } from './terminal-ipc'
|
||||
import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width'
|
||||
@@ -17192,6 +17196,24 @@ ipcMain.handle('hermes:quick-entry:settings:set', async (_event, patch) => {
|
||||
// resulting state and asks for changes over IPC. Windows only (the login item is
|
||||
// a Run-key value). All decisions live in electron/codex-link.ts.
|
||||
const AITURK_IDE_DATA_ROOT = path.dirname(HERMES_HOME)
|
||||
const compressionStore = createCompressionStore(path.join(AITURK_IDE_DATA_ROOT, 'codex-bridge'))
|
||||
const isHattiStore = createIsHattiStore(path.join(AITURK_IDE_DATA_ROOT, 'codex-bridge'))
|
||||
const codexListeStore = createCodexListeStore(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(
|
||||
@@ -17243,11 +17265,84 @@ ipcMain.handle('aiturk:codex-link:get', async 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:is-hatti:get', async event => {
|
||||
assertCodexLinkSender(event)
|
||||
return isHattiStore.get()
|
||||
})
|
||||
|
||||
ipcMain.handle('aiturk:is-hatti:set', async (event, settings) => {
|
||||
assertCodexLinkSender(event)
|
||||
return isHattiStore.save(settings)
|
||||
})
|
||||
|
||||
ipcMain.handle('aiturk:codex-liste:get', async event => {
|
||||
assertCodexLinkSender(event)
|
||||
return codexListeStore.get()
|
||||
})
|
||||
|
||||
ipcMain.handle('aiturk:codex-liste:set', async (event, settings) => {
|
||||
assertCodexLinkSender(event)
|
||||
return codexListeStore.save(settings)
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -103,11 +103,12 @@ it.skipIf(!process.env.AITURK_TEST_BACKEND_PORT)(
|
||||
).toBe(200)
|
||||
expect((await call(`${route}/resume?profile=default`, 'POST')).status).toBe(200)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const ws = new WebSocket(`wss://127.0.0.1:${proxyPort}/api/ws?profile=default`, {
|
||||
const tlsOptions: https.RequestOptions = {
|
||||
ca: identity.cert,
|
||||
checkServerIdentity,
|
||||
headers: { Authorization: `Bearer ${deviceToken}` }
|
||||
})
|
||||
}
|
||||
const ws = new WebSocket(`wss://127.0.0.1:${proxyPort}/api/ws?profile=default`, tlsOptions)
|
||||
const timer = setTimeout(() => {
|
||||
ws.terminate()
|
||||
reject(new Error('Chat ping timed out'))
|
||||
|
||||
@@ -295,6 +295,14 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
...(process.platform === 'win32'
|
||||
? {
|
||||
codexLink: {
|
||||
getCompression: () => ipcRenderer.invoke('aiturk:compression:get'),
|
||||
setCompression: settings => ipcRenderer.invoke('aiturk:compression:set', settings),
|
||||
getIsHatti: () => ipcRenderer.invoke('aiturk:is-hatti:get'),
|
||||
setIsHatti: settings => ipcRenderer.invoke('aiturk:is-hatti:set', settings),
|
||||
getCodexListe: () => ipcRenderer.invoke('aiturk:codex-liste:get'),
|
||||
setCodexListe: settings => ipcRenderer.invoke('aiturk:codex-liste:set', settings),
|
||||
getStagedTransition: () => ipcRenderer.invoke('aiturk:staged-transition:get'),
|
||||
setStagedTransition: patch => ipcRenderer.invoke('aiturk:staged-transition:set', patch),
|
||||
get: () => ipcRenderer.invoke('aiturk:codex-link:get'),
|
||||
set: patch => ipcRenderer.invoke('aiturk:codex-link:set', patch)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
export interface StagedTransitionSettings {
|
||||
enabled: boolean
|
||||
endpoint: string
|
||||
model: string
|
||||
fallbackModel: string
|
||||
rejectionRules: { from: string; to: string }[]
|
||||
exemptModels: string[]
|
||||
thresholds: { ret: number; sart: number }
|
||||
questions: { ret: string; sart: string }
|
||||
criteria: { ret: { true: string; false: string }; sart: { true: string; false: string } }
|
||||
timeoutSeconds: number
|
||||
maxConcurrent: number
|
||||
queueWaitSeconds: number
|
||||
failureAction: 'pass' | 'next'
|
||||
// Schema 2 (14 Oct 2026). A schema 1 file gets these from the defaults; every stage starts disabled.
|
||||
watchModels: string[]
|
||||
prefilter: { enabled: boolean; question: string; threshold: number; target: string; timeoutSeconds: number }
|
||||
autoRoute: {
|
||||
enabled: boolean
|
||||
model: string
|
||||
timeoutSeconds: number
|
||||
questions: { zor: string; arastirma: string }
|
||||
thresholds: { zor: number; arastirma: number }
|
||||
targets: { basit: string; zor: string; arastirma: string }
|
||||
}
|
||||
finishCheck: { enabled: boolean; question: string; threshold: number; note: string }
|
||||
verifierCheck: { enabled: boolean; question: string; threshold: number }
|
||||
policy: { source: 'local' | 'dify'; endpoint: string; refreshMinutes: number }
|
||||
}
|
||||
|
||||
export interface StagedTransitionPolicyStatus {
|
||||
fetchedAt?: string
|
||||
lastGoodAt?: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
sections: string[]
|
||||
}
|
||||
|
||||
export interface StagedTransitionState {
|
||||
settings: StagedTransitionSettings
|
||||
keyConfigured: boolean
|
||||
policyKeyConfigured?: boolean
|
||||
policyStatus?: StagedTransitionPolicyStatus | null
|
||||
models?: { id: string; name: string }[]
|
||||
modelsAvailable?: boolean
|
||||
}
|
||||
|
||||
export interface StagedTransitionPatch {
|
||||
settings: StagedTransitionSettings
|
||||
apiKey?: string
|
||||
policyApiKey?: string
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
createStagedTransitionStore,
|
||||
readPolicyStatus,
|
||||
stagedTransitionDefaults,
|
||||
validateStagedTransition
|
||||
} from './staged-transition'
|
||||
|
||||
const dirs: string[] = []
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
describe('staged transition settings', () => {
|
||||
it('persists rules and protects keys without returning a secret', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jev-settings-test-'))
|
||||
dirs.push(dir)
|
||||
const store = createStagedTransitionStore(dir, path.join(dir, 'missing.env'), () => 'encrypted-test-value')
|
||||
const settings = structuredClone(stagedTransitionDefaults)
|
||||
settings.fallbackModel = 'fallback'
|
||||
settings.rejectionRules = [{ from: 'model-a', to: 'model-b' }]
|
||||
const saved = store.save({ settings, apiKey: 'dummy-secret' })
|
||||
expect(saved.settings.rejectionRules).toEqual(settings.rejectionRules)
|
||||
expect(saved.keyConfigured).toBe(true)
|
||||
expect(JSON.stringify(saved)).not.toContain('dummy-secret')
|
||||
expect(fs.readFileSync(path.join(dir, 'jev-settings.json'), 'utf8')).not.toContain('dummy-secret')
|
||||
expect(store.get()).toEqual(saved)
|
||||
store.save({ settings: { ...settings, timeoutSeconds: 123 } })
|
||||
expect(store.get().keyConfigured).toBe(true)
|
||||
})
|
||||
it('rejects invalid rules without replacing the saved settings', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jev-settings-test-'))
|
||||
dirs.push(dir)
|
||||
const store = createStagedTransitionStore(dir, path.join(dir, 'missing.env'), x => x)
|
||||
const before = store.save({ settings: structuredClone(stagedTransitionDefaults) })
|
||||
expect(() =>
|
||||
store.save({
|
||||
settings: {
|
||||
...before.settings,
|
||||
rejectionRules: [
|
||||
{ from: 'a', to: 'b' },
|
||||
{ from: 'b', to: 'a' }
|
||||
]
|
||||
}
|
||||
})
|
||||
).toThrow()
|
||||
expect(store.get().settings).toEqual(before.settings)
|
||||
expect(() => validateStagedTransition({ ...before.settings, maxConcurrent: -1 })).toThrow()
|
||||
expect(() => validateStagedTransition({ ...before.settings, thresholds: { ret: NaN, sart: 0.7 } })).toThrow()
|
||||
expect(() => validateStagedTransition({ ...before.settings, endpoint: 'http://example.com' })).toThrow()
|
||||
})
|
||||
it('loads a schema 1 file with every new stage disabled and keeps unknown-free output', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jev-settings-test-'))
|
||||
dirs.push(dir)
|
||||
const old = structuredClone(stagedTransitionDefaults) as unknown as Record<string, unknown>
|
||||
|
||||
for (const key of ['watchModels', 'prefilter', 'autoRoute', 'finishCheck', 'verifierCheck', 'policy']) {
|
||||
delete old[key]
|
||||
}
|
||||
fs.writeFileSync(path.join(dir, 'jev-settings.json'), JSON.stringify({ settings: old, protectedKey: 'k' }))
|
||||
const store = createStagedTransitionStore(dir, path.join(dir, 'missing.env'), x => x)
|
||||
const state = store.get()
|
||||
expect(state.settings.prefilter.enabled).toBe(false)
|
||||
expect(state.settings.autoRoute.enabled).toBe(false)
|
||||
expect(state.settings.finishCheck.enabled).toBe(false)
|
||||
expect(state.settings.verifierCheck.enabled).toBe(false)
|
||||
expect(state.settings.policy.source).toBe('local')
|
||||
expect(state.keyConfigured).toBe(true)
|
||||
const partial = { ...structuredClone(stagedTransitionDefaults), prefilter: { enabled: true, threshold: 0.8 } }
|
||||
const filled = validateStagedTransition(partial)
|
||||
expect(filled.prefilter.enabled).toBe(true)
|
||||
expect(filled.prefilter.threshold).toBe(0.8)
|
||||
expect(filled.prefilter.target).toBe(stagedTransitionDefaults.prefilter.target)
|
||||
})
|
||||
it('keeps the Dify key protected and reports the last rules fetch', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jev-settings-test-'))
|
||||
dirs.push(dir)
|
||||
const store = createStagedTransitionStore(dir, path.join(dir, 'missing.env'), () => 'protected-value')
|
||||
const settings = { ...structuredClone(stagedTransitionDefaults), policy: { ...stagedTransitionDefaults.policy, source: 'dify' as const } }
|
||||
const saved = store.save({ settings, policyApiKey: 'app-dummy-secret' })
|
||||
expect(saved.policyKeyConfigured).toBe(true)
|
||||
expect(fs.readFileSync(path.join(dir, 'jev-settings.json'), 'utf8')).not.toContain('app-dummy-secret')
|
||||
expect(store.save({ settings }).policyKeyConfigured).toBe(true)
|
||||
expect(store.save({ settings, policyApiKey: '' }).policyKeyConfigured).toBe(false)
|
||||
expect(readPolicyStatus(dir)).toBeNull()
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'jev-policy.json'),
|
||||
JSON.stringify({ fetchedAt: 'now', ok: true, error: 'Dify down', overrides: { autoRoute: { enabled: true } } })
|
||||
)
|
||||
expect(store.get().policyStatus).toEqual({ fetchedAt: 'now', lastGoodAt: undefined, ok: true, error: 'Dify down', sections: ['autoRoute'] })
|
||||
})
|
||||
it('rejects invalid decision stage values', () => {
|
||||
const base = structuredClone(stagedTransitionDefaults)
|
||||
expect(() => validateStagedTransition({ ...base, prefilter: { ...base.prefilter, threshold: 2 } })).toThrow()
|
||||
expect(() => validateStagedTransition({ ...base, prefilter: { ...base.prefilter, timeoutSeconds: 0 } })).toThrow()
|
||||
expect(() =>
|
||||
validateStagedTransition({ ...base, autoRoute: { ...base.autoRoute, targets: { ...base.autoRoute.targets, zor: 'Otomatik' } } })
|
||||
).toThrow()
|
||||
expect(() => validateStagedTransition({ ...base, finishCheck: { ...base.finishCheck, note: '' } })).toThrow()
|
||||
expect(() => validateStagedTransition({ ...base, policy: { ...base.policy, endpoint: 'http://dify.example' } })).toThrow()
|
||||
expect(() => validateStagedTransition({ ...base, policy: { ...base.policy, source: 'web' } })).toThrow()
|
||||
expect(() => validateStagedTransition({ ...base, watchModels: ['a', 'a'] })).toThrow()
|
||||
for (const endpoint of ['https:dify.example/v1', 'https:/dify.example', 'https:///dify.example', 'ftp://dify.example']) {
|
||||
expect(() => validateStagedTransition({ ...base, policy: { ...base.policy, endpoint } })).toThrow()
|
||||
expect(() => validateStagedTransition({ ...base, endpoint })).toThrow()
|
||||
}
|
||||
expect(() =>
|
||||
validateStagedTransition({ ...base, prefilter: { ...base.prefilter, target: base.autoRoute.model } })
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,329 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import defaults from '../assets/codex-bridge/jev-settings-defaults.json'
|
||||
|
||||
import type {
|
||||
StagedTransitionPatch,
|
||||
StagedTransitionPolicyStatus,
|
||||
StagedTransitionSettings,
|
||||
StagedTransitionState
|
||||
} from './staged-transition-types'
|
||||
|
||||
export const stagedTransitionDefaults = defaults as StagedTransitionSettings
|
||||
|
||||
// Sections added in schema 2 (14 Oct 2026). Mirrors codex-bridge/jev_settings.py: a missing section or
|
||||
// field comes from the defaults, so a schema 1 file stays valid and every new stage starts disabled.
|
||||
const SECTIONS = ['watchModels', 'prefilter', 'autoRoute', 'finishCheck', 'verifierCheck', 'policy'] as const
|
||||
const POLICY_FILE = 'jev-policy.json'
|
||||
|
||||
type Plain = Record<string, unknown>
|
||||
|
||||
const isPlain = (value: unknown): value is Plain => Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
|
||||
function fill(value: unknown, fallback: unknown): unknown {
|
||||
if (!isPlain(fallback) || !isPlain(value)) {
|
||||
return value
|
||||
}
|
||||
const out: Plain = { ...value }
|
||||
|
||||
for (const [key, def] of Object.entries(fallback)) {
|
||||
out[key] = key in value ? fill(value[key], def) : structuredClone(def)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export function withSchemaDefaults(value: unknown): unknown {
|
||||
if (!isPlain(value)) {
|
||||
return value
|
||||
}
|
||||
const out: Plain = { ...value }
|
||||
const defs = stagedTransitionDefaults as unknown as Plain
|
||||
|
||||
for (const section of SECTIONS) {
|
||||
out[section] = section in value ? fill(value[section], defs[section]) : structuredClone(defs[section])
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
const text = (s: unknown, max = 10000): s is string => typeof s === 'string' && s.trim().length > 0 && s.length <= max
|
||||
const probability = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n) && n >= 0 && n <= 1
|
||||
const integer = (n: unknown, min: number, max: number): n is number =>
|
||||
typeof n === 'number' && Number.isInteger(n) && n >= min && n <= max
|
||||
|
||||
// Same acceptance as the bridge (urllib.parse.urlsplit): the literal https:// prefix and a host. WHATWG URL alone
|
||||
// also accepts "https:host" and "https:/host", which the bridge rejects.
|
||||
function https(value: unknown): value is string {
|
||||
if (!text(value, 2048) || !/^https:\/\/[^/?#\s]/i.test(value.trim())) {
|
||||
return false
|
||||
}
|
||||
let url: URL
|
||||
|
||||
try {
|
||||
url = new URL(value.trim())
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
return url.protocol === 'https:' && Boolean(url.hostname) && !url.username && !url.password && !url.hash
|
||||
}
|
||||
|
||||
export function validateStagedTransition(input: unknown): StagedTransitionSettings {
|
||||
if (!input || typeof input !== 'object') {
|
||||
throw new Error('Invalid settings')
|
||||
}
|
||||
const v = withSchemaDefaults(input) as StagedTransitionSettings
|
||||
|
||||
const list = (s: unknown, allowEmpty: boolean) =>
|
||||
Array.isArray(s) &&
|
||||
(allowEmpty || s.length > 0) &&
|
||||
s.length <= 64 &&
|
||||
s.every(x => text(x, 256)) &&
|
||||
new Set(s.map(x => x.trim())).size === s.length
|
||||
|
||||
const seconds = (n: unknown) => integer(n, 1, 3600)
|
||||
|
||||
if (typeof v.enabled !== 'boolean' || !text(v.endpoint, 2048) || !text(v.model, 256)) {
|
||||
throw new Error('Invalid connection settings')
|
||||
}
|
||||
|
||||
if (!https(v.endpoint)) {
|
||||
throw new Error('HTTPS endpoint required')
|
||||
}
|
||||
|
||||
if (
|
||||
!text(v.fallbackModel, 256) ||
|
||||
!list(v.exemptModels, true) ||
|
||||
!Array.isArray(v.rejectionRules) ||
|
||||
v.rejectionRules.length > 64 ||
|
||||
v.rejectionRules.some(r => !text(r.from, 256) || !text(r.to, 256) || r.from.trim() === r.to.trim()) ||
|
||||
new Set(v.rejectionRules.map(r => r.from.trim())).size !== v.rejectionRules.length
|
||||
) {
|
||||
throw new Error('Invalid model rules')
|
||||
}
|
||||
|
||||
const routes = new Map(v.rejectionRules.map(r => [r.from.trim(), r.to.trim()]))
|
||||
|
||||
for (const start of routes.keys()) {
|
||||
const seen = new Set<string>()
|
||||
let current: string | undefined = start
|
||||
|
||||
while (current && !v.exemptModels.includes(current)) {
|
||||
if (seen.has(current)) {
|
||||
throw new Error('Model rules contain a cycle')
|
||||
}
|
||||
seen.add(current)
|
||||
current = routes.get(current) ?? (current === v.fallbackModel.trim() ? undefined : v.fallbackModel.trim())
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of ['ret', 'sart'] as const) {
|
||||
if (!text(v.questions?.[id]) || !probability(v.thresholds?.[id])) {
|
||||
throw new Error('Invalid evaluation rule')
|
||||
}
|
||||
|
||||
for (const outcome of ['true', 'false'] as const) {
|
||||
if (typeof v.criteria?.[id]?.[outcome] !== 'string' || v.criteria[id][outcome].length > 10000) {
|
||||
throw new Error('Invalid decision criteria')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!seconds(v.timeoutSeconds) ||
|
||||
!seconds(v.queueWaitSeconds) ||
|
||||
!integer(v.maxConcurrent, 0, 512) ||
|
||||
!['pass', 'next'].includes(v.failureAction)
|
||||
) {
|
||||
throw new Error('Invalid request limits')
|
||||
}
|
||||
|
||||
if (!list(v.watchModels, true)) {
|
||||
throw new Error('Invalid watch models')
|
||||
}
|
||||
|
||||
const p = v.prefilter
|
||||
|
||||
if (
|
||||
typeof p?.enabled !== 'boolean' ||
|
||||
!text(p.question) ||
|
||||
!probability(p.threshold) ||
|
||||
!text(p.target, 256) ||
|
||||
!integer(p.timeoutSeconds, 1, 60)
|
||||
) {
|
||||
throw new Error('Invalid prefilter')
|
||||
}
|
||||
|
||||
const a = v.autoRoute
|
||||
|
||||
if (
|
||||
typeof a?.enabled !== 'boolean' ||
|
||||
!text(a.model, 256) ||
|
||||
!integer(a.timeoutSeconds, 1, 60) ||
|
||||
(['zor', 'arastirma'] as const).some(k => !text(a.questions?.[k]) || !probability(a.thresholds?.[k])) ||
|
||||
(['basit', 'zor', 'arastirma'] as const).some(k => !text(a.targets?.[k], 256))
|
||||
) {
|
||||
throw new Error('Invalid automatic routing')
|
||||
}
|
||||
|
||||
if (Object.values(a.targets).some(t => t.trim() === a.model.trim()) || p.target.trim() === a.model.trim()) {
|
||||
throw new Error('Automatic routing cannot target itself')
|
||||
}
|
||||
|
||||
const f = v.finishCheck
|
||||
|
||||
if (
|
||||
typeof f?.enabled !== 'boolean' ||
|
||||
!text(f.question) ||
|
||||
!probability(f.threshold) ||
|
||||
!text(f.note, 4000)
|
||||
) {
|
||||
throw new Error('Invalid finish check')
|
||||
}
|
||||
|
||||
const c = v.verifierCheck
|
||||
|
||||
if (typeof c?.enabled !== 'boolean' || !text(c.question) || !probability(c.threshold)) {
|
||||
throw new Error('Invalid verifier check')
|
||||
}
|
||||
|
||||
const pol = v.policy
|
||||
|
||||
if (!['local', 'dify'].includes(pol?.source) || !https(pol.endpoint) || !integer(pol.refreshMinutes, 1, 1440)) {
|
||||
throw new Error('Invalid policy source')
|
||||
}
|
||||
|
||||
const trimmed = (s: string) => s.trim()
|
||||
|
||||
return {
|
||||
enabled: v.enabled,
|
||||
endpoint: v.endpoint.trim(),
|
||||
model: v.model.trim(),
|
||||
fallbackModel: v.fallbackModel.trim(),
|
||||
rejectionRules: v.rejectionRules.map(r => ({ from: r.from.trim(), to: r.to.trim() })),
|
||||
exemptModels: v.exemptModels.map(trimmed),
|
||||
questions: { ...v.questions },
|
||||
thresholds: { ...v.thresholds },
|
||||
criteria: structuredClone(v.criteria),
|
||||
timeoutSeconds: v.timeoutSeconds,
|
||||
maxConcurrent: v.maxConcurrent,
|
||||
queueWaitSeconds: v.queueWaitSeconds,
|
||||
failureAction: v.failureAction,
|
||||
watchModels: v.watchModels.map(trimmed),
|
||||
prefilter: { ...p, target: p.target.trim() },
|
||||
autoRoute: {
|
||||
enabled: a.enabled,
|
||||
model: a.model.trim(),
|
||||
timeoutSeconds: a.timeoutSeconds,
|
||||
questions: { zor: a.questions.zor, arastirma: a.questions.arastirma },
|
||||
thresholds: { zor: a.thresholds.zor, arastirma: a.thresholds.arastirma },
|
||||
targets: { basit: a.targets.basit.trim(), zor: a.targets.zor.trim(), arastirma: a.targets.arastirma.trim() }
|
||||
},
|
||||
finishCheck: { enabled: f.enabled, question: f.question, threshold: f.threshold, note: f.note },
|
||||
verifierCheck: { enabled: c.enabled, question: c.question, threshold: c.threshold },
|
||||
policy: { source: pol.source, endpoint: pol.endpoint.trim(), refreshMinutes: pol.refreshMinutes }
|
||||
}
|
||||
}
|
||||
|
||||
// The bridge writes jev-policy.json after each Dify fetch; only a summary reaches the renderer.
|
||||
export function readPolicyStatus(directory: string): StagedTransitionPolicyStatus | null {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(path.join(directory, POLICY_FILE), 'utf8'))
|
||||
|
||||
if (!isPlain(data)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
fetchedAt: typeof data.fetchedAt === 'string' ? data.fetchedAt : undefined,
|
||||
lastGoodAt: typeof data.lastGoodAt === 'string' ? data.lastGoodAt : undefined,
|
||||
ok: data.ok === true,
|
||||
error: typeof data.error === 'string' && data.error ? data.error.slice(0, 500) : undefined,
|
||||
sections: isPlain(data.overrides) ? Object.keys(data.overrides) : []
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function createStagedTransitionStore(
|
||||
directory: string,
|
||||
legacyKeyFile: string,
|
||||
protect: (value: string) => string
|
||||
) {
|
||||
const file = path.join(directory, 'jev-settings.json')
|
||||
|
||||
const read = (): { settings: StagedTransitionSettings; protectedKey?: string; protectedPolicyKey?: string } => {
|
||||
if (!fs.existsSync(file)) {
|
||||
return { settings: structuredClone(stagedTransitionDefaults) }
|
||||
}
|
||||
const data = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
|
||||
return {
|
||||
settings: validateStagedTransition(data.settings),
|
||||
protectedKey: data.protectedKey,
|
||||
protectedPolicyKey: data.protectedPolicyKey
|
||||
}
|
||||
}
|
||||
|
||||
const state = (): StagedTransitionState => {
|
||||
const current = read()
|
||||
let legacy = Boolean(process.env.TYPESAFE_API_KEY?.trim())
|
||||
|
||||
if (!legacy && fs.existsSync(legacyKeyFile)) {
|
||||
const match = /^\s*TYPESAFE_API_KEY\s*=\s*(.*)$/m.exec(fs.readFileSync(legacyKeyFile, 'utf8'))
|
||||
legacy = Boolean(
|
||||
match?.[1]
|
||||
?.trim()
|
||||
.replace(/^(['"])(.*)\1$/, '$2')
|
||||
.trim()
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
settings: current.settings,
|
||||
keyConfigured: Boolean(current.protectedKey) || legacy,
|
||||
policyKeyConfigured: Boolean(current.protectedPolicyKey),
|
||||
policyStatus: readPolicyStatus(directory)
|
||||
}
|
||||
}
|
||||
|
||||
const secret = (value: unknown, name: string) => {
|
||||
if (value !== undefined && (typeof value !== 'string' || value.length > 8192)) {
|
||||
throw new Error(`Invalid ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get: state,
|
||||
save(patch: StagedTransitionPatch): StagedTransitionState {
|
||||
const settings = validateStagedTransition(patch?.settings)
|
||||
secret(patch.apiKey, 'API key')
|
||||
secret(patch.policyApiKey, 'policy key')
|
||||
|
||||
const current = read()
|
||||
|
||||
const update = (value: string | undefined, previous: string | undefined) =>
|
||||
value === undefined ? previous : value.trim() ? protect(value.trim()) : undefined
|
||||
|
||||
const protectedKey = update(patch.apiKey, current.protectedKey)
|
||||
const protectedPolicyKey = update(patch.policyApiKey, current.protectedPolicyKey)
|
||||
|
||||
fs.mkdirSync(directory, { recursive: true })
|
||||
const tmp = `${file}.${process.pid}.tmp`
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tmp, JSON.stringify({ settings, protectedKey, protectedPolicyKey }, null, 2), { mode: 0o600 })
|
||||
fs.renameSync(tmp, file)
|
||||
} finally {
|
||||
if (fs.existsSync(tmp)) {
|
||||
fs.unlinkSync(tmp)
|
||||
}
|
||||
}
|
||||
|
||||
return state()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "aiturk-ide",
|
||||
"productName": "AITURK IDE",
|
||||
"private": true,
|
||||
"version": "1.0.0-beta.28",
|
||||
"version": "1.0.0-beta.42",
|
||||
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
||||
"author": "AITURK / TurkServis",
|
||||
"repository": {
|
||||
@@ -151,7 +151,8 @@
|
||||
"unist-util-visit-parents": "6.0.2",
|
||||
"use-stick-to-bottom": "1.1.6",
|
||||
"vfile": "6.0.3",
|
||||
"web-haptics": "0.0.6"
|
||||
"web-haptics": "0.0.6",
|
||||
"ws": "8.21.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"get-windows": "9.3.0"
|
||||
@@ -169,6 +170,7 @@
|
||||
"@types/node": "22.20.1",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/ws": "8.18.2",
|
||||
"@vitejs/plugin-react": "6.0.3",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"bippy": "0.5.43",
|
||||
@@ -304,8 +306,11 @@
|
||||
"from": "assets/codex-bridge",
|
||||
"to": "codex-bridge",
|
||||
"filter": [
|
||||
"jev-settings-defaults.json",
|
||||
"compression-defaults.json",
|
||||
"**/*.py",
|
||||
"**/*.pyd",
|
||||
"native-watch.exe",
|
||||
"**/LICENSE",
|
||||
"!test_*.py"
|
||||
]
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
* - electronPlatformName: 'win32' | 'darwin' | 'linux'
|
||||
* - arch: Arch enum (0=ia32, 1=x64, 2=armv7l, 3=arm64, 4=universal)
|
||||
*/
|
||||
import { buildNativeWatchLauncher } from './build-native-watch-launcher.mjs'
|
||||
import { existsSync, rmSync, renameSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { Arch } from 'electron-builder'
|
||||
@@ -111,6 +112,7 @@ export function preserveRollbackBackup(appOutDir, productExeName = 'Hermes.exe')
|
||||
}
|
||||
|
||||
export default async function beforePack(context) {
|
||||
if (context?.electronPlatformName === 'win32') buildNativeWatchLauncher()
|
||||
const appOutDir = context && context.appOutDir
|
||||
const platformName = context && context.electronPlatformName
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Build the Windows artifact from source on Windows or the Linux/Wine CI host. */
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { rmSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const sourceHome = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../assets/codex-bridge')
|
||||
export function compilerCommand({ platform = process.platform, env = process.env, source, output }) {
|
||||
const args = ['/nologo', '/target:exe', '/platform:anycpu', '/reference:System.Web.Extensions.dll', `/out:${output}`, source]
|
||||
if (platform === 'win32') {
|
||||
return { command: path.win32.join(env.SystemRoot || 'C:/Windows', 'Microsoft.NET/Framework64/v4.0.30319/csc.exe'), args }
|
||||
}
|
||||
if (platform === 'linux') return { command: 'mcs', args }
|
||||
throw new Error(`Windows launcher compilation is not configured on ${platform}`)
|
||||
}
|
||||
|
||||
export function buildNativeWatchLauncher({ home = sourceHome, ...options } = {}) {
|
||||
const output = path.join(home, 'native-watch.exe')
|
||||
const invocation = compilerCommand({ ...options, source: path.join(home, 'NativeWatchBootstrap.cs'), output })
|
||||
// A missing compiler or broken source must fail packaging, never reuse an old EXE.
|
||||
rmSync(output, { force: true })
|
||||
execFileSync(invocation.command, invocation.args, { windowsHide: true, stdio: 'inherit' })
|
||||
const bytes = readFileSync(output)
|
||||
if (bytes.length < 64 || bytes.toString('ascii', 0, 2) !== 'MZ' || bytes.toString('ascii', bytes.readUInt32LE(60), bytes.readUInt32LE(60) + 4) !== 'PE\0\0') {
|
||||
rmSync(output, { force: true })
|
||||
throw new Error('Native watch compiler did not produce a Windows PE executable')
|
||||
}
|
||||
return output
|
||||
}
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) buildNativeWatchLauncher()
|
||||
@@ -0,0 +1,36 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, copyFileSync, readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { compilerCommand, buildNativeWatchLauncher } from './build-native-watch-launcher.mjs'
|
||||
|
||||
const desktop = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
test('Windows and Linux hosts build the same Windows source with the required assembly', () => {
|
||||
const input = { source: 'source with spaces.cs', output: 'output with spaces.exe' }
|
||||
const win = compilerCommand({ ...input, platform: 'win32', env: { SystemRoot: 'D:/Windows' } })
|
||||
const linux = compilerCommand({ ...input, platform: 'linux' })
|
||||
assert.equal(win.command, 'D:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe')
|
||||
assert.equal(linux.command, 'mcs')
|
||||
assert.deepEqual(win.args, linux.args)
|
||||
assert.ok(linux.args.includes('/reference:System.Web.Extensions.dll'))
|
||||
assert.equal(linux.args.at(-1), input.source)
|
||||
assert.throws(() => compilerCommand({ ...input, platform: 'darwin' }), /not configured/)
|
||||
})
|
||||
|
||||
test('real host compiler builds PE from source, and broken source fails without stale artifact', () => {
|
||||
// Inside the source workspace, including on the mounted Linux CI fixture.
|
||||
const home = mkdtempSync(path.join(desktop, '.native-watch-test-'))
|
||||
try {
|
||||
copyFileSync(path.join(desktop, 'assets/codex-bridge/NativeWatchBootstrap.cs'), path.join(home, 'NativeWatchBootstrap.cs'))
|
||||
const exe = buildNativeWatchLauncher({ home })
|
||||
const first = readFileSync(exe)
|
||||
assert.equal(first.toString('ascii', 0, 2), 'MZ')
|
||||
writeFileSync(exe, 'stale artifact')
|
||||
buildNativeWatchLauncher({ home })
|
||||
assert.equal(readFileSync(exe).toString('ascii', 0, 2), 'MZ')
|
||||
writeFileSync(path.join(home, 'NativeWatchBootstrap.cs'), 'invalid C# source')
|
||||
assert.throws(() => buildNativeWatchLauncher({ home }))
|
||||
assert.equal(existsSync(exe), false)
|
||||
} finally { rmSync(home, { recursive: true, force: true }) }
|
||||
})
|
||||
@@ -12,6 +12,7 @@ vi.mock('@/store/notifications', () => ({ notifyError }))
|
||||
|
||||
const RUNNING: CodexLinkStatus = {
|
||||
enabled: true,
|
||||
mod: 'aiturk',
|
||||
codexFound: true,
|
||||
venvReady: true,
|
||||
foreign: false,
|
||||
@@ -139,6 +140,13 @@ describe('Codex Link settings', () => {
|
||||
expect(screen.getByRole('switch', { name: 'Codex Bağlama' }).getAttribute('aria-checked')).toBe('false')
|
||||
})
|
||||
|
||||
it('does not claim original settings were restored when disconnect failed', async () => {
|
||||
get.mockResolvedValue({ ...RUNNING, enabled: false, lastError: 'config-write-failed' })
|
||||
renderPage()
|
||||
expect(await screen.findByText('Codex ayar dosyası yazılamadı.')).toBeTruthy()
|
||||
expect(screen.queryByText('Codex Bağlama kapalı. Codex kendi ayarlarıyla çalışıyor.')).toBeNull()
|
||||
})
|
||||
|
||||
it('rolls the switch back and reports when the change fails', async () => {
|
||||
get.mockResolvedValue(RUNNING)
|
||||
set.mockRejectedValue(new Error('codex-link:unavailable'))
|
||||
@@ -191,3 +199,18 @@ describe('Codex Link settings', () => {
|
||||
expect(get).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('connection mode controls', () => {
|
||||
it('sends the mode and renders the saved direct preference', async () => {
|
||||
get.mockResolvedValue({ ...RUNNING, mod: 'dogrudan' })
|
||||
set.mockResolvedValue({ ...RUNNING, mod: 'aiturk' })
|
||||
renderPage()
|
||||
const direct = await screen.findByRole('radio', { name: 'Doğrudan (önerilen): abonelik modelleri köprüden bağımsız' })
|
||||
await waitFor(() => expect((direct as HTMLInputElement).checked).toBe(true))
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'AITURK: combolar Codex listesinde, tüm modeller köprüden' }))
|
||||
await waitFor(() => expect(set).toHaveBeenCalledWith({ mod: 'aiturk' }))
|
||||
await waitFor(() => expect((direct as HTMLInputElement).checked).toBe(false))
|
||||
expect(screen.getByText("Codex'i yeniden başlatın")).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,9 @@ import { notifyError } from '@/store/notifications'
|
||||
import type { CodexLinkPatch, CodexLinkStatus } from '../../../electron/codex-link-types'
|
||||
|
||||
import { EmptyState, ListRow, ListRowSkeleton, SettingsContent, SettingsSection } from './primitives'
|
||||
import { CompressionSettingsView } from './compression-settings'
|
||||
import { CodexListeSettingsView } from './codex-liste-settings'
|
||||
import { IsHattiSettingsView } from './is-hatti-settings'
|
||||
|
||||
type Tone = 'muted' | 'ok' | 'warn'
|
||||
|
||||
@@ -40,6 +43,7 @@ export function codexLinkStatusLines(status: CodexLinkStatus, copy: CodexLinkCop
|
||||
}
|
||||
|
||||
if (!status.enabled) {
|
||||
if (status.lastError) return [...lines, ...error]
|
||||
return [...lines, { id: 'off', text: copy.off, tone: 'muted' }, ...error]
|
||||
}
|
||||
|
||||
@@ -55,7 +59,7 @@ export function codexLinkStatusLines(status: CodexLinkStatus, copy: CodexLinkCop
|
||||
|
||||
if (status.ideDurum === 'anahtar-yok') {
|
||||
lines.push({ id: 'ide', text: copy.signIn, tone: 'warn' })
|
||||
} else if (status.ideDurum === 'tamam' && status.ideModel !== null) {
|
||||
} else if (status.mod === 'aiturk' && status.ideDurum === 'tamam' && status.ideModel !== null) {
|
||||
lines.push({ id: 'ide', text: copy.models(status.ideModel), tone: 'ok' })
|
||||
}
|
||||
|
||||
@@ -168,6 +172,27 @@ export function CodexLinkSettings() {
|
||||
title={copy.title}
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
title={copy.modeTitle}
|
||||
below={
|
||||
<fieldset disabled={!status || busy !== null} className="grid gap-2 py-2">
|
||||
{(['dogrudan', 'aiturk'] as const).map(mod => (
|
||||
<label key={mod} className="flex min-h-11 cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="codex-link-mode"
|
||||
value={mod}
|
||||
checked={(status?.mod ?? 'dogrudan') === mod}
|
||||
onChange={() => void change({ mod }, 'toggle')}
|
||||
/>
|
||||
<span>{mod === 'dogrudan' ? copy.directMode : copy.aiturkMode}</span>
|
||||
</label>
|
||||
))}
|
||||
<p className="text-(--ui-text-tertiary)">{copy.modeRestartNote}</p>
|
||||
</fieldset>
|
||||
}
|
||||
/>
|
||||
|
||||
{!status && !loadFailed && <ListRowSkeleton wide />}
|
||||
|
||||
{loadFailed && !status && (
|
||||
@@ -228,6 +253,9 @@ export function CodexLinkSettings() {
|
||||
{copy.restartNote}
|
||||
</p>
|
||||
</SettingsSection>
|
||||
<IsHattiSettingsView />
|
||||
<CodexListeSettingsView />
|
||||
<CompressionSettingsView />
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n/context'
|
||||
|
||||
import { CodexListeSettingsView } from './codex-liste-settings'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
const aday = (slug: string, extra: Record<string, unknown> = {}) => ({ slug, ad: slug.replace('ide/', ''), kind: 'model', medya: false, efor: false, neden: '', sira: null, menude: false, ...extra })
|
||||
|
||||
it('counts the menu with the subscription models, pins and hides, and saves the choice', async () => {
|
||||
const adaylar = [aday('ide/agnes'), aday('ide/agnes-high', { efor: true }), aday('ide/veo', { medya: true }), ...Array.from({ length: 99 }, (_, i) => aday(`ide/m${String(i).padStart(2, '0')}`))]
|
||||
const save = vi.fn(async (ayar: unknown) => ({ ayar, adaylar, ozgunSayisi: 2, sinir: 100, at: 't' }))
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
|
||||
codexLink: {
|
||||
getCodexListe: async () => ({ ayar: { medya_gizle: true, efor_gizle: true, one_al: [], gizli: [] }, adaylar, ozgunSayisi: 2, sinir: 100, at: 't' }),
|
||||
setCodexListe: save
|
||||
}
|
||||
}
|
||||
render(<I18nProvider configClient={null} initialLocale="tr"><CodexListeSettingsView /></I18nProvider>)
|
||||
// 2 subscription + 98 of the 100 shown candidates fit; agnes-high and veo are hidden.
|
||||
expect(await screen.findByText('Menüde 100 / 100 · menüye sığmayan 2 · gizli 2')).toBeTruthy()
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Model ara' }), { target: { value: 'm98' } })
|
||||
const satir = screen.getByText('m98').closest('li')!
|
||||
expect(within(satir).getByText('Menüye sığmıyor')).toBeTruthy()
|
||||
fireEvent.click(within(satir).getByRole('button', { name: 'Öne al' }))
|
||||
expect(within(satir).getByText('Menüde, 3. sıra')).toBeTruthy()
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Model ara' }), { target: { value: 'agnes' } })
|
||||
fireEvent.click(within(screen.getByText('agnes').closest('li')!).getByRole('button', { name: 'Gizle' }))
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Düşük/orta/yüksek kopyalarını gizle' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Kaydet' }))
|
||||
await waitFor(() => expect(save).toHaveBeenCalledWith({ medya_gizle: true, efor_gizle: false, one_al: ['ide/m98'], gizli: ['ide/agnes'] }))
|
||||
expect(await screen.findByText(/Codex model listesini birkaç dakika içinde yeniler/)).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Varsayılana dön' }))
|
||||
await waitFor(() => expect(save).toHaveBeenLastCalledWith({ medya_gizle: true, efor_gizle: true, one_al: [], gizli: [] }))
|
||||
})
|
||||
|
||||
it('explains when the bridge has not built the list yet', async () => {
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
|
||||
codexLink: { getCodexListe: async () => ({ ayar: { medya_gizle: true, efor_gizle: true, one_al: [], gizli: [] }, adaylar: [], ozgunSayisi: 0, sinir: 100, at: null }), setCodexListe: vi.fn() }
|
||||
}
|
||||
render(<I18nProvider configClient={null} initialLocale="tr"><CodexListeSettingsView /></I18nProvider>)
|
||||
expect(await screen.findByText(/Model listesi henüz oluşmadı/)).toBeTruthy()
|
||||
})
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useI18n } from '@/i18n/context'
|
||||
import { Link } from '@/lib/icons'
|
||||
|
||||
import {
|
||||
CODEX_LISTE_VARSAYILAN,
|
||||
type CodexAday,
|
||||
type CodexListeSettings,
|
||||
type CodexListeState,
|
||||
onizle
|
||||
} from '../../../electron/codex-liste-types'
|
||||
|
||||
import { ListRow, SettingsSection } from './primitives'
|
||||
|
||||
type Filtre = 'hepsi' | 'menude' | 'disarida' | 'gizli'
|
||||
|
||||
/** Which added models fill the Codex model picker. The Codex desktop app shows at most 100 models (its own
|
||||
* fixed limit); the user pins, hides and filters to choose which ones make it. */
|
||||
export function CodexListeSettingsView() {
|
||||
const api = window.hermesDesktop?.codexLink
|
||||
const { locale } = useI18n()
|
||||
const tr = locale === 'tr'
|
||||
const t = (a: string, b: string) => (tr ? a : b)
|
||||
const [state, setState] = useState<CodexListeState | null>(null)
|
||||
const [draft, setDraft] = useState<CodexListeSettings | null>(null)
|
||||
const [arama, setArama] = useState('')
|
||||
const [filtre, setFiltre] = useState<Filtre>('hepsi')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api?.getCodexListe?.().then(
|
||||
next => {
|
||||
if (active) {
|
||||
setState(next)
|
||||
setDraft(next.ayar)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (active) {
|
||||
setFailed(true)
|
||||
setMessage(tr ? 'Codex model listesi ayarı okunamadı.' : 'Could not load the Codex model list setting.')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [api, tr])
|
||||
|
||||
const onizleme = useMemo(() => (state && draft ? onizle(state, draft) : []), [state, draft])
|
||||
|
||||
if (!api?.getCodexListe || !api.setCodexListe) return null
|
||||
|
||||
const menude = onizleme.filter(a => a.menude).length
|
||||
const disarida = onizleme.filter(a => !a.neden && !a.menude).length
|
||||
const gizlenen = onizleme.filter(a => a.neden).length
|
||||
const sinir = state?.sinir ?? 100
|
||||
const ozgun = state?.ozgunSayisi ?? 0
|
||||
|
||||
const sirali = [...onizleme].sort((a, b) => (a.sira ?? 1e9) - (b.sira ?? 1e9))
|
||||
const aranan = arama.trim().toLocaleLowerCase(locale)
|
||||
|
||||
const gorunen = sirali.filter(a => {
|
||||
if (aranan && !`${a.ad} ${a.slug}`.toLocaleLowerCase(locale).includes(aranan)) return false
|
||||
if (filtre === 'menude') return a.menude
|
||||
if (filtre === 'disarida') return !a.neden && !a.menude
|
||||
if (filtre === 'gizli') return Boolean(a.neden)
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
const degistir = (patch: Partial<CodexListeSettings>) => draft && setDraft({ ...draft, ...patch })
|
||||
|
||||
const oneAl = (slug: string) => {
|
||||
if (!draft) return
|
||||
const pinned = draft.one_al.includes(slug)
|
||||
degistir({ one_al: pinned ? draft.one_al.filter(s => s !== slug) : [...draft.one_al, slug], gizli: draft.gizli.filter(s => s !== slug) })
|
||||
}
|
||||
|
||||
const yukari = (slug: string) => {
|
||||
if (!draft) return
|
||||
const i = draft.one_al.indexOf(slug)
|
||||
if (i <= 0) return
|
||||
const next = [...draft.one_al]
|
||||
;[next[i - 1], next[i]] = [next[i], next[i - 1]]
|
||||
degistir({ one_al: next })
|
||||
}
|
||||
|
||||
const gizle = (slug: string) => {
|
||||
if (!draft) return
|
||||
const hidden = draft.gizli.includes(slug)
|
||||
degistir({ gizli: hidden ? draft.gizli.filter(s => s !== slug) : [...draft.gizli, slug], one_al: draft.one_al.filter(s => s !== slug) })
|
||||
}
|
||||
|
||||
const save = async (next: CodexListeSettings) => {
|
||||
setBusy(true)
|
||||
setMessage('')
|
||||
setFailed(false)
|
||||
|
||||
try {
|
||||
const saved = await api.setCodexListe!(next)
|
||||
setState({ ...saved, adaylar: state?.adaylar ?? saved.adaylar })
|
||||
setDraft(saved.ayar)
|
||||
setMessage(t('Kaydedildi. Codex model listesini birkaç dakika içinde yeniler (en geç 5 dakika sonra Codex penceresine dönünce).', 'Saved. Codex refreshes its model list within a few minutes (at the latest when you return to its window after 5 minutes).'))
|
||||
} catch {
|
||||
setFailed(true)
|
||||
setMessage(t('Kaydedilemedi.', 'Could not save.'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const durum = (a: CodexAday) => {
|
||||
if (a.neden === 'gizli') return t('Gizli', 'Hidden')
|
||||
if (a.neden === 'medya') return t('Resim/ses modeli, gizli', 'Image/audio model, hidden')
|
||||
if (a.neden === 'efor') return t('Düşük/orta/yüksek kopyası, gizli', 'Effort copy, hidden')
|
||||
if (a.menude) return t(`Menüde, ${(a.sira ?? 0) + 1}. sıra`, `In the menu, position ${(a.sira ?? 0) + 1}`)
|
||||
|
||||
return t('Menüye sığmıyor', 'Does not fit in the menu')
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection icon={Link} title={t('Codex’te görünecek modeller', 'Models shown in Codex')}>
|
||||
<p className="text-sm text-(--ui-text-secondary)">
|
||||
{t(
|
||||
`Codex’in model menüsü en çok ${sinir} model gösterir; bu sınır Codex uygulamasının kendi sınırıdır. Codex’in kendi ${ozgun} modeli her zaman başta durur. Kalan yerlere hangi modellerin gireceğini burada seçin: öne aldıklarınız başa gelir, gizledikleriniz çıkar. Menüde olmayan bir modeli Codex’e “modeli … yap” diyerek yine seçebilirsiniz.`,
|
||||
`The Codex model menu shows at most ${sinir} models; this is the Codex app’s own limit. Codex’s own ${ozgun} models always come first. Choose which models fill the rest: pinned ones come first, hidden ones are removed. You can still pick a model that is not in the menu by asking Codex to switch to it.`
|
||||
)}
|
||||
</p>
|
||||
{state && draft && (
|
||||
<>
|
||||
<p className="text-sm font-medium" role="status">
|
||||
{t(`Menüde ${ozgun + menude} / ${sinir} · menüye sığmayan ${disarida} · gizli ${gizlenen}`, `In the menu ${ozgun + menude} / ${sinir} · not fitting ${disarida} · hidden ${gizlenen}`)}
|
||||
</p>
|
||||
<ListRow
|
||||
action={<Switch aria-label={t('Resim, ses ve video modellerini gizle', 'Hide image, audio and video models')} checked={draft.medya_gizle} disabled={busy} onCheckedChange={v => degistir({ medya_gizle: v })} />}
|
||||
description={t('Bunlar Codex sohbetinde çalışmaz.', 'These do not work in a Codex chat.')}
|
||||
title={t('Resim, ses ve video modellerini gizle', 'Hide image, audio and video models')}
|
||||
/>
|
||||
<ListRow
|
||||
action={<Switch aria-label={t('Düşük/orta/yüksek kopyalarını gizle', 'Hide low/medium/high copies')} checked={draft.efor_gizle} disabled={busy} onCheckedChange={v => degistir({ efor_gizle: v })} />}
|
||||
description={t('Ana modeli listede olan “-low”, “-high” gibi kopyalar gizlenir; düşünme seviyesini Codex’in kendi seçicisinden seçersiniz.', 'Copies such as “-low” or “-high” whose base model is listed are hidden; pick the reasoning level in Codex’s own selector.')}
|
||||
title={t('Düşük/orta/yüksek kopyalarını gizle', 'Hide low/medium/high copies')}
|
||||
/>
|
||||
{state.adaylar.length === 0 ? (
|
||||
<p className="text-sm text-(--ui-text-secondary)">
|
||||
{t('Model listesi henüz oluşmadı. Codex’i açıp model menüsüne bir kez bakın, sonra bu sayfayı yeniden açın.', 'The model list has not been built yet. Open the Codex model menu once, then reopen this page.')}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Input aria-label={t('Model ara', 'Search models')} className="min-h-11 min-w-0 flex-1" onChange={e => setArama(e.target.value)} placeholder={t('Model ara', 'Search models')} value={arama} />
|
||||
<select aria-label={t('Göster', 'Show')} className="min-h-11 rounded-md border bg-(--ui-bg) p-2" onChange={e => setFiltre(e.target.value as Filtre)} value={filtre}>
|
||||
<option value="hepsi">{t('Hepsi', 'All')}</option>
|
||||
<option value="menude">{t('Menüde olanlar', 'In the menu')}</option>
|
||||
<option value="disarida">{t('Menüye sığmayanlar', 'Not fitting')}</option>
|
||||
<option value="gizli">{t('Gizlenenler', 'Hidden')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<ul aria-label={t('Modeller', 'Models')} className="max-h-[32rem] divide-y overflow-y-auto rounded-md border">
|
||||
{gorunen.map(a => {
|
||||
const pinned = draft.one_al.includes(a.slug)
|
||||
const hidden = draft.gizli.includes(a.slug)
|
||||
|
||||
return (
|
||||
<li className="flex flex-wrap items-center gap-2 p-2" key={a.slug}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{a.ad}</div>
|
||||
<div className="truncate text-xs text-(--ui-text-tertiary)">{durum(a)}</div>
|
||||
</div>
|
||||
{pinned && draft.one_al.indexOf(a.slug) > 0 && (
|
||||
<Button aria-label={t(`${a.ad} bir yukarı`, `Move ${a.ad} up`)} className="min-h-11" disabled={busy} onClick={() => yukari(a.slug)} size="sm" variant="ghost">
|
||||
↑
|
||||
</Button>
|
||||
)}
|
||||
<Button aria-pressed={pinned} className="min-h-11" disabled={busy} onClick={() => oneAl(a.slug)} size="sm" variant={pinned ? 'default' : 'secondary'}>
|
||||
{pinned ? t('Öne alındı', 'Pinned') : t('Öne al', 'Pin')}
|
||||
</Button>
|
||||
<Button aria-pressed={hidden} className="min-h-11" disabled={busy} onClick={() => gizle(a.slug)} size="sm" variant="ghost">
|
||||
{hidden ? t('Göster', 'Show') : t('Gizle', 'Hide')}
|
||||
</Button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button disabled={busy} onClick={() => void save(draft)}>
|
||||
{busy ? t('Kaydediliyor…', 'Saving…') : t('Kaydet', 'Save')}
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={() => void save({ ...CODEX_LISTE_VARSAYILAN, one_al: [], gizli: [] })} variant="secondary">
|
||||
{t('Varsayılana dön', 'Restore default')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{message && <p role={failed ? 'alert' : 'status'}>{message}</p>}
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n/context'
|
||||
|
||||
import defaults from '../../../assets/codex-bridge/compression-defaults.json'
|
||||
|
||||
import { CompressionSettingsView } from './compression-settings'
|
||||
|
||||
afterEach(() => { cleanup(); delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop })
|
||||
|
||||
it('loads settings, excludes small fallback models, and saves the selected values', async () => {
|
||||
const state = { settings: defaults, modelsAvailable: true, models: [
|
||||
{ id: 'ide/small', name: 'Small', window: 32768, source: 'catalog' },
|
||||
{ id: 'ide/large', name: 'Large', window: 1000000, source: 'catalog' }
|
||||
] }
|
||||
const save = vi.fn(async settings => ({ ...state, settings }))
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { codexLink: { getCompression: async () => state, setCompression: save } }
|
||||
render(<I18nProvider configClient={null} initialLocale="tr"><CompressionSettingsView /></I18nProvider>)
|
||||
const keep = await screen.findByRole('spinbutton', { name: 'Korunan son mesajlaşma sayısı' })
|
||||
expect((keep as HTMLInputElement).value).toBe('10')
|
||||
const fallback = screen.getByRole('combobox', { name: 'Yedek sıkıştırıcı (1M)' })
|
||||
expect(fallback.textContent).not.toContain('Small')
|
||||
fireEvent.change(keep, { target: { value: '12' } })
|
||||
fireEvent.change(fallback, { target: { value: 'ide/large' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Kaydet' }))
|
||||
await waitFor(() => expect(save).toHaveBeenCalledWith(expect.objectContaining({ keepTurns: 12, fallbackModel: 'ide/large' })))
|
||||
expect(await screen.findByText('Kaydedildi. Sonraki hazırlıkta uygulanacak.')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows a failed save without claiming success', async () => {
|
||||
const save = vi.fn().mockRejectedValue(new Error('disk unavailable'))
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { codexLink: { getCompression: async () => ({ settings: defaults, models: [], modelsAvailable: false }), setCompression: save } }
|
||||
render(<I18nProvider configClient={null} initialLocale="tr"><CompressionSettingsView /></I18nProvider>)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Kaydet' }))
|
||||
expect((await screen.findByRole('alert')).textContent).toContain('Kaydedilemedi')
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useI18n } from '@/i18n/context'
|
||||
import { Link } from '@/lib/icons'
|
||||
|
||||
import type { CompressionSettings, CompressionState } from '../../../electron/compression-settings-types'
|
||||
|
||||
import { ListRow, SettingsSection } from './primitives'
|
||||
|
||||
export function CompressionSettingsView() {
|
||||
const api = window.hermesDesktop?.codexLink
|
||||
const { locale } = useI18n()
|
||||
const tr = locale === 'tr'
|
||||
const t = (a: string, b: string) => tr ? a : b
|
||||
const [state, setState] = useState<CompressionState | null>(null)
|
||||
const [draft, setDraft] = useState<CompressionSettings | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [failed, setFailed] = useState(false)
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api?.getCompression?.().then(next => {
|
||||
if (active) { setState(next); setDraft(next.settings) }
|
||||
}, () => {
|
||||
if (active) { setFailed(true); setMessage(tr ? 'Sıkıştırma ayarları okunamadı.' : 'Could not load compression settings.') }
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [api, tr])
|
||||
if (!api?.getCompression) return null
|
||||
const change = <K extends keyof CompressionSettings>(key: K, value: CompressionSettings[K]) => {
|
||||
if (draft) setDraft({ ...draft, [key]: value })
|
||||
}
|
||||
const numeric = (key: 'keepTurns' | 'transferPercent' | 'maxTokens' | 'triggerPercent' | 'targetPercent' | 'timeoutSeconds', label: string, min: number, max: number) => (
|
||||
<ListRow key={key} title={label} action={<Input aria-label={label} className="min-h-11 w-32" type="number" min={min} max={max} step={1} disabled={busy} value={draft?.[key] ?? ''} onChange={e => change(key, e.target.valueAsNumber)} />} />
|
||||
)
|
||||
const modelSelect = (key: 'primaryModel' | 'fallbackModel', label: string) => {
|
||||
const models = (state?.models ?? []).filter(m => key !== 'fallbackModel' || m.window >= 1000000)
|
||||
const selected = draft?.[key] ?? ''
|
||||
return <ListRow title={label} action={
|
||||
<select aria-label={label} className="min-h-11 max-w-full rounded-md border bg-(--ui-bg) p-2" disabled={busy} value={selected} onChange={e => change(key, e.target.value)}>
|
||||
<option value="">{key === 'primaryModel' ? t('Mevcut model (ayrı çağrı)', 'Current model (separate call)') : t('Yedek seçilmedi', 'No fallback selected')}</option>
|
||||
{selected && !models.some(m => m.id === selected) && <option value={selected}>{selected} — {t('katalogda doğrulanmadı', 'not verified in catalog')}</option>}
|
||||
{models.map(m => <option key={m.id} value={m.id}>{m.name} ({Math.round(m.window / 1000)}K)</option>)}
|
||||
</select>
|
||||
} />
|
||||
}
|
||||
return <SettingsSection icon={Link} title={t('Bağlam ve Sıkıştırma', 'Context and Compression')}>
|
||||
<p className="text-sm text-(--ui-text-secondary)">{t('Ek modellerle geçişleri ve aynı ek modelde bağlamı yönetir. Sohbet kimliği ve tam geçmiş korunur. Abonelik modellerinin kendi aralarındaki geçişleri Codex yönetir.', 'Manages transfers involving added models and their ongoing context. Chat identity and full history are preserved. Codex manages transitions between subscription models.')}</p>
|
||||
{draft && <>
|
||||
{modelSelect('primaryModel', t('Sıkıştırmadan sorumlu model', 'Primary summarizer'))}
|
||||
{modelSelect('fallbackModel', t('Yedek sıkıştırıcı (1M)', 'Fallback summarizer (1M)'))}
|
||||
{state && !state.modelsAvailable && <p role="status">{t('Model kataloğuna ulaşılamadı.', 'Model catalog unavailable.')}</p>}
|
||||
<ListRow title={t('Mevcut modele geri dönüş', 'Fall back to current model')} description={t('Sıkıştırıcılar başarısız olursa ve geçmiş mevcut modele sığıyorsa kullanılır.', 'Used if summarizers fail and the history fits the current model.')} action={<Switch aria-label={t('Mevcut modele geri dönüş', 'Fall back to current model')} checked={draft.currentModelFallback} disabled={busy} onCheckedChange={v => change('currentModelFallback', v)} />} />
|
||||
<ListRow title={t('Sıkıştırma seviyesi', 'Compression level')} action={<select aria-label={t('Sıkıştırma seviyesi', 'Compression level')} className="min-h-11 rounded-md border p-2" value={draft.level} disabled={busy} onChange={e => change('level', e.target.value as CompressionSettings['level'])}>
|
||||
{(['mini', 'az', 'orta', 'fazla', 'cok-fazla'] as const).map((value, i) => <option key={value} value={value}>{(tr ? ['Mini', 'Az', 'Orta', 'Fazla', 'Çok Fazla'] : ['Minimal', 'Light', 'Medium', 'High', 'Very high'])[i]}</option>)}
|
||||
</select>} />
|
||||
{numeric('keepTurns', t('Korunan son mesajlaşma sayısı', 'Recent exchanges to preserve'), 1, 100)}
|
||||
<p className="text-sm text-(--ui-text-secondary)">{t('Bir mesajlaşma, kullanıcı mesajı ve ardından gelen cevap/araç işlemleridir. Korunan bölüm sığmazsa geçiş durur; bu sayı kendiliğinden azaltılmaz.', 'An exchange includes a user message and subsequent replies/tool operations. If the protected section cannot fit, the transfer stops without reducing this count.')}</p>
|
||||
<ListRow title={t('Geçiş bütçesi', 'Transfer budget')} action={<select aria-label={t('Geçiş bütçesi', 'Transfer budget')} className="min-h-11 rounded-md border p-2" disabled={busy} value={draft.budgetMode} onChange={e => change('budgetMode', e.target.value as CompressionSettings['budgetMode'])}><option value="percent">{t('Yüzde', 'Percent')}</option><option value="tokens">{t('Maksimum token', 'Maximum tokens')}</option></select>} />
|
||||
{draft.budgetMode === 'percent' ? numeric('transferPercent', t('Hedef model bağlamı (%)', 'Target model context (%)'), 10, 90) : numeric('maxTokens', t('Maksimum giriş tokenı', 'Maximum input tokens'), 1024, 2000000)}
|
||||
<ListRow title={t('Otomatik sıkıştırma', 'Automatic compression')} action={<Switch aria-label={t('Otomatik sıkıştırma', 'Automatic compression')} disabled={busy} checked={draft.autoEnabled} onCheckedChange={v => change('autoEnabled', v)} />} />
|
||||
{numeric('triggerPercent', t('Başlama eşiği (%)', 'Trigger threshold (%)'), 20, 90)}
|
||||
{numeric('targetPercent', t('Sıkıştırma sonrası hedef (%)', 'Target after compression (%)'), 10, 80)}
|
||||
{numeric('timeoutSeconds', t('Sıkıştırıcı bekleme süresi (saniye)', 'Summarizer timeout (seconds)'), 1, 3600)}
|
||||
<Button disabled={busy || draft.targetPercent >= draft.triggerPercent} onClick={async () => {
|
||||
setBusy(true); setMessage(''); setFailed(false)
|
||||
try {
|
||||
const next = await api.setCompression(draft)
|
||||
setState(next); setDraft(next.settings); setMessage(t('Kaydedildi. Sonraki hazırlıkta uygulanacak.', 'Saved. Applies to the next preparation.'))
|
||||
} catch { setFailed(true); setMessage(t('Kaydedilemedi. Değerleri ve hedefin eşikten küçük olduğunu kontrol edin.', 'Could not save. Check the values and ensure the target is below the trigger.')) }
|
||||
finally { setBusy(false) }
|
||||
}}>{busy ? t('Kaydediliyor…', 'Saving…') : t('Kaydet', 'Save')}</Button>
|
||||
</>}
|
||||
{message && <p role={failed ? 'alert' : 'status'}>{message}</p>}
|
||||
</SettingsSection>
|
||||
}
|
||||
@@ -58,6 +58,7 @@ import { NotificationsSettings } from './notifications-settings'
|
||||
import { PluginsSettings } from './plugins-settings'
|
||||
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
|
||||
import { SessionsSettings } from './sessions-settings'
|
||||
import { StagedTransitionSettingsView } from './staged-transition-settings'
|
||||
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
|
||||
|
||||
const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
@@ -72,13 +73,14 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
'notifications',
|
||||
'billing',
|
||||
'codex-link',
|
||||
'staged-transition',
|
||||
'plugins',
|
||||
'sessions',
|
||||
'about'
|
||||
]
|
||||
|
||||
export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
const aiturkCopy = useAiturkCopy()
|
||||
const codexLinkCopy = useCodexLinkCopy()
|
||||
const navigate = useNavigate()
|
||||
@@ -267,7 +269,9 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
||||
id: 'aiturk-phone',
|
||||
label: aiturkCopy.mobile.nav,
|
||||
onSelect: () => {
|
||||
void window.hermesDesktop?.aiturkMobile?.open().catch(error => notifyError(error, aiturkCopy.mobile.openFailed))
|
||||
void window.hermesDesktop?.aiturkMobile
|
||||
?.open()
|
||||
.catch(error => notifyError(error, aiturkCopy.mobile.openFailed))
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -282,6 +286,13 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
||||
id: 'codex-link',
|
||||
label: codexLinkCopy.nav,
|
||||
onSelect: () => setActiveView('codex-link')
|
||||
},
|
||||
{
|
||||
active: activeView === 'staged-transition',
|
||||
icon: Link,
|
||||
id: 'staged-transition',
|
||||
label: locale === 'tr' ? 'Kademeli Geçiş' : 'Staged Transition',
|
||||
onSelect: () => setActiveView('staged-transition')
|
||||
}
|
||||
]
|
||||
: []),
|
||||
@@ -338,7 +349,18 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
||||
onSelect: () => setActiveView('about')
|
||||
}
|
||||
],
|
||||
[activeView, keysView, providerView, t, aiturkCopy, codexLinkCopy, setActiveView, openProviderView, openKeysView]
|
||||
[
|
||||
activeView,
|
||||
keysView,
|
||||
providerView,
|
||||
t,
|
||||
locale,
|
||||
aiturkCopy,
|
||||
codexLinkCopy,
|
||||
setActiveView,
|
||||
openProviderView,
|
||||
openKeysView
|
||||
]
|
||||
)
|
||||
|
||||
// Type-to-search: printable keystrokes on the Settings surface (outside any
|
||||
@@ -459,6 +481,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
||||
<PluginsSettings />
|
||||
) : activeView === 'codex-link' ? (
|
||||
<CodexLinkSettings />
|
||||
) : activeView === 'staged-transition' ? (
|
||||
<StagedTransitionSettingsView />
|
||||
) : (
|
||||
<SessionsSettings />
|
||||
)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n/context'
|
||||
|
||||
import { IsHattiSettingsView } from './is-hatti-settings'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
it('shows the custom text box only for custom text and saves the choice', async () => {
|
||||
const save = vi.fn(async (s: unknown) => s)
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
|
||||
codexLink: { getIsHatti: async () => ({ secim: 'onerilen', talimat: '' }), setIsHatti: save }
|
||||
}
|
||||
render(<I18nProvider configClient={null} initialLocale="tr"><IsHattiSettingsView /></I18nProvider>)
|
||||
const select = await screen.findByRole('combobox', { name: 'İş dağıtımı' })
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
fireEvent.change(select, { target: { value: 'ozel' } })
|
||||
const box = screen.getByRole('textbox', { name: 'Codex’e eklenecek kural' })
|
||||
expect((screen.getByRole('button', { name: 'Kaydet' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
fireEvent.change(box, { target: { value: 'kendi kuralım' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Kaydet' }))
|
||||
await waitFor(() => expect(save).toHaveBeenCalledWith({ secim: 'ozel', talimat: 'kendi kuralım' }))
|
||||
expect(await screen.findByText(/yeniden başlatma gerekmez/)).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Varsayılana dön' }))
|
||||
await waitFor(() => expect(save).toHaveBeenLastCalledWith({ secim: 'onerilen', talimat: 'kendi kuralım' }))
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useI18n } from '@/i18n/context'
|
||||
import { Link } from '@/lib/icons'
|
||||
|
||||
import type { IsHattiSecim, IsHattiSettings } from '../../../electron/is-hatti-settings'
|
||||
|
||||
import { ListRow, SettingsSection } from './primitives'
|
||||
|
||||
const SECENEKLER: { value: IsHattiSecim; tr: string; en: string; aciklama: string; desc: string }[] = [
|
||||
{ value: 'onerilen', tr: 'Önerilen', en: 'Recommended', aciklama: 'Her gün, saat başı gibi tekrarlı işler AITURK IDE botuna gider. Kodlama ve diğer işler Codex’te kalır: Codex işi kendisi yapmaz, kolay işi bir alt ajana, zor işi alt ajan ekibine verir; sonucu doğrulayıp size aktarır.', desc: 'Repeating or scheduled work goes to the AITURK IDE bot. Coding and other work stays in Codex, which hands easy work to one subagent and hard work to a subagent team, then checks and reports the result.' },
|
||||
{ value: 'buyuk', tr: 'Büyük işleri AITURK’e gönder', en: 'Send large work to AITURK', aciklama: 'Eski davranış: birkaç adımlı ya da çok dosyalı işler AITURK’e gönderilir, küçük işleri Codex yapar.', desc: 'Previous behaviour: multi-step or multi-file work goes to AITURK; Codex does small tasks.' },
|
||||
{ value: 'kapali', tr: 'Hiçbir işi AITURK’e gönderme', en: 'Never send work to AITURK', aciklama: 'Codex’e iş dağıtımı hakkında hiçbir not eklenmez; her işi Codex yapar.', desc: 'No routing note is added; Codex does every task.' },
|
||||
{ value: 'ozel', tr: 'Kendi metnim', en: 'Custom text', aciklama: 'Codex’e eklenecek kuralı siz yazarsınız.', desc: 'You write the rule given to Codex.' }
|
||||
]
|
||||
|
||||
export function IsHattiSettingsView() {
|
||||
const api = window.hermesDesktop?.codexLink
|
||||
const { locale } = useI18n()
|
||||
const tr = locale === 'tr'
|
||||
const t = (a: string, b: string) => (tr ? a : b)
|
||||
const [draft, setDraft] = useState<IsHattiSettings | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api?.getIsHatti?.().then(
|
||||
next => active && setDraft(next),
|
||||
() => {
|
||||
if (active) {
|
||||
setFailed(true)
|
||||
setMessage(tr ? 'İş dağıtımı ayarı okunamadı.' : 'Could not load the work routing setting.')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [api, tr])
|
||||
|
||||
if (!api?.getIsHatti || !api.setIsHatti) return null
|
||||
|
||||
const save = async (next: IsHattiSettings) => {
|
||||
setBusy(true)
|
||||
setMessage('')
|
||||
setFailed(false)
|
||||
|
||||
try {
|
||||
setDraft(await api.setIsHatti!(next))
|
||||
setMessage(t('Kaydedildi. Codex’in bir sonraki mesajından itibaren geçerli; yeniden başlatma gerekmez.', 'Saved. Applies from Codex’s next message; no restart needed.'))
|
||||
} catch {
|
||||
setFailed(true)
|
||||
setMessage(t('Kaydedilemedi. Kendi metninizi seçtiyseniz kutunun boş olmadığını kontrol edin.', 'Could not save. If you chose custom text, make sure it is not empty.'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const secili = SECENEKLER.find(s => s.value === draft?.secim)
|
||||
|
||||
return (
|
||||
<SettingsSection icon={Link} title={t('AITURK iş hattı', 'AITURK work routing')}>
|
||||
<p className="text-sm text-(--ui-text-secondary)">
|
||||
{t('Codex masaüstü uygulamasının hangi işleri AITURK’e devredeceğini seçin. Kullanıcı “Codex” derse iş AITURK’e devredilmez.', 'Choose which work the Codex desktop app hands to AITURK. Saying “Codex” keeps the task in Codex.')}
|
||||
</p>
|
||||
{draft && (
|
||||
<>
|
||||
<ListRow
|
||||
action={
|
||||
<select
|
||||
aria-label={t('İş dağıtımı', 'Work routing')}
|
||||
className="min-h-11 max-w-full rounded-md border bg-(--ui-bg) p-2"
|
||||
disabled={busy}
|
||||
onChange={e => setDraft({ ...draft, secim: e.target.value as IsHattiSecim })}
|
||||
value={draft.secim}
|
||||
>
|
||||
{SECENEKLER.map(s => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{tr ? s.tr : s.en}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
}
|
||||
description={secili ? (tr ? secili.aciklama : secili.desc) : undefined}
|
||||
title={t('İş dağıtımı', 'Work routing')}
|
||||
/>
|
||||
{draft.secim === 'ozel' && (
|
||||
<>
|
||||
<textarea
|
||||
aria-label={t('Codex’e eklenecek kural', 'Rule given to Codex')}
|
||||
className="min-h-40 w-full rounded-md border bg-(--ui-bg) p-2 text-sm"
|
||||
disabled={busy}
|
||||
onChange={e => setDraft({ ...draft, talimat: e.target.value })}
|
||||
placeholder={t('Örnek: Kodlama işlerini alt ajanlara ver. Her gün tekrarlanacak işleri {komut} komutuyla AITURK’e gönder.', 'Example: Give coding to subagents. Send daily work to AITURK with {komut}.')}
|
||||
value={draft.talimat}
|
||||
/>
|
||||
<p className="text-sm text-(--ui-text-secondary)">
|
||||
{t('Metinde {zamanla} yazarsanız AITURK IDE botuna zamanlanmış iş veren komut, {komut} yazarsanız işi AITURK’e gönderen komut yerine konur.', 'Write {zamanla} to insert the command that schedules work on the AITURK IDE bot, or {komut} for the command that sends work to AITURK.')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button disabled={busy || (draft.secim === 'ozel' && !draft.talimat.trim())} onClick={() => void save(draft)}>
|
||||
{busy ? t('Kaydediliyor…', 'Saving…') : t('Kaydet', 'Save')}
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={() => void save({ secim: 'onerilen', talimat: draft.talimat })} variant="secondary">
|
||||
{t('Varsayılana dön', 'Restore default')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{message && <p role={failed ? 'alert' : 'status'}>{message}</p>}
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n/context'
|
||||
|
||||
import defaults from '../../../assets/codex-bridge/jev-settings-defaults.json'
|
||||
|
||||
import { StagedTransitionSettingsView } from './staged-transition-settings'
|
||||
|
||||
const get = vi.fn()
|
||||
const set = vi.fn()
|
||||
|
||||
const state = () => ({
|
||||
settings: structuredClone(defaults),
|
||||
keyConfigured: true,
|
||||
modelsAvailable: true,
|
||||
models: [
|
||||
{ id: 'A', name: 'Model A' },
|
||||
{ id: 'B', name: 'Model B' }
|
||||
]
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
|
||||
codexLink: { getStagedTransition: get, setStagedTransition: set }
|
||||
}
|
||||
get.mockResolvedValue(state())
|
||||
set.mockImplementation(async patch => ({ ...state(), settings: patch.settings }))
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
const show = () =>
|
||||
render(
|
||||
<I18nProvider configClient={null} initialLocale="tr">
|
||||
<StagedTransitionSettingsView />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
describe('staged transition settings UI', () => {
|
||||
it('adds and removes model routing and always-pass rows', async () => {
|
||||
show()
|
||||
await screen.findByText('Kademeli Geçiş')
|
||||
fireEvent.click(screen.getByText('+ Geçiş kuralı ekle'))
|
||||
expect(screen.getByLabelText('İtiraz edilen model 3')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Kullanılacak model 3')).toBeTruthy()
|
||||
fireEvent.click(screen.getByLabelText('Geçiş kuralı 3 sil'))
|
||||
expect(screen.queryByLabelText('İtiraz edilen model 3')).toBeNull()
|
||||
fireEvent.click(screen.getByText('+ Her zaman geç kuralı ekle'))
|
||||
expect(screen.getByLabelText('Her zaman geçen model 4')).toBeTruthy()
|
||||
fireEvent.click(screen.getByLabelText('Her zaman geç kuralı 4 sil'))
|
||||
fireEvent.change(screen.getByLabelText('İsteği yerine getirmeme denetim metni'), {
|
||||
target: { value: 'custom prompt' }
|
||||
})
|
||||
fireEvent.click(screen.getByText('Kaydet'))
|
||||
await screen.findByText('Kaydedildi. Yeni değerlendirmelerde uygulanır.')
|
||||
expect(set.mock.calls[0][0].settings.questions.ret).toBe('custom prompt')
|
||||
expect(set.mock.calls[0][0]).not.toHaveProperty('apiKey')
|
||||
expect(set.mock.calls[0][0]).not.toHaveProperty('policyApiKey')
|
||||
})
|
||||
it('edits the decision stages, the correction channel and the Dify rules source', async () => {
|
||||
get.mockResolvedValue({
|
||||
...state(),
|
||||
policyKeyConfigured: false,
|
||||
policyStatus: { ok: true, fetchedAt: 'dün', sections: ['autoRoute'], error: 'Dify kapalı' }
|
||||
})
|
||||
show()
|
||||
await screen.findByText('Karar aşamaları')
|
||||
fireEvent.click(screen.getByLabelText('Ret ön kontrolü'))
|
||||
fireEvent.change(screen.getByLabelText('Ret ön kontrolü eşiği'), { target: { value: '0.75' } })
|
||||
fireEvent.click(screen.getByLabelText('Otomatik model'))
|
||||
fireEvent.change(screen.getByLabelText('Zor iş sorusu'), { target: { value: 'zor mu?' } })
|
||||
fireEvent.click(screen.getByLabelText('Erken bitiş denetimi'))
|
||||
fireEvent.change(screen.getByLabelText('Modele verilecek not'), { target: { value: 'işi bitir' } })
|
||||
fireEvent.click(screen.getByLabelText('Workflow doğrulayıcı elemesi'))
|
||||
fireEvent.click(screen.getByText('+ Düzeltme kanalı modeli ekle'))
|
||||
expect(screen.getByLabelText('Düzeltme kanalı modeli 4')).toBeTruthy()
|
||||
fireEvent.click(screen.getByLabelText('Düzeltme kanalı modeli 4 sil'))
|
||||
fireEvent.change(screen.getByLabelText('Dify uygulama anahtarı'), { target: { value: 'app-key' } })
|
||||
fireEvent.click(screen.getByText('Kaydet'))
|
||||
await screen.findByText('Kaydedildi. Yeni değerlendirmelerde uygulanır.')
|
||||
const saved = set.mock.calls[0][0]
|
||||
expect(saved.settings.prefilter).toMatchObject({ enabled: true, threshold: 0.75 })
|
||||
expect(saved.settings.autoRoute).toMatchObject({ enabled: true, questions: { zor: 'zor mu?' } })
|
||||
expect(saved.settings.finishCheck).toMatchObject({ enabled: true, note: 'işi bitir' })
|
||||
expect(saved.settings.verifierCheck.enabled).toBe(true)
|
||||
expect(saved.settings.watchModels).toEqual(defaults.watchModels)
|
||||
expect(saved.policyApiKey).toBe('app-key')
|
||||
expect((screen.getByLabelText('Dify uygulama anahtarı') as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
it('retains user edits if persistence fails', async () => {
|
||||
set.mockRejectedValue(new Error('disk error'))
|
||||
show()
|
||||
await screen.findByText('Kademeli Geçiş')
|
||||
fireEvent.change(screen.getByLabelText('Jev modeli'), { target: { value: 'new-model' } })
|
||||
fireEvent.click(screen.getByText('Kaydet'))
|
||||
await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('Kaydedilemedi'))
|
||||
expect((screen.getByLabelText('Jev modeli') as HTMLInputElement).value).toBe('new-model')
|
||||
})
|
||||
})
|
||||
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user