feat: Add Qwen CLI provider support
Add support for Qwen Code CLI (qwen) as a new LLM provider. This enables users to use the qwen CLI tool as a provider, similar to the existing claude-cli and codex-cli providers. Changes: - Add QwenCliProvider implementation with JSON event parsing - Add comprehensive unit tests (32 test cases) - Register qwen-cli protocol in factory_provider.go - Add providerTypeQwenCLI in factory.go - Add qwen-code to default model list in defaults.go
This commit is contained in:
parent
a1e8ee56f0
commit
0b2f6bc467
5 changed files with 963 additions and 1 deletions
|
|
@ -323,6 +323,12 @@ func DefaultConfig() *Config {
|
|||
AuthMethod: "oauth",
|
||||
},
|
||||
|
||||
// Qwen CLI - Local CLI tool (requires `qwen` installed)
|
||||
{
|
||||
ModelName: "qwen-code",
|
||||
Model: "qwen-cli/qwen-code",
|
||||
},
|
||||
|
||||
// Ollama (local) - https://ollama.com
|
||||
{
|
||||
ModelName: "llama3",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const (
|
|||
providerTypeCodexCLIToken
|
||||
providerTypeClaudeCLI
|
||||
providerTypeCodexCLI
|
||||
providerTypeQwenCLI
|
||||
providerTypeGitHubCopilot
|
||||
)
|
||||
|
||||
|
|
@ -182,6 +183,14 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
|||
sel.providerType = providerTypeCodexCLI
|
||||
sel.workspace = workspace
|
||||
return sel, nil
|
||||
case "qwen-cli", "qwen-code", "qwencode":
|
||||
workspace := cfg.WorkspacePath()
|
||||
if workspace == "" {
|
||||
workspace = "."
|
||||
}
|
||||
sel.providerType = providerTypeQwenCLI
|
||||
sel.workspace = workspace
|
||||
return sel, nil
|
||||
case "deepseek":
|
||||
if cfg.Providers.DeepSeek.APIKey != "" {
|
||||
sel.apiKey = cfg.Providers.DeepSeek.APIKey
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ func ExtractProtocol(model string) (protocol, modelID string) {
|
|||
// CreateProviderFromConfig creates a provider based on the ModelConfig.
|
||||
// It uses the protocol prefix in the Model field to determine which provider to create.
|
||||
// Supported protocols: openai, litellm, anthropic, anthropic-messages, antigravity,
|
||||
// claude-cli, codex-cli, github-copilot
|
||||
// claude-cli, codex-cli, qwen-cli, github-copilot
|
||||
// Returns the provider, the model ID (without protocol prefix), and any error.
|
||||
func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) {
|
||||
if cfg == nil {
|
||||
|
|
@ -190,6 +190,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
|||
}
|
||||
return NewCodexCliProvider(workspace), modelID, nil
|
||||
|
||||
case "qwen-cli", "qwencli":
|
||||
workspace := cfg.Workspace
|
||||
if workspace == "" {
|
||||
workspace = "."
|
||||
}
|
||||
return NewQwenCliProvider(workspace), modelID, nil
|
||||
|
||||
case "github-copilot", "copilot":
|
||||
apiBase := cfg.APIBase
|
||||
if apiBase == "" {
|
||||
|
|
|
|||
244
pkg/providers/qwen_cli_provider.go
Normal file
244
pkg/providers/qwen_cli_provider.go
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package providers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// QwenCliProvider implements LLMProvider by wrapping the qwen CLI as a subprocess.
|
||||
type QwenCliProvider struct {
|
||||
command string
|
||||
workspace string
|
||||
}
|
||||
|
||||
// NewQwenCliProvider creates a new Qwen CLI provider.
|
||||
func NewQwenCliProvider(workspace string) *QwenCliProvider {
|
||||
return &QwenCliProvider{
|
||||
command: "qwen",
|
||||
workspace: workspace,
|
||||
}
|
||||
}
|
||||
|
||||
// Chat implements LLMProvider.Chat by executing the qwen CLI in non-interactive mode.
|
||||
func (p *QwenCliProvider) Chat(
|
||||
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
|
||||
) (*LLMResponse, error) {
|
||||
if p.command == "" {
|
||||
return nil, fmt.Errorf("qwen command not configured")
|
||||
}
|
||||
|
||||
prompt := p.buildPrompt(messages, tools)
|
||||
|
||||
args := []string{
|
||||
"-p",
|
||||
"--output-format=json",
|
||||
"--yolo",
|
||||
}
|
||||
if model != "" && model != "qwen-cli" {
|
||||
args = append(args, "-m", model)
|
||||
}
|
||||
args = append(args, "-") // read prompt from stdin
|
||||
|
||||
cmd := exec.CommandContext(ctx, p.command, args...)
|
||||
cmd.Stdin = bytes.NewReader([]byte(prompt))
|
||||
if p.workspace != "" {
|
||||
cmd.Dir = p.workspace
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
|
||||
// Parse JSON output even if exit code is non-zero,
|
||||
// because qwen may write diagnostic noise to stderr
|
||||
// but still produce valid JSON output.
|
||||
if stdoutStr := stdout.String(); stdoutStr != "" {
|
||||
resp, parseErr := p.parseJSONEvents(stdoutStr)
|
||||
if parseErr == nil && resp != nil && (resp.Content != "" || len(resp.ToolCalls) > 0) {
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if ctx.Err() == context.Canceled {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if stderrStr := stderr.String(); stderrStr != "" {
|
||||
return nil, fmt.Errorf("qwen cli error: %s", stderrStr)
|
||||
}
|
||||
return nil, fmt.Errorf("qwen cli error: %w", err)
|
||||
}
|
||||
|
||||
return p.parseJSONEvents(stdout.String())
|
||||
}
|
||||
|
||||
// GetDefaultModel returns the default model identifier.
|
||||
func (p *QwenCliProvider) GetDefaultModel() string {
|
||||
return "qwen-cli"
|
||||
}
|
||||
|
||||
// buildPrompt converts messages to a prompt string for the Qwen CLI.
|
||||
// System messages are prepended as instructions since Qwen CLI has no --system-prompt flag.
|
||||
func (p *QwenCliProvider) 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, 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)
|
||||
if len(conversationParts) == 1 && len(systemParts) == 0 && len(tools) == 0 {
|
||||
return conversationParts[0]
|
||||
}
|
||||
|
||||
sb.WriteString(strings.Join(conversationParts, "\n"))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// qwenEvent represents a single event from qwen CLI JSON output.
|
||||
type qwenEvent struct {
|
||||
Type string `json:"type"`
|
||||
Subtype string `json:"subtype,omitempty"`
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Message *qwenMessage `json:"message,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
IsError bool `json:"is_error,omitempty"`
|
||||
Usage *qwenUsage `json:"usage,omitempty"`
|
||||
Error *qwenEventError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type qwenMessage struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Role string `json:"role"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Content []qwenContentBlock `json:"content,omitempty"`
|
||||
Usage *qwenUsage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type qwenContentBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
type qwenUsage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"`
|
||||
TotalTokens int `json:"total_tokens,omitempty"`
|
||||
}
|
||||
|
||||
type qwenEventError struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// parseJSONEvents processes the JSON output from qwen CLI.
|
||||
func (p *QwenCliProvider) parseJSONEvents(output string) (*LLMResponse, error) {
|
||||
output = strings.TrimSpace(output)
|
||||
if output == "" {
|
||||
return nil, fmt.Errorf("qwen cli returned empty output")
|
||||
}
|
||||
|
||||
// Qwen CLI outputs a JSON array of events
|
||||
var events []qwenEvent
|
||||
if err := json.Unmarshal([]byte(output), &events); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse qwen cli response: %w", err)
|
||||
}
|
||||
|
||||
var contentParts []string
|
||||
var usage *UsageInfo
|
||||
var lastError string
|
||||
|
||||
for _, event := range events {
|
||||
switch event.Type {
|
||||
case "assistant":
|
||||
if event.Message != nil && len(event.Message.Content) > 0 {
|
||||
for _, block := range event.Message.Content {
|
||||
if block.Type == "text" && block.Text != "" {
|
||||
contentParts = append(contentParts, block.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
case "result":
|
||||
if event.IsError {
|
||||
lastError = event.Result
|
||||
if event.Error != nil && event.Error.Message != "" {
|
||||
lastError = event.Error.Message
|
||||
}
|
||||
} else if event.Result != "" {
|
||||
// Result may contain the full response text
|
||||
// Only use it if we haven't collected content from assistant events
|
||||
if len(contentParts) == 0 {
|
||||
contentParts = append(contentParts, event.Result)
|
||||
}
|
||||
}
|
||||
if event.Usage != nil {
|
||||
usage = &UsageInfo{
|
||||
PromptTokens: event.Usage.InputTokens,
|
||||
CompletionTokens: event.Usage.OutputTokens,
|
||||
TotalTokens: event.Usage.TotalTokens,
|
||||
}
|
||||
}
|
||||
case "error":
|
||||
lastError = event.Error.Message
|
||||
}
|
||||
}
|
||||
|
||||
if lastError != "" && len(contentParts) == 0 {
|
||||
return nil, fmt.Errorf("qwen cli: %s", lastError)
|
||||
}
|
||||
|
||||
content := strings.Join(contentParts, "\n")
|
||||
|
||||
// Extract tool calls from response text (same pattern as other CLI providers)
|
||||
toolCalls := extractToolCallsFromText(content)
|
||||
|
||||
finishReason := "stop"
|
||||
if len(toolCalls) > 0 {
|
||||
finishReason = "tool_calls"
|
||||
content = stripToolCallsFromText(content)
|
||||
}
|
||||
|
||||
return &LLMResponse{
|
||||
Content: strings.TrimSpace(content),
|
||||
ToolCalls: toolCalls,
|
||||
FinishReason: finishReason,
|
||||
Usage: usage,
|
||||
}, nil
|
||||
}
|
||||
696
pkg/providers/qwen_cli_provider_test.go
Normal file
696
pkg/providers/qwen_cli_provider_test.go
Normal file
|
|
@ -0,0 +1,696 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// --- Compile-time interface check ---
|
||||
|
||||
var _ LLMProvider = (*QwenCliProvider)(nil)
|
||||
|
||||
// --- Helper: create mock CLI scripts ---
|
||||
|
||||
// createMockQwenCLI creates a temporary script that simulates the qwen CLI.
|
||||
func createMockQwenCLI(t *testing.T, stdout, stderr string, exitCode int) string {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("mock CLI scripts not supported on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
if stdout != "" {
|
||||
if err := os.WriteFile(filepath.Join(dir, "stdout.txt"), []byte(stdout), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if stderr != "" {
|
||||
if err := os.WriteFile(filepath.Join(dir, "stderr.txt"), []byte(stderr), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("#!/bin/sh\n")
|
||||
if stderr != "" {
|
||||
sb.WriteString(fmt.Sprintf("cat '%s/stderr.txt' >&2\n", dir))
|
||||
}
|
||||
if stdout != "" {
|
||||
sb.WriteString(fmt.Sprintf("cat '%s/stdout.txt'\n", dir))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("exit %d\n", exitCode))
|
||||
|
||||
script := filepath.Join(dir, "qwen")
|
||||
if err := os.WriteFile(script, []byte(sb.String()), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return script
|
||||
}
|
||||
|
||||
// createSlowMockQwenCLI creates a script that sleeps before responding.
|
||||
func createSlowMockQwenCLI(t *testing.T, sleepSeconds int) string {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("mock CLI scripts not supported on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
script := filepath.Join(dir, "qwen")
|
||||
content := fmt.Sprintf(`#!/bin/sh
|
||||
sleep %d
|
||||
cat <<'EOFMOCK'
|
||||
[{"type":"result","subtype":"success","is_error":false,"result":"late response"}]
|
||||
EOFMOCK
|
||||
`, sleepSeconds)
|
||||
if err := os.WriteFile(script, []byte(content), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return script
|
||||
}
|
||||
|
||||
// createArgCaptureQwenCLI creates a script that captures CLI args to a file.
|
||||
func createArgCaptureQwenCLI(t *testing.T, argsFile string) string {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("mock CLI scripts not supported on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
script := filepath.Join(dir, "qwen")
|
||||
content := fmt.Sprintf(`#!/bin/sh
|
||||
echo "$@" > '%s'
|
||||
cat <<'EOFMOCK'
|
||||
[{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"test"}]
|
||||
EOFMOCK
|
||||
`, argsFile)
|
||||
if err := os.WriteFile(script, []byte(content), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return script
|
||||
}
|
||||
|
||||
// --- Constructor tests ---
|
||||
|
||||
func TestNewQwenCliProvider(t *testing.T) {
|
||||
p := NewQwenCliProvider("/test/workspace")
|
||||
if p == nil {
|
||||
t.Fatal("NewQwenCliProvider returned nil")
|
||||
}
|
||||
if p.workspace != "/test/workspace" {
|
||||
t.Errorf("workspace = %q, want %q", p.workspace, "/test/workspace")
|
||||
}
|
||||
if p.command != "qwen" {
|
||||
t.Errorf("command = %q, want %q", p.command, "qwen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewQwenCliProvider_EmptyWorkspace(t *testing.T) {
|
||||
p := NewQwenCliProvider("")
|
||||
if p.workspace != "" {
|
||||
t.Errorf("workspace = %q, want empty", p.workspace)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetDefaultModel tests ---
|
||||
|
||||
func TestQwenCliProvider_GetDefaultModel(t *testing.T) {
|
||||
p := NewQwenCliProvider("/workspace")
|
||||
if got := p.GetDefaultModel(); got != "qwen-cli" {
|
||||
t.Errorf("GetDefaultModel() = %q, want %q", got, "qwen-cli")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Chat() tests ---
|
||||
|
||||
func TestQwenChat_Success(t *testing.T) {
|
||||
mockJSON := `[{"type":"system","subtype":"init"},{"type":"assistant","message":{"content":[{"type":"text","text":"Hello from mock!"}]}},{"type":"result","subtype":"success","is_error":false,"result":"Hello from mock!","usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}]`
|
||||
script := createMockQwenCLI(t, mockJSON, "", 0)
|
||||
|
||||
p := NewQwenCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
resp, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
if resp.Content != "Hello from mock!" {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "Hello from mock!")
|
||||
}
|
||||
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")
|
||||
}
|
||||
if resp.Usage.PromptTokens != 10 {
|
||||
t.Errorf("PromptTokens = %d, want 10", resp.Usage.PromptTokens)
|
||||
}
|
||||
if resp.Usage.CompletionTokens != 5 {
|
||||
t.Errorf("CompletionTokens = %d, want 5", resp.Usage.CompletionTokens)
|
||||
}
|
||||
if resp.Usage.TotalTokens != 15 {
|
||||
t.Errorf("TotalTokens = %d, want 15", resp.Usage.TotalTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenChat_ResultOnly(t *testing.T) {
|
||||
// Test when only result event is present (no assistant event)
|
||||
mockJSON := `[{"type":"result","subtype":"success","is_error":false,"result":"Result only response","usage":{"input_tokens":5,"output_tokens":3}}]`
|
||||
script := createMockQwenCLI(t, mockJSON, "", 0)
|
||||
|
||||
p := NewQwenCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
resp, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
if resp.Content != "Result only response" {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "Result only response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenChat_IsErrorResponse(t *testing.T) {
|
||||
mockJSON := `[{"type":"result","subtype":"error","is_error":true,"result":"API key invalid"}]`
|
||||
script := createMockQwenCLI(t, mockJSON, "", 0)
|
||||
|
||||
p := NewQwenCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Chat() expected error when is_error=true")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "API key invalid") {
|
||||
t.Errorf("error = %q, want to contain 'API key invalid'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenChat_WithToolCallsInResponse(t *testing.T) {
|
||||
result := `Let me check the weather.
|
||||
{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{\"location\":\"NYC\"}"}}]}`
|
||||
mockJSON := fmt.Sprintf(
|
||||
`[{"type":"result","subtype":"success","is_error":false,"result":%q,"usage":{"input_tokens":5,"output_tokens":20}}]`,
|
||||
result,
|
||||
)
|
||||
script := createMockQwenCLI(t, mockJSON, "", 0)
|
||||
|
||||
p := NewQwenCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
resp, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "What's the weather?"},
|
||||
}, nil, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() 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"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenChat_StderrError(t *testing.T) {
|
||||
script := createMockQwenCLI(t, "", "Error: connection failed", 1)
|
||||
|
||||
p := NewQwenCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Chat() expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "connection failed") {
|
||||
t.Errorf("error = %q, want to contain 'connection failed'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenChat_NonZeroExitNoStderr(t *testing.T) {
|
||||
script := createMockQwenCLI(t, "", "", 1)
|
||||
|
||||
p := NewQwenCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Chat() expected error for non-zero exit")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "qwen cli error") {
|
||||
t.Errorf("error = %q, want to contain 'qwen cli error'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenChat_CommandNotFound(t *testing.T) {
|
||||
p := NewQwenCliProvider(t.TempDir())
|
||||
p.command = "/nonexistent/qwen-binary-that-does-not-exist"
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Chat() expected error for missing command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenChat_InvalidResponseJSON(t *testing.T) {
|
||||
script := createMockQwenCLI(t, "not valid json", "", 0)
|
||||
|
||||
p := NewQwenCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Chat() expected error for invalid JSON")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to parse qwen cli response") {
|
||||
t.Errorf("error = %q, want to contain 'failed to parse qwen cli response'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenChat_EmptyResponse(t *testing.T) {
|
||||
script := createMockQwenCLI(t, "", "", 0)
|
||||
|
||||
p := NewQwenCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Chat() expected error for empty response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenChat_ContextCancellation(t *testing.T) {
|
||||
script := createSlowMockQwenCLI(t, 2) // sleep 2s
|
||||
|
||||
p := NewQwenCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
_, err := p.Chat(ctx, []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Chat() expected error on context cancellation")
|
||||
}
|
||||
// Should fail well before the full 2s sleep completes
|
||||
if elapsed > 3*time.Second {
|
||||
t.Errorf("Chat() took %v, expected to fail faster via context cancellation", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenChat_PassesModelFlag(t *testing.T) {
|
||||
argsFile := filepath.Join(t.TempDir(), "args.txt")
|
||||
script := createArgCaptureQwenCLI(t, argsFile)
|
||||
|
||||
p := NewQwenCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hi"},
|
||||
}, nil, "qwen-max", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
argsBytes, _ := os.ReadFile(argsFile)
|
||||
args := string(argsBytes)
|
||||
if !strings.Contains(args, "-m") {
|
||||
t.Errorf("CLI args missing -m, got: %s", args)
|
||||
}
|
||||
if !strings.Contains(args, "qwen-max") {
|
||||
t.Errorf("CLI args missing model name, got: %s", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenChat_SkipsModelFlagForQwenCli(t *testing.T) {
|
||||
argsFile := filepath.Join(t.TempDir(), "args.txt")
|
||||
script := createArgCaptureQwenCLI(t, argsFile)
|
||||
|
||||
p := NewQwenCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hi"},
|
||||
}, nil, "qwen-cli", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
argsBytes, _ := os.ReadFile(argsFile)
|
||||
args := string(argsBytes)
|
||||
if strings.Contains(args, "-m") {
|
||||
t.Errorf("CLI args should NOT contain -m for qwen-cli, got: %s", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenChat_SkipsModelFlagForEmptyModel(t *testing.T) {
|
||||
argsFile := filepath.Join(t.TempDir(), "args.txt")
|
||||
script := createArgCaptureQwenCLI(t, argsFile)
|
||||
|
||||
p := NewQwenCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hi"},
|
||||
}, nil, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
argsBytes, _ := os.ReadFile(argsFile)
|
||||
args := string(argsBytes)
|
||||
if strings.Contains(args, "-m") {
|
||||
t.Errorf("CLI args should NOT contain -m for empty model, got: %s", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) {
|
||||
mockJSON := `[{"type":"result","result":"ok","session_id":"s"}]`
|
||||
script := createMockQwenCLI(t, mockJSON, "", 0)
|
||||
|
||||
p := NewQwenCliProvider("")
|
||||
p.command = script
|
||||
|
||||
resp, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() with empty workspace error = %v", err)
|
||||
}
|
||||
if resp.Content != "ok" {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "ok")
|
||||
}
|
||||
}
|
||||
|
||||
// --- buildPrompt tests ---
|
||||
|
||||
func TestQwenBuildPrompt_SingleUser(t *testing.T) {
|
||||
p := NewQwenCliProvider("/workspace")
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}
|
||||
got := p.buildPrompt(messages, nil)
|
||||
want := "Hello"
|
||||
if got != want {
|
||||
t.Errorf("buildPrompt() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenBuildPrompt_Conversation(t *testing.T) {
|
||||
p := NewQwenCliProvider("/workspace")
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "Hi"},
|
||||
{Role: "assistant", Content: "Hello!"},
|
||||
{Role: "user", Content: "How are you?"},
|
||||
}
|
||||
got := p.buildPrompt(messages, nil)
|
||||
want := "Hi\nAssistant: Hello!\nHow are you?"
|
||||
if got != want {
|
||||
t.Errorf("buildPrompt() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenBuildPrompt_WithSystemMessage(t *testing.T) {
|
||||
p := NewQwenCliProvider("/workspace")
|
||||
messages := []Message{
|
||||
{Role: "system", Content: "You are helpful."},
|
||||
{Role: "user", Content: "Hello"},
|
||||
}
|
||||
got := p.buildPrompt(messages, nil)
|
||||
if !strings.Contains(got, "## System Instructions") {
|
||||
t.Error("missing system instructions header")
|
||||
}
|
||||
if !strings.Contains(got, "You are helpful.") {
|
||||
t.Error("missing system message")
|
||||
}
|
||||
if !strings.Contains(got, "## Task") {
|
||||
t.Error("missing task header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenBuildPrompt_WithToolResults(t *testing.T) {
|
||||
p := NewQwenCliProvider("/workspace")
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "What's the weather?"},
|
||||
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_123"},
|
||||
}
|
||||
got := p.buildPrompt(messages, nil)
|
||||
if !strings.Contains(got, "[Tool Result for call_123]") {
|
||||
t.Errorf("buildPrompt() missing tool result marker, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `{"temp": 72}`) {
|
||||
t.Errorf("buildPrompt() missing tool result content, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenBuildPrompt_WithTools(t *testing.T) {
|
||||
p := NewQwenCliProvider("/workspace")
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}
|
||||
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.Error("buildPrompt() missing tool definition")
|
||||
}
|
||||
if !strings.Contains(got, "Available Tools") {
|
||||
t.Error("buildPrompt() missing tools header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenBuildPrompt_EmptyMessages(t *testing.T) {
|
||||
p := NewQwenCliProvider("/workspace")
|
||||
got := p.buildPrompt(nil, nil)
|
||||
if got != "" {
|
||||
t.Errorf("buildPrompt(nil) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- parseJSONEvents tests ---
|
||||
|
||||
func TestQwenParseJSONEvents_Success(t *testing.T) {
|
||||
p := NewQwenCliProvider("/workspace")
|
||||
output := `[{"type":"result","subtype":"success","is_error":false,"result":"Hello, world!","usage":{"input_tokens":10,"output_tokens":20}}]`
|
||||
|
||||
resp, err := p.parseJSONEvents(output)
|
||||
if err != nil {
|
||||
t.Fatalf("parseJSONEvents() error = %v", err)
|
||||
}
|
||||
if resp.Content != "Hello, world!" {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "Hello, world!")
|
||||
}
|
||||
if resp.FinishReason != "stop" {
|
||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
||||
}
|
||||
if resp.Usage == nil {
|
||||
t.Fatal("Usage should not be nil")
|
||||
}
|
||||
if resp.Usage.PromptTokens != 10 {
|
||||
t.Errorf("PromptTokens = %d, want 10", resp.Usage.PromptTokens)
|
||||
}
|
||||
if resp.Usage.CompletionTokens != 20 {
|
||||
t.Errorf("CompletionTokens = %d, want 20", resp.Usage.CompletionTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenParseJSONEvents_WithAssistantEvent(t *testing.T) {
|
||||
p := NewQwenCliProvider("/workspace")
|
||||
output := `[{"type":"assistant","message":{"content":[{"type":"text","text":"Assistant says hi"}]}},{"type":"result","subtype":"success","is_error":false,"result":"Result text","usage":{"input_tokens":5,"output_tokens":3}}]`
|
||||
|
||||
resp, err := p.parseJSONEvents(output)
|
||||
if err != nil {
|
||||
t.Fatalf("parseJSONEvents() error = %v", err)
|
||||
}
|
||||
// Should prefer assistant event content over result
|
||||
if !strings.Contains(resp.Content, "Assistant says hi") {
|
||||
t.Errorf("Content should contain assistant text, got %q", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenParseJSONEvents_Error(t *testing.T) {
|
||||
p := NewQwenCliProvider("/workspace")
|
||||
output := `[{"type":"result","subtype":"error","is_error":true,"result":"Something went wrong"}]`
|
||||
|
||||
_, err := p.parseJSONEvents(output)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when is_error=true")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Something went wrong") {
|
||||
t.Errorf("error = %q, want to contain 'Something went wrong'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenParseJSONEvents_NoResultEvent(t *testing.T) {
|
||||
p := NewQwenCliProvider("/workspace")
|
||||
output := `[{"type":"system","subtype":"init"}]`
|
||||
|
||||
resp, err := p.parseJSONEvents(output)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.Content != "" {
|
||||
t.Errorf("Content = %q, want empty", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenParseJSONEvents_EmptyOutput(t *testing.T) {
|
||||
p := NewQwenCliProvider("/workspace")
|
||||
_, err := p.parseJSONEvents("")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenParseJSONEvents_InvalidJSON(t *testing.T) {
|
||||
p := NewQwenCliProvider("/workspace")
|
||||
_, err := p.parseJSONEvents("not json")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenParseJSONEvents_NoUsage(t *testing.T) {
|
||||
p := NewQwenCliProvider("/workspace")
|
||||
output := `[{"type":"result","subtype":"success","is_error":false,"result":"hi"}]`
|
||||
|
||||
resp, err := p.parseJSONEvents(output)
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if resp.Usage != nil {
|
||||
t.Errorf("Usage should be nil when no tokens, got %+v", resp.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenParseJSONEvents_WithToolCalls(t *testing.T) {
|
||||
p := NewQwenCliProvider("/workspace")
|
||||
result := `Let me check.
|
||||
{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{\"location\":\"Tokyo\"}"}}]}`
|
||||
output := fmt.Sprintf(`[{"type":"result","subtype":"success","is_error":false,"result":%q}]`, result)
|
||||
|
||||
resp, err := p.parseJSONEvents(output)
|
||||
if err != nil {
|
||||
t.Fatalf("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 = %d, want 1", len(resp.ToolCalls))
|
||||
}
|
||||
if resp.ToolCalls[0].Name != "get_weather" {
|
||||
t.Errorf("Name = %q, want %q", resp.ToolCalls[0].Name, "get_weather")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Factory integration tests ---
|
||||
|
||||
func TestCreateProviderFromConfig_QwenCli(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "qwen-code",
|
||||
Model: "qwen-cli/qwen-code",
|
||||
Workspace: "/test/ws",
|
||||
}
|
||||
|
||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProviderFromConfig(qwen-cli) error = %v", err)
|
||||
}
|
||||
|
||||
if modelID != "qwen-code" {
|
||||
t.Errorf("modelID = %q, want %q", modelID, "qwen-code")
|
||||
}
|
||||
|
||||
qwenProvider, ok := provider.(*QwenCliProvider)
|
||||
if !ok {
|
||||
t.Fatalf("CreateProviderFromConfig(qwen-cli) returned %T, want *QwenCliProvider", provider)
|
||||
}
|
||||
if qwenProvider.workspace != "/test/ws" {
|
||||
t.Errorf("workspace = %q, want %q", qwenProvider.workspace, "/test/ws")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProviderFromConfig_QwenCliDefaultWorkspace(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "qwen-code",
|
||||
Model: "qwen-cli/qwen-code",
|
||||
Workspace: "",
|
||||
}
|
||||
|
||||
provider, _, err := CreateProviderFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProviderFromConfig error = %v", err)
|
||||
}
|
||||
|
||||
qwenProvider, ok := provider.(*QwenCliProvider)
|
||||
if !ok {
|
||||
t.Fatalf("returned %T, want *QwenCliProvider", provider)
|
||||
}
|
||||
if qwenProvider.workspace != "." {
|
||||
t.Errorf("workspace = %q, want %q (default)", qwenProvider.workspace, ".")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue