diff --git a/.github/workflows/update-translations.yml b/.github/workflows/update-translations.yml index 03a4a46d6c..da9f74dc65 100644 --- a/.github/workflows/update-translations.yml +++ b/.github/workflows/update-translations.yml @@ -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''' diff --git a/scripts/translations/.translation_state.json b/scripts/translations/.translation_state.json index 2f2c007188..b2761448b0 100644 --- a/scripts/translations/.translation_state.json +++ b/scripts/translations/.translation_state.json @@ -1323,6 +1323,7 @@ "readonly": "72bb90897ab1eadc", "saved": "022564c54312e2d6", "search": "6a279c5b3bf2a5ea", + "sectionTab": "72eb63f032e47e34", "selectDestinationType": "f2706be40f66125c", "selected": "d7cbbb688b2e506c", "separator": "be237eda7fff4fd3", @@ -1354,6 +1355,112 @@ "url": "e7a241debad56609", "urlHelpText": "099d4f8edfcd5fd2" }, + "alert_library": { + "alertCount": "42c0f3db6ef90f50", + "categories": "292c06f0045a45d0", + "clearFilters": "7179ea0035fca783", + "contribute": "73c910aa328725a7", + "drawer": { + "customize": "cbd25697f502caf2", + "customizeFailed": "3853332997c114a5", + "customizeHint": "520afc305b886bc5", + "dataAvailable": "60d0189cfc855989", + "detects": "ccf5fa44743c685f", + "install": "569ca49f4aaf7846", + "lastData": "51d523bffcc5d222", + "loadFailed": "37bfa7fd67233d41", + "loading": "2b53b9539b7967ae", + "matchCount": "8148171b1f427cbf", + "metricCondition": "f5dd63fb8516ca86", + "needsDataCallout": "ce20978da8a7bbcd", + "neverIngested": "e2e18a767a2b6db5", + "neverIngestedCallout": "ecc380732e7c5f17", + "noDescription": "d70ccbaeed378a1a", + "previewBlocked": "c9951285b2da039a", + "query": "b80a37564fbb3c07", + "staleCallout": "94261c7bc838bedf", + "stream": "1eec3071dd26e224", + "streamNotFound": "b46fc36172de7113", + "streamType": "74e61e305b5bd502", + "unknownTime": "ed8aa125a36b4882", + "wouldNotTrigger": "cc40a3009d66d7ea", + "wouldTrigger": "a5c52923ecd0f1e2" + }, + "emptyLibraryDescription": "b211ca78f7e7bbfb", + "emptyLibraryTitle": "770a0a4fc4064de4", + "error": { + "http": "70af237dd4ace2a2", + "malformed": "905a8a1240203c0c", + "network": "e5da46b898314cd8", + "unparseable": "504a3220b319fac0", + "unsupported_version": "7ea61f7a17f70f44" + }, + "facetAria": "5205fd2ec706ced8", + "header": "6bbc0c5ddade1739", + "install": { + "allInstalled": "12a408574848f442", + "back": "76900f1bfd16c8d4", + "cancel": "19766ed6ccb2f4a3", + "clear": "83b12c2216efb4fd", + "destinationIntro": "a0244cd125884be2", + "destinationLabel": "293d404a500f5f9a", + "destinationPlaceholder": "ee63978fc3b6d11a", + "destinationsLoadFailed": "4eeeed3a003c5cf3", + "destinationsNoneBody": "a23c34ef4536e30d", + "done": "11a6767d5674c7e4", + "folderIntro": "24734b49092f06de", + "frequency": "24947227759d0aca", + "minutes": "1f6fa6f69d185e60", + "next": "1ff57a29d7c9d11b", + "openDestinations": "c56a8a631d92885b", + "retry": "d8b8392e2c542950", + "retryFailed": "32780945b5f6a491", + "run": "9e46e74f7343acaf", + "selectAll": "1fc9a387654d410f", + "selectedCount": "30ae395b1b4c7b5c", + "silence": "6ce6a4db3720f473", + "someFailed": "ee5fdeb3e3c9d108", + "stepAlerts": "f62e9267d196c761", + "stepDestination": "293d404a500f5f9a", + "stepFolder": "74ccd43303847f26", + "stepInstall": "569ca49f4aaf7846", + "stepTune": "22c58d84f08e3a4e", + "subtitle": "e6e7fe927fdd5c2c", + "summaryTuningNone": "cda6ba311e7559b0", + "summaryTuningOverride": "4618a77d0da6425f", + "title": "51c2237e36acd054", + "tuneHint": "a1d0aa04f74fc521", + "tuneToggle": "4ab88542cfe87ce9", + "unknownError": "65d61bbf27a965c6", + "unreadableConditions": "d1cc7ff4f20c16eb" + }, + "loadFailedTitle": "e3b677d1fe8827f2", + "noCategoryMatch": "c547cfd24e097d9e", + "noCollectorDescription": "6ea4cc692bb2ec9b", + "noCollectorTitle": "f2c2ba2f9dafa112", + "noMatchesDescription": "e09a161e41ec40f8", + "noMatchesTitle": "0106064dcab637bc", + "notIngested": "ba8613ab3f202cb7", + "notIngestedHint": "991f8a9ff071a1a4", + "openDetails": "7944e5ee09b9de0d", + "retry": "942087cc2d41e013", + "searchCategories": "d03ec37c5f391830", + "searchCategoriesAria": "c706b0716899e4e9", + "searchLabel": "ba077f1ec4bc707f", + "searchPlaceholder": "4d4a4ce75c9021f4", + "sectionTab": "dc20b3d5d2cddf82", + "selectedCount": "529aacfdfd2b17bf", + "setupGuide": "8f74f3d0f19fb883", + "severity": "5e9f98120dbe5682", + "severityAll": "a52ace420f2175d0", + "severityCritical": "427dd2969bd140be", + "severityInfo": "170322a32f3c35b2", + "severityWarning": "e981ddae45d8f4ca", + "statAll": "a52ace420f2175d0", + "statNeedsData": "78682547ea359c4f", + "statReady": "bda18b9e55aad1b4", + "subtitle": "0f81e02279537f02" + }, "alert_list": { "alert_history": "0e76960093379060" }, @@ -1493,7 +1600,7 @@ "filterAll": "a52ace420f2175d0", "filterCustom": "494ca78f7374e46f", "filterPrebuilt": "e230325386e39e00", - "header": "56b564b75c7fdb48", + "header": "cc866009e75ae530", "import": { "bodyInvalidJson": "480f9431f4d467d7", "bodyRequired": "d4ca6c4a49ca3efc", @@ -1721,6 +1828,7 @@ "alerting": "1de753e2276627f1", "alertsBatchedTogether": "811eae5840321f22", "all": "a52ace420f2175d0", + "allAlerts": "050645715214988d", "anomaly": { "addFilter": "e520df698000f85f", "addNewDestination": "bbac7cf5de5ee1b1", @@ -2552,10 +2660,22 @@ "windowValue": "773bf1ccd3a8c14d" }, "sources": { + "dbm": { + "label": "4c75600fe5d78bc8", + "toast": "a37e50cf08c41e97" + }, + "dbmlocks": { + "label": "4c75600fe5d78bc8", + "toast": "909ca4be9da28501" + }, "default": { "label": "0d45260035d06818", "toast": "59219e19e8aa4b19" }, + "library": { + "label": "cbd25697f502caf2", + "toast": "06db7c3b9ef77e7a" + }, "logs": { "label": "0d45260035d06818", "toast": "d5f32be7466780c7" @@ -2574,6 +2694,12 @@ "actionsDropped": "6ccca2dfa94ba06f", "broadMatch": "be25c7f95c7368b8", "convertedToSql": "b9f0a519a7147ba9", + "dbmFingerprintVersion": "0ded13dbfdbedccb", + "dbmLockSampling": "8fc649067f802aa2", + "dbmNoFingerprint": "74cef8bf55524d67", + "dbmNoInstance": "bbf6f14f9317d42a", + "dbmRollupLag": "d16184639f26a155", + "dbmServerVantage": "b8527d8d6ab0ed3b", "emptyQuery": "0cbe88fd61c513c9", "histogramNotSupported": "31c694f252fe4d6c", "joinSingleStream": "80d612948932c0a9", @@ -2599,9 +2725,14 @@ "previewEvaluation": { "dataPoint": "5fae3b4f7c463dee", "dataPoints": "9ec100d7b1c26b2f", + "levelCritical": "427dd2969bd140be", + "levelWarning": "e981ddae45d8f4ca", "matchingGroup": "d714f7576ec710f1", "matchingGroups": "dfcab8f954f923ec", + "matchingSeries": "9cda38ea3a2bd78c", + "matchingSeriesPlural": "9cda38ea3a2bd78c", "realTimeReason": "4fb1b12e41303565", + "reasonLevel": "b565a8532392111d", "reasonMatch": "3c81801fa820bbd1", "reasonNoMatch": "8a3e743f78a14670", "result": "f6a214f7a5fcda0c", @@ -3722,6 +3853,14 @@ "global": "a258b30f88c30650", "stream": "1eec3071dd26e224" }, + "dataConfidence": { + "belowTopN": "44d7a0b396437e91", + "estimated": "d361760d6f1edc0e", + "gap": "54d5dbf0124e59af", + "live": "247610f4dedd4ab7", + "topNSubset": "5d73bbc22e132368", + "truncated": "227b1c2e53cfd4a3" + }, "deletionStatus": { "completed": "22a970d2e5b1cc23", "failed": "031a8f0f659df890", @@ -3966,6 +4105,24 @@ "drawer": { "closeDrawer": "7a0d06d516580b7f" }, + "emojiPicker": { + "chooseIcon": "b9edcf16a2bceff0", + "groups": { + "ai": "11fb682be0a0233d", + "creatures": "9915bdfb4d7cc4fc", + "data": "cec3a9b89b2e3913", + "infra": "ce0cff719a947476", + "objects": "de5236ee1561d9d4", + "results": "219c4a6c86a716e9", + "services": "604dce445e326d95", + "status": "920e413c7d411b61", + "teams": "1e1a1c078af2887a", + "world": "78ae647dc5544d22" + }, + "noResults": "cb6a8a3098a93fd6", + "removeIcon": "93d6de7104f0b39f", + "searchPlaceholder": "d47dbbe19a63e486" + }, "file": { "clearAll": "29a390f9237e3c84", "removeFile": "fffa2e32da712d49" @@ -4733,6 +4890,8 @@ "fixed": "1246fc93bca00b90", "fixedValue": "b192fd7a75193b9c", "folder": "74ccd43303847f26", + "folderIcon": "a35abcd6dac9a2e5", + "folderIconAria": "4c879f8b05b36809", "folderRequired": "e3d91814a2d89326", "folders": "c4d6bb200f4c058a", "fullscreen": "d920ce826795038d", @@ -5505,6 +5664,1056 @@ "zeroPlaceholder": "5feceb66ffc86f38", "zoomLabel": "509c517ede79fef0" }, + "dbm": { + "activity": { + "columns": { + "application": "e7ad522ea327e5ba", + "blockedBy": "36931a940259dfe1", + "lastQuery": "5d831de673f700e4", + "query": "b80a37564fbb3c07", + "running": "2958ac7c7c1b3823", + "session": "6959b4159575d8dd", + "state": "a3b50c476732c740", + "user": "b512d97e7cbf97c2", + "waitEvent": "7ab15c8cd5a34cdf" + }, + "counts": { + "capped": "3c4e13af1b687e22", + "complete": "d8505577209bccdc", + "filtered": "5ec5d98d2f6972f6" + }, + "disclosure": { + "filtered": "490b94d93e04f957", + "more": "e8a4ea2ac2fd5b44", + "sampled": "603d3c1994efb39d", + "sampledUnknown": "ec3272c20a579aff", + "short": "ca6d44f5eda72fcd", + "shortUnknown": "e8ba42767437e45f" + }, + "healthy": { + "checklistTitle": "8cc61151462f0cc2", + "checks": { + "meaning": { + "detail": "ef8ebf49ee16622d", + "title": "54f2d0064528cb64" + }, + "pipeline": { + "detail": "61fdd79e9a4dc831", + "detailUnknown": "2a61cee8ed04ee5a", + "title": "bce9a287b3b34e6c" + }, + "sampling": { + "detail": "99027238b5267194", + "detailUnknown": "8a13a03a5cd21917", + "title": "61dba72e0014358d" + } + }, + "collectionHealthy": "9168f029da8873f5", + "description": "81687d83b5eb0c69", + "descriptionPast": "5596842be8361184", + "title": "52c3533c98914a89" + }, + "notCollecting": { + "checklistTitle": "cf26b983696fd931", + "checks": { + "enabled": { + "no": "df63a906ffbd7b2a", + "noDetail": "d4c1b2fa1954e764", + "ok": "8a29bf0d8d275108", + "okDetail": "a02ed7fb0d2d3ba3" + }, + "queries": { + "no": "c10332291ac3e0f6", + "noDetail": "2fa43a9d37b44283", + "ok": "c5cc18c005b6fab6", + "okDetail": "bde2d8554cd2c8b5", + "okDetailNoFleet": "2348118686e3a894" + }, + "sampling": { + "no": "3ee1330c5a2620e1", + "noDetail": "9ff660af94f1f7d5" + } + }, + "description": "e2f61d8b77c5a7ed", + "title": "149e9cc8645410e5" + }, + "searchPlaceholder": "ef5fbaefd6dba726", + "state": { + "unknown": "0e906fd86d00ebf4" + }, + "subtitle": "baced9b1b8cf6357", + "subtitlePast": "c1d9c44562703ce3", + "summary": { + "idleInTransaction": "ff2e0c7318ecdc5b", + "onCpu": "41d57724c8eb5b2c", + "sessions": "6fa3cbf451b2a1d5", + "waiting": "6e293a8c009e0011" + }, + "title": "38da1505ca837328", + "transactionOpen": "56ca82c3416e10d6", + "wait": { + "onCpu": "08e24f59d004b84b", + "plain": "4e8b10f5aec0b797", + "qualified": "76fec6793b47e1e3", + "remainder": "d3275b24ee951581", + "remainderNoShare": "5873a8c58a01d102" + } + }, + "ai": { + "blockingFix": "3182b06ef4000954", + "blockingFixHint": "17e03875b674dc70", + "deadlockFix": "63770262bc5aeba3", + "deadlockFixHint": "742ea77410b89fbc", + "rowSuggestFix": "cfa33dcfad9451c3", + "suggestFix": "cfa33dcfad9451c3", + "suggestFixHint": "3ce2f69c807f1ac1" + }, + "blocked": { + "columns": { + "application": "e7ad522ea327e5ba", + "blocking": "f778a33133e5c30b", + "heldUpBy": "0d91a125f1d4c944", + "session": "6959b4159575d8dd", + "sessionQuery": "4b26663684cda2d8", + "stuckQuery": "962ba2da7333d116", + "waiting": "6e293a8c009e0011", + "waitingFor": "68a86b7bebd2e858" + }, + "coverage": { + "checked": "c50d68e9e6803d91", + "checkedUnknown": "9538b235de44df1b", + "summary": "e583ffe619b54aab", + "summaryCapped": "0f650c7f19870f34", + "summaryManyRoots": "27de9b3be346a2ab", + "summaryOneRoot": "d19e135be045694d" + }, + "deep": "7a5735c0262c785b", + "degraded": "cbed33ad77ea1104", + "footer": { + "allLeadBack": "3731c6aa42a1b385", + "allLeadBackDetail": "8afa57994fbd6911", + "releaseAll": "47188656630a96ef", + "releaseAllDetail": "d155fbacaf542a1b", + "switchToBlocking": "59e3d008f17fccec", + "timeLost": "201a413741cb8e75" + }, + "healthy": { + "checklistTitle": "8cc61151462f0cc2", + "checks": { + "meaning": { + "detail": "35c5a21138209783", + "title": "54f2d0064528cb64" + }, + "sampling": { + "detail": "99027238b5267194", + "detailUnknown": "8a13a03a5cd21917", + "title": "5adbdcf2202db980" + }, + "sessions": { + "detail": "ac3da94bc20463a7", + "title": "90330b5b292e4e91" + } + }, + "collectionHealthy": "9168f029da8873f5", + "description": "32c32d13fdc4a033", + "descriptionPast": "448694694219f0fa", + "title": "2f66294abed43c76" + }, + "holdsTheLock": "9e135b7e930459cd", + "idleInTransaction": "80790b2dabe82b6a", + "inChain": "9185ac12ea245ca5", + "lastStatement": "b27c3cbfcbacffbf", + "longestWait": "c7e8963260c371b4", + "notCollecting": { + "checklistTitle": "cf26b983696fd931", + "checks": { + "enabled": { + "no": "df63a906ffbd7b2a", + "noDetail": "d4c1b2fa1954e764", + "ok": "8a29bf0d8d275108", + "okDetail": "a02ed7fb0d2d3ba3" + }, + "queries": { + "no": "c10332291ac3e0f6", + "noDetail": "2fa43a9d37b44283", + "ok": "c5cc18c005b6fab6", + "okDetail": "bde2d8554cd2c8b5", + "okDetailNoFleet": "2348118686e3a894" + }, + "sampling": { + "no": "41d43b403b83d0b8", + "noDetail": "2b5f3af8c6febfa0", + "ok": "10134a89e7d33305", + "okDetail": "bffa02c3cf566287" + }, + "settings": { + "detail": "b795fc6336b2fc26", + "title": "3734e0b8249b2f39" + } + }, + "description": "057b52649c6f57ca", + "title": "891bba31dafc334e" + }, + "notWaiting": "8ceb5247217bb2fe", + "perspective": { + "blocking": "692c411ee47b3481", + "blockingHint": "33c422971e6cc302", + "waiting": "e35c2e0fe39fc376", + "waitingHint": "9e6c00f40d167720" + }, + "pills": { + "root": "32446a10e7e96768", + "waiting": "77aaf105c28c4c82", + "waitingBlocking": "ef05150edc8cd88c" + }, + "rootCount": "ce593fc61daaa6cb", + "rowActions": { + "alertMe": "6b76bf57e7d08007", + "copy": "8e5a75e93167b5a0", + "showChain": "5ea85713bb5832cd" + }, + "searchPlaceholder": "9d15a817c957bdb7", + "sessionCount": "872855722557db38", + "subtitle": "9d56af5532ac67e4", + "subtitlePast": "72e80c743af6f901", + "summary": { + "chains": "79aaa1a603013cfd", + "longest": "0344209e8a455cc7", + "waiting": "25d8254cd271d6b8" + }, + "terminate": { + "copied": "8d525e5f158b9afe", + "hint": "80decc27d1bb1fa9", + "hintNoInstance": "3af0c760403de2d7" + }, + "title": "61d2afe2349913ce", + "waitEvent": { + "advisory": "398b32c9f4819823", + "buffer": "9520b883245dd5a8", + "extend": "3998337c8bb3dde8", + "lock": "10ab31347cb7b3a5", + "object": "47d9b8327844fdf3", + "row": "d7a8575e29711ba6", + "table": "48124663284d45d2", + "transaction": "d16ea670f3fa3534" + }, + "waitingCount": "fa5e74d564dd526e", + "waitingCountPast": "f922abb285bda31a", + "waitingOn": "33f64ff69cb80e8a" + }, + "breakdown": { + "loading": "7eb1adad6a1418ed", + "noSchema": "423ec07ec4376f30", + "noService": "0ba8105bbc7b85fd", + "nothingToAttribute": "210b4828b9c22778", + "queryCount": "b4c1491b12b6dcef", + "shortfall": "883e563de1437274" + }, + "common": { + "lastRefreshed": "41200a71998f3e68", + "loadFailed": "188f776fec6ce374", + "reload": "0e91610117029a62" + }, + "coverage": { + "lineCountedTo": "d8f1b4147c054a4f", + "lineCountedToBehind": "fda756c37a0ee644", + "lineExact": "22fc00c3268448a7", + "lineExactEstimated": "3abdcfd5183d925a", + "lineGap": "71db3c726692dd2f", + "lineHealthy": "ad6f89d68992ae3f", + "lineHealthyAll": "9724ad1e593159ca", + "lineHealthyAllEstimated": "4ee79e730680ad42", + "lineQuery": "40f2ccb80331a448", + "lineRemainder": "a7837a746f591c45", + "lineStale": "b2cda703219a3a2d", + "lineSubset": "2a2dc4a6ecfc51b9", + "lineSubsetStale": "724acfa75ce56553", + "lineSubsetUnnamed": "d424eb157dd48fb2", + "lineSubsetUnnamedStale": "9067cb83dc4f6778", + "lineTruncated": "d371a0ff86bb307c", + "lineTruncatedStale": "357b4547a1fe8be0", + "lineUncodedErrors": "b6ac4733db8ca570", + "unknownNote": "55a5134d23a0dba2" + }, + "databases": { + "alertMe": "4c75600fe5d78bc8", + "attention": { + "connections": "1e5fac867454a4fe", + "noLimit": "cbb564dd838f0437", + "unknown": "059dcc0936227b8d" + }, + "columnHints": { + "attention": "f637acbed6494055", + "load": "d84e60b175a2d68e", + "p50": "b31ed3264efc0bd5", + "p95": "3504e3d0f6a5228b", + "p99": "bbc4787025054613", + "qps": "cbe3d3c993d70f3d" + }, + "columnSubLabels": { + "attention": "b3eacd33433b31b5", + "calls": "948fe603f61dc036", + "load": "948fe603f61dc036" + }, + "columns": { + "attention": "c1ebc7817870e5be", + "calls": "b73a5e2ca6458665", + "errorRate": "031a8f0f659df890", + "instance": "fa7fe67124e94375", + "load": "1235846a41c69aa3", + "p50": "9d00457332cec0b0", + "p95": "24c98f9916f7d053", + "p99": "dc589a96b56401f7", + "qps": "195c6f1482e3224c", + "services": "681bf81aba87bd68" + }, + "databaseCount": "c405ca55f4cdfccc", + "failedChip": "cd8f5051cba3686a", + "moreServices": "ecadd87ff0e5d0aa", + "openQueries": "1cc7003257a55ac4", + "searchPlaceholder": "5b5e4ae9bc9f4827", + "subtitle": "ca2269cf34caf992", + "summary": { + "calls": "b73a5e2ca6458665", + "databases": "61d2afe2349913ce", + "failed": "031a8f0f659df890", + "time": "1235846a41c69aa3" + }, + "title": "61d2afe2349913ce", + "unnamedInstance": "11ca9f99e438664d", + "unnamedInstanceHint": "d793c53a423bbdd2" + }, + "deadlocks": { + "chips": { + "cadence": "a2189667d81080f0", + "noQueries": "cede8c832375a5cf", + "oppositeOrder": "0ac624b15136c63b", + "partial": "930887d6546ac57f" + }, + "columns": { + "applications": "98e33b0f3104fcf1", + "cancelled": "d353a99eb4556847", + "count": "cdc2395de66afa4f", + "finished": "7804f7a79a9eee32", + "lastSeen": "49cb79fa27c3c7d9", + "objects": "3711d7b9f930756e", + "pair": "5edb5b87efad467a", + "when": "cf9c7aa24a26aac4" + }, + "coverage": { + "capped": "f0526660ab60a8ff", + "complete": "5b43084840cbe358", + "mysqlPartial": "e4bc684b193c8abc", + "readUpTo": "8a5b9e36f62605a3" + }, + "deadlockCount": "48c75baf5315db30", + "detail": { + "bothSidesLose": "742ef5f82bf617b4", + "copy": "4f61ddf87f04daa4", + "copyForSlack": "e21f935f11d7e966", + "copyForSlackHint": "71294c89cabdc3f2", + "copyHint": "a07ec10b0a0dd73c", + "copySqlTooltip": "636199e234e8d7c2", + "deadlock": "4f6c0efa1a0f9935", + "eachWaits": "33f89b01a3f49d2d", + "earlier": "e10ae99074011888", + "fixBody": "88c1adb586d85b45", + "fixGeneric": "7828652ddd07e5b8", + "fixTitle": "f010256efc746736", + "later": "73b6e48a1b552d11", + "lostLabel": "40283f9da276ba3c", + "mostRecent": "e021bac2df584ff4", + "noQueryCaptured": "a34e03703b260166", + "nthOf": "790f5dfff0cec7ee", + "occurrencesHint": "61b0dd7aa9a7e6d5", + "occurrencesTitle": "c811a5cf10abffe0", + "oneSideLoses": "67b7a0b473bd9a43", + "partialBody": "5ce0c956ef3c122d", + "partialTitle": "9489413ab7de814b", + "pid": "65f494aa84f1b7a0", + "plainBody": "334fdaa9c9981916", + "plainTitle": "f2a44ec104c6c5b4", + "rawLock": "1b15c5459368f3a2", + "seeInTopQueries": "cc8757f686c7142b", + "showAbove": "2da59bd3f2496c5f", + "survivor": "a4a0b1bdb8c523dd", + "thread": "03fb9d31d0826f1e", + "transaction": "00b12e15f66da866", + "victim": "3612c0bb0e395267", + "waitingFor": "32f00133a310f239", + "waitingForRow": "6d3ba221e632427a", + "whichService": "972d25ef37273a57" + }, + "grouping": { + "events": "b7f2d4d2a0a4ece4", + "eventsHint": "9305feb0a1b47b24", + "pairs": "1b9663de2a0ba3c8", + "pairsHint": "21302e4829aed22b" + }, + "healthy": { + "checklistTitle": "8cc61151462f0cc2", + "checks": { + "lastEver": { + "detail": "2181b189cac8e65e", + "detailNone": "cd24db1afa31470e", + "title": "fcb17f818664b608", + "titleNone": "ce011680f813185b" + }, + "reading": { + "detail": "c982fa7d30ab5ee9", + "detailNoFleet": "aa194712d45387a7", + "detailUnknown": "b69eab8b301f65e8", + "detailUnknownNoFleet": "62bbaf8403af1290", + "title": "a8457583d08609cb" + }, + "reporting": { + "detail": "a262168a666e6f4d", + "detailMysqlPartial": "ce33ce0b200fb6ad", + "detailUnknown": "e1036e3ea6190374", + "title": "de8cf28edb6c1194", + "titleUnknown": "328ab639176fdd2a" + }, + "traffic": { + "detail": "e40153d3590fbe42", + "title": "841059be9b6cd125", + "titleUnknown": "9d42933c15426294" + } + }, + "collectionHealthy": "9168f029da8873f5", + "description": "a7ad9ee0fb9cebf7", + "title": "d5efd15179bb6830", + "widen": "8a57c95d4fdfeafc" + }, + "notCollecting": { + "checklistTitle": "cf26b983696fd931", + "checks": { + "enabled": { + "no": "df63a906ffbd7b2a", + "noDetail": "d4c1b2fa1954e764", + "ok": "8a29bf0d8d275108", + "okDetail": "a02ed7fb0d2d3ba3" + }, + "log": { + "no": "2f454391cbbd3f9f", + "noDetail": "9330465d59c7f04c", + "ok": "800ffc1bc3f9e67d", + "okDetail": "8ca58d30c1c69eb5" + }, + "queries": { + "no": "c10332291ac3e0f6", + "noDetail": "2fa43a9d37b44283", + "ok": "c5cc18c005b6fab6", + "okDetail": "bde2d8554cd2c8b5", + "okDetailNoFleet": "2348118686e3a894" + }, + "settings": { + "detail": "627ec959fad78019", + "title": "3734e0b8249b2f39" + } + }, + "description": "9bbd6b57c4478509", + "title": "d5322a22041ca71c" + }, + "pairCount": "eb533bd6cea9a7cf", + "rowActions": { + "alert": "d1022c65fec4347f", + "copy": "1acfaec9ff47ffaa", + "topQueries": "c6ffd87f34386528" + }, + "searchPlaceholder": "9d15a817c957bdb7", + "storm": { + "biasWarning": "a69d2ab0a04812b4", + "body": "f74e7504e4b676dc", + "copyForSlack": "e21f935f11d7e966", + "title": "c904ede925e58ed8" + }, + "subtitle": "cb6dd0125d1cabd5", + "summary": { + "deadlocks": "cdc2395de66afa4f", + "pairs": "dd72f4dae2323619" + }, + "title": "61d2afe2349913ce" + }, + "delta": { + "comparison": "99d351b2e23b19f2", + "gone": "b5b1ac15d8490040", + "goneHint": "b85e06c88178d6db", + "new": "d86187d772d115eb", + "newHint": "54a56e8f4bcb70cf", + "noChange": "5d69f9dac5b02100", + "timesFewer": "66cdf11df5e527a7", + "timesMore": "40cbfcc49ed3b15e", + "wasValue": "1e85a89d54743897" + }, + "detail": { + "alertMe": "1355b68662d07dfb", + "ambiguousStream": "3c562653886e1647", + "backToActivity": "34d780645a414747", + "backToDeadlocks": "a6b541030fe3261a", + "backToQueries": "5ed06ee85e01f804", + "backToSamples": "8a6b8936570d755b", + "belowTopN": { + "body": "9ee77ac546b24b11", + "capped": "cb6d6e6e5233fd45", + "title": "26f286406dd13f97" + }, + "callersEmpty": { + "checklistTitle": "8baa614116cf6baa", + "checks": { + "server": { + "ok": "d51029aef8c54a67", + "okDetail": "2a5053462b1a1b1c" + } + }, + "description": "f00d3f8fd58bd9b1", + "setUp": "bc1fead76b999a20", + "title": "a65ccc30bef5a23e" + }, + "callingServices": { + "coverage": "2d38c787617b3bf0", + "coverageUnknown": "8cc4d6eb6d7a6f29", + "noneNamed": "a916e91b3ecd22a8", + "title": "a1c0159de7cc4445", + "tracedCalls": "e62ad8ebcd640f0e", + "unjoinable": "d9096b3194bfb7f4" + }, + "columns": { + "callShare": "2554f334e55ade45", + "caller": "9bc9b83c11ddcb41", + "calls": "b73a5e2ca6458665", + "duration": "4fc52a3c4c558b51", + "p50": "9d00457332cec0b0", + "status": "920e413c7d411b61", + "time": "33b93476cf597a33" + }, + "copyQuery": "8e5a75e93167b5a0", + "copySummary": "e21f935f11d7e966", + "copySummaryHint": "f94c758f08af3b36", + "endpointsConcentrated": "a7415e7be2c31101", + "endpointsHint": "2f152f6254bf4e2e", + "endpointsTitle": "6232b6396c871369", + "errorRate": "7d047f1cf1215d4f", + "errorsByCode": { + "exactHint": "11e93e9ac08ffd11", + "noCode": "d210e0d4b5afc519", + "sampleHint": "14ce64e949b654ce", + "title": "68e083296db6bae0" + }, + "firstSeen": "7ef8346a2b878fa9", + "firstSeenCaveat": "c72190d565eff3cc", + "firstSeenHint": "99328bb38f9b438f", + "invalidStream": "42bb740f4729b7d6", + "lastSeen": "9d2f737d748ddf26", + "lastSeenHint": "e7cbd5f9d66bd7fb", + "latencyTitle": "7f5fb7a6e419d850", + "liveSeries": "f756770545aec2b1", + "noEndpoints": "6163e03f1d603a5c", + "noSamples": "f27a16796442b4d1", + "noSeries": "aa1b2b031999ecf1", + "noStream": "140a05cb42c028f8", + "notFound": { + "action": "b57cf7d00c595412", + "description": "4ff8c37c91ea4813", + "descriptionNoOrg": "b72236059c9cc559", + "missingDescription": "226c07d5f4c0251a", + "missingTitle": "bfcee317bfd002da", + "scopeHint": "218a89ee3c736cea", + "title": "7c559415db730763" + }, + "overlap": { + "clientObserved": "0cc72e26cc95f9cc", + "serverCounted": "74fa921eaf413c66", + "serverExecution": "d1c16227348faea7", + "serverWait": "34d15b733a982bad" + }, + "pickStream": "cc28e43b883c1877", + "plans": { + "capturedDurations": "525b2816b6543505", + "driftCallout": "b09549423d3efb15", + "estVsAct": "2eb446f712662876", + "executedLabel": "2e9e80d9d5941bc4", + "executedTooltip": "d9ffdb6f3fc5f77a", + "firstSeen": "32fbd28bce07040c", + "lastSeen": "21fd79c7de52e1cf", + "noPlanForQuery": "fd56c375875223d0", + "noPlanForQueryHint": "9849100ecd72a60b", + "noPlans": "8d1fa7612e09480b", + "noPlansHint": "21262b6fd5d9ebe2", + "noTree": "33f3f2621ccb7050", + "nodeCost": "50e6ffdc8e5eef1f", + "planHash": "fa8ed0bdabdd6bcb", + "sourceLabel": "073e5c3630b65e2a", + "sourceTooltip": "e506543056c4cec4", + "stableCaveat": "c062b9daafbac576", + "title": "4cbdf91f40ad540d", + "viewPlans": "a72e2bd3e148c05e" + }, + "qps": "4442d8a826a1d482", + "sampleError": "031a8f0f659df890", + "sampleOk": "565339bc4d33d728", + "samplesHint": "d3063ed5f62e5ce6", + "samplesTitle": "7fa055d96b89998e", + "serverMetrics": { + "ambiguous": "3eb8f3a485f54e5f", + "ambiguousHint": "f3e2349cc41d54d9", + "calls": "93af2616998cbe78", + "clientSubtitle": "c7e39a517339c215", + "instance": "b5bc8412584b3b6e", + "instanceWide": "945b53947fd3e993", + "meanExecution": "2dc2642f39323e96", + "meanWait": "b3a1800f1dbe02e6", + "noMatch": "1c345325772df3f7", + "noMatchHint": "0f211d86ea737a9b", + "off": "cfd426d669020ae7", + "offHint": "56c30521fd3c3b5c", + "readFailed": "8e6a3dadf8e5e3af", + "readFailedHint": "601754164a493686", + "rows": "cd6fbedddc1e2f2a", + "setUp": "4da10f1fbb17cac2", + "sharedBlksHit": "f79e09b32a821d5b", + "sharedBlksRead": "ebb4dc265f55437f", + "subtitle": "265dc2471fb456fa", + "tempBlksWritten": "86a5de795a11c350", + "title": "e533788253352fef" + }, + "stats": { + "approx": "2a0b0e0e5305fb9a", + "calls": "b73a5e2ca6458665", + "callsDelta": "5f43b86afd15b45c", + "errors": "031a8f0f659df890", + "exact": "fa79d4746c21cd96", + "load": "1235846a41c69aa3", + "loadShare": "4ba30c9900b1a0fb", + "max": "e2e3b2eadf5a70d5", + "noErrors": "07e5463c8506731d", + "p50": "9d00457332cec0b0", + "p95": "24c98f9916f7d053" + }, + "summaryCopied": "03f165cefa2202a1", + "summaryCopyFailed": "a9fac2e4fb8e17eb", + "tabs": { + "callers": "eee8b3507a5f9ec0", + "callersHint": "bb1ff44ec7e38b42", + "callersLocked": "5018036d0fce4c0e", + "overview": "d4b1ea5708dd5329", + "overviewHint": "414f06a4dfa33169", + "plans": "4cbdf91f40ad540d", + "plansHint": "3e8630b1776d73a6" + }, + "title": "c74947a14f8d0d26", + "typicalMarker": "c9051b7f2c2f66ee", + "unattributed": "d2ac0736a7608a7c", + "viewTrace": "11778ca2a26a4ab8", + "viewTraces": "9a299ceee07b5508", + "volumeTitle": "380ba851d45f262b", + "whereItRuns": { + "columns": { + "avg": "74d0a4c88e8a8c87", + "location": "88a550e0f44eff78" + }, + "emptyFiltered": "768e75941b86cd2f", + "focusHint": "fd748bb7d5a6f527", + "focused": "4f9a966fb271c31a", + "hint": "e08792f99ddd486c", + "noInstance": "0fc5c3353d5485ce", + "showAll": "e018ede498f32724", + "title": "6cc00d310273f37c", + "trackedHint": "529c5844f1e7533b" + } + }, + "empty": { + "action": "d8b8392e2c542950", + "checkTrace": "3fdfe79f7a33cc5d", + "checklistTitle": "cf26b983696fd931", + "checks": { + "alsoWorthKnowing": { + "detail": "c4e73d5ce5567ed9", + "title": "97253d9805248227" + }, + "counted": { + "no": "a3608ec32a03e829", + "noDetail": "8e1bc0fc34c54d2f", + "ok": "e632340d97b9e26c", + "okDetail": "c3d064054f87e280" + }, + "dbSpans": { + "no": "255ccbc7d295e3f6", + "noDetail": "f4c633e333fe35e9", + "ok": "ddd3ab1fe34ed80b", + "okDetail": "4830e75112cce624" + }, + "enabled": { + "no": "df63a906ffbd7b2a", + "noDetail": "0851e3e1d6586bc8", + "ok": "8a29bf0d8d275108", + "okDetail": "a02ed7fb0d2d3ba3" + }, + "permission": { + "no": "0a3adc137d086768", + "noDetail": "202900a5a674c740", + "ok": "079c9a4e7a0e1fe7", + "okDetail": "9f33773792fe1f78" + }, + "traces": { + "no": "df04cf3e8c6d118a", + "noDetail": "fe69183a9cb933f9", + "ok": "553703705523aea2", + "okDetail": "7126c65b11be7a7e" + }, + "window": { + "no": "99a2af54729cf89b", + "noDetail": "c4d6b4186514ac22", + "ok": "097e1fe7b909a801", + "okDetail": "c84e860835b489e5" + } + }, + "filtered": { + "action": "7179ea0035fca783", + "description": "2f417fa889eb34fe", + "title": "d2928e528a389f57" + }, + "showMeHow": "815f34303d3fa4cb", + "subtitle": "4ee491e6d656a5d8", + "subtitleCounts": "cb2bcf85e05caa0d", + "title": "2dd2eb08778b8aaf", + "viewDocs": "392e16f77e9f5d6d" + }, + "filters": { + "allEngines": "3e443c4d346170b8", + "allEnvs": "f19ac5a6af557f79", + "allInstances": "76c10219010f0c2a", + "allNamespaces": "c41468c144a7b981", + "allServices": "5b980982fa251af0", + "clearScope": "29a390f9237e3c84", + "dimension": { + "env": "ba5285161ba6eed0", + "insight": "e43ba08557cbb4f1", + "instance": "3549b0028b75d981", + "namespace": "df0ad6e43880f09c", + "service": "9df6b026a8c6c26e", + "system": "ed9f6f25068608ef" + }, + "popoverTitle": "2ee6635f891bb689", + "removeScope": "7b7141ee715851b5", + "scope": "546ebb8eb993ea56" + }, + "freshness": { + "neverAggregated": "97037dc42d71e89c" + }, + "insights": { + "all-failing": { + "body": "de128c63d8d03478", + "rule": "beede1508c5b3ba2", + "title": "26821820527c2c9b" + }, + "alsoAffects": "f906e95032d39705", + "baseline": { + "label": "d30f96196fcafc74", + "previous": "b2d92539b715edf2", + "previousHint": "0dc1bf0ddb08d2f1", + "previousShort": "b70cbf3f9b014ab7", + "yesterday": "60511279bc9b7504", + "yesterdayHint": "2077ac94a79ff34e", + "yesterdayShort": "83a02d56055df4f8" + }, + "clearFilter": "ba59e2d4a5e39a42", + "findRow": "affe78aded9921af", + "hide": "ac20a57bfde0bbc4", + "more": "73d065d41bbad790", + "n-plus-one": { + "body": "1b341125ed2b1b88", + "rule": "fed037aaae8e9685", + "title": "24ed2989c2afa28b" + }, + "new-expensive": { + "body": "673b9532ea51e694", + "rule": "748d85357dfae4fe", + "title": "7f0fbfb2f8f8b29a" + }, + "rank-churn": { + "body": "3e7c4f374a57f0c0", + "rule": "fc8b9ad4226faec6", + "title": "f775bd54b60b28b9", + "unranked": "c4c44d9b6bc1dd1f" + }, + "regression": { + "body": "b4c5ab32845b0130", + "rule": "75efbe415f97d0a4", + "title": "1476a550f6f3ae46" + }, + "show": "0df6f1cad36c49da", + "showLess": "94ea9b1d33a02975", + "showRows": "1bbe3051414ea38e", + "volume-shift": { + "body": "f918fcacfbdabcdd", + "rule": "5c3791751762c82e", + "title": "722040bc714a79ae" + } + }, + "instanceMetrics": { + "cacheHit": "d49f7590d68e7e25", + "columnHeader": "b8fd501ce1fa39a1", + "columnHint": "98279261b90b1d81", + "connectionsNoLimit": "6ff40963673afa31", + "connectionsOf": "41d0e5a55f592f62", + "deadlockLabel": "89de8c20698b05f1", + "deadlocks": "f477d3ecbbcf9f32", + "lagBytes": "f06a1a42813d7247", + "lagLabel": "8a4b61b2c9870c97", + "lagSeconds": "f06a1a42813d7247", + "limitUnknownHint": "39d208280049c339", + "noLimitHint": "91e8615aba65a2e0", + "noReading": "b438d4b3d25997b8", + "noReadingHint": "5a011ab2f57e1156", + "noTraffic": "2b211d9b062ba6ab", + "noTrafficHint": "786564b66c742335", + "trendLabel": "1eb3dc4d9f8c9cf9", + "unmatched": { + "engineUnsupported": "d1d8423e1098ff27", + "engineUnsupportedHint": "6812ecf8a124610c", + "loopback": "373a8f698e18184c", + "loopbackHint": "0a8b4588b30de78f", + "noReceiver": "98f2ebed202df3f0", + "noReceiverHint": "cb0f1b03e8f02983", + "noReceiverMariadbHint": "02f16f23a960513e", + "pooler": "373a8f698e18184c", + "poolerHint": "c5146862490fa8ab", + "unreadable": "560b9bd6e471a0aa", + "unreadableHint": "02e4390e9b90d26d" + } + }, + "list": { + "overlap": { + "clientObserved": "9aba3344ab7d978d", + "serverCounted": "d595c799a59f677d", + "serverExecution": "7137c70d983e5c12", + "serverReported": "c5f2115901ab4a41", + "serverWait": "fc05f7d3f854cb6f" + } + }, + "page": { + "tabs": { + "activity": "38da1505ca837328", + "activityHint": "d53ae9944db4830e", + "blocked": "5bac8cf1c59575e9", + "blockedHint": "a4afeecada809dce", + "blockedLocked": "9ea44dbd4949d8ce", + "deadlocks": "cdc2395de66afa4f", + "deadlocksHint": "d222bc383310eb1a", + "deadlocksLocked": "ceabd3594d40a0ff", + "overview": "d4b1ea5708dd5329", + "queries": "6fbe590f4fe13e19", + "samples": "7e38c4258cbb11a2", + "samplesHintClient": "c72466ca18192c9d", + "samplesHintServer": "778e07fb24756bb8", + "samplesHintUnknown": "4b9b9cf87f20a602", + "tableHealth": "e25af56211da67f1", + "tableHealthHint": "9485be56aae791d5", + "tableHealthLocked": "81b80150a2bc6190" + } + }, + "queries": { + "actions": { + "alert": "1355b68662d07dfb", + "copied": "2b5f7fe902cb48bc", + "copy": "8e5a75e93167b5a0", + "copyFailed": "b2bb5886dceb7908", + "open": "67d16bb1d5a749a3", + "traces": "9a299ceee07b5508" + }, + "columnHints": { + "calls": "6ab05749596bd833", + "errors": "aca82f21e2e1e5bf", + "load": "23b1330bd44aa929", + "max": "3d474f4b4273a348", + "p50": "b31ed3264efc0bd5", + "p95": "3504e3d0f6a5228b", + "p99": "bbc4787025054613", + "perTrace": "b28571e6673fdb9b" + }, + "columnSubLabels": { + "calls": "2db82b914e83692b", + "load": "2db82b914e83692b" + }, + "columns": { + "calls": "b73a5e2ca6458665", + "errors": "031a8f0f659df890", + "load": "1235846a41c69aa3", + "max": "e2e3b2eadf5a70d5", + "p95": "24c98f9916f7d053", + "p99": "dc589a96b56401f7", + "perTrace": "c01f2094044d826f", + "query": "b80a37564fbb3c07", + "services": "681bf81aba87bd68" + }, + "completionBias": { + "body": "3ef4dcbbbdf9b66f", + "title": "f387c1d54e177860" + }, + "errorsAll": "5ef5ef0364b6939c", + "errorsNone": "140bedbf9c3f6d56", + "foldRowDetail": "7f6c827fca9b6968", + "foldRowDetailOpen": "00a4f935a41f15f4", + "foldRowText": "5f9ffb4e25864237", + "otherRow": "25658b1bcebc087f", + "otherRowDetail": "5bfd7b92f5425242", + "otherRowDetailShort": "b4a1bf83363b4fe7", + "otherRowTextFor": "14c1445333267bb7", + "queryCount": "9dc6fd734b88d899", + "searchPlaceholder": "dee778815155758c", + "serverList": { + "columnHints": { + "mean": "633132f89efaaef3", + "time": "0c713b679c30f73c" + }, + "columns": { + "calls": "b73a5e2ca6458665", + "meanTime": "ad266030faf3897b", + "query": "b80a37564fbb3c07", + "totalTime": "01051c4ac8195ce5" + }, + "subtitle": "7db75d664dd2300a", + "title": "37a765fbf4448e1b", + "truncated": "cd3780b2d03b636d" + }, + "stmtClass": { + "all": "ab9dcc553467b834", + "allHint": "8e5beeb9b29be0dd", + "query": "80d2e18c7da9f0be", + "queryHint": "4610c57ac29e9a78" + }, + "subtitle": "bde88aa5104f1ad3", + "subtitleServer": "41d8d980fcbae490", + "summary": { + "calls": "b73a5e2ca6458665", + "failed": "031a8f0f659df890", + "queries": "f0e5d5b427726b2c", + "time": "1235846a41c69aa3" + }, + "title": "6fbe590f4fe13e19", + "trendLabel": "95ded69384a7e085", + "truncatedText": "b1c01c2c3d2d401b" + }, + "recommendations": { + "allClearDescription": "4f772463287faba0", + "allClearTitle": "656ce38699ad3070", + "andMore": "c44be9d5c74e6c5b", + "countersCumulative": "9aad20efea1f816f", + "enginePartialDescription": "babb2a50c300fae3", + "enginePartialTitle": "450949895e07e616", + "high-impact-blocker": { + "body": "0825700a38a8e304", + "rule": "082f9a498bcd119c", + "title": "2575697abbcfe2ee" + }, + "high-row-count": { + "body": "2e035510ad4dc873", + "rule": "564fd36e839c6d86", + "title": "a45dc760d7b4380d" + }, + "long-running-query": { + "body": "44366d5e90e92795", + "rule": "89f2fc75a7363654", + "title": "9a92a46f746ed593" + }, + "subtitle": "d9ab2d20cfe05fab", + "title": "0738ee00b61bc3af", + "unused-index": { + "body": "9c35a1069b731ae5", + "caveat": "c6bb8f36c88aae8a", + "rule": "6f1f33117a3d1787", + "title": "d02d2c1397c998e2" + } + }, + "rowChips": { + "allFailed": "a883b01930629f33", + "busier": "4e65a51122fc3af4", + "climbed": "c541f40ab519f89e", + "loop": "f8ca436660128d95", + "new": "770f54e880a10153", + "slower": "c38639a0bd66b303" + }, + "samples": { + "columnHints": { + "duration": "66e4b67ba0acbb77" + }, + "columns": { + "duration": "168d36c2d331807e", + "query": "b80a37564fbb3c07", + "service": "d677190e0a9990e7", + "status": "920e413c7d411b61", + "when": "cf9c7aa24a26aac4" + }, + "counts": { + "complete": "d90b359b81d66b8a", + "filtered": "243ed475ffbf8ef5", + "truncated": "50343595564709a2" + }, + "disclosureDetail": "f1f7444c2716bc75", + "disclosureShort": "66c4324089c3d8ba", + "failed": "031a8f0f659df890", + "failedWithCode": "6d189b23e24e6596", + "logOffDescription": "9fc30e52775d668a", + "logOffTitle": "8c392c7b99e3bd05", + "partial": "53bd06c2fc71c2e1", + "scatterOk": "22a970d2e5b1cc23", + "searchPlaceholder": "2f1ab6dcc2354e7e", + "serverList": { + "columnHints": { + "took": "dfdcce45b9cfadae" + }, + "columns": { + "query": "b80a37564fbb3c07", + "took": "168d36c2d331807e", + "user": "b512d97e7cbf97c2", + "when": "cf9c7aa24a26aac4" + }, + "subtitle": "4cbf5d42125dba16", + "title": "72a3187c47d84035", + "truncated": "890656bf3bd9bccf" + }, + "subtitle": "67869fd3af7fcf97", + "subtitleServer": "643ec8b14b6ff53b", + "title": "7e38c4258cbb11a2", + "viewTrace": "0e2865ffff2d46c0" + }, + "tableHealth": { + "checkGrant": "19f3f8c09a666413", + "checkRange": "dd65ff216640bb12", + "checkRecipe": "ff4d64a63b3fbc94", + "checklistTitle": "8de0b3df1a96843c", + "columns": { + "autovacuumCount": "08a7a8bc9f9dc115", + "deadTupPct": "c4f218ef36c2bbac", + "deadTuples": "c6009d4a5a7dc947", + "frozenXidAge": "63cbf74670087322", + "heapBytes": "9430a43e7f1db215", + "idxScanCount": "3caa7fd29e867953", + "instance": "425f233626b3cd02", + "lastAnalyze": "2a74bc8c0ad81cfa", + "lastAutovacuum": "f13e2989c724ae5a", + "lastVacuum": "35b81844537a450e", + "liveTuples": "e60c251a9f56d2a6", + "modSinceAnalyze": "46d14c643cd8576a", + "overheadBytes": "406f38b96c25d184", + "relation": "16d1c9050a0b32e8", + "seqScanCount": "15e8e6d2e16f2940", + "seqTupRead": "2422e5368e6409fc", + "totalBytes": "51cdf6b25609d894" + }, + "countLine": "f4bf8269e8f93c62", + "countersCumulative": "59dce9341030416c", + "engineUnsupportedDescription": "82d968f510b6e12d", + "engineUnsupportedTitle": "2917dc3eae1cd638", + "never": "6300ef800bb88429", + "notCollectingDescription": "7ebf5d6d83793673", + "notCollectingTitle": "7b0476ed807f91ed", + "searchPlaceholder": "3394de3474d7cbf2", + "subtitle": "7b3f3ed37654fd29", + "title": "e25af56211da67f1", + "tuplesEstimated": "0ede6fcd9e883244" + } + }, "emptyState": { "allCaughtUp": "2f485b9971dfa582", "createFirstAlert": "7fdaecaea65a3717", @@ -6495,6 +7704,7 @@ }, "ingestion": { "adminRegion": "0ffda4cfc0650beb", + "aerospike": "ce7999e71cdee64f", "ai": { "selectIntegrationPrompt": "a0ce42f3b966249c", "viewDocsLinkLabel": "fcec91509759ad99", @@ -6503,6 +7713,7 @@ }, "aiLabel": "c59321604a890ae6", "and": "6201111b83a0cb5b", + "ansible": "e876abbe46eade2f", "authHeaderHelp": "2fe2b9817583ee32", "authHeaderLabel": "da266ca82e8aea34", "awsSetup": { @@ -6563,6 +7774,7 @@ "streamToEventHub": "9f5399704aa46af4", "underPrefix": "80e4ad056bd7eb90" }, + "cassandra": "e3297e400dff12fe", "categrafDocLinkText": "35361a9a0ec6c19f", "checkFurtherDocumentationAt": "290593ffb96ce7e0", "clear": "83b12c2216efb4fd", @@ -6592,12 +7804,14 @@ "docLinkHere": "fcec91509759ad99", "dotnetlogs": "bf105ff74109d017", "dotnettracing": "71c30432bed2665f", + "dynamodb": "c58e371231b732ce", "errorWhileGeneratingRumToken": "a6d81b3ffaba9724", "errorWhileRefreshingRumToken": "aa0c9423abb1b381", "filebeat": "ec10af8eda7ff9e2", "fluentBitDocLinkText": "49dbff75fa6117d7", "fluentbit": "b6344fe6e6115ccf", "fluentd": "4f2c848b37c5a944", + "gactions": "fae83bd48c808f08", "generateRUMTokenLabel": "475c7f1c1a5cff60", "generateRUMTokenMessage": "ea76eb1e37cb811f", "header": "a22bd608ff67405e", @@ -6727,6 +7941,7 @@ } } }, + "jenkins": "9a4d10e69361c7d6", "languagesLabel": "73d87075dd9f09ac", "launchCloudFormationStack": "2e4bfaf27c788b70", "linuxUnixMacosManualInstall": "4cce8d7c825eec13", @@ -6736,6 +7951,7 @@ "loongCollectorDocLinkText": "a7f5fbec21370711", "macos": "aed6b7aa2a0511a9", "manageTokensBtnLabel": "558e32f8487b0dd9", + "mariadb": "2abb19ec46770d68", "mcp": { "auth": { "oauth": "c324eda2422e19d1", @@ -6825,6 +8041,7 @@ "otlpHttp": "c7fcbcefe82c2d5a", "parametersToEnterInAwsWizard": "3559f49af02df001", "prometheusDocLinkText": "a454c921984f8a3b", + "python": "18885f27b5af9012", "recommendedLabel": "d70604e843046137", "redirectionIngestionMsg": "82c954b6424eaa90", "region": "125045d8913fe65f", @@ -6914,6 +8131,45 @@ "countLogs": "1f2a2a07ec3f5874", "countMetricStreams": "cbbf52fc24212dc3", "countSpans": "7c048203171e0876", + "dbmConfigureMariadbDesc": "486b4d7615b415f6", + "dbmConfigureMssqlDesc": "65ab2474a9ad3724", + "dbmConfigureMysqlDesc": "cbabf6052cb92e31", + "dbmConfigurePostgresDesc": "be9e3c7f1e5443a9", + "dbmConfigureTitle": "55fb8a274cfa84f3", + "dbmDatabaseLabel": "fa7fe67124e94375", + "dbmMariadbLogPathHelp": "b073c6935a850c54", + "dbmMariadbLogPathLabel": "6e8831d8f6af1a3d", + "dbmMysqlLogPathHelp": "b073c6935a850c54", + "dbmMysqlLogPathLabel": "0256c8b25968cd4a", + "dbmPgAutoExplainDesc": "a9bc6fe7b0d289c9", + "dbmPgAutoExplainTitle": "239dbd1c6432dd05", + "dbmPgLogPathHelp": "f17af5fef2db7bce", + "dbmPgLogPathLabel": "f0437b5cefade46e", + "dbmPgLoggingDesc": "849e743d2cf54f4e", + "dbmPgLoggingTitle": "9eabc87b1bf56cdc", + "dbmPgLoggingVerifyDesc": "7c3a5e43fb0adc41", + "dbmPgLoggingVerifyTitle": "08a2c341dbf12c57", + "dbmPrepareMariadbDesc": "67023c1f0fec4fcd", + "dbmPrepareMariadbTitle": "7b2d256964d6be8b", + "dbmPrepareMssqlDesc": "e657834d0b767246", + "dbmPrepareMssqlTitle": "5c402ebe50b5af91", + "dbmPrepareMysqlDesc": "67023c1f0fec4fcd", + "dbmPrepareMysqlTitle": "7b2d256964d6be8b", + "dbmPreparePostgresDesc": "3ca7ddc7c770670d", + "dbmPreparePostgresTitle": "5c402ebe50b5af91", + "dbmRunMariadbDesc": "a47ea3a6daba87a1", + "dbmRunMssqlDesc": "40eff0da0c011d64", + "dbmRunMysqlDesc": "a47ea3a6daba87a1", + "dbmRunPostgresDesc": "019aab2cdfede82e", + "dbmRunTitle": "cfafea93cbd1a797", + "dbmVerifyBlockingDesc": "f1e17c3177c065f0", + "dbmVerifyDesc": "3aa0deb0b59290e4", + "dbmVerifyFullDesc": "671544e23565f8bb", + "dbmVerifyFullNoSampleTextDesc": "10f747fdbbd3f60b", + "dbmVerifyFullPlainPlansDesc": "35d80c3770d29f68", + "dbmVerifyFullSlowTableHealthDesc": "dd6c1ab782e4ff10", + "dbmVerifyTableHealthDesc": "469368609a2d5a73", + "dbmVerifyTitle": "775ce583d76c0cbc", "downloadEnvTooltip": "8594d2ee749572c4", "downloadJmxJarDesc": "e5748cdbab2f5739", "downloadJmxJarTitle": "ca71f1427285ba4d", @@ -7015,6 +8271,8 @@ "macosTroubleUnifiedLogQ": "73929272430fea69", "macosTroubleVolumeA": "5ecae34a74bc41b3", "macosTroubleVolumeQ": "62dfc254c9336251", + "mariadbHostLabel": "4da4c6a310725eba", + "mariadbTagline": "3a1f2d0468fce6b3", "mongodbHostLabel": "4baef46079590687", "mongodbTagline": "7df8782ca959720e", "mostLikelyFix": "b7313ad0dbf53982", @@ -7123,6 +8381,8 @@ "postgresSuperuserNote": "4349581a5749b502", "postgresqlHostLabel": "62078e95df21d22e", "postgresqlTagline": "ad5e0446cc4e3956", + "prepareMariadbDesc": "cb0188bb9e93ba4c", + "prepareMariadbTitle": "de4eb0a1d90d62fe", "prepareMongodbDesc": "202b3bf4e15f36b2", "prepareMongodbTitle": "4703a9877a2493a8", "prepareMysqlDesc": "cb0188bb9e93ba4c", @@ -7192,6 +8452,7 @@ "rumTroubleTokenVisibleA": "def33604ba6518e9", "rumTroubleTokenVisibleQ": "8b4fe3f031cdffea", "runCollectorDesc": "4061965ac8d64c4e", + "runCollectorMariadbDesc": "3aabea0cc708a0e4", "runCollectorMysqlDesc": "3aabea0cc708a0e4", "runCollectorPostgresqlDesc": "72483f1585b422cc", "runCollectorRedisDesc": "2f49b6c92a2f1129", @@ -7248,6 +8509,7 @@ "verifyK8sDataDesc": "cf711245000d11bf", "verifyLinuxAgentDesc": "e2fa7a8c9f8a6f99", "verifyMacosAgentDesc": "3ce157c09644516b", + "verifyMariadbMetricsDesc": "d7e8487a8231def2", "verifyMongodbMetricsDesc": "2ac479eff5ef6225", "verifyMysqlMetricsDesc": "fda554dd4d46301e", "verifyOracledbMetricsDesc": "aa19ee0522971fcf", @@ -7273,11 +8535,13 @@ }, "singleRegion": "dea8496912b45faf", "singleRegionDeployHint": "ed473c676a9a78b3", + "snowflake": "1dbd59f661d68b90", "stackSetDeployHint": "5e2af56a9b7668e8", "stackSetsNoPrefillHint": "eb2cdd22d0cc32b0", "syslogNg": "1a1151c7ec7dcb5d", "targetRegions": "0270a7c4fa59b290", "targetRegionsToEnterInDeploymentTargets": "0a4fd1acea11bffe", + "terraform": "05b073bd5e46bbed", "tokenAuthHeader": "ca5839e38a15433e", "tokenCreateError": "8cec121b0a901bc9", "tokenCreatedSuccess": "d889ad266ef85f23", @@ -7302,7 +8566,8 @@ }, "whereStackSetIsManaged": "d6813437b3b95e1c", "whereStacksWillBeDeployed": "dde5db32cdda333c", - "windowsManualInstall": "ca7fdef7a9f2ef70" + "windowsManualInstall": "ca7fdef7a9f2ef70", + "zookeeper": "2dc0f3567834ecd5" }, "invitation": { "accept": "89713b9c9c1b8f65", @@ -7814,6 +9079,7 @@ "billings": "3ac8bbca9a74cfbd", "dashboard": "a53bcafb67d960df", "data": "cec3a9b89b2e3913", + "databases": "61d2afe2349913ce", "docs": "7af023c43013b9a5", "experience": "8eab0f09df013b1b", "help": "b79cac926e0b2e34", @@ -7821,6 +9087,7 @@ "iam": "7c6826445eb63c22", "incidents": "bfe868931580fb3b", "index": "bfd297afbf9e7430", + "infra": "b624a9c3c3bf143f", "ingestion": "a22bd608ff67405e", "ingestionText": "bad7a0a6d517944a", "keyboardShortcuts": "59cdaa26dd9dcd4c", @@ -7838,6 +9105,7 @@ "signOut": "0b184bc5d5341998", "slos": "46da04c2507b963f", "synthetic": "f45c092981915635", + "topQueries": "1857d11942a7bdda", "traces": "998741a58ed2b411", "upgradeToProPlan": "ceedbe69adf7dba9", "workflows": "e2073645cb0dbbdd" @@ -8147,6 +9415,8 @@ "actionEnable": "5342e09f2729fbc6", "addDefaultPrice": "92ed77b4843ae090", "addTier": "99d0667a09e99f56", + "addTimeWindow": "543a38e740300a35", + "addUsageCondition": "183934827feca2f6", "addUsageKeyPlaceholder": "2964f06b45dc43d2", "applyTierWhen": "25965b6832f57f12", "builtInRefreshed": "a3684cc330c2a3e3", @@ -8186,6 +9456,7 @@ "importBtn": "2cff9baabf56ca00", "importTitle": "e6d6fc73333ae076", "invalidRegex": "ca084af17b9247b5", + "localTimeHint": "50eee2e44ad9bb3a", "matchFound": "c0e2dad4a52fc173", "matchPatternField": "d4f25add0f3d78f4", "matchPatternLabel": "573ca064480be2a8", @@ -8233,9 +9504,11 @@ "pricePerMillionHeader": "a6725dcd2ac003eb", "pricePreview": "669b21b87ffd7c72", "pricingTiers": "6d19c6365319d46a", - "pricingTiersDesc": "621d8effce6a1506", + "pricingTiersDesc": "afc70053880fc602", "quickSetup": "c6be75d4dbc641ab", "refresh": "0e91610117029a62", + "removeTimeWindow": "ed9c2fa92ddd3d01", + "removeUsageCondition": "07cbaed5f8fa9503", "save": "1509f561f2416598", "saveShadowedWarning": "2379752fbc00c288", "searchPlaceholder": "37b90680b8421a45", @@ -8247,15 +9520,28 @@ "tabAll": "a52ace420f2175d0", "tabCustom": "494ca78f7374e46f", "tabSystem": "6725e7bbcd28f3a8", + "testAtTimeHint": "031c0c2b257cb2cc", + "testAtTimeLabel": "b855a18eef52f07f", "testBtn": "532eaabd9574880d", "testMatch": "70507cd969b4f37b", "testMatchSubtitle": "0df0a0984d135df2", "testMatchTitle": "e84c188476e77efe", "threshold": "0da627ada476c179", + "tierAlwaysActive": "e2e3729c295d1de6", + "tierDefaultName": "21b111cbfe6e8fca", "tierName": "d0ee54c0a3aee012", "tierNamePlaceholder": "bd0309e3f682e643", + "tierNeedsRestriction": "2d6ea73178698ee0", "tierPriceMissingKey": "297091edba57e01b", "tierUsageKeyPlainNumber": "9d8de8043ca9903f", + "timeBasedChip": "2a257b8f8ff99732", + "timeWindowFrom": "218197693424e015", + "timeWindowInvalid": "d8ee0ad7aaeba17f", + "timeWindowSameBounds": "11957dd713ce63b9", + "timeWindowTo": "f4b06ef6d3c81436", + "timeWindowWrapsHint": "ef50d5686588985d", + "timeWindows": "8f8f49b41e4e77e0", + "timeWindowsDesc": "47a725f4a5d3a633", "tip1": "891043c9df98c186", "tip2": "3f2b8908d1ecd52e", "tip3": "69c0c582832660f2", @@ -13726,7 +15012,8 @@ "rowsReturned": "f2cf3f1ef95e8f26", "storedProcedure": "c3d80b4555b5cc4d", "tableCollection": "a31c11333192b892", - "user": "b512d97e7cbf97c2" + "user": "b512d97e7cbf97c2", + "viewQueryTrend": "df5dc6da572cfc5a" }, "errLabel": "af440ad05977eb32", "error": "d98ee0e5f9399db9", @@ -13971,6 +15258,7 @@ "colRpcService": "3349a6fb53a01b54", "colUrl": "e7a241debad56609", "critical": "427dd2969bd140be", + "databases": "61d2afe2349913ce", "degraded": "a8494c12f2243903", "durationRange": "37f0ed319261ac08", "enterpriseFeature": "8022fefd22ea3c66", diff --git a/scripts/translations/main.py b/scripts/translations/main.py index 07056b0ab8..54e7bb862f 100755 --- a/scripts/translations/main.py +++ b/scripts/translations/main.py @@ -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 diff --git a/src/api/management/src/request/model_pricing.rs b/src/api/management/src/request/model_pricing.rs index 5d2eae423b..cbba4b4d50 100644 --- a/src/api/management/src/request/model_pricing.rs +++ b/src/api/management/src/request/model_pricing.rs @@ -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, /// 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, } @@ -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(), ]; diff --git a/src/builtins/src/model_pricing.rs b/src/builtins/src/model_pricing.rs index 770991a19e..87d877affb 100644 --- a/src/builtins/src/model_pricing.rs +++ b/src/builtins/src/model_pricing.rs @@ -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 = 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 { diff --git a/src/config/src/meta/model_pricing.rs b/src/config/src/meta/model_pricing.rs index 0aa25dba68..b70c820758 100644 --- a/src/config/src/meta/model_pricing.rs +++ b/src/config/src/meta/model_pricing.rs @@ -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, + /// 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, +} + +/// 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) -> 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); + } } diff --git a/src/core/src/traces/otel/pricing.rs b/src/core/src/traces/otel/pricing.rs index a1d9f1f029..f501bc2b3b 100644 --- a/src/core/src/traces/otel/pricing.rs +++ b/src/core/src/traces/otel/pricing.rs @@ -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, ) -> 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 { } /// 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, + /// Recurring UTC time-of-day windows during which this tier applies. + /// Empty means the tier is not time-restricted. + pub utc_windows: Vec, /// 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, + 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) -> &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 { + vec![ + UtcTimeWindow::from_hm((1, 0), (4, 0)), + UtcTimeWindow::from_hm((6, 0), (10, 0)), + ] +} + /// Global model pricing database pub static MODEL_PRICING: Lazy> = Lazy::new(|| { vec![ @@ -196,11 +255,13 @@ pub static MODEL_PRICING: Lazy> = 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> = 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> = 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> = 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> = 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> = 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> = 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 { + 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] diff --git a/src/core/src/traces/otel/processor.rs b/src/core/src/traces/otel/processor.rs index 6ee26e9102..769a0155a8 100644 --- a/src/core/src/traces/otel/processor.rs +++ b/src/core/src/traces/otel/processor.rs @@ -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, 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, + resource_attributes: &HashMap, + 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() }, diff --git a/src/db/src/model_pricing.rs b/src/db/src/model_pricing.rs index fde79dc9f0..ea900bdc54 100644 --- a/src/db/src/model_pricing.rs +++ b/src/db/src/model_pricing.rs @@ -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, + span_ts_micros: Option, ) -> 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, tier_usage: &HashMap, + span_ts_micros: Option, ) -> 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, + span_ts_micros: Option, +) -> 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, + span_ts_micros: Option, ) -> 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 #[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 { + 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() }, diff --git a/web/src/components/settings/ModelPricingEditor.schema.ts b/web/src/components/settings/ModelPricingEditor.schema.ts index b550d5ee73..5e9bf7fa5a 100644 --- a/web/src/components/settings/ModelPricingEditor.schema.ts +++ b/web/src/components/settings/ModelPricingEditor.schema.ts @@ -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>; export type ModelPricingTier = z.infer; +export type ModelPricingTierWindow = z.infer; export type ModelPricingPriceRow = z.infer; diff --git a/web/src/components/settings/ModelPricingEditor.spec.ts b/web/src/components/settings/ModelPricingEditor.spec.ts index 4624499283..87ed6ab2e7 100644 --- a/web/src/components/settings/ModelPricingEditor.spec.ts +++ b/web/src/components/settings/ModelPricingEditor.spec.ts @@ -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 = {}) => ({ 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) diff --git a/web/src/components/settings/ModelPricingEditor.vue b/web/src/components/settings/ModelPricingEditor.vue index 77b6cf29e8..e5d3a03f5e 100644 --- a/web/src/components/settings/ModelPricingEditor.vue +++ b/web/src/components/settings/ModelPricingEditor.vue @@ -38,73 +38,49 @@ along with this program. If not, see . 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" >
-
+ {{ t("modelPricing.modelDetails") }} -
-
+ + {{ t("modelPricing.modelDetailsDesc") }} -
+
-
+
-
- {{ t("modelPricing.modelNameField") }} - - + +
-
+
-
- {{ t("modelPricing.matchPatternField") }} - - + +
. :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" > - {{ ex.name }} - {{ ex.match_pattern }} + {{ ex.name }} + + {{ ex.match_pattern }} . 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" >
-
+ {{ t("modelPricing.pricingTiers") }} -
-
+ + {{ t("modelPricing.pricingTiersDesc") }} -
+
-
+
. v-if="idx > 0 && tier.condition" class="rounded-default bg-surface-panel border-card-glass-border border px-3.5 py-3" > -
- {{ t("modelPricing.applyTierWhen") }} +
+ + {{ t("modelPricing.applyTierWhen") }} + + + + +
@@ -270,11 +262,105 @@ along with this program. If not, see .
+ +
+ + {{ t("modelPricing.timeWindows") }} + +
+ {{ t("modelPricing.timeWindowsDesc") }} +
+ +
+
+
+ +
+
+ +
+
+ + + + + + {{ t("modelPricing.timeWindowWrapsHint") }} + +
+
+ +
+ {{ t("modelPricing.localTimeHint", { range: windowLocalHint(win) }) }} +
+
+ + +
+ + +
+ + {{ t("modelPricing.addUsageCondition") }} + + + {{ t("modelPricing.addTimeWindow") }} + +
+
- {{ - t("modelPricing.quickSetup") - }} + {{ t("modelPricing.quickSetup") }} .
-
- {{ t("modelPricing.tokenPrices") }} - - {{ t("modelPricing.tokenPricesUnit") }} +
+ {{ t("modelPricing.tokenPrices") }} + {{ t("modelPricing.tokenPricesUnit") }}
@@ -374,17 +458,13 @@ along with this program. If not, see .
-
-
- {{ t("modelPricing.noPricesDefined") }} -
-
- {{ t("modelPricing.noPricesDesc") }} -
-
+ size="inline" + hide-action + :title="t('modelPricing.noPricesDefined')" + :description="t('modelPricing.noPricesDesc')" + />
}; + return { + name, + condition, + prices: {} as Record, + 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 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, diff --git a/web/src/components/settings/ModelPricingList.spec.ts b/web/src/components/settings/ModelPricingList.spec.ts index 2e1593eae6..55941ace1f 100644 --- a/web/src/components/settings/ModelPricingList.spec.ts +++ b/web/src/components/settings/ModelPricingList.spec.ts @@ -201,6 +201,31 @@ const builtInModel = (overrides: Partial = {}) => ({ ...overrides, }); +// Peak / off-peak model: an unrestricted off-peak tier plus a UTC-windowed peak +// tier — the DeepSeek shape. +const peakOffPeakModel = (overrides: Partial = {}) => ({ + 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); + }); + }); }); diff --git a/web/src/components/settings/ModelPricingList.vue b/web/src/components/settings/ModelPricingList.vue index fb23dc92e0..66a71c104a 100644 --- a/web/src/components/settings/ModelPricingList.vue +++ b/web/src/components/settings/ModelPricingList.vue @@ -180,13 +180,13 @@ along with this program. If not, see . + + {{ t("modelPricing.timeBasedChip") }} + + + + —
@@ -407,60 +462,100 @@ along with this program. If not, see .
-
+ {{ t("modelPricing.colPattern") }} -
- {{ pricingDialogRow.match_pattern }} + + + {{ + pricingDialogRow.match_pattern + }}
- -
- - - - - - - - - + + +
-
- + + + +
+
+
+ +
+
+ {{ tier.name || t("modelPricing.tierDefaultName") }} +
+
+ {{ tier.condition.usage_key }} {{ operatorSymbol(tier.condition.operator) }} + {{ tier.condition.value }} - {{ t("modelPricing.usageType") }} -
+
+
+ {{ t("modelPricing.timeWindows") }} + {{ formatUtcWindows(tier.utc_windows) }} +
+
- {{ t("modelPricing.colPricing") }} -
- {{ formatPriceKey(key) }} - + + + + + + + + + + - {{ formatPerMillion(price) }} - - - -
+ {{ t("modelPricing.usageType") }} + + {{ t("modelPricing.colPricing") }} +
+
+ {{ formatPriceKey(key) }} + + {{ formatPerMillion(price) }} +
+
—
+
—
@@ -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(() => 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][] { diff --git a/web/src/components/settings/TestModelMatchDialog.spec.ts b/web/src/components/settings/TestModelMatchDialog.spec.ts index dfffa39273..bdce06b15b 100644 --- a/web/src/components/settings/TestModelMatchDialog.spec.ts +++ b/web/src/components/settings/TestModelMatchDialog.spec.ts @@ -107,28 +107,65 @@ const OButtonStub = { template: ``, }; +// 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: `
+ +
`, - 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: ` + + `, +}; + // ── Mount factory ──────────────────────────────────────────────────────────── function mountDialog(props: Record = {}) { @@ -139,6 +176,7 @@ function mountDialog(props: Record = {}) { 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"); + }); }); }); diff --git a/web/src/components/settings/TestModelMatchDialog.vue b/web/src/components/settings/TestModelMatchDialog.vue index 279aea112f..fb92c257ca 100644 --- a/web/src/components/settings/TestModelMatchDialog.vue +++ b/web/src/components/settings/TestModelMatchDialog.vue @@ -18,45 +18,43 @@
+ + + + +
- -
- {{ t("modelPricing.modelNameHint") }} -
- - - - + + + {{ t("modelPricing.localTimeHint", { range: testAtTimeLocalHint }) }} +
-
+
@@ -65,26 +63,30 @@
- -
- {{ t("modelPricing.enterModelName") }} -
+
- -
- {{ t("modelPricing.clickToTest") }} -
+
@@ -94,27 +96,18 @@ class="flex flex-col gap-3" data-test="test-match-no-result" > -
-
- + +
+ {{ t("modelPricing.noMatchFound") }}
-
-
- {{ t("modelPricing.noMatchFound") }} -
-
- {{ - t("modelPricing.noMatchDesc", { - modelName: testModelName, - }) - }} -
+
+ {{ + t("modelPricing.noMatchDesc", { + modelName: testModelName, + }) + }}
-
+
@@ -132,33 +125,23 @@
-
-
- + +
+ {{ t("modelPricing.matchFound") }}
-
-
- {{ t("modelPricing.matchFound") }} -
-
- {{ testResult.matched.name }} -
+
+ {{ testResult.matched.name }}
- - {{ sourceLabel(testResult.matched) }} - -
+ +
{{ t("settings.testModelMatchDialog.condition") }} - {{ matchedTierDef.condition.usage_key }} {{ operatorSymbol(matchedTierDef.condition.operator) }} - {{ matchedTierDef.condition.value }}
-
+
+ + {{ formatUtcWindows(matchedTierWindows) }} + {{ + matchedTierWindowsLocal + }} +
+
{{ t("modelPricing.defaultPricingTier") }}
@@ -237,10 +233,13 @@ >
-
- - {{ t("modelPricing.noPricingForTier") }} -
+
@@ -250,15 +249,28 @@ diff --git a/web/src/lib/forms/Time/OFormTime.vue b/web/src/lib/forms/Time/OFormTime.vue index eebe19686a..e7ce8383d2 100644 --- a/web/src/lib/forms/Time/OFormTime.vue +++ b/web/src/lib/forms/Time/OFormTime.vue @@ -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" diff --git a/web/src/lib/forms/Time/OTime.spec.ts b/web/src/lib/forms/Time/OTime.spec.ts index 339f8f5a57..9341db6d1b 100644 --- a/web/src/lib/forms/Time/OTime.spec.ts +++ b/web/src/lib/forms/Time/OTime.spec.ts @@ -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(); + }); + }); }); diff --git a/web/src/lib/forms/Time/OTime.types.ts b/web/src/lib/forms/Time/OTime.types.ts index 3026199809..acd8af1213 100644 --- a/web/src/lib/forms/Time/OTime.types.ts +++ b/web/src/lib/forms/Time/OTime.types.ts @@ -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` */ diff --git a/web/src/lib/forms/Time/OTime.vue b/web/src/lib/forms/Time/OTime.vue index 6056ae62ff..e51a079efa 100644 --- a/web/src/lib/forms/Time/OTime.vue +++ b/web/src/lib/forms/Time/OTime.vue @@ -26,6 +26,7 @@ const props = withDefaults(defineProps(), { readonly: false, clearable: false, withSeconds: false, + format24: false, }); const emit = defineEmits(); @@ -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(() => 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(() => [ + + [
- +