Skip to content

Commit a4fd576

Browse files
authored
Merge pull request #37 from fcmiranda/feature/opencode-support
feat: integrate opencode CLI provider with automatic model fallback a…
2 parents bcba703 + 8283f64 commit a4fd576

8 files changed

Lines changed: 315 additions & 26 deletions

File tree

README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ AI-powered Git commit message generator that analyzes your staged changes and ou
1111

1212
- Generates configurable number of commit message suggestions from your staged diff
1313
- Generates 10 pull request titles based on the diff between the current branch and a target branch
14-
- Providers: GitHub Copilot (default), OpenAI, Anthropic (Claude Code CLI)
14+
- Providers: opencode (default, free models), GitHub Copilot, OpenAI, Anthropic (Claude Code CLI), Gemini CLI
1515
- Multi-language support: Any language (English, Arabic, Korean, etc.)
1616
- Interactive config to pick provider/model/language and set keys
1717
- Simple output suitable for piping into TUI menus (one message per line)
@@ -22,6 +22,8 @@ AI-powered Git commit message generator that analyzes your staged changes and ou
2222
go install github.com/m7medvision/lazycommit@latest
2323
```
2424

25+
The default provider is `opencode`, so install and authenticate the `opencode` CLI before running `lazycommit commit`.
26+
2527
Or build from source:
2628

2729
```bash
@@ -80,8 +82,16 @@ lazycommit uses a two-file configuration system to separate sensitive provider s
8082
Contains API keys, tokens, and provider-specific settings. **Do not share this file.**
8183

8284
```yaml
83-
active_provider: copilot # default if a GitHub token is found
85+
active_provider: opencode # default; uses opencode CLI free models
8486
providers:
87+
opencode:
88+
model: "opencode/minimax-m2.5-free" # Uses opencode CLI - no API key needed
89+
fallback_models:
90+
- "opencode/minimax-m2.5-free"
91+
- "opencode/ling-2.6-flash-free"
92+
- "opencode/hy3-preview-free"
93+
- "opencode/nemotron-3-super-free"
94+
num_suggestions: 10
8595
copilot:
8696
api_key: "$GITHUB_TOKEN" # Uses GitHub token; token is exchanged internally
8797
model: "gpt-4o" # or "openai/gpt-4o"; both accepted
@@ -264,6 +274,7 @@ If you are using Commitizen with Lazygit, you can add this custom command:
264274
## Troubleshooting
265275

266276
- "No staged changes to commit." — run `git add` first.
277+
- "opencode CLI not found" — install `opencode` or switch providers with `lazycommit config set`.
267278
- "API key not set" — set the appropriate key in `.lazycommit.yaml` or env var and rerun.
268279
- Copilot errors about token exchange — ensure your GitHub token has models scope or is valid; try setting `GITHUB_TOKEN`.
269280

cmd/commit.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@ var commitCmd = &cobra.Command{
4141

4242
providerName := config.GetProvider()
4343

44-
// API key is not needed for anthropic provider (uses CLI)
44+
// API keys are not needed for CLI-backed providers.
4545
var apiKey string
46-
if providerName != "anthropic" {
46+
if providerName != "anthropic" && providerName != "gemini" && providerName != "opencode" {
4747
var err error
4848
apiKey, err = config.GetAPIKey()
4949
if err != nil {
@@ -53,7 +53,7 @@ var commitCmd = &cobra.Command{
5353
}
5454

5555
var model string
56-
if providerName == "copilot" || providerName == "openai" || providerName == "anthropic" {
56+
if providerName == "copilot" || providerName == "openai" || providerName == "anthropic" || providerName == "gemini" || providerName == "opencode" {
5757
var err error
5858
model, err = config.GetModel()
5959
if err != nil {
@@ -86,6 +86,12 @@ var commitCmd = &cobra.Command{
8686
numSuggestions = 10
8787
}
8888
aiProvider = provider.NewGeminiProvider(model, numSuggestions)
89+
case "opencode":
90+
numSuggestions := config.GetNumSuggestions()
91+
if numSuggestions <= 0 {
92+
numSuggestions = 10
93+
}
94+
aiProvider = provider.NewOpencodeProvider(model, config.GetFallbackModels(), numSuggestions)
8995
default:
9096
// Default to copilot if provider is not set or unknown
9197
aiProvider = provider.NewCopilotProvider(apiKey, endpoint)

cmd/config.go

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ func runInteractiveConfig() {
8181

8282
providerPrompt := &survey.Select{
8383
Message: "Choose a provider:",
84-
Options: []string{"openai", "copilot", "anthropic", "gemini"},
84+
Options: []string{"opencode", "openai", "copilot", "anthropic", "gemini"},
8585
Default: currentProvider,
8686
}
8787
var selectedProvider string
@@ -152,8 +152,8 @@ func runInteractiveConfig() {
152152
fmt.Printf("Language set to: %s\n", langValue)
153153
}
154154

155-
// API key configuration - skip for copilot, anthropic and gemini
156-
if selectedProvider != "copilot" && selectedProvider != "anthropic" && selectedProvider != "gemini" {
155+
// API key configuration - skip for CLI-backed providers and copilot token exchange.
156+
if selectedProvider != "copilot" && selectedProvider != "anthropic" && selectedProvider != "gemini" && selectedProvider != "opencode" {
157157
apiKeyPrompt := &survey.Input{
158158
Message: fmt.Sprintf("Enter API Key for %s:", selectedProvider),
159159
}
@@ -175,13 +175,16 @@ func runInteractiveConfig() {
175175
fmt.Println("Anthropic provider uses Claude Code CLI - no API key needed.")
176176
} else if selectedProvider == "gemini" {
177177
fmt.Println("Gemini provider uses Gemini CLI - no API key needed.")
178+
} else if selectedProvider == "opencode" {
179+
fmt.Println("opencode provider uses opencode CLI - no API key needed.")
178180
}
179181

180182
availableModels := map[string][]string{
181183
"openai": {},
182184
"copilot": {},
183185
"anthropic": {},
184186
"gemini": {},
187+
"opencode": {},
185188
}
186189

187190
modelDisplayToID := map[string]string{}
@@ -222,6 +225,12 @@ func runInteractiveConfig() {
222225
availableModels["gemini"] = append(availableModels["gemini"], display)
223226
modelDisplayToID[display] = m.APIModel
224227
}
228+
case "opencode":
229+
for _, m := range models.OpencodeModels {
230+
display := fmt.Sprintf("%s (%s)", m.Name, m.APIModel)
231+
availableModels["opencode"] = append(availableModels["opencode"], display)
232+
modelDisplayToID[display] = m.APIModel
233+
}
225234
}
226235

227236
modelPrompt := &survey.Select{
@@ -232,7 +241,7 @@ func runInteractiveConfig() {
232241
// Try to set the default to the current model if possible
233242
isValidDefault := false
234243
currentDisplay := ""
235-
if selectedProvider == "openai" || selectedProvider == "anthropic" || selectedProvider == "copilot" || selectedProvider == "gemini" {
244+
if selectedProvider == "openai" || selectedProvider == "anthropic" || selectedProvider == "copilot" || selectedProvider == "gemini" || selectedProvider == "opencode" {
236245
for display, id := range modelDisplayToID {
237246
if id == currentModel || display == currentModel {
238247
isValidDefault = true
@@ -261,7 +270,7 @@ func runInteractiveConfig() {
261270
}
262271

263272
selectedModel := selectedDisplay
264-
if selectedProvider == "openai" || selectedProvider == "anthropic" || selectedProvider == "copilot" || selectedProvider == "gemini" {
273+
if selectedProvider == "openai" || selectedProvider == "anthropic" || selectedProvider == "copilot" || selectedProvider == "gemini" || selectedProvider == "opencode" {
265274
selectedModel = modelDisplayToID[selectedDisplay]
266275
}
267276

@@ -274,8 +283,8 @@ func runInteractiveConfig() {
274283
fmt.Printf("Model set to: %s\n", selectedModel)
275284
}
276285

277-
// Number of suggestions configuration for anthropic and gemini
278-
if selectedProvider == "anthropic" || selectedProvider == "gemini" {
286+
// Number of suggestions configuration for CLI-backed providers.
287+
if selectedProvider == "anthropic" || selectedProvider == "gemini" || selectedProvider == "opencode" {
279288
numSuggestionsPrompt := &survey.Input{
280289
Message: "Number of commit message suggestions (default: 10):",
281290
Default: "10",
@@ -299,8 +308,8 @@ func runInteractiveConfig() {
299308
// Get current endpoint
300309
currentEndpoint, _ := config.GetEndpoint()
301310

302-
// Endpoint configuration prompt - skip for anthropic and gemini since they use CLI
303-
if selectedProvider != "anthropic" && selectedProvider != "gemini" {
311+
// Endpoint configuration prompt - skip CLI-backed providers.
312+
if selectedProvider != "anthropic" && selectedProvider != "gemini" && selectedProvider != "opencode" {
304313
endpointPrompt := &survey.Input{
305314
Message: "Enter custom endpoint URL (leave empty for default):",
306315
Default: currentEndpoint,

cmd/pr.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,9 @@ var prCmd = &cobra.Command{
5151

5252
providerName := config.GetProvider()
5353

54-
// API key is not needed for anthropic provider (uses CLI)
54+
// API keys are not needed for CLI-backed providers.
5555
var apiKey string
56-
if providerName != "anthropic" {
56+
if providerName != "anthropic" && providerName != "gemini" && providerName != "opencode" {
5757
var err error
5858
apiKey, err = config.GetAPIKey()
5959
if err != nil {
@@ -63,7 +63,7 @@ var prCmd = &cobra.Command{
6363
}
6464

6565
var model string
66-
if providerName == "copilot" || providerName == "openai" || providerName == "anthropic" {
66+
if providerName == "copilot" || providerName == "openai" || providerName == "anthropic" || providerName == "gemini" || providerName == "opencode" {
6767
var err error
6868
model, err = config.GetModel()
6969
if err != nil {
@@ -90,6 +90,9 @@ var prCmd = &cobra.Command{
9090
case "gemini":
9191
numSuggestions := config.GetNumSuggestions()
9292
aiProvider = provider.NewGeminiProvider(model, numSuggestions)
93+
case "opencode":
94+
numSuggestions := config.GetNumSuggestions()
95+
aiProvider = provider.NewOpencodeProvider(model, config.GetFallbackModels(), numSuggestions)
9396
default:
9497
// Default to copilot if provider is not set or unknown
9598
aiProvider = provider.NewCopilotProvider(apiKey, endpoint)

internal/config/config.go

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ import (
1414
)
1515

1616
type ProviderConfig struct {
17-
APIKey string `mapstructure:"api_key"`
18-
Model string `mapstructure:"model"`
19-
EndpointURL string `mapstructure:"endpoint_url"`
20-
NumSuggestions int `mapstructure:"num_suggestions"`
17+
APIKey string `mapstructure:"api_key"`
18+
Model string `mapstructure:"model"`
19+
EndpointURL string `mapstructure:"endpoint_url"`
20+
NumSuggestions int `mapstructure:"num_suggestions"`
21+
FallbackModels []string `mapstructure:"fallback_models"`
2122
}
2223

2324
type Config struct {
@@ -34,15 +35,22 @@ func InitConfig() {
3435

3536
viper.SetConfigFile(filepath.Join(getConfigDir(), ".lazycommit.yaml"))
3637

38+
viper.SetDefault("active_provider", "opencode")
39+
viper.SetDefault("providers.opencode.model", "opencode/minimax-m2.5-free")
40+
viper.SetDefault("providers.opencode.fallback_models", []string{
41+
"opencode/minimax-m2.5-free",
42+
"opencode/ling-2.6-flash-free",
43+
"opencode/hy3-preview-free",
44+
"opencode/nemotron-3-super-free",
45+
})
46+
viper.SetDefault("providers.opencode.num_suggestions", 10)
47+
3748
if token, err := LoadGitHubToken(); err == nil && token != "" {
38-
viper.SetDefault("active_provider", "copilot")
3949
viper.SetDefault("providers.copilot.api_key", token)
40-
viper.SetDefault("providers.copilot.model", "openai/gpt-5-mini")
41-
} else {
42-
viper.SetDefault("active_provider", "openai")
43-
viper.SetDefault("providers.openai.model", "openai/gpt-5-mini")
4450
}
4551

52+
viper.SetDefault("providers.copilot.model", "openai/gpt-5-mini")
53+
viper.SetDefault("providers.openai.model", "openai/gpt-5-mini")
4654
viper.SetDefault("providers.anthropic.model", "claude-haiku-4-5")
4755
viper.SetDefault("providers.anthropic.num_suggestions", 10)
4856
viper.SetDefault("providers.gemini.model", "flash")
@@ -153,6 +161,8 @@ func GetEndpoint() (string, error) {
153161
return "", nil // Anthropic uses CLI, no endpoint needed
154162
case "gemini":
155163
return "", nil // Gemini uses CLI, no endpoint needed
164+
case "opencode":
165+
return "", nil // opencode uses CLI, no endpoint needed
156166
default:
157167
return "", fmt.Errorf("no default endpoint available for provider '%s'", cfg.ActiveProvider)
158168
}
@@ -280,6 +290,17 @@ func GetNumSuggestions() int {
280290
return providerConfig.NumSuggestions
281291
}
282292

293+
func GetFallbackModels() []string {
294+
if cfg == nil {
295+
InitConfig()
296+
}
297+
providerConfig, err := GetActiveProviderConfig()
298+
if err != nil {
299+
return nil
300+
}
301+
return providerConfig.FallbackModels
302+
}
303+
283304
func SetNumSuggestions(provider, numSuggestions string) error {
284305
if cfg == nil {
285306
InitConfig()

internal/provider/models/models.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const (
2626
ProviderOpenAI ModelProvider = "openai"
2727
ProviderAnthropic ModelProvider = "anthropic"
2828
ProviderGemini ModelProvider = "gemini"
29+
ProviderOpencode ModelProvider = "opencode"
2930

3031
GPT41 ModelID = "gpt-4.1"
3132
GPT41Mini ModelID = "gpt-4.1-mini"
@@ -48,6 +49,11 @@ const (
4849
GeminiFlashLite ModelID = "flash-lite"
4950
Gemini25Pro ModelID = "gemini-2.5-pro"
5051
Gemini25Flash ModelID = "gemini-2.5-flash"
52+
53+
OpencodeMinimaxM25Free ModelID = "opencode/minimax-m2.5-free"
54+
OpencodeLing26FlashFree ModelID = "opencode/ling-2.6-flash-free"
55+
OpencodeHy3PreviewFree ModelID = "opencode/hy3-preview-free"
56+
OpencodeNemotron3SuperFree ModelID = "opencode/nemotron-3-super-free"
5157
)
5258

5359
var OpenAIModels = map[ModelID]Model{
@@ -281,3 +287,30 @@ var GeminiModels = map[ModelID]Model{
281287
SupportsAttachments: true,
282288
},
283289
}
290+
291+
var OpencodeModels = map[ModelID]Model{
292+
OpencodeMinimaxM25Free: {
293+
ID: OpencodeMinimaxM25Free,
294+
Name: "Minimax M2.5 Free",
295+
Provider: ProviderOpencode,
296+
APIModel: string(OpencodeMinimaxM25Free),
297+
},
298+
OpencodeLing26FlashFree: {
299+
ID: OpencodeLing26FlashFree,
300+
Name: "Ling 2.6 Flash Free",
301+
Provider: ProviderOpencode,
302+
APIModel: string(OpencodeLing26FlashFree),
303+
},
304+
OpencodeHy3PreviewFree: {
305+
ID: OpencodeHy3PreviewFree,
306+
Name: "HY3 Preview Free",
307+
Provider: ProviderOpencode,
308+
APIModel: string(OpencodeHy3PreviewFree),
309+
},
310+
OpencodeNemotron3SuperFree: {
311+
ID: OpencodeNemotron3SuperFree,
312+
Name: "Nemotron 3 Super Free",
313+
Provider: ProviderOpencode,
314+
APIModel: string(OpencodeNemotron3SuperFree),
315+
},
316+
}

0 commit comments

Comments
 (0)