mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 08:09:27 +00:00
feat: model pricing time-based conditonal pricing (#13903)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
github-actions[bot]
Claude Opus 5
parent
d87170ce0e
commit
abc0fdc42d
@@ -266,6 +266,33 @@ jobs:
|
||||
echo "No translation changes detected"
|
||||
fi
|
||||
|
||||
- name: Set up Node.js
|
||||
if: '!cancelled() && steps.check_changes.outputs.changes == ''true'''
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Format the generated locale files
|
||||
working-directory: web
|
||||
if: '!cancelled() && steps.check_changes.outputs.changes == ''true'''
|
||||
# `json.dumps(indent=2)` and prettier disagree about where a short array
|
||||
# wraps, so any locale file the script rewrites can land unformatted and
|
||||
# fail the `format:check` gate in unit-tests.yml — a failure the PR author
|
||||
# cannot see coming and did not cause. Formatting here, with the repo's
|
||||
# own pinned prettier and .prettierrc.json, means what gets committed is
|
||||
# already what that gate demands. Files the script left alone are already
|
||||
# formatted, so this adds nothing to the diff.
|
||||
#
|
||||
# `--ignore-scripts` because prettier and its plugins are pure JS: none of
|
||||
# the package install hooks (cypress, esbuild, @parcel/watcher) are needed
|
||||
# to format JSON, and skipping them keeps this step from running yet more
|
||||
# third-party code fetched on behalf of the PR.
|
||||
run: |
|
||||
npm ci --ignore-scripts
|
||||
npx prettier --write "src/locales/languages/*.json"
|
||||
|
||||
- name: Commit translations onto the PR branch
|
||||
id: commit
|
||||
if: '!cancelled() && steps.check_changes.outputs.changes == ''true'''
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -148,7 +148,10 @@ def main():
|
||||
# does not match prettier's formatting (short arrays are written
|
||||
# multi-line), so an unconditional write would reformat an otherwise
|
||||
# unchanged locale file and break the `format:check` gate. Skipping the
|
||||
# write keeps the committed prettier formatting intact.
|
||||
# write keeps the committed prettier formatting intact. A file that *did*
|
||||
# change is rewritten in json.dumps style throughout, so callers must run
|
||||
# prettier over whatever this rewrites — update-translations.yml does that
|
||||
# before it commits.
|
||||
if target != existing:
|
||||
# Flushed per locale (atomically), so a run that is cancelled or dies
|
||||
# part way through still leaves every completed locale on disk for CI
|
||||
|
||||
@@ -21,7 +21,9 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use config::meta::model_pricing::{BUILT_IN_ORG, META_ORG, ModelPricingDefinition, PricingSource};
|
||||
use config::meta::model_pricing::{
|
||||
BUILT_IN_ORG, META_ORG, MINUTES_PER_DAY, ModelPricingDefinition, PricingSource,
|
||||
};
|
||||
use db::model_pricing;
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(feature = "enterprise")]
|
||||
@@ -647,7 +649,8 @@ pub struct TestModelMatchRequest {
|
||||
#[serde(default)]
|
||||
pub usage: HashMap<String, i64>,
|
||||
/// Optional span timestamp in microseconds. When provided, only definitions
|
||||
/// whose `valid_from` <= timestamp are considered.
|
||||
/// whose `valid_from` <= timestamp are considered, and tiers restricted to
|
||||
/// recurring UTC time-of-day windows (peak / off-peak) are resolved against it.
|
||||
#[serde(default)]
|
||||
pub timestamp: Option<i64>,
|
||||
}
|
||||
@@ -712,7 +715,7 @@ pub async fn test_model_match(
|
||||
let matched = model_pricing::find_pricing_sync_at(&entries, &req.model_name, req.timestamp);
|
||||
|
||||
let (tier, costs, total_cost) = if let Some(ref def) = matched {
|
||||
let result = model_pricing::calculate_cost_from_definition(def, &req.usage);
|
||||
let result = model_pricing::calculate_cost_from_definition(def, &req.usage, req.timestamp);
|
||||
let total = result.cost.get("total").copied().unwrap_or(0.0);
|
||||
let costs = result
|
||||
.cost
|
||||
@@ -754,8 +757,17 @@ fn validate_definition(item: &ModelPricingDefinition) -> Result<(), String> {
|
||||
if item.tiers.is_empty() {
|
||||
return Err("At least one pricing tier is required".to_string());
|
||||
}
|
||||
if item.tiers.iter().all(|t| t.condition.is_some()) {
|
||||
return Err("At least one tier must have no condition (default fallback)".to_string());
|
||||
// The fallback tier must be unrestricted: no usage condition *and* no UTC time
|
||||
// window. Otherwise a span outside every window would have no tier to price with.
|
||||
if item
|
||||
.tiers
|
||||
.iter()
|
||||
.all(|t| t.condition.is_some() || !t.utc_windows.is_empty())
|
||||
{
|
||||
return Err(
|
||||
"At least one tier must have no condition and no time window (default fallback)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
for tier in &item.tiers {
|
||||
if let Some(ref cond) = tier.condition {
|
||||
@@ -772,6 +784,20 @@ fn validate_definition(item: &ModelPricingDefinition) -> Result<(), String> {
|
||||
));
|
||||
}
|
||||
}
|
||||
for window in &tier.utc_windows {
|
||||
if window.start_minute > MINUTES_PER_DAY || window.end_minute > MINUTES_PER_DAY {
|
||||
return Err(format!(
|
||||
"Tier '{}' time window must be within 0-{} minutes past UTC midnight, got {}-{}",
|
||||
tier.name, MINUTES_PER_DAY, window.start_minute, window.end_minute
|
||||
));
|
||||
}
|
||||
if window.start_minute % MINUTES_PER_DAY == window.end_minute % MINUTES_PER_DAY {
|
||||
return Err(format!(
|
||||
"Tier '{}' time window start and end are the same ({}); leave the window list empty for an always-on tier",
|
||||
tier.name, window.start_minute
|
||||
));
|
||||
}
|
||||
}
|
||||
for (key, &price) in &tier.prices {
|
||||
if price.is_nan() || price.is_infinite() {
|
||||
return Err(format!(
|
||||
@@ -907,6 +933,7 @@ mod tests {
|
||||
name: "default".to_string(),
|
||||
condition: None,
|
||||
prices: Default::default(),
|
||||
utc_windows: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -991,11 +1018,104 @@ mod tests {
|
||||
..Default::default()
|
||||
}),
|
||||
prices: Default::default(),
|
||||
utc_windows: Vec::new(),
|
||||
}];
|
||||
let err = validate_definition(&def).unwrap_err();
|
||||
assert!(err.contains("At least one tier must have no condition"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_definition_all_time_windowed_tiers_fails() {
|
||||
use config::meta::model_pricing::UtcTimeWindow;
|
||||
let mut def = valid_definition();
|
||||
// Peak + "off-peak" both windowed leaves no tier for a span outside both.
|
||||
def.tiers = vec![
|
||||
PricingTierDefinition {
|
||||
name: "peak".to_string(),
|
||||
utc_windows: vec![UtcTimeWindow::from_hm((1, 0), (4, 0))],
|
||||
..default_tier()
|
||||
},
|
||||
PricingTierDefinition {
|
||||
name: "off-peak".to_string(),
|
||||
utc_windows: vec![UtcTimeWindow::from_hm((10, 0), (1, 0))],
|
||||
..default_tier()
|
||||
},
|
||||
];
|
||||
let err = validate_definition(&def).unwrap_err();
|
||||
assert!(err.contains("no condition and no time window"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_definition_windowed_tier_with_default_passes() {
|
||||
use config::meta::model_pricing::UtcTimeWindow;
|
||||
let mut def = valid_definition();
|
||||
def.tiers = vec![
|
||||
PricingTierDefinition {
|
||||
name: "peak".to_string(),
|
||||
utc_windows: vec![
|
||||
UtcTimeWindow::from_hm((1, 0), (4, 0)),
|
||||
UtcTimeWindow::from_hm((6, 0), (10, 0)),
|
||||
],
|
||||
..default_tier()
|
||||
},
|
||||
default_tier(),
|
||||
];
|
||||
assert!(validate_definition(&def).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_definition_window_out_of_range_fails() {
|
||||
use config::meta::model_pricing::UtcTimeWindow;
|
||||
let mut def = valid_definition();
|
||||
def.tiers = vec![
|
||||
PricingTierDefinition {
|
||||
name: "peak".to_string(),
|
||||
utc_windows: vec![UtcTimeWindow {
|
||||
start_minute: 60,
|
||||
end_minute: 1441,
|
||||
}],
|
||||
..default_tier()
|
||||
},
|
||||
default_tier(),
|
||||
];
|
||||
let err = validate_definition(&def).unwrap_err();
|
||||
assert!(err.contains("within 0-1440 minutes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_definition_window_end_of_day_passes() {
|
||||
use config::meta::model_pricing::UtcTimeWindow;
|
||||
let mut def = valid_definition();
|
||||
def.tiers = vec![
|
||||
PricingTierDefinition {
|
||||
name: "night".to_string(),
|
||||
utc_windows: vec![UtcTimeWindow::from_hm((22, 0), (24, 0))],
|
||||
..default_tier()
|
||||
},
|
||||
default_tier(),
|
||||
];
|
||||
assert!(validate_definition(&def).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_definition_degenerate_window_fails() {
|
||||
use config::meta::model_pricing::UtcTimeWindow;
|
||||
let mut def = valid_definition();
|
||||
def.tiers = vec![
|
||||
PricingTierDefinition {
|
||||
name: "peak".to_string(),
|
||||
utc_windows: vec![UtcTimeWindow {
|
||||
start_minute: 300,
|
||||
end_minute: 300,
|
||||
}],
|
||||
..default_tier()
|
||||
},
|
||||
default_tier(),
|
||||
];
|
||||
let err = validate_definition(&def).unwrap_err();
|
||||
assert!(err.contains("start and end are the same"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_definition_tier_empty_condition_usage_key_fails() {
|
||||
use config::meta::model_pricing::TierCondition;
|
||||
@@ -1009,6 +1129,7 @@ mod tests {
|
||||
..Default::default()
|
||||
}),
|
||||
prices: Default::default(),
|
||||
utc_windows: Vec::new(),
|
||||
},
|
||||
default_tier(),
|
||||
];
|
||||
@@ -1029,6 +1150,7 @@ mod tests {
|
||||
..Default::default()
|
||||
}),
|
||||
prices: Default::default(),
|
||||
utc_windows: Vec::new(),
|
||||
},
|
||||
default_tier(),
|
||||
];
|
||||
|
||||
@@ -179,6 +179,61 @@ mod tests {
|
||||
assert!(ts >= 0);
|
||||
}
|
||||
|
||||
/// The built-in source (`llm_pricing.json`) lives in a separate repo, so this
|
||||
/// pins the exact JSON shape a peak/off-peak entry uses — DeepSeek V4 here.
|
||||
#[test]
|
||||
fn test_parse_built_in_entry_with_utc_windows() {
|
||||
let json = r#"[
|
||||
{
|
||||
"name": "DeepSeek V4 Pro",
|
||||
"provider": "DeepSeek",
|
||||
"description": "DeepSeek V4 Pro",
|
||||
"match_pattern": "(?i)deepseek-v4-pro",
|
||||
"tiers": [
|
||||
{
|
||||
"name": "Off-Peak",
|
||||
"prices": { "input": 6.6e-07, "output": 1.98e-06 }
|
||||
},
|
||||
{
|
||||
"name": "Peak (01:00-04:00, 06:00-10:00 UTC)",
|
||||
"utc_windows": [
|
||||
{ "start_minute": 60, "end_minute": 240 },
|
||||
{ "start_minute": 360, "end_minute": 600 }
|
||||
],
|
||||
"prices": { "input": 1.32e-06, "output": 3.96e-06 }
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
let entries: Vec<BuiltInModelPricingEntry> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
let tiers = &entries[0].tiers;
|
||||
assert_eq!(tiers.len(), 2);
|
||||
|
||||
// The unrestricted off-peak tier is listed FIRST on purpose: a binary that
|
||||
// predates `utc_windows` ignores the field, and its fallback lookup picks the
|
||||
// first tier with no condition — which must be the cheaper off-peak rate.
|
||||
assert!(tiers[0].utc_windows.is_empty());
|
||||
assert!(tiers[0].condition.is_none());
|
||||
|
||||
assert_eq!(tiers[1].utc_windows.len(), 2);
|
||||
assert_eq!(tiers[1].utc_windows[0].start_minute, 60);
|
||||
assert_eq!(tiers[1].utc_windows[0].end_minute, 240);
|
||||
assert_eq!(tiers[1].utc_windows[1].start_minute, 360);
|
||||
assert_eq!(tiers[1].utc_windows[1].end_minute, 600);
|
||||
|
||||
// Peak is exactly double off-peak on every priced key.
|
||||
for key in ["input", "output"] {
|
||||
let off = tiers[0].prices[key];
|
||||
let peak = tiers[1].prices[key];
|
||||
assert!(
|
||||
(peak - off * 2.0).abs() < 1e-15,
|
||||
"{key}: {peak} != 2 * {off}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_result_fields() {
|
||||
let r = SyncResult {
|
||||
|
||||
@@ -147,6 +147,84 @@ pub struct PricingTierDefinition {
|
||||
/// exact for token counts < 2^53 (~9 quadrillion), well beyond practical span sizes.
|
||||
#[serde(default)]
|
||||
pub prices: HashMap<String, f64>,
|
||||
/// Recurring UTC time-of-day windows during which this tier applies.
|
||||
///
|
||||
/// Empty (the default) means the tier is not time-restricted. Providers such as
|
||||
/// DeepSeek publish "peak" and "off-peak" rates that repeat every day, which is a
|
||||
/// time-of-day range rather than an absolute cutover (that is what `valid_from` is
|
||||
/// for). A tier applies when the span falls in ANY of its windows *and* its
|
||||
/// `condition` (if set) passes, so windows compose with context-length tiering.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub utc_windows: Vec<UtcTimeWindow>,
|
||||
}
|
||||
|
||||
/// Number of minutes in a day. Window bounds are normalized modulo this value, so
|
||||
/// `1440` (i.e. 24:00) is accepted as an end bound and normalizes to `0`.
|
||||
pub const MINUTES_PER_DAY: u32 = 1440;
|
||||
|
||||
/// A recurring UTC time-of-day window during which a pricing tier applies.
|
||||
///
|
||||
/// Bounds are minutes since UTC midnight and the range is half-open:
|
||||
/// `[start_minute, end_minute)`. A window whose `start_minute` is greater than its
|
||||
/// `end_minute` wraps past midnight (e.g. `990 -> 30` is 16:30–00:30 UTC). Equal
|
||||
/// bounds are rejected on write — leave `utc_windows` empty for an always-on tier.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Eq, ToSchema)]
|
||||
#[serde(default)]
|
||||
pub struct UtcTimeWindow {
|
||||
/// Inclusive start, minutes since UTC midnight (0–1440).
|
||||
pub start_minute: u32,
|
||||
/// Exclusive end, minutes since UTC midnight (0–1440).
|
||||
pub end_minute: u32,
|
||||
}
|
||||
|
||||
impl UtcTimeWindow {
|
||||
/// Build a window from UTC clock times. `end` may be `(24, 0)` for end-of-day.
|
||||
pub fn from_hm(start: (u32, u32), end: (u32, u32)) -> Self {
|
||||
Self {
|
||||
start_minute: start.0 * 60 + start.1,
|
||||
end_minute: end.0 * 60 + end.1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `minute_of_day` (minutes since UTC midnight) falls inside this window.
|
||||
pub fn contains(&self, minute_of_day: u32) -> bool {
|
||||
let start = self.start_minute % MINUTES_PER_DAY;
|
||||
let end = self.end_minute % MINUTES_PER_DAY;
|
||||
let m = minute_of_day % MINUTES_PER_DAY;
|
||||
if start == end {
|
||||
// Degenerate window — treat as "all day" rather than "never", so a
|
||||
// misconfigured entry still prices spans instead of silently dropping them.
|
||||
true
|
||||
} else if start < end {
|
||||
m >= start && m < end
|
||||
} else {
|
||||
// Wraps past midnight.
|
||||
m >= start || m < end
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Minutes since UTC midnight for a Unix timestamp in microseconds.
|
||||
/// Uses Euclidean division so pre-epoch timestamps stay in `0..1440`.
|
||||
pub fn utc_minute_of_day(ts_micros: i64) -> u32 {
|
||||
let secs = ts_micros.div_euclid(1_000_000);
|
||||
(secs.rem_euclid(86_400) / 60) as u32
|
||||
}
|
||||
|
||||
/// Whether a set of windows admits `ts_micros`. An empty window list is unrestricted.
|
||||
/// When the timestamp is unknown, a time-restricted tier never matches — the caller
|
||||
/// falls back to the unrestricted default tier.
|
||||
pub fn windows_match(windows: &[UtcTimeWindow], ts_micros: Option<i64>) -> bool {
|
||||
if windows.is_empty() {
|
||||
return true;
|
||||
}
|
||||
match ts_micros {
|
||||
Some(ts) => {
|
||||
let minute = utc_minute_of_day(ts);
|
||||
windows.iter().any(|w| w.contains(minute))
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Condition for a pricing tier.
|
||||
@@ -430,4 +508,122 @@ mod tests {
|
||||
fn test_default_true_returns_true() {
|
||||
assert!(default_true());
|
||||
}
|
||||
|
||||
// ── UTC time windows ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_utc_window_from_hm() {
|
||||
let w = UtcTimeWindow::from_hm((1, 0), (4, 0));
|
||||
assert_eq!(w.start_minute, 60);
|
||||
assert_eq!(w.end_minute, 240);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_utc_window_contains_half_open_range() {
|
||||
let w = UtcTimeWindow::from_hm((1, 0), (4, 0));
|
||||
// start is inclusive, end is exclusive
|
||||
assert!(w.contains(60));
|
||||
assert!(w.contains(239));
|
||||
assert!(!w.contains(240));
|
||||
assert!(!w.contains(59));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_utc_window_wraps_past_midnight() {
|
||||
// 16:30 -> 00:30 UTC
|
||||
let w = UtcTimeWindow::from_hm((16, 30), (0, 30));
|
||||
assert!(w.contains(990)); // 16:30
|
||||
assert!(w.contains(1439)); // 23:59
|
||||
assert!(w.contains(0)); // 00:00
|
||||
assert!(w.contains(29)); // 00:29
|
||||
assert!(!w.contains(30)); // 00:30 — exclusive end
|
||||
assert!(!w.contains(600)); // 10:00
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_utc_window_end_of_day_normalizes() {
|
||||
// 22:00 -> 24:00 must cover the tail of the day, not collapse to empty.
|
||||
let w = UtcTimeWindow::from_hm((22, 0), (24, 0));
|
||||
assert!(w.contains(1320));
|
||||
assert!(w.contains(1439));
|
||||
assert!(!w.contains(0));
|
||||
assert!(!w.contains(1319));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_utc_window_equal_bounds_is_all_day() {
|
||||
let w = UtcTimeWindow {
|
||||
start_minute: 300,
|
||||
end_minute: 300,
|
||||
};
|
||||
assert!(w.contains(0));
|
||||
assert!(w.contains(300));
|
||||
assert!(w.contains(1439));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_utc_minute_of_day() {
|
||||
// 1970-01-01T00:00:00Z
|
||||
assert_eq!(utc_minute_of_day(0), 0);
|
||||
// 1970-01-01T01:30:00Z
|
||||
assert_eq!(utc_minute_of_day(5_400_000_000), 90);
|
||||
// one full day later, same clock time
|
||||
assert_eq!(utc_minute_of_day(86_400_000_000 + 5_400_000_000), 90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_utc_minute_of_day_negative_timestamp() {
|
||||
// One minute before the epoch is 23:59 UTC, not a negative minute.
|
||||
assert_eq!(utc_minute_of_day(-60_000_000), 1439);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_windows_match_empty_is_unrestricted() {
|
||||
assert!(windows_match(&[], None));
|
||||
assert!(windows_match(&[], Some(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_windows_match_any_window() {
|
||||
// DeepSeek peak hours: 01:00-04:00 and 06:00-10:00 UTC
|
||||
let peak = vec![
|
||||
UtcTimeWindow::from_hm((1, 0), (4, 0)),
|
||||
UtcTimeWindow::from_hm((6, 0), (10, 0)),
|
||||
];
|
||||
let at = |h: i64, m: i64| Some((h * 3600 + m * 60) * 1_000_000);
|
||||
assert!(windows_match(&peak, at(2, 30)));
|
||||
assert!(windows_match(&peak, at(7, 0)));
|
||||
assert!(!windows_match(&peak, at(5, 0)));
|
||||
assert!(!windows_match(&peak, at(12, 0)));
|
||||
assert!(!windows_match(&peak, at(0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_windows_match_unknown_timestamp_never_matches() {
|
||||
let peak = vec![UtcTimeWindow::from_hm((1, 0), (4, 0))];
|
||||
assert!(!windows_match(&peak, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pricing_tier_utc_windows_default_empty_and_omitted() {
|
||||
let json = r#"{"name":"default"}"#;
|
||||
let tier: PricingTierDefinition = serde_json::from_str(json).unwrap();
|
||||
assert!(tier.utc_windows.is_empty());
|
||||
let val = serde_json::to_value(&tier).unwrap();
|
||||
assert!(!val.as_object().unwrap().contains_key("utc_windows"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pricing_tier_utc_windows_roundtrip() {
|
||||
let tier = PricingTierDefinition {
|
||||
name: "peak".to_string(),
|
||||
utc_windows: vec![UtcTimeWindow::from_hm((1, 0), (4, 0))],
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_string(&tier).unwrap();
|
||||
let back: PricingTierDefinition = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.utc_windows.len(), 1);
|
||||
assert_eq!(back.utc_windows[0].start_minute, 60);
|
||||
assert_eq!(back.utc_windows[0].end_minute, 240);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
use std::sync::LazyLock as Lazy;
|
||||
|
||||
use config::meta::model_pricing::{UtcTimeWindow, windows_match};
|
||||
use regex::Regex;
|
||||
use tiktoken_rs::{get_bpe_from_model, o200k_base};
|
||||
|
||||
@@ -41,6 +42,9 @@ pub fn calculate_token_count(model_name: &str, prompt: &str) -> i64 {
|
||||
/// * `model_name` - Name of the model (e.g., "gpt-4", "claude-sonnet-4-6")
|
||||
/// * `input_tokens` - Number of input tokens
|
||||
/// * `output_tokens` - Number of output tokens
|
||||
/// * `ts_micros` - Span start time in microseconds, used to resolve tiers that are restricted to
|
||||
/// recurring UTC time-of-day windows (peak / off-peak pricing). Pass `None` when the time is
|
||||
/// unknown — the unrestricted tier is used instead.
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Some((input_cost, output_cost, total_cost))` if pricing is found
|
||||
@@ -49,12 +53,13 @@ pub fn calculate_cost(
|
||||
model_name: &str,
|
||||
input_tokens: i64,
|
||||
output_tokens: i64,
|
||||
ts_micros: Option<i64>,
|
||||
) -> Option<(f64, f64, f64)> {
|
||||
// Find matching pricing
|
||||
let pricing = MODEL_PRICING.iter().find(|p| p.matches(model_name))?;
|
||||
|
||||
// Get appropriate tier
|
||||
let tier = pricing.get_tier(input_tokens);
|
||||
let tier = pricing.get_tier(input_tokens, ts_micros);
|
||||
|
||||
// Calculate costs (tokens / 1,000,000 * price_per_million)
|
||||
let input_cost = (input_tokens as f64 / 1_000_000.0) * tier.input_price_per_million;
|
||||
@@ -89,16 +94,27 @@ pub fn model_patterns() -> impl Iterator<Item = &'static str> {
|
||||
}
|
||||
|
||||
/// Pricing tier based on token count or other conditions
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PricingTier {
|
||||
/// Minimum input tokens for this tier (None means no minimum)
|
||||
pub min_input_tokens: Option<i64>,
|
||||
/// Recurring UTC time-of-day windows during which this tier applies.
|
||||
/// Empty means the tier is not time-restricted.
|
||||
pub utc_windows: Vec<UtcTimeWindow>,
|
||||
/// Cost per 1M input tokens in USD
|
||||
pub input_price_per_million: f64,
|
||||
/// Cost per 1M output tokens in USD
|
||||
pub output_price_per_million: f64,
|
||||
}
|
||||
|
||||
impl PricingTier {
|
||||
/// A tier with neither a token threshold nor a time window — the unconditional
|
||||
/// fallback used when no restricted tier applies.
|
||||
fn is_unrestricted(&self) -> bool {
|
||||
self.min_input_tokens.is_none() && self.utc_windows.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Model pricing information
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModelPricing {
|
||||
@@ -116,9 +132,9 @@ impl ModelPricing {
|
||||
Self {
|
||||
pattern: pattern.to_string(),
|
||||
tiers: vec![PricingTier {
|
||||
min_input_tokens: None,
|
||||
input_price_per_million: input_price,
|
||||
output_price_per_million: output_price,
|
||||
..Default::default()
|
||||
}],
|
||||
compiled: Regex::new(pattern).expect("invalid regex in model pricing pattern"),
|
||||
}
|
||||
@@ -133,19 +149,53 @@ impl ModelPricing {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the appropriate pricing tier for the given token counts
|
||||
pub fn get_tier(&self, input_tokens: i64) -> &PricingTier {
|
||||
/// Create a model whose rates change with the UTC time of day (e.g. DeepSeek's
|
||||
/// peak / off-peak pricing). Spans inside `peak_windows` are billed at the peak
|
||||
/// rate; everything else falls through to the off-peak rate.
|
||||
pub fn peak_off_peak(
|
||||
pattern: &str,
|
||||
peak_windows: Vec<UtcTimeWindow>,
|
||||
peak_input: f64,
|
||||
peak_output: f64,
|
||||
off_peak_input: f64,
|
||||
off_peak_output: f64,
|
||||
) -> Self {
|
||||
Self::tiered(
|
||||
pattern,
|
||||
vec![
|
||||
PricingTier {
|
||||
utc_windows: peak_windows,
|
||||
input_price_per_million: peak_input,
|
||||
output_price_per_million: peak_output,
|
||||
..Default::default()
|
||||
},
|
||||
PricingTier {
|
||||
input_price_per_million: off_peak_input,
|
||||
output_price_per_million: off_peak_output,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Get the appropriate pricing tier for the given token counts and span time.
|
||||
/// Restricted tiers (token threshold and/or UTC time window) are evaluated in
|
||||
/// order; the first one that applies wins, otherwise the unrestricted tier does.
|
||||
pub fn get_tier(&self, input_tokens: i64, ts_micros: Option<i64>) -> &PricingTier {
|
||||
for tier in &self.tiers {
|
||||
if let Some(min) = tier.min_input_tokens {
|
||||
if input_tokens >= min {
|
||||
return tier;
|
||||
}
|
||||
} else {
|
||||
return tier;
|
||||
if !windows_match(&tier.utc_windows, ts_micros) {
|
||||
continue;
|
||||
}
|
||||
match tier.min_input_tokens {
|
||||
Some(min) if input_tokens < min => continue,
|
||||
_ => return tier,
|
||||
}
|
||||
}
|
||||
// Fallback to last tier (default)
|
||||
self.tiers.last().unwrap()
|
||||
// Fallback: the unrestricted tier, else the last one defined.
|
||||
self.tiers
|
||||
.iter()
|
||||
.find(|t| t.is_unrestricted())
|
||||
.unwrap_or_else(|| self.tiers.last().unwrap())
|
||||
}
|
||||
|
||||
/// Check if this pricing matches the given model name
|
||||
@@ -154,6 +204,15 @@ impl ModelPricing {
|
||||
}
|
||||
}
|
||||
|
||||
/// DeepSeek peak-hour windows: 01:00-04:00 and 06:00-10:00 UTC.
|
||||
/// Every other hour is off-peak at half the peak rate.
|
||||
fn deepseek_peak_windows() -> Vec<UtcTimeWindow> {
|
||||
vec![
|
||||
UtcTimeWindow::from_hm((1, 0), (4, 0)),
|
||||
UtcTimeWindow::from_hm((6, 0), (10, 0)),
|
||||
]
|
||||
}
|
||||
|
||||
/// Global model pricing database
|
||||
pub static MODEL_PRICING: Lazy<Vec<ModelPricing>> = Lazy::new(|| {
|
||||
vec![
|
||||
@@ -196,11 +255,13 @@ pub static MODEL_PRICING: Lazy<Vec<ModelPricing>> = Lazy::new(|| {
|
||||
vec![
|
||||
PricingTier {
|
||||
min_input_tokens: Some(200_000),
|
||||
utc_windows: Vec::new(),
|
||||
input_price_per_million: 10.00,
|
||||
output_price_per_million: 37.50,
|
||||
},
|
||||
PricingTier {
|
||||
min_input_tokens: None,
|
||||
utc_windows: Vec::new(),
|
||||
input_price_per_million: 5.00,
|
||||
output_price_per_million: 25.00,
|
||||
},
|
||||
@@ -211,11 +272,13 @@ pub static MODEL_PRICING: Lazy<Vec<ModelPricing>> = Lazy::new(|| {
|
||||
vec![
|
||||
PricingTier {
|
||||
min_input_tokens: Some(200_000),
|
||||
utc_windows: Vec::new(),
|
||||
input_price_per_million: 6.00,
|
||||
output_price_per_million: 22.50,
|
||||
},
|
||||
PricingTier {
|
||||
min_input_tokens: None,
|
||||
utc_windows: Vec::new(),
|
||||
input_price_per_million: 3.00,
|
||||
output_price_per_million: 15.00,
|
||||
},
|
||||
@@ -226,11 +289,13 @@ pub static MODEL_PRICING: Lazy<Vec<ModelPricing>> = Lazy::new(|| {
|
||||
vec![
|
||||
PricingTier {
|
||||
min_input_tokens: Some(200_000),
|
||||
utc_windows: Vec::new(),
|
||||
input_price_per_million: 6.00,
|
||||
output_price_per_million: 22.50,
|
||||
},
|
||||
PricingTier {
|
||||
min_input_tokens: None,
|
||||
utc_windows: Vec::new(),
|
||||
input_price_per_million: 3.00,
|
||||
output_price_per_million: 15.00,
|
||||
},
|
||||
@@ -241,11 +306,13 @@ pub static MODEL_PRICING: Lazy<Vec<ModelPricing>> = Lazy::new(|| {
|
||||
vec![
|
||||
PricingTier {
|
||||
min_input_tokens: Some(200_000),
|
||||
utc_windows: Vec::new(),
|
||||
input_price_per_million: 6.00,
|
||||
output_price_per_million: 22.50,
|
||||
},
|
||||
PricingTier {
|
||||
min_input_tokens: None,
|
||||
utc_windows: Vec::new(),
|
||||
input_price_per_million: 3.00,
|
||||
output_price_per_million: 15.00,
|
||||
},
|
||||
@@ -269,11 +336,13 @@ pub static MODEL_PRICING: Lazy<Vec<ModelPricing>> = Lazy::new(|| {
|
||||
vec![
|
||||
PricingTier {
|
||||
min_input_tokens: Some(200_000),
|
||||
utc_windows: Vec::new(),
|
||||
input_price_per_million: 4.00,
|
||||
output_price_per_million: 18.00,
|
||||
},
|
||||
PricingTier {
|
||||
min_input_tokens: None,
|
||||
utc_windows: Vec::new(),
|
||||
input_price_per_million: 2.00,
|
||||
output_price_per_million: 12.00,
|
||||
},
|
||||
@@ -286,11 +355,13 @@ pub static MODEL_PRICING: Lazy<Vec<ModelPricing>> = Lazy::new(|| {
|
||||
vec![
|
||||
PricingTier {
|
||||
min_input_tokens: Some(200_000),
|
||||
utc_windows: Vec::new(),
|
||||
input_price_per_million: 2.50,
|
||||
output_price_per_million: 15.00,
|
||||
},
|
||||
PricingTier {
|
||||
min_input_tokens: None,
|
||||
utc_windows: Vec::new(),
|
||||
input_price_per_million: 1.25,
|
||||
output_price_per_million: 10.00,
|
||||
},
|
||||
@@ -305,9 +376,30 @@ pub static MODEL_PRICING: Lazy<Vec<ModelPricing>> = Lazy::new(|| {
|
||||
ModelPricing::simple("gemini-pro", 0.50, 1.50),
|
||||
ModelPricing::simple("gemini-embedding-001", 0.15, 0.0),
|
||||
// DeepSeek Models
|
||||
// Reference: synced llm_pricing.json fallback values, prices per 1M tokens.
|
||||
ModelPricing::simple("(?i)deepseek-v4-flash", 0.14, 0.28),
|
||||
ModelPricing::simple("(?i)deepseek-v4-pro", 0.435, 0.87),
|
||||
// Reference: https://api-docs.deepseek.com/quick_start/pricing
|
||||
//
|
||||
// V4 bills at two rates that alternate with the UTC clock: peak hours are
|
||||
// 01:00-04:00 and 06:00-10:00 UTC, and every other hour is off-peak at half
|
||||
// the peak rate. `input` here is the cache-miss rate; cache-hit input is
|
||||
// priced through user-defined `cache_read_input_tokens` entries.
|
||||
ModelPricing::peak_off_peak(
|
||||
"(?i)deepseek-v4-flash",
|
||||
deepseek_peak_windows(),
|
||||
0.44,
|
||||
1.32,
|
||||
0.22,
|
||||
0.66,
|
||||
),
|
||||
ModelPricing::peak_off_peak(
|
||||
"(?i)deepseek-v4-pro",
|
||||
deepseek_peak_windows(),
|
||||
1.32,
|
||||
3.96,
|
||||
0.66,
|
||||
1.98,
|
||||
),
|
||||
// Legacy DeepSeek model names, kept for historical spans — these are no longer
|
||||
// on the published price list and are not peak/off-peak split.
|
||||
ModelPricing::simple("(?i)deepseek-(?:v3|chat)(?:$|-)", 0.27, 1.10),
|
||||
ModelPricing::simple("(?i)deepseek-(?:r1|reasoner)(?:-\\d[\\d-]*)?$", 0.55, 2.19),
|
||||
ModelPricing::simple("(?i)deepseek-r1-distill-llama-8b", 0.04, 0.04),
|
||||
@@ -366,7 +458,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_calculate_cost_gpt4() {
|
||||
let (input_cost, output_cost, total_cost) = calculate_cost("gpt-4o", 1000, 500).unwrap();
|
||||
let (input_cost, output_cost, total_cost) =
|
||||
calculate_cost("gpt-4o", 1000, 500, None).unwrap();
|
||||
assert_eq!(input_cost, 0.0025); // 1000 / 1M * $2.50
|
||||
assert_eq!(output_cost, 0.005); // 500 / 1M * $10.00
|
||||
assert_eq!(total_cost, 0.0075);
|
||||
@@ -376,7 +469,7 @@ mod tests {
|
||||
fn test_calculate_cost_claude_sonnet_tiered_default_tier() {
|
||||
// Test with < 200k tokens (default tier)
|
||||
let (input_cost, output_cost, total_cost) =
|
||||
calculate_cost("claude-sonnet-4-6", 50_000, 10_000).unwrap();
|
||||
calculate_cost("claude-sonnet-4-6", 50_000, 10_000, None).unwrap();
|
||||
assert!((input_cost - 0.15).abs() < 1e-10); // 50k / 1M * $3.00
|
||||
assert!((output_cost - 0.15).abs() < 1e-10); // 10k / 1M * $15.00
|
||||
assert!((total_cost - 0.30).abs() < 1e-10);
|
||||
@@ -386,7 +479,7 @@ mod tests {
|
||||
fn test_calculate_cost_claude_sonnet_tiered_extended_tier() {
|
||||
// Test with > 200k tokens (extended context tier)
|
||||
let (input_cost, output_cost, total_cost) =
|
||||
calculate_cost("claude-sonnet-4-6", 250_000, 10_000).unwrap();
|
||||
calculate_cost("claude-sonnet-4-6", 250_000, 10_000, None).unwrap();
|
||||
assert!((input_cost - 1.5).abs() < 1e-10); // 250k / 1M * $6.00
|
||||
assert!((output_cost - 0.225).abs() < 1e-10); // 10k / 1M * $22.50
|
||||
assert!((total_cost - 1.725).abs() < 1e-10);
|
||||
@@ -394,13 +487,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_calculate_cost_unknown_model() {
|
||||
assert!(calculate_cost("unknown-model-xyz", 1000, 500).is_none());
|
||||
assert!(calculate_cost("unknown-model-xyz", 1000, 500, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_cost_gemini() {
|
||||
let (input_cost, output_cost, total_cost) =
|
||||
calculate_cost("gemini-1.5-flash", 10_000, 5_000).unwrap();
|
||||
calculate_cost("gemini-1.5-flash", 10_000, 5_000, None).unwrap();
|
||||
assert!((input_cost - 0.00075).abs() < 1e-10); // 10k / 1M * $0.075
|
||||
assert!((output_cost - 0.0015).abs() < 1e-10); // 5k / 1M * $0.30
|
||||
assert!((total_cost - 0.00225).abs() < 1e-10);
|
||||
@@ -416,7 +509,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_zero_tokens() {
|
||||
let (input_cost, output_cost, total_cost) = calculate_cost("gpt-4o", 0, 0).unwrap();
|
||||
let (input_cost, output_cost, total_cost) = calculate_cost("gpt-4o", 0, 0, None).unwrap();
|
||||
assert_eq!(input_cost, 0.0);
|
||||
assert_eq!(output_cost, 0.0);
|
||||
assert_eq!(total_cost, 0.0);
|
||||
@@ -454,43 +547,121 @@ mod tests {
|
||||
#[test]
|
||||
fn test_claude_hyphen_not_dot() {
|
||||
// Anthropic model IDs use hyphens, not dots — no \\. needed
|
||||
assert!(calculate_cost("claude-3-5-haiku", 1000, 500).is_some());
|
||||
assert!(calculate_cost("claude-haiku-3-5", 1000, 500).is_some());
|
||||
assert!(calculate_cost("claude-haiku-4-5", 1000, 500).is_some());
|
||||
assert!(calculate_cost("claude-opus-4-6", 1000, 500).is_some());
|
||||
assert!(calculate_cost("claude-3-5-haiku", 1000, 500, None).is_some());
|
||||
assert!(calculate_cost("claude-haiku-3-5", 1000, 500, None).is_some());
|
||||
assert!(calculate_cost("claude-haiku-4-5", 1000, 500, None).is_some());
|
||||
assert!(calculate_cost("claude-opus-4-6", 1000, 500, None).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_openai_models() {
|
||||
// Verify new models are matched
|
||||
assert!(calculate_cost("gpt-5.1", 1000, 500).is_some());
|
||||
assert!(calculate_cost("gpt-5-pro", 1000, 500).is_some());
|
||||
assert!(calculate_cost("gpt-5-nano", 1000, 500).is_some());
|
||||
assert!(calculate_cost("o1", 1000, 500).is_some());
|
||||
assert!(calculate_cost("o1-pro", 1000, 500).is_some());
|
||||
assert!(calculate_cost("o3-pro", 1000, 500).is_some());
|
||||
assert!(calculate_cost("o3-mini", 1000, 500).is_some());
|
||||
assert!(calculate_cost("gpt-5.1", 1000, 500, None).is_some());
|
||||
assert!(calculate_cost("gpt-5-pro", 1000, 500, None).is_some());
|
||||
assert!(calculate_cost("gpt-5-nano", 1000, 500, None).is_some());
|
||||
assert!(calculate_cost("o1", 1000, 500, None).is_some());
|
||||
assert!(calculate_cost("o1-pro", 1000, 500, None).is_some());
|
||||
assert!(calculate_cost("o3-pro", 1000, 500, None).is_some());
|
||||
assert!(calculate_cost("o3-mini", 1000, 500, None).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_gemini_models() {
|
||||
assert!(calculate_cost("gemini-3.1-pro-preview", 1000, 500).is_some());
|
||||
assert!(calculate_cost("gemini-3.1-flash-lite-preview", 1000, 500).is_some());
|
||||
assert!(calculate_cost("gemini-3-flash-preview", 1000, 500).is_some());
|
||||
assert!(calculate_cost("gemini-embedding-001", 1000, 500).is_some());
|
||||
assert!(calculate_cost("gemini-3.1-pro-preview", 1000, 500, None).is_some());
|
||||
assert!(calculate_cost("gemini-3.1-flash-lite-preview", 1000, 500, None).is_some());
|
||||
assert!(calculate_cost("gemini-3-flash-preview", 1000, 500, None).is_some());
|
||||
assert!(calculate_cost("gemini-embedding-001", 1000, 500, None).is_some());
|
||||
}
|
||||
|
||||
/// Microseconds since the epoch for a UTC clock time on 1970-01-01.
|
||||
fn micros_at_utc(hour: i64, minute: i64) -> Option<i64> {
|
||||
Some((hour * 3600 + minute * 60) * 1_000_000)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_models() {
|
||||
// Off-peak (12:00 UTC): $0.66/$1.98 per 1M for v4-pro.
|
||||
let (input_cost, output_cost, total_cost) =
|
||||
calculate_cost("deepseek-v4-pro", 1000, 500).unwrap();
|
||||
calculate_cost("deepseek-v4-pro", 1000, 500, micros_at_utc(12, 0)).unwrap();
|
||||
|
||||
assert!((input_cost - 0.000435).abs() < 1e-12);
|
||||
assert!((output_cost - 0.000435).abs() < 1e-12);
|
||||
assert!((total_cost - 0.00087).abs() < 1e-12);
|
||||
assert!(calculate_cost("deepseek-chat", 1000, 500).is_some());
|
||||
assert!(calculate_cost("deepseek/deepseek-r1-0528", 1000, 500).is_some());
|
||||
assert!(calculate_cost("deepseek-r1-distill-qwen-32b", 1000, 500).is_some());
|
||||
assert!((input_cost - 0.00066).abs() < 1e-12);
|
||||
assert!((output_cost - 0.00099).abs() < 1e-12);
|
||||
assert!((total_cost - 0.00165).abs() < 1e-12);
|
||||
assert!(calculate_cost("deepseek-chat", 1000, 500, None).is_some());
|
||||
assert!(calculate_cost("deepseek/deepseek-r1-0528", 1000, 500, None).is_some());
|
||||
assert!(calculate_cost("deepseek-r1-distill-qwen-32b", 1000, 500, None).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_peak_is_double_off_peak() {
|
||||
for model in ["deepseek-v4-pro", "deepseek-v4-flash"] {
|
||||
let (peak_in, peak_out, _) =
|
||||
calculate_cost(model, 1_000_000, 1_000_000, micros_at_utc(2, 0)).unwrap();
|
||||
let (off_in, off_out, _) =
|
||||
calculate_cost(model, 1_000_000, 1_000_000, micros_at_utc(12, 0)).unwrap();
|
||||
assert!(
|
||||
(peak_in - off_in * 2.0).abs() < 1e-12,
|
||||
"{model} input: peak {peak_in} != 2x off-peak {off_in}"
|
||||
);
|
||||
assert!(
|
||||
(peak_out - off_out * 2.0).abs() < 1e-12,
|
||||
"{model} output: peak {peak_out} != 2x off-peak {off_out}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_v4_flash_published_rates() {
|
||||
// https://api-docs.deepseek.com/quick_start/pricing — cache-miss input / output.
|
||||
let (peak_in, peak_out, _) = calculate_cost(
|
||||
"deepseek-v4-flash",
|
||||
1_000_000,
|
||||
1_000_000,
|
||||
micros_at_utc(6, 30),
|
||||
)
|
||||
.unwrap();
|
||||
assert!((peak_in - 0.44).abs() < 1e-12);
|
||||
assert!((peak_out - 1.32).abs() < 1e-12);
|
||||
|
||||
let (off_in, off_out, _) = calculate_cost(
|
||||
"deepseek-v4-flash",
|
||||
1_000_000,
|
||||
1_000_000,
|
||||
micros_at_utc(23, 0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!((off_in - 0.22).abs() < 1e-12);
|
||||
assert!((off_out - 0.66).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_unknown_timestamp_falls_back_to_off_peak() {
|
||||
// No span time → the unrestricted (off-peak) tier, never a windowed one.
|
||||
let (input_cost, ..) = calculate_cost("deepseek-v4-pro", 1_000_000, 0, None).unwrap();
|
||||
assert!((input_cost - 0.66).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_time_windows_do_not_affect_unwindowed_models() {
|
||||
// A model with no time-restricted tier prices identically at any hour.
|
||||
let at_peak = calculate_cost("gpt-4o", 1000, 500, micros_at_utc(2, 0)).unwrap();
|
||||
let at_off = calculate_cost("gpt-4o", 1000, 500, micros_at_utc(14, 0)).unwrap();
|
||||
let no_ts = calculate_cost("gpt-4o", 1000, 500, None).unwrap();
|
||||
assert_eq!(at_peak, at_off);
|
||||
assert_eq!(at_peak, no_ts);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_length_tier_still_selected_with_timestamp() {
|
||||
// Time windows must not disturb the existing token-threshold tiering.
|
||||
// Below the 200k threshold → base rate ($3/1M), regardless of the hour.
|
||||
let (small_in, ..) =
|
||||
calculate_cost("claude-sonnet-4-6", 100_000, 0, micros_at_utc(2, 0)).unwrap();
|
||||
assert!((small_in - 0.30).abs() < 1e-12);
|
||||
// Above it → extended-context rate ($6/1M).
|
||||
let (large_in, ..) =
|
||||
calculate_cost("claude-sonnet-4-6", 300_000, 0, micros_at_utc(2, 0)).unwrap();
|
||||
assert!((large_in - 1.80).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -114,7 +114,8 @@ impl OtelIngestionProcessor {
|
||||
/// Process span with optional user-defined model pricing entries.
|
||||
/// If matching pricing is found in `org_pricing_entries`, it takes priority over built-in
|
||||
/// pricing. `span_start_nanos` is the span's `start_time_unix_nano` from the OTLP payload;
|
||||
/// it is used to select the most-recently-applicable pricing definition via `valid_from`.
|
||||
/// it is used to select the most-recently-applicable pricing definition via `valid_from`
|
||||
/// and the time-of-day tier via `utc_windows`. Zero means unknown: no time restriction.
|
||||
/// In production always call `get_org_pricing_entries` to populate this slice.
|
||||
pub fn process_span_with_pricing(
|
||||
&self,
|
||||
@@ -258,12 +259,15 @@ impl OtelIngestionProcessor {
|
||||
let mut input_includes_cache = extracted.input_includes_cache;
|
||||
|
||||
if is_generation_or_embedding(extracted.op_name) {
|
||||
let span_ts_micros = i64::try_from(span_start_nanos / 1_000).unwrap_or(i64::MAX);
|
||||
// Zero means the payload carried no start time; treat it as unknown rather
|
||||
// than as 1970-01-01, which would land every such span in a time-of-day tier.
|
||||
let span_ts_micros = (span_start_nanos != 0)
|
||||
.then(|| i64::try_from(span_start_nanos / 1_000).unwrap_or(i64::MAX));
|
||||
let matched_pricing = extracted.model_name.as_ref().and_then(|mn| {
|
||||
crate::db::model_pricing::find_pricing_sync_at(
|
||||
org_pricing_entries,
|
||||
mn,
|
||||
Some(span_ts_micros),
|
||||
span_ts_micros,
|
||||
)
|
||||
});
|
||||
let tokenizer_key: &str = extracted.model_name.as_deref().unwrap_or("");
|
||||
@@ -300,6 +304,7 @@ impl OtelIngestionProcessor {
|
||||
&pricing_def,
|
||||
&billable_usage,
|
||||
&tier_usage,
|
||||
span_ts_micros,
|
||||
);
|
||||
if !result.cost.is_empty() {
|
||||
log::debug!(
|
||||
@@ -315,9 +320,12 @@ impl OtelIngestionProcessor {
|
||||
} else if let Some(ref model_name) = extracted.model_name {
|
||||
let input_tokens = billable_usage.get("input").cloned().unwrap_or_default();
|
||||
let output_tokens = billable_usage.get("output").cloned().unwrap_or_default();
|
||||
if let Some((input_cost, output_cost, total_cost)) =
|
||||
pricing::calculate_cost(model_name, input_tokens, output_tokens)
|
||||
{
|
||||
if let Some((input_cost, output_cost, total_cost)) = pricing::calculate_cost(
|
||||
model_name,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
span_ts_micros,
|
||||
) {
|
||||
cost.insert("input".to_string(), input_cost);
|
||||
cost.insert("output".to_string(), output_cost);
|
||||
cost.insert("total".to_string(), total_cost);
|
||||
@@ -635,6 +643,20 @@ impl OtelIngestionProcessor {
|
||||
resource_attributes: &HashMap<String, json::Value>,
|
||||
scope_name: Option<&str>,
|
||||
events: &[Event],
|
||||
) {
|
||||
self.process_span_at(span_attributes, resource_attributes, scope_name, events, 0);
|
||||
}
|
||||
|
||||
/// `process_span` with an explicit span start time, for models whose rates
|
||||
/// depend on the UTC time of day (peak / off-peak pricing).
|
||||
#[cfg(test)]
|
||||
pub fn process_span_at(
|
||||
&self,
|
||||
span_attributes: &mut HashMap<String, json::Value>,
|
||||
resource_attributes: &HashMap<String, json::Value>,
|
||||
scope_name: Option<&str>,
|
||||
events: &[Event],
|
||||
span_start_nanos: u64,
|
||||
) {
|
||||
self.process_span_with_pricing(
|
||||
span_attributes,
|
||||
@@ -642,7 +664,7 @@ impl OtelIngestionProcessor {
|
||||
scope_name,
|
||||
events,
|
||||
&[],
|
||||
0,
|
||||
span_start_nanos,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1099,8 +1121,12 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_span_calculates_deepseek_cost_from_token_only_span() {
|
||||
/// Nanoseconds since the epoch for a UTC clock time on 1970-01-01.
|
||||
fn nanos_at_utc(hour: u64, minute: u64) -> u64 {
|
||||
(hour * 3600 + minute * 60) * 1_000_000_000
|
||||
}
|
||||
|
||||
fn deepseek_costs(span_start_nanos: u64) -> (f64, f64, f64) {
|
||||
let processor = OtelIngestionProcessor::new();
|
||||
|
||||
let mut span_attrs = HashMap::new();
|
||||
@@ -1115,24 +1141,79 @@ mod tests {
|
||||
let resource_attrs = HashMap::new();
|
||||
let events = vec![];
|
||||
|
||||
processor.process_span(&mut span_attrs, &resource_attrs, None, &events);
|
||||
processor.process_span_at(
|
||||
&mut span_attrs,
|
||||
&resource_attrs,
|
||||
None,
|
||||
&events,
|
||||
span_start_nanos,
|
||||
);
|
||||
|
||||
let input_cost = span_attrs
|
||||
.get(GenAiExtensions::USAGE_COST_INPUT)
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap();
|
||||
let output_cost = span_attrs
|
||||
.get(GenAiExtensions::USAGE_COST_OUTPUT)
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap();
|
||||
let total_cost = span_attrs
|
||||
.get(GenAiAttributes::USAGE_COST)
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap();
|
||||
(
|
||||
span_attrs
|
||||
.get(GenAiExtensions::USAGE_COST_INPUT)
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap(),
|
||||
span_attrs
|
||||
.get(GenAiExtensions::USAGE_COST_OUTPUT)
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap(),
|
||||
span_attrs
|
||||
.get(GenAiAttributes::USAGE_COST)
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
assert!((input_cost - 0.000435).abs() < 1e-12);
|
||||
assert!((output_cost - 0.000435).abs() < 1e-12);
|
||||
assert!((total_cost - 0.00087).abs() < 1e-12);
|
||||
#[test]
|
||||
fn test_process_span_calculates_deepseek_cost_from_token_only_span() {
|
||||
// 12:00 UTC is outside DeepSeek's peak windows → off-peak: $0.66/$1.98 per 1M.
|
||||
let (input_cost, output_cost, total_cost) = deepseek_costs(nanos_at_utc(12, 0));
|
||||
|
||||
assert!((input_cost - 0.00066).abs() < 1e-12);
|
||||
assert!((output_cost - 0.00099).abs() < 1e-12);
|
||||
assert!((total_cost - 0.00165).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_span_uses_deepseek_peak_rate_during_peak_hours() {
|
||||
// 02:30 UTC falls in the 01:00-04:00 peak window → $1.32/$3.96 per 1M.
|
||||
let (input_cost, output_cost, total_cost) = deepseek_costs(nanos_at_utc(2, 30));
|
||||
|
||||
assert!((input_cost - 0.00132).abs() < 1e-12);
|
||||
assert!((output_cost - 0.00198).abs() < 1e-12);
|
||||
assert!((total_cost - 0.0033).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_span_deepseek_second_peak_window_and_gap() {
|
||||
// 07:00 UTC is inside the second peak window (06:00-10:00).
|
||||
let (peak_input, ..) = deepseek_costs(nanos_at_utc(7, 0));
|
||||
assert!((peak_input - 0.00132).abs() < 1e-12);
|
||||
|
||||
// 05:00 UTC sits in the gap between the two peak windows → off-peak.
|
||||
let (gap_input, ..) = deepseek_costs(nanos_at_utc(5, 0));
|
||||
assert!((gap_input - 0.00066).abs() < 1e-12);
|
||||
|
||||
// 04:00 UTC is the exclusive end of the first window → off-peak.
|
||||
let (edge_input, ..) = deepseek_costs(nanos_at_utc(4, 0));
|
||||
assert!((edge_input - 0.00066).abs() < 1e-12);
|
||||
|
||||
// 01:00 UTC is the inclusive start of the first window → peak.
|
||||
let (start_input, ..) = deepseek_costs(nanos_at_utc(1, 0));
|
||||
assert!((start_input - 0.00132).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_span_deepseek_missing_start_time_falls_back_to_unrestricted() {
|
||||
// A zero start_time_unix_nano means the payload carried no timestamp. It must not
|
||||
// be read as 1970-01-01 00:00 UTC, which would pin the span to whatever tier
|
||||
// covers UTC midnight; an unknown time falls back to the unrestricted tier.
|
||||
let (input_cost, output_cost, total_cost) = deepseek_costs(0);
|
||||
|
||||
assert!((input_cost - 0.00066).abs() < 1e-12);
|
||||
assert!((output_cost - 0.00099).abs() < 1e-12);
|
||||
assert!((total_cost - 0.00165).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1456,6 +1537,7 @@ mod tests {
|
||||
("input".to_string(), 0.000005),
|
||||
("output".to_string(), 0.00002),
|
||||
]),
|
||||
utc_windows: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
@@ -1529,6 +1611,7 @@ mod tests {
|
||||
("cache_read_input_tokens".to_string(), 0.0000001),
|
||||
("cache_creation_input_tokens".to_string(), 0.0000005),
|
||||
]),
|
||||
utc_windows: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
@@ -1640,6 +1723,7 @@ mod tests {
|
||||
("output".to_string(), 0.000002),
|
||||
("cache_read_input_tokens".to_string(), 0.0000001),
|
||||
]),
|
||||
utc_windows: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
@@ -1716,6 +1800,7 @@ mod tests {
|
||||
0.003625 / 1_000_000.0,
|
||||
),
|
||||
]),
|
||||
utc_windows: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
@@ -1810,6 +1895,7 @@ mod tests {
|
||||
("input".to_string(), 0.000001), // $1/1M
|
||||
("output".to_string(), 0.000002), // $2/1M
|
||||
]),
|
||||
utc_windows: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
|
||||
+306
-32
@@ -27,7 +27,9 @@ use std::{
|
||||
sync::{Arc, LazyLock},
|
||||
};
|
||||
|
||||
use config::meta::model_pricing::{BUILT_IN_ORG, META_ORG, ModelPricingDefinition, PricingSource};
|
||||
use config::meta::model_pricing::{
|
||||
BUILT_IN_ORG, META_ORG, ModelPricingDefinition, PricingSource, windows_match,
|
||||
};
|
||||
use dashmap::DashMap;
|
||||
use infra::table;
|
||||
use regex::{Regex, RegexBuilder};
|
||||
@@ -290,11 +292,16 @@ pub struct CostResult {
|
||||
/// Returns a `CostResult` with a map of usage_key -> cost and the name of the selected tier.
|
||||
/// The "total" key in the usage map is always skipped — cost total is always computed as the sum
|
||||
/// of individual component costs to avoid double-counting.
|
||||
///
|
||||
/// `span_ts_micros` is the span's start time; it selects tiers restricted to recurring UTC
|
||||
/// time-of-day windows (peak / off-peak pricing). Pass `None` when the time is unknown —
|
||||
/// time-restricted tiers are then skipped in favour of the unrestricted default tier.
|
||||
pub fn calculate_cost_from_definition(
|
||||
definition: &ModelPricingDefinition,
|
||||
usage: &HashMap<String, i64>,
|
||||
span_ts_micros: Option<i64>,
|
||||
) -> CostResult {
|
||||
calculate_cost_from_definition_with_tier_usage(definition, usage, usage)
|
||||
calculate_cost_from_definition_with_tier_usage(definition, usage, usage, span_ts_micros)
|
||||
}
|
||||
|
||||
/// Calculate cost using `usage`, but select conditional pricing tiers with `tier_usage`.
|
||||
@@ -304,8 +311,9 @@ pub fn calculate_cost_from_definition_with_tier_usage(
|
||||
definition: &ModelPricingDefinition,
|
||||
usage: &HashMap<String, i64>,
|
||||
tier_usage: &HashMap<String, i64>,
|
||||
span_ts_micros: Option<i64>,
|
||||
) -> CostResult {
|
||||
let tier = match select_tier(definition, tier_usage) {
|
||||
let tier = match select_tier(definition, tier_usage, span_ts_micros) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
log::warn!(
|
||||
@@ -358,22 +366,50 @@ pub fn calculate_cost_from_definition_with_tier_usage(
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a tier is a candidate for `usage` at `span_ts_micros`.
|
||||
/// Both restrictions must hold: the span must fall in one of the tier's UTC windows
|
||||
/// (if any) *and* satisfy the tier's usage condition (if any).
|
||||
fn tier_matches(
|
||||
tier: &config::meta::model_pricing::PricingTierDefinition,
|
||||
usage: &HashMap<String, i64>,
|
||||
span_ts_micros: Option<i64>,
|
||||
) -> bool {
|
||||
if !windows_match(&tier.utc_windows, span_ts_micros) {
|
||||
return false;
|
||||
}
|
||||
match tier.condition {
|
||||
Some(ref cond) => {
|
||||
let actual = usage.get(&cond.usage_key).copied().unwrap_or(0) as f64;
|
||||
cond.operator.evaluate(actual, cond.value)
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// A tier with neither a condition nor UTC windows — the unconditional fallback.
|
||||
fn is_default_tier(tier: &config::meta::model_pricing::PricingTierDefinition) -> bool {
|
||||
tier.condition.is_none() && tier.utc_windows.is_empty()
|
||||
}
|
||||
|
||||
fn select_tier<'a>(
|
||||
definition: &'a ModelPricingDefinition,
|
||||
usage: &HashMap<String, i64>,
|
||||
span_ts_micros: Option<i64>,
|
||||
) -> Option<&'a config::meta::model_pricing::PricingTierDefinition> {
|
||||
// Evaluate conditional tiers in order; first match wins.
|
||||
// Evaluate restricted tiers (conditional and/or time-windowed) in order; first match
|
||||
// wins. The unconditional tier is skipped here so its position in the list — often
|
||||
// first — does not shadow the restricted ones.
|
||||
for tier in &definition.tiers {
|
||||
if let Some(ref cond) = tier.condition {
|
||||
let actual = usage.get(&cond.usage_key).copied().unwrap_or(0) as f64;
|
||||
if cond.operator.evaluate(actual, cond.value) {
|
||||
return Some(tier);
|
||||
}
|
||||
if is_default_tier(tier) {
|
||||
continue;
|
||||
}
|
||||
if tier_matches(tier, usage, span_ts_micros) {
|
||||
return Some(tier);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: first unconditional tier (validation guarantees at least one exists).
|
||||
definition.tiers.iter().find(|t| t.condition.is_none())
|
||||
// Fallback: first unrestricted tier (validation guarantees at least one exists).
|
||||
definition.tiers.iter().find(|t| is_default_tier(t))
|
||||
}
|
||||
|
||||
// ── Startup: cache + watch ────────────────────────────────────────────────────
|
||||
@@ -480,7 +516,9 @@ pub async fn delete_by_id(org_id: &str, id: &str) -> Result<bool, anyhow::Error>
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use config::meta::model_pricing::{PricingTierDefinition, TierCondition, TierOperator};
|
||||
use config::meta::model_pricing::{
|
||||
PricingTierDefinition, TierCondition, TierOperator, UtcTimeWindow,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -493,6 +531,204 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Peak / off-peak (UTC time-of-day) tiers ───────────────────────────
|
||||
|
||||
/// Microseconds since the epoch for a UTC clock time on 1970-01-01.
|
||||
fn at_utc(hour: i64, minute: i64) -> Option<i64> {
|
||||
Some((hour * 3600 + minute * 60) * 1_000_000)
|
||||
}
|
||||
|
||||
/// A DeepSeek-shaped definition: a peak tier restricted to 01:00-04:00 and
|
||||
/// 06:00-10:00 UTC, and an unrestricted off-peak tier at half the rate.
|
||||
fn peak_off_peak_definition() -> ModelPricingDefinition {
|
||||
make_definition(vec![
|
||||
PricingTierDefinition {
|
||||
name: "Peak".to_string(),
|
||||
condition: None,
|
||||
prices: HashMap::from([
|
||||
("input".to_string(), 0.00000132),
|
||||
("output".to_string(), 0.00000396),
|
||||
]),
|
||||
utc_windows: vec![
|
||||
UtcTimeWindow::from_hm((1, 0), (4, 0)),
|
||||
UtcTimeWindow::from_hm((6, 0), (10, 0)),
|
||||
],
|
||||
},
|
||||
PricingTierDefinition {
|
||||
name: "Off-Peak".to_string(),
|
||||
condition: None,
|
||||
prices: HashMap::from([
|
||||
("input".to_string(), 0.00000066),
|
||||
("output".to_string(), 0.00000198),
|
||||
]),
|
||||
utc_windows: Vec::new(),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_peak_tier_inside_window() {
|
||||
let def = peak_off_peak_definition();
|
||||
let usage = HashMap::from([("input".to_string(), 1_000_000i64)]);
|
||||
|
||||
for (h, m) in [(1, 0), (2, 30), (3, 59), (6, 0), (9, 59)] {
|
||||
let result = calculate_cost_from_definition(&def, &usage, at_utc(h, m));
|
||||
assert_eq!(result.tier_name, "Peak", "expected peak at {h:02}:{m:02}");
|
||||
assert!((result.cost["input"] - 1.32).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_off_peak_tier_outside_window() {
|
||||
let def = peak_off_peak_definition();
|
||||
let usage = HashMap::from([("input".to_string(), 1_000_000i64)]);
|
||||
|
||||
// 00:59 (before), 04:00 (exclusive end), 05:00 (gap), 10:00 (exclusive end), 23:00
|
||||
for (h, m) in [(0, 59), (4, 0), (5, 0), (10, 0), (23, 0)] {
|
||||
let result = calculate_cost_from_definition(&def, &usage, at_utc(h, m));
|
||||
assert_eq!(
|
||||
result.tier_name, "Off-Peak",
|
||||
"expected off-peak at {h:02}:{m:02}"
|
||||
);
|
||||
assert!((result.cost["input"] - 0.66).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_windowed_tier_never_used_as_fallback() {
|
||||
// With no span timestamp, the time-restricted tier must not be selected.
|
||||
let def = peak_off_peak_definition();
|
||||
let usage = HashMap::from([("input".to_string(), 1_000_000i64)]);
|
||||
let result = calculate_cost_from_definition(&def, &usage, None);
|
||||
assert_eq!(result.tier_name, "Off-Peak");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_tier_first_in_list_does_not_shadow_windowed_tier() {
|
||||
// The built-in `llm_pricing.json` lists the unrestricted off-peak tier FIRST so
|
||||
// that older binaries (which ignore `utc_windows`) fall back to the cheaper rate.
|
||||
// The current code must still reach the peak tier during its window.
|
||||
let mut def = peak_off_peak_definition();
|
||||
def.tiers.reverse();
|
||||
assert_eq!(def.tiers[0].name, "Off-Peak");
|
||||
|
||||
let usage = HashMap::from([("input".to_string(), 1_000_000i64)]);
|
||||
assert_eq!(
|
||||
calculate_cost_from_definition(&def, &usage, at_utc(2, 0)).tier_name,
|
||||
"Peak"
|
||||
);
|
||||
assert_eq!(
|
||||
calculate_cost_from_definition(&def, &usage, at_utc(14, 0)).tier_name,
|
||||
"Off-Peak"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_windowed_tier_first_in_list_does_not_shadow_default() {
|
||||
// The peak tier is listed first; outside its windows the default must still win.
|
||||
let def = peak_off_peak_definition();
|
||||
assert_eq!(def.tiers[0].name, "Peak");
|
||||
let usage = HashMap::from([("input".to_string(), 1_000i64)]);
|
||||
assert_eq!(
|
||||
calculate_cost_from_definition(&def, &usage, at_utc(14, 0)).tier_name,
|
||||
"Off-Peak"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_window_wrapping_midnight() {
|
||||
let def = make_definition(vec![
|
||||
PricingTierDefinition {
|
||||
name: "Night".to_string(),
|
||||
condition: None,
|
||||
prices: HashMap::from([("input".to_string(), 0.000001)]),
|
||||
utc_windows: vec![UtcTimeWindow::from_hm((22, 0), (2, 0))],
|
||||
},
|
||||
PricingTierDefinition {
|
||||
name: "Day".to_string(),
|
||||
condition: None,
|
||||
prices: HashMap::from([("input".to_string(), 0.000002)]),
|
||||
utc_windows: Vec::new(),
|
||||
},
|
||||
]);
|
||||
let usage = HashMap::from([("input".to_string(), 1_000i64)]);
|
||||
|
||||
for (h, m) in [(22, 0), (23, 30), (0, 0), (1, 59)] {
|
||||
assert_eq!(
|
||||
calculate_cost_from_definition(&def, &usage, at_utc(h, m)).tier_name,
|
||||
"Night",
|
||||
"expected night tier at {h:02}:{m:02}"
|
||||
);
|
||||
}
|
||||
for (h, m) in [(2, 0), (12, 0), (21, 59)] {
|
||||
assert_eq!(
|
||||
calculate_cost_from_definition(&def, &usage, at_utc(h, m)).tier_name,
|
||||
"Day",
|
||||
"expected day tier at {h:02}:{m:02}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_window_and_condition_must_both_hold() {
|
||||
// A tier carrying BOTH a window and a usage condition applies only when both pass.
|
||||
let def = make_definition(vec![
|
||||
PricingTierDefinition {
|
||||
name: "Peak Extended".to_string(),
|
||||
condition: Some(TierCondition {
|
||||
usage_key: "input".to_string(),
|
||||
operator: TierOperator::Gt,
|
||||
value: 200_000.0,
|
||||
}),
|
||||
prices: HashMap::from([("input".to_string(), 0.00001)]),
|
||||
utc_windows: vec![UtcTimeWindow::from_hm((1, 0), (4, 0))],
|
||||
},
|
||||
PricingTierDefinition {
|
||||
name: "Default".to_string(),
|
||||
condition: None,
|
||||
prices: HashMap::from([("input".to_string(), 0.000001)]),
|
||||
utc_windows: Vec::new(),
|
||||
},
|
||||
]);
|
||||
|
||||
let big = HashMap::from([("input".to_string(), 300_000i64)]);
|
||||
let small = HashMap::from([("input".to_string(), 1_000i64)]);
|
||||
|
||||
// both hold
|
||||
assert_eq!(
|
||||
calculate_cost_from_definition(&def, &big, at_utc(2, 0)).tier_name,
|
||||
"Peak Extended"
|
||||
);
|
||||
// window holds, condition does not
|
||||
assert_eq!(
|
||||
calculate_cost_from_definition(&def, &small, at_utc(2, 0)).tier_name,
|
||||
"Default"
|
||||
);
|
||||
// condition holds, window does not
|
||||
assert_eq!(
|
||||
calculate_cost_from_definition(&def, &big, at_utc(14, 0)).tier_name,
|
||||
"Default"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_windows_compose_with_tier_usage_override() {
|
||||
// Tier selection uses `tier_usage` (total input incl. cache) while pricing uses
|
||||
// `usage` (uncached input only) — the window must be honoured on the same path.
|
||||
let def = peak_off_peak_definition();
|
||||
let billable = HashMap::from([("input".to_string(), 100_000i64)]);
|
||||
let tier_usage = HashMap::from([("input".to_string(), 1_000_000i64)]);
|
||||
|
||||
let result = calculate_cost_from_definition_with_tier_usage(
|
||||
&def,
|
||||
&billable,
|
||||
&tier_usage,
|
||||
at_utc(2, 0),
|
||||
);
|
||||
assert_eq!(result.tier_name, "Peak");
|
||||
assert!((result.cost["input"] - 0.132).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_default_tier() {
|
||||
let def = make_definition(vec![PricingTierDefinition {
|
||||
@@ -502,10 +738,11 @@ mod tests {
|
||||
("input".to_string(), 0.000003),
|
||||
("output".to_string(), 0.000015),
|
||||
]),
|
||||
utc_windows: Vec::new(),
|
||||
}]);
|
||||
|
||||
let usage = HashMap::from([("input".to_string(), 1000i64), ("output".to_string(), 500)]);
|
||||
let result = calculate_cost_from_definition(&def, &usage);
|
||||
let result = calculate_cost_from_definition(&def, &usage, None);
|
||||
assert_eq!(result.tier_name, "Default");
|
||||
assert!((result.cost["input"] - 0.003).abs() < 1e-10);
|
||||
assert!((result.cost["output"] - 0.0075).abs() < 1e-10);
|
||||
@@ -522,6 +759,7 @@ mod tests {
|
||||
("input".to_string(), 0.000003),
|
||||
("output".to_string(), 0.000015),
|
||||
]),
|
||||
utc_windows: Vec::new(),
|
||||
},
|
||||
PricingTierDefinition {
|
||||
name: "Extended Context".to_string(),
|
||||
@@ -534,6 +772,7 @@ mod tests {
|
||||
("input".to_string(), 0.000006),
|
||||
("output".to_string(), 0.0000225),
|
||||
]),
|
||||
utc_windows: Vec::new(),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -542,7 +781,7 @@ mod tests {
|
||||
("input".to_string(), 50_000i64),
|
||||
("output".to_string(), 10_000),
|
||||
]);
|
||||
let result = calculate_cost_from_definition(&def, &usage);
|
||||
let result = calculate_cost_from_definition(&def, &usage, None);
|
||||
assert_eq!(result.tier_name, "Default");
|
||||
assert!((result.cost["input"] - 0.15).abs() < 1e-10);
|
||||
|
||||
@@ -551,7 +790,7 @@ mod tests {
|
||||
("input".to_string(), 250_000i64),
|
||||
("output".to_string(), 10_000),
|
||||
]);
|
||||
let result = calculate_cost_from_definition(&def, &usage);
|
||||
let result = calculate_cost_from_definition(&def, &usage, None);
|
||||
assert_eq!(result.tier_name, "Extended Context");
|
||||
assert!((result.cost["input"] - 1.5).abs() < 1e-10);
|
||||
assert!((result.cost["output"] - 0.225).abs() < 1e-10);
|
||||
@@ -567,6 +806,7 @@ mod tests {
|
||||
("input".to_string(), 0.000001),
|
||||
("cache_read_input_tokens".to_string(), 0.0000001),
|
||||
]),
|
||||
utc_windows: Vec::new(),
|
||||
},
|
||||
PricingTierDefinition {
|
||||
name: "Extended Context".to_string(),
|
||||
@@ -579,6 +819,7 @@ mod tests {
|
||||
("input".to_string(), 0.000002),
|
||||
("cache_read_input_tokens".to_string(), 0.0000002),
|
||||
]),
|
||||
utc_windows: Vec::new(),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -588,8 +829,12 @@ mod tests {
|
||||
]);
|
||||
let tier_usage = HashMap::from([("input".to_string(), 1_000i64)]);
|
||||
|
||||
let result =
|
||||
calculate_cost_from_definition_with_tier_usage(&def, &billable_usage, &tier_usage);
|
||||
let result = calculate_cost_from_definition_with_tier_usage(
|
||||
&def,
|
||||
&billable_usage,
|
||||
&tier_usage,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(result.tier_name, "Extended Context");
|
||||
assert!((result.cost["input"] - 0.0004).abs() < 1e-10);
|
||||
@@ -648,10 +893,11 @@ mod tests {
|
||||
("input".to_string(), 0.000003),
|
||||
("output".to_string(), 0.000015),
|
||||
]),
|
||||
utc_windows: Vec::new(),
|
||||
}]);
|
||||
|
||||
let usage = HashMap::from([("input".to_string(), 0i64), ("output".to_string(), 0)]);
|
||||
let result = calculate_cost_from_definition(&def, &usage);
|
||||
let result = calculate_cost_from_definition(&def, &usage, None);
|
||||
assert!(result.cost.is_empty());
|
||||
}
|
||||
|
||||
@@ -678,6 +924,7 @@ mod tests {
|
||||
name: "Old".to_string(),
|
||||
condition: None,
|
||||
prices: HashMap::from([("input".to_string(), 0.000001)]),
|
||||
utc_windows: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
@@ -691,6 +938,7 @@ mod tests {
|
||||
name: "New".to_string(),
|
||||
condition: None,
|
||||
prices: HashMap::from([("input".to_string(), 0.000005)]),
|
||||
utc_windows: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
@@ -722,6 +970,7 @@ mod tests {
|
||||
("output".to_string(), 0.000015),
|
||||
("total".to_string(), 0.0001), // should be ignored
|
||||
]),
|
||||
utc_windows: Vec::new(),
|
||||
}]);
|
||||
|
||||
let usage = HashMap::from([
|
||||
@@ -729,7 +978,7 @@ mod tests {
|
||||
("output".to_string(), 500),
|
||||
("total".to_string(), 1500), // should be skipped
|
||||
]);
|
||||
let result = calculate_cost_from_definition(&def, &usage);
|
||||
let result = calculate_cost_from_definition(&def, &usage, None);
|
||||
// total should be computed as sum of input+output costs, not from the "total" usage key
|
||||
assert!((result.cost["input"] - 0.003).abs() < 1e-10);
|
||||
assert!((result.cost["output"] - 0.0075).abs() < 1e-10);
|
||||
@@ -743,11 +992,12 @@ mod tests {
|
||||
name: "Default".to_string(),
|
||||
condition: None,
|
||||
prices: HashMap::from([("input".to_string(), 0.000003)]),
|
||||
utc_windows: Vec::new(),
|
||||
}]);
|
||||
|
||||
// "output" has tokens but no price configured → should not appear in cost
|
||||
let usage = HashMap::from([("input".to_string(), 1000i64), ("output".to_string(), 500)]);
|
||||
let result = calculate_cost_from_definition(&def, &usage);
|
||||
let result = calculate_cost_from_definition(&def, &usage, None);
|
||||
assert!((result.cost["input"] - 0.003).abs() < 1e-10);
|
||||
assert!(!result.cost.contains_key("output"));
|
||||
assert!((result.cost["total"] - 0.003).abs() < 1e-10);
|
||||
@@ -765,11 +1015,13 @@ mod tests {
|
||||
value: 100.0,
|
||||
}),
|
||||
prices: HashMap::from([("input".to_string(), 0.00001)]),
|
||||
utc_windows: Vec::new(),
|
||||
},
|
||||
PricingTierDefinition {
|
||||
name: "Default".to_string(),
|
||||
condition: None,
|
||||
prices: HashMap::from([("input".to_string(), 0.000001)]),
|
||||
utc_windows: Vec::new(),
|
||||
},
|
||||
])
|
||||
};
|
||||
@@ -780,61 +1032,73 @@ mod tests {
|
||||
|
||||
// Gt: 150 > 100 → conditional, 100 !> 100 → default
|
||||
assert_eq!(
|
||||
calculate_cost_from_definition(&make_tiered(TierOperator::Gt), &usage_150).tier_name,
|
||||
calculate_cost_from_definition(&make_tiered(TierOperator::Gt), &usage_150, None)
|
||||
.tier_name,
|
||||
"Conditional"
|
||||
);
|
||||
assert_eq!(
|
||||
calculate_cost_from_definition(&make_tiered(TierOperator::Gt), &usage_100).tier_name,
|
||||
calculate_cost_from_definition(&make_tiered(TierOperator::Gt), &usage_100, None)
|
||||
.tier_name,
|
||||
"Default"
|
||||
);
|
||||
|
||||
// Gte: 100 >= 100 → conditional, 50 !>= 100 → default
|
||||
assert_eq!(
|
||||
calculate_cost_from_definition(&make_tiered(TierOperator::Gte), &usage_100).tier_name,
|
||||
calculate_cost_from_definition(&make_tiered(TierOperator::Gte), &usage_100, None)
|
||||
.tier_name,
|
||||
"Conditional"
|
||||
);
|
||||
assert_eq!(
|
||||
calculate_cost_from_definition(&make_tiered(TierOperator::Gte), &usage_50).tier_name,
|
||||
calculate_cost_from_definition(&make_tiered(TierOperator::Gte), &usage_50, None)
|
||||
.tier_name,
|
||||
"Default"
|
||||
);
|
||||
|
||||
// Lt: 50 < 100 → conditional, 100 !< 100 → default
|
||||
assert_eq!(
|
||||
calculate_cost_from_definition(&make_tiered(TierOperator::Lt), &usage_50).tier_name,
|
||||
calculate_cost_from_definition(&make_tiered(TierOperator::Lt), &usage_50, None)
|
||||
.tier_name,
|
||||
"Conditional"
|
||||
);
|
||||
assert_eq!(
|
||||
calculate_cost_from_definition(&make_tiered(TierOperator::Lt), &usage_100).tier_name,
|
||||
calculate_cost_from_definition(&make_tiered(TierOperator::Lt), &usage_100, None)
|
||||
.tier_name,
|
||||
"Default"
|
||||
);
|
||||
|
||||
// Lte: 100 <= 100 → conditional, 150 !<= 100 → default
|
||||
assert_eq!(
|
||||
calculate_cost_from_definition(&make_tiered(TierOperator::Lte), &usage_100).tier_name,
|
||||
calculate_cost_from_definition(&make_tiered(TierOperator::Lte), &usage_100, None)
|
||||
.tier_name,
|
||||
"Conditional"
|
||||
);
|
||||
assert_eq!(
|
||||
calculate_cost_from_definition(&make_tiered(TierOperator::Lte), &usage_150).tier_name,
|
||||
calculate_cost_from_definition(&make_tiered(TierOperator::Lte), &usage_150, None)
|
||||
.tier_name,
|
||||
"Default"
|
||||
);
|
||||
|
||||
// Eq: 100 == 100 → conditional, 50 != 100 → default
|
||||
assert_eq!(
|
||||
calculate_cost_from_definition(&make_tiered(TierOperator::Eq), &usage_100).tier_name,
|
||||
calculate_cost_from_definition(&make_tiered(TierOperator::Eq), &usage_100, None)
|
||||
.tier_name,
|
||||
"Conditional"
|
||||
);
|
||||
assert_eq!(
|
||||
calculate_cost_from_definition(&make_tiered(TierOperator::Eq), &usage_50).tier_name,
|
||||
calculate_cost_from_definition(&make_tiered(TierOperator::Eq), &usage_50, None)
|
||||
.tier_name,
|
||||
"Default"
|
||||
);
|
||||
|
||||
// Neq: 50 != 100 → conditional, 100 == 100 → default
|
||||
assert_eq!(
|
||||
calculate_cost_from_definition(&make_tiered(TierOperator::Neq), &usage_50).tier_name,
|
||||
calculate_cost_from_definition(&make_tiered(TierOperator::Neq), &usage_50, None)
|
||||
.tier_name,
|
||||
"Conditional"
|
||||
);
|
||||
assert_eq!(
|
||||
calculate_cost_from_definition(&make_tiered(TierOperator::Neq), &usage_100).tier_name,
|
||||
calculate_cost_from_definition(&make_tiered(TierOperator::Neq), &usage_100, None)
|
||||
.tier_name,
|
||||
"Default"
|
||||
);
|
||||
}
|
||||
@@ -850,6 +1114,7 @@ mod tests {
|
||||
name: "Default".to_string(),
|
||||
condition: None,
|
||||
prices: HashMap::from([("input".to_string(), 0.000001)]),
|
||||
utc_windows: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
@@ -861,6 +1126,7 @@ mod tests {
|
||||
name: "Default".to_string(),
|
||||
condition: None,
|
||||
prices: HashMap::from([("input".to_string(), 0.000001)]),
|
||||
utc_windows: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
@@ -880,6 +1146,7 @@ mod tests {
|
||||
name: "Default".to_string(),
|
||||
condition: None,
|
||||
prices: HashMap::from([("input".to_string(), 0.000001)]),
|
||||
utc_windows: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
@@ -891,6 +1158,7 @@ mod tests {
|
||||
name: "Default".to_string(),
|
||||
condition: None,
|
||||
prices: HashMap::from([("input".to_string(), 0.000001)]),
|
||||
utc_windows: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
@@ -911,6 +1179,7 @@ mod tests {
|
||||
name: "Default".to_string(),
|
||||
condition: None,
|
||||
prices: HashMap::new(),
|
||||
utc_windows: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
@@ -923,6 +1192,7 @@ mod tests {
|
||||
name: "Default".to_string(),
|
||||
condition: None,
|
||||
prices: HashMap::new(),
|
||||
utc_windows: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
@@ -943,6 +1213,7 @@ mod tests {
|
||||
name: "Default".to_string(),
|
||||
condition: None,
|
||||
prices: HashMap::from([("input".to_string(), 0.000003)]),
|
||||
utc_windows: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
}]));
|
||||
@@ -966,6 +1237,7 @@ mod tests {
|
||||
name: "Acme Custom".to_string(),
|
||||
condition: None,
|
||||
prices: HashMap::from([("input".to_string(), 0.000005)]),
|
||||
utc_windows: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
}]));
|
||||
@@ -1008,6 +1280,7 @@ mod tests {
|
||||
name: "Org".to_string(),
|
||||
condition: None,
|
||||
prices: HashMap::from([("input".to_string(), 0.000010)]),
|
||||
utc_windows: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
@@ -1022,6 +1295,7 @@ mod tests {
|
||||
name: "BuiltIn".to_string(),
|
||||
condition: None,
|
||||
prices: HashMap::from([("input".to_string(), 0.000003)]),
|
||||
utc_windows: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
|
||||
@@ -19,6 +19,8 @@ import { z } from "zod";
|
||||
|
||||
const PURE_INT = /^\d+$/;
|
||||
const HAS_SPACE = /\s/;
|
||||
/** 24-hour clock time, `HH:MM` — the shape `OFormTime` binds. */
|
||||
const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
/**
|
||||
* Strip Rust/PCRE inline flag groups that JavaScript RegExp doesn't understand
|
||||
@@ -40,10 +42,20 @@ export const tierConditionSchema = z.object({
|
||||
value: z.coerce.number(),
|
||||
});
|
||||
|
||||
// One recurring UTC time-of-day window. Bounds are held as `HH:MM` strings in the
|
||||
// form (what OFormTime binds) and converted to minutes-past-midnight at submit.
|
||||
// A window whose start is later than its end wraps past midnight.
|
||||
export const tierWindowSchema = z.object({
|
||||
start: z.string(),
|
||||
end: z.string(),
|
||||
});
|
||||
|
||||
export const tierSchema = z.object({
|
||||
name: z.string(),
|
||||
// null for the default (first) tier; an object for conditional tiers.
|
||||
condition: tierConditionSchema.nullable().optional(),
|
||||
// Recurring UTC hours this tier is limited to. Empty = always active.
|
||||
utc_windows: z.array(tierWindowSchema).default([]),
|
||||
prices: z.array(priceRowSchema).default([]),
|
||||
// Staging "add price" row — non-validated form state, auto-committed at submit.
|
||||
draftKey: z.string().optional().default(""),
|
||||
@@ -135,9 +147,42 @@ export const makeModelPricingSchema = (
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// UTC time windows: both bounds must be HH:MM and must differ. Equal
|
||||
// bounds are ambiguous ("never" vs "always"), so the tier must instead
|
||||
// drop the window to be always active.
|
||||
const tierLabel = tier.name || `#${i + 1}`;
|
||||
(tier.utc_windows ?? []).forEach((win, w) => {
|
||||
const start = String(win.start ?? "").trim();
|
||||
const end = String(win.end ?? "").trim();
|
||||
const startOk = HHMM.test(start);
|
||||
const endOk = HHMM.test(end);
|
||||
if (!startOk) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["tiers", i, "utc_windows", w, "start"],
|
||||
message: t("modelPricing.timeWindowInvalid", { name: tierLabel }),
|
||||
});
|
||||
}
|
||||
if (!endOk) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["tiers", i, "utc_windows", w, "end"],
|
||||
message: t("modelPricing.timeWindowInvalid", { name: tierLabel }),
|
||||
});
|
||||
}
|
||||
if (startOk && endOk && start === end) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["tiers", i, "utc_windows", w, "end"],
|
||||
message: t("modelPricing.timeWindowSameBounds", { name: tierLabel }),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export type ModelPricingForm = z.infer<ReturnType<typeof makeModelPricingSchema>>;
|
||||
export type ModelPricingTier = z.infer<typeof tierSchema>;
|
||||
export type ModelPricingTierWindow = z.infer<typeof tierWindowSchema>;
|
||||
export type ModelPricingPriceRow = z.infer<typeof priceRowSchema>;
|
||||
|
||||
@@ -65,6 +65,9 @@ const mockStore = {
|
||||
state: {
|
||||
theme: "light",
|
||||
selectedOrganization: { identifier: "test-org" },
|
||||
// "UTC" keeps the local-time window hints quiet by default; individual
|
||||
// tests override to a real zone to exercise the conversion.
|
||||
timezone: "UTC",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -167,6 +170,7 @@ const row = (key: string, value: number) => ({ key, value });
|
||||
const tier = (overrides: Record<string, any> = {}) => ({
|
||||
name: "Default",
|
||||
condition: null,
|
||||
utc_windows: [] as any[],
|
||||
prices: [] as any[],
|
||||
draftKey: "",
|
||||
draftValue: 0,
|
||||
@@ -190,6 +194,7 @@ describe("ModelPricingEditor.vue", () => {
|
||||
// reset store theme defaults
|
||||
mockStore.state.theme = "light";
|
||||
mockStore.state.selectedOrganization = { identifier: "test-org" };
|
||||
mockStore.state.timezone = "UTC";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -368,6 +373,308 @@ describe("ModelPricingEditor.vue", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Schema validation: UTC time windows", () => {
|
||||
const schema = makeModelPricingSchema(i18n.global.t as any);
|
||||
// Isolate the window rules (path tiers[i].utc_windows[w].start|end).
|
||||
const windowIssue = (start: string, end: string, bound: "start" | "end") => {
|
||||
const res = schema.safeParse({
|
||||
name: "X",
|
||||
match_pattern: "gpt",
|
||||
tiers: [
|
||||
{ name: "Default", condition: null, prices: [{ key: "input", value: 1 }] },
|
||||
{ name: "Peak", condition: null, utc_windows: [{ start, end }], prices: [] },
|
||||
],
|
||||
});
|
||||
return res.success
|
||||
? ""
|
||||
: (res.error.issues.find(
|
||||
(iss: any) =>
|
||||
iss.path[0] === "tiers" && iss.path[2] === "utc_windows" && iss.path[4] === bound,
|
||||
)?.message ?? "");
|
||||
};
|
||||
|
||||
it("accepts a well-formed HH:MM window", () => {
|
||||
expect(windowIssue("01:00", "04:00", "start")).toBe("");
|
||||
expect(windowIssue("01:00", "04:00", "end")).toBe("");
|
||||
});
|
||||
|
||||
it("accepts a window that wraps past midnight", () => {
|
||||
expect(windowIssue("22:00", "02:00", "start")).toBe("");
|
||||
expect(windowIssue("22:00", "02:00", "end")).toBe("");
|
||||
});
|
||||
|
||||
it("rejects a malformed start bound", () => {
|
||||
expect(windowIssue("25:00", "04:00", "start")).toContain("HH:MM");
|
||||
});
|
||||
|
||||
it("rejects a malformed end bound", () => {
|
||||
expect(windowIssue("01:00", "4pm", "end")).toContain("HH:MM");
|
||||
});
|
||||
|
||||
it("rejects a window whose bounds are equal", () => {
|
||||
expect(windowIssue("01:00", "01:00", "end")).toContain("start and end must differ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UTC time windows (peak / off-peak pricing)", () => {
|
||||
it("converts HH:MM bounds to minutes past UTC midnight at submit", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
fillValid(wrapper, [
|
||||
tier({ prices: [row("input", 660)] }),
|
||||
tier({
|
||||
name: "Peak",
|
||||
prices: [row("input", 1320)],
|
||||
utc_windows: [
|
||||
{ start: "01:00", end: "04:00" },
|
||||
{ start: "06:00", end: "10:00" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
await nextTick();
|
||||
await getForm(wrapper).handleSubmit();
|
||||
await flushPromises();
|
||||
|
||||
const payload = mockService.create.mock.calls[0][1];
|
||||
expect(payload.tiers[0].utc_windows).toEqual([]);
|
||||
expect(payload.tiers[1].utc_windows).toEqual([
|
||||
{ start_minute: 60, end_minute: 240 },
|
||||
{ start_minute: 360, end_minute: 600 },
|
||||
]);
|
||||
// A window-only tier carries no usage condition.
|
||||
expect(payload.tiers[1].condition).toBeNull();
|
||||
});
|
||||
|
||||
it("shows each window converted to the user's timezone", async () => {
|
||||
mockStore.state.timezone = "Asia/Kolkata"; // UTC+5:30, no DST
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
fillValid(wrapper, [
|
||||
tier({ prices: [row("input", 1)] }),
|
||||
tier({
|
||||
name: "Peak",
|
||||
prices: [row("input", 2)],
|
||||
utc_windows: [{ start: "01:00", end: "04:00" }],
|
||||
}),
|
||||
]);
|
||||
await nextTick();
|
||||
|
||||
const hint = wrapper.find('[data-test="model-pricing-tier-window-local-hint-1-0"]');
|
||||
expect(hint.exists()).toBe(true);
|
||||
expect(hint.text()).toContain("06:30–09:30");
|
||||
});
|
||||
|
||||
it("hides the local-time hint when the user's timezone is UTC", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
fillValid(wrapper, [
|
||||
tier({ prices: [row("input", 1)] }),
|
||||
tier({
|
||||
name: "Peak",
|
||||
prices: [row("input", 2)],
|
||||
utc_windows: [{ start: "01:00", end: "04:00" }],
|
||||
}),
|
||||
]);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-test="model-pricing-tier-window-local-hint-1-0"]').exists()).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the live 24h hours bar for a tier with parseable windows", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
fillValid(wrapper, [
|
||||
tier({ prices: [row("input", 1)] }),
|
||||
tier({
|
||||
name: "Peak",
|
||||
prices: [row("input", 2)],
|
||||
utc_windows: [
|
||||
{ start: "01:00", end: "04:00" },
|
||||
{ start: "06:00", end: "10:00" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
await nextTick();
|
||||
|
||||
const bar = wrapper.find('[data-test="model-pricing-tier-window-bar-1"]');
|
||||
expect(bar.exists()).toBe(true);
|
||||
expect(bar.findAll("[data-test^='utc-hours-bar-segment-']")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("hides the hours bar while no window row parses to a drawable range", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
fillValid(wrapper, [
|
||||
tier({ prices: [row("input", 1)] }),
|
||||
tier({
|
||||
name: "Peak",
|
||||
prices: [row("input", 2)],
|
||||
// Unparseable + degenerate — nothing the preview can draw yet.
|
||||
utc_windows: [
|
||||
{ start: "", end: "04:00" },
|
||||
{ start: "05:00", end: "05:00" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-test="model-pricing-tier-window-bar-1"]').exists()).toBe(false);
|
||||
// The window rows themselves still render for editing.
|
||||
expect(wrapper.find('[data-test="model-pricing-tier-window-start-1-0"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("sends a midnight-wrapping window as start > end", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
fillValid(wrapper, [
|
||||
tier({ prices: [row("input", 1)] }),
|
||||
tier({
|
||||
name: "Night",
|
||||
prices: [row("input", 2)],
|
||||
utc_windows: [{ start: "22:00", end: "02:00" }],
|
||||
}),
|
||||
]);
|
||||
await nextTick();
|
||||
await getForm(wrapper).handleSubmit();
|
||||
await flushPromises();
|
||||
|
||||
const payload = mockService.create.mock.calls[0][1];
|
||||
expect(payload.tiers[1].utc_windows).toEqual([{ start_minute: 1320, end_minute: 120 }]);
|
||||
});
|
||||
|
||||
it("blocks submit when a non-default tier has neither a condition nor a window", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
fillValid(wrapper, [
|
||||
tier({ prices: [row("input", 1)] }),
|
||||
tier({ name: "Orphan", prices: [row("input", 2)] }),
|
||||
]);
|
||||
await nextTick();
|
||||
await getForm(wrapper).handleSubmit();
|
||||
await flushPromises();
|
||||
|
||||
expect(mockService.create).not.toHaveBeenCalled();
|
||||
expect(mockToastFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variant: "error",
|
||||
message: expect.stringContaining("Orphan"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("addWindow / removeWindow mutate the form-owned tier", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
fillValid(wrapper, [
|
||||
tier({ prices: [row("input", 1)] }),
|
||||
tier({ name: "Peak", condition: null, prices: [row("input", 2)] }),
|
||||
]);
|
||||
await nextTick();
|
||||
|
||||
(wrapper.vm as any).addWindow(1);
|
||||
await nextTick();
|
||||
let tiers = getForm(wrapper).state.values.tiers;
|
||||
expect(tiers[1].utc_windows).toEqual([{ start: "01:00", end: "04:00" }]);
|
||||
|
||||
(wrapper.vm as any).removeWindow(1, 0);
|
||||
await nextTick();
|
||||
tiers = getForm(wrapper).state.values.tiers;
|
||||
expect(tiers[1].utc_windows).toEqual([]);
|
||||
});
|
||||
|
||||
it("addCondition / removeCondition toggle the usage condition", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
fillValid(wrapper, [
|
||||
tier({ prices: [row("input", 1)] }),
|
||||
tier({
|
||||
name: "Peak",
|
||||
prices: [row("input", 2)],
|
||||
utc_windows: [{ start: "01:00", end: "04:00" }],
|
||||
}),
|
||||
]);
|
||||
await nextTick();
|
||||
|
||||
(wrapper.vm as any).addCondition(1);
|
||||
await nextTick();
|
||||
expect(getForm(wrapper).state.values.tiers[1].condition).toEqual({
|
||||
usage_key: "input",
|
||||
operator: "gt",
|
||||
value: 200000,
|
||||
});
|
||||
|
||||
(wrapper.vm as any).removeCondition(1);
|
||||
await nextTick();
|
||||
expect(getForm(wrapper).state.values.tiers[1].condition).toBeNull();
|
||||
});
|
||||
|
||||
it("hydrates a window-only tier from the API without inventing a condition", async () => {
|
||||
mockService.get.mockResolvedValue({
|
||||
data: {
|
||||
id: "ds",
|
||||
name: "DeepSeek V4 Pro",
|
||||
match_pattern: "(?i)deepseek-v4-pro",
|
||||
enabled: true,
|
||||
tiers: [
|
||||
{ name: "Off-Peak", condition: null, prices: { input: 0.00000066 } },
|
||||
{
|
||||
name: "Peak",
|
||||
condition: null,
|
||||
utc_windows: [
|
||||
{ start_minute: 60, end_minute: 240 },
|
||||
{ start_minute: 360, end_minute: 600 },
|
||||
],
|
||||
prices: { input: 0.00000132 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const wrapper = createWrapper({ query: { id: "ds" } });
|
||||
await flushPromises();
|
||||
|
||||
const tiers = getForm(wrapper).state.values.tiers;
|
||||
expect(tiers[0].utc_windows).toEqual([]);
|
||||
expect(tiers[1].condition).toBeNull();
|
||||
expect(tiers[1].utc_windows).toEqual([
|
||||
{ start: "01:00", end: "04:00" },
|
||||
{ start: "06:00", end: "10:00" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("round-trips an end-of-day bound of 1440 as 00:00", async () => {
|
||||
mockService.get.mockResolvedValue({
|
||||
data: {
|
||||
id: "n",
|
||||
name: "Night",
|
||||
match_pattern: "night",
|
||||
enabled: true,
|
||||
tiers: [
|
||||
{ name: "Default", condition: null, prices: { input: 0.000001 } },
|
||||
{
|
||||
name: "Late",
|
||||
condition: null,
|
||||
utc_windows: [{ start_minute: 1320, end_minute: 1440 }],
|
||||
prices: { input: 0.000002 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const wrapper = createWrapper({ query: { id: "n" } });
|
||||
await flushPromises();
|
||||
|
||||
const tiers = getForm(wrapper).state.values.tiers;
|
||||
expect(tiers[1].utc_windows).toEqual([{ start: "22:00", end: "00:00" }]);
|
||||
|
||||
await getForm(wrapper).handleSubmit();
|
||||
await flushPromises();
|
||||
const payload = mockService.update.mock.calls[0][2];
|
||||
// 22:00 -> 00:00 still means "22:00 to end of day" via the wrap rule.
|
||||
expect(payload.tiers[1].utc_windows).toEqual([{ start_minute: 1320, end_minute: 0 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Schema validation (real OForm)", () => {
|
||||
it("blocks submit and calls neither create nor update when name/pattern are empty", async () => {
|
||||
const wrapper = createWrapper();
|
||||
@@ -761,10 +1068,12 @@ describe("ModelPricingEditor.vue", () => {
|
||||
expect(payload.match_pattern).toBe("gpt-4o");
|
||||
expect(Array.isArray(payload.tiers)).toBe(true);
|
||||
|
||||
// EXACT tier shape — only {condition, name, prices}; NO draftKey/draftValue leak
|
||||
// EXACT tier shape — only {condition, name, prices, utc_windows}; NO
|
||||
// draftKey/draftValue leak
|
||||
const t0 = payload.tiers[0];
|
||||
expect(Object.keys(t0).sort()).toEqual(["condition", "name", "prices"]);
|
||||
expect(Object.keys(t0).sort()).toEqual(["condition", "name", "prices", "utc_windows"]);
|
||||
expect(t0.condition).toBeNull(); // default (first) tier
|
||||
expect(t0.utc_windows).toEqual([]); // default tier is never time-restricted
|
||||
expect(typeof t0.name).toBe("string");
|
||||
|
||||
// prices is a per-token MAP of numbers (committed row + committed draft)
|
||||
|
||||
@@ -38,73 +38,49 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
class="bg-surface-panel border-card-glass-border rounded-t-default flex flex-row items-center justify-between gap-3 border-b px-4 py-2.5"
|
||||
>
|
||||
<div>
|
||||
<div class="form-card-title text-compact font-semibold">
|
||||
<OText variant="panel-title" class="text-compact font-semibold">
|
||||
{{ t("modelPricing.modelDetails") }}
|
||||
</div>
|
||||
<div class="form-card-subtitle text-2xs mt-px opacity-60">
|
||||
</OText>
|
||||
<OText variant="meta" class="text-2xs mt-px block">
|
||||
{{ t("modelPricing.modelDetailsDesc") }}
|
||||
</div>
|
||||
</OText>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-card-body flex flex-row gap-4 px-4 pt-2.5 pb-2">
|
||||
<div class="flex flex-row gap-4 px-4 pt-2.5 pb-2">
|
||||
<div class="flex-1">
|
||||
<div
|
||||
class="field-label mb-1 flex h-5 items-center gap-1 text-xs font-semibold opacity-75"
|
||||
<OFormInput
|
||||
name="name"
|
||||
:label="t('modelPricing.modelNameField')"
|
||||
:placeholder="t('modelPricing.modelNamePlaceholder', { example: raw('GPT-4o') })"
|
||||
data-test="model-pricing-name-input"
|
||||
>
|
||||
{{ t("modelPricing.modelNameField") }}
|
||||
<OButton
|
||||
variant="ghost"
|
||||
size="icon-xs-sq"
|
||||
class="ml-1"
|
||||
data-test="model-pricing-name-info-btn"
|
||||
>
|
||||
<OIcon name="info" size="xs" :class="'text-text-secondary'" />
|
||||
<template #tooltip>
|
||||
<OTooltip
|
||||
side="right"
|
||||
max-width="18.75rem"
|
||||
:content="t('modelPricing.modelNameTooltip')"
|
||||
/>
|
||||
</OButton>
|
||||
</div>
|
||||
<OFormInput
|
||||
name="name"
|
||||
:placeholder="
|
||||
t('modelPricing.modelNamePlaceholder', {
|
||||
example: raw('GPT-4o'),
|
||||
})
|
||||
"
|
||||
data-test="model-pricing-name-input"
|
||||
/>
|
||||
</template>
|
||||
</OFormInput>
|
||||
</div>
|
||||
<div class="flex flex-1 items-start gap-1">
|
||||
<div class="flex flex-1 items-end gap-1">
|
||||
<div class="flex-1">
|
||||
<div
|
||||
class="field-label mb-1 flex h-5 items-center gap-1 text-xs font-semibold opacity-75"
|
||||
<OFormInput
|
||||
name="match_pattern"
|
||||
:label="t('modelPricing.matchPatternField')"
|
||||
:placeholder="
|
||||
t('modelPricing.matchPatternPlaceholder', { example: raw('gpt-4.*') })
|
||||
"
|
||||
data-test="model-pricing-pattern-input"
|
||||
>
|
||||
{{ t("modelPricing.matchPatternField") }}
|
||||
<OButton
|
||||
variant="ghost"
|
||||
size="icon-xs-sq"
|
||||
class="ml-1"
|
||||
data-test="model-pricing-pattern-info-btn"
|
||||
>
|
||||
<OIcon name="info" size="xs" :class="'text-text-secondary'" />
|
||||
<template #tooltip>
|
||||
<OTooltip
|
||||
side="right"
|
||||
max-width="18.75rem"
|
||||
:content="t('modelPricing.matchPatternTooltip')"
|
||||
/>
|
||||
</OButton>
|
||||
</div>
|
||||
<OFormInput
|
||||
name="match_pattern"
|
||||
:placeholder="
|
||||
t('modelPricing.matchPatternPlaceholder', {
|
||||
example: raw('gpt-4.*'),
|
||||
})
|
||||
"
|
||||
data-test="model-pricing-pattern-input"
|
||||
/>
|
||||
</template>
|
||||
</OFormInput>
|
||||
</div>
|
||||
<OButton
|
||||
variant="ghost"
|
||||
@@ -147,11 +123,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:key="ex.name"
|
||||
class="border-card-glass-border grid grid-cols-[11.25rem_1fr_auto] items-center gap-3 border-b px-3 py-2 text-xs last:border-b-0"
|
||||
>
|
||||
<span class="examples-model-name font-medium">{{ ex.name }}</span>
|
||||
<code
|
||||
class="text-2xs bg-surface-subtle rounded-default px-1.5 py-px font-mono break-all"
|
||||
>{{ ex.match_pattern }}</code
|
||||
>
|
||||
<OText variant="body-strong" class="text-xs">{{ ex.name }}</OText>
|
||||
<!-- Presentation only. The copy button beside it keeps using
|
||||
@/utils/clipboard: OCode's own `copyable` is
|
||||
navigator.clipboard-only, which fails silently on HTTP and
|
||||
inside reka-ui's focus trap. -->
|
||||
<OCode truncate>{{ ex.match_pattern }}</OCode>
|
||||
<OButton
|
||||
variant="ghost"
|
||||
size="icon-xs-sq"
|
||||
@@ -183,16 +160,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
class="bg-surface-panel border-card-glass-border rounded-t-default flex flex-row items-center justify-between gap-3 border-b px-4 py-2.5"
|
||||
>
|
||||
<div>
|
||||
<div class="form-card-title text-compact font-semibold">
|
||||
<OText variant="panel-title" class="text-compact font-semibold">
|
||||
{{ t("modelPricing.pricingTiers") }}
|
||||
</div>
|
||||
<div class="form-card-subtitle text-2xs mt-px opacity-60">
|
||||
</OText>
|
||||
<OText variant="meta" class="text-2xs mt-px block">
|
||||
{{ t("modelPricing.pricingTiersDesc") }}
|
||||
</div>
|
||||
</OText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-card-body flex flex-col gap-3 px-4 pt-2.5 pb-2">
|
||||
<div class="flex flex-col gap-3 px-4 pt-2.5 pb-2">
|
||||
<div
|
||||
v-for="(tier, idx) in formTiers"
|
||||
:key="idx"
|
||||
@@ -234,8 +211,23 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
v-if="idx > 0 && tier.condition"
|
||||
class="rounded-default bg-surface-panel border-card-glass-border border px-3.5 py-3"
|
||||
>
|
||||
<div class="sub-label text-2xs mb-2 font-semibold tracking-[0.06em] opacity-65">
|
||||
{{ t("modelPricing.applyTierWhen") }}
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<OText variant="section">
|
||||
{{ t("modelPricing.applyTierWhen") }}
|
||||
</OText>
|
||||
<OButton
|
||||
variant="ghost"
|
||||
size="icon-xs-sq"
|
||||
type="button"
|
||||
:data-test="`model-pricing-tier-condition-remove-btn-${idx}`"
|
||||
@click="removeCondition(idx)"
|
||||
>
|
||||
<OIcon name="close" size="xs" />
|
||||
<OTooltip
|
||||
:side-offset="4"
|
||||
:content="t('modelPricing.removeUsageCondition')"
|
||||
/>
|
||||
</OButton>
|
||||
</div>
|
||||
<div class="flex flex-nowrap items-end gap-2">
|
||||
<div class="min-w-32.5 flex-1">
|
||||
@@ -270,11 +262,105 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recurring UTC time windows (non-default tiers only). Used by
|
||||
providers that bill peak / off-peak rates, e.g. DeepSeek. -->
|
||||
<div
|
||||
v-if="idx > 0 && tierWindows(tier).length"
|
||||
class="rounded-default bg-surface-panel border-card-glass-border border px-3.5 py-3"
|
||||
>
|
||||
<OText variant="section">
|
||||
{{ t("modelPricing.timeWindows") }}
|
||||
</OText>
|
||||
<div class="text-2xs mt-px mb-2 opacity-55">
|
||||
{{ t("modelPricing.timeWindowsDesc") }}
|
||||
</div>
|
||||
<!-- Key by INDEX to match the index-based field names — see the
|
||||
price-row comment below for why a stable-id key breaks binding. -->
|
||||
<div v-for="(win, winIdx) in tierWindows(tier)" :key="winIdx" class="py-0.5">
|
||||
<div class="flex flex-nowrap items-end gap-2">
|
||||
<div class="w-35 shrink-0">
|
||||
<OFormTime
|
||||
:name="`tiers[${idx}].utc_windows[${winIdx}].start`"
|
||||
:label="t('modelPricing.timeWindowFrom')"
|
||||
format24
|
||||
:data-test="`model-pricing-tier-window-start-${idx}-${winIdx}`"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-35 shrink-0">
|
||||
<OFormTime
|
||||
:name="`tiers[${idx}].utc_windows[${winIdx}].end`"
|
||||
:label="t('modelPricing.timeWindowTo')"
|
||||
format24
|
||||
:data-test="`model-pricing-tier-window-end-${idx}-${winIdx}`"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex h-8.5 items-center gap-2">
|
||||
<OButton
|
||||
variant="outline-destructive"
|
||||
size="icon"
|
||||
type="button"
|
||||
:data-test="`model-pricing-tier-window-remove-btn-${idx}-${winIdx}`"
|
||||
@click="removeWindow(idx, winIdx)"
|
||||
>
|
||||
<OIcon name="delete" size="sm" />
|
||||
<OTooltip
|
||||
:side-offset="4"
|
||||
:content="t('modelPricing.removeTimeWindow')"
|
||||
/>
|
||||
</OButton>
|
||||
<span
|
||||
v-if="windowWraps(win)"
|
||||
class="text-2xs whitespace-nowrap opacity-55"
|
||||
:data-test="`model-pricing-tier-window-wrap-hint-${idx}-${winIdx}`"
|
||||
>
|
||||
{{ t("modelPricing.timeWindowWrapsHint") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- The same window in the viewer's timezone -->
|
||||
<div
|
||||
v-if="windowLocalHint(win)"
|
||||
class="text-2xs mt-1 opacity-55"
|
||||
:data-test="`model-pricing-tier-window-local-hint-${idx}-${winIdx}`"
|
||||
>
|
||||
{{ t("modelPricing.localTimeHint", { range: windowLocalHint(win) }) }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Live 24h preview of the hours the tier covers -->
|
||||
<UtcHoursBar
|
||||
v-if="parsedTierWindows(tier).length"
|
||||
class="mt-3"
|
||||
:windows="parsedTierWindows(tier)"
|
||||
:data-test="`model-pricing-tier-window-bar-${idx}`"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Add a restriction to a non-default tier -->
|
||||
<div v-if="idx > 0" class="flex flex-wrap items-center gap-2">
|
||||
<OButton
|
||||
v-if="!tier.condition"
|
||||
variant="outline"
|
||||
size="sm-action"
|
||||
type="button"
|
||||
:data-test="`model-pricing-tier-add-condition-btn-${idx}`"
|
||||
@click="addCondition(idx)"
|
||||
>
|
||||
{{ t("modelPricing.addUsageCondition") }}
|
||||
</OButton>
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="sm-action"
|
||||
type="button"
|
||||
:data-test="`model-pricing-tier-add-window-btn-${idx}`"
|
||||
@click="addWindow(idx)"
|
||||
>
|
||||
{{ t("modelPricing.addTimeWindow") }}
|
||||
</OButton>
|
||||
</div>
|
||||
|
||||
<!-- Quick Setup -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="sub-label text-2xs font-semibold tracking-[0.06em] opacity-65">{{
|
||||
t("modelPricing.quickSetup")
|
||||
}}</span>
|
||||
<OText variant="section">{{ t("modelPricing.quickSetup") }}</OText>
|
||||
<OButton
|
||||
v-for="tpl in usageTemplates"
|
||||
:key="tpl.name"
|
||||
@@ -304,11 +390,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<!-- Price Table -->
|
||||
<div>
|
||||
<div class="price-table-label text-2xs mb-2 font-semibold tracking-[0.03em]">
|
||||
{{ t("modelPricing.tokenPrices") }}
|
||||
<span class="price-table-label-sub font-normal tracking-normal opacity-55">
|
||||
{{ t("modelPricing.tokenPricesUnit") }}</span
|
||||
>
|
||||
<div class="mb-2">
|
||||
<OText variant="label">{{ t("modelPricing.tokenPrices") }}</OText>
|
||||
<OText variant="meta"> {{ t("modelPricing.tokenPricesUnit") }}</OText>
|
||||
</div>
|
||||
|
||||
<div class="price-table overflow-hidden">
|
||||
@@ -374,17 +458,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
<OEmptyState
|
||||
v-if="!tier.prices.length"
|
||||
class="price-empty flex flex-col items-center gap-0.75 p-4"
|
||||
>
|
||||
<div class="price-empty-title text-xs font-medium">
|
||||
{{ t("modelPricing.noPricesDefined") }}
|
||||
</div>
|
||||
<div class="price-empty-sub text-2xs opacity-55">
|
||||
{{ t("modelPricing.noPricesDesc") }}
|
||||
</div>
|
||||
</div>
|
||||
size="inline"
|
||||
hide-action
|
||||
:title="t('modelPricing.noPricesDefined')"
|
||||
:description="t('modelPricing.noPricesDesc')"
|
||||
/>
|
||||
|
||||
<!-- Add row (staging draft, form-owned) -->
|
||||
<div
|
||||
@@ -534,14 +614,21 @@ import OForm from "@/lib/forms/Form/OForm.vue";
|
||||
import { useOForm } from "@/lib/forms/Form/useOForm";
|
||||
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
|
||||
import OFormSelect from "@/lib/forms/Select/OFormSelect.vue";
|
||||
import OFormTime from "@/lib/forms/Time/OFormTime.vue";
|
||||
import UtcHoursBar, { type UtcHoursWindow } from "@/components/settings/UtcHoursBar.vue";
|
||||
import OCode from "@/lib/core/Code/OCode.vue";
|
||||
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
import OText from "@/lib/core/Typography/OText.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
|
||||
import { copyToClipboard } from "@/utils/clipboard";
|
||||
import { formatUtcWindowsInTz } from "@/utils/formatters";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import {
|
||||
makeModelPricingSchema,
|
||||
type ModelPricingForm,
|
||||
type ModelPricingTier,
|
||||
type ModelPricingTierWindow,
|
||||
} from "./ModelPricingEditor.schema";
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
@@ -619,7 +706,12 @@ function createEmptyModel() {
|
||||
|
||||
// API-shaped tier (per-token price map). Used to seed the working `model`.
|
||||
function newTier(name: string, condition: any = null) {
|
||||
return { name, condition, prices: {} as Record<string, number> };
|
||||
return {
|
||||
name,
|
||||
condition,
|
||||
prices: {} as Record<string, number>,
|
||||
utc_windows: [] as Array<{ start_minute: number; end_minute: number }>,
|
||||
};
|
||||
}
|
||||
|
||||
// ── map ↔ rows converters (the field-array unlock) ───────────────────────────
|
||||
@@ -628,29 +720,99 @@ function newFormTier(name: string, condition: any = null) {
|
||||
return {
|
||||
name,
|
||||
condition,
|
||||
utc_windows: [] as ModelPricingTierWindow[],
|
||||
prices: [] as Array<{ key: string; value: number }>,
|
||||
draftKey: "",
|
||||
draftValue: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── UTC time-window converters ───────────────────────────────────────────────
|
||||
// The API stores minutes past UTC midnight; OFormTime binds `HH:MM`. A window
|
||||
// whose start is later than its end wraps past midnight (e.g. 22:00 → 02:00).
|
||||
|
||||
function minutesToHhmm(minutes: number): string {
|
||||
// 1440 (24:00) is a valid API bound but not a valid <input type="time"> value;
|
||||
// it means the same instant as 00:00, so normalise it.
|
||||
const m = ((Math.round(Number(minutes) || 0) % 1440) + 1440) % 1440;
|
||||
const hh = String(Math.floor(m / 60)).padStart(2, "0");
|
||||
const mm = String(m % 60).padStart(2, "0");
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
|
||||
/** `HH:MM` → minutes past UTC midnight, or null when unparseable. */
|
||||
function hhmmToMinutes(value: string): number | null {
|
||||
const match = /^(\d{1,2}):(\d{2})$/.exec(String(value ?? "").trim());
|
||||
if (!match) return null;
|
||||
const hours = Number(match[1]);
|
||||
const mins = Number(match[2]);
|
||||
if (hours > 23 || mins > 59) return null;
|
||||
return hours * 60 + mins;
|
||||
}
|
||||
|
||||
/** A tier's windows, tolerant of form state seeded before the field existed. */
|
||||
function tierWindows(tier: any): ModelPricingTierWindow[] {
|
||||
return (tier?.utc_windows ?? []) as ModelPricingTierWindow[];
|
||||
}
|
||||
|
||||
/** True when a window crosses UTC midnight — surfaced as a hint next to the row. */
|
||||
function windowWraps(win: ModelPricingTierWindow): boolean {
|
||||
const start = hhmmToMinutes(win.start);
|
||||
const end = hhmmToMinutes(win.end);
|
||||
return start !== null && end !== null && start > end;
|
||||
}
|
||||
|
||||
/** One window rendered in the user's configured timezone, or "" when there is
|
||||
* nothing meaningful to show (unparseable bounds, or the timezone is UTC). */
|
||||
function windowLocalHint(win: ModelPricingTierWindow): string {
|
||||
const start = hhmmToMinutes(win.start);
|
||||
const end = hhmmToMinutes(win.end);
|
||||
if (start === null || end === null) return "";
|
||||
return formatUtcWindowsInTz([{ start_minute: start, end_minute: end }], store.state.timezone);
|
||||
}
|
||||
|
||||
/** The tier's windows as minutes, keeping only rows the 24h preview can draw. */
|
||||
function parsedTierWindows(tier: any): UtcHoursWindow[] {
|
||||
return tierWindows(tier)
|
||||
.map((w) => ({
|
||||
start_minute: hhmmToMinutes(w?.start),
|
||||
end_minute: hhmmToMinutes(w?.end),
|
||||
}))
|
||||
.filter(
|
||||
(w): w is UtcHoursWindow =>
|
||||
w.start_minute !== null && w.end_minute !== null && w.start_minute !== w.end_minute,
|
||||
);
|
||||
}
|
||||
|
||||
// API model (per-token price MAP) → FORM value (per-million ROW array).
|
||||
function modelToForm(m: any): ModelPricingForm {
|
||||
const tiers = (m?.tiers ?? []).map((tier: any, i: number) => ({
|
||||
name: tier.name ?? "",
|
||||
condition:
|
||||
i === 0
|
||||
? null
|
||||
: tier.condition
|
||||
? { ...tier.condition }
|
||||
: { usage_key: "input", operator: "gt", value: 0 },
|
||||
prices: Object.entries(tier.prices ?? {}).map(([k, v]) => ({
|
||||
key: k,
|
||||
value: toPerMillion(Number(v)),
|
||||
})),
|
||||
draftKey: "",
|
||||
draftValue: 0,
|
||||
}));
|
||||
const tiers = (m?.tiers ?? []).map((tier: any, i: number) => {
|
||||
const windows: ModelPricingTierWindow[] = (tier.utc_windows ?? []).map((w: any) => ({
|
||||
start: minutesToHhmm(w?.start_minute),
|
||||
end: minutesToHhmm(w?.end_minute),
|
||||
}));
|
||||
return {
|
||||
name: tier.name ?? "",
|
||||
// Tier 0 is the unconditional default. A later tier keeps its own condition;
|
||||
// one restricted only by time windows keeps `null` rather than being handed a
|
||||
// usage condition it never had.
|
||||
condition:
|
||||
i === 0
|
||||
? null
|
||||
: tier.condition
|
||||
? { ...tier.condition }
|
||||
: windows.length
|
||||
? null
|
||||
: { usage_key: "input", operator: "gt", value: 0 },
|
||||
utc_windows: i === 0 ? [] : windows,
|
||||
prices: Object.entries(tier.prices ?? {}).map(([k, v]) => ({
|
||||
key: k,
|
||||
value: toPerMillion(Number(v)),
|
||||
})),
|
||||
draftKey: "",
|
||||
draftValue: 0,
|
||||
};
|
||||
});
|
||||
return {
|
||||
name: m?.name ?? "",
|
||||
match_pattern: m?.match_pattern ?? "",
|
||||
@@ -669,6 +831,20 @@ function formToModelTiers(tiers: any[]): any[] {
|
||||
}
|
||||
const dk = String(tier.draftKey ?? "").trim();
|
||||
if (dk) prices[dk] = fromPerMillion(Number(tier.draftValue) || 0);
|
||||
// Drop unparseable rows and degenerate windows (start === end), which the API
|
||||
// rejects as ambiguous — an always-active tier carries no window at all.
|
||||
const utcWindows =
|
||||
i === 0
|
||||
? []
|
||||
: (tier.utc_windows ?? [])
|
||||
.map((w: any) => ({
|
||||
start_minute: hhmmToMinutes(w?.start),
|
||||
end_minute: hhmmToMinutes(w?.end),
|
||||
}))
|
||||
.filter(
|
||||
(w: any) =>
|
||||
w.start_minute !== null && w.end_minute !== null && w.start_minute !== w.end_minute,
|
||||
);
|
||||
return {
|
||||
name: tier.name,
|
||||
condition:
|
||||
@@ -681,6 +857,7 @@ function formToModelTiers(tiers: any[]): any[] {
|
||||
value: Number(tier.condition.value) || 0,
|
||||
}
|
||||
: null,
|
||||
utc_windows: utcWindows,
|
||||
prices,
|
||||
};
|
||||
});
|
||||
@@ -719,6 +896,9 @@ const operators = [
|
||||
function addTier() {
|
||||
setTiers([
|
||||
...formTiers.value,
|
||||
// Seeded with a usage condition (the long-standing default). It can be swapped
|
||||
// for — or combined with — a UTC time window from the tier body.
|
||||
// Untranslated on purpose: this is persisted verbatim as `tier.name`.
|
||||
newFormTier(raw("Conditional Tier"), {
|
||||
usage_key: "input",
|
||||
operator: "gt",
|
||||
@@ -731,6 +911,37 @@ function removeTier(idx: number) {
|
||||
setTiers(formTiers.value.filter((_: any, i: number) => i !== idx));
|
||||
}
|
||||
|
||||
function patchTier(idx: number, patch: (_tier: any) => any) {
|
||||
setTiers(formTiers.value.map((tier: any, i: number) => (i === idx ? patch(tier) : tier)));
|
||||
}
|
||||
|
||||
function addCondition(idx: number) {
|
||||
patchTier(idx, (tier) => ({
|
||||
...tier,
|
||||
condition: { usage_key: "input", operator: "gt", value: 200000 },
|
||||
}));
|
||||
}
|
||||
|
||||
function removeCondition(idx: number) {
|
||||
patchTier(idx, (tier) => ({ ...tier, condition: null }));
|
||||
}
|
||||
|
||||
// Seeded with DeepSeek's first peak window (01:00-04:00 UTC) — the common case
|
||||
// this exists for, and a concrete example of the HH:MM format.
|
||||
function addWindow(idx: number) {
|
||||
patchTier(idx, (tier) => ({
|
||||
...tier,
|
||||
utc_windows: [...(tier.utc_windows ?? []), { start: "01:00", end: "04:00" }],
|
||||
}));
|
||||
}
|
||||
|
||||
function removeWindow(tierIdx: number, winIdx: number) {
|
||||
patchTier(tierIdx, (tier) => ({
|
||||
...tier,
|
||||
utc_windows: (tier.utc_windows ?? []).filter((_: any, j: number) => j !== winIdx),
|
||||
}));
|
||||
}
|
||||
|
||||
function removePrice(tierIdx: number, entryIdx: number) {
|
||||
setTiers(
|
||||
formTiers.value.map((tier: any, i: number) =>
|
||||
@@ -881,6 +1092,16 @@ async function save(value?: ModelPricingForm) {
|
||||
}
|
||||
}
|
||||
|
||||
// A non-default tier with neither a usage condition nor a time window can never
|
||||
// be selected — the unconditional tier 0 always wins first.
|
||||
for (let i = 1; i < tiers.length; i++) {
|
||||
const tier = tiers[i];
|
||||
if (!tier.condition && !(tier.utc_windows ?? []).length) {
|
||||
notifyWarn(t("modelPricing.tierNeedsRestriction", { name: tier.name || `#${i + 1}` }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Require at least one non-zero price in the default tier (committed + draft).
|
||||
const defaultTier = tiers[0];
|
||||
if (defaultTier) {
|
||||
@@ -978,8 +1199,11 @@ onBeforeMount(async () => {
|
||||
delete model.value.source;
|
||||
}
|
||||
for (let i = 1; i < model.value.tiers.length; i++) {
|
||||
if (!model.value.tiers[i].condition) {
|
||||
model.value.tiers[i].condition = {
|
||||
const tier = model.value.tiers[i];
|
||||
// A tier restricted only by UTC time windows legitimately has no usage
|
||||
// condition — leave it alone rather than fabricating one.
|
||||
if (!tier.condition && !(tier.utc_windows ?? []).length) {
|
||||
tier.condition = {
|
||||
usage_key: "input",
|
||||
operator: "gt",
|
||||
value: 0,
|
||||
|
||||
@@ -201,6 +201,31 @@ const builtInModel = (overrides: Partial<any> = {}) => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Peak / off-peak model: an unrestricted off-peak tier plus a UTC-windowed peak
|
||||
// tier — the DeepSeek shape.
|
||||
const peakOffPeakModel = (overrides: Partial<any> = {}) => ({
|
||||
id: "org-peak",
|
||||
name: "DeepSeek V4 Pro",
|
||||
match_pattern: "(?i)deepseek-v4-pro",
|
||||
source: "org",
|
||||
org_id: "test-org",
|
||||
enabled: true,
|
||||
tiers: [
|
||||
{ name: "Off-Peak", condition: null, prices: { input: 6.6e-7, output: 1.98e-6 } },
|
||||
{
|
||||
name: "Peak",
|
||||
condition: null,
|
||||
utc_windows: [
|
||||
{ start_minute: 60, end_minute: 240 },
|
||||
{ start_minute: 360, end_minute: 600 },
|
||||
],
|
||||
prices: { input: 1.32e-6, output: 3.96e-6 },
|
||||
},
|
||||
],
|
||||
children: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mockModels = [orgModel(), metaOrgModel(), builtInModel()];
|
||||
|
||||
// ── Store / i18n ─────────────────────────────────────────────────────────────
|
||||
@@ -273,6 +298,11 @@ const mockI18n = createI18n({
|
||||
shadowBannerPrefix: "Shadowed by",
|
||||
shadowBannerSuffix: "rule",
|
||||
shadowedTooltip: "Shadowed by {name}",
|
||||
timeBasedChip: "Time-based",
|
||||
timeWindows: "Active hours (UTC)",
|
||||
localTimeHint: "In your timezone: {range}",
|
||||
tierAlwaysActive: "Always active (default)",
|
||||
tierDefaultName: "Default",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1191,4 +1221,122 @@ describe("ModelPricingList.vue", () => {
|
||||
expect(modelPricingService.list).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Time-based (peak / off-peak) pricing ─────────────────────────────────
|
||||
|
||||
describe("time-based (peak / off-peak) pricing", () => {
|
||||
// OTable keeps its loading skeleton up for a minimum real-time window, so
|
||||
// body cells appear a beat after flushPromises — wait for them.
|
||||
async function waitForChips(w: any, count: number) {
|
||||
return vi.waitFor(() => {
|
||||
const chips = w.findAll('[data-test="model-pricing-time-based-chip"]');
|
||||
expect(chips).toHaveLength(count);
|
||||
return chips;
|
||||
});
|
||||
}
|
||||
|
||||
it("shows the Time-based chip only for models with UTC-windowed tiers", async () => {
|
||||
vi.mocked(modelPricingService.list).mockResolvedValue({
|
||||
data: [orgModel(), peakOffPeakModel()],
|
||||
} as any);
|
||||
wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
const chips = await waitForChips(wrapper, 1);
|
||||
expect(chips[0].text()).toContain("Time-based");
|
||||
});
|
||||
|
||||
it("opens the pricing drawer on the peak model when the chip is clicked", async () => {
|
||||
vi.mocked(modelPricingService.list).mockResolvedValue({
|
||||
data: [orgModel(), peakOffPeakModel()],
|
||||
} as any);
|
||||
wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
const chips = await waitForChips(wrapper, 1);
|
||||
await chips[0].trigger("click");
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.showPricingDialog).toBe(true);
|
||||
expect(wrapper.vm.pricingDialogRow?.id).toBe("org-peak");
|
||||
});
|
||||
|
||||
it("renders every tier in the drawer, each with its restriction caption", async () => {
|
||||
wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
wrapper.vm.openPricingDialog(peakOffPeakModel());
|
||||
await nextTick();
|
||||
|
||||
const drawer = wrapper.findComponent(ODrawerStub);
|
||||
const tierCards = drawer.findAll('[data-test^="model-pricing-drawer-tier-"]');
|
||||
expect(tierCards).toHaveLength(2);
|
||||
// Off-peak: unrestricted default caption. Peak: its formatted UTC hours.
|
||||
expect(tierCards[0].text()).toContain("Off-Peak");
|
||||
expect(tierCards[0].text()).toContain("Always active (default)");
|
||||
expect(tierCards[1].text()).toContain("Peak");
|
||||
expect(tierCards[1].text()).toContain("01:00–04:00, 06:00–10:00 UTC");
|
||||
});
|
||||
|
||||
it("shows both tiers' prices in the drawer, not only the default tier's", async () => {
|
||||
wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
wrapper.vm.openPricingDialog(peakOffPeakModel());
|
||||
await nextTick();
|
||||
|
||||
const drawer = wrapper.findComponent(ODrawerStub);
|
||||
// 0.66 / 1.98 off-peak and 1.32 / 3.96 peak, all per 1M tokens.
|
||||
expect(drawer.text()).toContain("$0.66");
|
||||
expect(drawer.text()).toContain("$1.98");
|
||||
expect(drawer.text()).toContain("$1.32");
|
||||
expect(drawer.text()).toContain("$3.96");
|
||||
});
|
||||
|
||||
it("skips the per-tier header for single-tier models in the drawer", async () => {
|
||||
wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
wrapper.vm.openPricingDialog(mockModels[0]);
|
||||
await nextTick();
|
||||
|
||||
const drawer = wrapper.findComponent(ODrawerStub);
|
||||
expect(drawer.findAll('[data-test^="model-pricing-drawer-tier-"]')).toHaveLength(1);
|
||||
expect(drawer.text()).not.toContain("Always active (default)");
|
||||
});
|
||||
|
||||
it("shows the windowed tier's hours in the user's timezone in the drawer", async () => {
|
||||
wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
store.state.timezone = "Asia/Kolkata"; // UTC+5:30, no DST
|
||||
|
||||
wrapper.vm.openPricingDialog(peakOffPeakModel());
|
||||
await nextTick();
|
||||
|
||||
const hint = wrapper.find('[data-test="model-pricing-drawer-tier-local-hint"]');
|
||||
expect(hint.exists()).toBe(true);
|
||||
expect(hint.text()).toContain("06:30–09:30, 11:30–15:30");
|
||||
});
|
||||
|
||||
it("draws the 24h hours bar for windowed tiers in the drawer", async () => {
|
||||
wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
wrapper.vm.openPricingDialog(peakOffPeakModel());
|
||||
await nextTick();
|
||||
|
||||
const drawer = wrapper.findComponent(ODrawerStub);
|
||||
expect(drawer.find('[data-test="utc-hours-bar"]').exists()).toBe(true);
|
||||
// Two windows, neither wrapping midnight → two filled segments.
|
||||
expect(drawer.findAll('[data-test^="utc-hours-bar-segment-"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("hasTimeBasedTiers reflects whether any tier has utc_windows", async () => {
|
||||
wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
expect(wrapper.vm.hasTimeBasedTiers(peakOffPeakModel())).toBe(true);
|
||||
expect(wrapper.vm.hasTimeBasedTiers(orgModel())).toBe(false);
|
||||
expect(wrapper.vm.hasTimeBasedTiers({ tiers: [{ utc_windows: [] }] })).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -180,13 +180,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
<template #cell-match_pattern="{ row }">
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<code
|
||||
class="bg-surface-subtle border-card-glass-border rounded-default block max-w-full border px-1.5 py-0.5 text-xs text-inherit"
|
||||
<OCode
|
||||
truncate
|
||||
:class="{
|
||||
'[text-decoration-color:currentColor] opacity-50 [text-decoration:line-through]':
|
||||
isChildRow(row),
|
||||
}"
|
||||
>{{ row.match_pattern }}</code
|
||||
>{{ row.match_pattern }}</OCode
|
||||
>
|
||||
<OIcon
|
||||
v-if="isChildRow(row)"
|
||||
@@ -262,6 +262,61 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
</OTooltip>
|
||||
</OTag>
|
||||
<OTag
|
||||
v-if="hasTimeBasedTiers(row)"
|
||||
type="countChip"
|
||||
value="neutral"
|
||||
clickable
|
||||
icon="schedule"
|
||||
data-test="model-pricing-time-based-chip"
|
||||
@click.stop="openPricingDialog(row)"
|
||||
>
|
||||
{{ t("modelPricing.timeBasedChip") }}
|
||||
<OTooltip side="top" align="center">
|
||||
<template #content>
|
||||
<div class="min-w-60">
|
||||
<div class="text-compact mb-1 font-bold">{{ row.name }}</div>
|
||||
<div
|
||||
v-for="(tier, tIdx) in row.tiers ?? []"
|
||||
:key="tIdx"
|
||||
class="mb-2 last:mb-0"
|
||||
>
|
||||
<div class="text-2xs font-semibold">
|
||||
{{ tier.name || t("modelPricing.tierDefaultName") }}
|
||||
</div>
|
||||
<div
|
||||
v-if="tier.utc_windows?.length"
|
||||
class="text-2xs font-mono opacity-70"
|
||||
>
|
||||
{{ formatUtcWindows(tier.utc_windows) }}
|
||||
</div>
|
||||
<div v-if="tierWindowsLocal(tier)" class="text-2xs font-mono opacity-70">
|
||||
{{ t("modelPricing.localTimeHint", { range: tierWindowsLocal(tier) }) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="!tier.condition && !tier.utc_windows?.length"
|
||||
class="text-2xs opacity-70"
|
||||
>
|
||||
{{ t("modelPricing.tierAlwaysActive") }}
|
||||
</div>
|
||||
<table class="mt-0.5 w-full border-collapse">
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="[key, price] in sortedPriceEntries(tier.prices || {})"
|
||||
:key="key"
|
||||
>
|
||||
<td class="py-0.5 pr-4 pl-0 text-xs">{{ formatPriceKey(key) }}</td>
|
||||
<td class="py-0.5 pr-0 pl-0 text-right text-xs font-medium">
|
||||
{{ formatPerMillion(price) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</OTooltip>
|
||||
</OTag>
|
||||
</template>
|
||||
<span v-else class="text-text-muted">—</span>
|
||||
</div>
|
||||
@@ -407,60 +462,100 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div v-if="pricingDialogRow">
|
||||
<div class="mb-4">
|
||||
<div class="text-text-secondary mb-1.5 text-xs font-semibold">
|
||||
<OText variant="label" class="mb-1.5 block">
|
||||
{{ t("modelPricing.colPattern") }}
|
||||
</div>
|
||||
<code
|
||||
class="bg-surface-subtle border-card-glass-border rounded-default text-compact block max-h-75 overflow-y-auto border px-1.5 px-2.5 py-0.5 py-1.5 text-xs break-all whitespace-pre-wrap text-inherit"
|
||||
>{{ pricingDialogRow.match_pattern }}</code
|
||||
>
|
||||
</OText>
|
||||
<!-- No `copyable`: OCode's copy is navigator.clipboard-only and fails
|
||||
silently on HTTP, unlike @/utils/clipboard's execCommand fallback. -->
|
||||
<OCode block class="max-h-75 overflow-y-auto">{{
|
||||
pricingDialogRow.match_pattern
|
||||
}}</OCode>
|
||||
</div>
|
||||
<OSeparator class="mb-4" />
|
||||
|
||||
<div>
|
||||
<div
|
||||
class="text-text-secondary pricing-section-label mt-2 mb-1.5 text-xs font-semibold"
|
||||
>
|
||||
<OText variant="label" class="mt-2 mb-1.5 block">
|
||||
{{ t("modelPricing.colPricing") }}
|
||||
</div>
|
||||
<div
|
||||
v-if="sortedPriceEntries(getDefaultTier(pricingDialogRow)?.prices || {}).length"
|
||||
class="border-card-glass-border rounded-default mt-2 overflow-hidden border"
|
||||
>
|
||||
<table class="w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
class="text-2xs text-table-header-text bg-table-header-bg border-table-header-border border-b px-3.5 py-1.5 text-left font-semibold"
|
||||
</OText>
|
||||
<div v-if="drawerTiers.length" class="mt-2 flex flex-col gap-3">
|
||||
<div
|
||||
v-for="(tier, tIdx) in drawerTiers"
|
||||
:key="tIdx"
|
||||
class="border-card-glass-border rounded-default overflow-hidden border"
|
||||
:data-test="`model-pricing-drawer-tier-${tIdx}`"
|
||||
>
|
||||
<!-- Tier header — only worth the space when there IS more than one tier -->
|
||||
<div
|
||||
v-if="drawerTiers.length > 1"
|
||||
class="bg-surface-panel border-card-glass-border border-b px-3.5 py-2.5"
|
||||
>
|
||||
<div class="text-compact font-semibold">
|
||||
{{ tier.name || t("modelPricing.tierDefaultName") }}
|
||||
</div>
|
||||
<div v-if="tier.condition" class="mt-0.5">
|
||||
<OCode
|
||||
>{{ tier.condition.usage_key }} {{ operatorSymbol(tier.condition.operator) }}
|
||||
{{ tier.condition.value }}</OCode
|
||||
>
|
||||
{{ t("modelPricing.usageType") }}
|
||||
</th>
|
||||
<th
|
||||
class="text-2xs text-table-header-text bg-table-header-bg border-table-header-border border-b px-3.5 py-1.5 text-right font-semibold"
|
||||
</div>
|
||||
<div v-if="tier.utc_windows?.length" class="mt-2">
|
||||
<div class="text-2xs mb-1.5 opacity-55">
|
||||
{{ t("modelPricing.timeWindows") }}
|
||||
<span class="ml-1 font-mono">{{ formatUtcWindows(tier.utc_windows) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="tierWindowsLocal(tier)"
|
||||
class="text-2xs mb-1.5 opacity-55"
|
||||
data-test="model-pricing-drawer-tier-local-hint"
|
||||
>
|
||||
{{ t("modelPricing.colPricing") }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="[key, price] in sortedPriceEntries(
|
||||
getDefaultTier(pricingDialogRow)?.prices || {},
|
||||
)"
|
||||
:key="key"
|
||||
class="last:[&>td]:border-b-0"
|
||||
{{ t("modelPricing.localTimeHint", { range: tierWindowsLocal(tier) }) }}
|
||||
</div>
|
||||
<UtcHoursBar :windows="tier.utc_windows" />
|
||||
</div>
|
||||
<div
|
||||
v-if="!tier.condition && !tier.utc_windows?.length"
|
||||
class="text-2xs mt-0.5 opacity-55"
|
||||
>
|
||||
<td class="text-compact border-table-row-divider border-b px-3.5 py-2">
|
||||
{{ formatPriceKey(key) }}
|
||||
</td>
|
||||
<td
|
||||
class="text-compact border-table-row-divider border-b px-3.5 py-2 text-right font-semibold"
|
||||
{{ t("modelPricing.tierAlwaysActive") }}
|
||||
</div>
|
||||
</div>
|
||||
<table
|
||||
v-if="sortedPriceEntries(tier.prices || {}).length"
|
||||
class="w-full border-collapse"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
class="text-2xs text-table-header-text bg-table-header-bg border-table-header-border border-b px-3.5 py-1.5 text-left font-semibold"
|
||||
>
|
||||
{{ t("modelPricing.usageType") }}
|
||||
</th>
|
||||
<th
|
||||
class="text-2xs text-table-header-text bg-table-header-bg border-table-header-border border-b px-3.5 py-1.5 text-right font-semibold"
|
||||
>
|
||||
{{ t("modelPricing.colPricing") }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="[key, price] in sortedPriceEntries(tier.prices || {})"
|
||||
:key="key"
|
||||
class="last:[&>td]:border-b-0"
|
||||
>
|
||||
{{ formatPerMillion(price) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<td class="text-compact border-table-row-divider border-b px-3.5 py-2">
|
||||
{{ formatPriceKey(key) }}
|
||||
</td>
|
||||
<td
|
||||
class="text-compact border-table-row-divider border-b px-3.5 py-2 text-right font-semibold"
|
||||
>
|
||||
{{ formatPerMillion(price) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="text-text-muted px-3.5 py-2 text-xs">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-text-muted">—</span>
|
||||
</div>
|
||||
@@ -502,6 +597,10 @@ import OTag from "@/lib/core/Badge/OTag.vue";
|
||||
import ODimensionChip from "@/lib/core/Badge/ODimensionChip.vue";
|
||||
import OSeparator from "@/lib/core/Separator/OSeparator.vue";
|
||||
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
|
||||
import UtcHoursBar from "@/components/settings/UtcHoursBar.vue";
|
||||
import OCode from "@/lib/core/Code/OCode.vue";
|
||||
import OText from "@/lib/core/Typography/OText.vue";
|
||||
import { operatorSymbol, formatUtcWindows, formatUtcWindowsInTz } from "@/utils/formatters";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import { useShortcuts } from "@/lib/vue-shortcut-manager";
|
||||
import { isInputFocused } from "@/utils/keyboardShortcuts";
|
||||
@@ -705,11 +804,26 @@ function formatPerMillion(pricePerToken: number | undefined | null): string {
|
||||
return `$${perMillion.toFixed(2)}`;
|
||||
}
|
||||
|
||||
// Mirrors the backend fallback rule: the default tier is the one restricted by
|
||||
// neither a usage condition nor a UTC time window (peak / off-peak pricing).
|
||||
function getDefaultTier(model: any) {
|
||||
const fallback = model.tiers?.find((t: any) => !t.condition);
|
||||
const fallback = model.tiers?.find((t: any) => !t.condition && !t.utc_windows?.length);
|
||||
return fallback || model.tiers?.[0];
|
||||
}
|
||||
|
||||
/** True when any tier is restricted to UTC hours — i.e. peak / off-peak pricing. */
|
||||
function hasTimeBasedTiers(model: any): boolean {
|
||||
return !!model.tiers?.some((t: any) => t.utc_windows?.length);
|
||||
}
|
||||
|
||||
/** All of the drawer row's tiers, in stored order (default tier included). */
|
||||
const drawerTiers = computed<any[]>(() => pricingDialogRow.value?.tiers ?? []);
|
||||
|
||||
/** A tier's windows in the user's timezone, or "" when that adds nothing (UTC). */
|
||||
function tierWindowsLocal(tier: any): string {
|
||||
return formatUtcWindowsInTz(tier?.utc_windows ?? [], store.state.timezone);
|
||||
}
|
||||
|
||||
const PRICE_KEY_ORDER = ["input", "output"];
|
||||
|
||||
function sortedPriceEntries(prices: Record<string, number>): [string, number][] {
|
||||
|
||||
@@ -107,28 +107,65 @@ const OButtonStub = {
|
||||
template: `<button data-test="o-button-stub" @click="$emit('click')"><slot /></button>`,
|
||||
};
|
||||
|
||||
// Mirrors OInput's real contract for the parts this dialog relies on: a
|
||||
// `for`-associated label with the required marker, help text, and the built-in
|
||||
// clear button whose data-test OInput derives as `${parentDataTest}-clear`.
|
||||
const OInputStub = {
|
||||
name: "OInput",
|
||||
props: ["modelValue", "placeholder"],
|
||||
emits: ["update:modelValue"],
|
||||
// Attr inheritance off, like the real OInput: otherwise the parent's
|
||||
// `data-test` lands on the root and hides the stub's own marker.
|
||||
inheritAttrs: false,
|
||||
// Booleans MUST be typed: passed as bare attributes they arrive as "" and an
|
||||
// untyped prop keeps that falsy value instead of coercing it to true.
|
||||
props: {
|
||||
modelValue: { type: String, default: "" },
|
||||
placeholder: { type: String, default: "" },
|
||||
label: { type: String, default: "" },
|
||||
helpText: { type: String, default: "" },
|
||||
required: { type: Boolean, default: false },
|
||||
clearable: { type: Boolean, default: false },
|
||||
autofocus: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ["update:modelValue", "clear"],
|
||||
template: `
|
||||
<div data-test="o-input-stub">
|
||||
<label v-if="label">{{ label }}<span v-if="required">*</span></label>
|
||||
<slot name="icon-left" />
|
||||
<input
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
<button
|
||||
v-if="clearable && modelValue !== '' && modelValue != null"
|
||||
:data-test="clearDataTest"
|
||||
@click="$emit('update:modelValue', ''); $emit('clear')"
|
||||
/>
|
||||
<slot name="icon-right" />
|
||||
<span v-if="helpText" data-test="o-input-stub-help">{{ helpText }}</span>
|
||||
</div>
|
||||
`,
|
||||
methods: {
|
||||
focus() {
|
||||
/* no-op for tests */
|
||||
computed: {
|
||||
clearDataTest(): string {
|
||||
return `${(this as any).$attrs["data-test"] ?? ""}-clear`;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const OTimeStub = {
|
||||
name: "OTime",
|
||||
props: ["modelValue", "label", "helpText", "clearable"],
|
||||
emits: ["update:modelValue"],
|
||||
template: `
|
||||
<input
|
||||
data-test="o-time-stub"
|
||||
type="time"
|
||||
:value="modelValue"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
`,
|
||||
};
|
||||
|
||||
// ── Mount factory ────────────────────────────────────────────────────────────
|
||||
|
||||
function mountDialog(props: Record<string, unknown> = {}) {
|
||||
@@ -139,6 +176,7 @@ function mountDialog(props: Record<string, unknown> = {}) {
|
||||
ODialog: ODialogStub,
|
||||
OButton: OButtonStub,
|
||||
OInput: OInputStub,
|
||||
OTime: OTimeStub,
|
||||
OIcon: OIconStub,
|
||||
OBadge: OBadgeStub,
|
||||
},
|
||||
@@ -158,6 +196,8 @@ describe("TestModelMatchDialog", () => {
|
||||
beforeEach(() => {
|
||||
mockTest.mockReset();
|
||||
mockTest.mockResolvedValue({ data: null });
|
||||
// Keep local-time hints quiet unless a test opts into a real zone.
|
||||
(store.state as any).timezone = "UTC";
|
||||
store.state.selectedOrganization = {
|
||||
label: "default Organization",
|
||||
id: 159,
|
||||
@@ -318,26 +358,38 @@ describe("TestModelMatchDialog", () => {
|
||||
});
|
||||
|
||||
describe("input clear button", () => {
|
||||
// OInput owns the clear affordance now (`clearable`), so the button is the
|
||||
// library's, named `${data-test}-clear`, and it refocuses the field itself.
|
||||
const CLEAR_BTN = '[data-test="test-match-model-input-clear"]';
|
||||
|
||||
it("does not render the clear button when input is empty", () => {
|
||||
wrapper = mountDialog({ modelValue: true });
|
||||
expect(wrapper.find('[data-test="test-match-clear-btn"]').exists()).toBe(false);
|
||||
expect(wrapper.find(CLEAR_BTN).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("renders the clear button when input has a value", async () => {
|
||||
wrapper = mountDialog({ modelValue: true });
|
||||
(wrapper.vm as any).testModelName = "gpt-4";
|
||||
await nextTick();
|
||||
expect(wrapper.find('[data-test="test-match-clear-btn"]').exists()).toBe(true);
|
||||
expect(wrapper.find(CLEAR_BTN).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the input when clearAndFocus is invoked", async () => {
|
||||
it("clears the model name when the clear button is pressed", async () => {
|
||||
wrapper = mountDialog({ modelValue: true });
|
||||
(wrapper.vm as any).testModelName = "gpt-4";
|
||||
await nextTick();
|
||||
(wrapper.vm as any).clearAndFocus();
|
||||
await wrapper.find(CLEAR_BTN).trigger("click");
|
||||
await nextTick();
|
||||
expect((wrapper.vm as any).testModelName).toBe("");
|
||||
});
|
||||
|
||||
it("labels the model-name field and marks it required", () => {
|
||||
wrapper = mountDialog({ modelValue: true });
|
||||
const label = wrapper.find('[data-test="o-input-stub"] label');
|
||||
expect(label.exists()).toBe(true);
|
||||
expect(label.text()).toContain("Model Name");
|
||||
expect(label.text()).toContain("*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runTest", () => {
|
||||
@@ -364,8 +416,12 @@ describe("TestModelMatchDialog", () => {
|
||||
expect(mockTest).toHaveBeenCalledWith("default", {
|
||||
model_name: "gpt-4",
|
||||
usage: undefined,
|
||||
timestamp: null,
|
||||
// "now" in microseconds — resolves valid_from and any peak/off-peak
|
||||
// UTC time window against the current instant.
|
||||
timestamp: expect.any(Number),
|
||||
});
|
||||
const sentTs = mockTest.mock.calls[0][1].timestamp;
|
||||
expect(sentTs).toBeCloseTo(Date.now() * 1000, -7);
|
||||
});
|
||||
|
||||
it("stores the API response on testResult after success", async () => {
|
||||
@@ -823,5 +879,156 @@ describe("TestModelMatchDialog", () => {
|
||||
expect(badge.text()).toBe("Global");
|
||||
expect(badge.attributes("data-variant")).toBe("default-outline");
|
||||
});
|
||||
|
||||
it("renders the matched tier's active UTC hours when it has time windows", async () => {
|
||||
wrapper = mountDialog({ modelValue: true });
|
||||
(wrapper.vm as any).testModelName = "deepseek-v4-pro";
|
||||
(wrapper.vm as any).testResult = {
|
||||
matched: {
|
||||
name: "DeepSeek V4 Pro",
|
||||
source: "built_in",
|
||||
tiers: [
|
||||
{ name: "Off-Peak", prices: { input: 6.6e-7 } },
|
||||
{
|
||||
name: "Peak",
|
||||
prices: { input: 1.32e-6 },
|
||||
utc_windows: [
|
||||
{ start_minute: 60, end_minute: 240 },
|
||||
{ start_minute: 360, end_minute: 600 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
tier: "Peak",
|
||||
};
|
||||
await nextTick();
|
||||
const windowsLine = wrapper.find('[data-test="test-match-tier-windows"]');
|
||||
expect(windowsLine.exists()).toBe(true);
|
||||
expect(windowsLine.text()).toContain("01:00–04:00, 06:00–10:00 UTC");
|
||||
// A time-restricted tier is not the default — the default caption must not show.
|
||||
expect(wrapper.text()).not.toContain("Default pricing tier");
|
||||
});
|
||||
});
|
||||
|
||||
describe("test at UTC time", () => {
|
||||
it("renders the optional UTC time input", () => {
|
||||
wrapper = mountDialog({ modelValue: true });
|
||||
// The component's own data-test falls through onto the stub's root input.
|
||||
expect(wrapper.find('[data-test="test-match-time-input"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("sends today's UTC date at the chosen time when a time is picked", async () => {
|
||||
mockTest.mockResolvedValue({ data: null });
|
||||
wrapper = mountDialog({ modelValue: true });
|
||||
(wrapper.vm as any).testModelName = "deepseek-v4-pro";
|
||||
(wrapper.vm as any).testAtTime = "02:30";
|
||||
await nextTick();
|
||||
await (wrapper.vm as any).runTest();
|
||||
await flushPromises();
|
||||
|
||||
const sentTs = mockTest.mock.calls[0][1].timestamp;
|
||||
const now = new Date();
|
||||
const expected =
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 2, 30) * 1000;
|
||||
expect(sentTs).toBe(expected);
|
||||
});
|
||||
|
||||
it("falls back to the current instant when the time is empty", async () => {
|
||||
mockTest.mockResolvedValue({ data: null });
|
||||
wrapper = mountDialog({ modelValue: true });
|
||||
(wrapper.vm as any).testModelName = "gpt-4";
|
||||
(wrapper.vm as any).testAtTime = "";
|
||||
await nextTick();
|
||||
await (wrapper.vm as any).runTest();
|
||||
await flushPromises();
|
||||
expect(mockTest.mock.calls[0][1].timestamp).toBeCloseTo(Date.now() * 1000, -7);
|
||||
});
|
||||
|
||||
it("re-runs the test when the time changes while a result is shown", async () => {
|
||||
mockTest.mockResolvedValue({
|
||||
data: { matched: { name: "gpt-4", source: "org" }, tier: "Default" },
|
||||
});
|
||||
wrapper = mountDialog({ modelValue: true });
|
||||
(wrapper.vm as any).testModelName = "gpt-4";
|
||||
await nextTick();
|
||||
await (wrapper.vm as any).runTest();
|
||||
await flushPromises();
|
||||
expect(mockTest).toHaveBeenCalledTimes(1);
|
||||
|
||||
(wrapper.vm as any).testAtTime = "07:00";
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
expect(mockTest).toHaveBeenCalledTimes(2);
|
||||
const secondTs = mockTest.mock.calls[1][1].timestamp;
|
||||
const now = new Date();
|
||||
expect(secondTs).toBe(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 7, 0) * 1000,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fire a test on time change before any result exists", async () => {
|
||||
wrapper = mountDialog({ modelValue: true });
|
||||
(wrapper.vm as any).testModelName = "gpt-4";
|
||||
(wrapper.vm as any).testAtTime = "07:00";
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
expect(mockTest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resets the chosen time when the dialog reopens", async () => {
|
||||
wrapper = mountDialog({ modelValue: false });
|
||||
(wrapper.vm as any).testAtTime = "07:00";
|
||||
await wrapper.setProps({ modelValue: true });
|
||||
await nextTick();
|
||||
expect((wrapper.vm as any).testAtTime).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("local timezone hints", () => {
|
||||
it("shows the chosen UTC time converted to the user's timezone", async () => {
|
||||
(store.state as any).timezone = "Asia/Kolkata"; // UTC+5:30, no DST
|
||||
wrapper = mountDialog({ modelValue: true });
|
||||
(wrapper.vm as any).testAtTime = "02:30";
|
||||
await nextTick();
|
||||
|
||||
const hint = wrapper.find('[data-test="test-match-time-local-hint"]');
|
||||
expect(hint.exists()).toBe(true);
|
||||
expect(hint.text()).toContain("08:00");
|
||||
});
|
||||
|
||||
it("hides the conversion when the user's timezone is UTC", async () => {
|
||||
wrapper = mountDialog({ modelValue: true });
|
||||
(wrapper.vm as any).testAtTime = "02:30";
|
||||
await nextTick();
|
||||
expect(wrapper.find('[data-test="test-match-time-local-hint"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("appends the matched tier's hours in the user's timezone", async () => {
|
||||
(store.state as any).timezone = "Asia/Kolkata";
|
||||
wrapper = mountDialog({ modelValue: true });
|
||||
(wrapper.vm as any).testModelName = "deepseek-v4-pro";
|
||||
(wrapper.vm as any).testResult = {
|
||||
matched: {
|
||||
name: "DeepSeek V4 Pro",
|
||||
source: "built_in",
|
||||
tiers: [
|
||||
{
|
||||
name: "Peak",
|
||||
prices: { input: 1.32e-6 },
|
||||
utc_windows: [
|
||||
{ start_minute: 60, end_minute: 240 },
|
||||
{ start_minute: 360, end_minute: 600 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
tier: "Peak",
|
||||
};
|
||||
await nextTick();
|
||||
|
||||
const line = wrapper.find('[data-test="test-match-tier-windows"]');
|
||||
expect(line.text()).toContain("01:00–04:00, 06:00–10:00 UTC");
|
||||
expect(line.text()).toContain("06:30–09:30, 11:30–15:30");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,45 +18,43 @@
|
||||
<!-- ── Left: Inputs ── -->
|
||||
<div class="flex w-85 shrink-0 flex-col gap-5 overflow-y-auto py-5 pr-5 pl-6">
|
||||
<!-- Model Name -->
|
||||
<OInput
|
||||
v-model="testModelName"
|
||||
:label="t('modelPricing.modelNameInput')"
|
||||
:help-text="t('modelPricing.modelNameHint')"
|
||||
:placeholder="
|
||||
t('settings.testModelMatchDialog.modelNamePlaceholder', {
|
||||
example: raw('gpt-4-turbo'),
|
||||
})
|
||||
"
|
||||
required
|
||||
clearable
|
||||
autofocus
|
||||
data-test="test-match-model-input"
|
||||
>
|
||||
<template #icon-left>
|
||||
<OIcon name="smart-toy" size="sm" class="shrink-0 opacity-[0.35]" />
|
||||
</template>
|
||||
</OInput>
|
||||
|
||||
<!-- Optional UTC time — lets peak / off-peak tiers be tested directly -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-compact text-text-heading font-semibold"
|
||||
>{{ t("modelPricing.modelNameInput") }}
|
||||
<span class="text-status-error-text">*</span></label
|
||||
>
|
||||
<div class="mb-0.5 text-xs leading-normal opacity-50">
|
||||
{{ t("modelPricing.modelNameHint") }}
|
||||
</div>
|
||||
<OInput
|
||||
ref="modelInputRef"
|
||||
v-model="testModelName"
|
||||
:placeholder="
|
||||
t('settings.testModelMatchDialog.modelNamePlaceholder', {
|
||||
example: raw('gpt-4-turbo'),
|
||||
})
|
||||
"
|
||||
data-test="test-match-model-input"
|
||||
>
|
||||
<template #icon-left>
|
||||
<OIcon name="smart-toy" size="sm" class="shrink-0 opacity-[0.35]" />
|
||||
</template>
|
||||
<template #icon-right>
|
||||
<OButton
|
||||
v-if="testModelName"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="opacity-[0.35] hover:opacity-70"
|
||||
@click="clearAndFocus"
|
||||
data-test="test-match-clear-btn"
|
||||
>
|
||||
<OIcon name="close" size="xs" />
|
||||
</OButton>
|
||||
</template>
|
||||
</OInput>
|
||||
<OTime
|
||||
v-model="testAtTime"
|
||||
:label="t('modelPricing.testAtTimeLabel')"
|
||||
:help-text="t('modelPricing.testAtTimeHint')"
|
||||
format24
|
||||
clearable
|
||||
data-test="test-match-time-input"
|
||||
/>
|
||||
<OText v-if="testAtTimeLocalHint" variant="meta" data-test="test-match-time-local-hint">
|
||||
{{ t("modelPricing.localTimeHint", { range: testAtTimeLocalHint }) }}
|
||||
</OText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Vertical divider ── -->
|
||||
<div class="bg-card-glass-border w-px shrink-0"></div>
|
||||
<OSeparator vertical />
|
||||
|
||||
<!-- ── Right: Live Results ── -->
|
||||
<div class="flex-1 overflow-y-auto py-5 pr-6 pl-5">
|
||||
@@ -65,26 +63,30 @@
|
||||
<div
|
||||
v-if="!testModelName"
|
||||
key="empty"
|
||||
class="flex h-full min-h-50 flex-col items-center justify-center gap-2.5"
|
||||
class="flex h-full min-h-50 items-center justify-center"
|
||||
data-test="test-match-empty"
|
||||
>
|
||||
<OIcon name="manage-search" size="xl" class="opacity-[0.12]" />
|
||||
<div class="text-compact text-center opacity-[0.35]">
|
||||
{{ t("modelPricing.enterModelName") }}
|
||||
</div>
|
||||
<OEmptyState
|
||||
size="inline"
|
||||
icon="manage-search"
|
||||
hide-action
|
||||
:title="t('modelPricing.enterModelName')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Typed but not yet tested -->
|
||||
<div
|
||||
v-else-if="testResult === null"
|
||||
key="waiting"
|
||||
class="flex h-full min-h-50 flex-col items-center justify-center gap-2.5"
|
||||
class="flex h-full min-h-50 items-center justify-center"
|
||||
data-test="test-match-waiting"
|
||||
>
|
||||
<OIcon name="ads-click" size="xl" class="opacity-[0.12]" />
|
||||
<div class="text-compact text-center opacity-[0.35]">
|
||||
{{ t("modelPricing.clickToTest") }}
|
||||
</div>
|
||||
<OEmptyState
|
||||
size="inline"
|
||||
icon="ads-click"
|
||||
hide-action
|
||||
:title="t('modelPricing.clickToTest')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- No Match -->
|
||||
@@ -94,27 +96,18 @@
|
||||
class="flex flex-col gap-3"
|
||||
data-test="test-match-no-result"
|
||||
>
|
||||
<div
|
||||
class="rounded-default bg-banner-error-soft-bg border-banner-error-soft-border flex items-center gap-3 border px-3.5 py-3"
|
||||
>
|
||||
<div
|
||||
class="rounded-default bg-status-error-bg text-status-error-text flex h-8 w-8 shrink-0 items-center justify-center"
|
||||
>
|
||||
<OIcon name="error-outline" size="md" />
|
||||
<OBanner variant="error-soft" icon="error-outline" inline-actions>
|
||||
<div class="text-compact font-bold">
|
||||
{{ t("modelPricing.noMatchFound") }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-compact text-status-error-text font-bold">
|
||||
{{ t("modelPricing.noMatchFound") }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs opacity-70">
|
||||
{{
|
||||
t("modelPricing.noMatchDesc", {
|
||||
modelName: testModelName,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs opacity-70">
|
||||
{{
|
||||
t("modelPricing.noMatchDesc", {
|
||||
modelName: testModelName,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</OBanner>
|
||||
<div
|
||||
class="rounded-default bg-surface-panel border-card-glass-border border px-3.5 py-3"
|
||||
>
|
||||
@@ -132,33 +125,23 @@
|
||||
<!-- Match Found -->
|
||||
<div v-else key="match" class="flex flex-col gap-3" data-test="test-match-result">
|
||||
<!-- Match status -->
|
||||
<div
|
||||
class="rounded-default bg-banner-success-bg border-banner-success-border flex items-center gap-3 border px-3.5 py-3"
|
||||
>
|
||||
<div
|
||||
class="rounded-default bg-status-success-bg text-status-success-text flex h-8 w-8 shrink-0 items-center justify-center"
|
||||
>
|
||||
<OIcon name="check-circle" size="md" />
|
||||
<OBanner variant="success" icon="check-circle" inline-actions>
|
||||
<div class="text-compact font-bold">
|
||||
{{ t("modelPricing.matchFound") }}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-compact text-status-success-text font-bold">
|
||||
{{ t("modelPricing.matchFound") }}
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-xs opacity-70">
|
||||
<code
|
||||
class="rounded-default bg-banner-success-bg border-banner-success-border inline border px-1.5 py-px font-mono text-xs font-semibold text-inherit"
|
||||
>{{ testResult.matched.name }}</code
|
||||
>
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs opacity-70">
|
||||
<OCode truncate>{{ testResult.matched.name }}</OCode>
|
||||
</div>
|
||||
<OTag
|
||||
type="modelSource"
|
||||
:value="testResult.matched.source || 'org'"
|
||||
class="text-2xs ml-auto shrink-0 font-semibold"
|
||||
>
|
||||
{{ sourceLabel(testResult.matched) }}
|
||||
</OTag>
|
||||
</div>
|
||||
<template #actions>
|
||||
<OTag
|
||||
type="modelSource"
|
||||
:value="testResult.matched.source || 'org'"
|
||||
class="text-2xs shrink-0 font-semibold"
|
||||
>
|
||||
{{ sourceLabel(testResult.matched) }}
|
||||
</OTag>
|
||||
</template>
|
||||
</OBanner>
|
||||
|
||||
<!-- Priority flow -->
|
||||
<div
|
||||
@@ -202,14 +185,27 @@
|
||||
</div>
|
||||
<div class="text-2xs mt-0.5 opacity-50" v-if="matchedTierDef?.condition">
|
||||
{{ t("settings.testModelMatchDialog.condition") }}
|
||||
<code
|
||||
class="tmm-cost-tier-desc-code rounded-default bg-surface-subtle text-2xs px-1 py-px"
|
||||
<OCode
|
||||
>{{ matchedTierDef.condition.usage_key }}
|
||||
{{ operatorSymbol(matchedTierDef.condition.operator) }}
|
||||
{{ matchedTierDef.condition.value }}</code
|
||||
{{ matchedTierDef.condition.value }}</OCode
|
||||
>
|
||||
</div>
|
||||
<div class="text-2xs mt-0.5 opacity-50" v-else>
|
||||
<div
|
||||
class="text-2xs mt-0.5 flex items-center gap-1 opacity-50"
|
||||
v-if="matchedTierWindows.length"
|
||||
data-test="test-match-tier-windows"
|
||||
>
|
||||
<OIcon name="schedule" size="xs" />
|
||||
<span class="font-mono">{{ formatUtcWindows(matchedTierWindows) }}</span>
|
||||
<span v-if="matchedTierWindowsLocal" class="font-mono">{{
|
||||
matchedTierWindowsLocal
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
class="text-2xs mt-0.5 opacity-50"
|
||||
v-if="!matchedTierDef?.condition && !matchedTierWindows.length"
|
||||
>
|
||||
{{ t("modelPricing.defaultPricingTier") }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -237,10 +233,13 @@
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1.75 p-3.5 text-xs italic opacity-40">
|
||||
<OIcon name="info-outline" size="sm" />
|
||||
{{ t("modelPricing.noPricingForTier") }}
|
||||
</div>
|
||||
<OEmptyState
|
||||
v-else
|
||||
size="inline"
|
||||
icon="info-outline"
|
||||
hide-action
|
||||
:title="t('modelPricing.noPricingForTier')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
@@ -250,15 +249,28 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from "vue";
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
import { useStore } from "vuex";
|
||||
import modelPricingService from "@/services/model_pricing";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
|
||||
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
|
||||
import OCode from "@/lib/core/Code/OCode.vue";
|
||||
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
import OSeparator from "@/lib/core/Separator/OSeparator.vue";
|
||||
import OText from "@/lib/core/Typography/OText.vue";
|
||||
import OTag from "@/lib/core/Badge/OTag.vue";
|
||||
import OInput from "@/lib/forms/Input/OInput.vue";
|
||||
import OTime from "@/lib/forms/Time/OTime.vue";
|
||||
import {
|
||||
operatorSymbol,
|
||||
formatUtcWindows,
|
||||
formatUtcWindowsInTz,
|
||||
utcMinuteToTzHhmm,
|
||||
minuteOfDayToHhmm,
|
||||
timezoneAbbr,
|
||||
} from "@/utils/formatters";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
@@ -275,21 +287,17 @@ const internalValue = computed({
|
||||
});
|
||||
|
||||
const testModelName = ref("");
|
||||
const modelInputRef = ref<any>(null);
|
||||
// Optional `HH:MM` UTC time-of-day to test at; empty = "right now". Lets a
|
||||
// peak / off-peak tier be exercised without waiting for its window.
|
||||
const testAtTime = ref("");
|
||||
|
||||
function clearAndFocus() {
|
||||
testModelName.value = "";
|
||||
nextTick(() => modelInputRef.value?.focus());
|
||||
}
|
||||
|
||||
// Reset state and focus on open
|
||||
// Reset on open. Focus is OInput's `autofocus`, not a ref call — OInput is
|
||||
// `<script setup>` with no defineExpose, so it has no focus() to reach for.
|
||||
watch(internalValue, (val) => {
|
||||
if (val) {
|
||||
testResult.value = null;
|
||||
testModelName.value = "";
|
||||
nextTick(() => {
|
||||
setTimeout(() => modelInputRef.value?.focus(), 100);
|
||||
});
|
||||
testAtTime.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
@@ -297,6 +305,24 @@ watch(internalValue, (val) => {
|
||||
|
||||
const testResult = ref<any>(null);
|
||||
|
||||
// The instant the backend resolves `valid_from` and any recurring UTC window
|
||||
// (peak / off-peak rates) against: today at the chosen UTC time, or "right now"
|
||||
// when no time is picked. Without it a peak-hour tier could never be shown.
|
||||
function testTimestampMicros(): number {
|
||||
const m = /^(\d{2}):(\d{2})/.exec(testAtTime.value || "");
|
||||
if (!m) return Date.now() * 1000;
|
||||
const now = new Date();
|
||||
return (
|
||||
Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth(),
|
||||
now.getUTCDate(),
|
||||
Number(m[1]),
|
||||
Number(m[2]),
|
||||
) * 1000
|
||||
);
|
||||
}
|
||||
|
||||
async function callTestApi() {
|
||||
if (!testModelName.value) {
|
||||
testResult.value = null;
|
||||
@@ -306,7 +332,7 @@ async function callTestApi() {
|
||||
const res = await modelPricingService.test(orgIdentifier.value, {
|
||||
model_name: testModelName.value,
|
||||
usage: undefined,
|
||||
timestamp: null,
|
||||
timestamp: testTimestampMicros(),
|
||||
});
|
||||
testResult.value = res.data;
|
||||
} catch {
|
||||
@@ -327,6 +353,12 @@ watch(testModelName, (val) => {
|
||||
if (!val) testResult.value = null;
|
||||
});
|
||||
|
||||
// A shown result answers "what applies at the tested time" — keep it honest by
|
||||
// re-running when that time changes rather than displaying a stale tier.
|
||||
watch(testAtTime, () => {
|
||||
if (testResult.value !== null && testModelName.value) callTestApi();
|
||||
});
|
||||
|
||||
// ── Derived display values ────────────────────────────────────────────────────
|
||||
|
||||
const PRICE_KEY_ORDER = ["input", "output"];
|
||||
@@ -365,17 +397,28 @@ const pricingRows = computed(() => {
|
||||
}));
|
||||
});
|
||||
|
||||
function operatorSymbol(op: string) {
|
||||
const map: Record<string, string> = {
|
||||
gt: ">",
|
||||
gte: "≥",
|
||||
lt: "<",
|
||||
lte: "≤",
|
||||
eq: "=",
|
||||
neq: "≠",
|
||||
};
|
||||
return map[op] || op;
|
||||
}
|
||||
const matchedTierWindows = computed<Array<{ start_minute: number; end_minute: number }>>(
|
||||
() => matchedTierDef.value?.utc_windows ?? [],
|
||||
);
|
||||
|
||||
// "· 06:30–09:30, 11:30–15:30 IST" — the matched tier's hours in the user's
|
||||
// timezone. Empty when the user's timezone is UTC (nothing to convert).
|
||||
const matchedTierWindowsLocal = computed(() => {
|
||||
const local = formatUtcWindowsInTz(matchedTierWindows.value, store.state.timezone);
|
||||
return local ? `· ${local}` : "";
|
||||
});
|
||||
|
||||
/** The chosen test time shown in the user's timezone (empty when unset, or when
|
||||
* the timezone matches UTC and the conversion would just repeat the input). */
|
||||
const testAtTimeLocalHint = computed(() => {
|
||||
const m = /^(\d{2}):(\d{2})/.exec(testAtTime.value || "");
|
||||
if (!m) return "";
|
||||
const tz = store.state.timezone;
|
||||
const minute = Number(m[1]) * 60 + Number(m[2]);
|
||||
const local = utcMinuteToTzHhmm(minute, tz);
|
||||
if (!local || local === minuteOfDayToHhmm(minute)) return "";
|
||||
return `${local} ${timezoneAbbr(tz)}`;
|
||||
});
|
||||
|
||||
function sourceLabel(model: any) {
|
||||
if (!model.source || model.source === "org")
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import UtcHoursBar from "./UtcHoursBar.vue";
|
||||
|
||||
const OTooltipStub = {
|
||||
name: "OTooltip",
|
||||
props: ["content", "side", "sideOffset"],
|
||||
template: `<span data-test="o-tooltip-stub" :data-content="content" />`,
|
||||
};
|
||||
|
||||
function mountBar(windows: Array<{ start_minute: number; end_minute: number }>) {
|
||||
return mount(UtcHoursBar, {
|
||||
props: { windows },
|
||||
global: { stubs: { OTooltip: OTooltipStub } },
|
||||
});
|
||||
}
|
||||
|
||||
function segmentStyles(wrapper: ReturnType<typeof mountBar>) {
|
||||
return wrapper
|
||||
.findAll("[data-test^='utc-hours-bar-segment-']")
|
||||
.map((el) => ({ left: el.element.style.left, width: el.element.style.width }));
|
||||
}
|
||||
|
||||
describe("UtcHoursBar", () => {
|
||||
it("renders one segment per non-wrapping window with proportional geometry", () => {
|
||||
// DeepSeek's peak hours: 01:00-04:00 and 06:00-10:00 UTC.
|
||||
const wrapper = mountBar([
|
||||
{ start_minute: 60, end_minute: 240 },
|
||||
{ start_minute: 360, end_minute: 600 },
|
||||
]);
|
||||
const segs = segmentStyles(wrapper);
|
||||
expect(segs).toHaveLength(2);
|
||||
// 60/1440 = 4.1667%, (240-60)/1440 = 12.5%
|
||||
expect(parseFloat(segs[0].left)).toBeCloseTo(4.1667, 3);
|
||||
expect(parseFloat(segs[0].width)).toBeCloseTo(12.5, 3);
|
||||
expect(parseFloat(segs[1].left)).toBeCloseTo(25, 3);
|
||||
expect(parseFloat(segs[1].width)).toBeCloseTo(16.6667, 3);
|
||||
});
|
||||
|
||||
it("splits a midnight-wrapping window into a tail run and a head run", () => {
|
||||
// 22:00 -> 02:00 wraps: [22:00, 24:00) plus [00:00, 02:00).
|
||||
const wrapper = mountBar([{ start_minute: 1320, end_minute: 120 }]);
|
||||
const segs = segmentStyles(wrapper);
|
||||
expect(segs).toHaveLength(2);
|
||||
expect(parseFloat(segs[0].left)).toBeCloseTo((1320 / 1440) * 100, 3);
|
||||
expect(parseFloat(segs[0].width)).toBeCloseTo((120 / 1440) * 100, 3);
|
||||
expect(parseFloat(segs[1].left)).toBeCloseTo(0, 3);
|
||||
expect(parseFloat(segs[1].width)).toBeCloseTo((120 / 1440) * 100, 3);
|
||||
});
|
||||
|
||||
it("labels each segment with the window's full range, not the split run", () => {
|
||||
const wrapper = mountBar([{ start_minute: 1320, end_minute: 120 }]);
|
||||
const tooltips = wrapper.findAll("[data-test='o-tooltip-stub']");
|
||||
expect(tooltips).toHaveLength(2);
|
||||
for (const tip of tooltips) {
|
||||
expect(tip.attributes("data-content")).toBe("22:00 – 02:00 UTC");
|
||||
}
|
||||
});
|
||||
|
||||
it("skips degenerate windows (start === end) instead of drawing a full-day run", () => {
|
||||
const wrapper = mountBar([
|
||||
{ start_minute: 300, end_minute: 300 },
|
||||
{ start_minute: 60, end_minute: 240 },
|
||||
]);
|
||||
expect(segmentStyles(wrapper)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders an empty track when there are no windows", () => {
|
||||
const wrapper = mountBar([]);
|
||||
expect(segmentStyles(wrapper)).toHaveLength(0);
|
||||
expect(wrapper.find("[data-test='utc-hours-bar']").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows the six-hour ruler from 00:00 through 24:00", () => {
|
||||
const wrapper = mountBar([]);
|
||||
const ticks = wrapper.findAll("[data-test='utc-hours-bar'] span").map((el) => el.text());
|
||||
expect(ticks).toEqual(["00:00", "06:00", "12:00", "18:00", "24:00"]);
|
||||
});
|
||||
|
||||
it("treats an end of 1440 (24:00) as end-of-day, a single run to the right edge", () => {
|
||||
// 20:00 -> 24:00. end % 1440 === 0 < start, so it takes the wrap branch and
|
||||
// the head run [0, 0) must collapse to nothing.
|
||||
const wrapper = mountBar([{ start_minute: 1200, end_minute: 1440 }]);
|
||||
const segs = segmentStyles(wrapper);
|
||||
expect(segs).toHaveLength(1);
|
||||
expect(parseFloat(segs[0].left)).toBeCloseTo((1200 / 1440) * 100, 3);
|
||||
expect(parseFloat(segs[0].width)).toBeCloseTo((240 / 1440) * 100, 3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<!-- A 24-hour UTC day strip with the given recurring windows filled in — the
|
||||
at-a-glance view of a peak / off-peak pricing tier's active hours. A window
|
||||
whose start is after its end wraps past midnight and renders as two runs. -->
|
||||
<template>
|
||||
<div class="flex flex-col gap-1" data-test="utc-hours-bar">
|
||||
<div
|
||||
class="bg-surface-subtle border-card-glass-border relative h-2 overflow-hidden rounded-full border"
|
||||
>
|
||||
<div
|
||||
v-for="(seg, i) in segments"
|
||||
:key="i"
|
||||
class="bg-accent absolute inset-y-0"
|
||||
:style="{ left: seg.left, width: seg.width }"
|
||||
:data-test="`utc-hours-bar-segment-${i}`"
|
||||
>
|
||||
<OTooltip side="top" :side-offset="6" :content="raw(seg.label)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-3xs flex justify-between font-mono opacity-45">
|
||||
<span v-for="tick in TICKS" :key="tick">{{ tick }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { raw } from "@/types/i18n";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import { minuteOfDayToHhmm } from "@/utils/formatters";
|
||||
|
||||
export interface UtcHoursWindow {
|
||||
start_minute: number;
|
||||
end_minute: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{ windows: UtcHoursWindow[] }>();
|
||||
|
||||
const MINUTES_PER_DAY = 1440;
|
||||
|
||||
const TICKS = Array.from({ length: 5 }, (_, i) => minuteOfDayToHhmm(i * 360));
|
||||
|
||||
function pct(minute: number): string {
|
||||
return `${((minute / MINUTES_PER_DAY) * 100).toFixed(4)}%`;
|
||||
}
|
||||
|
||||
interface Segment {
|
||||
left: string;
|
||||
width: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function pushRun(out: Segment[], from: number, to: number, label: string) {
|
||||
if (to <= from) return;
|
||||
out.push({ left: pct(from), width: pct(to - from), label });
|
||||
}
|
||||
|
||||
const segments = computed<Segment[]>(() => {
|
||||
const out: Segment[] = [];
|
||||
for (const win of props.windows ?? []) {
|
||||
const start =
|
||||
((Math.round(win.start_minute) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY;
|
||||
const end =
|
||||
((Math.round(win.end_minute) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY;
|
||||
if (start === end) continue; // degenerate — rejected upstream, never drawn
|
||||
const label = `${minuteOfDayToHhmm(start)} – ${minuteOfDayToHhmm(end)} UTC`;
|
||||
if (start < end) {
|
||||
pushRun(out, start, end, label);
|
||||
} else {
|
||||
// Wraps past midnight: tail of the day + head of the next.
|
||||
pushRun(out, start, MINUTES_PER_DAY, label);
|
||||
pushRun(out, 0, end, label);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
</script>
|
||||
@@ -30,6 +30,7 @@ if (import.meta.env.DEV && !form) {
|
||||
:max="props.max"
|
||||
:step="props.step"
|
||||
:with-seconds="props.withSeconds"
|
||||
:format24="props.format24"
|
||||
:help-text="props.helpText"
|
||||
:clearable="props.clearable"
|
||||
:readonly="props.readonly"
|
||||
|
||||
@@ -145,4 +145,92 @@ describe("OTime", () => {
|
||||
await flushPromises();
|
||||
expect(document.body.querySelector('[data-test="otime-close"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("format24", () => {
|
||||
const openPopup = async (w: VueWrapper) => {
|
||||
await w.find('[aria-label="Open time picker"]').trigger("click");
|
||||
await flushPromises();
|
||||
};
|
||||
|
||||
it("renders a text input instead of a native time input", () => {
|
||||
wrapper = mount(OTime, {
|
||||
attachTo: document.body,
|
||||
props: { format24: true, modelValue: "13:30" },
|
||||
});
|
||||
expect(wrapper.find('input[type="time"]').exists()).toBe(false);
|
||||
const input = wrapper.find('input[type="text"]');
|
||||
expect(input.exists()).toBe(true);
|
||||
expect((input.element as HTMLInputElement).value).toBe("13:30");
|
||||
});
|
||||
|
||||
it("normalises and emits a typed HH:MM value", async () => {
|
||||
wrapper = mount(OTime, {
|
||||
attachTo: document.body,
|
||||
props: { format24: true, modelValue: "" },
|
||||
});
|
||||
const input = wrapper.find('input[type="text"]');
|
||||
(input.element as HTMLInputElement).value = "7:05";
|
||||
await input.trigger("change");
|
||||
expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["07:05"]);
|
||||
});
|
||||
|
||||
it("reverts an unparseable or out-of-range entry without emitting", async () => {
|
||||
wrapper = mount(OTime, {
|
||||
attachTo: document.body,
|
||||
props: { format24: true, modelValue: "09:00" },
|
||||
});
|
||||
const input = wrapper.find('input[type="text"]');
|
||||
(input.element as HTMLInputElement).value = "25:99";
|
||||
await input.trigger("change");
|
||||
expect(wrapper.emitted("update:modelValue")).toBeUndefined();
|
||||
expect((input.element as HTMLInputElement).value).toBe("09:00");
|
||||
});
|
||||
|
||||
it("hides the AM/PM toggle in the popup", async () => {
|
||||
wrapper = mount(OTime, {
|
||||
attachTo: document.body,
|
||||
props: { format24: true },
|
||||
});
|
||||
await openPopup(wrapper);
|
||||
expect(document.body.querySelector('[aria-label="AM"]')).toBeNull();
|
||||
expect(document.body.querySelector('[aria-label="PM"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a dual-ring hour face with 24 hour targets", async () => {
|
||||
wrapper = mount(OTime, {
|
||||
attachTo: document.body,
|
||||
props: { format24: true },
|
||||
});
|
||||
await openPopup(wrapper);
|
||||
const face = document.body.querySelector('[data-test="otime-clock-face"]')!;
|
||||
const numbers = Array.from(face.querySelectorAll("text")).map((t) => t.textContent?.trim());
|
||||
expect(numbers).toHaveLength(24);
|
||||
expect(numbers).toContain("00");
|
||||
expect(numbers).toContain("13");
|
||||
expect(numbers).toContain("23");
|
||||
});
|
||||
|
||||
it("selects an inner-ring hour directly (no AM/PM math)", async () => {
|
||||
wrapper = mount(OTime, {
|
||||
attachTo: document.body,
|
||||
props: { format24: true, modelValue: "" },
|
||||
});
|
||||
await openPopup(wrapper);
|
||||
const face = document.body.querySelector('[data-test="otime-clock-face"]')!;
|
||||
const target = Array.from(face.querySelectorAll('g[role="button"]')).find(
|
||||
(g) => g.querySelector("text")?.textContent?.trim() === "22",
|
||||
)!;
|
||||
(target as HTMLElement).dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flushPromises();
|
||||
expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["22:00"]);
|
||||
});
|
||||
|
||||
it("keeps the 12-hour face and AM/PM by default", async () => {
|
||||
wrapper = mount(OTime, { attachTo: document.body });
|
||||
await openPopup(wrapper);
|
||||
const face = document.body.querySelector('[data-test="otime-clock-face"]')!;
|
||||
expect(face.querySelectorAll("text")).toHaveLength(12);
|
||||
expect(document.body.querySelector('[aria-label="AM"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,13 @@ export interface TimeProps {
|
||||
modelValue?: string;
|
||||
/** Enable seconds precision (`HH:MM:SS`) */
|
||||
withSeconds?: boolean;
|
||||
/**
|
||||
* Force a 24-hour clock: 00–23 header, a dual-ring hour face (1–12 outer,
|
||||
* 13–00 inner), no AM/PM toggle, and a deterministic `HH:MM` text input —
|
||||
* the native time input's displayed format follows the OS locale, which may
|
||||
* be 12-hour.
|
||||
*/
|
||||
format24?: boolean;
|
||||
/** Minimum selectable time — `HH:MM` */
|
||||
min?: string;
|
||||
/** Maximum selectable time — `HH:MM` */
|
||||
|
||||
@@ -26,6 +26,7 @@ const props = withDefaults(defineProps<TimeProps>(), {
|
||||
readonly: false,
|
||||
clearable: false,
|
||||
withSeconds: false,
|
||||
format24: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<TimeEmits>();
|
||||
@@ -80,6 +81,9 @@ const SVG_CX = 110;
|
||||
const SVG_CY = 110;
|
||||
const NUM_RADIUS = 82;
|
||||
const HAND_RADIUS = 68;
|
||||
// 24-hour face: 13–00 sit on a second ring inside the 1–12 ring.
|
||||
const NUM_RADIUS_INNER = 50;
|
||||
const HAND_RADIUS_INNER = 38;
|
||||
|
||||
function calcPos(index: number, total: number, radius: number) {
|
||||
const angle = (index / total) * 2 * Math.PI - Math.PI / 2;
|
||||
@@ -94,6 +98,7 @@ interface ClockNum {
|
||||
label: I18nText;
|
||||
x: number;
|
||||
y: number;
|
||||
inner?: boolean;
|
||||
}
|
||||
|
||||
// Screen-reader label for a clock-face number ("7 hour", "05 minute"). One
|
||||
@@ -109,12 +114,21 @@ const clockNumberKey = computed<I18nKey>(() =>
|
||||
|
||||
const clockNumbers = computed((): ClockNum[] => {
|
||||
if (clockMode.value === "hour") {
|
||||
return Array.from({ length: 12 }, (_, i) => {
|
||||
const outer = Array.from({ length: 12 }, (_, i): ClockNum => {
|
||||
const h = i + 1;
|
||||
const index = h % 12;
|
||||
const pos = calcPos(index, 12, NUM_RADIUS);
|
||||
return { value: h, label: raw(String(h)), ...pos };
|
||||
});
|
||||
if (!props.format24) return outer;
|
||||
// Inner ring: 13..23 then 00 (midnight) at the 12-o'clock position.
|
||||
const inner = Array.from({ length: 12 }, (_, i): ClockNum => {
|
||||
const h = i === 11 ? 0 : i + 13;
|
||||
const index = (i + 1) % 12;
|
||||
const pos = calcPos(index, 12, NUM_RADIUS_INNER);
|
||||
return { value: h, label: raw(String(h).padStart(2, "0")), ...pos, inner: true };
|
||||
});
|
||||
return [...outer, ...inner];
|
||||
}
|
||||
return Array.from({ length: 12 }, (_, i) => {
|
||||
const pos = calcPos(i, 12, NUM_RADIUS);
|
||||
@@ -123,18 +137,18 @@ const clockNumbers = computed((): ClockNum[] => {
|
||||
});
|
||||
|
||||
const handPos = computed(() => {
|
||||
let index: number;
|
||||
if (clockMode.value === "hour") {
|
||||
index = internalHour.value % 12;
|
||||
} else {
|
||||
const val = clockMode.value === "minute" ? internalMinute.value : internalSecond.value;
|
||||
index = Math.floor(val / 5) % 12;
|
||||
const index = internalHour.value % 12;
|
||||
const onInnerRing = props.format24 && (internalHour.value === 0 || internalHour.value >= 13);
|
||||
return calcPos(index, 12, onInnerRing ? HAND_RADIUS_INNER : HAND_RADIUS);
|
||||
}
|
||||
return calcPos(index, 12, HAND_RADIUS);
|
||||
const val = clockMode.value === "minute" ? internalMinute.value : internalSecond.value;
|
||||
return calcPos(Math.floor(val / 5) % 12, 12, HAND_RADIUS);
|
||||
});
|
||||
|
||||
function isClockNumSelected(num: ClockNum): boolean {
|
||||
if (clockMode.value === "hour") {
|
||||
if (props.format24) return internalHour.value === num.value;
|
||||
return (internalHour.value % 12 || 12) === num.value;
|
||||
}
|
||||
const val = clockMode.value === "minute" ? internalMinute.value : internalSecond.value;
|
||||
@@ -146,9 +160,12 @@ function onClockClick(num: ClockNum) {
|
||||
if (props.disabled || props.readonly) return;
|
||||
|
||||
if (clockMode.value === "hour") {
|
||||
const h12 = num.value;
|
||||
const h24 = isAM.value ? (h12 === 12 ? 0 : h12) : h12 === 12 ? 12 : h12 + 12;
|
||||
internalHour.value = h24;
|
||||
if (props.format24) {
|
||||
internalHour.value = num.value;
|
||||
} else {
|
||||
const h12 = num.value;
|
||||
internalHour.value = isAM.value ? (h12 === 12 ? 0 : h12) : h12 === 12 ? 12 : h12 + 12;
|
||||
}
|
||||
clockMode.value = "minute";
|
||||
emitCurrentValue();
|
||||
} else if (clockMode.value === "minute") {
|
||||
@@ -191,7 +208,11 @@ function emitCurrentValue() {
|
||||
}
|
||||
|
||||
// ── Header display ─────────────────────────────────────────────
|
||||
const displayHour = computed(() => String(internalHour.value % 12 || 12).padStart(2, "0"));
|
||||
const displayHour = computed(() =>
|
||||
props.format24
|
||||
? String(internalHour.value).padStart(2, "0")
|
||||
: String(internalHour.value % 12 || 12).padStart(2, "0"),
|
||||
);
|
||||
const displayMinute = computed(() => String(internalMinute.value).padStart(2, "0"));
|
||||
const displaySecond = computed(() => String(internalSecond.value).padStart(2, "0"));
|
||||
|
||||
@@ -202,6 +223,36 @@ function handleClear(e: Event) {
|
||||
emit("clear");
|
||||
}
|
||||
|
||||
// ── 24-hour text input handler ──────────────────────────────
|
||||
// Parses `HH:MM` (seconds optional with `withSeconds`) on commit; anything
|
||||
// unparseable reverts the field to the last good value instead of emitting.
|
||||
function handleText24Change(e: Event) {
|
||||
const el = e.target as HTMLInputElement;
|
||||
const val = el.value.trim();
|
||||
if (!val) {
|
||||
emit("update:modelValue", "");
|
||||
emit("change", "");
|
||||
return;
|
||||
}
|
||||
const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(val);
|
||||
const h = m ? Number(m[1]) : NaN;
|
||||
const min = m ? Number(m[2]) : NaN;
|
||||
const s = m ? Number(m[3] ?? 0) : NaN;
|
||||
if (!m || h > 23 || min > 59 || s > 59) {
|
||||
el.value = props.modelValue ?? "";
|
||||
return;
|
||||
}
|
||||
internalHour.value = h;
|
||||
internalMinute.value = min;
|
||||
internalSecond.value = s;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const str = props.withSeconds ? `${pad(h)}:${pad(min)}:${pad(s)}` : `${pad(h)}:${pad(min)}`;
|
||||
// Normalise the visible text too ("7:5" → "07:05").
|
||||
el.value = str;
|
||||
emit("update:modelValue", str);
|
||||
emit("change", str);
|
||||
}
|
||||
|
||||
// ── Native time input handler ───────────────────────────────
|
||||
function handleNativeTimeChange(e: Event) {
|
||||
const val = (e.target as HTMLInputElement).value;
|
||||
@@ -280,7 +331,29 @@ const fieldClasses = computed(() => [
|
||||
<OIcon name="schedule" size="sm" />
|
||||
</PopoverTrigger>
|
||||
|
||||
<!-- 24h mode: a plain text field, because the native time input renders
|
||||
in the OS locale's clock (often 12-hour AM/PM) with no override. -->
|
||||
<input
|
||||
v-if="format24"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
:value="modelValue ?? ''"
|
||||
:tabindex="inputTabindex"
|
||||
:placeholder="placeholder ?? raw('HH:MM')"
|
||||
:maxlength="withSeconds ? 8 : 5"
|
||||
:disabled="disabled || undefined"
|
||||
:readonly="readonly || undefined"
|
||||
:class="[
|
||||
'text-datepicker-text min-w-0 flex-1 bg-transparent ps-2 outline-none',
|
||||
clearable ? 'pe-2' : 'pe-3',
|
||||
]"
|
||||
@change="handleText24Change"
|
||||
@focus="emit('focus', $event)"
|
||||
@blur="emit('blur', $event)"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
type="time"
|
||||
:value="modelValue ?? ''"
|
||||
:tabindex="inputTabindex"
|
||||
@@ -380,8 +453,9 @@ const fieldClasses = computed(() => [
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- AM / PM horizontal pill -->
|
||||
<!-- AM / PM horizontal pill (12-hour mode only) -->
|
||||
<div
|
||||
v-if="!format24"
|
||||
class="rounded-default border-datepicker-border ms-3 flex shrink-0 overflow-hidden border"
|
||||
>
|
||||
<button
|
||||
@@ -456,7 +530,7 @@ const fieldClasses = computed(() => [
|
||||
<circle
|
||||
:cx="num.x"
|
||||
:cy="num.y"
|
||||
r="15"
|
||||
:r="num.inner ? 13 : 15"
|
||||
:class="
|
||||
isClockNumSelected(num)
|
||||
? 'fill-datepicker-clock-selected-bg'
|
||||
@@ -468,12 +542,14 @@ const fieldClasses = computed(() => [
|
||||
:y="num.y"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="central"
|
||||
font-size="13"
|
||||
:font-size="num.inner ? 11 : 13"
|
||||
class="pointer-events-none select-none"
|
||||
:class="
|
||||
isClockNumSelected(num)
|
||||
? 'fill-datepicker-clock-selected-text'
|
||||
: 'fill-datepicker-day-text'
|
||||
: num.inner
|
||||
? 'fill-datepicker-weekday-text'
|
||||
: 'fill-datepicker-day-text'
|
||||
"
|
||||
>
|
||||
{{ num.label }}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10184,7 +10184,7 @@
|
||||
"copyPattern": "Copy pattern",
|
||||
"copied": "Copied!",
|
||||
"pricingTiers": "Pricing Tiers",
|
||||
"pricingTiersDesc": "The first tier is the default. Additional tiers apply when usage conditions are met.",
|
||||
"pricingTiersDesc": "The first tier is the default. Additional tiers apply when their usage condition and UTC time window are met.",
|
||||
"tierName": "Tier Name",
|
||||
"applyTierWhen": "Apply this tier when",
|
||||
"quickSetup": "Quick setup:",
|
||||
@@ -10276,7 +10276,25 @@
|
||||
"saveShadowedWarning": "Saved, but this entry is shadowed by \"{winner}\" which has the same pattern and higher priority. It will never be used for cost calculation unless the other entry is deleted or disabled.",
|
||||
"modelPricingSaved": "Model pricing saved",
|
||||
"errSave": "Failed to save",
|
||||
"errLoadModel": "Failed to load model"
|
||||
"errLoadModel": "Failed to load model",
|
||||
"tierDefaultName": "Default",
|
||||
"timeWindows": "Active hours (UTC)",
|
||||
"timeWindowsDesc": "Restrict this tier to recurring UTC hours, for providers that bill peak and off-peak rates.",
|
||||
"timeWindowFrom": "From",
|
||||
"timeWindowTo": "To",
|
||||
"addTimeWindow": "Add time window",
|
||||
"removeTimeWindow": "Remove time window",
|
||||
"addUsageCondition": "Add usage condition",
|
||||
"removeUsageCondition": "Remove usage condition",
|
||||
"timeWindowInvalid": "Tier \"{name}\": enter a time as HH:MM (24-hour UTC).",
|
||||
"timeWindowSameBounds": "Tier \"{name}\": start and end must differ. Remove the window to make the tier always active.",
|
||||
"timeWindowWrapsHint": "Wraps past midnight UTC.",
|
||||
"tierNeedsRestriction": "Tier \"{name}\" has no usage condition and no time window, so it would never be reached. Add one, or delete the tier.",
|
||||
"timeBasedChip": "Time-based",
|
||||
"tierAlwaysActive": "Always active (default)",
|
||||
"testAtTimeLabel": "Test at UTC time",
|
||||
"testAtTimeHint": "Leave empty to use the current time. Peak / off-peak tiers resolve against this time.",
|
||||
"localTimeHint": "In your timezone: {range}"
|
||||
},
|
||||
"aiObservability": {
|
||||
"title": "AI Observability",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,12 @@ import {
|
||||
durationFormatter,
|
||||
maskText,
|
||||
convertToCamelCase,
|
||||
operatorSymbol,
|
||||
minuteOfDayToHhmm,
|
||||
formatUtcWindows,
|
||||
utcMinuteToTzHhmm,
|
||||
timezoneAbbr,
|
||||
formatUtcWindowsInTz,
|
||||
} from "./formatters";
|
||||
|
||||
// Buffer-based btoa/atob for jsdom
|
||||
@@ -494,3 +500,118 @@ describe("convertToCamelCase", () => {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// operatorSymbol / minuteOfDayToHhmm / formatUtcWindows (model pricing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("operatorSymbol", () => {
|
||||
it("maps the six comparison operators to display symbols", () => {
|
||||
expect(operatorSymbol("gt")).toBe(">");
|
||||
expect(operatorSymbol("gte")).toBe("≥");
|
||||
expect(operatorSymbol("lt")).toBe("<");
|
||||
expect(operatorSymbol("lte")).toBe("≤");
|
||||
expect(operatorSymbol("eq")).toBe("=");
|
||||
expect(operatorSymbol("neq")).toBe("≠");
|
||||
});
|
||||
|
||||
it("returns the input unchanged for unknown operators", () => {
|
||||
expect(operatorSymbol("between")).toBe("between");
|
||||
});
|
||||
});
|
||||
|
||||
describe("minuteOfDayToHhmm", () => {
|
||||
it("formats minutes past midnight as zero-padded HH:MM", () => {
|
||||
expect(minuteOfDayToHhmm(0)).toBe("00:00");
|
||||
expect(minuteOfDayToHhmm(60)).toBe("01:00");
|
||||
expect(minuteOfDayToHhmm(605)).toBe("10:05");
|
||||
expect(minuteOfDayToHhmm(1439)).toBe("23:59");
|
||||
});
|
||||
|
||||
it("renders exactly 1440 as 24:00 (an end-of-day bound, not a wrap)", () => {
|
||||
expect(minuteOfDayToHhmm(1440)).toBe("24:00");
|
||||
});
|
||||
|
||||
it("normalises out-of-range and negative minutes into the day", () => {
|
||||
expect(minuteOfDayToHhmm(1500)).toBe("01:00");
|
||||
expect(minuteOfDayToHhmm(-60)).toBe("23:00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatUtcWindows", () => {
|
||||
it("joins windows as comma-separated ranges with a UTC suffix", () => {
|
||||
expect(
|
||||
formatUtcWindows([
|
||||
{ start_minute: 60, end_minute: 240 },
|
||||
{ start_minute: 360, end_minute: 600 },
|
||||
]),
|
||||
).toBe("01:00–04:00, 06:00–10:00 UTC");
|
||||
});
|
||||
|
||||
it("keeps a midnight-wrapping window as start–end", () => {
|
||||
expect(formatUtcWindows([{ start_minute: 1320, end_minute: 120 }])).toBe("22:00–02:00 UTC");
|
||||
});
|
||||
|
||||
it("returns an empty string for no windows", () => {
|
||||
expect(formatUtcWindows([])).toBe("");
|
||||
expect(formatUtcWindows(undefined as any)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("utcMinuteToTzHhmm", () => {
|
||||
it("converts a UTC minute into the target timezone (fixed-offset zone)", () => {
|
||||
// Asia/Kolkata is UTC+5:30 year-round — no DST flakiness.
|
||||
expect(utcMinuteToTzHhmm(60, "Asia/Kolkata")).toBe("06:30");
|
||||
expect(utcMinuteToTzHhmm(0, "Asia/Kolkata")).toBe("05:30");
|
||||
});
|
||||
|
||||
it("wraps past midnight in the target timezone", () => {
|
||||
// 22:00 UTC = 03:30 next day in Kolkata.
|
||||
expect(utcMinuteToTzHhmm(1320, "Asia/Kolkata")).toBe("03:30");
|
||||
});
|
||||
|
||||
it("is identity for UTC", () => {
|
||||
expect(utcMinuteToTzHhmm(600, "UTC")).toBe("10:00");
|
||||
});
|
||||
|
||||
it("returns empty string for an unknown timezone", () => {
|
||||
expect(utcMinuteToTzHhmm(60, "Not/AZone")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("timezoneAbbr", () => {
|
||||
it("returns a short display name for a valid zone", () => {
|
||||
const abbr = timezoneAbbr("Asia/Kolkata");
|
||||
expect(abbr.length).toBeGreaterThan(0);
|
||||
expect(abbr).not.toBe("Asia/Kolkata");
|
||||
});
|
||||
|
||||
it("falls back to the input for an unknown zone", () => {
|
||||
expect(timezoneAbbr("Not/AZone")).toBe("Not/AZone");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatUtcWindowsInTz", () => {
|
||||
it("converts each window and appends the zone abbreviation", () => {
|
||||
const out = formatUtcWindowsInTz(
|
||||
[
|
||||
{ start_minute: 60, end_minute: 240 },
|
||||
{ start_minute: 360, end_minute: 600 },
|
||||
],
|
||||
"Asia/Kolkata",
|
||||
);
|
||||
expect(out).toContain("06:30–09:30");
|
||||
expect(out).toContain("11:30–15:30");
|
||||
expect(out).toContain(timezoneAbbr("Asia/Kolkata"));
|
||||
});
|
||||
|
||||
it("returns empty when the zone is UTC-equivalent — nothing to convert", () => {
|
||||
expect(formatUtcWindowsInTz([{ start_minute: 60, end_minute: 240 }], "UTC")).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty for no windows, missing zone, or an unknown zone", () => {
|
||||
expect(formatUtcWindowsInTz([], "Asia/Kolkata")).toBe("");
|
||||
expect(formatUtcWindowsInTz([{ start_minute: 60, end_minute: 240 }], "")).toBe("");
|
||||
expect(formatUtcWindowsInTz([{ start_minute: 60, end_minute: 240 }], "Not/AZone")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -252,6 +252,129 @@ export const convertToCamelCase = (str: string) => {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
|
||||
};
|
||||
|
||||
/** Display symbol for a model-pricing tier-condition operator. */
|
||||
export function operatorSymbol(op: string): string {
|
||||
const map: Record<string, string> = {
|
||||
gt: ">",
|
||||
gte: "≥",
|
||||
lt: "<",
|
||||
lte: "≤",
|
||||
eq: "=",
|
||||
neq: "≠",
|
||||
};
|
||||
return map[op] || op;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minutes past UTC midnight → `HH:MM`. 1440 renders as `24:00` so a range
|
||||
* ending at midnight labels its end rather than wrapping back to `00:00`.
|
||||
*/
|
||||
export function minuteOfDayToHhmm(minute: number): string {
|
||||
if (minute === 1440) return "24:00";
|
||||
const m = ((Math.round(minute) % 1440) + 1440) % 1440;
|
||||
return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Recurring UTC windows → `"01:00–04:00, 06:00–10:00 UTC"`. */
|
||||
export function formatUtcWindows(
|
||||
windows: Array<{ start_minute: number; end_minute: number }>,
|
||||
): string {
|
||||
if (!windows?.length) return "";
|
||||
const ranges = windows
|
||||
.map((w) => `${minuteOfDayToHhmm(w.start_minute)}–${minuteOfDayToHhmm(w.end_minute)}`)
|
||||
.join(", ");
|
||||
return `${ranges} UTC`;
|
||||
}
|
||||
|
||||
// Constructing an Intl.DateTimeFormat is the expensive part and these run on every
|
||||
// render. `null` caches a timezone the runtime rejects, so it is not retried.
|
||||
const hhmmFormatters = new Map<string, Intl.DateTimeFormat | null>();
|
||||
const abbrFormatters = new Map<string, Intl.DateTimeFormat | null>();
|
||||
|
||||
function cachedFormatter(
|
||||
cache: Map<string, Intl.DateTimeFormat | null>,
|
||||
timeZone: string,
|
||||
build: () => Intl.DateTimeFormat,
|
||||
): Intl.DateTimeFormat | null {
|
||||
const cached = cache.get(timeZone);
|
||||
if (cached !== undefined) return cached;
|
||||
let formatter: Intl.DateTimeFormat | null;
|
||||
try {
|
||||
formatter = build();
|
||||
} catch {
|
||||
formatter = null;
|
||||
}
|
||||
cache.set(timeZone, formatter);
|
||||
return formatter;
|
||||
}
|
||||
|
||||
/**
|
||||
* A UTC minute-of-day rendered as `HH:MM` in the given IANA timezone (today's
|
||||
* offset — good enough for a conversion hint; DST shifts it by design).
|
||||
* Returns "" for an unknown timezone.
|
||||
*/
|
||||
export function utcMinuteToTzHhmm(minute: number, timeZone: string): string {
|
||||
const formatter = cachedFormatter(
|
||||
hhmmFormatters,
|
||||
timeZone,
|
||||
() =>
|
||||
new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}),
|
||||
);
|
||||
if (!formatter) return "";
|
||||
const m = ((Math.round(minute) % 1440) + 1440) % 1440;
|
||||
const now = new Date();
|
||||
const d = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), Math.floor(m / 60), m % 60),
|
||||
);
|
||||
return formatter.format(d);
|
||||
}
|
||||
|
||||
/** Short display name for an IANA timezone, e.g. "GMT+5:30" or "PST". */
|
||||
export function timezoneAbbr(timeZone: string): string {
|
||||
const formatter = cachedFormatter(
|
||||
abbrFormatters,
|
||||
timeZone,
|
||||
() =>
|
||||
new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
timeZoneName: "short",
|
||||
}),
|
||||
);
|
||||
if (!formatter) return timeZone;
|
||||
const parts = formatter.formatToParts(new Date());
|
||||
return parts.find((p) => p.type === "timeZoneName")?.value ?? timeZone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recurring UTC windows converted into the given timezone —
|
||||
* `"06:30–09:30, 11:30–15:30 GMT+5:30"`. Returns "" when there is nothing to
|
||||
* show: no windows, an unknown timezone, or a timezone equivalent to UTC
|
||||
* (where the conversion would just repeat the UTC line).
|
||||
*/
|
||||
export function formatUtcWindowsInTz(
|
||||
windows: Array<{ start_minute: number; end_minute: number }>,
|
||||
timeZone: string,
|
||||
): string {
|
||||
if (!windows?.length || !timeZone) return "";
|
||||
const ranges = windows.map((w) => {
|
||||
const start = utcMinuteToTzHhmm(w.start_minute, timeZone);
|
||||
const end = utcMinuteToTzHhmm(w.end_minute, timeZone);
|
||||
if (!start || !end) return "";
|
||||
return `${start}–${end}`;
|
||||
});
|
||||
if (ranges.some((r) => !r)) return "";
|
||||
const utcRanges = windows.map(
|
||||
(w) => `${minuteOfDayToHhmm(w.start_minute % 1440)}–${minuteOfDayToHhmm(w.end_minute % 1440)}`,
|
||||
);
|
||||
if (ranges.join(", ") === utcRanges.join(", ")) return "";
|
||||
return `${ranges.join(", ")} ${timezoneAbbr(timeZone)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-exported, not reimplemented: `@/utils/zincutils` barrels this module, so
|
||||
* this is how the many `import { convertUnixToDateFormat } from "@/utils/zincutils"`
|
||||
|
||||
Reference in New Issue
Block a user