|
| 1 | +package cli |
| 2 | + |
| 3 | +import ( |
| 4 | + "fmt" |
| 5 | + "os" |
| 6 | + "path/filepath" |
| 7 | + "strings" |
| 8 | + |
| 9 | + "github.com/Gitlawb/zero/internal/config" |
| 10 | + "github.com/Gitlawb/zero/internal/dictation" |
| 11 | + "github.com/Gitlawb/zero/internal/tui" |
| 12 | +) |
| 13 | + |
| 14 | +// Default batch cloud models (§8). Overridable via stt.model. |
| 15 | +const ( |
| 16 | + defaultGroqSTTModel = "whisper-large-v3-turbo" |
| 17 | + defaultOpenAISTTModel = "whisper-1" |
| 18 | + groqSTTBaseURL = "https://api.groq.com/openai/v1" |
| 19 | +) |
| 20 | + |
| 21 | +// newDictationTranscriberFactory returns the closure the TUI calls to build a |
| 22 | +// transcriber when a recording starts. It lives here, not in the TUI, because it |
| 23 | +// resolves provider API keys (credstore + env + configured providers) — logic |
| 24 | +// that belongs with the rest of the CLI's credential handling. |
| 25 | +// |
| 26 | +// serverMgr is the shared, session-long sherpa-onnx streaming server; it is |
| 27 | +// spawned lazily on the first local-streaming recording and torn down on exit. |
| 28 | +// |
| 29 | +// preferStreaming asks for the streaming backend; the returned bool reports |
| 30 | +// whether streaming was actually built (false → the caller uses the batch |
| 31 | +// pipeline). A misconfigured streaming backend surfaces its own SetupError. |
| 32 | +func newDictationTranscriberFactory(resolved config.ResolvedConfig, userConfigPath string, serverMgr *dictation.ServerManager) func(config.STTConfig, bool) (tui.Transcriber, bool, error) { |
| 33 | + return func(stt config.STTConfig, preferStreaming bool) (tui.Transcriber, bool, error) { |
| 34 | + // stt is passed in (not captured) so a mid-session change — e.g. the F9 |
| 35 | + // auto-download writing localModelPath — takes effect on the next call. |
| 36 | + if serverMgr != nil { |
| 37 | + serverMgr.SetModelPath(stt.LocalModelPath) |
| 38 | + } |
| 39 | + if preferStreaming { |
| 40 | + t, err := buildStreamingTranscriber(stt, resolved, userConfigPath, serverMgr) |
| 41 | + switch { |
| 42 | + case err == nil && t != nil: |
| 43 | + return t, true, nil |
| 44 | + case err != nil && stt.STTStreamProvider() != config.STTProviderLocal: |
| 45 | + // An explicitly-chosen cloud streaming backend that failed to build |
| 46 | + // (e.g. a missing key) should surface its own error, not silently |
| 47 | + // fall back to batch — the user asked for that backend specifically. |
| 48 | + return nil, true, err |
| 49 | + } |
| 50 | + // The default local streaming backend isn't set up: fall through to the |
| 51 | + // batch pipeline so a cloud-batch provider (or a batch-only setup) still |
| 52 | + // works instead of erroring on a streaming server the user never asked for. |
| 53 | + } |
| 54 | + t, err := buildBatchTranscriber(stt, resolved, userConfigPath) |
| 55 | + return t, false, err |
| 56 | + } |
| 57 | +} |
| 58 | + |
| 59 | +// buildStreamingTranscriber constructs the configured streaming backend, or |
| 60 | +// returns (nil, nil) when streaming is not applicable so the caller falls back |
| 61 | +// to batch. |
| 62 | +func buildStreamingTranscriber(stt config.STTConfig, resolved config.ResolvedConfig, userConfigPath string, serverMgr *dictation.ServerManager) (dictation.Transcriber, error) { |
| 63 | + switch stt.STTStreamProvider() { |
| 64 | + case config.STTProviderDeepgram: |
| 65 | + key := resolveSTTKey(resolved, userConfigPath, dictation.StreamProviderDeepgram, "DEEPGRAM_API_KEY") |
| 66 | + return dictation.NewDeepgramTranscriber(dictation.DeepgramConfig{ |
| 67 | + APIKey: key, |
| 68 | + Model: stt.StreamModel, |
| 69 | + Language: stt.Language, |
| 70 | + }) |
| 71 | + case config.STTProviderOpenAI: |
| 72 | + key := resolveSTTKey(resolved, userConfigPath, dictation.ProviderOpenAI, "OPENAI_API_KEY") |
| 73 | + return dictation.NewOpenAIRealtimeTranscriber(dictation.OpenAIRealtimeConfig{ |
| 74 | + APIKey: key, |
| 75 | + Model: stt.StreamModel, |
| 76 | + }) |
| 77 | + default: // local streaming via the warm sherpa-onnx server |
| 78 | + // STTStreamProvider() defaults to "local" whenever streamProvider is unset — |
| 79 | + // including when the user picked a CLOUD batch provider (Groq/OpenAI) and |
| 80 | + // never touched streaming. Don't silently stream through the local sherpa |
| 81 | + // server in that case: local streaming is only appropriate when it was |
| 82 | + // explicitly requested (streamProvider: "local") OR the batch provider is |
| 83 | + // itself local. Otherwise report "not applicable" so the factory falls back |
| 84 | + // to the cloud batch pipeline the user actually chose. |
| 85 | + localWanted := stt.StreamProvider == config.STTProviderLocal || stt.STTProvider() == config.STTProviderLocal |
| 86 | + if !localWanted { |
| 87 | + return nil, nil |
| 88 | + } |
| 89 | + // The local streaming transcriber validates the model only lazily (at |
| 90 | + // connect time), so gate on the model path here — with none set, report |
| 91 | + // "not applicable" (nil, nil) so the factory falls back to the batch |
| 92 | + // pipeline instead of committing to a server that can't start. |
| 93 | + if serverMgr == nil || strings.TrimSpace(stt.LocalModelPath) == "" { |
| 94 | + return nil, nil |
| 95 | + } |
| 96 | + return dictation.NewLocalStreamingTranscriber(serverMgr), nil |
| 97 | + } |
| 98 | +} |
| 99 | + |
| 100 | +// newDictationServerManager builds the warm sherpa-onnx streaming server manager |
| 101 | +// from config. Construction is side-effect free — the server spawns lazily on |
| 102 | +// the first local-streaming recording — so it is always safe to create. |
| 103 | +func newDictationServerManager(stt config.STTConfig) *dictation.ServerManager { |
| 104 | + return dictation.NewServerManager(dictation.ServerConfig{ |
| 105 | + Binary: stt.LocalServerBinary, |
| 106 | + ModelPath: stt.LocalModelPath, |
| 107 | + Port: stt.LocalServerPort, |
| 108 | + NumThreads: stt.NumThreads, |
| 109 | + }) |
| 110 | +} |
| 111 | + |
| 112 | +// buildBatchTranscriber constructs the configured batch transcriber, resolving |
| 113 | +// credentials for the cloud providers. |
| 114 | +func buildBatchTranscriber(stt config.STTConfig, resolved config.ResolvedConfig, userConfigPath string) (dictation.Transcriber, error) { |
| 115 | + switch stt.STTProvider() { |
| 116 | + case config.STTProviderGroq: |
| 117 | + key := resolveSTTKey(resolved, userConfigPath, dictation.ProviderGroq, "GROQ_API_KEY") |
| 118 | + model := stt.Model |
| 119 | + if model == "" { |
| 120 | + model = defaultGroqSTTModel |
| 121 | + } |
| 122 | + return dictation.NewCloudTranscriber(dictation.CloudConfig{ |
| 123 | + Provider: dictation.ProviderGroq, |
| 124 | + BaseURL: groqSTTBaseURL, |
| 125 | + APIKey: key, |
| 126 | + Model: model, |
| 127 | + Language: stt.Language, |
| 128 | + }) |
| 129 | + case config.STTProviderOpenAI: |
| 130 | + key := resolveSTTKey(resolved, userConfigPath, dictation.ProviderOpenAI, "OPENAI_API_KEY") |
| 131 | + model := stt.Model |
| 132 | + if model == "" { |
| 133 | + model = defaultOpenAISTTModel |
| 134 | + } |
| 135 | + return dictation.NewCloudTranscriber(dictation.CloudConfig{ |
| 136 | + Provider: dictation.ProviderOpenAI, |
| 137 | + BaseURL: config.OpenAIBaseURL, |
| 138 | + APIKey: key, |
| 139 | + Model: model, |
| 140 | + Language: stt.Language, |
| 141 | + }) |
| 142 | + default: // local |
| 143 | + return dictation.NewLocalTranscriber(dictation.LocalConfig{ |
| 144 | + Binary: stt.LocalBinary, |
| 145 | + ModelPath: stt.LocalModelPath, |
| 146 | + NumThreads: stt.NumThreads, |
| 147 | + }) |
| 148 | + } |
| 149 | +} |
| 150 | + |
| 151 | +// sttEnvVar returns the conventional environment variable a cloud STT provider's |
| 152 | +// key is read from. |
| 153 | +func sttEnvVar(provider string) string { |
| 154 | + switch provider { |
| 155 | + case dictation.ProviderGroq: |
| 156 | + return "GROQ_API_KEY" |
| 157 | + case dictation.ProviderOpenAI: // == StreamProviderOpenAI ("openai") |
| 158 | + return "OPENAI_API_KEY" |
| 159 | + case dictation.StreamProviderDeepgram: |
| 160 | + return "DEEPGRAM_API_KEY" |
| 161 | + } |
| 162 | + return "" |
| 163 | +} |
| 164 | + |
| 165 | +// newSTTKeyStatus returns a predicate reporting whether a cloud STT provider |
| 166 | +// already has a resolvable API key (configured provider, credstore, or env). |
| 167 | +func newSTTKeyStatus(resolved config.ResolvedConfig, userConfigPath string) func(string) bool { |
| 168 | + return func(provider string) bool { |
| 169 | + return resolveSTTKey(resolved, userConfigPath, provider, sttEnvVar(provider)) != "" |
| 170 | + } |
| 171 | +} |
| 172 | + |
| 173 | +// newSaveSTTKey returns a function that stores a cloud STT provider's API key in |
| 174 | +// the encrypted credential store, so the inline prompt can persist it. |
| 175 | +func newSaveSTTKey(userConfigPath string) func(string, string) error { |
| 176 | + return func(provider, key string) error { |
| 177 | + if strings.TrimSpace(userConfigPath) == "" { |
| 178 | + return fmt.Errorf("no config path to store the key") |
| 179 | + } |
| 180 | + store, err := config.ProviderKeyStoreAt(filepath.Dir(userConfigPath)) |
| 181 | + if err != nil { |
| 182 | + return err |
| 183 | + } |
| 184 | + return store.Set(provider, strings.TrimSpace(key)) |
| 185 | + } |
| 186 | +} |
| 187 | + |
| 188 | +// resolveSTTKey finds an API key for an STT cloud provider, reusing Zero's |
| 189 | +// existing credentials (§2 — "zero new credential UI"). Precedence: a matching |
| 190 | +// configured provider's resolved key, then the encrypted credential store, then |
| 191 | +// the provider's conventional environment variable. |
| 192 | +func resolveSTTKey(resolved config.ResolvedConfig, userConfigPath, provider, envVar string) string { |
| 193 | + if key := keyFromConfiguredProviders(resolved, provider); key != "" { |
| 194 | + return key |
| 195 | + } |
| 196 | + if userConfigPath != "" { |
| 197 | + if store, err := config.ProviderKeyStoreAt(filepath.Dir(userConfigPath)); err == nil { |
| 198 | + if key, ok, _ := store.Get(provider); ok && strings.TrimSpace(key) != "" { |
| 199 | + return strings.TrimSpace(key) |
| 200 | + } |
| 201 | + } |
| 202 | + } |
| 203 | + return strings.TrimSpace(os.Getenv(envVar)) |
| 204 | +} |
| 205 | + |
| 206 | +// keyFromConfiguredProviders returns the resolved API key of a configured |
| 207 | +// provider that belongs to the given cloud provider (matched by catalog id, |
| 208 | +// provider name/kind, or base-URL host), or "" when none is configured. |
| 209 | +func keyFromConfiguredProviders(resolved config.ResolvedConfig, provider string) string { |
| 210 | + provider = strings.ToLower(provider) |
| 211 | + profiles := append([]config.ProviderProfile{resolved.Provider}, resolved.Providers...) |
| 212 | + for _, p := range profiles { |
| 213 | + if strings.TrimSpace(p.APIKey) == "" { |
| 214 | + continue |
| 215 | + } |
| 216 | + if providerMatches(p, provider) { |
| 217 | + return strings.TrimSpace(p.APIKey) |
| 218 | + } |
| 219 | + } |
| 220 | + return "" |
| 221 | +} |
| 222 | + |
| 223 | +func providerMatches(p config.ProviderProfile, provider string) bool { |
| 224 | + fields := []string{ |
| 225 | + strings.ToLower(p.CatalogID), |
| 226 | + strings.ToLower(p.Provider), |
| 227 | + strings.ToLower(string(p.ProviderKind)), |
| 228 | + strings.ToLower(p.Name), |
| 229 | + strings.ToLower(p.BaseURL), |
| 230 | + } |
| 231 | + for _, f := range fields { |
| 232 | + if f != "" && strings.Contains(f, provider) { |
| 233 | + return true |
| 234 | + } |
| 235 | + } |
| 236 | + return false |
| 237 | +} |
0 commit comments