diff --git a/CHANGELOG.md b/CHANGELOG.md index 9988bca9..24b020bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,28 @@ This project uses semantic versioning for public releases. Use `MAJOR.MINOR.PATC - `MINOR` changes add user-visible features and improvements. - `PATCH` changes fix bugs, polish existing behavior, or make small internal improvements. +## [1.2.0] - 2026-07-14 + +### Added + +- A Preserve exact wording option that skips transcript cleanup while still supporting literal translation, voice macros, and Edit Mode. +- An instruction guard that retries or falls back to the literal transcript when cleanup appears to answer a dictated prompt instead of preserving it. +- An option to keep dictations in clipboard-manager history. +- A menu bar action for adding copied words directly to the custom vocabulary while avoiding duplicates. + +### Improved + +- Post-processing now switches to the fallback model immediately when the primary model is rate-limited, remembers daily limits across restarts, and shows their reset times in Settings. +- The default fallback model now uses Qwen 3.6 27B, and models scheduled for Groq shutdown in July or August 2026 have been removed from the picker. +- Transcription failures now distinguish network outages from slow providers and display clearer errors in the recording overlay. +- The default context model now uses Qwen 3.6 27B and strips reasoning tags from context summaries. +- Local model requests now honor their configured timeouts, and transcription uses response formats compatible with a wider range of OpenAI-style models. +- Permission and update checks use fewer unnecessary background wakeups. + +### Fixed + +- Fixed a recording overlay resource leak that could leave hidden animations running after the overlay closed. + ## [1.1.0] - 2026-06-03 ### Added diff --git a/Makefile b/Makefile index 9712a07c..00fda5c5 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,7 @@ APP_EXECUTABLE = $(MACOS_DIR)/$(APP_NAME) APP_EXECUTABLE_TARGET := $(subst $(space),\ ,$(APP_EXECUTABLE)) SOURCES = $(shell find Sources -name '*.swift' -type f | LC_ALL=C sort) +TEST_RUNNER = $(BUILD_DIR)/FreeFlowTests RESOURCES = $(CONTENTS)/Resources ARCH ?= $(shell uname -m) @@ -25,7 +26,7 @@ ICON_SOURCE = Resources/AppIcon-Source.png ICON_ICNS = Resources/AppIcon.icns endif -.PHONY: all clean run icon dmg codesign-dmg notarize +.PHONY: all clean run icon dmg codesign-dmg notarize test all: $(APP_EXECUTABLE_TARGET) @@ -68,6 +69,18 @@ endif @codesign --force --options runtime --sign "$(CODESIGN_IDENTITY)" --entitlements FreeFlow.entitlements "$(APP_BUNDLE)" @echo "Built $(APP_BUNDLE)" +test: $(TEST_RUNNER) + @$(TEST_RUNNER) + +$(TEST_RUNNER): Sources/AppContextService.swift Sources/LLMAPITransport.swift Sources/ModelConfiguration.swift Tests/AppContextServiceTests.swift + @mkdir -p "$(BUILD_DIR)" + swiftc \ + -parse-as-library \ + -o "$(TEST_RUNNER)" \ + -sdk $(shell xcrun --show-sdk-path) \ + -target $(ARCH)-apple-macosx13.0 \ + Sources/AppContextService.swift Sources/LLMAPITransport.swift Sources/ModelConfiguration.swift Tests/AppContextServiceTests.swift + icon: $(ICON_ICNS) $(ICON_ICNS): $(ICON_SOURCE) diff --git a/Sources/AppContextService.swift b/Sources/AppContextService.swift index 17832dbf..d82fdbe8 100644 --- a/Sources/AppContextService.swift +++ b/Sources/AppContextService.swift @@ -27,6 +27,7 @@ struct AppContext { } final class AppContextService { + static let defaultContextModel = "qwen/qwen3.6-27b" static let defaultContextPrompt = """ You are a context synthesis assistant for a speech-to-text pipeline. Given app/window metadata and an optional screenshot, output exactly two sentences that describe what the user is doing right now and the likely writing intent in the current window. @@ -53,14 +54,14 @@ Return only two sentences, no labels, no markdown, no extra commentary. apiKey: String, baseURL: String = "https://api.groq.com/openai/v1", customContextPrompt: String = "", - contextModel: String = "meta-llama/llama-4-scout-17b-16e-instruct", + contextModel: String = AppContextService.defaultContextModel, screenshotMaxDimension: CGFloat = AppContextService.defaultScreenshotMaxDimension ) { self.apiKey = apiKey self.baseURL = baseURL self.customContextPrompt = customContextPrompt let trimmedModel = contextModel.trimmingCharacters(in: .whitespacesAndNewlines) - self.contextModel = trimmedModel.isEmpty ? "meta-llama/llama-4-scout-17b-16e-instruct" : trimmedModel + self.contextModel = trimmedModel.isEmpty ? Self.defaultContextModel : trimmedModel self.screenshotMaxDimension = screenshotMaxDimension > 0 ? screenshotMaxDimension : AppContextService.defaultScreenshotMaxDimension @@ -278,15 +279,25 @@ Selected text: \(selectedText ?? "None") return nil } - let cleaned = content.trimmingCharacters(in: .whitespacesAndNewlines) - guard !cleaned.isEmpty else { return nil } - return (activity: normalizedActivitySummary(cleaned), prompt: fullPrompt) + guard let activity = Self.activitySummary(from: content, model: model) else { return nil } + return (activity: activity, prompt: fullPrompt) } catch { return nil } } - private func normalizedActivitySummary(_ value: String) -> String { + static func activitySummary(from rawContent: String, model: String) -> String? { + var content = rawContent + if ModelConfiguration.config(for: model).shouldStripThinkTags { + content = ModelConfiguration.stripThinkTags(content) + } + + let cleaned = content.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty else { return nil } + return normalizedActivitySummary(cleaned) + } + + private static func normalizedActivitySummary(_ value: String) -> String { let sentences = value .split(whereSeparator: { $0 == "." || $0 == "。" || $0 == "!" || $0 == "?" }) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } diff --git a/Sources/AppState.swift b/Sources/AppState.swift index c65bc809..0626d972 100644 --- a/Sources/AppState.swift +++ b/Sources/AppState.swift @@ -223,6 +223,7 @@ final class AppState: ObservableObject, @unchecked Sendable { private let contextScreenshotMaxDimensionStorageKey = "context_screenshot_max_dimension" private let shortcutStartDelayStorageKey = "shortcut_start_delay" private let preserveClipboardStorageKey = "preserve_clipboard" + private let preserveExactWordingStorageKey = "preserve_exact_wording" private let keepDictationInClipboardHistoryStorageKey = "keep_dictation_in_clipboard_history" private let pressEnterVoiceCommandStorageKey = "press_enter_voice_command_enabled" private let alertSoundsEnabledStorageKey = "alert_sounds_enabled" @@ -275,8 +276,10 @@ final class AppState: ObservableObject, @unchecked Sendable { ("ca", "Catalan") ] static let defaultPostProcessingModel = "openai/gpt-oss-20b" - static let defaultPostProcessingFallbackModel = "meta-llama/llama-4-scout-17b-16e-instruct" - static let defaultContextModel = "meta-llama/llama-4-scout-17b-16e-instruct" + static let defaultPostProcessingFallbackModel = "qwen/qwen3.6-27b" + static let defaultContextModel = "qwen/qwen3.6-27b" + private static let deprecatedDefaultPostProcessingFallbackModel = "meta-llama/llama-4-scout-17b-16e-instruct" + private static let deprecatedDefaultContextModel = "meta-llama/llama-4-scout-17b-16e-instruct" private static let trailingPressEnterCommandPattern = try! NSRegularExpression( pattern: #"(?i)(?:^|[ \t\r\n,;:\-]+)press[ \t\r\n]+enter[\s\p{P}]*$"# ) @@ -505,6 +508,12 @@ final class AppState: ObservableObject, @unchecked Sendable { } } + @Published var preserveExactWording: Bool { + didSet { + UserDefaults.standard.set(preserveExactWording, forKey: preserveExactWordingStorageKey) + } + } + @Published var keepDictationInClipboardHistory: Bool { didSet { UserDefaults.standard.set(keepDictationInClipboardHistory, forKey: keepDictationInClipboardHistoryStorageKey) @@ -626,8 +635,10 @@ final class AppState: ObservableObject, @unchecked Sendable { let transcriptionAPIURL = Self.loadOptionalStoredAPIValue(account: transcriptionAPIURLStorageKey) let transcriptionAPIKey = Self.loadStoredAPIKey(account: transcriptionAPIKeyStorageKey) let postProcessingModel = UserDefaults.standard.string(forKey: postProcessingModelStorageKey) ?? Self.defaultPostProcessingModel - let postProcessingFallbackModel = UserDefaults.standard.string(forKey: postProcessingFallbackModelStorageKey) ?? Self.defaultPostProcessingFallbackModel - let contextModel = UserDefaults.standard.string(forKey: contextModelStorageKey) ?? Self.defaultContextModel + let postProcessingFallbackModel = Self.loadStoredPostProcessingFallbackModel( + key: postProcessingFallbackModelStorageKey + ) + let contextModel = Self.loadStoredContextModel(key: contextModelStorageKey) let shortcuts = Self.loadShortcutConfiguration( holdKey: holdShortcutStorageKey, toggleKey: toggleShortcutStorageKey, @@ -676,6 +687,7 @@ final class AppState: ObservableObject, @unchecked Sendable { let preserveClipboard = UserDefaults.standard.object(forKey: preserveClipboardStorageKey) == nil ? true : UserDefaults.standard.bool(forKey: preserveClipboardStorageKey) + let preserveExactWording = UserDefaults.standard.bool(forKey: preserveExactWordingStorageKey) let keepDictationInClipboardHistory = UserDefaults.standard.bool(forKey: keepDictationInClipboardHistoryStorageKey) let realtimeStreamingEnabled = UserDefaults.standard.bool(forKey: realtimeStreamingEnabledStorageKey) let realtimeStreamingModel = UserDefaults.standard.string(forKey: realtimeStreamingModelStorageKey) ?? "" @@ -750,6 +762,7 @@ final class AppState: ObservableObject, @unchecked Sendable { self.outputLanguage = outputLanguage self.shortcutStartDelay = shortcutStartDelay self.preserveClipboard = preserveClipboard + self.preserveExactWording = preserveExactWording self.keepDictationInClipboardHistory = keepDictationInClipboardHistory self.realtimeStreamingEnabled = realtimeStreamingEnabled self.realtimeStreamingModel = realtimeStreamingModel @@ -860,6 +873,34 @@ final class AppState: ObservableObject, @unchecked Sendable { return defaultAPIBaseURL } + private static func loadStoredContextModel(key: String) -> String { + guard let stored = UserDefaults.standard.string(forKey: key) else { + return defaultContextModel + } + + let trimmed = stored.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed == deprecatedDefaultContextModel { + UserDefaults.standard.set(defaultContextModel, forKey: key) + return defaultContextModel + } + + return trimmed.isEmpty ? defaultContextModel : trimmed + } + + private static func loadStoredPostProcessingFallbackModel(key: String) -> String { + guard let stored = UserDefaults.standard.string(forKey: key) else { + return defaultPostProcessingFallbackModel + } + + let trimmed = stored.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed == deprecatedDefaultPostProcessingFallbackModel { + UserDefaults.standard.set(defaultPostProcessingFallbackModel, forKey: key) + return defaultPostProcessingFallbackModel + } + + return trimmed.isEmpty ? defaultPostProcessingFallbackModel : trimmed + } + private static func loadShortcutConfiguration( holdKey: String, toggleKey: String, @@ -1182,7 +1223,8 @@ final class AppState: ObservableObject, @unchecked Sendable { postProcessingService: postProcessingService, customVocabulary: capturedCustomVocabulary, customSystemPrompt: capturedCustomSystemPrompt, - outputLanguage: self.outputLanguage + outputLanguage: self.outputLanguage, + preserveExactWording: self.preserveExactWording ) finalTranscript = result.finalTranscript processingStatus = Self.statusMessage( @@ -2426,6 +2468,9 @@ final class AppState: ObservableObject, @unchecked Sendable { case voiceMacro(command: String) case postProcessingSucceeded case postProcessingFailedFallback + case preservedExactWording + case preservedExactWordingTranslated + case preservedExactWordingTranslationFailedFallback case commandModeSucceeded(invocation: CommandInvocation) case commandModeFailedFallback(invocation: CommandInvocation) @@ -2441,6 +2486,12 @@ final class AppState: ObservableObject, @unchecked Sendable { return isRetry ? "Post-processing failed on retry, using raw transcript" : "Post-processing failed, using raw transcript" + case .preservedExactWording: + return "Preserved exact wording, skipped post-processing" + case .preservedExactWordingTranslated: + return "Preserved exact wording, translated to output language" + case .preservedExactWordingTranslationFailedFallback: + return "Verbatim translation failed, using untranslated raw transcript" case .commandModeSucceeded(let invocation): return "Edit mode succeeded (\(invocation.rawValue))" case .commandModeFailedFallback(let invocation): @@ -2456,7 +2507,8 @@ final class AppState: ObservableObject, @unchecked Sendable { postProcessingService: PostProcessingService, customVocabulary: String, customSystemPrompt: String, - outputLanguage: String = "" + outputLanguage: String = "", + preserveExactWording: Bool ) async -> (finalTranscript: String, outcome: TranscriptProcessingOutcome, prompt: String) { let trimmedRawTranscript = rawTranscript.trimmingCharacters(in: .whitespacesAndNewlines) @@ -2484,7 +2536,37 @@ final class AppState: ObservableObject, @unchecked Sendable { os_log(.info, log: recordingLog, "Voice macro triggered: %{public}@", macro.command) return (macro.payload, .voiceMacro(command: macro.command), "") } - + + // Preserve-exact-wording mode. Two sub-cases so translation + // stays honored: + // + // 1. No Output Language set — skip the LLM entirely and + // return the raw transcript verbatim. + // 2. Output Language IS set — route through a stripped-down + // translate-only prompt. The user asked for another + // language; silently dropping translation defeats their + // settings. The translate-only path preserves filler, + // informal wording, and profanity 1:1 while still hitting + // the target language. + if preserveExactWording { + let targetLanguage = outputLanguage.trimmingCharacters(in: .whitespacesAndNewlines) + if targetLanguage.isEmpty { + return (trimmedRawTranscript, .preservedExactWording, "") + } + do { + let result = try await postProcessingService.translateVerbatim( + transcript: trimmedRawTranscript, + targetLanguage: targetLanguage + ) + return (result.transcript, .preservedExactWordingTranslated, result.prompt) + } catch { + os_log(.error, log: recordingLog, + "Verbatim translation failed: %{public}@", + error.localizedDescription) + return (trimmedRawTranscript, .preservedExactWordingTranslationFailedFallback, "") + } + } + do { let result = try await postProcessingService.postProcess( transcript: trimmedRawTranscript, @@ -2652,7 +2734,8 @@ final class AppState: ObservableObject, @unchecked Sendable { postProcessingService: postProcessingService, customVocabulary: self.customVocabulary, customSystemPrompt: self.customSystemPrompt, - outputLanguage: self.outputLanguage + outputLanguage: self.outputLanguage, + preserveExactWording: self.preserveExactWording ) try Task.checkCancellation() @@ -2699,7 +2782,8 @@ final class AppState: ObservableObject, @unchecked Sendable { let shouldPersistRawDictationFallback: Bool switch result.outcome { - case .postProcessingFailedFallback: + case .postProcessingFailedFallback, + .preservedExactWordingTranslationFailedFallback: shouldPersistRawDictationFallback = !trimmedFinalTranscript.isEmpty default: shouldPersistRawDictationFallback = false diff --git a/Sources/LLMAPITransport.swift b/Sources/LLMAPITransport.swift index e414d13b..24de389d 100644 --- a/Sources/LLMAPITransport.swift +++ b/Sources/LLMAPITransport.swift @@ -1,32 +1,41 @@ import Foundation enum LLMAPITransport { - private static let requestSession: URLSession = { - makeEphemeralSession() - }() - - private static func makeEphemeralSession() -> URLSession { + private static func makeEphemeralSession(timeout: TimeInterval) -> URLSession { let configuration = URLSessionConfiguration.ephemeral configuration.requestCachePolicy = .reloadIgnoringLocalCacheData configuration.urlCache = nil - configuration.timeoutIntervalForRequest = 20 - configuration.timeoutIntervalForResource = 30 + // URLSession's resource timeout is session-scoped, while each caller + // already puts its configured timeout on the URLRequest. Keep both + // session timers aligned with that request instead of applying one + // global timeout to every provider and operation. + configuration.timeoutIntervalForRequest = timeout + configuration.timeoutIntervalForResource = timeout return URLSession(configuration: configuration) } + private static func timeout(for request: URLRequest) -> TimeInterval { + let requestTimeout = request.timeoutInterval + guard requestTimeout.isFinite, requestTimeout > 0 else { + return 60 + } + return requestTimeout + } + static func data( for request: URLRequest ) async throws -> (Data, URLResponse) { - try await requestSession.data(for: request) + let session = makeEphemeralSession(timeout: timeout(for: request)) + defer { session.finishTasksAndInvalidate() } + return try await session.data(for: request) } static func upload( for request: URLRequest, from bodyData: Data ) async throws -> (Data, URLResponse) { - // Use a fresh session for each upload so a bad reused connection cannot - // poison subsequent transcription uploads. - let session = makeEphemeralSession() + // Fresh session per upload — no poisoned connection re-use. + let session = makeEphemeralSession(timeout: timeout(for: request)) defer { session.finishTasksAndInvalidate() } return try await session.upload(for: request, from: bodyData) } diff --git a/Sources/LLMCooldownManager.swift b/Sources/LLMCooldownManager.swift new file mode 100644 index 00000000..1fb95ae9 --- /dev/null +++ b/Sources/LLMCooldownManager.swift @@ -0,0 +1,186 @@ +import Foundation + +/// Tracks per-model rate-limit cooldowns so subsequent requests skip a rate-limited model +/// instead of sending a doomed request and paying an extra round-trip. +/// +/// Two storage tiers: +/// - Minute-level limits (retry-after < 1 hour): stored in memory, cleared on app restart. +/// - Daily limits (retry-after >= 1 hour): persisted in UserDefaults so the cooldown +/// survives app restarts and is visible in the Settings UI. +actor LLMCooldownManager { + + /// Shared instance used by all PostProcessingService instances across the app. + /// Rate limits apply at the Groq organization level, so one shared state is correct. + static let shared = LLMCooldownManager() + + /// Cooldowns at or above this threshold are treated as daily limits and persisted. + private let dailyLimitThreshold: TimeInterval = 3600 + + /// Fallback cooldown used when a 429 carries no parseable timing header. Kept well below + /// `dailyLimitThreshold` so it stays in memory and lets the next call re-probe soon. + private static let defaultReprobeCooldownSeconds: TimeInterval = 60 + + /// In-memory store for short-lived minute-level cooldowns. + private var cooldowns: [String: Date] = [:] + + /// Returns true if the given model is currently blocked by a rate-limit cooldown. + /// Checks both in-memory (minute-level) and UserDefaults (daily-level) stores. + func isInCooldown(_ model: String) -> Bool { + let now = Date() + + // Check in-memory store first — minute-level limits live here. + if let until = cooldowns[model] { + if now < until { return true } + // Entry expired; remove it to keep the dictionary clean. + cooldowns.removeValue(forKey: model) + } + + // Check UserDefaults for persisted daily-limit entries. + if let until = persistedExpiry(for: model) { + if now < until { return true } + // Entry expired; remove it from UserDefaults. + clearPersistedExpiry(for: model) + } + + return false + } + + /// Registers a cooldown for a model using the retry-after duration from the API 429 response. + /// Minute-level durations stay in memory; daily-level durations are also written to UserDefaults. + /// Pass `persist: true` for a daily-limit signal (the RPD reset header) so a daily quota that + /// happens to reset in under an hour is still persisted and shown in Settings, not kept in memory. + func setCooldown(_ model: String, retryAfterSeconds: TimeInterval, persist: Bool = false) { + let expiryDate = Date().addingTimeInterval(retryAfterSeconds) + if persist || retryAfterSeconds >= dailyLimitThreshold { + // Daily limit: persist to UserDefaults so it survives app restarts. + persistExpiry(expiryDate, for: model) + } else { + // Minute-level limit: keep in memory only; it will expire within minutes. + cooldowns[model] = expiryDate + } + } + + /// Returns an available model to use up-front, or nil when none is: the primary if it is not + /// cooling down; otherwise the fallback if it exists and is itself not cooling down; otherwise + /// nil, so the caller can skip a doomed request when BOTH models are rate-limited. + func effectivePrimary(_ primary: String, fallback: String?) -> String? { + if !isInCooldown(primary) { + return primary + } + guard let fallback, !isInCooldown(fallback) else { + return nil + } + return fallback + } + + // MARK: - UserDefaults (daily-limit persistence) + + /// Shared key format so SettingsView can read expiry dates without going through the actor. + /// nonisolated allows this to be called synchronously from SwiftUI view code. + nonisolated static func udKey(for model: String) -> String { + "llm_cooldown_expiry_\(model)" + } + + /// Instance wrapper used internally by the actor methods below. + private func udKey(_ model: String) -> String { + Self.udKey(for: model) + } + + /// Reads the persisted cooldown expiry for a model from UserDefaults. + /// Returns nil if no entry exists. + private func persistedExpiry(for model: String) -> Date? { + let timestamp = UserDefaults.standard.double(forKey: udKey(model)) + guard timestamp > 0 else { return nil } + return Date(timeIntervalSince1970: timestamp) + } + + /// Writes a cooldown expiry date for a model to UserDefaults as a Unix timestamp. + private func persistExpiry(_ date: Date, for model: String) { + UserDefaults.standard.set(date.timeIntervalSince1970, forKey: udKey(model)) + } + + /// Removes a model's cooldown entry from UserDefaults once it has expired. + private func clearPersistedExpiry(for model: String) { + UserDefaults.standard.removeObject(forKey: udKey(model)) + } + + // MARK: - Rate-limit header parsing + + /// Reads from a Groq 429 response how long the model must cool down AND whether the limit is a + /// daily one (so the caller persists it even when the remaining time is short). + /// Priority: an exhausted daily request quota (`x-ratelimit-remaining-requests` <= 0, using the + /// `x-ratelimit-reset-requests` RPD reset) → `retry-after` (delta-seconds) → + /// `x-ratelimit-reset-tokens` (the per-minute / Tokens-Per-Minute reset) → a short re-probe + /// fallback. The RPD check runs FIRST because Groq usually also sends `retry-after`; honoring + /// that first would hide a short, near-reset daily window and stop it from persisting. + /// nonisolated static so the call site computes it without an actor hop. + nonisolated static func rateLimitCooldown(from httpResponse: HTTPURLResponse) -> (seconds: TimeInterval, isDaily: Bool) { + // Daily (RPD) quota spent: classify as daily and use its reset even when retry-after is also + // present, so a short near-reset daily window still persists and surfaces in Settings. + let remainingRequests = httpResponse.value(forHTTPHeaderField: "x-ratelimit-remaining-requests") + .flatMap { Double($0.trimmingCharacters(in: .whitespacesAndNewlines)) } + if let remainingRequests, remainingRequests <= 0, + let dailyReset = httpResponse.value(forHTTPHeaderField: "x-ratelimit-reset-requests").flatMap(parseGroqDuration) { + return (dailyReset, true) + } + // retry-after is the authoritative wait Groq sets specifically on a 429. + if let value = httpResponse.value(forHTTPHeaderField: "retry-after").flatMap(parseGroqDuration) { + return (value, false) + } + // x-ratelimit-reset-tokens carries the per-minute (TPM) reset, e.g. "7.66s". + if let value = httpResponse.value(forHTTPHeaderField: "x-ratelimit-reset-tokens").flatMap(parseGroqDuration) { + return (value, false) + } + // No timing header present (rare): cool down briefly so the next call can re-probe. + return (Self.defaultReprobeCooldownSeconds, false) + } + + /// Parses a Groq duration string into a TimeInterval. Accepts bare seconds ("2", "7.66"), + /// a single suffixed unit ("7.66s", "120ms"), and compound forms ("2m59.56s", "1h0m0s", + /// "1h2m3.5s"). Returns nil for empty, unrecognized-unit, negative, or non-finite input — + /// so a malformed header can never yield a negative/NaN/infinite cooldown. + private nonisolated static func parseGroqDuration(_ value: String) -> TimeInterval? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + // A bare number is plain seconds — covers the `retry-after` integer form ("2"). + // Reject NaN/infinite/negative (Double() also accepts "nan"/"inf"/"-3"/"0x10"). + if let seconds = Double(trimmed) { return (seconds.isFinite && seconds >= 0) ? seconds : nil } + // Otherwise accumulate segments left to right (h, m, s, ms). + var total: TimeInterval = 0 + var numberBuffer = "" + var matchedAnyUnit = false + var index = trimmed.startIndex + while index < trimmed.endIndex { + let character = trimmed[index] + if character.isNumber || character == "." { + numberBuffer.append(character) + index = trimmed.index(after: index) + continue + } + // Hit a unit: the preceding digits must form a valid number. + guard let number = Double(numberBuffer) else { return nil } + numberBuffer = "" + // "ms" must be checked before the single-letter "m"/"s". + if trimmed[index...].hasPrefix("ms") { + total += number / 1000.0 + index = trimmed.index(index, offsetBy: 2) + } else if character == "h" { + total += number * 3600.0 + index = trimmed.index(after: index) + } else if character == "m" { + total += number * 60.0 + index = trimmed.index(after: index) + } else if character == "s" { + total += number + index = trimmed.index(after: index) + } else { + return nil // Unrecognized unit. + } + matchedAnyUnit = true + } + // Reject a trailing number with no unit (e.g. "1h30") and unit-less input. + guard numberBuffer.isEmpty, matchedAnyUnit else { return nil } + // Reject a non-finite/negative accumulated total for the same safety reason. + return (total.isFinite && total >= 0) ? total : nil + } +} diff --git a/Sources/ModelConfiguration.swift b/Sources/ModelConfiguration.swift index ced1e9a1..0e976873 100644 --- a/Sources/ModelConfiguration.swift +++ b/Sources/ModelConfiguration.swift @@ -9,13 +9,10 @@ public struct ModelConfig { public struct ModelConfiguration { public static let llmModels = [ - "llama-3.3-70b-versatile", - "llama-3.1-8b-instant", - "meta-llama/llama-4-scout-17b-16e-instruct", "openai/gpt-oss-20b", "openai/gpt-oss-120b", "openai/gpt-oss-safeguard-20b", - "qwen/qwen3-32b", + "qwen/qwen3.6-27b", "allam-2-7b", "groq/compound", "groq/compound-mini", @@ -35,6 +32,7 @@ public struct ModelConfiguration { // Normalize providerless aliases if cleanModel == "qwen3-32b" { cleanModel = "qwen/qwen3-32b" } + else if cleanModel == "qwen3.6-27b" { cleanModel = "qwen/qwen3.6-27b" } else if cleanModel == "gpt-oss-20b" { cleanModel = "openai/gpt-oss-20b" } else if cleanModel == "gpt-oss-120b" { cleanModel = "openai/gpt-oss-120b" } else if cleanModel == "gpt-oss-safeguard-20b" { cleanModel = "openai/gpt-oss-safeguard-20b" } @@ -68,6 +66,13 @@ public struct ModelConfiguration { includeReasoning: nil, shouldStripThinkTags: true ) + } else if cleanModel == "qwen/qwen3.6-27b" { + return ModelConfig( + maxCompletionTokens: nil, + reasoningEffort: "none", + includeReasoning: false, + shouldStripThinkTags: true + ) } else if cleanModel == "llama-3.1-8b-instant" { return ModelConfig( maxCompletionTokens: nil, diff --git a/Sources/PostProcessingService.swift b/Sources/PostProcessingService.swift index 4465a061..1a813cdf 100644 --- a/Sources/PostProcessingService.swift +++ b/Sources/PostProcessingService.swift @@ -2,6 +2,9 @@ import Foundation enum PostProcessingError: LocalizedError { case requestFailed(Int, String) + /// The model rejected the request because its rate limit was exceeded. + /// Carries the model name and the number of seconds until the limit resets. + case rateLimited(model: String, retryAfter: TimeInterval) case invalidResponse(String) case invalidInput(String) case emptyOutput @@ -12,6 +15,8 @@ enum PostProcessingError: LocalizedError { switch self { case .requestFailed(let statusCode, let details): "Post-processing failed with status \(statusCode): \(details)" + case .rateLimited(let model, let retryAfter): + "Model \(model) rate-limited — retry in \(Int(retryAfter))s" case .invalidResponse(let details): "Invalid post-processing response: \(details)" case .invalidInput(let details): @@ -133,7 +138,7 @@ Behavior: private let preferredFallbackModel: String private let instructionExecutionGuardEnabled: Bool private let defaultModel = "openai/gpt-oss-20b" - private let defaultFallbackModel = "meta-llama/llama-4-scout-17b-16e-instruct" + private let defaultFallbackModel = "qwen/qwen3.6-27b" private let defaultModelReasoningEffort = "low" private let postProcessingMaxCompletionTokens = 4096 private var postProcessingTimeoutSeconds: TimeInterval { @@ -197,6 +202,59 @@ Behavior: } } + /// Translate a raw transcript into the target language without + /// performing any of the polishing normally applied by the cleanup + /// pipeline. Preserves original phrasing 1:1 — no filler removal, + /// no reformatting, no rewording, no punctuation additions beyond + /// what's grammatically required by the target language. + /// + /// Used by the "Preserve exact wording" path when the user has + /// also configured an Output Language: skipping the LLM entirely + /// there would silently drop translation, so we route through a + /// minimal translate-only prompt instead. + func translateVerbatim( + transcript: String, + targetLanguage: String + ) async throws -> PostProcessingResult { + let trimmedTranscript = transcript.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedTranscript.isEmpty else { + throw PostProcessingError.invalidInput("Transcript must not be empty") + } + let trimmedLanguage = targetLanguage.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedLanguage.isEmpty else { + throw PostProcessingError.invalidInput("Target language must not be empty") + } + + let timeoutSeconds = postProcessingTimeoutSeconds + return try await withThrowingTaskGroup(of: PostProcessingResult.self) { group in + group.addTask { [weak self] in + guard let self else { + throw PostProcessingError.invalidResponse("Post-processing service deallocated") + } + return try await self.translateVerbatimWithFallback( + transcript: trimmedTranscript, + targetLanguage: trimmedLanguage + ) + } + + group.addTask { + try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) + throw PostProcessingError.requestTimedOut(timeoutSeconds) + } + + do { + guard let result = try await group.next() else { + throw PostProcessingError.invalidResponse("No translation result") + } + group.cancelAll() + return result + } catch { + group.cancelAll() + throw error + } + } + } + func commandTransform( selectedText: String, voiceCommand: String, @@ -254,8 +312,17 @@ Behavior: customSystemPrompt: String = "", outputLanguage: String = "" ) async throws -> PostProcessingResult { - let primaryModel = resolvedPrimaryModel() + var primaryModel = resolvedPrimaryModel() let retryModel = resolvedRetryModel(for: primaryModel) + + // Circuit breaker: pick a model that isn't cooling down. If BOTH are cooling, skip cleanup + // and return the raw transcript rather than send a doomed request. Reassigning primaryModel + // keeps the call site below byte-identical to upstream. + guard let availableModel = await LLMCooldownManager.shared.effectivePrimary(primaryModel, fallback: retryModel) else { + return PostProcessingResult(transcript: transcript.trimmingCharacters(in: .whitespacesAndNewlines), prompt: "") + } + primaryModel = availableModel + do { return try await process( transcript: transcript, @@ -266,11 +333,17 @@ Behavior: outputLanguage: outputLanguage ) } catch let error as PostProcessingError { + // Unified fallback policy: decide whether to retry on the other model. let shouldFallback: Bool switch error { + case .rateLimited: + // The cooldown was already registered inside process() when the 429 was + // detected — for the fallback attempt too — so here we only switch models. + shouldFallback = true case .requestFailed(let statusCode, _): shouldFallback = statusCode == 429 case .emptyOutput: + // Empty output is a soft failure; try the other model once before giving up. shouldFallback = true case .suspectedInstructionExecution: shouldFallback = true @@ -282,9 +355,20 @@ Behavior: throw error } + // No distinct fallback left to try. Still honor the raw-transcript safe-exit for a + // suspected-instruction-execution so an up-front cooldown swap doesn't lose it. guard let retryModel else { throw error } + guard primaryModel != retryModel else { + if case .suspectedInstructionExecution = error { + return PostProcessingResult( + transcript: transcript.trimmingCharacters(in: .whitespacesAndNewlines), + prompt: "" + ) + } + throw error + } do { return try await process( @@ -311,8 +395,16 @@ Behavior: customVocabulary: [String], outputLanguage: String = "" ) async throws -> PostProcessingResult { - let primaryModel = resolvedPrimaryModel() + var primaryModel = resolvedPrimaryModel() let retryModel = resolvedRetryModel(for: primaryModel) + + // Circuit breaker: pick a model that isn't cooling down. If BOTH are cooling, skip the + // transform and return the selection unchanged rather than send a doomed request. + guard let availableModel = await LLMCooldownManager.shared.effectivePrimary(primaryModel, fallback: retryModel) else { + return PostProcessingResult(transcript: selectedText, prompt: "") + } + primaryModel = availableModel + do { return try await processCommandTransform( selectedText: selectedText, @@ -323,11 +415,15 @@ Behavior: outputLanguage: outputLanguage ) } catch let error as PostProcessingError { + // Unified fallback policy: decide whether to retry on the other model. let shouldFallback: Bool switch error { - case .requestFailed(let statusCode, _): - shouldFallback = statusCode == 429 + case .rateLimited: + // The cooldown was already registered inside processCommandTransform() when the + // 429 was detected — for the fallback attempt too — so here we only switch models. + shouldFallback = true case .emptyOutput: + // Empty output is a soft failure; try the other model once before giving up. shouldFallback = true default: shouldFallback = false @@ -337,7 +433,8 @@ Behavior: throw error } - guard let retryModel else { + // Guard against re-trying the same model when primaryModel is already the fallback. + guard let retryModel, primaryModel != retryModel else { throw error } @@ -465,6 +562,15 @@ Model: \(model) } guard httpResponse.statusCode == 200 else { + // For 429 responses, read how long the model is rate-limited from the headers so + // the circuit breaker knows exactly when it becomes available again. + if httpResponse.statusCode == 429 { + // Register the cooldown here so BOTH the primary and the fallback attempt feed + // the breaker (the retry calls this same method), then surface the error. + let cooldown = LLMCooldownManager.rateLimitCooldown(from: httpResponse) + await LLMCooldownManager.shared.setCooldown(model, retryAfterSeconds: cooldown.seconds, persist: cooldown.isDaily) + throw PostProcessingError.rateLimited(model: model, retryAfter: cooldown.seconds) + } let message = String(data: data, encoding: .utf8) ?? "" throw PostProcessingError.requestFailed(httpResponse.statusCode, message) } @@ -476,7 +582,7 @@ Model: \(model) let rawContent = message["content"] as? String else { throw PostProcessingError.invalidResponse("Missing choices[0].message.content") } - + var content = rawContent if config.shouldStripThinkTags { content = ModelConfiguration.stripThinkTags(content) @@ -596,6 +702,13 @@ Model: \(model) } guard httpResponse.statusCode == 200 else { + // Same 429 handling as process(): register the cooldown for whichever model + // (primary or fallback) hit the limit, then surface the error. + if httpResponse.statusCode == 429 { + let cooldown = LLMCooldownManager.rateLimitCooldown(from: httpResponse) + await LLMCooldownManager.shared.setCooldown(model, retryAfterSeconds: cooldown.seconds, persist: cooldown.isDaily) + throw PostProcessingError.rateLimited(model: model, retryAfter: cooldown.seconds) + } let message = String(data: data, encoding: .utf8) ?? "" throw PostProcessingError.requestFailed(httpResponse.statusCode, message) } @@ -607,7 +720,7 @@ Model: \(model) let rawContent = message["content"] as? String else { throw PostProcessingError.invalidResponse("Missing choices[0].message.content") } - + var content = rawContent if config.shouldStripThinkTags { content = ModelConfiguration.stripThinkTags(content) @@ -628,6 +741,161 @@ Model: \(model) prompt + "\n\nIMPORTANT: Translate the final cleaned text into \(language). Output ONLY in \(language), regardless of the original spoken language." } + /// System prompt used for verbatim translation. Deliberately + /// minimal — the whole point of this path is to translate word- + /// for-word without cleanup, so we avoid every rewrite / formatting + /// instruction from `defaultSystemPrompt`. + static func verbatimTranslationSystemPrompt(targetLanguage: String) -> String { + """ + You are a literal translator. + + Translate the user's transcript into \(targetLanguage) as literally as possible. + + Rules: + - Preserve every word the user spoke, including filler words such as "um", "uh", "like", "you know", false starts, and repetitions. Translate these into the closest natural equivalent in \(targetLanguage) rather than deleting them. + - Do NOT reword, summarize, restructure, or improve the sentence. + - Do NOT correct grammar mistakes, awkward phrasing, or informal wording. Keep the same register and flow. + - Do NOT add punctuation beyond what the target language grammatically requires. If the source has no punctuation, add only the minimum needed to make the sentence readable in \(targetLanguage). + - Do NOT wrap the output in quotes or explain your translation. Return only the translated text. + - Keep profanity, slang, and explicit language intact. + - Output ONLY in \(targetLanguage), regardless of the source language. + """ + } + + private func translateVerbatimWithFallback( + transcript: String, + targetLanguage: String + ) async throws -> PostProcessingResult { + let primaryModel = resolvedPrimaryModel() + let retryModel = resolvedRetryModel(for: primaryModel) + do { + return try await translateVerbatim( + transcript: transcript, + targetLanguage: targetLanguage, + model: primaryModel + ) + } catch let error as PostProcessingError { + let shouldFallback: Bool + switch error { + case .requestFailed(let statusCode, _): + shouldFallback = statusCode == 429 + case .emptyOutput: + shouldFallback = true + default: + shouldFallback = false + } + guard shouldFallback, let retryModel else { throw error } + return try await translateVerbatim( + transcript: transcript, + targetLanguage: targetLanguage, + model: retryModel + ) + } + } + + private func translateVerbatim( + transcript: String, + targetLanguage: String, + model: String + ) async throws -> PostProcessingResult { + var request = URLRequest(url: URL(string: "\(baseURL)/chat/completions")!) + request.httpMethod = "POST" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = postProcessingTimeoutSeconds + + let systemPrompt = Self.verbatimTranslationSystemPrompt(targetLanguage: targetLanguage) + let userMessage = """ + Translate the transcript below into \(targetLanguage), keeping the wording literal. + + TRANSCRIPT: + << String { + var result = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !result.isEmpty else { return "" } + if result.hasPrefix("\"") && result.hasSuffix("\"") && result.count > 1 { + result.removeFirst() + result.removeLast() + result = result.trimmingCharacters(in: .whitespacesAndNewlines) + } + return result + } + private func sanitizePostProcessedTranscript(_ value: String) -> String { var result = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !result.isEmpty else { return "" } diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index d639bf1b..348a8e6a 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -56,6 +56,10 @@ struct ProviderSettingsFields: View { @State private var postProcessingModelDraft: String = "" @State private var postProcessingFallbackModelDraft: String = "" @State private var contextModelDraft: String = "" + /// Updated by the cooldown timer so warning labels clear at expiry without user interaction. + @State private var now: Date = Date() + /// Tracks whether the Settings window is the frontmost active window. + @Environment(\.controlActiveState) private var controlActiveState let showsModelDescription: Bool @@ -94,6 +98,37 @@ struct ProviderSettingsFields: View { appState.postProcessingFallbackModel = trimmed } + /// True when this view's hosting window is the frontmost key window. While true a 5s timer + /// advances `now`, so a daily-limit warning appears within ~5s of being written and clears + /// within ~5s of expiry. SwiftUI removes the timer when this view leaves the hierarchy + /// (switching tabs or dismissing the Setup sheet). The timer must NOT be gated on a warning + /// already being visible: nothing else observes the UserDefaults cooldown keys, so a freshly + /// written cooldown would otherwise never trigger a re-render. Cost is negligible. + private var shouldRunCooldownTimer: Bool { + controlActiveState == .key + } + + /// Reads the persisted daily-limit expiry date for a model directly from UserDefaults. + /// Uses the shared key from LLMCooldownManager to avoid duplicating the storage contract. + /// Returns nil if no daily limit is active or if the entry has already expired. + private func dailyCooldownExpiry(for model: String) -> Date? { + let key = LLMCooldownManager.udKey(for: model) + let timestamp = UserDefaults.standard.double(forKey: key) + guard timestamp > 0 else { return nil } + let date = Date(timeIntervalSince1970: timestamp) + return date > now ? date : nil + } + + /// Formats a cooldown reset time, including the date only when the reset falls on a later day, + /// so a daily limit that resets after midnight is not shown as an ambiguous bare time. + private func formattedCooldownReset(_ expiry: Date) -> String { + // Calendar.isDate(_:inSameDayAs:) is a macOS-native calendar-aware same-day comparison. + if Calendar.current.isDate(expiry, inSameDayAs: now) { + return expiry.formatted(date: .omitted, time: .shortened) + } + return expiry.formatted(date: .abbreviated, time: .shortened) + } + private func commitContextModel() { let trimmed = contextModelDraft.trimmingCharacters(in: .whitespacesAndNewlines) contextModelDraft = trimmed @@ -164,6 +199,18 @@ struct ProviderSettingsFields: View { } ) + // Shows when this model has hit its daily Groq rate limit. + // Disappears automatically once the limit window resets. + if let expiry = dailyCooldownExpiry(for: appState.postProcessingModel) { + Label { + Text("Daily limit reached — resets at \(formattedCooldownReset(expiry))") + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .font(.caption) + .foregroundStyle(.orange) + } + ModelDropdownView( title: "Post-Processing Fallback Model", subtitle: "Used as the explicit retry model for transcript cleanup and Edit Mode transforms.", @@ -177,6 +224,18 @@ struct ProviderSettingsFields: View { } ) + // Shows when the fallback model has also hit its daily limit. + // In this state, both models are unavailable until their limits reset. + if let expiry = dailyCooldownExpiry(for: appState.postProcessingFallbackModel) { + Label { + Text("Fallback daily limit reached — resets at \(formattedCooldownReset(expiry))") + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .font(.caption) + .foregroundStyle(.orange) + } + ModelDropdownView( title: "Context Model", subtitle: "Used for context inference, with a text-only retry when screenshot analysis fails.", @@ -340,6 +399,15 @@ struct ProviderSettingsFields: View { contextModelDraft = value } } + // Tick every 5s while this view's window is key so a daily-limit warning can appear and + // auto-clear without any external state change. SwiftUI removes this timer when the + // window is backgrounded or this view leaves the hierarchy (tab switch or sheet dismissal). + if shouldRunCooldownTimer { + Color.clear.frame(width: 0, height: 0) + .onReceive(Timer.publish(every: 5, on: .main, in: .common).autoconnect()) { value in + now = value + } + } } } @@ -663,6 +731,9 @@ struct GeneralSettingsView: View { SettingsCard("Edit Mode", icon: "pencil") { commandModeSection } + SettingsCard("Cleanup", icon: "sparkles") { + cleanupSection + } SettingsCard("Clipboard", icon: "doc.on.clipboard") { clipboardSection } @@ -1187,6 +1258,22 @@ struct GeneralSettingsView: View { } } + // MARK: Cleanup + + private var cleanupSection: some View { + VStack(alignment: .leading, spacing: 10) { + Toggle("Preserve exact wording", isOn: $appState.preserveExactWording) + + Text("When on, \(AppName.displayName) skips the LLM cleanup step and pastes the transcript verbatim — filler words, informal phrasing, and explicit language are all preserved. Voice macros and Edit Mode still run.") + .font(.caption) + .foregroundStyle(.secondary) + + Text("If Output Language is set, the transcript is still translated into that language, but the translation is literal: no rewording, no filler removal, no reformatting.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + // MARK: Clipboard private var clipboardSection: some View { diff --git a/Sources/TranscriptionService.swift b/Sources/TranscriptionService.swift index 94c2b43a..33c0b302 100644 --- a/Sources/TranscriptionService.swift +++ b/Sources/TranscriptionService.swift @@ -4,11 +4,23 @@ import os.log private let transcriptionLog = OSLog(subsystem: "com.zachlatta.freeflow", category: "Transcription") class TranscriptionService { + private static let modelsSupportingVerboseJSON: Set = [ + // OpenAI's Whisper model supports segment metadata. The newer + // gpt-4o-transcribe family only supports the plain JSON format. + "whisper-1", + // Groq's hosted Whisper models support verbose_json and expose the + // segment metadata used by the hallucination filter below. + "whisper-large-v3", + "whisper-large-v3-turbo" + ] + private let apiKey: String private let baseURL: URL private let transcriptionModel: String private let language: String? - private let transcriptionResponseFormat = "verbose_json" + private var transcriptionResponseFormat: String { + Self.responseFormat(forModel: transcriptionModel) + } private var transcriptionTimeoutSeconds: TimeInterval { let override = UserDefaults.standard.double(forKey: "transcription_timeout_seconds") return override > 0 ? override : 20 @@ -28,6 +40,11 @@ class TranscriptionService { self.language = (trimmedLanguage?.isEmpty == false) ? trimmedLanguage : nil } + static func responseFormat(forModel model: String) -> String { + let normalizedModel = model.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return modelsSupportingVerboseJSON.contains(normalizedModel) ? "verbose_json" : "json" + } + // Validate API key by hitting a lightweight endpoint static func validateAPIKey(_ key: String, baseURL: String = "https://api.groq.com/openai/v1") async -> Bool { let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines) @@ -231,6 +248,8 @@ class TranscriptionService { return "Endpoint not found at \(provider) (HTTP 404). Base URL is likely wrong for this provider." case 413: return "Audio file too large for \(provider) (HTTP 413). Try a shorter recording." + case 400: + return "Provider rejected the request (HTTP 400). Check your model name and Base URL in Settings." case 429: return "Rate limit reached at \(provider) (HTTP 429). Wait a moment and try again." case 500..<600: diff --git a/Tests/AppContextServiceTests.swift b/Tests/AppContextServiceTests.swift new file mode 100644 index 00000000..39eb0a6a --- /dev/null +++ b/Tests/AppContextServiceTests.swift @@ -0,0 +1,86 @@ +import Foundation + +@main +struct AppContextServiceTests { + static func main() { + testQwenRawOutputIsSummarized() + testQwenReasoningOutputIsStripped() + testNonStrippingModelPreservesExistingBehavior() + testDeprecatedGroqModelsAreNotPredefined() + testQwenCleanupDisablesReasoning() + print("AppContextServiceTests passed") + } + + private static func testQwenRawOutputIsSummarized() { + let output = """ + The user is replying to an email about the product launch. They likely intend to confirm the next steps. This third sentence should be dropped. + """ + + let summary = AppContextService.activitySummary(from: output, model: "qwen/qwen3.6-27b") + + expectEqual( + summary, + "The user is replying to an email about the product launch. They likely intend to confirm the next steps." + ) + } + + private static func testQwenReasoningOutputIsStripped() { + let output = """ + + Hidden chain of thought should never appear in context. + It contains misleading details. + + The user is editing a project note in FreeFlow. They likely intend to tighten the release wording. + """ + + let summary = AppContextService.activitySummary(from: output, model: "qwen/qwen3.6-27b") + + expectEqual( + summary, + "The user is editing a project note in FreeFlow. They likely intend to tighten the release wording." + ) + expect(summary?.contains("Hidden chain of thought") == false, "Qwen reasoning leaked into summary") + } + + private static func testNonStrippingModelPreservesExistingBehavior() { + let output = "Visible for non-stripping models. The user is writing a status update." + + let summary = AppContextService.activitySummary( + from: output, + model: "meta-llama/llama-4-scout-17b-16e-instruct" + ) + + expectEqual(summary, output) + } + + private static func testDeprecatedGroqModelsAreNotPredefined() { + let deprecatedModels = [ + "qwen/qwen3-32b", + "meta-llama/llama-4-scout-17b-16e-instruct", + "llama-3.1-8b-instant", + "llama-3.3-70b-versatile" + ] + + for model in deprecatedModels { + expect(!ModelConfiguration.llmModels.contains(model), "Deprecated model remains in picker: \(model)") + } + expect(ModelConfiguration.llmModels.contains("qwen/qwen3.6-27b"), "New fallback is missing from picker") + } + + private static func testQwenCleanupDisablesReasoning() { + let config = ModelConfiguration.config(for: "qwen/qwen3.6-27b") + + expect(config.reasoningEffort == "none", "Qwen cleanup should disable reasoning") + expect(config.includeReasoning == false, "Qwen cleanup should exclude reasoning output") + } + + private static func expectEqual(_ actual: String?, _ expected: String, file: StaticString = #file, line: UInt = #line) { + expect(actual == expected, "Expected \(expected.debugDescription), got \((actual ?? "nil").debugDescription)", file: file, line: line) + } + + private static func expect(_ condition: Bool, _ message: String, file: StaticString = #file, line: UInt = #line) { + if !condition { + fatalError("\(file):\(line): \(message)") + } + } +}