Skip to content

Commit 14669ad

Browse files
committed
feat(cli): wire the dictation transcriber factory and streaming server
Build the transcriber factory the TUI calls per recording (resolving cloud provider keys from configured providers, the credential store, then env), the warm sherpa-onnx streaming server manager, and the STT key status/save helpers; pass them into the TUI options and tear the server down on exit.
1 parent 0e3d53d commit 14669ad

3 files changed

Lines changed: 347 additions & 3 deletions

File tree

‎internal/cli/app.go‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,11 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
729729
Scope: scope,
730730
})
731731
lastKnownMCPConfig := mcpConfig
732+
sttServerManager := newDictationServerManager(resolved.STT)
733+
sttDownloadRoot := ""
734+
if dir, err := config.UserConfigDir(); err == nil {
735+
sttDownloadRoot = filepath.Join(dir, "zero", "stt")
736+
}
732737
return deps.runTUI(context.Background(), tui.Options{
733738
Cwd: workspaceRoot,
734739
Version: version,
@@ -792,9 +797,15 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
792797
merged, _ := plugins.MergedSkillsLoaded(deps.skillsDir(), pluginActivation.skillRoots)
793798
return merged
794799
}),
795-
PermissionMode: permissionMode,
796-
Notify: resolved.Notify,
797-
KeyBindings: resolved.KeyBindings,
800+
PermissionMode: permissionMode,
801+
Notify: resolved.Notify,
802+
KeyBindings: resolved.KeyBindings,
803+
STT: resolved.STT,
804+
BuildDictationTranscriber: newDictationTranscriberFactory(resolved, userConfigPath, sttServerManager),
805+
ShutdownDictationServer: sttServerManager.Shutdown,
806+
STTDownloadRoot: sttDownloadRoot,
807+
STTKeyStatus: newSTTKeyStatus(resolved, userConfigPath),
808+
SaveSTTKey: newSaveSTTKey(userConfigPath),
798809
Setup: tui.SetupOptions{
799810
Visible: setupVisible,
800811
Required: needsSetup,

‎internal/cli/dictation.go‎

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
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+
}

‎internal/cli/dictation_test.go‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package cli
2+
3+
import (
4+
"errors"
5+
"testing"
6+
7+
"github.com/Gitlawb/zero/internal/config"
8+
"github.com/Gitlawb/zero/internal/dictation"
9+
)
10+
11+
func TestFactoryFallsBackToBatchWhenLocalStreamingNotConfigured(t *testing.T) {
12+
t.Setenv("GROQ_API_KEY", "gsk-test")
13+
resolved := config.ResolvedConfig{STT: config.STTConfig{
14+
Provider: config.STTProviderGroq, // cloud batch
15+
// StreamProvider unset → defaults to local, which has no model configured.
16+
}}
17+
factory := newDictationTranscriberFactory(resolved, "", newDictationServerManager(resolved.STT))
18+
19+
tr, streaming, err := factory(resolved.STT, true) // prefer streaming
20+
if err != nil {
21+
t.Fatalf("expected batch fallback, got error: %v", err)
22+
}
23+
if streaming {
24+
t.Error("should have fallen back to batch (streaming=false)")
25+
}
26+
if tr == nil {
27+
t.Error("expected a Groq batch transcriber")
28+
}
29+
}
30+
31+
func TestFactoryCloudProviderIgnoresLeftoverLocalModel(t *testing.T) {
32+
// Regression: pick local once (LocalModelPath gets set), then switch to a
33+
// cloud batch provider. streamProvider stays unset (defaults to "local"), so
34+
// the streaming factory must NOT fire up the sherpa server on the leftover
35+
// path — it should fall back to the cloud batch pipeline the user chose.
36+
t.Setenv("GROQ_API_KEY", "gsk-test")
37+
stt := config.STTConfig{
38+
Provider: config.STTProviderGroq, // cloud batch, chosen now
39+
LocalModelPath: "/leftover/model/dir", // set by an earlier local selection
40+
}
41+
resolved := config.ResolvedConfig{STT: stt}
42+
factory := newDictationTranscriberFactory(resolved, "", newDictationServerManager(stt))
43+
44+
tr, streaming, err := factory(stt, true) // prefer streaming
45+
if err != nil {
46+
t.Fatalf("expected cloud batch fallback, got error: %v", err)
47+
}
48+
if streaming {
49+
t.Error("a cloud provider must not stream through the local sherpa server")
50+
}
51+
if tr == nil {
52+
t.Error("expected a Groq batch transcriber")
53+
}
54+
}
55+
56+
func TestFactorySurfacesExplicitCloudStreamingError(t *testing.T) {
57+
// Deepgram chosen explicitly but no key resolvable → surface the setup error.
58+
resolved := config.ResolvedConfig{STT: config.STTConfig{
59+
StreamProvider: config.STTProviderDeepgram,
60+
}}
61+
factory := newDictationTranscriberFactory(resolved, "", nil)
62+
63+
_, streaming, err := factory(resolved.STT, true)
64+
if err == nil {
65+
t.Fatal("expected a setup error for missing Deepgram key")
66+
}
67+
if !streaming {
68+
t.Error("an explicit cloud streaming choice should report streaming=true even on error")
69+
}
70+
var setupErr *dictation.SetupError
71+
if !errors.As(err, &setupErr) {
72+
t.Errorf("want *SetupError, got %v", err)
73+
}
74+
}
75+
76+
func TestFactoryLocalBatchSetupErrorWhenNoModel(t *testing.T) {
77+
resolved := config.ResolvedConfig{STT: config.STTConfig{Provider: config.STTProviderLocal}}
78+
factory := newDictationTranscriberFactory(resolved, "", newDictationServerManager(resolved.STT))
79+
80+
_, _, err := factory(resolved.STT, true)
81+
var setupErr *dictation.SetupError
82+
if !errors.As(err, &setupErr) {
83+
t.Fatalf("want *SetupError guiding local model setup, got %v", err)
84+
}
85+
}
86+
87+
func TestResolveSTTKeyPrefersConfiguredProvider(t *testing.T) {
88+
resolved := config.ResolvedConfig{
89+
Providers: []config.ProviderProfile{
90+
{Name: "groq", CatalogID: "groq", BaseURL: "https://api.groq.com/openai/v1", APIKey: "gsk-configured"},
91+
},
92+
}
93+
if got := resolveSTTKey(resolved, "", dictation.ProviderGroq, "GROQ_API_KEY"); got != "gsk-configured" {
94+
t.Errorf("key = %q, want the configured provider's key", got)
95+
}
96+
}

0 commit comments

Comments
 (0)