mirror of
https://github.com/ollama/ollama.git
synced 2026-04-22 00:36:11 +02:00
Compare commits
4 Commits
pdevine/qw
...
v0.17.0-rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06edabdde1 | ||
|
|
8b4e5a82a8 | ||
|
|
3445223311 | ||
|
|
fa6c0127e6 |
@@ -41,6 +41,11 @@ type InferenceCompute struct {
|
|||||||
VRAM string
|
VRAM string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InferenceInfo struct {
|
||||||
|
Computes []InferenceCompute
|
||||||
|
DefaultContextLength int
|
||||||
|
}
|
||||||
|
|
||||||
func New(s *store.Store, devMode bool) *Server {
|
func New(s *store.Store, devMode bool) *Server {
|
||||||
p := resolvePath("ollama")
|
p := resolvePath("ollama")
|
||||||
return &Server{store: s, bin: p, dev: devMode}
|
return &Server{store: s, bin: p, dev: devMode}
|
||||||
@@ -272,9 +277,12 @@ func openRotatingLog() (io.WriteCloser, error) {
|
|||||||
|
|
||||||
// Attempt to retrieve inference compute information from the server
|
// Attempt to retrieve inference compute information from the server
|
||||||
// log. Set ctx to timeout to control how long to wait for the logs to appear
|
// log. Set ctx to timeout to control how long to wait for the logs to appear
|
||||||
func GetInferenceComputer(ctx context.Context) ([]InferenceCompute, error) {
|
func GetInferenceInfo(ctx context.Context) (*InferenceInfo, error) {
|
||||||
inference := []InferenceCompute{}
|
info := &InferenceInfo{}
|
||||||
marker := regexp.MustCompile(`inference compute.*library=`)
|
computeMarker := regexp.MustCompile(`inference compute.*library=`)
|
||||||
|
defaultCtxMarker := regexp.MustCompile(`vram-based default context`)
|
||||||
|
defaultCtxRegex := regexp.MustCompile(`default_num_ctx=(\d+)`)
|
||||||
|
|
||||||
q := `inference compute.*%s=["]([^"]*)["]`
|
q := `inference compute.*%s=["]([^"]*)["]`
|
||||||
nq := `inference compute.*%s=(\S+)\s`
|
nq := `inference compute.*%s=(\S+)\s`
|
||||||
type regex struct {
|
type regex struct {
|
||||||
@@ -340,8 +348,8 @@ func GetInferenceComputer(ctx context.Context) ([]InferenceCompute, error) {
|
|||||||
scanner := bufio.NewScanner(file)
|
scanner := bufio.NewScanner(file)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
match := marker.FindStringSubmatch(line)
|
// Check for inference compute lines
|
||||||
if len(match) > 0 {
|
if computeMarker.MatchString(line) {
|
||||||
ic := InferenceCompute{
|
ic := InferenceCompute{
|
||||||
Library: get("library", line),
|
Library: get("library", line),
|
||||||
Variant: get("variant", line),
|
Variant: get("variant", line),
|
||||||
@@ -352,12 +360,25 @@ func GetInferenceComputer(ctx context.Context) ([]InferenceCompute, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("Matched", "inference compute", ic)
|
slog.Info("Matched", "inference compute", ic)
|
||||||
inference = append(inference, ic)
|
info.Computes = append(info.Computes, ic)
|
||||||
} else {
|
continue
|
||||||
// Break out on first non matching line after we start matching
|
|
||||||
if len(inference) > 0 {
|
|
||||||
return inference, nil
|
|
||||||
}
|
}
|
||||||
|
// Check for default context length line
|
||||||
|
if defaultCtxMarker.MatchString(line) {
|
||||||
|
match := defaultCtxRegex.FindStringSubmatch(line)
|
||||||
|
if len(match) > 1 {
|
||||||
|
numCtx, err := strconv.Atoi(match[1])
|
||||||
|
if err == nil {
|
||||||
|
info.DefaultContextLength = numCtx
|
||||||
|
slog.Info("Matched default context length", "default_num_ctx", numCtx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
// If we've found compute info but hit a non-matching line, return what we have
|
||||||
|
// This handles older server versions that don't log the default context line
|
||||||
|
if len(info.Computes) > 0 {
|
||||||
|
return info, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|||||||
@@ -205,44 +205,50 @@ func TestServerCmdCloudSettingEnv(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetInferenceComputer(t *testing.T) {
|
func TestGetInferenceInfo(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
log string
|
log string
|
||||||
exp []InferenceCompute
|
expComputes []InferenceCompute
|
||||||
|
expDefaultCtxLen int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "metal",
|
name: "metal",
|
||||||
log: `time=2025-06-30T09:23:07.374-07:00 level=DEBUG source=sched.go:108 msg="starting llm scheduler"
|
log: `time=2025-06-30T09:23:07.374-07:00 level=DEBUG source=sched.go:108 msg="starting llm scheduler"
|
||||||
time=2025-06-30T09:23:07.416-07:00 level=INFO source=types.go:130 msg="inference compute" id=0 library=metal variant="" compute="" driver=0.0 name="" total="96.0 GiB" available="96.0 GiB"
|
time=2025-06-30T09:23:07.416-07:00 level=INFO source=types.go:130 msg="inference compute" id=0 library=metal variant="" compute="" driver=0.0 name="" total="96.0 GiB" available="96.0 GiB"
|
||||||
|
time=2025-06-30T09:23:07.417-07:00 level=INFO source=routes.go:1721 msg="vram-based default context" total_vram="96.0 GiB" default_num_ctx=262144
|
||||||
time=2025-06-30T09:25:56.197-07:00 level=DEBUG source=ggml.go:155 msg="key not found" key=general.alignment default=32
|
time=2025-06-30T09:25:56.197-07:00 level=DEBUG source=ggml.go:155 msg="key not found" key=general.alignment default=32
|
||||||
`,
|
`,
|
||||||
exp: []InferenceCompute{{
|
expComputes: []InferenceCompute{{
|
||||||
Library: "metal",
|
Library: "metal",
|
||||||
Driver: "0.0",
|
Driver: "0.0",
|
||||||
VRAM: "96.0 GiB",
|
VRAM: "96.0 GiB",
|
||||||
}},
|
}},
|
||||||
|
expDefaultCtxLen: 262144,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "cpu",
|
name: "cpu",
|
||||||
log: `time=2025-07-01T17:59:51.470Z level=INFO source=gpu.go:377 msg="no compatible GPUs were discovered"
|
log: `time=2025-07-01T17:59:51.470Z level=INFO source=gpu.go:377 msg="no compatible GPUs were discovered"
|
||||||
time=2025-07-01T17:59:51.470Z level=INFO source=types.go:130 msg="inference compute" id=0 library=cpu variant="" compute="" driver=0.0 name="" total="31.3 GiB" available="30.4 GiB"
|
time=2025-07-01T17:59:51.470Z level=INFO source=types.go:130 msg="inference compute" id=0 library=cpu variant="" compute="" driver=0.0 name="" total="31.3 GiB" available="30.4 GiB"
|
||||||
|
time=2025-07-01T17:59:51.471Z level=INFO source=routes.go:1721 msg="vram-based default context" total_vram="31.3 GiB" default_num_ctx=32768
|
||||||
[GIN] 2025/07/01 - 18:00:09 | 200 | 50.263µs | 100.126.204.152 | HEAD "/"
|
[GIN] 2025/07/01 - 18:00:09 | 200 | 50.263µs | 100.126.204.152 | HEAD "/"
|
||||||
`,
|
`,
|
||||||
exp: []InferenceCompute{{
|
expComputes: []InferenceCompute{{
|
||||||
Library: "cpu",
|
Library: "cpu",
|
||||||
Driver: "0.0",
|
Driver: "0.0",
|
||||||
VRAM: "31.3 GiB",
|
VRAM: "31.3 GiB",
|
||||||
}},
|
}},
|
||||||
|
expDefaultCtxLen: 32768,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "cuda1",
|
name: "cuda1",
|
||||||
log: `time=2025-07-01T19:33:43.162Z level=DEBUG source=amd_linux.go:419 msg="amdgpu driver not detected /sys/module/amdgpu"
|
log: `time=2025-07-01T19:33:43.162Z level=DEBUG source=amd_linux.go:419 msg="amdgpu driver not detected /sys/module/amdgpu"
|
||||||
releasing cuda driver library
|
releasing cuda driver library
|
||||||
time=2025-07-01T19:33:43.162Z level=INFO source=types.go:130 msg="inference compute" id=GPU-452cac9f-6960-839c-4fb3-0cec83699196 library=cuda variant=v12 compute=6.1 driver=12.7 name="NVIDIA GeForce GT 1030" total="3.9 GiB" available="3.9 GiB"
|
time=2025-07-01T19:33:43.162Z level=INFO source=types.go:130 msg="inference compute" id=GPU-452cac9f-6960-839c-4fb3-0cec83699196 library=cuda variant=v12 compute=6.1 driver=12.7 name="NVIDIA GeForce GT 1030" total="3.9 GiB" available="3.9 GiB"
|
||||||
|
time=2025-07-01T19:33:43.163Z level=INFO source=routes.go:1721 msg="vram-based default context" total_vram="3.9 GiB" default_num_ctx=4096
|
||||||
[GIN] 2025/07/01 - 18:00:09 | 200 | 50.263µs | 100.126.204.152 | HEAD "/"
|
[GIN] 2025/07/01 - 18:00:09 | 200 | 50.263µs | 100.126.204.152 | HEAD "/"
|
||||||
`,
|
`,
|
||||||
exp: []InferenceCompute{{
|
expComputes: []InferenceCompute{{
|
||||||
Library: "cuda",
|
Library: "cuda",
|
||||||
Variant: "v12",
|
Variant: "v12",
|
||||||
Compute: "6.1",
|
Compute: "6.1",
|
||||||
@@ -250,6 +256,7 @@ time=2025-07-01T19:33:43.162Z level=INFO source=types.go:130 msg="inference comp
|
|||||||
Name: "NVIDIA GeForce GT 1030",
|
Name: "NVIDIA GeForce GT 1030",
|
||||||
VRAM: "3.9 GiB",
|
VRAM: "3.9 GiB",
|
||||||
}},
|
}},
|
||||||
|
expDefaultCtxLen: 4096,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "frank",
|
name: "frank",
|
||||||
@@ -257,9 +264,10 @@ time=2025-07-01T19:33:43.162Z level=INFO source=types.go:130 msg="inference comp
|
|||||||
releasing cuda driver library
|
releasing cuda driver library
|
||||||
time=2025-07-01T19:36:13.315Z level=INFO source=types.go:130 msg="inference compute" id=GPU-d6de3398-9932-6902-11ec-fee8e424c8a2 library=cuda variant=v12 compute=7.5 driver=12.8 name="NVIDIA GeForce RTX 2080 Ti" total="10.6 GiB" available="10.4 GiB"
|
time=2025-07-01T19:36:13.315Z level=INFO source=types.go:130 msg="inference compute" id=GPU-d6de3398-9932-6902-11ec-fee8e424c8a2 library=cuda variant=v12 compute=7.5 driver=12.8 name="NVIDIA GeForce RTX 2080 Ti" total="10.6 GiB" available="10.4 GiB"
|
||||||
time=2025-07-01T19:36:13.315Z level=INFO source=types.go:130 msg="inference compute" id=GPU-9abb57639fa80c50 library=rocm variant="" compute=gfx1030 driver=6.3 name=1002:73bf total="16.0 GiB" available="1.3 GiB"
|
time=2025-07-01T19:36:13.315Z level=INFO source=types.go:130 msg="inference compute" id=GPU-9abb57639fa80c50 library=rocm variant="" compute=gfx1030 driver=6.3 name=1002:73bf total="16.0 GiB" available="1.3 GiB"
|
||||||
|
time=2025-07-01T19:36:13.316Z level=INFO source=routes.go:1721 msg="vram-based default context" total_vram="26.6 GiB" default_num_ctx=32768
|
||||||
[GIN] 2025/07/01 - 18:00:09 | 200 | 50.263µs | 100.126.204.152 | HEAD "/"
|
[GIN] 2025/07/01 - 18:00:09 | 200 | 50.263µs | 100.126.204.152 | HEAD "/"
|
||||||
`,
|
`,
|
||||||
exp: []InferenceCompute{
|
expComputes: []InferenceCompute{
|
||||||
{
|
{
|
||||||
Library: "cuda",
|
Library: "cuda",
|
||||||
Variant: "v12",
|
Variant: "v12",
|
||||||
@@ -276,6 +284,20 @@ time=2025-07-01T19:33:43.162Z level=INFO source=types.go:130 msg="inference comp
|
|||||||
VRAM: "16.0 GiB",
|
VRAM: "16.0 GiB",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
expDefaultCtxLen: 32768,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing_default_context",
|
||||||
|
log: `time=2025-06-30T09:23:07.374-07:00 level=DEBUG source=sched.go:108 msg="starting llm scheduler"
|
||||||
|
time=2025-06-30T09:23:07.416-07:00 level=INFO source=types.go:130 msg="inference compute" id=0 library=metal variant="" compute="" driver=0.0 name="" total="96.0 GiB" available="96.0 GiB"
|
||||||
|
time=2025-06-30T09:25:56.197-07:00 level=DEBUG source=ggml.go:155 msg="key not found" key=general.alignment default=32
|
||||||
|
`,
|
||||||
|
expComputes: []InferenceCompute{{
|
||||||
|
Library: "metal",
|
||||||
|
Driver: "0.0",
|
||||||
|
VRAM: "96.0 GiB",
|
||||||
|
}},
|
||||||
|
expDefaultCtxLen: 0, // No default context line, should return 0
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -288,18 +310,21 @@ time=2025-07-01T19:33:43.162Z level=INFO source=types.go:130 msg="inference comp
|
|||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond)
|
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
ics, err := GetInferenceComputer(ctx)
|
info, err := GetInferenceInfo(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf(" failed to get inference compute: %v", err)
|
t.Fatalf("failed to get inference info: %v", err)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(ics, tt.exp) {
|
if !reflect.DeepEqual(info.Computes, tt.expComputes) {
|
||||||
t.Fatalf("got:\n%#v\nwant:\n%#v", ics, tt.exp)
|
t.Fatalf("computes mismatch\ngot:\n%#v\nwant:\n%#v", info.Computes, tt.expComputes)
|
||||||
|
}
|
||||||
|
if info.DefaultContextLength != tt.expDefaultCtxLen {
|
||||||
|
t.Fatalf("default context length mismatch: got %d, want %d", info.DefaultContextLength, tt.expDefaultCtxLen)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetInferenceComputerTimeout(t *testing.T) {
|
func TestGetInferenceInfoTimeout(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond)
|
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
@@ -308,7 +333,7 @@ func TestGetInferenceComputerTimeout(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to write log file %s: %s", serverLogPath, err)
|
t.Fatalf("failed to write log file %s: %s", serverLogPath, err)
|
||||||
}
|
}
|
||||||
_, err = GetInferenceComputer(ctx)
|
_, err = GetInferenceInfo(ctx)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected timeout")
|
t.Fatal("expected timeout")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
|
|
||||||
// currentSchemaVersion defines the current database schema version.
|
// currentSchemaVersion defines the current database schema version.
|
||||||
// Increment this when making schema changes that require migrations.
|
// Increment this when making schema changes that require migrations.
|
||||||
const currentSchemaVersion = 13
|
const currentSchemaVersion = 14
|
||||||
|
|
||||||
// database wraps the SQLite connection.
|
// database wraps the SQLite connection.
|
||||||
// SQLite handles its own locking for concurrent access:
|
// SQLite handles its own locking for concurrent access:
|
||||||
@@ -73,7 +73,7 @@ func (db *database) init() error {
|
|||||||
agent BOOLEAN NOT NULL DEFAULT 0,
|
agent BOOLEAN NOT NULL DEFAULT 0,
|
||||||
tools BOOLEAN NOT NULL DEFAULT 0,
|
tools BOOLEAN NOT NULL DEFAULT 0,
|
||||||
working_dir TEXT NOT NULL DEFAULT '',
|
working_dir TEXT NOT NULL DEFAULT '',
|
||||||
context_length INTEGER NOT NULL DEFAULT 4096,
|
context_length INTEGER NOT NULL DEFAULT 0,
|
||||||
window_width INTEGER NOT NULL DEFAULT 0,
|
window_width INTEGER NOT NULL DEFAULT 0,
|
||||||
window_height INTEGER NOT NULL DEFAULT 0,
|
window_height INTEGER NOT NULL DEFAULT 0,
|
||||||
config_migrated BOOLEAN NOT NULL DEFAULT 0,
|
config_migrated BOOLEAN NOT NULL DEFAULT 0,
|
||||||
@@ -251,6 +251,12 @@ func (db *database) migrate() error {
|
|||||||
return fmt.Errorf("migrate v12 to v13: %w", err)
|
return fmt.Errorf("migrate v12 to v13: %w", err)
|
||||||
}
|
}
|
||||||
version = 13
|
version = 13
|
||||||
|
case 13:
|
||||||
|
// change default context_length from 4096 to 0 (VRAM-based tiered defaults)
|
||||||
|
if err := db.migrateV13ToV14(); err != nil {
|
||||||
|
return fmt.Errorf("migrate v13 to v14: %w", err)
|
||||||
|
}
|
||||||
|
version = 14
|
||||||
default:
|
default:
|
||||||
// If we have a version we don't recognize, just set it to current
|
// If we have a version we don't recognize, just set it to current
|
||||||
// This might happen during development
|
// This might happen during development
|
||||||
@@ -474,6 +480,22 @@ func (db *database) migrateV12ToV13() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// migrateV13ToV14 changes the default context_length from 4096 to 0.
|
||||||
|
// When context_length is 0, the ollama server uses VRAM-based tiered defaults.
|
||||||
|
func (db *database) migrateV13ToV14() error {
|
||||||
|
_, err := db.conn.Exec(`UPDATE settings SET context_length = 0 WHERE context_length = 4096`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update context_length default: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.conn.Exec(`UPDATE settings SET schema_version = 14`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update schema version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// cleanupOrphanedData removes orphaned records that may exist due to the foreign key bug
|
// cleanupOrphanedData removes orphaned records that may exist due to the foreign key bug
|
||||||
func (db *database) cleanupOrphanedData() error {
|
func (db *database) cleanupOrphanedData() error {
|
||||||
_, err := db.conn.Exec(`
|
_, err := db.conn.Exec(`
|
||||||
|
|||||||
@@ -98,6 +98,43 @@ func TestSchemaMigrations(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMigrationV13ToV14ContextLength(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(tmpDir, "test.db")
|
||||||
|
|
||||||
|
db, err := newDatabase(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create database: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
_, err = db.conn.Exec("UPDATE settings SET context_length = 4096, schema_version = 13")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to seed v13 settings row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.migrate(); err != nil {
|
||||||
|
t.Fatalf("migration from v13 to v14 failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var contextLength int
|
||||||
|
if err := db.conn.QueryRow("SELECT context_length FROM settings").Scan(&contextLength); err != nil {
|
||||||
|
t.Fatalf("failed to read context_length: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if contextLength != 0 {
|
||||||
|
t.Fatalf("expected context_length to migrate to 0, got %d", contextLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
version, err := db.getSchemaVersion()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get schema version: %v", err)
|
||||||
|
}
|
||||||
|
if version != currentSchemaVersion {
|
||||||
|
t.Fatalf("expected schema version %d, got %d", currentSchemaVersion, version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestChatDeletionWithCascade(t *testing.T) {
|
func TestChatDeletionWithCascade(t *testing.T) {
|
||||||
t.Run("chat deletion cascades to related messages", func(t *testing.T) {
|
t.Run("chat deletion cascades to related messages", func(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|||||||
2
app/store/testdata/schema.sql
vendored
2
app/store/testdata/schema.sql
vendored
@@ -13,7 +13,7 @@ CREATE TABLE IF NOT EXISTS settings (
|
|||||||
agent BOOLEAN NOT NULL DEFAULT 0,
|
agent BOOLEAN NOT NULL DEFAULT 0,
|
||||||
tools BOOLEAN NOT NULL DEFAULT 0,
|
tools BOOLEAN NOT NULL DEFAULT 0,
|
||||||
working_dir TEXT NOT NULL DEFAULT '',
|
working_dir TEXT NOT NULL DEFAULT '',
|
||||||
context_length INTEGER NOT NULL DEFAULT 4096,
|
context_length INTEGER NOT NULL DEFAULT 0,
|
||||||
window_width INTEGER NOT NULL DEFAULT 0,
|
window_width INTEGER NOT NULL DEFAULT 0,
|
||||||
window_height INTEGER NOT NULL DEFAULT 0,
|
window_height INTEGER NOT NULL DEFAULT 0,
|
||||||
config_migrated BOOLEAN NOT NULL DEFAULT 0,
|
config_migrated BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
|||||||
@@ -289,10 +289,12 @@ export class InferenceCompute {
|
|||||||
}
|
}
|
||||||
export class InferenceComputeResponse {
|
export class InferenceComputeResponse {
|
||||||
inferenceComputes: InferenceCompute[];
|
inferenceComputes: InferenceCompute[];
|
||||||
|
defaultContextLength: number;
|
||||||
|
|
||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.inferenceComputes = this.convertValues(source["inferenceComputes"], InferenceCompute);
|
this.inferenceComputes = this.convertValues(source["inferenceComputes"], InferenceCompute);
|
||||||
|
this.defaultContextLength = source["defaultContextLength"];
|
||||||
}
|
}
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
ChatEvent,
|
ChatEvent,
|
||||||
DownloadEvent,
|
DownloadEvent,
|
||||||
ErrorEvent,
|
ErrorEvent,
|
||||||
InferenceCompute,
|
|
||||||
InferenceComputeResponse,
|
InferenceComputeResponse,
|
||||||
ModelCapabilitiesResponse,
|
ModelCapabilitiesResponse,
|
||||||
Model,
|
Model,
|
||||||
@@ -407,7 +406,7 @@ export async function* pullModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getInferenceCompute(): Promise<InferenceCompute[]> {
|
export async function getInferenceCompute(): Promise<InferenceComputeResponse> {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/inference-compute`);
|
const response = await fetch(`${API_BASE}/api/v1/inference-compute`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -416,8 +415,7 @@ export async function getInferenceCompute(): Promise<InferenceCompute[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const inferenceComputeResponse = new InferenceComputeResponse(data);
|
return new InferenceComputeResponse(data);
|
||||||
return inferenceComputeResponse.inferenceComputes || [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchHealth(): Promise<boolean> {
|
export async function fetchHealth(): Promise<boolean> {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
type CloudStatusResponse,
|
type CloudStatusResponse,
|
||||||
updateCloudSetting,
|
updateCloudSetting,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
|
getInferenceCompute,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
|
||||||
function AnimatedDots() {
|
function AnimatedDots() {
|
||||||
@@ -77,6 +78,13 @@ export default function Settings() {
|
|||||||
|
|
||||||
const settings = settingsData?.settings || null;
|
const settings = settingsData?.settings || null;
|
||||||
|
|
||||||
|
const { data: inferenceComputeResponse } = useQuery({
|
||||||
|
queryKey: ["inferenceCompute"],
|
||||||
|
queryFn: getInferenceCompute,
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultContextLength = inferenceComputeResponse?.defaultContextLength;
|
||||||
|
|
||||||
const updateSettingsMutation = useMutation({
|
const updateSettingsMutation = useMutation({
|
||||||
mutationFn: updateSettings,
|
mutationFn: updateSettings,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -204,7 +212,7 @@ export default function Settings() {
|
|||||||
Models: "",
|
Models: "",
|
||||||
Agent: false,
|
Agent: false,
|
||||||
Tools: false,
|
Tools: false,
|
||||||
ContextLength: 4096,
|
ContextLength: 0,
|
||||||
});
|
});
|
||||||
updateSettingsMutation.mutate(defaultSettings);
|
updateSettingsMutation.mutate(defaultSettings);
|
||||||
}
|
}
|
||||||
@@ -507,13 +515,11 @@ export default function Settings() {
|
|||||||
</Description>
|
</Description>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<Slider
|
<Slider
|
||||||
value={(() => {
|
value={settings.ContextLength || defaultContextLength || 0}
|
||||||
// Otherwise use the settings value
|
|
||||||
return settings.ContextLength || 4096;
|
|
||||||
})()}
|
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
handleChange("ContextLength", value);
|
handleChange("ContextLength", value);
|
||||||
}}
|
}}
|
||||||
|
disabled={!defaultContextLength}
|
||||||
options={[
|
options={[
|
||||||
{ value: 4096, label: "4k" },
|
{ value: 4096, label: "4k" },
|
||||||
{ value: 8192, label: "8k" },
|
{ value: 8192, label: "8k" },
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ export interface SliderProps {
|
|||||||
value?: number;
|
value?: number;
|
||||||
onChange?: (value: number) => void;
|
onChange?: (value: number) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
|
const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
|
||||||
({ label, options, value = 0, onChange }, ref) => {
|
({ label, options, value = 0, onChange, disabled = false }, ref) => {
|
||||||
const [selectedValue, setSelectedValue] = React.useState(value);
|
const [selectedValue, setSelectedValue] = React.useState(value);
|
||||||
const [isDragging, setIsDragging] = React.useState(false);
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
@@ -20,6 +21,7 @@ const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
|
|||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
const handleClick = (optionValue: number) => {
|
const handleClick = (optionValue: number) => {
|
||||||
|
if (disabled) return;
|
||||||
setSelectedValue(optionValue);
|
setSelectedValue(optionValue);
|
||||||
onChange?.(optionValue);
|
onChange?.(optionValue);
|
||||||
};
|
};
|
||||||
@@ -39,6 +41,7 @@ const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseDown = (e: React.MouseEvent) => {
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
if (disabled) return;
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
};
|
};
|
||||||
@@ -77,7 +80,7 @@ const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2" ref={ref}>
|
<div className={`space-y-2 ${disabled ? "opacity-50" : ""}`} ref={ref}>
|
||||||
{label && <label className="text-sm font-medium">{label}</label>}
|
{label && <label className="text-sm font-medium">{label}</label>}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute top-[9px] left-2 right-2 h-1 bg-neutral-200 dark:bg-neutral-700 pointer-events-none rounded-full" />
|
<div className="absolute top-[9px] left-2 right-2 h-1 bg-neutral-200 dark:bg-neutral-700 pointer-events-none rounded-full" />
|
||||||
@@ -88,10 +91,11 @@ const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleClick(option.value)}
|
onClick={() => handleClick(option.value)}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
className="relative px-3 py-6 -mx-3 -my-6 z-10 cursor-pointer"
|
disabled={disabled}
|
||||||
|
className={`relative px-3 py-6 -mx-3 -my-6 z-10 ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
|
||||||
>
|
>
|
||||||
<div className="relative w-5 h-5 flex items-center justify-center">
|
<div className="relative w-5 h-5 flex items-center justify-center">
|
||||||
{selectedValue === option.value && (
|
{selectedValue === option.value && !disabled && (
|
||||||
<div className="w-4 h-4 bg-white dark:bg-white border border-neutral-400 dark:border-neutral-500 rounded-full cursor-grab active:cursor-grabbing" />
|
<div className="w-4 h-4 bg-white dark:bg-white border border-neutral-400 dark:border-neutral-500 rounded-full cursor-grab active:cursor-grabbing" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,12 +28,14 @@ export function useSelectedModel(currentChatId?: string, searchQuery?: string) {
|
|||||||
currentChatId && currentChatId !== "new" ? currentChatId : "",
|
currentChatId && currentChatId !== "new" ? currentChatId : "",
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: inferenceComputes = [] } = useQuery({
|
const { data: inferenceComputeResponse } = useQuery({
|
||||||
queryKey: ["inference-compute"],
|
queryKey: ["inferenceCompute"],
|
||||||
queryFn: getInferenceCompute,
|
queryFn: getInferenceCompute,
|
||||||
enabled: !settings.selectedModel, // Only fetch if no model is selected
|
enabled: !settings.selectedModel, // Only fetch if no model is selected
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const inferenceComputes = inferenceComputeResponse?.inferenceComputes || [];
|
||||||
|
|
||||||
const totalVRAM = useMemo(
|
const totalVRAM = useMemo(
|
||||||
() => getTotalVRAM(inferenceComputes),
|
() => getTotalVRAM(inferenceComputes),
|
||||||
[inferenceComputes],
|
[inferenceComputes],
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ type InferenceCompute struct {
|
|||||||
|
|
||||||
type InferenceComputeResponse struct {
|
type InferenceComputeResponse struct {
|
||||||
InferenceComputes []InferenceCompute `json:"inferenceComputes"`
|
InferenceComputes []InferenceCompute `json:"inferenceComputes"`
|
||||||
|
DefaultContextLength int `json:"defaultContextLength"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModelCapabilitiesResponse struct {
|
type ModelCapabilitiesResponse struct {
|
||||||
|
|||||||
16
app/ui/ui.go
16
app/ui/ui.go
@@ -1420,11 +1420,6 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) error {
|
|||||||
settings.Models = envconfig.Models()
|
settings.Models = envconfig.Models()
|
||||||
}
|
}
|
||||||
|
|
||||||
// set default context length if not set
|
|
||||||
if settings.ContextLength == 0 {
|
|
||||||
settings.ContextLength = 4096
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include current runtime settings
|
// Include current runtime settings
|
||||||
settings.Agent = s.Agent
|
settings.Agent = s.Agent
|
||||||
settings.Tools = s.Tools
|
settings.Tools = s.Tools
|
||||||
@@ -1500,14 +1495,14 @@ func (s *Server) writeCloudStatus(w http.ResponseWriter) error {
|
|||||||
func (s *Server) getInferenceCompute(w http.ResponseWriter, r *http.Request) error {
|
func (s *Server) getInferenceCompute(w http.ResponseWriter, r *http.Request) error {
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
|
ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
serverInferenceComputes, err := server.GetInferenceComputer(ctx)
|
info, err := server.GetInferenceInfo(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log().Error("failed to get inference compute", "error", err)
|
s.log().Error("failed to get inference info", "error", err)
|
||||||
return fmt.Errorf("failed to get inference compute: %w", err)
|
return fmt.Errorf("failed to get inference info: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
inferenceComputes := make([]responses.InferenceCompute, len(serverInferenceComputes))
|
inferenceComputes := make([]responses.InferenceCompute, len(info.Computes))
|
||||||
for i, ic := range serverInferenceComputes {
|
for i, ic := range info.Computes {
|
||||||
inferenceComputes[i] = responses.InferenceCompute{
|
inferenceComputes[i] = responses.InferenceCompute{
|
||||||
Library: ic.Library,
|
Library: ic.Library,
|
||||||
Variant: ic.Variant,
|
Variant: ic.Variant,
|
||||||
@@ -1520,6 +1515,7 @@ func (s *Server) getInferenceCompute(w http.ResponseWriter, r *http.Request) err
|
|||||||
|
|
||||||
response := responses.InferenceComputeResponse{
|
response := responses.InferenceComputeResponse{
|
||||||
InferenceComputes: inferenceComputes,
|
InferenceComputes: inferenceComputes,
|
||||||
|
DefaultContextLength: info.DefaultContextLength,
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -1956,6 +1956,10 @@ func runInteractiveTUI(cmd *cobra.Command) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
launchIntegration := func(name string) bool {
|
launchIntegration := func(name string) bool {
|
||||||
|
if err := config.EnsureInstalled(name); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
// If not configured or model no longer exists, prompt for model selection
|
// If not configured or model no longer exists, prompt for model selection
|
||||||
configuredModel := config.IntegrationModel(name)
|
configuredModel := config.IntegrationModel(name)
|
||||||
if configuredModel == "" || !config.ModelExists(cmd.Context(), configuredModel) || config.IsCloudModelDisabled(cmd.Context(), configuredModel) {
|
if configuredModel == "" || !config.ModelExists(cmd.Context(), configuredModel) || config.IsCloudModelDisabled(cmd.Context(), configuredModel) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
type integration struct {
|
type integration struct {
|
||||||
Models []string `json:"models"`
|
Models []string `json:"models"`
|
||||||
Aliases map[string]string `json:"aliases,omitempty"`
|
Aliases map[string]string `json:"aliases,omitempty"`
|
||||||
|
Onboarded bool `json:"onboarded,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
@@ -139,34 +140,54 @@ func SaveIntegration(appName string, models []string) error {
|
|||||||
key := strings.ToLower(appName)
|
key := strings.ToLower(appName)
|
||||||
existing := cfg.Integrations[key]
|
existing := cfg.Integrations[key]
|
||||||
var aliases map[string]string
|
var aliases map[string]string
|
||||||
if existing != nil && existing.Aliases != nil {
|
var onboarded bool
|
||||||
|
if existing != nil {
|
||||||
aliases = existing.Aliases
|
aliases = existing.Aliases
|
||||||
|
onboarded = existing.Onboarded
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.Integrations[key] = &integration{
|
cfg.Integrations[key] = &integration{
|
||||||
Models: models,
|
Models: models,
|
||||||
Aliases: aliases,
|
Aliases: aliases,
|
||||||
|
Onboarded: onboarded,
|
||||||
}
|
}
|
||||||
|
|
||||||
return save(cfg)
|
return save(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// integrationOnboarded marks an integration as onboarded in ollama's config.
|
||||||
|
func integrationOnboarded(appName string) error {
|
||||||
|
cfg, err := load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
key := strings.ToLower(appName)
|
||||||
|
existing := cfg.Integrations[key]
|
||||||
|
if existing == nil {
|
||||||
|
existing = &integration{}
|
||||||
|
}
|
||||||
|
existing.Onboarded = true
|
||||||
|
cfg.Integrations[key] = existing
|
||||||
|
return save(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
// IntegrationModel returns the first configured model for an integration, or empty string if not configured.
|
// IntegrationModel returns the first configured model for an integration, or empty string if not configured.
|
||||||
func IntegrationModel(appName string) string {
|
func IntegrationModel(appName string) string {
|
||||||
ic, err := loadIntegration(appName)
|
integrationConfig, err := loadIntegration(appName)
|
||||||
if err != nil || len(ic.Models) == 0 {
|
if err != nil || len(integrationConfig.Models) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return ic.Models[0]
|
return integrationConfig.Models[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// IntegrationModels returns all configured models for an integration, or nil.
|
// IntegrationModels returns all configured models for an integration, or nil.
|
||||||
func IntegrationModels(appName string) []string {
|
func IntegrationModels(appName string) []string {
|
||||||
ic, err := loadIntegration(appName)
|
integrationConfig, err := loadIntegration(appName)
|
||||||
if err != nil || len(ic.Models) == 0 {
|
if err != nil || len(integrationConfig.Models) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return ic.Models
|
return integrationConfig.Models
|
||||||
}
|
}
|
||||||
|
|
||||||
// LastModel returns the last model that was run, or empty string if none.
|
// LastModel returns the last model that was run, or empty string if none.
|
||||||
@@ -234,12 +255,12 @@ func loadIntegration(appName string) (*integration, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
ic, ok := cfg.Integrations[strings.ToLower(appName)]
|
integrationConfig, ok := cfg.Integrations[strings.ToLower(appName)]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, os.ErrNotExist
|
return nil, os.ErrNotExist
|
||||||
}
|
}
|
||||||
|
|
||||||
return ic, nil
|
return integrationConfig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveAliases(appName string, aliases map[string]string) error {
|
func saveAliases(appName string, aliases map[string]string) error {
|
||||||
@@ -272,8 +293,8 @@ func listIntegrations() ([]integration, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result := make([]integration, 0, len(cfg.Integrations))
|
result := make([]integration, 0, len(cfg.Integrations))
|
||||||
for _, ic := range cfg.Integrations {
|
for _, integrationConfig := range cfg.Integrations {
|
||||||
result = append(result, *ic)
|
result = append(result, *integrationConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|||||||
@@ -228,6 +228,31 @@ func IsIntegrationInstalled(name string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AutoInstallable returns true if the integration can be automatically
|
||||||
|
// installed when not found (e.g. via npm).
|
||||||
|
func AutoInstallable(name string) bool {
|
||||||
|
switch strings.ToLower(name) {
|
||||||
|
case "openclaw", "clawdbot", "moltbot":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureInstalled checks if an auto-installable integration is present and
|
||||||
|
// offers to install it if missing. Returns nil for non-auto-installable
|
||||||
|
// integrations or when the binary is already on PATH.
|
||||||
|
func EnsureInstalled(name string) error {
|
||||||
|
if !AutoInstallable(name) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if IsIntegrationInstalled(name) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := ensureOpenclawInstalled()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// IsEditorIntegration returns true if the named integration uses multi-model
|
// IsEditorIntegration returns true if the named integration uses multi-model
|
||||||
// selection (implements the Editor interface).
|
// selection (implements the Editor interface).
|
||||||
func IsEditorIntegration(name string) bool {
|
func IsEditorIntegration(name string) bool {
|
||||||
@@ -926,6 +951,10 @@ Examples:
|
|||||||
return fmt.Errorf("unknown integration: %s", name)
|
return fmt.Errorf("unknown integration: %s", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := EnsureInstalled(name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if modelFlag != "" && IsCloudModelDisabled(cmd.Context(), modelFlag) {
|
if modelFlag != "" && IsCloudModelDisabled(cmd.Context(), modelFlag) {
|
||||||
modelFlag = ""
|
modelFlag = ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +1,287 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"net"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/api"
|
||||||
"github.com/ollama/ollama/envconfig"
|
"github.com/ollama/ollama/envconfig"
|
||||||
|
"github.com/ollama/ollama/types/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const defaultGatewayPort = 18789
|
||||||
|
|
||||||
|
// Bound model capability probing so launch/config cannot hang on slow/unreachable API calls.
|
||||||
|
var openclawModelShowTimeout = 5 * time.Second
|
||||||
|
|
||||||
type Openclaw struct{}
|
type Openclaw struct{}
|
||||||
|
|
||||||
func (c *Openclaw) String() string { return "OpenClaw" }
|
func (c *Openclaw) String() string { return "OpenClaw" }
|
||||||
|
|
||||||
func (c *Openclaw) Run(model string, args []string) error {
|
func (c *Openclaw) Run(model string, args []string) error {
|
||||||
bin := "openclaw"
|
bin, err := ensureOpenclawInstalled()
|
||||||
if _, err := exec.LookPath(bin); err != nil {
|
|
||||||
bin = "clawdbot"
|
|
||||||
if _, err := exec.LookPath(bin); err != nil {
|
|
||||||
return fmt.Errorf("openclaw is not installed, install from https://docs.openclaw.ai")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
models := []string{model}
|
|
||||||
if config, err := loadIntegration("openclaw"); err == nil && len(config.Models) > 0 {
|
|
||||||
models = config.Models
|
|
||||||
} else if config, err := loadIntegration("clawdbot"); err == nil && len(config.Models) > 0 {
|
|
||||||
models = config.Models
|
|
||||||
}
|
|
||||||
var err error
|
|
||||||
models, err = resolveEditorModels("openclaw", models, func() ([]string, error) {
|
|
||||||
return selectModels(context.Background(), "openclaw", "")
|
|
||||||
})
|
|
||||||
if errors.Is(err, errCancelled) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := c.Edit(models); err != nil {
|
|
||||||
return fmt.Errorf("setup failed: %w", err)
|
firstLaunch := true
|
||||||
|
if integrationConfig, err := loadIntegration("openclaw"); err == nil {
|
||||||
|
firstLaunch = !integrationConfig.Onboarded
|
||||||
|
}
|
||||||
|
|
||||||
|
if firstLaunch {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n%sSecurity%s\n\n", ansiBold, ansiReset)
|
||||||
|
fmt.Fprintf(os.Stderr, " OpenClaw can read files and run actions when tools are enabled.\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " A bad prompt can trick it into doing unsafe things.\n\n")
|
||||||
|
fmt.Fprintf(os.Stderr, "%s Learn more: https://docs.openclaw.ai/gateway/security%s\n\n", ansiGray, ansiReset)
|
||||||
|
|
||||||
|
ok, err := confirmPrompt("I understand the risks. Continue?")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !c.onboarded() {
|
if !c.onboarded() {
|
||||||
// Onboarding not completed: run it (model already set via Edit)
|
fmt.Fprintf(os.Stderr, "\n%sSetting up OpenClaw with Ollama...%s\n", ansiGreen, ansiReset)
|
||||||
// Use "ollama" as gateway token for simple local access
|
fmt.Fprintf(os.Stderr, "%s Model: %s%s\n\n", ansiGray, model, ansiReset)
|
||||||
|
|
||||||
cmd := exec.Command(bin, "onboard",
|
cmd := exec.Command(bin, "onboard",
|
||||||
|
"--non-interactive",
|
||||||
|
"--accept-risk",
|
||||||
"--auth-choice", "skip",
|
"--auth-choice", "skip",
|
||||||
"--gateway-token", "ollama",
|
"--gateway-token", "ollama",
|
||||||
|
"--install-daemon",
|
||||||
|
"--skip-channels",
|
||||||
|
"--skip-skills",
|
||||||
)
|
)
|
||||||
cmd.Stdin = os.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
return cmd.Run()
|
if err := cmd.Run(); err != nil {
|
||||||
|
return windowsHint(fmt.Errorf("openclaw onboarding failed: %w\n\nTry running: openclaw onboard", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Onboarding completed: run gateway
|
patchDeviceScopes()
|
||||||
cmd := exec.Command(bin, append([]string{"gateway"}, args...)...)
|
|
||||||
|
// Onboarding overwrites openclaw.json, so re-apply the model config
|
||||||
|
// that Edit() wrote before Run() was called.
|
||||||
|
if err := c.Edit([]string{model}); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s Warning: could not re-apply model config: %v%s\n", ansiYellow, err, ansiReset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(model, ":cloud") || strings.HasSuffix(model, "-cloud") {
|
||||||
|
if ensureWebSearchPlugin() {
|
||||||
|
registerWebSearchPlugin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if firstLaunch {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n%sPreparing your assistant — this may take a moment...%s\n\n", ansiGray, ansiReset)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n%sStarting your assistant — this may take a moment...%s\n\n", ansiGray, ansiReset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When extra args are passed through, run exactly what the user asked for
|
||||||
|
// after setup and skip the built-in gateway+TUI convenience flow.
|
||||||
|
if len(args) > 0 {
|
||||||
|
cmd := exec.Command(bin, args...)
|
||||||
|
cmd.Env = openclawEnv()
|
||||||
cmd.Stdin = os.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
// Capture output to detect "already running" message
|
cmd.Stderr = os.Stderr
|
||||||
var outputBuf bytes.Buffer
|
if err := cmd.Run(); err != nil {
|
||||||
cmd.Stdout = io.MultiWriter(os.Stdout, &outputBuf)
|
return windowsHint(err)
|
||||||
cmd.Stderr = io.MultiWriter(os.Stderr, &outputBuf)
|
}
|
||||||
|
if firstLaunch {
|
||||||
err = cmd.Run()
|
if err := integrationOnboarded("openclaw"); err != nil {
|
||||||
if err != nil && strings.Contains(outputBuf.String(), "Gateway already running") {
|
return fmt.Errorf("failed to save onboarding state: %w", err)
|
||||||
fmt.Fprintf(os.Stderr, "%sOpenClaw has been configured with Ollama. Gateway is already running.%s\n", ansiGreen, ansiReset)
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
token, port := c.gatewayInfo()
|
||||||
|
addr := fmt.Sprintf("localhost:%d", port)
|
||||||
|
|
||||||
|
// If the gateway is already running (e.g. via the daemon), restart it
|
||||||
|
// so it picks up any config changes from Edit() above (model, provider, etc.).
|
||||||
|
if portOpen(addr) {
|
||||||
|
restart := exec.Command(bin, "daemon", "restart")
|
||||||
|
restart.Env = openclawEnv()
|
||||||
|
if err := restart.Run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s Warning: daemon restart failed: %v%s\n", ansiYellow, err, ansiReset)
|
||||||
|
}
|
||||||
|
if !waitForPort(addr, 10*time.Second) {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s Warning: gateway did not come back after restart%s\n", ansiYellow, ansiReset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the gateway isn't running, start it as a background child process.
|
||||||
|
if !portOpen(addr) {
|
||||||
|
gw := exec.Command(bin, "gateway", "run", "--force")
|
||||||
|
gw.Env = openclawEnv()
|
||||||
|
if err := gw.Start(); err != nil {
|
||||||
|
return windowsHint(fmt.Errorf("failed to start gateway: %w", err))
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if gw.Process != nil {
|
||||||
|
_ = gw.Process.Kill()
|
||||||
|
_ = gw.Wait()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "%sStarting gateway...%s\n", ansiGray, ansiReset)
|
||||||
|
if !waitForPort(addr, 30*time.Second) {
|
||||||
|
return windowsHint(fmt.Errorf("gateway did not start on %s", addr))
|
||||||
|
}
|
||||||
|
|
||||||
|
printOpenclawReady(bin, token, port, firstLaunch)
|
||||||
|
|
||||||
|
tuiArgs := []string{"tui"}
|
||||||
|
if firstLaunch {
|
||||||
|
tuiArgs = append(tuiArgs, "--message", "Wake up, my friend!")
|
||||||
|
}
|
||||||
|
tui := exec.Command(bin, tuiArgs...)
|
||||||
|
tui.Env = openclawEnv()
|
||||||
|
tui.Stdin = os.Stdin
|
||||||
|
tui.Stdout = os.Stdout
|
||||||
|
tui.Stderr = os.Stderr
|
||||||
|
if err := tui.Run(); err != nil {
|
||||||
|
return windowsHint(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if firstLaunch {
|
||||||
|
if err := integrationOnboarded("openclaw"); err != nil {
|
||||||
|
return fmt.Errorf("failed to save onboarding state: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// gatewayInfo reads the gateway auth token and port from the OpenClaw config.
|
||||||
|
func (c *Openclaw) gatewayInfo() (token string, port int) {
|
||||||
|
port = defaultGatewayPort
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", port
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range []string{
|
||||||
|
filepath.Join(home, ".openclaw", "openclaw.json"),
|
||||||
|
filepath.Join(home, ".clawdbot", "clawdbot.json"),
|
||||||
|
} {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var config map[string]any
|
||||||
|
if json.Unmarshal(data, &config) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
gw, _ := config["gateway"].(map[string]any)
|
||||||
|
if p, ok := gw["port"].(float64); ok && p > 0 {
|
||||||
|
port = int(p)
|
||||||
|
}
|
||||||
|
auth, _ := gw["auth"].(map[string]any)
|
||||||
|
if t, _ := auth["token"].(string); t != "" {
|
||||||
|
token = t
|
||||||
|
}
|
||||||
|
return token, port
|
||||||
|
}
|
||||||
|
return "", port
|
||||||
|
}
|
||||||
|
|
||||||
|
func printOpenclawReady(bin, token string, port int, firstLaunch bool) {
|
||||||
|
u := fmt.Sprintf("http://localhost:%d", port)
|
||||||
|
if token != "" {
|
||||||
|
u += "/#token=" + url.QueryEscape(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "\n%s✓ OpenClaw is running%s\n\n", ansiGreen, ansiReset)
|
||||||
|
fmt.Fprintf(os.Stderr, " Open the Web UI:\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " %s\n\n", hyperlink(u, u))
|
||||||
|
|
||||||
|
if firstLaunch {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s Quick start:%s\n", ansiBold, ansiReset)
|
||||||
|
fmt.Fprintf(os.Stderr, "%s /help see all commands%s\n", ansiGray, ansiReset)
|
||||||
|
fmt.Fprintf(os.Stderr, "%s %s configure --section channels connect WhatsApp, Telegram, etc.%s\n", ansiGray, bin, ansiReset)
|
||||||
|
fmt.Fprintf(os.Stderr, "%s %s skills browse and install skills%s\n\n", ansiGray, bin, ansiReset)
|
||||||
|
fmt.Fprintf(os.Stderr, "%s The OpenClaw gateway is running in the background.%s\n", ansiYellow, ansiReset)
|
||||||
|
fmt.Fprintf(os.Stderr, "%s Stop it with: %s gateway stop%s\n\n", ansiYellow, bin, ansiReset)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "%sTip: connect WhatsApp, Telegram, and more with: %s configure --section channels%s\n", ansiGray, bin, ansiReset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openclawEnv returns the current environment with provider API keys cleared
|
||||||
|
// so openclaw only uses the Ollama gateway, not keys from the user's shell.
|
||||||
|
func openclawEnv() []string {
|
||||||
|
clear := map[string]bool{
|
||||||
|
"ANTHROPIC_API_KEY": true,
|
||||||
|
"ANTHROPIC_OAUTH_TOKEN": true,
|
||||||
|
"OPENAI_API_KEY": true,
|
||||||
|
"GEMINI_API_KEY": true,
|
||||||
|
"MISTRAL_API_KEY": true,
|
||||||
|
"GROQ_API_KEY": true,
|
||||||
|
"XAI_API_KEY": true,
|
||||||
|
"OPENROUTER_API_KEY": true,
|
||||||
|
}
|
||||||
|
var env []string
|
||||||
|
for _, e := range os.Environ() {
|
||||||
|
key, _, _ := strings.Cut(e, "=")
|
||||||
|
if !clear[key] {
|
||||||
|
env = append(env, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
// portOpen checks if a TCP port is currently accepting connections.
|
||||||
|
func portOpen(addr string) bool {
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForPort(addr string, timeout time.Duration) bool {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
|
||||||
|
if err == nil {
|
||||||
|
conn.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowsHint(err error) error {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w\n\n"+
|
||||||
|
"OpenClaw runs best on WSL2.\n"+
|
||||||
|
"Quick setup: wsl --install\n"+
|
||||||
|
"Guide: https://docs.openclaw.ai/windows", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// onboarded checks if OpenClaw onboarding wizard was completed
|
// onboarded checks if OpenClaw onboarding wizard was completed
|
||||||
@@ -107,6 +313,144 @@ func (c *Openclaw) onboarded() bool {
|
|||||||
return lastRunAt != ""
|
return lastRunAt != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// patchDeviceScopes upgrades the local CLI device's paired scopes to include
|
||||||
|
// operator.admin. Only patches the local device, not remote ones.
|
||||||
|
// Best-effort: silently returns on any error.
|
||||||
|
func patchDeviceScopes() {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceID := readLocalDeviceID(home)
|
||||||
|
if deviceID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(home, ".openclaw", "devices", "paired.json")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var devices map[string]map[string]any
|
||||||
|
if err := json.Unmarshal(data, &devices); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dev, ok := devices[deviceID]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
required := []string{
|
||||||
|
"operator.read",
|
||||||
|
"operator.admin",
|
||||||
|
"operator.approvals",
|
||||||
|
"operator.pairing",
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := patchScopes(dev, "scopes", required)
|
||||||
|
if tokens, ok := dev["tokens"].(map[string]any); ok {
|
||||||
|
for _, tok := range tokens {
|
||||||
|
if tokenMap, ok := tok.(map[string]any); ok {
|
||||||
|
if patchScopes(tokenMap, "scopes", required) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !changed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := json.MarshalIndent(devices, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(path, out, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLocalDeviceID reads the local device ID from openclaw's identity file.
|
||||||
|
func readLocalDeviceID(home string) string {
|
||||||
|
data, err := os.ReadFile(filepath.Join(home, ".openclaw", "identity", "device-auth.json"))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var auth map[string]any
|
||||||
|
if err := json.Unmarshal(data, &auth); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
id, _ := auth["deviceId"].(string)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchScopes ensures obj[key] contains all required scopes. Returns true if
|
||||||
|
// any scopes were added.
|
||||||
|
func patchScopes(obj map[string]any, key string, required []string) bool {
|
||||||
|
existing, _ := obj[key].([]any)
|
||||||
|
have := make(map[string]bool, len(existing))
|
||||||
|
for _, s := range existing {
|
||||||
|
if str, ok := s.(string); ok {
|
||||||
|
have[str] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
added := false
|
||||||
|
for _, s := range required {
|
||||||
|
if !have[s] {
|
||||||
|
existing = append(existing, s)
|
||||||
|
added = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if added {
|
||||||
|
obj[key] = existing
|
||||||
|
}
|
||||||
|
return added
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureOpenclawInstalled() (string, error) {
|
||||||
|
if _, err := exec.LookPath("openclaw"); err == nil {
|
||||||
|
return "openclaw", nil
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("clawdbot"); err == nil {
|
||||||
|
return "clawdbot", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := exec.LookPath("npm"); err != nil {
|
||||||
|
return "", fmt.Errorf("openclaw is not installed and npm was not found\n\n" +
|
||||||
|
"Install Node.js first:\n" +
|
||||||
|
" https://nodejs.org/\n\n" +
|
||||||
|
"Then rerun:\n" +
|
||||||
|
" ollama launch\n" +
|
||||||
|
"and select OpenClaw")
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := confirmPrompt("OpenClaw is not installed. Install with npm?")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("openclaw installation cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "\nInstalling OpenClaw...\n")
|
||||||
|
cmd := exec.Command("npm", "install", "-g", "openclaw@latest")
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to install openclaw: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := exec.LookPath("openclaw"); err != nil {
|
||||||
|
return "", fmt.Errorf("openclaw was installed but the binary was not found on PATH\n\nYou may need to restart your shell")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "%sOpenClaw installed successfully%s\n\n", ansiGreen, ansiReset)
|
||||||
|
return "openclaw", nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Openclaw) Paths() []string {
|
func (c *Openclaw) Paths() []string {
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
p := filepath.Join(home, ".openclaw", "openclaw.json")
|
p := filepath.Join(home, ".openclaw", "openclaw.json")
|
||||||
@@ -161,8 +505,7 @@ func (c *Openclaw) Edit(models []string) error {
|
|||||||
ollama["baseUrl"] = envconfig.Host().String() + "/v1"
|
ollama["baseUrl"] = envconfig.Host().String() + "/v1"
|
||||||
// needed to register provider
|
// needed to register provider
|
||||||
ollama["apiKey"] = "ollama-local"
|
ollama["apiKey"] = "ollama-local"
|
||||||
// TODO(parthsareen): potentially move to responses
|
ollama["api"] = "ollama"
|
||||||
ollama["api"] = "openai-completions"
|
|
||||||
|
|
||||||
// Build map of existing models to preserve user customizations
|
// Build map of existing models to preserve user customizations
|
||||||
existingModels, _ := ollama["models"].([]any)
|
existingModels, _ := ollama["models"].([]any)
|
||||||
@@ -175,25 +518,13 @@ func (c *Openclaw) Edit(models []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client, _ := api.ClientFromEnvironment()
|
||||||
|
|
||||||
var newModels []any
|
var newModels []any
|
||||||
for _, model := range models {
|
for _, m := range models {
|
||||||
entry := map[string]any{
|
entry, _ := openclawModelConfig(context.Background(), client, m)
|
||||||
"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)
|
// Merge existing fields (user customizations)
|
||||||
if existing, ok := existingByID[model]; ok {
|
if existing, ok := existingByID[m]; ok {
|
||||||
for k, v := range existing {
|
for k, v := range existing {
|
||||||
if _, isNew := entry[k]; !isNew {
|
if _, isNew := entry[k]; !isNew {
|
||||||
entry[k] = v
|
entry[k] = v
|
||||||
@@ -230,7 +561,213 @@ func (c *Openclaw) Edit(models []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return writeWithBackup(configPath, data)
|
if err := writeWithBackup(configPath, data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any per-session model overrides so the new primary takes effect
|
||||||
|
// immediately rather than being shadowed by a cached modelOverride.
|
||||||
|
clearSessionModelOverride(models[0])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearSessionModelOverride removes per-session model overrides from the main
|
||||||
|
// agent session so the global primary model takes effect on the next TUI launch.
|
||||||
|
func clearSessionModelOverride(primary string) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := filepath.Join(home, ".openclaw", "agents", "main", "sessions", "sessions.json")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var sessions map[string]map[string]any
|
||||||
|
if json.Unmarshal(data, &sessions) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
for _, sess := range sessions {
|
||||||
|
if override, _ := sess["modelOverride"].(string); override != "" && override != primary {
|
||||||
|
delete(sess, "modelOverride")
|
||||||
|
delete(sess, "providerOverride")
|
||||||
|
sess["model"] = primary
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := json.MarshalIndent(sessions, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(path, out, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
const webSearchNpmPackage = "@ollama/openclaw-web-search"
|
||||||
|
|
||||||
|
// ensureWebSearchPlugin installs the openclaw-web-search extension into the
|
||||||
|
// user-level extensions directory (~/.openclaw/extensions/) if it isn't already
|
||||||
|
// present. Returns true if the extension is available.
|
||||||
|
func ensureWebSearchPlugin() bool {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginDir := filepath.Join(home, ".openclaw", "extensions", "openclaw-web-search")
|
||||||
|
if _, err := os.Stat(filepath.Join(pluginDir, "index.ts")); err == nil {
|
||||||
|
return true // already installed
|
||||||
|
}
|
||||||
|
|
||||||
|
npmBin, err := exec.LookPath("npm")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the tarball via `npm pack`, extract it flat into the plugin dir.
|
||||||
|
pack := exec.Command(npmBin, "pack", webSearchNpmPackage, "--pack-destination", pluginDir)
|
||||||
|
out, err := pack.Output()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s Warning: could not download web search plugin: %v%s\n", ansiYellow, err, ansiReset)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
tgzName := strings.TrimSpace(string(out))
|
||||||
|
tgzPath := filepath.Join(pluginDir, tgzName)
|
||||||
|
defer os.Remove(tgzPath)
|
||||||
|
|
||||||
|
tar := exec.Command("tar", "xzf", tgzPath, "--strip-components=1", "-C", pluginDir)
|
||||||
|
if err := tar.Run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s Warning: could not extract web search plugin: %v%s\n", ansiYellow, err, ansiReset)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "%s ✓ Installed web search plugin%s\n", ansiGreen, ansiReset)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerWebSearchPlugin adds plugins.entries.openclaw-web-search to the OpenClaw
|
||||||
|
// config so the gateway activates it on next start. Best-effort; silently returns
|
||||||
|
// on any error.
|
||||||
|
func registerWebSearchPlugin() {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(home, ".openclaw", "openclaw.json")
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var config map[string]any
|
||||||
|
if json.Unmarshal(data, &config) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins, _ := config["plugins"].(map[string]any)
|
||||||
|
if plugins == nil {
|
||||||
|
plugins = make(map[string]any)
|
||||||
|
}
|
||||||
|
entries, _ := plugins["entries"].(map[string]any)
|
||||||
|
if entries == nil {
|
||||||
|
entries = make(map[string]any)
|
||||||
|
}
|
||||||
|
if _, ok := entries["openclaw-web-search"]; ok {
|
||||||
|
return // already registered
|
||||||
|
}
|
||||||
|
entries["openclaw-web-search"] = map[string]any{"enabled": true}
|
||||||
|
plugins["entries"] = entries
|
||||||
|
config["plugins"] = plugins
|
||||||
|
|
||||||
|
// Disable the built-in web search since our plugin replaces it.
|
||||||
|
tools, _ := config["tools"].(map[string]any)
|
||||||
|
if tools == nil {
|
||||||
|
tools = make(map[string]any)
|
||||||
|
}
|
||||||
|
web, _ := tools["web"].(map[string]any)
|
||||||
|
if web == nil {
|
||||||
|
web = make(map[string]any)
|
||||||
|
}
|
||||||
|
web["search"] = map[string]any{"enabled": false}
|
||||||
|
tools["web"] = web
|
||||||
|
config["tools"] = tools
|
||||||
|
|
||||||
|
out, err := json.MarshalIndent(config, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(configPath, out, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openclawModelConfig builds an OpenClaw model config entry with capability detection.
|
||||||
|
// The second return value indicates whether the model is a cloud (remote) model.
|
||||||
|
func openclawModelConfig(ctx context.Context, client *api.Client, modelID string) (map[string]any, bool) {
|
||||||
|
entry := map[string]any{
|
||||||
|
"id": modelID,
|
||||||
|
"name": modelID,
|
||||||
|
"input": []any{"text"},
|
||||||
|
"cost": map[string]any{
|
||||||
|
"input": 0,
|
||||||
|
"output": 0,
|
||||||
|
"cacheRead": 0,
|
||||||
|
"cacheWrite": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if client == nil {
|
||||||
|
return entry, false
|
||||||
|
}
|
||||||
|
|
||||||
|
showCtx := ctx
|
||||||
|
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
showCtx, cancel = context.WithTimeout(ctx, openclawModelShowTimeout)
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Show(showCtx, &api.ShowRequest{Model: modelID})
|
||||||
|
if err != nil {
|
||||||
|
return entry, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set input types based on vision capability
|
||||||
|
if slices.Contains(resp.Capabilities, model.CapabilityVision) {
|
||||||
|
entry["input"] = []any{"text", "image"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set reasoning based on thinking capability
|
||||||
|
if slices.Contains(resp.Capabilities, model.CapabilityThinking) {
|
||||||
|
entry["reasoning"] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cloud models: use hardcoded limits for context/output tokens.
|
||||||
|
// Capability detection above still applies (vision, thinking).
|
||||||
|
if resp.RemoteModel != "" {
|
||||||
|
if l, ok := lookupCloudModelLimit(modelID); ok {
|
||||||
|
entry["contextWindow"] = l.Context
|
||||||
|
entry["maxTokens"] = l.Output
|
||||||
|
}
|
||||||
|
return entry, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract context window from ModelInfo (local models only)
|
||||||
|
for key, val := range resp.ModelInfo {
|
||||||
|
if strings.HasSuffix(key, ".context_length") {
|
||||||
|
if ctxLen, ok := val.(float64); ok && ctxLen > 0 {
|
||||||
|
entry["contextWindow"] = int(ctxLen)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Openclaw) Models() []string {
|
func (c *Openclaw) Models() []string {
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestOpenclawIntegration(t *testing.T) {
|
func TestOpenclawIntegration(t *testing.T) {
|
||||||
@@ -26,6 +36,124 @@ func TestOpenclawIntegration(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenclawRunPassthroughArgs(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("uses a POSIX shell test binary")
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
|
||||||
|
if err := integrationOnboarded("openclaw"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||||
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{
|
||||||
|
"wizard": {"lastRunAt": "2026-01-01T00:00:00Z"}
|
||||||
|
}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bin := filepath.Join(tmpDir, "openclaw")
|
||||||
|
if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$HOME/invocations.log\"\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &Openclaw{}
|
||||||
|
if err := c.Run("llama3.2", []string{"gateway", "--someflag"}); err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(tmpDir, "invocations.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||||
|
if len(lines) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 invocation, got %d: %v", len(lines), lines)
|
||||||
|
}
|
||||||
|
if lines[0] != "gateway --someflag" {
|
||||||
|
t.Fatalf("invocation = %q, want %q", lines[0], "gateway --someflag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenclawRunFirstLaunchPersistence(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("uses a POSIX shell test binary")
|
||||||
|
}
|
||||||
|
|
||||||
|
oldHook := DefaultConfirmPrompt
|
||||||
|
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
defer func() { DefaultConfirmPrompt = oldHook }()
|
||||||
|
|
||||||
|
t.Run("success persists onboarding flag", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
|
||||||
|
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||||
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Mark OpenClaw onboarding complete so Run takes passthrough path directly.
|
||||||
|
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{
|
||||||
|
"wizard": {"lastRunAt": "2026-01-01T00:00:00Z"}
|
||||||
|
}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "openclaw"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &Openclaw{}
|
||||||
|
if err := c.Run("llama3.2", []string{"gateway", "--status"}); err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
integrationConfig, err := loadIntegration("openclaw")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadIntegration() error = %v", err)
|
||||||
|
}
|
||||||
|
if !integrationConfig.Onboarded {
|
||||||
|
t.Fatal("expected onboarding flag to be persisted after successful run")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failure does not persist onboarding flag", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
|
||||||
|
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||||
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{
|
||||||
|
"wizard": {"lastRunAt": "2026-01-01T00:00:00Z"}
|
||||||
|
}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "openclaw"), []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &Openclaw{}
|
||||||
|
if err := c.Run("llama3.2", []string{"gateway", "--status"}); err == nil {
|
||||||
|
t.Fatal("expected run failure")
|
||||||
|
}
|
||||||
|
integrationConfig, err := loadIntegration("openclaw")
|
||||||
|
if err == nil && integrationConfig.Onboarded {
|
||||||
|
t.Fatal("expected onboarding flag to remain unset after failed run")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestOpenclawEdit(t *testing.T) {
|
func TestOpenclawEdit(t *testing.T) {
|
||||||
c := &Openclaw{}
|
c := &Openclaw{}
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
@@ -359,19 +487,16 @@ func TestOpenclawEditSchemaFields(t *testing.T) {
|
|||||||
modelList := ollama["models"].([]any)
|
modelList := ollama["models"].([]any)
|
||||||
entry := modelList[0].(map[string]any)
|
entry := modelList[0].(map[string]any)
|
||||||
|
|
||||||
// Verify required schema fields
|
// Verify base schema fields (always set regardless of API availability)
|
||||||
if entry["reasoning"] != false {
|
if entry["id"] != "llama3.2" {
|
||||||
t.Error("reasoning should be false")
|
t.Errorf("id = %v, want llama3.2", entry["id"])
|
||||||
|
}
|
||||||
|
if entry["name"] != "llama3.2" {
|
||||||
|
t.Errorf("name = %v, want llama3.2", entry["name"])
|
||||||
}
|
}
|
||||||
if entry["input"] == nil {
|
if entry["input"] == nil {
|
||||||
t.Error("input should be set")
|
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)
|
cost := entry["cost"].(map[string]any)
|
||||||
if cost["cacheRead"] == nil {
|
if cost["cacheRead"] == nil {
|
||||||
t.Error("cost.cacheRead should be set")
|
t.Error("cost.cacheRead should be set")
|
||||||
@@ -876,3 +1001,589 @@ func TestOpenclawOnboarded(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenclawGatewayInfo(t *testing.T) {
|
||||||
|
c := &Openclaw{}
|
||||||
|
|
||||||
|
t.Run("returns defaults when no config exists", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
|
||||||
|
token, port := c.gatewayInfo()
|
||||||
|
if token != "" {
|
||||||
|
t.Errorf("expected empty token, got %q", token)
|
||||||
|
}
|
||||||
|
if port != defaultGatewayPort {
|
||||||
|
t.Errorf("expected default port %d, got %d", defaultGatewayPort, port)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("reads token and port from config", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||||
|
os.MkdirAll(configDir, 0o755)
|
||||||
|
os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{
|
||||||
|
"gateway": {
|
||||||
|
"port": 9999,
|
||||||
|
"auth": {"mode": "token", "token": "my-secret"}
|
||||||
|
}
|
||||||
|
}`), 0o644)
|
||||||
|
|
||||||
|
token, port := c.gatewayInfo()
|
||||||
|
if token != "my-secret" {
|
||||||
|
t.Errorf("expected token %q, got %q", "my-secret", token)
|
||||||
|
}
|
||||||
|
if port != 9999 {
|
||||||
|
t.Errorf("expected port 9999, got %d", port)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("uses default port when not in config", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||||
|
os.MkdirAll(configDir, 0o755)
|
||||||
|
os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{
|
||||||
|
"gateway": {"auth": {"token": "tok"}}
|
||||||
|
}`), 0o644)
|
||||||
|
|
||||||
|
token, port := c.gatewayInfo()
|
||||||
|
if token != "tok" {
|
||||||
|
t.Errorf("expected token %q, got %q", "tok", token)
|
||||||
|
}
|
||||||
|
if port != defaultGatewayPort {
|
||||||
|
t.Errorf("expected default port %d, got %d", defaultGatewayPort, port)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("falls back to legacy clawdbot config", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
legacyDir := filepath.Join(tmpDir, ".clawdbot")
|
||||||
|
os.MkdirAll(legacyDir, 0o755)
|
||||||
|
os.WriteFile(filepath.Join(legacyDir, "clawdbot.json"), []byte(`{
|
||||||
|
"gateway": {"port": 12345, "auth": {"token": "legacy-token"}}
|
||||||
|
}`), 0o644)
|
||||||
|
|
||||||
|
token, port := c.gatewayInfo()
|
||||||
|
if token != "legacy-token" {
|
||||||
|
t.Errorf("expected token %q, got %q", "legacy-token", token)
|
||||||
|
}
|
||||||
|
if port != 12345 {
|
||||||
|
t.Errorf("expected port 12345, got %d", port)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handles corrupted JSON gracefully", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||||
|
os.MkdirAll(configDir, 0o755)
|
||||||
|
os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{corrupted`), 0o644)
|
||||||
|
|
||||||
|
token, port := c.gatewayInfo()
|
||||||
|
if token != "" {
|
||||||
|
t.Errorf("expected empty token, got %q", token)
|
||||||
|
}
|
||||||
|
if port != defaultGatewayPort {
|
||||||
|
t.Errorf("expected default port, got %d", port)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handles missing gateway section", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||||
|
os.MkdirAll(configDir, 0o755)
|
||||||
|
os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{"theme":"dark"}`), 0o644)
|
||||||
|
|
||||||
|
token, port := c.gatewayInfo()
|
||||||
|
if token != "" {
|
||||||
|
t.Errorf("expected empty token, got %q", token)
|
||||||
|
}
|
||||||
|
if port != defaultGatewayPort {
|
||||||
|
t.Errorf("expected default port, got %d", port)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintOpenclawReady(t *testing.T) {
|
||||||
|
t.Run("includes port in URL", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
old := os.Stderr
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
os.Stderr = w
|
||||||
|
|
||||||
|
printOpenclawReady("openclaw", "", 9999, false)
|
||||||
|
|
||||||
|
w.Close()
|
||||||
|
os.Stderr = old
|
||||||
|
buf.ReadFrom(r)
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
if !strings.Contains(output, "localhost:9999") {
|
||||||
|
t.Errorf("expected port 9999 in output, got:\n%s", output)
|
||||||
|
}
|
||||||
|
if strings.Contains(output, "#token=") {
|
||||||
|
t.Error("should not include token fragment when token is empty")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("URL-escapes token", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
old := os.Stderr
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
os.Stderr = w
|
||||||
|
|
||||||
|
printOpenclawReady("openclaw", "my token&special=chars", defaultGatewayPort, false)
|
||||||
|
|
||||||
|
w.Close()
|
||||||
|
os.Stderr = old
|
||||||
|
buf.ReadFrom(r)
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
escaped := url.QueryEscape("my token&special=chars")
|
||||||
|
if !strings.Contains(output, "#token="+escaped) {
|
||||||
|
t.Errorf("expected URL-escaped token %q in output, got:\n%s", escaped, output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("simple token is not mangled", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
old := os.Stderr
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
os.Stderr = w
|
||||||
|
|
||||||
|
printOpenclawReady("openclaw", "ollama", defaultGatewayPort, false)
|
||||||
|
|
||||||
|
w.Close()
|
||||||
|
os.Stderr = old
|
||||||
|
buf.ReadFrom(r)
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
if !strings.Contains(output, "#token=ollama") {
|
||||||
|
t.Errorf("expected #token=ollama in output, got:\n%s", output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("includes web UI hint", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
old := os.Stderr
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
os.Stderr = w
|
||||||
|
|
||||||
|
printOpenclawReady("openclaw", "", defaultGatewayPort, false)
|
||||||
|
|
||||||
|
w.Close()
|
||||||
|
os.Stderr = old
|
||||||
|
buf.ReadFrom(r)
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
if !strings.Contains(output, "Open the Web UI") {
|
||||||
|
t.Errorf("expected web UI hint in output, got:\n%s", output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("first launch shows quick start tips", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
old := os.Stderr
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
os.Stderr = w
|
||||||
|
|
||||||
|
printOpenclawReady("openclaw", "ollama", defaultGatewayPort, true)
|
||||||
|
|
||||||
|
w.Close()
|
||||||
|
os.Stderr = old
|
||||||
|
buf.ReadFrom(r)
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
for _, want := range []string{"/help", "channels", "skills", "gateway"} {
|
||||||
|
if !strings.Contains(output, want) {
|
||||||
|
t.Errorf("expected %q in first-launch output, got:\n%s", want, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("subsequent launch shows single tip", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
old := os.Stderr
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
os.Stderr = w
|
||||||
|
|
||||||
|
printOpenclawReady("openclaw", "ollama", defaultGatewayPort, false)
|
||||||
|
|
||||||
|
w.Close()
|
||||||
|
os.Stderr = old
|
||||||
|
buf.ReadFrom(r)
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
if !strings.Contains(output, "Tip:") {
|
||||||
|
t.Errorf("expected single tip line, got:\n%s", output)
|
||||||
|
}
|
||||||
|
if strings.Contains(output, "Quick start") {
|
||||||
|
t.Errorf("should not show quick start on subsequent launch")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenclawModelConfig(t *testing.T) {
|
||||||
|
t.Run("nil client returns base config", func(t *testing.T) {
|
||||||
|
cfg, _ := openclawModelConfig(context.Background(), nil, "llama3.2")
|
||||||
|
|
||||||
|
if cfg["id"] != "llama3.2" {
|
||||||
|
t.Errorf("id = %v, want llama3.2", cfg["id"])
|
||||||
|
}
|
||||||
|
if cfg["name"] != "llama3.2" {
|
||||||
|
t.Errorf("name = %v, want llama3.2", cfg["name"])
|
||||||
|
}
|
||||||
|
if cfg["cost"] == nil {
|
||||||
|
t.Error("cost should be set")
|
||||||
|
}
|
||||||
|
// Should not have capability fields without API
|
||||||
|
if _, ok := cfg["reasoning"]; ok {
|
||||||
|
t.Error("reasoning should not be set without API")
|
||||||
|
}
|
||||||
|
if _, ok := cfg["contextWindow"]; ok {
|
||||||
|
t.Error("contextWindow should not be set without API")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("sets vision input when model has vision capability", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/show" {
|
||||||
|
fmt.Fprintf(w, `{"capabilities":["vision"],"model_info":{"llama.context_length":4096}}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
u, _ := url.Parse(srv.URL)
|
||||||
|
client := api.NewClient(u, srv.Client())
|
||||||
|
|
||||||
|
cfg, _ := openclawModelConfig(context.Background(), client, "llava:7b")
|
||||||
|
|
||||||
|
input, ok := cfg["input"].([]any)
|
||||||
|
if !ok || len(input) != 2 {
|
||||||
|
t.Errorf("input = %v, want [text image]", cfg["input"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("sets text-only input when model lacks vision", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/show" {
|
||||||
|
fmt.Fprintf(w, `{"capabilities":["completion"],"model_info":{}}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
u, _ := url.Parse(srv.URL)
|
||||||
|
client := api.NewClient(u, srv.Client())
|
||||||
|
|
||||||
|
cfg, _ := openclawModelConfig(context.Background(), client, "llama3.2")
|
||||||
|
|
||||||
|
input, ok := cfg["input"].([]any)
|
||||||
|
if !ok || len(input) != 1 {
|
||||||
|
t.Errorf("input = %v, want [text]", cfg["input"])
|
||||||
|
}
|
||||||
|
if _, ok := cfg["reasoning"]; ok {
|
||||||
|
t.Error("reasoning should not be set for non-thinking model")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("sets reasoning when model has thinking capability", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/show" {
|
||||||
|
fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{}}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
u, _ := url.Parse(srv.URL)
|
||||||
|
client := api.NewClient(u, srv.Client())
|
||||||
|
|
||||||
|
cfg, _ := openclawModelConfig(context.Background(), client, "qwq")
|
||||||
|
|
||||||
|
if cfg["reasoning"] != true {
|
||||||
|
t.Error("expected reasoning = true for thinking model")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("extracts context window from model info", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/show" {
|
||||||
|
fmt.Fprintf(w, `{"capabilities":[],"model_info":{"llama.context_length":131072}}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
u, _ := url.Parse(srv.URL)
|
||||||
|
client := api.NewClient(u, srv.Client())
|
||||||
|
|
||||||
|
cfg, _ := openclawModelConfig(context.Background(), client, "llama3.2")
|
||||||
|
|
||||||
|
if cfg["contextWindow"] != 131072 {
|
||||||
|
t.Errorf("contextWindow = %v, want 131072", cfg["contextWindow"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handles all capabilities together", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/show" {
|
||||||
|
fmt.Fprintf(w, `{"capabilities":["vision","thinking"],"model_info":{"qwen3.context_length":32768}}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
u, _ := url.Parse(srv.URL)
|
||||||
|
client := api.NewClient(u, srv.Client())
|
||||||
|
|
||||||
|
cfg, _ := openclawModelConfig(context.Background(), client, "qwen3-vision")
|
||||||
|
|
||||||
|
input, ok := cfg["input"].([]any)
|
||||||
|
if !ok || len(input) != 2 {
|
||||||
|
t.Errorf("input = %v, want [text image]", cfg["input"])
|
||||||
|
}
|
||||||
|
if cfg["reasoning"] != true {
|
||||||
|
t.Error("expected reasoning = true")
|
||||||
|
}
|
||||||
|
if cfg["contextWindow"] != 32768 {
|
||||||
|
t.Errorf("contextWindow = %v, want 32768", cfg["contextWindow"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns base config when show fails", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
fmt.Fprintf(w, `{"error":"model not found"}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
u, _ := url.Parse(srv.URL)
|
||||||
|
client := api.NewClient(u, srv.Client())
|
||||||
|
|
||||||
|
cfg, _ := openclawModelConfig(context.Background(), client, "missing-model")
|
||||||
|
|
||||||
|
if cfg["id"] != "missing-model" {
|
||||||
|
t.Errorf("id = %v, want missing-model", cfg["id"])
|
||||||
|
}
|
||||||
|
// Should still have input (default)
|
||||||
|
if cfg["input"] == nil {
|
||||||
|
t.Error("input should always be set")
|
||||||
|
}
|
||||||
|
if _, ok := cfg["reasoning"]; ok {
|
||||||
|
t.Error("reasoning should not be set when show fails")
|
||||||
|
}
|
||||||
|
if _, ok := cfg["contextWindow"]; ok {
|
||||||
|
t.Error("contextWindow should not be set when show fails")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("times out slow show and returns base config", func(t *testing.T) {
|
||||||
|
oldTimeout := openclawModelShowTimeout
|
||||||
|
openclawModelShowTimeout = 50 * time.Millisecond
|
||||||
|
t.Cleanup(func() { openclawModelShowTimeout = oldTimeout })
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/show" {
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{"llama.context_length":4096}}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
u, _ := url.Parse(srv.URL)
|
||||||
|
client := api.NewClient(u, srv.Client())
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
cfg, _ := openclawModelConfig(context.Background(), client, "slow-model")
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
if elapsed >= 250*time.Millisecond {
|
||||||
|
t.Fatalf("openclawModelConfig took too long: %v", elapsed)
|
||||||
|
}
|
||||||
|
if cfg["id"] != "slow-model" {
|
||||||
|
t.Errorf("id = %v, want slow-model", cfg["id"])
|
||||||
|
}
|
||||||
|
if _, ok := cfg["reasoning"]; ok {
|
||||||
|
t.Error("reasoning should not be set on timeout")
|
||||||
|
}
|
||||||
|
if _, ok := cfg["contextWindow"]; ok {
|
||||||
|
t.Error("contextWindow should not be set on timeout")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("skips zero context length", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/show" {
|
||||||
|
fmt.Fprintf(w, `{"capabilities":[],"model_info":{"llama.context_length":0}}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
u, _ := url.Parse(srv.URL)
|
||||||
|
client := api.NewClient(u, srv.Client())
|
||||||
|
|
||||||
|
cfg, _ := openclawModelConfig(context.Background(), client, "test-model")
|
||||||
|
|
||||||
|
if _, ok := cfg["contextWindow"]; ok {
|
||||||
|
t.Error("contextWindow should not be set for zero value")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cloud model uses hardcoded limits", func(t *testing.T) {
|
||||||
|
// Use a model name that's in cloudModelLimits and make the server
|
||||||
|
// report it as a remote/cloud model
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/show" {
|
||||||
|
fmt.Fprintf(w, `{"capabilities":[],"model_info":{},"remote_model":"minimax-m2.5"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
u, _ := url.Parse(srv.URL)
|
||||||
|
client := api.NewClient(u, srv.Client())
|
||||||
|
|
||||||
|
cfg, isCloud := openclawModelConfig(context.Background(), client, "minimax-m2.5:cloud")
|
||||||
|
|
||||||
|
if !isCloud {
|
||||||
|
t.Error("expected isCloud = true for cloud model")
|
||||||
|
}
|
||||||
|
if cfg["contextWindow"] != 204_800 {
|
||||||
|
t.Errorf("contextWindow = %v, want 204800", cfg["contextWindow"])
|
||||||
|
}
|
||||||
|
if cfg["maxTokens"] != 128_000 {
|
||||||
|
t.Errorf("maxTokens = %v, want 128000", cfg["maxTokens"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cloud model with vision capability gets image input", func(t *testing.T) {
|
||||||
|
// Regression test: cloud models must not skip capability detection.
|
||||||
|
// A cloud model that reports vision capability should have input: [text, image].
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/show" {
|
||||||
|
fmt.Fprintf(w, `{"capabilities":["vision"],"model_info":{},"remote_model":"qwen3-vl"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
u, _ := url.Parse(srv.URL)
|
||||||
|
client := api.NewClient(u, srv.Client())
|
||||||
|
|
||||||
|
cfg, isCloud := openclawModelConfig(context.Background(), client, "qwen3-vl:235b-cloud")
|
||||||
|
|
||||||
|
if !isCloud {
|
||||||
|
t.Error("expected isCloud = true for cloud vision model")
|
||||||
|
}
|
||||||
|
input, ok := cfg["input"].([]any)
|
||||||
|
if !ok || len(input) != 2 {
|
||||||
|
t.Errorf("input = %v, want [text image] for cloud vision model", cfg["input"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cloud model with thinking capability gets reasoning flag", func(t *testing.T) {
|
||||||
|
// Regression test: cloud models must not skip capability detection.
|
||||||
|
// A cloud model that reports thinking capability should have reasoning: true.
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/show" {
|
||||||
|
fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{},"remote_model":"qwq-cloud"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
u, _ := url.Parse(srv.URL)
|
||||||
|
client := api.NewClient(u, srv.Client())
|
||||||
|
|
||||||
|
cfg, isCloud := openclawModelConfig(context.Background(), client, "qwq:cloud")
|
||||||
|
|
||||||
|
if !isCloud {
|
||||||
|
t.Error("expected isCloud = true for cloud thinking model")
|
||||||
|
}
|
||||||
|
if cfg["reasoning"] != true {
|
||||||
|
t.Error("expected reasoning = true for cloud thinking model")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationOnboarded(t *testing.T) {
|
||||||
|
t.Run("returns false when not set", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
|
||||||
|
integrationConfig, err := loadIntegration("openclaw")
|
||||||
|
if err == nil && integrationConfig.Onboarded {
|
||||||
|
t.Error("expected false for fresh config")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns true after integrationOnboarded", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
os.MkdirAll(filepath.Join(tmpDir, ".ollama"), 0o755)
|
||||||
|
|
||||||
|
if err := integrationOnboarded("openclaw"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
integrationConfig, err := loadIntegration("openclaw")
|
||||||
|
if err != nil || !integrationConfig.Onboarded {
|
||||||
|
t.Error("expected true after integrationOnboarded")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("is case insensitive", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
os.MkdirAll(filepath.Join(tmpDir, ".ollama"), 0o755)
|
||||||
|
|
||||||
|
if err := integrationOnboarded("OpenClaw"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
integrationConfig, err := loadIntegration("openclaw")
|
||||||
|
if err != nil || !integrationConfig.Onboarded {
|
||||||
|
t.Error("expected true when set with different case")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("preserves existing integration data", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
os.MkdirAll(filepath.Join(tmpDir, ".ollama"), 0o755)
|
||||||
|
|
||||||
|
if err := SaveIntegration("openclaw", []string{"llama3.2", "mistral"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := integrationOnboarded("openclaw"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify onboarded is set
|
||||||
|
integrationConfig, err := loadIntegration("openclaw")
|
||||||
|
if err != nil || !integrationConfig.Onboarded {
|
||||||
|
t.Error("expected true after integrationOnboarded")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify models are preserved
|
||||||
|
model := IntegrationModel("openclaw")
|
||||||
|
if model != "llama3.2" {
|
||||||
|
t.Errorf("expected first model llama3.2, got %q", model)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const (
|
|||||||
ansiReset = "\033[0m"
|
ansiReset = "\033[0m"
|
||||||
ansiGray = "\033[37m"
|
ansiGray = "\033[37m"
|
||||||
ansiGreen = "\033[32m"
|
ansiGreen = "\033[32m"
|
||||||
|
ansiYellow = "\033[33m"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrCancelled is returned when the user cancels a selection.
|
// ErrCancelled is returned when the user cancels a selection.
|
||||||
|
|||||||
@@ -524,7 +524,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case "enter", " ":
|
case "enter", " ":
|
||||||
item := m.items[m.cursor]
|
item := m.items[m.cursor]
|
||||||
|
|
||||||
if item.integration != "" && !config.IsIntegrationInstalled(item.integration) {
|
if item.integration != "" && !config.IsIntegrationInstalled(item.integration) && !config.AutoInstallable(item.integration) {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,6 +555,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
item := m.items[m.cursor]
|
item := m.items[m.cursor]
|
||||||
if item.integration != "" || item.isRunModel {
|
if item.integration != "" || item.isRunModel {
|
||||||
if item.integration != "" && !config.IsIntegrationInstalled(item.integration) {
|
if item.integration != "" && !config.IsIntegrationInstalled(item.integration) {
|
||||||
|
if config.AutoInstallable(item.integration) {
|
||||||
|
// Auto-installable: select to trigger install flow
|
||||||
|
m.selected = true
|
||||||
|
m.quitting = true
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if item.integration != "" && config.IsEditorIntegration(item.integration) {
|
if item.integration != "" && config.IsEditorIntegration(item.integration) {
|
||||||
@@ -618,7 +624,11 @@ func (m model) View() string {
|
|||||||
var modelSuffix string
|
var modelSuffix string
|
||||||
if item.integration != "" {
|
if item.integration != "" {
|
||||||
if !isInstalled {
|
if !isInstalled {
|
||||||
|
if config.AutoInstallable(item.integration) {
|
||||||
|
title += " " + notInstalledStyle.Render("(install)")
|
||||||
|
} else {
|
||||||
title += " " + notInstalledStyle.Render("(not installed)")
|
title += " " + notInstalledStyle.Render("(not installed)")
|
||||||
|
}
|
||||||
} else if m.cursor == i {
|
} else if m.cursor == i {
|
||||||
if mdl := config.IntegrationModel(item.integration); mdl != "" && m.modelExists(mdl) {
|
if mdl := config.IntegrationModel(item.integration); mdl != "" && m.modelExists(mdl) {
|
||||||
modelSuffix = " " + modelStyle.Render("("+mdl+")")
|
modelSuffix = " " + modelStyle.Render("("+mdl+")")
|
||||||
@@ -634,7 +644,9 @@ func (m model) View() string {
|
|||||||
|
|
||||||
desc := item.description
|
desc := item.description
|
||||||
if !isInstalled && item.integration != "" && m.cursor == i {
|
if !isInstalled && item.integration != "" && m.cursor == i {
|
||||||
if hint := config.IntegrationInstallHint(item.integration); hint != "" {
|
if config.AutoInstallable(item.integration) {
|
||||||
|
desc = "Press enter to install"
|
||||||
|
} else if hint := config.IntegrationInstallHint(item.integration); hint != "" {
|
||||||
desc = hint
|
desc = hint
|
||||||
} else {
|
} else {
|
||||||
desc = "not installed"
|
desc = "not installed"
|
||||||
|
|||||||
@@ -4,47 +4,65 @@ title: OpenClaw
|
|||||||
|
|
||||||
OpenClaw 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.
|
OpenClaw 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
|
## Quick start
|
||||||
|
|
||||||
Install [OpenClaw](https://openclaw.ai/)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -g openclaw@latest
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run the onboarding wizard:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openclaw onboard --install-daemon
|
|
||||||
```
|
|
||||||
|
|
||||||
<Note>OpenClaw 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
|
```bash
|
||||||
ollama launch openclaw
|
ollama launch openclaw
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Ollama handles everything automatically:
|
||||||
|
|
||||||
|
1. **Install** — If OpenClaw isn't installed, Ollama prompts to install it via npm
|
||||||
|
2. **Security** — On the first launch, a security notice explains the risks of tool access
|
||||||
|
3. **Model** — Pick a model from the selector (local or cloud)
|
||||||
|
4. **Onboarding** — Ollama configures the provider, installs the gateway daemon, and sets your model as the primary
|
||||||
|
5. **Gateway** — Starts in the background and opens the OpenClaw TUI
|
||||||
|
|
||||||
|
<Note>OpenClaw requires a larger context window. It is recommended to use a context window of at least 64k tokens if using local models. See [Context length](/context-length) for more information.</Note>
|
||||||
|
|
||||||
<Note>Previously known as Clawdbot. `ollama launch clawdbot` still works as an alias.</Note>
|
<Note>Previously known as Clawdbot. `ollama launch clawdbot` still works as an alias.</Note>
|
||||||
|
|
||||||
This configures OpenClaw to use Ollama and starts the gateway.
|
## Configure without launching
|
||||||
If the gateway is already running, no changes need to be made as the gateway will auto-reload the changes.
|
|
||||||
|
|
||||||
|
To change the model without starting the gateway and TUI:
|
||||||
|
|
||||||
To configure without launching:
|
```bash
|
||||||
|
|
||||||
```shell
|
|
||||||
ollama launch openclaw --config
|
ollama launch openclaw --config
|
||||||
```
|
```
|
||||||
|
|
||||||
## Recommended Models
|
To use a specific model directly:
|
||||||
|
|
||||||
- `qwen3-coder`
|
```bash
|
||||||
- `glm-4.7`
|
ollama launch openclaw --model kimi-k2.5:cloud
|
||||||
- `gpt-oss:20b`
|
```
|
||||||
- `gpt-oss:120b`
|
|
||||||
|
If the gateway is already running, it restarts automatically to pick up the new model.
|
||||||
|
|
||||||
|
## Recommended models
|
||||||
|
|
||||||
|
**Cloud models**:
|
||||||
|
|
||||||
|
- `kimi-k2.5:cloud` — Multimodal reasoning with subagents
|
||||||
|
- `minimax-m2.5:cloud` — Fast, efficient coding and real-world productivity
|
||||||
|
- `glm-5:cloud` — Reasoning and code generation
|
||||||
|
|
||||||
|
**Local models:**
|
||||||
|
|
||||||
|
- `glm-4.7-flash` — Reasoning and code generation locally (~25 GB VRAM)
|
||||||
|
|
||||||
|
More models at [ollama.com/search](https://ollama.com/search?c=cloud).
|
||||||
|
|
||||||
|
## Connect messaging apps
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw configure --section channels
|
||||||
|
```
|
||||||
|
|
||||||
|
Link WhatsApp, Telegram, Slack, Discord, or iMessage to chat with your local models from anywhere.
|
||||||
|
|
||||||
|
## Stopping the gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw gateway stop
|
||||||
|
```
|
||||||
|
|
||||||
Cloud models are also available at [ollama.com/search?c=cloud](https://ollama.com/search?c=cloud).
|
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
|
|
||||||
static int mlx_dynamic_open(mlx_dynamic_handle* handle, const char* path) {
|
static int mlx_dynamic_open(mlx_dynamic_handle* handle, const char* path) {
|
||||||
handle->ctx = (void*) DLOPEN(path);
|
handle->ctx = (void*) DLOPEN(path);
|
||||||
CHECK(handle->ctx != NULL);
|
if (handle->ctx == NULL) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user