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:
parent
f2addff099
commit
da96f6419d
3 changed files with 463 additions and 0 deletions
|
|
@ -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 == "" {
|
||||
|
|
|
|||
176
pkg/providers/gemini_cli_provider.go
Normal file
176
pkg/providers/gemini_cli_provider.go
Normal 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"`
|
||||
}
|
||||
280
pkg/providers/gemini_cli_provider_test.go
Normal file
280
pkg/providers/gemini_cli_provider_test.go
Normal 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, ".")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue