# #region estimate_token_budget [C:3] [TYPE Module] [SEMANTICS translate, token, budget, estimation, llm] # @BRIEF Calculate safe batch_size and max_tokens for LLM translation calls based on actual content length and model context window limits. # @LAYER Domain # @RELATION DEPENDS_ON -> [TranslationExecutor] # @RELATION DEPENDS_ON -> [TranslationExecutor] # @RATIONALE Added comment clarifying PROVIDER_DEFAULTS is a fallback — primary source should be LLMProvider API. # DeepSeek v4 Flash supports up to 64K context window; output is limited by max_tokens. # @REJECTED External tokenizer library — would introduce heavy dependency for estimation only. # Fixed batch_size of 50 — causes truncation on long-content rows. # #region DEFAULT_CONTEXT_WINDOW [TYPE Constant] DEFAULT_CONTEXT_WINDOW = 64000 # #endregion DEFAULT_CONTEXT_WINDOW # #region DEFAULT_MAX_OUTPUT_TOKENS [TYPE Constant] DEFAULT_MAX_OUTPUT_TOKENS = 16384 # #endregion DEFAULT_MAX_OUTPUT_TOKENS # @BRIEF CoT reasoning overhead tokens for DeepSeek models (~2000 tokens for chain-of-thought). # #region REASONING_OVERHEAD [TYPE Constant] REASONING_OVERHEAD = 2000 # #endregion REASONING_OVERHEAD # #region PROVIDER_DEFAULTS [TYPE Constant] # Maps model name (or "default" fallback) to capacity limits. # @RATIONALE Different providers have drastically different context windows and # output limits. Using a single default for all causes either wasted # capacity (underestimation) or truncation (overestimation). PROVIDER_DEFAULTS: dict[str, dict[str, int]] = { "gpt-4o-mini": {"context_window": 128000, "max_output_tokens": 16384}, "gpt-4o": {"context_window": 128000, "max_output_tokens": 16384}, "o1-mini": {"context_window": 128000, "max_output_tokens": 65536}, "claude-3-5-sonnet": {"context_window": 200000, "max_output_tokens": 8192}, "deepseek-v4-flash": {"context_window": 64000, "max_output_tokens": 8192}, "default": {"context_window": 64000, "max_output_tokens": 16384}, } # #endregion PROVIDER_DEFAULTS # Increased from 60 to 120 because SQL/dashboard text and JSON structure need more. # #region OUTPUT_PER_ROW_PER_LANG [TYPE Constant] # Increased from 60 to 120 because SQL/dashboard text and JSON structure need more. OUTPUT_PER_ROW_PER_LANG = 120 # #endregion OUTPUT_PER_ROW_PER_LANG # #region JSON_OVERHEAD_PER_ROW [TYPE Constant] JSON_OVERHEAD_PER_ROW = 50 # #endregion JSON_OVERHEAD_PER_ROW # #region PROMPT_BASE_TOKENS [TYPE Constant] # Increased from 300 to 600 to account for longer template, system msg, and dict preamble. PROMPT_BASE_TOKENS = 600 # #endregion PROMPT_BASE_TOKENS # @BRIEF Cap for dictionary tokens in estimation to avoid overestimating. # #region DICT_TOKENS_PER_ENTRY [TYPE Constant] DICT_TOKENS_PER_ENTRY = 20 # #endregion DICT_TOKENS_PER_ENTRY # #region DICT_TOKENS_MAX [TYPE Constant] DICT_TOKENS_MAX = 5000 # #endregion DICT_TOKENS_MAX # #region CHARS_PER_TOKEN_MIXED [TYPE Constant] CHARS_PER_TOKEN_MIXED = 2.2 # #endregion CHARS_PER_TOKEN_MIXED # #region MIN_MAX_TOKENS [TYPE Constant] MIN_MAX_TOKENS = 4096 # #endregion MIN_MAX_TOKENS # #region MAX_OUTPUT_HEADROOM [TYPE Constant] # Increased from 1000 to 3000 because SQL/dashboard text output varies significantly. MAX_OUTPUT_HEADROOM = 3000 # #endregion MAX_OUTPUT_HEADROOM # region _estimate_tokens_for_text [TYPE Function] # @BRIEF Estimate token count for a text string with CJK-aware heuristics. # CJK characters (~1.5 chars/token) vs other text (~2.2 chars/token). # @PRE text is a string. # @POST Returns estimated token count >= 1. # @RATIONALE CJK characters are more token-dense than Latin/Cyrillic text. # Using a single ratio undercounts CJK input and causes truncation. # @REJECTED Using tiktoken — would introduce a heavy dependency for estimation only. def _estimate_tokens_for_text(text: str) -> int: """Estimate token count with CJK-aware heuristics. CJK characters (CJK Unified Ideographs) use ~1.5 chars/token. All other characters use ~2.2 chars/token. """ if not text: return 1 cjk_count = 0 other_count = 0 for ch in text: if '\u4e00' <= ch <= '\u9fff' or '\u3000' <= ch <= '\u303f' or '\uff00' <= ch <= '\uffef': cjk_count += 1 else: other_count += 1 cjk_tokens = cjk_count / 1.5 if cjk_count else 0 other_tokens = other_count / 2.2 if other_count else 0 return max(1, int(cjk_tokens + other_tokens)) # endregion _estimate_tokens_for_text # region _count_rows_that_fit [TYPE Function] # @BRIEF Count how many rows fit within the available input budget. # @PRE input_per_row is non-empty; available_budget > 0. # @POST Returns (safe_count, total_input_tokens). When no rows fit, returns (0, 0). def _count_rows_that_fit( input_per_row: list[int], available_budget: int, ) -> tuple[int, int]: """Count consecutive rows that fit within available_budget. Returns: (safe_count, total_input_tokens): Number of rows and their total tokens. Returns (0, 0) when the first row alone does not fit (MEDIUM fix). """ running_total = 0 safe_size = 0 for tokens in input_per_row: if running_total + tokens + REASONING_OVERHEAD < available_budget: running_total += tokens safe_size += 1 else: break # MEDIUM: Return (0, 0) when no rows fit — signal upstream that oversizing occurred. # Prevents silent clamping to 1 which would produce truncated LLM calls. return safe_size, running_total # endregion _count_rows_that_fit # region estimate_token_budget [C:3] [TYPE Function] # @BRIEF Estimate token budget for a batch of source rows and return safe batch parameters. # @PRE source_rows is a list of dicts (can be empty). target_languages is a non-empty list. # @POST Returns dict with batch_size_adjusted, estimated_input_tokens, estimated_output_tokens, max_output_needed, warning. # @RATIONALE Uses character-count heuristics (chars/2.2 for mixed text) since exact tokenization # depends on the LLM model. Estimates are intentionally conservative to prevent truncation. # @REJECTED Using tiktoken or similar tokenizer — would introduce a heavy dependency and still # not match DeepSeek's tokenizer exactly. def _calculate_output_tokens( safe_size: int, num_languages: int, ) -> int: """Calculate estimated output tokens for a batch.""" return ( safe_size * num_languages * OUTPUT_PER_ROW_PER_LANG + safe_size * JSON_OVERHEAD_PER_ROW + REASONING_OVERHEAD ) def _apply_output_aware_batch_sizing( safe_size: int, num_languages: int, max_output_tokens: int, ) -> int: """Reduce batch size until estimated output fits within max_output_tokens.""" while safe_size > 0: needed_output = ( safe_size * num_languages * OUTPUT_PER_ROW_PER_LANG + safe_size * JSON_OVERHEAD_PER_ROW + REASONING_OVERHEAD + MAX_OUTPUT_HEADROOM ) if needed_output <= max_output_tokens: break safe_size -= 1 return safe_size def _build_warning( batch_size: int | None, safe_size: int, total_rows: int, context_window: int, estimated_input: int, max_output_needed: int, dict_warning: str | None, ) -> str | None: """Build warning message when batch size is reduced.""" warning = None if batch_size and safe_size < batch_size: total_estimated = estimated_input + max_output_needed warning = ( f"Reduced batch size from {batch_size} to {safe_size} " f"(estimated {total_estimated} tokens vs {context_window} window)" ) elif not batch_size and safe_size < total_rows: warning = ( f"Auto-calculated batch size of {safe_size} from {total_rows} rows " f"(output-limited)" ) if dict_warning: warning = f"{warning}; {dict_warning}" if warning else dict_warning return warning def estimate_token_budget( source_rows: list[dict], target_languages: list[str], source_column: str = "source_text", context_columns: list[str] | None = None, dictionary_entries: list | None = None, batch_size: int | None = None, context_window: int | None = None, max_output_tokens: int | None = None, provider_info: str | None = None, ) -> dict: """Estimate token budget and return safe batch parameters. Args: source_rows: List of row dicts with source text. target_languages: List of target language codes. source_column: Key for the source text in each row dict. context_columns: Optional list of keys for context columns. dictionary_entries: Optional list of dictionary entries for glossary. batch_size: Desired batch size. If None, auto-calculate max safe size. context_window: Model context window. If None, resolved from provider_info. max_output_tokens: Hard max output tokens limit. If None, resolved from provider_info. provider_info: Optional provider model name (e.g. "gpt-4o-mini") for provider-aware defaults. When provided without explicit context_window/max_output_tokens, uses PROVIDER_DEFAULTS to set appropriate limits. Returns: dict with: batch_size_adjusted: Safe batch size (may be less than requested). estimated_input_tokens: Total estimated input tokens for the batch. estimated_output_tokens: Total estimated output tokens. max_output_needed: Recommended max_tokens for this batch. warning: str | None if batch was reduced. available_input_budget: Total input token budget per batch (context_window - max_output_tokens). max_output_tokens: The effective max output tokens limit used for this batch. """ if not target_languages: target_languages = ["en"] # Resolve provider-aware defaults if provider_info and context_window is None and max_output_tokens is None: provider_settings = PROVIDER_DEFAULTS.get(provider_info) if provider_settings: context_window = provider_settings["context_window"] max_output_tokens = provider_settings["max_output_tokens"] # Fall back to module defaults if still None if context_window is None: context_window = DEFAULT_CONTEXT_WINDOW if max_output_tokens is None: max_output_tokens = DEFAULT_MAX_OUTPUT_TOKENS num_languages = len(target_languages) # 1. Estimate tokens per row using CJK-aware heuristics input_per_row = [] limit = batch_size if batch_size else len(source_rows) for i, row in enumerate(source_rows): if i >= limit: break text = str(row.get(source_column, "") or "") estimated_tokens = _estimate_tokens_for_text(text) if context_columns: for col in context_columns: val = str(row.get(col, "") or "") estimated_tokens += _estimate_tokens_for_text(val) input_per_row.append(estimated_tokens) # 2. Calculate dictionary tokens with warning if capped dict_tokens = 0 dict_warning = None if dictionary_entries: raw_dict_tokens = len(dictionary_entries) * DICT_TOKENS_PER_ENTRY dict_tokens = min(raw_dict_tokens, DICT_TOKENS_MAX) if raw_dict_tokens > DICT_TOKENS_MAX: dict_warning = ( f"Dictionary entries ({len(dictionary_entries)} entries " f"≈ {raw_dict_tokens} tokens) exceed cap of {DICT_TOKENS_MAX} — " f"truncated to {DICT_TOKENS_MAX} in estimation" ) prompt_tokens = PROMPT_BASE_TOKENS + dict_tokens available_input_budget = context_window - max_output_tokens # 3. Calculate safe batch size using shared helper safe_size, input_total = _count_rows_that_fit(input_per_row, available_input_budget) estimated_input = prompt_tokens + input_total # If batch_size was specified and we reduced it, recalculate if batch_size and safe_size < batch_size: _, truncated_total = _count_rows_that_fit( input_per_row[:batch_size], available_input_budget, ) estimated_input = prompt_tokens + truncated_total # 4. Estimate output tokens (includes JSON overhead per row) estimated_output = _calculate_output_tokens(safe_size, num_languages) # 5. Output-aware batch sizing: reduce batch if output exceeds limit safe_size = _apply_output_aware_batch_sizing( safe_size, num_languages, max_output_tokens, ) # Ensure at least 1 row per batch — prevents empty batch allocation safe_size = max(safe_size, 1) # Recalculate totals after output-aware reduction if safe_size > 0: new_input_total = sum(input_per_row[:safe_size]) estimated_input = prompt_tokens + new_input_total estimated_output = _calculate_output_tokens(safe_size, num_languages) # 6. Calculate recommended max_tokens max_output_needed = min( estimated_output + MAX_OUTPUT_HEADROOM, context_window - estimated_input, ) max_output_needed = max(max_output_needed, MIN_MAX_TOKENS) max_output_needed = min(max_output_needed, max_output_tokens) # 7. Generate warning if batch was reduced warning = _build_warning( batch_size, safe_size, len(source_rows), context_window, estimated_input, max_output_needed, dict_warning, ) return { "batch_size_adjusted": safe_size, "estimated_input_tokens": estimated_input, "estimated_output_tokens": estimated_output, "max_output_needed": max_output_needed, "warning": warning, "available_input_budget": available_input_budget, "max_output_tokens": max_output_tokens, } # endregion estimate_token_budget # #endregion estimate_token_budget # endregion estimate_token_budget # #endregion estimate_token_budget