wavespestcontrolfl

waves-llm

Use when adding or modifying ANY LLM call site — model tiers, the DEEP helper, the ROUTES map, cross-provider fallbacks, or model env vars.

wavespestcontrolfl 0 1 Updated 3w ago
GitHub

Install

npx skillscat add wavespestcontrolfl/waves-customer-portal/waves-llm

Install via the SkillsCat registry.

SKILL.md

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.VISION

Tiers 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 on
fastStructured / 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 a
claude-* literal outside models.js / llm/deep.js.

2. Picking a tier

Tiers are semantic; the model each tier CURRENTLY resolves to lives only in
server/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) from
services/llm/call.js (first TEXT block) or stripThinkingBlocks from
deep.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 in
server/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 the
MODEL_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) put thinking blocks ahead of the text block, so
    content[0].text reads 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 than FALLBACK_MIN_MS of the caller's time budget
    remains; structured calls (options.jsonSchema) go through
    dispatchWithFallback, 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 referencing
MODELS.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 through
server/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 override
    GEMINI_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.js image 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' in admin-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) → retry GEMINI_VISION_FALLBACK_MODEL only 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) and
    satellite-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)
    and turf_ocr remain intentional consensus fan-outs,
    and visionAnalysis lanes (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.imageScreen is GPT-5.6 Sol first with Claude VISION as the
    backup; hero alt text stays on visionAnalysis.
    lawn-assessment.js#analyzePhoto (lawn scoring, changed first that day)
    and tree-shrub-assessment.js#analyzePhoto call 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's averageScores passes 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) use TEXT_POLICIES.photoIdVision: Gemini 3.8 Flash first;
    the same photo goes to ChatGPT's best vision model (OPENAI_FRONTIER)
    when Gemini misses, scores itself under PHOTO_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's
    photoIdVision ladder is unchanged): plant-engine.js's identifyPlantV2
    (lawn/tree/shrub/palm photo ID) uses TEXT_POLICIES.plantIdVision: Gemini
    3.8 Flash first, GPT-6 Sol (OPENAI_PLANT_ID, its own selector off
    OPENAI_FRONTIER) as the second opinion, sequential, same trigger rules as
    photoIdVision. Beyond that: Claude Fable 5.1 at effort high
    (MODELS.ROUTES.plantIdReferee, single-leg, no automatic fallback) gets
    one more look at the same photos as a deciding vote — ships DARK behind
    GATE_PLANT_ID_REFEREE (server/config/feature-gates.js's
    plantIdRefereeLive(), 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
    at likely, never pretty_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
    outside EXTREME/DEEP — do not copy this shape elsewhere without a
    fresh owner ruling.
    Estimate-image ruling 2026-09-25: satellite-analyzer.js and
    property-lookup-v2.js use TEXT_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_VISION owns
    the fallback independently of text/report models and legacy OPENAI_MODEL
    / OPENAI_VISION_MODEL pins. The satellite result remains single_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 — see isValidVisionScores there
    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-provider
TEXT_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 through
llm/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 (transcription gpt-4o-transcribe-diarize
    with Gemini fallback; V2 extraction routes CALL_EXTRACTION_PROVIDER /
    CALL_EXTRACTION_MODEL, default openai/gpt-5.6-sol with 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 in ROUTES. 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

  1. Pick the tier by job (table above); import from models.js.
  2. DEEP → createDeepMessage, max_tokens ≥ 4096.
  3. VOICE for customer-facing copy; escalate high-stakes to FLAGSHIP.
  4. Cross-provider → ROUTES + llm/call.js + Claude fallback.
  5. npm run check:domain-rules passes.
  6. New env vars documented in CLAUDE.md's Environment Variables section.