Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 35 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ AI-powered Git commit message generator that analyzes your staged changes and ou
## Features

- Generates 10 commit message suggestions from your staged diff
- Providers: GitHub Copilot (default), OpenAI, OpenRouter
- Providers: GitHub Copilot (default), OpenAI
- Interactive config to pick provider/model and set keys
- Simple output suitable for piping into TUI menus (one message per line)

Expand Down Expand Up @@ -68,25 +68,49 @@ providers:
copilot:
api_key: "$GITHUB_TOKEN" # Uses GitHub token; token is exchanged internally
model: "gpt-4o" # or "openai/gpt-4o"; both accepted
# endpoint_url: "https://api.githubcopilot.com" # Optional - uses default if not specified
openai:
api_key: "$OPENAI_API_KEY"
model: "gpt-4o"
openrouter:
api_key: "$OPENROUTER_API_KEY" # or a literal key
model: "openai/gpt-4o" # OpenRouter model IDs, e.g. anthropic/claude-3.5-sonnet
# endpoint_url: "https://api.openai.com/v1" # Optional - uses default if not specified
# Custom provider example (e.g., local Ollama):
# local:
# api_key: "not-needed"
# model: "llama3.1:8b"
# endpoint_url: "http://localhost:11434/v1"
```

Notes:
- Copilot: requires a GitHub token with models scope. The tool can also discover IDE Copilot tokens, but models scope is recommended.
- Environment variable references are supported by prefixing with `$` (e.g., `$OPENAI_API_KEY`).
### Custom Endpoints

### Configure via CLI
You can configure custom API endpoints for any provider, which is useful for:
- **Local AI models**: Ollama, LM Studio, or other local inference servers
- **Enterprise proxies**: Internal API gateways or proxy servers
- **Alternative providers**: Any OpenAI-compatible API endpoint

```bash
lazycommit config set # interactive provider/model/key picker
lazycommit config get # show current provider/model
The `endpoint_url` field is optional. If not specified, the official endpoint for that provider will be used.

#### Examples

**Ollama (local):**
```yaml
active_provider: openai # Use openai provider for Ollama compatibility
providers:
openai:
api_key: "ollama" # Ollama doesn't require real API keys
model: "llama3.1:8b"
endpoint_url: "http://localhost:11434/v1"
```

<!-- **Z.AI (GLM models):** -->
<!-- ```yaml -->
<!-- active_provider: openai -->
<!-- providers: -->
<!-- openai: -->
<!-- api_key: "$ZAI_API_KEY" -->
<!-- model: "glm-4.6" -->
<!-- endpoint_url: "https://api.z.ai/api/paas/v4/" -->
<!-- ``` -->

## Integration with TUI Git clients

Because `lazycommit commit` prints plain lines, it plugs nicely into menu UIs.
Expand All @@ -111,22 +135,6 @@ customCommands:
labelFormat: "{{ .raw | green }}"
```

Tips:
- For `lazycommit commit`, you can omit `filter` and just use `valueFormat: "{{ .raw }}"` and `labelFormat: "{{ .raw | green }}"`.
- If you pipe a numbered list tool (e.g., `bunx bunnai`), keep the regex groups `number` and `message` as shown.

## Providers and models

- Copilot (default when a GitHub token is available): uses `gpt-4o` unless overridden. Accepts `openai/gpt-4o` and normalizes it to `gpt-4o`.
- OpenAI: choose from models defined in the interactive picker (e.g., gpt‑4o, gpt‑4.1, o3, o1, etc.).
- OpenRouter: pick from OpenRouter-prefixed IDs (e.g., `openai/gpt-4o`, `anthropic/claude-3.5-sonnet`). Extra headers are set automatically.

## How it works

- Reads `git diff --cached`.
- Sends a single prompt to the selected provider to generate 10 lines.
- Prints the lines exactly, suitable for piping/selecting.

## Troubleshooting

- "No staged changes to commit." — run `git add` first.
Expand Down
12 changes: 9 additions & 3 deletions cmd/commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,20 @@ var commitCmd = &cobra.Command{
}
}

endpoint, err := config.GetEndpoint()
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting endpoint: %v\n", err)
os.Exit(1)
}
Comment on lines +59 to +63

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This block retrieves the endpoint, but the error is only handled by printing to stderr and exiting. It would be better to propagate this error so that the calling function can handle it gracefully, potentially providing more context to the user or attempting a fallback mechanism.

Consider returning the error to allow the caller to handle it.


switch providerName {
case "copilot":
aiProvider = provider.NewCopilotProviderWithModel(apiKey, model)
aiProvider = provider.NewCopilotProviderWithModel(apiKey, model, endpoint)
case "openai":
aiProvider = provider.NewOpenAIProvider(apiKey, model)
aiProvider = provider.NewOpenAIProvider(apiKey, model, endpoint)
default:
// Default to copilot if provider is not set or unknown
aiProvider = provider.NewCopilotProvider(apiKey)
aiProvider = provider.NewCopilotProvider(apiKey, endpoint)
}

commitMessages, err := aiProvider.GenerateCommitMessages(context.Background(), diff)
Expand Down
78 changes: 66 additions & 12 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"fmt"
"net/url"
"os"

"github.com/AlecAivazis/survey/v2"
Expand All @@ -26,8 +27,14 @@ var getCmd = &cobra.Command{
fmt.Println("Error getting model:", err)
os.Exit(1)
}
endpoint, err := config.GetEndpoint()
if err != nil {
fmt.Println("Error getting endpoint:", err)
os.Exit(1)
}
fmt.Printf("Active Provider: %s\n", provider)
fmt.Printf("Model: %s\n", model)
fmt.Printf("Endpoint: %s\n", endpoint)
},
}

Expand All @@ -39,13 +46,40 @@ var setCmd = &cobra.Command{
},
}

func validateEndpointURL(val interface{}) error {
endpoint, ok := val.(string)
if !ok {
return fmt.Errorf("endpoint must be a string")
}

// Empty string is valid (uses default)
if endpoint == "" {
return nil
}

parsedURL, err := url.Parse(endpoint)
if err != nil {
return fmt.Errorf("invalid URL format: %w", err)
}

if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return fmt.Errorf("endpoint must use http or https protocol")
}

if parsedURL.Host == "" {
return fmt.Errorf("endpoint must have a valid host")
}

return nil
Comment on lines +49 to +73

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The validateEndpointURL function could benefit from more robust validation. Currently, it checks for http or https schemes and a valid host. However, it doesn't check for other potential issues, such as invalid characters, malformed URLs, or excessively long URLs. Consider adding additional checks to ensure that the endpoint URL is well-formed and safe to use.

For example, you could use a regular expression to validate the URL format or check the URL length to prevent buffer overflow issues.

}

func runInteractiveConfig() {
currentProvider := config.GetProvider()
currentModel, _ := config.GetModel()

providerPrompt := &survey.Select{
Message: "Choose a provider:",
Options: []string{"openai", "openrouter", "copilot"},
Options: []string{"openai", "copilot"},
Default: currentProvider,
}
var selectedProvider string
Expand Down Expand Up @@ -87,9 +121,8 @@ func runInteractiveConfig() {

// Dynamically generate available models for OpenAI
availableModels := map[string][]string{
"openai": {},
"openrouter": {},
"copilot": {"gpt-4o"}, // TODO: update if copilot models are dynamic
"openai": {},
"copilot": {"gpt-4o"}, // TODO: update if copilot models are dynamic
}

modelDisplayToID := map[string]string{}
Expand All @@ -99,12 +132,6 @@ func runInteractiveConfig() {
availableModels["openai"] = append(availableModels["openai"], display)
modelDisplayToID[display] = string(id)
}
} else if selectedProvider == "openrouter" {
for id, m := range models.OpenRouterModels {
display := fmt.Sprintf("%s (%s)", m.Name, string(id))
availableModels["openrouter"] = append(availableModels["openrouter"], display)
modelDisplayToID[display] = string(id)
}
}

modelPrompt := &survey.Select{
Expand All @@ -115,7 +142,7 @@ func runInteractiveConfig() {
// Try to set the default to the current model if possible
isValidDefault := false
currentDisplay := ""
if selectedProvider == "openai" || selectedProvider == "openrouter" {
if selectedProvider == "openai" {
for display, id := range modelDisplayToID {
if id == currentModel || display == currentModel {
isValidDefault = true
Expand Down Expand Up @@ -144,7 +171,7 @@ func runInteractiveConfig() {
}

selectedModel := selectedDisplay
if selectedProvider == "openai" || selectedProvider == "openrouter" {
if selectedProvider == "openai" {
selectedModel = modelDisplayToID[selectedDisplay]
}

Expand All @@ -156,6 +183,33 @@ func runInteractiveConfig() {
}
fmt.Printf("Model set to: %s\n", selectedModel)
}

// Get current endpoint
currentEndpoint, _ := config.GetEndpoint()

// Endpoint configuration prompt
endpointPrompt := &survey.Input{
Message: "Enter custom endpoint URL (leave empty for default):",
Default: currentEndpoint,
}
var endpoint string
err = survey.AskOne(endpointPrompt, &endpoint, survey.WithValidator(validateEndpointURL))
if err != nil {
fmt.Println(err.Error())
return
}

// Only set endpoint if it's different from current
if endpoint != currentEndpoint && endpoint != "" {
err := config.SetEndpoint(selectedProvider, endpoint)
if err != nil {
fmt.Printf("Error setting endpoint: %v\n", err)
return
}
fmt.Printf("Endpoint set to: %s\n", endpoint)
} else if endpoint == "" {
fmt.Println("Using default endpoint for provider")
}
}

func init() {
Expand Down
63 changes: 61 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"runtime"
Expand All @@ -12,8 +13,9 @@ import (
)

type ProviderConfig struct {
APIKey string `mapstructure:"api_key"`
Model string `mapstructure:"model"`
APIKey string `mapstructure:"api_key"`
Model string `mapstructure:"model"`
EndpointURL string `mapstructure:"endpoint_url"`
}

type Config struct {
Expand Down Expand Up @@ -124,6 +126,28 @@ func GetModel() (string, error) {
return providerConfig.Model, nil
}

func GetEndpoint() (string, error) {
providerConfig, err := GetActiveProviderConfig()
if err != nil {
return "", err
}

// If custom endpoint is configured, use it
if providerConfig.EndpointURL != "" {
return providerConfig.EndpointURL, nil
}

// Return default endpoints based on provider
switch cfg.ActiveProvider {
case "openai":
return "https://api.openai.com/v1", nil
case "copilot":
return "https://api.githubcopilot.com", nil
default:
return "", fmt.Errorf("no default endpoint available for provider '%s'", cfg.ActiveProvider)
}
}

func SetProvider(provider string) error {
if cfg == nil {
InitConfig()
Expand All @@ -150,6 +174,41 @@ func SetAPIKey(provider, apiKey string) error {
return viper.WriteConfig()
}

func validateEndpointURL(endpoint string) error {
if endpoint == "" {
return nil // Empty endpoint is valid (will use default)
}

parsedURL, err := url.Parse(endpoint)
if err != nil {
return fmt.Errorf("invalid URL format: %w", err)
}

if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return fmt.Errorf("endpoint must use http or https protocol")
}

if parsedURL.Host == "" {
return fmt.Errorf("endpoint must have a valid host")
}

return nil
}
Comment on lines +177 to +196

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The validateEndpointURL function is duplicated in cmd/config.go. This duplication increases the risk of inconsistencies and makes maintenance more difficult. Consider creating a single, reusable validation function in a common utility package and calling it from both places.

Comment on lines +177 to +196

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The validateEndpointURL function could benefit from more robust validation. Currently, it checks for http or https schemes and a valid host. However, it doesn't check for other potential issues, such as invalid characters, malformed URLs, or excessively long URLs. Consider adding additional checks to ensure that the endpoint URL is well-formed and safe to use.

For example, you could use a regular expression to validate the URL format or check the URL length to prevent buffer overflow issues.


func SetEndpoint(provider, endpoint string) error {
if cfg == nil {
InitConfig()
}

// Validate endpoint URL
if err := validateEndpointURL(endpoint); err != nil {
return err
}

viper.Set(fmt.Sprintf("providers.%s.endpoint_url", provider), endpoint)
return viper.WriteConfig()
}

func LoadGitHubToken() (string, error) {
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
return token, nil
Expand Down
Loading