File size: 12,154 Bytes
cd7e7a5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 | """
model_context.py
Query and cache model context window sizes from OpenAI-compatible APIs.
Provides token estimation for context usage tracking.
"""
import logging
import sys
from typing import Dict, List, Optional
from urllib.parse import urlparse
import httpx
logger = logging.getLogger(__name__)
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1", "host.docker.internal"}
_PRIVATE_PREFIXES = ("10.", "172.16.", "172.17.", "172.18.", "172.19.",
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.",
"172.25.", "172.26.", "172.27.", "172.28.", "172.29.",
"172.30.", "172.31.", "192.168.", "100.")
def _normalize_base_for_compare(url: str) -> str:
url = (url or "").strip().rstrip("/")
for suffix in ("/chat/completions", "/models", "/completions", "/v1/messages"):
if url.endswith(suffix):
url = url[: -len(suffix)].rstrip("/")
return url
def _configured_endpoint_kind(url: str) -> Optional[str]:
"""Return configured endpoint kind for a chat/base URL when available."""
target = _normalize_base_for_compare(url)
if not target:
return None
if "core.database" not in sys.modules:
return None
try:
from core.database import SessionLocal, ModelEndpoint
db = SessionLocal()
try:
rows = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
for ep in rows:
base = _normalize_base_for_compare(getattr(ep, "base_url", "") or "")
if not base:
continue
if target != base and not target.startswith(base + "/"):
continue
kind = (getattr(ep, "endpoint_kind", None) or "auto").strip().lower()
if kind in ("local", "api", "proxy"):
return kind
if getattr(ep, "api_key", None):
parsed = urlparse(base)
host = (parsed.hostname or "").lower()
path = (parsed.path or "").rstrip("/")
if parsed.port != 11434 and "ollama" not in host and (path.endswith("/v1") or "/openai" in path):
return "proxy"
return "auto"
finally:
db.close()
except Exception:
return None
def _is_local_endpoint(url: str) -> bool:
"""Check if URL points to a local/private/tailscale address."""
kind = _configured_endpoint_kind(url)
if kind in ("api", "proxy"):
return False
if kind == "local":
return True
try:
host = urlparse(url).hostname or ""
return host in _LOCAL_HOSTS or host.startswith(_PRIVATE_PREFIXES)
except Exception:
return False
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
DEFAULT_CONTEXT = 128000
REQUEST_TIMEOUT = 5
# Known context windows for major API models (used as fallback when /models
# endpoint doesn't report context_length).
# Substring matching — use the shortest unique prefix so variants get caught.
KNOWN_CONTEXT_WINDOWS = {
# --- Anthropic ---
'claude-sonnet-4-5': 200000,
'claude-sonnet-4-6': 200000,
'claude-sonnet-4': 200000,
'claude-opus-4': 200000,
'claude-haiku-4': 200000,
'claude-haiku-3-5': 200000,
'claude-3-5-sonnet': 200000,
'claude-3-5-haiku': 200000,
'claude-3-opus': 200000,
'claude-3-sonnet': 200000,
'claude-3-haiku': 200000,
# --- OpenAI ---
'gpt-5': 400000,
'gpt-4.1': 1047576,
'gpt-4.1-mini': 1047576,
'gpt-4.1-nano': 1047576,
'gpt-4o': 128000,
'gpt-4o-mini': 128000,
'gpt-4-turbo': 128000,
'gpt-4': 8192,
'gpt-3.5-turbo': 16385,
'o1': 200000,
'o1-mini': 128000,
'o1-pro': 200000,
'o3': 200000,
'o3-mini': 200000,
'o4-mini': 200000,
# --- DeepSeek ---
'deepseek-chat': 64000,
'deepseek-coder': 64000,
'deepseek-reasoner': 64000,
'deepseek-r1': 64000,
'deepseek-v3': 64000,
'deepseek-v2': 64000,
# --- Google ---
'gemini-2.5-pro': 1048576,
'gemini-2.5-flash': 1048576,
'gemini-2.0-flash': 1048576,
'gemini-1.5-pro': 1048576,
'gemini-1.5-flash': 1048576,
'gemma-4': 262144,
'gemma-3': 128000,
'gemma-2': 8192,
# --- Mistral ---
'mistral-large': 128000,
'mistral-medium': 32000,
'mistral-small': 32000,
'mistral-nemo': 128000,
'mistral-7b': 32000,
'mixtral': 32000,
'codestral': 32000,
'pixtral': 128000,
# --- xAI ---
'grok-4': 131072,
'grok-3': 131072,
'grok-2': 131072,
# --- Meta / Llama ---
'llama-4': 1048576,
'llama-3.3': 131072,
'llama-3.2': 131072,
'llama-3.1': 131072,
'llama-3': 131072,
# --- Qwen ---
'qwen3': 131072,
'qwen2.5': 131072,
'qwen2': 32768,
'qwq': 32768,
# --- Cohere ---
'command-r-plus': 128000,
'command-r': 128000,
'command-a': 256000,
# --- Perplexity ---
'sonar-pro': 200000,
'sonar': 128000,
# --- MiniMax ---
'minimax': 1000000,
# --- Moonshot / Kimi ---
'moonshot': 128000,
'kimi': 128000,
# --- Microsoft ---
'phi-4': 16000,
'phi-3': 128000,
# --- Nvidia ---
'nemotron': 131072,
# --- Yi ---
'yi-large': 32768,
'yi-1.5': 16384,
# --- 01.ai ---
'yi-lightning': 16384,
# --- Nous ---
'hermes': 131072,
'nous-hermes': 131072,
# --- Open community ---
'dolphin': 32768,
'mythomax': 4096,
'wizard': 32768,
'openchat': 8192,
'solar': 32768,
}
# ---------------------------------------------------------------------------
# Cache
# ---------------------------------------------------------------------------
_context_cache: Dict[str, int] = {}
def get_context_length(endpoint_url: str, model: str) -> int:
"""Get the context window size for a model.
Queries /v1/models on the endpoint and looks for context_length
or context_window fields. Caches result per model ID.
Falls back to DEFAULT_CONTEXT if unavailable.
"""
configured_kind = _configured_endpoint_kind(endpoint_url)
is_local = _is_local_endpoint(endpoint_url)
if not is_local and model in _context_cache:
return _context_cache[model]
ctx = _query_context_length(endpoint_url, model)
# Only cache non-default values to allow retry on next request.
# Local endpoints can restart with a different --max-model-len while keeping
# the same model id, so always re-query them instead of serving stale cache.
if not is_local and (ctx != DEFAULT_CONTEXT or configured_kind in ("api", "proxy")):
_context_cache[model] = ctx
logger.info(f"Context length for {model}: {ctx}")
return ctx
def _lookup_known(model: str) -> Optional[int]:
"""Check known context windows by substring match.
Picks the LONGEST matching key so a short key never shadows a more specific
one. Without this, 'o1' (200k) precedes 'o1-mini' (128k) in the table and a
first-match return would report o1-mini's window as 200k.
"""
name = model.lower()
basename = name.split("/")[-1] if "/" in name else name
basename = basename.split(":")[0] # strip :free, :extended etc.
best_key: Optional[str] = None
best_ctx: Optional[int] = None
for key, ctx in KNOWN_CONTEXT_WINDOWS.items():
if key in basename or key in name:
if best_key is None or len(key) > len(best_key):
best_key, best_ctx = key, ctx
return best_ctx
def _query_context_length(endpoint_url: str, model: str) -> int:
"""Query the model API for context length."""
known = _lookup_known(model)
api_ctx = None
configured_kind = _configured_endpoint_kind(endpoint_url)
# Large OpenAI-compatible proxies can make /models expensive. If the
# endpoint is explicitly configured as API/proxy, prefer known context
# metadata (or the default) over downloading the full catalog.
if configured_kind in ("api", "proxy"):
if known:
logger.info(f"Using known context window for {model}: {known}")
return known
return DEFAULT_CONTEXT
# Try llama.cpp /slots endpoint first — reports actual serving context
if _is_local_endpoint(endpoint_url):
try:
base = endpoint_url.split("/v1")[0] if "/v1" in endpoint_url else endpoint_url.rsplit("/", 1)[0]
r = httpx.get(f"{base}/slots", timeout=REQUEST_TIMEOUT)
if r.is_success:
slots = r.json()
if isinstance(slots, list) and slots:
n_ctx = slots[0].get("n_ctx")
if n_ctx and isinstance(n_ctx, int) and n_ctx > 0:
logger.info(f"llama.cpp /slots reports n_ctx={n_ctx} for {model}")
return n_ctx
except Exception:
pass
models_url = endpoint_url.replace("/chat/completions", "/models")
try:
r = httpx.get(models_url, timeout=REQUEST_TIMEOUT)
if r.is_success:
data = r.json()
models_list = data.get("data") or []
for m in models_list:
mid = m.get("id", "")
if mid == model or mid.split("/")[-1] == model.split("/")[-1]:
for field in (
"context_length",
"context_window",
"max_model_len",
"max_context_length",
"max_seq_len",
):
val = m.get(field)
if val and isinstance(val, (int, float)) and val > 0:
api_ctx = int(val)
break
if not api_ctx:
meta = m.get("meta") or m.get("model_extra") or {}
if isinstance(meta, dict):
# n_ctx is the actual serving context (set via -c flag in llama.cpp)
for field in ("n_ctx", "context_length", "context_window", "max_model_len"):
val = meta.get(field)
if val and isinstance(val, (int, float)) and val > 0:
api_ctx = int(val)
break
break
except Exception as e:
logger.debug(f"Failed to query context length for {model}: {e}")
# For local/self-hosted endpoints, trust the API value (user set --max-model-len)
# For cloud APIs, use the larger value (API can report low defaults)
if api_ctx and known:
_is_local = _is_local_endpoint(endpoint_url)
if _is_local and api_ctx < known:
logger.info(f"Local endpoint reports {api_ctx} for {model} (known max: {known}) — using API value")
return api_ctx
result = max(api_ctx, known)
if api_ctx < known:
logger.info(f"API reported {api_ctx} for {model}, using known {known} instead")
return result
if api_ctx:
return api_ctx
if known:
logger.info(f"Using known context window for {model}: {known}")
return known
return DEFAULT_CONTEXT
def estimate_tokens(messages: List[Dict]) -> int:
"""Rough token estimate for a list of messages.
Uses chars * 0.3 which is closer to real BPE tokenizer output
than the commonly-cited chars/4 (which underestimates by ~20-30%).
Also adds ~4 tokens per message for role/formatting overhead.
"""
total = 0
for msg in messages:
total += 4 # per-message overhead (role, separators)
content = msg.get("content", "")
if isinstance(content, str):
total += int(len(content) * 0.3)
elif isinstance(content, list):
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
total += int(len(item.get("text", "")) * 0.3)
return total
|