Documentation
Kotlin et Android
Le SDK Maven de Gaspard pour Android (minSdk 21) et la JVM : coroutines, Flow pour le flux, Ktor et Spring.
SDK officiel du hub HubMix pour l'API publique v1 (/v1/public) : Gaspard,
l'assistant IA, et depuis la 1.1.0 le tuteur, la confiance (signaux d'arnaque des
annonces), les embeddings et la vérification des webhooks (portail développeurs retiré le 28 octobre 2026 : plus aucun webhook n'est envoyé). Il sert aussi bien dans une application Android (clé publiable, session
visiteur, consentement) que sur un serveur JVM (Ktor, Spring… avec une clé secrète).
- Coroutines : toutes les méthodes sont
suspendet main-safe (le réseau tourne surDispatchers.IO) ; annuler la coroutine annule l'appel HTTP. - Réponses en flux (
askStream) : unFlowfroid d'événementsDeltapuisCompleted. - OkHttp 4.12, kotlinx.serialization, kotlinx.coroutines. Bytecode JVM 17, compatible Android (minSdk 21 et plus).
Installation #
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
mavenCentral()
mavenLocal() // tant que le SDK n'est publié que localement (./gradlew publishToMavenLocal)
}
}
// build.gradle.kts du module
dependencies {
implementation("ch.valais-connect:gaspard:1.1.0")
}Le SDK vise Kotlin 2.0 (métadonnées, API et stdlib 2.0.21 ; kotlinx-coroutines 1.9 et kotlinx-serialization 1.7) : il se lit avec n'importe quel compilateur Kotlin 2.x, dont le Kotlin intégré à l'Android Gradle Plugin 9. Vérifié avec une app Android neuve (AGP 9.1.1, minSdk 24), compilée en release avec R8 et lancée sur un émulateur Android 12.
Sur Android, les coroutines du thread principal (Dispatchers.Main, lifecycleScope,
viewModelScope) viennent de kotlinx-coroutines-android, déjà présent avec androidx.lifecycle ;
sans lui, ajoutez implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0").
Configuration #
val gaspard = GaspardClient {
apiKey = "vck_live_…" // OU publishableKey = "vcp_live_…" (exactement une des deux)
baseUrl = "https://hubmix.dealmood-solutions.com" // par défaut ; le SDK ajoute /v1/public
maxRetries = 2 // par défaut ; 0 désactive les nouvelles tentatives
timeout = 60.seconds // par requête ; un flux n'a pas de délai total
// okHttpClient = monOkHttp // pour partager connexions, intercepteurs, proxy…
}| Option | Rôle |
|---|---|
apiKey |
Clé serveur vck_live_… → Authorization: Bearer. Jamais dans une application mobile. |
publishableKey |
Clé widget / application vcp_live_… → X-Gaspard-Key. |
origin |
Avec une clé publiable hors navigateur (Android) : envoyée comme Origin, doit faire partie des origines déclarées pour le client. |
endUser |
Clés serveur : l'identifiant de votre utilisateur → X-End-User (seul un hachage est stocké). |
userAgent |
gaspard-kotlin/1.1.0 par défaut. |
Une erreur de configuration (aucune clé ou les deux, consent = false, endUser avec une clé
publiable, identifiant de requête mal formé…) lève une IllegalArgumentException avant toute
requête. Le client est thread-safe : créez-en un seul et partagez-le.
Chaque méthode accepte en dernier paramètre options = RequestOptions(requestId = "…", endUser = "…")
(identifiant de requête X-Request-Id, utilisateur final pour cet appel seulement).
Méthodes #
| Méthode | Appel |
|---|---|
privacyNotice(locale = "fr") |
GET /privacy-notice — le texte à faire accepter |
openSession(consent = true, locale, allowTraining) |
POST /sessions — clé publiable ; garde le jeton visiteur |
visitorToken / setVisitorToken(token) |
lire / restaurer (ou oublier avec null) le jeton vst_… |
startConversation(locale = "fr", page = null) |
POST /gaspard/conversations |
ask(conversationId, text, firstName = null) |
réponse complète (Answer) |
askStream(conversationId, text, firstName = null) |
Flow<GaspardStreamEvent> |
conversation(conversationId) |
la conversation et ses messages |
feedback(messageId, Rating.UP / Rating.DOWN, comment = null) |
note une réponse |
searchKnowledge(query, limit = 5, topics = null) |
recherche dans les connaissances |
consent(), giveConsent(noticeVersion, allowTraining), withdrawConsent() |
consentement |
exportData(), eraseData() |
droit d'accès, droit à l'effacement |
forEndUser(id) |
clé serveur : un client dérivé pour l'un de vos utilisateurs |
products() |
GET /products — les produits du hub |
tutor.explain(lesson, messages, mode, learner, locale) |
clé serveur : POST /tutor/explanations |
tutor.quiz(lesson, count, difficulty, kinds, learner, locale) |
clé serveur : POST /tutor/quizzes |
tutor.flashcards(lesson, count, learner, locale) |
clé serveur : POST /tutor/flashcards |
tutor.grade(lesson, question, answer, countryCode, locale) |
clé serveur : POST /tutor/grades |
trust.assess(listings) |
clé serveur : POST /trust/assessments |
embeddings.create(texts) |
clé serveur : POST /embeddings |
GaspardWebhooks.verify(payload, signature, secrets) |
vérifie un webhook du portail |
Les types (Answer, Conversation, Listing…) sont des data class en camelCase. Les champs
inconnus renvoyés par le serveur sont ignorés et les valeurs d'énumération inconnues deviennent
UNKNOWN : une évolution de l'API ne casse pas votre application.
Dates #
Les dates restent des chaînes ISO 8601 ("2026-10-13T10:00:00Z"), car java.time n'existe qu'à
partir d'Android 8 (API 26). Pour un Instant :
val debut: Instant = conversation.startedAt.toGaspardInstant()Sous l'API 26, activez le desugaring (isCoreLibraryDesugaringEnabled = true et la dépendance
com.android.tools:desugar_jdk_libs) avant d'utiliser toGaspardInstant().
Android #
Ce qu'il faut savoir #
- Permission :
<uses-permission android:name="android.permission.INTERNET" />dansAndroidManifest.xml. - Clé publiable seulement : une application utilise une clé
vcp_live_…+origin(l'une des origines déclarées pour ce client, p. ex.https://app.mon-domaine.ch). Ne mettez jamais une clé secrètevck_live_…dans un APK : elle peut en être extraite en quelques minutes. - Consentement : avant
openSession, affichez le texte deprivacyNotice()et ne poursuivez que si la personne l'accepte. Le SDK refuseopenSession(consent = false). - Jeton visiteur : il vit 30 jours ; enregistrez-le (DataStore) et restaurez-le au démarrage
avec
setVisitorToken, sinon chaque lancement crée un nouveau visiteur. - R8 / ProGuard : les règles nécessaires à kotlinx.serialization pour les modèles du SDK sont
livrées dans le jar (
META-INF/proguard/gaspard.pro) et appliquées automatiquement. OkHttp et kotlinx.serialization livrent les leurs. - Thread principal : vous pouvez appeler le SDK depuis
viewModelScope(Dispatchers.Main) : il bascule lui-même surDispatchers.IO.
Exemple : ViewModel, consentement, flux, DataStore #
// Un seul client pour toute l'application (p. ex. dans votre conteneur d'injection).
val gaspard = GaspardClient {
publishableKey = BuildConfig.GASPARD_PUBLISHABLE_KEY // vcp_live_…
origin = "https://app.mon-domaine.ch"
}
private val Context.gaspardStore by preferencesDataStore(name = "gaspard")
private val VISITOR_TOKEN = stringPreferencesKey("visitor_token")
data class ChatUiState(
val notice: PrivacyNotice? = null, // non null : afficher le dialogue de consentement
val answer: String = "",
val sources: List<Source> = emptyList(),
val loading: Boolean = false,
val error: String? = null,
)
class ChatViewModel(
private val gaspard: GaspardClient,
private val store: DataStore<Preferences>,
) : ViewModel() {
private val _state = MutableStateFlow(ChatUiState())
val state: StateFlow<ChatUiState> = _state.asStateFlow()
private var conversationId: String? = null
private var asking: Job? = null
init {
viewModelScope.launch {
val saved = store.data.first()[VISITOR_TOKEN]
if (saved != null) {
gaspard.setVisitorToken(saved)
} else {
// Pas encore de session : montrer la notice avant tout.
_state.update { it.copy(notice = gaspard.privacyNotice(locale = "fr")) }
}
}
}
/** Appelé par le bouton « J'accepte » du dialogue. */
fun acceptNotice(allowTraining: Boolean) = viewModelScope.launch {
try {
val session = gaspard.openSession(consent = true, locale = "fr", allowTraining = allowTraining)
store.edit { it[VISITOR_TOKEN] = session.token }
_state.update { it.copy(notice = null) }
} catch (e: GaspardException) {
_state.update { it.copy(error = e.message) }
}
}
fun ask(question: String) {
asking?.cancel() // une nouvelle question annule la précédente (et son appel HTTP)
asking = viewModelScope.launch {
_state.update { it.copy(answer = "", sources = emptyList(), loading = true, error = null) }
try {
val id = conversationId ?: gaspard.startConversation(locale = "fr", page = "accueil").id
.also { conversationId = it }
gaspard.askStream(id, question).collect { event ->
when (event) {
is GaspardStreamEvent.Delta ->
_state.update { it.copy(answer = it.answer + event.text) }
is GaspardStreamEvent.Completed ->
_state.update { it.copy(answer = event.answer.text, sources = event.answer.sources) }
}
}
} catch (e: GaspardException) {
when (e.code) {
"session_required" -> { // jeton expiré ou effacé : redemander le consentement
gaspard.setVisitorToken(null)
store.edit { it.remove(VISITOR_TOKEN) }
_state.update { it.copy(notice = gaspard.privacyNotice()) }
}
"conversation_full" -> conversationId = null // la prochaine question en ouvre une autre
else -> _state.update { it.copy(error = "Gaspard est indisponible, réessayez.") }
}
} finally {
_state.update { it.copy(loading = false) }
}
}
}
}Dans l'interface (Compose), affichez notice.text dans un AlertDialog avec « J'accepte » et
« Refuser » (refuser = ne pas ouvrir de session), puis state.answer qui se remplit au fil du flux.
Après l'effacement des données (eraseData()), le client oublie de lui-même le jeton visiteur ;
supprimez aussi celui enregistré dans DataStore.
Serveur (clé secrète) #
Ktor #
val gaspard = GaspardClient {
apiKey = System.getenv("GASPARD_API_KEY") // vck_live_…, jamais côté client
}
fun Application.gaspardRoutes() = routing {
post("/assistant/{conversationId}") {
val user = call.principal<UserIdPrincipal>()!!.name
val question = call.receiveText()
// Une conversation par utilisateur de votre service : X-End-User
val answer = gaspard.forEndUser(user).ask(call.parameters["conversationId"]!!, question)
call.respond(mapOf("text" to answer.text, "sources" to answer.sources.map { it.name }))
}
}Spring (WebFlux / coroutines) #
@Configuration
class GaspardConfiguration {
@Bean
fun gaspard(@Value("\${gaspard.api-key}") apiKey: String): GaspardClient =
GaspardClient { this.apiKey = apiKey }
}
@RestController
class AssistantController(private val gaspard: GaspardClient) {
@PostMapping("/assistant/conversations")
suspend fun start(principal: Principal): Conversation =
gaspard.forEndUser(principal.name).startConversation(locale = "fr")
@GetMapping("/assistant/knowledge")
suspend fun search(@RequestParam q: String): List<Passage> =
gaspard.searchKnowledge(q, limit = 5).passages
}Avec une clé serveur, consent(), giveConsent(), exportData() et eraseData() concernent
l'utilisateur final indiqué par endUser / forEndUser : montrez-lui d'abord privacyNotice().
Produits du hub #
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é
secrète : avec une clé publiable (application Android), l'appel lève une
IllegalArgumentException avant toute requête. Les valeurs que l'API peut étendre (niveaux,
verdicts, signaux) sont des chaînes.
val result = gaspard.trust.assess(
listOf(TrustListing(kind = "housing", title = annonce.titre, description = annonce.texte,
priceAmount = annonce.loyer, currency = "EUR", countryCode = "FR", city = "Lyon")),
)
if (result.assessments.first().withheld) { /* à faire vérifier ; findings dit pourquoi */ }
val cartes = gaspard.tutor.flashcards(Lesson(title = "La photosynthèse", content = texte), count = 8)
val vecteurs = gaspard.embeddings.create(listOf("chambre à louer", "room for rent")).embeddingsWebhooks #
L'ancien portail développeurs (retiré le 28 octobre 2026) signait chaque webhook (X-VC-Signature: t=…,v1=…). Vérifiez-le sur le corps brut :
post("/webhooks/valais-connect") { // Ktor
val body = call.receive<ByteArray>()
try {
val event = GaspardWebhooks.verify(body, call.request.headers["X-VC-Signature"], listOf(secret))
// dédoublonner sur event.id, puis traiter event.type (key.revoked, quota.warning…)
call.respond(HttpStatusCode.NoContent)
} catch (e: WebhookVerificationException) {
call.respond(HttpStatusCode.BadRequest, e.code)
}
}Tolérance de 300 s par défaut ; pendant une rotation, passez les deux secrets. Comparaison en
temps constant (MessageDigest.isEqual).
Flux (Server-Sent Events) #
gaspard.askStream(conversationId, "Wo finde ich einen Studentenjob in Berlin?").collect { event ->
when (event) {
is GaspardStreamEvent.Delta -> print(event.text)
is GaspardStreamEvent.Completed -> println("\nSources : ${event.answer.sources}")
}
}Le Flow est froid : chaque collect pose la question. Un événement error du serveur lève une
GaspardException (assistant_busy et assistant_unavailable → 503, conversation_full → 409) ;
un flux qui se termine sans réponse lève stream_incomplete. Annuler la collecte (first(),
cancel(), fin du viewModelScope) ferme la connexion.
Erreurs et nouvelles tentatives #
Toute erreur est une GaspardException :
| Propriété | Contenu |
|---|---|
status |
statut HTTP, 0 sans réponse |
code |
error.code de l'API (consent_required, rate_limited…), sinon http_<statut> ; network_error, timeout, invalid_response, stream_incomplete côté client |
message |
message pour les développeurs |
requestId |
identifiant à communiquer au support |
retryAfterSeconds |
délai demandé par Retry-After, s'il y en a un |
try {
gaspard.ask(conversationId, question)
} catch (e: GaspardException) {
when (e.code) {
"rate_limited", "assistant_busy" -> afficher("Réessayez dans ${e.retryAfterSeconds ?: 5} s")
"token_quota_exceeded" -> afficher("Quota du mois atteint")
else -> log.warn("Gaspard ${e.status} ${e.code} (requête ${e.requestId})", e)
}
}Le SDK réessaie lui-même, jusqu'à maxRetries fois (2 par défaut) : 503 assistant_busy,
429 rate_limited et, pour GET et DELETE seulement, les erreurs réseau et les délais dépassés.
Il attend Retry-After secondes (au-delà de 30 s, il ne réessaie pas et lève l'erreur), sinon
0,5 s puis 1 s, 2 s… Jamais pour token_quota_exceeded, les autres erreurs 4xx, ni un flux déjà
commencé. Reposer une question est sans risque : le serveur n'enregistre un échange qu'avec sa
réponse.