diff --git a/decisions/WF-ADR-0048-shared-routing-core-apple-embedding.md b/decisions/WF-ADR-0048-shared-routing-core-apple-embedding.md index 5e4d3813..89d00ca6 100644 --- a/decisions/WF-ADR-0048-shared-routing-core-apple-embedding.md +++ b/decisions/WF-ADR-0048-shared-routing-core-apple-embedding.md @@ -2,7 +2,7 @@ schema_version: 1 id: WF-ADR-0048 type: decision -status: accepted-for-phase-0-spikes +status: accepted date: 2026-07-24 tags: [rust, swift, ios, ipados, ffi, routing, providers, persistence] --- @@ -133,6 +133,22 @@ mobile independence or introduce a Swift routing implementation. - Core extraction lands without provider or UI behavior changes. - XCFramework/bridge work lands separately from the extraction. +## Implementation status + +The first extraction boundary provides: + +- `wayfinder-routing-core` as the renamed authoritative scorer and planner; +- `wayfinder-runtime-contracts` as secret-free, serializable host contracts; +- gateway and compatibility consumers compiled against the renamed core; +- hard eligibility filters that run before score-based tier selection; +- stable input-order selection and pre-output fallback planning within the + recommended tier; +- a manifest-level dependency test that rejects unreviewed production + dependencies. + +The generated Swift bridge, XCFramework assembly, physical-device proof, and +provider-execution placement decision remain separate gated work. + ## Consequences The router remains one inspectable authority while each Apple host can use the diff --git a/roadmaps/WF-ROADMAP-0016-native-mobile-v0.2.md b/roadmaps/WF-ROADMAP-0016-native-mobile-v0.2.md index 16d73294..c7a26dee 100644 --- a/roadmaps/WF-ROADMAP-0016-native-mobile-v0.2.md +++ b/roadmaps/WF-ROADMAP-0016-native-mobile-v0.2.md @@ -167,6 +167,11 @@ Exit: ## Phase 1 — extract the portable routing core +Implementation note: the pure routing crate and typed runtime-contract crate +are extracted, and current gateway/compatibility consumers use the renamed +core. Swift bindings, XCFramework assembly, and simulator/device bridge proof +remain before this phase's exit gate is complete. + - isolate pure routing and runtime-contract crates; - remove host/server assumptions from route planning; - add typed request, candidate, plan, explanation, and receipt contracts; diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 3b5896b5..2d2bd659 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1697,10 +1697,10 @@ dependencies = [ "wayfinder-apple-foundation-xpc", "wayfinder-codex-app-server", "wayfinder-config", - "wayfinder-core", "wayfinder-gateway", "wayfinder-macos-xpc", "wayfinder-providers", + "wayfinder-routing-core", "wayfinder-service", ] @@ -1726,8 +1726,8 @@ dependencies = [ "tokio", "tower", "wayfinder-config", - "wayfinder-core", "wayfinder-gateway", + "wayfinder-routing-core", "wayfinder-service", ] @@ -1741,17 +1741,7 @@ dependencies = [ "thiserror", "toml", "toml_edit", - "wayfinder-core", -] - -[[package]] -name = "wayfinder-core" -version = "2026.7.0" -dependencies = [ - "regex", - "serde", - "serde_json", - "thiserror", + "wayfinder-routing-core", ] [[package]] @@ -1778,8 +1768,8 @@ dependencies = [ "wayfinder-apple-foundation-xpc", "wayfinder-codex-app-server", "wayfinder-config", - "wayfinder-core", "wayfinder-providers", + "wayfinder-routing-core", "wayfinder-service", ] @@ -1803,7 +1793,27 @@ dependencies = [ "serde_json", "thiserror", "tokio", - "wayfinder-core", + "wayfinder-routing-core", +] + +[[package]] +name = "wayfinder-routing-core" +version = "2026.7.0" +dependencies = [ + "regex", + "serde", + "serde_json", + "thiserror", + "toml", + "wayfinder-runtime-contracts", +] + +[[package]] +name = "wayfinder-runtime-contracts" +version = "2026.7.0" +dependencies = [ + "serde", + "serde_json", ] [[package]] @@ -1816,8 +1826,8 @@ dependencies = [ "sha2", "thiserror", "tokio", - "wayfinder-core", "wayfinder-providers", + "wayfinder-routing-core", ] [[package]] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 11974a2a..eb983051 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,7 +1,8 @@ [workspace] resolver = "3" members = [ - "crates/wayfinder-core", + "crates/wayfinder-routing-core", + "crates/wayfinder-runtime-contracts", "crates/wayfinder-config", "crates/wayfinder-providers", "crates/wayfinder-gateway", diff --git a/rust/README.md b/rust/README.md index 2525ecb7..4cd7bfb3 100644 --- a/rust/README.md +++ b/rust/README.md @@ -3,8 +3,10 @@ Rust is Wayfinder's sole production router and gateway runtime under WF-ADR-0046. This workspace contains: -- `wayfinder-core` — deterministic feature extraction, scoring, tiers, and - explanations; +- `wayfinder-routing-core` — authoritative deterministic feature extraction, + compatibility filtering, scoring, tier selection, and route planning; +- `wayfinder-runtime-contracts` — secret-free requests, destination snapshots, + exclusion reasons, plans, explanations, and receipts; - `wayfinder-config` — typed routing/gateway configuration and preserved mutations; - `wayfinder-providers` — bounded provider clients and streaming translation; @@ -43,14 +45,16 @@ Wayfinder Desktop continues to embed `wayfinder-router` as a separately running signed helper. Native iPhone and iPad v0.2 does not run that executable or an internal HTTP server. -WF-ADR-0048 extracts a pure `wayfinder-routing-core` and typed runtime contracts -from this workspace. The gateway and generated Swift bridge will consume the -same core and golden fixtures. The pure core may not perform filesystem, -Keychain, process, provider, HTTP-server, UI, or Apple-framework work. +WF-ADR-0048 defines the extracted `wayfinder-routing-core` and +`wayfinder-runtime-contracts` as the shared routing authority. The gateway +already consumes that core, and dependency-boundary plus golden-corpus tests +protect its portability. The generated Swift bridge will consume these same +crates and fixtures in its own pull request. The pure core may not perform +filesystem, Keychain, process, provider, HTTP-server, UI, or Apple-framework +work. -The extraction, bridge/XCFramework, provider execution choice, iOS shell, auth, -Apple model, and pairing remain separate pull requests under -WF-ROADMAP-0016. +The bridge/XCFramework, provider execution choice, iOS shell, auth, Apple +model, and pairing remain separate pull requests under WF-ROADMAP-0016. ## Governing documents diff --git a/rust/crates/wayfinder-cli/Cargo.toml b/rust/crates/wayfinder-cli/Cargo.toml index c55a74d0..226205bc 100644 --- a/rust/crates/wayfinder-cli/Cargo.toml +++ b/rust/crates/wayfinder-cli/Cargo.toml @@ -19,7 +19,7 @@ uuid.workspace = true wayfinder-apple-foundation-xpc = { path = "../wayfinder-apple-foundation-xpc" } wayfinder-codex-app-server = { path = "../wayfinder-codex-app-server" } wayfinder-config = { path = "../wayfinder-config" } -wayfinder-core = { path = "../wayfinder-core" } +wayfinder-routing-core = { path = "../wayfinder-routing-core" } wayfinder-gateway = { path = "../wayfinder-gateway" } wayfinder-providers = { path = "../wayfinder-providers" } wayfinder-service = { path = "../wayfinder-service" } diff --git a/rust/crates/wayfinder-cli/src/config_command.rs b/rust/crates/wayfinder-cli/src/config_command.rs index 4807c53d..faf73448 100644 --- a/rust/crates/wayfinder-cli/src/config_command.rs +++ b/rust/crates/wayfinder-cli/src/config_command.rs @@ -14,7 +14,7 @@ use wayfinder_config::{ CONFIG_FILE, CONFIG_PATH_ENV, TierOrderPolicy, apply_supported_routing_fragment, find_config_file, routing_config_from_toml, }; -use wayfinder_core::{FEATURE_ORDER, RoutingConfig, Weights}; +use wayfinder_routing_core::{FEATURE_ORDER, RoutingConfig, Weights}; use crate::{EXIT_CONFIG, EXIT_OK, EXIT_USAGE, write_error, write_output}; diff --git a/rust/crates/wayfinder-cli/src/lib.rs b/rust/crates/wayfinder-cli/src/lib.rs index 978fd247..5ad4a476 100644 --- a/rust/crates/wayfinder-cli/src/lib.rs +++ b/rust/crates/wayfinder-cli/src/lib.rs @@ -25,10 +25,6 @@ use wayfinder_config::{ CONFIG_PATH_ENV, THRESHOLD_ENV, TierOrderPolicy, find_config_file, load_routing_config, routing_config_from_toml, }; -use wayfinder_core::{ - ComplexityScore, FEATURE_ORDER, Lexicon, RoutingConfig, RoutingMode, Weights, binary_tiers, - explain_score, score_complexity, -}; use wayfinder_gateway::delivery::{ AppleFoundationModelDelivery, BufferedProviderDelivery, CodexAppServerDelivery, CredentialError, CredentialSource, OpenAiCompatibleDelivery, ProviderDelivery, @@ -40,6 +36,10 @@ use wayfinder_gateway::{AppState, ConfiguredModel, RouteOn, build_reloadable_rou use wayfinder_providers::openai_compat::{ DEFAULT_CONNECT_TIMEOUT, OpenAiProviderClient, ProviderClientConfig, SecretValue, }; +use wayfinder_routing_core::{ + ComplexityScore, FEATURE_ORDER, Lexicon, RoutingConfig, RoutingMode, Weights, binary_tiers, + explain_score, score_complexity, +}; use wayfinder_service::credentials::{LegacyCommandLimits, resolve_legacy_command}; use wayfinder_service::pricing::{LedgerLoad, SavingsLedger}; diff --git a/rust/crates/wayfinder-compat-tests/Cargo.toml b/rust/crates/wayfinder-compat-tests/Cargo.toml index b9a997c3..902c513a 100644 --- a/rust/crates/wayfinder-compat-tests/Cargo.toml +++ b/rust/crates/wayfinder-compat-tests/Cargo.toml @@ -16,7 +16,7 @@ serde_json.workspace = true tokio.workspace = true tower.workspace = true wayfinder-config = { path = "../wayfinder-config" } -wayfinder-core = { path = "../wayfinder-core" } +wayfinder-routing-core = { path = "../wayfinder-routing-core" } wayfinder-gateway = { path = "../wayfinder-gateway" } wayfinder-service = { path = "../wayfinder-service" } diff --git a/rust/crates/wayfinder-compat-tests/src/bin/decision-bench.rs b/rust/crates/wayfinder-compat-tests/src/bin/decision-bench.rs index 7fc07548..b5402a3a 100644 --- a/rust/crates/wayfinder-compat-tests/src/bin/decision-bench.rs +++ b/rust/crates/wayfinder-compat-tests/src/bin/decision-bench.rs @@ -10,7 +10,7 @@ use std::time::Instant; use serde::Deserialize; use serde_json::json; -use wayfinder_core::{RoutingConfig, score_complexity}; +use wayfinder_routing_core::{RoutingConfig, score_complexity}; #[derive(Deserialize)] struct Fixture { diff --git a/rust/crates/wayfinder-compat-tests/src/bin/gateway-bench.rs b/rust/crates/wayfinder-compat-tests/src/bin/gateway-bench.rs index e13e5488..3bc3c947 100644 --- a/rust/crates/wayfinder-compat-tests/src/bin/gateway-bench.rs +++ b/rust/crates/wayfinder-compat-tests/src/bin/gateway-bench.rs @@ -12,8 +12,8 @@ use http::{Request, StatusCode}; use serde::Deserialize; use serde_json::{Value, json}; use tower::ServiceExt; -use wayfinder_core::RoutingConfig; use wayfinder_gateway::{AppState, build_router}; +use wayfinder_routing_core::RoutingConfig; const MAX_OPERATIONS: usize = 2_000_000; const RESPONSE_LIMIT: usize = 1024 * 1024; diff --git a/rust/crates/wayfinder-compat-tests/tests/config_parity.rs b/rust/crates/wayfinder-compat-tests/tests/config_parity.rs index 85b4628e..f2c6ae71 100644 --- a/rust/crates/wayfinder-compat-tests/tests/config_parity.rs +++ b/rust/crates/wayfinder-compat-tests/tests/config_parity.rs @@ -3,7 +3,7 @@ use std::error::Error; use serde::Deserialize; use wayfinder_config::{TierOrderPolicy, routing_config_from_toml}; -use wayfinder_core::{FEATURE_ORDER, RoutingConfig, RoutingMode, score_complexity}; +use wayfinder_routing_core::{FEATURE_ORDER, RoutingConfig, RoutingMode, score_complexity}; const ROUTING_CONFIG_VECTORS: &str = include_str!("../fixtures/routing-config.json"); const WHERE: &str = "compat-vector"; diff --git a/rust/crates/wayfinder-compat-tests/tests/embedded_gateway_parity.rs b/rust/crates/wayfinder-compat-tests/tests/embedded_gateway_parity.rs new file mode 100644 index 00000000..ff2da636 --- /dev/null +++ b/rust/crates/wayfinder-compat-tests/tests/embedded_gateway_parity.rs @@ -0,0 +1,51 @@ +use std::error::Error; + +use axum::body::{Body, to_bytes}; +use http::{Request, StatusCode}; +use serde_json::{Value, json}; +use tower::ServiceExt; +use wayfinder_gateway::{AppState, build_router}; +use wayfinder_routing_core::{RoutingConfig, score_complexity}; + +#[tokio::test] +async fn embedded_core_and_gateway_return_identical_decisions() -> Result<(), Box> { + let routing = RoutingConfig::binary(0.5); + let state = AppState::new(routing.clone(), Vec::new(), false, "2026.7.0"); + let prompts = [ + "hi", + "Prove the theorem under exactly these constraints. Derive the invariant and explain why it prevents concurrency deadlock.", + ]; + + for prompt in prompts { + let embedded = score_complexity(prompt, &routing)?; + let request = Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&json!({ + "model": "auto", + "messages": [{"role": "user", "content": prompt}] + }))?))?; + let response = build_router(state.clone()).oneshot(request).await?; + assert_eq!(response.status(), StatusCode::OK, "{prompt:?}"); + let body = to_bytes(response.into_body(), 1024 * 1024).await?; + let gateway: Value = serde_json::from_slice(&body)?; + let decision = gateway + .get("wayfinder") + .ok_or("gateway response omitted decision")?; + + assert_eq!(decision["score"], json!(embedded.score), "{prompt:?} score"); + assert_eq!( + decision["model"], + json!(embedded.recommendation), + "{prompt:?} recommendation" + ); + assert_eq!( + decision["features"], + serde_json::to_value(embedded.features)?, + "{prompt:?} features" + ); + } + + Ok(()) +} diff --git a/rust/crates/wayfinder-compat-tests/tests/gateway_http_parity.rs b/rust/crates/wayfinder-compat-tests/tests/gateway_http_parity.rs index 48f8838a..407cdac5 100644 --- a/rust/crates/wayfinder-compat-tests/tests/gateway_http_parity.rs +++ b/rust/crates/wayfinder-compat-tests/tests/gateway_http_parity.rs @@ -6,8 +6,8 @@ use http::{Request, StatusCode}; use serde::Deserialize; use serde_json::{Map, Value}; use tower::ServiceExt; -use wayfinder_core::RoutingConfig; use wayfinder_gateway::{AppState, ConfiguredModel, build_router}; +use wayfinder_routing_core::RoutingConfig; const GATEWAY_HTTP_VECTORS: &str = include_str!("../fixtures/gateway-http.json"); #[derive(Debug, Deserialize)] diff --git a/rust/crates/wayfinder-compat-tests/tests/routing_parity.rs b/rust/crates/wayfinder-compat-tests/tests/routing_parity.rs index dcd6a356..a92a82e1 100644 --- a/rust/crates/wayfinder-compat-tests/tests/routing_parity.rs +++ b/rust/crates/wayfinder-compat-tests/tests/routing_parity.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use std::error::Error; use serde::Deserialize; -use wayfinder_core::{ +use wayfinder_routing_core::{ ClassifierModel, ComplexityScore, RoutingConfig, RoutingMode, Tier, Weights, score_complexity, }; @@ -131,7 +131,7 @@ fn assert_decision_matches( ); assert_eq!( expected_features.len(), - wayfinder_core::FEATURE_ORDER.len(), + wayfinder_routing_core::FEATURE_ORDER.len(), "{name} feature count" ); for (feature, expected_value) in expected_features { diff --git a/rust/crates/wayfinder-config/Cargo.toml b/rust/crates/wayfinder-config/Cargo.toml index 795fe94b..6c25b6f6 100644 --- a/rust/crates/wayfinder-config/Cargo.toml +++ b/rust/crates/wayfinder-config/Cargo.toml @@ -13,7 +13,7 @@ serde.workspace = true thiserror.workspace = true toml.workspace = true toml_edit.workspace = true -wayfinder-core = { path = "../wayfinder-core" } +wayfinder-routing-core = { path = "../wayfinder-routing-core" } [dev-dependencies] serde_json.workspace = true diff --git a/rust/crates/wayfinder-config/src/lib.rs b/rust/crates/wayfinder-config/src/lib.rs index 3968c3b1..37341e62 100644 --- a/rust/crates/wayfinder-config/src/lib.rs +++ b/rust/crates/wayfinder-config/src/lib.rs @@ -11,7 +11,7 @@ use std::path::{Path, PathBuf}; use thiserror::Error; use toml::Value; -use wayfinder_core::{ +use wayfinder_routing_core::{ ClassifierModel, CoreError, FEATURE_ORDER, Lexicon, RoutingConfig, Tier, Weights, binary_tiers, python_round, }; @@ -818,7 +818,7 @@ fn invalid(where_: &str, message: impl Into) -> ConfigError { #[cfg(test)] mod tests { use super::*; - use wayfinder_core::{RoutingMode, score_complexity}; + use wayfinder_routing_core::{RoutingMode, score_complexity}; fn parse(text: &str, order: TierOrderPolicy) -> Result { routing_config_from_toml(text, "fixture", None, order) diff --git a/rust/crates/wayfinder-gateway/Cargo.toml b/rust/crates/wayfinder-gateway/Cargo.toml index fbd314fe..69715f06 100644 --- a/rust/crates/wayfinder-gateway/Cargo.toml +++ b/rust/crates/wayfinder-gateway/Cargo.toml @@ -28,7 +28,7 @@ uuid.workspace = true wayfinder-config = { path = "../wayfinder-config" } wayfinder-apple-foundation-xpc = { path = "../wayfinder-apple-foundation-xpc" } wayfinder-codex-app-server = { path = "../wayfinder-codex-app-server" } -wayfinder-core = { path = "../wayfinder-core" } +wayfinder-routing-core = { path = "../wayfinder-routing-core" } wayfinder-providers = { path = "../wayfinder-providers" } wayfinder-service = { path = "../wayfinder-service" } diff --git a/rust/crates/wayfinder-gateway/src/codex_control.rs b/rust/crates/wayfinder-gateway/src/codex_control.rs index 9066560c..1bb55ca3 100644 --- a/rust/crates/wayfinder-gateway/src/codex_control.rs +++ b/rust/crates/wayfinder-gateway/src/codex_control.rs @@ -501,7 +501,7 @@ mod tests { use axum::http::{Method, Request}; use serde_json::{Value, json}; use tower::ServiceExt; - use wayfinder_core::RoutingConfig; + use wayfinder_routing_core::RoutingConfig; use super::*; use crate::{AppState, build_router}; diff --git a/rust/crates/wayfinder-gateway/src/decision_policy.rs b/rust/crates/wayfinder-gateway/src/decision_policy.rs index dac16ae9..4a5a3bd6 100644 --- a/rust/crates/wayfinder-gateway/src/decision_policy.rs +++ b/rust/crates/wayfinder-gateway/src/decision_policy.rs @@ -8,7 +8,9 @@ use std::collections::BTreeSet; use serde_json::Value; use thiserror::Error; use wayfinder_config::gateway::GatewayConfig; -use wayfinder_core::{CoreError, Lexicon, RoutingConfig, Tier, recommend_tier, score_complexity}; +use wayfinder_routing_core::{ + CoreError, Lexicon, RoutingConfig, Tier, recommend_tier, score_complexity, +}; /// Header carrying a binary threshold override. pub const THRESHOLD_HEADER: &str = "x-wayfinder-threshold"; @@ -574,7 +576,7 @@ model = "large" #[test] fn classifier_prefer_directive_falls_through() -> Result<(), Box> { let routing = RoutingConfig { - classifier: Some(wayfinder_core::ClassifierModel::new( + classifier: Some(wayfinder_routing_core::ClassifierModel::new( vec!["local".to_owned(), "cloud".to_owned()], Default::default(), vec![0.0, 0.0], diff --git a/rust/crates/wayfinder-gateway/src/lib.rs b/rust/crates/wayfinder-gateway/src/lib.rs index 4f90331a..420092b4 100644 --- a/rust/crates/wayfinder-gateway/src/lib.rs +++ b/rust/crates/wayfinder-gateway/src/lib.rs @@ -44,11 +44,6 @@ use tower::ServiceExt; use uuid::Uuid; use wayfinder_config::dump_routing_toml; use wayfinder_config::gateway::{GatewayModel, ProviderKind, ProviderTier}; -use wayfinder_core::profiles::{LexiconProfile, profiles}; -use wayfinder_core::{ - ComplexityScore, FeatureContribution, Features, RoutingConfig, Tier, explain_score, - python_round, recommend_tier, score_complexity, -}; use wayfinder_providers::anthropic::{ MessagesStreamTranslator, anthropic_error, anthropic_to_openai_request, openai_to_anthropic_response, @@ -58,6 +53,11 @@ use wayfinder_providers::reliability::{ failover_candidates, is_auth_failure, is_retryable, precheck_ok, }; use wayfinder_providers::sse::{SseDecoder, SseEvent}; +use wayfinder_routing_core::profiles::{LexiconProfile, profiles}; +use wayfinder_routing_core::{ + ComplexityScore, FeatureContribution, Features, RoutingConfig, Tier, explain_score, + python_round, recommend_tier, score_complexity, +}; use wayfinder_service::pricing::{ LedgerError, PriceTable, SavingsLedger, SavingsReport, UtcDate, estimate_tokens, price_table, table_version, turn_cost, usage_tokens, @@ -3134,7 +3134,7 @@ mod tests { use wayfinder_config::gateway::{ Budget as GatewayBudget, GatewayConfig, ProviderKind, ProviderTier, RateLimit, VirtualKey, }; - use wayfinder_core::{ClassifierModel, RoutingConfig}; + use wayfinder_routing_core::{ClassifierModel, RoutingConfig}; use wayfinder_service::pricing::{SavingsLedger, UtcDate, turn_cost}; use super::{ diff --git a/rust/crates/wayfinder-gateway/src/metrics.rs b/rust/crates/wayfinder-gateway/src/metrics.rs index 7cc8a099..3d8dc734 100644 --- a/rust/crates/wayfinder-gateway/src/metrics.rs +++ b/rust/crates/wayfinder-gateway/src/metrics.rs @@ -5,7 +5,7 @@ use std::fmt::Write as _; use std::sync::Mutex; use thiserror::Error; -use wayfinder_core::python_round; +use wayfinder_routing_core::python_round; /// Default maximum unique values retained by any dynamic label family. pub const DEFAULT_MAX_LABEL_SERIES: usize = 256; diff --git a/rust/crates/wayfinder-providers/Cargo.toml b/rust/crates/wayfinder-providers/Cargo.toml index b223d6ec..94094263 100644 --- a/rust/crates/wayfinder-providers/Cargo.toml +++ b/rust/crates/wayfinder-providers/Cargo.toml @@ -14,7 +14,7 @@ http.workspace = true reqwest.workspace = true serde_json.workspace = true thiserror.workspace = true -wayfinder-core = { path = "../wayfinder-core" } +wayfinder-routing-core = { path = "../wayfinder-routing-core" } [dev-dependencies] tokio.workspace = true diff --git a/rust/crates/wayfinder-providers/src/reliability.rs b/rust/crates/wayfinder-providers/src/reliability.rs index 274bf66b..561c38e9 100644 --- a/rust/crates/wayfinder-providers/src/reliability.rs +++ b/rust/crates/wayfinder-providers/src/reliability.rs @@ -6,7 +6,7 @@ use std::collections::{HashMap, HashSet}; -use wayfinder_core::python_round; +use wayfinder_routing_core::python_round; /// HTTP statuses that may succeed when attempted again. pub const RETRYABLE_STATUS: [u16; 5] = [429, 500, 502, 503, 504]; diff --git a/rust/crates/wayfinder-routing-core/Cargo.toml b/rust/crates/wayfinder-routing-core/Cargo.toml new file mode 100644 index 00000000..8da19508 --- /dev/null +++ b/rust/crates/wayfinder-routing-core/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "wayfinder-routing-core" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +regex.workspace = true +serde.workspace = true +thiserror.workspace = true +wayfinder-runtime-contracts = { path = "../wayfinder-runtime-contracts" } + +[dev-dependencies] +serde_json.workspace = true +toml.workspace = true + +[lints] +workspace = true diff --git a/rust/crates/wayfinder-routing-core/README.md b/rust/crates/wayfinder-routing-core/README.md new file mode 100644 index 00000000..af4cf759 --- /dev/null +++ b/rust/crates/wayfinder-routing-core/README.md @@ -0,0 +1,12 @@ +# wayfinder-routing-core + +The one authoritative, deterministic Wayfinder routing implementation. + +This crate may depend only on portable parsing, serialization, and error +libraries. Production code here must not perform filesystem, network, process, +async-runtime, Keychain, provider, UI, or Apple-framework work. + +The gateway and generated Apple bindings consume this crate and the same golden +fixtures. Platform hosts supply typed values from +`wayfinder-runtime-contracts`; they do not reimplement scoring or tier +selection. diff --git a/rust/crates/wayfinder-core/src/lib.rs b/rust/crates/wayfinder-routing-core/src/lib.rs similarity index 78% rename from rust/crates/wayfinder-core/src/lib.rs rename to rust/crates/wayfinder-routing-core/src/lib.rs index 140467c9..3251014f 100644 --- a/rust/crates/wayfinder-core/src/lib.rs +++ b/rust/crates/wayfinder-routing-core/src/lib.rs @@ -1,7 +1,8 @@ //! Pure, deterministic Wayfinder routing core. //! -//! This crate mirrors `wayfinder_router.complexity` and deliberately has no -//! network, process, filesystem, async-runtime, or secret dependencies. +//! This crate is the single authoritative routing implementation shared by +//! gateway and embedded Apple hosts. It deliberately has no network, process, +//! filesystem, async-runtime, platform-framework, or secret dependencies. #![forbid(unsafe_code)] @@ -13,6 +14,13 @@ use std::collections::{BTreeMap, BTreeSet}; use std::sync::OnceLock; use thiserror::Error; +pub use wayfinder_runtime_contracts::{ + BillingClass, CandidateAssessment, DestinationCapabilities, DestinationSnapshot, + ExclusionReason, ExecutionBoundary, PrivacyPosture, ProviderReadiness, + RUNTIME_CONTRACT_VERSION, RouteExplanation, RoutePlan, RouteReceipt, RoutingRequest, + RoutingRequirements, +}; + /// Default cut for the binary local/cloud router. pub const DEFAULT_THRESHOLD: f64 = 0.5; @@ -119,6 +127,9 @@ pub enum CoreError { /// A classifier shape is invalid. #[error("invalid classifier: {0}")] InvalidClassifier(String), + /// A platform-neutral runtime contract is invalid. + #[error("invalid runtime contract: {0}")] + InvalidContract(String), } #[derive(Debug)] @@ -756,6 +767,130 @@ pub fn explain_score(features: &Features, weights: &Weights) -> Vec CandidateAssessment { + let mut exclusions = Vec::new(); + if destination.readiness != ProviderReadiness::Ready { + exclusions.push(ExclusionReason::ProviderNotReady); + } + if !request + .privacy_posture + .permits(destination.execution_boundary) + { + exclusions.push(ExclusionReason::PrivacyBoundaryDenied); + } + if !destination.capabilities.text { + exclusions.push(ExclusionReason::TextUnsupported); + } + if let Some(required) = request.requirements.context_tokens { + match destination.context_window { + Some(available) if available < required => { + exclusions.push(ExclusionReason::ContextWindowTooSmall); + } + None => exclusions.push(ExclusionReason::ContextWindowUnknown), + Some(_) => {} + } + } + if request.requirements.image_input && !destination.capabilities.image_input { + exclusions.push(ExclusionReason::ImageInputUnsupported); + } + if request.requirements.tools && !destination.capabilities.tools { + exclusions.push(ExclusionReason::ToolsUnsupported); + } + if request.requirements.streaming && !destination.capabilities.streaming { + exclusions.push(ExclusionReason::StreamingUnsupported); + } + if !destination.automatic_eligible { + exclusions.push(ExclusionReason::AutomaticNotAllowed); + } + CandidateAssessment { + destination_id: destination.id.clone(), + exclusions, + } +} + +/// Plan an Automatic route over secret-free destination snapshots. +/// +/// The configured scorer selects a tier. Hard filters run first, then the +/// first eligible destination in that tier wins with remaining matching +/// candidates preserved as stable pre-output fallback order. This function +/// never crosses to a different tier implicitly. +pub fn plan_automatic_route( + request: &RoutingRequest, + candidates: &[DestinationSnapshot], + config: &RoutingConfig, +) -> Result { + validate_runtime_contract(request, candidates)?; + let scored = score_complexity(&request.prompt, config)?; + let assessments = candidates + .iter() + .map(|candidate| assess_destination(request, candidate)) + .collect::>(); + let eligible_ids = candidates + .iter() + .zip(&assessments) + .filter(|(candidate, assessment)| { + assessment.is_eligible() && candidate.route_tier == scored.recommendation + }) + .map(|(candidate, _)| candidate.id.clone()) + .collect::>(); + let selected_destination_id = eligible_ids.first().cloned(); + let fallback_destination_ids = eligible_ids.into_iter().skip(1).collect(); + + Ok(RoutePlan { + schema_version: RUNTIME_CONTRACT_VERSION, + request_id: request.request_id.clone(), + score: scored.score, + recommendation: scored.recommendation, + selected_destination_id, + fallback_destination_ids, + candidates: assessments, + }) +} + +fn validate_runtime_contract( + request: &RoutingRequest, + candidates: &[DestinationSnapshot], +) -> Result<(), CoreError> { + if request.schema_version != RUNTIME_CONTRACT_VERSION { + return Err(CoreError::InvalidContract(format!( + "unsupported schema version {}", + request.schema_version + ))); + } + if request.request_id.trim().is_empty() { + return Err(CoreError::InvalidContract( + "request_id must be non-empty".to_owned(), + )); + } + let mut ids = BTreeSet::new(); + for candidate in candidates { + if candidate.id.trim().is_empty() + || candidate.provider_id.trim().is_empty() + || candidate.model_id.trim().is_empty() + || candidate.route_tier.trim().is_empty() + { + return Err(CoreError::InvalidContract( + "destination identity and route_tier must be non-empty".to_owned(), + )); + } + if !ids.insert(candidate.id.as_str()) { + return Err(CoreError::InvalidContract(format!( + "duplicate destination id {:?}", + candidate.id + ))); + } + } + Ok(()) +} + /// Python `round(value, places)` behavior for the bounded numeric paths used by /// routing. It rounds the formatted true binary value half-to-even, matching the /// parity-proven JavaScript mirror rather than Rust's integer `round` rule. @@ -910,6 +1045,44 @@ mod tests { score_complexity(text, &RoutingConfig::default()) } + fn destination( + id: &str, + route_tier: &str, + execution_boundary: ExecutionBoundary, + ) -> DestinationSnapshot { + DestinationSnapshot { + id: id.to_owned(), + provider_id: format!("provider-{id}"), + model_id: format!("model-{id}"), + display_name: id.to_owned(), + route_tier: route_tier.to_owned(), + execution_boundary, + readiness: ProviderReadiness::Ready, + billing_class: BillingClass::Unknown, + context_window: Some(8_192), + capabilities: DestinationCapabilities { + text: true, + streaming: true, + image_input: false, + tools: false, + }, + automatic_eligible: true, + } + } + + fn request(privacy_posture: PrivacyPosture) -> RoutingRequest { + RoutingRequest { + schema_version: RUNTIME_CONTRACT_VERSION, + request_id: "request-1".to_owned(), + prompt: "hello".to_owned(), + privacy_posture, + requirements: RoutingRequirements { + streaming: true, + ..RoutingRequirements::default() + }, + } + } + #[test] fn python_round_matches_binary_half_even_traps() { assert_eq!(python_round(0.005, 2), 0.01); @@ -1019,4 +1192,71 @@ mod tests { assert_eq!(explanation.len(), 11); Ok(()) } + + #[test] + fn automatic_plan_filters_before_scoring_and_preserves_fallback_order() -> Result<(), CoreError> + { + let hosted = destination("hosted", "local", ExecutionBoundary::Hosted); + let local_first = destination("local-first", "local", ExecutionBoundary::OnDevice); + let local_second = destination("local-second", "local", ExecutionBoundary::OnDevice); + + let plan = plan_automatic_route( + &request(PrivacyPosture::OnDeviceOnly), + &[hosted, local_first, local_second], + &RoutingConfig::default(), + )?; + + assert_eq!(plan.recommendation, "local"); + assert_eq!(plan.selected_destination_id.as_deref(), Some("local-first")); + assert_eq!(plan.fallback_destination_ids, ["local-second"]); + assert_eq!( + plan.candidates[0].exclusions, + [ExclusionReason::PrivacyBoundaryDenied] + ); + assert!(plan.candidates[1].is_eligible()); + assert!(plan.candidates[2].is_eligible()); + Ok(()) + } + + #[test] + fn automatic_plan_does_not_cross_tiers_when_selected_tier_is_unavailable() + -> Result<(), CoreError> { + let hosted = destination("hosted", "cloud", ExecutionBoundary::Hosted); + let plan = plan_automatic_route( + &request(PrivacyPosture::HostedAllowed), + &[hosted], + &RoutingConfig::default(), + )?; + + assert_eq!(plan.recommendation, "local"); + assert_eq!(plan.selected_destination_id, None); + assert!(plan.fallback_destination_ids.is_empty()); + assert!(plan.candidates[0].is_eligible()); + Ok(()) + } + + #[test] + fn candidate_assessment_fails_closed_for_unknown_context() { + let mut candidate = destination("local", "local", ExecutionBoundary::OnDevice); + candidate.context_window = None; + let mut input = request(PrivacyPosture::OnDeviceOnly); + input.requirements.context_tokens = Some(1_024); + + assert_eq!( + assess_destination(&input, &candidate).exclusions, + [ExclusionReason::ContextWindowUnknown] + ); + } + + #[test] + fn automatic_plan_rejects_duplicate_destination_ids() { + let candidate = destination("same", "local", ExecutionBoundary::OnDevice); + let result = plan_automatic_route( + &request(PrivacyPosture::OnDeviceOnly), + &[candidate.clone(), candidate], + &RoutingConfig::default(), + ); + + assert!(matches!(result, Err(CoreError::InvalidContract(_)))); + } } diff --git a/rust/crates/wayfinder-core/src/profiles.rs b/rust/crates/wayfinder-routing-core/src/profiles.rs similarity index 99% rename from rust/crates/wayfinder-core/src/profiles.rs rename to rust/crates/wayfinder-routing-core/src/profiles.rs index cba04d7c..71931221 100644 --- a/rust/crates/wayfinder-core/src/profiles.rs +++ b/rust/crates/wayfinder-routing-core/src/profiles.rs @@ -1,4 +1,4 @@ -//! Stock starter lexicons, ported byte-for-byte from `wayfinder_router.profiles`. +//! Stock starter lexicons preserved from the checked migration corpus. //! //! These are calibration seeds, not validated routers. `curated` profiles are //! hand-authored; `mined` profiles come from RouterBench and retain the Python diff --git a/rust/crates/wayfinder-routing-core/tests/dependency_boundary.rs b/rust/crates/wayfinder-routing-core/tests/dependency_boundary.rs new file mode 100644 index 00000000..b13a3af4 --- /dev/null +++ b/rust/crates/wayfinder-routing-core/tests/dependency_boundary.rs @@ -0,0 +1,28 @@ +use std::collections::BTreeSet; +use std::error::Error; + +use toml::Value; + +const MANIFEST: &str = include_str!("../Cargo.toml"); + +#[test] +fn production_dependencies_preserve_the_pure_routing_boundary() -> Result<(), Box> { + let manifest = toml::from_str::(MANIFEST)?; + let dependencies = manifest + .get("dependencies") + .and_then(Value::as_table) + .ok_or("routing-core manifest must declare dependencies")?; + let actual = dependencies + .keys() + .map(String::as_str) + .collect::>(); + let expected = ["regex", "serde", "thiserror", "wayfinder-runtime-contracts"] + .into_iter() + .collect::>(); + + assert_eq!( + actual, expected, + "adding a production dependency requires an explicit routing-boundary review" + ); + Ok(()) +} diff --git a/rust/crates/wayfinder-core/Cargo.toml b/rust/crates/wayfinder-runtime-contracts/Cargo.toml similarity index 79% rename from rust/crates/wayfinder-core/Cargo.toml rename to rust/crates/wayfinder-runtime-contracts/Cargo.toml index 38055056..0e9f19a0 100644 --- a/rust/crates/wayfinder-core/Cargo.toml +++ b/rust/crates/wayfinder-runtime-contracts/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "wayfinder-core" +name = "wayfinder-runtime-contracts" version.workspace = true edition.workspace = true rust-version.workspace = true @@ -8,9 +8,7 @@ license.workspace = true repository.workspace = true [dependencies] -regex.workspace = true serde.workspace = true -thiserror.workspace = true [dev-dependencies] serde_json.workspace = true diff --git a/rust/crates/wayfinder-runtime-contracts/src/lib.rs b/rust/crates/wayfinder-runtime-contracts/src/lib.rs new file mode 100644 index 00000000..5741db26 --- /dev/null +++ b/rust/crates/wayfinder-runtime-contracts/src/lib.rs @@ -0,0 +1,302 @@ +//! Platform-neutral data contracts shared by Wayfinder routing hosts. +//! +//! These value types contain no credentials, provider payloads, filesystem +//! paths, platform handles, or executable behavior. They are suitable for +//! generated bindings and portable golden fixtures. + +#![forbid(unsafe_code)] + +use serde::{Deserialize, Serialize}; + +/// Current schema version for embedded runtime values. +pub const RUNTIME_CONTRACT_VERSION: u32 = 1; + +/// Where prompt content is executed. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ExecutionBoundary { + /// The current device executes the request. + OnDevice, + /// A trusted device on the local network receives the request. + LocalNetwork, + /// A hosted provider receives the request. + Hosted, +} + +/// User-selected maximum content boundary. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum PrivacyPosture { + /// Only the current device may receive content. + OnDeviceOnly, + /// The current device or trusted local devices may receive content. + LocalDevices, + /// On-device, local-network, and hosted destinations are permitted. + HostedAllowed, +} + +impl PrivacyPosture { + /// Whether this posture permits a destination boundary. + #[must_use] + pub const fn permits(self, boundary: ExecutionBoundary) -> bool { + match self { + Self::OnDeviceOnly => matches!(boundary, ExecutionBoundary::OnDevice), + Self::LocalDevices => { + matches!( + boundary, + ExecutionBoundary::OnDevice | ExecutionBoundary::LocalNetwork + ) + } + Self::HostedAllowed => true, + } + } +} + +/// Normalized provider readiness used before scoring. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ProviderReadiness { + /// Readiness is not yet known. + Checking, + /// Account authorization is absent. + SignedOut, + /// An authorization flow is active. + Authorizing, + /// The destination may execute requests. + Ready, + /// Stored authorization must be refreshed by the user. + ReauthenticationRequired, + /// The provider reports a current usage limit. + UsageLimited, + /// The configured model is not currently available. + ModelUnavailable, + /// Required network access is not currently available. + NetworkUnavailable, + /// This host platform cannot execute the provider. + UnsupportedPlatform, + /// The provider is unavailable for a bounded known reason. + Unavailable, + /// Readiness failed with a sanitized error. + Failed, +} + +/// Provider accounting class without fabricated cost semantics. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum BillingClass { + /// Execution uses a model on the current device. + OnDevice, + /// Execution is covered by a documented account subscription. + Subscription, + /// Execution is metered by an API provider. + ApiMetered, + /// Accounting semantics are not known. + Unknown, +} + +/// Capabilities that can make a destination ineligible before scoring. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct DestinationCapabilities { + /// Text input and output are supported. + pub text: bool, + /// Incremental output is supported. + pub streaming: bool, + /// Image input is supported. + pub image_input: bool, + /// Reviewed tool calls are supported. + pub tools: bool, +} + +/// A host-provided, secret-free snapshot of one concrete destination. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct DestinationSnapshot { + /// Stable destination identifier. + pub id: String, + /// Stable provider identifier. + pub provider_id: String, + /// Provider model identifier. + pub model_id: String, + /// User-visible destination name. + pub display_name: String, + /// Configured routing tier or route member name. + pub route_tier: String, + /// Current content execution boundary. + pub execution_boundary: ExecutionBoundary, + /// Current normalized readiness. + pub readiness: ProviderReadiness, + /// Accounting classification. + pub billing_class: BillingClass, + /// Advertised context limit when known. + pub context_window: Option, + /// Advertised destination capabilities. + pub capabilities: DestinationCapabilities, + /// Whether the user has allowed Automatic to consider this destination. + pub automatic_eligible: bool, +} + +/// Hard requirements supplied to eligibility filtering before scoring. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct RoutingRequirements { + /// Required context capacity when known. + pub context_tokens: Option, + /// The request includes image input. + pub image_input: bool, + /// The request requires tool support. + pub tools: bool, + /// The caller requires incremental output. + pub streaming: bool, +} + +/// Platform-neutral input to the authoritative routing engine. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct RoutingRequest { + /// Contract version; must equal [`RUNTIME_CONTRACT_VERSION`]. + pub schema_version: u32, + /// Opaque bounded request identifier supplied by the host. + pub request_id: String, + /// Text scored by the deterministic routing core. + pub prompt: String, + /// Maximum content boundary selected by the user. + pub privacy_posture: PrivacyPosture, + /// Hard compatibility requirements. + pub requirements: RoutingRequirements, +} + +/// Stable reasons a candidate was excluded before scoring. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ExclusionReason { + /// Provider readiness is not `ready`. + ProviderNotReady, + /// The current privacy posture denies this boundary. + PrivacyBoundaryDenied, + /// Text generation is not supported. + TextUnsupported, + /// The destination does not advertise a context window. + ContextWindowUnknown, + /// The destination context window is too small. + ContextWindowTooSmall, + /// Image input is required but unsupported. + ImageInputUnsupported, + /// Tools are required but unsupported. + ToolsUnsupported, + /// Streaming is required but unsupported. + StreamingUnsupported, + /// The user has excluded this destination from Automatic. + AutomaticNotAllowed, +} + +/// Eligibility result for one input destination. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CandidateAssessment { + /// Destination being assessed. + pub destination_id: String, + /// Empty when the candidate is eligible. + pub exclusions: Vec, +} + +impl CandidateAssessment { + /// Whether the candidate passed every hard compatibility filter. + #[must_use] + pub fn is_eligible(&self) -> bool { + self.exclusions.is_empty() + } +} + +/// Deterministic output of scoring and destination planning. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct RoutePlan { + /// Contract version. + pub schema_version: u32, + /// Input request identifier. + pub request_id: String, + /// Rounded deterministic complexity score. + pub score: f64, + /// Tier or route member recommended by the configured scorer. + pub recommendation: String, + /// Selected concrete destination, absent when no candidate is eligible. + pub selected_destination_id: Option, + /// Remaining eligible destinations in stable fallback order. + pub fallback_destination_ids: Vec, + /// Assessment for every supplied candidate in input order. + pub candidates: Vec, +} + +/// Compact deterministic explanation suitable for a route inspector. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct RouteExplanation { + /// Rounded deterministic complexity score. + pub score: f64, + /// Stable reason codes emitted by the core. + pub reason_codes: Vec, +} + +/// Persistable, secret-free execution receipt. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct RouteReceipt { + /// Contract version. + pub schema_version: u32, + /// Destination selected by the route plan. + pub destination_id: String, + /// Provider display identifier at execution time. + pub provider_id: String, + /// Model display identifier at execution time. + pub model_id: String, + /// Actual content execution boundary. + pub execution_boundary: ExecutionBoundary, + /// Deterministic score when Automatic selected the destination. + pub score: Option, + /// Stable routing reason codes. + pub reason_codes: Vec, +} + +#[cfg(test)] +mod tests { + use std::error::Error; + + use super::*; + + #[test] + fn privacy_postures_enforce_exact_boundary_ladders() { + assert!(PrivacyPosture::OnDeviceOnly.permits(ExecutionBoundary::OnDevice)); + assert!(!PrivacyPosture::OnDeviceOnly.permits(ExecutionBoundary::LocalNetwork)); + assert!(!PrivacyPosture::OnDeviceOnly.permits(ExecutionBoundary::Hosted)); + + assert!(PrivacyPosture::LocalDevices.permits(ExecutionBoundary::OnDevice)); + assert!(PrivacyPosture::LocalDevices.permits(ExecutionBoundary::LocalNetwork)); + assert!(!PrivacyPosture::LocalDevices.permits(ExecutionBoundary::Hosted)); + + assert!(PrivacyPosture::HostedAllowed.permits(ExecutionBoundary::OnDevice)); + assert!(PrivacyPosture::HostedAllowed.permits(ExecutionBoundary::LocalNetwork)); + assert!(PrivacyPosture::HostedAllowed.permits(ExecutionBoundary::Hosted)); + } + + #[test] + fn route_contract_round_trips_without_secret_fields() -> Result<(), Box> { + let request = RoutingRequest { + schema_version: RUNTIME_CONTRACT_VERSION, + request_id: "request-1".to_owned(), + prompt: "Explain this locally".to_owned(), + privacy_posture: PrivacyPosture::OnDeviceOnly, + requirements: RoutingRequirements { + streaming: true, + ..RoutingRequirements::default() + }, + }; + + let encoded = serde_json::to_string(&request)?; + let decoded: RoutingRequest = serde_json::from_str(&encoded)?; + + assert_eq!(decoded, request); + for forbidden in [ + "access_token", + "refresh_token", + "api_key", + "authorization", + "credential_path", + ] { + assert!(!encoded.contains(forbidden)); + } + Ok(()) + } +} diff --git a/rust/crates/wayfinder-service/Cargo.toml b/rust/crates/wayfinder-service/Cargo.toml index 5400a583..259f7ada 100644 --- a/rust/crates/wayfinder-service/Cargo.toml +++ b/rust/crates/wayfinder-service/Cargo.toml @@ -14,7 +14,7 @@ serde_json.workspace = true sha2.workspace = true thiserror.workspace = true tokio.workspace = true -wayfinder-core = { path = "../wayfinder-core" } +wayfinder-routing-core = { path = "../wayfinder-routing-core" } wayfinder-providers = { path = "../wayfinder-providers" } [lints] diff --git a/rust/crates/wayfinder-service/src/pricing.rs b/rust/crates/wayfinder-service/src/pricing.rs index 2a33089a..fca00ea7 100644 --- a/rust/crates/wayfinder-service/src/pricing.rs +++ b/rust/crates/wayfinder-service/src/pricing.rs @@ -18,7 +18,7 @@ use serde::Serialize; use serde_json::Value; use sha2::{Digest, Sha256}; use thiserror::Error; -use wayfinder_core::python_round; +use wayfinder_routing_core::python_round; /// The compatibility estimate used when an upstream omits token usage. pub const CHARS_PER_TOKEN: usize = 4;