Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ adheres to [Semantic Versioning](https://semver.org/).

## Unreleased

### yoagent

- **DeepSeek is priced** (after `prices::enable_bundled()`): `deepseek-flash`, `deepseek-v4-pro` and the legacy `deepseek-v4-flash` (billed as Flash), from DeepSeek's pricing page. **At the peak rate:** DeepSeek bills half of it off-peak (all hours but 01:00–04:00 and 06:00–10:00 UTC on weekdays), and a `CostConfig` has no time of day, so costs are an upper bound — off-peak they read up to twice the bill, and a `Budget` stops early rather than late. Install the off-peak rates as an override to count those instead (`docs/concepts/pricing.md`). `ModelConfig::deepseek(..)` for those ids now has a `cost`; `Budget::for_model` works for them. The price audit checks the rates as exactly twice models.dev's off-peak ones, and models.dev data (`enable_live`, a fetch or a price cache written by an earlier version) never brings DeepSeek's off-peak rates back over the bundled peak ones. `examples/llm_compaction_live.rs` reads DeepSeek's rates from the snapshot instead of its own literals.

### yoagent-rutis (0.4.0)

- **`yoagent_rutis::adapters`: where the pi and DSH adapters are.** `adapters()` returns the folders of the adapter files the crate ships (`plugins/pi`, `plugins/dsh`, `plugins/yoagent.d.ts`), so a host can copy them and `npm ci` the copy instead of keeping its own copy of them in step with the crate's.
Expand Down
32 changes: 29 additions & 3 deletions docs/concepts/pricing.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,14 +191,34 @@ every lookup is `None`. Once it has:
`None` otherwise. `ModelConfig::anthropic("claude-sonnet-5", ..)` is priced
exactly like `claude_sonnet_5()`; `meta` prices `muse-spark-1.1` and
`muse-spark-1.2` and leaves any other id (the far cheaper contributor tier,
say) unknown.
say) unknown. `deepseek` prices `deepseek-flash`, `deepseek-v4-pro` and the
legacy `deepseek-v4-flash` (see below).
- **Gateways and custom endpoints** — `custom`, `openai_compat`, `local`,
`ollama`, `opencode_zen`, `opencode_go`, `mock` — are always `None`, even
for an id the table lists. A gateway's bill is not the vendor's list price.

`None` means **unknown**, never free. Set `config.cost` yourself for a model
the table does not list.

**DeepSeek is priced at its peak rate.** DeepSeek bills half its peak rate
off-peak: every hour except 01:00–04:00 and 06:00–10:00 UTC on weekdays,
about 79% of the week. A `CostConfig` has no time of day, so the snapshot
carries the peak rate, the upper bound: off-peak, `cost_usd` and
`total_cost_usd()` are up to twice the bill, and a `Budget` stops early rather
than late. To count off-peak spend instead, install the off-peak rates as an
override (`install_override` or `YOAGENT_PRICES`, below):

```json
{ "schema": 1, "providers": { "deepseek": {
"deepseek-flash": { "input": 0.15, "output": 0.6, "cache_read": 0.003, "cache_write": 0.15 },
"deepseek-v4-flash": { "input": 0.15, "output": 0.6, "cache_read": 0.003, "cache_write": 0.15 },
"deepseek-v4-pro": { "input": 0.66, "output": 1.98, "cache_read": 0.022, "cache_write": 0.66 } } } }
```

Each model needs its own entry (one left out keeps the peak rate), and
`cache_write` is stated as the input rate: DeepSeek reports no cache-write
tokens, and leaving it out makes the override report a warning.

## Overriding prices at runtime

A price change should not have to wait for a yoagent release. Constructors
Expand Down Expand Up @@ -444,7 +464,9 @@ for every model it lists.
V4 Pro's price. The mapping is conservative:
- A model whose cost carries structure `CostConfig` cannot express is
**skipped**, not approximated. That covers a separate reasoning rate, a
non-context tier and an unknown key.
non-context tier and an unknown key — and DeepSeek's models, priced by
time of day: models.dev lists the off-peak rate, which must not replace
the snapshot's peak one (they are reported as skipped, without a warning).
- A skipped model that the bundled snapshot lists is **named in a `warn`
log**. It keeps the snapshot's price when the snapshot is enabled
(`enable_live*` always enables it).
Expand Down Expand Up @@ -528,7 +550,11 @@ drift, on an entry that vanished upstream, and on a comparison that silently
checked nothing. The comparison logic also runs offline in CI, against a
checked-in slice of models.dev. models.dev is community-maintained, so a
failure sends a human to the entry's `source`; the audit never edits the
file. Run the live audit before a release:
file. Where the vendor and models.dev legitimately differ, the audit records
the difference and asserts it both ways rather than skipping the field: for
DeepSeek, the snapshot's rates must be exactly twice models.dev's (which lists
the off-peak ones), and models.dev's separate `reasoning` rate must equal its
output rate. Run the live audit before a release:

```text
cargo test --all-features --test price_audit -- --ignored --nocapture
Expand Down
31 changes: 7 additions & 24 deletions examples/llm_compaction_live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,7 @@ use std::sync::Arc;
use yoagent::context::ContextConfig;
use yoagent::gasp::{GaspRecorder, GoalRef};
use yoagent::llm_compaction::SUMMARY_MARKER;
use yoagent::provider::{
CostConfig, ModelConfig, ProviderError, StreamConfig, StreamEvent, StreamProvider,
};
use yoagent::provider::{ModelConfig, ProviderError, StreamConfig, StreamEvent, StreamProvider};
use yoagent::*;

/// Dry-run double (`YO_DRY_RUN=1`): bulky deterministic text, so the harness's
Expand Down Expand Up @@ -110,18 +108,12 @@ fn priced(id: &str) -> ModelConfig {
// maps `prompt_cache_hit_tokens` onto `Usage::cache_read`. So a hit
// rate here measures *their* caching, not yoagent's placement.
//
// Rates live here rather than in `ModelConfig::deepseek` because
// DeepSeek prices by time of day — off-peak is exactly half of peak
// (peak = 01:00-04:00 and 06:00-10:00 UTC). A crate-level preset would
// be wrong half the day. These are the **peak** rates, so the cost
// column over-reports rather than under-reports off-peak runs.
// DeepSeek has no cache-write category at all: populating its cache
// is free, which is the asymmetry the provider comparison turns on.
// `deepseek-v4-flash` is a legacy name DeepSeek still accepts, served
// by V4.1 Flash and billed at the Flash price.
"deepseek-flash" => deepseek_priced("deepseek-flash", 0.30, 1.20, 0.006),
"deepseek-v4-flash" => deepseek_priced("deepseek-v4-flash", 0.30, 1.20, 0.006),
"deepseek-v4-pro" => deepseek_priced("deepseek-v4-pro", 1.32, 3.96, 0.044),
// Priced from the bundled snapshot (`prices::enable_bundled()` in
// `main`) at DeepSeek's **peak** rate: off-peak is exactly half, and a
// `CostConfig` has no time of day, so the cost column over-reports
// rather than under-reports off-peak runs. DeepSeek has no cache-write
// category: populating its cache is free (a miss is plain input), which
// is the asymmetry the provider comparison turns on.
other if other.starts_with("deepseek") => {
noting_unpriced(ModelConfig::deepseek(other, other))
}
Expand All @@ -142,15 +134,6 @@ fn note_unpriced(model: &str) {
);
}

/// A DeepSeek config carrying peak-window rates. `cache_write` is left unset
/// because DeepSeek has no write category (it would bill at the input rate,
/// but DeepSeek never reports cache-write tokens — a miss is plain input).
fn deepseek_priced(id: &str, input: f64, output: f64, cache_read: f64) -> ModelConfig {
let mut config = ModelConfig::deepseek(id, id);
config.cost = Some(CostConfig::new(input, output).with_cache_read(cache_read));
config
}

fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
std::env::var(key)
.ok()
Expand Down
16 changes: 8 additions & 8 deletions src/provider/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,12 +131,11 @@ pub(crate) fn openai_id_is_reasoning(id: &str) -> bool {
/// // config.cost.as_mut()` would silently do nothing — but set every rate
/// // you pay, since the rest start at zero (an unset cache rate then bills
/// // at the input rate):
/// let mut deepseek = ModelConfig::deepseek("deepseek-flash", "DeepSeek Flash");
/// assert!(deepseek.cost.is_none());
/// // DeepSeek Flash peak rates (off-peak is half); DeepSeek has no
/// // cache-write category.
/// deepseek.cost = Some(CostConfig::new(0.30, 1.20).with_cache_read(0.006));
/// assert_eq!(deepseek.cost.as_ref().unwrap().input_per_million, 0.30);
/// let mut mistral = ModelConfig::mistral("mistral-large-latest", "Mistral Large");
/// assert!(mistral.cost.is_none());
/// // The rates you pay, from Mistral's pricing page (these are placeholders).
/// mistral.cost = Some(CostConfig::new(2.0, 6.0).with_cache_read(0.2));
/// assert_eq!(mistral.cost.as_ref().unwrap().input_per_million, 2.0);
///
/// // A model you run for free is `Some` with zero rates, and costs $0:
/// let mut local = ModelConfig::local("http://localhost:1234/v1", "qwen3");
Expand Down Expand Up @@ -2968,7 +2967,8 @@ mod tests {
ModelConfig::qwen("qwen-plus", "Qwen"),
ModelConfig::xai("grok-4-1-fast", "Grok"),
ModelConfig::groq("llama-3.3-70b-versatile", "Llama"),
ModelConfig::deepseek("deepseek-v4-flash", "DeepSeek"),
// A DeepSeek id the snapshot does not list (the retired names are).
ModelConfig::deepseek("deepseek-chat", "DeepSeek"),
ModelConfig::mistral("mistral-large-latest", "Mistral"),
ModelConfig::google("gemini-3-pro", "Gemini"),
// `meta` looks its id up too: the contributor tier is not listed,
Expand Down Expand Up @@ -3067,7 +3067,7 @@ mod tests {
assert_eq!(sonnet.cost, Some(CostConfig::new(1.8, 9.0)));
let opus = ModelConfig::claude_opus_5().with_prices(&table);
assert_eq!(opus.cost, ModelConfig::claude_opus_5().cost);
let unknown = ModelConfig::deepseek("deepseek-flash", "D").with_prices(&table);
let unknown = ModelConfig::mistral("mistral-large-latest", "M").with_prices(&table);
assert!(unknown.cost.is_none());
// Explicit, so it applies to a local endpoint too: free, not unknown.
let local = ModelConfig::local("http://localhost:1234/v1", "qwen3").with_prices(&table);
Expand Down
32 changes: 32 additions & 0 deletions src/provider/prices.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,38 @@
"verified": "2026-08-19"
}
},
"deepseek": {
"deepseek-flash": {
"input": 0.3,
"output": 1.2,
"cache_read": 0.006,
"cache_write": 0.3,
"cache_write_at_input": true,
"note": "Peak rate. DeepSeek bills half of it off-peak: all hours but 01:00-04:00 and 06:00-10:00 UTC on weekdays (about 79% of the week). A flat CostConfig has no time of day, so the peak rate is the upper bound: costs are over-stated off-peak, never under-stated, and a Budget stops early rather than late.",
"source": "https://api-docs.deepseek.com/quick_start/pricing",
"verified": "2026-10-16"
},
"deepseek-v4-flash": {
"input": 0.3,
"output": 1.2,
"cache_read": 0.006,
"cache_write": 0.3,
"cache_write_at_input": true,
"note": "The legacy name: served by DeepSeek-V4.1-Flash and billed at the deepseek-flash price. Peak rate. DeepSeek bills half of it off-peak: all hours but 01:00-04:00 and 06:00-10:00 UTC on weekdays (about 79% of the week). A flat CostConfig has no time of day, so the peak rate is the upper bound: costs are over-stated off-peak, never under-stated, and a Budget stops early rather than late.",
"source": "https://api-docs.deepseek.com/quick_start/pricing",
"verified": "2026-10-16"
},
"deepseek-v4-pro": {
"input": 1.32,
"output": 3.96,
"cache_read": 0.044,
"cache_write": 1.32,
"cache_write_at_input": true,
"note": "Peak rate. DeepSeek bills half of it off-peak: all hours but 01:00-04:00 and 06:00-10:00 UTC on weekdays (about 79% of the week). A flat CostConfig has no time of day, so the peak rate is the upper bound: costs are over-stated off-peak, never under-stated, and a Budget stops early rather than late.",
"source": "https://api-docs.deepseek.com/quick_start/pricing",
"verified": "2026-10-16"
}
},
"meta": {
"muse-spark-1.1": {
"input": 1.25,
Expand Down
26 changes: 26 additions & 0 deletions src/provider/prices/fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,10 @@ impl PriceTable {
reason,
})
};
if TIME_OF_DAY_PRICED.contains(&provider) {
skip(TIME_OF_DAY_REASON.into());
continue;
}
let Some(cost) = info.get("cost") else {
skip("models.dev lists no cost".into());
continue;
Expand All @@ -418,8 +422,10 @@ impl PriceTable {
}
}
let builtin = builtin_ref();
// Skipped by design (time-of-day pricing) is not news; unmappable data is.
let dropped: Vec<String> = skipped
.iter()
.filter(|s| !TIME_OF_DAY_PRICED.contains(&s.provider.as_str()))
.filter(|s| builtin.entry(&s.provider, &s.model).is_some())
.map(|s| format!("{}/{} ({})", s.provider, s.model, s.reason))
.collect();
Expand Down Expand Up @@ -514,6 +520,16 @@ impl PriceTable {
CacheRead::Problem(problem) => (None, Some(problem)),
CacheRead::Found(cached) => (Some(cached), None),
};
// A cache written before a provider was priced by time of day still
// holds models.dev's off-peak rates: drop them as the mapping does.
let cached = cached.map(|mut c| {
if matches!(source, PriceSource::ModelsDev | PriceSource::ModelsDevAt(_)) {
c.table
.entries
.retain(|provider, _| !TIME_OF_DAY_PRICED.contains(&provider.as_str()));
}
c
});
if let Some(Cached {
table,
age: Some(age),
Expand Down Expand Up @@ -700,6 +716,16 @@ async fn write_cache(path: &Path, url: &str, table: &PriceTable) -> Result<(), P
})
}

/// Providers whose rates depend on the time of day (DeepSeek: half price
/// off-peak). models.dev lists one of the two rates and a flat `CostConfig`
/// cannot express both, so their models are skipped: the bundled snapshot
/// carries the peak rate (an upper bound), and a fetched off-peak rate must
/// not silently replace it.
const TIME_OF_DAY_PRICED: &[&str] = &["deepseek"];

const TIME_OF_DAY_REASON: &str = "priced by time of day (peak and off-peak): a flat \
CostConfig cannot express it; the bundled snapshot carries the peak rate";

/// One models.dev `cost` object as a [`CostConfig`], or why it cannot be
/// expressed as one.
fn map_models_dev_cost(cost: &Value) -> Result<CostConfig, String> {
Expand Down
24 changes: 20 additions & 4 deletions tests/fixtures/models_dev_slice.json
Original file line number Diff line number Diff line change
Expand Up @@ -214,10 +214,26 @@
"output": 393216
},
"cost": {
"input": 0.435,
"output": 0.87,
"reasoning": 0.87,
"cache_read": 0.003625
"input": 0.66,
"output": 1.98,
"reasoning": 1.98,
"cache_read": 0.022
}
},
"deepseek-v4-flash": {
"id": "deepseek-v4-flash",
"name": "DeepSeek V4 Flash (served by V4.1 Flash)",
"reasoning": true,
"tool_call": true,
"limit": {
"context": 1000000,
"output": 393216
},
"cost": {
"input": 0.15,
"output": 0.6,
"reasoning": 0.6,
"cache_read": 0.003
}
}
}
Expand Down
Loading
Loading