feat(providers): add gemini-cli provider

Adds GeminiCliProvider that wraps the Gemini CLI as a subprocess,
following the same pattern as the existing claude-cli and codex-cli
providers.

The provider invokes:
  gemini --yolo --output-format json --prompt ""

with the prompt sent via stdin. The --prompt "" flag enables
non-interactive (headless) mode, reading the full prompt from stdin.

Key details:
- Model sentinel: "gemini-cli" skips --model flag (uses CLI default)
- Explicit model: "gemini-cli/gemini-2.5-pro" passes --model gemini-2.5-pro
- System messages prepended to stdin (no --system-prompt flag in gemini)
- Parses JSON response format: {"response": "...", "stats": {"models": {...}}}
- Token usage summed across all models in stats.models (gemini uses
  multiple internal models per request)
- Tool calls extracted from response text using shared extractToolCallsFromText
- New protocol: "gemini-cli" / alias "geminicli"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Eric Jacksch 2026-03-16 01:26:40 -04:00
parent f2addff099
commit da96f6419d
3 changed files with 463 additions and 0 deletions

View file

@ -190,6 +190,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
}
return NewCodexCliProvider(workspace), modelID, nil
case "gemini-cli", "geminicli":
workspace := cfg.Workspace
if workspace == "" {
workspace = "."
}
return NewGeminiCliProvider(workspace), modelID, nil
case "github-copilot", "copilot":
apiBase := cfg.APIBase
if apiBase == "" {

View file

@ -0,0 +1,176 @@
package providers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
)
// GeminiCliProvider implements LLMProvider using the gemini CLI as a subprocess.
type GeminiCliProvider struct {
command string
workspace string
}
// NewGeminiCliProvider creates a new Gemini CLI provider.
func NewGeminiCliProvider(workspace string) *GeminiCliProvider {
return &GeminiCliProvider{
command: "gemini",
workspace: workspace,
}
}
// Chat implements LLMProvider.Chat by executing the gemini CLI.
func (p *GeminiCliProvider) Chat(
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
) (*LLMResponse, error) {
prompt := p.buildPrompt(messages, tools)
// --prompt "" triggers non-interactive stdin mode; the empty string is appended to stdin input.
args := []string{"--yolo", "--output-format", "json", "--prompt", ""}
if model != "" && model != "gemini-cli" {
args = append(args, "--model", model)
}
cmd := exec.CommandContext(ctx, p.command, args...)
if p.workspace != "" {
cmd.Dir = p.workspace
}
cmd.Stdin = bytes.NewReader([]byte(prompt))
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
stderrStr := strings.TrimSpace(stderr.String())
stdoutStr := strings.TrimSpace(stdout.String())
switch {
case stderrStr != "" && stdoutStr != "":
return nil, fmt.Errorf("gemini cli error: %w\nstderr: %s\nstdout: %s", err, stderrStr, stdoutStr)
case stderrStr != "":
return nil, fmt.Errorf("gemini cli error: %s", stderrStr)
case stdoutStr != "":
return nil, fmt.Errorf("gemini cli error: %w\noutput: %s", err, stdoutStr)
default:
return nil, fmt.Errorf("gemini cli error: %w", err)
}
}
return p.parseGeminiCliResponse(stdout.String())
}
// GetDefaultModel returns the default model identifier.
func (p *GeminiCliProvider) GetDefaultModel() string {
return "gemini-cli"
}
// buildPrompt converts messages to a prompt string for the Gemini CLI.
// System messages are prepended as instructions since Gemini CLI has no --system-prompt flag.
func (p *GeminiCliProvider) buildPrompt(messages []Message, tools []ToolDefinition) string {
var systemParts []string
var conversationParts []string
for _, msg := range messages {
switch msg.Role {
case "system":
systemParts = append(systemParts, msg.Content)
case "user":
conversationParts = append(conversationParts, "User: "+msg.Content)
case "assistant":
conversationParts = append(conversationParts, "Assistant: "+msg.Content)
case "tool":
conversationParts = append(conversationParts,
fmt.Sprintf("[Tool Result for %s]: %s", msg.ToolCallID, msg.Content))
}
}
var sb strings.Builder
if len(systemParts) > 0 {
sb.WriteString("## System Instructions\n\n")
sb.WriteString(strings.Join(systemParts, "\n\n"))
sb.WriteString("\n\n## Task\n\n")
}
if len(tools) > 0 {
sb.WriteString(buildCLIToolsPrompt(tools))
sb.WriteString("\n\n")
}
// Simplify single user message (no prefix) when there is no system or tools context
if len(conversationParts) == 1 && len(systemParts) == 0 && len(tools) == 0 {
return strings.TrimPrefix(conversationParts[0], "User: ")
}
sb.WriteString(strings.Join(conversationParts, "\n"))
return sb.String()
}
// parseGeminiCliResponse parses the JSON output from the gemini CLI.
func (p *GeminiCliProvider) parseGeminiCliResponse(output string) (*LLMResponse, error) {
var resp geminiCliJSONResponse
if err := json.Unmarshal([]byte(output), &resp); err != nil {
return nil, fmt.Errorf("failed to parse gemini cli response: %w", err)
}
toolCalls := extractToolCallsFromText(resp.Response)
finishReason := "stop"
content := resp.Response
if len(toolCalls) > 0 {
finishReason = "tool_calls"
content = stripToolCallsFromText(resp.Response)
}
var usage *UsageInfo
if resp.Stats.Models != nil {
var totalInput, totalCandidates, totalAll int
for _, m := range resp.Stats.Models {
totalInput += m.Tokens.Input
totalCandidates += m.Tokens.Candidates
totalAll += m.Tokens.Total
}
if totalInput > 0 || totalCandidates > 0 || totalAll > 0 {
usage = &UsageInfo{
PromptTokens: totalInput,
CompletionTokens: totalCandidates,
TotalTokens: totalAll,
}
}
}
return &LLMResponse{
Content: strings.TrimSpace(content),
ToolCalls: toolCalls,
FinishReason: finishReason,
Usage: usage,
}, nil
}
// geminiCliJSONResponse represents the JSON output from the gemini CLI.
type geminiCliJSONResponse struct {
SessionID string `json:"session_id"`
Response string `json:"response"`
Stats geminiCliStatsBlock `json:"stats"`
}
// geminiCliStatsBlock holds the stats section of the gemini CLI response.
type geminiCliStatsBlock struct {
Models map[string]geminiCliModelStats `json:"models"`
}
// geminiCliModelStats holds token usage for a single model in the stats block.
type geminiCliModelStats struct {
Tokens geminiCliTokens `json:"tokens"`
}
// geminiCliTokens holds the token counts for a model.
type geminiCliTokens struct {
Input int `json:"input"`
Candidates int `json:"candidates"`
Total int `json:"total"`
}

View file

@ -0,0 +1,280 @@
package providers
import (
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
// --- Compile-time interface check ---
var _ LLMProvider = (*GeminiCliProvider)(nil)
// --- Constructor tests ---
func TestNewGeminiCliProvider(t *testing.T) {
p := NewGeminiCliProvider("/test/workspace")
if p == nil {
t.Fatal("NewGeminiCliProvider returned nil")
}
if p.workspace != "/test/workspace" {
t.Errorf("workspace = %q, want %q", p.workspace, "/test/workspace")
}
if p.command != "gemini" {
t.Errorf("command = %q, want %q", p.command, "gemini")
}
}
// --- GetDefaultModel tests ---
func TestGeminiCliProvider_GetDefaultModel(t *testing.T) {
p := NewGeminiCliProvider("/workspace")
if got := p.GetDefaultModel(); got != "gemini-cli" {
t.Errorf("GetDefaultModel() = %q, want %q", got, "gemini-cli")
}
}
// --- buildPrompt tests ---
func TestGeminiCliProvider_BuildPrompt_SingleUser(t *testing.T) {
p := NewGeminiCliProvider("/workspace")
messages := []Message{
{Role: "user", Content: "Hello"},
}
got := p.buildPrompt(messages, nil)
// Single user message with no system or tools should be simplified (no prefix)
want := "Hello"
if got != want {
t.Errorf("buildPrompt() = %q, want %q", got, want)
}
}
func TestGeminiCliProvider_BuildPrompt_WithSystem(t *testing.T) {
p := NewGeminiCliProvider("/workspace")
messages := []Message{
{Role: "system", Content: "You are a helpful assistant."},
{Role: "user", Content: "What is Go?"},
}
got := p.buildPrompt(messages, nil)
if !strings.Contains(got, "## System Instructions") {
t.Errorf("buildPrompt() missing ## System Instructions header, got %q", got)
}
if !strings.Contains(got, "You are a helpful assistant.") {
t.Errorf("buildPrompt() missing system message content, got %q", got)
}
if !strings.Contains(got, "## Task") {
t.Errorf("buildPrompt() missing ## Task header, got %q", got)
}
if !strings.Contains(got, "What is Go?") {
t.Errorf("buildPrompt() missing user message content, got %q", got)
}
}
func TestGeminiCliProvider_BuildPrompt_WithTools(t *testing.T) {
p := NewGeminiCliProvider("/workspace")
messages := []Message{
{Role: "user", Content: "What is the weather?"},
}
tools := []ToolDefinition{
{
Type: "function",
Function: ToolFunctionDefinition{
Name: "get_weather",
Description: "Get weather for a location",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"location": map[string]any{"type": "string"},
},
},
},
},
}
got := p.buildPrompt(messages, tools)
if !strings.Contains(got, "get_weather") {
t.Errorf("buildPrompt() missing tool definition, got %q", got)
}
if !strings.Contains(got, "Available Tools") {
t.Errorf("buildPrompt() missing Available Tools header, got %q", got)
}
if !strings.Contains(got, "What is the weather?") {
t.Errorf("buildPrompt() missing user message, got %q", got)
}
}
// --- parseGeminiCliResponse tests ---
func TestGeminiCliProvider_ParseResponse_Basic(t *testing.T) {
p := NewGeminiCliProvider("/workspace")
output := `{
"session_id": "abc123",
"response": "Hello! How can I assist you?",
"stats": {
"models": {
"gemini-2.5-flash-lite": {
"tokens": {
"input": 2634,
"candidates": 29,
"total": 2735
}
},
"gemini-3-flash-preview": {
"tokens": {
"input": 16921,
"candidates": 14,
"total": 16935
}
}
}
}
}`
resp, err := p.parseGeminiCliResponse(output)
if err != nil {
t.Fatalf("parseGeminiCliResponse() error = %v", err)
}
if resp.Content != "Hello! How can I assist you?" {
t.Errorf("Content = %q, want %q", resp.Content, "Hello! How can I assist you?")
}
if resp.FinishReason != "stop" {
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
}
if len(resp.ToolCalls) != 0 {
t.Errorf("ToolCalls len = %d, want 0", len(resp.ToolCalls))
}
if resp.Usage == nil {
t.Fatal("Usage should not be nil")
}
// Summed input: 2634 + 16921 = 19555
if resp.Usage.PromptTokens != 19555 {
t.Errorf("PromptTokens = %d, want 19555", resp.Usage.PromptTokens)
}
// Summed candidates: 29 + 14 = 43
if resp.Usage.CompletionTokens != 43 {
t.Errorf("CompletionTokens = %d, want 43", resp.Usage.CompletionTokens)
}
// Summed total: 2735 + 16935 = 19670
if resp.Usage.TotalTokens != 19670 {
t.Errorf("TotalTokens = %d, want 19670", resp.Usage.TotalTokens)
}
}
func TestGeminiCliProvider_ParseResponse_WithToolCalls(t *testing.T) {
p := NewGeminiCliProvider("/workspace")
output := `{"session_id":"s1","response":"Checking weather.\n{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"location\\\":\\\"NYC\\\"}\"}}]}","stats":{}}`
resp, err := p.parseGeminiCliResponse(output)
if err != nil {
t.Fatalf("parseGeminiCliResponse() error = %v", err)
}
if resp.FinishReason != "tool_calls" {
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
}
if len(resp.ToolCalls) != 1 {
t.Fatalf("ToolCalls len = %d, want 1", len(resp.ToolCalls))
}
if resp.ToolCalls[0].Name != "get_weather" {
t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "get_weather")
}
if resp.ToolCalls[0].Arguments["location"] != "NYC" {
t.Errorf("ToolCalls[0].Arguments[location] = %v, want NYC", resp.ToolCalls[0].Arguments["location"])
}
if strings.Contains(resp.Content, "tool_calls") {
t.Errorf("Content should not contain tool_calls JSON, got %q", resp.Content)
}
}
func TestGeminiCliProvider_ParseResponse_InvalidJSON(t *testing.T) {
p := NewGeminiCliProvider("/workspace")
_, err := p.parseGeminiCliResponse("not valid json")
if err == nil {
t.Fatal("expected error for invalid JSON")
}
if !strings.Contains(err.Error(), "failed to parse gemini cli response") {
t.Errorf("error = %q, want to contain 'failed to parse gemini cli response'", err.Error())
}
}
func TestGeminiCliProvider_ParseResponse_NoStats(t *testing.T) {
p := NewGeminiCliProvider("/workspace")
output := `{"session_id":"s","response":"hello"}`
resp, err := p.parseGeminiCliResponse(output)
if err != nil {
t.Fatalf("error = %v", err)
}
if resp.Content != "hello" {
t.Errorf("Content = %q, want %q", resp.Content, "hello")
}
if resp.Usage != nil {
t.Errorf("Usage should be nil when no stats, got %+v", resp.Usage)
}
}
// --- Factory tests ---
func TestCreateProvider_GeminiCli(t *testing.T) {
cfg := config.DefaultConfig()
cfg.ModelList = []config.ModelConfig{
{ModelName: "gemini-cli", Model: "gemini-cli/gemini-cli", Workspace: "/test/ws"},
}
cfg.Agents.Defaults.Model = "gemini-cli"
provider, modelID, err := CreateProvider(cfg)
if err != nil {
t.Fatalf("CreateProvider(gemini-cli) error = %v", err)
}
geminiProvider, ok := provider.(*GeminiCliProvider)
if !ok {
t.Fatalf("CreateProvider(gemini-cli) returned %T, want *GeminiCliProvider", provider)
}
if geminiProvider.workspace != "/test/ws" {
t.Errorf("workspace = %q, want %q", geminiProvider.workspace, "/test/ws")
}
// modelID should be the part after the slash
if modelID != "gemini-cli" {
t.Errorf("modelID = %q, want %q", modelID, "gemini-cli")
}
}
func TestCreateProvider_GeminiCliWithModel(t *testing.T) {
cfg := config.DefaultConfig()
cfg.ModelList = []config.ModelConfig{
{ModelName: "gemini-flash", Model: "gemini-cli/gemini-2.5-flash", Workspace: "/ws"},
}
cfg.Agents.Defaults.Model = "gemini-flash"
provider, modelID, err := CreateProvider(cfg)
if err != nil {
t.Fatalf("CreateProvider(gemini-cli/gemini-2.5-flash) error = %v", err)
}
if _, ok := provider.(*GeminiCliProvider); !ok {
t.Fatalf("CreateProvider returned %T, want *GeminiCliProvider", provider)
}
// modelID should carry through the actual model name
if modelID != "gemini-2.5-flash" {
t.Errorf("modelID = %q, want %q", modelID, "gemini-2.5-flash")
}
}
func TestCreateProvider_GeminiCliDefaultWorkspace(t *testing.T) {
cfg := config.DefaultConfig()
cfg.ModelList = []config.ModelConfig{
{ModelName: "gemini-cli", Model: "gemini-cli/gemini-cli"},
}
cfg.Agents.Defaults.Model = "gemini-cli"
provider, _, err := CreateProvider(cfg)
if err != nil {
t.Fatalf("CreateProvider error = %v", err)
}
geminiProvider, ok := provider.(*GeminiCliProvider)
if !ok {
t.Fatalf("returned %T, want *GeminiCliProvider", provider)
}
if geminiProvider.workspace != "." {
t.Errorf("workspace = %q, want %q (default)", geminiProvider.workspace, ".")
}
}