freemodel-sync
Discover, test and rank free LLMs, then write the top-ranked working one (plus fallbacks) into the configs of tools you already use.
Free LLM endpoints come and go: models get added, rate-limited, deprecated or quietly start
costing money. freemodel-sync points your bots and scripts at a free model that passed a
recent probe. It cannot guarantee the model keeps working between runs.
Not a proxy. freemodel-sync never sees or forwards your traffic. It only edits config files (with a backup) and then gets out of the way. Your tools keep talking to the provider directly.
Two ways to use it
- Python CLI (
pip install freemodel-sync): discovers, probes and ranks free models, then writes the winner into your tools' config files. Runs anywhere, e.g. from cron or CI. - dsh plugin (
dsh-freemodel, inplugins/dsh-freemodel/): a self-contained TypeScript plugin for dsh. It lists the free OpenRouter, Groq, SenseNova and Bailian models that passed a probe directly in the dsh model picker, with capability and quota badges, a settings card, a/freemodelcommand anddsh-freemodel status/doctor. Keys stay in dsh's credential store or your environment. Optionalfreemodel-autobest-model routing and the guarded default-model switch are both off by default. Version 0.9.0, not yet published to npm (build it from this repo).
How it works
discover ──► filter ──► probe (tiny, cached, budgeted) ──► rank ──► select ──► write configs ──► notify
- Discover free models from the public OpenRouter catalog
(
GET /api/v1/models, no key needed). A model counts as free only if its id ends in:free, prompt and completion prices are0, and it takes and returns text. Theopenrouter/freemeta-router is excluded. - Filter by tool-calling support, minimum context window, id globs, and expiry:
models whose
expiration_dateis within N days are dropped (expired models are always dropped, even withexclude_expiring_within_days: null), and models expiring within 30 days are penalised. - Probe each candidate with one tiny streaming request (
max_tokens: 16by default,providers.openrouter.probe_max_tokens) using your own OpenRouter key, measuring availability and time-to-first-token. Probing is frugal: candidates are tried in order of promise, results are cached (6 h by default), at mostmax_testsnew probes are made per run (at least 1), and the round stops while keepingreserve_requestsof the free requests left today, as reported byfree_model_daily_requests.remaininginGET /api/v1/key(UTC day; per OpenRouter's documentation at the time of writing, 50/day below 10 purchased credits and 1000/day above). If the key endpoint reports nothing, a conservative 50/day is assumed against a local UTC-day counter.- Account problems are not model failures. A 401, a 402, an OpenRouter platform 429
(free-model per-minute/per-day cap) or a 403 about the account (key permissions, guardrails,
budget limits, or a bare 403 with no details) stops the round after that single request; the
model is not cached as failed, and
Retry-After/X-RateLimit-Resetpause later runs until the reset. A 403 whose error metadata points at the model itself (routing or moderation details, e.g. a free endpoint that refuses this kind of client, or flagged input) is a failure of that model only: it is cached as failed and probing goes on with the next model. Upstream-provider 429s, 408, 5xx and timeouts count as transient failures and are retried aftertransient_ttl_minutes(30 by default). So do errors that arrive inside the stream after HTTP 200, such as a mid-stream rate limit ("temporarily rate-limited upstream"): probing goes on with the next model; only an explicit 401/402 or account-level 403 in the stream still stops the round. Other errors (e.g. 400/404 "no endpoints") are model failures. On Groq, every 401, 403 and 429 is treated as an account problem. - Without an API key,
test,rankandsyncexit with an error and send nothing.
- Account problems are not model failures. A 401, a 402, an OpenRouter platform 429
(free-model per-minute/per-day cap) or a 403 about the account (key permissions, guardrails,
budget limits, or a bare 403 with no details) stops the round after that single request; the
model is not cached as failed, and
- Rank with an explainable score (availability ≫ latency, context, tools, expiry).
Fallbacks are models that passed their probe; untested models are used only if you set
ranking.fallbacks_allow_untested: true, and are then marked(untested). Non-chat models (safety classifiers, embedding, rerank, speech) are excluded by default. - Write the primary model and fallback list into each configured target. Every write is
idempotent (unchanged → untouched), supports
--dry-runwith a diff, makes a timestamped*.bak-*backup (mode 0600 or stricter, the newest 5 kept by default; seebackups) and replaces the file atomically. Withbackups.keep: 0no backup is made and existing backups are left alone (never deleted); delete old*.bak-*files yourself if you turn backups off. If no model passes its probe, nothing is written. - Notify (optional) via Telegram or a generic webhook when the primary model changes. If any
target fails to write, the run exits
1, the recorded state is not advanced and no notification is sent; the next run retries.
Install
pipx install freemodel-sync # latest release from PyPI
pipx install git+https://github.com/ndwlbj/freemodel-sync # unreleased changes from main
# or run without installing
uvx --from git+https://github.com/ndwlbj/freemodel-sync freemodel-sync --help
# from a local checkout
pipx install .
Requires Python 3.10+ (0.3.3 was the last release that supports 3.9). Runtime dependencies: httpx, PyYAML.
Quick start
export OPENROUTER_API_KEY=... # your own key; only read from the environment
freemodel-sync discover # list free models (no key needed)
freemodel-sync test --max-tests 3 # probe the 3 most promising ones
freemodel-sync rank --json # full ranking + selection as JSON
cp examples/config.example.yml ~/.config/freemodel-sync/config.yml # then uncomment and edit targets
freemodel-sync sync --dry-run # show the diffs
freemodel-sync sync # write configs, notify on change
Run sync from cron or a systemd timer, e.g. every 3 hours. Keep the key out of the crontab:
put export OPENROUTER_API_KEY=... in a file only you can read (chmod 600 ~/.config/freemodel-sync/env)
and source it, or use EnvironmentFile= in a systemd unit.
0 */3 * * * . "$HOME/.config/freemodel-sync/env" && freemodel-sync sync >> "$HOME/.cache/freemodel-sync/sync.log" 2>&1
Exit codes: 0 success, 1 error (bad config, missing key, a target failed), 2 no usable model
(no file written), 3 probing was stopped by an account/quota problem such as a platform rate limit
or missing credits (no file written, no model marked as failed), 4 another run is still in progress
(test, rank and sync take a lock next to the state file, so overlapping cron runs never probe twice).
The lock is an advisory OS lock on <state_path>.lock (flock on Linux and macOS): the operating
system releases it when the process exits or crashes, so there is no stale lock to delete. It only
serializes runs that use the same state_path, does not stop other programs from editing the target
files, and may not work on network file systems such as NFS. On Windows the lock uses msvcrt;
releases up to 0.3.3 take no lock there.
test and rank both return 3 when an account/quota problem stopped probing.
sync --dry-run is zero-cost: it uses only cached probe results (from an earlier test/sync), sends no chat or /key request, needs no API key, and writes no target, state or cache file. It still fetches the public model list. Add --probe to probe as usual during a dry run (this spends free-tier requests and updates the cache). rank --no-probe and sync --no-probe likewise use cached results only.
Configuration
Default location: ~/.config/freemodel-sync/config.yml (override with -c FILE or
$FREEMODEL_SYNC_CONFIG). Every key is optional; see
examples/config.example.yml for the full annotated file.
Its targets and notification channels are commented out: uncomment the ones you use and point
them at your own files.
- An unknown key (usually a typo such as
testing.max_test) is ignored with a warning that names it. Keys underproviders.<name>are provider options and are not checked. - On/off options (
notify.*.enabled, thedshoptioncheck_model,preserve_comments) accepttrue/false,yes/no,on/offand1/0, quoted or not. Any other value is an error rather than a guess. - A target file that does not exist is created with only the managed settings, and a warning is logged (often the path is a typo or a placeholder).
- With no
targets,syncwrites no file but still records the selection in the state file and sends notifications when it changes (a notify-only setup).
Providers
| provider | key variable | free when | notes |
|---|---|---|---|
openrouter (default) |
OPENROUTER_API_KEY |
:free id and every price (including pricing.overrides) is 0; a zero-priced model without :free is not used |
public model list, quota from /api/v1/key |
groq |
GROQ_API_KEY |
your Groq organization is on the Free plan; confirm with plan: free |
Groq's API has no prices; the model list needs the key |
sensenova |
SENSENOVA_API_KEY |
you are in the SenseNova Token Plan public beta; confirm with plan: token-plan, and the catalog pricing must be the string "0" |
off by default; no quota API (recorded as beta/unknown), 403 outside the plan is a model failure |
bailian |
DASHSCOPE_API_KEY |
you confirm free_quota_guard: confirmed (the console's "stop when the free quota is used up" switch) and list the models in models with their hand-entered allowances |
off by default; Beijing only; per-model quota is manual/unverified; 403 AllocationQuota.FreeTierOnly records quota and stops the channel for the UTC day |
Groq and multi-provider ranking need freemodel-sync 0.3.0 or newer. SenseNova is new in P7 and
Bailian in P8; both are off by default. Without plan: token-plan SenseNova lists and probes
nothing, and a model whose catalog price is no longer the string "0" is removed with a warning.
Bailian lists and probes nothing until BOTH free_quota_guard: confirmed and a non-empty models
allowlist are set; its per-model allowances are entered by hand, labelled "手动,未核实", and never
used to deduct the local probe budget. Neither SenseNova nor Bailian is part of the public daily
snapshot (both need the user's own account). All enabled providers feed one ranking. The providers section replaces the default
(OpenRouter only), so you can drop OpenRouter or set enabled: false. Each provider uses its own
key variable, cache file, daily counter and rate-limit pause, and a quota problem stops probing only
for that provider. The first enabled provider uses testing.cache_path itself; every other one uses
<name>.<provider>.json next to it (e.g. results.groq.json). The binding follows the order of the
providers section, so reordering it hands the first provider's daily counter and rate-limit pause
to another provider until the next UTC day (cached results are keyed by provider and never mix).
providers.<name>.daily_free_limit overrides testing.daily_free_limit for one provider. Fallbacks always come from the primary's provider. Restrict a target to the
providers its tool has a key for with options.providers: [openrouter]; otherwise its base URL may
switch provider. Details and sources are in docs/providers.md.
Each discovered model carries capabilities (tools, vision, reasoning; null means the
catalog does not say, never false) and an optional model-level quota. The shared quota type
supports a five-hour rolling window (rolling_5h), a weekly window (week), a daily reset
(day), a one-off total (total) and a public-beta marker (beta); missing values stay
unknown. rank --json / output.ranking_json writes schema_version: 2 and includes these
objects under every model and a normalised quota per provider, while keeping the old
daily_free_limit / daily_free_used / daily_free_remaining keys so a v1 reader still works.
Old caches and snapshots without the new fields are read as unknown. The dsh plugin shows compact
capability/quota badges in its picker, including 能力未知 / 额度未知 for unknown values; a
hand-entered allowance is labelled 手动,未核实. A probe can also fail with capacity (queue /
no capacity): it is retried like a transient failure, gets its own consecutive counter, is penalised
in the ranking, and after ranking.capacity_downgrade_after (default 3) such results in a row it
is no longer selectable. A quota failure (Bailian's 403 AllocationQuota.FreeTierOnly) stops that
channel until the next UTC day.
providers:
openrouter: {}
groq: {plan: free} # only if your Groq org is on the Free plan
sensenova: {plan: token-plan} # only inside the SenseNova Token Plan beta
bailian:
free_quota_guard: confirmed # console "stop when the free quota is used up" switch is on
models:
- id: deepseek-v3.2 # example id; use the console's ids
quota: {unit: tokens, period: total, limit: 1000000}
targets:
- adapter: openai-env
path: ~/bot/.env
options: {providers: [openrouter]} # this bot only has an OpenRouter key
Manual tier-2 registration (P9)
Platforms without a public API (ZCode, Qoder, WorkBuddy) can be registered by
hand in manual_providers. A registration contains only a model id, an
optional label/quota note and an expiry date; the Python side never requests
those domains. freemodel-sync registry list|check [--json] shows the
entries, expiry and expired state using local data only. Manual entries are
tier 2, shown as 手动,未核实, and are excluded from automatic selection by
default; a target must list the registration in options.providers to select
it. Availability probing is not done by Python at all: the dsh plugin does it
through ctx.llm, only when manualProbe.enabled and the entry's probe are
both on, at most once per model per day. An account error stops that platform
for the day; capacity is recorded but does not stop it. See
docs/manual-registry.md.
Targets (adapters)
| adapter | file | what it writes |
|---|---|---|
openai-env |
KEY=VALUE env file |
OPENAI_BASE_URL, OPENAI_MODEL, OPENAI_FALLBACK_MODELS (names configurable). OPENAI_FALLBACK_MODELS is not a standard variable: your tool must read it itself. Other lines, comments and your API key are left untouched. |
openai-yaml |
any YAML file | base_url, model, fallback_models under a dotted key_path (default llm). |
dsh |
dsh (DeepSeek Harness) cordis.patch.yml-style list |
Replaces only the agent-default-model entry with config: {provider: <p>, model: '<m>'}; other entries and comments are kept byte-for-byte. Use provider_map to map openrouter to your dsh provider id. Other keys already in that entry's config (e.g. reasoningEffort) and its name are kept; entry_name sets the name. dsh has no fallback-list entry, so fallbacks are not written (fallback_entry_id is deprecated and ignored). If the same file declares the provider's models list (dsh's llm-pi-ai entry), a model missing from it is refused and nothing is written; check_model: false turns this off. A file that is not a block-style list (e.g. a flow-style [...] list) or that has two entries with the managed id is refused rather than rewritten. |
astrbot (experimental) |
AstrBot data/cmd_config.json |
Updates the model of an existing provider entry (provider_id): the top-level model field for AstrBot ≥ 4.10 (provider_sources layout), model_config.model for older versions. Never creates providers or touches keys or provider sources. |
Example dsh result:
- id: agent-default-model
config: {provider: openrouter, model: 'vendor/some-model:free'}
Known limitations
- AstrBot: both layouts were checked against AstrBot source (v4.29.1); an entry matching
neither raises an error instead of being skipped. AstrBot keeps its config in memory and rewrites
the file when you save in the WebUI, so restart AstrBot after a sync (and avoid editing in
the WebUI at the same time). Provider entries have no fallback list, so only the primary model is
written; mapping fallbacks to AstrBot's
fallback_provider_idsis not implemented yet. When the model changes, the whole JSON file is written back with 2-space indentation and non-ASCII text kept as is (the format AstrBot itself writes), so a hand-formatted file is reformatted; keys and values other than the model are unchanged. - YAML comments (0.3.0+): install the optional extra (
pipx install "freemodel-sync[yaml-comments]") andopenai-yamledits the file with ruamel.yaml, keeping comments, key order and quoting. Without it, PyYAML re-dumps the whole document when a value changes (an unchanged file is never rewritten): comments are dropped and unrelated values can be re-typed or re-quoted (1.10becomes1.1,yesbecomestrue,0755becomes493,"yes"becomes'yes'). So with the defaultpreserve_comments: autoand no ruamel.yaml, a file that would lose anything is refused (the target fails and the file is left unchanged); a file PyYAML writes back as is (for example one freemodel-sync already wrote this way) is still updated.preserve_comments: truerequires ruamel.yaml;falsealways uses PyYAML and accepts the re-dump. Thedshadapter always preserves comments. - Time-based pricing: a
:freemodel whosepricing.overrideshas a non-zero price (for example at peak hours or for long contexts) is skipped with a warning. - Free quota: the daily budget comes from
free_model_daily_requests(never inferred fromis_free_tier);testing.daily_free_limitoverrides it, counted per UTC day. The counters are read once, before probing, and OpenRouter'susedcount can trail requests that were already sent, so shown quota figures may lag. When this tool's own probe count for the UTC day is higher than the reportedused, the budget uses the local count. - Timeouts:
testing.timeout_sapplies to probe requests only. Fetching the model list and the quota uses a fixed 30-second timeout. - Providers: OpenRouter, Groq, SenseNova and Bailian (the last three need an explicit confirmation). Bailian's remaining free allowance has no verified public API yet, so its quota stays manual. SiliconFlow was evaluated but its API does not say which models are free; see docs/providers.md.
Notifications
- Telegram: set
notify.telegramand exportTELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID. - Webhook (e.g. a QQ bot bridge): set
notify.webhook.url_env(defaultFREEMODEL_SYNC_WEBHOOK_URL). Webhook URLs usually contain a token, so keep them in the environment: an inlinenotify.webhook.urlworks but logs a warning, plus another if the config file is readable by other users. The POST body is{"event": "primary_changed", "old", "new", "fallbacks", "text"}.
Notification failures are logged (without tokens or full URLs) and never fail a sync. A configured
channel whose environment variables are not set logs a warning when a notification is due, so a
misspelt variable name does not go unnoticed; remove the channel (or set enabled: false) if you do
not use it.
Daily health check
The repository runs an automated daily health check of free models with GitHub Actions
(daily-snapshot.yml).
- When: scheduled with cron
17 1 * * *, i.e. 01:17 UTC, about 09:17 Beijing time (UTC+8). GitHub can start scheduled runs late, or skip them when it is busy; the workflow can also be started by hand. - What: it discovers the free models of OpenRouter (and of Groq, only when a Groq key is configured and the Groq organization is confirmed to be on the Free plan). It then probes at most 15 models per provider, best static score first, with one tiny request each, staying within the free-request quota and keeping 10 requests in reserve. An account or quota error stops probing for that provider. Models past their expiration date are not probed; the snapshot only lists their ids under the provider.
- Where: results are committed to the public
databranch (mainis never touched): latest snapshot, pluslatest.jsonand onesnapshots/YYYY-MM-DD.jsonper day in the snapshot folder. - When a provider fails: if model discovery fails for one provider, that day's file is marked
"partial": trueandlatest.*keep the last complete snapshot, so the public report never shows that provider's models as gone. If discovery fails for every provider, nothing is committed and the run fails. - Check the date: the snapshot runs with the maintainer's own OpenRouter key. If that key stops
working, or GitHub disables the schedule (it does so after 60 days without activity in a public
repository), no new snapshot is committed and
latest.*simply stays old. The date is in the title oflatest.mdand in the newestYYYY-MM-DD.jsonfile name: if it is more than two days old, treat the snapshot as stale.
The table shows each model's status (ok, 403, 404, 429, timeout, ...), latency and
context length, plus the changes since the previous snapshot and models expiring soon. It is
informational only: each result is one request from GitHub Actions at one moment and can differ for
you, so run freemodel-sync test with your own key before relying on a model.
Writing a provider
Providers are plugins. Subclass Provider, implement discover() and test() (and optionally
quota()), and register it:
from freemodel_sync.providers import Provider, register_provider
@register_provider
class MyProvider(Provider):
name = "myprovider"
base_url = "https://api.example.com/v1"
api_key_env = "MYPROVIDER_API_KEY"
def discover(self): ...
def test(self, model, timeout=30.0): ...
Possible future providers (not implemented, no timeline): Zhipu GLM Flash, Google Gemini. Adapters follow the same pattern
(freemodel_sync.adapters.base.Adapter + register_adapter).
Development
python -m pip install -e ".[dev]"
ruff check src tests scripts
ruff format --check src tests scripts
pytest
The dsh plugin (lists free OpenRouter/Groq/SenseNova/Bailian models in the dsh picker, with optional freemodel-auto best-model routing and guarded autoDefaultModel profile switch, both default off) lives in plugins/dsh-freemodel/ (TypeScript, pnpm; see its README and
docs/dsh-plugin-verification.md).
Tests never touch the network (HTTP is mocked with httpx.MockTransport). See
CONTRIBUTING.md.
Security
To report a vulnerability privately, see SECURITY.md.
- Keys are read only from environment variables; freemodel-sync does not write them to config files
or print them.
Log output (including
-v) redacts the exact key values the run read from the environment as well as anything shaped like a key (Telegram bot tokens, Bearer tokens,sk-,sk-or-,sk-ws-/sk-sp-andgsk_keys), and HTTP request logging (--debug-http) is off by default. - Symlinked targets are followed: the real file is backed up and replaced, the link is kept.
- Never commit your
config.yml,.envfiles, backups or cache; the.gitignorecovers the defaults.
License
MIT © freemodel-sync contributors
Metadata
Release files for freemodel-sync 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| freemodel_sync-0.4.0.tar.gz | 1.9 MB | With provenance | Details for freemodel_sync-0.4.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| freemodel_sync-0.4.0-py3-none-any.whl | Python 3 | none | any | Details for freemodel_sync-0.4.0-py3-none-any.whl |
Total release size: 2.0 MB
Release files / freemodel_sync-0.4.0.tar.gz
| Download URL | freemodel_sync-0.4.0.tar.gz |
|---|---|
| Size | 1.9 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4b311b43b2d8f57859735a6dbfc6c6dcfa1aff288f34dbc17649e06e899b3d66
|
|
BLAKE2b-256 checksum How to use checksums |
52d1814c991cccd724168a31f73a23ded126a1ba1f7c1c6ea72ded72aa2dc467
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 2, 2026.
Transparency logRelease files / freemodel_sync-0.4.0-py3-none-any.whl
| Download URL | freemodel_sync-0.4.0-py3-none-any.whl |
|---|---|
| Size | 103.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4b232d5e7111c1b216c003cc46565009ee2b285fbd57ed7f4022c55ff21376be
|
|
BLAKE2b-256 checksum How to use checksums |
cda9baad3186891a74bb639bcdd7030c729894e20e05e947e15d7b51094465d9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 2, 2026.
Transparency logSecurity for freemodel-sync 0.4.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for freemodel_sync-0.4.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 2, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.