Use when adding or modifying ANY LLM call site — model tiers, the DEEP helper, the ROUTES map, cross-provider fallbacks, or model env vars.
Install
npx skillscat add wavespestcontrolfl/waves-customer-portal/waves-llm Install via the SkillsCat registry.
Waves LLM call sites — tiers, routing, fallbacks
1. Never hardcode a model ID
Import a quality tier from server/config/models.js:
const MODELS = require('../config/models'); // adjust path
// MODELS.DEEP / MODELS.FLAGSHIP / MODELS.WORKHORSE / MODELS.FAST / MODELS.VOICE / MODELS.VISIONTiers are workload tiers (registry header, directive 2026-07-16 and the
2026-09-25 cost audit): the least-expensive model that is reliably strong for
the lane, Opus reserved for work a human or a customer reads, Fable explicit
only. The test for a lane: if nobody reads the output (a classifier, an
extractor, a verifier whose verdict is consumed by code), it belongs onfastStructured / FAST / WORKHORSE, not highStakes / FLAGSHIP. Every tier
is env-overridable (MODEL_FLAGSHIP, etc.)
so a model swap is a Railway var flip, never a code hunt. A per-feature pin
that can't use a tier still lives in models.js under the MODEL_<NAME>
registry convention (see LAWN_CHALLENGE) — never in the service file.
Enforced mechanically: npm run check:domain-rules fails the build on aclaude-* literal outside models.js / llm/deep.js.
2. Picking a tier
Tiers are semantic; the model each tier CURRENTLY resolves to lives only inserver/config/models.js (fallback string + env override) — read it there,
never from docs, which go stale.
| Tier | Use for |
|---|---|
DEEP |
Deepest reasoning, latency-tolerant, low-volume: agronomic wiki/KB stack, SMS draft verifier, shadow judge, blog fact-check gate. Defaults to the same Opus model as FLAGSHIP, with an OpenAI backup on refusal or API failure (llm/deep.js) |
EXTREME |
Explicit, latency-tolerant Fable opt-in only — no automatic live workflow routes here; callers must deliberately select it |
FLAGSHIP |
Best general reasoning: Intelligence Bar, advisors, analysis, agents |
WORKHORSE |
Drafting + content generation |
FAST |
High-volume classification, tagging, signals |
VOICE |
Customer-facing copy where warm/natural beats raw reasoning: SMS replies, service recaps, social posts. High-stakes messages (cancellations, complaints) escalate to FLAGSHIP at the call site |
VISION |
Image scoring, called with the SDK directly. No Anthropic call sends temperature (current models 400 on sampling controls); the Gemini scorer keeps its own |
2b. Reading a Message — never content[0]
Always-thinking models (Opus 5.5, Fable) put a thinking block ahead of the
text block. Read the answer with anthropicText(response) fromservices/llm/call.js (first TEXT block) or stripThinkingBlocks fromdeep.js — never content[0].text. Tool loops push response.content back
whole (thinking blocks included) so the next turn stays valid.
Opus 5.5 readiness (2026-09-25): request sizing lives inserver/services/llm/anthropic-wire.js. anthropicMaxTokens(model, cap) raises
a cap to a thinking floor on models that think by default (Opus 5+, Fable,
Mythos — thinking spends from max_tokens ahead of the text block) and leaves
it alone everywhere else; anthropicEffortConfig(model) spreads theMODEL_ANTHROPIC_EFFORT pin (5.5 defaults to medium, 4.8 to high) onto
models that accept every effort level (Opus 4.7+, Sonnet 5+, Fable, Mythos).
The adapter and the DEEP helper apply both; a new direct SDK call on an Opus
tier must too (max_tokens: anthropicMaxTokens(MODELS.X, n) plus...anthropicEffortConfig(MODELS.X)), and it runs inside the call ledger:await ledgerCall('anthropic', MODELS.X, () => client.messages.create({...}), { laneId: '<switchboard lane>' }) with the lane's policy at ledger: 'call'.tests/llm-call-ledger-coverage.test.js fails on an unwrapped direct call
outside its short, counted list of known exceptions (streaming voice, and lanes
whose Gemini / OpenAI legs are still raw fetches). thinking: { type: 'disabled' } and
forced tool_choice any/tool are 400s on 5.5 — only the two VOICE lanes send
the former (VOICE is Sonnet) and nothing sends the latter. Flip order in the
registry header.
2c. Caching
The adapter and the DEEP helper both put an ephemeral breakpoint on the
system prompt, so repeat calls inside five minutes read it back at ~0.1x.
Minimum cacheable prefix: 512 tokens on Opus 5 / 5.5 / Fable, 1024 on Opus 4.8
and Sonnet 5, 2048 on Opus 4.7, 4096 on Opus 4.5 / 4.6 and Haiku 4.5 — a
shorter prompt never caches, whatever the TTL (the previsit brief's ~450-token
prompt is below all of them). Check cached_input_tokens in llm_dispatch_log
before and after any caching change.
3. DEEP call sites — the helper is mandatory
Every DEEP call goes through server/services/llm/deep.js:
const { createDeepMessage } = require('../llm/deep');
const response = await createDeepMessage(anthropicClient, { ...params });Why (both have caused real parsing bugs):
- Thinking blocks. Always-thinking models (Fable, and Opus 5.5 once the
tiers flip) putthinkingblocks ahead of thetextblock, socontent[0].textreads the wrong block. The helper strips them. - Refusals. The model's safety classifiers can refuse benign
pesticide/termiticide-adjacent content (HTTP 200,stop_reason: 'refusal').
The helper retries on OpenAI (TEXT_POLICIES.deepAnalysis.fallback), and
API failures get the same backup. On the raw-message path the backup is
skipped when less thanFALLBACK_MIN_MSof the caller's time budget
remains; structured calls (options.jsonSchema) go throughdispatchWithFallback, where both legs share one deadline and the OpenAI
leg runs whenever any budget is left.
Also required at DEEP sites:
max_tokens≥ 4096 — thinking spends from the same budget.- Pass your own Anthropic client (per-site timeout/retry config and test
mocks keep working).
Enforced mechanically: check:domain-rules fails on a file referencingMODELS.DEEP without the helper.
4. Cross-provider routing (OpenAI / Gemini)
Owner directive 2026-06-17: the best model is the LIVE model — no
shadow/competing-model machinery. Some features route to OpenAI/Gemini via
the ROUTES map in models.js, dispatched throughserver/services/llm/call.js (the one fail-closed place for OpenAI
Responses / Gemini generateContent / Anthropic SDK).
Currently live:
- GPT-5.5 (
MODEL_OPENAI_BEST): lead-triage classification
(lead-triage.js), knowledge-base Q&A (knowledge-bridge.js), estimate
assistant (estimate-assistant.js). - Gemini 3.8 Flash (
MODEL_GEMINI_VISION; per-service overrideGEMINI_VISION_MODEL): every Gemini image-analysis leg — the photo lanes
(lawn-assessment.js,pest-identification.js,tree-shrub-assessment.js,treatment-zone-suggest.js,tech-social-caption.js,satellite-analyzer.js,property-lookup-v2.js,ai-chart-builder.jsimage intent) plus the per-service-env lanes that default to the
same registry selector (GEMINI_TURF_OCR_MODEL,LAWN_VISION_MODEL,GEMINI_PROPERTY_MODEL). Owner ruling 2026-09-06: one model for all of them.
Completion-photo captions also run on it (TEXT_POLICIES.photoCaptions,laneId: 'photo_scoring'inadmin-dispatch.js) — owner directive
2026-09-24, Claude Opus fallback only.
Cross-provider call sites keep an automatic fallback (Claude unless an owner-approved exception below applies) so
a provider issue never causes a gap:
- OpenAI features → Claude (the estimate assistant then falls to a
deterministic template). - Gemini vision ladders (lawn, pest, tree-shrub, treatment-zone, tech-caption
photo read) → retryGEMINI_VISION_FALLBACK_MODELonly when it names a
different model (the default equals BEST, so the retry rung is skipped),
THEN Claude VISION only if both Gemini rungs miss. Two exceptions make ONE
Gemini call with no retry: completion-photo captions
(TEXT_POLICIES.photoCaptions, Gemini → Claude through the dispatcher) andsatellite-analyzer.js(Gemini → Sol; see the estimate-image ruling below). Owner ruling
2026-09-24: the four customer photo-scoring lanes below (lawn, pest,
tree-shrub, satellite) are Gemini-first ladders — no parallel fan-out or
averaging. The ruling does NOT cover every vision lane:property_trio(text/web records, not images)
andturf_ocrremain intentional consensus fan-outs,
andvisionAnalysislanes (vision-delta, lawn quality gate, hero alt,
WDO brief) stay Anthropic-first — don't remove those without a new ruling.
The generated-image SCREEN is the ruled exception (owner 2026-09-25):TEXT_POLICIES.imageScreenis GPT-5.6 Sol first with Claude VISION as the
backup; hero alt text stays onvisionAnalysis.lawn-assessment.js#analyzePhoto(lawn scoring, changed first that day)
andtree-shrub-assessment.js#analyzePhotocall Gemini only; Claude runs
ONLY when Gemini returns nothing (HTTP/parse/empty/schema-invalid miss). A
single-model result still goes through each file's own single-model path
(lawn/tree-shrub'saverageScorespasses the lone result through
unchanged) — a one-model read can never surface as the two-model "agreed"
case.
Photo ID ruling 2026-09-26:pest-identification.js#analyzePhoto/identifyPest(website funnel, SMS photo triage, admin assessments, the
customer app) useTEXT_POLICIES.photoIdVision: Gemini 3.8 Flash first;
the same photo goes to ChatGPT's best vision model (OPENAI_FRONTIER)
when Gemini misses, scores itself underPHOTO_ID_ESCALATE_BELOW(default
0.80), or lists a runner-up of different risk. Sequential per photo, no
Claude leg. The second look's answer decides (resolvePhoto): an agreement
on the same species keeps the lower confidence, otherwise it is the lone
answer (downgraded a notch); a risky runner-up whose second look never
came back makes the upload an inspection-first consultation.
Plant photo ID ruling 2026-09-28, narrowed 2026-09-29 (replaces the
09-26 ruling above FOR THE PLANT ENGINE ONLY — the pest engine'sphotoIdVisionladder is unchanged):plant-engine.js'sidentifyPlantV2
(lawn/tree/shrub/palm photo ID) usesTEXT_POLICIES.plantIdVision: Gemini
3.8 Flash first, GPT-6 Sol (OPENAI_PLANT_ID, its own selector offOPENAI_FRONTIER) as the second opinion, sequential, same trigger rules asphotoIdVision. Beyond that: Claude Fable 5.1 at efforthigh
(MODELS.ROUTES.plantIdReferee, single-leg, no automatic fallback) gets
one more look at the same photos as a deciding vote — ships DARK behindGATE_PLANT_ID_REFEREE(server/config/feature-gates.js'splantIdRefereeLive(), read at call time). Owner ruling 2026-09-29
narrowed the referee to a plant-NAME tie-break only: it fires ONLY in
identify mode, and ONLY for an identity lane (turf/weeds for a lawn, host
for tree_shrub/palm) where Gemini and Sol DISAGREED — never for a workup, a
missing second opinion, or a low-confidence agreement. Its own top either
matches one side of the disagreement (that side goes first, wording capped
atlikely, neverpretty_sure) or it doesn't — a third name or no usable
answer leaves the lane exactly as the escalation left it (no partial
credit, no appended candidate). Gate off (default): the ladder is
byte-identical to Gemini → Sol, no third call. This is the ONE place in
the repo an LLM call site is intentionally single-provider with a Fable leg
outsideEXTREME/DEEP— do not copy this shape elsewhere without a
fresh owner ruling.
Estimate-image ruling 2026-09-25:satellite-analyzer.jsandproperty-lookup-v2.jsuseTEXT_POLICIES.estimateVision: Gemini 3.8 Flash
first, GPT-6 Sol only when Gemini fails or its output is invalid. No Claude,
third model, or parallel vision calls.MODEL_OPENAI_ESTIMATE_VISIONowns
the fallback independently of text/report models and legacyOPENAI_MODEL
/OPENAI_VISION_MODELpins. The satellite result remainssingle_model;
V2 preserves its measurement provenance with exactly one source.
All four files validate a parsed response against its own JSON-contract
shape before accepting it — a syntactically valid but empty/malformed
response (e.g.{}, or a field out of range) is still a truthy object, so
without this check it would read as a real result, skip the fallback, and
let missing fields become false zero-score findings (the exact bug Codex
flagged P1 on lawn-assessment 2026-09-24 — seeisValidVisionScoresthere
for the pattern each file's own validator mirrors).
Gemini parsing trap (twin of the DEEP thinking-block rule): Gemini 3.x
Flash is a thinking model — always JOIN ALL text parts of the response,
never read parts[0], and budget output tokens for the thinking spend.
Lawn visit exception (owner 2026-09-08, DECISIONS.md): the lawn visit
assessment (services/lawn-visit-assessment.js, GATE_LAWN_VISIT_ASSESSMENT)
is Gemini-first with GPT-6 Sol (OPENAI_LAWN_ASSESSMENT, MODEL_OPENAI_LAWN_ASSESSMENT;
Astra until the 2026-09-29 ruling) as its fallback —
no Claude vision leg, no parallel providers. It is still a two-providerTEXT_POLICIES entry (lawnVisitAssessment) through dispatchWithFallback;
only the fallback provider differs. Behind the dark GATE_LAWN_ASSESSMENT_REFEREE
(owner 2026-09-29) analyzeVisit adds a Sol second opinion when Gemini's
answer is unsure or serious (low/unknown-confidence or moderate/severe finding,
unknown grass) and a Claude Fable 5.1 tie-break (ROUTES.lawnAssessmentReferee,MODEL_LAWN_ASSESSMENT_REFEREE, effort high, single leg, called throughllm/call.js#dispatch) ONLY when Gemini and Sol disagree on a NAME (grass type
or what a finding is); scores, severities and every other field always stay as
Gemini read them (services/lawn-visit-referee.js). Do not copy this shape to
another lane without an owner ruling. Gemini thinkingLevel (LOW | MEDIUM | HIGH) and
image label parts (numbered photos) are dispatcher payload fields any lane
may use.
Adding a new cross-provider feature: add a ROUTES entry (env-overridable),
dispatch through llm/call.js, implement the Claude fallback, and document
the route in models.js. Never call the OpenAI/Gemini SDK directly from a
service.
5. The exceptions
- Call transcription + extraction keep their own providers/models in
call-recording-processor.js(transcriptiongpt-4o-transcribe-diarize
with Gemini fallback; V2 extraction routesCALL_EXTRACTION_PROVIDER/CALL_EXTRACTION_MODEL, defaultopenai/gpt-5.6-solwith a
registry-pinned Claude Opus fallback — 25-call bake-off 2026-07-18; kill =CALL_EXTRACTION_PROVIDER=gemini). Pipeline-specific by design; it is
intentionally NOT inROUTES. Change the extraction route only with a
fresh bake-off through the exact V2 contract. - Managed agents stay on Anthropic — the Managed Agents API has no
OpenAI equivalent. - Intelligence Bar has its own model overrides:
INTELLIGENCE_BAR_MODEL/INTELLIGENCE_BAR_TECH_MODEL(default
FLAGSHIP).
6. Checklist for a new LLM call site
- Pick the tier by job (table above); import from
models.js. - DEEP →
createDeepMessage,max_tokens≥ 4096. - VOICE for customer-facing copy; escalate high-stakes to FLAGSHIP.
- Cross-provider →
ROUTES+llm/call.js+ Claude fallback. npm run check:domain-rulespasses.- New env vars documented in CLAUDE.md's Environment Variables section.