Conversation
Adds a Mistral provider that reads Vibe session log files from ~/.vibe/logs/session/*/metadata.json to report daily token usage and estimated cost using Devstral pricing ($0.40/M input, $2.00/M output). No API key or network access required. isAvailable() returns true when the Vibe session log directory exists (i.e. Vibe is installed). probe() returns a UsageSnapshot with CostUsage and DailyUsageReport. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Adds a log-only Mistral provider that reads ~/.vibe/logs/session/*/meta.json
to report daily token usage and estimated cost using Devstral pricing
($0.40/M input tokens, $2.00/M output tokens).
No API key or network access is required. isAvailable() returns true when
the Vibe session log directory exists (i.e. Vibe is installed). probe()
returns a UsageSnapshot with a DailyUsageReport only — no CostUsage or
quota data, since Mistral doesn't expose useful quota information via API.
Key implementation details:
- Vibe session directories use UTC timestamps in their names
(session_YYYYMMDD_HHMMSS_sessionid); parsing now uses the full
date+time component with UTC timezone to correctly classify sessions
that cross UTC midnight into the right local calendar day
- Session metadata is in meta.json (not metadata.json)
- Working time is not tracked (Vibe logs don't include wall-clock duration)
- Working time card in MenuContentView is gated on workingTime > 0
Removes MistralSettingsRepository sub-protocol and all conformances
(JSONSettingsRepository, UserDefaultsProviderSettingsRepository) since
no API key storage is needed. MistralProvider now uses the base
ProviderSettingsRepository.
Branding: uses cat.fill SF Symbol as the provider switcher icon.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…elds Switches the Vibe session log analyzer from computing token totals and cost from raw prompt/completion token counts to reading the pre-aggregated session_total_llm_tokens and session_cost fields directly from meta.json. Removes the inputPricePerMToken and outputPricePerMToken constants. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
📝 WalkthroughWalkthroughA new AI provider for Mistral has been integrated into the application, including provider implementation, usage tracking via Vibe session log analysis, visual identity configuration, UI conditional rendering, app wiring, and comprehensive test coverage. Changes
Sequence DiagramsequenceDiagram
actor App as ClaudeBarApp
participant Provider as MistralProvider
participant Probe as MistralUsageProbe
participant Analyzer as VibeSessionLogAnalyzer
participant FS as File System
App->>Provider: Initialize with MistralUsageProbe + SettingsRepository
Provider->>Provider: Load isEnabled from SettingsRepository
App->>Provider: Call refresh()
Provider->>Provider: Set isSyncing = true
Provider->>Probe: probe()
Probe->>FS: Check ~/.vibe/logs/session exists
alt Directory Exists
Probe->>Analyzer: analyzeToday()
Analyzer->>FS: List session directories (session_YYYYMMDD_*)
Analyzer->>FS: Read meta.json from each session
Analyzer->>Analyzer: Parse timestamps, aggregate tokens/costs
Analyzer->>Analyzer: Split into today vs previous periods
Analyzer-->>Probe: Return DailyUsageReport
Probe-->>Provider: Return UsageSnapshot with report
else Directory Missing
Probe-->>Provider: Return empty UsageSnapshot
end
Provider->>Provider: Update snapshot, set isSyncing = false
Provider->>SettingsRepository: Persist isEnabled state if changed
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment Tip CodeRabbit can generate a title for your PR based on the changes.Add |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (2)
Tests/InfrastructureTests/Mistral/VibeSessionLogAnalyzerTests.swift (1)
58-97: Make date-bound tests deterministic with fixednowand UTC calendar.Using
Date()andCalendar.currentin “today/yesterday” tests can become flaky around local day boundaries.🧪 Deterministic test setup example
+ var utcCalendar = Calendar(identifier: .gregorian) + utcCalendar.timeZone = TimeZone(secondsFromGMT: 0)! + let fixedNow = Date(timeIntervalSince1970: 1_710_000_000) // stable reference point + let analyzer = VibeSessionLogAnalyzer( vibeSessionsDir: tempDir, - now: { Date() } + calendar: utcCalendar, + now: { fixedNow } )🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Tests/InfrastructureTests/Mistral/VibeSessionLogAnalyzerTests.swift` around lines 58 - 97, The tests use Date() and Calendar.current which can be flaky; make them deterministic by passing a fixed now value (e.g. let fixedNow = Date(timeIntervalSince1970: 1_700_000_000)) into the VibeSessionLogAnalyzer now closure and by creating yesterday/today dates using a UTC calendar (let utc = Calendar(identifier: .gregorian); utc.timeZone = TimeZone(secondsFromGMT: 0)!) instead of Calendar.current; update the two tests to call makeSessionDir(in: tempDir, date: fixedNow, suffix: ...) for today and use utc.date(byAdding: .day, value: -1, to: fixedNow) for yesterday so analyzeToday() and the `#expect` assertions are stable.Sources/Domain/Provider/Mistral/MistralProvider.swift (1)
49-54: Avoid duplicating the provider id string in initialization.Line 53 hardcodes
"mistral"even though Line 11 already defines the canonical id. Keeping one source of truth reduces drift risk.♻️ Suggested refactor
public final class MistralProvider: AIProvider, `@unchecked` Sendable { + private static let providerId = "mistral" + - public let id: String = "mistral" + public let id: String = Self.providerId @@ - self.isEnabled = settingsRepository.isEnabled(forProvider: "mistral", defaultValue: false) + self.isEnabled = settingsRepository.isEnabled(forProvider: Self.providerId, defaultValue: false) }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/Domain/Provider/Mistral/MistralProvider.swift` around lines 49 - 54, The init currently hardcodes "mistral" when calling settingsRepository.isEnabled; change it to use the provider's canonical id constant instead (e.g., replace the literal with Self.id or MistralProvider.id) so the provider id is sourced from the single canonical symbol defined at the top of the type (ensure the static/constant identifier used matches the existing declaration).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@Sources/Domain/Provider/Mistral/MistralProvider.swift`:
- Line 8: The MistralProvider's refresh() has unsynchronized shared state
(isSyncing, snapshot, lastError) causing races when invoked concurrently; update
MistralProvider to serialize refresh executions—either make refresh()
actor-isolated (convert MistralProvider to an actor or move refresh into a
dedicated actor/serial Task) or add an atomic in-flight counter/lock to ensure
only one refresh runs at a time and only that execution flips isSyncing and
updates snapshot/lastError; remove or narrow use of `@unchecked` Sendable if
converting to an actor, and ensure defer { isSyncing = false } runs only for the
owning execution (use counter decrement or actor-isolation) so state remains
consistent.
In `@Sources/Infrastructure/Mistral/MistralUsageProbe.swift`:
- Around line 4-6: Update the top comment in MistralUsageProbe (the probe
implementation) to reflect the current data source and pricing: replace
references to "~/.vibe/logs/session/*/metadata.json" with
"~/.vibe/logs/session/*/meta.json" and replace "Devstral pricing" with
"analyzer-produced totals from session meta.json" (or similar wording) so the
docstring matches the actual behavior of MistralUsageProbe.
In `@Sources/Infrastructure/Mistral/VibeSessionLogAnalyzer.swift`:
- Around line 81-83: The session scanner currently only checks for "meta.json"
and skips folders that instead contain "metadata.json"; update the logic in
VibeSessionLogAnalyzer (the loop that builds metadataURL and uses
fileManager.fileExists) to look for both filenames: first try
entry.appendingPathComponent("meta.json"), and if that does not exist, try
entry.appendingPathComponent("metadata.json"); use the first existing URL (or
continue only if neither exists) so sessions written as "metadata.json" are
included in totals.
In `@Tests/InfrastructureTests/Mistral/MistralUsageProbeTests.swift`:
- Around line 61-76: The test in MistralUsageProbeTests named `probe returns
UsageSnapshot with costUsage and dailyUsageReport` contradicts its assertions
(it expects costUsage == nil); rename the test function to reflect that
costUsage is nil (for example `probe returns UsageSnapshot with no costUsage and
dailyUsageReport`) by changing the `@Test` func name in the MistralUsageProbeTests
class where `probe returns UsageSnapshot with costUsage and dailyUsageReport` is
declared, keeping the body (mock setup, creation of MistralUsageProbe, and
assertions) unchanged so test intent matches its assertions.
---
Nitpick comments:
In `@Sources/Domain/Provider/Mistral/MistralProvider.swift`:
- Around line 49-54: The init currently hardcodes "mistral" when calling
settingsRepository.isEnabled; change it to use the provider's canonical id
constant instead (e.g., replace the literal with Self.id or MistralProvider.id)
so the provider id is sourced from the single canonical symbol defined at the
top of the type (ensure the static/constant identifier used matches the existing
declaration).
In `@Tests/InfrastructureTests/Mistral/VibeSessionLogAnalyzerTests.swift`:
- Around line 58-97: The tests use Date() and Calendar.current which can be
flaky; make them deterministic by passing a fixed now value (e.g. let fixedNow =
Date(timeIntervalSince1970: 1_700_000_000)) into the VibeSessionLogAnalyzer now
closure and by creating yesterday/today dates using a UTC calendar (let utc =
Calendar(identifier: .gregorian); utc.timeZone = TimeZone(secondsFromGMT: 0)!)
instead of Calendar.current; update the two tests to call makeSessionDir(in:
tempDir, date: fixedNow, suffix: ...) for today and use utc.date(byAdding: .day,
value: -1, to: fixedNow) for yesterday so analyzeToday() and the `#expect`
assertions are stable.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 60c6e25d-2d7f-4178-a537-3a68133ba5b8
⛔ Files ignored due to path filters (3)
Sources/App/Resources/Assets.xcassets/MistralIcon.imageset/mistral_128.pngis excluded by!**/*.pngSources/App/Resources/Assets.xcassets/MistralIcon.imageset/mistral_192.pngis excluded by!**/*.pngSources/App/Resources/Assets.xcassets/MistralIcon.imageset/mistral_64.pngis excluded by!**/*.png
📒 Files selected for processing (10)
Sources/App/ClaudeBarApp.swiftSources/App/Resources/Assets.xcassets/MistralIcon.imageset/Contents.jsonSources/App/Views/MenuContentView.swiftSources/App/Views/ProviderVisualIdentity.swiftSources/Domain/Provider/Mistral/MistralProvider.swiftSources/Infrastructure/Mistral/MistralUsageProbe.swiftSources/Infrastructure/Mistral/VibeSessionLogAnalyzer.swiftTests/InfrastructureTests/Mistral/MistralUsageProbeTests.swiftTests/InfrastructureTests/Mistral/VibeSessionLogAnalyzerTests.swiftTests/InfrastructureTests/Settings/JSONSettingsRepositoryProviderTests.swift
| /// Observable class with its own state (isSyncing, snapshot, error). | ||
| /// Owns its probe and manages its own data lifecycle. | ||
| @Observable | ||
| public final class MistralProvider: AIProvider, @unchecked Sendable { |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Verify whether provider refreshes can be triggered concurrently.
# Expected: identify call-sites using Task/task groups or overlapping refresh orchestration.
rg -nP --type=swift -C3 '\brefresh\s*\(' Sources
rg -nP --type=swift -C3 'Task\s*\{|async let|withTaskGroup' Sources
rg -nP --type=swift -C3 '\bisSyncing\b' SourcesRepository: tddworks/ClaudeBar
Length of output: 50374
Concurrent refresh() calls can leave state inconsistent: isSyncing and snapshot lack synchronization.
@unchecked Sendable permits concurrent access, and call sites in MenuContentView (lines 755–760, 769–782) use withTaskGroup and Task { ... } to invoke refresh(). While a guard check exists at line 776 (guard !provider.isSyncing else { return }), it creates a race condition: between the check and the actual call, another task may start refreshing. Once concurrent calls are in flight, the defer { isSyncing = false } only marks completion of the last one, leaving the state flag inconsistent with actual ongoing work. The same applies to unsynchronized writes to snapshot and lastError.
Serialize refresh state updates—e.g., actor-gate the method or use an atomic in-flight counter—to prevent overlapping refresh executions.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Sources/Domain/Provider/Mistral/MistralProvider.swift` at line 8, The
MistralProvider's refresh() has unsynchronized shared state (isSyncing,
snapshot, lastError) causing races when invoked concurrently; update
MistralProvider to serialize refresh executions—either make refresh()
actor-isolated (convert MistralProvider to an actor or move refresh into a
dedicated actor/serial Task) or add an atomic in-flight counter/lock to ensure
only one refresh runs at a time and only that execution flips isSyncing and
updates snapshot/lastError; remove or narrow use of `@unchecked` Sendable if
converting to an actor, and ensure defer { isSyncing = false } runs only for the
owning execution (use counter decrement or actor-isolation) so state remains
consistent.
| /// Probes Vibe session logs for daily cost and token usage. | ||
| /// Reads from ~/.vibe/logs/session/*/metadata.json using Devstral pricing. | ||
| /// |
There was a problem hiding this comment.
Update stale probe docs to match current data source.
Line [5] says metadata.json and Devstral pricing, but implementation now uses analyzer-produced totals from meta.json session metadata. Please align comments with actual behavior.
✏️ Suggested doc fix
-/// Reads from ~/.vibe/logs/session/*/metadata.json using Devstral pricing.
+/// Reads from ~/.vibe/logs/session/*/meta.json and uses Vibe-provided
+/// aggregated `session_total_llm_tokens` and `session_cost` values.📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| /// Probes Vibe session logs for daily cost and token usage. | |
| /// Reads from ~/.vibe/logs/session/*/metadata.json using Devstral pricing. | |
| /// | |
| /// Probes Vibe session logs for daily cost and token usage. | |
| /// Reads from ~/.vibe/logs/session/*/meta.json and uses Vibe-provided | |
| /// aggregated `session_total_llm_tokens` and `session_cost` values. | |
| /// |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Sources/Infrastructure/Mistral/MistralUsageProbe.swift` around lines 4 - 6,
Update the top comment in MistralUsageProbe (the probe implementation) to
reflect the current data source and pricing: replace references to
"~/.vibe/logs/session/*/metadata.json" with "~/.vibe/logs/session/*/meta.json"
and replace "Devstral pricing" with "analyzer-produced totals from session
meta.json" (or similar wording) so the docstring matches the actual behavior of
MistralUsageProbe.
| let metadataURL = entry.appendingPathComponent("meta.json") | ||
| guard fileManager.fileExists(atPath: metadataURL.path) else { continue } | ||
|
|
There was a problem hiding this comment.
Handle both meta.json and metadata.json session files.
Line [81] currently requires only meta.json. If a Vibe install writes metadata.json, sessions are silently skipped, causing incorrect usage/cost totals.
🔧 Compatibility fix
- let metadataURL = entry.appendingPathComponent("meta.json")
- guard fileManager.fileExists(atPath: metadataURL.path) else { continue }
+ let metadataCandidates = ["meta.json", "metadata.json"]
+ guard let metadataURL = metadataCandidates
+ .map({ entry.appendingPathComponent($0) })
+ .first(where: { fileManager.fileExists(atPath: $0.path) }) else { continue }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let metadataURL = entry.appendingPathComponent("meta.json") | |
| guard fileManager.fileExists(atPath: metadataURL.path) else { continue } | |
| let metadataCandidates = ["meta.json", "metadata.json"] | |
| guard let metadataURL = metadataCandidates | |
| .map({ entry.appendingPathComponent($0) }) | |
| .first(where: { fileManager.fileExists(atPath: $0.path) }) else { continue } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Sources/Infrastructure/Mistral/VibeSessionLogAnalyzer.swift` around lines 81
- 83, The session scanner currently only checks for "meta.json" and skips
folders that instead contain "metadata.json"; update the logic in
VibeSessionLogAnalyzer (the loop that builds metadataURL and uses
fileManager.fileExists) to look for both filenames: first try
entry.appendingPathComponent("meta.json"), and if that does not exist, try
entry.appendingPathComponent("metadata.json"); use the first existing URL (or
continue only if neither exists) so sessions written as "metadata.json" are
included in totals.
| @Test func `probe returns UsageSnapshot with costUsage and dailyUsageReport`() async throws { | ||
| let mockAnalyzer = MockDailyUsageAnalyzing() | ||
| let report = makeReport() | ||
| given(mockAnalyzer) | ||
| .analyzeToday() | ||
| .willReturn(report) | ||
|
|
||
| let probe = MistralUsageProbe(vibeLogAnalyzer: mockAnalyzer) | ||
|
|
||
| let snapshot = try await probe.probe() | ||
|
|
||
| #expect(snapshot.providerId == "mistral") | ||
| #expect(snapshot.quotas.isEmpty) | ||
| #expect(snapshot.costUsage == nil) | ||
| #expect(snapshot.dailyUsageReport != nil) | ||
| } |
There was a problem hiding this comment.
Rename test to match its actual assertion.
Line [61] says “with costUsage”, but Line [74] asserts costUsage == nil. Rename for clarity.
✏️ Suggested rename
- `@Test` func `probe returns UsageSnapshot with costUsage and dailyUsageReport`() async throws {
+ `@Test` func `probe returns UsageSnapshot with dailyUsageReport and no costUsage`() async throws {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Tests/InfrastructureTests/Mistral/MistralUsageProbeTests.swift` around lines
61 - 76, The test in MistralUsageProbeTests named `probe returns UsageSnapshot
with costUsage and dailyUsageReport` contradicts its assertions (it expects
costUsage == nil); rename the test function to reflect that costUsage is nil
(for example `probe returns UsageSnapshot with no costUsage and
dailyUsageReport`) by changing the `@Test` func name in the MistralUsageProbeTests
class where `probe returns UsageSnapshot with costUsage and dailyUsageReport` is
declared, keeping the body (mock setup, creation of MistralUsageProbe, and
assertions) unchanged so test intent matches its assertions.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #161 +/- ##
==========================================
+ Coverage 80.06% 80.26% +0.19%
==========================================
Files 102 104 +2
Lines 7741 7834 +93
==========================================
+ Hits 6198 6288 +90
- Misses 1543 1546 +3
🚀 New features to boost your workflow:
|
|
@farmdawgnation great job! thanks! |
Disclaimer: I'm a Swift n00b. I know enough to review the code Claude generated and assert it doesn't have anything obviously wild going on, but I would not be surprised if something slipped by.
This PR adds support for Mistral as a provider for ClaudeBar. Because Mistral doesn't provide an elegant way to check the current rate limiting (at least, not one that I've been able to find), I opted for just representing what the current API spend and token usage actually is from Mistral Vibe session logs available on the local filesystem. Welcome any and all feedback here. This would (clearly) not represent any impact from outside Mistral usage (e.g. OpenCode, LeChat, etc).
Summary by CodeRabbit
New Features
Improvements
Tests