All checks were successful
CI / Format (push) Successful in 40s
CI / CUDA type-check (push) Successful in 1m39s
CI / Clippy (push) Successful in 2m56s
CI / Test (push) Successful in 6m36s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
cortex can now validate locally-unrecognised bearer keys against the
helexa-upstream authority and reserve/settle their budget there — mesh
accounts work for real inference. The EntitlementProvider trait is the
seam, so cortex's enforcement (auth.rs, metering.rs) is otherwise
unchanged.
- entitlements_upstream.rs: UpstreamEntitlementProvider over reqwest →
B2's /authz/v1 (resolve/reserve/settle/release/snapshot), presenting the
operator client bearer. Maps the wire contract back to the trait: granted
→ Reservation, rejected → BudgetError, 401 → InvalidKey. Fail-closed —
unreachable resolve → AuthError::Unavailable (503, never 401);
unreachable reserve → retryable BudgetError::RateLimited (refuse, never
serve un-authorized). settle/release are best-effort (the upstream
sweeper reaps a lost one).
- entitlements_chain.rs: ChainedEntitlementProvider tries local first
(operator + infra keys, no network), falls through to upstream for
unknown keys, and dispatches reserve/settle/release/snapshot to whichever
backend resolved each account (local treats unknown principals as
uncapped, so it can't be the blind default).
- cortex-core: AuthError::Unavailable{retry_after_secs}; [upstream] config
(enabled/url/bearer/timeout). auth.rs maps Unavailable → 503 +
Retry-After distinctly from InvalidKey → 401, regardless of require_auth.
- state.rs wires the chain when [upstream].enabled, else stays purely local.
Tests (upstream_chain.rs, 4): local key resolves without touching upstream;
unknown key falls through to a mock upstream; unknown-everywhere → 401
InvalidKey; upstream-unreachable → Unavailable (503-mapped), with local keys
still resolving. Existing gateway suites updated for the new config field.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
133 lines
4.7 KiB
Rust
133 lines
4.7 KiB
Rust
//! Issue #68: the `cost` wire contract on `GET /v1/models`.
|
|
//!
|
|
//! `cost` is operator-set pricing sourced from the `models.toml` catalogue
|
|
//! profile (the source of truth today; the marketplace clearing house #59
|
|
//! later — both must read the same value metering/#51 bills against). The
|
|
//! shape is the models.dev/opencode convention: **USD per 1,000,000 tokens,
|
|
//! as JSON numbers**, with optional `cache_read`/`cache_write` tiers. This
|
|
//! test pins:
|
|
//! - the units/shape (per-million floats, not per-token, not strings);
|
|
//! - that cache fields flow through when present and are omitted otherwise;
|
|
//! - the load-bearing **absent vs `0.0`** distinction (#68): a model with
|
|
//! no catalogue `cost` omits the key entirely (price unknown), distinct
|
|
//! from an explicit `0.0` (intentionally free).
|
|
//!
|
|
//! Catalogue-only models surface via Pass 1 of `list_models` even with no
|
|
//! feasible neuron, so this is hermetic — no nodes or poller needed.
|
|
|
|
use cortex_core::config::{
|
|
EvictionSettings, EvictionStrategy, GatewayConfig, GatewaySettings, NeuronEndpoint,
|
|
};
|
|
use cortex_gateway::state::CortexState;
|
|
use std::sync::Arc;
|
|
use tokio::net::TcpListener;
|
|
|
|
#[tokio::test]
|
|
async fn v1_models_cost_units_shape_and_absent_vs_zero() {
|
|
// Three catalogue models exercise the whole contract: a priced model
|
|
// with cache tiers, an intentionally-free model (explicit 0.0), and an
|
|
// unpriced model (no `cost` block at all).
|
|
let models_toml = r#"
|
|
[[models]]
|
|
id = "priced-model"
|
|
harness = "candle"
|
|
cost.input = 0.5
|
|
cost.output = 1.5
|
|
cost.cache_read = 0.05
|
|
cost.cache_write = 0.6
|
|
|
|
[[models]]
|
|
id = "free-model"
|
|
harness = "candle"
|
|
cost.input = 0.0
|
|
cost.output = 0.0
|
|
|
|
[[models]]
|
|
id = "unpriced-model"
|
|
harness = "candle"
|
|
"#;
|
|
let cat_path = std::env::temp_dir().join("cortex_test_issue68_models.toml");
|
|
std::fs::write(&cat_path, models_toml).unwrap();
|
|
|
|
let config = GatewayConfig {
|
|
gateway: GatewaySettings {
|
|
listen: "127.0.0.1:0".into(),
|
|
metrics_listen: "127.0.0.1:0".into(),
|
|
},
|
|
eviction: EvictionSettings {
|
|
strategy: EvictionStrategy::Lru,
|
|
defrag_after_cycles: 0,
|
|
},
|
|
// Never contacted: build_app does not spawn the poller, so the
|
|
// catalogue alone drives /v1/models.
|
|
neurons: vec![NeuronEndpoint {
|
|
name: "mock-node".into(),
|
|
endpoint: "http://127.0.0.1:1".into(),
|
|
}],
|
|
models_config: cat_path.to_string_lossy().into_owned(),
|
|
entitlements: Default::default(),
|
|
upstream: Default::default(),
|
|
};
|
|
|
|
let fleet = Arc::new(CortexState::from_config(&config));
|
|
let app = cortex_gateway::build_app(Arc::clone(&fleet));
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, app).await.unwrap();
|
|
});
|
|
|
|
let body: serde_json::Value = reqwest::Client::new()
|
|
.get(format!("http://{addr}/v1/models"))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
|
|
let data = body["data"].as_array().expect("data is an array");
|
|
let entry = |id: &str| {
|
|
data.iter()
|
|
.find(|m| m["id"] == id)
|
|
.unwrap_or_else(|| panic!("{id} present in /v1/models"))
|
|
.clone()
|
|
};
|
|
|
|
// Priced model: exact values flow through as JSON numbers (USD per 1M
|
|
// tokens). If anything rescaled by 10⁶ or stringified, these fail.
|
|
let priced = entry("priced-model");
|
|
assert_eq!(priced["cost"]["input"], 0.5);
|
|
assert_eq!(priced["cost"]["output"], 1.5);
|
|
assert_eq!(priced["cost"]["cache_read"], 0.05);
|
|
assert_eq!(priced["cost"]["cache_write"], 0.6);
|
|
assert!(
|
|
priced["cost"]["input"].is_number(),
|
|
"cost.input must be a JSON number, not a string"
|
|
);
|
|
|
|
// Intentionally free: cost present, rates explicitly 0.0. Unset cache
|
|
// tiers are omitted (skip_serializing_if), not emitted as null/0.
|
|
let free = entry("free-model");
|
|
assert_eq!(free["cost"]["input"], 0.0);
|
|
assert_eq!(free["cost"]["output"], 0.0);
|
|
assert!(
|
|
free["cost"].get("cache_read").is_none(),
|
|
"absent cache tiers must be omitted, not null"
|
|
);
|
|
assert!(free["cost"].get("cache_write").is_none());
|
|
|
|
// Unpriced: the whole `cost` object is omitted — "price unknown",
|
|
// distinct from the free model's explicit 0.0. This is the #68
|
|
// distinction opencode needs to avoid showing $0 for a model whose
|
|
// price simply hasn't been declared.
|
|
let unpriced = entry("unpriced-model");
|
|
assert!(
|
|
unpriced.get("cost").is_none(),
|
|
"a model with no catalogue cost must omit `cost` entirely, got {:?}",
|
|
unpriced.get("cost")
|
|
);
|
|
|
|
let _ = std::fs::remove_file(&cat_path);
|
|
}
|