mirror of
https://github.com/ollama/ollama.git
synced 2026-04-22 00:36:11 +02:00
Compare commits
10 Commits
v0.15.1-rc
...
brucemacd/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6f5a982d3 | ||
|
|
7b62c41060 | ||
|
|
26acab64b7 | ||
|
|
e0f03790b1 | ||
|
|
3ab842b0f5 | ||
|
|
b8e8ef8929 | ||
|
|
465d124183 | ||
|
|
d310e56fa3 | ||
|
|
a1ca428c90 | ||
|
|
16750865d1 |
14
cmd/cmd.go
14
cmd/cmd.go
@@ -1419,10 +1419,10 @@ func thinkingOutputClosingText(plainText bool) string {
|
|||||||
return readline.ColorGrey + readline.ColorBold + text + readline.ColorDefault
|
return readline.ColorGrey + readline.ColorBold + text + readline.ColorDefault
|
||||||
}
|
}
|
||||||
|
|
||||||
func chat(cmd *cobra.Command, opts runOptions) (*api.Message, error) {
|
func chat(cmd *cobra.Command, opts runOptions) (*api.Message, *api.Metrics, error) {
|
||||||
client, err := api.ClientFromEnvironment()
|
client, err := api.ClientFromEnvironment()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
p := progress.NewProgress(os.Stderr)
|
p := progress.NewProgress(os.Stderr)
|
||||||
@@ -1515,7 +1515,7 @@ func chat(cmd *cobra.Command, opts runOptions) (*api.Message, error) {
|
|||||||
|
|
||||||
if err := client.Chat(cancelCtx, req, fn); err != nil {
|
if err := client.Chat(cancelCtx, req, fn); err != nil {
|
||||||
if errors.Is(err, context.Canceled) {
|
if errors.Is(err, context.Canceled) {
|
||||||
return nil, nil
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// this error should ideally be wrapped properly by the client
|
// this error should ideally be wrapped properly by the client
|
||||||
@@ -1523,9 +1523,9 @@ func chat(cmd *cobra.Command, opts runOptions) (*api.Message, error) {
|
|||||||
p.StopAndClear()
|
p.StopAndClear()
|
||||||
fmt.Println("An error occurred while processing your message. Please try again.")
|
fmt.Println("An error occurred while processing your message. Please try again.")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
return nil, nil
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(opts.Messages) > 0 {
|
if len(opts.Messages) > 0 {
|
||||||
@@ -1535,14 +1535,14 @@ func chat(cmd *cobra.Command, opts runOptions) (*api.Message, error) {
|
|||||||
|
|
||||||
verbose, err := cmd.Flags().GetBool("verbose")
|
verbose, err := cmd.Flags().GetBool("verbose")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if verbose {
|
if verbose {
|
||||||
latest.Summary()
|
latest.Summary()
|
||||||
}
|
}
|
||||||
|
|
||||||
return &api.Message{Role: role, Thinking: thinkingContent.String(), Content: fullResponse.String()}, nil
|
return &api.Message{Role: role, Thinking: thinkingContent.String(), Content: fullResponse.String()}, &latest.Metrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func generate(cmd *cobra.Command, opts runOptions) error {
|
func generate(cmd *cobra.Command, opts runOptions) error {
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/envconfig"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Claude implements Runner for Claude Code integration
|
// Claude implements Runner for Claude Code integration
|
||||||
@@ -18,17 +22,37 @@ func (c *Claude) args(model string) []string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Claude) findPath() (string, error) {
|
||||||
|
if p, err := exec.LookPath("claude"); err == nil {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
name := "claude"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
name = "claude.exe"
|
||||||
|
}
|
||||||
|
fallback := filepath.Join(home, ".claude", "local", name)
|
||||||
|
if _, err := os.Stat(fallback); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Claude) Run(model string) error {
|
func (c *Claude) Run(model string) error {
|
||||||
if _, err := exec.LookPath("claude"); err != nil {
|
claudePath, err := c.findPath()
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("claude is not installed, install from https://code.claude.com/docs/en/quickstart")
|
return fmt.Errorf("claude is not installed, install from https://code.claude.com/docs/en/quickstart")
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command("claude", c.args(model)...)
|
cmd := exec.Command(claudePath, c.args(model)...)
|
||||||
cmd.Stdin = os.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
cmd.Env = append(os.Environ(),
|
cmd.Env = append(os.Environ(),
|
||||||
"ANTHROPIC_BASE_URL=http://localhost:11434",
|
"ANTHROPIC_BASE_URL="+envconfig.Host().String(),
|
||||||
"ANTHROPIC_API_KEY=",
|
"ANTHROPIC_API_KEY=",
|
||||||
"ANTHROPIC_AUTH_TOKEN=ollama",
|
"ANTHROPIC_AUTH_TOKEN=ollama",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -19,6 +22,62 @@ func TestClaudeIntegration(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClaudeFindPath(t *testing.T) {
|
||||||
|
c := &Claude{}
|
||||||
|
|
||||||
|
t.Run("finds claude in PATH", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
name := "claude"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
name = "claude.exe"
|
||||||
|
}
|
||||||
|
fakeBin := filepath.Join(tmpDir, name)
|
||||||
|
os.WriteFile(fakeBin, []byte("#!/bin/sh\n"), 0o755)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
|
||||||
|
got, err := c.findPath()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != fakeBin {
|
||||||
|
t.Errorf("findPath() = %q, want %q", got, fakeBin)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("falls back to ~/.claude/local/claude", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", t.TempDir()) // empty dir, no claude binary
|
||||||
|
|
||||||
|
name := "claude"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
name = "claude.exe"
|
||||||
|
}
|
||||||
|
fallback := filepath.Join(tmpDir, ".claude", "local", name)
|
||||||
|
os.MkdirAll(filepath.Dir(fallback), 0o755)
|
||||||
|
os.WriteFile(fallback, []byte("#!/bin/sh\n"), 0o755)
|
||||||
|
|
||||||
|
got, err := c.findPath()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != fallback {
|
||||||
|
t.Errorf("findPath() = %q, want %q", got, fallback)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns error when neither PATH nor fallback exists", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", t.TempDir()) // empty dir, no claude binary
|
||||||
|
|
||||||
|
_, err := c.findPath()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestClaudeArgs(t *testing.T) {
|
func TestClaudeArgs(t *testing.T) {
|
||||||
c := &Claude{}
|
c := &Claude{}
|
||||||
|
|
||||||
|
|||||||
195
cmd/config/clawdbot.go
Normal file
195
cmd/config/clawdbot.go
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/envconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Clawdbot struct{}
|
||||||
|
|
||||||
|
func (c *Clawdbot) String() string { return "Clawdbot" }
|
||||||
|
|
||||||
|
const ansiGreen = "\033[32m"
|
||||||
|
|
||||||
|
func (c *Clawdbot) Run(model string) error {
|
||||||
|
if _, err := exec.LookPath("clawdbot"); err != nil {
|
||||||
|
return fmt.Errorf("clawdbot is not installed, install from https://docs.clawd.bot")
|
||||||
|
}
|
||||||
|
|
||||||
|
models := []string{model}
|
||||||
|
if config, err := loadIntegration("clawdbot"); err == nil && len(config.Models) > 0 {
|
||||||
|
models = config.Models
|
||||||
|
}
|
||||||
|
if err := c.Edit(models); err != nil {
|
||||||
|
return fmt.Errorf("setup failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("clawdbot", "gateway")
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
|
||||||
|
// Capture output to detect "already running" message
|
||||||
|
var outputBuf bytes.Buffer
|
||||||
|
cmd.Stdout = io.MultiWriter(os.Stdout, &outputBuf)
|
||||||
|
cmd.Stderr = io.MultiWriter(os.Stderr, &outputBuf)
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil && strings.Contains(outputBuf.String(), "Gateway already running") {
|
||||||
|
fmt.Fprintf(os.Stderr, "%sClawdbot has been configured with Ollama. Gateway is already running.%s\n", ansiGreen, ansiReset)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Clawdbot) Paths() []string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
p := filepath.Join(home, ".clawdbot", "clawdbot.json")
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
return []string{p}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Clawdbot) Edit(models []string) error {
|
||||||
|
if len(models) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := filepath.Join(home, ".clawdbot", "clawdbot.json")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read into map[string]any to preserve unknown fields
|
||||||
|
config := make(map[string]any)
|
||||||
|
if data, err := os.ReadFile(configPath); err == nil {
|
||||||
|
_ = json.Unmarshal(data, &config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate/create: models.providers.ollama (preserving other providers)
|
||||||
|
modelsSection, _ := config["models"].(map[string]any)
|
||||||
|
if modelsSection == nil {
|
||||||
|
modelsSection = make(map[string]any)
|
||||||
|
}
|
||||||
|
providers, _ := modelsSection["providers"].(map[string]any)
|
||||||
|
if providers == nil {
|
||||||
|
providers = make(map[string]any)
|
||||||
|
}
|
||||||
|
ollama, _ := providers["ollama"].(map[string]any)
|
||||||
|
if ollama == nil {
|
||||||
|
ollama = make(map[string]any)
|
||||||
|
}
|
||||||
|
|
||||||
|
ollama["baseUrl"] = envconfig.Host().String() + "/v1"
|
||||||
|
// needed to register provider
|
||||||
|
ollama["apiKey"] = "ollama-local"
|
||||||
|
// TODO(parthsareen): potentially move to responses
|
||||||
|
ollama["api"] = "openai-completions"
|
||||||
|
|
||||||
|
// Build map of existing models to preserve user customizations
|
||||||
|
existingModels, _ := ollama["models"].([]any)
|
||||||
|
existingByID := make(map[string]map[string]any)
|
||||||
|
for _, m := range existingModels {
|
||||||
|
if entry, ok := m.(map[string]any); ok {
|
||||||
|
if id, ok := entry["id"].(string); ok {
|
||||||
|
existingByID[id] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var newModels []any
|
||||||
|
for _, model := range models {
|
||||||
|
entry := map[string]any{
|
||||||
|
"id": model,
|
||||||
|
"name": model,
|
||||||
|
"reasoning": false,
|
||||||
|
"input": []any{"text"},
|
||||||
|
"cost": map[string]any{
|
||||||
|
"input": 0,
|
||||||
|
"output": 0,
|
||||||
|
"cacheRead": 0,
|
||||||
|
"cacheWrite": 0,
|
||||||
|
},
|
||||||
|
// TODO(parthsareen): get these values from API
|
||||||
|
"contextWindow": 131072,
|
||||||
|
"maxTokens": 16384,
|
||||||
|
}
|
||||||
|
// Merge existing fields (user customizations)
|
||||||
|
if existing, ok := existingByID[model]; ok {
|
||||||
|
for k, v := range existing {
|
||||||
|
if _, isNew := entry[k]; !isNew {
|
||||||
|
entry[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newModels = append(newModels, entry)
|
||||||
|
}
|
||||||
|
ollama["models"] = newModels
|
||||||
|
|
||||||
|
providers["ollama"] = ollama
|
||||||
|
modelsSection["providers"] = providers
|
||||||
|
config["models"] = modelsSection
|
||||||
|
|
||||||
|
// Update agents.defaults.model.primary (preserving other agent settings)
|
||||||
|
agents, _ := config["agents"].(map[string]any)
|
||||||
|
if agents == nil {
|
||||||
|
agents = make(map[string]any)
|
||||||
|
}
|
||||||
|
defaults, _ := agents["defaults"].(map[string]any)
|
||||||
|
if defaults == nil {
|
||||||
|
defaults = make(map[string]any)
|
||||||
|
}
|
||||||
|
modelConfig, _ := defaults["model"].(map[string]any)
|
||||||
|
if modelConfig == nil {
|
||||||
|
modelConfig = make(map[string]any)
|
||||||
|
}
|
||||||
|
modelConfig["primary"] = "ollama/" + models[0]
|
||||||
|
defaults["model"] = modelConfig
|
||||||
|
agents["defaults"] = defaults
|
||||||
|
config["agents"] = agents
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(config, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeWithBackup(configPath, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Clawdbot) Models() []string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := readJSONFile(filepath.Join(home, ".clawdbot", "clawdbot.json"))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
modelsSection, _ := config["models"].(map[string]any)
|
||||||
|
providers, _ := modelsSection["providers"].(map[string]any)
|
||||||
|
ollama, _ := providers["ollama"].(map[string]any)
|
||||||
|
modelList, _ := ollama["models"].([]any)
|
||||||
|
|
||||||
|
var result []string
|
||||||
|
for _, m := range modelList {
|
||||||
|
if entry, ok := m.(map[string]any); ok {
|
||||||
|
if id, ok := entry["id"].(string); ok {
|
||||||
|
result = append(result, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
625
cmd/config/clawdbot_test.go
Normal file
625
cmd/config/clawdbot_test.go
Normal file
@@ -0,0 +1,625 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClawdbotIntegration(t *testing.T) {
|
||||||
|
c := &Clawdbot{}
|
||||||
|
|
||||||
|
t.Run("String", func(t *testing.T) {
|
||||||
|
if got := c.String(); got != "Clawdbot" {
|
||||||
|
t.Errorf("String() = %q, want %q", got, "Clawdbot")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("implements Runner", func(t *testing.T) {
|
||||||
|
var _ Runner = c
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("implements Editor", func(t *testing.T) {
|
||||||
|
var _ Editor = c
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClawdbotEdit(t *testing.T) {
|
||||||
|
c := &Clawdbot{}
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
|
||||||
|
configDir := filepath.Join(tmpDir, ".clawdbot")
|
||||||
|
configPath := filepath.Join(configDir, "clawdbot.json")
|
||||||
|
|
||||||
|
cleanup := func() { os.RemoveAll(configDir) }
|
||||||
|
|
||||||
|
t.Run("fresh install", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
if err := c.Edit([]string{"llama3.2"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertClawdbotModelExists(t, configPath, "llama3.2")
|
||||||
|
assertClawdbotPrimaryModel(t, configPath, "ollama/llama3.2")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("multiple models - first is primary", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
if err := c.Edit([]string{"llama3.2", "mistral"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertClawdbotModelExists(t, configPath, "llama3.2")
|
||||||
|
assertClawdbotModelExists(t, configPath, "mistral")
|
||||||
|
assertClawdbotPrimaryModel(t, configPath, "ollama/llama3.2")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("preserve other providers", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
os.MkdirAll(configDir, 0o755)
|
||||||
|
os.WriteFile(configPath, []byte(`{"models":{"providers":{"anthropic":{"apiKey":"xxx"}}}}`), 0o644)
|
||||||
|
if err := c.Edit([]string{"llama3.2"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(configPath)
|
||||||
|
var cfg map[string]any
|
||||||
|
json.Unmarshal(data, &cfg)
|
||||||
|
models := cfg["models"].(map[string]any)
|
||||||
|
providers := models["providers"].(map[string]any)
|
||||||
|
if providers["anthropic"] == nil {
|
||||||
|
t.Error("anthropic provider was removed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("preserve top-level keys", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
os.MkdirAll(configDir, 0o755)
|
||||||
|
os.WriteFile(configPath, []byte(`{"theme":"dark","mcp":{"servers":{}}}`), 0o644)
|
||||||
|
if err := c.Edit([]string{"llama3.2"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(configPath)
|
||||||
|
var cfg map[string]any
|
||||||
|
json.Unmarshal(data, &cfg)
|
||||||
|
if cfg["theme"] != "dark" {
|
||||||
|
t.Error("theme was removed")
|
||||||
|
}
|
||||||
|
if cfg["mcp"] == nil {
|
||||||
|
t.Error("mcp was removed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("preserve user customizations on models", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
c.Edit([]string{"llama3.2"})
|
||||||
|
|
||||||
|
// User adds custom field
|
||||||
|
data, _ := os.ReadFile(configPath)
|
||||||
|
var cfg map[string]any
|
||||||
|
json.Unmarshal(data, &cfg)
|
||||||
|
models := cfg["models"].(map[string]any)
|
||||||
|
providers := models["providers"].(map[string]any)
|
||||||
|
ollama := providers["ollama"].(map[string]any)
|
||||||
|
modelList := ollama["models"].([]any)
|
||||||
|
entry := modelList[0].(map[string]any)
|
||||||
|
entry["customField"] = "user-value"
|
||||||
|
configData, _ := json.MarshalIndent(cfg, "", " ")
|
||||||
|
os.WriteFile(configPath, configData, 0o644)
|
||||||
|
|
||||||
|
// Re-run Edit
|
||||||
|
c.Edit([]string{"llama3.2"})
|
||||||
|
|
||||||
|
data, _ = os.ReadFile(configPath)
|
||||||
|
json.Unmarshal(data, &cfg)
|
||||||
|
models = cfg["models"].(map[string]any)
|
||||||
|
providers = models["providers"].(map[string]any)
|
||||||
|
ollama = providers["ollama"].(map[string]any)
|
||||||
|
modelList = ollama["models"].([]any)
|
||||||
|
entry = modelList[0].(map[string]any)
|
||||||
|
if entry["customField"] != "user-value" {
|
||||||
|
t.Error("custom field was lost")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("edit replaces models list", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
c.Edit([]string{"llama3.2", "mistral"})
|
||||||
|
c.Edit([]string{"llama3.2"})
|
||||||
|
|
||||||
|
assertClawdbotModelExists(t, configPath, "llama3.2")
|
||||||
|
assertClawdbotModelNotExists(t, configPath, "mistral")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty models is no-op", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
os.MkdirAll(configDir, 0o755)
|
||||||
|
original := `{"existing":"data"}`
|
||||||
|
os.WriteFile(configPath, []byte(original), 0o644)
|
||||||
|
|
||||||
|
c.Edit([]string{})
|
||||||
|
|
||||||
|
data, _ := os.ReadFile(configPath)
|
||||||
|
if string(data) != original {
|
||||||
|
t.Error("empty models should not modify file")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("corrupted JSON treated as empty", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
os.MkdirAll(configDir, 0o755)
|
||||||
|
os.WriteFile(configPath, []byte(`{corrupted`), 0o644)
|
||||||
|
|
||||||
|
if err := c.Edit([]string{"llama3.2"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := os.ReadFile(configPath)
|
||||||
|
var cfg map[string]any
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
t.Error("result should be valid JSON")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wrong type models section", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
os.MkdirAll(configDir, 0o755)
|
||||||
|
os.WriteFile(configPath, []byte(`{"models":"not a map"}`), 0o644)
|
||||||
|
|
||||||
|
if err := c.Edit([]string{"llama3.2"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertClawdbotModelExists(t, configPath, "llama3.2")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClawdbotModels(t *testing.T) {
|
||||||
|
c := &Clawdbot{}
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
|
||||||
|
t.Run("no config returns nil", func(t *testing.T) {
|
||||||
|
if models := c.Models(); len(models) > 0 {
|
||||||
|
t.Errorf("expected nil/empty, got %v", models)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns all ollama models", func(t *testing.T) {
|
||||||
|
configDir := filepath.Join(tmpDir, ".clawdbot")
|
||||||
|
os.MkdirAll(configDir, 0o755)
|
||||||
|
os.WriteFile(filepath.Join(configDir, "clawdbot.json"), []byte(`{
|
||||||
|
"models":{"providers":{"ollama":{"models":[
|
||||||
|
{"id":"llama3.2"},
|
||||||
|
{"id":"mistral"}
|
||||||
|
]}}}
|
||||||
|
}`), 0o644)
|
||||||
|
|
||||||
|
models := c.Models()
|
||||||
|
if len(models) != 2 {
|
||||||
|
t.Errorf("expected 2 models, got %v", models)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
func assertClawdbotModelExists(t *testing.T, path, model string) {
|
||||||
|
t.Helper()
|
||||||
|
data, _ := os.ReadFile(path)
|
||||||
|
var cfg map[string]any
|
||||||
|
json.Unmarshal(data, &cfg)
|
||||||
|
models := cfg["models"].(map[string]any)
|
||||||
|
providers := models["providers"].(map[string]any)
|
||||||
|
ollama := providers["ollama"].(map[string]any)
|
||||||
|
modelList := ollama["models"].([]any)
|
||||||
|
for _, m := range modelList {
|
||||||
|
if entry, ok := m.(map[string]any); ok {
|
||||||
|
if entry["id"] == model {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Errorf("model %s not found", model)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertClawdbotModelNotExists(t *testing.T, path, model string) {
|
||||||
|
t.Helper()
|
||||||
|
data, _ := os.ReadFile(path)
|
||||||
|
var cfg map[string]any
|
||||||
|
json.Unmarshal(data, &cfg)
|
||||||
|
models, _ := cfg["models"].(map[string]any)
|
||||||
|
providers, _ := models["providers"].(map[string]any)
|
||||||
|
ollama, _ := providers["ollama"].(map[string]any)
|
||||||
|
modelList, _ := ollama["models"].([]any)
|
||||||
|
for _, m := range modelList {
|
||||||
|
if entry, ok := m.(map[string]any); ok {
|
||||||
|
if entry["id"] == model {
|
||||||
|
t.Errorf("model %s should not exist", model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertClawdbotPrimaryModel(t *testing.T, path, expected string) {
|
||||||
|
t.Helper()
|
||||||
|
data, _ := os.ReadFile(path)
|
||||||
|
var cfg map[string]any
|
||||||
|
json.Unmarshal(data, &cfg)
|
||||||
|
agents := cfg["agents"].(map[string]any)
|
||||||
|
defaults := agents["defaults"].(map[string]any)
|
||||||
|
model := defaults["model"].(map[string]any)
|
||||||
|
if model["primary"] != expected {
|
||||||
|
t.Errorf("primary model = %v, want %v", model["primary"], expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClawdbotPaths(t *testing.T) {
|
||||||
|
c := &Clawdbot{}
|
||||||
|
|
||||||
|
t.Run("returns path when config exists", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
configDir := filepath.Join(tmpDir, ".clawdbot")
|
||||||
|
os.MkdirAll(configDir, 0o755)
|
||||||
|
os.WriteFile(filepath.Join(configDir, "clawdbot.json"), []byte(`{}`), 0o644)
|
||||||
|
|
||||||
|
paths := c.Paths()
|
||||||
|
if len(paths) != 1 {
|
||||||
|
t.Errorf("expected 1 path, got %d", len(paths))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns nil when config missing", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
if paths := c.Paths(); paths != nil {
|
||||||
|
t.Errorf("expected nil, got %v", paths)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClawdbotModelsEdgeCases(t *testing.T) {
|
||||||
|
c := &Clawdbot{}
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
configDir := filepath.Join(tmpDir, ".clawdbot")
|
||||||
|
configPath := filepath.Join(configDir, "clawdbot.json")
|
||||||
|
cleanup := func() { os.RemoveAll(configDir) }
|
||||||
|
|
||||||
|
t.Run("corrupted JSON returns nil", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
os.MkdirAll(configDir, 0o755)
|
||||||
|
os.WriteFile(configPath, []byte(`{corrupted`), 0o644)
|
||||||
|
if models := c.Models(); models != nil {
|
||||||
|
t.Errorf("expected nil, got %v", models)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wrong type at models level", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
os.MkdirAll(configDir, 0o755)
|
||||||
|
os.WriteFile(configPath, []byte(`{"models":"string"}`), 0o644)
|
||||||
|
if models := c.Models(); models != nil {
|
||||||
|
t.Errorf("expected nil, got %v", models)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wrong type at providers level", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
os.MkdirAll(configDir, 0o755)
|
||||||
|
os.WriteFile(configPath, []byte(`{"models":{"providers":"string"}}`), 0o644)
|
||||||
|
if models := c.Models(); models != nil {
|
||||||
|
t.Errorf("expected nil, got %v", models)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wrong type at ollama level", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
os.MkdirAll(configDir, 0o755)
|
||||||
|
os.WriteFile(configPath, []byte(`{"models":{"providers":{"ollama":"string"}}}`), 0o644)
|
||||||
|
if models := c.Models(); models != nil {
|
||||||
|
t.Errorf("expected nil, got %v", models)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("model entry missing id", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
os.MkdirAll(configDir, 0o755)
|
||||||
|
os.WriteFile(configPath, []byte(`{"models":{"providers":{"ollama":{"models":[{"name":"test"}]}}}}`), 0o644)
|
||||||
|
if len(c.Models()) != 0 {
|
||||||
|
t.Error("expected empty for missing id")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("model id is not string", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
os.MkdirAll(configDir, 0o755)
|
||||||
|
os.WriteFile(configPath, []byte(`{"models":{"providers":{"ollama":{"models":[{"id":123}]}}}}`), 0o644)
|
||||||
|
if len(c.Models()) != 0 {
|
||||||
|
t.Error("expected empty for non-string id")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClawdbotEditSchemaFields(t *testing.T) {
|
||||||
|
c := &Clawdbot{}
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
configPath := filepath.Join(tmpDir, ".clawdbot", "clawdbot.json")
|
||||||
|
|
||||||
|
if err := c.Edit([]string{"llama3.2"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := os.ReadFile(configPath)
|
||||||
|
var cfg map[string]any
|
||||||
|
json.Unmarshal(data, &cfg)
|
||||||
|
models := cfg["models"].(map[string]any)
|
||||||
|
providers := models["providers"].(map[string]any)
|
||||||
|
ollama := providers["ollama"].(map[string]any)
|
||||||
|
modelList := ollama["models"].([]any)
|
||||||
|
entry := modelList[0].(map[string]any)
|
||||||
|
|
||||||
|
// Verify required schema fields
|
||||||
|
if entry["reasoning"] != false {
|
||||||
|
t.Error("reasoning should be false")
|
||||||
|
}
|
||||||
|
if entry["input"] == nil {
|
||||||
|
t.Error("input should be set")
|
||||||
|
}
|
||||||
|
if entry["contextWindow"] == nil {
|
||||||
|
t.Error("contextWindow should be set")
|
||||||
|
}
|
||||||
|
if entry["maxTokens"] == nil {
|
||||||
|
t.Error("maxTokens should be set")
|
||||||
|
}
|
||||||
|
cost := entry["cost"].(map[string]any)
|
||||||
|
if cost["cacheRead"] == nil {
|
||||||
|
t.Error("cost.cacheRead should be set")
|
||||||
|
}
|
||||||
|
if cost["cacheWrite"] == nil {
|
||||||
|
t.Error("cost.cacheWrite should be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClawdbotEditModelNames(t *testing.T) {
|
||||||
|
c := &Clawdbot{}
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
configPath := filepath.Join(tmpDir, ".clawdbot", "clawdbot.json")
|
||||||
|
cleanup := func() { os.RemoveAll(filepath.Join(tmpDir, ".clawdbot")) }
|
||||||
|
|
||||||
|
t.Run("model with colon tag", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
if err := c.Edit([]string{"llama3.2:70b"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertClawdbotModelExists(t, configPath, "llama3.2:70b")
|
||||||
|
assertClawdbotPrimaryModel(t, configPath, "ollama/llama3.2:70b")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("model with slash", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
if err := c.Edit([]string{"library/model:tag"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertClawdbotModelExists(t, configPath, "library/model:tag")
|
||||||
|
assertClawdbotPrimaryModel(t, configPath, "ollama/library/model:tag")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("model with hyphen", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
if err := c.Edit([]string{"test-model"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertClawdbotModelExists(t, configPath, "test-model")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClawdbotEditAgentsPreservation(t *testing.T) {
|
||||||
|
c := &Clawdbot{}
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
configDir := filepath.Join(tmpDir, ".clawdbot")
|
||||||
|
configPath := filepath.Join(configDir, "clawdbot.json")
|
||||||
|
cleanup := func() { os.RemoveAll(configDir) }
|
||||||
|
|
||||||
|
t.Run("preserve other agent defaults", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
os.MkdirAll(configDir, 0o755)
|
||||||
|
os.WriteFile(configPath, []byte(`{"agents":{"defaults":{"model":{"primary":"old"},"temperature":0.7}}}`), 0o644)
|
||||||
|
|
||||||
|
c.Edit([]string{"llama3.2"})
|
||||||
|
|
||||||
|
data, _ := os.ReadFile(configPath)
|
||||||
|
var cfg map[string]any
|
||||||
|
json.Unmarshal(data, &cfg)
|
||||||
|
agents := cfg["agents"].(map[string]any)
|
||||||
|
defaults := agents["defaults"].(map[string]any)
|
||||||
|
if defaults["temperature"] != 0.7 {
|
||||||
|
t.Error("temperature setting was lost")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("preserve other agents besides defaults", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
os.MkdirAll(configDir, 0o755)
|
||||||
|
os.WriteFile(configPath, []byte(`{"agents":{"defaults":{},"custom-agent":{"foo":"bar"}}}`), 0o644)
|
||||||
|
|
||||||
|
c.Edit([]string{"llama3.2"})
|
||||||
|
|
||||||
|
data, _ := os.ReadFile(configPath)
|
||||||
|
var cfg map[string]any
|
||||||
|
json.Unmarshal(data, &cfg)
|
||||||
|
agents := cfg["agents"].(map[string]any)
|
||||||
|
if agents["custom-agent"] == nil {
|
||||||
|
t.Error("custom-agent was lost")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const testClawdbotFixture = `{
|
||||||
|
"theme": "dark",
|
||||||
|
"mcp": {"servers": {"custom": {"enabled": true}}},
|
||||||
|
"models": {
|
||||||
|
"providers": {
|
||||||
|
"anthropic": {"apiKey": "xxx"},
|
||||||
|
"ollama": {
|
||||||
|
"baseUrl": "http://127.0.0.1:11434/v1",
|
||||||
|
"models": [{"id": "old-model", "customField": "preserved"}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {"model": {"primary": "old"}, "temperature": 0.7},
|
||||||
|
"custom-agent": {"foo": "bar"}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
func TestClawdbotEdit_RoundTrip(t *testing.T) {
|
||||||
|
c := &Clawdbot{}
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
configDir := filepath.Join(tmpDir, ".clawdbot")
|
||||||
|
configPath := filepath.Join(configDir, "clawdbot.json")
|
||||||
|
|
||||||
|
os.MkdirAll(configDir, 0o755)
|
||||||
|
os.WriteFile(configPath, []byte(testClawdbotFixture), 0o644)
|
||||||
|
|
||||||
|
if err := c.Edit([]string{"llama3.2", "mistral"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := os.ReadFile(configPath)
|
||||||
|
var cfg map[string]any
|
||||||
|
json.Unmarshal(data, &cfg)
|
||||||
|
|
||||||
|
// Verify top-level preserved
|
||||||
|
if cfg["theme"] != "dark" {
|
||||||
|
t.Error("theme not preserved")
|
||||||
|
}
|
||||||
|
mcp := cfg["mcp"].(map[string]any)
|
||||||
|
servers := mcp["servers"].(map[string]any)
|
||||||
|
if servers["custom"] == nil {
|
||||||
|
t.Error("mcp.servers.custom not preserved")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify other providers preserved
|
||||||
|
models := cfg["models"].(map[string]any)
|
||||||
|
providers := models["providers"].(map[string]any)
|
||||||
|
if providers["anthropic"] == nil {
|
||||||
|
t.Error("anthropic provider not preserved")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify agents preserved
|
||||||
|
agents := cfg["agents"].(map[string]any)
|
||||||
|
if agents["custom-agent"] == nil {
|
||||||
|
t.Error("custom-agent not preserved")
|
||||||
|
}
|
||||||
|
defaults := agents["defaults"].(map[string]any)
|
||||||
|
if defaults["temperature"] != 0.7 {
|
||||||
|
t.Error("temperature not preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClawdbotEdit_Idempotent(t *testing.T) {
|
||||||
|
c := &Clawdbot{}
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
configDir := filepath.Join(tmpDir, ".clawdbot")
|
||||||
|
configPath := filepath.Join(configDir, "clawdbot.json")
|
||||||
|
|
||||||
|
os.MkdirAll(configDir, 0o755)
|
||||||
|
os.WriteFile(configPath, []byte(testClawdbotFixture), 0o644)
|
||||||
|
|
||||||
|
c.Edit([]string{"llama3.2", "mistral"})
|
||||||
|
firstData, _ := os.ReadFile(configPath)
|
||||||
|
|
||||||
|
c.Edit([]string{"llama3.2", "mistral"})
|
||||||
|
secondData, _ := os.ReadFile(configPath)
|
||||||
|
|
||||||
|
if string(firstData) != string(secondData) {
|
||||||
|
t.Error("repeated edits with same models produced different results")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClawdbotEdit_MultipleConsecutiveEdits(t *testing.T) {
|
||||||
|
c := &Clawdbot{}
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
configDir := filepath.Join(tmpDir, ".clawdbot")
|
||||||
|
configPath := filepath.Join(configDir, "clawdbot.json")
|
||||||
|
|
||||||
|
os.MkdirAll(configDir, 0o755)
|
||||||
|
os.WriteFile(configPath, []byte(testClawdbotFixture), 0o644)
|
||||||
|
|
||||||
|
for i := range 10 {
|
||||||
|
models := []string{"model-a", "model-b"}
|
||||||
|
if i%2 == 0 {
|
||||||
|
models = []string{"model-x", "model-y", "model-z"}
|
||||||
|
}
|
||||||
|
if err := c.Edit(models); err != nil {
|
||||||
|
t.Fatalf("edit %d failed: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := os.ReadFile(configPath)
|
||||||
|
var cfg map[string]any
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
t.Fatalf("file is not valid JSON after multiple edits: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg["theme"] != "dark" {
|
||||||
|
t.Error("theme lost after multiple edits")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClawdbotEdit_BackupCreated(t *testing.T) {
|
||||||
|
c := &Clawdbot{}
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
configDir := filepath.Join(tmpDir, ".clawdbot")
|
||||||
|
configPath := filepath.Join(configDir, "clawdbot.json")
|
||||||
|
backupDir := filepath.Join(os.TempDir(), "ollama-backups")
|
||||||
|
|
||||||
|
os.MkdirAll(configDir, 0o755)
|
||||||
|
uniqueMarker := fmt.Sprintf("test-marker-%d", os.Getpid())
|
||||||
|
original := fmt.Sprintf(`{"theme": "%s"}`, uniqueMarker)
|
||||||
|
os.WriteFile(configPath, []byte(original), 0o644)
|
||||||
|
|
||||||
|
if err := c.Edit([]string{"model-a"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
backups, _ := filepath.Glob(filepath.Join(backupDir, "clawdbot.json.*"))
|
||||||
|
foundBackup := false
|
||||||
|
for _, backup := range backups {
|
||||||
|
data, _ := os.ReadFile(backup)
|
||||||
|
if string(data) == original {
|
||||||
|
foundBackup = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundBackup {
|
||||||
|
t.Error("backup with original content not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClawdbotEdit_CreatesDirectoryIfMissing(t *testing.T) {
|
||||||
|
c := &Clawdbot{}
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
configDir := filepath.Join(tmpDir, ".clawdbot")
|
||||||
|
|
||||||
|
if _, err := os.Stat(configDir); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("directory should not exist before test")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.Edit([]string{"model-a"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(configDir); os.IsNotExist(err) {
|
||||||
|
t.Fatal("directory was not created")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/envconfig"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Droid implements Runner and Editor for Droid integration
|
// Droid implements Runner and Editor for Droid integration
|
||||||
@@ -117,7 +119,7 @@ func (d *Droid) Edit(models []string) error {
|
|||||||
newModels = append(newModels, modelEntry{
|
newModels = append(newModels, modelEntry{
|
||||||
Model: model,
|
Model: model,
|
||||||
DisplayName: model,
|
DisplayName: model,
|
||||||
BaseURL: "http://localhost:11434/v1",
|
BaseURL: envconfig.Host().String() + "/v1",
|
||||||
APIKey: "ollama",
|
APIKey: "ollama",
|
||||||
Provider: "generic-chat-completion-api",
|
Provider: "generic-chat-completion-api",
|
||||||
MaxOutputTokens: 64000,
|
MaxOutputTokens: 64000,
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ func TestDroidEdit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if model["baseUrl"] != "http://localhost:11434/v1" {
|
if model["baseUrl"] != "http://127.0.0.1:11434/v1" {
|
||||||
t.Errorf("unexpected baseUrl: %s", model["baseUrl"])
|
t.Errorf("unexpected baseUrl: %s", model["baseUrl"])
|
||||||
}
|
}
|
||||||
if model["apiKey"] != "ollama" {
|
if model["apiKey"] != "ollama" {
|
||||||
@@ -447,7 +447,7 @@ const testDroidSettingsFixture = `{
|
|||||||
{
|
{
|
||||||
"model": "existing-ollama-model",
|
"model": "existing-ollama-model",
|
||||||
"displayName": "existing-ollama-model",
|
"displayName": "existing-ollama-model",
|
||||||
"baseUrl": "http://localhost:11434/v1",
|
"baseUrl": "http://127.0.0.1:11434/v1",
|
||||||
"apiKey": "ollama",
|
"apiKey": "ollama",
|
||||||
"provider": "generic-chat-completion-api",
|
"provider": "generic-chat-completion-api",
|
||||||
"maxOutputTokens": 64000,
|
"maxOutputTokens": 64000,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ type Editor interface {
|
|||||||
// integrations is the registry of available integrations.
|
// integrations is the registry of available integrations.
|
||||||
var integrations = map[string]Runner{
|
var integrations = map[string]Runner{
|
||||||
"claude": &Claude{},
|
"claude": &Claude{},
|
||||||
|
"clawdbot": &Clawdbot{},
|
||||||
"codex": &Codex{},
|
"codex": &Codex{},
|
||||||
"droid": &Droid{},
|
"droid": &Droid{},
|
||||||
"opencode": &OpenCode{},
|
"opencode": &OpenCode{},
|
||||||
@@ -242,6 +243,7 @@ func LaunchCmd(checkServerHeartbeat func(cmd *cobra.Command, args []string) erro
|
|||||||
|
|
||||||
Supported integrations:
|
Supported integrations:
|
||||||
claude Claude Code
|
claude Claude Code
|
||||||
|
clawdbot Clawdbot
|
||||||
codex Codex
|
codex Codex
|
||||||
droid Droid
|
droid Droid
|
||||||
opencode OpenCode
|
opencode OpenCode
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/envconfig"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OpenCode implements Runner and Editor for OpenCode integration
|
// OpenCode implements Runner and Editor for OpenCode integration
|
||||||
@@ -88,7 +90,7 @@ func (o *OpenCode) Edit(modelList []string) error {
|
|||||||
"npm": "@ai-sdk/openai-compatible",
|
"npm": "@ai-sdk/openai-compatible",
|
||||||
"name": "Ollama (local)",
|
"name": "Ollama (local)",
|
||||||
"options": map[string]any{
|
"options": map[string]any{
|
||||||
"baseURL": "http://localhost:11434/v1",
|
"baseURL": envconfig.Host().String() + "/v1",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,17 +107,26 @@ func (o *OpenCode) Edit(modelList []string) error {
|
|||||||
|
|
||||||
for name, cfg := range models {
|
for name, cfg := range models {
|
||||||
if cfgMap, ok := cfg.(map[string]any); ok {
|
if cfgMap, ok := cfg.(map[string]any); ok {
|
||||||
if displayName, ok := cfgMap["name"].(string); ok {
|
if isOllamaModel(cfgMap) && !selectedSet[name] {
|
||||||
if strings.HasSuffix(displayName, "[Ollama]") && !selectedSet[name] {
|
delete(models, name)
|
||||||
delete(models, name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, model := range modelList {
|
for _, model := range modelList {
|
||||||
|
if existing, ok := models[model].(map[string]any); ok {
|
||||||
|
// migrate existing models without _launch marker
|
||||||
|
if isOllamaModel(existing) {
|
||||||
|
existing["_launch"] = true
|
||||||
|
if name, ok := existing["name"].(string); ok {
|
||||||
|
existing["name"] = strings.TrimSuffix(name, " [Ollama]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
models[model] = map[string]any{
|
models[model] = map[string]any{
|
||||||
"name": fmt.Sprintf("%s [Ollama]", model),
|
"name": model,
|
||||||
|
"_launch": true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,3 +212,15 @@ func (o *OpenCode) Models() []string {
|
|||||||
slices.Sort(keys)
|
slices.Sort(keys)
|
||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isOllamaModel reports whether a model config entry is managed by us
|
||||||
|
func isOllamaModel(cfg map[string]any) bool {
|
||||||
|
if v, ok := cfg["_launch"].(bool); ok && v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// previously used [Ollama] as a suffix for the model managed by ollama launch
|
||||||
|
if name, ok := cfg["name"].(string); ok {
|
||||||
|
return strings.HasSuffix(name, "[Ollama]")
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -161,6 +161,76 @@ func TestOpenCodeEdit(t *testing.T) {
|
|||||||
assertOpenCodeModelNotExists(t, configPath, "mistral")
|
assertOpenCodeModelNotExists(t, configPath, "mistral")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("preserve user customizations on managed models", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
if err := o.Edit([]string{"llama3.2"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add custom fields to the model entry (simulating user edits)
|
||||||
|
data, _ := os.ReadFile(configPath)
|
||||||
|
var cfg map[string]any
|
||||||
|
json.Unmarshal(data, &cfg)
|
||||||
|
provider := cfg["provider"].(map[string]any)
|
||||||
|
ollama := provider["ollama"].(map[string]any)
|
||||||
|
models := ollama["models"].(map[string]any)
|
||||||
|
entry := models["llama3.2"].(map[string]any)
|
||||||
|
entry["_myPref"] = "custom-value"
|
||||||
|
entry["_myNum"] = 42
|
||||||
|
configData, _ := json.MarshalIndent(cfg, "", " ")
|
||||||
|
os.WriteFile(configPath, configData, 0o644)
|
||||||
|
|
||||||
|
// Re-run Edit — should preserve custom fields
|
||||||
|
if err := o.Edit([]string{"llama3.2"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ = os.ReadFile(configPath)
|
||||||
|
json.Unmarshal(data, &cfg)
|
||||||
|
provider = cfg["provider"].(map[string]any)
|
||||||
|
ollama = provider["ollama"].(map[string]any)
|
||||||
|
models = ollama["models"].(map[string]any)
|
||||||
|
entry = models["llama3.2"].(map[string]any)
|
||||||
|
|
||||||
|
if entry["_myPref"] != "custom-value" {
|
||||||
|
t.Errorf("_myPref was lost: got %v", entry["_myPref"])
|
||||||
|
}
|
||||||
|
if entry["_myNum"] != float64(42) {
|
||||||
|
t.Errorf("_myNum was lost: got %v", entry["_myNum"])
|
||||||
|
}
|
||||||
|
if v, ok := entry["_launch"].(bool); !ok || !v {
|
||||||
|
t.Errorf("_launch marker missing or false: got %v", entry["_launch"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("migrate legacy [Ollama] suffix entries", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
// Write a config with a legacy entry (has [Ollama] suffix but no _launch marker)
|
||||||
|
os.MkdirAll(configDir, 0o755)
|
||||||
|
os.WriteFile(configPath, []byte(`{"provider":{"ollama":{"models":{"llama3.2":{"name":"llama3.2 [Ollama]"}}}}}`), 0o644)
|
||||||
|
|
||||||
|
if err := o.Edit([]string{"llama3.2"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := os.ReadFile(configPath)
|
||||||
|
var cfg map[string]any
|
||||||
|
json.Unmarshal(data, &cfg)
|
||||||
|
provider := cfg["provider"].(map[string]any)
|
||||||
|
ollama := provider["ollama"].(map[string]any)
|
||||||
|
models := ollama["models"].(map[string]any)
|
||||||
|
entry := models["llama3.2"].(map[string]any)
|
||||||
|
|
||||||
|
// _launch marker should be added
|
||||||
|
if v, ok := entry["_launch"].(bool); !ok || !v {
|
||||||
|
t.Errorf("_launch marker not added during migration: got %v", entry["_launch"])
|
||||||
|
}
|
||||||
|
// [Ollama] suffix should be stripped
|
||||||
|
if name, ok := entry["name"].(string); !ok || name != "llama3.2" {
|
||||||
|
t.Errorf("name suffix not stripped: got %q", entry["name"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("remove model preserves non-ollama models", func(t *testing.T) {
|
t.Run("remove model preserves non-ollama models", func(t *testing.T) {
|
||||||
cleanup()
|
cleanup()
|
||||||
os.MkdirAll(configDir, 0o755)
|
os.MkdirAll(configDir, 0o755)
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func generateInteractive(cmd *cobra.Command, opts runOptions) error {
|
func generateInteractive(cmd *cobra.Command, opts runOptions) error {
|
||||||
|
var sessionPromptTokens int64
|
||||||
|
var sessionCompletionTokens int64
|
||||||
|
|
||||||
usage := func() {
|
usage := func() {
|
||||||
fmt.Fprintln(os.Stderr, "Available Commands:")
|
fmt.Fprintln(os.Stderr, "Available Commands:")
|
||||||
fmt.Fprintln(os.Stderr, " /set Set session variables")
|
fmt.Fprintln(os.Stderr, " /set Set session variables")
|
||||||
@@ -37,6 +40,7 @@ func generateInteractive(cmd *cobra.Command, opts runOptions) error {
|
|||||||
fmt.Fprintln(os.Stderr, " /load <model> Load a session or model")
|
fmt.Fprintln(os.Stderr, " /load <model> Load a session or model")
|
||||||
fmt.Fprintln(os.Stderr, " /save <model> Save your current session")
|
fmt.Fprintln(os.Stderr, " /save <model> Save your current session")
|
||||||
fmt.Fprintln(os.Stderr, " /clear Clear session context")
|
fmt.Fprintln(os.Stderr, " /clear Clear session context")
|
||||||
|
fmt.Fprintln(os.Stderr, " /usage Show session token usage")
|
||||||
fmt.Fprintln(os.Stderr, " /bye Exit")
|
fmt.Fprintln(os.Stderr, " /bye Exit")
|
||||||
fmt.Fprintln(os.Stderr, " /?, /help Help for a command")
|
fmt.Fprintln(os.Stderr, " /?, /help Help for a command")
|
||||||
fmt.Fprintln(os.Stderr, " /? shortcuts Help for keyboard shortcuts")
|
fmt.Fprintln(os.Stderr, " /? shortcuts Help for keyboard shortcuts")
|
||||||
@@ -445,6 +449,9 @@ func generateInteractive(cmd *cobra.Command, opts runOptions) error {
|
|||||||
} else {
|
} else {
|
||||||
usageShow()
|
usageShow()
|
||||||
}
|
}
|
||||||
|
case strings.HasPrefix(line, "/usage"):
|
||||||
|
fmt.Printf("prompt tokens: %d\n", sessionPromptTokens)
|
||||||
|
fmt.Printf("completion tokens: %d\n", sessionCompletionTokens)
|
||||||
case strings.HasPrefix(line, "/help"), strings.HasPrefix(line, "/?"):
|
case strings.HasPrefix(line, "/help"), strings.HasPrefix(line, "/?"):
|
||||||
args := strings.Fields(line)
|
args := strings.Fields(line)
|
||||||
if len(args) > 1 {
|
if len(args) > 1 {
|
||||||
@@ -499,7 +506,7 @@ func generateInteractive(cmd *cobra.Command, opts runOptions) error {
|
|||||||
|
|
||||||
opts.Messages = append(opts.Messages, newMessage)
|
opts.Messages = append(opts.Messages, newMessage)
|
||||||
|
|
||||||
assistant, err := chat(cmd, opts)
|
assistant, metrics, err := chat(cmd, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "does not support thinking") ||
|
if strings.Contains(err.Error(), "does not support thinking") ||
|
||||||
strings.Contains(err.Error(), "invalid think value") {
|
strings.Contains(err.Error(), "invalid think value") {
|
||||||
@@ -509,6 +516,10 @@ func generateInteractive(cmd *cobra.Command, opts runOptions) error {
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if metrics != nil {
|
||||||
|
sessionPromptTokens += int64(metrics.PromptEvalCount)
|
||||||
|
sessionCompletionTokens += int64(metrics.EvalCount)
|
||||||
|
}
|
||||||
if assistant != nil {
|
if assistant != nil {
|
||||||
opts.Messages = append(opts.Messages, *assistant)
|
opts.Messages = append(opts.Messages, *assistant)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,7 @@
|
|||||||
"group": "Integrations",
|
"group": "Integrations",
|
||||||
"pages": [
|
"pages": [
|
||||||
"/integrations/claude-code",
|
"/integrations/claude-code",
|
||||||
|
"/integrations/clawdbot",
|
||||||
"/integrations/cline",
|
"/integrations/cline",
|
||||||
"/integrations/codex",
|
"/integrations/codex",
|
||||||
"/integrations/droid",
|
"/integrations/droid",
|
||||||
|
|||||||
48
docs/integrations/clawdbot.mdx
Normal file
48
docs/integrations/clawdbot.mdx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
title: Clawdbot
|
||||||
|
---
|
||||||
|
|
||||||
|
Clawdbot is a personal AI assistant that runs on your own devices. It bridges messaging services (WhatsApp, Telegram, Slack, Discord, iMessage, and more) to AI coding agents through a centralized gateway.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Install [Clawdbot](https://clawd.bot/)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g clawdbot@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run the onboarding wizard:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot onboard --install-daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
<Note>Clawdbot requires a larger context window. It is recommended to use a context window of at least 64k tokens. See [Context length](/context-length) for more information.</Note>
|
||||||
|
|
||||||
|
## Usage with Ollama
|
||||||
|
|
||||||
|
### Quick setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ollama launch clawdbot
|
||||||
|
```
|
||||||
|
|
||||||
|
This configures Clawdbot to use Ollama and starts the gateway.
|
||||||
|
If the gateway is already running, no changes need to be made as the gateway will auto-reload the changes.
|
||||||
|
|
||||||
|
|
||||||
|
To configure without launching:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ollama launch clawdbot --config
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommended Models
|
||||||
|
|
||||||
|
- `qwen3-coder`
|
||||||
|
- `glm-4.7`
|
||||||
|
- `gpt-oss:20b`
|
||||||
|
- `gpt-oss:120b`
|
||||||
|
|
||||||
|
Cloud models are also available at [ollama.com/search?c=cloud](https://ollama.com/search?c=cloud).
|
||||||
@@ -223,12 +223,7 @@ func New(c fs.Config) (model.Model, error) {
|
|||||||
|
|
||||||
keyLength := int(c.Uint("attention.key_length"))
|
keyLength := int(c.Uint("attention.key_length"))
|
||||||
valueLength := int(c.Uint("attention.value_length"))
|
valueLength := int(c.Uint("attention.value_length"))
|
||||||
kvLoraRank := int(c.Uint("attention.kv_lora_rank"))
|
kqScale := 1.0 / math.Sqrt(float64(keyLength))
|
||||||
qkRopeHeadDim := int(c.Uint("rope.dimension_count"))
|
|
||||||
|
|
||||||
// For MLA absorption, the effective key dimension is kvLoraRank + qkRopeHeadDim
|
|
||||||
mlaKeyLength := kvLoraRank + qkRopeHeadDim
|
|
||||||
kqScale := 1.0 / math.Sqrt(float64(mlaKeyLength))
|
|
||||||
|
|
||||||
var pre []string
|
var pre []string
|
||||||
switch c.String("tokenizer.ggml.pre") {
|
switch c.String("tokenizer.ggml.pre") {
|
||||||
@@ -246,7 +241,7 @@ func New(c fs.Config) (model.Model, error) {
|
|||||||
Values: c.Strings("tokenizer.ggml.tokens"),
|
Values: c.Strings("tokenizer.ggml.tokens"),
|
||||||
Types: c.Ints("tokenizer.ggml.token_type"),
|
Types: c.Ints("tokenizer.ggml.token_type"),
|
||||||
Merges: c.Strings("tokenizer.ggml.merges"),
|
Merges: c.Strings("tokenizer.ggml.merges"),
|
||||||
AddBOS: c.Bool("tokenizer.ggml.add_bos_token", true),
|
AddBOS: c.Bool("tokenizer.ggml.add_bos_token", false),
|
||||||
BOS: []int32{int32(c.Uint("tokenizer.ggml.bos_token_id"))},
|
BOS: []int32{int32(c.Uint("tokenizer.ggml.bos_token_id"))},
|
||||||
AddEOS: c.Bool("tokenizer.ggml.add_eos_token", false),
|
AddEOS: c.Bool("tokenizer.ggml.add_eos_token", false),
|
||||||
EOS: append(
|
EOS: append(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/ollama/ollama/api"
|
"github.com/ollama/ollama/api"
|
||||||
)
|
)
|
||||||
@@ -17,12 +18,34 @@ const (
|
|||||||
ministralCollectingToolArgs
|
ministralCollectingToolArgs
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ministralEvent represents an event emitted during parsing
|
||||||
|
type ministralEvent interface {
|
||||||
|
isMinistralEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
type ministralEventContent struct {
|
||||||
|
content string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ministralEventThinking struct {
|
||||||
|
thinking string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ministralEventToolCall struct {
|
||||||
|
name string
|
||||||
|
args string // raw JSON string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ministralEventContent) isMinistralEvent() {}
|
||||||
|
func (ministralEventThinking) isMinistralEvent() {}
|
||||||
|
func (ministralEventToolCall) isMinistralEvent() {}
|
||||||
|
|
||||||
type MinistralParser struct {
|
type MinistralParser struct {
|
||||||
state ministralParserState
|
state ministralParserState
|
||||||
buffer strings.Builder
|
buffer strings.Builder
|
||||||
tools []api.Tool
|
tools []api.Tool
|
||||||
hasThinkingSupport bool
|
hasThinkingSupport bool
|
||||||
currentTool *api.Tool
|
pendingToolName string // stores tool name while collecting args
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *MinistralParser) HasToolSupport() bool {
|
func (p *MinistralParser) HasToolSupport() bool {
|
||||||
@@ -63,74 +86,251 @@ func toolByName(tools []api.Tool, n string) (*api.Tool, error) {
|
|||||||
return nil, fmt.Errorf("tool '%s' not found", n)
|
return nil, fmt.Errorf("tool '%s' not found", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *MinistralParser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
|
const (
|
||||||
p.buffer.WriteString(s)
|
ministralToolCallsTag = "[TOOL_CALLS]"
|
||||||
|
ministralThinkTag = "[THINK]"
|
||||||
|
ministralThinkEndTag = "[/THINK]"
|
||||||
|
ministralArgsTag = "[ARGS]"
|
||||||
|
)
|
||||||
|
|
||||||
|
// eat consumes the parser's buffer, and returns a list of any unambiguous
|
||||||
|
// events from the current parser state. The second return value indicates
|
||||||
|
// whether to keep looping (true when state transitions, false when waiting
|
||||||
|
// for more data).
|
||||||
|
func (p *MinistralParser) eat() ([]ministralEvent, bool) {
|
||||||
|
var events []ministralEvent
|
||||||
|
|
||||||
switch p.state {
|
switch p.state {
|
||||||
case ministralCollectingContent:
|
case ministralCollectingContent:
|
||||||
if strings.Contains(p.buffer.String(), "[TOOL_CALLS]") {
|
bufStr := p.buffer.String()
|
||||||
before, _ := splitAtTag(&p.buffer, "[TOOL_CALLS]", false)
|
|
||||||
if before != "" {
|
// Check for [TOOL_CALLS] tag
|
||||||
return before, "", calls, nil
|
if strings.Contains(bufStr, ministralToolCallsTag) {
|
||||||
|
split := strings.SplitN(bufStr, ministralToolCallsTag, 2)
|
||||||
|
before := strings.TrimRightFunc(split[0], unicode.IsSpace)
|
||||||
|
if len(before) > 0 {
|
||||||
|
events = append(events, ministralEventContent{content: before})
|
||||||
}
|
}
|
||||||
|
after := split[1]
|
||||||
|
p.buffer.Reset()
|
||||||
|
p.buffer.WriteString(after)
|
||||||
p.state = ministralCollectingToolName
|
p.state = ministralCollectingToolName
|
||||||
} else if strings.Contains(p.buffer.String(), "[THINK]") {
|
return events, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for [THINK] tag
|
||||||
|
if strings.Contains(bufStr, ministralThinkTag) {
|
||||||
|
split := strings.SplitN(bufStr, ministralThinkTag, 2)
|
||||||
|
before := strings.TrimRightFunc(split[0], unicode.IsSpace)
|
||||||
|
if len(before) > 0 {
|
||||||
|
events = append(events, ministralEventContent{content: before})
|
||||||
|
}
|
||||||
|
after := split[1]
|
||||||
|
p.buffer.Reset()
|
||||||
|
p.buffer.WriteString(after)
|
||||||
p.state = ministralCollectingThinkingContent
|
p.state = ministralCollectingThinkingContent
|
||||||
return "", "", calls, nil
|
return events, true
|
||||||
} else {
|
|
||||||
p.buffer.Reset()
|
|
||||||
return s, "", calls, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for partial tag overlap with [TOOL_CALLS] or [THINK]
|
||||||
|
overlapToolCalls := overlap(bufStr, ministralToolCallsTag)
|
||||||
|
overlapThink := overlap(bufStr, ministralThinkTag)
|
||||||
|
maxOverlap := max(overlapToolCalls, overlapThink)
|
||||||
|
|
||||||
|
if maxOverlap > 0 {
|
||||||
|
// Withhold the potential partial tag
|
||||||
|
beforePartialTag := bufStr[:len(bufStr)-maxOverlap]
|
||||||
|
trailingWS := trailingWhitespaceLen(beforePartialTag)
|
||||||
|
ambiguousStart := len(beforePartialTag) - trailingWS
|
||||||
|
unambiguous := bufStr[:ambiguousStart]
|
||||||
|
ambiguous := bufStr[ambiguousStart:]
|
||||||
|
p.buffer.Reset()
|
||||||
|
p.buffer.WriteString(ambiguous)
|
||||||
|
if len(unambiguous) > 0 {
|
||||||
|
events = append(events, ministralEventContent{content: unambiguous})
|
||||||
|
}
|
||||||
|
return events, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// No tag found: emit content but withhold trailing whitespace
|
||||||
|
whitespaceLen := trailingWhitespaceLen(bufStr)
|
||||||
|
ambiguousStart := len(bufStr) - whitespaceLen
|
||||||
|
unambiguous := bufStr[:ambiguousStart]
|
||||||
|
ambiguous := bufStr[ambiguousStart:]
|
||||||
|
p.buffer.Reset()
|
||||||
|
p.buffer.WriteString(ambiguous)
|
||||||
|
if len(unambiguous) > 0 {
|
||||||
|
events = append(events, ministralEventContent{content: unambiguous})
|
||||||
|
}
|
||||||
|
return events, false
|
||||||
|
|
||||||
case ministralCollectingThinkingContent:
|
case ministralCollectingThinkingContent:
|
||||||
if strings.Contains(p.buffer.String(), "[/THINK]") {
|
bufStr := p.buffer.String()
|
||||||
thinkingContent, after := splitAtTag(&p.buffer, "[/THINK]", true)
|
|
||||||
p.state = ministralCollectingContent
|
if strings.Contains(bufStr, ministralThinkEndTag) {
|
||||||
if after != "" {
|
split := strings.SplitN(bufStr, ministralThinkEndTag, 2)
|
||||||
p.buffer.Reset()
|
thinkingContent := split[0]
|
||||||
return after, thinkingContent, calls, nil
|
after := strings.TrimLeftFunc(split[1], unicode.IsSpace)
|
||||||
}
|
|
||||||
return "", thinkingContent, calls, nil
|
|
||||||
} else {
|
|
||||||
p.buffer.Reset()
|
p.buffer.Reset()
|
||||||
return "", s, calls, nil
|
p.buffer.WriteString(after)
|
||||||
}
|
if len(thinkingContent) > 0 {
|
||||||
case ministralCollectingToolName:
|
events = append(events, ministralEventThinking{thinking: thinkingContent})
|
||||||
if strings.Contains(p.buffer.String(), "[ARGS]") {
|
|
||||||
name, _ := splitAtTag(&p.buffer, "[ARGS]", false)
|
|
||||||
|
|
||||||
t, err := toolByName(p.tools, name)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", calls, err
|
|
||||||
}
|
}
|
||||||
p.currentTool = t
|
|
||||||
p.state = ministralCollectingToolArgs
|
|
||||||
return "", "", calls, nil
|
|
||||||
}
|
|
||||||
return "", "", calls, nil
|
|
||||||
case ministralCollectingToolArgs:
|
|
||||||
if strings.Contains(p.buffer.String(), "}") {
|
|
||||||
before, _ := splitAtTag(&p.buffer, "}", false)
|
|
||||||
before += "}"
|
|
||||||
|
|
||||||
var args api.ToolCallFunctionArguments
|
|
||||||
if err := json.Unmarshal([]byte(before), &args); err != nil {
|
|
||||||
// todo - throw a better error
|
|
||||||
return "", "", calls, err
|
|
||||||
}
|
|
||||||
|
|
||||||
p.state = ministralCollectingContent
|
p.state = ministralCollectingContent
|
||||||
|
return events, true
|
||||||
|
}
|
||||||
|
|
||||||
call := api.ToolCall{
|
// Check for partial overlap with [/THINK]
|
||||||
|
if overlapLen := overlap(bufStr, ministralThinkEndTag); overlapLen > 0 {
|
||||||
|
unambiguous := bufStr[:len(bufStr)-overlapLen]
|
||||||
|
ambiguous := bufStr[len(bufStr)-overlapLen:]
|
||||||
|
p.buffer.Reset()
|
||||||
|
p.buffer.WriteString(ambiguous)
|
||||||
|
if len(unambiguous) > 0 {
|
||||||
|
events = append(events, ministralEventThinking{thinking: unambiguous})
|
||||||
|
}
|
||||||
|
return events, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// No tag found: emit all thinking content
|
||||||
|
p.buffer.Reset()
|
||||||
|
if len(bufStr) > 0 {
|
||||||
|
events = append(events, ministralEventThinking{thinking: bufStr})
|
||||||
|
}
|
||||||
|
return events, false
|
||||||
|
|
||||||
|
case ministralCollectingToolName:
|
||||||
|
bufStr := p.buffer.String()
|
||||||
|
|
||||||
|
if strings.Contains(bufStr, ministralArgsTag) {
|
||||||
|
split := strings.SplitN(bufStr, ministralArgsTag, 2)
|
||||||
|
toolName := split[0]
|
||||||
|
after := split[1]
|
||||||
|
p.pendingToolName = toolName
|
||||||
|
p.buffer.Reset()
|
||||||
|
p.buffer.WriteString(after)
|
||||||
|
p.state = ministralCollectingToolArgs
|
||||||
|
return events, true
|
||||||
|
}
|
||||||
|
// Wait for more data
|
||||||
|
return events, false
|
||||||
|
|
||||||
|
case ministralCollectingToolArgs:
|
||||||
|
bufStr := p.buffer.String()
|
||||||
|
jsonEnd := findJSONEnd(bufStr)
|
||||||
|
|
||||||
|
if jsonEnd != -1 {
|
||||||
|
jsonStr := bufStr[:jsonEnd+1]
|
||||||
|
remaining := bufStr[jsonEnd+1:]
|
||||||
|
|
||||||
|
events = append(events, ministralEventToolCall{
|
||||||
|
name: p.pendingToolName,
|
||||||
|
args: jsonStr,
|
||||||
|
})
|
||||||
|
|
||||||
|
p.pendingToolName = ""
|
||||||
|
p.buffer.Reset()
|
||||||
|
p.buffer.WriteString(remaining)
|
||||||
|
p.state = ministralCollectingContent
|
||||||
|
return events, true
|
||||||
|
}
|
||||||
|
// Wait for more data
|
||||||
|
return events, false
|
||||||
|
|
||||||
|
default:
|
||||||
|
panic("unexpected ministral event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseEvents loops calling eat() until it returns false
|
||||||
|
func (p *MinistralParser) parseEvents() []ministralEvent {
|
||||||
|
var all []ministralEvent
|
||||||
|
keepLooping := true
|
||||||
|
for keepLooping {
|
||||||
|
var events []ministralEvent
|
||||||
|
events, keepLooping = p.eat()
|
||||||
|
all = append(all, events...)
|
||||||
|
}
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MinistralParser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
|
||||||
|
p.buffer.WriteString(s)
|
||||||
|
|
||||||
|
events := p.parseEvents()
|
||||||
|
|
||||||
|
var contentBuilder, thinkingBuilder strings.Builder
|
||||||
|
var toolCalls []api.ToolCall
|
||||||
|
|
||||||
|
for _, event := range events {
|
||||||
|
switch e := event.(type) {
|
||||||
|
case ministralEventContent:
|
||||||
|
contentBuilder.WriteString(e.content)
|
||||||
|
case ministralEventThinking:
|
||||||
|
thinkingBuilder.WriteString(e.thinking)
|
||||||
|
case ministralEventToolCall:
|
||||||
|
// Validate tool exists
|
||||||
|
tool, toolErr := toolByName(p.tools, e.name)
|
||||||
|
if toolErr != nil {
|
||||||
|
return contentBuilder.String(), thinkingBuilder.String(), toolCalls, toolErr
|
||||||
|
}
|
||||||
|
// Parse JSON arguments
|
||||||
|
var args api.ToolCallFunctionArguments
|
||||||
|
if jsonErr := json.Unmarshal([]byte(e.args), &args); jsonErr != nil {
|
||||||
|
return contentBuilder.String(), thinkingBuilder.String(), toolCalls, jsonErr
|
||||||
|
}
|
||||||
|
toolCalls = append(toolCalls, api.ToolCall{
|
||||||
Function: api.ToolCallFunction{
|
Function: api.ToolCallFunction{
|
||||||
Name: p.currentTool.Function.Name,
|
Name: tool.Function.Name,
|
||||||
Arguments: args,
|
Arguments: args,
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
calls = append(calls, call)
|
|
||||||
return "", "", calls, nil
|
|
||||||
}
|
}
|
||||||
return "", "", calls, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.buffer.String(), thinking, calls, nil
|
return contentBuilder.String(), thinkingBuilder.String(), toolCalls, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findJSONEnd finds the index of the closing brace that completes a JSON object.
|
||||||
|
// It properly handles nested objects, arrays, and strings (including escaped characters).
|
||||||
|
// Returns -1 if the JSON is not yet complete.
|
||||||
|
func findJSONEnd(s string) int {
|
||||||
|
depth := 0
|
||||||
|
inString := false
|
||||||
|
escaped := false
|
||||||
|
|
||||||
|
for i, r := range s {
|
||||||
|
if inString {
|
||||||
|
switch {
|
||||||
|
case escaped:
|
||||||
|
// If the previous character was a backslash, skip this character
|
||||||
|
escaped = false
|
||||||
|
case r == '\\':
|
||||||
|
// Mark the next character as escaped
|
||||||
|
escaped = true
|
||||||
|
case r == '"':
|
||||||
|
// End of string literal
|
||||||
|
inString = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r {
|
||||||
|
case '"':
|
||||||
|
// Start of string literal
|
||||||
|
inString = true
|
||||||
|
case '{', '[':
|
||||||
|
// Increase nesting level for objects and arrays
|
||||||
|
depth++
|
||||||
|
case '}', ']':
|
||||||
|
// Decrease nesting level
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
// Reached the end of the root JSON structure
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1
|
||||||
}
|
}
|
||||||
|
|||||||
545
model/parsers/ministral_test.go
Normal file
545
model/parsers/ministral_test.go
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
package parsers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMinistralParserStreaming(t *testing.T) {
|
||||||
|
type step struct {
|
||||||
|
input string
|
||||||
|
wantEvents []ministralEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
desc string
|
||||||
|
tools []api.Tool
|
||||||
|
steps []step
|
||||||
|
think bool // whether to enable thinking support
|
||||||
|
}{
|
||||||
|
// Content streaming
|
||||||
|
{
|
||||||
|
desc: "simple content",
|
||||||
|
steps: []step{
|
||||||
|
{input: "Hello, how can I help you?", wantEvents: []ministralEvent{
|
||||||
|
ministralEventContent{content: "Hello, how can I help you?"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "streaming content word by word",
|
||||||
|
steps: []step{
|
||||||
|
{input: "Hello,", wantEvents: []ministralEvent{ministralEventContent{content: "Hello,"}}},
|
||||||
|
{input: " how", wantEvents: []ministralEvent{ministralEventContent{content: " how"}}},
|
||||||
|
{input: " can I help?", wantEvents: []ministralEvent{ministralEventContent{content: " can I help?"}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Simple tool calls
|
||||||
|
{
|
||||||
|
desc: "simple tool call",
|
||||||
|
tools: []api.Tool{{Function: api.ToolFunction{Name: "get_weather"}}},
|
||||||
|
steps: []step{
|
||||||
|
{input: `[TOOL_CALLS]get_weather[ARGS]{"location": "San Francisco"}`, wantEvents: []ministralEvent{
|
||||||
|
ministralEventToolCall{name: "get_weather", args: `{"location": "San Francisco"}`},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "tool call with nested object",
|
||||||
|
tools: []api.Tool{{Function: api.ToolFunction{Name: "create_entities"}}},
|
||||||
|
steps: []step{
|
||||||
|
{input: `[TOOL_CALLS]create_entities[ARGS]{"entities": [{"entityType": "Person", "name": "Jack", "observations": ["Works as a baker"]}]}`, wantEvents: []ministralEvent{
|
||||||
|
ministralEventToolCall{name: "create_entities", args: `{"entities": [{"entityType": "Person", "name": "Jack", "observations": ["Works as a baker"]}]}`},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "tool call with deeply nested objects",
|
||||||
|
tools: []api.Tool{{Function: api.ToolFunction{Name: "update_config"}}},
|
||||||
|
steps: []step{
|
||||||
|
{input: `[TOOL_CALLS]update_config[ARGS]{"settings": {"user": {"profile": {"name": "John", "age": 30}}, "theme": "dark"}}`, wantEvents: []ministralEvent{
|
||||||
|
ministralEventToolCall{name: "update_config", args: `{"settings": {"user": {"profile": {"name": "John", "age": 30}}, "theme": "dark"}}`},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "tool call with array of objects",
|
||||||
|
tools: []api.Tool{{Function: api.ToolFunction{Name: "process_items"}}},
|
||||||
|
steps: []step{
|
||||||
|
{input: `[TOOL_CALLS]process_items[ARGS]{"items": [{"id": 1}, {"id": 2}, {"id": 3}]}`, wantEvents: []ministralEvent{
|
||||||
|
ministralEventToolCall{name: "process_items", args: `{"items": [{"id": 1}, {"id": 2}, {"id": 3}]}`},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "tool call with escaped quotes in string",
|
||||||
|
tools: []api.Tool{{Function: api.ToolFunction{Name: "search"}}},
|
||||||
|
steps: []step{
|
||||||
|
{input: `[TOOL_CALLS]search[ARGS]{"query": "say \"hello\""}`, wantEvents: []ministralEvent{
|
||||||
|
ministralEventToolCall{name: "search", args: `{"query": "say \"hello\""}`},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "tool call with braces inside string",
|
||||||
|
tools: []api.Tool{{Function: api.ToolFunction{Name: "format"}}},
|
||||||
|
steps: []step{
|
||||||
|
{input: `[TOOL_CALLS]format[ARGS]{"template": "Hello {name}!"}`, wantEvents: []ministralEvent{
|
||||||
|
ministralEventToolCall{name: "format", args: `{"template": "Hello {name}!"}`},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "empty JSON object",
|
||||||
|
tools: []api.Tool{{Function: api.ToolFunction{Name: "no_args"}}},
|
||||||
|
steps: []step{
|
||||||
|
{input: `[TOOL_CALLS]no_args[ARGS]{}`, wantEvents: []ministralEvent{
|
||||||
|
ministralEventToolCall{name: "no_args", args: `{}`},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "JSON with newlines in string",
|
||||||
|
tools: []api.Tool{{Function: api.ToolFunction{Name: "write"}}},
|
||||||
|
steps: []step{
|
||||||
|
{input: `[TOOL_CALLS]write[ARGS]{"content": "line1\nline2\nline3"}`, wantEvents: []ministralEvent{
|
||||||
|
ministralEventToolCall{name: "write", args: `{"content": "line1\nline2\nline3"}`},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "backslash in string value",
|
||||||
|
tools: []api.Tool{{Function: api.ToolFunction{Name: "path"}}},
|
||||||
|
steps: []step{
|
||||||
|
{input: `[TOOL_CALLS]path[ARGS]{"dir": "C:\\Users\\test"}`, wantEvents: []ministralEvent{
|
||||||
|
ministralEventToolCall{name: "path", args: `{"dir": "C:\\Users\\test"}`},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Content after tool call
|
||||||
|
{
|
||||||
|
desc: "content after tool call",
|
||||||
|
tools: []api.Tool{{Function: api.ToolFunction{Name: "test"}}},
|
||||||
|
steps: []step{
|
||||||
|
// NOTE: It's unclear if this is valid Ministral output, but the parser
|
||||||
|
// currently treats text after a tool call as regular content. This test
|
||||||
|
// documents that behavior so we notice if it changes.
|
||||||
|
{input: `[TOOL_CALLS]test[ARGS]{"a": 1}some content after`, wantEvents: []ministralEvent{
|
||||||
|
ministralEventToolCall{name: "test", args: `{"a": 1}`},
|
||||||
|
ministralEventContent{content: "some content after"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Multiple tool calls
|
||||||
|
{
|
||||||
|
desc: "multiple tool calls in sequence",
|
||||||
|
tools: []api.Tool{
|
||||||
|
{Function: api.ToolFunction{Name: "get_weather"}},
|
||||||
|
{Function: api.ToolFunction{Name: "get_time"}},
|
||||||
|
},
|
||||||
|
steps: []step{
|
||||||
|
{input: `[TOOL_CALLS]get_weather[ARGS]{"location": "NYC"}[TOOL_CALLS]get_time[ARGS]{"timezone": "EST"}`, wantEvents: []ministralEvent{
|
||||||
|
ministralEventToolCall{name: "get_weather", args: `{"location": "NYC"}`},
|
||||||
|
ministralEventToolCall{name: "get_time", args: `{"timezone": "EST"}`},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "multiple tool calls streamed separately",
|
||||||
|
tools: []api.Tool{
|
||||||
|
{Function: api.ToolFunction{Name: "tool_a"}},
|
||||||
|
{Function: api.ToolFunction{Name: "tool_b"}},
|
||||||
|
},
|
||||||
|
steps: []step{
|
||||||
|
{input: `[TOOL_CALLS]tool_a[ARGS]{"x": 1}`, wantEvents: []ministralEvent{
|
||||||
|
ministralEventToolCall{name: "tool_a", args: `{"x": 1}`},
|
||||||
|
}},
|
||||||
|
{input: `[TOOL_CALLS]tool_b[ARGS]{"y": 2}`, wantEvents: []ministralEvent{
|
||||||
|
ministralEventToolCall{name: "tool_b", args: `{"y": 2}`},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Streaming tool calls
|
||||||
|
{
|
||||||
|
desc: "streaming tool call with nested objects",
|
||||||
|
tools: []api.Tool{{Function: api.ToolFunction{Name: "create_entities"}}},
|
||||||
|
steps: []step{
|
||||||
|
{input: "[TOOL_CALLS]create_entities[ARGS]", wantEvents: []ministralEvent{}},
|
||||||
|
{input: `{"entities": [{"entityType": "Person",`, wantEvents: []ministralEvent{}},
|
||||||
|
{input: ` "name": "Jack",`, wantEvents: []ministralEvent{}},
|
||||||
|
{input: ` "observations": ["Works`, wantEvents: []ministralEvent{}},
|
||||||
|
{input: ` as a baker"]}`, wantEvents: []ministralEvent{}},
|
||||||
|
{input: `]}`, wantEvents: []ministralEvent{
|
||||||
|
ministralEventToolCall{name: "create_entities", args: `{"entities": [{"entityType": "Person", "name": "Jack", "observations": ["Works as a baker"]}]}`},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "streaming with incomplete JSON waits for completion",
|
||||||
|
tools: []api.Tool{{Function: api.ToolFunction{Name: "test"}}},
|
||||||
|
steps: []step{
|
||||||
|
{input: "[TOOL_CALLS]test[ARGS]{", wantEvents: []ministralEvent{}},
|
||||||
|
{input: `"a": {`, wantEvents: []ministralEvent{}},
|
||||||
|
{input: `"b": 1`, wantEvents: []ministralEvent{}},
|
||||||
|
{input: `}`, wantEvents: []ministralEvent{}},
|
||||||
|
{input: `}`, wantEvents: []ministralEvent{
|
||||||
|
ministralEventToolCall{name: "test", args: `{"a": {"b": 1}}`},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Partial tag handling
|
||||||
|
{
|
||||||
|
desc: "partial tool tag fakeout",
|
||||||
|
steps: []step{
|
||||||
|
{input: "abc[TOOL", wantEvents: []ministralEvent{ministralEventContent{content: "abc"}}},
|
||||||
|
{input: " not a tag", wantEvents: []ministralEvent{ministralEventContent{content: "[TOOL not a tag"}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "tool call tag split across chunks",
|
||||||
|
tools: []api.Tool{{Function: api.ToolFunction{Name: "test"}}},
|
||||||
|
steps: []step{
|
||||||
|
{input: "[TOOL_", wantEvents: []ministralEvent{}},
|
||||||
|
{input: "CALLS]test[ARGS]{}", wantEvents: []ministralEvent{
|
||||||
|
ministralEventToolCall{name: "test", args: `{}`},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "content before tool call",
|
||||||
|
tools: []api.Tool{{Function: api.ToolFunction{Name: "get_weather"}}},
|
||||||
|
steps: []step{
|
||||||
|
{input: "hello [TOOL_CALLS]get_weather[ARGS]{}", wantEvents: []ministralEvent{
|
||||||
|
ministralEventContent{content: "hello"},
|
||||||
|
ministralEventToolCall{name: "get_weather", args: `{}`},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "whitespace between content and tool call is trimmed",
|
||||||
|
tools: []api.Tool{{Function: api.ToolFunction{Name: "test"}}},
|
||||||
|
steps: []step{
|
||||||
|
{input: "content \n [TOOL_CALLS]test[ARGS]{}", wantEvents: []ministralEvent{
|
||||||
|
ministralEventContent{content: "content"},
|
||||||
|
ministralEventToolCall{name: "test", args: `{}`},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "tabs and newlines before tool call are trimmed",
|
||||||
|
tools: []api.Tool{{Function: api.ToolFunction{Name: "test"}}},
|
||||||
|
steps: []step{
|
||||||
|
{input: "content\t\n\t[TOOL_CALLS]test[ARGS]{}", wantEvents: []ministralEvent{
|
||||||
|
ministralEventContent{content: "content"},
|
||||||
|
ministralEventToolCall{name: "test", args: `{}`},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "non-breaking space before tool call is trimmed",
|
||||||
|
tools: []api.Tool{{Function: api.ToolFunction{Name: "test"}}},
|
||||||
|
steps: []step{
|
||||||
|
// \u00a0 is non-breaking space, which unicode.IsSpace considers whitespace
|
||||||
|
{input: "content\u00a0[TOOL_CALLS]test[ARGS]{}", wantEvents: []ministralEvent{
|
||||||
|
ministralEventContent{content: "content"},
|
||||||
|
ministralEventToolCall{name: "test", args: `{}`},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "whitespace before THINK tag is trimmed",
|
||||||
|
steps: []step{
|
||||||
|
{input: "content \n [THINK]thinking[/THINK]after", wantEvents: []ministralEvent{
|
||||||
|
ministralEventContent{content: "content"},
|
||||||
|
ministralEventThinking{thinking: "thinking"},
|
||||||
|
ministralEventContent{content: "after"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "trailing whitespace withheld then emitted",
|
||||||
|
steps: []step{
|
||||||
|
{input: "Hello ", wantEvents: []ministralEvent{ministralEventContent{content: "Hello"}}},
|
||||||
|
{input: "world", wantEvents: []ministralEvent{ministralEventContent{content: " world"}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "trailing newline withheld then emitted",
|
||||||
|
steps: []step{
|
||||||
|
{input: "Hello\n", wantEvents: []ministralEvent{ministralEventContent{content: "Hello"}}},
|
||||||
|
{input: "world", wantEvents: []ministralEvent{ministralEventContent{content: "\nworld"}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Thinking support
|
||||||
|
{
|
||||||
|
desc: "thinking content",
|
||||||
|
think: true,
|
||||||
|
steps: []step{
|
||||||
|
{input: "thinking here[/THINK]", wantEvents: []ministralEvent{
|
||||||
|
ministralEventThinking{thinking: "thinking here"},
|
||||||
|
}},
|
||||||
|
{input: "content after", wantEvents: []ministralEvent{
|
||||||
|
ministralEventContent{content: "content after"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "thinking with whitespace after end tag",
|
||||||
|
think: true,
|
||||||
|
steps: []step{
|
||||||
|
{input: "my thoughts[/THINK] \n response", wantEvents: []ministralEvent{
|
||||||
|
ministralEventThinking{thinking: "my thoughts"},
|
||||||
|
ministralEventContent{content: "response"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "non-breaking space after think end tag is trimmed",
|
||||||
|
think: true,
|
||||||
|
steps: []step{
|
||||||
|
// \u00a0 is non-breaking space
|
||||||
|
{input: "thinking[/THINK]\u00a0response", wantEvents: []ministralEvent{
|
||||||
|
ministralEventThinking{thinking: "thinking"},
|
||||||
|
ministralEventContent{content: "response"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "partial think end tag",
|
||||||
|
think: true,
|
||||||
|
steps: []step{
|
||||||
|
{input: "thinking[/THI", wantEvents: []ministralEvent{ministralEventThinking{thinking: "thinking"}}},
|
||||||
|
{input: "NK]after", wantEvents: []ministralEvent{ministralEventContent{content: "after"}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "think tag fakeout",
|
||||||
|
think: true,
|
||||||
|
steps: []step{
|
||||||
|
{input: "thinking[/THI", wantEvents: []ministralEvent{ministralEventThinking{thinking: "thinking"}}},
|
||||||
|
{input: "not end tag", wantEvents: []ministralEvent{ministralEventThinking{thinking: "[/THInot end tag"}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "thinking then tool call",
|
||||||
|
think: true,
|
||||||
|
tools: []api.Tool{{Function: api.ToolFunction{Name: "test"}}},
|
||||||
|
steps: []step{
|
||||||
|
{input: "let me think[/THINK][TOOL_CALLS]test[ARGS]{}", wantEvents: []ministralEvent{
|
||||||
|
ministralEventThinking{thinking: "let me think"},
|
||||||
|
ministralEventToolCall{name: "test", args: `{}`},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Content then THINK tag transition
|
||||||
|
{
|
||||||
|
desc: "content then think tag",
|
||||||
|
steps: []step{
|
||||||
|
{input: "content[THINK]thinking[/THINK]more", wantEvents: []ministralEvent{
|
||||||
|
ministralEventContent{content: "content"},
|
||||||
|
ministralEventThinking{thinking: "thinking"},
|
||||||
|
ministralEventContent{content: "more"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Unicode handling
|
||||||
|
{
|
||||||
|
desc: "unicode content",
|
||||||
|
steps: []step{
|
||||||
|
{input: "你好 🌍 مرحبا", wantEvents: []ministralEvent{
|
||||||
|
ministralEventContent{content: "你好 🌍 مرحبا"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "unicode in tool args",
|
||||||
|
tools: []api.Tool{{Function: api.ToolFunction{Name: "greet"}}},
|
||||||
|
steps: []step{
|
||||||
|
{input: `[TOOL_CALLS]greet[ARGS]{"message": "你好 🌍"}`, wantEvents: []ministralEvent{
|
||||||
|
ministralEventToolCall{name: "greet", args: `{"message": "你好 🌍"}`},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.desc, func(t *testing.T) {
|
||||||
|
parser := MinistralParser{}
|
||||||
|
parser.hasThinkingSupport = tc.think
|
||||||
|
parser.Init(tc.tools, nil, nil)
|
||||||
|
|
||||||
|
for i, step := range tc.steps {
|
||||||
|
parser.buffer.WriteString(step.input)
|
||||||
|
gotEvents := parser.parseEvents()
|
||||||
|
|
||||||
|
if len(gotEvents) == 0 && len(step.wantEvents) == 0 {
|
||||||
|
// avoid deep equal on empty vs. nil slices
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(gotEvents, step.wantEvents) {
|
||||||
|
t.Errorf("step %d: input %q: got events %#v, want %#v", i, step.input, gotEvents, step.wantEvents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMinistralParser_Errors(t *testing.T) {
|
||||||
|
t.Run("unknown tool returns error", func(t *testing.T) {
|
||||||
|
p := &MinistralParser{}
|
||||||
|
p.Init([]api.Tool{{Function: api.ToolFunction{Name: "known_tool"}}}, nil, nil)
|
||||||
|
|
||||||
|
_, _, _, err := p.Add(`[TOOL_CALLS]unknown_tool[ARGS]{"a": 1}`, true)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unknown tool")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid JSON returns error", func(t *testing.T) {
|
||||||
|
p := &MinistralParser{}
|
||||||
|
p.Init([]api.Tool{{Function: api.ToolFunction{Name: "test"}}}, nil, nil)
|
||||||
|
|
||||||
|
_, _, _, err := p.Add(`[TOOL_CALLS]test[ARGS]{invalid json}`, true)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid JSON")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindJSONEnd(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple object",
|
||||||
|
input: `{"a": 1}`,
|
||||||
|
expected: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nested object",
|
||||||
|
input: `{"a": {"b": 2}}`,
|
||||||
|
expected: 14,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "array inside object",
|
||||||
|
input: `{"items": [1, 2, 3]}`,
|
||||||
|
expected: 19,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "braces in string",
|
||||||
|
input: `{"template": "Hello {name}!"}`,
|
||||||
|
expected: 28,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "escaped quotes",
|
||||||
|
input: `{"msg": "say \"hi\""}`,
|
||||||
|
expected: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "incomplete object",
|
||||||
|
input: `{"a": {"b": 1}`,
|
||||||
|
expected: -1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deeply nested",
|
||||||
|
input: `{"a": {"b": {"c": {"d": 1}}}}`,
|
||||||
|
expected: 28,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "object with trailing content",
|
||||||
|
input: `{"a": 1} extra`,
|
||||||
|
expected: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "array",
|
||||||
|
input: `[{"a": 1}, {"b": 2}]`,
|
||||||
|
expected: 19,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "escaped backslash before quote",
|
||||||
|
input: `{"path": "C:\\"}`,
|
||||||
|
expected: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
input: "",
|
||||||
|
expected: -1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no opening brace",
|
||||||
|
input: "hello world",
|
||||||
|
expected: -1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only opening brace",
|
||||||
|
input: "{",
|
||||||
|
expected: -1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unclosed string",
|
||||||
|
input: `{"key": "unclosed`,
|
||||||
|
expected: -1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double escaped backslash then quote",
|
||||||
|
input: `{"path": "C:\\\\"}`,
|
||||||
|
expected: 17,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unicode in key and value",
|
||||||
|
input: `{"키": "값"}`,
|
||||||
|
expected: 13,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nested arrays",
|
||||||
|
input: `{"matrix": [[1, 2], [3, 4]]}`,
|
||||||
|
expected: 27,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed nesting",
|
||||||
|
input: `{"a": [{"b": {"c": [1, 2, 3]}}]}`,
|
||||||
|
expected: 31,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := findJSONEnd(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("findJSONEnd(%q) = %d, want %d", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMinistralParser_HasToolSupport(t *testing.T) {
|
||||||
|
p := &MinistralParser{}
|
||||||
|
if !p.HasToolSupport() {
|
||||||
|
t.Error("expected HasToolSupport to return true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMinistralParser_HasThinkingSupport(t *testing.T) {
|
||||||
|
p := &MinistralParser{hasThinkingSupport: false}
|
||||||
|
if p.HasThinkingSupport() {
|
||||||
|
t.Error("expected HasThinkingSupport to return false")
|
||||||
|
}
|
||||||
|
|
||||||
|
p = &MinistralParser{hasThinkingSupport: true}
|
||||||
|
if !p.HasThinkingSupport() {
|
||||||
|
t.Error("expected HasThinkingSupport to return true")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package parsers
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/ollama/ollama/api"
|
"github.com/ollama/ollama/api"
|
||||||
"github.com/ollama/ollama/harmony"
|
"github.com/ollama/ollama/harmony"
|
||||||
@@ -114,3 +115,33 @@ func splitAtTag(sb *strings.Builder, tag string, trimAfter bool) (string, string
|
|||||||
sb.WriteString(after)
|
sb.WriteString(after)
|
||||||
return before, after // return events
|
return before, after // return events
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// overlap returns the longest overlap between the suffix of s and the prefix of delim
|
||||||
|
func overlap(s, delim string) int {
|
||||||
|
max := min(len(delim), len(s))
|
||||||
|
for i := max; i > 0; i-- {
|
||||||
|
if strings.HasSuffix(s, delim[:i]) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// trailingWhitespaceLen returns the length in bytes of trailing whitespace in s
|
||||||
|
func trailingWhitespaceLen(s string) int {
|
||||||
|
remaining := s
|
||||||
|
total := 0
|
||||||
|
for len(remaining) > 0 {
|
||||||
|
r, size := utf8.DecodeLastRuneInString(remaining)
|
||||||
|
// if it's an invalid utf8 rune, assume it isn't whitespace
|
||||||
|
if r == utf8.RuneError && size == 1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !unicode.IsSpace(r) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
total += size
|
||||||
|
remaining = remaining[:len(remaining)-size]
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/ollama/ollama/api"
|
"github.com/ollama/ollama/api"
|
||||||
"github.com/ollama/ollama/logutil"
|
"github.com/ollama/ollama/logutil"
|
||||||
@@ -194,36 +193,6 @@ func eat(p *Qwen3CoderParser) ([]qwenEvent, bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(drifkin): move this to a shared location
|
|
||||||
// longest overlap between suffix of s and prefix of delim
|
|
||||||
func overlap(s, delim string) int {
|
|
||||||
max := min(len(delim), len(s))
|
|
||||||
for i := max; i > 0; i-- {
|
|
||||||
if strings.HasSuffix(s, delim[:i]) {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func trailingWhitespaceLen(s string) int {
|
|
||||||
remaining := s
|
|
||||||
total := 0
|
|
||||||
for len(remaining) > 0 {
|
|
||||||
r, size := utf8.DecodeLastRuneInString(remaining)
|
|
||||||
// if it's an invalid utf8 rune, assume it isn't whitespace
|
|
||||||
if r == utf8.RuneError && size == 1 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if !unicode.IsSpace(r) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
total += size
|
|
||||||
remaining = remaining[:len(remaining)-size]
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
type XMLFunctionCall struct {
|
type XMLFunctionCall struct {
|
||||||
XMLName xml.Name `xml:"function"`
|
XMLName xml.Name `xml:"function"`
|
||||||
Name string `xml:"name,attr"`
|
Name string `xml:"name,attr"`
|
||||||
|
|||||||
@@ -95,6 +95,13 @@ func getTensorNewType(kv fsggml.KV, qs *quantizeState, newType fsggml.TensorType
|
|||||||
// for the 8-expert model, bumping this to Q8_0 trades just ~128MB
|
// for the 8-expert model, bumping this to Q8_0 trades just ~128MB
|
||||||
newType = fsggml.TensorTypeQ8_0
|
newType = fsggml.TensorTypeQ8_0
|
||||||
}
|
}
|
||||||
|
} else if strings.Contains(name, "attn_k_b.weight") ||
|
||||||
|
strings.Contains(name, "attn_v_b.weight") ||
|
||||||
|
strings.Contains(name, "attn_kv_a_mqa.weight") ||
|
||||||
|
strings.Contains(name, "attn_q_a.weight") ||
|
||||||
|
strings.Contains(name, "attn_q_b.weight") {
|
||||||
|
// MLA tensors need higher precision to avoid quality degradation
|
||||||
|
newType = fsggml.TensorTypeQ8_0
|
||||||
} else if strings.Contains(name, "ffn_down") {
|
} else if strings.Contains(name, "ffn_down") {
|
||||||
iLayer := qs.iFfnDown
|
iLayer := qs.iFfnDown
|
||||||
n_layer := qs.nFfnDown
|
n_layer := qs.nFfnDown
|
||||||
|
|||||||
Reference in New Issue
Block a user