Workflow de développement MMSP
L'ajout ou la mise à jour de la prise en charge d'un modèle suit les étapes ci-dessous, dans l'ordre. Lorsqu'une étape indique arrêtez-vous et demandez, faites une pause et demandez à l'utilisateur ; ne continuez pas tant que le problème n'est pas résolu et ne comblez jamais le vide vous-même.
Plan du répertoire
llmsdk_docs/<model_version>/ Instantané de la documentation officielle, un dossier par génération de modèle (README.md + docs/)
api_captures/<protocol>/ Captures d'API brutes ignorées par git : charges utiles des requêtes + événements de flux
src_py/mmsp/<client_type>/ Client Python, un dossier par type de client : <vendor>_official/ ou <protocol>/
src_py/mmsp/auto_client.py Crée le client qu'un nom de type de client spécifie ; la famille d'un ID de modèle nomme son client officiel
src_ts/src/<client_type>/ Client TypeScript, miroir du dossier Python
src_ts/src/autoClient.ts Routage TypeScript, miroir de auto_client.py
src_py/tests/test_client.py Tests e2e paramétrés (AVAILABLE_MODELS avec vérification d'environnement)
src_ts/tests/client.test.ts Même chose pour TypeScript
changelog/unreleased/ Où vont les modifications non publiées ; renommé en version lors de la publication
changelog/<version>/ Résumé de la version (README.md) plus un fichier de détail par entrée
changelog/README.md Le format de l'entrée : bloc de métadonnées, règles du corps, appairage bilingue
CHANGELOG.md Une ligne brève par version pointant vers changelog/
src_py/mmsp/integration/ Le playground, les pages du traceur et le serveur MMSP, miroir dans src_ts/src/integration/
site/ Le site Astro à mmsp.penguin.ooo
.agents/skills/mmsp-dev/kill-ai-slop/ Guide et scanner contre le 'slop' IA achetés pour le travail d'interface ci-dessous
<name>.zh.md Contrepartie chinoise, obligatoire pour chaque fichier dans changelog/
Étape 1 — Synchroniser la documentation officielle dans llmsdk_docs/
- Les sources doivent être le site de documentation officiel du fournisseur du modèle (par exemple docs.anthropic.com, platform.openai.com, ai.google.dev). N'utilisez jamais de miroirs tiers, de billets de blog ou de mémoire de modèle.
- Enregistrez l'instantané sous
llmsdk_docs/<model_version>/en suivant les conventions de dossier existantes et listez le dossier dansllmsdk_docs/README.md. L'exécution de ce workflow est la demande explicite que la règle du dépôt contre l'édition dellmsdk_docs/demande. - Lorsque la documentation récupérée diffère d'un instantané existant, la nouvelle documentation officielle l'emporte : mettez à jour les anciens fichiers sur place.
- L'instantané doit être suffisamment complet pour implémenter à partir de celui-ci : schémas de requête/réponse, séquence d'événements de flux, sortie de raisonnement, appel d'outils, champs d'utilisation et réponses d'erreur.
- Arrêtez-vous et demandez si l'URL officielle est incertaine ou si une page ne peut pas être récupérée. L'utilisateur peut coller le contenu manuellement.
Étape 2 — Capturer un échange d'API en direct dans api_captures/
- Condition préalable : la variable d'environnement de la clé API du fournisseur doit être définie et utilisable. Utilisez les mêmes variables d'environnement et URL de base que
src_py/tests/test_client.py(conditionAVAILABLE_MODELSet_create_client). Arrêtez-vous et demandez à l'utilisateur de fournir la clé si elle est manquante ; le workflow ne doit pas continuer sans elle. - En utilisant le SDK officiel du fournisseur, ou HTTP brut exactement comme documenté, exécutez une requête d'appel d'outil en streaming avec le raisonnement activé, puis renvoyez le résultat de l'outil pour que la capture montre également comment les tours de l'assistant sont renvoyés.
- Enregistrez l'échange complet non modifié sous
api_captures/<protocol>/(ignoré par git), par exempleround1.request.jsonplusround1.stream.jsonlavec chaque événement de flux brut dans l'ordre. N'enregistrez jamais d'identifiants. - Lorsque quelque chose dans l'échange ne peut pas être sérialisé en JSON (charges utiles binaires, objets SDK contenant des valeurs non JSON), enregistrez sa représentation
str()et analysez à partir de celle-ci. Gardez le conteneur analysable : enveloppez le texte dans un objet JSON pour que le.jsonlreste une valeur JSON par ligne ({"unserializable_str": "<str(event)>"}, en gardant l'événement dans l'ordre du flux), ou mettez tout l'échange dans un fichier.txtfrère lorsque rien de celui-ci ne se sérialise. Une capturestr()est toujours l'enregistrement faisant autorité de ce que l'API a renvoyé. Ne supprimez jamais l'événement, ne le modifiez pas à la main pour qu'il soit du JSON valide, et ne revenez pas à la documentation parce que la sérialisation a échoué. - Arrêtez-vous et demandez en cas d'erreur d'API (clé invalide, quota insuffisant, limite de débit). Ne simulez pas la réponse et ne continuez pas uniquement à partir de la documentation.
- La capture est la référence d'implémentation principale et prévaut sur la documentation : là où elles divergent, implémentez ce que l'API a réellement renvoyé.
- Sondez ensuite la relecture, car la capture montre seulement ce que l'API envoie, pas ce qu'elle doit recevoir en retour. Renvoyez le tour de l'assistant à plusieurs reprises, en supprimant à chaque fois un champ candidat — l'ID de l'élément de raisonnement, son contenu, l'élément de raisonnement entier, chaque champ que vous seriez tenté de conserver — et enregistrez quelles suppressions l'API accepte toujours avec le comportement du modèle intact. Ce résultat, et non la forme de la réponse, décide ce que l'étape 3 stocke. Enregistrez les résultats de la sonde à côté de la capture.
Étape 3 — Implémenter les clients Python et TypeScript
- Un dossier par type de client, nommé d'après celui-ci : un client officiel par fournisseur (
openai_official/,anthropic_official/,google_official/,zai_official/,moonshot_official/,deepseek_official/,minimax_official/) parle l'API propre du fournisseur, connaît chaque génération des modèles du fournisseur et lit la clé du fournisseur depuis l'environnement ; un client compatible par protocole filaire (openai_responses/,openai_chat/,openai_chat_vllm_adapter/,openai_embedding/,ant_messages/,google_genai/,mmsp/) parle ce protocole pour n'importe quel point de terminaison. La classe est nommée d'après le dossier (OpenAIOfficialClient,OpenaiChatClient), et le type de client est le nom du dossier avec des traits d'union (openai-official,openai-chat).- Une nouvelle génération des modèles d'un fournisseur va dans le client officiel du fournisseur. Différenciez le nouveau protocole (capture + documentation) par rapport à ce que le client envoie aujourd'hui ; là où une génération diffère, même par un seul nom de clé, distinguez les générations à l'intérieur du client par une version explicite (
"4-6" in self._model,"gpt-6" in self._model), jamais par une sous-chaîne nue comme"claude" in model, et gardez les générations plus anciennes fonctionnelles. Ne supprimez jamais une génération à moins que l'utilisateur ne l'ordonne explicitement. - Un nouveau fournisseur obtient un nouveau dossier de client officiel, un nouveau type de client
<vendor>-officialdansauto_client.py/autoClient.ts, et une nouvelle ligne dans le tableau des familles de modèles là-bas : la famille est le préfixe par lequel chaque ID des modèles du fournisseur commence (glm-,kimi-), comparé sans tenir compte de la casse. - Un nouveau protocole filaire que plusieurs fournisseurs desservent obtient un dossier de client compatible et un type de client ; un client compatible ne porte aucune particularité de fournisseur.
- Une nouvelle génération des modèles d'un fournisseur va dans le client officiel du fournisseur. Différenciez le nouveau protocole (capture + documentation) par rapport à ce que le client envoie aujourd'hui ; là où une génération diffère, même par un seul nom de clé, distinguez les générations à l'intérieur du client par une version explicite (
auto_client.py/autoClient.tscréent le client qu'unclient_typenomme, à partir de la table unique des types de client. Sansclient_type, la famille par laquelle un ID de modèle commence nomme son client officiel ; un ID sans famille connue lève une exception et demande une famille. Le routage n'a aucune autre règle : pas de correspondance de sous-chaîne sur les ID de modèle, pas de correspondance de version en dehors des clients officiels.- Un identifiant va seulement là où il a été donné. C'est le modèle de sécurité, et chaque constructeur de client le suit. Une clé passée comme
api_keyest utilisée telle quelle. Un client qui lit la clé générique de l'environnement (OPENAI_API_KEYpouropenai_officialet les clients compatibles avec le protocole OpenAI,ANTHROPIC_API_KEYpouranthropic_officialetant_messages,GEMINI_API_KEYpourgoogle_genai) ne la lit que lorsque le point de terminaison vient aussi de l'environnement (OPENAI_BASE_URL/ANTHROPIC_BASE_URL/GEMINI_BASE_URL, ou la valeur par défaut du fournisseur) : donné unbase_urlet sansapi_key, il lève une erreur à la construction au lieu d'envoyer cette clé à un autre point de terminaison. C'estresolve_credentials/resolveCredentialsdansutils; appelez-la, ne la réimplémentez pas. Tout autre client officiel (deepseek_official,zai_official,moonshot_official,minimax_official,google_official) lit sa propre variable (DEEPSEEK_API_KEY,ZAI_API_KEY,MOONSHOT_API_KEY,MINIMAX_API_KEY,GEMINI_API_KEY) quelle que soit l'URL de base qui lui est donnée, et jamais la générique ; sans l'une ou l'autre, il lève<VAR> is required for <Client>.. Remettez chaque identifiant au SDK du fournisseur explicitement, y compris "aucun" (authToken: nullen TypeScript, unauth_tokeneffacé en Python), car les SDK remplissent ce qui ne leur est pas remis à partir de leurs propres variables d'environnement. Les tests hors ligne danstests/env-credentials.test.ts/tests/test_env_credentials.pyconstruisent chaque client dans un environnement contrôlé et lisent l'identifiant que l'instance du SDK détient ; ajoutez-leur un nouveau client. - La relecture doit être minimale, pas exhaustive. Un tour d'assistant relu doit être accepté par l'API avec le comportement du modèle intact ; il n'a pas à reproduire le champ de l'élément filaire champ pour champ.
fidelityest un canal d'exception, pas un miroir de la charge utile : un champ y gagne une place uniquement lorsque la sonde de l'étape 2 montre que la requête échoue ou que le modèle se dégrade sans lui. Les ID générés par le fournisseur que l'API régénère ou ignore — un ID d'élément de raisonnement, un ID d'élément de sortie — restent en dehors. Les champs que l'API exige vont dedans : lephasede GPT-5.5, lasignaturede raisonnement de Claude, le nom exact du champ de raisonnement qu'un amont strict exige. - Ne copiez jamais dans
fidelityce qu'un champ universel transporte déjà — le texte de raisonnement, le nom d'un appel d'outil ou ses arguments analysés. Reconstruisez plutôt l'élément filaire à partir des champs universels. tool_call_idn'est pas optionnel : capturez toujours l'ID d'appel du fournisseur et rejouez-le. Lorsque le format filaire porte à la fois un ID d'élément et un ID d'appel, l'ID d'appel est celui qui corrèle un résultat à son appel.- Suivez le client de référence ; ne reconcevez pas.
openai_official/est la forme pour les protocoles de style Responses,openai_chat/pour les Chat Completions : même ordre de méthodes, même flux de contrôle, mêmes noms. Un nouveau client doit se lire comme une différence par rapport à sa référence, car c'est ainsi qu'il sera examiné. - Gardez-le lisible de haut en bas. Un lecteur doit suivre une requête ou une réponse directement à travers le fichier sans sauter entre les définitions, donc l'inlining est la valeur par défaut : lisez les attributs du SDK directement (
model_output.delta,model_output.item.call_id) et gardez l'accès aux champs, l'arithmétique d'utilisation et le texte d'erreur là où ils sont utilisés. Extrayez une fonction d'aide uniquement pour un bloc vraiment volumineux et autonome — le genre qui enterrerait le flux principal s'il était inline, comme la récupération et le décodage d'une image — jamais pour quelques lignes. Miroir des méthodes privées propres du client de référence (_convert_thinking_level_*,_convert_tool_choice) au lieu d'inventer une couche à côté d'elles ; une couche d'adaptation qui accepte à la fois des dictionnaires et des objets SDK n'en fait jamais partie, car les événements sont typés avec les types propres du SDK. - Les clients produisent uniquement des deltas ; la classe de base ferme les éléments.
transform_model_output_to_uni_event/transformModelOutputToUniEventtransforme un événement filaire en unUniEventdont lescontent_itemssont les éléments.deltaque cet événement transporte, dans l'ordre filaire, et jamais un élément.done._streaming_response_internal/_streamingResponseInternalconstruit la requête, ouvre le flux et génère un événement par événement filaire : une boucle simple, sans comptabilité d'élément, sans accumulateur, sans analyse d'argument, sans fusion d'utilisation et sans arrêt synthétisé.usage_metadata/finish_reasonchevauchent les événementsstoplà où le filaire les rapporte —message_startetmessage_deltad'Anthropic, les morceaux de fin et d'utilisation des Chat Completions,response.completed— fusionnés champ par champ par la base ; l'événementstopd'un client ne termine jamais le flux public. Un événement filaire sans rien d'universel est l'événement delta vide : pas d'éléments, utilisation et raison de fin nulles. La classe de base remet chaque delta àStreamItems(stream_items.py/streamItems.ts), qui l'envoie dès qu'il arrive et ferme chaque élément avec son élément.donelorsque l'élément suivant commence ou que le flux du client se termine ; la base émet ensuite l'événement stop unique ou lèveEmptyResponseError. Les événements qui violent le protocole lèventStreamProtocolErrordans tous les modes, et les tests e2e partagés vérifient le flux de chaque client avecassert_stream_grammar/assertStreamGrammar. - Les deltas d'un même élément sont contigus et partagent un seul
fidelity.item_id. La sortie du modèle est séquentielle, donc un élément coule à la fois et les événements de fin d'élément du fournisseur (content_block_stop,response.output_item.done,step.stop) sont listés comme des non-opérations : une passerelle qui ferme ses éléments en retard ou dans le désordre ne change rien. Passez l'ID propre du fournisseur, converti en chaîne, commefidelity.item_id: l'indexdu bloc Anthropic, leitem_idde l'événement Responses ouitem.id or item.call_id(None/undefinedlà où une passerelle n'en envoie pas), l'indexde l'étape Interactions, le champ filaire des Chat Completions (reasoning_content,reasoning,content,tool_calls), le type de partie generateContent (function_call,thought,inline_thinking,inline_data,text) ; les vecteurs d'intégration n'en portent aucun.StreamItemsle supprime, donc il n'atteint jamais les consommateurs, les traces ou les historiques. StreamItemsdécide où un élément se termine ; les clients ne le font jamais. Un delta appartient à l'élément suivant lorsqu'il porte un autreitem_id, est d'un autre type (une étape de pensée Interactions passant texte, image, texte correspond à trois éléments sous un seul index), ou commence un élément par lui-même : untool_call.deltaportant un nom (chaque fournisseur l'envoie une fois, sur le premier delta de l'appel, donc les appels Chat Completions n'ont pas besoin de leurs propres ID), une image (inline_thinking, ouinline_dataavec un type MIMEimage/*, tandis que l'audio coule en morceaux d'un élément), un vecteur d'intégration. Sinon, il continue l'élément qui coule maintenant : un delta sans ID le fait, de même que les arguments d'un appel quel que soit l'ID qu'une passerelle leur met. La fidélité envoyée seule sous l'ID de l'élément est celle de cet élément, quel que soit le type qui la porte (unthought_signatureInteractions après une pensée image). La table par type dans ce fichier — le champ croissant, les champs d'en-tête du premier delta, comment les morceaux se joignent — est le seul code par type dans le flux : l'élément fait est le premier delta de l'élément avec le champ croissant remplacé par la jointure de
... [tronqué pour la traduction]