From b795da2ab9b32b2f655699d61c2d13de1c9c89e3 Mon Sep 17 00:00:00 2001 From: "James Hurst [Studio]" Date: Fri, 29 May 2026 13:34:06 -0600 Subject: [PATCH 01/13] Fix verbose_json incompatibility with OpenAI transcribe models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI's gpt-4o-transcribe and gpt-4o-mini-transcribe model family only accepts "json" or "text" as response_format — sending "verbose_json" returns a 400 unsupported_value error and the setup test fails. Make transcriptionResponseFormat model-aware: models whose name contains "transcribe" get "json"; all others (Groq whisper-large-v3, etc.) keep "verbose_json" so the hallucination filter's no_speech_prob segments remain available. The hallucination filter already degrades gracefully when segments are absent, so there is no second change needed. Also add an explicit 400 case to friendlyHTTPMessage so users see "Check your model name and Base URL in Settings" rather than the generic fallback, which is actionable for this exact failure mode. Co-Authored-By: Claude Sonnet 4.6 --- Sources/TranscriptionService.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/TranscriptionService.swift b/Sources/TranscriptionService.swift index 3fa7a4b3..5bfe958b 100644 --- a/Sources/TranscriptionService.swift +++ b/Sources/TranscriptionService.swift @@ -8,7 +8,12 @@ class TranscriptionService { private let baseURL: URL private let transcriptionModel: String private let language: String? - private let transcriptionResponseFormat = "verbose_json" + private var transcriptionResponseFormat: String { + // OpenAI's *-transcribe model family rejects "verbose_json" with a 400 error; + // other providers (Groq whisper-large-v3, etc.) need it for no_speech_prob segments. + // The hallucination filter degrades gracefully when segments are absent. + transcriptionModel.lowercased().contains("transcribe") ? "json" : "verbose_json" + } private var transcriptionTimeoutSeconds: TimeInterval { let override = UserDefaults.standard.double(forKey: "transcription_timeout_seconds") return override > 0 ? override : 20 @@ -199,6 +204,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: From d635b531ded9abb6cbc18e11e454d99cf55c208b Mon Sep 17 00:00:00 2001 From: Saphi Date: Sun, 28 Jun 2026 07:47:33 -0300 Subject: [PATCH 02/13] fix: fall back to the backup cleanup model the moment the main one is rate-limited, instead of retrying it every dictation When the post-processing (cleanup) model returns HTTP 429, record a short per-model cooldown and route the next requests to the backup model up-front, instead of retrying the rate-limited model on every dictation. Daily limits are persisted and surfaced in Settings so the user can see when a model is unavailable until reset. Most logic lives in a new self-contained LLMCooldownManager so the PostProcessingService backbone keeps only minimal hooks. The duration parser rejects negative / non-finite header values so a malformed 429 can never produce a bad cooldown. Modified files: - Sources/LLMCooldownManager.swift - Sources/PostProcessingService.swift - Sources/SettingsView.swift --- Sources/LLMCooldownManager.swift | 175 ++++++++++++++++++++++++++++ Sources/PostProcessingService.swift | 58 +++++++-- Sources/SettingsView.swift | 58 +++++++++ 3 files changed, 283 insertions(+), 8 deletions(-) create mode 100644 Sources/LLMCooldownManager.swift diff --git a/Sources/LLMCooldownManager.swift b/Sources/LLMCooldownManager.swift new file mode 100644 index 00000000..0f086490 --- /dev/null +++ b/Sources/LLMCooldownManager.swift @@ -0,0 +1,175 @@ +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. + func setCooldown(_ model: String, retryAfterSeconds: TimeInterval) { + let expiryDate = Date().addingTimeInterval(retryAfterSeconds) + if 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 the model to use up-front: the fallback when the primary is in a rate-limit + /// cooldown and a fallback exists, otherwise the primary. Lets a caller route around a + /// cooling-down model in a single call. Behaviorally identical to the inline swap it replaces. + func effectivePrimary(_ primary: String, fallback: String?) -> String { + if isInCooldown(primary), let fallback { + return fallback + } + return primary + } + + // 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 how long a rate-limited model must cool down from a Groq 429 response. + /// Priority: `retry-after` (delta-seconds) → `x-ratelimit-reset-requests` (the daily / + /// Requests-Per-Day reset) → `x-ratelimit-reset-tokens` (the per-minute / Tokens-Per-Minute + /// reset) → a 60s fallback when no timing header is present. Reading reset-requests is what + /// lets a genuine daily limit cross the persistence threshold and surface in Settings. + /// nonisolated static so the HTTP call site computes it synchronously without an actor hop. + nonisolated static func retryAfterSeconds(from httpResponse: HTTPURLResponse) -> TimeInterval { + // retry-after is the authoritative wait Groq sets specifically on a 429. + if let value = httpResponse.value(forHTTPHeaderField: "retry-after").flatMap(parseGroqDuration) { + return value + } + // x-ratelimit-reset-requests carries the daily (RPD) reset, e.g. "2m59.56s" or hours. + if let value = httpResponse.value(forHTTPHeaderField: "x-ratelimit-reset-requests").flatMap(parseGroqDuration) { + return value + } + // 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 + } + // No timing header present (rare): cool down briefly so the next call can re-probe. + return Self.defaultReprobeCooldownSeconds + } + + /// 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/PostProcessingService.swift b/Sources/PostProcessingService.swift index 4465a061..709186f9 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): @@ -254,8 +259,13 @@ Behavior: customSystemPrompt: String = "", outputLanguage: String = "" ) async throws -> PostProcessingResult { - let primaryModel = resolvedPrimaryModel() + var primaryModel = resolvedPrimaryModel() let retryModel = resolvedRetryModel(for: primaryModel) + + // Circuit breaker: swap to the fallback up-front if the primary is cooling down. + // Reassigning primaryModel keeps the call site below byte-identical to upstream. + primaryModel = await LLMCooldownManager.shared.effectivePrimary(primaryModel, fallback: retryModel) + do { return try await process( transcript: transcript, @@ -266,11 +276,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,7 +298,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 } @@ -311,8 +328,12 @@ Behavior: customVocabulary: [String], outputLanguage: String = "" ) async throws -> PostProcessingResult { - let primaryModel = resolvedPrimaryModel() + var primaryModel = resolvedPrimaryModel() let retryModel = resolvedRetryModel(for: primaryModel) + + // Circuit breaker: swap to the fallback up-front if the primary is cooling down. + primaryModel = await LLMCooldownManager.shared.effectivePrimary(primaryModel, fallback: retryModel) + do { return try await processCommandTransform( selectedText: selectedText, @@ -323,11 +344,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 +362,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 +491,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 retryAfter = LLMCooldownManager.retryAfterSeconds(from: httpResponse) + await LLMCooldownManager.shared.setCooldown(model, retryAfterSeconds: retryAfter) + throw PostProcessingError.rateLimited(model: model, retryAfter: retryAfter) + } let message = String(data: data, encoding: .utf8) ?? "" throw PostProcessingError.requestFailed(httpResponse.statusCode, message) } @@ -476,7 +511,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 +631,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 retryAfter = LLMCooldownManager.retryAfterSeconds(from: httpResponse) + await LLMCooldownManager.shared.setCooldown(model, retryAfterSeconds: retryAfter) + throw PostProcessingError.rateLimited(model: model, retryAfter: retryAfter) + } let message = String(data: data, encoding: .utf8) ?? "" throw PostProcessingError.requestFailed(httpResponse.statusCode, message) } @@ -607,7 +649,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) diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index d639bf1b..3c5b444a 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,27 @@ 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 + } + private func commitContextModel() { let trimmed = contextModelDraft.trimmingCharacters(in: .whitespacesAndNewlines) contextModelDraft = trimmed @@ -164,6 +189,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 \(expiry.formatted(date: .omitted, time: .shortened))") + } 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 +214,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 \(expiry.formatted(date: .omitted, time: .shortened))") + } 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 +389,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 + } + } } } From 2e9e05922ba6e6330f4db83a94cfd2d5b0369f8a Mon Sep 17 00:00:00 2001 From: Saphi Date: Sun, 28 Jun 2026 08:07:28 -0300 Subject: [PATCH 03/13] fix: address CodeRabbit review (daily persistence, raw-transcript safe-exit, reset-date label) - Persist a daily cooldown by header kind: when the value comes from the daily (Requests-Per-Day) reset header, persist it even if it resets in under an hour, so it survives restart and shows in Settings (was kept in memory only). - After an up-front cooldown swap, still return the safe raw transcript on a suspected-instruction-execution instead of throwing the error. - Settings reset label now includes the date when the cooldown expires on a later day, so a cross-midnight daily reset is not shown as an ambiguous bare time. Modified files: - Sources/LLMCooldownManager.swift - Sources/PostProcessingService.swift - Sources/SettingsView.swift --- Sources/LLMCooldownManager.swift | 25 ++++++++++++++----------- Sources/PostProcessingService.swift | 26 ++++++++++++++++++-------- Sources/SettingsView.swift | 14 ++++++++++++-- 3 files changed, 44 insertions(+), 21 deletions(-) diff --git a/Sources/LLMCooldownManager.swift b/Sources/LLMCooldownManager.swift index 0f086490..fa80b514 100644 --- a/Sources/LLMCooldownManager.swift +++ b/Sources/LLMCooldownManager.swift @@ -47,9 +47,11 @@ actor LLMCooldownManager { /// 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. - func setCooldown(_ model: String, retryAfterSeconds: TimeInterval) { + /// 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 retryAfterSeconds >= dailyLimitThreshold { + if persist || retryAfterSeconds >= dailyLimitThreshold { // Daily limit: persist to UserDefaults so it survives app restarts. persistExpiry(expiryDate, for: model) } else { @@ -101,27 +103,28 @@ actor LLMCooldownManager { // MARK: - Rate-limit header parsing - /// Reads how long a rate-limited model must cool down from a Groq 429 response. + /// 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: `retry-after` (delta-seconds) → `x-ratelimit-reset-requests` (the daily / /// Requests-Per-Day reset) → `x-ratelimit-reset-tokens` (the per-minute / Tokens-Per-Minute - /// reset) → a 60s fallback when no timing header is present. Reading reset-requests is what - /// lets a genuine daily limit cross the persistence threshold and surface in Settings. - /// nonisolated static so the HTTP call site computes it synchronously without an actor hop. - nonisolated static func retryAfterSeconds(from httpResponse: HTTPURLResponse) -> TimeInterval { + /// reset) → a short re-probe fallback when no timing header is present. Only the RPD header + /// marks the limit daily; `retry-after` is ambiguous, so its daily-ness is left to the duration + /// threshold in `setCooldown`. nonisolated static so the call site computes it without an actor hop. + nonisolated static func rateLimitCooldown(from httpResponse: HTTPURLResponse) -> (seconds: TimeInterval, isDaily: Bool) { // retry-after is the authoritative wait Groq sets specifically on a 429. if let value = httpResponse.value(forHTTPHeaderField: "retry-after").flatMap(parseGroqDuration) { - return value + return (value, false) } // x-ratelimit-reset-requests carries the daily (RPD) reset, e.g. "2m59.56s" or hours. if let value = httpResponse.value(forHTTPHeaderField: "x-ratelimit-reset-requests").flatMap(parseGroqDuration) { - return value + return (value, true) } // 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 + return (value, false) } // No timing header present (rare): cool down briefly so the next call can re-probe. - return Self.defaultReprobeCooldownSeconds + return (Self.defaultReprobeCooldownSeconds, false) } /// Parses a Groq duration string into a TimeInterval. Accepts bare seconds ("2", "7.66"), diff --git a/Sources/PostProcessingService.swift b/Sources/PostProcessingService.swift index 709186f9..3609b679 100644 --- a/Sources/PostProcessingService.swift +++ b/Sources/PostProcessingService.swift @@ -298,8 +298,18 @@ Behavior: throw error } - // Guard against re-trying the same model when primaryModel is already the fallback. - guard let retryModel, primaryModel != retryModel else { + // 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 } @@ -496,9 +506,9 @@ Model: \(model) 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 retryAfter = LLMCooldownManager.retryAfterSeconds(from: httpResponse) - await LLMCooldownManager.shared.setCooldown(model, retryAfterSeconds: retryAfter) - throw PostProcessingError.rateLimited(model: model, retryAfter: retryAfter) + 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) @@ -634,9 +644,9 @@ Model: \(model) // 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 retryAfter = LLMCooldownManager.retryAfterSeconds(from: httpResponse) - await LLMCooldownManager.shared.setCooldown(model, retryAfterSeconds: retryAfter) - throw PostProcessingError.rateLimited(model: model, retryAfter: retryAfter) + 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) diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index 3c5b444a..08849d19 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -119,6 +119,16 @@ struct ProviderSettingsFields: View { 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 @@ -193,7 +203,7 @@ struct ProviderSettingsFields: View { // Disappears automatically once the limit window resets. if let expiry = dailyCooldownExpiry(for: appState.postProcessingModel) { Label { - Text("Daily limit reached — resets at \(expiry.formatted(date: .omitted, time: .shortened))") + Text("Daily limit reached — resets at \(formattedCooldownReset(expiry))") } icon: { Image(systemName: "exclamationmark.triangle.fill") } @@ -218,7 +228,7 @@ struct ProviderSettingsFields: View { // 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 \(expiry.formatted(date: .omitted, time: .shortened))") + Text("Fallback daily limit reached — resets at \(formattedCooldownReset(expiry))") } icon: { Image(systemName: "exclamationmark.triangle.fill") } From 087a6d068459feefa5c67c6b7d3ae7a3dde06228 Mon Sep 17 00:00:00 2001 From: Saphi Date: Sun, 28 Jun 2026 15:19:05 -0300 Subject: [PATCH 04/13] fix: address CodeRabbit re-review (RPD detection + skip doomed request when both models cool) - rateLimitCooldown now checks x-ratelimit-remaining-requests <= 0 first and uses the daily (RPD) reset, so a short near-reset daily window is still persisted and shown in Settings even when retry-after is also present. - effectivePrimary returns nil when BOTH the primary and the fallback are cooling; the wrappers then skip the doomed request and degrade gracefully (raw transcript for cleanup, selection unchanged for Edit Mode). Modified files: - Sources/LLMCooldownManager.swift - Sources/PostProcessingService.swift --- Sources/LLMCooldownManager.swift | 40 +++++++++++++++++------------ Sources/PostProcessingService.swift | 18 +++++++++---- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/Sources/LLMCooldownManager.swift b/Sources/LLMCooldownManager.swift index fa80b514..1fb95ae9 100644 --- a/Sources/LLMCooldownManager.swift +++ b/Sources/LLMCooldownManager.swift @@ -60,14 +60,17 @@ actor LLMCooldownManager { } } - /// Returns the model to use up-front: the fallback when the primary is in a rate-limit - /// cooldown and a fallback exists, otherwise the primary. Lets a caller route around a - /// cooling-down model in a single call. Behaviorally identical to the inline swap it replaces. - func effectivePrimary(_ primary: String, fallback: String?) -> String { - if isInCooldown(primary), let fallback { - return fallback + /// 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 } - return primary + guard let fallback, !isInCooldown(fallback) else { + return nil + } + return fallback } // MARK: - UserDefaults (daily-limit persistence) @@ -105,20 +108,25 @@ actor LLMCooldownManager { /// 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: `retry-after` (delta-seconds) → `x-ratelimit-reset-requests` (the daily / - /// Requests-Per-Day reset) → `x-ratelimit-reset-tokens` (the per-minute / Tokens-Per-Minute - /// reset) → a short re-probe fallback when no timing header is present. Only the RPD header - /// marks the limit daily; `retry-after` is ambiguous, so its daily-ness is left to the duration - /// threshold in `setCooldown`. nonisolated static so the call site computes it without an actor hop. + /// 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-requests carries the daily (RPD) reset, e.g. "2m59.56s" or hours. - if let value = httpResponse.value(forHTTPHeaderField: "x-ratelimit-reset-requests").flatMap(parseGroqDuration) { - return (value, true) - } // 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) diff --git a/Sources/PostProcessingService.swift b/Sources/PostProcessingService.swift index 3609b679..5a5de5a5 100644 --- a/Sources/PostProcessingService.swift +++ b/Sources/PostProcessingService.swift @@ -262,9 +262,13 @@ Behavior: var primaryModel = resolvedPrimaryModel() let retryModel = resolvedRetryModel(for: primaryModel) - // Circuit breaker: swap to the fallback up-front if the primary is cooling down. - // Reassigning primaryModel keeps the call site below byte-identical to upstream. - primaryModel = await LLMCooldownManager.shared.effectivePrimary(primaryModel, fallback: retryModel) + // 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( @@ -341,8 +345,12 @@ Behavior: var primaryModel = resolvedPrimaryModel() let retryModel = resolvedRetryModel(for: primaryModel) - // Circuit breaker: swap to the fallback up-front if the primary is cooling down. - primaryModel = await LLMCooldownManager.shared.effectivePrimary(primaryModel, fallback: retryModel) + // 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( From 1501e3dfc483b4f0f7227c33e801d80234e8d201 Mon Sep 17 00:00:00 2001 From: ChaoticQubit Date: Tue, 30 Jun 2026 23:04:48 -0400 Subject: [PATCH 05/13] Add preserve-exact-wording toggle Add a new Cleanup settings card with a toggle that, when on, skips the LLM post-processing step so the raw transcript is pasted verbatim, including profanity and informal wording. Voice macros and Edit Mode continue to run as before. --- Sources/AppState.swift | 21 ++++++++++++++++++++- Sources/SettingsView.swift | 15 +++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/Sources/AppState.swift b/Sources/AppState.swift index c65bc809..f0ba9d6b 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" @@ -505,6 +506,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) @@ -676,6 +683,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 +758,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 @@ -2426,6 +2435,7 @@ final class AppState: ObservableObject, @unchecked Sendable { case voiceMacro(command: String) case postProcessingSucceeded case postProcessingFailedFallback + case preservedExactWording case commandModeSucceeded(invocation: CommandInvocation) case commandModeFailedFallback(invocation: CommandInvocation) @@ -2441,6 +2451,8 @@ 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 .commandModeSucceeded(let invocation): return "Edit mode succeeded (\(invocation.rawValue))" case .commandModeFailedFallback(let invocation): @@ -2484,7 +2496,14 @@ 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: skip the LLM cleanup step so the + // raw transcript from the transcription service is used verbatim, + // including profanity and informal wording. + if preserveExactWording { + return (trimmedRawTranscript, .preservedExactWording, "") + } + do { let result = try await postProcessingService.postProcess( transcript: trimmedRawTranscript, diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index d639bf1b..3de5aebc 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -663,6 +663,9 @@ struct GeneralSettingsView: View { SettingsCard("Edit Mode", icon: "pencil") { commandModeSection } + SettingsCard("Cleanup", icon: "sparkles") { + cleanupSection + } SettingsCard("Clipboard", icon: "doc.on.clipboard") { clipboardSection } @@ -1187,6 +1190,18 @@ 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 raw transcript from the transcription service verbatim, including informal or explicit words. Voice macros and Edit Mode still run.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + // MARK: Clipboard private var clipboardSection: some View { From 822465599582aa7b410c5a994df3bad7643b811f Mon Sep 17 00:00:00 2001 From: ChaoticQubit Date: Wed, 1 Jul 2026 00:27:06 -0400 Subject: [PATCH 06/13] Route preserve-exact-wording through verbatim translator Address the interaction between the Preserve exact wording toggle and the Output Language setting. Previously the toggle skipped postProcess() entirely, which also silently dropped translation. Users who had configured an Output Language stopped seeing translated output once they enabled verbatim mode. Add PostProcessingService.translateVerbatim, a translate-only path that shares the primary/fallback model selection but uses a stripped- down system prompt: literal translation, keep filler words, keep informal wording, keep profanity, no reformatting. processTranscript now routes: - preserveExactWording=off: unchanged (regular postProcess). - preserveExactWording=on, no Output Language: skip LLM entirely. - preserveExactWording=on, Output Language set: translateVerbatim. Thread preserveExactWording as an explicit function parameter instead of reading from self, matching how outputLanguage and the vocabulary settings are already passed. Add TranscriptProcessingOutcome cases for the two new paths and surface them in the status message. Persist the raw dictation for retry when translateVerbatim fails, matching postProcessingFailedFallback. --- Sources/AppState.swift | 49 +++++-- Sources/PostProcessingService.swift | 190 ++++++++++++++++++++++++++++ Sources/SettingsView.swift | 6 +- 3 files changed, 236 insertions(+), 9 deletions(-) diff --git a/Sources/AppState.swift b/Sources/AppState.swift index f0ba9d6b..c81a1f79 100644 --- a/Sources/AppState.swift +++ b/Sources/AppState.swift @@ -1191,7 +1191,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( @@ -2436,6 +2437,8 @@ final class AppState: ObservableObject, @unchecked Sendable { case postProcessingSucceeded case postProcessingFailedFallback case preservedExactWording + case preservedExactWordingTranslated + case preservedExactWordingTranslationFailedFallback case commandModeSucceeded(invocation: CommandInvocation) case commandModeFailedFallback(invocation: CommandInvocation) @@ -2453,6 +2456,10 @@ final class AppState: ObservableObject, @unchecked Sendable { : "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): @@ -2468,7 +2475,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) @@ -2497,11 +2505,34 @@ final class AppState: ObservableObject, @unchecked Sendable { return (macro.payload, .voiceMacro(command: macro.command), "") } - // Preserve-exact-wording mode: skip the LLM cleanup step so the - // raw transcript from the transcription service is used verbatim, - // including profanity and informal wording. + // 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 { - return (trimmedRawTranscript, .preservedExactWording, "") + 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 { @@ -2671,7 +2702,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() @@ -2718,7 +2750,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/PostProcessingService.swift b/Sources/PostProcessingService.swift index 4465a061..19d4660c 100644 --- a/Sources/PostProcessingService.swift +++ b/Sources/PostProcessingService.swift @@ -197,6 +197,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, @@ -628,6 +681,143 @@ 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 "" } diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index 3de5aebc..8e6d659d 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -1196,7 +1196,11 @@ struct GeneralSettingsView: 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 raw transcript from the transcription service verbatim, including informal or explicit words. Voice macros and Edit Mode still run.") + 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) } From 2003af97b9c610882535282fa8c37f58a0c67adc Mon Sep 17 00:00:00 2001 From: ChaoticQubit Date: Wed, 1 Jul 2026 00:39:22 -0400 Subject: [PATCH 07/13] Use verbatim-specific sanitizer for translateVerbatim sanitizePostProcessedTranscript treats the string "EMPTY" as a sentinel meaning "nothing to paste" because the cleanup system prompt explicitly instructs the model to return that value when appropriate. The verbatim translation prompt has no such instruction, so applying the same sanitizer risks silently dropping a legitimate literal translation of the word "empty" into the target language. Add sanitizeVerbatimTranslation, which only trims whitespace and strips wrapping quotes. --- Sources/PostProcessingService.swift | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/Sources/PostProcessingService.swift b/Sources/PostProcessingService.swift index 19d4660c..92132acd 100644 --- a/Sources/PostProcessingService.swift +++ b/Sources/PostProcessingService.swift @@ -814,10 +814,28 @@ Model: \(model) guard !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw PostProcessingError.emptyOutput } - let sanitized = sanitizePostProcessedTranscript(content) + let sanitized = sanitizeVerbatimTranslation(content) return PostProcessingResult(transcript: sanitized, prompt: promptForDisplay) } + /// Sanitizer for the verbatim translation path. Deliberately + /// omits the `"EMPTY"` sentinel that `sanitizePostProcessedTranscript` + /// uses — that sentinel is reserved by the cleanup prompt (which + /// asks the LLM to return `EMPTY` when there's nothing to paste). + /// The verbatim prompt has no such instruction, so a legitimate + /// literal translation of the word "empty" must reach the user + /// instead of being silently dropped. + private func sanitizeVerbatimTranslation(_ value: String) -> 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 "" } From 3446322c61ef2ba0918a0f2bea8933a50656d451 Mon Sep 17 00:00:00 2001 From: Personal Date: Wed, 1 Jul 2026 11:14:18 +0200 Subject: [PATCH 08/13] fix: increase URLSession timeouts for local LLM inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous 20s request timeout and 30s resource budget are too tight for local ASR and post-processing models, which routinely take longer under load. - Shared session (API validation + post-processing): timeoutIntervalForRequest 20s→120s, timeoutIntervalForResource 30s→300s - Upload session (transcription): timeoutIntervalForRequest 300s, timeoutIntervalForResource removed (no budget cap) — a chunking proxy that processes long audio in segments needs effectively unlimited time Without this, recordings longer than ~20 s triggered "Transcription timed out" or silently dropped because the URLSession killed the request before the local model finished responding. Co-Authored-By: Claude Sonnet 4.6 --- Sources/LLMAPITransport.swift | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Sources/LLMAPITransport.swift b/Sources/LLMAPITransport.swift index e414d13b..7081e375 100644 --- a/Sources/LLMAPITransport.swift +++ b/Sources/LLMAPITransport.swift @@ -9,8 +9,8 @@ enum LLMAPITransport { let configuration = URLSessionConfiguration.ephemeral configuration.requestCachePolicy = .reloadIgnoringLocalCacheData configuration.urlCache = nil - configuration.timeoutIntervalForRequest = 20 - configuration.timeoutIntervalForResource = 30 + configuration.timeoutIntervalForRequest = 120 // 2 min to start receiving each packet + configuration.timeoutIntervalForResource = 300 // 5 min total per request (local LLMs are slow) return URLSession(configuration: configuration) } @@ -24,9 +24,14 @@ enum LLMAPITransport { 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. + // Use generous timeouts: local ASR + chunking can take minutes. + let configuration = URLSessionConfiguration.ephemeral + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.urlCache = nil + configuration.timeoutIntervalForRequest = 300 // 5 min to start receiving response + configuration.timeoutIntervalForResource = .infinity // no cap on total transfer + let session = URLSession(configuration: configuration) defer { session.finishTasksAndInvalidate() } return try await session.upload(for: request, from: bodyData) } From d776b7da284b50ae5577d4c86293cce8009cb8e7 Mon Sep 17 00:00:00 2001 From: marcbodea Date: Fri, 10 Jul 2026 13:41:40 +0300 Subject: [PATCH 09/13] Switch context model to qwen/qwen3.6-27b --- Sources/AppContextService.swift | 5 +++-- Sources/AppState.swift | 19 +++++++++++++++++-- Sources/ModelConfiguration.swift | 9 +++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/Sources/AppContextService.swift b/Sources/AppContextService.swift index 17832dbf..9176a68e 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 diff --git a/Sources/AppState.swift b/Sources/AppState.swift index c65bc809..786d9dce 100644 --- a/Sources/AppState.swift +++ b/Sources/AppState.swift @@ -276,7 +276,8 @@ final class AppState: ObservableObject, @unchecked Sendable { ] 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 defaultContextModel = "qwen/qwen3.6-27b" + 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}]*$"# ) @@ -627,7 +628,7 @@ final class AppState: ObservableObject, @unchecked Sendable { 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 contextModel = Self.loadStoredContextModel(key: contextModelStorageKey) let shortcuts = Self.loadShortcutConfiguration( holdKey: holdShortcutStorageKey, toggleKey: toggleShortcutStorageKey, @@ -860,6 +861,20 @@ 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 loadShortcutConfiguration( holdKey: String, toggleKey: String, diff --git a/Sources/ModelConfiguration.swift b/Sources/ModelConfiguration.swift index ced1e9a1..fe030c98 100644 --- a/Sources/ModelConfiguration.swift +++ b/Sources/ModelConfiguration.swift @@ -16,6 +16,7 @@ public struct ModelConfiguration { "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 +36,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 +70,13 @@ public struct ModelConfiguration { includeReasoning: nil, shouldStripThinkTags: true ) + } else if cleanModel == "qwen/qwen3.6-27b" { + return ModelConfig( + maxCompletionTokens: nil, + reasoningEffort: nil, + includeReasoning: nil, + shouldStripThinkTags: true + ) } else if cleanModel == "llama-3.1-8b-instant" { return ModelConfig( maxCompletionTokens: nil, From 29bf9e36239e0e76d2b21d8b7cafce55b41ec773 Mon Sep 17 00:00:00 2001 From: marcbodea Date: Fri, 10 Jul 2026 14:05:36 +0300 Subject: [PATCH 10/13] Strip think tags before summarizing activity --- Makefile | 15 ++++++- Sources/AppContextService.swift | 18 +++++++-- Tests/AppContextServiceTests.swift | 63 ++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 Tests/AppContextServiceTests.swift 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 9176a68e..d82fdbe8 100644 --- a/Sources/AppContextService.swift +++ b/Sources/AppContextService.swift @@ -279,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/Tests/AppContextServiceTests.swift b/Tests/AppContextServiceTests.swift new file mode 100644 index 00000000..e26a64a4 --- /dev/null +++ b/Tests/AppContextServiceTests.swift @@ -0,0 +1,63 @@ +import Foundation + +@main +struct AppContextServiceTests { + static func main() { + testQwenRawOutputIsSummarized() + testQwenReasoningOutputIsStripped() + testNonStrippingModelPreservesExistingBehavior() + 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 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)") + } + } +} From 6bc9ad6046c02d5e633071184cc28fd3854b10c2 Mon Sep 17 00:00:00 2001 From: marcbodea Date: Fri, 10 Jul 2026 17:31:09 +0300 Subject: [PATCH 11/13] fix: gate transcription response format by model capability --- Sources/TranscriptionService.swift | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/Sources/TranscriptionService.swift b/Sources/TranscriptionService.swift index 5bfe958b..b61bbc04 100644 --- a/Sources/TranscriptionService.swift +++ b/Sources/TranscriptionService.swift @@ -4,15 +4,22 @@ 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 var transcriptionResponseFormat: String { - // OpenAI's *-transcribe model family rejects "verbose_json" with a 400 error; - // other providers (Groq whisper-large-v3, etc.) need it for no_speech_prob segments. - // The hallucination filter degrades gracefully when segments are absent. - transcriptionModel.lowercased().contains("transcribe") ? "json" : "verbose_json" + Self.responseFormat(forModel: transcriptionModel) } private var transcriptionTimeoutSeconds: TimeInterval { let override = UserDefaults.standard.double(forKey: "transcription_timeout_seconds") @@ -33,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) From 40c5bbf2faf6b16b5e979729b89bb937a39411ba Mon Sep 17 00:00:00 2001 From: marcbodea Date: Fri, 10 Jul 2026 17:31:09 +0300 Subject: [PATCH 12/13] fix: honor per-request timeouts in API transport --- Sources/LLMAPITransport.swift | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/Sources/LLMAPITransport.swift b/Sources/LLMAPITransport.swift index 7081e375..24de389d 100644 --- a/Sources/LLMAPITransport.swift +++ b/Sources/LLMAPITransport.swift @@ -1,23 +1,33 @@ 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 = 120 // 2 min to start receiving each packet - configuration.timeoutIntervalForResource = 300 // 5 min total per request (local LLMs are slow) + // 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( @@ -25,13 +35,7 @@ enum LLMAPITransport { from bodyData: Data ) async throws -> (Data, URLResponse) { // Fresh session per upload — no poisoned connection re-use. - // Use generous timeouts: local ASR + chunking can take minutes. - let configuration = URLSessionConfiguration.ephemeral - configuration.requestCachePolicy = .reloadIgnoringLocalCacheData - configuration.urlCache = nil - configuration.timeoutIntervalForRequest = 300 // 5 min to start receiving response - configuration.timeoutIntervalForResource = .infinity // no cap on total transfer - let session = URLSession(configuration: configuration) + let session = makeEphemeralSession(timeout: timeout(for: request)) defer { session.finishTasksAndInvalidate() } return try await session.upload(for: request, from: bodyData) } From 1de2c2fec50cfa24bf988b670a7598138914a05f Mon Sep 17 00:00:00 2001 From: marcbodea Date: Tue, 14 Jul 2026 00:36:06 +0300 Subject: [PATCH 13/13] Prepare v1.2.0 release --- CHANGELOG.md | 22 ++++++++++++++++++++++ Sources/AppState.swift | 21 +++++++++++++++++++-- Sources/ModelConfiguration.swift | 8 ++------ Sources/PostProcessingService.swift | 2 +- Tests/AppContextServiceTests.swift | 23 +++++++++++++++++++++++ 5 files changed, 67 insertions(+), 9 deletions(-) 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/Sources/AppState.swift b/Sources/AppState.swift index 7a79310c..0626d972 100644 --- a/Sources/AppState.swift +++ b/Sources/AppState.swift @@ -276,8 +276,9 @@ 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 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}]*$"# @@ -634,7 +635,9 @@ 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 postProcessingFallbackModel = Self.loadStoredPostProcessingFallbackModel( + key: postProcessingFallbackModelStorageKey + ) let contextModel = Self.loadStoredContextModel(key: contextModelStorageKey) let shortcuts = Self.loadShortcutConfiguration( holdKey: holdShortcutStorageKey, @@ -884,6 +887,20 @@ final class AppState: ObservableObject, @unchecked Sendable { 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, diff --git a/Sources/ModelConfiguration.swift b/Sources/ModelConfiguration.swift index fe030c98..0e976873 100644 --- a/Sources/ModelConfiguration.swift +++ b/Sources/ModelConfiguration.swift @@ -9,13 +9,9 @@ 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", @@ -73,8 +69,8 @@ public struct ModelConfiguration { } else if cleanModel == "qwen/qwen3.6-27b" { return ModelConfig( maxCompletionTokens: nil, - reasoningEffort: nil, - includeReasoning: nil, + reasoningEffort: "none", + includeReasoning: false, shouldStripThinkTags: true ) } else if cleanModel == "llama-3.1-8b-instant" { diff --git a/Sources/PostProcessingService.swift b/Sources/PostProcessingService.swift index 1d478aa1..1a813cdf 100644 --- a/Sources/PostProcessingService.swift +++ b/Sources/PostProcessingService.swift @@ -138,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 { diff --git a/Tests/AppContextServiceTests.swift b/Tests/AppContextServiceTests.swift index e26a64a4..39eb0a6a 100644 --- a/Tests/AppContextServiceTests.swift +++ b/Tests/AppContextServiceTests.swift @@ -6,6 +6,8 @@ struct AppContextServiceTests { testQwenRawOutputIsSummarized() testQwenReasoningOutputIsStripped() testNonStrippingModelPreservesExistingBehavior() + testDeprecatedGroqModelsAreNotPredefined() + testQwenCleanupDisablesReasoning() print("AppContextServiceTests passed") } @@ -51,6 +53,27 @@ struct AppContextServiceTests { 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) }