From 66dfaa5e6feb694492db4c6224c9f38fc7f956fa Mon Sep 17 00:00:00 2001 From: Rubin Simons Date: Sun, 18 Oct 2026 19:09:08 +0200 Subject: [PATCH] fix(providers): only read stream usage.cost when declared `usage.cost` is not part of the OpenAI chat completions spec, and not all gateways use the same unit. For example, OpenRouter reports USD while Cortecs reports credits where 1 EUR = 1,000,000 credits, which in the current implementation inflated billed session costs by 1,000,000x. This change gates `usage.cost` behind `openai.usage_cost` (which by default is `false`) and enables it in the OpenRouter plugin so other OpenAI-compatible and models.dev catalog providers price turns from their rate tables. Before this change, after one interaction with this and select other models.dev/cortecs models, I had 16000+ USD on the statusline; after this change it showed a correctly verified 0.017 USD. Closes #1286. --- maki-lua/src/api/provider.rs | 2 + maki-lua/tests/provider_replay/main.rs | 40 +++++++++++++- maki-providers/src/providers/aperture.rs | 1 + maki-providers/src/providers/catalog.rs | 1 + maki-providers/src/providers/codec.rs | 15 ++++++ maki-providers/src/providers/copilot/mod.rs | 1 + maki-providers/src/providers/local.rs | 2 + .../src/providers/openai/platform.rs | 1 + maki-providers/src/providers/openai_compat.rs | 54 +++++++++++-------- maki-providers/src/providers/xai/platform.rs | 1 + maki-providers/src/providers/zai/mod.rs | 1 + plugins/openrouter/init.lua | 1 + site/docs/content/lua-api/_index.md | 2 + 13 files changed, 99 insertions(+), 23 deletions(-) diff --git a/maki-lua/src/api/provider.rs b/maki-lua/src/api/provider.rs index 13cd343448..077026219d 100644 --- a/maki-lua/src/api/provider.rs +++ b/maki-lua/src/api/provider.rs @@ -701,6 +701,8 @@ fn is_selected(_lua: &Lua, #[ctx] plugin: Arc, slug: String) -> LuaResult Model, model_id: &str ); assert_eq!(model.supports_thinking(), thinks); } + +#[test_case(OPENROUTER, "openrouter/openai/gpt-5.5", Some(STREAMED_COST) ; "openrouter_reads_usage_cost")] +#[test_case(MISTRAL, "mistral/mistral-medium-latest", None ; "mistral_ignores_usage_cost")] +fn only_opted_in_providers_read_usage_cost(slug: &str, spec: &str, expected: Option) { + let _state = isolated_state(); + let (base_url, _requests) = serve(COST_SSE); + unsafe { std::env::set_var(base_url_env_var(slug), base_url) }; + let _host = load_bundled(slug); + let key_env = plugin::spec(slug).expect(NOT_BUILT).api_key_env; + unsafe { std::env::set_var(key_env, MISTRAL_KEY) }; + + let provider = plugin::create(slug, Timeouts::default()).expect(NOT_BUILT); + let model = Model::from_spec(spec).expect(UNKNOWN_MODEL); + let (tx, _rx) = flume::unbounded(); + let resp = smol::block_on(provider.stream_message( + &model, + &[Message::user(PROMPT.into())], + "", + &json!([]), + &tx, + RequestOptions::default(), + None, + )) + .expect(STREAM_FAILED); + + assert_eq!(resp.usage.cost, expected); +} diff --git a/maki-providers/src/providers/aperture.rs b/maki-providers/src/providers/aperture.rs index 8e08ebb4c9..3639da96f7 100644 --- a/maki-providers/src/providers/aperture.rs +++ b/maki-providers/src/providers/aperture.rs @@ -45,6 +45,7 @@ static CONFIG: OpenAiCompatConfig = OpenAiCompatConfig { base_url: Cow::Borrowed(""), max_tokens_field: Cow::Borrowed(MAX_TOKENS_FIELD), include_stream_usage: true, + usage_cost: false, provider_name: Cow::Borrowed(DISPLAY_NAME), }; diff --git a/maki-providers/src/providers/catalog.rs b/maki-providers/src/providers/catalog.rs index 17a36e15c8..72745b9669 100644 --- a/maki-providers/src/providers/catalog.rs +++ b/maki-providers/src/providers/catalog.rs @@ -449,6 +449,7 @@ static CATALOG_PROVIDER_CONFIG: OpenAiCompatConfig = OpenAiCompatConfig { base_url: Cow::Borrowed(""), max_tokens_field: Cow::Borrowed(DEFAULT_MAX_TOKENS_FIELD), include_stream_usage: true, + usage_cost: false, provider_name: Cow::Borrowed("catalog"), }; diff --git a/maki-providers/src/providers/codec.rs b/maki-providers/src/providers/codec.rs index a8de349418..44bdbf31ba 100644 --- a/maki-providers/src/providers/codec.rs +++ b/maki-providers/src/providers/codec.rs @@ -63,6 +63,8 @@ pub struct OpenAiWire { /// `None` asks for `stream_options.include_usage`, which every openai /// endpoint worth billing for supports. pub include_stream_usage: Option, + /// Read `usage.cost` from the stream as the turn's USD bill. + pub usage_cost: bool, /// How this provider's API spells reasoning effort. Stating it replaces /// the generic thinking pass, see [`Self::apply_body`]. pub thinking: Option, @@ -140,6 +142,7 @@ impl OpenAiWire { let Self { max_tokens_field: _, include_stream_usage: _, + usage_cost: _, thinking, headers: _, extra_body, @@ -177,6 +180,7 @@ impl OpenAiWire { let Self { max_tokens_field: _, include_stream_usage: _, + usage_cost: _, thinking: _, headers, extra_body: _, @@ -434,6 +438,7 @@ fn compat_config(options: &CodecOptions) -> OpenAiCompatConfig { .clone() .map_or(Cow::Borrowed(DEFAULT_MAX_TOKENS_FIELD), Cow::Owned), include_stream_usage: options.openai.include_stream_usage.unwrap_or(true), + usage_cost: options.openai.usage_cost, provider_name: options.provider_name.clone(), } } @@ -848,4 +853,14 @@ mod tests { .unwrap(); assert_eq!(wire.thinking_override(model_id), expected); } + + #[test_case(json!({}), false ; "off_by_default")] + #[test_case(json!({ "usage_cost": true }), true ; "enabled_when_declared")] + fn compat_config_forwards_usage_cost(authored: Value, expected: bool) { + let options = CodecOptions { + openai: wire(authored).unwrap(), + ..CodecOptions::new(Protocol::Openai, DECLARED_SLUG) + }; + assert_eq!(compat_config(&options).usage_cost, expected); + } } diff --git a/maki-providers/src/providers/copilot/mod.rs b/maki-providers/src/providers/copilot/mod.rs index c1dd84bd2b..b116cdff15 100644 --- a/maki-providers/src/providers/copilot/mod.rs +++ b/maki-providers/src/providers/copilot/mod.rs @@ -265,6 +265,7 @@ impl Copilot { BufReader::new(response.into_body()), event_tx, self.stream_timeout, + false, ) .await } else { diff --git a/maki-providers/src/providers/local.rs b/maki-providers/src/providers/local.rs index fc71188f7c..fdab804950 100644 --- a/maki-providers/src/providers/local.rs +++ b/maki-providers/src/providers/local.rs @@ -551,6 +551,7 @@ pub(crate) const OLLAMA: LocalEndpointConfig = LocalEndpointConfig { base_url: Cow::Borrowed(OLLAMA_BASE_URL), max_tokens_field: Cow::Borrowed(DEFAULT_MAX_TOKENS_FIELD), include_stream_usage: true, + usage_cost: false, provider_name: Cow::Borrowed("Ollama"), }, // Ollama ignores the budget field, so effort is the only thing it hears. @@ -572,6 +573,7 @@ pub(crate) const LLAMACPP: LocalEndpointConfig = LocalEndpointConfig { base_url: Cow::Borrowed(LLAMACPP_BASE_URL), max_tokens_field: Cow::Borrowed(DEFAULT_MAX_TOKENS_FIELD), include_stream_usage: true, + usage_cost: false, provider_name: Cow::Borrowed("LlamaCpp"), }, thinking_fallback: ThinkingFallback::BudgetField, diff --git a/maki-providers/src/providers/openai/platform.rs b/maki-providers/src/providers/openai/platform.rs index 1a320e79d3..5dfb117b32 100644 --- a/maki-providers/src/providers/openai/platform.rs +++ b/maki-providers/src/providers/openai/platform.rs @@ -29,6 +29,7 @@ static CONFIG: OpenAiCompatConfig = OpenAiCompatConfig { base_url: Cow::Borrowed("https://api.openai.com/v1"), max_tokens_field: Cow::Borrowed("max_completion_tokens"), include_stream_usage: true, + usage_cost: false, provider_name: Cow::Borrowed("OpenAI"), }; diff --git a/maki-providers/src/providers/openai_compat.rs b/maki-providers/src/providers/openai_compat.rs index 92423a612f..7c1b22fee3 100644 --- a/maki-providers/src/providers/openai_compat.rs +++ b/maki-providers/src/providers/openai_compat.rs @@ -51,6 +51,7 @@ pub(crate) struct OpenAiCompatConfig { pub base_url: Cow<'static, str>, pub max_tokens_field: Cow<'static, str>, pub include_stream_usage: bool, + pub usage_cost: bool, pub provider_name: Cow<'static, str>, } @@ -265,6 +266,7 @@ impl OpenAiCompatProvider { BufReader::new(response.into_body()), event_tx, self.stream_timeout, + self.config.usage_cost, ) .await } else { @@ -608,6 +610,7 @@ pub async fn parse_sse( reader: impl AsyncBufRead + Unpin, event_tx: &Sender, stream_timeout: Duration, + usage_cost: bool, ) -> Result { let mut lines = reader.lines(); @@ -656,6 +659,7 @@ pub async fn parse_sse( cache_creation: 0, cost: u .cost + .filter(|_| usage_cost) .or_else(|| chunk.copilot_usage.as_ref().and_then(copilot_usd)), }; } @@ -902,7 +906,7 @@ data: {\"choices\":[{\"finish_reason\":\"stop\",\"delta\":{}}],\"usage\":{\"prom data: [DONE]\n"; let (tx, rx) = flume::unbounded(); - let resp = parse_sse(Cursor::new(sse.as_bytes()), &tx, TEST_STREAM_TIMEOUT) + let resp = parse_sse(Cursor::new(sse.as_bytes()), &tx, TEST_STREAM_TIMEOUT, false) .await .unwrap(); @@ -934,7 +938,7 @@ data: {\"choices\":[{\"finish_reason\":\"stop\",\"delta\":{}}],\"usage\":{\"prom data: [DONE]\n"; let (tx, _rx) = flume::unbounded(); - let resp = parse_sse(Cursor::new(sse.as_bytes()), &tx, TEST_STREAM_TIMEOUT) + let resp = parse_sse(Cursor::new(sse.as_bytes()), &tx, TEST_STREAM_TIMEOUT, false) .await .unwrap(); @@ -946,12 +950,13 @@ data: [DONE]\n"; /// OpenRouter quotes the bill on the same chunk as the token counts, so the /// counts have to come through whatever it says the price is. - #[test_case(json!(0.00045), Some(0.00045) ; "number")] - #[test_case(json!("0.00045"), Some(0.00045) ; "quoted_number")] - #[test_case(json!("free"), None ; "unparsable_string")] - #[test_case(json!({"usd": 0.00045}), None ; "unknown_shape")] - #[test_case(json!(null), None ; "null")] - fn parse_sse_cost_from_usage(cost: Value, expected: Option) { + #[test_case(json!(0.00045), true, Some(0.00045) ; "number")] + #[test_case(json!("0.00045"), true, Some(0.00045) ; "quoted_number")] + #[test_case(json!(67807), false, None ; "ignored_when_disabled")] + #[test_case(json!("free"), true, None ; "unparsable_string")] + #[test_case(json!({"usd": 0.00045}), true, None ; "unknown_shape")] + #[test_case(json!(null), true, None ; "null")] + fn parse_sse_cost_from_usage(cost: Value, usage_cost: bool, expected: Option) { smol::block_on(async { let chunk = json!({ "choices": [{"finish_reason": "stop", "delta": {}}], @@ -960,9 +965,14 @@ data: [DONE]\n"; let sse = format!("data: {chunk}\n\ndata: [DONE]\n"); let (tx, _rx) = flume::unbounded(); - let resp = parse_sse(Cursor::new(sse.as_bytes()), &tx, TEST_STREAM_TIMEOUT) - .await - .unwrap(); + let resp = parse_sse( + Cursor::new(sse.as_bytes()), + &tx, + TEST_STREAM_TIMEOUT, + usage_cost, + ) + .await + .unwrap(); assert_eq!(resp.usage.input, 100, "{COUNTS_SURVIVE_A_BAD_COST}"); assert_eq!(resp.usage.output, 10, "{COUNTS_SURVIVE_A_BAD_COST}"); @@ -988,7 +998,7 @@ data: [DONE]\n"; let sse = format!("data: {chunk}\n\ndata: [DONE]\n"); let (tx, _rx) = flume::unbounded(); - let resp = parse_sse(Cursor::new(sse.as_bytes()), &tx, TEST_STREAM_TIMEOUT) + let resp = parse_sse(Cursor::new(sse.as_bytes()), &tx, TEST_STREAM_TIMEOUT, false) .await .unwrap(); @@ -1021,7 +1031,7 @@ data: {\"choices\":[{\"finish_reason\":\"stop\",\"delta\":{}}],\"usage\":{\"prom data: [DONE]\n"; let (tx, rx) = flume::unbounded(); - let resp = parse_sse(Cursor::new(sse.as_bytes()), &tx, TEST_STREAM_TIMEOUT) + let resp = parse_sse(Cursor::new(sse.as_bytes()), &tx, TEST_STREAM_TIMEOUT, false) .await .unwrap(); @@ -1055,7 +1065,7 @@ data: [DONE]\n"; let sse = format!("data: {{\"choices\":[{{\"delta\":{delta}}}]}}\n\ndata: [DONE]\n"); let (tx, _rx) = flume::unbounded(); - let resp = parse_sse(Cursor::new(sse.as_bytes()), &tx, TEST_STREAM_TIMEOUT) + let resp = parse_sse(Cursor::new(sse.as_bytes()), &tx, TEST_STREAM_TIMEOUT, false) .await .unwrap(); @@ -1163,7 +1173,7 @@ data: {\"choices\":[{\"finish_reason\":\"tool_calls\",\"delta\":{}}],\"usage\":{ data: [DONE]\n"; let (tx, rx) = flume::unbounded(); - let resp = parse_sse(Cursor::new(sse.as_bytes()), &tx, TEST_STREAM_TIMEOUT) + let resp = parse_sse(Cursor::new(sse.as_bytes()), &tx, TEST_STREAM_TIMEOUT, false) .await .unwrap(); @@ -1198,7 +1208,7 @@ data: [DONE]\n"; data: {\"error\":{\"message\":\"Server overloaded\",\"type\":\"overloaded_error\"}}\n"; let (tx, _rx) = flume::unbounded(); - let err = parse_sse(Cursor::new(sse.as_bytes()), &tx, TEST_STREAM_TIMEOUT) + let err = parse_sse(Cursor::new(sse.as_bytes()), &tx, TEST_STREAM_TIMEOUT, false) .await .unwrap_err(); @@ -1225,7 +1235,7 @@ data: {\"choices\":[{\"finish_reason\":\"tool_calls\",\"delta\":{}}],\"usage\":{ data: [DONE]\n"; let (tx, _rx) = flume::unbounded(); - let resp = parse_sse(Cursor::new(sse.as_bytes()), &tx, TEST_STREAM_TIMEOUT) + let resp = parse_sse(Cursor::new(sse.as_bytes()), &tx, TEST_STREAM_TIMEOUT, false) .await .unwrap(); @@ -1246,6 +1256,7 @@ data: [DONE]\n"; Cursor::new(TWO_UNNAMED_TOOL_CALLS_SSE.as_bytes()), &tx, TEST_STREAM_TIMEOUT, + false, ) .await .unwrap(); @@ -1304,7 +1315,7 @@ data: {\"choices\":[{\"finish_reason\":\"tool_calls\",\"delta\":{}}],\"usage\":{ data: [DONE]\n"; let (tx, _rx) = flume::unbounded(); - let resp = parse_sse(Cursor::new(sse.as_bytes()), &tx, TEST_STREAM_TIMEOUT) + let resp = parse_sse(Cursor::new(sse.as_bytes()), &tx, TEST_STREAM_TIMEOUT, false) .await .unwrap(); @@ -1332,7 +1343,7 @@ data: {\"choices\":[{\"finish_reason\":\"tool_calls\",\"delta\":{}}],\"usage\":{ data: [DONE]\n"; let (tx, _rx) = flume::unbounded(); - let resp = parse_sse(Cursor::new(sse.as_bytes()), &tx, TEST_STREAM_TIMEOUT) + let resp = parse_sse(Cursor::new(sse.as_bytes()), &tx, TEST_STREAM_TIMEOUT, false) .await .unwrap(); @@ -1436,7 +1447,7 @@ data: [DONE]\n"; smol::block_on(async { let sse = "data: [DONE]\n"; let (tx, _rx) = flume::unbounded(); - let resp = parse_sse(Cursor::new(sse.as_bytes()), &tx, TEST_STREAM_TIMEOUT) + let resp = parse_sse(Cursor::new(sse.as_bytes()), &tx, TEST_STREAM_TIMEOUT, false) .await .unwrap(); assert!(resp.message.content.is_empty()); @@ -1459,7 +1470,7 @@ data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\ data: [DONE]\n"; let (tx, rx) = flume::unbounded(); - let resp = parse_sse(Cursor::new(sse.as_bytes()), &tx, TEST_STREAM_TIMEOUT) + let resp = parse_sse(Cursor::new(sse.as_bytes()), &tx, TEST_STREAM_TIMEOUT, false) .await .unwrap(); @@ -1493,6 +1504,7 @@ data: [DONE]\n"; base_url: Cow::Borrowed("https://example.test/v1"), max_tokens_field: Cow::Borrowed(DEFAULT_MAX_TOKENS_FIELD), include_stream_usage: true, + usage_cost: false, provider_name: Cow::Borrowed("test"), }; diff --git a/maki-providers/src/providers/xai/platform.rs b/maki-providers/src/providers/xai/platform.rs index 4bf068ee07..69b33e5752 100644 --- a/maki-providers/src/providers/xai/platform.rs +++ b/maki-providers/src/providers/xai/platform.rs @@ -27,6 +27,7 @@ static CONFIG: OpenAiCompatConfig = OpenAiCompatConfig { base_url: Cow::Borrowed("https://api.x.ai/v1"), max_tokens_field: Cow::Borrowed(DEFAULT_MAX_TOKENS_FIELD), include_stream_usage: true, + usage_cost: false, provider_name: Cow::Borrowed("xAI"), }; diff --git a/maki-providers/src/providers/zai/mod.rs b/maki-providers/src/providers/zai/mod.rs index 822eadc74c..ef95a968f8 100644 --- a/maki-providers/src/providers/zai/mod.rs +++ b/maki-providers/src/providers/zai/mod.rs @@ -39,6 +39,7 @@ static CONFIG_STANDARD: OpenAiCompatConfig = OpenAiCompatConfig { base_url: Cow::Borrowed(BASE_URL), max_tokens_field: Cow::Borrowed(MAX_TOKENS_FIELD), include_stream_usage: false, + usage_cost: false, provider_name: Cow::Borrowed(DISPLAY_NAME), }; diff --git a/plugins/openrouter/init.lua b/plugins/openrouter/init.lua index b29c0e1f82..26f244b1a2 100644 --- a/plugins/openrouter/init.lua +++ b/plugins/openrouter/init.lua @@ -109,6 +109,7 @@ maki.provider.register({ .. "e.g. `openrouter/anthropic/claude-sonnet-4`.", }, openai = { + usage_cost = true, -- `prefer-high` is only for models the listing never described. Each listed -- model narrows it with the `effort` on its row. thinking = { dialect = "prefer-high", field = "reasoning.effort", requires_support = true }, diff --git a/site/docs/content/lua-api/_index.md b/site/docs/content/lua-api/_index.md index 2e63a5a954..5dc08d8395 100644 --- a/site/docs/content/lua-api/_index.md +++ b/site/docs/content/lua-api/_index.md @@ -4288,6 +4288,8 @@ it does for a built-in provider. Defaults to `max_tokens`. - `include_stream_usage` (`boolean`) Ask for usage on the stream. Defaults to `true`. + - `usage_cost` (`boolean`) Price turns from `usage.cost` on the stream + (in USD). Defaults to `false`. - `thinking` (`table`) How the API spells reasoning effort. Without it, each model's `thinking_fields` decides. - `dialect` (`string`) Required. One of `"standard"`, `"codex"`,