Imported from moqimoqidea/model-price (
AGENTS.md). Install upstream withnpx skills add moqimoqidea/model-price. Copyright stays with the author.
AGENTS.md
Guidance for an agent changing this repository: how the code is layered, which invariants must survive a refactor, and where a given change belongs.
This file deliberately does not restate the other documents.
| Document | Read it when |
|---|---|
README.md |
you need what the skill does, how to install it, or why the report is shaped the way it is (for a person) |
SKILL.md |
you are using the skill at runtime and need to pick a command, a provider set, and a format |
references/daily-workflow.md |
you are changing daily run, staged delivery, repair, or publication procedures |
references/reporting.md |
you are changing report preparation or source-scoped introduction summarization |
references/privacy.md |
you are changing public diagnostics, private captures, or publication safeguards |
im/dingtalk.md |
you are delivering through DingTalk or changing its DWS compatibility contract |
references/schema.md |
you are consuming or changing the JSON payload |
references/source-notes.md |
you are touching one vendor's source or parser |
AGENTS.md |
you are maintaining the code — this file |
What this is
A single-skill repository whose root is the skill directory. It queries public, credential-free model catalogues, introductions, capabilities, specifications, and official price documents across 20 providers. Its two output modes are a model and cross-provider comparison, and a whole-catalogue scan that reports launches, withdrawals, billing changes, and price moves since the previous scan or a retained historical baseline.
Standard library only, Python 3, no install step, no build step.
Layout
.
├── SKILL.md runtime contract the skill host loads
├── README.md the person-facing document
├── AGENTS.md code structure, invariants, and change locations
├── agents/openai.yaml platform interface metadata (display name, default prompt, policy)
├── im/
│ └── dingtalk.md DingTalk transport version, routing, and verification contract
├── references/
│ ├── schema.md the JSON payload, field by field
│ ├── announcement-audit.md dated findings and announcement coverage limits
│ └── source-notes.md per-vendor source and parser notes
├── scripts/
│ ├── query_model_prices.py the stable CLI entry point — argument parsing and dispatch only
│ ├── update_tencent_model_mirror.py offline maintenance entry point
│ ├── audit_model_retirements.py read-only official evidence audit
│ └── model_price/ the implementation
│ ├── __init__.py the responsibility split, restated for a reader
│ ├── paths.py filesystem anchors, cache TTL, schema versions
│ ├── errors.py SourceError, typed HTTP routing failures, SkillUpdateError
│ ├── privacy.py diagnostic redaction and private-capture validation
│ ├── core.py HttpClient, the PriceSource contract, atomic JSON writes
│ ├── documents.py shared document retrieval and OpenAI's public Docs MCP fallback
│ ├── text.py normalisation of text scraped out of vendor documents
│ ├── models.py model identity: normalisation, aliases, family matching
│ ├── pricing.py price and record shapes, the unit vocabulary, rescaling
│ ├── parsing.py document readers: HTML/Markdown tables, price columns, the rates one cell publishes, time bands
│ ├── caching.py CacheStore and the CachedPriceSource decorator
│ ├── updating.py Git fast-forward before an explicit refresh
│ ├── snapshots.py archived baselines, retention, and point-in-time selection
│ ├── diffing.py what moved between two baselines
│ ├── changes.py every change cause grouped by channel and literal model ID
│ ├── delta.py the scan-every-catalogue run
│ ├── deepseek_updates.py DeepSeek's official update log, read once for every date consumer
│ ├── mistral_catalogue.py Mistral's public model data, shared by identity, introductions, and notices
│ ├── lifecycle_sources.py official retirement notice readers
│ ├── lifecycle.py independent notice history and milestone changes
│ ├── announcements/ official publication, access, and quoted-rate history
│ │ ├── core.py independent archive, identity joins, and change causes
│ │ ├── parsing.py feeds, article subjects, and scoped official claims
│ │ ├── documents.py literal blog data and article prose, without running JavaScript
│ │ └── sources.py each channel's own public discovery policy
│ ├── reporting.py the shared wording: one value, written once
│ ├── messages.py the two plain-text messages those values are laid into
│ ├── rendering.py offline scan artifacts and validated introduction summaries
│ ├── budget.py how much of a message fits the channel carrying it
│ ├── registry.py wiring, provider selection, cross-provider queries
│ ├── providers/ one module per vendor, registered in its __init__.py
│ └── descriptions/ the independent model-introduction subsystem
│ ├── __init__.py the subsystem's public surface
│ ├── core.py the DescriptionSource contract and record shape
│ ├── sources.py one class per official introduction source
│ ├── parsing.py Markdown/HTML readers specific to introduction pages
│ ├── resolver.py read only the hosting channel's introduction per literal model ID
│ ├── registry.py construct the resolver
│ ├── tencent_mirror.py validate the checked-in Tencent capture
│ └── data/tencent-models.json the checked-in mirror
├── tests/ unittest, no network
├── cache/ runtime state, contents not in git
└── snapshots/ runtime state, contents not in git
How a run flows
A query and a scan share the front half and diverge at the adapters.
query_model_prices.py CLI: parse args, select providers, render
└── registry.build_adapters() one CachedPriceSource per provider
└── CachedPriceSource( ProviderAdapter( HttpClient ) )
compare / provider / list
├── registry.select_compare_providers() requested > inferred overseas > domestic
├── adapter.search(model)
│ ├── list_models() → model_matches()
│ └── adapter.query() → parse the document → pricing.make_record()
├── descriptions.resolve_many(every model)
└── messages.comparison_message(payload)
delta
├── registry.select_catalog_providers() never infers an overseas provider
├── adapter.catalog_records() always fresh — a cache hit reads as "no change"
├── SnapshotStore.select() previous, yesterday, last month, or a timestamp
├── snapshots.build_snapshot() → SnapshotStore.write() archives every success
├── diffing.compare_snapshots(selected, current) the channel's policy decides what is compared
├── lifecycle_sources.read_events() fresh official notice evidence
├── lifecycle.scan_lifecycle() independent history and date crossings
├── announcements.scan_announcements() fresh releases, independently archived
├── descriptions.resolve_many(changed models only)
└── messages.scan_message(payload)
The CLI and retirement audit construct DocumentClient, an HttpClient subtype.
It shares OpenAI's logical-document cache, bounded fallback state, and public
Docs MCP connection across price, introduction, and notice readers. Vendor
parsers still consume text and do not implement transport fallback themselves.
Both transports use the same HTTP host budget and pause rules. Retrieval evidence
is optional metadata and never participates in catalogue or notice comparisons.
The output layer reads the domain layer; the domain layer never reads the output
layer. messages formats nothing itself — it asks reporting for each value and
its two entry points are the only things that know how a message is laid out:
reporting.py— one value, one wording: labels for every enum a source can publish, an amount in the unit it was billed in, a moment with its offset.messages.py— the layout: headings, numbering, indentation, blank lines, and the block list each entry point renders.budget.py— how far a message overruns its limit, measured and nothing else: it counts characters and never touches a line of text.
Never move a value formatter into messages.py, never let reporting.py decide
where a line goes, and never let budget.py know what a message says.
How a message stays within its limit
Every selected report block is rendered. Two limits exist and both are handled the same way — by measuring and saying so, never by cutting:
The scan's default presentation includes standard prices only. When a channel has standard price movements on at least two distinct listed models, both its capability and price sections use one batch item naming all affected IDs and the channel's official HTTPS catalogue page. Price-only models need no repeated prose in that item; other changes to those models still get their own introductions and facts. Single-model movements, new listings, and new/removed standard offers stay detailed. A mass catalogue removal whose models share one official notice is one item naming every literal ID, the notice, the replacement and dates; a removal with a different notice or another simultaneous cause stays detailed. Full introductions remain in JSON; full prices remain in JSON and snapshots. These presentation choices are made before measuring length, never to meet a budget.
An adapter may declare reports_price_movements = False — OpenRouter does, because
it rebills whichever endpoint a route lands on and those amounts move daily without
telling a reader anything. The comparison then reports that channel's listing and
notices alone: diffing.model_changes skips its offers entirely, so no price or
billing-mode movement enters changes, a day of rate noise reads unchanged, and
messages.changed_blocks leaves the channel out of 模型价格. That is a comparison
rule, not a rendering trick: the message and the JSON agree, and nothing downstream
may re-introduce an amount. Amounts are still read and archived, and compare still
answers a question about one model. The policy travels from the adapter through the
provider report rather than being inferred from a provider ID.
- The introduction limit (300 characters) lives at
descriptions.core: a vendor summary longer than that is kept whole and markedsummary_needs_condensing.reporting.summary_labelturns that flag into a label naming the original length, so the reader sees that this one has to be summarized before it is sent. The flag rides on the record, which is what gets cached, so it is re-derived on every read rather than frozen at capture. - The message limit (
--max-chars, default 3000) lives atmessages.finalize. It stacks every block, measures the text, and appends one closing line naming the overrun when there is one. An over-long report is the complete report plus that line — never a shorter report.
Neither the tool nor budget can summarize: it takes no credentials and has no
model to ask. So the code marks the overrun and the summarization is done by
whoever writes the message out. budget exposes overage(text, limit) and
fits_within(text, limit) for that measurement, and nothing that selects a
shorter rendering.
The host may supply a source-scoped introduction summary through the offline
render command. rendering validates the exact provider/literal model identity,
available source, fields, and existing introduction length limit; messages
substitutes that prose for purpose/capability/specification repetition while
retaining lifecycle, sources, and all other facts. The original payload is never
modified. Identical missing-introduction fields may share one channel-local block
with every affected ID, independently of the message budget. Summary fidelity is
the host's responsibility; Python does not infer or evaluate semantic claims.
Adding a block means adding one entry to the block list in messages.py, not an
if inside a renderer. A block that renders to nothing drops out by itself
(stacked already leaves out an empty block), so a conditional block needs no
branch of its own.
Invariants
These are the rules the code exists to hold. A change that breaks one is a bug, even when the tests still pass.
- Never merge two variants into one price row. Delivery mode, region, time band, context tier, promotion, generation, currency, and the scope a cell published a rate under each keep their own row. A tidy comparison that averages them is wrong.
- Identify by content, never by a name list. Tables are located by the
wording of a model column. A price column is one whose header names a charge
or whose cells publish amounts in the adapter's own currency — the second
reading is what admits a column headed 输入音频时长 whose cells are 元/小时, and
a header naming only how big the request is stays a condition. Adding a model
name to code so a parser recognizes it is the anti-pattern the whole
parsinglayer was written to remove. - A header that describes the request is a condition, not a price. A length
band (
条件 输入长度:千 token) or a peak/off-peak label still has to survive intoconditions; reading it as a price column silently drops the tier that distinguishes rows. - Read only amounts that name their own currency. A page publishing both CNY and USD tables yields the adapter's own currency and nothing relabelled.
- A published zero is a price; an unreadable one is not. A vendor that
charges nothing states it in words or as a zero, and both are that model's
price: recorded as an amount of
0with the vendor's own wording indisplay. What stays unpriced is a charge this tool could not read at all — a tier the page does not quote, or a figure published without the currency it is in. Both facts reach the reader, and they never read alike. Free-price recognition lives inpricing(is_free_statement,is_free_amount); an adapter that reads its cells itself delegates to it rather than growing its own rule. - An amount is shown with the terms the vendor published beside it. When a
vendor prints a second rate, a multiplier, or the day its rate stops, that is
what the amount is — a rate limited in time or in scope, not the model's
ordinary price — and it travels in
list_amount,discount, oreffective_until. Each is read only where the vendor stated it: a multiplier is never derived from two amounts, and a period is never inferred from prose, from another vendor, or from a neighbouring row. They are written once, inreporting(format_price,price_terms_text), so the comparison and the scan cannot show different numbers for the same price. - A price a vendor explains in prose keeps the vendor's sentence. Where no
column states a promotion, the sentence beside the table is kept verbatim in
pricing_notes— "at least through November 21, 2026" is not an end date, so nothing is parsed out of it — and the amount read from the table stays the rate billed today. A sentence is kept only for the models it names, matched against the whole catalogue at once so that one aboutGPT-5.6 Solis not also read as naminggpt-5.6. Notes are never compared: a reworded one is not a change. - Peak/off-peak windows are per platform and quoted verbatim. Read the window
from that vendor's own document into
time_bands;publishes_time_bandsgates it so a platform that states no window is never handed another platform's. A rule is selected by model name first, delivery label second — one page can bill two generations on different calendars. - A description failure never touches a price result. A missing, retired, or
unreadable introduction is reported as
not_found,source_error, orbudget_exhaustedwhen this run's per-host request budget stopped the read before it was attempted. A budget refusal is never worded as a broken source; neither status may suppress or alter a price that parsed fine. Introductions are scoped to the hosting provider and literal model ID. Never infer an introduction source from the model name, fall back to its creator, or merge introductions across channels or retired-name aliases. A channel without a reader reports absence. - Never synthesize Tencent model text. TokenHub's detail cards sit behind an
authenticated console, so their checked-in mirror is the only source. A model
absent from it is
not_found— not a cue to infer capabilities from a name. The anonymous Hunyuan research index can independently prove a publication; quote its public prose as announcement evidence, never as a captured TokenHub card or proof that the hosted API has opened. - A failed or empty scan never writes a baseline.
source_errorandempty_scanare reported and every prior successful archive is kept, so the change survives into the run after the source recovers. A listed model with no price is not an empty scan: keep it in the snapshot with unknown price so its listing and later price publication are detected separately. - Cache and baseline are different things. The cache is a 3-hour TTL store of
responses, reused inside that window.
deltaalways reads the sources afresh — a cache hit would be handed back as "no change". Successful scans are archived per provider, with a hard limit of 1000 snapshots. One catalogue model owns one record; every published API alias travels inmodel_aliasesand is still accepted byquery/search, so an alias rename is not a model removal. Before pruning, reserve the last scan of each day in the recent three-month calendar window, then the first scan of each day while space remains; fill the rest with the newest scans. This preserves yesterday/month lookups and yesterday's first scan without letting a high-frequency schedule grow past the count limit. - Bump the schema version when a shape changes.
CACHE_SCHEMA_VERSIONcovers parsed responses (CACHE_TTL),SNAPSHOT_SCHEMA_VERSIONcovers baselines. A parser or source change requires the cache bump, or stale parsed data is served for the rest of its TTL; a baseline written by an older shape is treated as absent rather than diffed against. - A length limit is met by summarizing, never by truncating. Two limits
exist. An introduction over 300 characters is kept whole and marked
summary_needs_condensing(descriptions.core.SUMMARY_MAX_CHARS), and the label says so and names the original length. A message over--max-chars(default 3000) is rendered complete with one closing line naming the overrun; no selected block is dropped, no list is capped, no string is sliced. This is not cosmetic: a cut price loses its decimal point and the reader cannot tell what was left out. The tool takes no credentials and has no model, so it never summarizes either — it measures and says so, and whoever sends the message summarizes. Those are the only two length rules; nothing inmessagesmay measure or slice a string, andbudgetonly counts characters. - The message carries no Markdown. No
**, no#, no tables, no Markdown links — hierarchy is numbering and indentation, and each source URL is written last on its line. Any displayed source URL starts withhttps://so a reader can open it directly. DingTalk reads a document back through its own parser, which mangles a report this dense. - The model introduction opens every message. Both
comparison_blocksandscan_blocksput it first, after the header and before prices or conclusions: a reader who does not know what a model is for cannot judge what it costs. Never move it below a price. Each channel introduces its own changed models using its own words and sources, once per literal model ID; a batch of price movements uses the linked presentation defined above. Its source line is never optional: an unsourced capability claim is not a fact. A scan labels every cause: new listing, catalogue removal, price movement (an amount change, never a caption or a unit correction), billing-mode addition/removal, price-item addition/removal, replacement revision, notice change, crossed milestone, official publication, access change, or announcement rate change — for a channel that reports no price movements, only the listing, notice, publication, and capability causes (invariant 24). Simultaneous causes all remain visible; listing state never replaces them. Notice facts and evidence belong with the same channel's model in 模型能力, not a separate section. Catalogue prices are shown only for models whosemodel_availabilityislisted. Announcement rates are separate quoted evidence, with complete original scope, never current catalogue offers. The message omits anactivelifecycle; catalogue listing is not proof of public access, and preview, legacy, retired, and unknown remain visible. - Statuses are reported, never softened.
source_error,not_found, andempty_scanreach the scan reader.update_skippedandcheck_failedremain in JSON while the scan message omits the Skill update check. A provider that held still is still named, because that shows the scan covered it. - No credentials, ever. The only authenticated path is refreshing the explicit Tencent mirror through a user-owned logged-in session, offline.
- An official update time needs official evidence. A vendor-labelled date or
a date the official update log publishes may populate
source_updated_at. When no such evidence exists, JSON retains the most recent successful snapshot before the current run. The scan message does not print update times. A date-only source remains date-only rather than acquiring an invented midnight. - IM transport facts have one owner per channel.
SKILL.mdroutes a sender to the selected channel contract; it does not restate versions or commands. DingTalk's DWS minimum version, exact route, preflight, target checks, and transport ceiling live only inim/dingtalk.md.README.mdmay explain why that contract exists, but must link to it instead of copying it. - One document is one request per run. Every registered provider owns a
one-pass
catalog_records()implementation, and adapters read stable pages throughPriceSource.document()and their documented read-only JSON APIs throughHttpClient.HttpClientretries only safe requests, respects shortRetry-Aftervalues, and enforces a per-host request budget. POST is never retried unless its caller marks the operation idempotent. - Retirement evidence is independent of catalogue disappearance. A missing price row alone does not prove EOS. Keep each hosting platform's literal ID and distinguish announcement, EOM, automatic redirect, and EOS. A failed or empty notice read cannot overwrite the prior notice history. The earliest possible shutdown date never becomes an asserted actual shutdown. A 【目录下架】 tag describes that catalogue only.
- Publication, listing, and access are independent facts. Registered official release sources discover models before they have rates or usable public APIs. Keep the published name rather than guessing an API ID; join only exact normalized literal names or catalogue display names. Never use family or retirement aliases to transfer release facts. Initial discoveries are first observations, not asserted new launches that day. Rolling-index omissions cannot retract retained releases, and failed or empty discovery cannot replace their archive. Access states require quoted official evidence for the model and audience; restrictions on a sibling variant, a feature, or model weights never close an otherwise public API. Third-party hosts need their own release evidence. Announcement archives have a separate compatibility version.
- A channel that reports no price movements is compared on its listing.
OpenRouter rebills whichever upstream endpoint a request routes to, so its
published amounts move daily without stating anything a reader can act on.
Its adapter declares
reports_price_movements = False;diffingthen skips that channel's offers entirely, so no price or billing-mode movement enterschanges, a day of rate noise readsunchanged, and the provider report says so. The rule lives in the comparison, not in the rendering: the message and the JSON agree, the channel is absent from 模型价格, and no amount, batch, or count for it is written, expanded, or re-counted by any later stage. Listing, notice, publication, and capability changes are reported as for any other channel — a new listing by its introduction and never by its rates. Amounts are still read, archived, and answering a named-model query (compare,provider), because those answer a question about one model rather than the daily comparison. The policy travels from the adapter through the provider report; nothing infers it from a provider ID.
Where a change goes
| Change | Put it in |
|---|---|
| Add a price provider | a new module in providers/, then register it in providers/__init__.py |
| A vendor's page moved or its table changed | that vendor's providers/*.py constants, plus references/source-notes.md |
| A unit a vendor bills in reads wrongly, or a new one appears | pricing.UNIT_MEASURES, then the wording in reporting.UNIT_MEASURE_LABELS |
| A cell that publishes several rates reads wrongly | parsing.cell_rates and the rules it is built from |
| Add a model-introduction source | descriptions/sources.py and DESCRIPTION_SOURCE_CLASSES; routing in descriptions/resolver.py |
| Change how one value reads | reporting.py |
| Change whether one channel's price movements are compared | that adapter's reports_price_movements, then diffing.py and messages.changed_blocks |
| Change how price lines are classified (amount, unit, line, caption) | diffing.price_changes and diffing.price_change_counts; wording in reporting.py |
| Change how a mass removal is batched by notice | messages.bulk_removal_groups; keep notice facts in reporting.py |
| Change how catalogue and notice changes are grouped by model | changes.py; keep labels in reporting.py |
| Change a description source's request budget or catalogue reuse | descriptions/sources.py; status wording in reporting.py |
| Change how a message is laid out | messages.py |
| Change offline artifact reading or introduction-summary validation | rendering.py, plus references/reporting.md |
| Change diagnostic privacy or private-capture rejection | privacy.py, plus references/privacy.md; never rewrite official price evidence |
| Change daily installed-version, delivery, repair, or publish gates | references/daily-workflow.md; transport-specific facts stay in im/ |
| Change which blocks a density keeps, or what opens a message | the section list in messages.py (comparison_sections / scan_sections) |
| Change the character budget or how a density is chosen | budget.py, plus DEFAULT_MAX_CHARS callers in query_model_prices.py |
| Change a DingTalk version, command, target check, or delivery recommendation | im/dingtalk.md; keep SKILL.md and README.md as links to that contract |
| Change which providers a query covers | registry.py |
| Change the cache or snapshot shape | paths.py version, then the reader and writer together |
| Change retirement sources or milestone semantics | lifecycle_sources.py or lifecycle.py, plus references/source-notes.md and references/schema.md |
| Change official model-publication discovery | announcements/sources.py and announcements/parsing.py, plus references/source-notes.md |
| Change release archives, access comparisons, or literal identity joins | announcements/core.py, plus references/schema.md and its own version in paths.py |
| Audit current official retirement evidence | scripts/audit_model_retirements.py; keep vendor readers in lifecycle_sources.py |
| Change retries, timeouts, request budgets, or HTTP diagnostics | core.py, plus the network policy in references/source-notes.md |
| Change OpenAI document transport or Docs MCP fallback | documents.py, plus references/source-notes.md; keep price and introduction parsers independent |
| Refresh the Tencent mirror | scripts/update_tencent_model_mirror.py |
| Change the CLI surface | query_model_prices.py — and SKILL.md and README.md, which both document it |
Adding a price provider
Most vendors publish a documented table of per-model prices, so the work is declaration rather than parsing:
class XiaomiAdapter(TabularPricingAdapter):
provider_id = "xiaomi"
provider_name = "小米 MiMo"
source_url = XIAOMI_URL
source_kind = "official_html"
currency = "CNY"
region = "中国区"
TabularPricingAdapter walks the document once and holds the rows, so a scan
costs one read rather than one query per model, and it reads whatever unit each
column bills in. Override only the policy that actually differs: price_kind,
cell_amount, cell_rates, price_unit, offer_name, model_column,
heading_conditions, includes_table, model_conditions, model_variants,
record_extras, or model_extras. Subclass PriceSource directly only when the vendor's shape is
genuinely not a table (aliyun reads a JSON API, tencent reads the public Slate
document API, baidu reads a Gatsby pre-fetch).
Three declarations deserve care: carry_forward_model for vendors that leave a
continuation row's model cell empty, publishes_time_bands for vendors that
explain a peak/off-peak window in prose beside the table, and source_documents
for a vendor that splits one catalogue across two price pages (Kling prices images
and video separately) — each row then keeps the page that published its rate.
Override catalog_records() when the adapter can build every record from the
document it already parsed. The inherited default walks the whole catalogue with
one query per model, which is correct but pays an HTTP request per model for an
adapter whose query fetches on its own. Aliyun's public Qianwen model-market API
returns series with all their independently priced model items, so its adapter
loads that catalogue once and reuses it for listing, queries, and scans.
Prefer the representation the page publishes for machines — its own Markdown copy when the tables are real Markdown tables, otherwise its public structured JSON. Parse rendered HTML only when the vendor publishes neither.
Conventions
- Standard library only. There is no requirements file and no
pyproject.tomlon purpose: the install story isgit cloneand run. A third-party import breaks it. from __future__ import annotationsat the top of every module.- Every module opens with a docstring saying why it exists and which constraint it holds — not an inventory of what it contains.
- Comments explain the reason at the point the rule is non-obvious. No comment restates the code under it.
- Type hints throughout.
Anyis for the loosely-shaped JSON payloads, where the shape is documented inreferences/schema.mdinstead. - Names carry the documentation:
publishes_time_bands,carry_forward_model,NON_IDENTITY_CONDITIONS,describes_request,price_column_kinds. - Reach for a function; use a class for a contract (
PriceSource,DescriptionSource) or for state that has to be carried across calls (TabularPricingAdapter._parsed_rows,CacheStore). - Change behaviour by overriding policy, not by copying a method into a subclass.
- Chinese text belongs only in strings a person reads: report wording,
regionvalues, and vendor-facing labels. Identifiers, comments, and docstrings are English. - Committed documentation and examples use repository-relative links, portable installation locations, or placeholders. Never embed a developer's absolute checkout, home, temporary folder, or private session path. Real installation paths belong only in local runtime artifacts and diagnostics.
query_model_prices.pystays thin and its invocation surface stays stable: argument parsing, provider selection, rendering, exit code.
Commands
python3 -m unittest discover -s tests # full suite, offline
python3 scripts/query_model_prices.py --help
python3 scripts/query_model_prices.py compare deepseek-flash --format json
python3 scripts/query_model_prices.py delta --format message
python3 scripts/query_model_prices.py delta --since yesterday
python3 scripts/query_model_prices.py delta --since last-month
python3 scripts/query_model_prices.py delta --since 2026-09-19T23:59:59+08:00
python3 scripts/query_model_prices.py compare deepseek-flash --format message --max-chars 1200
python3 scripts/update_tencent_model_mirror.py CAPTURE.json
python3 scripts/audit_model_retirements.py --provider zhipu
Tests never touch the network. GET readers accept an object with get_text; JSON
POST readers use request or post_form. The suite passes fixture clients mapping
a URL and, when needed, a request body to a document or an exception. Add a vendor
by adding its fixture and a test that parses it, not by adding a live request.
Runtime state
cache/ and snapshots/ hold local state that must not be committed, but the
directories themselves must exist because the script writes into them. Each
therefore carries its own .gitignore — * plus !.gitignore — rather than being
listed in the root .gitignore, which would drop the directory along with its
contents and leave a fresh clone with nowhere to write.
The two checkouts
The repository may be cloned twice on a working machine: a development clone, and an installed copy in the skill host's configured search path. They are independent clones, so editing one changes nothing in the other until the change is committed, pushed, and the other fast-forwards.
- The installed copy self-updates at the start of every
--refreshand everydelta: it fetches upstream, checks whether the incoming commits actually touched this skill, and only then fast-forwards and re-executes itself. A dirty working tree there turns the update intoupdate_skipped. - Daily
delta --require-currentinstead requires a clean tree and exact HEAD equality with the fetched upstream, including changes outside the skill path. Verification failure stops before constructing any source reader. Offlinerenderbypasses Git, source clients, caches, and snapshot stores entirely. - Never install by copying. A copy has no
.git, so every refresh reportscheck_failed. A symlink to a real clone resolves to the repository and is fine. snapshots/history is not in git. A fresh clone has no baselines, so its first defaultdeltareportsbaseline_createdfor every provider instead of a comparison. Copy the directory over when that matters. A historical request with no matching baseline reportsbaseline_not_foundrather than silently selecting another date.
Git remotes and publishing
The repository is published through two remotes so that users in mainland China have a nearby source:
origin:https://github.com/moqimoqidea/model-pricegitee:https://gitee.com/moqimoqidea/model-price
Every code or documentation commit must be pushed to both remotes. Push the same
branch to origin and gitee, then verify that both remote-tracking refs resolve
to the local commit. Adding a checkout's mirror remote uses:
git remote add gitee https://gitee.com/moqimoqidea/model-price
git push origin <branch>
git push gitee <branch>
Anti-patterns
- Restating
SKILL.md's runtime rules orREADME.md's usage in a docstring. Point at the document instead. - Copying an IM channel's version or command into multiple documents. Route to
its file under
im/instead. - Teaching a parser a model name. Fix the rule it uses.
- Merging currencies, time bands, regions, generations, or the tiers one cell published, to make a comparison tidier.
- Reporting a price of zero for a model whose price was simply unreadable — one billed on a tier the source does not quote, or printed without the currency it is in. Say it is unpriced. A zero the vendor actually published is a different fact: it is a price, and it is recorded as one.
- Letting a missing source become an empty row or a silent omission.
- Truncating anything to fit a limit — a message, an introduction, a list, or a
single line. Cut words, never content: summarize. Nor letting a length rule live
anywhere but
budget.py,messages.finalize, and the summary limit indescriptions.core. - Printing a model's introduction once per channel, or below the price.
- Removing a channel's price movements from the message while the comparison still reports them — or re-deriving an amount for a channel that reports none.
- Caching anything
deltareads. - Growing the CLI entry point past argument parsing and dispatch.
- Adding a dependency.
