"""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()