liter-llm 2.2.3

Universal LLM API client — 174 providers, streaming, tool calling. Rust-powered, type-safe, compiled.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
//! Unit tests for [`super`] (the `cost` module): flat and tier-aware cost
//! calculation, prefix-fallback lookup, provider/model key resolution, and
//! the [`super::ModelInfo`] / [`super::ModelTier`] projection.
//!
//! Tests that exercise custom pricing schemas use inline JSON fixtures
//! deserialized directly into [`super::ModelPricing`] rather than depending
//! on the shape of the committed `schemas/catalog.json`. Tests that need
//! resolution over a known-present model name (e.g. `gpt-4`) use the real
//! embedded catalog registry, matching the pre-existing test style in this
//! module.

use super::*;

fn without_resolution_metadata(mut info: ModelInfo) -> ModelInfo {
    info.matched_key.clear();
    info.exact = false;
    info
}

#[test]
fn completion_cost_known_model_returns_expected_value() {
    let cost = completion_cost("gpt-4", 100, 50).expect("gpt-4 must be in registry");
    let expected = 100.0 * 0.00003 + 50.0 * 0.00006;
    assert!((cost - expected).abs() < 1e-12, "expected {expected}, got {cost}");
}

#[test]
fn completion_cost_unknown_model_returns_none() {
    assert!(
        completion_cost("unknown-model-xyz", 100, 50).is_none(),
        "unknown model should return None"
    );
}

#[test]
fn completion_cost_gpt4o_matches_published_pricing() {
    let cost = completion_cost("gpt-4o", 1_000, 500).expect("gpt-4o must be in registry");
    let expected = 1_000.0 * 0.0000025 + 500.0 * 0.00001;
    assert!((cost - expected).abs() < 1e-12, "expected {expected}, got {cost}");
}

#[test]
fn completion_cost_embedding_model_has_zero_output_cost() {
    let cost = completion_cost("text-embedding-3-small", 100, 0).expect("text-embedding-3-small must be in registry");
    assert!(cost > 0.0, "input tokens must have a positive cost");

    let pricing = model_pricing("text-embedding-3-small").expect("text-embedding-3-small must be in pricing registry");
    assert_eq!(pricing.output_cost_per_token, 0.0, "embedding output cost must be zero");
}

#[test]
fn model_pricing_returns_none_for_unknown_model() {
    assert!(model_pricing("does-not-exist").is_none());
}

#[test]
fn model_pricing_prefix_fallback_matches_shorter_name() {
    let exact = model_pricing("gpt-4").expect("gpt-4 must be in registry");
    let prefix = model_pricing("gpt-4-0613").expect("gpt-4-0613 should match gpt-4 via prefix");
    assert!(
        (exact.input_cost_per_token - prefix.input_cost_per_token).abs() < 1e-15,
        "prefix match should return the same pricing as exact match"
    );
}

#[test]
fn completion_cost_prefix_fallback() {
    let cost = completion_cost("gpt-4-0613", 100, 50);
    assert!(cost.is_some(), "gpt-4-0613 should resolve via prefix fallback to gpt-4");
}

#[test]
fn model_pricing_returns_correct_fields_for_known_model() {
    let p = model_pricing("gpt-4o-mini").expect("gpt-4o-mini must be in registry");
    assert!(
        (p.input_cost_per_token - 0.00000015).abs() < 1e-12,
        "unexpected input_cost_per_token: {}",
        p.input_cost_per_token
    );
    assert!(
        (p.output_cost_per_token - 0.0000006).abs() < 1e-12,
        "unexpected output_cost_per_token: {}",
        p.output_cost_per_token
    );
}

#[test]
fn completion_cost_with_cache_applies_discount_when_pricing_available() {
    let pricing = ModelPricing {
        input_cost_per_token: 1e-5,
        output_cost_per_token: 2e-5,
        cache_read_input_token_cost: Some(1e-6),
        cache_creation_input_token_cost: None,
        ..Default::default()
    };
    let expected = 800.0 * 1e-5 + 200.0 * 1e-6 + 50.0 * 2e-5;
    let uncached = 1000 - 200;
    let actual = (uncached as f64) * pricing.input_cost_per_token
        + 200.0
            * pricing
                .cache_read_input_token_cost
                .expect("cache_read_input_token_cost should be set")
        + 50.0 * pricing.output_cost_per_token;
    assert!((actual - expected).abs() < 1e-12);
}

#[test]
fn completion_cost_accounts_for_cache_creation_surcharge() {
    let pricing = ModelPricing {
        input_cost_per_token: 1e-5,
        output_cost_per_token: 2e-5,
        cache_read_input_token_cost: Some(1e-6),
        cache_creation_input_token_cost: Some(1.25e-5),
        ..Default::default()
    };
    let actual = compute_cost(&pricing, 1_000, 200, 300, 50);
    let expected = 500.0 * 1e-5 + 200.0 * 1e-6 + 300.0 * 1.25e-5 + 50.0 * 2e-5;
    assert!((actual - expected).abs() < 1e-12);
}

#[test]
fn completion_cost_with_cache_falls_back_to_input_rate_without_cache_pricing() {
    let with_cache = completion_cost_with_cache("gpt-4", 1_000, 200, 50).expect("gpt-4 must be in registry");
    let without = completion_cost("gpt-4", 1_000, 50).expect("gpt-4 must be in registry");
    assert!((with_cache - without).abs() < 1e-12);
}

#[test]
fn completion_cost_with_cache_clamps_cached_tokens_to_prompt_tokens() {
    let cost = completion_cost_with_cache("gpt-4", 100, 500, 0).expect("gpt-4 must be in registry");
    let clamped = completion_cost_with_cache("gpt-4", 100, 100, 0).expect("gpt-4 must be in registry");
    assert!((cost - clamped).abs() < 1e-12);
}

#[test]
fn completion_cost_with_cache_uses_registry_cache_pricing_when_available() {
    let pricing = model_pricing("claude-sonnet-4-5").expect("claude-sonnet-4-5 must be in registry");
    let cache_rate = pricing
        .cache_read_input_token_cost
        .expect("claude-sonnet-4-5 must have cache_read_input_token_cost");
    assert!(
        cache_rate < pricing.input_cost_per_token,
        "cache rate ({cache_rate}) must be cheaper than input rate ({})",
        pricing.input_cost_per_token
    );
    let expected = 800.0 * pricing.input_cost_per_token + 200.0 * cache_rate + 50.0 * pricing.output_cost_per_token;
    let actual =
        completion_cost_with_cache("claude-sonnet-4-5", 1_000, 200, 50).expect("claude-sonnet-4-5 must be priceable");
    assert!((actual - expected).abs() < 1e-12, "expected {expected}, got {actual}");
    let no_cache = completion_cost("claude-sonnet-4-5", 1_000, 50).expect("claude-sonnet-4-5 must be priceable");
    assert!(
        actual < no_cache,
        "cached cost ({actual}) must be < uncached ({no_cache})"
    );
}

#[test]
fn completion_cost_with_cache_unknown_model_returns_none() {
    assert!(completion_cost_with_cache("unknown-model-xyz", 100, 10, 50).is_none());
}

#[test]
fn catalog_embedded_json_parses() {
    assert!(
        PRICING.as_ref().is_ok(),
        "embedded schemas/catalog.json failed to parse into the registry: {:?}",
        PRICING.as_ref().err()
    );
}

/// A cost-only record containing only the two required fields must still
/// deserialize directly into `ModelPricing`, with every other field
/// defaulting to `None` / empty.
#[test]
fn legacy_cost_only_record_still_parses() {
    let json = r#"{
        "input_cost_per_token": 0.000001,
        "output_cost_per_token": 0.000002
    }"#;
    let p: ModelPricing = serde_json::from_str(json).expect("cost-only record must parse");

    assert!((p.input_cost_per_token - 0.000001).abs() < 1e-15);
    assert!((p.output_cost_per_token - 0.000002).abs() < 1e-15);
    assert_eq!(p.cache_read_input_token_cost, None);
    assert_eq!(p.cache_creation_input_token_cost, None);
    assert_eq!(p.input_cost_per_audio_token, None);
    assert_eq!(p.output_cost_per_audio_token, None);
    assert_eq!(p.output_cost_per_reasoning_token, None);
    assert_eq!(p.max_tokens, None);
    assert_eq!(p.max_input_tokens, None);
    assert_eq!(p.max_output_tokens, None);
    assert_eq!(p.mode, None);
    assert_eq!(p.supports_vision, None);
    assert_eq!(p.supports_function_calling, None);
    assert_eq!(p.supports_reasoning, None);
    assert_eq!(p.supports_structured_output, None);
    assert_eq!(p.supports_audio_input, None);
    assert_eq!(p.supports_audio_output, None);
    assert_eq!(p.supports_prompt_caching, None);
    assert!(p.tiers.is_empty());
}

/// A record with tiers and full metadata projects correctly into
/// `ModelInfo` / `ModelTier`.
#[test]
fn model_info_parses_extended_fields() {
    let json = r#"{
        "input_cost_per_token": 0.000005,
        "output_cost_per_token": 0.000015,
        "cache_read_input_token_cost": 0.0000008,
        "cache_creation_input_token_cost": 0.0000012,
        "input_cost_per_audio_token": 0.0000023,
        "output_cost_per_audio_token": 0.0000023,
        "output_cost_per_reasoning_token": 0.00001,
        "tiers": [
            {
                "min_context_tokens": 200000,
                "input_cost_per_token": 0.00001,
                "output_cost_per_token": 0.00003,
                "cache_read_input_token_cost": 0.0000016
            }
        ],
        "max_tokens": 400000,
        "max_input_tokens": 272000,
        "max_output_tokens": 128000,
        "mode": "chat",
        "supports_vision": true,
        "supports_function_calling": true,
        "supports_reasoning": true,
        "supports_structured_output": true,
        "supports_audio_input": true,
        "supports_audio_output": false,
        "supports_prompt_caching": true
    }"#;
    let pricing: ModelPricing = serde_json::from_str(json).expect("extended record must parse");
    let info = ModelInfo::from(&pricing);

    assert!((info.input_cost_per_token - 0.000005).abs() < 1e-15);
    assert!((info.output_cost_per_token - 0.000015).abs() < 1e-15);
    assert_eq!(info.cache_read_input_token_cost, Some(0.0000008));
    assert_eq!(info.cache_creation_input_token_cost, Some(0.0000012));
    assert_eq!(info.input_cost_per_audio_token, Some(0.0000023));
    assert_eq!(info.output_cost_per_audio_token, Some(0.0000023));
    assert_eq!(info.output_cost_per_reasoning_token, Some(0.00001));
    assert_eq!(info.max_tokens, Some(400_000));
    assert_eq!(info.max_input_tokens, Some(272_000));
    assert_eq!(info.max_output_tokens, Some(128_000));
    assert_eq!(info.mode.as_deref(), Some("chat"));
    assert_eq!(info.supports_vision, Some(true));
    assert_eq!(info.supports_function_calling, Some(true));
    assert_eq!(info.supports_reasoning, Some(true));
    assert_eq!(info.supports_structured_output, Some(true));
    assert_eq!(info.supports_audio_input, Some(true));
    assert_eq!(info.supports_audio_output, Some(false));
    assert_eq!(info.supports_prompt_caching, Some(true));

    assert_eq!(info.tiers.len(), 1);
    let tier = &info.tiers[0];
    assert_eq!(tier.min_context_tokens, 200_000);
    assert!((tier.input_cost_per_token - 0.00001).abs() < 1e-15);
    assert!((tier.output_cost_per_token - 0.00003).abs() < 1e-15);
    assert_eq!(tier.cache_read_input_token_cost, Some(0.0000016));
    assert_eq!(tier.cache_creation_input_token_cost, None);
}

/// `model_info` resolves through the same prefix-fallback path as
/// `model_pricing`, against the real embedded registry.
#[test]
fn model_info_prefix_fallback() {
    let exact = model_info("gpt-4").expect("gpt-4 must be in registry");
    let prefix = model_info("gpt-4-0613").expect("gpt-4-0613 should resolve via prefix fallback to gpt-4");
    assert_eq!(exact.matched_key, "gpt-4");
    assert!(exact.exact);
    assert_eq!(prefix.matched_key, "gpt-4");
    assert!(!prefix.exact);
    assert!(
        (exact.input_cost_per_token - prefix.input_cost_per_token).abs() < 1e-15,
        "prefix match should return the same pricing as exact match"
    );
}

#[test]
fn model_info_does_not_price_unsafe_product_variants_as_base_models() {
    assert!(model_info("gpt-4-realtime").is_none());
    assert!(model_info("gpt-4-audio").is_none());
    assert!(model_info("gpt-4-search").is_none());
}

#[test]
fn exact_model_info_rejects_revision_fallback() {
    assert!(exact_model_info("gpt-4").is_some());
    assert!(exact_model_info("gpt-4-0613").is_none());
}

#[test]
fn model_info_unknown_returns_none() {
    assert!(model_info("definitely-not-a-real-model-xyz").is_none());
}

/// A tiered model bills at the base rate below the tier threshold and at the
/// tier rate at/above it, including the tier's own cache rate.
#[test]
fn completion_cost_uses_tier_above_threshold() {
    let json = r#"{
        "input_cost_per_token": 0.000003,
        "output_cost_per_token": 0.000015,
        "cache_read_input_token_cost": 0.0000003,
        "tiers": [
            {
                "min_context_tokens": 200000,
                "input_cost_per_token": 0.000006,
                "output_cost_per_token": 0.0000225,
                "cache_read_input_token_cost": 0.0000006
            }
        ]
    }"#;
    let pricing: ModelPricing = serde_json::from_str(json).expect("tiered record must parse");

    let below = compute_cost(&pricing, 100_000, 0, 0, 1_000);
    let expected_below = 100_000.0 * 0.000003 + 1_000.0 * 0.000015;
    assert!(
        (below - expected_below).abs() < 1e-9,
        "expected {expected_below}, got {below}"
    );

    let above = compute_cost(&pricing, 250_000, 0, 0, 1_000);
    let expected_above = 250_000.0 * 0.000006 + 1_000.0 * 0.0000225;
    assert!(
        (above - expected_above).abs() < 1e-9,
        "expected {expected_above}, got {above}"
    );

    let above_cached = compute_cost(&pricing, 250_000, 50_000, 0, 1_000);
    let expected_above_cached = 200_000.0 * 0.000006 + 50_000.0 * 0.0000006 + 1_000.0 * 0.0000225;
    assert!(
        (above_cached - expected_above_cached).abs() < 1e-9,
        "expected {expected_above_cached}, got {above_cached}"
    );
}

/// An unpriced catalog model (no `pricing` object upstream) still resolves
/// through `model_info` with its limits/mode/capabilities populated, and
/// `completion_cost` treats it as unknown rather than silently free.
#[test]
fn unpriced_model_resolves_with_unknown_cost_and_full_metadata() {
    let info = model_info("agentrouter/claude-opus-4-8").expect("unpriced catalog model must still resolve");

    assert_eq!(info.input_cost_per_token, 0.0);
    assert_eq!(info.output_cost_per_token, 0.0);
    assert!(!info.price_known);
    assert_eq!(info.max_tokens, Some(1_000_000));
    assert_eq!(
        info.max_input_tokens,
        Some(1_000_000),
        "falls back to context when limit.input is absent"
    );
    assert_eq!(info.max_output_tokens, Some(128_000));
    assert_eq!(info.mode.as_deref(), Some("chat"));
    assert_eq!(info.supports_function_calling, Some(true));
    assert_eq!(info.supports_reasoning, Some(true));
    assert_eq!(info.supports_structured_output, Some(false));
    assert_eq!(info.supports_vision, Some(true));
    assert_eq!(info.supports_audio_input, Some(false));
    assert_eq!(info.supports_audio_output, Some(false));
    assert_eq!(info.supports_prompt_caching, Some(false));

    let cost = completion_cost("agentrouter/claude-opus-4-8", 1_000, 500);
    assert_eq!(cost, None, "unpriced model must not be presented as free");
}

#[test]
fn zero_priced_non_embedding_models_are_marked_unknown() {
    for model in [
        "gpt-image-1",
        "veo-3.1-generate-preview",
        "lyria-3-clip-preview",
        "gemma-4-26b-a4b-it",
    ] {
        let info = model_info(model).unwrap_or_else(|| panic!("catalog model {model} must resolve"));
        assert!(!info.price_known, "{model} must not be presented as free");
        assert_eq!(completion_cost(model, 1_000_000, 1_000_000), None);
    }
}

/// A primary-provider ("openai") model resolves both via its combined
/// `"{provider}/{model}"` key and its bare alias, to the same entry.
///
/// Deliberately does not assert on price literals: those are regenerated by
/// the scheduled models.dev catalog sync and are not what this test exists
/// to prove (see `registry_from_catalog_str`, which clones the identical
/// `ModelPricing` value into both the combined-key and bare-alias registry
/// slots). `model_info` is compared in full (via `ModelInfo`'s `PartialEq`)
/// rather than cherry-picking fields, so any future field added to the
/// pricing/capability projection stays covered by this invariant.
#[test]
fn openai_model_resolves_via_combined_key_and_bare_alias() {
    let combined = model_info("openai/gpt-4o").expect("openai/gpt-4o must resolve via combined key");
    let bare = model_info("gpt-4o").expect("gpt-4o must resolve via bare alias (primary provider)");
    assert_eq!(
        without_resolution_metadata(bare),
        without_resolution_metadata(combined),
        "bare alias must resolve to the same entry as the combined key"
    );
}

/// An "anthropic" (primary-provider) model resolves via both the combined
/// key and the bare alias, to the same entry. See
/// `openai_model_resolves_via_combined_key_and_bare_alias` for why this
/// intentionally avoids pinning price literals.
#[test]
fn anthropic_model_resolves_via_combined_key_and_bare_alias() {
    let combined =
        model_info("anthropic/claude-sonnet-4-5").expect("anthropic/claude-sonnet-4-5 must resolve via combined key");
    let bare = model_info("claude-sonnet-4-5").expect("claude-sonnet-4-5 must resolve via bare alias");
    assert_eq!(
        without_resolution_metadata(bare),
        without_resolution_metadata(combined),
        "bare alias must resolve to the same entry as the combined key"
    );
}

/// A "google" (primary-provider) model resolves via both the combined key
/// and the bare alias, to the same entry. See
/// `openai_model_resolves_via_combined_key_and_bare_alias` for why this
/// intentionally avoids pinning price literals.
#[test]
fn google_model_resolves_via_combined_key_and_bare_alias() {
    let combined =
        model_info("google/gemini-2.5-flash-lite").expect("google/gemini-2.5-flash-lite must resolve via combined key");
    let bare = model_info("gemini-2.5-flash-lite").expect("gemini-2.5-flash-lite must resolve via bare alias");
    assert_eq!(
        without_resolution_metadata(bare),
        without_resolution_metadata(combined),
        "bare alias must resolve to the same entry as the combined key"
    );
}

/// A non-primary provider's model resolves via its combined key but has no
/// bare alias: neither the exact bare name nor prefix-fallback stripping
/// may accidentally resolve it (or collide with an unrelated primary-provider
/// entry).
#[test]
fn non_primary_provider_bare_name_does_not_resolve() {
    let combined =
        model_pricing("mistral/codestral-latest").expect("mistral/codestral-latest must resolve via combined key");
    assert!((combined.input_cost_per_token - 0.0000003).abs() < 1e-12);
    assert!((combined.output_cost_per_token - 0.0000009).abs() < 1e-12);

    assert!(
        model_pricing("codestral-latest").is_none(),
        "non-primary-provider models must not be reachable via a bare name"
    );
    assert!(
        completion_cost("codestral-latest", 100, 50).is_none(),
        "completion_cost must not resolve a non-primary-provider bare name"
    );
}