MMSP Development Workflow
Adding or updating model support follows the stages below, in order. Where a stage says stop and ask, pause and ask the user; do not continue until the issue is resolved, and never fill the gap yourself.
Directory map
llmsdk_docs/<model_version>/ Official docs snapshot, one folder per model generation (README.md + docs/)
api_captures/<protocol>/ Git-ignored raw API captures: request payloads + stream events
src_py/mmsp/<client_type>/ Python client, one folder per client type: <vendor>_official/ or <protocol>/
src_py/mmsp/auto_client.py Creates the client a client_type names; a model id's family names its official client
src_ts/src/<client_type>/ TypeScript client, mirrors the Python folder
src_ts/src/autoClient.ts TypeScript routing, mirrors auto_client.py
src_py/tests/test_client.py Parameterized e2e tests (env-gated AVAILABLE_MODELS)
src_ts/tests/client.test.ts Same for TypeScript
changelog/unreleased/ Where unshipped changes go; renamed to the version at release
changelog/<version>/ Release summary (README.md) plus one detail file per entry
changelog/README.md The entry format: metadata block, body rules, bilingual pairing
CHANGELOG.md One brief line per release linking into changelog/
src_py/mmsp/integration/ The playground, the tracer pages and the MMSP server, mirrored in src_ts/src/integration/
site/ The Astro site at mmsp.penguin.ooo
.agents/skills/mmsp-dev/kill-ai-slop/ Vendored AI-slop guide and scanner for the UI work below
<name>.zh.md Chinese counterpart, required for every file in changelog/
Stage 1 — Sync official docs into llmsdk_docs/
- Sources must be the model vendor's official documentation site (e.g. docs.anthropic.com, platform.openai.com, ai.google.dev). Never use third-party mirrors, blog posts, or model memory.
- Save the snapshot under
llmsdk_docs/<model_version>/following the existing folder conventions, and list the folder inllmsdk_docs/README.md. Running this workflow is the explicit request that the repository rule against editingllmsdk_docs/asks for. - When the fetched docs differ from an existing snapshot, the new official docs win: update the old files in place.
- The snapshot must be complete enough to implement from: request/response schemas, streaming event sequence, thinking output, tool calling, usage fields, and error responses.
- Stop and ask if the official URL is uncertain or a page cannot be fetched. The user can paste the content manually.
Stage 2 — Capture a live API exchange into api_captures/
- Gate: the provider's API key environment variable must be set and usable. Use the same environment variables and base URLs as
src_py/tests/test_client.py(AVAILABLE_MODELSgating and_create_client). Stop and ask the user to supply the key if it is missing; the workflow must not continue without it. - Using the provider's official SDK, or raw HTTP exactly as documented, run one streaming tool-call request with thinking enabled, then send the tool result back so the capture also shows how assistant turns are re-sent.
- Save the complete exchange unmodified under
api_captures/<protocol>/(git-ignored), e.g.round1.request.jsonplusround1.stream.jsonlwith every raw stream event in order. Never save credentials. - When something in the exchange will not serialize to JSON (binary payloads, SDK objects holding non-JSON values), save its
str()form and analyze from that. Keep the container parseable: wrap the text in a JSON object so the.jsonlstays one JSON value per line ({"unserializable_str": "<str(event)>"}, keeping the event in stream order), or put the whole exchange in a sibling.txtwhen nothing about it serializes. Astr()capture is still the authoritative record of what the API returned. Never drop the event, hand-edit it into valid JSON, or fall back to the docs because serialization failed. - Stop and ask on any API error (invalid key, insufficient quota, rate limit). Do not mock the response or continue from docs alone.
- The capture is the primary implementation reference and outranks the docs: where they disagree, implement what the API actually returned.
- Then probe the replay, because the capture only shows what the API sends, not what it needs back. Re-send the assistant turn repeatedly, each time with one candidate field removed — the reasoning item's id, its content, the whole reasoning item, each field you were tempted to keep — and record which removals the API still accepts with the model's behavior intact. That result, not the shape of the response, decides what Stage 3 stores. Save the probe outcomes next to the capture.
Stage 3 — Implement the Python and TypeScript clients
- One folder per client type, named after it: an official client per vendor (
openai_official/,anthropic_official/,google_official/,zai_official/,moonshot_official/,deepseek_official/,minimax_official/) speaks the vendor's own API, knows every generation of the vendor's models, and reads the vendor's key from the environment; a compatible client per wire protocol (openai_responses/,openai_chat/,openai_chat_vllm_adapter/,openai_embedding/,ant_messages/,google_genai/,mmsp/) speaks that protocol for any endpoint. The class is named after the folder (OpenAIOfficialClient,OpenaiChatClient), and the client type is the folder name with hyphens (openai-official,openai-chat).- A new generation of a vendor's models goes into the vendor's official client. Diff the new protocol (capture + docs) against what the client sends today; where a generation differs, even by a single key name, tell the generations apart inside the client by explicit version (
"4-6" in self._model,"gpt-6" in self._model), never by a bare substring like"claude" in model, and keep the older generations working. Never drop a generation unless the user explicitly instructs it. - A new vendor gets a new official client folder, a new
<vendor>-officialclient type inauto_client.py/autoClient.ts, and a new row in the model family table there: the family is the prefix every id of the vendor's models begins with (glm-,kimi-), matched case-insensitively. - A new wire protocol that several vendors serve gets a compatible client folder and client type; a compatible client carries no vendor's quirks.
- A new generation of a vendor's models goes into the vendor's official client. Diff the new protocol (capture + docs) against what the client sends today; where a generation differs, even by a single key name, tell the generations apart inside the client by explicit version (
auto_client.py/autoClient.tscreate the client aclient_typenames, from the one table of client types. Without aclient_type, the family a model id begins with names its official client; an id of no known family raises and asks for one. The routing has no other rule: no substring matching on model ids, no version matching outside the official clients.- A credential goes only where it was given for. This is the security model, and every client constructor follows it. A key passed as
api_keyis used as it is. A client that reads the environment's generic key (OPENAI_API_KEYforopenai_officialand the OpenAI-protocol compatible clients,ANTHROPIC_API_KEYforanthropic_officialandant_messages,GEMINI_API_KEYforgoogle_genai) reads it only when the endpoint comes from the environment too (OPENAI_BASE_URL/ANTHROPIC_BASE_URL/GEMINI_BASE_URL, or the provider's own default): given abase_urland noapi_key, it raises at construction instead of sending that key to another endpoint. That isresolve_credentials/resolveCredentialsinutils; call it, do not reimplement it. Any other official client (deepseek_official,zai_official,moonshot_official,minimax_official,google_official) reads its own variable (DEEPSEEK_API_KEY,ZAI_API_KEY,MOONSHOT_API_KEY,MINIMAX_API_KEY,GEMINI_API_KEY) whatever base URL it is given, and never the generic one; without either it raises<VAR> is required for <Client>.. Hand every credential to the vendor SDK explicitly, including "none" (authToken: nullin TypeScript, a clearedauth_tokenin Python), because the SDKs fill whatever they are not handed from their own environment variables. The offline tests intests/env-credentials.test.ts/tests/test_env_credentials.pybuild every client under a controlled environment and read the credential the SDK instance holds; add a new client to them. - Replay must be minimal, not exhaustive. A replayed assistant turn has to be accepted by the API with the model's behavior intact; it does not have to reproduce the wire item field for field.
fidelityis an exception channel, not a mirror of the payload: a field earns a place there only when the Stage 2 probe shows the request fails or the model degrades without it. Provider-generated ids the API regenerates or ignores — a reasoning item id, an output-item id — stay out. Fields the API demands go in: GPT-5.5'sphase, Claude's thinkingsignature, the exact reasoning field name a strict upstream requires. - Never copy into
fidelitywhat a universal field already carries — the thinking text, a tool call's name or parsed arguments. Rebuild the wire item from the universal fields instead. tool_call_idis not optional: always capture the provider's call id and replay it. Where the wire format carries both an item id and a call id, the call id is the one that correlates a result to its call.- Follow the reference client; do not redesign.
openai_official/is the shape for Responses-style protocols,openai_chat/for Chat Completions: same method order, same control flow, same names. A new client should read as a diff against its reference, because that is how it will be reviewed. - Keep it readable top to bottom. A reader should follow one request or response straight through the file without jumping between definitions, so inlining is the default: read the SDK's attributes directly (
model_output.delta,model_output.item.call_id) and keep field access, usage arithmetic, and error text where they are used. Extract a helper only for a genuinely large, self-contained block — the kind that would bury the main flow if inlined, such as fetching and decoding an image — never for a few lines. Mirror the reference client's own private methods (_convert_thinking_level_*,_convert_tool_choice) instead of inventing a layer beside them; a shim that accepts both dicts and SDK objects is never one of them, because the events are typed with the SDK's own types. - Clients yield deltas only; the base class closes the items.
transform_model_output_to_uni_event/transformModelOutputToUniEventturns one wire event into oneUniEventwhosecontent_itemsare the.deltaitems that event carries, in wire order, and never a.doneitem._streaming_response_internal/_streamingResponseInternalbuilds the request, opens the stream, and yields one event per wire event: a plain loop, with no item bookkeeping, no accumulator, no argument parsing, no usage merging, and no synthesized stop.usage_metadata/finish_reasonride onstopevents wherever the wire reports them — Anthropicmessage_startandmessage_delta, the Chat Completions finish and usage chunks,response.completed— merged field by field by the base; a client'sstopevent never ends the public stream. A wire event with nothing universal is the empty delta event: no items, null usage and finish reason. The base class hands every delta toStreamItems(stream_items.py/streamItems.ts), which sends it out as it arrives and closes each item with its.doneitem when the next item begins or the client's stream ends; the base then emits the one stop event or raisesEmptyResponseError. Events that break the protocol raiseStreamProtocolErrorin every mode, and the shared e2e tests check every client's stream withassert_stream_grammar/assertStreamGrammar. - The deltas of one item are contiguous and share one
fidelity.item_id. Model output is serial, so one item streams at a time and the provider's end-of-item events (content_block_stop,response.output_item.done,step.stop) are listed as no-ops: a gateway that closes its items late or out of order changes nothing. Pass the provider's own id, stringified, asfidelity.item_id: the Anthropic blockindex, the Responses eventitem_idoritem.id or item.call_id(None/undefinedwhere a gateway sends none), the Interactions stepindex, the Chat Completions wire field (reasoning_content,reasoning,content,tool_calls), the generateContent part kind (function_call,thought,inline_thinking,inline_data,text); embedding vectors carry none.StreamItemsstrips it, so it never reaches consumers, traces or histories. StreamItemsdecides where an item ends; clients never do. A delta belongs to the next item when it carries anotheritem_id, is of another kind (an Interactions thought step going text, image, text is three items under one index), or begins an item by itself: atool_call.deltacarrying a name (every provider sends it once, on the call's first delta, so Chat Completions calls need no ids of their own), an image (inline_thinking, orinline_datawith animage/*MIME type, while audio streams in chunks of one item), an embedding vector. Otherwise it continues the item streaming now: a delta without an id does, and so do a call's arguments whatever id a gateway puts on them. Fidelity sent alone under the item's id is that item's, whatever kind carries it (an Interactionsthought_signatureafter an image thought). The per-kind table in that file — the growing field, the header fields of the first delta, how the chunks join — is the only per-kind code in the stream: the done item is the item's first delta with the growing field replaced by the join of every delta's (tool call arguments parsed) plus the item's fidelity.- Fidelity goes out once, when it is complete. Attach it to the delta at which the client knows it whole: Anthropic's signature on its
signature_delta, as athinking.deltawith emptythinking; the Responses reasoningchannel/encrypted_contenton an emptythinking.deltayielded atresponse.output_item.done, the last delta of that item; GPT'sphaseon an emptytext.deltaatresponse.output_item.added.StreamItemscopies it onto the.doneitem. An identical fidelity repeated on later deltas of the same item goes out once (Chat Completions tags every reasoning delta withreasoning_field); a different one is aStreamProtocolError. - Stream on deltas; never re-read the completed item. A tool call opens with a
tool_call.deltacarrying the name and call id on the item-added event and continues with argument fragments; the completion event yields nothing but the fidelity unknown until then. Never read content back from a completed item or cross-check it against what the deltas produced;response.function_call_arguments.donereturns the empty delta event. Leave provider error events (response.failed,response.error,error) to the unknown-event guard rather than translating them into MMSP errors, except on Gemini Interactions: neither of its SDKs raises on the SSEerrorevent that arrives inside an open stream, so the guard would drop the provider's failure and the stream would end as one without usage, andgoogle_officialraises on anerrorevent carrying an error object instead, with the provider's code and message.minimax_officialis the deliberate exception: it ignores the argument deltas and reads each call fromresponse.output_item.done, yielding onetool_call.deltawith the name, call id, and whole arguments. A client streams a call on deltas or delivers the completed item, never both, so the fragments and the complete call can never disagree. - The message transform switches on
.donetypes.transform_uni_message_to_model_inputmatchestext.done,thinking.done,tool_call.done,tool_result.done, and the rest; a message never carries a.deltaitem, and legacy item types are converted before a client sees the messages. - The message transform keeps the order of the content items. Whatever a turn produced — thinking, then text, then a tool call — has to leave the transform in that order. Where a protocol makes an item its own entry (a Responses
reasoning/function_call, say) and the message text is collected separately, flush the collected text before appending the entry; a message appended after the items it preceded is what DeepSeek answers with "No tool output found for tool call". Anthropic Messages and Gemini keep one ordered block list per message, so appending in item order is enough; Chat Completions splits content andtool_callsinto fields of one message and carries no interleaving at all. UniConfigkeys rarely map one-to-one onto provider config keys. Stop and ask: list every non-obvious mapping and confirm it with the user before coding. Never decide silently.- Every
ThinkingLevelmust stay usable on every client — never raise for a thinking level. Map each level to the closest level the model supports and degrade silently when a level has no exact equivalent (e.g.google_officialmapsNONEtoMINIMAL, or tolowon the models that rejectminimal;moonshot_officialmapsNONEtolowbecause K3 cannot disable reasoning). temperatureandtool_choice(and other unsupported parameter values, e.g.prompt_caching) may reject with an exception, but must raise the MMSP-specificUnsupportedParameterErrorfromerrors.py/errors.ts, never a bareValueError/Error. Keep the message wording consistent with existing clients (containing "not support").- Implement Python and TypeScript together with identical behavior.
Stage 4 — Verify
- Register the model in the env-gated
AVAILABLE_MODELSlists of both test files with correct capability flags. Do not add model-specific test functions or files. - That
AVAILABLE_MODELSentry is the only test change a new model is entitled to.src_py/tests/test_client.pyandsrc_ts/tests/client.test.tsare shared contracts every client must already satisfy: never rewrite their bodies, assertions, prompts, or helpers to accommodate one model, and never branch inside them on a model name. A failing shared test means the client is wrong until proven otherwise. Stop and ask for explicit approval before any broader test edit, and get it per change — approval for one edit is not approval for the next. AVAILABLE_MODELSkeeps only the newest version of each model family per provider block (e.g. gemini-3.6-flash, not gemini-3.5-flash or 3.5-flash-lite as well). When a newer generation lands, replace the older entry — the old client folder stays supported and routed (see Stage 3) but is no longer e2e-tested.- Static checks:
make lintinsrc_py/;npm run lintandnpm run buildinsrc_ts/. - Run only the new model's e2e tests; the full suites are slow and spend real API quota:
cd src_py && uv run pytest -vvv tests/test_client.py -k "<model-name>"cd src_ts && npm run test -- -t "<model-name>"
- Leave unrelated tests to CI.
Supported-model registry
src_py/mmsp/registry.py/src_ts/src/registry.tslist the supported models as entries of (model, base_url, client) plus input/output modalities, context window, and USD-stored pricing keyed by MMSP's usage buckets. Keep both languages identical; the registry unit test constructs every entry throughAutoLLMClient.- For OpenRouter-hosted entries, pull authoritative data from the live models API
GET https://openrouter.ai/api/v1/models(docs: https://openrouter.ai/docs/api/api-reference/models/list-all-models-and-their-properties):pricing.prompt/completionare USD per token (multiply by 1e6), pluscontext_lengthandarchitecture.input_modalities/output_modalities. The API lists chat models only — embedding models are absent and must be checked via their model pages. - SiliconFlow publishes no pricing API; declare official CNY list prices with the
cny()initializer (converted to USD storage at 7 CNY/USD).
Record and ship
- Unreleased changes all land in
changelog/unreleased/, named for its state rather than a number because the version is not decided until release. Never create a numbered folder for unshipped work and never invent the next version number; release preparation renamesunreleased/to the decided version. - Write
changelog/unreleased/YYYY-MM-DD-<slug>.mdin the formatchangelog/README.mdspecifies: the metadata block (Date,Type,Scope,PR,Issue,Breaking) directly under the title, then## What changedrecording what the change did, plus the factual detail it introduced (config mappings, registry metadata, protocol differences) and## Compatibilitywhen it breaks something. - The entry records what was done, never the reasoning behind it and never the state of the codebase.
## Why,## Problem,## Decision,## Alternatives considered,## Verification,## Risksdo not belong on disk, and neither do claims like "Xis not exported fromY": they read as fact, go stale as the code moves, and every later reader pays to re-check them. - The reasoning is still required — it goes where it stays tied to its moment. Report it in the conversation as the work happens (what the probes returned, which alternatives you weighed and why the others lost, what the verification actually covered, what you are unsure of), and write it into the PR description. Anyone who needs it later pulls the PR description,
git log, orgit blame. - Link the PR and any issue the change closes as full URLs (
https://github.com/Prism-Shadow/model-message-stream-protocol/pull/N), in the metadata block and in the release-README line. A bare#Nis not a link in a Markdown file. The PR number only exists once the PR is open, so open it first, then add both links in a follow-up commit on the same branch. - Write the Chinese counterpart
changelog/<version>/YYYY-MM-DD-<slug>.zh.mdin the same PR, mirroring the English file section for section. The metadata block stays English verbatim (only theBreakingreason is prose to translate), as do code identifiers, model ids, and links;changelog/README.mdlists the standard heading renderings. An entry without its counterpart is unfinished. - Add one line at the top of that version's
changelog/<version>/README.mdand the matching line inREADME.zh.md, whose[详情]link points at the.zh.mdentry; the rootCHANGELOG.mdandCHANGELOG.zh.mdkeep one line per release, added at release preparation. - Commit on a feature branch and open a PR with
gh pr create --base dev; direct pushes todevare rejected.
UI work — the playground, the tracer and the site
The three pages share one look, and every change to them goes through two references before it ships:
kill-ai-slop/GUIDE.md, the field guide to the machine-default tics of generated interfaces, with its scanner:node .agents/skills/mmsp-dev/kill-ai-slop/scripts/scan.mjs <dir>. Follow its order — scan, triage each hit as slop or intended, report, then fix — and readreferences/taxonomy.mdandreferences/fixes.mdfor what each tell is and what replaces it. The playground and tracer pages live inside Python and TypeScript strings, so scan an extracted copy of the HTML.- https://www.beautifului.dev/, the reference for AI-native components: the loading line with a shimmer and the elapsed time, the collapsible "Thought for N s" trace, tool-call chips, the composer with a round send button, segmented controls with a gliding thumb.
The system they share:
- Neutral tokens (
--bg,--surface,--raised,--text,--muted,--subtle,--ring) with one accent; color carries meaning only (a stop reason, an item's kind). Light and dark follow the system throughprefers-color-schemeand adata-themeoverride; the playground and the tracer share themmsp.playground.themekey, the site usesmmsp-site.theme. - Inter for the interface, JetBrains Mono only for ids, JSON, paths of data and token counts. Depth from 1px rings, not large shadows. Motion 150–250 ms on
cubic-bezier(0.23, 1, 0.32, 1), only where something changes, and off underprefers-reduced-motion. - One mark everywhere: the four-tile MMSP logo of
site/src/components/Logo.astro(two#477dfbtiles and two dark ones, white letters), also the favicon of all three pages. The site's brand scale insite/src/styles/global.cssis built around#477dfb, a pure blue at the hue of fennel flower#7aa2f7, asbrand-500: images and the mark use#477dfb, text on white usesbrand-600or darker, and text in the dark theme usesbrand-400, which is#7aa2f7itself. The language and theme controls of the top bar are buttons that cycle on click (the other language; system, light, dark), not menus. The home page fits one desktop window, footer included: the player zooms to at most 85%, then its two stream panes are capped and follow their newest line. The README and social images are rendered fromsite/artwork/withrender.sh. Before committing an image, shrink it: PNGs to at most 1760 px wide (twice the README column) and a 256-colour palette (imagequant, thenpyoxipng), GIFs withgifsicle -O3 --lossy=15, dropping every other motion frame first. The README images together stay under 1 MB. Each tile of the mark is its own shape (the dark ones reach half a unit under the blue ones at the seams): one dark square under the blue tiles shows as a grey rim on white. The logo files for others to use are.github/images/mmsp-logo.svgandmmsp-logo-dark.svg(outlined letters) with 512 px PNGs of each, built bysite/artwork/logo.py <NotoSans-Bold.ttf> .github/imageswith the PNGs rendered from the SVGs in Chrome. Every image has one copy in the repository: the site serves.github/images/social-preview.pngthroughsite/src/pages/social-preview.png.tsrather than a second file insite/public/.
What the owner has asked for, and keeps asking for:
- As few words as possible; logos and icons over sentences. The site home page is modeled on https://penguin.ooo/: the mark, the fixed slogan with its turning verb, the second line, two buttons, the vendor logos, and the recorded streams beside them, visible without scrolling. The top bar ends with language, theme and GitHub.
- A list is carried by alignment, spacing and hierarchy: no tinted boxes, no colored left bars, no pills for words that are not a status. Roles read as uppercase words.
- Side panels float and take no width, so a page's column sits in the same place whatever else is open. Navigation in the tracer reads like a file explorer: back, forward and up, then the address.
How the pages are built and checked:
- The playground page is one HTML document embedded as
CHAT_TEMPLATEin both servers. Python serves it through Jinja, so it may hold no{{,{%or{#, and every backslash is doubled; TypeScript holds it in a template literal, so backticks and${are escaped too. Edit it once and regenerate both embeddings; keep the element ids and function names the page tests assert. - The tracer shares
_TRACER_HEAD,_TRACER_SCRIPTand_ICONS(TRACER_HEAD,TRACER_SCRIPT,ICONS) and a_pageshell betweentracer.py(Jinja page bodies) andtracer.ts(the same markup built in code). A tracer test asserts a trace page never contains0.6, its sign of a leaked sixth embedding value, so no CSS number or SVG path there may contain it. - CI runs the model tests (
jest.yml,pytest.yml) only for changes outsidesite/; a site-only change runs the site build alone. - Verify in headless Chrome against both servers (and
npm run buildplusastro previewfor the site): light and dark, desktop and a 390 px phone, no console errors, no horizontal scroll. A passing scan is not a better page; look at the screenshots. - Scanner hits accepted on purpose: the shimmer on loading text, the round send and remove buttons, Lucide-derived icons, Inter, mono for data, and the owner's slogan as a sentence-long headline.