You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/23 09:34:06 UTC

(maka) 03/09: feat(pricing): add revisioned native rate catalog

This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git

commit defd2ed756c047f90bf9ab13c02a9735aa918771
Author: Wang <m4...@gmail.com>
AuthorDate: Wed Sep 23 15:38:22 2026 +0800

    feat(pricing): add revisioned native rate catalog
    
    Generated-by: Codex
---
 crates/config/build.rs                             |   1 +
 crates/config/migrations/0001_configuration.sql    |  11 +
 crates/config/src/database.rs                      |   1 +
 crates/config/src/lib.rs                           |   1 +
 crates/config/src/pricing.rs                       | 168 +++++++++++++
 crates/config/src/pricing/catalog.rs               |  92 +++++++
 crates/config/tests/pricing.rs                     | 253 ++++++++++++++++++++
 crates/protocol/src/lib.rs                         |   1 +
 crates/protocol/src/pricing.rs                     | 136 +++++++++++
 crates/runtime-host/src/server.rs                  |   1 +
 crates/runtime-host/src/server/authority.rs        |   2 +
 crates/runtime-host/src/server/dispatch.rs         |   3 +
 crates/runtime-host/src/server/operations.rs       |  14 ++
 crates/runtime-host/src/server/pricing.rs          |  55 +++++
 .../tests/integration/client_pricing.rs}           |  49 ++--
 crates/runtime-host/tests/integration/main.rs      |   1 +
 crates/runtime/src/lib.rs                          |   1 +
 crates/runtime/src/pricing.rs                      | 266 +++++++++++++++++++++
 docs/rust-parity.md                                |   2 +-
 docs/rust-parity.zh-CN.md                          |   2 +-
 docs/rust-runtime.md                               |   4 +-
 docs/rust-runtime.zh-CN.md                         |   3 +-
 scripts/rust/catalog-facts-entry.mjs               |   5 +
 scripts/rust/generate-catalog-facts.mjs            |  19 ++
 tests/fixtures/client-pricing-entry.mjs            | 120 ++++++++++
 tests/fixtures/client.mjs                          |   1 +
 26 files changed, 1178 insertions(+), 34 deletions(-)

diff --git a/crates/config/build.rs b/crates/config/build.rs
index 682d044606..47c4973eac 100644
--- a/crates/config/build.rs
+++ b/crates/config/build.rs
@@ -23,6 +23,7 @@ fn main() {
         "../../scripts/rust/generate-catalog-facts.mjs",
         "../../scripts/rust/catalog-facts-entry.mjs",
         "../../packages/core/src",
+        "../../packages/runtime/src/telemetry/builtin-pricing.ts",
         "../../scripts/sync-model-metadata.mjs",
         "../../scripts/model-metadata/models-dev-api.snapshot.json",
         "../../package-lock.json",
diff --git a/crates/config/migrations/0001_configuration.sql b/crates/config/migrations/0001_configuration.sql
index a0646847bc..1c7deca824 100644
--- a/crates/config/migrations/0001_configuration.sql
+++ b/crates/config/migrations/0001_configuration.sql
@@ -23,6 +23,17 @@ CREATE TABLE connection_catalog (
     default_target TEXT
 );
 INSERT INTO connection_catalog VALUES(1, 0, NULL);
+CREATE TABLE pricing_authority (
+    singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
+    revision INTEGER NOT NULL CHECK(revision BETWEEN 0 AND 9007199254740991),
+    builtin_digest TEXT
+);
+INSERT INTO pricing_authority VALUES(1, 0, NULL);
+CREATE TABLE pricing_overrides (
+    model_key TEXT PRIMARY KEY,
+    sort_key BLOB NOT NULL,
+    record_json TEXT NOT NULL CHECK(json_valid(record_json) AND length(CAST(record_json AS BLOB)) <= 4096)
+);
 CREATE TABLE connections (
     connection_id TEXT PRIMARY KEY,
     slug TEXT NOT NULL UNIQUE,
diff --git a/crates/config/src/database.rs b/crates/config/src/database.rs
index c97d0b406c..8c3fb2584e 100644
--- a/crates/config/src/database.rs
+++ b/crates/config/src/database.rs
@@ -139,5 +139,6 @@ async fn initialize(connection: &mut SqliteConnection) -> std::result::Result<()
     )
     .execute(&mut *connection)
     .await?;
+    crate::pricing::initialize(connection).await?;
     Ok(())
 }
diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs
index ff6bb02863..6ffc73c06c 100644
--- a/crates/config/src/lib.rs
+++ b/crates/config/src/lib.rs
@@ -33,6 +33,7 @@ pub mod onboarding;
 pub mod plugin_authorization;
 mod plugin_credentials;
 mod policy;
+pub mod pricing;
 pub mod projection;
 mod vault;
 
diff --git a/crates/config/src/pricing.rs b/crates/config/src/pricing.rs
new file mode 100644
index 0000000000..39bd98efa7
--- /dev/null
+++ b/crates/config/src/pricing.rs
@@ -0,0 +1,168 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+mod catalog;
+pub(crate) use catalog::initialize;
+
+use crate::{ConfigError, ConfigurationStore, Result, TransactionMode, database::unsigned};
+use maka_runtime::pricing::{Entry, Mutation, Page, Pricing, Query, ResetEffect, Update, Updated};
+use sqlx::{Row, SqliteConnection};
+
+const MAX_REVISION: u64 = 9_007_199_254_740_991;
+const PAGE_ITEMS: usize = 128;
+// Reserve envelope space; each admitted record is bounded independently.
+const PAGE_ENTRY_BYTES: usize = 47 * 1024;
+
+impl ConfigurationStore {
+    /// Continuations are valid only while both bundled and custom rates are unchanged.
+    pub async fn query_pricing(&self, query: Query) -> Result<Page> {
+        self.transaction(TransactionMode::Deferred, move |connection| Box::pin(async move {
+            let revision = revision(connection).await?;
+            let offset = match query {
+                Query::Start => 0,
+                Query::Continue { revision: expected, offset } => {
+                    if expected > MAX_REVISION || offset > i64::MAX as u64 {
+                        return Err(invalid("invalid pricing continuation"));
+                    }
+                    if expected != revision {
+                        return Ok(Page::RevisionChanged { expected_revision: expected, actual_revision: revision });
+                    }
+                    let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM effective_pricing")
+                        .fetch_one(&mut *connection).await?;
+                    if offset >= unsigned(count)? {
+                        return Err(invalid("pricing offset is outside the catalog"));
+                    }
+                    offset
+                }
+            };
+            let rows = sqlx::query(
+                "SELECT record_json, custom, builtin FROM effective_pricing ORDER BY sort_key LIMIT ? OFFSET ?"
+            ).bind((PAGE_ITEMS + 1) as i64).bind(offset as i64)
+                .fetch_all(&mut *connection).await?;
+            let mut entries = Vec::new();
+            let mut bytes = 0;
+            let mut more = false;
+            for row in rows {
+                let pricing = serde_json::from_str(row.try_get("record_json")?)?;
+                let entry = if row.try_get::<bool, _>("custom")? {
+                    Entry::Custom { pricing, reset_effect: if row.try_get::<bool, _>("builtin")? {
+                        ResetEffect::RestoreBuiltin
+                    } else { ResetEffect::BecomeUnpriced } }
+                } else { Entry::Builtin { pricing } };
+                let size = serde_json::to_vec(&entry)?.len() + 1;
+                if entries.len() == PAGE_ITEMS || bytes + size > PAGE_ENTRY_BYTES {
+                    more = true;
+                    break;
+                }
+                bytes += size;
+                entries.push(entry);
+            }
+            let next_offset = more.then_some(offset + entries.len() as u64);
+            Ok(Page::Page { revision, offset, entries, next_offset })
+        })).await
+    }
+
+    /// A quote is a value, not a live lookup. Persist it with the model accounting fact.
+    pub async fn model_pricing(&self, model_key: String) -> Result<(u64, Option<Pricing>)> {
+        maka_runtime::pricing::validate_key(&model_key).map_err(invalid)?;
+        self.transaction(TransactionMode::Deferred, move |connection| {
+            Box::pin(async move {
+                let revision = revision(connection).await?;
+                let record: Option<String> = sqlx::query_scalar(
+                    "SELECT record_json FROM effective_pricing WHERE model_key = ?",
+                )
+                .bind(model_key)
+                .fetch_optional(&mut *connection)
+                .await?;
+                Ok((
+                    revision,
+                    record
+                        .map(|record| serde_json::from_str(&record))
+                        .transpose()?,
+                ))
+            })
+        })
+        .await
+    }
+
+    pub async fn update_pricing(&self, update: Update) -> Result<Updated> {
+        if update.expected_revision > MAX_REVISION {
+            return Err(invalid("invalid pricing revision"));
+        }
+        match &update.mutation {
+            Mutation::Upsert { pricing } => pricing.validate().map_err(invalid)?,
+            Mutation::Delete { model_key } => {
+                maka_runtime::pricing::validate_key(model_key).map_err(invalid)?
+            }
+        }
+        self.transaction(TransactionMode::Immediate, move |connection| Box::pin(async move {
+            let current = revision(connection).await?;
+            if current != update.expected_revision {
+                return Ok(Updated::RevisionConflict {
+                    expected_revision: update.expected_revision, actual_revision: current,
+                });
+            }
+            let changed = match update.mutation {
+                Mutation::Upsert { pricing } => {
+                    let old: Option<String> = sqlx::query_scalar(
+                        "SELECT record_json FROM pricing_overrides WHERE model_key = ?"
+                    ).bind(&pricing.model_key).fetch_optional(&mut *connection).await?;
+                    if old.as_deref().map(serde_json::from_str::<Pricing>).transpose()?.as_ref() == Some(&pricing) {
+                        false
+                    } else {
+                        let record = serde_json::to_string(&pricing)?;
+                        if record.len() > 4096 { return Err(invalid("model price exceeds record limit")); }
+                        sqlx::query(
+                            "INSERT INTO pricing_overrides(model_key, sort_key, record_json) VALUES(?, ?, ?)
+                             ON CONFLICT(model_key) DO UPDATE SET record_json = excluded.record_json"
+                        ).bind(&pricing.model_key).bind(sort_key(&pricing.model_key)).bind(record)
+                            .execute(&mut *connection).await?;
+                        true
+                    }
+                }
+                Mutation::Delete { model_key } => sqlx::query(
+                    "DELETE FROM pricing_overrides WHERE model_key = ?"
+                ).bind(model_key).execute(&mut *connection).await?.rows_affected() != 0,
+            };
+            if !changed { return Ok(Updated::Unchanged { revision: current }); }
+            let revision = current.checked_add(1).filter(|revision| *revision <= MAX_REVISION)
+                .ok_or_else(|| invalid("pricing revision exhausted"))?;
+            sqlx::query("UPDATE pricing_authority SET revision = ? WHERE singleton = 1")
+                .bind(revision as i64).execute(&mut *connection).await?;
+            Ok(Updated::Committed { revision })
+        })).await
+    }
+}
+
+async fn revision(connection: &mut SqliteConnection) -> Result<u64> {
+    unsigned(
+        sqlx::query_scalar("SELECT revision FROM pricing_authority WHERE singleton = 1")
+            .fetch_one(connection)
+            .await?,
+    )
+}
+
+fn invalid(message: impl Into<String>) -> ConfigError {
+    ConfigError::Invalid(message.into())
+}
+
+// Big-endian UTF-16 preserves the client's exact, locale-independent key ordering.
+fn sort_key(key: &str) -> Vec<u8> {
+    key.encode_utf16().flat_map(u16::to_be_bytes).collect()
+}
diff --git a/crates/config/src/pricing/catalog.rs b/crates/config/src/pricing/catalog.rs
new file mode 100644
index 0000000000..c111becf2c
--- /dev/null
+++ b/crates/config/src/pricing/catalog.rs
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//! Disposable bundled-rate projection. Only overrides and the revision are durable.
+use super::{MAX_REVISION, sort_key};
+use maka_event_log::StoreError;
+use maka_runtime::pricing::Pricing;
+use sqlx::{Connection, QueryBuilder, SqliteConnection};
+use std::sync::LazyLock;
+
+const FACTS: &str = include_str!(concat!(env!("OUT_DIR"), "/pricing-facts.json"));
+const DIGEST: &str = include_str!(concat!(env!("OUT_DIR"), "/pricing-digest.txt"));
+static PRICES: LazyLock<Vec<Pricing>> = LazyLock::new(|| {
+    let prices: Vec<Pricing> = serde_json::from_str(FACTS).expect("generated model rates");
+    for price in &prices {
+        price.validate().expect("valid generated model rate");
+    }
+    prices
+});
+
+pub(crate) async fn initialize(connection: &mut SqliteConnection) -> Result<(), StoreError> {
+    let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
+    sqlx::raw_sql(
+        "CREATE TEMP TABLE builtin_pricing (
+            model_key TEXT PRIMARY KEY, sort_key BLOB NOT NULL, record_json TEXT NOT NULL
+        );
+        CREATE TEMP VIEW effective_pricing AS
+            SELECT b.model_key, b.sort_key, b.record_json, 0 AS custom, 1 AS builtin
+            FROM builtin_pricing b WHERE NOT EXISTS (
+                SELECT 1 FROM main.pricing_overrides p WHERE p.model_key = b.model_key
+            )
+            UNION ALL
+            SELECT p.model_key, p.sort_key, p.record_json, 1 AS custom,
+                EXISTS(SELECT 1 FROM builtin_pricing b WHERE b.model_key = p.model_key) AS builtin
+            FROM main.pricing_overrides p;",
+    )
+    .execute(&mut *tx)
+    .await?;
+    for chunk in PRICES.chunks(128) {
+        let mut query =
+            QueryBuilder::new("INSERT INTO builtin_pricing(model_key, sort_key, record_json) ");
+        query.push_values(chunk, |mut row, pricing| {
+            row.push_bind(&pricing.model_key)
+                .push_bind(sort_key(&pricing.model_key))
+                .push_bind(serde_json::to_string(pricing).expect("finite generated rates"));
+        });
+        query.build().execute(&mut *tx).await?;
+    }
+    // An application update can change bundled rates without changing overrides.
+    // Invalidate previously issued cursors and distinguish newly captured quotes.
+    let changed = sqlx::query(
+        "UPDATE pricing_authority
+         SET revision = revision + CASE WHEN builtin_digest IS NULL THEN 0 ELSE 1 END,
+             builtin_digest = ?
+         WHERE singleton = 1 AND builtin_digest IS NOT ?
+           AND (builtin_digest IS NULL OR revision < ?)",
+    )
+    .bind(DIGEST)
+    .bind(DIGEST)
+    .bind(MAX_REVISION as i64)
+    .execute(&mut *tx)
+    .await?;
+    if changed.rows_affected() == 0 {
+        let digest: String =
+            sqlx::query_scalar("SELECT builtin_digest FROM pricing_authority WHERE singleton = 1")
+                .fetch_one(&mut *tx)
+                .await?;
+        if digest != DIGEST {
+            return Err(StoreError::InvalidTransition(
+                "pricing revision exhausted".into(),
+            ));
+        }
+    }
+    tx.commit().await?;
+    Ok(())
+}
diff --git a/crates/config/tests/pricing.rs b/crates/config/tests/pricing.rs
new file mode 100644
index 0000000000..33332d5721
--- /dev/null
+++ b/crates/config/tests/pricing.rs
@@ -0,0 +1,253 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use maka_config::ConfigurationStore;
+use maka_event_log::root::{RootNamespaces, RootOwner};
+use maka_runtime::pricing::{Entry, Mutation, Page, Pricing, Query, ResetEffect, Update, Updated};
+use std::sync::Arc;
+
+async fn entries(store: &ConfigurationStore) -> (u64, Vec<Entry>) {
+    let mut query = Query::Start;
+    let mut all = Vec::new();
+    loop {
+        let page = store.query_pricing(query).await.unwrap();
+        assert!(serde_json::to_vec(&page).unwrap().len() <= 48 * 1024);
+        let Page::Page {
+            revision,
+            offset,
+            entries,
+            next_offset,
+        } = page
+        else {
+            panic!("stable snapshot")
+        };
+        assert_eq!(offset, all.len() as u64);
+        assert!(entries.len() <= 128);
+        all.extend(entries);
+        match next_offset {
+            Some(offset) => query = Query::Continue { revision, offset },
+            None => return (revision, all),
+        }
+    }
+}
+
+fn price(entry: &Entry) -> &Pricing {
+    match entry {
+        Entry::Builtin { pricing } | Entry::Custom { pricing, .. } => pricing,
+    }
+}
+
+#[tokio::test]
+async fn pricing_cas_pages_and_quotes_survive_reopen_and_bundled_rate_replacement() {
+    let temp = tempfile::tempdir().unwrap();
+    let owner = Arc::new(
+        RootOwner::create(
+            &temp.path().join("root"),
+            &RootNamespaces {
+                ownership: temp.path().join("owners"),
+                control: temp.path().join("control"),
+            },
+        )
+        .unwrap(),
+    );
+    let store = ConfigurationStore::for_root(owner.clone()).await.unwrap();
+    let (revision, initial) = entries(&store).await;
+    assert_eq!(revision, 0);
+    assert!(
+        initial.len() > 128,
+        "exercise pagination against the real offline catalog"
+    );
+    let original = price(&initial[0]).clone();
+    let mut custom = original.clone();
+    custom.input_usd_per_million += 10.0;
+    let update = Update {
+        expected_revision: 0,
+        mutation: Mutation::Upsert {
+            pricing: custom.clone(),
+        },
+    };
+    assert_eq!(
+        store.update_pricing(update.clone()).await.unwrap(),
+        Updated::Committed { revision: 1 }
+    );
+    // A lost mutation reply cannot overwrite a later state on retry.
+    assert_eq!(
+        store.update_pricing(update).await.unwrap(),
+        Updated::RevisionConflict {
+            expected_revision: 0,
+            actual_revision: 1
+        }
+    );
+    assert!(matches!(
+        store
+            .query_pricing(Query::Continue {
+                revision: 0,
+                offset: 128
+            })
+            .await
+            .unwrap(),
+        Page::RevisionChanged {
+            expected_revision: 0,
+            actual_revision: 1
+        }
+    ));
+    assert_eq!(
+        store
+            .update_pricing(Update {
+                expected_revision: 1,
+                mutation: Mutation::Upsert {
+                    pricing: custom.clone()
+                },
+            })
+            .await
+            .unwrap(),
+        Updated::Unchanged { revision: 1 }
+    );
+    let captured = store.model_pricing(custom.model_key.clone()).await.unwrap();
+    assert_eq!(captured, (1, Some(custom.clone())));
+    assert!(entries(&store).await.1.iter().any(|entry| *entry
+        == Entry::Custom {
+            pricing: custom.clone(),
+            reset_effect: ResetEffect::RestoreBuiltin,
+        }));
+
+    // Supplementary-plane keys sort before U+E000 in JS, unlike UTF-8 ordering.
+    let mut revision = 1;
+    for key in ["fixture:\u{e000}", "fixture:\u{10000}"] {
+        revision += 1;
+        assert_eq!(
+            store
+                .update_pricing(Update {
+                    expected_revision: revision - 1,
+                    mutation: Mutation::Upsert {
+                        pricing: Pricing {
+                            model_key: key.into(),
+                            ..custom.clone()
+                        }
+                    },
+                })
+                .await
+                .unwrap(),
+            Updated::Committed { revision }
+        );
+    }
+    let (_, all) = entries(&store).await;
+    assert!(all.windows(2).all(|pair| {
+        price(&pair[0])
+            .model_key
+            .encode_utf16()
+            .cmp(price(&pair[1]).model_key.encode_utf16())
+            .is_lt()
+    }));
+    assert!(
+        all.iter()
+            .filter(|entry| matches!(
+                entry,
+                Entry::Custom {
+                    reset_effect: ResetEffect::BecomeUnpriced,
+                    ..
+                }
+            ))
+            .count()
+            >= 2
+    );
+    assert!(
+        store
+            .query_pricing(Query::Continue {
+                revision,
+                offset: all.len() as u64
+            })
+            .await
+            .is_err()
+    );
+    store.close().await.unwrap();
+
+    let store = ConfigurationStore::for_root(owner.clone()).await.unwrap();
+    assert_eq!(
+        store.model_pricing(custom.model_key.clone()).await.unwrap(),
+        (3, Some(custom.clone()))
+    );
+    assert_eq!(
+        store
+            .update_pricing(Update {
+                expected_revision: 3,
+                mutation: Mutation::Delete {
+                    model_key: custom.model_key.clone()
+                }
+            })
+            .await
+            .unwrap(),
+        Updated::Committed { revision: 4 }
+    );
+    assert_eq!(
+        store.model_pricing(custom.model_key.clone()).await.unwrap(),
+        (4, Some(original))
+    );
+    assert_eq!(
+        captured,
+        (1, Some(custom)),
+        "captured quote is not a mutable pricing reference"
+    );
+    assert_eq!(
+        store
+            .update_pricing(Update {
+                expected_revision: 4,
+                mutation: Mutation::Delete {
+                    model_key: "missing:model".into()
+                }
+            })
+            .await
+            .unwrap(),
+        Updated::Unchanged { revision: 4 }
+    );
+    store.close().await.unwrap();
+
+    // Emulate a binary built from another bundled-rate version; keep overrides.
+    let db = rusqlite::Connection::open(owner.canonical_path().join("configuration-rust.sqlite"))
+        .unwrap();
+    db.execute(
+        "UPDATE pricing_authority SET builtin_digest = 'previous-build'",
+        [],
+    )
+    .unwrap();
+    drop(db);
+    let store = ConfigurationStore::for_root(owner).await.unwrap();
+    assert!(matches!(
+        store
+            .query_pricing(Query::Continue {
+                revision: 4,
+                offset: 1
+            })
+            .await
+            .unwrap(),
+        Page::RevisionChanged {
+            actual_revision: 5,
+            ..
+        }
+    ));
+    assert_eq!(
+        store
+            .model_pricing("fixture:\u{e000}".into())
+            .await
+            .unwrap()
+            .0,
+        5
+    );
+    store.close().await.unwrap();
+}
diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs
index 45e1fdfcb5..68656a668e 100644
--- a/crates/protocol/src/lib.rs
+++ b/crates/protocol/src/lib.rs
@@ -40,6 +40,7 @@ pub mod onboarding;
 pub mod operation;
 pub mod operation_error;
 pub mod plugin;
+pub mod pricing;
 pub mod project;
 pub mod request_headers;
 pub mod resource;
diff --git a/crates/protocol/src/pricing.rs b/crates/protocol/src/pricing.rs
new file mode 100644
index 0000000000..f6dd6a9329
--- /dev/null
+++ b/crates/protocol/src/pricing.rs
@@ -0,0 +1,136 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use crate::{Operation, ProtocolError, Result, codec::MAX_SAFE_INTEGER};
+use maka_runtime::pricing::{Mutation, Page, Query, Update, Updated};
+use serde_json::Value;
+
+pub fn supports(operation: Operation) -> bool {
+    matches!(
+        operation,
+        Operation::PricingQuery | Operation::PricingMutate
+    )
+}
+
+pub enum Input {
+    Query(Query),
+    Update(Update),
+}
+
+pub fn decode_input(operation: Operation, value: &Value) -> Result<Input> {
+    match operation {
+        Operation::PricingQuery => {
+            let query: Query = decode(value)?;
+            if let Query::Continue { revision, offset } = query
+                && (revision > MAX_SAFE_INTEGER || offset > MAX_SAFE_INTEGER)
+            {
+                return Err(ProtocolError::invalid("invalid pricing continuation"));
+            }
+            Ok(Input::Query(query))
+        }
+        Operation::PricingMutate => {
+            let input: Update = decode(value)?;
+            if input.expected_revision > MAX_SAFE_INTEGER {
+                return Err(ProtocolError::invalid("invalid pricing revision"));
+            }
+            match &input.mutation {
+                Mutation::Upsert { pricing } => pricing.validate(),
+                Mutation::Delete { model_key } => maka_runtime::pricing::validate_key(model_key),
+            }
+            .map_err(ProtocolError::invalid)?;
+            Ok(Input::Update(input))
+        }
+        _ => Err(ProtocolError::invalid("unknown pricing operation")),
+    }
+}
+
+pub fn decode_output(operation: Operation, value: &Value) -> Result<Value> {
+    match operation {
+        Operation::PricingQuery => {
+            let result: Page = decode(value)?;
+            match result {
+                Page::RevisionChanged {
+                    expected_revision,
+                    actual_revision,
+                } => {
+                    conflict(expected_revision, actual_revision)?;
+                }
+                Page::Page {
+                    revision,
+                    offset,
+                    entries,
+                    next_offset,
+                } => {
+                    if revision > MAX_SAFE_INTEGER
+                        || offset > MAX_SAFE_INTEGER
+                        || entries.len() > 128
+                        || serde_json::to_vec(value).map_err(invalid)?.len() > 48 * 1024
+                        || next_offset.is_some_and(|next| {
+                            entries.is_empty()
+                                || next > MAX_SAFE_INTEGER
+                                || next != offset + entries.len() as u64
+                        })
+                    {
+                        return Err(ProtocolError::invalid("invalid pricing page"));
+                    }
+                    let mut previous: Option<&str> = None;
+                    for entry in &entries {
+                        let (maka_runtime::pricing::Entry::Builtin { pricing }
+                        | maka_runtime::pricing::Entry::Custom { pricing, .. }) = entry;
+                        pricing.validate().map_err(ProtocolError::invalid)?;
+                        if previous.is_some_and(|key| {
+                            key.encode_utf16()
+                                .cmp(pricing.model_key.encode_utf16())
+                                .is_ge()
+                        }) {
+                            return Err(ProtocolError::invalid("pricing page is not ordered"));
+                        }
+                        previous = Some(&pricing.model_key);
+                    }
+                }
+            }
+        }
+        Operation::PricingMutate => match decode::<Updated>(value)? {
+            Updated::Committed { revision } | Updated::Unchanged { revision } => {
+                if revision > MAX_SAFE_INTEGER {
+                    return Err(ProtocolError::invalid("invalid pricing revision"));
+                }
+            }
+            Updated::RevisionConflict {
+                expected_revision,
+                actual_revision,
+            } => conflict(expected_revision, actual_revision)?,
+        },
+        _ => return Err(ProtocolError::invalid("unknown pricing operation")),
+    }
+    Ok(value.clone())
+}
+
+fn conflict(expected: u64, actual: u64) -> Result<()> {
+    if expected > MAX_SAFE_INTEGER || actual > MAX_SAFE_INTEGER || expected == actual {
+        return Err(ProtocolError::invalid("invalid pricing revision conflict"));
+    }
+    Ok(())
+}
+fn decode<T: serde::de::DeserializeOwned>(value: &Value) -> Result<T> {
+    serde_json::from_value(value.clone()).map_err(invalid)
+}
+fn invalid(error: impl std::fmt::Display) -> ProtocolError {
+    ProtocolError::invalid(error.to_string())
+}
diff --git a/crates/runtime-host/src/server.rs b/crates/runtime-host/src/server.rs
index aaa362b90f..c2c93bdebe 100644
--- a/crates/runtime-host/src/server.rs
+++ b/crates/runtime-host/src/server.rs
@@ -43,6 +43,7 @@ mod operations;
 mod outbound;
 pub(crate) mod plugin_authorization;
 mod plugin_remote;
+mod pricing;
 mod projects;
 mod sandbox_setup;
 pub(crate) use projects::Usage as ProjectUsage;
diff --git a/crates/runtime-host/src/server/authority.rs b/crates/runtime-host/src/server/authority.rs
index d4223f5baa..cf02d24615 100644
--- a/crates/runtime-host/src/server/authority.rs
+++ b/crates/runtime-host/src/server/authority.rs
@@ -223,6 +223,8 @@ fn path_free(request: &Request) -> bool {
         | Operation::ConnectionCatalogQuery
         | Operation::ExecutorCatalogQuery
         | Operation::ModelProviderCatalogQuery
+        | Operation::PricingQuery
+        | Operation::PricingMutate
         | Operation::ConnectionModelsFetch
         | Operation::ConnectionOnboardingVerify
         | Operation::ConnectionOnboardingSave
diff --git a/crates/runtime-host/src/server/dispatch.rs b/crates/runtime-host/src/server/dispatch.rs
index 8de10d8903..4dde118976 100644
--- a/crates/runtime-host/src/server/dispatch.rs
+++ b/crates/runtime-host/src/server/dispatch.rs
@@ -82,6 +82,9 @@ impl Host {
             self.plugins.wake_background_work();
             return Ok(Outcome::success(serde_json::json!({})));
         }
+        if maka_protocol::pricing::supports(operation) {
+            return super::pricing::execute(self, operation, &input).await;
+        }
         if operation == Operation::HostUpgradePrepare {
             return match self
                 .prepare_retirement(
diff --git a/crates/runtime-host/src/server/operations.rs b/crates/runtime-host/src/server/operations.rs
index df741b3ce3..e2b1fb94c4 100644
--- a/crates/runtime-host/src/server/operations.rs
+++ b/crates/runtime-host/src/server/operations.rs
@@ -30,6 +30,10 @@ pub struct Operations;
 
 impl OperationRegistry for Operations {
     fn decode_input(&self, operation: Operation, value: &Value) -> Result<Value> {
+        if maka_protocol::pricing::supports(operation) {
+            maka_protocol::pricing::decode_input(operation, value)?;
+            return Ok(value.clone());
+        }
         if maka_protocol::sandbox_setup::supports(operation) {
             maka_protocol::sandbox_setup::decode_input(value)?;
             return Ok(value.clone());
@@ -111,6 +115,9 @@ impl OperationRegistry for Operations {
         }
     }
     fn decode_output(&self, operation: Operation, value: &Value) -> Result<Value> {
+        if maka_protocol::pricing::supports(operation) {
+            return maka_protocol::pricing::decode_output(operation, value);
+        }
         if maka_protocol::sandbox_setup::supports(operation) {
             maka_protocol::sandbox_setup::decode_output(value)?;
             return Ok(value.clone());
@@ -187,6 +194,13 @@ impl OperationRegistry for Operations {
         }
     }
     fn error_codes(&self, operation: Operation) -> Option<&[OperationErrorCode]> {
+        if maka_protocol::pricing::supports(operation) {
+            return Some(if operation == Operation::PricingQuery {
+                configuration::QUERY_ERRORS
+            } else {
+                configuration::MUTATION_ERRORS
+            });
+        }
         if maka_protocol::sandbox_setup::supports(operation) {
             return Some(maka_protocol::sandbox_setup::ERRORS);
         }
diff --git a/crates/runtime-host/src/server/pricing.rs b/crates/runtime-host/src/server/pricing.rs
new file mode 100644
index 0000000000..94dbcf5b2a
--- /dev/null
+++ b/crates/runtime-host/src/server/pricing.rs
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::{Host, HostError, configuration};
+use maka_protocol::{Operation, Outcome, pricing::Input};
+use maka_runtime::pricing::Updated;
+use serde_json::{Value, json};
+use std::sync::atomic::Ordering;
+
+pub(super) async fn execute(
+    host: &Host,
+    operation: Operation,
+    value: &Value,
+) -> Result<Outcome, HostError> {
+    let output = match maka_protocol::pricing::decode_input(operation, value)? {
+        Input::Query(input) => host
+            .configuration
+            .query_pricing(input)
+            .await
+            .map(serde_json::to_value),
+        Input::Update(input) => host
+            .configuration
+            .update_pricing(input)
+            .await
+            .map(|result| {
+                if matches!(result, Updated::Committed { .. }) {
+                    let revision = host.change_revision.fetch_add(1, Ordering::SeqCst) + 1;
+                    let _ = host
+                        .changes
+                        .send(json!({ "kind": "configuration.changed", "revision": revision }));
+                }
+                serde_json::to_value(result)
+            }),
+    };
+    Ok(match output {
+        Ok(value) => Outcome::success(maka_protocol::pricing::decode_output(operation, &value?)?),
+        Err(error) => Outcome::failure(configuration::failure(error)),
+    })
+}
diff --git a/crates/runtime/src/lib.rs b/crates/runtime-host/tests/integration/client_pricing.rs
similarity index 57%
copy from crates/runtime/src/lib.rs
copy to crates/runtime-host/tests/integration/client_pricing.rs
index 886f4d8f54..ad645696d2 100644
--- a/crates/runtime/src/lib.rs
+++ b/crates/runtime-host/tests/integration/client_pricing.rs
@@ -17,33 +17,22 @@
  * under the License.
  */
 
-//! Runtime facts and execution contracts; no V8 or SQLite dependency.
-pub mod access;
-pub mod archive;
-pub mod artifact;
-pub mod attachment;
-pub mod capability;
-pub mod composition;
-pub mod configuration;
-pub mod context;
-pub mod continuation;
-pub mod event;
-mod event_write;
-pub mod execution;
-pub mod executor;
-pub mod handoff;
-pub mod input;
-pub mod interaction;
-pub mod message;
-pub mod model;
-pub mod oauth;
-pub mod provider;
-pub mod read;
-pub mod scope;
-pub mod session;
-pub mod shell_result;
-pub mod shell_run;
-pub mod terminal;
-pub mod tool_call;
-pub mod tool_output;
-pub mod tools;
+use super::support::client_probe::ClientFixture;
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn source_client_pages_edits_and_recovers_native_pricing() {
+    let fixture = ClientFixture::new("maka-pricing-client-");
+    for reopened in [false, true] {
+        fixture
+            .run(
+                "--pricing-workspace",
+                reopened,
+                if reopened {
+                    "pricing-reopened"
+                } else {
+                    "pricing-passed"
+                },
+            )
+            .await;
+    }
+}
diff --git a/crates/runtime-host/tests/integration/main.rs b/crates/runtime-host/tests/integration/main.rs
index 345ce0d1f5..6891cd9ac8 100644
--- a/crates/runtime-host/tests/integration/main.rs
+++ b/crates/runtime-host/tests/integration/main.rs
@@ -35,6 +35,7 @@ mod client_onboarding;
 mod client_openai_options;
 mod client_patch;
 mod client_plugins;
+mod client_pricing;
 mod client_questions;
 mod client_remote_access;
 mod client_shell;
diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs
index 886f4d8f54..22fea9930a 100644
--- a/crates/runtime/src/lib.rs
+++ b/crates/runtime/src/lib.rs
@@ -37,6 +37,7 @@ pub mod interaction;
 pub mod message;
 pub mod model;
 pub mod oauth;
+pub mod pricing;
 pub mod provider;
 pub mod read;
 pub mod scope;
diff --git a/crates/runtime/src/pricing.rs b/crates/runtime/src/pricing.rs
new file mode 100644
index 0000000000..5227124f11
--- /dev/null
+++ b/crates/runtime/src/pricing.rs
@@ -0,0 +1,266 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//! Non-secret model rates and auditable estimates. This is not a billing ledger.
+
+use crate::model::ModelUsage;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
+pub enum Query {
+    Start,
+    Continue { revision: u64, offset: u64 },
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
+#[serde(
+    tag = "source",
+    rename_all = "snake_case",
+    rename_all_fields = "camelCase",
+    deny_unknown_fields
+)]
+pub enum Entry {
+    Builtin {
+        pricing: Pricing,
+    },
+    Custom {
+        pricing: Pricing,
+        reset_effect: ResetEffect,
+    },
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum ResetEffect {
+    RestoreBuiltin,
+    BecomeUnpriced,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+#[serde(
+    tag = "kind",
+    rename_all = "snake_case",
+    rename_all_fields = "camelCase",
+    deny_unknown_fields
+)]
+pub enum Page {
+    Page {
+        revision: u64,
+        offset: u64,
+        entries: Vec<Entry>,
+        next_offset: Option<u64>,
+    },
+    RevisionChanged {
+        expected_revision: u64,
+        actual_revision: u64,
+    },
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+#[serde(
+    tag = "kind",
+    rename_all = "snake_case",
+    rename_all_fields = "camelCase",
+    deny_unknown_fields
+)]
+pub enum Mutation {
+    Upsert { pricing: Pricing },
+    Delete { model_key: String },
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct Update {
+    pub expected_revision: u64,
+    pub mutation: Mutation,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(
+    tag = "kind",
+    rename_all = "snake_case",
+    rename_all_fields = "camelCase",
+    deny_unknown_fields
+)]
+pub enum Updated {
+    Committed {
+        revision: u64,
+    },
+    Unchanged {
+        revision: u64,
+    },
+    RevisionConflict {
+        expected_revision: u64,
+        actual_revision: u64,
+    },
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct Pricing {
+    pub model_key: String,
+    #[serde(rename = "inputUsdPer1M")]
+    pub input_usd_per_million: f64,
+    #[serde(rename = "outputUsdPer1M")]
+    pub output_usd_per_million: f64,
+    #[serde(
+        rename = "cacheReadUsdPer1M",
+        default,
+        deserialize_with = "present_rate",
+        skip_serializing_if = "Option::is_none"
+    )]
+    #[schemars(with = "f64")]
+    pub cache_read_usd_per_million: Option<f64>,
+    #[serde(
+        rename = "cacheWriteUsdPer1M",
+        default,
+        deserialize_with = "present_rate",
+        skip_serializing_if = "Option::is_none"
+    )]
+    #[schemars(with = "f64")]
+    pub cache_write_usd_per_million: Option<f64>,
+}
+
+impl Pricing {
+    pub fn validate(&self) -> Result<(), &'static str> {
+        validate_key(&self.model_key)?;
+        for rate in [
+            Some(self.input_usd_per_million),
+            Some(self.output_usd_per_million),
+            self.cache_read_usd_per_million,
+            self.cache_write_usd_per_million,
+        ]
+        .into_iter()
+        .flatten()
+        {
+            if !rate.is_finite() || rate < 0.0 {
+                return Err("invalid model price");
+            }
+        }
+        Ok(())
+    }
+
+    /// Input and output totals include their cache and reasoning subsets.
+    /// Optional cache rates inherit the input rate, never a made-up discount.
+    /// Missing total usage or an unrepresentable estimate is not zero cost.
+    pub fn estimate(&self, usage: &ModelUsage) -> Result<Option<f64>, &'static str> {
+        self.validate()?;
+        let (Some(input), Some(output)) = (usage.input_tokens, usage.output_tokens) else {
+            return Ok(None);
+        };
+        if input != 0
+            && ((usage.cache_read_tokens.is_none()
+                && self
+                    .cache_read_usd_per_million
+                    .is_some_and(|rate| rate != self.input_usd_per_million))
+                || (usage.cache_write_tokens.is_none()
+                    && self
+                        .cache_write_usd_per_million
+                        .is_some_and(|rate| rate != self.input_usd_per_million)))
+        {
+            return Ok(None);
+        }
+        let read = usage.cache_read_tokens.unwrap_or(0).min(input);
+        let write = usage.cache_write_tokens.unwrap_or(0).min(input - read);
+        let uncached = input - read - write;
+        // Scale the count before multiplication: a finite rate need not fit when
+        // multiplied by a whole token count but may still produce a finite cost.
+        let amount = uncached as f64 / 1_000_000.0 * self.input_usd_per_million
+            + read as f64 / 1_000_000.0
+                * self
+                    .cache_read_usd_per_million
+                    .unwrap_or(self.input_usd_per_million)
+            + write as f64 / 1_000_000.0
+                * self
+                    .cache_write_usd_per_million
+                    .unwrap_or(self.input_usd_per_million)
+            + output as f64 / 1_000_000.0 * self.output_usd_per_million;
+        Ok(amount.is_finite().then_some(amount))
+    }
+}
+
+/// Model keys are exact identities, not normalized provider or model names.
+pub fn validate_key(key: &str) -> Result<(), &'static str> {
+    if key.is_empty()
+        || key.trim() != key
+        || key.encode_utf16().count() > 128
+        || key.chars().any(char::is_control)
+    {
+        return Err("invalid pricing model key");
+    }
+    Ok(())
+}
+
+fn present_rate<'de, D: serde::Deserializer<'de>>(
+    deserializer: D,
+) -> Result<Option<f64>, D::Error> {
+    f64::deserialize(deserializer).map(Some)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn estimate_preserves_unknowns_and_does_not_charge_token_subsets_twice() {
+        let price = Pricing {
+            model_key: "provider:model".into(),
+            input_usd_per_million: 10.0,
+            output_usd_per_million: 20.0,
+            cache_read_usd_per_million: Some(1.0),
+            cache_write_usd_per_million: Some(12.0),
+        };
+        let mut usage = ModelUsage {
+            input_tokens: Some(1_000_000),
+            output_tokens: Some(1_000_000),
+            cache_read_tokens: Some(200_000),
+            cache_write_tokens: Some(100_000),
+            reasoning_tokens: Some(500_000),
+        };
+        assert_eq!(price.estimate(&usage).unwrap(), Some(28.4));
+        usage.input_tokens = None;
+        assert_eq!(price.estimate(&usage).unwrap(), None);
+        usage.input_tokens = Some(0);
+        usage.output_tokens = Some(0);
+        assert_eq!(price.estimate(&usage).unwrap(), Some(0.0));
+        // An impossible cache report cannot produce a negative uncached charge.
+        usage.input_tokens = Some(100_000);
+        assert_eq!(price.estimate(&usage).unwrap(), Some(0.1));
+        usage.cache_read_tokens = None;
+        assert_eq!(
+            price.estimate(&usage).unwrap(),
+            None,
+            "unknown cache split changes the price"
+        );
+        let huge = Pricing {
+            input_usd_per_million: f64::MAX,
+            cache_read_usd_per_million: None,
+            cache_write_usd_per_million: None,
+            ..price
+        };
+        usage.cache_read_tokens = None;
+        usage.cache_write_tokens = None;
+        usage.input_tokens = Some(1);
+        assert!(huge.estimate(&usage).unwrap().unwrap().is_finite());
+        usage.input_tokens = Some(u64::MAX);
+        assert_eq!(huge.estimate(&usage).unwrap(), None);
+    }
+}
diff --git a/docs/rust-parity.md b/docs/rust-parity.md
index e3602edb9e..29d6e26381 100644
--- a/docs/rust-parity.md
+++ b/docs/rust-parity.md
@@ -89,7 +89,7 @@ implementations; wire adapters may retain existing client vocabulary.
 | Goal | Query/arm/control, continuation, termination, budget and recovery semantics. | **Plugin + Host.** Goal policy owns subsequent submissions; Host enforces admitted hard limits and records usage. Retiring the plugin must close future submission admission. |
 | Daily review / recap | Daily-review query/mutate, scheduled review and `session.recap.generate`. | **Plugin + Host.** Summarization/selection/output in plugins; reuse Scheduler and authorized history/model services. Host retains any canonical Session metadata commit. |
 | External agents | Setup start/query/cancel; execution adapters, configuration, auth, conversation identity, adapter-specific attachments/interactions/resume/fork. | **Plugin + Host.** Implement concrete CLI/ACP adapters as Executor plugins over owned processes/HTTP. Generic Executor support is not a shipped adapter. Host owns authorization, cancellation and canonical external-event recording. |
-| Usage / Pricing | Physical Agent and auxiliary SDK call accounting and bounded source reads are implemented. Public Usage queries, revision-consistent screens/activity pages, pricing query/mutate and valuation remain. | **Plugin + Host.** Reports, price policy and rebuildable projections may be an Insights domain; Host records usage independently of plugin availability and provides consistent reads. Missing usage must not become zero. |
+| Usage / Pricing | Physical Agent and auxiliary SDK accounting, bounded source reads, and native pricing query/CAS edits are implemented. Public plugin accounting/pricing capabilities, revision-consistent Usage screens/activity pages and per-call valuation remain. | **Plugin + Host.** Reports and rebuildable projections may be an Insights domain; Host records usage independently of plugin availability and provides consistent reads. Missing usage must not become zero. |
 | Background health | BackgroundTaskHealth process and endpoint checks. | **Plugin + Host.** Health interpretation and Tool in a plugin; Host exposes authorized resource observations and bounded probes. A stored PID is not resource ownership. |
 | Session lineage | Public Rust/JS history copying and source reads, native branch/revision create/abandon, independent inherited pruning, Desktop canonical-input editing and complete durable drafts are implemented. Regeneration is removed; edits use revision and resend. | **Host.** One transactional lineage and workspace authority; plugins request changes through commands, not private lineage storage. |
 | Session lifecycle | Native and public plugin removal/preview/receipt APIs, atomic family retirement and queued cancellation, restart cleanup and shared-worktree ownership are implemented. Unreferenced canonical-material reclamation and shared Session queries remain. | **Host.** Preserve referenced history and accepted receipts; unknown process cleanup must not delete its workspace. Shared queries require real collaboration authorization. |
diff --git a/docs/rust-parity.zh-CN.md b/docs/rust-parity.zh-CN.md
index c4fe08de40..db16ffdfc2 100644
--- a/docs/rust-parity.zh-CN.md
+++ b/docs/rust-parity.zh-CN.md
@@ -79,7 +79,7 @@ runtime 契约不能反向依赖插件实现,协议适配层可以保留现有
 | Goal | 查询、arm、控制、续跑、终止、预算与恢复语义。 | **插件 + Host。** Goal 决定后续提交;Host 执行已准入的硬限制并记录用量。插件退休后不能继续提交。 |
 | Daily review/recap | daily-review 查询/修改、定时复盘、`session.recap.generate`。 | **插件 + Host。** 选择、总结及输出由插件负责,复用 Scheduler 和授权历史/模型服务;规范 Session 元数据的提交仍归 Host。 |
 | 外部 agent | setup start/query/cancel;具体执行适配、配置、鉴权、对话身份,以及附件/交互/resume/fork。 | **插件 + Host。** CLI/ACP 适配作为 Executor 插件,使用受管理进程/HTTP。已有 Executor 框架不等于已有具体 adapter。Host 负责授权、取消和外部事件落盘。 |
-| Usage/Pricing | 已实现 Agent 与辅助 SDK 调用的物理准入计量及有界事实读取。公共 Usage 查询、一致版本视图及活动分页、Pricing 查询/修改和估价仍待接入。 | **插件 + Host。** 报表、价格策略和可重建投影可归 Insights 领域;Host 不依赖插件存活来记录用量,并提供一致快照。缺失用量不能视为零。 |
+| Usage/Pricing | 已实现 Agent 与辅助 SDK 的物理计量、有界事实读取及原生报价查询/CAS 修改。公共插件计量/报价能力、一致版本的 Usage 视图与活动分页、逐调用估价仍待接入。 | **插件 + Host。** 报表和可重建投影可归 Insights 领域;Host 不依赖插件存活来记录用量,并提供一致快照。缺失用量不能视为零。 |
 | 后台健康 | BackgroundTaskHealth 的进程和端点检查。 | **插件 + Host。** 插件解释健康状态并提供工具;Host 提供授权资源观察和有界探测。保存 PID 不等于拥有进程。 |
 | Session 谱系 | 已实现公共 Rust/JS 历史复制和原始输入读取、原生分支/修订创建与放弃、继承历史独立裁剪、Desktop 规范输入编辑及完整持久草稿。已移除 regenerate,修改请求使用编辑重发。 | **Host。** 谱系及工作区只有一个事务权威;插件请求命令,不在私有存储中重做谱系。 |
 | Session 生命周期 | 已实现原生与公共插件删除/预览/回执接口、原子家族退休及队列取消、重启清理和共享 worktree 所有权。无引用规范材料回收及 shared 查询仍待完成。 | **Host。** 保留被引用历史及已接受回执;进程清理不确定时不得删除工作目录。shared 查询需要真实协作授权。 |
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index 033433529e..98311a05ed 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -215,7 +215,9 @@ user/project content paths stay separate from private journals.
 - Accounting counts physical model admissions, including failed retries and auxiliary
   plugin calls. Provider usage survives rejected output; missing counters and unresolved
   outcomes remain unknown. History copies do not duplicate usage, and Session removal
-  retains accounting facts. Usage screens and pricing are not yet connected.
+  retains accounting facts. Pricing queries and CAS edits use a bounded, revision-pinned
+  catalog; custom rates survive restart and bundled-rate updates invalidate old cursors.
+  Usage screens and per-call valuation are not yet connected.
 - Subscriptions deliver frames only after `subscription.ready`. Reconnection replays
   active text from the committed log under backpressure; no copied transcript overlay.
 - Model messages, content and tool outcomes are typed through provider projection.
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index 024d12fd68..5c49af6090 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -168,7 +168,8 @@ Host 不可连接不代表进程已停止。`host logs --root-id <rootId>` 返
   失败响应的片段只用于展示,不纳入模型历史;用户取消不显示为 provider 失败。
 - 计量按模型物理准入统计,包含失败重试和插件辅助调用。被拒绝的回复仍保留已报告用量;
   缺失计数与未确认结果保持未知。历史复制不重复计量,Session 删除保留计量事实。
-  Usage 页面与 Pricing 尚未接入。
+  报价支持有界、固定版本的查询与 CAS 修改;自定义报价跨重启保留,内置报价更新使旧分页失效。
+  Usage 页面与逐调用估价尚未接入。
 - 模型消息、内容块与工具结果在 provider 投影中保持类型化;路由与发现共享类型化契约。
   工具 JSON、schema 和厂商扩展保留开放结构。
 - 订阅收到 `subscription.ready` 后才交付帧。重连按背压从已提交日志补发活动文本,
diff --git a/scripts/rust/catalog-facts-entry.mjs b/scripts/rust/catalog-facts-entry.mjs
index 6fd7551073..2f979840aa 100644
--- a/scripts/rust/catalog-facts-entry.mjs
+++ b/scripts/rust/catalog-facts-entry.mjs
@@ -32,6 +32,11 @@ import {
   buildConnectionModelCatalogEntries,
 } from '../../packages/core/src/model-catalog.ts';
 import { getModelCapabilities } from '@ai-sdk/anthropic/internal';
+import { BUILTIN_PRICING } from '../../packages/runtime/src/telemetry/builtin-pricing.ts';
+
+export function outputPricingFacts() {
+  return BUILTIN_PRICING;
+}
 
 export function outputProviderFacts() {
   return Object.fromEntries(
diff --git a/scripts/rust/generate-catalog-facts.mjs b/scripts/rust/generate-catalog-facts.mjs
index d1c48b161b..d1cdcd5bbe 100644
--- a/scripts/rust/generate-catalog-facts.mjs
+++ b/scripts/rust/generate-catalog-facts.mjs
@@ -18,6 +18,7 @@
  */
 
 import { createRequire } from 'node:module';
+import { createHash } from 'node:crypto';
 import { readFile, writeFile } from 'node:fs/promises';
 import { isAbsolute, join, resolve } from 'node:path';
 import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -26,6 +27,7 @@ const directory = resolve(process.argv[2]);
 const require = createRequire(join(resolve(process.env.MAKA_JS_DEPS || root), 'package.json'));
 const { build } = require('esbuild');
 const metadata = join(directory, 'model-metadata.generated.ts');
+const pricing = join(directory, 'model-pricing.generated.ts');
 const { main } = await import('../sync-model-metadata.mjs');
 await main([
   'node',
@@ -34,6 +36,8 @@ await main([
   join(root, 'scripts/model-metadata/models-dev-api.snapshot.json'),
   '--output',
   metadata,
+  '--pricing-output',
+  pricing,
 ]);
 const bundle = join(directory, 'catalog-facts-source.mjs');
 const result = await build({
@@ -52,6 +56,9 @@ const result = await build({
         build.onResolve({ filter: /^\.\/model-metadata\.generated\.js$/ }, () => ({
           path: metadata,
         }));
+        build.onResolve({ filter: /^\.\/model-pricing\.generated\.js$/ }, () => ({
+          path: pricing,
+        }));
         build.onResolve({ filter: /^@maka\// }, async ({ path }) => {
           const [, name, ...subpath] = path.split('/');
           const packageRoot = join(root, 'packages', name);
@@ -89,3 +96,15 @@ await writeFile(
   JSON.stringify(source.outputProviderFacts()),
 );
 await writeFile(join(directory, 'catalog-oracle.json'), JSON.stringify(source.oracleFixtures()));
+const pricingFacts = JSON.stringify(
+  source
+    .outputPricingFacts()
+    .sort((left, right) =>
+      left.modelKey < right.modelKey ? -1 : left.modelKey > right.modelKey ? 1 : 0,
+    ),
+);
+await writeFile(join(directory, 'pricing-facts.json'), pricingFacts);
+await writeFile(
+  join(directory, 'pricing-digest.txt'),
+  createHash('sha256').update(pricingFacts).digest('hex'),
+);
diff --git a/tests/fixtures/client-pricing-entry.mjs b/tests/fixtures/client-pricing-entry.mjs
new file mode 100644
index 0000000000..a8af2b64bd
--- /dev/null
+++ b/tests/fixtures/client-pricing-entry.mjs
@@ -0,0 +1,120 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { connect } from 'node:net';
+import { once } from 'node:events';
+import { parseArgs } from 'node:util';
+import { connectRuntimeHostMessageTransport } from '../../packages/runtime-host/src/client/connection.ts';
+import { FramedTransport } from '../../packages/runtime-host/src/transport/framed-transport.ts';
+import {
+  INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
+  RUNTIME_HOST_PROTOCOL_VERSION,
+} from '../../packages/runtime-host/src/protocol/index.ts';
+
+const { values } = parseArgs({
+  options: {
+    socket: { type: 'string' },
+    'root-id': { type: 'string' },
+    'pricing-workspace': { type: 'string' },
+    reopened: { type: 'boolean' },
+  },
+});
+const socket = connect(values.socket);
+const transport = new FramedTransport(socket);
+let connection;
+try {
+  await once(socket, 'connect');
+  const connected = await connectRuntimeHostMessageTransport({
+    transport,
+    expectedRootId: values['root-id'],
+    compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
+    protocol: { min: RUNTIME_HOST_PROTOCOL_VERSION, max: RUNTIME_HOST_PROTOCOL_VERSION },
+    handshakeTimeoutMs: 3000,
+    livenessIntervalMs: 60000,
+  });
+  assert.equal(connected.kind, 'connected');
+  connection = connected.connection;
+  const request = (operation, input) => connection.request(operation, input, 5000);
+  const snapshot = async () => {
+    let page = await request('pricing.query', { kind: 'start' });
+    const revision = page.revision;
+    const entries = [];
+    while (true) {
+      assert.equal(page.kind, 'page');
+      assert.equal(page.revision, revision);
+      assert.equal(page.offset, entries.length);
+      entries.push(...page.entries);
+      if (page.nextOffset === null) return { revision, entries };
+      page = await request('pricing.query', {
+        kind: 'continue',
+        revision,
+        offset: page.nextOffset,
+      });
+    }
+  };
+  const initial = await snapshot();
+  assert(initial.entries.length > 128);
+  const key = 'fixture:priced-model';
+  const pricing = { modelKey: key, inputUsdPer1M: 2.5, outputUsdPer1M: 10 };
+  if (values.reopened) {
+    assert.equal(initial.revision, 1);
+    assert.deepEqual(
+      initial.entries.find(({ pricing }) => pricing.modelKey === key),
+      {
+        source: 'custom',
+        pricing,
+        resetEffect: 'become_unpriced',
+      },
+    );
+    assert.deepEqual(
+      await request('pricing.mutate', {
+        expectedRevision: 1,
+        mutation: { kind: 'delete', modelKey: key },
+      }),
+      { kind: 'committed', revision: 2 },
+    );
+    assert(!(await snapshot()).entries.some(({ pricing }) => pricing.modelKey === key));
+  } else {
+    assert.equal(initial.revision, 0);
+    const mutation = { expectedRevision: 0, mutation: { kind: 'upsert', pricing } };
+    assert.deepEqual(await request('pricing.mutate', mutation), { kind: 'committed', revision: 1 });
+    assert.deepEqual(await request('pricing.mutate', mutation), {
+      kind: 'revision_conflict',
+      expectedRevision: 0,
+      actualRevision: 1,
+    });
+    assert.deepEqual(
+      await request('pricing.query', {
+        kind: 'continue',
+        revision: 0,
+        offset: 128,
+      }),
+      { kind: 'revision_changed', expectedRevision: 0, actualRevision: 1 },
+    );
+    assert.deepEqual(await request('pricing.mutate', { ...mutation, expectedRevision: 1 }), {
+      kind: 'unchanged',
+      revision: 1,
+    });
+  }
+  console.log(values.reopened ? 'pricing-reopened' : 'pricing-passed');
+} finally {
+  transport.abort();
+  await connection?.close();
+}
diff --git a/tests/fixtures/client.mjs b/tests/fixtures/client.mjs
index be69ab7a22..45ae42a4aa 100644
--- a/tests/fixtures/client.mjs
+++ b/tests/fixtures/client.mjs
@@ -22,6 +22,7 @@ import { spawnSync } from 'node:child_process';
 import { withSourceBundle } from '../support/source.mjs';
 
 const entries = [
+  ['--pricing-workspace', 'client-pricing-entry.mjs'],
   ['--sandbox-workspace', 'client-sandbox-entry.mjs'],
   ['--skills-client-workspace', 'client-skills-entry.mjs'],
   ['--scheduler-workspace', 'client-scheduler-entry.mjs'],


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org