164 lines
6.7 KiB
Python
164 lines
6.7 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
|
||
|
||
PREFIXES = {"custom_tool_call": "ctc", "custom_tool_call_output": "ctco",
|
||
"function_call": "fc", "function_call_output": "fc"}
|
||
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):
|
||
return min(int(window * RATIO), 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 split_history(items, keep_users=2):
|
||
"""Keep the last two user turns and complete tool pairs; never split a call.
|
||
|
||
System/developer items are kept verbatim regardless of their position.
|
||
"""
|
||
users = [i for i, x in enumerate(items) if isinstance(x, dict) and x.get("role") == "user"]
|
||
cut = users[-keep_users] if len(users) >= keep_users else (users[-1] if users else len(items))
|
||
# 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):
|
||
"""Summarize old history to fit 70%, leaving the original untouched.
|
||
|
||
summarize(text, max_tokens) must return a completed semantic summary or
|
||
raise. Tool execution is forbidden for that separate model request.
|
||
Cache keys include the caller scope, exact history and available budget.
|
||
"""
|
||
req = normalize(request)
|
||
window = int(window)
|
||
if window < 1024:
|
||
raise TransferError("Hedef modelin bağlam kapasitesi doğrulanamadı.")
|
||
limit = input_budget(req, window)
|
||
if not force and tokens(req) <= limit:
|
||
return req
|
||
protected, old, recent = split_history(req.get("input") or [])
|
||
base = dict(req, input=protected + recent)
|
||
available = limit - tokens(base) - 256
|
||
# Preserve the last user turn even if keeping two turns is too expensive.
|
||
if available < 1024 or not old:
|
||
protected, old, recent = split_history(req.get("input") or [], keep_users=1)
|
||
base = dict(req, input=protected + recent)
|
||
available = limit - tokens(base) - 256
|
||
if available < 512 or not old:
|
||
raise TransferError("Son mesaj ve zorunlu talimatlar hedefe sığmıyor; geçmiş korundu, modele eksik içerik gönderilmedi.")
|
||
# Encrypted items must be rendered by their owner, never summarized as ciphertext.
|
||
if any(isinstance(x, dict) and x.get("encrypted_content") for x in old):
|
||
raise TransferError("Şifreli geçmiş önce sahibi tarafından okunabilir özete çevrilmeli.")
|
||
source = encoded(old)
|
||
budget = min(12000, max(512, available // 2))
|
||
key = hashlib.sha256((scope + str(budget) + source).encode()).hexdigest()
|
||
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.")
|
||
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()
|