Keep the upstream model-list ETag: chatgpt.com repeats it in every answer (x-models-etag), and the bridge's own stamp made Codex re-download the ~17 MB merged list after every answer (6198 times in one day). Replace the flat 2 bytes/token estimate with measured ratios (673 real requests): Claude family and unknown names 2.5, recognised non-Claude families 3.4. Non-Claude chats were counted about twice as large, were summarized too early and got false "does not fit" errors. Ship IDE beta.47 (bridge version 26). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeNgWNzPjzUKQuS32RUYXx
328 lines
15 KiB
Python
328 lines
15 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 re
|
||
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": "fco"}
|
||
RATIO = 0.70
|
||
PEER_OMITTED = "[Aktarım notu: Burada okunamayan şifreli bir alt ajan iletisi vardı; aktarılmadı.]"
|
||
REASONING_OMITTED = "[Aktarım notu: Şifreli model düşünme kaydı aktarılmadı.]"
|
||
|
||
|
||
def summary_missing(source, summary):
|
||
"""Check literal decisions, file paths and numeric settings, never secrets.
|
||
|
||
This is a loss detector for concrete references, not a semantic proof of
|
||
every sentence. Missing references must not enter a durable checkpoint.
|
||
"""
|
||
if isinstance(source, str):
|
||
try:
|
||
source = json.loads(source)
|
||
except ValueError:
|
||
pass
|
||
def strings(value):
|
||
if isinstance(value, str):
|
||
yield value
|
||
elif isinstance(value, dict):
|
||
for child in value.values():
|
||
yield from strings(child)
|
||
elif isinstance(value, list):
|
||
for child in value:
|
||
yield from strings(child)
|
||
text = '\n'.join(strings(source)).replace('\\', '/')
|
||
target = summary.replace('\\', '/').replace('`', '')
|
||
identifiers = set(re.findall(r'\b[A-Z][A-Z0-9]{0,23}(?:-[A-Z][A-Z0-9]{0,23}){0,3}-\d[A-Za-z0-9_-]{0,63}\b', text))
|
||
identifiers = {x for x in identifiers if not re.match(r'(?:SECRET|TOKEN|APIKEY|PASSWORD|OTP)-', x)}
|
||
paths = set(re.findall(r'(?<![A-Za-z0-9_.-])(?:[A-Za-z]:/(?:[A-Za-z0-9_.-]{1,160}/){0,24}|(?:[A-Za-z0-9_.-]{1,160}/){1,24})[A-Za-z0-9_.-]{1,160}\.(?:json|py|tsx?|jsx?|md|txt|ya?ml|toml|ini|sql|csv|log)\b', text)) if '/' in text else set()
|
||
assignments = set(re.findall(r'\b([A-Za-z_][A-Za-z0-9_]{0,63})\s*=\s*(\d{1,16}|true|false)\b', text))
|
||
assignments = {(key, value) for key, value in assignments
|
||
if not re.search(r'key|token|secret|password|sifre|anahtar|otp', key, re.I)}
|
||
missing = sum(value not in target for value in identifiers | paths)
|
||
missing += sum(not re.search(re.escape(key) + r'\s*[=:]\s*' + re.escape(value) + r'\b', target)
|
||
for key, value in assignments)
|
||
return missing
|
||
|
||
|
||
def require_summary_coverage(source, summary):
|
||
missing = summary_missing(source, summary)
|
||
if missing:
|
||
raise TransferError(f'Özet {missing} karar, dosya yolu veya sayısal ayarı korumadı; eksik özet kabul edilmedi.')
|
||
|
||
|
||
class TransferError(ValueError):
|
||
pass
|
||
|
||
|
||
def encoded(value):
|
||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||
|
||
|
||
# Bytes per token, measured on 673 real added-model requests (20 October 2026; bridge request bytes against the
|
||
# provider's reported input tokens): Claude family 2.55 at the lowest quartile (median 2.58); every other family
|
||
# 3.4 or more at the lowest 5% (Gemini, GPT, abliteration, Jail-Omni, bekci ~3.7-4.2). The old flat 2 bytes/token
|
||
# counted non-Claude chats about twice as large, summarized too early and raised false "does not fit" errors.
|
||
# Claude, profiles and anything unrecognised keep the safe 2.5; only a recognised non-Claude family gets 3.4.
|
||
SAFE_BYTES_PER_TOKEN = 2.5
|
||
NON_CLAUDE_BYTES_PER_TOKEN = 3.4
|
||
CLAUDE_HINTS = ("claude", "anthropic", "opus", "sonnet", "haiku")
|
||
NON_CLAUDE_HINTS = ("gemini", "gemma", "gpt", "grok", "deepseek", "qwen", "glm", "kimi", "llama", "mistral",
|
||
"nemotron", "minimax", "seed-", "mimo", "abliterat")
|
||
|
||
|
||
def bytes_per_token(model):
|
||
slug = str(model or "").lower()
|
||
if not slug or any(h in slug for h in CLAUDE_HINTS):
|
||
return SAFE_BYTES_PER_TOKEN
|
||
return NON_CLAUDE_BYTES_PER_TOKEN if any(h in slug for h in NON_CLAUDE_HINTS) else SAFE_BYTES_PER_TOKEN
|
||
|
||
|
||
def tokens(value):
|
||
"""Conservative token estimate. A request (dict with "model") uses that model's measured ratio."""
|
||
model = value.get("model") if isinstance(value, dict) else None
|
||
return int(len(encoded(value).encode("utf-8")) / bytes_per_token(model)) + 1
|
||
|
||
|
||
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") in ("compaction", "reasoning") and item.get("encrypted_content")
|
||
and not str(item["encrypted_content"]).startswith(ozet_kopru.ISARET))
|
||
or (item.get("type") == "agent_message" and any(
|
||
isinstance(part, dict) and part.get("type") == "encrypted_content"
|
||
for part in item.get("content") or [])))
|
||
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":
|
||
# Treat the typed wrapper as opaque regardless of whether
|
||
# its bytes happen to look like plaintext. Never decrypt,
|
||
# guess, or forward it to a different provider.
|
||
portable.append({"type": "input_text", "text": PEER_OMITTED})
|
||
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 item.get("encrypted_content"):
|
||
visible.append(REASONING_OMITTED)
|
||
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 and summary_missing(old[:saved[0]], saved[1]):
|
||
saved = 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)
|
||
# The configured target already bounds the resulting context. Applying
|
||
# the level's ratio again denied the summarizer most of that budget.
|
||
# Level controls writing style; the whole available budget stays usable.
|
||
budget = max(512, available)
|
||
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.")
|
||
require_summary_coverage(old, summary)
|
||
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()
|