Skip to content

Commit 1edcd74

Browse files
cailurusclaude
andcommitted
Add cost metric and in-app update check.
- Activity dashboard gains a Cost unit: per-model input/output tokens are recorded and priced against a built-in API price table (ModelPricing), with a Requests/Tokens/Cost toggle. Cost is derived, not stored. - About shows the app version (now read from Info.plist as the single source of truth) and a "Check for Updates" action that queries GitHub Releases and links to the download. No auto-update. - build-app.sh can stamp a version into Info.plist; template bumped to 0.1.2. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 9a2d9a9 commit 1edcd74

13 files changed

Lines changed: 559 additions & 110 deletions

Resources/Info.plist

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,32 @@
22
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
33
<plist version="1.0">
44
<dict>
5-
<key>CFBundleName</key>
6-
<string>Copilot Bridge</string>
7-
<key>CFBundleDisplayName</key>
8-
<string>Copilot Bridge</string>
9-
<key>CFBundleIdentifier</key>
10-
<string>com.copilotbridge.app</string>
11-
<key>CFBundleVersion</key>
12-
<string>1</string>
13-
<key>CFBundleShortVersionString</key>
14-
<string>0.1.0</string>
15-
<key>CFBundlePackageType</key>
16-
<string>APPL</string>
17-
<key>CFBundleExecutable</key>
18-
<string>CopilotBridge</string>
19-
<key>CFBundleIconFile</key>
20-
<string>AppIcon</string>
21-
<key>LSMinimumSystemVersion</key>
22-
<string>14.0</string>
23-
<key>LSUIElement</key>
24-
<true/>
25-
<key>NSHumanReadableCopyright</key>
26-
<string>Copilot Bridge — local, uses your own GitHub Copilot subscription.</string>
27-
<key>NSAppTransportSecurity</key>
28-
<dict>
29-
<key>NSAllowsLocalNetworking</key>
30-
<true/>
31-
</dict>
5+
<key>CFBundleDisplayName</key>
6+
<string>Copilot Bridge</string>
7+
<key>CFBundleExecutable</key>
8+
<string>CopilotBridge</string>
9+
<key>CFBundleIconFile</key>
10+
<string>AppIcon</string>
11+
<key>CFBundleIdentifier</key>
12+
<string>com.copilotbridge.app</string>
13+
<key>CFBundleName</key>
14+
<string>Copilot Bridge</string>
15+
<key>CFBundlePackageType</key>
16+
<string>APPL</string>
17+
<key>CFBundleShortVersionString</key>
18+
<string>0.1.2</string>
19+
<key>CFBundleVersion</key>
20+
<string>1</string>
21+
<key>LSMinimumSystemVersion</key>
22+
<string>14.0</string>
23+
<key>LSUIElement</key>
24+
<true/>
25+
<key>NSAppTransportSecurity</key>
26+
<dict>
27+
<key>NSAllowsLocalNetworking</key>
28+
<true/>
29+
</dict>
30+
<key>NSHumanReadableCopyright</key>
31+
<string>Copilot Bridge — local, uses your own GitHub Copilot subscription.</string>
3232
</dict>
3333
</plist>

Sources/CopilotBridge/Core/ActivityStore.swift

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,39 @@ import Foundation
1313
final class ActivityStore: ObservableObject {
1414
struct Stat: Codable, Equatable {
1515
var requests: Int = 0
16+
/// Total tokens = input + output. Kept for the heatmap/back-compat.
1617
var tokens: Int = 0
18+
var inputTokens: Int = 0
19+
var outputTokens: Int = 0
20+
21+
init(requests: Int = 0, tokens: Int = 0, inputTokens: Int = 0, outputTokens: Int = 0) {
22+
self.requests = requests
23+
self.tokens = tokens
24+
self.inputTokens = inputTokens
25+
self.outputTokens = outputTokens
26+
}
27+
28+
/// Tolerates older files that only had `requests`/`tokens` (split fields absent).
29+
init(from decoder: Decoder) throws {
30+
let c = try decoder.container(keyedBy: CodingKeys.self)
31+
requests = (try? c.decode(Int.self, forKey: .requests)) ?? 0
32+
tokens = (try? c.decode(Int.self, forKey: .tokens)) ?? 0
33+
inputTokens = (try? c.decode(Int.self, forKey: .inputTokens)) ?? 0
34+
outputTokens = (try? c.decode(Int.self, forKey: .outputTokens)) ?? 0
35+
}
1736
}
1837

1938
/// The metric the dashboard is currently showing.
2039
enum Unit: String, CaseIterable, Identifiable {
21-
case requests, tokens
40+
case requests, tokens, cost
2241
var id: String { rawValue }
23-
var title: String { self == .requests ? "Requests" : "Tokens" }
42+
var title: String {
43+
switch self {
44+
case .requests: return "Requests"
45+
case .tokens: return "Tokens"
46+
case .cost: return "Cost"
47+
}
48+
}
2449
}
2550

2651
/// day (yyyy-MM-dd, local) -> model id -> stat
@@ -50,16 +75,20 @@ final class ActivityStore: ObservableObject {
5075
unit == .requests ? stat.requests : stat.tokens
5176
}
5277

53-
/// Records a batch of per-model request counts and token counts on the current local
54-
/// day with a single save.
55-
func record(requests: [String: Int], tokens: [String: Int], on date: Date = Date()) {
56-
let models = Set(requests.keys).union(tokens.keys)
78+
/// Records a batch of per-model request counts and input/output token counts on the
79+
/// current local day with a single save.
80+
func record(requests: [String: Int], inputTokens: [String: Int], outputTokens: [String: Int], on date: Date = Date()) {
81+
let models = Set(requests.keys).union(inputTokens.keys).union(outputTokens.keys)
5782
guard !models.isEmpty else { return }
5883
let key = Self.dayKey(date)
5984
for model in models {
6085
var stat = days[key]?[model] ?? Stat()
86+
let inTok = max(0, inputTokens[model] ?? 0)
87+
let outTok = max(0, outputTokens[model] ?? 0)
6188
stat.requests += max(0, requests[model] ?? 0)
62-
stat.tokens += max(0, tokens[model] ?? 0)
89+
stat.inputTokens += inTok
90+
stat.outputTokens += outTok
91+
stat.tokens += inTok + outTok
6392
days[key, default: [:]][model] = stat
6493
}
6594
save()
@@ -94,6 +123,36 @@ final class ActivityStore: ObservableObject {
94123
/// Number of distinct days with at least one recorded model.
95124
var activeDays: Int { days.values.filter { !$0.isEmpty }.count }
96125

126+
// MARK: Cost aggregates (USD, derived from token split × ModelPricing)
127+
128+
private func cost(_ model: String, _ stat: Stat) -> Double {
129+
ModelPricing.cost(model: model, inputTokens: stat.inputTokens, outputTokens: stat.outputTokens)
130+
}
131+
132+
/// Estimated equivalent API cost (USD) across all history.
133+
func costTotal() -> Double {
134+
days.values.reduce(0) { acc, byModel in
135+
acc + byModel.reduce(0) { $0 + cost($1.key, $1.value) }
136+
}
137+
}
138+
139+
/// Estimated cost for a given local day.
140+
func cost(on date: Date) -> Double {
141+
(days[Self.dayKey(date)]?.reduce(0) { $0 + cost($1.key, $1.value) }) ?? 0
142+
}
143+
144+
/// Today's estimated cost.
145+
func costToday() -> Double { cost(on: Date()) }
146+
147+
/// Per-model cost totals (USD), highest first.
148+
func modelCostTotals() -> [(model: String, cost: Double)] {
149+
var totals: [String: Double] = [:]
150+
for byModel in days.values {
151+
for (model, stat) in byModel { totals[model, default: 0] += cost(model, stat) }
152+
}
153+
return totals.sorted { $0.value > $1.value }.map { (model: $0.key, cost: $0.value) }
154+
}
155+
97156
// MARK: Persistence
98157

99158
private func load() {

Sources/CopilotBridge/Core/AppState.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ final class AppState: ObservableObject {
3939
/// Set after applying a Codex profile when prior threads use another provider,
4040
/// which drives the migration prompt sheet.
4141
@Published var pendingMigration: MigrationPrompt?
42+
/// Latest update-check result, shown in About.
43+
@Published private(set) var updateStatus: UpdateStatus = .idle
4244
let activity = ActivityStore()
4345

4446
private let upstream: CopilotUpstream
@@ -99,6 +101,18 @@ final class AppState: ObservableObject {
99101
startProxy()
100102
}
101103
}
104+
checkForUpdates()
105+
}
106+
107+
// MARK: Updates
108+
109+
func checkForUpdates() {
110+
guard updateStatus != .checking else { return }
111+
updateStatus = .checking
112+
Task {
113+
let result = await UpdateChecker.check(currentVersion: AppInfo.version)
114+
self.updateStatus = result
115+
}
102116
}
103117

104118
var endpoint: ConfigWriter.Endpoint {
@@ -299,7 +313,7 @@ final class AppState: ObservableObject {
299313
self.requestCount = count
300314
self.lastError = err
301315
let stats = await engine.drainModelStats()
302-
self.activity.record(requests: stats.requests, tokens: stats.tokens)
316+
self.activity.record(requests: stats.requests, inputTokens: stats.input, outputTokens: stats.output)
303317
if let err {
304318
self.lastActivity = "Last error: \(err.prefix(80))"
305319
} else if count > 0 {
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import Foundation
2+
3+
/// Approximate official API list prices for estimating the *equivalent* cost of token
4+
/// usage. The user is on a fixed Copilot subscription, so this is a "what would these
5+
/// tokens cost at the underlying provider's API" figure, not a real bill.
6+
///
7+
/// Prices are USD per 1,000,000 tokens, input/output separate. These are approximate
8+
/// list prices as of 2026-07 and WILL drift — update the table when providers change
9+
/// pricing. Unknown models price at 0.
10+
struct ModelPrice: Equatable {
11+
let inputPerM: Double
12+
let outputPerM: Double
13+
}
14+
15+
enum ModelPricing {
16+
/// Keyed by a normalized model id (lowercased, `[1m]`/date suffix stripped).
17+
static let table: [String: ModelPrice] = [
18+
// OpenAI
19+
"gpt-5": ModelPrice(inputPerM: 1.25, outputPerM: 10.0),
20+
"gpt-5-mini": ModelPrice(inputPerM: 0.25, outputPerM: 2.0),
21+
"gpt-4o": ModelPrice(inputPerM: 2.5, outputPerM: 10.0),
22+
"gpt-4o-mini": ModelPrice(inputPerM: 0.15, outputPerM: 0.6),
23+
"gpt-4.1": ModelPrice(inputPerM: 2.0, outputPerM: 8.0),
24+
"gpt-4-turbo": ModelPrice(inputPerM: 10.0, outputPerM: 30.0),
25+
"o1": ModelPrice(inputPerM: 15.0, outputPerM: 60.0),
26+
"o3": ModelPrice(inputPerM: 2.0, outputPerM: 8.0),
27+
"o4-mini": ModelPrice(inputPerM: 1.1, outputPerM: 4.4),
28+
// Anthropic
29+
"claude-opus-4": ModelPrice(inputPerM: 15.0, outputPerM: 75.0),
30+
"claude-sonnet-4": ModelPrice(inputPerM: 3.0, outputPerM: 15.0),
31+
"claude-haiku-4": ModelPrice(inputPerM: 0.8, outputPerM: 4.0),
32+
// Google
33+
"gemini-2.5-pro": ModelPrice(inputPerM: 1.25, outputPerM: 10.0),
34+
"gemini-2.5-flash": ModelPrice(inputPerM: 0.3, outputPerM: 2.5),
35+
]
36+
37+
/// Cost in USD for a resolved model id and its input/output token split.
38+
static func cost(model: String, inputTokens: Int, outputTokens: Int) -> Double {
39+
guard let price = price(for: model) else { return 0 }
40+
return Double(inputTokens) / 1_000_000 * price.inputPerM
41+
+ Double(outputTokens) / 1_000_000 * price.outputPerM
42+
}
43+
44+
/// Resolves a price by exact normalized id, then by model family keywords.
45+
static func price(for model: String) -> ModelPrice? {
46+
let id = normalize(model)
47+
if let exact = table[id] { return exact }
48+
49+
// Family fallback: match on family + size keyword so dated/suffixed ids still price.
50+
if id.contains("claude") || id.contains("opus") || id.contains("sonnet") || id.contains("haiku") {
51+
if id.contains("opus") { return table["claude-opus-4"] }
52+
if id.contains("sonnet") { return table["claude-sonnet-4"] }
53+
if id.contains("haiku") { return table["claude-haiku-4"] }
54+
}
55+
if id.contains("gemini") {
56+
if id.contains("flash") { return table["gemini-2.5-flash"] }
57+
return table["gemini-2.5-pro"]
58+
}
59+
if id.hasPrefix("gpt-5") { return id.contains("mini") ? table["gpt-5-mini"] : table["gpt-5"] }
60+
if id.hasPrefix("gpt-4o") { return id.contains("mini") ? table["gpt-4o-mini"] : table["gpt-4o"] }
61+
if id.hasPrefix("o1") { return table["o1"] }
62+
if id.hasPrefix("o3") { return table["o3"] }
63+
if id.hasPrefix("o4") { return table["o4-mini"] }
64+
return nil
65+
}
66+
67+
private static func normalize(_ model: String) -> String {
68+
var id = model.lowercased()
69+
if id.hasSuffix("[1m]") { id = String(id.dropLast(4)) }
70+
// Strip a trailing 8-digit date, e.g. claude-3.5-sonnet-20240620.
71+
let parts = id.split(separator: "-", omittingEmptySubsequences: false).map(String.init)
72+
if let last = parts.last, last.count == 8, last.allSatisfy(\.isNumber) {
73+
id = parts.dropLast().joined(separator: "-")
74+
}
75+
return id
76+
}
77+
}

Sources/CopilotBridge/Core/Models.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,10 @@ func formatCompactCount(_ n: Int) -> String {
141141
}
142142
return "\(n)"
143143
}
144+
145+
/// USD amount for the dashboard: `$0.00` under $1K, then `$1.2K` / `$3.4M`.
146+
func formatUSD(_ amount: Double) -> String {
147+
if amount >= 1_000_000 { return String(format: "$%.1fM", amount / 1_000_000) }
148+
if amount >= 1_000 { return String(format: "$%.1fK", amount / 1_000) }
149+
return String(format: "$%.2f", amount)
150+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import Foundation
2+
3+
/// App metadata read from the bundle (single source of truth = Info.plist).
4+
enum AppInfo {
5+
static var version: String {
6+
(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.0.0"
7+
}
8+
}
9+
10+
/// Result of an update check, surfaced to the UI.
11+
enum UpdateStatus: Equatable {
12+
case idle
13+
case checking
14+
case upToDate
15+
case available(version: String, url: String)
16+
case failed(String)
17+
}
18+
19+
/// Checks GitHub Releases for a newer version. Manual/guided install only — this never
20+
/// downloads or replaces the app; it points the user at the release download.
21+
enum UpdateChecker {
22+
static let latestReleaseURL = URL(string: "https://api.github.com/repos/cailurus/CopilotBridge/releases/latest")!
23+
24+
/// True if `remote` is a strictly higher semantic version than `local`.
25+
/// Tolerant of a leading `v` and missing components (treated as 0).
26+
static func isNewer(_ remote: String, than local: String) -> Bool {
27+
let r = components(remote)
28+
let l = components(local)
29+
let n = max(r.count, l.count)
30+
for i in 0..<n {
31+
let rv = i < r.count ? r[i] : 0
32+
let lv = i < l.count ? l[i] : 0
33+
if rv != lv { return rv > lv }
34+
}
35+
return false
36+
}
37+
38+
private static func components(_ version: String) -> [Int] {
39+
version
40+
.trimmingCharacters(in: .whitespaces)
41+
.drop { $0 == "v" || $0 == "V" }
42+
.split(separator: ".")
43+
.map { Int($0.prefix { $0.isNumber }) ?? 0 }
44+
}
45+
46+
/// Parses a GitHub `releases/latest` payload into a tag and a best download URL
47+
/// (the `.dmg` asset if present, else the release page).
48+
static func parseLatest(_ data: Data) -> (tag: String, downloadURL: String)? {
49+
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
50+
let tag = obj["tag_name"] as? String else { return nil }
51+
let assets = obj["assets"] as? [[String: Any]] ?? []
52+
let dmg = assets.first { ($0["name"] as? String)?.lowercased().hasSuffix(".dmg") == true }
53+
let url = (dmg?["browser_download_url"] as? String)
54+
?? (obj["html_url"] as? String)
55+
?? "https://github.com/cailurus/CopilotBridge/releases/latest"
56+
return (tag, url)
57+
}
58+
59+
/// Fetches the latest release and compares it to `currentVersion`.
60+
static func check(currentVersion: String) async -> UpdateStatus {
61+
var req = URLRequest(url: latestReleaseURL, timeoutInterval: 8)
62+
req.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
63+
req.setValue("CopilotBridge", forHTTPHeaderField: "User-Agent") // GitHub requires a UA
64+
do {
65+
let (data, resp) = try await URLSession.shared.data(for: req)
66+
guard let http = resp as? HTTPURLResponse, http.statusCode == 200 else {
67+
return .failed("Update check failed (HTTP \((resp as? HTTPURLResponse)?.statusCode ?? -1))")
68+
}
69+
guard let parsed = parseLatest(data) else {
70+
return .failed("Unexpected response from GitHub.")
71+
}
72+
return isNewer(parsed.tag, than: currentVersion)
73+
? .available(version: parsed.tag, url: parsed.downloadURL)
74+
: .upToDate
75+
} catch {
76+
return .failed(error.localizedDescription)
77+
}
78+
}
79+
}

0 commit comments

Comments
 (0)