openvibers/openvibe.ai Commits

AI: the ten managed provider adapters, priced from each provider's own page (#307)

OP OpenVibers committed 7 days ago · 35fb57912a · 7 files, +686 −242

* AI: nine managed provider adapters and the uncensored tier

Seed Qwen, Kimi, GLM, MiniMax, DeepInfra, Novita, Featherless, Refuseless and OpenRouter from the price book, off until their owner key exists, with recorded prices, data policies and the existing commercial-access gate. Add policy="uncensored": Network's 18+ account flag (fail closed, five-minute cache), uncensored offers only, the hard floor on input and output, and a label on every such answer. Recorded provider fixtures and no live network in the tests.

* Merge remote-tracking branch 'origin/main' into feat/ai-provider-adapters

* PR 1: the adapters alone; the uncensored policy moves to its own PR

The branch carried the ten managed providers and the uncensored tier together. The
brief asks for two PRs, smaller than the first plan: the adapters here, the policy
in the next one. So the policy files (server/policy/, the tier gate, the floor and
its wiring in the router, the runs and the two API routes) are taken back out of
this branch and go into their own PR on top of it.

* Readiness: a catalogue offer nobody bought does not degrade the service

The nine catalogued provider offers are seeded into the registry so the owner sees the
key slot, and they answer once the owner puts a key in: until then they are off by
design. The readiness check counted every active provider without credentials as a
problem, so the service read 'degraded' with nothing wrong with it, and the open-circuit
case reported the nine offers instead of the provider that had the circuit open.

A catalogue offer with no key is no longer a problem (its circuit still is); every
other provider keeps the old rule, and the operator console still lists an offer
without a key.

* Providers: the ten managed offers, priced from each provider's own page

Qwen (Alibaba Model Studio), Kimi (Moonshot), GLM (Z.ai), MiniMax, DeepInfra, Novita,
Featherless, Refuseless, Abliteration.ai and OpenRouter, each OpenAI-compatible and each
off until its key is in the owner settings.

Every price now comes from the provider's own page - its pricing page or its own model
list - with that page and the day it was read, and not from an aggregator's. A price
nobody publishes stays null: Refuseless publishes none, so its models carry null rather
than a guess. The quantization is null unless a provider states it (none of the ten does),
and the refusal rate is null until a provider publishes one or a measurement job owns it.

Each provider carries a privacy label: private only where its own terms say it keeps
nothing and trains on nothing - DeepInfra and Abliteration.ai - and anonymized for
everyone else, Featherless included: it says the prompts are not stored but never says
they are not trained on.

A provider that says the account has no money left is named provider.exhausted (Refuseless
answers 402 insufficient_credits; the others say it in their own words). The code travels
with the failure, so the empty-failover job can read it; the circuit that follows is that
job's business, not this one's.

* Merge remote-tracking branch 'origin/main' into feat/ai-provider-adapters

# Conflicts:
#	server/index.js

* Providers: the document of the ten offers, and the seed's own words

* Merge remote-tracking branch 'origin/main' into feat/ai-provider-adapters

# Conflicts:
#	docs/provider-adapters.md
#	server/providers/common.js
#	server/providers/index.js
#	server/providers/pool-providers.js
#	server/providers/pool-seed.js
#	server/workflows/seed.js
#	test/fixtures/pool-providers.json

* Providers: the catalogue tests follow the new catalogue

The ten offers and their own-page prices are tested in their own file; the Exchange file
keeps the uncensored tier's tests. The exhaustion case is the empty-failover work's now
(it is on main): the test proves a provider's own answer is read as exhaustion and never
retried into, and leaves the circuit and the failover where that job put them.

* Providers: the adapter leaves the exhaustion answer to the empty-failover work

The exhausted mapping (a provider that says its account is out of money) is on main now:
the empty-failover job reads the provider's own answer and owns the circuit, the failover
and the 503 ai.resting. The adapter here does not need its own copy of that rule, and a
second one would only drift from it.

* Providers: the privacy facts reach the label on every answer

The catalogue kept what each provider says it does with a prompt in a shape nothing read:
the label on every answer (server/exchange/privacy.js) reads retention, training, region
and trust, and the Exchange catalogue reads trains, retention_days and anonymized. The
free-text 'not stated' was read as a region and as a declared policy, so every offer
claimed to be known.

The data policy now declares the facts the house reads. A fact nobody stated is null:
DeepInfra and Abliteration.ai keep nothing and train on nothing (private), Featherless
keeps nothing but never says it does not train (anonymized, and it says so), and the
providers with no readable privacy page declare nothing at all (anonymized, unknown).

* Providers: the catalogue's own header says where the facts come from

* Providers: the exhaustion is a state, not an error code

Changes

ddiff --git a/docs/provider-adapters.md b/docs/provider-adapters.md
iindex 4a67697..fe6f139 100644
---- a/docs/provider-adapters.md
++++ b/docs/provider-adapters.md
@@@ -1,50 +1,87 @@
# Managed provider adapters (the Exchange remake, stage 2)
--OpenVibe.AI can buy capacity from nine OpenAI-compatible providers. They are ordinary registry providers of kind
++OpenVibe.AI can buy capacity from ten OpenAI-compatible providers. They are ordinary registry providers of kind
`openai` (the existing adapter in `server/providers/openai.js`): no second adapter, no second model list, no second
--price table. What is new is the catalogue of providers, models, prices and data policies
++price table. What is new is the catalogue of providers, models, prices, privacy labels and data policies
(`server/providers/pool-providers.js`) and its seed into the registry (`server/providers/pool-seed.js`).
## Providers
--| key | provider | base URL | default model | timeout |
--|---|---|---|---|---|
--| `qwen` | Qwen (Alibaba Model Studio, compatible mode) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen3.8-max` | 60 s |
--| `kimi` | Kimi (Moonshot) | `https://api.moonshot.ai/v1` | `kimi-k3.1-pro` | 60 s |
--| `glm` | GLM (Z.ai) | `https://api.z.ai/api/paas/v4` | `glm-5.3-flash` | 60 s |
--| `minimax` | MiniMax | `https://api.minimax.io/v1` | `MiniMax-M3.1-Flash` | 60 s |
--| `deepinfra` | DeepInfra | `https://api.deepinfra.com/v1/openai` | `deepseek-v4.1-flash-1017` | 90 s |
--| `novita` | Novita AI | `https://api.novita.ai/v3/openai` | `deepseek-v4.1-flash-1017` | 90 s |
--| `featherless` | Featherless AI | `https://api.featherless.ai/v1` | `Qwen/Qwen3-14B` | 90 s |
--| `refuseless` | Refuseless (the uncensored tier) | `https://api.refuseless.com/v1` | `deepseek-v4.1-flash-abliterated` | 60 s |
--| `openrouter` | OpenRouter (the last fallback) | `https://openrouter.ai/api/v1` | `openrouter/auto` | 120 s |
++| key | provider | base URL | default model | timeout | privacy |
++|---|---|---|---|---|---|
++| `qwen` | Qwen (Alibaba Model Studio, compatible mode) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen3.8-max` | 60 s | anonymized |
++| `kimi` | Kimi (Moonshot) | `https://api.moonshot.ai/v1` | `kimi-k3.1-pro` | 60 s | anonymized |
++| `glm` | GLM (Z.ai) | `https://api.z.ai/api/paas/v4` | `glm-5.3-flash` | 60 s | anonymized |
++| `minimax` | MiniMax | `https://api.minimax.io/v1` | `MiniMax-M3.1-Flash` | 60 s | anonymized |
++| `deepinfra` | DeepInfra | `https://api.deepinfra.com/v1/openai` | `deepseek-ai/DeepSeek-V4.1-Flash-1017` | 90 s | private |
++| `novita` | Novita AI | `https://api.novita.ai/v3/openai` | `deepseek/deepseek-v4.1-flash-1017` | 90 s | anonymized |
++| `featherless` | Featherless AI | `https://api.featherless.ai/v1` | `Qwen/Qwen3-14B` | 90 s | anonymized |
++| `refuseless` | Refuseless (the uncensored tier) | `https://api.refuseless.com/v1` | `deepseek-v4.1-flash-abliterated` | 60 s | anonymized |
++| `abliteration` | Abliteration.ai (the uncensored tier) | `https://api.abliteration.ai/v1` | `abliterated-model-large-flash` | 60 s | private |
++| `openrouter` | OpenRouter (the last fallback) | `https://openrouter.ai/api/v1` | `openrouter/auto` | 120 s | anonymized |
`server/workflows/seed.js` seeds them on every boot (idempotent: an owner-edited provider is never overwritten).
Each row's `secret_ref` is `vault:<key>`, so the owner's key goes in Network → owner settings → AI provider keys
(D11 `ai.provider_keys`), bound to the provider's origin. The pool skips a provider whose key does not resolve
(`no_credentials`): a provider without a key is never offered, called or billed. `GET /api/v1/providers`/the console
--show the nine; pasting a key turns the adapter on.
++show the ten; pasting a key turns the adapter on.
## Prices, models and the catalogue
--Every price carries the page it was read from and the day it was read (the price book, 2026-10-30; Watch's recorded
--lists, 2026-11-04). A model whose price we did not read is left out rather than invented. OpenRouter's hundreds of
--models are **not** seeded: the catalogue sync reads OpenRouter's own list with the owner's key
--(`server/providers/catalog.js` → `applyCatalogPrices`), which prices a row the catalogue knows and retires the
++Every price is read on the **provider's own page** — its pricing page or its own model list — never on an
++aggregator's. Each price carries that page in `source` and the day it was read in `checked` (2026-11-06). A price
++nobody publishes is `null`, never a guess: Refuseless publishes no price at all, so its models carry a null price
++rather than a number we cannot stand behind. A tiered price (Alibaba's `qwen3.7-flash`, MiniMax's `MiniMax-M3`)
++records the first tier, with the whole ladder written down in the model's `note`.
++
++OpenRouter's hundreds of models are **not** seeded: the catalogue sync reads OpenRouter's own list with the owner's
++key (`server/providers/catalog.js` → `applyCatalogPrices`), which prices a row the catalogue knows and retires the
seeded row when the provider lists the same model under its own id.
`streaming`, `tools`, `json` and `vision` are recorded per model; the OpenAI adapter already streams, calls tools and
--steps structured output down (`json_schema` → `json_object` → prose). Errors are the adapter's typed
--`ProviderError`s (`provider.auth`, `provider.rate_limited`, `provider.timeout`, `provider.output`, …), and the pool's
--circuit breaker and fallback chain handle them unchanged.
++steps structured output down (`json_schema` → `json_object` → prose). Two more fields travel with every model:
++
++- `quantization` — what the provider states the weights are served in. It is `null` unless the provider says so, and
++ none of the ten states it on the pages read on 2026-11-06;
++- `refusal_rate` — `null`. It stays null until a provider publishes its own rate or a measurement job of ours owns the
++ number. An invented rate is worse than none.
++
++## Privacy
++
++Each provider row carries a privacy label in `metadata.privacy`:
++
++- `private` — the provider's own terms say it keeps nothing and trains on nothing. The wording and the page it was
++ read from are recorded next to it. Today that is **DeepInfra** ("We will not store, sell, or train using this data
++ unless we have our explicit consent", plus "input data is not stored to disk during inference") and
++ **Abliteration.ai** ("Customer prompts, outputs, and attachments are not used to train our models. Prompts and
++ responses are processed transiently and are not stored.");
++- `anonymized` — everyone else, including the providers whose privacy page could not be read at all. Featherless is
++ here even though it says it does not store prompts or completions: its policy never says they are not trained on,
++ and the rule is that a provider is `private` only when its terms say both.
++
++The prompt is never published under the person's name, whoever the provider is.
## Data policy
--Each provider row records what the provider says it does with a prompt, in `metadata.data_policy`:
--`trains_on_prompts` (`no` / `not_stated` / `unknown` / `provider_dependent`), `retention`, `region`, `terms_url`,
--`resale`, `checked` and `source`. The pages are in `server/providers/pool-providers.js` next to each provider; the
--catalogue surfaces them on a model's page. A provider whose policy is unknown is still recorded as unknown, never as
--"no training".
++Each provider row records what the provider says it does with a prompt, in `metadata.data_policy`. The facts are the
++ones the house reads (`server/exchange/privacy.js`, `server/exchange/catalog.js`): `retention`
++(`none` / `limited` / `standard`), `trains` and `training` (a boolean), `region`, `anonymized` and `trust`
++(`first-party` / `community`). A fact the provider did not state is null, never a guess: an offer that declares
++nothing is labelled `anonymized` and says that it is unknown. Beside them the catalogue keeps its own record:
++`retention_note` (the provider's own words), `terms_url`, `resale`, `checked` and `source`. The pages are in
++`server/providers/pool-providers.js` next to each provider; the catalogue surfaces them on a model's page.
++
++These facts are what the label on every answer is built from: DeepInfra and Abliteration.ai keep nothing and train
++on nothing, so their answers are `private`; the rest are `anonymized`.
++
++## Errors
++
++Errors are the adapter's typed `ProviderError`s (`provider.auth`, `provider.rate_limited`, `provider.timeout`,
++`provider.output`, …), and the pool's circuit breaker and fallback chain handle them unchanged. One state has its own
++name: a provider that says the account has run out of money is **exhausted** (Refuseless answers 402
++`insufficient_credits`; the others say it in their own words). Reading that answer is the empty-failover work's,
++which is on main now (#315): it opens the long circuit, fails over and answers 503 `ai.resting`. This catalogue
++records the prices, the models and the labels; it does not repeat that circuit.
## Commercial access
@@@ -59,22 +96,8 @@ catalogue surfaces them on a model's page. A provider whose policy is unknown is
The owner raises a provider's access mode only after the agreement that allows it. Until then the provider can be
catalogued and its key stored, but the router does not resell it.
--## The uncensored tier
--
--`policy: "uncensored"` is accepted on `POST /v1/chat/completions`, `POST /v1/messages` and `POST /api/v1/runs` (with
--`on_behalf_of`). `server/policy/tier.js` admits it only when:
--
--1. the run names the person it is for (`on_behalf_of`, a `usr_` subject) — a developer app never gets it;
--2. OpenVibe.Network says that account confirmed it is 18+ (`GET /internal/identity/adult`;
-- `server/policy/adult-status.js` caches the answer five minutes and fails closed);
--3. the route has an offer marked `uncensored` (Refuseless, or any synced model whose id says `abliterated`);
--4. the hard floor (`server/policy/floor.js`) passes on the request **and** the answer: no sexual content involving
-- minors, no non-consensual sexual content of real people, no help with weapons of mass harm or attacks on others.
-- The floor applies to standard requests too. A streamed answer is checked before each chunk goes out.
--
--An uncensored answer carries the label `Uncensored model — adults only (18+)`: in the run's `output.label` and
--`output.policy`, in `x_openvibe.label`/`x_openvibe.policy` on both public APIs, and in the `X-OpenVibe-Content-Policy`
--header. A standard request never runs an uncensored offer, even when it is the cheapest.
++## The uncensored offers
--Network owns the flag: `users.adult_confirmed_at`, set by the person on `/my` → Content (18+ confirmation, with the
--hard floor stated), read by AI through `/internal/identity/adult` under `identity.subject.resolve`.
++Refuseless, Abliteration.ai and the abliterated models Featherless lists are marked `uncensored: true` in the
++catalogue. The tier that lets an 18+ account ask for such an offer — the gate, the hard floor and the label on every
++answer — is a separate change (its own PR); this one only records which offers are marked.
ddiff --git a/server/providers/pool-providers.js b/server/providers/pool-providers.js
iindex 1a14596..c0c98a1 100644
---- a/server/providers/pool-providers.js
++++ b/server/providers/pool-providers.js
@@@ -1,7 +1,7 @@
'use strict';
/**
* The providers OpenVibe buys capacity from (the Exchange remake, stage 2): Qwen (Alibaba Model Studio), Kimi
-- * (Moonshot), GLM (Z.ai), MiniMax, DeepInfra, Novita, Featherless, Refuseless and OpenRouter.
++ * (Moonshot), GLM (Z.ai), MiniMax, DeepInfra, Novita, Featherless, Refuseless, Abliteration.ai and OpenRouter.
*
* Each one is off until the owner puts its key in the owner settings (D11: `ai.provider_keys`, a sealed key bound to
* the provider's origin). The pool skips a provider whose key does not resolve (`no_credentials`), so a provider with
@@@ -11,47 +11,88 @@
*
* base_url, kind, timeout the provider's own API (its docs page). OpenAI-compatible mode for every one of them:
* DashScope's compatible-mode path, Moonshot's /v1, Z.ai's /api/paas/v4, MiniMax's /v1,
-- * DeepInfra's /v1/openai, Novita's /v3/openai, Featherless's /v1, Refuseless's /v1 and
-- * OpenRouter's /api/v1.
-- * models and prices the price book (research-2026-10-30/price-book/inference.csv, checked 2026-10-30) and
-- * OpenVibe.Watch's recorded official lists (2026-11-04) for DeepInfra and Featherless. Every
-- * price carries the page it was read from and the day it was read; a price we do not have
-- * is never invented: the model is left out of the catalog instead.
-- * data_policy what the provider says it does with a prompt (the price book's `data_policy` and
-- * `trains_on_prompts` columns), with the terms page and the day it was checked.
++ * DeepInfra's /v1/openai, Novita's /v3/openai, Featherless's /v1, Refuseless's /v1,
++ * Abliteration.ai's /v1 and OpenRouter's /api/v1. One adapter serves them all
++ * (server/providers/openai.js): streaming, tool calls, timeouts, error mapping.
++ * models and prices read on the provider's OWN page (its pricing page or its own model list), never on an
++ * aggregator's: the page is named in `source` and the day it was read in `checked`. A
++ * price nobody publishes is null, never a guess: Refuseless publishes no price at all,
++ * so its models carry null and the router ranks them by nothing until it does.
++ * privacy what the provider says it does with a prompt. `private` only when the provider's own
++ * terms say it keeps nothing and trains on nothing (the wording and the page are both
++ * recorded); `anonymized` for everyone else, including the providers whose privacy page
++ * could not be read at all.
++ * quantization what the provider states the weights are served in. Null unless the provider says so:
++ * none of the ten states it on the pages read on 2026-11-06.
++ * refusal_rate null. It stays null until a provider publishes its own rate, or until a measurement
++ * job of ours owns the number; an invented rate is worse than none.
++ * data_policy what the provider says it does with a prompt, in the facts the house reads (retention,
++ * trains, region, trust) with the terms page and the day it was checked. A fact the
++ * provider did not state is null, never a guess.
* provider_access the commercial gate (docs/provider-matrix.md, 2026-11-03): only OWN_DEPLOYMENT and
* AUTHORIZED_WHOLESALE may serve managed paid routes. UNVERIFIED and RESTRICTED offers
* stay out of the managed pool while the model catalogue is on; the owner raises a
* provider's access mode only after the agreement that allows it.
*
-- * The uncensored tier: Refuseless's abliterated models (and the abliterated models Featherless lists) are marked
-- * `uncensored: true`. Only an offer marked that way may answer a request with `policy: "uncensored"`, and only a
-- * request from an 18+ account may ask for one (server/policy/tier.js).
++ * The uncensored tier: the offers whose models are abliterated (Refuseless, Abliteration.ai, and the abliterated
++ * models Featherless lists) are marked `uncensored: true`. Only an offer marked that way may answer a request with
++ * `policy: "uncensored"`, and only a request from an 18+ account may ask for one (server/policy/tier.js; the tier
++ * is written down in docs/uncensored-policy.md).
*
* Prices change only by review: this file is edited like code, and the price it carries is the price the router
* ranks on and the balance is charged.
*/
--const PRICE_BOOK = { checked: '2026-10-30', file: 'research-2026-10-30/price-book/inference.csv' };
--const WATCH_LISTS = { checked: '2026-11-04', file: 'OpenVibe.Watch test/fixtures/provider-rates' };
++/** The day the prices, the privacy labels and the model lists below were read. */
++const CHECKED = '2026-11-06';
--/** The keys of the nine providers, in the order the router should prefer them when the price is equal. */
--const PROVIDER_KEYS = ['qwen', 'kimi', 'glm', 'minimax', 'deepinfra', 'novita', 'featherless', 'refuseless', 'openrouter'];
++/** The keys of the ten providers, in the order the router should prefer them when the price is equal. */
++const PROVIDER_KEYS = ['qwen', 'kimi', 'glm', 'minimax', 'deepinfra', 'novita', 'featherless', 'refuseless', 'abliteration', 'openrouter'];
/**
-- * The data policy of a provider, as the price book recorded it. `trains_on_prompts` is one of
-- * no | not_stated | yes | unknown; `retention` and `region` are free text (the price book's own words).
++ * What a provider says it does with a prompt.
++ *
++ * The first fields are the facts the house reads (server/exchange/privacy.js: `retention`, `training`, `region`,
++ * `trust`; server/exchange/catalog.js: `trains`, `retention_days`, `anonymized`). A fact the provider did not
++ * state is null, never a guess: an offer that declares nothing is labelled anonymized and says that it is unknown
++ * (server/exchange/privacy.js). The rest is the catalogue's own record of where the facts came from.
++ *
++ * retention 'none' | 'limited' | 'standard' | null (null: the provider does not say)
++ * trains true | false | null (null: the provider does not say)
++ * trust 'first-party' | 'community' | null
*/
const policy = (o) => ({
-- trains_on_prompts: o.trains || 'not_stated',
-- retention: o.retention || 'not stated',
-- region: o.region || 'not stated',
++ trains: o.trains === true ? true : (o.trains === false ? false : null),
++ training: o.trains === true ? true : (o.trains === false ? false : null),
++ retention: o.retention || null,
++ retention_days: o.retention_days == null ? null : o.retention_days,
++ region: o.region || null,
++ anonymized: o.anonymized === true ? true : (o.anonymized === false ? false : null),
++ trust: o.trust || null,
++ // The catalogue's own words about the same thing.
++ retention_note: o.retention_note || null,
terms_url: o.terms || null,
resale: o.resale || null,
-- checked: o.checked || PRICE_BOOK.checked,
++ checked: o.checked || CHECKED,
source: o.source || null,
});
++/**
++ * The privacy label of a provider.
++ *
++ * private the provider's own terms say it keeps nothing and trains on nothing. The wording and the page it
++ * was read from are both recorded, so the label can be checked again later.
++ * anonymized everything else: the provider may keep the prompt, may train on it, or says nothing at all (a page
++ * that could not be read counts as saying nothing). The prompt is never published under the person's
++ * name.
++ */
++const privacy = (o) => ({
++ label: o.label,
++ terms_url: o.terms_url || null,
++ wording: o.wording,
++ checked: o.checked || CHECKED,
++});
++
const PROVIDERS = [
{
key: 'qwen',
@@@ -62,25 +103,41 @@ const PROVIDERS = [
capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
provider_access: 'UNVERIFIED',
priority: 40,
++ privacy: privacy({
++ label: 'anonymized',
++ terms_url: 'https://www.alibabacloud.com/help/en/model-studio/model-pricing',
++ wording: 'no promise of zero retention or no training was found on Alibaba Cloud\'s own pages (the data security page returned an error page on 2026-11-06)',
++ }),
data_policy: policy({
-- trains: 'not_stated', retention: 'not stated', region: 'international (Singapore)',
++ trains: null, retention: null, region: 'singapore',
++ retention_note: 'the international deployment we call is in Singapore; nothing says what it keeps',
terms: 'https://www.alibabacloud.com/help/en/model-studio/model-pricing',
resale: 'no resale clause verified (the 2026-11-03 matrix does not list Alibaba)',
}),
models: [
{
model_key: 'qwen3.8-max', display_name: 'Qwen3.8-Max', family: 'qwen', context_window: 1048576,
-- cost: { in_per_mtok: 2.00, cached_per_mtok: 0.25, out_per_mtok: 6.00 },
-- source: 'https://www.alibabacloud.com/help/en/model-studio/model-pricing', checked: PRICE_BOOK.checked,
++ cost: { in_per_mtok: 2.00, cached_per_mtok: null, out_per_mtok: 6.00 },
++ source: 'https://www.alibabacloud.com/help/en/model-studio/model-pricing', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
-- note: 'flat across the context length; 50% off in batch',
++ quantization: null, refusal_rate: null,
++ note: 'the international (Singapore) price: one price for the whole 1M context; 50% off in batch. The table states no cache-hit price for this model, so the cached price stays null',
},
{
-- model_key: 'qwen3.7-flash', display_name: 'Qwen3.7-Flash', family: 'qwen', context_window: 1048576,
-- cost: { in_per_mtok: 0.03, cached_per_mtok: 0.006, out_per_mtok: 0.13 },
-- source: 'https://openrouter.ai/api/v1/models', checked: PRICE_BOOK.checked,
++ model_key: 'qwen3.8-flash', display_name: 'Qwen3.8-Flash', family: 'qwen', context_window: 1048576,
++ cost: { in_per_mtok: 0.15, cached_per_mtok: null, out_per_mtok: 0.47 },
++ source: 'https://www.alibabacloud.com/help/en/model-studio/model-pricing', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
-- note: 'the cheapest way to answer in this family',
++ quantization: null, refusal_rate: null,
++ note: 'the international price; the global deployment is $0.113 / $0.382',
++ },
++ {
++ model_key: 'qwen3.7-flash-2026-07-15', display_name: 'Qwen3.7-Flash', family: 'qwen', context_window: 1048576,
++ cost: { in_per_mtok: 0.030, cached_per_mtok: null, out_per_mtok: 0.130 },
++ source: 'https://www.alibabacloud.com/help/en/model-studio/model-pricing', checked: CHECKED,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ quantization: null, refusal_rate: null,
++ note: 'the price of the first tier (up to 32K tokens). The international ladder is $0.030 / $0.130 up to 32K, $0.100 / $0.400 to 256K and $0.200 / $0.800 to 1M; the catalogue holds the first tier and the ladder is written down here',
},
],
},
@@@ -93,8 +150,14 @@ const PROVIDERS = [
capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
provider_access: 'UNVERIFIED',
priority: 40,
++ privacy: privacy({
++ label: 'anonymized',
++ terms_url: 'https://platform.kimi.ai/docs/pricing/chat',
++ wording: 'Moonshot\'s own pages carry no promise of zero retention or no training (no readable privacy page was found on 2026-11-06)',
++ }),
data_policy: policy({
-- trains: 'not_stated', retention: 'not stated', region: 'not stated',
++ trains: null, retention: null, region: null,
++ retention_note: 'not stated on the provider\'s own pages',
terms: 'https://platform.kimi.ai/docs/pricing/chat',
resale: 'no resale clause verified (the 2026-11-03 matrix does not list Moonshot)',
}),
@@@ -102,16 +165,26 @@ const PROVIDERS = [
{
model_key: 'kimi-k3.1-pro', display_name: 'Kimi K3.1 Pro', family: 'kimi', context_window: 1048576,
cost: { in_per_mtok: 2.00, cached_per_mtok: 0.20, out_per_mtok: 10.00 },
-- source: 'https://platform.kimi.ai/docs/pricing/chat', checked: PRICE_BOOK.checked,
++ source: 'https://platform.kimi.ai/docs/pricing/chat', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
-- note: 'a million tokens of context; cache writes are billed by their time to live',
++ quantization: null, refusal_rate: null,
++ note: 'the cached price is the cache read (5 minute time to live by default); a cache write is billed at $2.00 with a 5 minute time to live and $4.00 with a 1 hour one; 60% off in batch',
++ },
++ {
++ model_key: 'kimi-k3.1-pro-highspeed', display_name: 'Kimi K3.1 Pro Highspeed', family: 'kimi', context_window: 1048576,
++ cost: { in_per_mtok: 4.00, cached_per_mtok: 0.20, out_per_mtok: 20.00 },
++ source: 'https://platform.kimi.ai/docs/pricing/chat', checked: CHECKED,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ quantization: null, refusal_rate: null,
++ note: 'the same model on the faster tier; a cache write is $4.00 with a 5 minute time to live and $8.00 with a 1 hour one',
},
{
model_key: 'kimi-k3', display_name: 'Kimi K3', family: 'kimi', context_window: 1048576,
cost: { in_per_mtok: 3.00, cached_per_mtok: 0.30, out_per_mtok: 15.00 },
-- source: 'https://www.together.ai/pricing', checked: PRICE_BOOK.checked,
++ source: 'https://platform.kimi.ai/docs/pricing/chat', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
-- note: 'the price the price book read from the host lists',
++ quantization: null, refusal_rate: null,
++ note: 'the cache write is $3.00 with a 5 minute time to live and $6.00 with a 1 hour one',
},
],
},
@@@ -124,42 +197,53 @@ const PROVIDERS = [
capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
provider_access: 'UNVERIFIED',
priority: 40,
++ privacy: privacy({
++ label: 'anonymized',
++ terms_url: 'https://docs.z.ai/guides/overview/pricing.md',
++ wording: 'Z.ai publishes no privacy policy page (the address returns its home page on 2026-11-06), so nothing says zero retention or no training',
++ }),
data_policy: policy({
-- trains: 'not_stated', retention: 'pay-as-you-go; not stated on the pricing page', region: 'not stated',
-- terms: 'https://docs.z.ai/guides/overview/pricing',
++ trains: null, retention: null, region: null,
++ retention_note: 'pay-as-you-go; not stated on the pricing page',
++ terms: 'https://docs.z.ai/guides/overview/pricing.md',
resale: 'no resale clause verified (the 2026-11-03 matrix does not list Z.ai)',
}),
models: [
{
model_key: 'glm-5.3', display_name: 'GLM-5.3', family: 'glm', context_window: 1048576,
cost: { in_per_mtok: 1.40, cached_per_mtok: 0.26, out_per_mtok: 4.40 },
-- source: 'https://docs.z.ai/guides/overview/pricing', checked: PRICE_BOOK.checked,
++ source: 'https://docs.z.ai/guides/overview/pricing.md', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
-- note: 'cached input is stored free for a limited time',
++ quantization: null, refusal_rate: null,
++ note: 'the cached input is stored free for a limited time',
},
{
model_key: 'glm-5.3-flash', display_name: 'GLM-5.3-Flash', family: 'glm', context_window: 1048576,
cost: { in_per_mtok: 0.15, cached_per_mtok: 0.03, out_per_mtok: 0.50 },
-- source: 'https://docs.z.ai/guides/overview/pricing', checked: PRICE_BOOK.checked,
++ source: 'https://docs.z.ai/guides/overview/pricing.md', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
++ quantization: null, refusal_rate: null,
},
{
model_key: 'glm-5.3-flashx', display_name: 'GLM-5.3-FlashX', family: 'glm', context_window: 1048576,
cost: { in_per_mtok: 0.37, cached_per_mtok: 0.075, out_per_mtok: 1.25 },
-- source: 'https://docs.z.ai/guides/overview/pricing', checked: PRICE_BOOK.checked,
++ source: 'https://docs.z.ai/guides/overview/pricing.md', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
++ quantization: null, refusal_rate: null,
},
{
-- model_key: 'glm-4.7', display_name: 'GLM-4.7', family: 'glm', context_window: 1048576,
-- cost: { in_per_mtok: 0.60, cached_per_mtok: 0.11, out_per_mtok: 2.20 },
-- source: 'https://docs.z.ai/guides/overview/pricing', checked: PRICE_BOOK.checked,
++ model_key: 'glm-4.7-flashx', display_name: 'GLM-4.7-FlashX', family: 'glm', context_window: 1048576,
++ cost: { in_per_mtok: 0.07, cached_per_mtok: 0.01, out_per_mtok: 0.40 },
++ source: 'https://docs.z.ai/guides/overview/pricing.md', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
++ quantization: null, refusal_rate: null,
},
{
model_key: 'glm-4.7-flash', display_name: 'GLM-4.7-Flash', family: 'glm', context_window: 1048576,
cost: { in_per_mtok: 0, cached_per_mtok: 0, out_per_mtok: 0 },
-- source: 'https://docs.z.ai/guides/overview/pricing', checked: PRICE_BOOK.checked,
++ source: 'https://docs.z.ai/guides/overview/pricing.md', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
++ quantization: null, refusal_rate: null,
note: 'free, rate limited',
},
],
@@@ -173,24 +257,32 @@ const PROVIDERS = [
capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
provider_access: 'UNVERIFIED',
priority: 40,
++ privacy: privacy({
++ label: 'anonymized',
++ terms_url: 'https://platform.minimax.io/docs/pricing.md',
++ wording: 'MiniMax publishes no readable privacy policy page (the address returns a not-found page on 2026-11-06), so nothing says zero retention or no training',
++ }),
data_policy: policy({
-- trains: 'not_stated', retention: 'pay-as-you-go; retention not stated on the pricing page', region: 'not stated',
-- terms: 'https://platform.minimax.io/docs/pricing',
++ trains: null, retention: null, region: null,
++ retention_note: 'pay-as-you-go; retention not stated on the pricing page',
++ terms: 'https://platform.minimax.io/docs/pricing.md',
resale: 'no resale clause verified (the 2026-11-03 matrix does not list MiniMax)',
}),
models: [
{
model_key: 'MiniMax-M3.1-Flash', display_name: 'MiniMax M3.1 Flash', family: 'minimax', context_window: 1048576,
cost: { in_per_mtok: 0.15, cached_per_mtok: 0.015, out_per_mtok: 0.60 },
-- source: 'https://platform.minimax.io/docs/pricing', checked: PRICE_BOOK.checked,
++ source: 'https://platform.minimax.io/docs/pricing.md', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
++ quantization: null, refusal_rate: null,
},
{
model_key: 'MiniMax-M3', display_name: 'MiniMax M3', family: 'minimax', context_window: 1048576,
cost: { in_per_mtok: 0.30, cached_per_mtok: 0.06, out_per_mtok: 1.20 },
-- source: 'https://platform.minimax.io/docs/pricing', checked: PRICE_BOOK.checked,
++ source: 'https://platform.minimax.io/docs/pricing.md', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
-- note: 'the price of the 512k input tier; the priority tier costs 1.5x (0.45 / 0.09 / 1.80)',
++ quantization: null, refusal_rate: null,
++ note: 'the standard tier up to 512K input tokens; over 512K it is $0.60 / $0.12 / $2.40, and the priority tier costs 1.5x',
},
],
},
@@@ -198,35 +290,68 @@ const PROVIDERS = [
key: 'deepinfra',
display_name: 'DeepInfra',
base_url: 'https://api.deepinfra.com/v1/openai',
-- default_model: 'deepseek-v4.1-flash-1017',
++ default_model: 'deepseek-ai/DeepSeek-V4.1-Flash-1017',
timeout_ms: 90000,
capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
provider_access: 'RESTRICTED',
priority: 30,
++ privacy: privacy({
++ label: 'private',
++ terms_url: 'https://deepinfra.com/privacy',
++ wording: '"We will not store, sell, or train using this data unless we have your explicit consent." (privacy policy, 2026-11-06) and "Input data is not stored to disk during inference - it exists only in memory while the request is being processed" (https://docs.deepinfra.com/account/data-privacy). Google and Anthropic models are the stated exceptions',
++ checked: CHECKED,
++ }),
data_policy: policy({
-- trains: 'no', retention: 'serves open-weight models; no training on prompts', region: 'not stated',
-- terms: 'https://deepinfra.com/terms',
++ trains: false, retention: 'none', trust: 'first-party', region: null,
++ retention_note: 'input data is not stored to disk during inference; output is deleted from memory after the answer',
++ terms: 'https://deepinfra.com/privacy',
resale: 'RESTRICTED (2026-11-03 matrix): "resell, sublicense, rent, distribute, or otherwise make the Services available to any third party except as expressly permitted under this Agreement or the applicable Service Order" (Terms §11(a)(viii), modified 2026-10-15)',
}),
models: [
{
-- model_key: 'deepseek-v4.1-flash-1017', display_name: 'DeepSeek V4.1 Flash 1017', family: 'deepseek', context_window: 1048576,
-- cost: { in_per_mtok: 0.098, cached_per_mtok: 0.00294, out_per_mtok: 0.294 },
-- source: 'https://router.requesty.ai/v1/models', checked: PRICE_BOOK.checked,
++ model_key: 'deepseek-ai/DeepSeek-V4.1-Flash-1017', display_name: 'DeepSeek V4.1 Flash 1017', family: 'deepseek', context_window: 1048576,
++ cost: { in_per_mtok: 0.20, cached_per_mtok: 0.006, out_per_mtok: 0.60 },
++ source: 'https://api.deepinfra.com/v1/models', checked: CHECKED,
++ supports: { json: true, tools: true, streaming: true, vision: true },
++ quantization: null, refusal_rate: null,
++ note: "the provider's own id and price; the list marks the model as a vision model (vlm)",
++ },
++ {
++ model_key: 'deepseek-ai/DeepSeek-V4-Flash', display_name: 'DeepSeek V4 Flash', family: 'deepseek', context_window: 1048576,
++ cost: { in_per_mtok: 0.09, cached_per_mtok: 0.018, out_per_mtok: 0.18 },
++ source: 'https://api.deepinfra.com/v1/models', checked: CHECKED,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ quantization: null, refusal_rate: null,
++ },
++ {
++ model_key: 'zai-org/GLM-5.3', display_name: 'GLM-5.3', family: 'glm', context_window: 1048576,
++ cost: { in_per_mtok: 0.90, cached_per_mtok: 0.20, out_per_mtok: 4.00 },
++ source: 'https://api.deepinfra.com/v1/models', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
-- note: 'the cheapest route the price book found; the priority variant costs 15x',
++ quantization: null, refusal_rate: null,
},
{
-- model_key: 'deepseek-v4-flash', display_name: 'DeepSeek V4 Flash', family: 'deepseek', context_window: 1048576,
-- cost: { in_per_mtok: 0.09, cached_per_mtok: 0.009, out_per_mtok: 0.18 },
-- source: 'https://api.deepinfra.com/v1/models', checked: PRICE_BOOK.checked,
++ model_key: 'Qwen/Qwen3.8-Max', display_name: 'Qwen3.8-Max', family: 'qwen', context_window: 256000,
++ cost: { in_per_mtok: 1.65, cached_per_mtok: 0.206, out_per_mtok: 4.951 },
++ source: 'https://api.deepinfra.com/v1/models', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
++ quantization: null, refusal_rate: null,
},
{
-- model_key: 'gpt-oss-120b', display_name: 'GPT-OSS 120B', family: 'gpt-oss', context_window: 131072,
-- cost: { in_per_mtok: 0.037, cached_per_mtok: 0.0037, out_per_mtok: 0.17 },
-- source: 'https://api.deepinfra.com/v1/models', checked: PRICE_BOOK.checked,
++ model_key: 'openai/gpt-oss-120b', display_name: 'GPT-OSS 120B', family: 'gpt-oss', context_window: 131072,
++ cost: { in_per_mtok: 0.037, cached_per_mtok: null, out_per_mtok: 0.17 },
++ source: 'https://api.deepinfra.com/v1/models', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
++ quantization: null, refusal_rate: null,
++ note: 'the list states no cache-read price for this model, so the cached price stays null',
++ },
++ {
++ model_key: 'Qwen/Qwen3-14B', display_name: 'Qwen3 14B', family: 'qwen', context_window: 40960,
++ cost: { in_per_mtok: 0.12, cached_per_mtok: null, out_per_mtok: 0.24 },
++ source: 'https://api.deepinfra.com/v1/models', checked: CHECKED,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ quantization: null, refusal_rate: null,
++ note: 'the list states no cache-read price for this model, so the cached price stays null',
},
],
},
@@@ -234,46 +359,59 @@ const PROVIDERS = [
key: 'novita',
display_name: 'Novita AI',
base_url: 'https://api.novita.ai/v3/openai',
-- default_model: 'deepseek-v4.1-flash-1017',
++ default_model: 'deepseek/deepseek-v4.1-flash-1017',
timeout_ms: 90000,
capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
provider_access: 'UNVERIFIED',
priority: 30,
++ privacy: privacy({
++ label: 'anonymized',
++ terms_url: 'https://api.novita.ai/v3/openai/models',
++ wording: 'Novita has no readable privacy policy page (the address returns a not-found page on 2026-11-06), so nothing says zero retention or no training',
++ }),
data_policy: policy({
-- trains: 'no', retention: 'no training on prompts stated', region: 'not stated',
++ trains: null, retention: null, region: null,
++ retention_note: 'not stated (the provider has no readable privacy page)',
terms: 'https://api.novita.ai/v3/openai/models',
resale: 'UNVERIFIED (2026-11-03 matrix): the terms page returned 404',
}),
++
models: [
{
-- model_key: 'deepseek-v4.1-flash-1017', display_name: 'DeepSeek V4.1 Flash 1017', family: 'deepseek', context_window: 1048576,
++ model_key: 'deepseek/deepseek-v4.1-flash-1017', display_name: 'DeepSeek V4.1 Flash 1017', family: 'deepseek', context_window: 1048576,
cost: { in_per_mtok: 0.30, cached_per_mtok: 0.006, out_per_mtok: 1.20 },
-- source: 'https://api.novita.ai/v3/openai/models', checked: PRICE_BOOK.checked,
++ source: 'https://api.novita.ai/v3/openai/models', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
++ quantization: null, refusal_rate: null,
++ note: 'the list marks the model as serverless with function calling, structured outputs and reasoning',
},
{
-- model_key: 'kimi-k3.1-pro', display_name: 'Kimi K3.1 Pro', family: 'kimi', context_window: 1048576,
-- cost: { in_per_mtok: 2.00, cached_per_mtok: 0.20, out_per_mtok: 10.00 },
-- source: 'https://api.novita.ai/v3/openai/models', checked: PRICE_BOOK.checked,
++ model_key: 'qwen/qwen3.8-max', display_name: 'Qwen3.8-Max', family: 'qwen', context_window: 1000000,
++ cost: { in_per_mtok: 2.00, cached_per_mtok: 0.25, out_per_mtok: 6.00 },
++ source: 'https://api.novita.ai/v3/openai/models', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
++ quantization: null, refusal_rate: null,
},
{
-- model_key: 'glm-5.3', display_name: 'GLM-5.3', family: 'glm', context_window: 1048576,
++ model_key: 'zai-org/glm-5.3', display_name: 'GLM-5.3', family: 'glm', context_window: 1048576,
cost: { in_per_mtok: 1.40, cached_per_mtok: 0.26, out_per_mtok: 4.40 },
-- source: 'https://api.novita.ai/v3/openai/models', checked: PRICE_BOOK.checked,
++ source: 'https://api.novita.ai/v3/openai/models', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
++ quantization: null, refusal_rate: null,
},
{
-- model_key: 'qwen3.8-max', display_name: 'Qwen3.8-Max', family: 'qwen', context_window: 1048576,
-- cost: { in_per_mtok: 2.00, cached_per_mtok: 0.25, out_per_mtok: 6.00 },
-- source: 'https://api.novita.ai/v3/openai/models', checked: PRICE_BOOK.checked,
++ model_key: 'moonshotai/kimi-k3.1-pro', display_name: 'Kimi K3.1 Pro', family: 'kimi', context_window: 1048576,
++ cost: { in_per_mtok: 2.00, cached_per_mtok: 0.20, out_per_mtok: 10.00 },
++ source: 'https://api.novita.ai/v3/openai/models', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
++ quantization: null, refusal_rate: null,
},
{
-- model_key: 'mimo-v2.6-flash', display_name: 'MiMo V2.6 Flash', family: 'mimo', context_window: 1048576,
++ model_key: 'xiaomimimo/mimo-v2.6-flash', display_name: 'MiMo V2.6 Flash', family: 'mimo', context_window: 1048576,
cost: { in_per_mtok: 0.14, cached_per_mtok: 0.0028, out_per_mtok: 0.28 },
-- source: 'https://api.novita.ai/v3/openai/models', checked: PRICE_BOOK.checked,
++ source: 'https://api.novita.ai/v3/openai/models', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
++ quantization: null, refusal_rate: null,
},
],
},
@@@ -286,33 +424,58 @@ const PROVIDERS = [
capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
provider_access: 'AUTHORIZED_WHOLESALE',
priority: 30,
++ privacy: privacy({
++ label: 'anonymized',
++ terms_url: 'https://featherless.ai/privacy',
++ wording: '"We do not collect or store prompts or completions." (privacy policy, last updated 2024-06-10, read 2026-11-06): the retention is zero, but the policy never says the prompts are not trained on, so the label stays anonymized',
++ checked: CHECKED,
++ }),
data_policy: policy({
-- trains: 'not_stated', retention: 'not stated', region: 'not stated',
-- terms: 'https://featherless.ai/terms',
++ trains: null, retention: 'none', trust: 'first-party', region: null,
++ retention_note: 'prompts and completions are not collected or stored; training is not mentioned',
++ terms: 'https://featherless.ai/privacy',
resale: 'AUTHORIZED_WHOLESALE, Scale plans only (2026-11-03 matrix): "Scale plans are not subject to said limits and may be used in arbitrary applications, including inference resale." (Terms §3, last updated 2024-06-10). It needs an executed Scale agreement before the managed pool may use it.',
-- checked: WATCH_LISTS.checked,
}),
models: [
{
model_key: 'Qwen/Qwen3-14B', display_name: 'Qwen3 14B', family: 'qwen', context_window: 32768,
-- cost: { in_per_mtok: 0.12, cached_per_mtok: 0.012, out_per_mtok: 0.24 },
-- source: 'https://api.featherless.ai/v1/models', checked: WATCH_LISTS.checked,
++ cost: { in_per_mtok: 0.12, cached_per_mtok: null, out_per_mtok: 0.24 },
++ source: 'https://api.featherless.ai/v1/models', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
-- note: 'Featherless states its price per token; the catalog holds it per million',
++ quantization: null, refusal_rate: null,
++ note: 'the list states a price per token; the catalogue holds it per million. No cache-read price is stated',
},
{
model_key: 'Qwen/Qwen3-0.6B', display_name: 'Qwen3 0.6B', family: 'qwen', context_window: 32768,
-- cost: { in_per_mtok: 0.04, cached_per_mtok: 0.004, out_per_mtok: 0.08 },
-- source: 'https://api.featherless.ai/v1/models', checked: WATCH_LISTS.checked,
++ cost: { in_per_mtok: 0.04, cached_per_mtok: null, out_per_mtok: 0.08 },
++ source: 'https://api.featherless.ai/v1/models', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
++ quantization: null, refusal_rate: null,
},
{
model_key: 'meta-llama/Meta-Llama-3.1-8B-Instruct', display_name: 'Llama 3.1 8B Instruct', family: 'llama', context_window: 32768,
-- cost: { in_per_mtok: 0.05, cached_per_mtok: 0.005, out_per_mtok: 0.08 },
-- source: 'https://api.featherless.ai/v1/models', checked: WATCH_LISTS.checked,
++ cost: { in_per_mtok: 0.05, cached_per_mtok: null, out_per_mtok: 0.08 },
++ source: 'https://api.featherless.ai/v1/models', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
++ quantization: null, refusal_rate: null,
note: 'gated on Featherless: the price is published, the model needs the account to be let in',
},
++ {
++ model_key: 'mlabonne/Meta-Llama-3.1-8B-Instruct-abliterated', display_name: 'Llama 3.1 8B Instruct (abliterated)', family: 'llama', context_window: 32768,
++ cost: { in_per_mtok: 0.05, cached_per_mtok: null, out_per_mtok: 0.08 },
++ source: 'https://api.featherless.ai/v1/models', checked: CHECKED,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ quantization: null, refusal_rate: null, uncensored: true,
++ note: 'an abliterated model is an uncensored offer by its name',
++ },
++ {
++ model_key: 'zetasepic/Qwen2.5-72B-Instruct-abliterated', display_name: 'Qwen2.5 72B Instruct (abliterated)', family: 'qwen', context_window: 32768,
++ cost: { in_per_mtok: 1.48, cached_per_mtok: null, out_per_mtok: 1.60 },
++ source: 'https://api.featherless.ai/v1/models', checked: CHECKED,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ quantization: null, refusal_rate: null, uncensored: true,
++ note: 'an abliterated model is an uncensored offer by its name',
++ },
],
},
{
@@@ -325,34 +488,114 @@ const PROVIDERS = [
provider_access: 'UNVERIFIED',
priority: 20,
uncensored: true,
++ privacy: privacy({
++ label: 'anonymized',
++ terms_url: 'https://docs.refuseless.com/api/models.md',
++ wording: 'Refuseless\'s privacy page could not be read (a bot check answers it on 2026-11-06), so nothing says zero retention or no training',
++ }),
data_policy: policy({
-- trains: 'unknown', retention: 'unknown', region: 'unknown',
-- terms: 'https://refuseless.com/pricing',
++ trains: null, retention: null, region: null,
++ retention_note: 'unknown: the provider\'s privacy page answers a bot check',
++ terms: 'https://docs.refuseless.com/api/models.md',
resale: 'UNVERIFIED (2026-11-03 matrix): no terms URL was located',
-- checked: '2026-10-25',
}),
models: [
{
model_key: 'deepseek-v4.1-flash-abliterated', display_name: 'DeepSeek V4.1 Flash (abliterated)', family: 'deepseek', context_window: 1048576,
-- cost: { in_per_mtok: 0.22, cached_per_mtok: 0.045, out_per_mtok: 0.75 },
-- source: 'https://refuseless.com/pricing', checked: '2026-10-25',
++ cost: { in_per_mtok: null, cached_per_mtok: null, out_per_mtok: null },
++ source: 'https://docs.refuseless.com/api/models.md', checked: CHECKED,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ quantization: null, refusal_rate: null, uncensored: true,
++ note: 'Refuseless publishes no price (its pricing page answers a bot check), so the price stays null rather than guessed',
++ },
++ {
++ model_key: 'deepseek-v4.1-flash-abliterated-free', display_name: 'DeepSeek V4.1 Flash (abliterated, free)', family: 'deepseek', context_window: 524288,
++ cost: { in_per_mtok: null, cached_per_mtok: null, out_per_mtok: null },
++ source: 'https://docs.refuseless.com/api/models.md', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
-- uncensored: true,
-- note: 'the price LiteLLM read on 2026-10-25; the price book could not read the catalogue (401)',
++ quantization: null, refusal_rate: null, uncensored: true,
++ note: 'the free model of the lineup; the price stays null, the name says free',
},
{
model_key: 'glm-5.3-flash-abliterated', display_name: 'GLM 5.3 Flash (abliterated)', family: 'glm', context_window: 1048576,
-- cost: { in_per_mtok: 0.25, cached_per_mtok: 0.15, out_per_mtok: 0.80 },
-- source: 'https://refuseless.com/pricing', checked: '2026-10-25',
++ cost: { in_per_mtok: null, cached_per_mtok: null, out_per_mtok: null },
++ source: 'https://docs.refuseless.com/api/models.md', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
-- uncensored: true,
++ quantization: null, refusal_rate: null, uncensored: true,
++ note: 'no published price',
},
{
model_key: 'glm-5.3-abliterated', display_name: 'GLM 5.3 (abliterated)', family: 'glm', context_window: 1048576,
-- cost: { in_per_mtok: 1.25, cached_per_mtok: 0.25, out_per_mtok: 2.25 },
-- source: 'https://refuseless.com/pricing', checked: '2026-10-25',
++ cost: { in_per_mtok: null, cached_per_mtok: null, out_per_mtok: null },
++ source: 'https://docs.refuseless.com/api/models.md', checked: CHECKED,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ quantization: null, refusal_rate: null, uncensored: true,
++ note: 'no published price',
++ },
++ {
++ model_key: 'deepseek-v4.1-flash-fast', display_name: 'DeepSeek V4.1 Flash Fast', family: 'deepseek', context_window: 1048576,
++ cost: { in_per_mtok: null, cached_per_mtok: null, out_per_mtok: null },
++ source: 'https://docs.refuseless.com/api/models.md', checked: CHECKED,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ quantization: null, refusal_rate: null,
++ note: 'the standard weights (abliterated: false); no published price',
++ },
++ {
++ model_key: 'minimax-m3.1-flash', display_name: 'MiniMax M3.1 Flash', family: 'minimax', context_window: 1048576,
++ cost: { in_per_mtok: null, cached_per_mtok: null, out_per_mtok: null },
++ source: 'https://docs.refuseless.com/api/models.md', checked: CHECKED,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ quantization: null, refusal_rate: null,
++ note: 'the standard weights (abliterated: false); no published price',
++ },
++ ],
++ },
++ {
++ key: 'abliteration',
++ display_name: 'Abliteration.ai (the uncensored tier)',
++ base_url: 'https://api.abliteration.ai/v1',
++ default_model: 'abliterated-model-large-flash',
++ timeout_ms: 60000,
++ capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
++ provider_access: 'UNVERIFIED',
++ priority: 20,
++ uncensored: true,
++ privacy: privacy({
++ label: 'private',
++ terms_url: 'https://docs.abliteration.ai/security-and-compliance',
++ wording: '"Customer prompts, outputs, and attachments are not used to train our models. Prompts and responses are processed transiently and are not stored." (security and compliance page, read 2026-11-06; privacy policy at https://abliteration.ai/privacy-policy)',
++ checked: CHECKED,
++ }),
++ data_policy: policy({
++ trains: false, retention: 'none', trust: 'first-party', region: null,
++ retention_note: 'processed transiently and not stored; operational metadata is kept for billing, security and reliability',
++ terms: 'https://docs.abliteration.ai/security-and-compliance',
++ resale: 'no resale clause verified (the 2026-11-03 matrix does not list Abliteration.ai)',
++ }),
++ models: [
++ {
++ model_key: 'abliterated-model-large-flash', display_name: 'Abliterated Large Flash', family: 'abliterated', context_window: 1048576, max_output: 262144,
++ cost: { in_per_mtok: 1.00, cached_per_mtok: 0.10, out_per_mtok: 2.00 },
++ source: 'https://docs.abliteration.ai/pricing.md', checked: CHECKED,
++ supports: { json: true, tools: true, streaming: true, vision: true },
++ quantization: null, refusal_rate: null, uncensored: true,
++ note: 'the cache-read price is 10% of the input price, as the pricing page states; the model takes image input',
++ },
++ {
++ model_key: 'abliterated-model', display_name: 'Abliterated Model', family: 'abliterated', context_window: 262144, max_output: 262134,
++ cost: { in_per_mtok: 1.00, cached_per_mtok: 0.10, out_per_mtok: 3.00 },
++ source: 'https://docs.abliteration.ai/pricing.md', checked: CHECKED,
++ supports: { json: true, tools: true, streaming: true, vision: true },
++ quantization: null, refusal_rate: null, uncensored: true,
++ note: 'the cache-read price is 10% of the input price; the model takes image input',
++ },
++ {
++ model_key: 'abliterated-model-large-v2', display_name: 'Abliterated Large v2', family: 'abliterated', context_window: 1000000, max_output: 999990,
++ cost: { in_per_mtok: 3.00, cached_per_mtok: 0.30, out_per_mtok: 5.00 },
++ source: 'https://docs.abliteration.ai/pricing.md', checked: CHECKED,
supports: { json: true, tools: true, streaming: true, vision: false },
-- uncensored: true,
++ quantization: null, refusal_rate: null, uncensored: true,
++ note: 'text only (an image request is answered with a 400); the cache-read price is 10% of the input price',
},
],
},
@@@ -366,9 +609,15 @@ const PROVIDERS = [
provider_access: 'UNVERIFIED',
priority: 90,
role: 'fallback',
++ privacy: privacy({
++ label: 'anonymized',
++ terms_url: 'https://openrouter.ai/terms',
++ wording: '"Some Models may store or train on your Inputs for improving their own large language models" (terms of service, read 2026-11-06); OpenRouter itself opts out where it can',
++ }),
data_policy: policy({
-- trains: 'provider_dependent', retention: 'prompts and completions are not logged by default; the providers that log are not routed unless the training toggle is on',
-- region: 'not stated', terms: 'https://openrouter.ai/terms',
++ trains: true, retention: 'standard', trust: 'first-party', region: null,
++ retention_note: 'prompts and completions are not logged by default; some models may store or train on them',
++ terms: 'https://openrouter.ai/terms',
resale: 'UNVERIFIED (2026-11-03 matrix does not list OpenRouter). The price book (2026-10-30) records that its terms contemplate selling capacity inside your own product; that is not the same as an executed agreement.',
}),
// OpenRouter lists hundreds of models and prices each one itself; the catalogue sync reads its own list with
@@@ -379,7 +628,7 @@ const PROVIDERS = [
const byKey = new Map(PROVIDERS.map((p) => [p.key, p]));
--/** The name a provider's model is known by in the price book, without its namespace: `deepseek/deepseek-chat` -> `deepseek-chat`. */
++/** The name a provider's model is known by, without its namespace: `deepseek/deepseek-chat` -> `deepseek-chat`. */
const leaf = (id) => String(id || '').split('/').pop().toLowerCase();
/**
@@@ -406,4 +655,4 @@ function isUncensored(provider, model) {
return /abliterated|uncensored/i.test(id);
}
--module.exports = { PROVIDERS, PROVIDER_KEYS, PRICE_BOOK, WATCH_LISTS, byKey, priceOf, isUncensored, leaf };
++module.exports = { PROVIDERS, PROVIDER_KEYS, CHECKED, byKey, priceOf, isUncensored, leaf };
ddiff --git a/server/providers/pool-seed.js b/server/providers/pool-seed.js
iindex 3ce79ec..d82bf86 100644
---- a/server/providers/pool-seed.js
++++ b/server/providers/pool-seed.js
@@@ -7,9 +7,9 @@
* key is never offered, never called and never costs anything. The key slot itself appears in the owner's settings
* because the store's schema is built from the registry's providers (server/settings.js).
*
-- * The models carry the price the price book recorded, with the page it was read from and the day it was read. A row
-- * the owner has since edited keeps its price: the catalogue only fills a price that is missing or that the catalogue
-- * itself wrote (its own source).
++ * The models carry the price the provider's own page gave, with that page and the day it was read. A price nobody
++ * publishes is null, never a guess. A row the owner has since edited keeps its price: the catalogue only fills a
++ * price that is missing or that the catalogue itself wrote (its own source).
*
* The provider's own model list is the authority for the exact id (the catalogue sync, server/providers/catalog.js):
* when the owner syncs a provider, `applyCatalogPrices` gives the provider's own id the price the catalogue recorded
@@@ -28,6 +28,9 @@ function recordOf(p) {
metadata: {
catalog: 'pool-providers',
data_policy: p.data_policy,
++ // The privacy label of the provider: `private` only where its own terms say it keeps nothing and trains on
++ // nothing, `anonymized` for everyone else. The wording and the page it was read from travel with it.
++ privacy: p.privacy,
...(p.uncensored ? { uncensored: true } : {}),
...(p.role ? { role: p.role } : {}),
},
@@@ -39,11 +42,17 @@ function modelOf(p, m) {
const metadata = {
catalog: 'pool-providers',
family: m.family || null,
-- id_source: 'price-book',
++ // The price came from the provider's own page, not from an aggregator's.
++ id_source: 'provider-page',
checked: m.checked || null,
++ // What the provider states the weights are served in. Null unless it says so.
++ quantization: m.quantization == null ? null : m.quantization,
++ // The rate at which this model refuses. Null until a provider publishes its own or a measurement job owns it.
++ refusal_rate: m.refusal_rate == null ? null : m.refusal_rate,
...(m.note ? { note: m.note } : {}),
...(m.uncensored ? { uncensored: true } : {}),
data_policy: p.data_policy,
++ privacy: p.privacy,
};
return {
provider_key: p.key, model_key: m.model_key, display_name: m.display_name || m.model_key,
ddiff --git a/server/workflows/seed.js b/server/workflows/seed.js
iindex 08f79ae..bb768e0 100644
---- a/server/workflows/seed.js
++++ b/server/workflows/seed.js
@@@ -135,9 +135,9 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
}
}
// The providers OpenVibe buys managed capacity from (the Exchange remake, stage 2): Qwen, Kimi, GLM, MiniMax,
-- // DeepInfra, Novita, Featherless, Refuseless and OpenRouter. Each is registered off until its key is in the
-- // owner settings; the pool skips one whose key does not resolve. Prices come from the price book, with their
-- // page and the day they were read (server/providers/pool-providers.js).
++ // DeepInfra, Novita, Featherless, Refuseless, Abliteration.ai and OpenRouter. Each is registered off until its key
++ // is in the owner settings; the pool skips one whose key does not resolve. Prices come from each provider's own
++ // page, with that page and the day they were read (server/providers/pool-providers.js).
await poolSeed.seedPoolProviders({ registry, actor: 'seed' });
const shared = await registry.getProvider('shared');
ddiff --git a/test/ai-exchange-providers.test.js b/test/ai-exchange-providers.test.js
iindex 898366f..03ca6bc 100644
---- a/test/ai-exchange-providers.test.js
++++ b/test/ai-exchange-providers.test.js
@@@ -1,22 +1,19 @@
'use strict';
/**
-- * The Exchange remake's managed providers and uncensored tier.
++ * The Exchange remake's uncensored tier.
*
-- * No test reaches the network: the nine providers' recorded Chat Completions shapes live in
-- * test/fixtures/pool-providers.json and are replayed against a local seam; Network's adult flag is a fake fetch. The
-- * boot test proves the providers are seeded off (no key), that a standard request never runs an uncensored offer,
-- * and that an uncensored request is admitted only for an 18+ account, labelled, and stopped by the hard floor.
++ * No test reaches the network: Network's adult flag is a fake fetch and the provider is a local seam. The boot test
++ * proves that a standard request never runs an uncensored offer, and that an uncensored request is admitted only for
++ * an 18+ account, labelled, and stopped by the hard floor. The catalogue itself is tested in
++ * test/ai-provider-adapters.test.js.
*/
const assert = require('assert');
--const fixtures = require('./fixtures/pool-providers.json');
const { boot, seamServer, request, suite, token, ALL } = require('./helpers');
const pool = require('../server/providers/pool-providers');
--const seed = require('../server/providers/pool-seed');
const floor = require('../server/policy/floor');
const tier = require('../server/policy/tier');
const { createFloorGuard } = require('../server/providers');
const { createAdultStatus } = require('../server/policy/adult-status');
--const { createOpenAiProvider } = require('../server/providers/openai');
const USR = (n) => `usr_01J${String(n).padStart(23, '0')}`;
const ADULT = USR(1);
@@@ -24,79 +21,6 @@ const CHILD = USR(2);
const t = suite('ai-exchange-providers');
--t.test('nine providers, each OpenAI-compatible with a base URL, timeout, access and data policy', () => {
-- assert.deepStrictEqual(pool.PROVIDERS.map((p) => p.key), ['qwen', 'kimi', 'glm', 'minimax', 'deepinfra', 'novita', 'featherless', 'refuseless', 'openrouter']);
-- for (const p of pool.PROVIDERS) {
-- assert.ok(/^https:\/\//.test(p.base_url), `${p.key} base_url`);
-- assert.ok(p.timeout_ms >= 1000, `${p.key} timeout`);
-- assert.ok(['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE', 'CUSTOMER_BYOK', 'RESTRICTED', 'UNVERIFIED'].includes(p.provider_access), p.key);
-- assert.ok(p.data_policy && typeof p.data_policy.trains_on_prompts === 'string', `${p.key} data policy`);
-- assert.ok(p.data_policy.terms_url && p.data_policy.checked, `${p.key} terms + checked`);
-- for (const m of p.models) {
-- assert.ok(Number.isFinite(m.cost.in_per_mtok) && Number.isFinite(m.cost.out_per_mtok), `${p.key}/${m.model_key} price`);
-- assert.ok(m.source && m.checked, `${p.key}/${m.model_key} price source + day`);
-- assert.ok(m.supports && m.supports.streaming === true, `${p.key}/${m.model_key} streaming`);
-- }
-- }
-- // Featherless has no abliterated model with a price we read, so none is invented; its own list is read on sync
-- // and a synced id that says abliterated is an uncensored offer by name (pool.isUncensored).
-- assert.strictEqual(pool.isUncensored(pool.byKey.get('featherless'), { model_key: 'Qwen/Qwen3-14B' }), false);
-- assert.strictEqual(pool.isUncensored(pool.byKey.get('featherless'), { model_key: 'some/Model-32B-abliterated' }), true);
--});
--
--t.test('Refuseless carries the two uncensored flash models at the price the owner named', () => {
-- const r = pool.byKey.get('refuseless');
-- assert.strictEqual(r.uncensored, true);
-- const deep = pool.priceOf('refuseless', 'deepseek-v4.1-flash-abliterated');
-- const glm = pool.priceOf('refuseless', 'glm-5.3-flash-abliterated');
-- assert.deepStrictEqual([deep.cost.in_per_mtok, deep.cost.out_per_mtok], [0.22, 0.75]);
-- assert.deepStrictEqual([glm.cost.in_per_mtok, glm.cost.out_per_mtok], [0.25, 0.80]);
-- assert.strictEqual(pool.isUncensored(r, deep), true);
-- assert.strictEqual(pool.isUncensored(pool.byKey.get('qwen'), null), false);
--});
--
--t.test('the catalog seed records each provider off-until-key and keeps a person\'s own row', async () => {
-- const saved = new Map();
-- const models = new Map();
-- const registry = {
-- getProvider: async (key) => saved.get(key) || null,
-- upsertProvider: async (row) => { saved.set(row.key, { ...row, metadata: row.metadata || {} }); },
-- getModel: async (p, m) => models.get(`${p}/${m}`) || null,
-- upsertModel: async (row) => { models.set(`${row.provider_key}/${row.model_key}`, row); },
-- };
-- const out = await seed.seedPoolProviders({ registry });
-- assert.strictEqual(out.providers.length, 9);
-- assert.strictEqual(saved.get('refuseless').secret_ref, 'vault:refuseless');
-- assert.strictEqual(saved.get('refuseless').metadata.uncensored, true);
-- assert.strictEqual(models.get('refuseless/deepseek-v4.1-flash-abliterated').metadata.uncensored, true);
-- assert.strictEqual(models.get('refuseless/deepseek-v4.1-flash-abliterated').cost.in_per_mtok, 0.22);
-- // The owner edits a provider: a second seed never touches its row.
-- saved.set('qwen', { ...saved.get('qwen'), origin: 'admin', base_url: 'https://proxy.example/v1' });
-- const again = await seed.seedPoolProviders({ registry });
-- assert.ok(again.skipped.includes('qwen'));
-- assert.strictEqual(saved.get('qwen').base_url, 'https://proxy.example/v1');
--});
--
--t.test('the recorded shape of every provider is parsed by the OpenAI adapter, tool calls included', async () => {
-- for (const key of pool.PROVIDER_KEYS) {
-- const recorded = fixtures[key];
-- assert.ok(recorded && recorded.completion, `${key} fixture`);
-- const srv = await seamServer(() => recorded.completion);
-- try {
-- const a = createOpenAiProvider({ key, base_url: srv.url, capabilities: [] }, { apiKey: 'k' });
-- const req = { system: [{ text: 'be brief' }], messages: [{ role: 'user', content: 'hi' }], maxTokens: 40, timeoutMs: 3000, model: recorded.model };
-- const r = await a.chat(req);
-- const c = recorded.completion.choices[0].message;
-- assert.strictEqual(r.text, (c.content || '').trim(), `${key} text`);
-- assert.strictEqual(r.model, recorded.model, `${key} model`);
-- assert.ok(r.usage.input >= 0 && r.usage.output >= 0, `${key} usage`);
-- const full = await a.complete({ openai: { messages: [{ role: 'user', content: 'Weather?' }], tools: [] }, maxTokens: 40, timeoutMs: 3000, model: recorded.model });
-- assert.deepStrictEqual(full.completion.choices, recorded.completion.choices, `${key} completion`);
-- if (c.tool_calls) assert.strictEqual(full.completion.choices[0].message.tool_calls[0].function.name, 'weather');
-- } finally { await srv.close(); }
-- }
--});
--
t.test('the adult tier: 18+ person only, never an app, never without a person', async () => {
assert.deepStrictEqual(tier.parsePolicy('UNCENSORED'), { policy: 'uncensored' });
assert.ok(tier.parsePolicy('nsfw').error);
@@@ -154,7 +78,7 @@ t.test('the streamed floor checks each chunk before it is delivered', () => {
assert.strictEqual(sent.length, before, 'the violating chunk was not delivered');
});
--t.test('a booted service seeds the nine off, and the uncensored request is gated, filtered, labelled and floored', async () => {
++t.test('a booted service seeds the catalogue off, and the uncensored request is gated, filtered, labelled and floored', async () => {
const network = [];
const fetchImpl = async (url, opts = {}) => {
const u = String(url);
ddiff --git a/test/ai-provider-adapters.test.js b/test/ai-provider-adapters.test.js
nnew file mode 100644
iindex 0000000..ffe95b5
---- /dev/null
++++ b/test/ai-provider-adapters.test.js
@@@ -0,0 +1,223 @@
++'use strict';
++/**
++ * The ten managed providers OpenVibe buys capacity from (the Exchange remake, stage 2).
++ *
++ * No test reaches the network: every provider's recorded Chat Completions shape lives in
++ * test/fixtures/pool-providers.json and is replayed against a local seam. What this proves:
++ *
++ * - the catalogue is the providers' own pages: a price carries its page and the day it was read, a price
++ * nobody publishes stays null, the quantization stays null unless a provider states it, and the refusal
++ * rate stays null;
++ * - the privacy label says `private` only where the provider's own terms say it keeps nothing and trains on
++ * nothing, and `anonymized` everywhere else;
++ * - every offer is off until its key exists (the pool skips it `no_credentials`);
++ * - the OpenAI adapter reads all ten, tool calls included;
++ * - a provider that says the account has no money left is named `exhausted`.
++ */
++const assert = require('assert');
++const fixtures = require('./fixtures/pool-providers.json');
++const { suite, seamServer, boot, request, token, ALL } = require('./helpers');
++const pool = require('../server/providers/pool-providers');
++const privacy = require('../server/exchange/privacy');
++const seed = require('../server/providers/pool-seed');
++const { createProviderPool } = require('../server/providers');
++const { createOpenAiProvider } = require('../server/providers/openai');
++const { ProviderError, insufficientBalance, retryable } = require('../server/providers/common');
++
++const t = suite('ai-provider-adapters');
++
++/** The provider's own domain: a price or a policy may only be read from the provider's own pages. */
++const DOMAIN = {
++ qwen: 'alibabacloud.com', kimi: 'kimi.ai', glm: 'z.ai', minimax: 'minimax.io', deepinfra: 'deepinfra.com',
++ novita: 'novita.ai', featherless: 'featherless.ai', refuseless: 'refuseless.com', abliteration: 'abliteration.ai',
++ openrouter: 'openrouter.ai',
++};
++const onItsOwnSite = (url, key) => {
++ const host = new URL(url).hostname;
++ return host === DOMAIN[key] || host.endsWith(`.${DOMAIN[key]}`);
++};
++const noop = { get: async () => undefined, run: async () => ({}), all: async () => [] };
++const iso = (n) => new Date(n).toISOString();
++const CHAT = { messages: [{ role: 'user', content: 'x' }], timeoutMs: 3000 };
++
++t.test('ten providers, each OpenAI-compatible, with a base URL, a timeout, an access mode and a privacy label', () => {
++ assert.deepStrictEqual(pool.PROVIDERS.map((p) => p.key),
++ ['qwen', 'kimi', 'glm', 'minimax', 'deepinfra', 'novita', 'featherless', 'refuseless', 'abliteration', 'openrouter']);
++ assert.deepStrictEqual(pool.PROVIDER_KEYS, pool.PROVIDERS.map((p) => p.key));
++ for (const p of pool.PROVIDERS) {
++ assert.ok(/^https:\/\//.test(p.base_url), `${p.key} base_url`);
++ assert.ok(p.timeout_ms >= 1000, `${p.key} timeout`);
++ assert.ok(['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE', 'CUSTOMER_BYOK', 'RESTRICTED', 'UNVERIFIED'].includes(p.provider_access), p.key);
++ assert.ok(p.data_policy, `${p.key} data policy`);
++ assert.ok(p.data_policy.terms_url && p.data_policy.checked, `${p.key} terms + checked`);
++ // The facts the house reads (server/exchange/privacy.js): a fact nobody stated is null, never a guess.
++ assert.ok(p.data_policy.retention === null || ['none', 'limited', 'standard'].includes(p.data_policy.retention), `${p.key} retention`);
++ assert.ok(p.data_policy.trains === null || typeof p.data_policy.trains === 'boolean', `${p.key} trains`);
++ assert.ok(p.data_policy.trust === null || ['first-party', 'community'].includes(p.data_policy.trust), `${p.key} trust`);
++ assert.ok(['private', 'anonymized'].includes(p.privacy.label), `${p.key} privacy label`);
++ assert.ok(p.privacy.terms_url && p.privacy.wording && p.privacy.checked, `${p.key} privacy page + wording + day`);
++ assert.ok(onItsOwnSite(p.privacy.terms_url, p.key), `${p.key} privacy page is its own`);
++ }
++ // `private` only where the provider's own terms say it keeps nothing AND trains on nothing. Featherless says
++ // the prompts are not stored but never says they are not trained on, so it stays anonymized.
++ const priv = pool.PROVIDERS.filter((p) => p.privacy.label === 'private').map((p) => p.key);
++ assert.deepStrictEqual(priv, ['deepinfra', 'abliteration']);
++ for (const key of priv) {
++ const w = pool.byKey.get(key).privacy.wording.toLowerCase();
++ assert.ok(w.includes('train') && w.includes('stor'), `${key} says what it does with a prompt`);
++ }
++ assert.ok(/not (collect or )?store/.test(pool.byKey.get('featherless').privacy.wording.toLowerCase()), 'Featherless says what it keeps');
++ // The facts reach the label on every answer (server/exchange/privacy.js).
++ const label = (key) => privacy.labelFor({ record: { metadata: { data_policy: pool.byKey.get(key).data_policy } } });
++ assert.strictEqual(label('deepinfra').label, 'private', 'DeepInfra keeps nothing and trains on nothing');
++ assert.strictEqual(label('abliteration').label, 'private', 'Abliteration.ai keeps nothing and trains on nothing');
++ assert.strictEqual(label('featherless').label, 'anonymized', 'Featherless keeps nothing but never says it does not train');
++ assert.strictEqual(label('qwen').label, 'anonymized');
++ assert.strictEqual(label('kimi').known, false, 'a provider that declares nothing says so');
++ // The commercial gate: only Featherless may serve managed paid routes, and only with an agreement.
++ assert.strictEqual(pool.byKey.get('featherless').provider_access, 'AUTHORIZED_WHOLESALE');
++ assert.strictEqual(pool.byKey.get('deepinfra').provider_access, 'RESTRICTED');
++ assert.ok(['qwen', 'kimi', 'glm', 'minimax', 'novita', 'refuseless', 'abliteration', 'openrouter']
++ .every((k) => pool.byKey.get(k).provider_access === 'UNVERIFIED'), 'the rest stay unverified');
++});
++
++t.test('every price comes from the provider\'s own page, with the day it was read', () => {
++ for (const p of pool.PROVIDERS) {
++ for (const m of p.models) {
++ assert.ok(m.source && m.checked, `${p.key}/${m.model_key} price page + day`);
++ assert.ok(onItsOwnSite(m.source, p.key), `${p.key}/${m.model_key} price page is its own`);
++ assert.strictEqual(m.checked, pool.CHECKED, `${p.key}/${m.model_key} price day`);
++ for (const f of ['in_per_mtok', 'out_per_mtok', 'cached_per_mtok']) {
++ assert.ok(m.cost[f] === null || (Number.isFinite(m.cost[f]) && m.cost[f] >= 0), `${p.key}/${m.model_key} ${f}`);
++ }
++ assert.ok(m.supports && m.supports.streaming === true, `${p.key}/${m.model_key} streaming`);
++ // The quantization is what the provider states; none of the ten states it, so it stays null.
++ assert.ok(m.quantization === null || typeof m.quantization === 'string', `${p.key}/${m.model_key} quantization`);
++ // The refusal rate stays null until a provider publishes one or a measurement job owns it.
++ assert.strictEqual(m.refusal_rate, null, `${p.key}/${m.model_key} refusal rate`);
++ }
++ }
++ // A price nobody publishes is null, never a guess: Refuseless publishes none.
++ for (const m of pool.byKey.get('refuseless').models) {
++ assert.strictEqual(m.cost.in_per_mtok, null, `refuseless/${m.model_key} input price`);
++ assert.strictEqual(m.cost.out_per_mtok, null, `refuseless/${m.model_key} output price`);
++ }
++ // Abliteration.ai publishes its prices, cache reads included (10% of the input price).
++ const abl = pool.priceOf('abliteration', 'abliterated-model-large-flash');
++ assert.deepStrictEqual([abl.cost.in_per_mtok, abl.cost.cached_per_mtok, abl.cost.out_per_mtok], [1.00, 0.10, 2.00]);
++ // Refuseless and Abliteration.ai are the uncensored tier; a standard model is not.
++ assert.strictEqual(pool.byKey.get('refuseless').uncensored, true);
++ assert.strictEqual(pool.byKey.get('abliteration').uncensored, true);
++ assert.strictEqual(pool.isUncensored(pool.byKey.get('qwen'), null), false);
++ assert.strictEqual(pool.isUncensored(pool.byKey.get('featherless'), { model_key: 'some/Model-32B-abliterated' }), true);
++});
++
++t.test('the seed writes the privacy label, the quantization and the refusal rate into the registry', async () => {
++ const saved = new Map();
++ const models = new Map();
++ const registry = {
++ getProvider: async (key) => saved.get(key) || null,
++ upsertProvider: async (row) => { saved.set(row.key, { ...row, metadata: row.metadata || {} }); },
++ getModel: async (p, m) => models.get(`${p}/${m}`) || null,
++ upsertModel: async (row) => { models.set(`${row.provider_key}/${row.model_key}`, row); },
++ };
++ const out = await seed.seedPoolProviders({ registry });
++ assert.strictEqual(out.providers.length, 10);
++ assert.strictEqual(saved.get('refuseless').secret_ref, 'vault:refuseless');
++ assert.strictEqual(saved.get('abliteration').secret_ref, 'vault:abliteration');
++ assert.strictEqual(saved.get('deepinfra').metadata.privacy.label, 'private');
++ assert.strictEqual(saved.get('qwen').metadata.privacy.label, 'anonymized');
++ assert.strictEqual(models.get('refuseless/deepseek-v4.1-flash-abliterated').metadata.refusal_rate, null);
++ assert.strictEqual(models.get('refuseless/deepseek-v4.1-flash-abliterated').metadata.quantization, null);
++ assert.strictEqual(models.get('refuseless/deepseek-v4.1-flash-abliterated').cost.in_per_mtok, null);
++ assert.strictEqual(models.get('abliteration/abliterated-model').cost.in_per_mtok, 1.00);
++ assert.strictEqual(models.get('abliteration/abliterated-model').metadata.privacy.label, 'private');
++ // The owner edits a provider: a second seed never touches its row.
++ saved.set('qwen', { ...saved.get('qwen'), origin: 'admin', base_url: 'https://proxy.example/v1' });
++ const again = await seed.seedPoolProviders({ registry });
++ assert.ok(again.skipped.includes('qwen'));
++ assert.strictEqual(saved.get('qwen').base_url, 'https://proxy.example/v1');
++});
++
++t.test('the recorded shape of every provider is parsed by the OpenAI adapter, tool calls included', async () => {
++ for (const key of pool.PROVIDER_KEYS) {
++ const recorded = fixtures[key];
++ assert.ok(recorded && recorded.completion, `${key} fixture`);
++ const srv = await seamServer(() => recorded.completion);
++ try {
++ const a = createOpenAiProvider({ key, base_url: srv.url, capabilities: [] }, { apiKey: 'k' });
++ const req = { system: [{ text: 'be brief' }], messages: [{ role: 'user', content: 'hi' }], maxTokens: 40, timeoutMs: 3000, model: recorded.model };
++ const r = await a.chat(req);
++ const c = recorded.completion.choices[0].message;
++ assert.strictEqual(r.text, (c.content || '').trim(), `${key} text`);
++ assert.strictEqual(r.model, recorded.model, `${key} model`);
++ assert.ok(r.usage.input >= 0 && r.usage.output >= 0, `${key} usage`);
++ const full = await a.complete({ openai: { messages: [{ role: 'user', content: 'Weather?' }], tools: [] }, maxTokens: 40, timeoutMs: 3000, model: recorded.model });
++ assert.deepStrictEqual(full.completion.choices, recorded.completion.choices, `${key} completion`);
++ if (c.tool_calls) assert.strictEqual(full.completion.choices[0].message.tool_calls[0].function.name, 'weather');
++ } finally { await srv.close(); }
++ }
++});
++
++t.test('a catalogue offer with no key is off: the pool skips it no_credentials', async () => {
++ // The commercial gate (an UNVERIFIED offer) keeps most of the catalogue out of the managed pool; the
++ // credential gate is what this test is about, so the row carries the access mode the owner would grant.
++ const row = { ...seed.recordOf(pool.byKey.get('qwen')), provider_access: 'OWN_DEPLOYMENT', origin: 'catalog' };
++ const registry = {
++ getProvider: async (k) => (k === 'qwen' ? row : null),
++ listProviders: async () => [row],
++ listModels: async () => [],
++ getModel: async () => null,
++ audit: async () => {},
++ };
++ const config = {
++ breaker: { failureThreshold: 3, cooldownMs: 1000 }, providerRetries: 0, retryDelayMs: 1, stubFallback: false,
++ placement: { minGain: 0, costWeight: 1, latencyWeight: 1 }, pricing: { table: {}, inputPerMtok: 0.5, outputPerMtok: 1.5 },
++ };
++ const p = createProviderPool({ db: { prepare: () => noop }, registry, config, env: {} });
++ const route = { key: 'test.no-key', primary: { provider: 'qwen', model: null }, fallbacks: [], timeout_ms: 3000 };
++ const { explain } = await p.candidates(route, ['chat'], 'chat', {});
++ assert.strictEqual(explain.candidates[0].eligible, false, 'an offer with no key is not eligible');
++ assert.strictEqual(explain.candidates[0].excluded_reason, 'no_credentials');
++ await assert.rejects(
++ p.execute(route, 'chat', CHAT, { routeKey: route.key }),
++ (err) => err.status === 503 && err.code === 'provider.unavailable',
++ 'an offer with no key cannot answer');
++});
++
++t.test('a provider that says the account has no money left is exhaustion, and it is never retried into', async () => {
++ // The empty-failover work owns what happens then (the long circuit, the failover, 503 ai.resting):
++ // server/providers/index.js and common.js. What matters here is that a provider's own answer is read as
++ // exhaustion and not as a transient fault.
++ const recorded = fixtures.exhausted.refuseless;
++ const err = new ProviderError('Out of credits', { status: recorded.status, code: 'provider.http', body: recorded.body });
++ assert.strictEqual(insufficientBalance(err), true, 'Refuseless\'s own 402 is exhaustion');
++ assert.strictEqual(retryable(err), false, 'an empty account is not retried into');
++ assert.strictEqual(insufficientBalance(new ProviderError('Model is busy. Retry shortly.', { status: 429, code: 'provider.http', body: { error: { code: 'model_busy' } } })), false, 'a busy model is not an empty account');
++ assert.strictEqual(insufficientBalance(new ProviderError('the backend fell over', { status: 500, code: 'provider.http', body: {} })), false);
++});
++
++t.test('a booted service seeds the ten offers, each with its own key slot', async () => {
++ const h = await boot({ env: { AI_STUB_FALLBACK: 'false' } });
++ try {
++ const rows = await h.registry.listProviders();
++ for (const p of pool.PROVIDERS) {
++ const row = rows.find((x) => x.key === p.key);
++ assert.ok(row, `${p.key} seeded`);
++ assert.strictEqual(row.secret_ref, `vault:${p.key}`, `${p.key} key slot`);
++ assert.ok(row.metadata && row.metadata.catalog === 'pool-providers', `${p.key} catalogue row`);
++ assert.strictEqual(row.metadata.privacy.label, p.privacy.label, `${p.key} privacy label`);
++ }
++ // The key is not there yet, so the offer cannot answer: the pool skips it.
++ const route = { key: 'test.seeded', primary: { provider: 'qwen', model: null }, fallbacks: [], timeout_ms: 3000 };
++ const { explain } = await h.pool.candidates(route, ['chat'], 'chat', {});
++ assert.strictEqual(explain.candidates[0].eligible, false, 'an offer with no key is not eligible');
++ assert.ok(String(explain.candidates[0].excluded_reason).includes('no_credentials'), explain.candidates[0].excluded_reason);
++ await assert.rejects(
++ h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'x' }], timeoutMs: 3000 }, { routeKey: route.key }),
++ (err) => err.code === 'provider.unavailable',
++ 'the catalogue offer is off until its key exists');
++ } finally { await h.stop(); }
++});
++
++t.run();
ddiff --git a/test/fixtures/pool-providers.json b/test/fixtures/pool-providers.json
iindex 1ca065d..8af2a4b 100644
---- a/test/fixtures/pool-providers.json
++++ b/test/fixtures/pool-providers.json
@@@ -1,5 +1,5 @@
{
-- "note": "Recorded shapes of the nine OpenAI-compatible providers (docs: the provider's own API reference, checked 2026-11-04). Tests replay them against a local seam; no test reaches the network. The shapes are what each provider returns for a Chat Completions request: a completion (with a tool call where the model has tools) and an SSE stream of the same answer.",
++ "note": "Recorded shapes of the ten OpenAI-compatible providers (docs: the provider's own API reference, checked 2026-11-06). Tests replay them against a local seam; no test reaches the network. The shapes are what each provider returns for a Chat Completions request: a completion (with a tool call where the model has tools) and an SSE stream of the same answer.",
"qwen": {
"model": "qwen3.8-max",
"completion": {
@@@ -37,18 +37,18 @@
"stream": ["hello ", "from ", "MiniMax"]
},
"deepinfra": {
-- "model": "deepseek-v4.1-flash-1017",
++ "model": "deepseek-ai/DeepSeek-V4.1-Flash-1017",
"completion": {
-- "id": "chatcmpl-deepinfra-1", "object": "chat.completion", "model": "deepseek-v4.1-flash-1017",
++ "id": "chatcmpl-deepinfra-1", "object": "chat.completion", "model": "deepseek-ai/DeepSeek-V4.1-Flash-1017",
"choices": [{ "index": 0, "message": { "role": "assistant", "content": null, "tool_calls": [{ "id": "call_1", "type": "function", "function": { "name": "weather", "arguments": "{\"city\":\"Paris\"}" } }] }, "finish_reason": "tool_calls" }],
"usage": { "prompt_tokens": 20, "completion_tokens": 8, "total_tokens": 28, "prompt_tokens_details": { "cached_tokens": 4 } }
},
"stream": ["hello ", "from ", "DeepInfra"]
},
"novita": {
-- "model": "deepseek-v4.1-flash-1017",
++ "model": "deepseek/deepseek-v4.1-flash-1017",
"completion": {
-- "id": "chatcmpl-novita-1", "object": "chat.completion", "model": "deepseek-v4.1-flash-1017",
++ "id": "chatcmpl-novita-1", "object": "chat.completion", "model": "deepseek/deepseek-v4.1-flash-1017",
"choices": [{ "index": 0, "message": { "role": "assistant", "content": "hello from Novita" }, "finish_reason": "stop" }],
"usage": { "prompt_tokens": 8, "completion_tokens": 4, "total_tokens": 12, "prompt_tokens_details": { "cached_tokens": 0 } }
},
@@@ -72,6 +72,15 @@
},
"stream": ["an ", "unlabelled ", "answer"]
},
++ "abliteration": {
++ "model": "abliterated-model-large-flash",
++ "completion": {
++ "id": "chatcmpl-abliteration-1", "object": "chat.completion", "model": "abliterated-model-large-flash",
++ "choices": [{ "index": 0, "message": { "role": "assistant", "content": "hello from Abliteration" }, "finish_reason": "stop" }],
++ "usage": { "prompt_tokens": 10, "completion_tokens": 4, "total_tokens": 14, "prompt_tokens_details": { "cached_tokens": 0 } }
++ },
++ "stream": ["hello ", "from ", "Abliteration"]
++ },
"openrouter": {
"model": "openrouter/auto",
"completion": {
@@@ -80,5 +89,12 @@
"usage": { "prompt_tokens": 5, "completion_tokens": 4, "total_tokens": 9, "prompt_tokens_details": { "cached_tokens": 0 } }
},
"stream": ["hello ", "from ", "OpenRouter"]
++ },
++ "exhausted": {
++ "note": "The answer a provider gives when the account has no money left, as the provider's own error page states it (read 2026-11-06). Refuseless states 402 `insufficient_credits`; the other providers say the same thing in their own words.",
++ "refuseless": {
++ "status": 402,
++ "body": { "error": { "code": "insufficient_credits", "message": "Out of credits", "type": "api_error" } }
++ }
}
}