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),

View file

@ -0,0 +1,131 @@
/**
* Sanity tests for the shared AI wire-format contract.
*
* The schemas themselves are mostly self-validating (Zod parses them at
* import time), so the focus here is the envelope contract: every
* frontend api.ts wraps its fetch result with the same shape, and a
* version drift between client and server should produce a clear,
* actionable error rather than silent corruption.
*
* Lives in the nutriphi module folder because nutriphi was the first
* consumer; the shared-types package itself has no test runner set up.
*/
import { describe, expect, it } from 'vitest';
import {
AI_SCHEMA_VERSION,
AiSchemaVersionMismatchError,
MealAnalysisSchema,
PlantIdentificationSchema,
} from '@mana/shared-types';
describe('AI_SCHEMA_VERSION', () => {
it('is a non-empty string constant', () => {
expect(typeof AI_SCHEMA_VERSION).toBe('string');
expect(AI_SCHEMA_VERSION.length).toBeGreaterThan(0);
});
});
describe('AiSchemaVersionMismatchError', () => {
it('captures both versions in the message', () => {
const err = new AiSchemaVersionMismatchError('99', '1' as typeof AI_SCHEMA_VERSION);
expect(err.message).toContain('99');
expect(err.message).toContain('1');
expect(err.received).toBe('99');
expect(err.expected).toBe('1');
});
it('defaults the expected version to AI_SCHEMA_VERSION', () => {
const err = new AiSchemaVersionMismatchError('42');
expect(err.expected).toBe(AI_SCHEMA_VERSION);
});
it('is named so it can be discriminated in catch blocks', () => {
const err = new AiSchemaVersionMismatchError('99');
expect(err.name).toBe('AiSchemaVersionMismatchError');
expect(err).toBeInstanceOf(Error);
});
});
describe('MealAnalysisSchema', () => {
const valid = {
foods: [{ name: 'Apfel', quantity: '1 Stück', calories: 95 }],
totalNutrition: {
calories: 95,
protein: 0.5,
carbohydrates: 25,
fat: 0.3,
fiber: 4.4,
sugar: 19,
},
description: 'Ein mittelgroßer Apfel',
confidence: 0.92,
};
it('accepts a complete payload', () => {
const parsed = MealAnalysisSchema.parse(valid);
expect(parsed.foods).toHaveLength(1);
expect(parsed.totalNutrition.calories).toBe(95);
});
it('fills in default empty arrays for warnings/suggestions', () => {
const parsed = MealAnalysisSchema.parse(valid);
expect(parsed.warnings).toEqual([]);
expect(parsed.suggestions).toEqual([]);
});
it('rejects invalid confidence (out of [0,1])', () => {
expect(() => MealAnalysisSchema.parse({ ...valid, confidence: 1.5 })).toThrow();
expect(() => MealAnalysisSchema.parse({ ...valid, confidence: -0.1 })).toThrow();
});
it('rejects missing required nutrition fields', () => {
const broken = {
...valid,
totalNutrition: { calories: 95 }, // missing protein, carbs, etc.
};
expect(() => MealAnalysisSchema.parse(broken)).toThrow();
});
it('allows foods without quantity/calories (model may not always estimate)', () => {
const minimal = {
...valid,
foods: [{ name: 'Käse' }],
};
const parsed = MealAnalysisSchema.parse(minimal);
expect(parsed.foods[0].name).toBe('Käse');
expect(parsed.foods[0].quantity).toBeUndefined();
});
});
describe('PlantIdentificationSchema', () => {
it('accepts a complete payload', () => {
const parsed = PlantIdentificationSchema.parse({
scientificName: 'Monstera deliciosa',
commonNames: ['Fensterblatt', 'Köstliches Fensterblatt'],
confidence: 0.88,
healthAssessment: 'Gesund',
wateringAdvice: 'Alle 7 Tage',
lightAdvice: 'Hell, indirektes Licht',
generalTips: ['Hohe Luftfeuchtigkeit bevorzugt'],
});
expect(parsed.scientificName).toBe('Monstera deliciosa');
expect(parsed.commonNames).toHaveLength(2);
});
it('fills in default empty arrays for commonNames/generalTips', () => {
const parsed = PlantIdentificationSchema.parse({});
expect(parsed.commonNames).toEqual([]);
expect(parsed.generalTips).toEqual([]);
});
it('accepts an empty object — every field is optional by design', () => {
const parsed = PlantIdentificationSchema.parse({});
expect(parsed.scientificName).toBeUndefined();
expect(parsed.confidence).toBeUndefined();
});
it('rejects out-of-range confidence', () => {
expect(() => PlantIdentificationSchema.parse({ confidence: 2 })).toThrow();
});
});

View file

@ -10,11 +10,37 @@
import { authStore } from '$lib/stores/auth.svelte';
import { getManaApiUrl } from '$lib/api/config';
// Wire format is the single source of truth in @mana/shared-types —
// the backend validates AI responses with these same Zod schemas.
import type { MealAnalysis } from '@mana/shared-types';
// the backend validates AI responses with these same Zod schemas and
// wraps them in an AiResponseEnvelope { schemaVersion, data }.
import {
AI_SCHEMA_VERSION,
AiSchemaVersionMismatchError,
type AiResponseEnvelope,
type MealAnalysis,
} from '@mana/shared-types';
export type MealAnalysisResult = MealAnalysis;
/**
* Decode an AI response envelope, asserting the schema version matches
* the one this client was compiled against. Throws if the server is on
* a different version (clears confusing "field is undefined" bugs in
* the wild instead you get an actionable error in the network panel).
*/
function unwrapEnvelope<T>(raw: unknown): T {
const env = raw as Partial<AiResponseEnvelope<T>> | null;
if (!env || typeof env !== 'object' || !('schemaVersion' in env)) {
throw new Error('AI response is not a versioned envelope');
}
if (env.schemaVersion !== AI_SCHEMA_VERSION) {
throw new AiSchemaVersionMismatchError(String(env.schemaVersion));
}
if (env.data === undefined) {
throw new Error('AI response envelope missing data field');
}
return env.data as T;
}
export interface UploadMealPhotoResult {
mediaId: string;
publicUrl: string;
@ -62,7 +88,7 @@ export async function analyzeMealPhoto(photoUrl: string): Promise<MealAnalysisRe
throw new Error(`Analysis failed (${res.status}): ${body || res.statusText}`);
}
return res.json() as Promise<MealAnalysisResult>;
return unwrapEnvelope<MealAnalysisResult>(await res.json());
}
/** Run Gemini analysis on a free-text meal description. */
@ -81,5 +107,5 @@ export async function analyzeMealText(description: string): Promise<MealAnalysis
throw new Error(`Analysis failed (${res.status}): ${body || res.statusText}`);
}
return res.json() as Promise<MealAnalysisResult>;
return unwrapEnvelope<MealAnalysisResult>(await res.json());
}

View file

@ -9,11 +9,32 @@
import { authStore } from '$lib/stores/auth.svelte';
import { getManaApiUrl } from '$lib/api/config';
// Wire format is the single source of truth in @mana/shared-types —
// the backend validates AI responses with the same Zod schema.
import type { PlantIdentification } from '@mana/shared-types';
// the backend validates AI responses with the same Zod schema and
// wraps them in an AiResponseEnvelope { schemaVersion, data }.
import {
AI_SCHEMA_VERSION,
AiSchemaVersionMismatchError,
type AiResponseEnvelope,
type PlantIdentification,
} from '@mana/shared-types';
export type IdentifyResult = PlantIdentification;
/** See nutriphi/api.ts for the rationale. */
function unwrapEnvelope<T>(raw: unknown): T {
const env = raw as Partial<AiResponseEnvelope<T>> | null;
if (!env || typeof env !== 'object' || !('schemaVersion' in env)) {
throw new Error('AI response is not a versioned envelope');
}
if (env.schemaVersion !== AI_SCHEMA_VERSION) {
throw new AiSchemaVersionMismatchError(String(env.schemaVersion));
}
if (env.data === undefined) {
throw new Error('AI response envelope missing data field');
}
return env.data as T;
}
export interface UploadPhotoResult {
storagePath: string;
publicUrl: string;
@ -62,5 +83,5 @@ export async function identifyPlant(photoUrl: string): Promise<IdentifyResult> {
throw new Error(`Identify failed (${res.status}): ${body || res.statusText}`);
}
return res.json() as Promise<IdentifyResult>;
return unwrapEnvelope<IdentifyResult>(await res.json());
}

View file

@ -20,6 +20,57 @@
import { z } from 'zod';
// ─── Wire-format versioning ──────────────────────────────────────
//
// All AI structured-output endpoints wrap their response as
// `{ schemaVersion, data }`. The version is bumped any time the data
// shape changes in a non-additive way. Frontend clients verify the
// version on receipt and throw if it doesn't match what they were
// compiled against — this prevents a stale browser cache from
// silently consuming a payload it can't decode.
//
// Bump rules:
// - Adding an optional field → no bump (forward-compatible)
// - Adding a required field → BUMP (old clients miss it)
// - Removing/renaming any field → BUMP (old clients break)
// - Changing a type → BUMP (zod parse fails on old client)
//
// History:
// 1 — initial schemas (foods/totalNutrition for nutriphi,
// scientificName/commonNames/etc for planta)
export const AI_SCHEMA_VERSION = '1' as const;
export type AiSchemaVersion = typeof AI_SCHEMA_VERSION;
/**
* Generic envelope used by every AI structured-output endpoint.
* Backend wraps the validated object in this; frontend api.ts
* unwraps it after checking schemaVersion === AI_SCHEMA_VERSION.
*/
export interface AiResponseEnvelope<T> {
schemaVersion: AiSchemaVersion;
data: T;
}
/**
* Thrown by frontend api.ts helpers when an envelope arrives with a
* schemaVersion the client wasn't compiled against. The error message
* includes both versions so it's obvious in the network panel which
* side is stale.
*/
export class AiSchemaVersionMismatchError extends Error {
constructor(
public readonly received: string,
public readonly expected: AiSchemaVersion = AI_SCHEMA_VERSION
) {
super(
`AI wire-format version mismatch: received "${received}", expected "${expected}". ` +
`The client and server are out of sync — reload the page or redeploy.`
);
this.name = 'AiSchemaVersionMismatchError';
}
}
// ─── NutriPhi: meal photo / text analysis ────────────────────────
const AnalyzedFoodSchema = z.object({