{
  "openapi": "3.1.0",
  "info": {
    "title": "RiftVoiceLab API",
    "version": "1.0.0",
    "summary": "Text zu Sprache mit den Stimmen, die deinem Schluessel zugeteilt sind.",
    "description": "Ein Schluessel, ein Regal: jeder API-Schluessel sieht ausschliesslich die Stimmen,\ndie ihm in der Werkbank zugeteilt wurden. Alles andere existiert fuer ihn nicht.\n\n**Authentifizierung** — `Authorization: Bearer rvl_…` (alternativ `X-API-Key`).\nDer Schluessel wird genau einmal angezeigt, beim Anlegen. Danach liegt bei uns\nnur noch sein SHA-256 — verloren heisst neu ausstellen.\n\n**Nicht in den Browser.** Ein Schluessel gehoert auf einen Server. Wer ihn in\nFrontend-Code legt, verschenkt sein Kontingent.",
    "x-logo": {
      "url": "https://voice.demianlabs.xyz/icon-192.png"
    },
    "contact": {
      "name": "RiftVoiceLab",
      "url": "https://voice.demianlabs.xyz/api"
    }
  },
  "servers": [
    {
      "url": "https://voice.demianlabs.xyz",
      "description": "Produktion"
    }
  ],
  "tags": [
    {
      "name": "Stimmen",
      "description": "Welche Stimmen dein Schluessel benutzen darf."
    },
    {
      "name": "Synthese",
      "description": "Text hinein, MP3 heraus."
    },
    {
      "name": "Modelle",
      "description": "Welches Modell spricht — und was es kostet."
    },
    {
      "name": "Schluessel",
      "description": "Zustand, Kontingent und Grenzen deines Schluessels."
    },
    {
      "name": "Dienst",
      "description": "Erreichbarkeit ohne Anmeldung."
    }
  ],
  "security": [
    {
      "bearerAuth": []
    },
    {
      "apiKeyHeader": []
    }
  ],
  "paths": {
    "/api/v1/health": {
      "get": {
        "tags": [
          "Dienst"
        ],
        "operationId": "health",
        "summary": "Erreichbarkeit pruefen",
        "description": "Ohne Schluessel abrufbar. Sagt nur, ob der Dienst antwortet.",
        "security": [],
        "responses": {
          "200": {
            "description": "Dienst antwortet.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "example": "ok"
                    },
                    "time": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "version": {
                      "type": "string",
                      "example": "1.0.0"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/me": {
      "get": {
        "tags": [
          "Schluessel"
        ],
        "operationId": "getKey",
        "summary": "Schluessel, Kontingent und Grenzen",
        "responses": {
          "200": {
            "description": "Zustand des Schluessels.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/KeyStatus"
                }
              }
            }
          },
          "401": {
            "description": "Schluessel fehlt oder ist unbekannt.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Schluessel stillgelegt oder abgelaufen.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/voices": {
      "get": {
        "tags": [
          "Stimmen"
        ],
        "operationId": "listVoices",
        "summary": "Zugeteilte Stimmen auflisten",
        "description": "Gibt genau die Stimmen zurueck, die diesem Schluessel zugeteilt sind.",
        "responses": {
          "200": {
            "description": "Liste der freigegebenen Stimmen.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/VoiceList"
                }
              }
            }
          },
          "401": {
            "description": "Schluessel fehlt oder ist unbekannt.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Schluessel stillgelegt oder abgelaufen.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/voices/{voiceId}": {
      "get": {
        "tags": [
          "Stimmen"
        ],
        "operationId": "getVoice",
        "summary": "Eine Stimme nachschlagen",
        "parameters": [
          {
            "name": "voiceId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Kennung aus /api/v1/voices."
          }
        ],
        "responses": {
          "200": {
            "description": "Die Stimme.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Voice"
                }
              }
            }
          },
          "401": {
            "description": "Schluessel fehlt oder ist unbekannt.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Nicht vorhanden oder fuer diesen Schluessel nicht freigegeben.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/models": {
      "get": {
        "tags": [
          "Modelle"
        ],
        "operationId": "listModels",
        "summary": "Modelle auflisten",
        "description": "RiftVoiceV1 spricht den Text wortgetreu. RiftVoiceV2 liest ihn vorher mit,\nsetzt Betonung, Pausen und Stimmung selbst und spricht ihn dann —\nrealistischer als jedes verfuegbare Modell. Rift 2.1 legt darueber noch\nsetzt Betonung sparsam statt ueberall, nimmt doppelte Pausen wieder\nheraus und gibt mit 192 statt 128 kbit/s aus.\n\n`characterCost` sagt, wie viele Zeichen ein Zeichen vom Kontingent nimmt:\nbei RiftVoiceV1 eines, bei RiftVoiceV2 zwei, bei Rift 2.1 zweieinhalb\n(aufgerundet auf ganze Zeichen).",
        "responses": {
          "200": {
            "description": "Die Modelle.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ModelList"
                }
              }
            }
          },
          "401": {
            "description": "Schluessel fehlt oder ist unbekannt.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/endpoints": {
      "get": {
        "tags": [
          "Synthese"
        ],
        "operationId": "listEndpoints",
        "summary": "Eigene Endpunkte auflisten",
        "description": "Endpunkte, die in der Werkbank angelegt und eingeschaltet sind. Jeder spricht mit einem fest eingestellten Modell.",
        "responses": {
          "200": {
            "description": "Die Endpunkte.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "description": "Schluessel fehlt oder ist unbekannt.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/endpoints/{endpointId}": {
      "post": {
        "tags": [
          "Synthese"
        ],
        "operationId": "createEndpointSpeech",
        "summary": "Ueber einen eigenen Endpunkt sprechen",
        "description": "Derselbe Rumpf wie bei `/api/v1/speech` (`voice`, `text`, `encoding`).\nDas Feld `model` zaehlt hier nicht — der Endpunkt legt es fest.\nAbgerechnet wird ein Zeichen je Zeichen.",
        "parameters": [
          {
            "name": "endpointId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SpeechRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Fertiger Ton.",
            "content": {
              "audio/mpeg": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "401": {
            "description": "Schluessel fehlt oder ist unbekannt.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Diesen Endpunkt gibt es nicht.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/speech": {
      "post": {
        "tags": [
          "Synthese"
        ],
        "operationId": "createSpeech",
        "summary": "Text sprechen lassen",
        "description": "Liefert standardmaessig rohe MP3-Bytes (`audio/mpeg`). Mit `\"encoding\": \"base64\"`\nkommt stattdessen JSON mit dem Ton als Base64 zurueck.\n\nAbgerechnet wird die Laenge des bereinigten Textes gegen das Tageskontingent\ndes Schluessels. Schlaegt die Synthese fehl, wird zurueckgebucht.\n\nMit `model` waehlst du die Stimmfuehrung. Ohne Angabe gilt `RiftVoiceV1` —\nbestehende Aufrufe bleiben damit unveraendert. `RiftVoiceV2` klingt\nrealistischer als jedes verfuegbare Modell und zaehlt jedes\nZeichen doppelt. `Rift 2.1` ist die Spitze: sparsame Regie ohne\ndoppelte Pausen, Ausgabe mit 192 kbit/s, und eine zu kurz geratene\nAufnahme wird nicht ausgeliefert sondern neu genommen — zweieinhalb\nZeichen je Zeichen, bei gleicher Wartezeit wie RiftVoiceV2.\nWas wirklich gebucht wurde, steht in\n`X-Rift-Billed-Chars` und im Feld `billedCharacters`; faellt ein\nSchritt aus, wird genau er zurueckgebucht.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SpeechRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Fertiger Ton.",
            "headers": {
              "X-Rift-Request-Id": {
                "schema": {
                  "type": "string"
                },
                "description": "Kennung dieses Laufs."
              },
              "X-Rift-Model": {
                "schema": {
                  "type": "string"
                },
                "description": "Das Modell, das gesprochen hat."
              },
              "X-Rift-Chars": {
                "schema": {
                  "type": "integer"
                },
                "description": "Zeichen im Text."
              },
              "X-Rift-Billed-Chars": {
                "schema": {
                  "type": "integer"
                },
                "description": "Tatsaechlich gebuchte Zeichen."
              },
              "X-Rift-Bytes": {
                "schema": {
                  "type": "integer"
                },
                "description": "Groesse der MP3."
              },
              "X-Rift-Ms": {
                "schema": {
                  "type": "integer"
                },
                "description": "Dauer des Laufs."
              },
              "X-Rift-Quota-Remaining": {
                "schema": {
                  "type": "string"
                },
                "description": "Rest heute, oder \"unlimited\"."
              },
              "X-Rift-Quota-Reset": {
                "schema": {
                  "type": "string"
                },
                "description": "ISO-Zeitpunkt des naechsten Resets, oder \"never\"."
              }
            },
            "content": {
              "audio/mpeg": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SpeechJson"
                }
              }
            }
          },
          "400": {
            "description": "Text oder Stimme fehlt, Text zu lang.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Schluessel fehlt oder ist unbekannt.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Schluessel stillgelegt oder abgelaufen.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Stimme nicht gefunden oder nicht freigegeben.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Zu viele gleichzeitige Anfragen dieses Schluessels.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Kontingent aufgebraucht oder zu schnell hintereinander.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "Die Synthese ist nicht durchgelaufen.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Dienst gerade nicht verfuegbar.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "`Authorization: Bearer rvl_…`"
      },
      "apiKeyHeader": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key",
        "description": "Gleichwertig zum Bearer-Kopf."
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "type",
              "code",
              "message",
              "requestId"
            ],
            "properties": {
              "type": {
                "type": "string",
                "example": "invalid_request_error"
              },
              "code": {
                "type": "string",
                "enum": [
                  "missing_key",
                  "invalid_key",
                  "key_disabled",
                  "key_expired",
                  "ip_not_allowed",
                  "no_voices",
                  "invalid_request",
                  "text_required",
                  "text_too_long",
                  "voice_required",
                  "voice_not_found",
                  "model_not_found",
                  "not_found",
                  "method_not_allowed",
                  "concurrent_limit",
                  "payload_too_large",
                  "quota_exhausted",
                  "rate_limited",
                  "synthesis_failed",
                  "voice_unavailable",
                  "maintenance",
                  "service_unavailable",
                  "internal"
                ]
              },
              "message": {
                "type": "string"
              },
              "detail": {
                "type": "string"
              },
              "retryAfter": {
                "type": "integer",
                "description": "Sekunden bis zum naechsten Versuch."
              },
              "requestId": {
                "type": "string"
              },
              "docs": {
                "type": "string"
              }
            }
          }
        }
      },
      "Voice": {
        "type": "object",
        "required": [
          "id",
          "name"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Kennung fuer das Feld \"voice\".",
            "example": "8kq2m1x9"
          },
          "name": {
            "type": "string",
            "example": "Erzaehler Nord"
          },
          "tone": {
            "type": "string",
            "example": "warm, ruhig"
          },
          "language": {
            "type": "string",
            "example": "DE"
          }
        }
      },
      "VoiceList": {
        "type": "object",
        "properties": {
          "object": {
            "type": "string",
            "example": "list"
          },
          "count": {
            "type": "integer",
            "example": 2
          },
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Voice"
            }
          }
        }
      },
      "Quota": {
        "type": "object",
        "properties": {
          "unlimited": {
            "type": "boolean"
          },
          "reset": {
            "type": "string",
            "enum": [
              "daily",
              "never"
            ],
            "description": "daily = Zaehler springt taeglich (Mitternacht Berlin) auf null; never = kein Reset, aufgebraucht heisst gesperrt."
          },
          "limit": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Zeichen pro Tag (daily) bzw. insgesamt (never), oder null ohne Grenze."
          },
          "used": {
            "type": "integer"
          },
          "remaining": {
            "type": [
              "integer",
              "null"
            ]
          },
          "day": {
            "type": "string",
            "example": "2026-09-03"
          },
          "resetsAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Naechster Reset, oder null bei reset = never."
          }
        }
      },
      "KeyStatus": {
        "type": "object",
        "properties": {
          "key": {
            "type": "object",
            "properties": {
              "id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              },
              "hint": {
                "type": "string",
                "example": "rvl_7fQ2x9aB…4kQz"
              },
              "active": {
                "type": "boolean"
              },
              "createdAt": {
                "type": "string",
                "format": "date-time"
              },
              "lastUsedAt": {
                "type": "string",
                "format": "date-time"
              },
              "expiresAt": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time"
              },
              "allowedIps": {
                "type": [
                  "array",
                  "null"
                ],
                "items": {
                  "type": "string"
                },
                "description": "Nur von diesen Adressen oder Bereichen nutzbar. null = von ueberall."
              }
            }
          },
          "quota": {
            "$ref": "#/components/schemas/Quota"
          },
          "limits": {
            "type": "object",
            "properties": {
              "maxTextLength": {
                "type": "integer",
                "example": 1000
              },
              "requestsPerMinute": {
                "type": "integer",
                "example": 60
              },
              "concurrentRequests": {
                "type": "integer",
                "example": 2
              }
            }
          },
          "voices": {
            "type": "object",
            "properties": {
              "scope": {
                "type": "string",
                "enum": [
                  "all",
                  "selected"
                ]
              },
              "count": {
                "type": "integer"
              }
            }
          },
          "models": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Model"
            }
          },
          "totals": {
            "type": "object",
            "properties": {
              "calls": {
                "type": "integer"
              },
              "characters": {
                "type": "integer"
              }
            }
          }
        }
      },
      "Model": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "Wert fuer das Feld \"model\".",
            "example": "Rift 2.1"
          },
          "alias": {
            "type": "string",
            "description": "Kurzform, ebenfalls erlaubt.",
            "example": "v2"
          },
          "name": {
            "type": "string",
            "example": "RiftVoiceV2"
          },
          "characterCost": {
            "type": "integer",
            "description": "Zeichen je Zeichen.",
            "example": 2
          },
          "description": {
            "type": "string"
          },
          "available": {
            "type": "boolean"
          }
        }
      },
      "ModelList": {
        "type": "object",
        "properties": {
          "object": {
            "type": "string",
            "example": "list"
          },
          "count": {
            "type": "integer",
            "example": 2
          },
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Model"
            }
          }
        }
      },
      "SpeechRequest": {
        "type": "object",
        "required": [
          "text",
          "voice"
        ],
        "properties": {
          "text": {
            "type": "string",
            "maxLength": 1000,
            "description": "Was gesprochen werden soll.",
            "example": "Guten Abend. Der Ton steht."
          },
          "voice": {
            "type": "string",
            "description": "Kennung aus /api/v1/voices.",
            "example": "8kq2m1x9"
          },
          "model": {
            "type": "string",
            "enum": [
              "RiftVoiceV1",
              "RiftVoiceV2"
            ],
            "default": "RiftVoiceV1",
            "description": "Ohne Angabe RiftVoiceV1. RiftVoiceV2 setzt Betonung und Stimmung selbst und zaehlt jedes Zeichen doppelt. \"v1\" und \"v2\" sind ebenfalls erlaubt.",
            "example": "RiftVoiceV2"
          },
          "format": {
            "type": "string",
            "enum": [
              "mp3"
            ],
            "default": "mp3"
          },
          "encoding": {
            "type": "string",
            "enum": [
              "binary",
              "base64"
            ],
            "default": "binary",
            "description": "\"binary\" liefert audio/mpeg, \"base64\" liefert JSON."
          }
        }
      },
      "SpeechJson": {
        "type": "object",
        "properties": {
          "object": {
            "type": "string",
            "example": "speech"
          },
          "requestId": {
            "type": "string"
          },
          "model": {
            "type": "string",
            "example": "RiftVoiceV1"
          },
          "format": {
            "type": "string",
            "example": "mp3"
          },
          "encoding": {
            "type": "string",
            "example": "base64"
          },
          "audio": {
            "type": "string",
            "contentEncoding": "base64"
          },
          "bytes": {
            "type": "integer"
          },
          "characters": {
            "type": "integer"
          },
          "billedCharacters": {
            "type": "integer",
            "description": "Zeichen mal Faktor des Modells."
          },
          "durationMs": {
            "type": "integer"
          },
          "voice": {
            "$ref": "#/components/schemas/Voice"
          },
          "quota": {
            "$ref": "#/components/schemas/Quota"
          }
        }
      }
    }
  }
}
