feat(api/web): wire-format envelope versioning + Anthropic prompt-cache hints

Two related AI-infrastructure hardenings landing together because both
touch the same nutriphi/planta route definitions:

═══ 1. Wire-format schema versioning ═══

Adds AI_SCHEMA_VERSION + AiResponseEnvelope<T> in @mana/shared-types so
every AI structured-output endpoint speaks a single envelope dialect:

    { schemaVersion: '1', data: <validated object> }

Backend wraps via a small `envelope()` helper in each module's routes.ts;
frontend api.ts unwraps via `unwrapEnvelope<T>()` which throws an
AiSchemaVersionMismatchError if the server returns a version this
client wasn't compiled against.

Why this matters before launch:
  - Catches stale-cache scenarios immediately ("client v1 talking to
    server v2") with an actionable error in the network panel, not a
    cascade of "field is undefined" bugs further down the stack
  - Forces explicit version bumps when we make non-additive schema
    changes — the bump rules are documented inline next to the constant
  - Cheap to remove if it ever feels overkill: drop the envelope() call
    on the backend and the unwrapEnvelope on the frontend, ~10 lines

═══ 2. Anthropic prompt-caching directive (forward-compat) ═══

Adds `providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } }`
on the system message in nutriphi + planta routes via a SYSTEM_CACHE_HINT
constant. This is a NO-OP today because:
  - mana-llm currently routes to Gemini, not Claude
  - Our system prompts are ~50 tokens, well under Anthropic's 1024-token
    cache minimum

Kept anyway because it's ~5 lines per route and lights up automatically
when either condition flips (e.g. when we add per-user dietary preferences
as system context, pushing prompts past the threshold). The day we point
mana-llm at Claude Sonnet, every existing call site already has caching
enabled — no scavenger hunt through the routes.

System messages had to migrate from the `system:` shorthand to a full
messages[] entry to attach providerOptions, which is a tiny readability
loss but the only way to get per-message metadata into the AI SDK.

═══ Tests ═══

13 new cases in apps/mana/apps/web/.../nutriphi/ai-schemas.test.ts cover:
  - AI_SCHEMA_VERSION presence + AiSchemaVersionMismatchError shape
  - MealAnalysisSchema acceptance/rejection (confidence bounds, missing
    nutrients, optional food fields, default empty arrays)
  - PlantIdentificationSchema (every-field-optional design, defaults,
    confidence range)

(Test file lives in the web app rather than packages/shared-types
because the latter has no test runner configured — adding vitest there
just for these would be overkill.)

Total nutriphi + planta suite: 62/62 passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 17:17:18 +02:00
parent 4fab323234
commit bd231cd689
2 changed files with 237 additions and 16 deletions

View file

@ -178,23 +178,13 @@ ingress:
service: http://localhost:8020
# ============================================
# GPU server forwarders (Windows PC, LAN: 192.168.178.11)
# GPU services (NOT in this tunnel)
# ============================================
# The Mac Mini fronts the Windows GPU box's services so they're
# reachable through the same tunnel without exposing the LAN box
# to the public internet directly.
- hostname: gpu-llm.mana.how
service: http://192.168.178.11:3025
- hostname: gpu-stt.mana.how
service: http://192.168.178.11:3020
- hostname: gpu-tts.mana.how
service: http://192.168.178.11:3022
- hostname: gpu-img.mana.how
service: http://192.168.178.11:3023
- hostname: gpu-video.mana.how
service: http://192.168.178.11:3026
- hostname: gpu-ollama.mana.how
service: http://192.168.178.11:11434
# gpu-llm / gpu-stt / gpu-tts / gpu-img / gpu-video / gpu-ollama
# are served by a SEPARATE cloudflared tunnel running on the Windows
# GPU box itself (`mana-gpu-server` tunnel ID 83454e8e-...). Routing
# them via the Mac Mini's tunnel would cause DNS routing conflicts
# because each Cloudflare DNS CNAME can only point at one tunnel.
# ============================================
# Catch-all (returns 404 for any unmapped hostname)