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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v
comments is now derived from the PR's `url` field (which always points
at the base repo, including cross-fork PRs).
### Added
- **Three new providers: DeepSeek, Mistral, Cohere.** Each is a standalone
provider package reusing the `openai-go` SDK pointed at the provider's
OpenAI-compatible endpoint — **no new dependency** (DeepSeek
`api.deepseek.com`, Mistral `api.mistral.ai/v1`, Cohere's
`compatibility/v1`). API keys via config or `DEEPSEEK_API_KEY` /
`MISTRAL_API_KEY` / `COHERE_API_KEY`; all three appear in
`commitbrief setup`. Structured output is prompt-driven (no
`response_format`) since these providers' strict-JSON support varies —
the retry-once-then-degrade pipeline (ADR-0014) covers non-conforming
output, same as Ollama. Total live providers: **9** (4 API + these 3 +
2 CLI-backed).
- **`--suggest-commit`.** After the review, makes a second free-form
provider call and prints a single Conventional Commit message for the
staged diff to stdout. Read-only — it suggests, never writes git
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,9 @@ Four API providers + two CLI-tool-backed providers ship in the box:
| **Anthropic** | Claude Opus 4.7, Sonnet 4.6, Haiku 4.5 | Ephemeral prompt caching (5 m TTL) cuts repeated input cost ~10×. |
| **OpenAI** | GPT-4o, GPT-4o-mini | Automatic prompt caching at ≥1024-token prefixes. |
| **Google Gemini** | Gemini 2.5 Pro (2 M context!), 2.5 Flash, 1.5 Flash | Largest free-tier context windows. |
| **DeepSeek** | deepseek-chat, deepseek-reasoner | OpenAI-compatible API (`DEEPSEEK_API_KEY`); JSON is prompt-driven (degrades gracefully). |
| **Mistral** | Mistral Large / Small, Codestral | OpenAI-compatible API (`MISTRAL_API_KEY`). |
| **Cohere** | Command R+ / R, Command A | Cohere's OpenAI-compatibility endpoint (`COHERE_API_KEY`). |
| **Ollama** | Whatever you've `ollama pull`'d | Local-only, no API key, no per-token cost. |
| **`claude-cli`** | Whatever your local Claude Code uses | Subprocess of `claude -p -` — no API key on our side; reuses your Claude Code subscription. `commitbrief --cli claude --staged`. |
| **`gemini-cli`** | Whatever your local Gemini CLI uses | Subprocess of `gemini -p` — no API key on our side; reuses your Gemini CLI auth. `commitbrief --cli gemini --staged`. |
Expand Down
3 changes: 3 additions & 0 deletions cmd/commitbrief/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ import (
// a local subprocess rather than an HTTPS API.
_ "github.com/CommitBrief/commitbrief/internal/provider/anthropic"
_ "github.com/CommitBrief/commitbrief/internal/provider/claude-cli"
_ "github.com/CommitBrief/commitbrief/internal/provider/cohere"
_ "github.com/CommitBrief/commitbrief/internal/provider/deepseek"
_ "github.com/CommitBrief/commitbrief/internal/provider/gemini"
_ "github.com/CommitBrief/commitbrief/internal/provider/gemini-cli"
_ "github.com/CommitBrief/commitbrief/internal/provider/mistral"
_ "github.com/CommitBrief/commitbrief/internal/provider/ollama"
_ "github.com/CommitBrief/commitbrief/internal/provider/openai"
)
Expand Down
9 changes: 9 additions & 0 deletions internal/config/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ func ApplyEnv(c *Config) {
if v := os.Getenv("GEMINI_API_KEY"); v != "" {
setProviderField(c, "gemini", func(p *ProviderConfig) { p.APIKey = v })
}
if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" {
setProviderField(c, "deepseek", func(p *ProviderConfig) { p.APIKey = v })
}
if v := os.Getenv("MISTRAL_API_KEY"); v != "" {
setProviderField(c, "mistral", func(p *ProviderConfig) { p.APIKey = v })
}
if v := os.Getenv("COHERE_API_KEY"); v != "" {
setProviderField(c, "cohere", func(p *ProviderConfig) { p.APIKey = v })
}
if v := os.Getenv("OLLAMA_HOST"); v != "" {
setProviderField(c, "ollama", func(p *ProviderConfig) { p.BaseURL = v })
}
Expand Down
157 changes: 157 additions & 0 deletions internal/provider/cohere/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: GPL-3.0-or-later

// Package cohere implements the Provider interface against Cohere's
// OpenAI-compatibility endpoint (https://api.cohere.ai/compatibility/v1),
// reusing the openai-go SDK — no new dependency. Structured output is
// prompt-driven (no response_format); JSON shape comes from the system
// prompt's contract plus the retry-once-then-degrade pipeline (ADR-0014
// §4), the same way Ollama works.
package cohere

import (
"context"
"errors"
"fmt"

sdk "github.com/openai/openai-go"
"github.com/openai/openai-go/option"
"github.com/openai/openai-go/shared"

"github.com/CommitBrief/commitbrief/internal/config"
"github.com/CommitBrief/commitbrief/internal/provider"
"github.com/CommitBrief/commitbrief/internal/tokens"
)

const (
defaultBaseURL = "https://api.cohere.ai/compatibility/v1"
defaultMaxTokens = 4096
testPingPrompt = "ping"
testPingMaxTok = 8
)

type Client struct {
sdk sdk.Client
model string
}

func New(cfg config.ProviderConfig) (provider.Provider, error) {
if cfg.APIKey == "" {
return nil, fmt.Errorf("cohere: %w", provider.ErrUnauthorized)
}
baseURL := cfg.BaseURL
if baseURL == "" {
baseURL = defaultBaseURL
}
return &Client{
sdk: sdk.NewClient(option.WithAPIKey(cfg.APIKey), option.WithBaseURL(baseURL)),
model: cfg.Model,
}, nil
}

func (c *Client) Name() string { return Name }

func (c *Client) DefaultModel() string {
if c.model != "" {
return c.model
}
return DefaultModel
}

func (c *Client) ContextWindow(model string) int {
if model == "" {
model = c.DefaultModel()
}
return contextWindowFor(model)
}

func (c *Client) EstimateTokens(s string) int { return tokens.Estimate(s) }

func (c *Client) Pricing(model string) provider.Pricing {
if model == "" {
model = c.DefaultModel()
}
return pricingFor(model)
}

func (c *Client) Review(ctx context.Context, req provider.Request) (provider.Response, error) {
completion, err := c.sdk.Chat.Completions.New(ctx, c.buildParams(req))
if err != nil {
return provider.Response{}, mapError(err)
}
return provider.Response{
Content: extractText(completion),
Model: completion.Model,
Usage: mapUsage(completion.Usage),
}, nil
}

func (c *Client) TestConnection(ctx context.Context) error {
_, err := c.sdk.Chat.Completions.New(ctx, sdk.ChatCompletionNewParams{
Model: shared.ChatModel(c.DefaultModel()),
MaxCompletionTokens: sdk.Int(testPingMaxTok),
Messages: []sdk.ChatCompletionMessageParamUnion{sdk.UserMessage(testPingPrompt)},
})
return mapError(err)
}

func (c *Client) buildParams(req provider.Request) sdk.ChatCompletionNewParams {
model := req.Model
if model == "" {
model = c.DefaultModel()
}
maxTokens := int64(req.MaxTokens)
if maxTokens <= 0 {
maxTokens = defaultMaxTokens
}
messages := make([]sdk.ChatCompletionMessageParamUnion, 0, 2)
if req.SystemPrompt != "" {
messages = append(messages, sdk.SystemMessage(req.SystemPrompt))
}
messages = append(messages, sdk.UserMessage(req.UserPrompt))
// No response_format — JSON is prompt-driven (retry/degrade covers
// non-conforming output). FreeForm (ADR-0015) is naturally satisfied.
return sdk.ChatCompletionNewParams{
Model: shared.ChatModel(model),
MaxCompletionTokens: sdk.Int(maxTokens),
Messages: messages,
}
}

func extractText(c *sdk.ChatCompletion) string {
if c == nil || len(c.Choices) == 0 {
return ""
}
return c.Choices[0].Message.Content
}

func mapUsage(u sdk.CompletionUsage) provider.Usage {
return provider.Usage{
InputTokens: int(u.PromptTokens),
OutputTokens: int(u.CompletionTokens),
CachedInputTokens: int(u.PromptTokensDetails.CachedTokens),
}
}

func mapError(err error) error {
if err == nil {
return nil
}
var apiErr *sdk.Error
if errors.As(err, &apiErr) {
switch apiErr.StatusCode {
case 401, 403:
return fmt.Errorf("cohere: %w: %s", provider.ErrUnauthorized, apiErr.Error())
case 429:
return fmt.Errorf("cohere: %w: %s", provider.ErrRateLimit, apiErr.Error())
case 404:
return fmt.Errorf("cohere: %w: %s", provider.ErrModelNotSupported, apiErr.Error())
}
}
return fmt.Errorf("cohere: %w", err)
}

func init() {
provider.Register(Name, New)
}

var _ provider.Provider = (*Client)(nil)
95 changes: 95 additions & 0 deletions internal/provider/cohere/cohere_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// SPDX-License-Identifier: GPL-3.0-or-later

package cohere

import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/CommitBrief/commitbrief/internal/config"
"github.com/CommitBrief/commitbrief/internal/provider"
)

func TestModelsAndSupport(t *testing.T) {
if len(Models()) != 3 {
t.Errorf("Models() = %v, want 3", Models())
}
if !IsModelSupported(ModelCommandRPlus) || IsModelSupported("gpt-4o") {
t.Error("model support check wrong")
}
Models()[0] = "tampered"
if Models()[0] == "tampered" {
t.Error("Models() must return a defensive copy")
}
}

func TestPricingAndContextWindow(t *testing.T) {
if p := pricingFor(ModelCommandRPlus); p.InputPer1M == 0 || p.OutputPer1M == 0 {
t.Errorf("command-r-plus pricing missing: %+v", p)
}
if pricingFor("unknown").InputPer1M != 0 {
t.Error("unknown model should yield zero pricing")
}
if contextWindowFor(ModelCommandA) != 256_000 {
t.Errorf("command-a context window wrong: %d", contextWindowFor(ModelCommandA))
}
if contextWindowFor("unknown") != defaultContextWindow {
t.Error("unknown model should fall back to default context window")
}
}

func TestNewMissingAPIKey(t *testing.T) {
if _, err := New(config.ProviderConfig{}); !errors.Is(err, provider.ErrUnauthorized) {
t.Errorf("err = %v, want ErrUnauthorized", err)
}
}

func TestNewDefaults(t *testing.T) {
c, err := New(config.ProviderConfig{APIKey: "k"})
if err != nil {
t.Fatal(err)
}
if c.Name() != Name || c.DefaultModel() != DefaultModel {
t.Errorf("Name/DefaultModel wrong: %q / %q", c.Name(), c.DefaultModel())
}
}

func TestRegisteredViaInit(t *testing.T) {
for _, n := range provider.Names() {
if n == Name {
return
}
}
t.Errorf("cohere not registered; Names() = %v", provider.Names())
}

func TestReviewWithFakeServer(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/chat/completions") {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "x", "object": "chat.completion", "created": 1, "model": ModelCommandRPlus,
"choices": []map[string]any{{"index": 0, "finish_reason": "stop",
"message": map[string]any{"role": "assistant", "content": "cohere review"}}},
"usage": map[string]any{"prompt_tokens": 25, "completion_tokens": 9, "total_tokens": 34},
})
}))
defer srv.Close()

c, _ := New(config.ProviderConfig{APIKey: "k", BaseURL: srv.URL})
resp, err := c.Review(context.Background(), provider.Request{UserPrompt: "diff", MaxTokens: 64})
if err != nil {
t.Fatalf("Review: %v", err)
}
if resp.Content != "cohere review" || resp.Usage.InputTokens != 25 {
t.Errorf("resp = %q / %+v", resp.Content, resp.Usage)
}
}
18 changes: 18 additions & 0 deletions internal/provider/cohere/context_window.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: GPL-3.0-or-later

package cohere

const defaultContextWindow = 128_000

var contextWindows = map[string]int{
ModelCommandRPlus: 128_000,
ModelCommandR: 128_000,
ModelCommandA: 256_000,
}

func contextWindowFor(model string) int {
if w, ok := contextWindows[model]; ok {
return w
}
return defaultContextWindow
}
30 changes: 30 additions & 0 deletions internal/provider/cohere/models.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: GPL-3.0-or-later

package cohere

const (
Name = "cohere"

ModelCommandRPlus = "command-r-plus"
ModelCommandR = "command-r"
ModelCommandA = "command-a-03-2025"

DefaultModel = ModelCommandRPlus
)

var supportedModels = []string{ModelCommandRPlus, ModelCommandR, ModelCommandA}

func Models() []string {
out := make([]string, len(supportedModels))
copy(out, supportedModels)
return out
}

func IsModelSupported(model string) bool {
for _, m := range supportedModels {
if m == model {
return true
}
}
return false
}
22 changes: 22 additions & 0 deletions internal/provider/cohere/pricing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: GPL-3.0-or-later

package cohere

import "github.com/CommitBrief/commitbrief/internal/provider"

// Cohere per-1M-token pricing snapshot (USD). Source:
// https://cohere.com/pricing — refresh on price change. No automatic
// prompt-cache discount is surfaced through the compatibility endpoint,
// so CachedInputPer1M is left at 0.
var pricingTable = map[string]provider.Pricing{
ModelCommandRPlus: {InputPer1M: 2.50, OutputPer1M: 10.00},
ModelCommandR: {InputPer1M: 0.15, OutputPer1M: 0.60},
ModelCommandA: {InputPer1M: 2.50, OutputPer1M: 10.00},
}

func pricingFor(model string) provider.Pricing {
if p, ok := pricingTable[model]; ok {
return p
}
return provider.Pricing{}
}
Loading
Loading