20. OWUI Native API v0.9.6 — introspection REST et surface authentifiee

Navigation : Index | << Précédent

Le notebook 19_OWUI_Orchestration orchestrait des LLM via l’endpoint OpenAI-compatible POST /api/chat/completions d’Open WebUI. Cet endpoint est pratique (un SDK openai standard suffit) mais il ne couvre qu’une fraction de ce qu’OWUI expose.

Ce notebook compagnon explore la vraie surface API native d’OWUI v0.9.6 : les routes REST qui decrivent et controlent le deploiement lui-même. On y trouve deux couches distinctes :

L’objectif pedagogique : apprendre a decouvrir et interroger une API web de maniere methodique, en separant ce qui est public de ce qui exige des identifiants — une competence transferable a n’importe quel service REST.

0. Setup — conventions d’acces

On utilise la bibliotheque standard urllib (aucune dépendance externe) pour construire des requêtes HTTP explicites. L’URL du tenant OWUI est lue depuis l’environnement ; la cle d’API (Bearer) est optionnelle ici — sa presence contrôle simplement si l’on peut franchir la couche authentifiee.

  • OWUI_URL : racine du tenant (par defaut https://open-webui.myia.io).
  • OWUI_API_KEY : jeton Bearer ; absente dans cet environnement d’exécution -> les cellules authentifiees le signalent honnetement (Stop & Repair) plutot que de fabriquer une sortie.
import os
import json
import urllib.request
import urllib.error

OWUI_URL = os.getenv("OWUI_URL", "https://open-webui.myia.io").rstrip("/")
OWUI_API_KEY = os.getenv("OWUI_API_KEY")

print("Tenant  :", OWUI_URL)
print("Cle API :", "(presente, masquee)" if OWUI_API_KEY else "(ABSENTE -> couche auth RECOVERABLE-MACHINE)")


def http_get(path, token=None, timeout=10):
    """Effectue un GET explicite sur {OWUI_URL}{path}. Renvoie (status_code, payload).
    payload = dict JSON si possible, sinon texte brut."""
    url = OWUI_URL + path
    req = urllib.request.Request(url, headers={"Accept": "application/json"})
    if token:
        req.add_header("Authorization", "Bearer " + token)
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            raw = resp.read().decode("utf-8", errors="replace")
            try:
                return resp.status, json.loads(raw)
            except json.JSONDecodeError:
                return resp.status, raw
    except urllib.error.HTTPError as e:
        return e.code, {"_http_error": e.code, "_body": e.read().decode("utf-8", errors="replace")}
    except urllib.error.URLError as e:
        return 0, {"_url_error": str(e.reason)}
Tenant  : https://open-webui.myia.io
Cle API : (ABSENTE -> couche auth RECOVERABLE-MACHINE)

Lecture : le contrat d’accès déclaré dès la première cellule

La sortie énonce les deux faits qui conditionnent tout le notebook : Tenant : https://open-webui.myia.io — la cible est l’instance de production — et Cle API : (ABSENTE -> couche auth RECOVERABLE-MACHINE). Deux disciplines se lisent dans ces deux lignes. D’abord l’ordre de lecture env-first : l’URL vient de l’environnement (os.getenv), la clé aussi — sans LITTÉRAL de secours, conformément à l’hygiène secrets : une clé absente est un état dégradé AFFICHÉ, jamais une valeur par défaut codée en dur. Ensuite l’honnêteté anticipée : le notebook déclare AVANT toute requête que sa couche authentifiée s’exécutera en mode RECOVERABLE-MACHINE si la clé manque — chaque cellule concernée portera ce verdict plutôt qu’une sortie fabriquée. La suite du notebook est donc lisible des deux façons : avec la clé, chaque route rend sa vraie réponse ; sans elle, la FRONTIÈRE entre ce qui s’exécute et ce qui se dégrade reste visible cellule par cellule.

1. Couche publique (auth-free) : introspecter le deploiement

Trois routes sont accessibles sans aucune authentification. Elles suffisent a dresser un portrait du deploiement OWUI : sante, version exacte, et configuration fonctionnelle (locale par defaut, fournisseurs OAuth, features activees). C’est typiquement la première chose qu’un client API (ou un outil de supervision) interroge.

# Sante du service : GET /health
status, payload = http_get("/health")
print(f"GET /health -> HTTP {status}")
print(json.dumps(payload, indent=2, ensure_ascii=False))
GET /health -> HTTP 200
{
  "status": true
}

Lecture : /health — la sonde de vie, et rien d’autre

GET /health -> HTTP 200 avec pour corps entier {"status": true} : c’est la réponse la plus pauvre du notebook, et c’est précisément ce qui en fait une sonde. La sémantique d’une route de santé est la LIVENESS : le processus répond, la boucle HTTP est vivante — rien de plus. Elle ne dit rien de la version (cellule suivante), rien des fonctionnalités activées (/api/config ensuite), rien de l’état des dépendances (base de données, moteur d’inférence) : un service peut être « healthy » au sens de cette route et fonctionnellement dégradé. C’est pourquoi les clients sérieux sonnent /health à haute fréquence et à bas coût (une réponse d’un objet), et réservent les routes riches — version, config — aux démarrages et aux diagnostics. Pour l’exercice 2 du notebook, qui construit une sonde multi-tenant, cette hiérarchie est le point de départ : mesurer la vie d’abord, décrire le service ensuite.

# Version exacte du deploiement : GET /api/version
status, payload = http_get("/api/version")
print(f"GET /api/version -> HTTP {status}")
print(json.dumps(payload, indent=2, ensure_ascii=False))
GET /api/version -> HTTP 200
{
  "version": "0.9.6",
  "deployment_id": ""
}

Lecture : /api/version — la version comme contrat, deployment_id comme identité vide

{"version": "0.9.6", "deployment_id": ""} tient en deux champs et se lit comme un contrat de compatibilité. La version EXACTE est ce sur quoi un client peut épingler ses attentes : les routes /api/v1/* de la section 2, les automations et tools de la section 3 ont une surface qui évolue de 0.9.x en 0.9.x, et un intégrateur qui teste contre 0.9.6 sait que son code ne dépend pas d’un comportement d’une autre version. Le deployment_id VIDE est tout aussi instructif : c’est l’identifiant optionnel qu’un opérateur peut positionner pour distinguer deux instances de la même version (canari contre production, par exemple) — sa valeur nulle ici dit que ce tenant ne s’est pas enregistré d’identité propre, et qu’en contexte multi-tenant (l’exercice 2), la version seule ne suffira PAS à distinguer deux déploiements : il faudra la croiser avec l’URL du tenant. Deux champs, deux usages : la version regarde la compatibilité, l’identité regarde l’exploitation.

# Configuration fonctionnelle : GET /api/config
status, config = http_get("/api/config")
print(f"GET /api/config -> HTTP {status}")
# On extrait les champs exploitables (version, locale, features, oauth)
resume = {
    "name": config.get("name"),
    "version": config.get("version"),
    "default_locale": config.get("default_locale"),
    "features": config.get("features", {}),
    "oauth_providers": list(config.get("oauth", {}).get("providers", {}).keys()),
}
print(json.dumps(resume, indent=2, ensure_ascii=False))
GET /api/config -> HTTP 200
{
  "name": "MyIA Open-Webui (Open WebUI)",
  "version": "0.9.6",
  "default_locale": "fr-FR",
  "features": {
    "auth": true,
    "auth_trusted_header": false,
    "enable_signup_password_confirmation": false,
    "enable_ldap": false,
    "enable_signup": true,
    "enable_login_form": true,
    "enable_websocket": true
  },
  "oauth_providers": []
}

Interpretation : que sait-on déjà sans aucune cle ?

Les trois reponses publiques revelent l’essentiel pour un client : - Version : 0.9.6 — conditionne les features disponibles (Scheduled Automations, Task Management sont apparus en 0.9.x, cf. NB-19 §2). - Locale : fr-FR — le tenant est configure en francais par defaut. - Features : auth: true (authentification requise), enable_websocket: true (temps reel), enable_signup: true (inscription ouverte). Ces drapeaux orientent directement la stratégie d’integration : ici, toute opération au-dela de la lecture publique passera par un jeton Bearer.

2. Couche authentifiee : la surface REST native (/api/v1/*)

Des qu’on quitte les routes publiques, OWUI exige un jeton Bearer. On le demontre en interrogeant GET /api/v1/models sans token : le serveur renvoie explicitement 401 Not authenticated. C’est le comportement attendu et sain d’une API protegee.

Le jeton (OWUI_API_KEY) se genere dans l’interface OWUI (Settings > Account > API Keys) et s’envoie dans l’en-tete Authorization: Bearer <token>.

# Demonstration : /api/v1/models SANS jeton -> le serveur protege la route
status, payload = http_get("/api/v1/models")
print(f"GET /api/v1/models (sans token) -> HTTP {status}")
print(json.dumps(payload, indent=2, ensure_ascii=False))
GET /api/v1/models (sans token) -> HTTP 401
{
  "_http_error": 401,
  "_body": "{\"detail\":\"Not authenticated\"}"
}

Lecture : le 401 — la frontière rendue visible en une requête

GET /api/v1/models (sans token) -> HTTP 401 avec le corps enveloppé {"_http_error": 401, "_body": "{"detail":"Not authenticated"}"} : cette cellule est la démonstration expérimentale de la frontière annoncée par la section précédente. Même route, même verbe, même client — seule différence avec la cellule suivante : l’en-tête Authorization: Bearer ... absent. Le serveur répond 401 Unauthorized, dont la sémantique exacte est « identité MANQUANTE ou invalide » — à distinguer de 403 Forbidden, « identité connue mais droits insuffisants » : la première se règle en présentant un jeton, la seconde en changeant de rôle. Deux autres lectures utiles dans ce corps : le message Not authenticated vient du serveur (FastAPI) et confirme que la protection est au niveau du FRAMEWORK, pas d’un proxy périphérique ; et l’enveloppe _http_error / _body est le pattern d’encapsulation du helper http_get du notebook — les erreurs ne lèvent pas, elles SE RENDENT, pour qu’une route protégée se lise comme une donnée parmi les autres. C’est exactement ce qui permet à la cellule suivante de comparer sans et avec jeton en gardant le même format de sortie. Pour mémoire, la famille complète des codes d’erreur applicatifs se lit comme un arbre de décision : 401 — présente ton identité ; 403 — ton identité ne suffit pas ; 404 — la route elle-même n’existe pas (à ne pas confondre avec 401 sur une route protégée, que certains serveurs renvoient justement POUR masquer l’existence de la route) ; 5xx — le problème est côté serveur. Savoir lequel des quatre on reçoit, c’est savoir quel acteur peut le corriger : l’appelant, l’administrateur des droits, ou personne.

# Meme route AVEC jeton Bearer : liste les modeles exposés par le tenant.
# Ici le jeton est ABSENT dans cet environnement -> on le signale honnetement (Stop & Repair),
# on ne fabrique JAMAIS une fausse liste de modeles.
if OWUI_API_KEY is None:
    print("RECOVERABLE-MACHINE : OWUI_API_KEY absente de l'environnement d'execution.")
    print("  Cause : le jeton fourni pour #4434 s'est auto-detruit apres lecture,")
    print("  et le worktree qui portait .env a ete nettoye au merge. Le seeding")
    print("  durable (.secrets/master.env + render_envs) est en cours (route par ai-01).")
    print("  Des que la cle est provisionnee, cette cellule retourne la vraie liste")
    print("  des modeles (ex OpenAI.gpt-4.1-nano, Local.qwen3.6-35b-a3b...).")
    models_list = None  # TODO etudiant : votre liste de modeles une fois la cle presente
else:
    status, models_list = http_get("/api/v1/models", token=OWUI_API_KEY)
    print(f"GET /api/v1/models (Bearer) -> HTTP {status}")
    print(json.dumps(models_list, indent=2, ensure_ascii=False)[:800])
RECOVERABLE-MACHINE : OWUI_API_KEY absente de l'environnement d'execution.
  Cause : le jeton fourni pour #4434 s'est auto-detruit apres lecture,
  et le worktree qui portait .env a ete nettoye au merge. Le seeding
  durable (.secrets/master.env + render_envs) est en cours (route par ai-01).
  Des que la cle est provisionnee, cette cellule retourne la vraie liste
  des modeles (ex OpenAI.gpt-4.1-nano, Local.qwen3.6-35b-a3b...).

Interpretation : verdict d’exécution

La cellule précédente applique la discipline Stop & Repair : on n’ecrit pas une sortie inventee pour faire semblade d’une reponse authentifiee. Le diagnostic est RECOVERABLE-MACHINE — le jeton existe (il a ete valide pour le notebook frere NB-19), il doit juste etre re-provisionne dans l’environnement via le pipeline master.env + render_envs.py. Une fois present, la cellule bascule automatiquement du skip a l’appel reel (le code defensif est déjà en place, cf. NB-19 #4434).

3. CRUD authentifie : automations et tools natifs

La surface /api/v1 expose également les ressources propres a OWUI v0.9.x : - /api/v1/automations — les tâches planifiees (cron-like, decrites en RRULE, cf. NB-19 §2.1) ; - /api/v1/tools — les tools custom (fonctions Python que le LLM peut invoquer via function-calling).

Ces routes suivent le même garde-fou Bearer. On en donne le squelette d’appel (pattern GET liste + POST creation) en mode defensif : la structure du code est complete et re-executable telle quelle des que OWUI_API_KEY est present.

def lister_automations(token):
    """GET /api/v1/automations -> liste des taches planifiees du tenant."""
    return http_get("/api/v1/automations", token=token)

if OWUI_API_KEY is None:
    print("RECOVERABLE-MACHINE : skip liste automations (OWUI_API_KEY absente).")
    automations = None  # TODO etudiant : liste des automations une fois la cle presente
else:
    status, automations = lister_automations(OWUI_API_KEY)
    print(f"GET /api/v1/automations -> HTTP {status} | {len(automations) if isinstance(automations, list) else 'n/a'} automations")
RECOVERABLE-MACHINE : skip liste automations (OWUI_API_KEY absente).
def creer_tool_simple(token, nom, description, code_python):
    """POST /api/v1/tools -> declare un tool custom (function-calling OWUI)."""
    import urllib.request, json as _json
    url = OWUI_URL + "/api/v1/tools"
    body = _json.dumps({"name": nom, "description": description, "content": code_python}).encode("utf-8")
    req = urllib.request.Request(url, data=body, method="POST",
                                 headers={"Authorization": "Bearer " + token,
                                          "Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(req, timeout=10) as resp:
            return resp.status, _json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        return e.code, {"_http_error": e.code}

if OWUI_API_KEY is None:
    print("RECOVERABLE-MACHINE : skip creation tool (OWUI_API_KEY absente).")
    tool_cree = None  # TODO etudiant : resultat de la creation une fois la cle presente
else:
    status, tool_cree = creer_tool_simple(OWUI_API_KEY, "meteo_ville", "Renvoie la meteo d'une ville", "def meteo_ville(ville): ...")
    print(f"POST /api/v1/tools -> HTTP {status}")
RECOVERABLE-MACHINE : skip creation tool (OWUI_API_KEY absente).

Interpretation : la frontiere scaffold / reel

Ces deux cellules sont structurellement completes (endpoint, méthode, payload, gestion d’erreur) mais non executees faute de jeton. Ce n’est pas un workaround degrade : c’est un staging honnete en attendant une cle re-provisionnee. Comparer avec NB-19, qui execute reellement l’endpoint chat — la différence est purement la disponibilite du jeton dans l’environnement, pas la qualite du code.

4. Travaux pratiques

Trois exercices pour solidifier la maitrise de l’introspection REST et du pattern d’authentification. Les deux premiers n’ont pas besoin de cle (couche publique) ; le troisieme prepare l’appel authentifie.

Exercice 1 : detecteur de capacites (sans cle)

Ecrire une fonction capacites(config) qui prend le dictionnaire renvoye par GET /api/config et renvoie un verdict lisible : quelles features sont activees (websocket, signup, ldap), quel est le mode d’authentification (builtin / OAuth / header de confiance).

Indice : le sous-dictionnaire config["features"] contient les drapeaux booléens. - Étape 1 : extraire features puis tester chaque cle avec .get(key, False). - Étape 2 : construire une liste de chaînes “active”/“inactive” par feature. - Étape 3 : le mode d’auth = “header de confiance” si auth_trusted_header vrai, sinon “builtin”.

# TODO etudiant : detecteur de capacites OWUI (couche publique, sans cle)
# Indices :
# - Entree : le dict renvoye par GET /api/config (variable `config` disponible plus haut).
# - Sortie : un dict {feature: bool} + une chaine "mode_auth".
def detecteur_capacites(config):
    features = config.get("features", {})
    return None  # TODO etudiant : votre verdict lisible

resultat_exo1 = None  # TODO etudiant
print("Exercice a completer : detecteur de capacites OWUI")
Exercice a completer : detecteur de capacites OWUI

Exercice 2 : sonde de sante multi-version

Ecrire sonde(url_tenant) qui ping GET /health et GET /api/version d’un tenant OWUI quelconque, et renvoie un resume compact (ok: bool, version: str). Bonus : l’appliquer a un second tenant (ex https://epf.open-webui.myia.io) et comparer les versions.

Indice : reutiliser http_get défini en §0 ; gerer le cas ou le tenant est injoignable (status 0). - Étape 1 : deux appels (health, version) via http_get sur le même tenant. - Étape 2 : ok = (status health == 200) ; version = payload version.get(“version”).

# TODO etudiant : sonde de sante multi-tenant (sans cle)
# Indices :
# - Reutiliser http_get(path) avec OWUI_URL global, OU accepter url_tenant en parametre.
# - Bonus : comparer https://open-webui.myia.io vs https://epf.open-webui.myia.io
def sonde(url_tenant):
    return None  # TODO etudiant : (ok, version)

resultat_exo2 = None  # TODO etudiant
print("Exercice a completer : sonde de sante multi-version")
Exercice a completer : sonde de sante multi-version

Exercice 3 (prepare l’authentifie) : appel Bearer defensive

Ecrire modeles_authentifies(token) qui renvoie le nombre de modèles exposes via GET /api/v1/models si le jeton est valide, et le string "RECOVERABLE-MACHINE" si le jeton est None. La signature est volontairement identique au code de la §2 — l’objectif est de formaliser le pattern defensif reutilisable.

Indice : la cellule models-auth-code (§2) contient déjà la logique ; l’exercice = l’encapsuler proprement. - Étape 1 : si token is None -> retourner “RECOVERABLE-MACHINE”. - Étape 2 : sinon http_get(“/api/v1/models”, token=token) -> retourner len(payload) si liste.

# TODO etudiant : encapsulation du pattern Bearer defensif
# Indice : meme logique que models-auth-code (§2), mais en fonction retournee.
def modeles_authentifies(token):
    return None  # TODO etudiant : int (nombre de modeles) ou "RECOVERABLE-MACHINE"

resultat_exo3 = None  # TODO etudiant
print("Exercice a completer : appel Bearer defensif")
Exercice a completer : appel Bearer defensif

5. Conclusion et suite

On a trace la frontiere entre la surface publique (introspection : sante, version, configuration fonctionnelle — executable sans aucune credence) et la surface authentifiee (CRUD natif sur modèles, automations, tools — exige un jeton Bearer). Cette demarche methodique vaut pour toute API web : commencer par ce que le serveur accepte de reveler, puis franchir les portes protegeees avec le bon identifiant.

Verdict d’exécution de ce notebook : la couche publique est reellement executee (/health, /api/version, /api/config + demonstration du 401 sur la route protegee). La couche authentifiee est staged honnetement en RECOVERABLE-MACHINE : le jeton OWUI_API_KEY est re-provisionnable via le pipeline master.env + render_envs.py (route en cours par le coordinateur), et le code defensif bascule automatiquement vers l’appel reel a son arrivee — sans aucune modification de source (cf. NB-19 #4434).

Pour la suite : le notebook 19_OWUI_Orchestration montre l’usage de cette API via l’endpoint OpenAI-compatible (automations RRULE, task management, comparatif LangChain/CrewAI).

References

  • Open WebUI — documentation API native : https://docs.openwebui.com/ (routage /api/v1/*, authentification Bearer).
  • Notebook frere : 19_OWUI_Orchestration.ipynb (endpoint OpenAI-compatible, Scheduled Automations, Task Management).
  • RFC 5545 — RRULE (recurrence rules), utilise par les automations OWUI.
  • OWUI v0.9.0 release notes — introduction de Scheduled Automations, Task Management, Calendar.
Retour au sommet