diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a1a84d109..598215f85e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.29.2 — Unreleased ### Added +- MiniMax: add a redacted diagnostic CLI export for safe issue reports (#1128). Thanks @Yuxin-Qiao! - Antigravity: show the complete per-model quota breakdown alongside the existing summary lanes (#1139). Thanks @guhyun9454! - Widget: show tertiary usage rows for providers that expose a third quota lane (#1160). Thanks @LeoLin990405! - DeepSeek: show optional web-session usage and cost summaries alongside the balance card (#1166). Thanks @Yuxin-Qiao! diff --git a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift new file mode 100644 index 0000000000..8ddeec2d6f --- /dev/null +++ b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift @@ -0,0 +1,129 @@ +import CodexBarCore +import Commander +import Foundation + +extension CodexBarCLI { + static func runDiagnose(_ values: ParsedValues) async { + let output = CLIOutputPreferences.from(values: values) + let config = Self.loadConfig(output: output) + + let providerRaw = values.options["provider"]?.last ?? "minimax" + guard providerRaw.lowercased() == "minimax" else { + Self.exit( + code: .failure, + message: "Error: only 'minimax' provider is supported for diagnose", + output: output, + kind: .args) + } + + let format = Self.decodeFormat(from: values) + guard format == .json else { + Self.exit( + code: .failure, + message: "Error: only JSON format is supported for diagnose", + output: output, + kind: .args) + } + + let pretty = values.flags.contains("pretty") + let verbose = values.flags.contains("verbose") + let browserDetection = BrowserDetection() + let fetcher = UsageFetcher() + + let tokenSelection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext: TokenAccountCLIContext + do { + tokenContext = try TokenAccountCLIContext( + selection: tokenSelection, + config: config, + verbose: verbose) + } catch { + Self.exit(code: .failure, message: "Error: \(error.localizedDescription)", output: output, kind: .config) + } + + let activeMiniMaxAccount: ProviderTokenAccount? = { + let accounts = (try? tokenContext.resolvedAccounts(for: .minimax)) ?? [] + return accounts.first + }() + let env = tokenContext.environment( + base: ProcessInfo.processInfo.environment, + provider: .minimax, + account: activeMiniMaxAccount, + codexActiveSourceOverride: nil) + let settings = tokenContext.settingsSnapshot( + for: .minimax, + account: activeMiniMaxAccount, + codexActiveSourceOverride: nil) + let sourceMode = tokenContext.preferredSourceMode(for: .minimax) + + let authMode = Self.resolveMiniMaxAuthMode(environment: env, settings: settings) + + let fetchContext = ProviderFetchContext( + runtime: .cli, + sourceMode: sourceMode, + includeCredits: true, + includeOptionalUsage: true, + webTimeout: 60, + webDebugDumpHTML: false, + verbose: verbose, + env: env, + settings: settings, + fetcher: fetcher, + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + + let outcome = await Self.fetchProviderUsage(provider: .minimax, context: fetchContext) + + let diagnostic = MiniMaxDiagnosticExportBuilder.build( + outcome: outcome, + settings: settings, + authMode: authMode) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + if pretty { + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + } else { + encoder.outputFormatting = .sortedKeys + } + + do { + let data = try encoder.encode(diagnostic) + var jsonString = String(data: data, encoding: .utf8) ?? "{}" + jsonString = LogRedactor.redact(jsonString) + print(jsonString) + } catch { + Self.exit( + code: .failure, + message: "Error encoding diagnostic: \(error.localizedDescription)", + output: output, + kind: .runtime) + } + + Self.exit(code: .success, output: output, kind: .runtime) + } +} + +extension CodexBarCLI { + static func resolveMiniMaxAuthMode( + environment: [String: String], + settings: ProviderSettingsSnapshot?) -> MiniMaxAuthMode + { + let apiToken = ProviderTokenResolver.minimaxToken(environment: environment) + let envCookieHeader = ProviderTokenResolver.minimaxCookie(environment: environment) + let settingsCookieHeader = CookieHeaderNormalizer.normalize(settings?.minimax?.manualCookieHeader) + let cookieHeader = envCookieHeader ?? settingsCookieHeader + return MiniMaxAuthMode.resolve(apiToken: apiToken, cookieHeader: cookieHeader) + } +} + +#if DEBUG +extension CodexBarCLI { + static func _resolveMiniMaxAuthModeForTesting( + environment: [String: String], + settings: ProviderSettingsSnapshot?) -> MiniMaxAuthMode + { + self.resolveMiniMaxAuthMode(environment: environment, settings: settings) + } +} +#endif diff --git a/Sources/CodexBarCLI/CLIEntry.swift b/Sources/CodexBarCLI/CLIEntry.swift index f6b7aa061a..35eb55b2be 100644 --- a/Sources/CodexBarCLI/CLIEntry.swift +++ b/Sources/CodexBarCLI/CLIEntry.swift @@ -30,7 +30,7 @@ enum CodexBarCLI { do { let invocation = try program.resolve(argv: argv) - Self.bootstrapLogging(values: invocation.parsedValues) + Self.bootstrapLogging(path: invocation.path, values: invocation.parsedValues) switch invocation.path { case ["usage"]: await self.runUsage(invocation.parsedValues) @@ -52,6 +52,8 @@ enum CodexBarCLI { self.runConfigSetAPIKey(invocation.parsedValues) case ["cache", "clear"]: self.runCacheClear(invocation.parsedValues) + case ["diagnose"]: + await self.runDiagnose(invocation.parsedValues) default: Self.exit( code: .failure, @@ -74,6 +76,7 @@ enum CodexBarCLI { let configProviderToggleSignature = CommandSignature.describe(ConfigProviderToggleOptions()) let configSetAPIKeySignature = CommandSignature.describe(ConfigSetAPIKeyOptions()) let cacheSignature = CommandSignature.describe(CacheOptions()) + let diagnoseSignature = CommandSignature.describe(DiagnoseOptions()) return [ CommandDescriptor( @@ -142,17 +145,27 @@ enum CodexBarCLI { signature: cacheSignature), ], defaultSubcommandName: "clear"), + CommandDescriptor( + name: "diagnose", + abstract: "Run provider diagnostic and emit safe JSON export", + discussion: nil, + signature: diagnoseSignature), ] } // MARK: - Helpers - private static func bootstrapLogging(values: ParsedValues) { + private static func bootstrapLogging(path: [String], values: ParsedValues) { + CodexBarLog.bootstrapIfNeeded(self.loggingConfiguration(path: path, values: values)) + } + + static func loggingConfiguration(path: [String], values: ParsedValues) -> CodexBarLog.Configuration { let isJSON = values.flags.contains("jsonOutput") || values.flags.contains("jsonOnly") let verbose = values.flags.contains("verbose") let rawLevel = values.options["logLevel"]?.last let level = Self.resolvedLogLevel(verbose: verbose, rawLevel: rawLevel) - CodexBarLog.bootstrapIfNeeded(.init(destination: .stderr, level: level, json: isJSON)) + let destination: CodexBarLog.Destination = path == ["diagnose"] ? .discard : .stderr + return .init(destination: destination, level: level, json: isJSON) } static func resolvedLogLevel(verbose: Bool, rawLevel: String?) -> CodexBarLog.Level { diff --git a/Sources/CodexBarCLI/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift index 0832594874..b46362d256 100644 --- a/Sources/CodexBarCLI/CLIHelp.swift +++ b/Sources/CodexBarCLI/CLIHelp.swift @@ -168,6 +168,26 @@ extension CodexBarCLI { """ } + static func diagnoseHelp(version: String) -> String { + """ + CodexBar \(version) + + Usage: + codexbar diagnose --provider minimax --format json + [--json-output] [--log-level ] + [-v|--verbose] + [--pretty] + + Description: + Run a MiniMax diagnostic fetch and print a safe JSON export for issue reporting. + The export is redacted and omits raw API tokens, cookies, auth headers, emails, + account IDs, org IDs, raw responses, and billing-history records. + + Examples: + codexbar diagnose --provider minimax --format json --pretty + """ + } + static func rootHelp(version: String) -> String { """ CodexBar \(version) @@ -198,6 +218,7 @@ extension CodexBarCLI { codexbar config disable --provider codexbar config set-api-key --provider (--api-key |--stdin) codexbar cache clear <--cookies|--cost|--all> [--provider ] + codexbar diagnose --provider minimax --format json [--pretty] Global flags: -h, --help Show help @@ -218,6 +239,7 @@ extension CodexBarCLI { codexbar config enable --provider grok codexbar config set-api-key --provider elevenlabs --stdin codexbar cache clear --cookies + codexbar diagnose --provider minimax --format json --pretty """ } } diff --git a/Sources/CodexBarCLI/CLIHelpers.swift b/Sources/CodexBarCLI/CLIHelpers.swift index c4444d8c9e..3ec5f3e9e5 100644 --- a/Sources/CodexBarCLI/CLIHelpers.swift +++ b/Sources/CodexBarCLI/CLIHelpers.swift @@ -366,6 +366,10 @@ extension CodexBarCLI { CommandSignature.describe(CacheOptions()) } + static func _diagnoseSignatureForTesting() -> CommandSignature { + CommandSignature.describe(DiagnoseOptions()) + } + static func _configSetAPIKeySignatureForTesting() -> CommandSignature { CommandSignature.describe(ConfigSetAPIKeyOptions()) } diff --git a/Sources/CodexBarCLI/CLIIO.swift b/Sources/CodexBarCLI/CLIIO.swift index 49242dabf1..1e371fd1b2 100644 --- a/Sources/CodexBarCLI/CLIIO.swift +++ b/Sources/CodexBarCLI/CLIIO.swift @@ -33,6 +33,8 @@ extension CodexBarCLI { print(Self.configHelp(version: version)) case "cache", "clear": print(Self.cacheHelp(version: version)) + case "diagnose": + print(Self.diagnoseHelp(version: version)) default: print(Self.rootHelp(version: version)) } diff --git a/Sources/CodexBarCLI/DiagnoseOptions.swift b/Sources/CodexBarCLI/DiagnoseOptions.swift new file mode 100644 index 0000000000..b8dfd08edd --- /dev/null +++ b/Sources/CodexBarCLI/DiagnoseOptions.swift @@ -0,0 +1,23 @@ +import CodexBarCore +import Commander +import Foundation + +struct DiagnoseOptions: CommanderParsable { + @Flag(names: [.short("v"), .long("verbose")], help: "Enable verbose logging") + var verbose: Bool = false + + @Flag(name: .long("json-output"), help: "Emit machine-readable logs") + var jsonOutput: Bool = false + + @Option(name: .long("log-level"), help: "Set log level (trace|verbose|debug|info|warning|error|critical)") + var logLevel: String? + + @Option(name: .long("provider"), help: "Provider to diagnose: minimax") + var provider: String? + + @Option(name: .long("format"), help: "Output format: json") + var format: String? + + @Flag(name: .long("pretty"), help: "Pretty-print JSON output") + var pretty: Bool = false +} diff --git a/Sources/CodexBarCore/Logging/CodexBarLog.swift b/Sources/CodexBarCore/Logging/CodexBarLog.swift index 2f9db069c4..7a64eb328a 100644 --- a/Sources/CodexBarCore/Logging/CodexBarLog.swift +++ b/Sources/CodexBarCore/Logging/CodexBarLog.swift @@ -4,6 +4,7 @@ import Logging public enum CodexBarLog { public enum Destination: Sendable { case stderr + case discard case oslog(subsystem: String) } @@ -85,6 +86,8 @@ public enum CodexBarLog { case .stderr: if config.json { return JSONStderrLogHandler(label: label) } return StreamLogHandler.standardError(label: label) + case .discard: + return DiscardLogHandler() case let .oslog(subsystem): #if canImport(os) return OSLogLogHandler(label: label, subsystem: subsystem) @@ -154,6 +157,18 @@ public enum CodexBarLog { } } +private struct DiscardLogHandler: LogHandler { + var metadata: Logger.Metadata = [:] + var logLevel: Logger.Level = .critical + + subscript(metadataKey metadataKey: String) -> Logger.Metadata.Value? { + get { self.metadata[metadataKey] } + set { self.metadata[metadataKey] = newValue } + } + + func log(event _: LogEvent) {} +} + public struct CodexBarLogger: Sendable { private let logFn: @Sendable (CodexBarLog.Level, String, [String: String]?) -> Void diff --git a/Sources/CodexBarCore/Logging/LogRedactor.swift b/Sources/CodexBarCore/Logging/LogRedactor.swift index 02cf42c404..d664fd7f84 100644 --- a/Sources/CodexBarCore/Logging/LogRedactor.swift +++ b/Sources/CodexBarCore/Logging/LogRedactor.swift @@ -18,13 +18,23 @@ public enum LogRedactor { pattern: #"(?i)(authorization\s*:\s*)([^\r\n]+)"#) private static let bearerRegex = Self.makeRegex( pattern: #"(?i)\bbearer\s+[a-z0-9._\-]+=*\b"#) + private static let minimaxCodingPlanTokenRegex = Self.makeRegex( + pattern: #"sk-cp-[^\s"'`;,)>\]]+"#) + private static let minimaxApiTokenRegex = Self.makeRegex( + pattern: #"sk-api-[^\s"'`;,)>\]]+"#) public static func redact(_ text: String) -> String { var output = text + // Email is broad and safe first output = self.replace(self.emailRegex, in: output, with: "") + // MiniMax tokens before broader rules catch them + output = self.replace(self.minimaxCodingPlanTokenRegex, in: output, with: "") + output = self.replace(self.minimaxApiTokenRegex, in: output, with: "") + // Bearer catches "bearer " before authorization wraps it + output = self.replace(self.bearerRegex, in: output, with: "Bearer ") + // Authorization catches the rest (already-redacted content) output = self.replace(self.cookieHeaderRegex, in: output, with: "$1") output = self.replace(self.authorizationRegex, in: output, with: "$1") - output = self.replace(self.bearerRegex, in: output, with: "Bearer ") return output } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAuthMode.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAuthMode.swift index 89f4c9c81d..ea0fd87a68 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAuthMode.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAuthMode.swift @@ -29,6 +29,14 @@ public enum MiniMaxAuthMode: Sendable { self != .apiToken } + public var description: String { + switch self { + case .apiToken: "apiToken" + case .cookie: "cookie" + case .none: "none" + } + } + private static func cleaned(_ raw: String?) -> String? { guard let value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDiagnosticExport.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDiagnosticExport.swift new file mode 100644 index 0000000000..e2b6253be5 --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDiagnosticExport.swift @@ -0,0 +1,211 @@ +import Foundation + +public struct MiniMaxDiagnosticExport: Codable, Sendable { + public let timestamp: Date + public let provider: String + public let source: String + public let authMode: String + public let authConfigured: Bool + public let usage: MiniMaxDiagnosticUsage? + public let fetchAttempts: [MiniMaxDiagnosticFetchAttempt] + public let error: MiniMaxDiagnosticError? + public let settingsSummary: MiniMaxSettingsSummary + + public init( + timestamp: Date, + provider: String, + source: String, + authMode: String, + authConfigured: Bool, + usage: MiniMaxDiagnosticUsage?, + fetchAttempts: [MiniMaxDiagnosticFetchAttempt], + error: MiniMaxDiagnosticError?, + settingsSummary: MiniMaxSettingsSummary) + { + self.timestamp = timestamp + self.provider = provider + self.source = source + self.authMode = authMode + self.authConfigured = authConfigured + self.usage = usage + self.fetchAttempts = fetchAttempts + self.error = error + self.settingsSummary = settingsSummary + } +} + +public struct MiniMaxDiagnosticUsage: Codable, Sendable { + public let planName: String? + public let availablePrompts: Int? + public let currentPrompts: Int? + public let remainingPrompts: Int? + public let windowMinutes: Int? + public let usedPercent: Double? + public let resetsAt: Date? + public let services: [MiniMaxDiagnosticServiceUsage]? + + public init( + planName: String?, + availablePrompts: Int?, + currentPrompts: Int?, + remainingPrompts: Int?, + windowMinutes: Int?, + usedPercent: Double?, + resetsAt: Date?, + services: [MiniMaxDiagnosticServiceUsage]?) + { + self.planName = planName + self.availablePrompts = availablePrompts + self.currentPrompts = currentPrompts + self.remainingPrompts = remainingPrompts + self.windowMinutes = windowMinutes + self.usedPercent = usedPercent + self.resetsAt = resetsAt + self.services = services + } + + public init(from snapshot: MiniMaxUsageSnapshot) { + self.planName = snapshot.planName + self.availablePrompts = snapshot.availablePrompts + self.currentPrompts = snapshot.currentPrompts + self.remainingPrompts = snapshot.remainingPrompts + self.windowMinutes = snapshot.windowMinutes + self.usedPercent = snapshot.usedPercent + self.resetsAt = snapshot.resetsAt + self.services = snapshot.services?.map { MiniMaxDiagnosticServiceUsage(from: $0) } + } +} + +public struct MiniMaxDiagnosticServiceUsage: Codable, Sendable { + public let displayName: String + public let percent: Double + public let windowType: String + public let resetsAt: Date? + public let resetDescription: String? + + public init(from service: MiniMaxServiceUsage) { + self.displayName = service.displayName + self.percent = service.percent + self.windowType = service.windowType + self.resetsAt = service.resetsAt + self.resetDescription = service.resetDescription + } +} + +public struct MiniMaxDiagnosticFetchAttempt: Codable, Sendable { + public let kind: String + public let wasAvailable: Bool + public let errorCategory: String? + + public init( + kind: String, + wasAvailable: Bool, + errorCategory: String?) + { + self.kind = kind + self.wasAvailable = wasAvailable + self.errorCategory = errorCategory + } + + public init(from attempt: ProviderFetchAttempt) { + self.kind = Self.kindLabel(attempt.kind) + self.wasAvailable = attempt.wasAvailable + self.errorCategory = attempt.errorDescription.map { Self.errorCategoryLabel($0) } + } + + private static func kindLabel(_ kind: ProviderFetchKind) -> String { + switch kind { + case .cli: "cli" + case .web: "web" + case .oauth: "oauth" + case .apiToken: "api" + case .localProbe: "local" + case .webDashboard: "web" + } + } + + private static func errorCategoryLabel(_ description: String?) -> String { + guard let desc = description?.lowercased() else { return "unknown" } + if desc.contains("network") || desc.contains("timeout") || desc.contains("connection") { + return "network" + } + if desc.contains("auth") || desc.contains("credential") || desc.contains("token") || desc.contains("cookie") { + return "auth" + } + if desc.contains("api") || desc.contains("http") || desc.contains("404") || desc.contains("403") { + return "api" + } + if desc.contains("parse") || desc.contains("format") || desc.contains("decode") { + return "parse" + } + return "unknown" + } +} + +public struct MiniMaxDiagnosticError: Codable, Sendable { + public let category: String + public let safeDescription: String + + public init(category: String, safeDescription: String) { + self.category = category + self.safeDescription = safeDescription + } + + public init(from error: Error) { + self.category = Self.errorCategory(error) + self.safeDescription = Self.safeDescription(for: error) + } + + private static func errorCategory(_ error: Error) -> String { + if let minimaxError = error as? MiniMaxUsageError { + switch minimaxError { + case .networkError: return "network" + case .invalidCredentials: return "auth" + case .apiError: return "api" + case .parseFailed: return "parse" + } + } + if let settingsError = error as? MiniMaxSettingsError { + switch settingsError { + case .missingCookie: return "auth" + } + } + if error is MiniMaxAPISettingsError { return "auth" } + return "unknown" + } + + private static func safeDescription(for error: Error) -> String { + if let minimaxError = error as? MiniMaxUsageError { + switch minimaxError { + case .networkError: + return "Network error - check your connection" + case .invalidCredentials: + return "Invalid credentials - please re-authenticate" + case .apiError: + return "API error - service returned an unexpected response" + case .parseFailed: + return "Parse error - unexpected response format" + } + } + if let settingsError = error as? MiniMaxSettingsError { + switch settingsError { + case .missingCookie: + return "Cookie not configured - import from browser or provide manually" + } + } + if error is MiniMaxAPISettingsError { + return "API settings error - check your token configuration" + } + return "An unexpected error occurred" + } +} + +public struct MiniMaxSettingsSummary: Codable, Sendable { + public let apiRegion: String + public let authMode: String + + public init(apiRegion: String, authMode: String) { + self.apiRegion = apiRegion + self.authMode = authMode + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDiagnosticExportBuilder.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDiagnosticExportBuilder.swift new file mode 100644 index 0000000000..b6d9891d6f --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDiagnosticExportBuilder.swift @@ -0,0 +1,51 @@ +import Foundation + +public enum MiniMaxDiagnosticExportBuilder { + public static func build( + outcome: ProviderFetchOutcome, + settings: ProviderSettingsSnapshot?, + authMode: MiniMaxAuthMode) -> MiniMaxDiagnosticExport + { + let sourceLabel = outcome.sourceLabel + let authConfigured = outcome.authConfigured(authMode: authMode) + let usage = outcome.usageSnapshot.map { MiniMaxDiagnosticUsage(from: $0) } + let error = outcome.failureError.map { MiniMaxDiagnosticError(from: $0) } + + let settingsSummary = MiniMaxSettingsSummary( + apiRegion: settings?.minimax?.apiRegion.rawValue ?? "global", + authMode: authMode.description) + + return MiniMaxDiagnosticExport( + timestamp: Date(), + provider: "minimax", + source: sourceLabel, + authMode: authMode.description, + authConfigured: authConfigured, + usage: usage, + fetchAttempts: outcome.attempts.map { MiniMaxDiagnosticFetchAttempt(from: $0) }, + error: error, + settingsSummary: settingsSummary) + } +} + +extension ProviderFetchOutcome { + fileprivate var sourceLabel: String { + guard case let .success(result) = result else { return "failed" } + return result.sourceLabel + } + + fileprivate func authConfigured(authMode: MiniMaxAuthMode) -> Bool { + guard case .success = result else { return authMode.usesAPIToken || authMode.usesCookie } + return true + } + + fileprivate var usageSnapshot: MiniMaxUsageSnapshot? { + guard case let .success(result) = result else { return nil } + return result.usage.minimaxUsage + } + + fileprivate var failureError: Error? { + guard case let .failure(error) = result else { return nil } + return error + } +} diff --git a/Tests/CodexBarTests/CLIArgumentParsingTests.swift b/Tests/CodexBarTests/CLIArgumentParsingTests.swift index 13bc0453f2..86377e8a0a 100644 --- a/Tests/CodexBarTests/CLIArgumentParsingTests.swift +++ b/Tests/CodexBarTests/CLIArgumentParsingTests.swift @@ -64,4 +64,24 @@ struct CLIArgumentParsingTests { #expect(!parsed.flags.contains("jsonOutput")) #expect(CodexBarCLI._decodeFormatForTesting(from: parsed) == .json) } + + @Test + func `diagnose accepts json output flag but discards provider logs`() throws { + let signature = CodexBarCLI._diagnoseSignatureForTesting() + let parser = CommandParser(signature: signature) + let parsed = try parser.parse(arguments: [ + "--provider", "minimax", + "--format", "json", + "--json-output", + ]) + + #expect(parsed.flags.contains("jsonOutput")) + let config = CodexBarCLI.loggingConfiguration(path: ["diagnose"], values: parsed) + switch config.destination { + case .discard: + break + case .stderr, .oslog: + Issue.record("diagnose should not emit provider logs beside the safe JSON export") + } + } } diff --git a/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift b/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift new file mode 100644 index 0000000000..ecea06c63a --- /dev/null +++ b/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift @@ -0,0 +1,61 @@ +import CodexBarCore +import Testing +@testable import CodexBarCLI + +struct CLIDiagnoseCommandTests { + @Test + func `diagnose help describes minimax JSON export`() { + let help = CodexBarCLI.diagnoseHelp(version: "0.0.0") + + #expect(help.contains("codexbar diagnose --provider minimax --format json")) + #expect(help.contains("safe JSON export")) + #expect(help.contains("raw API tokens")) + } + + private func makeSettingsWithMiniMaxCookie(_ manualCookieHeader: String) -> ProviderSettingsSnapshot { + ProviderSettingsSnapshot( + debugMenuEnabled: false, + debugKeepCLISessionsAlive: false, + codex: nil, + claude: nil, + cursor: nil, + opencode: nil, + opencodego: nil, + alibaba: nil, + factory: nil, + minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings( + cookieSource: .manual, + manualCookieHeader: manualCookieHeader, + apiRegion: .global), + manus: nil, + zai: nil, + copilot: nil, + kilo: nil, + kimi: nil, + augment: nil, + amp: nil, + ollama: nil) + } + + @Test + func `diagnose auth mode uses settings-backed MiniMax manual cookie when env token is absent`() { + let settings = self.makeSettingsWithMiniMaxCookie("Cookie: session_id=demo-cookie") + + let authMode = CodexBarCLI._resolveMiniMaxAuthModeForTesting( + environment: [:], + settings: settings) + + #expect(authMode == .cookie) + } + + @Test + func `diagnose auth mode keeps apiToken precedence over settings cookie`() { + let settings = self.makeSettingsWithMiniMaxCookie("Cookie: session_id=demo-cookie") + + let authMode = CodexBarCLI._resolveMiniMaxAuthModeForTesting( + environment: [MiniMaxAPISettingsReader.apiTokenKey: "sk-api-demo-token"], + settings: settings) + + #expect(authMode == .apiToken) + } +} diff --git a/Tests/CodexBarTests/MiniMaxDiagnosticExportTests.swift b/Tests/CodexBarTests/MiniMaxDiagnosticExportTests.swift new file mode 100644 index 0000000000..1a13d55cf5 --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxDiagnosticExportTests.swift @@ -0,0 +1,272 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct MiniMaxDiagnosticExportTests { + @Test + func `diagnostic export encodes to JSON with all safe fields`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let export = MiniMaxDiagnosticExport( + timestamp: now, + provider: "minimax", + source: "api", + authMode: "apiToken", + authConfigured: true, + usage: MiniMaxDiagnosticUsage( + planName: "Max", + availablePrompts: 1000, + currentPrompts: 250, + remainingPrompts: 750, + windowMinutes: 300, + usedPercent: 25, + resetsAt: now.addingTimeInterval(18000), + services: nil), + fetchAttempts: [ + MiniMaxDiagnosticFetchAttempt( + kind: "api", + wasAvailable: true, + errorCategory: nil), + ], + error: nil, + settingsSummary: MiniMaxSettingsSummary( + apiRegion: "global", + authMode: "apiToken")) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = .prettyPrinted + let data = try encoder.encode(export) + let json = String(data: data, encoding: .utf8) ?? "" + + #expect(json.contains("\"provider\"")) + #expect(json.contains("\"minimax\"")) + #expect(json.contains("\"authConfigured\"")) + #expect(!json.contains("sk-cp-")) + #expect(!json.contains("sk-api-")) + #expect(!json.contains("Bearer")) + #expect(!json.contains("errorMessage")) + #expect(!json.contains("localizedDescription")) + } + + @Test + func `raw error text never appears in encoded JSON`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let export = MiniMaxDiagnosticExport( + timestamp: now, + provider: "minimax", + source: "failed", + authMode: "apiToken", + authConfigured: true, + usage: nil, + fetchAttempts: [ + MiniMaxDiagnosticFetchAttempt( + kind: "api", + wasAvailable: true, + errorCategory: "network"), + ], + error: MiniMaxDiagnosticError( + category: "network", + safeDescription: "Network error - check your connection"), + settingsSummary: MiniMaxSettingsSummary( + apiRegion: "global", + authMode: "apiToken")) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = .prettyPrinted + let data = try encoder.encode(export) + let json = String(data: data, encoding: .utf8) ?? "" + + #expect(!json.contains("connection refused")) + #expect(!json.contains("network probe")) + #expect(!json.contains("2024-01-01")) + #expect(!json.contains("not safe to expose")) + #expect(!json.contains("localizedDescription")) + #expect(!json.contains("raw")) + #expect(!json.contains("errorMessage")) + #expect(json.contains("errorCategory")) + #expect(json.contains("\"network\"")) + } + + @Test + func `diagnostic error maps MiniMaxUsageError categories safely`() { + let networkError = MiniMaxUsageError.networkError("connection refused") + let invalidCreds = MiniMaxUsageError.invalidCredentials + let apiError = MiniMaxUsageError.apiError("HTTP 404") + let parseError = MiniMaxUsageError.parseFailed("unexpected") + + let diagNetwork = MiniMaxDiagnosticError(from: networkError) + #expect(diagNetwork.category == "network") + #expect(!diagNetwork.safeDescription.contains("connection refused")) + + let diagCreds = MiniMaxDiagnosticError(from: invalidCreds) + #expect(diagCreds.category == "auth") + + let diagAPI = MiniMaxDiagnosticError(from: apiError) + #expect(diagAPI.category == "api") + + let diagParse = MiniMaxDiagnosticError(from: parseError) + #expect(diagParse.category == "parse") + } + + @Test + func `diagnostic error maps MiniMaxSettingsError categories safely`() { + let missingCookie = MiniMaxSettingsError.missingCookie + let diag = MiniMaxDiagnosticError(from: missingCookie) + #expect(diag.category == "auth") + #expect(diag.safeDescription.contains("Cookie")) + } + + @Test + func `fetch attempt error maps to safe category, never raw text`() { + let attemptWithRawError = ProviderFetchAttempt( + strategyID: "minimax.api", + kind: .apiToken, + wasAvailable: true, + errorDescription: "MiniMax API timeout after 30 seconds - connection refused for host platform.minimax.io") + let diagAttempt = MiniMaxDiagnosticFetchAttempt(from: attemptWithRawError) + #expect(diagAttempt.kind == "api") + #expect(diagAttempt.wasAvailable == true) + let errorCategoryOne = diagAttempt.errorCategory + #expect(errorCategoryOne == "network") + let cat1 = errorCategoryOne ?? "" + #expect(!cat1.contains("timeout")) + #expect(!cat1.contains("connection refused")) + #expect(!cat1.contains("platform.minimax.io")) + + let attemptWithAuthError = ProviderFetchAttempt( + strategyID: "minimax.web", + kind: .web, + wasAvailable: false, + errorDescription: "invalid auth token cookie HERTZ-SESSION=abc123") + let diagAuthAttempt = MiniMaxDiagnosticFetchAttempt(from: attemptWithAuthError) + #expect(diagAuthAttempt.wasAvailable == false) + let errorCategoryTwo = diagAuthAttempt.errorCategory + #expect(errorCategoryTwo == "auth") + let cat2 = errorCategoryTwo ?? "" + #expect(!cat2.contains("HERTZ-SESSION")) + } + + @Test + func `usage maps from MiniMaxUsageSnapshot correctly`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: 1000, + currentPrompts: 250, + remainingPrompts: 750, + windowMinutes: 300, + usedPercent: 25, + resetsAt: now.addingTimeInterval(18000), + updatedAt: now, + services: nil) + + let diagUsage = MiniMaxDiagnosticUsage(from: snapshot) + #expect(diagUsage.planName == "Max") + #expect(diagUsage.availablePrompts == 1000) + #expect(diagUsage.currentPrompts == 250) + #expect(diagUsage.remainingPrompts == 750) + #expect(diagUsage.windowMinutes == 300) + #expect(diagUsage.usedPercent == 25) + } + + @Test + func `service usage maps from MiniMaxServiceUsage correctly`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let service = MiniMaxServiceUsage( + serviceType: "Text Generation", + windowType: "5 hours", + timeRange: "10:00-15:00(UTC+8)", + usage: 750, + limit: 1000, + percent: 75, + resetsAt: now.addingTimeInterval(18000), + resetDescription: "5 hours") + + let diagService = MiniMaxDiagnosticServiceUsage(from: service) + #expect(diagService.displayName == "Text Generation") + #expect(diagService.percent == 75) + #expect(diagService.windowType == "5 hours") + } + + @Test + func `builder creates safe diagnostic with error on failure`() { + let error = MiniMaxUsageError.networkError("timeout") + let outcome = ProviderFetchOutcome( + result: .failure(error), + attempts: [ + ProviderFetchAttempt( + strategyID: "minimax.api", + kind: .apiToken, + wasAvailable: true, + errorDescription: "timeout"), + ]) + + let diag = MiniMaxDiagnosticExportBuilder.build( + outcome: outcome, + settings: nil, + authMode: .apiToken) + + #expect(diag.provider == "minimax") + #expect(diag.source == "failed") + #expect(diag.authConfigured == true) + #expect(diag.usage == nil) + #expect(diag.error != nil) + #expect(diag.error?.category == "network") + #expect(diag.fetchAttempts.count == 1) + #expect(diag.fetchAttempts[0].errorCategory == "network") + } + + @Test + func `builder creates safe diagnostic with usage on success`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: 1000, + currentPrompts: 250, + remainingPrompts: 750, + windowMinutes: 300, + usedPercent: 25, + resetsAt: now.addingTimeInterval(18000), + updatedAt: now) + + let result = ProviderFetchResult( + usage: UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(18000), + resetDescription: nil), + secondary: nil, + tertiary: nil, + minimaxUsage: snapshot, + updatedAt: now), + credits: nil, + dashboard: nil, + sourceLabel: "api", + strategyID: "minimax.api", + strategyKind: .apiToken) + + let outcome = ProviderFetchOutcome( + result: .success(result), + attempts: [ + ProviderFetchAttempt( + strategyID: "minimax.api", + kind: .apiToken, + wasAvailable: true, + errorDescription: nil), + ]) + + let diag = MiniMaxDiagnosticExportBuilder.build( + outcome: outcome, + settings: nil, + authMode: .apiToken) + + #expect(diag.provider == "minimax") + #expect(diag.source == "api") + #expect(diag.authConfigured == true) + #expect(diag.usage != nil) + #expect(diag.usage?.planName == "Max") + #expect(diag.error == nil) + } +} diff --git a/Tests/CodexBarTests/MiniMaxLogRedactorTests.swift b/Tests/CodexBarTests/MiniMaxLogRedactorTests.swift new file mode 100644 index 0000000000..d37ed2251e --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxLogRedactorTests.swift @@ -0,0 +1,105 @@ +import CodexBarCore +import Foundation +import Testing + +@Suite(.serialized) +struct MiniMaxLogRedactorTests { + private static var miniMaxCpPlaceholder: String { + ["sk", "cp", "placeholder"].joined(separator: "-") + } + + private static var miniMaxApiPlaceholder: String { + ["sk", "api", "placeholder"].joined(separator: "-") + } + + @Test + func `sk-cp token is redacted`() { + let input = Self.miniMaxCpPlaceholder + let redacted = LogRedactor.redact(input) + #expect(redacted.contains("sk-cp-") == false) + #expect(redacted.contains("")) + #expect(redacted.contains("placeholder") == false) + } + + @Test + func `sk-api token is redacted`() { + let input = Self.miniMaxApiPlaceholder + let redacted = LogRedactor.redact(input) + #expect(redacted.contains("sk-api-") == false) + #expect(redacted.contains("")) + #expect(redacted.contains("placeholder") == false) + } + + @Test + func `cookie header is redacted`() { + let input = "Cookie: session=cookie-session-placeholder; token=\(Self.miniMaxCpPlaceholder)" + let redacted = LogRedactor.redact(input) + #expect(redacted.contains("session=cookie-session-placeholder") == false) + #expect(redacted.contains(Self.miniMaxCpPlaceholder) == false) + #expect(redacted.contains("Cookie: ")) + } + + @Test + func `authorization header value is redacted`() { + // Short obvious placeholder, not JWT-like + let input = "Authorization: Bearer fake-bearer-token" + let redacted = LogRedactor.redact(input) + #expect(redacted.contains("fake-bearer-token") == false) + #expect(redacted.contains("Authorization:")) + } + + @Test + func `bearer token is not present in raw form`() { + let input = "Authorization: bearer \(Self.miniMaxApiPlaceholder)" + let redacted = LogRedactor.redact(input) + #expect(redacted.contains(Self.miniMaxApiPlaceholder) == false) + } + + @Test + func `email is redacted`() { + let input = "Contact: user@example.com" + let redacted = LogRedactor.redact(input) + #expect(redacted.contains("user@example.com") == false) + #expect(redacted.contains("")) + } + + @Test + func `minimax token in cookie is not present in raw form`() { + let input = "Cookie: session=session-placeholder; token=\(Self.miniMaxCpPlaceholder)" + let redacted = LogRedactor.redact(input) + #expect(redacted.contains("session=session-placeholder") == false) + #expect(redacted.contains(Self.miniMaxCpPlaceholder) == false) + } + + @Test + func `redacted text no longer matches original token pattern`() { + let originalToken = Self.miniMaxCpPlaceholder + let input = "Token: \(originalToken)" + let redacted = LogRedactor.redact(input) + + #expect(redacted.contains(originalToken) == false) + #expect(redacted.contains("")) + } + + @Test + func `minimax token with punctuation suffix is fully redacted`() { + let punctuatedToken = "\(Self.miniMaxApiPlaceholder).suffix-more" + let input = "Error: token=\(punctuatedToken)" + let redacted = LogRedactor.redact(input) + + #expect(redacted.contains("sk-api-") == false) + #expect(redacted.contains("suffix-more") == false) + #expect(redacted.contains("")) + } + + @Test + func `authorization header minimax token leaves no suffix fragment`() { + let punctuatedToken = "\(Self.miniMaxCpPlaceholder)-part.two" + let input = "Authorization: Bearer \(punctuatedToken)" + let redacted = LogRedactor.redact(input) + + #expect(redacted.contains("sk-cp-") == false) + #expect(redacted.contains("part.two") == false) + #expect(redacted.contains("Authorization: ")) + } +} diff --git a/docs/minimax.md b/docs/minimax.md index aa427bcd7e..c70f77a603 100644 --- a/docs/minimax.md +++ b/docs/minimax.md @@ -60,3 +60,32 @@ quota card and omits the chart instead of treating the whole provider as failed. - `Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift` - `Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift` - `Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift` + +## CLI diagnose command + +The `diagnose` command performs a real MiniMax diagnostic invocation and emits a safe, redacted JSON export +for issue reporting and verification. + +### Usage +``` +codexbar diagnose --provider minimax --format json --pretty +``` + +### Output +- Structural diagnostic JSON with provider, source, auth mode, usage snapshot, fetch attempts, and error categories. +- All sensitive fields (API tokens, cookies, emails, auth headers) are redacted via `LogRedactor`. +- Errors are mapped to safe categories (`network`, `auth`, `api`, `parse`) with user-friendly descriptions. +- No raw API responses, raw error messages, tokens, cookies, emails, account IDs, org IDs, or billing history. + +### What is excluded from output +- Raw API tokens (`sk-cp-*`, `sk-api-*`) and authorization headers +- Cookie header values +- Email addresses +- Account IDs, org IDs +- Raw error messages (replaced with safe category-based descriptions) +- Raw HTTP responses or request bodies +- Billing history details + +### Exit codes +- `0`: Diagnostic completed successfully (even if MiniMax auth is not configured) +- `1`: Unknown error or invalid arguments