HubMix

Documentation

PHP et Laravel

Le SDK Composer de Gaspard pour PHP 8.2+, avec fournisseur de services, façade et configuration Laravel auto-découverts.

Client PHP de l'API publique du hub HubMix (/v1/public) : Gaspard, l'assistant IA, et depuis la 1.1.0 le tuteur, la confiance (signaux d'arnaque des annonces), les embeddings et le vérificateur des webhooks (portail développeurs retiré le 28 octobre 2026 : plus aucun webhook n'est envoyé) ; avec une intégration Laravel prête à l'emploi (fournisseur de services, façade et configuration auto-découverts).

  • PHP 8.2 ou plus récent, n'importe quel framework ; HTTP via Guzzle 7.8+ ou 8 (déjà livré avec Laravel : Laravel 13 est livré avec Guzzle 8).
  • Objets typés et immuables (final readonly), dates en DateTimeImmutable.
  • Réponses en flux (Server-Sent Events) lues au fil de l'eau, via un Generator.
  • Nouvelles tentatives automatiques quand Gaspard est occupé, une seule exception (GaspardException).

Installation #

Terminal
composer require valais-connect/gaspard

Démarrage rapide (PHP) #

PHP
use ValaisConnect\Gaspard\Gaspard;

$gaspard = new Gaspard(apiKey: getenv('GASPARD_API_KEY')); // clé serveur vck_live_…

$conversation = $gaspard->startConversation(locale: 'fr', page: 'accueil');
$answer = $gaspard->ask($conversation->id, 'Comment trouver un logement étudiant à Lyon ?');

echo $answer->text, PHP_EOL;
foreach ($answer->sources as $source) {
    echo "- {$source->name} {$source->url}", PHP_EOL;
}

La clé serveur (vck_live_…) est secrète : gardez-la côté serveur, jamais dans un navigateur ni dans une application mobile.

Afficher la réponse au fil de l'eau #

askStream() renvoie un Generator d'événements StreamEvent : des morceaux de texte (delta), puis la réponse complète (answer, toujours le dernier événement, aussi disponible via getReturn()).

PHP
$stream = $gaspard->askStream($conversation->id, 'Que faire à Montréal ce week-end ?');

foreach ($stream as $event) {
    if ($event->isDelta()) {
        echo $event->text;          // un morceau de la réponse
    }
}

$answer = $stream->getReturn();     // Answer : texte complet, sources, offres (listings)…

La requête part au premier tour de boucle. Un flux n'est jamais relancé une fois commencé : s'il se termine par une erreur (assistant_busy, conversation_full…), une GaspardException est levée pendant l'itération.

Recherche dans la base de connaissances #

PHP
use ValaisConnect\Gaspard\Topic;

$results = $gaspard->searchKnowledge('Berlin Universitäten', limit: 3, topics: [Topic::EDUCATION, Topic::HISTORY]);

foreach ($results as $passage) {       // SearchResults est itérable et dénombrable
    printf("%.2f %s — %s\n", $passage->score, $passage->title, $passage->source?->name);
}

Configuration #

Argument nommé Défaut Rôle
apiKey — Clé serveur vck_live_… → Authorization: Bearer ….
publishableKey — Clé publiable vcp_live_… → X-Gaspard-Key (exactement l'une des deux clés).
origin — Avec une clé publiable hors navigateur : une des origines déclarées pour le client.
baseUrl https://hubmix.dealmood-solutions.com Hôte du service ; le SDK ajoute /v1/public.
endUser — Clé serveur : identifiant de votre utilisateur → X-End-User.
timeout 60.0 Secondes par requête (flux : connexion et chaque lecture, pas de délai total).
maxRetries 2 Nouvelles tentatives (voir plus bas) ; 0 les désactive.
userAgent gaspard-php/1.1.0 En-tête User-Agent.
http client Guzzle Votre propre GuzzleHttp\ClientInterface (proxy, middlewares, tests).

Une erreur de configuration (aucune clé ou les deux, endUser invalide, consent: false…) lève une InvalidArgumentException avant toute requête.

Gaspard::fromConfig([...]) construit le client à partir d'un tableau aux clés snake_case (api_key, base_url, timeout, max_retries, publishable_key, origin, end_user).

Laravel #

Le fournisseur de services et la façade sont découverts automatiquement. Dans .env :

.env
GASPARD_API_KEY=vck_live_xxxxxxxxxxxxxxxx
# Facultatif :
GASPARD_BASE_URL=https://hubmix.dealmood-solutions.com
GASPARD_TIMEOUT=60
GASPARD_MAX_RETRIES=2

Pour modifier la configuration, publiez config/gaspard.php :

Terminal
php artisan vendor:publish --tag=gaspard-config

Le client est un singleton du conteneur (ValaisConnect\Gaspard\Gaspard, alias gaspard), créé à la première utilisation. Utilisez la façade ou l'injection de dépendances :

PHP
use ValaisConnect\Gaspard\Gaspard as GaspardClient;
use ValaisConnect\Gaspard\Laravel\Facades\Gaspard;

// Façade
$notice = Gaspard::privacyNotice('fr');

// Injection
public function __construct(private GaspardClient $gaspard) {}

Vos utilisateurs (X-End-User) #

Avec une clé serveur, indiquez pour qui vous parlez : leurs conversations et consentements restent les leurs (seule une empreinte à clé de l'identifiant est stockée). forEndUser() renvoie une copie du client ; le singleton partagé n'est jamais modifié (sûr avec Octane).

PHP
$gaspard = Gaspard::forEndUser((string) auth()->id());

Contrôleur : réponse en flux vers le navigateur #

Le contrôleur relaie le flux de Gaspard au navigateur, au même format SSE (delta, puis answer ou error).

PHP
<?php

namespace App\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
use ValaisConnect\Gaspard\GaspardException;
use ValaisConnect\Gaspard\Laravel\Facades\Gaspard;

class AssistantController extends Controller
{
    public function start(Request $request): JsonResponse
    {
        $conversation = Gaspard::forEndUser((string) $request->user()->id)
            ->startConversation(locale: app()->getLocale(), page: 'aide');

        return response()->json($conversation); // les objets du SDK sont JsonSerializable
    }

    public function ask(Request $request, string $conversation): StreamedResponse
    {
        $data = $request->validate(['text' => ['required', 'string', 'max:2000']]);
        $user = $request->user();
        $gaspard = Gaspard::forEndUser((string) $user->id);

        return response()->stream(function () use ($gaspard, $conversation, $data, $user): void {
            $send = static function (string $event, array $payload): void {
                echo "event: {$event}\ndata: " . json_encode($payload, JSON_UNESCAPED_UNICODE) . "\n\n";
                if (ob_get_level() > 0) {
                    ob_flush();
                }
                flush();
            };

            try {
                foreach ($gaspard->askStream($conversation, $data['text'], $user->first_name) as $event) {
                    if ($event->isDelta()) {
                        $send('delta', ['text' => $event->text]);
                    } else {
                        $send('answer', $event->answer->toArray());
                    }
                }
            } catch (GaspardException $error) {
                report($error);
                $send('error', ['code' => $error->errorCode]);
            }
        }, 200, [
            'Content-Type' => 'text/event-stream',
            'Cache-Control' => 'no-cache',
            'X-Accel-Buffering' => 'no', // nginx : ne pas mettre le flux en tampon
        ]);
    }
}
PHP
// routes/web.php
Route::middleware('auth')->group(function () {
    Route::post('/assistant/conversations', [AssistantController::class, 'start']);
    Route::post('/assistant/conversations/{conversation}/messages', [AssistantController::class, 'ask']);
});

Côté navigateur, EventSource ne fait que des GET : lisez le flux avec fetch :

JavaScript
const response = await fetch(`/assistant/conversations/${id}/messages`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
  body: JSON.stringify({ text }),
});
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  buffer += value;
  let end;
  while ((end = buffer.indexOf('\n\n')) >= 0) {
    const block = buffer.slice(0, end);
    buffer = buffer.slice(end + 2);
    const event = /^event: (.*)$/m.exec(block)?.[1];
    const data = JSON.parse(/^data: (.*)$/m.exec(block)?.[1] ?? 'null');
    if (event === 'delta') output.textContent += data.text;
    if (event === 'answer') showSources(data.sources);
    if (event === 'error') showError(data.code);
  }
}

Tests de votre application #

La façade se simule comme toute façade Laravel :

PHP
use ValaisConnect\Gaspard\Laravel\Facades\Gaspard;
use ValaisConnect\Gaspard\Model\PrivacyNotice;

Gaspard::shouldReceive('privacyNotice')->once()->andReturn(new PrivacyNotice('2026-10', 'Texte…', 365));

Hors Laravel, injectez un client Guzzle avec GuzzleHttp\Handler\MockHandler : new Gaspard(apiKey: 'vck_live_test', http: new Client(['handler' => HandlerStack::create($mock)])).

Application avec une clé publiable #

Pour une application de bureau ou un outil qui parle directement à Gaspard au nom d'un visiteur, utilisez la clé publiable du client et son origine autorisée, puis ouvrez une session une fois que le visiteur a accepté l'avis de confidentialité :

PHP
$gaspard = new Gaspard(publishableKey: 'vcp_live_…', origin: 'https://mon-app.example.ch');

$notice = $gaspard->privacyNotice('fr');
// … afficher $notice->text et recueillir l'accord du visiteur …

$session = $gaspard->openSession(consent: true, allowTraining: false);
saveSomewhere($session->token);                   // valable 30 jours

// Plus tard : reprendre la même session
$gaspard->setVisitorToken(loadFromSomewhere());   // ou null pour l'oublier
$conversation = $gaspard->startConversation();

Le jeton de visiteur (vst_…) est gardé dans le client et envoyé avec chaque requête suivante.

Consentement et données personnelles #

Avec une clé serveur, ces méthodes concernent l'utilisateur indiqué par forEndUser() ; avec une clé publiable, le visiteur de la session.

PHP
$user = Gaspard::forEndUser((string) auth()->id());

$notice = $user->privacyNotice();
$user->giveConsent($notice->version, allowTraining: true); // après acceptation de $notice->text
$status = $user->consent();          // ConsentStatus : given, upToDate, noticeVersion…
$user->withdrawConsent();
$data = $user->exportData();         // VisitorData : droit d'accès
$erasure = $user->eraseData();       // Erasure : droit à l'effacement

Erreurs et nouvelles tentatives #

Tout échec d'un appel lève ValaisConnect\Gaspard\GaspardException (une RuntimeException) :

Propriété Contenu
status Statut HTTP ; 0 sans réponse (réseau, délai, erreur dans un flux). getCode() le renvoie aussi.
errorCode Code de l'API (consent_required, assistant_busy, conversation_full, token_quota_exceeded…), sinon http_<statut> ; network_error, timeout, invalid_response ou stream_incomplete côté SDK.
getMessage() Message lisible.
requestId Identifiant de la requête (request_id ou en-tête X-Request-Id), à citer au support.
retryAfter Secondes à attendre (Retry-After), ou null.

errorCode et non code : \Exception::$code existe déjà en PHP (un entier).

PHP
use ValaisConnect\Gaspard\GaspardException;

try {
    $answer = $gaspard->ask($conversationId, $question);
} catch (GaspardException $error) {
    match ($error->errorCode) {
        'conversation_full' => $conversationId = $gaspard->startConversation()->id,
        'token_quota_exceeded' => logger()->warning("Quota atteint, réessayer dans {$error->retryAfter} s"),
        default => report($error),
    };
}

Le SDK réessaie tout seul, au plus maxRetries fois (2 par défaut) :

  • 503 assistant_busy et 429 rate_limited, pour toutes les méthodes ;
  • les erreurs réseau et délais dépassés, pour les GET et DELETE seulement.

Il attend Retry-After secondes quand l'en-tête est présent (au-delà de 30 s, il ne réessaie pas et lève l'erreur), sinon 0,5 s × 2^tentative. Jamais pour token_quota_exceeded, les autres 4xx, ni pour un flux déjà commencé. Reposer une question est sans risque : le serveur n'enregistre un échange qu'avec sa réponse. Vous pouvez aussi fournir votre identifiant de requête : $gaspard->ask($id, $text, requestId: 'commande-12345') (8 à 64 caractères [A-Za-z0-9._-]).

Référence #

Méthode HTTP Retour
privacyNotice(locale = 'fr') GET /privacy-notice PrivacyNotice
openSession(consent, locale = 'fr', allowTraining = false) POST /sessions Session
setVisitorToken(?token) / visitorToken() — —
startConversation(locale = 'fr', page = null) POST /gaspard/conversations Conversation
ask(conversationId, text, firstName = null) POST /gaspard/conversations/{id}/messages Answer
askStream(conversationId, text, firstName = null) idem, Accept: text/event-stream Generator<StreamEvent> (retour : Answer)
conversation(conversationId) GET /gaspard/conversations/{id} ConversationThread
feedback(messageId, rating, comment = null) POST /gaspard/messages/{id}/feedback Feedback
searchKnowledge(query, limit = 5, topics = []) POST /knowledge/search SearchResults
consent() GET /visitors/me/consent ConsentStatus
giveConsent(noticeVersion, allowTraining = false) PUT /visitors/me/consent ConsentStatus
withdrawConsent() DELETE /visitors/me/consent ConsentStatus
exportData() GET /visitors/me/data VisitorData
eraseData() DELETE /visitors/me Erasure
forEndUser(id) / withEndUser(id) — copie du client
products() GET /products list<Product>
tutor()->explain(lesson, messages, mode, learner, locale) POST /tutor/explanations TutorReply
tutor()->quiz(lesson, count, difficulty, kinds, learner, locale) POST /tutor/quizzes Quiz
tutor()->flashcards(lesson, count, learner, locale) POST /tutor/flashcards Flashcards
tutor()->grade(lesson, question, answer, countryCode, locale) POST /tutor/grades Grade
trust()->assess(listings) POST /trust/assessments TrustResult
embeddings()->create(texts) POST /embeddings Embeddings
Webhook::verify(payload, signatureHeader, secret, tolerance = 300) — WebhookEvent

Les méthodes appelées pour un utilisateur acceptent aussi les arguments nommés endUser: (pour un seul appel) et requestId:.

Les types (ValaisConnect\Gaspard\Model\…) exposent des propriétés en camelCase ($answer->remainingMessages, $listing->sourceName…) ; les champs inconnus envoyés par le serveur sont ignorés. toArray() et json_encode() redonnent le format de l'API (snake_case).

Produits du hub (clé serveur) #

Le tuteur, la confiance et les embeddings sont activés par application par l'équipe HubMix (sur demande) ; sans le produit, l'API répond 403 insufficient_scope. Ils demandent une clé serveur : avec une clé publiable, tutor(), trust() et embeddings() lèvent une InvalidArgumentException avant toute requête. Les valeurs imbriquées (leçon, apprenant, annonce) sont des tableaux aux noms de l'API (snake_case) ; seuls les arguments donnés sont envoyés.

PHP
use ValaisConnect\Gaspard\Laravel\Facades\Gaspard;

// Confiance : à la publication d'une annonce
$result = Gaspard::trust()->assess([[
    'kind' => 'housing',
    'title' => $listing->title,
    'description' => $listing->description,
    'price_amount' => $listing->rent,
    'currency' => 'EUR',
    'country_code' => 'FR',
    'city' => 'Lyon',
]]);
if ($result->assessments[0]->withheld) {
    $listing->holdForReview($result->assessments[0]->findings);
}

// Tuteur : un quiz sur un cours
$quiz = Gaspard::tutor()->quiz(
    lesson: ['title' => 'La photosynthèse', 'content' => $course->text],
    count: 5,
    difficulty: 'medium',
    learner: ['level' => 'bachelor', 'country_code' => 'SN'],
    locale: 'fr',
);

// Embeddings : un vecteur par texte, dans le même ordre
$vectors = Gaspard::embeddings()->create(['chambre à louer', 'room for rent'])->embeddings;