openapi: 3.1.0
info:
  title: FitVete Food & Nutrition API
  version: "1.4.0"
  description: >
    Query a curated, confidence-scored database of foods, recipes, drinks, and full
    micronutrient breakdowns over a simple REST API — plus barcode lookup and AI
    vision for meal photos and Nutrition Facts labels.


    Authentication is the `x-api-key` header (or `?apiKey=`). Requests are metered
    by daily points: most endpoints cost 1 point, image (vision) endpoints cost 10.
    Every response includes `X-RateLimit-*` headers. Results carry a `source` field
    (`corpus` = served from our curated dataset; `usda`/`apileague`/`openfoodfacts`/
    `vision` = freshly fetched and added to the dataset).
  contact:
    name: FitVete API support
    email: api@fitvete.com
    url: https://fitvete.com/api/
  license:
    name: Proprietary
    url: https://fitvete.com/api/
servers:
  - url: https://auth.fitvete.com/functions/v1/food-api
    description: Production
security:
  - ApiKeyHeader: []
tags:
  - name: Foods
  - name: Recipes
  - name: Drinks
  - name: Barcode
  - name: Vision
  - name: Intelligence
paths:
  /v1/search-foods:
    get:
      tags: [Foods]
      summary: Search foods by name
      description: "Returns calories, macros, and a confidence score per match. Cost: 1 point."
      parameters:
        - { name: query, in: query, required: true, schema: { type: string }, example: chicken, description: Food name. }
        - { name: number, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 25, default: 10 }, description: Max results. }
      responses:
        "200":
          description: Matching foods.
          content:
            application/json:
              schema:
                type: object
                properties:
                  number: { type: integer }
                  source: { type: string, enum: [corpus, usda] }
                  foods:
                    type: array
                    items: { $ref: "#/components/schemas/Food" }
              example:
                number: 1
                source: corpus
                foods:
                  - { name: chicken breast, calories: 120, protein_g: 22, carbs_g: 0, fat_g: 3, confidence: 100, source: food }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/QuotaExceeded" }
        "429": { $ref: "#/components/responses/BurstLimited" }
  /v1/compute-nutrition:
    get:
      tags: [Foods]
      summary: Compute nutrition for an ingredient list
      description: "Full nutrient breakdown (macros + 25+ micronutrients, properties, caloric breakdown) for a free-text ingredient list. Cost: 1 point."
      parameters:
        - { name: ingredients, in: query, required: true, schema: { type: string }, example: "1 cup rice", description: "One or more ingredients; separate lines with newlines." }
      responses:
        "200":
          description: Computed nutrition.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ComputedNutrition" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/QuotaExceeded" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/recipes/search:
    get:
      tags: [Recipes]
      summary: Search recipes by name and filters
      description: >
        Name search plus rich filters. A bare name query is served corpus-first;
        filtered/paged searches run upstream. Returns id, title, image, ready time,
        and calories per result. Cost: 1 point.
      parameters:
        - { name: query, in: query, required: false, schema: { type: string }, example: pasta, description: Name or keywords (optional if filters are given). }
        - { name: diet, in: query, required: false, schema: { type: string }, example: vegetarian, description: "e.g. vegetarian, vegan, ketogenic, paleo." }
        - { name: cuisine, in: query, required: false, schema: { type: string }, example: italian, description: "See GET /v1/recipes/cuisines." }
        - { name: type, in: query, required: false, schema: { type: string }, example: main course, description: "Meal type; see GET /v1/recipes/meal-types. Alias: mealType." }
        - { name: intolerances, in: query, required: false, schema: { type: string }, example: gluten,dairy, description: Comma-separated. }
        - { name: includeIngredients, in: query, required: false, schema: { type: string }, example: tomato,basil, description: Comma-separated must-include. }
        - { name: excludeIngredients, in: query, required: false, schema: { type: string }, example: peanut, description: Comma-separated must-exclude. }
        - { name: maxReadyTime, in: query, required: false, schema: { type: integer }, example: 30, description: Max minutes to prepare. }
        - { name: minCalories, in: query, required: false, schema: { type: integer } }
        - { name: maxCalories, in: query, required: false, schema: { type: integer }, example: 600 }
        - { name: minProtein, in: query, required: false, schema: { type: integer }, example: 20 }
        - { name: sort, in: query, required: false, schema: { type: string }, example: popularity, description: "e.g. popularity, calories, time, protein." }
        - { name: offset, in: query, required: false, schema: { type: integer, minimum: 0, maximum: 900 }, description: Pagination offset. }
        - { name: number, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 25, default: 10 } }
      responses:
        "200":
          description: Matching recipes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  number: { type: integer }
                  offset: { type: integer }
                  totalResults: { type: integer }
                  source: { type: string }
                  recipes:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer, nullable: true }
                        title: { type: string }
                        image: { type: string, nullable: true }
                        ready_in_minutes: { type: integer, nullable: true }
                        calories: { type: integer, nullable: true }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/QuotaExceeded" }
  /v1/recipes/random:
    get:
      tags: [Recipes]
      summary: Get random recipes
      description: "Random full recipes, harvested into the corpus. Cost: 1 point."
      parameters:
        - { name: number, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 25, default: 5 } }
        - { name: tags, in: query, required: false, schema: { type: string }, example: vegetarian,dessert, description: Comma-separated diet/meal tags. }
      responses:
        "200":
          description: Random recipes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  number: { type: integer }
                  source: { type: string }
                  recipes: { type: array, items: { type: object, additionalProperties: true } }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/recipes/autocomplete:
    get:
      tags: [Recipes]
      summary: Autocomplete recipe titles
      description: "Title suggestions for a partial query. Cost: 1 point."
      parameters:
        - { name: query, in: query, required: true, schema: { type: string }, example: choc }
        - { name: number, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 25, default: 10 } }
      responses:
        "200":
          description: Suggestions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  number: { type: integer }
                  source: { type: string }
                  suggestions:
                    type: array
                    items:
                      type: object
                      properties: { id: { type: integer, nullable: true }, title: { type: string } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/recipes/by-nutrients:
    get:
      tags: [Recipes]
      summary: Find recipes by nutrient ranges
      description: "Match recipes to calorie/protein/carb/fat ranges. At least one range required. Cost: 1 point."
      parameters:
        - { name: minCalories, in: query, required: false, schema: { type: integer } }
        - { name: maxCalories, in: query, required: false, schema: { type: integer }, example: 600 }
        - { name: minProtein, in: query, required: false, schema: { type: integer }, example: 30 }
        - { name: maxProtein, in: query, required: false, schema: { type: integer } }
        - { name: minCarbs, in: query, required: false, schema: { type: integer } }
        - { name: maxCarbs, in: query, required: false, schema: { type: integer } }
        - { name: minFat, in: query, required: false, schema: { type: integer } }
        - { name: maxFat, in: query, required: false, schema: { type: integer } }
        - { name: number, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 25, default: 10 } }
      responses:
        "200":
          description: Matching recipes with per-recipe macros.
          content:
            application/json:
              schema:
                type: object
                properties:
                  number: { type: integer }
                  source: { type: string }
                  recipes:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer, nullable: true }
                        title: { type: string }
                        image: { type: string, nullable: true }
                        calories: { type: integer, nullable: true }
                        protein_g: { type: integer }
                        carbs_g: { type: integer }
                        fat_g: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/recipes/diets:
    get:
      tags: [Recipes]
      summary: List supported diet filters
      description: "Reference list (no upstream call). Cost: 1 point."
      responses:
        "200": { description: Supported diets., content: { application/json: { schema: { type: object, properties: { source: { type: string }, diets: { type: array, items: { type: string } } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/recipes/meal-types:
    get:
      tags: [Recipes]
      summary: List supported meal types
      description: "Reference list (no upstream call). Cost: 1 point."
      responses:
        "200": { description: Supported meal types., content: { application/json: { schema: { type: object, properties: { source: { type: string }, meal_types: { type: array, items: { type: string } } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/recipes/cuisines:
    get:
      tags: [Recipes]
      summary: List supported cuisines
      description: "Reference list (no upstream call). Cost: 1 point."
      responses:
        "200": { description: Supported cuisines., content: { application/json: { schema: { type: object, properties: { source: { type: string }, cuisines: { type: array, items: { type: string } } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/recipes/{id}/similar:
    get:
      tags: [Recipes]
      summary: Find similar recipes
      description: "Recipes similar to the given id. Cost: 1 point."
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer }, example: 715538 }
        - { name: number, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 25, default: 5 } }
      responses:
        "200":
          description: Similar recipes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  number: { type: integer }
                  source: { type: string }
                  recipes:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer, nullable: true }
                        title: { type: string }
                        ready_in_minutes: { type: integer, nullable: true }
                        servings: { type: integer, nullable: true }
                        source_url: { type: string, nullable: true }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/recipes/{id}:
    get:
      tags: [Recipes]
      summary: Get full recipe detail
      description: "Images, dietary properties, nutrition, ingredients with measures, and step-by-step instructions. Optional servings scaler. Cost: 1 point."
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer }, example: 715538 }
        - { name: servings, in: query, required: false, schema: { type: integer, minimum: 1 }, description: Add a serving_scale block with macros scaled to this many servings. }
      responses:
        "200":
          description: Full recipe payload (provider-shaped) plus a `source` field (and `serving_scale` if servings given).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/drinks/search:
    get:
      tags: [Drinks]
      summary: Search drinks and cocktails
      description: "Flavors, glass type, ingredients, and nutrition. Cost: 1 point."
      parameters:
        - { name: query, in: query, required: true, schema: { type: string }, example: margarita }
        - { name: number, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 25, default: 10 } }
      responses:
        "200":
          description: Matching drinks.
          content:
            application/json:
              schema:
                type: object
                properties:
                  number: { type: integer }
                  source: { type: string }
                  drinks: { type: array, items: { type: object, additionalProperties: true } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/barcode/{code}:
    get:
      tags: [Barcode]
      summary: Look up a packaged food by barcode
      description: "UPC/EAN lookup (Open Food Facts), harvested into the corpus. Cost: 1 point."
      parameters:
        - { name: code, in: path, required: true, schema: { type: string }, example: "3017620422003", description: Numeric UPC/EAN. }
      responses:
        "200":
          description: Product found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BarcodeProduct" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/score:
    post:
      tags: [Intelligence]
      summary: Personalized health score for a food
      description: >
        Deterministic 0–100 score + A–F grade for a food, personalized to the
        user's profile. Rubric: Nutri-Score-derived base (penalize energy,
        saturated fat, sugar, sodium; reward fiber, protein), minus an
        ultra-processing (NOVA) penalty, then profile modifiers. An allergen or
        avoid-list match forces grade F. Cost: 3 points.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                food:
                  type: object
                  description: Reference the food by one of name, barcode, or id.
                  properties: { name: { type: string }, barcode: { type: string }, id: { type: string } }
                profile:
                  type: object
                  properties:
                    goals: { type: array, items: { type: string }, example: ["build muscle", "low sugar"] }
                    diet: { type: string, example: vegan }
                    allergies: { type: array, items: { type: string }, example: ["milk", "peanut"] }
                    avoid: { type: array, items: { type: string }, example: ["palm oil"] }
                explain: { type: boolean, description: Add a one-line natural-language summary. }
              required: [food]
            example: { food: { name: "chicken breast" }, profile: { goals: ["build muscle"] } }
      responses:
        "200":
          description: Score result.
          content:
            application/json:
              schema:
                type: object
                properties:
                  source: { type: string }
                  food: { type: string }
                  score: { type: integer, description: 0–100. }
                  grade: { type: string, description: A–E, or F for allergen/avoid hits. }
                  reasons: { type: array, items: { type: string } }
                  flags: { type: array, items: { type: object, properties: { type: { type: string }, value: { type: string } } } }
                  eco_score: { type: string, nullable: true }
                  nova: { type: integer, nullable: true }
                  summary: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/QuotaExceeded" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/analyze-ingredients:
    post:
      tags: [Intelligence]
      summary: Analyze an ingredient list
      description: "Detect additives/E-numbers, allergens, NOVA ultra-processing level, and vegan/vegetarian/gluten-free flags. Cost: 2 points."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties: { ingredients: { type: string } }
              required: [ingredients]
            example: { ingredients: "sugar, palm oil, hazelnuts, skim milk, cocoa, soy lecithin, E471" }
      responses:
        "200":
          description: Ingredient analysis.
          content:
            application/json:
              schema:
                type: object
                properties:
                  source: { type: string }
                  additives: { type: array, items: { type: object, properties: { code: { type: string }, name: { type: string }, concern: { type: string } } } }
                  allergens: { type: array, items: { type: string } }
                  nova: { type: integer, description: 1–4 ultra-processing level. }
                  nova_reason: { type: string }
                  vegan: { type: boolean }
                  vegetarian: { type: boolean }
                  gluten_free: { type: boolean }
                  reasons: { type: array, items: { type: string } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/parse:
    post:
      tags: [Intelligence]
      summary: Parse a free-text meal (NLP)
      description: "Turn a natural-language meal into structured items with quantities + per-item and total nutrition. Cost: 5 points."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties: { text: { type: string } }
              required: [text]
            example: { text: "2 eggs and a slice of whole wheat toast with butter" }
      responses:
        "200":
          description: Parsed meal.
          content:
            application/json:
              schema:
                type: object
                properties:
                  source: { type: string }
                  text: { type: string }
                  calories: { type: integer }
                  protein_g: { type: integer }
                  carbs_g: { type: integer }
                  fat_g: { type: integer }
                  items:
                    type: array
                    items:
                      type: object
                      properties: { name: { type: string }, quantity: { type: number }, unit: { type: string }, calories: { type: integer }, protein_g: { type: integer }, carbs_g: { type: integer }, fat_g: { type: integer } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/Unreadable" }
        "502": { $ref: "#/components/responses/ProviderDown" }
  /v1/alternatives:
    get:
      tags: [Intelligence]
      summary: Healthier alternatives
      description: "Higher-scoring swaps for a food from the catalog, ranked. Cost: 1 point."
      parameters:
        - { name: name, in: query, required: false, schema: { type: string }, example: dark chocolate }
        - { name: barcode, in: query, required: false, schema: { type: string } }
        - { name: id, in: query, required: false, schema: { type: string } }
        - { name: number, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 10, default: 5 } }
      responses:
        "200":
          description: Ranked alternatives.
          content:
            application/json:
              schema:
                type: object
                properties:
                  source: { type: string }
                  for: { type: string }
                  base_score: { type: integer }
                  base_grade: { type: string }
                  alternatives:
                    type: array
                    items:
                      type: object
                      properties: { name: { type: string }, calories: { type: integer }, protein_g: { type: integer }, carbs_g: { type: integer }, fat_g: { type: integer }, score: { type: integer } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/foods/autocomplete:
    get:
      tags: [Foods]
      summary: Food name typeahead
      description: "Fast food name suggestions from the corpus (upstream fallback). Cost: 1 point."
      parameters:
        - { name: query, in: query, required: true, schema: { type: string }, example: chick }
        - { name: number, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 25, default: 10 } }
      responses:
        "200":
          description: Suggestions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  number: { type: integer }
                  source: { type: string }
                  suggestions:
                    type: array
                    items:
                      type: object
                      properties: { name: { type: string }, calories: { type: integer } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/nutrition-from-photo:
    post:
      tags: [Vision]
      summary: Estimate meal nutrition from a photo
      description: "AI vision estimates macros + nutrients from a meal image. Cost: 10 points. Send multipart field `image`, or POST raw bytes with an image/* content-type. Max 5 MB; JPEG/PNG/WebP."
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                image: { type: string, format: binary }
              required: [image]
          image/jpeg:
            schema: { type: string, format: binary }
      responses:
        "200":
          description: Estimated meal nutrition.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PhotoNutrition" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/QuotaExceeded" }
        "413": { $ref: "#/components/responses/TooLarge" }
        "415": { $ref: "#/components/responses/UnsupportedMedia" }
        "422": { $ref: "#/components/responses/Unreadable" }
        "502": { $ref: "#/components/responses/ProviderDown" }
  /v1/nutrition-label:
    post:
      tags: [Vision]
      summary: Parse a Nutrition Facts label from a photo
      description: "AI vision transcribes a Nutrition Facts label into structured fields. Cost: 10 points. Send multipart field `image`, or POST raw bytes with an image/* content-type. Max 5 MB; JPEG/PNG/WebP."
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                image: { type: string, format: binary }
              required: [image]
          image/jpeg:
            schema: { type: string, format: binary }
      responses:
        "200":
          description: Parsed label.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LabelNutrition" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/QuotaExceeded" }
        "413": { $ref: "#/components/responses/TooLarge" }
        "415": { $ref: "#/components/responses/UnsupportedMedia" }
        "422": { $ref: "#/components/responses/Unreadable" }
        "502": { $ref: "#/components/responses/ProviderDown" }
components:
  securitySchemes:
    ApiKeyHeader:
      type: apiKey
      in: header
      name: x-api-key
      description: Your API key (fv_live_…). Get one free at https://fitvete.com/api/
  schemas:
    Nutrient:
      type: object
      properties:
        key: { type: string, example: fiber }
        name: { type: string, example: Fiber }
        amount: { type: number, example: 0.6 }
        unit: { type: string, example: g }
        percent_of_daily_needs: { type: number, example: 2.4 }
    Food:
      type: object
      properties:
        name: { type: string }
        calories: { type: integer, description: Per 100 g. }
        protein_g: { type: integer }
        carbs_g: { type: integer }
        fat_g: { type: integer }
        confidence: { type: number, description: 0–100; higher = more corroborated. }
        source: { type: string }
    ComputedNutrition:
      type: object
      properties:
        source: { type: string }
        calories: { type: integer }
        protein_g: { type: integer }
        carbs_g: { type: integer }
        fat_g: { type: integer }
        nutrients: { type: array, items: { $ref: "#/components/schemas/Nutrient" } }
        confidence: { type: number }
    BarcodeProduct:
      type: object
      properties:
        source: { type: string }
        found: { type: boolean }
        code: { type: string }
        name: { type: string }
        brand: { type: string, nullable: true }
        calories: { type: integer }
        protein_g: { type: integer }
        carbs_g: { type: integer }
        fat_g: { type: integer }
        serving: { type: string, nullable: true }
        image_url: { type: string, nullable: true }
    PhotoNutrition:
      type: object
      properties:
        source: { type: string, example: vision }
        name: { type: string }
        calories: { type: integer }
        protein_g: { type: integer }
        carbs_g: { type: integer }
        fat_g: { type: integer }
        items:
          type: array
          items:
            type: object
            properties: { name: { type: string }, calories: { type: integer } }
        confidence: { type: string, enum: [high, medium, low] }
        nutrients: { type: array, items: { $ref: "#/components/schemas/Nutrient" } }
    LabelNutrition:
      type: object
      properties:
        source: { type: string, example: vision }
        calories: { type: integer }
        protein_g: { type: integer }
        carbs_g: { type: integer }
        fat_g: { type: integer }
        serving_size: { type: string, nullable: true }
        servings_per_container: { type: number, nullable: true }
        nutrients: { type: array, items: { $ref: "#/components/schemas/Nutrient" } }
    Error:
      type: object
      properties:
        status: { type: string, example: failure }
        code: { type: integer }
        message: { type: string }
  responses:
    BadRequest:
      description: Missing or invalid parameter.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Unauthorized:
      description: Missing, invalid, or deleted API key.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    QuotaExceeded:
      description: Daily point limit reached — upgrade your plan.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NotFound:
      description: Not found.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    BurstLimited:
      description: Per-minute burst limit hit — retry after the Retry-After seconds.
      headers:
        Retry-After: { schema: { type: integer }, description: Seconds to wait. }
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    TooLarge:
      description: Image exceeds the 5 MB limit.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    UnsupportedMedia:
      description: Unsupported image type — use JPEG, PNG, or WebP.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Unreadable:
      description: Image is valid, but nutrition could not be read clearly.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    ProviderDown:
      description: Vision provider temporarily unavailable — retry.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
