feat(spend): model-centric Models view with per-tool breakdown and billing attribution - #2569
feat(spend): model-centric Models view with per-tool breakdown and billing attribution#2569Yuxin-Qiao wants to merge 42 commits into
Conversation
Split out the data layer from the Usage & Spend work so UI modules can land as small follow-up PRs on top of it. No UI / App-target changes. - Per-tool local session scanners: Antigravity, Gemini CLI, Kimi Code, MiniMax, OpenCode, Qwen Code (WAL-aware SQLite, incremental source fingerprints, Gregorian-day bucketing). - Pricing: models.dev catalog + Google/ThirdParty tiers, routing-prefix stripping, overflow-safe thresholds, provider-reported vs estimated cost. - Core plumbing: cost-usage scanner/cache helpers, subagent rollout shape, parser hash, branding/descriptor updates. Foundation for the dashboard UI; behavior additive and backward compatible. Co-authored-by: Cursor <cursoragent@cursor.com>
Enabling `supportsTokenCost` for Groq changes which providers surface in the descriptor-driven generic Cost row — that is dashboard behavior whose expectations live in the App-layer tests (SpendDashboardModelTests, GroqMenuCardModelTests). Those App tests are not part of this data-layer split, so flipping the flag here breaks CI. Defer the flag to the UI follow-up that carries the matching test updates; the `costSource: .providerReported` tagging stays. Co-authored-by: Cursor <cursoragent@cursor.com>
…cursor adapters Generalize the Usage & Spend data layer into a registerable scanner framework modeled on tokscale's define_clients!, so adding support for a mainstream tool means registering one adapter instead of editing central switches. - LocalHistoryScanning: per-tool protocol (source id, display name, home resolution, scan) with a bundled LocalHistoryScanContext. - LocalHistoryScannerRegistry: source-keyed, order-preserving registry with a process-wide `shared` instance pre-populated from LocalHistoryBuiltInScanners. - Wrap the six existing scanners (Kimi/Gemini/OpenCode/MiniMax/Antigravity/ Qwen) as built-in registrations; no App-layer behavior change. - ZcodeSessionScanner: reads ~/.zcode/cli/rollout/model-io-sess_*.jsonl and normalizes ZCode's cache-inclusive input (cross-checked against totalTokens) so cached prefixes are never billed twice; priced at the official Z.ai rate. - Cursor/Trae degraded scanners: read the local state DBs to surface real per-day model activity, with all token/cost fields left nil because neither tool mirrors token usage locally (billing is server-side). Tests cover the registry, zcode cache normalization, and the degraded sources. Co-authored-by: Cursor <cursoragent@cursor.com>
Refactor the local-history framework from "each scanner builds its own snapshot" to a two-layer design modeled on tokscale's UnifiedMessage: - UnifiedUsageEvent: a single normalized record (full token detail, or degraded model-only) that every thin tool parser emits. Per-event billingProviderID carries source evidence for harness tools. - UsageEventAggregator: the one place that does day/model bucketing, cached-prefix normalization (total cross-check, fallback subtract), models.dev pricing, provider-reported-cost passthrough, partial-pricing nil handling, and snapshot construction. Previously this logic was re-implemented (and occasionally dropped) in every scanner. Migrate Zcode, Qwen, Cursor, and Trae to thin parsers over the engine (Zcode 381->200 lines; Qwen -237). Engine output is byte-identical to the prior hand-rolled accumulators, verified by the existing suites. Add CopilotSessionScanner: reads GitHub Copilot CLI's session-state/*/events.jsonl session.shutdown modelMetrics rollup, normalizes model ids to pricing keys, and traces each model to the real billing vendor (claude-* -> anthropic, gpt-* -> openai, gemini-* -> google) so a harness is priced at the model source, not the tool. New mainstream tools now only need a thin parse->events adapter plus one registration line; aggregation/normalization/pricing are reused and cannot be forgotten. Engine covered by 7 focused tests; Copilot by 3. Co-authored-by: Cursor <cursoragent@cursor.com>
Fixes CI swift-test-macos shard failures: blankLinesBetweenImports, redundantSwiftTestingSuite, indent, redundantThrows. No logic change; both suites still pass. Co-authored-by: Cursor <cursoragent@cursor.com>
Manual refresh reconciles the entire local history in one pass instead of creeping across refreshes: - CostUsageFetcher: forceRefresh forces a rescan and lifts the 512MB per-refresh byte budget (configureFullRescan), so users with gigabytes of session history see the true total on a single manual refresh rather than watching token counts climb over several clicks. - CostUsageScanner: per-file progress callback (progressHandler) threaded through the scan loop. - SpendDashboardController: CodexScanProgressStore bounces scan-queue progress onto the main actor for live UI updates. - PreferencesSpendDashboardPane: show "Scanning history X/Y" beside the refresh spinner during a full rescan (21 locales). Co-authored-by: Cursor <cursoragent@cursor.com>
The Italian catalog test requires every key whose value still equals English
to be on an explicit intentionallyUnchanged allowlist. The new progress string
was added with an English placeholder, breaking that guard. Provide the real
translation ("Scansione cronologia %d/%d") so the catalog stays fully
localized.
Co-authored-by: Cursor <cursoragent@cursor.com>
Addresses ClawSweeper review findings on the local-history layer: - Copilot ownership: local Copilot events carry no explicit billing-provider field, so attributing claude-*/gpt-*/gemini-* models to other providers mixed Copilot activity into those providers' rows by name alone. Ownership now stays with Copilot; the vendor id is used only as the rate-lookup key (pricingProviderIDs), never to re-attribute. - Headline cost completeness: the aggregator dropped unpriced days via compactMap and still published the remaining priced subtotal as the 30-day total. It now withholds the headline cost whenever any token-bearing day is unpriced, so a partial subtotal never masquerades as a complete total. - Cache-write pricing: the shared models.dev pricing path received only uncached input, cache reads, and output, so catalogs with a distinct cache-write rate mispriced records carrying writes. The request now carries cacheCreationInputTokens, priced at the catalog cache-write rate with an input-rate fallback (matching cache reads). Tests: Copilot ownership stays Copilot; headline cost withheld when any token day is unpriced; cache-write priced at its own rate and via fallback. Co-authored-by: Cursor <cursoragent@cursor.com>
The embedded Google rate table was consulted first and returned before the models.dev lookup, so a refreshed catalog correction or price change for these models was never used. The catalog is now consulted first; the embedded table remains the offline fallback for model ids the catalog has not caught up with yet, matching the documented intent. Co-authored-by: Cursor <cursoragent@cursor.com>
The OpenCode database query filtered exclusively on data.time.created, but some databases populate only the message.time_created column (the established OpenCode Go reader falls back to it via COALESCE). Those rows were silently excluded. Apply the same COALESCE fallback in both the projection and the window filter. Co-authored-by: Cursor <cursoragent@cursor.com>
Spend aggregation clamps requestedDays to 30, so scanning 365 days on every dashboard load wasted work. The Codex spend snapshot now scans 30 days, while the token activity heatmap loads its 365-day history through a dedicated actor cache keyed by cache root, account identity, and auth fingerprint. The cache coalesces in-flight loads, refreshes at most every 15 minutes, and always expires at local day rollover so the heatmap reflects the current day. Within the heatmap the recent 30-day snapshot overrides annual entries for the days it covers, keeping recent totals consistent with the spend view. A failed annual scan retains the normal spend snapshot and falls back to 30-day activity instead of failing the source.
Previously the local-history scanners (Antigravity, Gemini CLI, Kimi, MiniMax, OpenCode, Qwen, Copilot) were registered but never reached the Usage & Spend dashboard: `costCapableProviders` filtered on `supportsTokenCost`, which is false for these providers, so they never appeared; and `CostUsageFetcher.supportsTokenSnapshot` had no path for them. - `costCapableProviders` now filters on `supportsDashboardHistory` (supportsTokenCost OR has localHistorySources) so these providers enter the dashboard pipeline. - `CostUsageFetcher.supportsTokenSnapshot` returns true for providers that opted into `localHistorySources`. - `loadTokenSnapshot` short-circuits local-history providers to the registered scanner via `LocalHistoryScannerRegistry`, returning an empty snapshot (historyCoverageIsEstablished=false) when the tool is not installed or has no usage in the window. This keeps the change entirely in CodexBarCore — the existing UsageStore/dashboard `refreshTokenUsageNow` path picks up local history automatically. - Copilot descriptor now declares `localHistorySources: [.copilot]` so the registered Copilot scanner is reachable. - Extract `runCorpusScan`/`resolvedPiScannerOptions`/`configureScannerRefresh` helpers to keep `loadTokenSnapshot` within the function-length budget. Cursor/Trae stay on their existing remote/degraded paths (Cursor is server-billed; Trae has no local token source). ZCode has no UsageProvider/descriptor yet, so it is out of scope here. Co-authored-by: Cursor <cursoragent@cursor.com>
…ed engine These three hand-written aggregations each carried a copy of day/model bucketing, cached-prefix handling, and pricing, and each published a partial subtotal as the complete headline cost when a day contained both priced and unpriced usage. Migrating them to UsageEventAggregator collapses that duplicated logic into the single engine and makes the unpriced-day withholding (previously fixed for the engine in r5) apply to them too. - Each scanner now walks its source files and emits UnifiedUsageEvent records; the engine owns bucketing, normalization, pricing, and snapshot construction. - Per-turn estimated cost is resolved in the parser (Kimi via the Moonshot third-party lookup with kimi-k3 fallback; MiniMax via the MiniMax third-party lookup falling back to provider-reported cost_usd; Antigravity already resolves it during the DB read) and carried as providerCostUSD so the engine trusts it without re-pricing. An unresolvable cost now surfaces as an unpriced day, not a partial subtotal. - UsageEventAggregator now emits reasoningTokens as nil when the bucket has none, matching the pre-migration output of these scanners (and the MiniMax/Antigravity convention that reasoning folds into output). - Consumption totals are unified on the engine's input+cacheRead+output shape: Kimi/MiniMax previously also counted cache-write tokens in the total, which is now reported separately via cacheCreationTokens and priced at its own rate. Tests: Kimi aggregate/malformed/mixed-pricing tests updated for the unified total and unpriced-day semantics; all three scanner suites, the engine suite, and ZCode/Copilot suites pass. Co-authored-by: Cursor <cursoragent@cursor.com>
The scanner dropped OpenCode's recorded provider on the floor: neither the JSON nor the opencode.db path parsed it, so per-model breakdowns carried no ownership evidence and cross-store dedup could not tell two otherwise identical records routed through different providers apart. - WireMessage and the opencode.db SQL now read `providerID` / `model.provider` (JSON) and `$.providerID` / `$.model.provider` (DB). - UsageRecord carries billingProviderID, the aggregation keeps the first sourced value per (day, model), and ModelBreakdown surfaces it. - The cross-store fingerprint now includes the provider, so records whose routing differs no longer collapse; legacy records without evidence use an empty slot and never get one guessed from the model name. Co-authored-by: Cursor <cursoragent@cursor.com>
- CostUsageFetcher: static helper call uses `self.` rather than `Self.` - Kimi/MiniMax scanners: drop trailing blank line at scope end Co-authored-by: Cursor <cursoragent@cursor.com>
…DB costs provider-reported
…ncellation into local-history scans
The Models PR adds dashboard strings to every catalog; the Italian catalog keeps them in English (same choice as steipete#2322), so the language catalog test needs the same allowlist entries to stay green in CI.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a0245f2cab
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // Local-history providers short-circuit here: their snapshot comes from the registered | ||
| // scanner, not a remote path or the Codex corpus scan below. | ||
| if Self.usesLocalHistorySnapshot(provider) { | ||
| return try await self.loadLocalHistorySnapshotOrEmpty( | ||
| provider: provider, | ||
| environment: environment, | ||
| now: now, | ||
| historyDays: clampedHistoryDays) |
There was a problem hiding this comment.
Refresh pricing before scanning local histories
When the models.dev cache is missing, stale, or does not contain a newly encountered model, a user with only a local-history provider such as Kimi, MiniMax, Qwen, or Copilot enabled never refreshes the catalog: this branch returns before refreshPricingIfAllowed, that helper currently only admits Codex/Claude, and the dashboard's subsequent direct scanner call also performs no refresh. Those providers therefore keep publishing nil or stale estimated costs until an unrelated Codex/Claude scan happens; refresh the pricing catalog for local-history providers before invoking their scanners.
Useful? React with 👍 / 👎.
| if let billingProviderID = breakdown.billingProviderID { | ||
| if let existing = self.billingProviderID, existing != billingProviderID { | ||
| self.sawConflictingBillingProviderID = true | ||
| } else if self.billingProviderID == nil { | ||
| self.billingProviderID = billingProviderID |
There was a problem hiding this comment.
Drop billing attribution when any merged source lacks evidence
When two reports contain the same model and only one breakdown has billingProviderID—for example, a newer namespaced record plus legacy history without routing evidence—this accumulator keeps the one known ID and applies it to their combined tokens and cost. SpendBillingAttribution then routes the entire merged breakdown to that vendor even though the evidence covers only one source; track missing ownership like the token buckets and emit nil unless every contributing breakdown agrees.
AGENTS.md reference: AGENTS.md:L46-L46
Useful? React with 👍 / 👎.
| modelName: modelName, | ||
| billingProviderID: self.sawConflictingBillingProviderID ? nil : self.billingProviderID, | ||
| costUSD: self.sawCost ? self.costUSD : nil, | ||
| totalTokens: self.sawTotalTokens ? self.totalTokens : nil, | ||
| inputTokens: self.sawInputTokens && !self.missingInputTokens ? self.inputTokens : nil, |
There was a problem hiding this comment.
Preserve request counts in merged breakdowns
When CostUsageDailyReport.merged rebuilds a model breakdown, requestCount is not accumulated or passed here alongside the newly preserved token buckets. This affects normal Claude dashboard loads because the main report is merged with the Pi report even when Pi contributes no rows, causing the new By tool/model UI to lose otherwise valid message counts; sum request counts with the same completeness and overflow handling used for the other integer fields.
Useful? React with 👍 / 👎.
coveredDayCount measures the coverage window, not the number of spend days, so the replacements used during the daily-card removal were wrong. The heatmap assertion now checks the 30-day window stays unwidened; the rollover and mutation cases already prove window behavior through totals. The localization coverage test follows steipete#2322 and reads the embedded model card from PreferencesSpendModelsView instead of the dashboard pane.
The stacked integration unified the spend and activity scans on a 365-day budget; the steipete#2548-authored scan budget tests still expected a 30-day spend scan. Update expectations and distinguish the activity failure case by call order instead of historyDays.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 314a7a2232
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if billingProviderIDs[key] == nil, let billingProviderID = record.billingProviderID { | ||
| billingProviderIDs[key] = billingProviderID | ||
| } |
There was a problem hiding this comment.
Drop ambiguous billing ownership within a model bucket
When OpenCode uses the same model through different billing providers on one day, this retains only the first provider ID while combining every record's tokens and cost into the same (day, model) bucket. The subscription view consequently attributes the entire bucket to whichever route happened to be encountered first; either include the billing provider in the bucket key or clear ownership when records disagree.
AGENTS.md reference: AGENTS.md:L46-L46
Useful? React with 👍 / 👎.
| let extensions = ["svg", "png"] | ||
| guard let url = extensions.lazy.compactMap({ | ||
| bundle.url(forResource: baseName, withExtension: $0) | ||
| }).first else { |
There was a problem hiding this comment.
Load the new full-color provider icons
For Claude, Gemini, Kimi, and Antigravity, both the old monochrome SVG and the new full-color PNG share the same basename, so this SVG-first lookup always selects the SVG. Their descriptors also retain the default template rendering mode, making the added PNG assets unreachable and leaving every spend-dashboard icon monochrome instead of preserving the intended brand colors.
Useful? React with 👍 / 👎.
| ]), | ||
| tokenCost: ProviderTokenCostConfig( | ||
| supportsTokenCost: false, | ||
| supportsTokenCost: true, |
There was a problem hiding this comment.
Carry Groq token buckets into model breakdowns
When Groq is enabled, this newly admits its snapshot to the generic spend/model dashboard, but GroqConsoleUsageSnapshot.toCostUsageTokenSnapshot() only copies each model's total tokens, requests, and cost into ModelBreakdown, despite the source retaining input, cached-input, and output counts. As a result, Groq rows have totals while the new per-model token-split and day-detail views report those buckets as unavailable; populate the breakdown fields before enabling this capability.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dc9ae675c4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } | ||
|
|
||
| private func metricText(_ value: Double) -> String { | ||
| spendModelsChartMetricText(value, metric: self.sortMetric) |
There was a problem hiding this comment.
Format model spend with the selected currency
When the dashboard group uses a non-USD currency, such as a EUR preference or provider-native currency, the model costs have already been converted, but this call omits self.currencyCode and therefore formats the chart axis and accessibility values using the helper's USD default. The per-model calls to spendModelsDayDetailModelSummaryText in PreferencesSpendModelsDayDetailView.swift make the same omission, so those rows can show dollar amounts beneath a correctly formatted non-USD total; pass the active currency through both formatting paths.
Useful? React with 👍 / 👎.
…uter billing route - Chart axis and day-detail model summaries now forward the group currency, so EUR and other non-USD groups no longer render converted amounts with a dollar label. - Walk nested model namespaces from the outside in: openrouter/anthropic/ claude-* keeps OpenRouter as the billing owner. - Withhold window cost totals when any token-bearing day is unpriced. - Add EUR regressions for both model-spend paths and update the nested-route expectation from inner vendor to outer route.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 00a3d05bfd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let uniqueCredentialFingerprint | ||
| { | ||
| return BillingGroupKey( | ||
| provider: input.provider, | ||
| identity: "credential:\(uniqueCredentialFingerprint)") |
There was a problem hiding this comment.
Keep fingerprint-less local history out of routed groups
When Codex history routes usage to a vendor such as MiniMax and that vendor's local-history snapshot has no credential fingerprint, this assigns the local snapshot the routed Codex fingerprint merely because it is the only one present. Attribution then merges both histories under the native local ID, allowing dashboardSubscriptionNames to attach the selected MiniMax account's plan even when the routed usage belongs to another account; keep the sources separate unless matching credential evidence exists.
AGENTS.md reference: AGENTS.md:L46-L46
Useful? React with 👍 / 👎.
| let points = Set(totalsByDay.keys).union(unknownDays).sorted().map { day in | ||
| SpendDashboardModel.TokenActivityPoint( | ||
| day: day, |
There was a problem hiding this comment.
Preserve covered zero days in the model heatmap
For a complete 365-day source with sparse activity, ModelAnalysis.dailyValues contains only days represented by model breakdowns, so this constructs points only for active days. SpendActivitySeries.make treats every absent date as uncovered rather than a known zero, causing the per-currency heatmap to report most of the established history as unavailable—for example, ten active days become 10/365 coverage. Pass the source coverage/zero-day evidence into this heatmap instead of deriving coverage solely from daily values.
Useful? React with 👍 / 👎.
| else { | ||
| continue | ||
| } | ||
| guard visitedFiles < self.maximumFiles else { break } |
There was a problem hiding this comment.
Sort Kimi histories before enforcing the file cap
When more than 20,000 in-window Kimi wire.jsonl files exist, this stops at whichever files FileManager enumerates first, whose order is not chronological. Recent sessions can therefore be omitted while older files consume the cap, understating current token usage and spend. Fresh evidence beyond the earlier OpenCode comment is that Kimi's scanner still applies the cap directly to an unsorted enumeration; collect eligible candidates and prioritize the newest before truncating.
Useful? React with 👍 / 👎.
|
Maintainer decision on the Usage & Spend program (applies to #2322, #2527, #2548, #2569, #2575, #2577 — closing together): We want the ideas — the token-activity view and the cross-provider cost overview in particular are genuinely good, and we're adopting them. What we can't absorb is the delivery shape: 8k–20k-line PRs bundling scanners, pricing, identity, and UI can't be reviewed honestly, and live testing of the current heads surfaced the kind of defects that big surfaces hide (duplicate token-activity cards in the Models view; the unified Overview rendering no chip for an enabled provider; the account-attribution issue flagged earlier). So we're taking this work as a base in-house and rebuilding it in small, individually landable slices — first the token-activity view rebuilt on the shared scan cache (no separate annual scan), then the cost overview aligned with the #2578 design discussion. Your commits will be cherry-picked where they fit, and every slice that draws on this work will credit you in the changelog, as today's merges already do. @Yuxin-Qiao — you're this repo's most prolific contributor and much of today's release is your work. Focused PRs the size of #2574 land same-day; that's the shape that serves both of us. |
PR body: feat(spend) model-centric Models view
Summary
Split from #2322 (the model-centric part), stacked on #2527 + #2548.
Adds the Models section to the Usage & Spend dashboard:
SpendModelIdentity,SpendToolIdentity,SpendProviderIdentity) and billing ownership attribution (SpendBillingAttribution) so native subscription allowance and third-party API spend stay separatedocs/provider-icon-sources.mdStacked PR
This branch is based on an integration of
main+ #2527 (b366ef85) + #2548 (341fc5b0). It must be rebased ontomainafter both land; the diff will then shrink to the models feature only. Overlaps resolved in this branch:SpendDashboardController: 2322's local-history load pipeline unified with feat(core): local real-usage data layer — unified event engine + registerable scanners + pricing foundation #2527's progress reporting and feat: add standalone token activity heatmap #2548's activity snapshot cache (single workhorseload(_:codexProgress:codexSnapshotLoader:codexActivitySnapshotLoader:…), local loaders default to the real scanners)SpendDashboardModel: 365-day token activity (feat: add standalone token activity heatmap #2548) unified with model-analysis aggregation; heatmap-only helpers extracted toSpendDashboardModel+TokenActivity.swift(file-length lint)PreferencesSpendDashboardPane: controller is injected from the app level (prewarmed), models section embedded per currency group;SpendProviderIcon/SpendDashboardPanelvisibility opened for reuseSpendActivityHeatmapView: addedinit(analysis:)that folds per-model daily values into per-day totals, preserving "unknown day" semanticsTest expectation adjustments vs #2322 (documented, behavior unchanged):
Screenshots
Models · Tokens view:
Models · Estimated spend view:
Models · By tool view:
Test
swift test --filter 'SpendModelIdentityTests|SpendToolPresentationTests|SpendModelsPresentationTests|SpendBillingAttributionTests|SpendDashboardKimiModelTests|SpendDashboardLocalAdapterTests|SpendDashboardLocalHistoryRecoveryTests|SpendDashboardClockRolloverTests|SpendDashboardDateTruthTests|SpendDashboardModelTests|SpendDashboardSourceConcurrencyTests|SpendChartDayHitTargetTests|SpendDashboardControllerTests|ProviderIconResourcesTests|ShareStatsTests|AppDelegateTests|GroqConsoleFetcherTests|GroqMenuCardModelTests'— 208 tests / 20 suites, all passing./Scripts/lint.sh lint— 0 violationsNot included
The separate Daily estimated spend card (per-day stacked spend by tool) is intentionally withheld from this PR; it is still being iterated and will land as its own follow-up PR.