diff --git a/README.md b/README.md
index 6016faf..5a36668 100644
--- a/README.md
+++ b/README.md
@@ -9,24 +9,50 @@ A native macOS push-to-talk dictation app. Hold a key, speak, release — your
speech is transcribed and (optionally) cleaned up by ChatGPT, then pasted at your
cursor in any app.
-Unlike [ZeroType](https://github.com/nick1ee/ZeroType) (which needs a paid API
-key), LizardType uses **your existing ChatGPT web session** (`cookies.json`) via
-a hidden, logged-in `WKWebView` — so transcription and cleanup run on your
-ChatGPT subscription. No API key, no extra cost.
+LizardType supports two interchangeable **API providers** (pick one in Settings):
+
+- **ChatGPT Web** (default) — uses **your existing ChatGPT web session**
+ (`cookies.json`) via a hidden, logged-in `WKWebView`, so transcription and
+ cleanup run on your ChatGPT subscription. No API key, no extra cost. Unlike
+ [ZeroType](https://github.com/nick1ee/ZeroType), which needs a paid API key.
+- **Groq API** — uses [Groq](https://console.groq.com/docs/quickstart)'s
+ OpenAI-compatible endpoints (Whisper for transcription, Llama/etc. for
+ cleanup). Fast and key-driven; no ChatGPT session needed. Just paste your
+ `GROQ_API_KEY`.
## How it works
```
-hold trigger key ─▶ record m4a ─▶ WKWebView (logged in via cookies.json)
- ├─ POST /backend-api/transcribe → raw text
- └─ POST /backend-api/conversation → cleaned text
+ ┌─ ChatGPT Web (cookies.json) ─ WKWebView
+hold trigger key ─▶ record m4a ─▶─┤ ├─ POST /backend-api/transcribe → raw text
+ │ └─ POST /backend-api/conversation → cleaned text
+ │
+ └─ Groq API (GROQ_API_KEY) ─ URLSession
+ ├─ POST /openai/v1/audio/transcriptions → raw text
+ └─ POST /openai/v1/chat/completions → cleaned text
│
paste at cursor (⌘V) ◀────────┘
```
-The WebView runs real WebKit on your Mac, so it passes Cloudflare like Safari and
-inherits your session. `/api/auth/session` provides the bearer token the
-`/backend-api/*` calls need.
+In **ChatGPT Web** mode the WebView runs real WebKit on your Mac, so it passes
+Cloudflare like Safari and inherits your session; `/api/auth/session` provides
+the bearer token the `/backend-api/*` calls need.
+
+### Using the Groq provider
+
+1. Get an API key at [console.groq.com](https://console.groq.com).
+2. **Settings → General → Provider → Groq API**, then paste the key (stored in
+ your macOS Keychain). Alternatively, leave it blank and set `GROQ_API_KEY` in
+ a `.env` file (current directory or `$HOME/.env`) — handy during `make run`.
+3. Optionally tweak the transcribe model (`whisper-large-v3-turbo`) and cleanup
+ model (`llama-3.3-70b-versatile`).
+
+Verify the whole pipeline from the terminal:
+
+```bash
+GROQ_API_KEY=gsk_… build/LizardType.app/Contents/MacOS/LizardType \
+ --selftest --groq /path/to/clip.m4a
+```
## Download
diff --git a/Resources/Info.plist b/Resources/Info.plist
index 87c0bf9..38ff282 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -9,9 +9,9 @@
CFBundleIdentifier
com.lizardtype.app
CFBundleVersion
- 1
+ 2
CFBundleShortVersionString
- 0.1.0
+ 0.2.0
CFBundleExecutable
LizardType
CFBundleIconFile
diff --git a/Sources/AppState.swift b/Sources/AppState.swift
index 23ac7c8..10364fc 100644
--- a/Sources/AppState.swift
+++ b/Sources/AppState.swift
@@ -49,6 +49,7 @@ final class AppState: ObservableObject {
let settings = AppSettings.shared
let recorder = AudioRecorder()
private let bridge = ChatGPTBridge()
+ private let groq = GroqClient()
private let hotkey = HotkeyManager()
private let overlay = OverlayController()
private var pipeline: Task?
@@ -137,21 +138,38 @@ final class AppState: ObservableObject {
}
}
+ /// The backend selected in Settings.
+ private var activeProvider: SpeechProvider {
+ settings.provider == .groq ? groq : bridge
+ }
+
func warmBridge() async {
status = .warming
- guard !settings.cookiesPath.isEmpty else {
- status = .error("Set cookies.json in Settings"); return
- }
- do {
- NSLog("[LizardType] warming bridge with %@", settings.cookiesPath)
- try await bridge.start(cookiesPath: settings.cookiesPath)
- await bridge.waitUntilReady()
- _ = try await bridge.accessToken(forceRefresh: true) // verify login
- status = .ready
- NSLog("[LizardType] bridge ready — logged in")
- } catch {
- status = .error(error.localizedDescription)
- NSLog("[LizardType] warm failed: %@", error.localizedDescription)
+ switch settings.provider {
+ case .groq:
+ do {
+ try await groq.validate() // ensure a key is resolvable
+ status = .ready
+ NSLog("[LizardType] Groq provider ready")
+ } catch {
+ status = .error(error.localizedDescription)
+ NSLog("[LizardType] Groq warm failed: %@", error.localizedDescription)
+ }
+ case .chatgpt:
+ guard !settings.cookiesPath.isEmpty else {
+ status = .error("Set cookies.json in Settings"); return
+ }
+ do {
+ NSLog("[LizardType] warming bridge with %@", settings.cookiesPath)
+ try await bridge.start(cookiesPath: settings.cookiesPath)
+ await bridge.waitUntilReady()
+ _ = try await bridge.accessToken(forceRefresh: true) // verify login
+ status = .ready
+ NSLog("[LizardType] bridge ready — logged in")
+ } catch {
+ status = .error(error.localizedDescription)
+ NSLog("[LizardType] warm failed: %@", error.localizedDescription)
+ }
}
}
@@ -265,10 +283,11 @@ final class AppState: ObservableObject {
busy = true
defer { busy = false; recorder.cleanup(url) }
do {
- await bridge.waitUntilReady()
+ let provider = activeProvider
+ if settings.provider == .chatgpt { await bridge.waitUntilReady() }
status = .transcribing
overlay.show(.transcribing)
- let raw = try await bridge.transcribe(audioURL: url, language: settings.transcribeLanguage)
+ let raw = try await provider.transcribe(audioURL: url, language: settings.transcribeLanguage)
guard !raw.isEmpty else { status = .ready; overlay.hide(); return }
lastTranscript = raw
@@ -276,9 +295,10 @@ final class AppState: ObservableObject {
if settings.cleanupEnabled {
status = .cleaning
overlay.show(.cleaning)
+ let cleanupModel = settings.provider == .groq ? settings.groqCleanupModel : settings.model
do {
- final = try await bridge.cleanup(raw: raw, prompt: settings.cleanupPrompt,
- model: settings.model, language: settings.oaiLanguage)
+ final = try await provider.cleanup(raw: raw, prompt: settings.cleanupPrompt,
+ model: cleanupModel, language: settings.oaiLanguage)
} catch {
// Cleanup failed (e.g. sentinel) — fall back to raw so text is never lost.
final = raw
diff --git a/Sources/Bridge/GroqClient.swift b/Sources/Bridge/GroqClient.swift
new file mode 100644
index 0000000..628f1a3
--- /dev/null
+++ b/Sources/Bridge/GroqClient.swift
@@ -0,0 +1,125 @@
+import Foundation
+
+/// Groq backend (OpenAI-compatible REST). Key-driven, no WebView.
+/// transcribe → POST /openai/v1/audio/transcriptions (whisper-large-v3-turbo)
+/// cleanup → POST /openai/v1/chat/completions (llama-3.3-70b-versatile)
+/// Auth: `Authorization: Bearer ` (see GroqSecrets).
+@MainActor
+final class GroqClient: SpeechProvider {
+
+ enum GroqError: LocalizedError {
+ case noKey
+ case http(Int, String)
+ case badResponse(String)
+ var errorDescription: String? {
+ switch self {
+ case .noKey: return "No Groq API key — paste one in Settings or set GROQ_API_KEY in .env"
+ case .http(let c, let b): return "Groq HTTP \(c): \(b)"
+ case .badResponse(let s): return "Unexpected Groq response: \(s)"
+ }
+ }
+ }
+
+ private let base = URL(string: "https://api.groq.com/openai/v1")!
+ private let session: URLSession
+
+ init(session: URLSession = .shared) { self.session = session }
+
+ private func key() throws -> String {
+ guard let k = GroqSecrets.apiKey() else { throw GroqError.noKey }
+ return k
+ }
+
+ /// Cheap auth/connectivity check used at warm-up. Throws on a missing key or
+ /// an auth failure; tolerates other transient errors (returns without throwing).
+ func validate() async throws {
+ _ = try key() // surface missing-key immediately
+ }
+
+ // MARK: - Transcribe
+
+ func transcribe(audioURL: URL, language: String) async throws -> String {
+ let apiKey = try key()
+ let model = AppSettings.shared.groqTranscribeModel
+ let fileData = try Data(contentsOf: audioURL)
+ let filename = audioURL.lastPathComponent
+ let mime = audioURL.pathExtension == "wav" ? "audio/wav" : "audio/mp4"
+
+ let boundary = "LizardType-\(UUID().uuidString)"
+ var body = Data()
+ func field(_ name: String, _ value: String) {
+ body.append("--\(boundary)\r\n")
+ body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n")
+ body.append("\(value)\r\n")
+ }
+ field("model", model)
+ if !language.isEmpty { field("language", language) }
+ field("response_format", "json")
+ body.append("--\(boundary)\r\n")
+ body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n")
+ body.append("Content-Type: \(mime)\r\n\r\n")
+ body.append(fileData)
+ body.append("\r\n--\(boundary)--\r\n")
+
+ var req = URLRequest(url: base.appendingPathComponent("audio/transcriptions"))
+ req.httpMethod = "POST"
+ req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+ req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+ req.httpBody = body
+
+ let obj = try await sendJSON(req)
+ guard let text = obj["text"] as? String else {
+ throw GroqError.badResponse(String(describing: obj))
+ }
+ return text.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ // MARK: - Cleanup
+
+ func cleanup(raw: String, prompt: String, model: String, language: String) async throws -> String {
+ let apiKey = try key()
+ let message = Prompts.cleanupMessage(prompt: prompt, raw: raw)
+ let payload: [String: Any] = [
+ "model": model,
+ "temperature": 0.2,
+ "messages": [["role": "user", "content": message]],
+ ]
+ var req = URLRequest(url: base.appendingPathComponent("chat/completions"))
+ req.httpMethod = "POST"
+ req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = try JSONSerialization.data(withJSONObject: payload)
+
+ let obj = try await sendJSON(req)
+ guard let choices = obj["choices"] as? [[String: Any]],
+ let first = choices.first,
+ let msg = first["message"] as? [String: Any],
+ let content = msg["content"] as? String else {
+ throw GroqError.badResponse(String(describing: obj))
+ }
+ let text = content.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !text.isEmpty else { throw GroqError.badResponse("empty cleanup result") }
+ return text
+ }
+
+ // MARK: - HTTP helper
+
+ private func sendJSON(_ req: URLRequest) async throws -> [String: Any] {
+ let (data, resp) = try await session.data(for: req)
+ let status = (resp as? HTTPURLResponse)?.statusCode ?? 0
+ guard status == 200 else {
+ let snippet = String(data: data.prefix(800), encoding: .utf8) ?? ""
+ throw GroqError.http(status, snippet)
+ }
+ guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ throw GroqError.badResponse(String(data: data.prefix(800), encoding: .utf8) ?? "")
+ }
+ return obj
+ }
+}
+
+private extension Data {
+ mutating func append(_ string: String) {
+ if let d = string.data(using: .utf8) { append(d) }
+ }
+}
diff --git a/Sources/Bridge/GroqSecrets.swift b/Sources/Bridge/GroqSecrets.swift
new file mode 100644
index 0000000..9d22ef7
--- /dev/null
+++ b/Sources/Bridge/GroqSecrets.swift
@@ -0,0 +1,97 @@
+import Foundation
+import Security
+
+/// Resolves and persists the Groq API key.
+///
+/// Lookup order (first hit wins):
+/// 1. Keychain — written from the Settings SecureField (survives reinstalls,
+/// works for shipped `.dmg` users).
+/// 2. `GROQ_API_KEY` process environment variable.
+/// 3. `.env` file (`KEY=VALUE`) in the current working directory, then `$HOME/.env`.
+/// Accepts `GROQ_API_KEY` or `GROQ_API`. Convenient for `make run` in dev.
+enum GroqSecrets {
+ private static let service = "com.lizardtype.groq"
+ private static let account = "api-key"
+
+ /// The effective key from any source, or nil if none is available.
+ static func apiKey() -> String? {
+ if let k = keychainKey(), !k.isEmpty { return k }
+ if let k = environmentKey(), !k.isEmpty { return k }
+ return nil
+ }
+
+ /// True if a key exists outside the Keychain (env / .env) — used by the UI to
+ /// tell the user a key was auto-detected even though the field is empty.
+ static func hasEnvKey() -> Bool {
+ (environmentKey()?.isEmpty == false)
+ }
+
+ /// The key currently stored in the Keychain (the editable Settings value).
+ static func keychainKey() -> String? {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ ]
+ var item: CFTypeRef?
+ guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
+ let data = item as? Data,
+ let s = String(data: data, encoding: .utf8) else { return nil }
+ return s
+ }
+
+ /// Write (or clear, when empty) the key in the Keychain.
+ static func setKeychainKey(_ key: String) {
+ let base: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ ]
+ SecItemDelete(base as CFDictionary)
+ let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8) else { return }
+ var add = base
+ add[kSecValueData as String] = data
+ add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
+ SecItemAdd(add as CFDictionary, nil)
+ }
+
+ // MARK: - Environment / .env
+
+ private static func environmentKey() -> String? {
+ let env = ProcessInfo.processInfo.environment
+ if let k = env["GROQ_API_KEY"], !k.isEmpty { return k }
+ if let k = env["GROQ_API"], !k.isEmpty { return k }
+ let fm = FileManager.default
+ let candidates = [
+ fm.currentDirectoryPath + "/.env",
+ (fm.homeDirectoryForCurrentUser.path) + "/.env",
+ ]
+ for path in candidates {
+ if let k = parseEnvFile(path) { return k }
+ }
+ return nil
+ }
+
+ /// Minimal `.env` parser: returns the value of GROQ_API_KEY / GROQ_API.
+ private static func parseEnvFile(_ path: String) -> String? {
+ guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }
+ for rawLine in contents.split(whereSeparator: \.isNewline) {
+ var line = rawLine.trimmingCharacters(in: .whitespaces)
+ if line.isEmpty || line.hasPrefix("#") { continue }
+ if line.hasPrefix("export ") { line = String(line.dropFirst("export ".count)) }
+ guard let eq = line.firstIndex(of: "=") else { continue }
+ let name = line[..= 2, let f = value.first, (f == "\"" || f == "'"), value.last == f {
+ value = String(value.dropFirst().dropLast())
+ }
+ if !value.isEmpty { return value }
+ }
+ return nil
+ }
+}
diff --git a/Sources/Bridge/SpeechProvider.swift b/Sources/Bridge/SpeechProvider.swift
new file mode 100644
index 0000000..1d97678
--- /dev/null
+++ b/Sources/Bridge/SpeechProvider.swift
@@ -0,0 +1,14 @@
+import Foundation
+
+/// A backend that can transcribe recorded audio and (optionally) clean it up
+/// with an LLM. Implemented by `ChatGPTBridge` (WebView) and `GroqClient` (REST).
+@MainActor
+protocol SpeechProvider: AnyObject {
+ /// Transcribe the audio file. Returns the raw transcript text.
+ func transcribe(audioURL: URL, language: String) async throws -> String
+
+ /// Run the cleanup LLM pass over `raw` using `prompt`. Returns cleaned text.
+ func cleanup(raw: String, prompt: String, model: String, language: String) async throws -> String
+}
+
+extension ChatGPTBridge: SpeechProvider {}
diff --git a/Sources/Diagnostics.swift b/Sources/Diagnostics.swift
index 3428913..faa197e 100644
--- a/Sources/Diagnostics.swift
+++ b/Sources/Diagnostics.swift
@@ -29,6 +29,13 @@ enum Diagnostics {
let audio = args.dropFirst().first {
FileManager.default.fileExists(atPath: $0) && ($0.hasSuffix(".m4a") || $0.hasSuffix(".wav"))
}
+
+ // --groq forces the Groq path; otherwise follow the configured provider.
+ if args.contains("--groq") || settings.provider == .groq {
+ await runGroqSelfTest(audio: audio, settings: settings, p: p)
+ return
+ }
+
guard !cookies.isEmpty else { p("no cookies path set → skipping pipeline"); return }
let bridge = ChatGPTBridge()
@@ -54,4 +61,32 @@ enum Diagnostics {
p("ERROR: \(error.localizedDescription)")
}
}
+
+ private static func runGroqSelfTest(audio: String?, settings: AppSettings,
+ p: (String) -> Void) async {
+ p("provider: Groq")
+ p("API key: \(GroqSecrets.apiKey() != nil ? "found ✓" : "MISSING ✗")")
+ p("transcribe model: \(settings.groqTranscribeModel)")
+ p("cleanup model: \(settings.groqCleanupModel)")
+ let groq = GroqClient()
+ do {
+ try await groq.validate()
+ if let audio {
+ p("transcribing \(audio) …")
+ let raw = try await groq.transcribe(audioURL: URL(fileURLWithPath: audio),
+ language: settings.transcribeLanguage)
+ p("RAW : \(raw)")
+ p("cleaning up…")
+ let clean = try await groq.cleanup(raw: raw, prompt: settings.cleanupPrompt,
+ model: settings.groqCleanupModel,
+ language: settings.oaiLanguage)
+ p("CLEAN : \(clean)")
+ } else {
+ p("(pass a .m4a/.wav path to also test transcribe + cleanup)")
+ }
+ p("=== self-test OK ===")
+ } catch {
+ p("ERROR: \(error.localizedDescription)")
+ }
+ }
}
diff --git a/Sources/LizardTypeApp.swift b/Sources/LizardTypeApp.swift
index e956f8f..be6c969 100644
--- a/Sources/LizardTypeApp.swift
+++ b/Sources/LizardTypeApp.swift
@@ -40,7 +40,7 @@ struct MenuContent: View {
}
Text("Push-to-talk: \(settings.shortcutDisplay)") // the real global trigger (hold)
Divider()
- Toggle("Clean up with ChatGPT", isOn: $settings.cleanupEnabled) // off → paste raw transcript
+ Toggle("Clean up transcript (LLM pass)", isOn: $settings.cleanupEnabled) // off → paste raw transcript
if !app.lastTranscript.isEmpty {
Divider()
Button("Copy last transcript") {
diff --git a/Sources/Model/Settings.swift b/Sources/Model/Settings.swift
index 5c485ef..0d85ac2 100644
--- a/Sources/Model/Settings.swift
+++ b/Sources/Model/Settings.swift
@@ -2,6 +2,21 @@ import Foundation
import Combine
import AppKit
+/// Backend that performs transcription + cleanup.
+/// - `.chatgpt`: logged-in WKWebView, runs on the user's ChatGPT session (no key).
+/// - `.groq`: Groq REST API (OpenAI-compatible), driven by an API key.
+enum APIProvider: String, Codable, CaseIterable {
+ case chatgpt
+ case groq
+
+ var label: String {
+ switch self {
+ case .chatgpt: return "ChatGPT Web (cookies)"
+ case .groq: return "Groq API (key)"
+ }
+ }
+}
+
/// Hold-to-talk trigger. We default to holding a single modifier key (most
/// ergonomic for push-to-talk) but also support a regular key+modifiers chord.
enum TriggerKind: String, Codable, CaseIterable {
@@ -25,6 +40,9 @@ final class AppSettings: ObservableObject {
static let shared = AppSettings()
private let d = UserDefaults.standard
+ @Published var provider: APIProvider { didSet { d.set(provider.rawValue, forKey: "provider") } }
+ @Published var groqTranscribeModel: String { didSet { d.set(groqTranscribeModel, forKey: "groqTranscribeModel") } }
+ @Published var groqCleanupModel: String { didSet { d.set(groqCleanupModel, forKey: "groqCleanupModel") } }
@Published var cookiesPath: String { didSet { d.set(cookiesPath, forKey: "cookiesPath") } }
@Published var trigger: TriggerKind { didSet { d.set(trigger.rawValue, forKey: "trigger") } }
// Custom shortcut (any key + modifiers). When enabled, overrides `trigger`.
@@ -45,6 +63,9 @@ final class AppSettings: ObservableObject {
}
private init() {
+ provider = APIProvider(rawValue: d.string(forKey: "provider") ?? "") ?? .chatgpt
+ groqTranscribeModel = d.string(forKey: "groqTranscribeModel") ?? "whisper-large-v3-turbo"
+ groqCleanupModel = d.string(forKey: "groqCleanupModel") ?? "llama-3.3-70b-versatile"
cookiesPath = d.string(forKey: "cookiesPath") ?? ""
trigger = TriggerKind(rawValue: d.string(forKey: "trigger") ?? "") ?? .rightOption
useCustomShortcut = d.bool(forKey: "useCustomShortcut")
diff --git a/Sources/UI/SettingsView.swift b/Sources/UI/SettingsView.swift
index 92ed8c0..a44e6b0 100644
--- a/Sources/UI/SettingsView.swift
+++ b/Sources/UI/SettingsView.swift
@@ -7,6 +7,7 @@ struct SettingsView: View {
@ObservedObject var app = AppState.shared
@ObservedObject var settings = AppSettings.shared
@State private var tab: SettingsTab
+ @State private var groqKey: String = GroqSecrets.keychainKey() ?? ""
init(tab: SettingsTab = .general) { _tab = State(initialValue: tab) }
@@ -25,7 +26,7 @@ struct SettingsView: View {
private var diagnostics: some View {
Form {
Section("Status") {
- statusRow("Logged in to ChatGPT", app.status == .ready)
+ statusRow("\(settings.provider.label) ready", app.status == .ready)
statusRow("Microphone", app.micAuthorized)
statusRow("Accessibility (paste + hotkey)", app.accessibilityTrusted)
statusRow("Input Monitoring (custom combos)", app.inputMonitoringTrusted)
@@ -83,16 +84,42 @@ struct SettingsView: View {
// MARK: General
private var general: some View {
Form {
- Section("ChatGPT session") {
- HStack {
- TextField("cookies.json path", text: $settings.cookiesPath)
- .textFieldStyle(.roundedBorder)
- Button("Choose…") { chooseCookies() }
- Button("Reconnect") { Task { await app.warmBridge() } }
+ Section("API provider") {
+ Picker("Provider", selection: $settings.provider) {
+ ForEach(APIProvider.allCases, id: \.self) { Text($0.label).tag($0) }
}
+ .onChange(of: settings.provider) { _, _ in Task { await app.warmBridge() } }
Text("Status: \(app.status.menuText)")
.font(.caption).foregroundStyle(.secondary)
}
+ if settings.provider == .chatgpt {
+ Section("ChatGPT session") {
+ HStack {
+ TextField("cookies.json path", text: $settings.cookiesPath)
+ .textFieldStyle(.roundedBorder)
+ Button("Choose…") { chooseCookies() }
+ Button("Reconnect") { Task { await app.warmBridge() } }
+ }
+ }
+ } else {
+ Section("Groq API") {
+ SecureField("GROQ_API_KEY (sk-…/gsk_…)", text: $groqKey)
+ .textFieldStyle(.roundedBorder)
+ .onSubmit { saveGroqKey() }
+ HStack {
+ Button("Save key") { saveGroqKey() }
+ Button("Reconnect") { Task { await app.warmBridge() } }
+ if groqKey.isEmpty && GroqSecrets.hasEnvKey() {
+ Text("Using key from environment / .env")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ }
+ Text("Get a key at console.groq.com. Stored in your macOS Keychain; leave empty to fall back to GROQ_API_KEY in .env.")
+ .font(.caption).foregroundStyle(.secondary)
+ TextField("Transcribe model", text: $settings.groqTranscribeModel)
+ TextField("Cleanup model", text: $settings.groqCleanupModel)
+ }
+ }
Section("Push-to-talk trigger") {
Toggle("Use a custom shortcut", isOn: $settings.useCustomShortcut)
.onChange(of: settings.useCustomShortcut) { _, _ in app.applyTrigger() }
@@ -110,7 +137,9 @@ struct SettingsView: View {
Section("Language & model") {
TextField("Transcribe language (e.g. zh, en)", text: $settings.transcribeLanguage)
TextField("UI language (oai-language, e.g. zh-TW)", text: $settings.oaiLanguage)
- TextField("Cleanup model slug", text: $settings.model)
+ if settings.provider == .chatgpt {
+ TextField("Cleanup model slug", text: $settings.model)
+ }
}
Section("Misc") {
Toggle("Play start/stop sounds", isOn: $settings.playSounds)
@@ -176,6 +205,11 @@ struct SettingsView: View {
}
}
+ private func saveGroqKey() {
+ GroqSecrets.setKeychainKey(groqKey)
+ Task { await app.warmBridge() }
+ }
+
private func chooseCookies() {
let p = NSOpenPanel()
p.allowedContentTypes = [.json]
diff --git a/docs/plans/2026-05-29-groq-provider-design.md b/docs/plans/2026-05-29-groq-provider-design.md
new file mode 100644
index 0000000..d43a59c
--- /dev/null
+++ b/docs/plans/2026-05-29-groq-provider-design.md
@@ -0,0 +1,92 @@
+# Groq API as an alternative provider — Design
+
+Date: 2026-05-29
+Status: accepted
+
+## Problem
+
+LizardType transcribes + cleans up speech through a logged-in ChatGPT
+`WKWebView` (no API key, runs on the user's subscription). That is the only
+backend. Some users have a Groq API key (`GROQ_API_KEY` in `.env`) and want a
+fast, key-driven alternative that does not depend on a ChatGPT web session or
+`cookies.json`.
+
+[Groq](https://console.groq.com/docs/quickstart) exposes OpenAI-compatible REST
+endpoints for both operations LizardType needs:
+
+- Transcription — `POST https://api.groq.com/openai/v1/audio/transcriptions`
+ (`whisper-large-v3-turbo`, `whisper-large-v3`)
+- Cleanup (chat) — `POST https://api.groq.com/openai/v1/chat/completions`
+ (`llama-3.3-70b-versatile`, …)
+
+Both authenticate with `Authorization: Bearer `.
+
+## Decision
+
+Add Groq as a **selectable provider** covering **both** transcription and
+cleanup. The user picks the provider in Settings; ChatGPT Web stays the default
+so existing behavior is unchanged.
+
+### Provider abstraction
+
+```swift
+@MainActor
+protocol SpeechProvider: AnyObject {
+ func transcribe(audioURL: URL, language: String) async throws -> String
+ func cleanup(raw: String, prompt: String, model: String, language: String) async throws -> String
+}
+```
+
+- `ChatGPTBridge` already has these exact methods → conform as-is.
+- `GroqClient` (new) implements them over `URLSession`. It reads its transcribe
+ model from settings; the cleanup `model` is passed in by `AppState`.
+
+`AppState` exposes a computed `activeProvider` and routes the recording pipeline
+through it. Warm-up is provider-aware:
+
+- ChatGPT → load the WebView, verify login (existing flow).
+- Groq → no WebView; verify an API key is resolvable, set `.ready`.
+
+### Key resolution (`GroqSecrets`)
+
+Order, first hit wins:
+
+1. **Keychain** (`service = com.lizardtype.groq`, `account = api-key`) — written
+ from the Settings `SecureField`.
+2. **`GROQ_API_KEY`** process environment variable.
+3. **`.env` file** — `KEY=VALUE` parse of `.env` in the current working
+ directory, then `$HOME/.env`. Supports `GROQ_API_KEY` and `GROQ_API`.
+
+(1) makes it work for shipped `.dmg` users; (2)/(3) make `make run` "just work"
+during development when a `.env` is present.
+
+### Settings (UserDefaults; key is NOT stored in UserDefaults)
+
+- `provider: APIProvider` (`chatgpt` | `groq`), default `chatgpt`
+- `groqTranscribeModel: String`, default `whisper-large-v3-turbo`
+- `groqCleanupModel: String`, default `llama-3.3-70b-versatile`
+
+The API key lives in the Keychain only.
+
+### UI
+
+General tab gains a **Provider** picker. When `groq` is selected it reveals:
+
+- a `SecureField` for the API key (writes Keychain), with a hint showing whether
+ a key was detected from the environment / `.env`,
+- transcribe-model and cleanup-model fields.
+
+Menu and Diagnostics labels are generalized from "ChatGPT" to provider-neutral
+wording.
+
+## Non-goals (YAGNI)
+
+- Streaming responses, model auto-discovery, per-provider prompt variants.
+- Sandbox/entitlement changes — outbound HTTPS works for a non-sandboxed
+ ad-hoc-signed app; Groq is HTTPS so no ATS exception is required.
+
+## Release
+
+Bump `CFBundleShortVersionString` `0.1.0 → 0.2.0`, update README, verify
+`make build`, then push tag `v0.2.0`. The existing `release.yml` workflow builds
+the `.dmg` and publishes the GitHub Release on `v*` tags.