From 2e5a7a0fcf5d7355e3f77fd9efdac7485b074a75 Mon Sep 17 00:00:00 2001 From: Sangrak Choi Date: Sun, 17 May 2026 15:57:21 +0900 Subject: [PATCH] fix: prevent OpenCode stats from inflating Zen usage OpenCode stats reports one aggregate across providers, so OpenCode Zen now keeps only opencode and opencode-go model rows and logs the excluded non-Zen amount for diagnostics. Constraint: OpenCode has no usable Zen usage API or provider filter in opencode stats Rejected: Removing OpenCode Zen tracking | model breakdown rows provide enough signal to isolate Zen usage Confidence: high Scope-risk: narrow Directive: Keep Zen totals tied to opencode and opencode-go model prefixes unless OpenCode adds a first-party provider usage API Tested: swiftlint provider and tests; xcodebuild OpenCodeZenProviderTests; xcodebuild clean build; launched Debug app and confirmed OpenCode Zen exclusion log Not-tested: GitHub Actions CI Co-authored-by: OmX --- .../Providers/OpenCodeZenProvider.swift | 124 +++++++++--------- .../OpenCodeZenProviderTests.swift | 122 +++++++++-------- 2 files changed, 125 insertions(+), 121 deletions(-) diff --git a/CopilotMonitor/CopilotMonitor/Providers/OpenCodeZenProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/OpenCodeZenProvider.swift index f3df05c..44e2667 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/OpenCodeZenProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/OpenCodeZenProvider.swift @@ -142,15 +142,20 @@ final class OpenCodeZenProvider: ProviderProtocol { private struct OpenCodeStats { let totalCost: Double let avgCostPerDay: Double - let sessions: Int - let messages: Int let modelCosts: [String: Double] + let modelMessages: [String: Int] + } + + private struct ModelUsageStats { + var cost: Double? + var messages: Int? } struct DisplayStatsAdjustment { let totalCost: Double let avgCostPerDay: Double let modelCosts: [String: Double] + let messages: Int let excludedCost: Double } @@ -168,12 +173,11 @@ final class OpenCodeZenProvider: ProviderProtocol { debugLog("Fetching current stats only (history tracking disabled)") let output = try await runOpenCodeStats(days: 7) let stats = try parseStats(output) - let endpointConfiguration = TokenManager.shared.getCodexEndpointConfiguration() let displayStats = Self.adjustStatsForDisplay( totalCost: stats.totalCost, avgCostPerDay: stats.avgCostPerDay, modelCosts: stats.modelCosts, - codexEndpointConfiguration: endpointConfiguration + modelMessages: stats.modelMessages ) let monthlyLimit = 1000.0 @@ -181,14 +185,14 @@ final class OpenCodeZenProvider: ProviderProtocol { logger.info("OpenCode Zen: $\(String(format: "%.2f", displayStats.totalCost)) (\(String(format: "%.1f", utilization))% of $\(monthlyLimit) limit)") if displayStats.excludedCost > 0 { let excludedSummary = String(format: "%.2f", displayStats.excludedCost) - logger.info("OpenCode Zen: Excluded $\(excludedSummary) of externally routed OpenAI usage from pay-as-you-go totals") - debugLog("Excluded $\(excludedSummary) of externally routed OpenAI usage from OpenCode Zen totals") + logger.info("OpenCode Zen: Excluded $\(excludedSummary) of non-Zen OpenCode stats usage from pay-as-you-go totals") + debugLog("Excluded $\(excludedSummary) of non-Zen OpenCode stats usage from OpenCode Zen totals") } let details = DetailedUsage( modelBreakdown: displayStats.modelCosts, - sessions: stats.sessions > 0 ? stats.sessions : nil, - messages: stats.messages > 0 ? stats.messages : nil, + sessions: nil, + messages: displayStats.messages > 0 ? displayStats.messages : nil, avgCostPerDay: displayStats.avgCostPerDay > 0 ? displayStats.avgCostPerDay : nil, monthlyCost: displayStats.totalCost, authSource: "opencode CLI via \(binarySourceDescription)" @@ -209,7 +213,7 @@ final class OpenCodeZenProvider: ProviderProtocol { let process = Process() process.executableURL = binaryPath // Use the unlimited --models form so filtering can inspect every - // reported openai/* model instead of truncating the stats table. + // reported provider/model row instead of truncating the stats table. process.arguments = ["stats", "--days", "\(days)", "--models"] let pipe = Pipe() @@ -259,54 +263,39 @@ final class OpenCodeZenProvider: ProviderProtocol { totalCost: Double, avgCostPerDay: Double, modelCosts: [String: Double], - codexEndpointConfiguration: CodexEndpointConfiguration + modelMessages: [String: Int] = [:] ) -> DisplayStatsAdjustment { - guard codexEndpointConfiguration.usesOpenAIProviderBaseURL, - case .external = codexEndpointConfiguration.mode else { - return DisplayStatsAdjustment( - totalCost: totalCost, - avgCostPerDay: avgCostPerDay, - modelCosts: modelCosts, - excludedCost: 0 - ) - } - - let excludedCost = modelCosts - .filter { isOpenAIModelRoutedThroughCodex($0.key) } + let zenModelCosts = modelCosts.filter { isOpenCodeZenModel($0.key) } + let zenCost = zenModelCosts .reduce(0.0) { partialResult, item in partialResult + max(item.value, 0) } - - guard excludedCost > 0 else { - return DisplayStatsAdjustment( - totalCost: totalCost, - avgCostPerDay: avgCostPerDay, - modelCosts: modelCosts, - excludedCost: 0 - ) - } - - let adjustedTotalCost = max(0, totalCost - excludedCost) + let excludedCost = max(0, totalCost - zenCost) let adjustedAvgCostPerDay: Double if totalCost > 0, avgCostPerDay > 0 { - adjustedAvgCostPerDay = max(0, avgCostPerDay * (adjustedTotalCost / totalCost)) + adjustedAvgCostPerDay = max(0, avgCostPerDay * (zenCost / totalCost)) } else { adjustedAvgCostPerDay = 0 } - let adjustedModelCosts = modelCosts.filter { !isOpenAIModelRoutedThroughCodex($0.key) } + let zenMessages = modelMessages + .filter { isOpenCodeZenModel($0.key) } + .reduce(0) { partialResult, item in + partialResult + max(item.value, 0) + } return DisplayStatsAdjustment( - totalCost: adjustedTotalCost, + totalCost: zenCost, avgCostPerDay: adjustedAvgCostPerDay, - modelCosts: adjustedModelCosts, + modelCosts: zenModelCosts, + messages: zenMessages, excludedCost: excludedCost ) } - static func isOpenAIModelRoutedThroughCodex(_ modelName: String) -> Bool { + static func isOpenCodeZenModel(_ modelName: String) -> Bool { let normalized = modelName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return normalized.hasPrefix("openai/") + return normalized.hasPrefix("opencode/") || normalized.hasPrefix("opencode-go/") } /// Parses opencode stats output using regex patterns. @@ -332,37 +321,27 @@ final class OpenCodeZenProvider: ProviderProtocol { throw ProviderError.decodingError("Invalid avg cost value") } - let sessionsPattern = #"│Sessions\s+([0-9,]+)"# - guard let sessionsMatch = output.range(of: sessionsPattern, options: .regularExpression) else { - throw ProviderError.decodingError("Cannot parse sessions") - } - let sessionsStr = String(output[sessionsMatch]) - .replacingOccurrences(of: #"│Sessions\s+"#, with: "", options: .regularExpression) - .replacingOccurrences(of: ",", with: "") - let sessions = Int(sessionsStr) ?? 0 - - let messagesPattern = #"│Messages\s+([0-9,]+)"# - guard let messagesMatch = output.range(of: messagesPattern, options: .regularExpression) else { - throw ProviderError.decodingError("Cannot parse messages") - } - let messagesStr = String(output[messagesMatch]) - .replacingOccurrences(of: #"│Messages\s+"#, with: "", options: .regularExpression) - .replacingOccurrences(of: ",", with: "") - let messages = Int(messagesStr) ?? 0 - let modelCosts = Self.parseModelCosts(from: output) + let modelMessages = Self.parseModelMessages(from: output) return OpenCodeStats( totalCost: totalCost, avgCostPerDay: avgCost, - sessions: sessions, - messages: messages, - modelCosts: modelCosts + modelCosts: modelCosts, + modelMessages: modelMessages ) } static func parseModelCosts(from output: String) -> [String: Double] { - var modelCosts: [String: Double] = [:] + parseModelUsageStats(from: output).compactMapValues(\.cost) + } + + static func parseModelMessages(from output: String) -> [String: Int] { + parseModelUsageStats(from: output).compactMapValues(\.messages) + } + + private static func parseModelUsageStats(from output: String) -> [String: ModelUsageStats] { + var modelUsageStats: [String: ModelUsageStats] = [:] var currentModel: String? var isInModelUsageSection = false @@ -394,7 +373,18 @@ final class OpenCodeZenProvider: ProviderProtocol { if text.hasPrefix("Cost") { guard let currentModel, let cost = dollarValue(in: text) else { continue } - modelCosts[currentModel] = cost + var stats = modelUsageStats[currentModel] ?? ModelUsageStats() + stats.cost = cost + modelUsageStats[currentModel] = stats + continue + } + + if text.hasPrefix("Messages") { + guard let currentModel, + let messages = integerValue(in: text) else { continue } + var stats = modelUsageStats[currentModel] ?? ModelUsageStats() + stats.messages = messages + modelUsageStats[currentModel] = stats continue } @@ -405,7 +395,7 @@ final class OpenCodeZenProvider: ProviderProtocol { currentModel = text } - return modelCosts + return modelUsageStats } private static func trimmedTableCell(_ line: String) -> String { @@ -429,6 +419,14 @@ final class OpenCodeZenProvider: ProviderProtocol { return valueText.flatMap(Double.init) } + private static func integerValue(in text: String) -> Int? { + guard let valueRange = text.range(of: #"[0-9][0-9,]*"#, options: .regularExpression) else { + return nil + } + let valueText = String(text[valueRange]).replacingOccurrences(of: ",", with: "") + return Int(valueText) + } + private static func isStatsMetricLine(_ text: String) -> Bool { let metricPrefixes = [ "Sessions", diff --git a/CopilotMonitor/CopilotMonitorTests/OpenCodeZenProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/OpenCodeZenProviderTests.swift index 06c285d..f93120c 100644 --- a/CopilotMonitor/CopilotMonitorTests/OpenCodeZenProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/OpenCodeZenProviderTests.swift @@ -8,12 +8,12 @@ final class OpenCodeZenProviderTests: XCTestCase { ┌────────────────────────────────────────────────────────┐ │ MODEL USAGE │ ├────────────────────────────────────────────────────────┤ - │ openai/gpt-5.5 │ + │ opencode/gpt-5.5 │ │ Messages 2,871 │ │ Input Tokens 12.5M │ │ Cost $215.2045 │ ├────────────────────────────────────────────────────────┤ - │ nano-gpt/moonshotai/kimi-k2.6:thinking │ + │ opencode-go/kimi-k2.6 │ │ Messages 18 │ │ Input Tokens 410.6K │ │ Cost $0.2251 │ @@ -22,17 +22,39 @@ final class OpenCodeZenProviderTests: XCTestCase { let modelCosts = OpenCodeZenProvider.parseModelCosts(from: output) - XCTAssertEqual(modelCosts["openai/gpt-5.5"], 215.2045) - XCTAssertEqual(modelCosts["nano-gpt/moonshotai/kimi-k2.6:thinking"], 0.2251) + XCTAssertEqual(modelCosts["opencode/gpt-5.5"], 215.2045) + XCTAssertEqual(modelCosts["opencode-go/kimi-k2.6"], 0.2251) XCTAssertEqual(modelCosts.count, 2) } + func testParseModelMessagesReadsCurrentMultilineStatsFormat() { + let output = #""" + ┌────────────────────────────────────────────────────────┐ + │ MODEL USAGE │ + ├────────────────────────────────────────────────────────┤ + │ opencode/gpt-5.5 │ + │ Messages 2,871 │ + │ Cost $215.2045 │ + ├────────────────────────────────────────────────────────┤ + │ openrouter/google/gemini-3-flash-preview │ + │ Messages 26 │ + │ Cost $0.5563 │ + └────────────────────────────────────────────────────────┘ + """# + + let modelMessages = OpenCodeZenProvider.parseModelMessages(from: output) + + XCTAssertEqual(modelMessages["opencode/gpt-5.5"], 2_871) + XCTAssertEqual(modelMessages["openrouter/google/gemini-3-flash-preview"], 26) + XCTAssertEqual(modelMessages.count, 2) + } + func testParseModelCostsIgnoresCostRowsOutsideModelUsageSection() { let output = #""" ┌────────────────────────────────────────────────────────┐ │ MODEL USAGE │ ├────────────────────────────────────────────────────────┤ - │ openai/gpt-5.5 │ + │ opencode/gpt-5.5 │ │ Messages 2,871 │ │ Cost $215.2045 │ ├────────────────────────────────────────────────────────┤ @@ -46,42 +68,44 @@ final class OpenCodeZenProviderTests: XCTestCase { let modelCosts = OpenCodeZenProvider.parseModelCosts(from: output) - XCTAssertEqual(modelCosts["openai/gpt-5.5"], 215.2045) + XCTAssertEqual(modelCosts["opencode/gpt-5.5"], 215.2045) XCTAssertNil(modelCosts["mcp-server"]) XCTAssertEqual(modelCosts.count, 1) } - func testAdjustStatsForDisplayExcludesParsedOpenAIModelsWhenOpenAIBaseURLRoutesToCodex() { - let configuration = CodexEndpointConfiguration( - mode: .external(usageURL: URL(string: "https://codex.2631.eu/api/codex/usage")!), - source: "/tmp/opencode.json", - usesOpenAIProviderBaseURL: true - ) + func testAdjustStatsForDisplayKeepsOnlyOpenCodeZenProviderPrefixes() { let modelCosts = [ - "openai/gpt-5.5": 215.2045, - "openai/gpt-5.4": 4.2252, - "nano-gpt/moonshotai/kimi-k2.6:thinking": 0.2251 + "opencode/gpt-5.5": 2.0, + "opencode-go/minimax-m2.7": 4.0, + "anthropic/claude-opus-4-7": 15.0, + "openrouter/google/gemini-3-flash-preview": 9.0, + "openai/gpt-5.5-fast": 0.0 + ] + let modelMessages = [ + "opencode/gpt-5.5": 1, + "opencode-go/minimax-m2.7": 159, + "anthropic/claude-opus-4-7": 2_848, + "openrouter/google/gemini-3-flash-preview": 26 ] let adjusted = OpenCodeZenProvider.adjustStatsForDisplay( - totalCost: 219.6548, - avgCostPerDay: 31.3792, + totalCost: 30.0, + avgCostPerDay: 10.0, modelCosts: modelCosts, - codexEndpointConfiguration: configuration + modelMessages: modelMessages ) - XCTAssertEqual(adjusted.excludedCost, 219.4297, accuracy: 0.0001) - XCTAssertEqual(adjusted.totalCost, 0.2251, accuracy: 0.0001) - XCTAssertEqual(adjusted.modelCosts.keys.sorted(), ["nano-gpt/moonshotai/kimi-k2.6:thinking"]) + XCTAssertEqual(adjusted.excludedCost, 24.0, accuracy: 0.0001) + XCTAssertEqual(adjusted.totalCost, 6.0, accuracy: 0.0001) + XCTAssertEqual(adjusted.avgCostPerDay, 2.0, accuracy: 0.0001) + XCTAssertEqual(adjusted.messages, 160) + XCTAssertEqual(adjusted.modelCosts.keys.sorted(), [ + "opencode-go/minimax-m2.7", + "opencode/gpt-5.5" + ]) } - func testAdjustStatsForDisplayExcludesOpenAIModelsWhenOpenAIBaseURLRoutesToCodex() { - let configuration = CodexEndpointConfiguration( - mode: .external(usageURL: URL(string: "https://codex.2631.eu/api/codex/usage")!), - source: "/tmp/opencode.json", - usesOpenAIProviderBaseURL: true - ) - + func testAdjustStatsForDisplayReturnsZeroWhenStatsHaveNoZenModels() { let adjusted = OpenCodeZenProvider.adjustStatsForDisplay( totalCost: 22.0, avgCostPerDay: 3.142857, @@ -90,39 +114,21 @@ final class OpenCodeZenProviderTests: XCTestCase { "openai/gpt-5.4-mini": 3.7001, "nano-gpt/minimax/minimax-m2.5": 4.2045, "nano-gpt/zai-org/glm-5:thinking": 1.6042 - ], - codexEndpointConfiguration: configuration + ] ) - XCTAssertEqual(adjusted.excludedCost, 14.968, accuracy: 0.0001) - XCTAssertEqual(adjusted.totalCost, 7.032, accuracy: 0.0001) - XCTAssertEqual(adjusted.avgCostPerDay, 1.004571, accuracy: 0.0001) - XCTAssertEqual(adjusted.modelCosts.keys.sorted(), [ - "nano-gpt/minimax/minimax-m2.5", - "nano-gpt/zai-org/glm-5:thinking" - ]) + XCTAssertEqual(adjusted.excludedCost, 22.0, accuracy: 0.0001) + XCTAssertEqual(adjusted.totalCost, 0, accuracy: 0.0001) + XCTAssertEqual(adjusted.avgCostPerDay, 0, accuracy: 0.0001) + XCTAssertEqual(adjusted.messages, 0) + XCTAssertTrue(adjusted.modelCosts.isEmpty) } - func testAdjustStatsForDisplayKeepsOpenAIModelsForExplicitUsageOverride() { - let configuration = CodexEndpointConfiguration( - mode: .external(usageURL: URL(string: "https://custom.example.com/api/codex/usage")!), - source: "/tmp/opencode.json", - usesOpenAIProviderBaseURL: false - ) - - let adjusted = OpenCodeZenProvider.adjustStatsForDisplay( - totalCost: 12.0, - avgCostPerDay: 4.0, - modelCosts: [ - "openai/gpt-5.4": 9.0, - "openrouter/qwen/qwen3": 3.0 - ], - codexEndpointConfiguration: configuration - ) - - XCTAssertEqual(adjusted.excludedCost, 0) - XCTAssertEqual(adjusted.totalCost, 12.0) - XCTAssertEqual(adjusted.avgCostPerDay, 4.0) - XCTAssertEqual(adjusted.modelCosts.count, 2) + func testIsOpenCodeZenModelMatchesOnlyZenProviderPrefixes() { + XCTAssertTrue(OpenCodeZenProvider.isOpenCodeZenModel("opencode/gpt-5.5")) + XCTAssertTrue(OpenCodeZenProvider.isOpenCodeZenModel(" opencode-go/minimax-m2.7 ")) + XCTAssertFalse(OpenCodeZenProvider.isOpenCodeZenModel("openai/gpt-5.5")) + XCTAssertFalse(OpenCodeZenProvider.isOpenCodeZenModel("openrouter/opencode/gpt-5.5")) + XCTAssertFalse(OpenCodeZenProvider.isOpenCodeZenModel("nano-gpt/minimax/minimax-m2.5")) } }