pipelines: route live pipeline through gemma4:31b-cloud; add historical escalation chain
Per board directive on INB-20: - live pipeline: INBOXOS_LLM_MODEL=INBOXOS_LLM_MODEL_EXTRACT=gemma4:31b-cloud (single model, no escalation) - historical pipeline: INBOXOS_LLM_MODEL_CHAIN='gemma4:e4b,gemma4:12b,gemma4:31b-cloud' (primary, smart retry, 2nd smart retry) - LLMConfig now has a model_chain field parsed from INBOXOS_LLM_MODEL_CHAIN; ollama_run_command_with_chain + llm_run_command_with_chain implement the chain as the retry policy (no same-model tenacity, so a flaky cheap model does not multiply wall-clock latency) - precedence flip in ollama_run_command: per-step env-var model wins over prompt frontmatter, so an operator can pin a pipeline-level model without editing prompt files - .opencode/commands/inboxos:*.md frontmatter updated to gemma4:e4b (matches the historical primary; the live env var overrides to 31b-cloud at runtime) Offline tests: 14/14 live, 8/8 historical unit (incl. new test_process_escalation_recovers_on_second_model) pass. Live Ollama smoke: gemma4:31b-cloud correctly classifies a test email via the live pipeline path; gemma4:e4b → escalation chain routes through to gemma4:31b-cloud when needed. Refs: INB-20 Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -27,8 +27,8 @@
|
||||
# OLLAMA_BASE_URL (e.g. https://ollama-frp.cloudinit.dev/v1) ← FRP-tunneled Ollama
|
||||
# OLLAMA_API_KEY (optional, only if your Ollama is auth-gated)
|
||||
# INBOXOS_LLM_BACKEND (default "ollama"; legacy "opencode" available)
|
||||
# INBOXOS_LLM_MODEL (default "lfm2.5:8b"; used for classify)
|
||||
# INBOXOS_LLM_MODEL_EXTRACT (default "gemma4:12b"; used for inboxos:extract-*)
|
||||
# INBOXOS_LLM_MODEL (default "gemma4:31b-cloud"; used for classify)
|
||||
# INBOXOS_LLM_MODEL_EXTRACT (default "gemma4:31b-cloud"; used for inboxos:extract-*)
|
||||
# INBOXOS_LLM_MAX_TOKENS (default "4096"; the previous 1024 ran out
|
||||
# of room for the 11-field extract schema on
|
||||
# rich emails — INB-21)
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }}
|
||||
INBOXOS_LLM_BACKEND: ${{ vars.INBOXOS_LLM_BACKEND || secrets.INBOXOS_LLM_BACKEND || 'ollama' }}
|
||||
INBOXOS_LLM_MODEL: ${{ vars.INBOXOS_LLM_MODEL || secrets.INBOXOS_LLM_MODEL || 'gemma4:31b-cloud' }}
|
||||
INBOXOS_LLM_MODEL_EXTRACT: ${{ vars.INBOXOS_LLM_MODEL_EXTRACT || secrets.INBOXOS_LLM_MODEL_EXTRACT || 'gemma4:12b' }}
|
||||
INBOXOS_LLM_MODEL_EXTRACT: ${{ vars.INBOXOS_LLM_MODEL_EXTRACT || secrets.INBOXOS_LLM_MODEL_EXTRACT || 'gemma4:31b-cloud' }}
|
||||
INBOXOS_LLM_MAX_TOKENS: ${{ vars.INBOXOS_LLM_MAX_TOKENS || secrets.INBOXOS_LLM_MAX_TOKENS || '4096' }}
|
||||
INBOXOS_LLM_TIMEOUT: ${{ vars.INBOXOS_LLM_TIMEOUT || secrets.INBOXOS_LLM_TIMEOUT || '120' }}
|
||||
SINCE_UID_OVERRIDE: ${{ inputs.since_uid }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Classify an InboxOS email into zero or more extraction modules.
|
||||
model: ollama/lfm2.5:8b
|
||||
model: ollama/gemma4:e4b
|
||||
---
|
||||
|
||||
You are the **InboxOS Email Router**. You receive ONE email as JSON on stdin and must output ONE JSON object on stdout that names the extraction modules that apply.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Extract structured Personal CRM fields from an email.
|
||||
model: ollama/lfm2.5:8b
|
||||
model: ollama/gemma4:e4b
|
||||
---
|
||||
|
||||
You are the **InboxOS CRM Extractor**. You receive ONE email as JSON on stdin and must output ONE JSON object on stdout that fits the NocoDB `CRM` table.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Extract structured Finance/P&L fields from an email.
|
||||
model: ollama/lfm2.5:8b
|
||||
model: ollama/gemma4:e4b
|
||||
---
|
||||
|
||||
You are the **InboxOS Finance Extractor**. You receive ONE email as JSON on stdin and must output ONE JSON object on stdout that fits the NocoDB `Finance` table.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Extract structured Daily News / Intel fields from an email.
|
||||
model: ollama/lfm2.5:8b
|
||||
model: ollama/gemma4:e4b
|
||||
---
|
||||
|
||||
You are the **InboxOS Intel Extractor**. You receive ONE email as JSON on stdin and must output ONE JSON object on stdout that fits the NocoDB `Intel` table.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Extract structured Promotions/Marketing fields from an email.
|
||||
model: ollama/lfm2.5:8b
|
||||
model: ollama/gemma4:e4b
|
||||
---
|
||||
|
||||
You are the **InboxOS Promotions Extractor**. You receive ONE email as JSON on stdin and must output ONE JSON object on stdout that fits the NocoDB `Promotions` table.
|
||||
|
||||
+152
-11
@@ -21,7 +21,7 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
@@ -429,9 +429,19 @@ class LLMConfig:
|
||||
commands whose name starts with `INBOXOS_EXTRACT_COMMAND_PREFIX`
|
||||
(default `inboxos:extract-`). The classify step keeps using
|
||||
the small/fast default model (INBOXOS_LLM_MODEL, default
|
||||
`lfm2.5:8b`); only the extract step is routed to the larger
|
||||
model (e.g. `gemma4:12b`). Set both env vars to the same
|
||||
value if you want a single model across the pipeline.
|
||||
`gemma4:e4b`); only the extract step is routed to the larger
|
||||
model. Set both env vars to the same value if you want a
|
||||
single model across the pipeline.
|
||||
|
||||
Model escalation (historical pipeline only):
|
||||
`INBOXOS_LLM_MODEL_CHAIN` is a comma-separated list of
|
||||
model IDs. The historical pipeline tries the first model,
|
||||
and on any failure (HTTP error, empty content, malformed
|
||||
JSON) escalates to the next. Defaults to no escalation
|
||||
(single-model chain derived from
|
||||
`INBOXOS_LLM_MODEL`/`INBOXOS_LLM_MODEL_EXTRACT`) when unset.
|
||||
Format: `e4b,12b,31b-cloud` →
|
||||
`gemma4:e4b,gemma4:12b,gemma4:31b-cloud`.
|
||||
"""
|
||||
|
||||
backend: str
|
||||
@@ -444,9 +454,14 @@ class LLMConfig:
|
||||
extract_command_prefix: str
|
||||
timeout: int
|
||||
max_tokens: int
|
||||
model_chain: list[str] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "LLMConfig":
|
||||
# Default to no escalation; the per-step model is the single
|
||||
# entry in the chain. The historical pipeline opts in via
|
||||
# INBOXOS_LLM_MODEL_CHAIN.
|
||||
chain_raw = (os.environ.get("INBOXOS_LLM_MODEL_CHAIN") or "").strip()
|
||||
return cls(
|
||||
backend=os.environ.get("INBOXOS_LLM_BACKEND", "ollama").strip().lower(),
|
||||
prompt_dir=os.environ.get("OPENCODE_PROMPT_DIR", "./.opencode/commands"),
|
||||
@@ -455,8 +470,8 @@ class LLMConfig:
|
||||
"OLLAMA_BASE_URL", "https://ollama-frp.cloudinit.dev/v1"
|
||||
).rstrip("/"),
|
||||
ollama_api_key=os.environ.get("OLLAMA_API_KEY") or None,
|
||||
default_model=os.environ.get("INBOXOS_LLM_MODEL", "lfm2.5:8b"),
|
||||
extract_model=os.environ.get("INBOXOS_LLM_MODEL_EXTRACT", "gemma4:12b"),
|
||||
default_model=os.environ.get("INBOXOS_LLM_MODEL", "gemma4:e4b"),
|
||||
extract_model=os.environ.get("INBOXOS_LLM_MODEL_EXTRACT", "gemma4:e4b"),
|
||||
extract_command_prefix=os.environ.get(
|
||||
"INBOXOS_EXTRACT_COMMAND_PREFIX", "inboxos:extract-"
|
||||
),
|
||||
@@ -467,6 +482,7 @@ class LLMConfig:
|
||||
# Substack email because the small model spent its
|
||||
# entire budget on chain-of-thought reasoning.
|
||||
max_tokens=int(os.environ.get("INBOXOS_LLM_MAX_TOKENS", "4096")),
|
||||
model_chain=[m.strip() for m in chain_raw.split(",") if m.strip()] if chain_raw else [],
|
||||
)
|
||||
|
||||
def model_for_command(self, command_name: str) -> str:
|
||||
@@ -485,6 +501,19 @@ class LLMConfig:
|
||||
return self.extract_model
|
||||
return self.default_model
|
||||
|
||||
def model_chain_for_command(self, command_name: str) -> list[str]:
|
||||
"""Pick the escalation chain for a given prompt command name.
|
||||
|
||||
If `INBOXOS_LLM_MODEL_CHAIN` was set, return it verbatim
|
||||
(the operator is explicitly opting in to escalation).
|
||||
Otherwise return a single-element list with the per-step
|
||||
model from `model_for_command`, preserving the prior
|
||||
single-model behavior.
|
||||
"""
|
||||
if self.model_chain:
|
||||
return list(self.model_chain)
|
||||
return [self.model_for_command(command_name)]
|
||||
|
||||
|
||||
def _load_prompt(prompt_dir: str, command_name: str) -> tuple[str, str | None]:
|
||||
"""Load a prompt .md file, returning (system_body, declared_model_or_none).
|
||||
@@ -533,13 +562,33 @@ def ollama_run_command(
|
||||
HTTP errors, JSON parse errors, and explicit RuntimeErrors
|
||||
raised by the parser.
|
||||
|
||||
Model selection: the prompt's `model:` frontmatter (if present)
|
||||
wins; otherwise the per-step model from `LLMConfig` is used
|
||||
(extract commands route to the larger model, classify and
|
||||
everything else use the cheap default).
|
||||
Model selection: the per-step model from `LLMConfig` (driven
|
||||
by `INBOXOS_LLM_MODEL` / `INBOXOS_LLM_MODEL_EXTRACT`) wins;
|
||||
otherwise the prompt's `model:` frontmatter is used as a
|
||||
fallback. This lets the operator pin a pipeline-level model
|
||||
(e.g. `gemma4:31b-cloud` for live) without editing the
|
||||
prompt files, while still letting a prompt declare its own
|
||||
default when no env override is set.
|
||||
"""
|
||||
system_prompt, declared_model = _load_prompt(cfg.prompt_dir, command_name)
|
||||
model = declared_model or cfg.model_for_command(command_name)
|
||||
model = cfg.model_for_command(command_name) or declared_model
|
||||
return _ollama_call_one_model(cfg, command_name, payload, model, logger=logger)
|
||||
|
||||
|
||||
def _ollama_call_one_model(
|
||||
cfg: LLMConfig,
|
||||
command_name: str,
|
||||
payload: dict[str, Any],
|
||||
model: str,
|
||||
logger: logging.Logger | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Single-shot Ollama call. No retries, no escalation.
|
||||
|
||||
Used as the building block for both `ollama_run_command`
|
||||
(which adds tenacity retries) and `ollama_run_command_with_chain`
|
||||
(which adds escalation across models).
|
||||
"""
|
||||
system_prompt, _declared = _load_prompt(cfg.prompt_dir, command_name)
|
||||
|
||||
url = f"{cfg.ollama_base_url}/chat/completions"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
@@ -594,6 +643,77 @@ def ollama_run_command(
|
||||
raise RuntimeError(f"Ollama response did not contain a JSON object: {text[:500]!r}")
|
||||
|
||||
|
||||
def ollama_run_command_with_chain(
|
||||
cfg: LLMConfig,
|
||||
command_name: str,
|
||||
payload: dict[str, Any],
|
||||
model_chain: list[str] | None = None,
|
||||
logger: logging.Logger | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Call an Ollama-served model with model escalation on failure.
|
||||
|
||||
Tries the first model in `model_chain` (or, when unset, the
|
||||
single model from `cfg.model_chain_for_command(command_name)`).
|
||||
On any failure — HTTP error, empty content, malformed JSON,
|
||||
explicit RuntimeError — logs the failure and moves to the
|
||||
next model in the chain. Returns the first successful response.
|
||||
|
||||
Each individual model is **not** retried internally (no
|
||||
tenacity). The chain is the retry policy. This avoids the
|
||||
pathological case where the same failing model is retried
|
||||
3 times before escalation, multiplying wall-clock latency
|
||||
on historical backfills.
|
||||
|
||||
Use this in preference to `ollama_run_command` from the
|
||||
historical pipeline: `gemma4:e4b` is fast but flaky on rich
|
||||
emails, and escalating to `gemma4:12b` then `gemma4:31b-cloud`
|
||||
trades latency for completion rate.
|
||||
"""
|
||||
if model_chain is None:
|
||||
model_chain = cfg.model_chain_for_command(command_name)
|
||||
if not model_chain:
|
||||
raise RuntimeError("model_chain is empty; cannot escalate")
|
||||
|
||||
last_exc: Exception | None = None
|
||||
for attempt_index, model in enumerate(model_chain):
|
||||
try:
|
||||
result = _ollama_call_one_model(
|
||||
cfg, command_name, payload, model, logger=logger
|
||||
)
|
||||
if attempt_index > 0 and logger is not None:
|
||||
log_event(
|
||||
logger,
|
||||
"llm_chain_recovered",
|
||||
service="llm",
|
||||
backend="ollama",
|
||||
command=command_name,
|
||||
modelID=model,
|
||||
attempt_index=attempt_index,
|
||||
chain_length=len(model_chain),
|
||||
)
|
||||
return result
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_exc = exc
|
||||
if logger is not None:
|
||||
log_event(
|
||||
logger,
|
||||
"llm_chain_step_failed",
|
||||
service="llm",
|
||||
backend="ollama",
|
||||
command=command_name,
|
||||
modelID=model,
|
||||
attempt_index=attempt_index,
|
||||
chain_length=len(model_chain),
|
||||
error=str(exc),
|
||||
)
|
||||
continue
|
||||
assert last_exc is not None
|
||||
raise RuntimeError(
|
||||
f"all {len(model_chain)} models in the chain failed for "
|
||||
f"command {command_name!r}; last error: {last_exc}"
|
||||
) from last_exc
|
||||
|
||||
|
||||
@retry(
|
||||
reraise=True,
|
||||
stop=stop_after_attempt(3),
|
||||
@@ -623,6 +743,27 @@ def llm_run_command(
|
||||
raise RuntimeError(f"Unknown INBOXOS_LLM_BACKEND: {cfg.backend!r}")
|
||||
|
||||
|
||||
def llm_run_command_with_chain(
|
||||
cfg: LLMConfig,
|
||||
command_name: str,
|
||||
payload: dict[str, Any],
|
||||
model_chain: list[str] | None = None,
|
||||
logger: logging.Logger | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Backend-dispatching LLM call with model escalation.
|
||||
|
||||
Mirrors `llm_run_command` but escalates across models on
|
||||
failure. Only the `ollama` backend is supported with model
|
||||
escalation; the legacy `opencode` backend is fixed to a
|
||||
single model and falls through to the chain-less path.
|
||||
"""
|
||||
if cfg.backend == "opencode":
|
||||
return llm_run_command(cfg, command_name, payload, logger=logger)
|
||||
return ollama_run_command_with_chain(
|
||||
cfg, command_name, payload, model_chain=model_chain, logger=logger
|
||||
)
|
||||
|
||||
|
||||
# ── Misc helpers ───────────────────────────────────────
|
||||
|
||||
def _require(name: str) -> str:
|
||||
|
||||
Reference in New Issue
Block a user