Separate encrypted reasoning from actual compaction, stop migration after native return, and retain full history and recent exchanges. Add typed settings, Gemini fallback, verified provider windows, bounded summaries and durable concurrent checkpoints. Validation: 245 bridge tests plus focused concurrency tests; 71 settings/connection tests; 66 supplementary tests; TypeScript checks; renderer and Electron bundles; real primary/native/fallback summary probes and 700K input acceptance.
213 lines
9.1 KiB
Python
213 lines
9.1 KiB
Python
"""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 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 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
|
||
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 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()
|