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

Adds AI_SCHEMA_VERSION + AiResponseEnvelope<T> in @mana/shared-types so
every AI structured-output endpoint speaks { schemaVersion, data }.
Backend wraps via envelope() in each module routes.ts; frontend api.ts
unwraps via unwrapEnvelope<T>() which throws AiSchemaVersionMismatchError
on drift — actionable network-panel error instead of cascading
'field is undefined' bugs further down the stack.

Also adds providerOptions.anthropic.cacheControl on the system message
in nutriphi + planta routes via SYSTEM_CACHE_HINT. NO-OP today (Gemini
backend, ~50-token prompts under the 1024-token cache minimum) but
lights up automatically when mana-llm routes to Claude or prompts grow
past the threshold. ~5 lines per route, no risk.

System messages migrated from system: shorthand to a full messages[]
entry — the only way to attach providerOptions per-message in the AI SDK.

13 new tests in nutriphi/ai-schemas.test.ts cover the version constant,
the mismatch error shape, and Zod accept/reject for both schemas. Total
nutriphi + planta suite: 62/62.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 17:21:19 +02:00
parent 9d1b25130d
commit 5aeae87474
6 changed files with 305 additions and 16 deletions

View file

@ -23,7 +23,12 @@
import { Hono } from 'hono';
import { generateObject } from 'ai';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { MealAnalysisSchema } from '@mana/shared-types';
import {
AI_SCHEMA_VERSION,
MealAnalysisSchema,
type AiResponseEnvelope,
type MealAnalysis,
} from '@mana/shared-types';
import { logger, type AuthVariables } from '@mana/shared-hono';
const LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025';
@ -36,6 +41,28 @@ const llm = createOpenAICompatible({
const ANALYSIS_PROMPT = `Du bist ein Ernährungsexperte. Analysiere die Mahlzeit und gib strukturierte Nährwertdaten zurück. Schätze realistische Portionsgrößen und Kalorien. Antworte auf Deutsch.`;
/**
* Provider hints attached to the system message. Forward-compat:
*
* - anthropic.cacheControl: ephemeral system-prompt caching. NO-OP today
* because (a) we route to Gemini via mana-llm and (b) the prompt is
* ~50 tokens well under Anthropic's 1024-token cache minimum. Becomes
* active automatically when mana-llm routes to Claude AND the prompt
* grows (e.g. once we attach per-user dietary preferences as system
* context, which would push us past the threshold).
*
* Kept here so the day we flip the backend, we don't have to revisit
* every route to enable caching it just starts working.
*/
const SYSTEM_CACHE_HINT = {
anthropic: { cacheControl: { type: 'ephemeral' as const } },
};
/** Wrap a validated AI object in the standard wire-format envelope. */
function envelope(data: MealAnalysis): AiResponseEnvelope<MealAnalysis> {
return { schemaVersion: AI_SCHEMA_VERSION, data };
}
const routes = new Hono<{ Variables: AuthVariables }>();
// ─── Photo Upload (server-only: S3 storage via mana-media) ───
@ -80,8 +107,12 @@ routes.post('/analysis/photo', async (c) => {
const { object } = await generateObject({
model: llm(VISION_MODEL),
schema: MealAnalysisSchema,
system: ANALYSIS_PROMPT,
messages: [
{
role: 'system',
content: ANALYSIS_PROMPT,
providerOptions: SYSTEM_CACHE_HINT,
},
{
role: 'user',
content: [
@ -92,7 +123,7 @@ routes.post('/analysis/photo', async (c) => {
],
temperature: 0.3,
});
return c.json(object);
return c.json(envelope(object));
} catch (err) {
logger.error('nutriphi.photo_analysis_failed', {
error: err instanceof Error ? err.message : String(err),
@ -111,11 +142,20 @@ routes.post('/analysis/text', async (c) => {
const { object } = await generateObject({
model: llm(VISION_MODEL),
schema: MealAnalysisSchema,
system: ANALYSIS_PROMPT,
prompt: `Analysiere diese Mahlzeit: ${description}`,
messages: [
{
role: 'system',
content: ANALYSIS_PROMPT,
providerOptions: SYSTEM_CACHE_HINT,
},
{
role: 'user',
content: `Analysiere diese Mahlzeit: ${description}`,
},
],
temperature: 0.3,
});
return c.json(object);
return c.json(envelope(object));
} catch (err) {
logger.error('nutriphi.text_analysis_failed', {
error: err instanceof Error ? err.message : String(err),

View file

@ -12,7 +12,12 @@
import { Hono } from 'hono';
import { generateObject } from 'ai';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { PlantIdentificationSchema } from '@mana/shared-types';
import {
AI_SCHEMA_VERSION,
PlantIdentificationSchema,
type AiResponseEnvelope,
type PlantIdentification,
} from '@mana/shared-types';
import { logger, type AuthVariables } from '@mana/shared-hono';
const LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025';
@ -25,6 +30,17 @@ const llm = createOpenAICompatible({
const IDENTIFICATION_PROMPT = `Du bist ein Pflanzenexperte. Analysiere das Pflanzenfoto und liefere eine strukturierte Identifikation mit lateinischem Namen, deutschen Trivialnamen, Pflegehinweisen und einer Gesundheitseinschätzung. Antworte auf Deutsch.`;
// See nutriphi/routes.ts for the rationale: this is a forward-compat
// hint for Anthropic prompt caching, ignored by Gemini today.
const SYSTEM_CACHE_HINT = {
anthropic: { cacheControl: { type: 'ephemeral' as const } },
};
/** Wrap a validated AI object in the standard wire-format envelope. */
function envelope(data: PlantIdentification): AiResponseEnvelope<PlantIdentification> {
return { schemaVersion: AI_SCHEMA_VERSION, data };
}
const routes = new Hono<{ Variables: AuthVariables }>();
// ─── Photo Upload (server-only: S3 storage) ─────────────────
@ -70,8 +86,12 @@ routes.post('/analysis/identify', async (c) => {
const { object } = await generateObject({
model: llm(VISION_MODEL),
schema: PlantIdentificationSchema,
system: IDENTIFICATION_PROMPT,
messages: [
{
role: 'system',
content: IDENTIFICATION_PROMPT,
providerOptions: SYSTEM_CACHE_HINT,
},
{
role: 'user',
content: [
@ -81,7 +101,7 @@ routes.post('/analysis/identify', async (c) => {
},
],
});
return c.json(object);
return c.json(envelope(object));
} catch (err) {
logger.error('planta.analysis_failed', {
error: err instanceof Error ? err.message : String(err),