Kill extract-wave truncation: 65k ceiling, hard thinking budget, reasoning-token telemetry
Docker Release / build-and-push (push) Successful in 57s
Docker Release / release (push) Skipped

Job 98194fa8d215 showed every extract call hitting the 32k cap with only
~20k chars visible despite reasoning effort=low - Gemini 2.5 Pro still
burned ~25k thinking tokens per sheet.

- EXTRACT_MAX_TOKENS default 32768 -> 65536 (model output ceiling)
- new EXTRACT_REASONING_MAX_TOKENS (default 2048): OpenRouter reasoning
  max_tokens / Gemini thinking_budget; takes precedence over effort
- log per-call reasoning token counts (usage.completion_tokens_details)
  and include thinking count in the finish_reason=length marker
This commit is contained in:
2026-08-09 08:17:41 -05:00
parent 76e0a52658
commit 3d7fce7bf9
4 changed files with 53 additions and 16 deletions
+40 -12
View File
@@ -117,7 +117,8 @@ def _dump_raw(seq: int, be: Dict[str, Any], usage_stage: str,
def _log_call(seq: int, be: Dict[str, Any], usage_stage: str,
user_text: str, images_b64: Optional[List[str]],
raw: str, parsed: Any, usd: Optional[float],
cached: bool = False) -> None:
cached: bool = False,
reasoning_tokens: Optional[int] = None) -> None:
"""One verbose per-call line for the job log (tee'd by job_log.py)."""
if not config.LLM_VERBOSE:
return
@@ -128,9 +129,10 @@ def _log_call(seq: int, be: Dict[str, Any], usage_stage: str,
cost_str = f"${usd:.4f}"
else:
cost_str = "cost n/a"
think_str = f"think {reasoning_tokens}tk | " if reasoning_tokens is not None else ""
print(f"[LLM] #{seq:04d} {usage_stage} | {be['model']} ({backend}) | "
f"in {len(user_text)}ch+{len(images_b64 or [])}img | "
f"out {len(raw)}ch | {cost_str} | {_summarize_parsed(parsed)}")
f"out {len(raw)}ch | {think_str}{cost_str} | {_summarize_parsed(parsed)}")
def set_text_backend(local: bool) -> None:
@@ -230,10 +232,11 @@ def _add_cached() -> None:
def _cache_key(model: str, system_prompt: str, user_text: str,
images_b64: Optional[List[str]], max_tokens: int,
json_mode: bool, reasoning_effort: Optional[str] = None) -> str:
json_mode: bool, reasoning_effort: Optional[str] = None,
reasoning_max_tokens: Optional[int] = None) -> str:
h = hashlib.sha256()
parts = [model, str(max_tokens), str(json_mode), str(reasoning_effort),
system_prompt, user_text]
str(reasoning_max_tokens), system_prompt, user_text]
for b in (images_b64 or []):
parts.append(b)
for p in parts:
@@ -372,6 +375,21 @@ def _response_cost(response) -> Optional[float]:
return float(cost) if isinstance(cost, (int, float)) else None
def _reasoning_tokens(response) -> Optional[int]:
"""Hidden thinking tokens for this call (OpenRouter usage details).
Direct evidence of whether the reasoning knob is working - without it,
thinking burn can only be inferred from char counts vs the token cap."""
try:
dump = response.model_dump()
except Exception:
return None
usage = dump.get("usage") or {}
details = usage.get("completion_tokens_details") or {}
n = details.get("reasoning_tokens")
return int(n) if isinstance(n, (int, float)) else None
def _parse(raw: str) -> Optional[Dict[str, Any]]:
"""Parse model JSON, falling back to truncation repair."""
try:
@@ -392,6 +410,7 @@ def call_json(
usage_tracker: Optional[Any] = None,
usage_stage: str = "?",
reasoning_effort: Optional[str] = None,
reasoning_max_tokens: Optional[int] = None,
) -> Optional[Dict[str, Any]]:
"""
Send one chat completion expecting a JSON object back.
@@ -401,8 +420,10 @@ def call_json(
truncation. images_b64: optional base64 JPEGs attached as high-detail image
parts. reasoning_effort: optional OpenRouter reasoning knob ("low"/"medium"/
"high") - keeps thinking models from burning the output budget on hidden
reasoning. Returns the parsed dict, or None on a hard failure (caller
degrades).
reasoning. reasoning_max_tokens: optional hard thinking-token budget
(OpenRouter reasoning max_tokens -> Gemini thinking_budget); stronger than
effort, and takes precedence when both are given. Returns the parsed dict,
or None on a hard failure (caller degrades).
"""
has_images = bool(images_b64)
be = _resolve_backend(has_images, model)
@@ -412,7 +433,8 @@ def call_json(
if config.LLM_CACHE:
cache_key = _cache_key(be["model"], system_prompt, user_text,
images_b64, max_tokens, json_mode=True,
reasoning_effort=reasoning_effort)
reasoning_effort=reasoning_effort,
reasoning_max_tokens=reasoning_max_tokens)
hit = _cache_get(cache_key)
if hit is not None:
_add_cached()
@@ -448,9 +470,13 @@ def call_json(
if be["usage"]:
extra_body["usage"] = {"include": True}
# OpenRouter reasoning knob; only sent to cloud backends (local
# servers reject unknown fields). Skipped when effort is blank.
if reasoning_effort and not be.get("local"):
extra_body["reasoning"] = {"effort": reasoning_effort}
# servers reject unknown fields). A hard thinking budget wins over
# the vaguer effort tier - OpenRouter treats them as exclusive.
if not be.get("local"):
if reasoning_max_tokens:
extra_body["reasoning"] = {"max_tokens": int(reasoning_max_tokens)}
elif reasoning_effort:
extra_body["reasoning"] = {"effort": reasoning_effort}
if extra_body:
kwargs["extra_body"] = extra_body
if use_json_mode:
@@ -466,19 +492,21 @@ def call_json(
choice = response.choices[0]
finish_reason = getattr(choice, "finish_reason", None)
raw = _strip_fences(choice.message.content or "")
reasoning_tokens = _reasoning_tokens(response)
if finish_reason == "length":
# Hit max_tokens (thinking tokens included on reasoning models).
# Logged explicitly so silent truncation isn't mistaken for a
# parse problem; _parse below still salvages what it can.
print(f"[LLM] output hit max_tokens (finish_reason=length, "
f"{len(raw)}ch returned)")
f"{len(raw)}ch returned, "
f"thinking={reasoning_tokens}tk)")
parsed = _parse(raw)
if parsed is not None:
_record_model(be, has_images, fell_back)
if cache_key:
_cache_set(cache_key, parsed)
_log_call(seq, be, usage_stage, user_text, images_b64,
raw, parsed, usd)
raw, parsed, usd, reasoning_tokens=reasoning_tokens)
if config.LLM_RAW_DUMP:
_dump_raw(seq, be, usage_stage, system_prompt, user_text,
images_b64, max_tokens, raw, parsed,