mirror of
https://github.com/ollama/ollama.git
synced 2026-04-24 17:55:43 +02:00
Compare commits
5 Commits
v0.21.2-rc
...
v0.21.3-rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea01af6f76 | ||
|
|
c2ebb4d57c | ||
|
|
590109c835 | ||
|
|
b4442c6d17 | ||
|
|
85ff8e4a21 |
14
api/types.go
14
api/types.go
@@ -1080,7 +1080,7 @@ func DefaultOptions() Options {
|
||||
}
|
||||
}
|
||||
|
||||
// ThinkValue represents a value that can be a boolean or a string ("high", "medium", "low")
|
||||
// ThinkValue represents a value that can be a boolean or a string ("high", "medium", "low", "max")
|
||||
type ThinkValue struct {
|
||||
// Value can be a bool or string
|
||||
Value interface{}
|
||||
@@ -1096,7 +1096,7 @@ func (t *ThinkValue) IsValid() bool {
|
||||
case bool:
|
||||
return true
|
||||
case string:
|
||||
return v == "high" || v == "medium" || v == "low"
|
||||
return v == "high" || v == "medium" || v == "low" || v == "max"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -1130,8 +1130,8 @@ func (t *ThinkValue) Bool() bool {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
// Any string value ("high", "medium", "low") means thinking is enabled
|
||||
return v == "high" || v == "medium" || v == "low"
|
||||
// Any string value ("high", "medium", "low", "max") means thinking is enabled
|
||||
return v == "high" || v == "medium" || v == "low" || v == "max"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -1169,14 +1169,14 @@ func (t *ThinkValue) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err == nil {
|
||||
// Validate string values
|
||||
if s != "high" && s != "medium" && s != "low" {
|
||||
return fmt.Errorf("invalid think value: %q (must be \"high\", \"medium\", \"low\", true, or false)", s)
|
||||
if s != "high" && s != "medium" && s != "low" && s != "max" {
|
||||
return fmt.Errorf("invalid think value: %q (must be \"high\", \"medium\", \"low\", \"max\", true, or false)", s)
|
||||
}
|
||||
t.Value = s
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("think must be a boolean or string (\"high\", \"medium\", \"low\", true, or false)")
|
||||
return fmt.Errorf("think must be a boolean or string (\"high\", \"medium\", \"low\", \"max\", true, or false)")
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler
|
||||
|
||||
@@ -495,6 +495,11 @@ func TestThinking_UnmarshalJSON(t *testing.T) {
|
||||
input: `{ "think": "low" }`,
|
||||
expectedThinking: &ThinkValue{Value: "low"},
|
||||
},
|
||||
{
|
||||
name: "string_max",
|
||||
input: `{ "think": "max" }`,
|
||||
expectedThinking: &ThinkValue{Value: "max"},
|
||||
},
|
||||
{
|
||||
name: "invalid_string",
|
||||
input: `{ "think": "invalid" }`,
|
||||
|
||||
@@ -582,10 +582,10 @@ func RunHandler(cmd *cobra.Command, args []string) error {
|
||||
opts.Think = &api.ThinkValue{Value: true}
|
||||
case "false":
|
||||
opts.Think = &api.ThinkValue{Value: false}
|
||||
case "high", "medium", "low":
|
||||
case "high", "medium", "low", "max":
|
||||
opts.Think = &api.ThinkValue{Value: thinkStr}
|
||||
default:
|
||||
return fmt.Errorf("invalid value for --think: %q (must be true, false, high, medium, or low)", thinkStr)
|
||||
return fmt.Errorf("invalid value for --think: %q (must be true, false, high, medium, low, or max)", thinkStr)
|
||||
}
|
||||
} else {
|
||||
opts.Think = nil
|
||||
|
||||
@@ -318,10 +318,18 @@ func names(items []ModelItem) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func recommendedNames(extra ...string) []string {
|
||||
out := make([]string, 0, len(recommendedModels)+len(extra))
|
||||
for _, item := range recommendedModels {
|
||||
out = append(out, item.Name)
|
||||
}
|
||||
return append(out, extra...)
|
||||
}
|
||||
|
||||
func TestBuildModelList_NoExistingModels(t *testing.T) {
|
||||
items, _, _, _ := buildModelList(nil, nil, "")
|
||||
|
||||
want := []string{"kimi-k2.6:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5"}
|
||||
want := recommendedNames()
|
||||
if diff := cmp.Diff(want, names(items)); diff != "" {
|
||||
t.Errorf("with no existing models, items should be recommended in order (-want +got):\n%s", diff)
|
||||
}
|
||||
@@ -350,7 +358,7 @@ func TestBuildModelList_OnlyLocalModels_CloudRecsStillFirst(t *testing.T) {
|
||||
|
||||
// Cloud recs always come first among recommended, regardless of installed inventory.
|
||||
// Cloud disablement is handled upstream in loadSelectableModels via filterCloudItems.
|
||||
want := []string{"kimi-k2.6:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5", "llama3.2", "qwen2.5"}
|
||||
want := recommendedNames("llama3.2", "qwen2.5")
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("cloud recs pinned first even when no cloud models installed (-want +got):\n%s", diff)
|
||||
}
|
||||
@@ -366,13 +374,13 @@ func TestBuildModelList_BothCloudAndLocal_RegularSort(t *testing.T) {
|
||||
got := names(items)
|
||||
|
||||
// All recs pinned at top (cloud before local in mixed case), then non-recs
|
||||
want := []string{"kimi-k2.6:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5", "llama3.2"}
|
||||
want := recommendedNames("llama3.2")
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("recs pinned at top, cloud recs first in mixed case (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelList_PreCheckedFirst(t *testing.T) {
|
||||
func TestBuildModelList_PreCheckedNonRecommendedFirstInMore(t *testing.T) {
|
||||
existing := []modelInfo{
|
||||
{Name: "llama3.2:latest", Remote: false},
|
||||
{Name: "glm-5.1:cloud", Remote: true},
|
||||
@@ -381,8 +389,9 @@ func TestBuildModelList_PreCheckedFirst(t *testing.T) {
|
||||
items, _, _, _ := buildModelList(existing, []string{"llama3.2"}, "")
|
||||
got := names(items)
|
||||
|
||||
if got[0] != "llama3.2" {
|
||||
t.Errorf("pre-checked model should be first, got %v", got)
|
||||
want := recommendedNames("llama3.2")
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("recommended block should stay fixed while checked non-recommended models lead More (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,7 +466,7 @@ func TestBuildModelList_ExistingCloudModelsNotPushedToBottom(t *testing.T) {
|
||||
// gemma4 and glm-5.1:cloud are installed so they sort normally;
|
||||
// qwen3.5:cloud and qwen3.5 are not installed so they go to the bottom
|
||||
// All recs: cloud first in mixed case, then local, in rec order within each
|
||||
want := []string{"kimi-k2.6:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5"}
|
||||
want := recommendedNames()
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("all recs, cloud first in mixed case (-want +got):\n%s", diff)
|
||||
}
|
||||
@@ -475,7 +484,7 @@ func TestBuildModelList_HasRecommendedCloudModel_OnlyNonInstalledAtBottom(t *tes
|
||||
// kimi-k2.6:cloud is installed so it sorts normally;
|
||||
// the rest of the recommendations are not installed so they go to the bottom
|
||||
// All recs pinned at top (cloud first in mixed case), then non-recs
|
||||
want := []string{"kimi-k2.6:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5", "llama3.2"}
|
||||
want := recommendedNames("llama3.2")
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("recs pinned at top, cloud first in mixed case (-want +got):\n%s", diff)
|
||||
}
|
||||
@@ -641,17 +650,32 @@ func TestBuildModelList_RecsAboveNonRecs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelList_CheckedBeforeRecs(t *testing.T) {
|
||||
func TestBuildModelList_CheckedRecommendedDoesNotReshuffleRecommendedOrder(t *testing.T) {
|
||||
existing := []modelInfo{
|
||||
{Name: "llama3.2:latest", Remote: false},
|
||||
{Name: "glm-5.1:cloud", Remote: true},
|
||||
}
|
||||
|
||||
items, _, _, _ := buildModelList(existing, []string{"llama3.2"}, "")
|
||||
items, _, _, _ := buildModelList(existing, []string{"qwen3.5:cloud", "glm-5.1:cloud"}, "")
|
||||
got := names(items)
|
||||
|
||||
if got[0] != "llama3.2" {
|
||||
t.Errorf("checked model should be first even before recs, got %v", got)
|
||||
want := recommendedNames("llama3.2")
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("checked recommended models should not reshuffle the fixed recommended order (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelList_StaleSavedKimiK25DoesNotReshuffleRecommendedOrder(t *testing.T) {
|
||||
existing := []modelInfo{
|
||||
{Name: "kimi-k2.5:cloud", Remote: true},
|
||||
}
|
||||
|
||||
items, _, _, _ := buildModelList(existing, []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud"}, "kimi-k2.5:cloud")
|
||||
got := names(items)
|
||||
|
||||
want := recommendedNames("kimi-k2.5:cloud")
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("stale saved kimi-k2.5 should stay in More without reshuffling the fixed recommended order (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -588,7 +588,7 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam
|
||||
return nil
|
||||
}
|
||||
|
||||
if (current == "" || needsConfigure || req.ModelOverride != "" || target != current) && !savedMatchesModels(saved, []string{target}) {
|
||||
if needsConfigure || req.ModelOverride != "" || (current != "" && target != current) || !savedMatchesModels(saved, []string{target}) {
|
||||
if err := prepareManagedSingleIntegration(name, runner, managed, target); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/ollama/ollama/cmd/config"
|
||||
)
|
||||
|
||||
@@ -511,6 +512,65 @@ func TestLaunchIntegration_ManagedSingleIntegrationRewritesWhenSavedDiffers(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_ManagedSingleIntegrationRewritesWhenLiveConfigDrifts(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
withInteractiveSession(t, true)
|
||||
withLauncherHooks(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/tags":
|
||||
fmt.Fprint(w, `{"models":[{"name":"gemma4"},{"name":"qwen3:8b"}]}`)
|
||||
case "/api/show":
|
||||
fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||
|
||||
if err := config.SaveIntegration("stubmanaged", []string{"gemma4"}); err != nil {
|
||||
t.Fatalf("failed to save managed integration config: %v", err)
|
||||
}
|
||||
|
||||
runner := &launcherManagedRunner{
|
||||
currentModel: "qwen3:8b",
|
||||
}
|
||||
withIntegrationOverride(t, "stubmanaged", runner)
|
||||
|
||||
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
|
||||
t.Fatal("selector should not be called when live config already provides the target")
|
||||
return "", nil
|
||||
}
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil {
|
||||
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||
}
|
||||
|
||||
if diff := compareStrings(runner.configured, []string{"qwen3:8b"}); diff != "" {
|
||||
t.Fatalf("expected Configure to reconcile stale saved config to live target: %s", diff)
|
||||
}
|
||||
if runner.refreshCalls != 1 {
|
||||
t.Fatalf("expected runtime refresh once after drift reconciliation, got %d", runner.refreshCalls)
|
||||
}
|
||||
if runner.ranModel != "qwen3:8b" {
|
||||
t.Fatalf("expected launch to run live configured model, got %q", runner.ranModel)
|
||||
}
|
||||
|
||||
saved, err := config.LoadIntegration("stubmanaged")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to reload managed integration config: %v", err)
|
||||
}
|
||||
if diff := compareStrings(saved.Models, []string{"qwen3:8b"}); diff != "" {
|
||||
t.Fatalf("saved models mismatch after drift reconciliation: %s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_ManagedSingleIntegrationStopsWhenRuntimeRefreshFails(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
@@ -1219,8 +1279,9 @@ func TestLaunchIntegration_EditorForceConfigure_FloatsCheckedModelsInPicker(t *t
|
||||
if len(gotItems) == 0 {
|
||||
t.Fatal("expected multi selector to receive items")
|
||||
}
|
||||
if gotItems[0] != "qwen3.5:cloud" {
|
||||
t.Fatalf("expected checked models floated to top with qwen3.5:cloud first, got %v", gotItems)
|
||||
wantItems := recommendedNames()
|
||||
if diff := cmp.Diff(wantItems, gotItems); diff != "" {
|
||||
t.Fatalf("expected fixed recommended order in selector items (-want +got):\n%s", diff)
|
||||
}
|
||||
if len(gotPreChecked) < 2 {
|
||||
t.Fatalf("expected prechecked models to be preserved, got %v", gotPreChecked)
|
||||
|
||||
@@ -361,18 +361,12 @@ func buildModelList(existing []modelInfo, preChecked []string, current string) (
|
||||
}
|
||||
|
||||
if hasLocalModel || hasCloudModel {
|
||||
// Keep the Recommended section pinned to recommendedModels order. Checked
|
||||
// and default-model priority only apply within the More section.
|
||||
slices.SortStableFunc(items, func(a, b ModelItem) int {
|
||||
ac, bc := checked[a.Name], checked[b.Name]
|
||||
aNew, bNew := notInstalled[a.Name], notInstalled[b.Name]
|
||||
aRec, bRec := recRank[a.Name] > 0, recRank[b.Name] > 0
|
||||
aCloud, bCloud := cloudModels[a.Name], cloudModels[b.Name]
|
||||
|
||||
if ac != bc {
|
||||
if ac {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if aRec != bRec {
|
||||
if aRec {
|
||||
return -1
|
||||
@@ -380,14 +374,14 @@ func buildModelList(existing []modelInfo, preChecked []string, current string) (
|
||||
return 1
|
||||
}
|
||||
if aRec && bRec {
|
||||
if aCloud != bCloud {
|
||||
if aCloud {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return recRank[a.Name] - recRank[b.Name]
|
||||
}
|
||||
if ac != bc {
|
||||
if ac {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
// Among checked non-recommended items - put the default first
|
||||
if ac && !aRec && current != "" {
|
||||
aCurrent := a.Name == current
|
||||
|
||||
@@ -28,6 +28,8 @@ var openclawModelShowTimeout = 5 * time.Second
|
||||
// openclawFreshInstall is set to true when ensureOpenclawInstalled performs an install
|
||||
var openclawFreshInstall bool
|
||||
|
||||
var openclawCanInstallDaemon = canInstallDaemon
|
||||
|
||||
type Openclaw struct{}
|
||||
|
||||
func (c *Openclaw) String() string { return "OpenClaw" }
|
||||
@@ -58,6 +60,7 @@ func (c *Openclaw) Run(model string, args []string) error {
|
||||
// the newest wizard flags (e.g. --auth-choice ollama).
|
||||
if !openclawFreshInstall {
|
||||
update := exec.Command(bin, "update")
|
||||
update.Env = openclawInstallEnv()
|
||||
update.Stdout = os.Stdout
|
||||
update.Stderr = os.Stderr
|
||||
_ = update.Run() // best-effort; continue even if update fails
|
||||
@@ -73,19 +76,18 @@ func (c *Openclaw) Run(model string, args []string) error {
|
||||
"--auth-choice", "ollama",
|
||||
"--custom-base-url", envconfig.Host().String(),
|
||||
"--custom-model-id", model,
|
||||
// Launch owns the first real gateway startup immediately after onboarding,
|
||||
// so don't let OpenClaw fail the whole first-run flow on a transient
|
||||
// daemon health probe.
|
||||
"--skip-health",
|
||||
"--skip-channels",
|
||||
"--skip-skills",
|
||||
}
|
||||
if canInstallDaemon() {
|
||||
if openclawCanInstallDaemon() {
|
||||
onboardArgs = append(onboardArgs, "--install-daemon")
|
||||
} else {
|
||||
// When we can't install a daemon (e.g. no systemd, sudo dropped
|
||||
// XDG_RUNTIME_DIR, or container environment), skip the gateway
|
||||
// health check so non-interactive onboarding completes. The
|
||||
// gateway is started as a foreground child process after onboarding.
|
||||
onboardArgs = append(onboardArgs, "--skip-health")
|
||||
}
|
||||
cmd := exec.Command(bin, onboardArgs...)
|
||||
cmd.Env = openclawInstallEnv()
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
@@ -101,6 +103,18 @@ func (c *Openclaw) Run(model string, args []string) error {
|
||||
// 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 {
|
||||
cleanup := func() {}
|
||||
if shouldEnsureGatewayForArgs(args) {
|
||||
cleanupFn, _, _, err := c.ensureGatewayReady(bin)
|
||||
if err != nil {
|
||||
return windowsHint(err)
|
||||
}
|
||||
if cleanupFn != nil {
|
||||
cleanup = cleanupFn
|
||||
}
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.Env = openclawEnv()
|
||||
cmd.Stdin = os.Stdin
|
||||
@@ -121,41 +135,11 @@ func (c *Openclaw) Run(model string, args []string) error {
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\n%sStarting your assistant — this may take a moment...%s\n\n", ansiGray, ansiReset)
|
||||
|
||||
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 (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))
|
||||
cleanup, token, port, err := c.ensureGatewayReady(bin)
|
||||
if err != nil {
|
||||
return windowsHint(err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
printOpenclawReady(bin, token, port, firstLaunch)
|
||||
|
||||
@@ -175,6 +159,66 @@ func (c *Openclaw) Run(model string, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldEnsureGatewayForArgs(args []string) bool {
|
||||
return len(args) > 0 && args[0] == "tui"
|
||||
}
|
||||
|
||||
func (c *Openclaw) ensureGatewayReady(bin string) (func(), string, int, error) {
|
||||
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 (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 daemon is installed but not currently listening, try to bring it
|
||||
// up before falling back to a foreground child process.
|
||||
if openclawCanInstallDaemon() && !portOpen(addr) {
|
||||
start := exec.Command(bin, "daemon", "start")
|
||||
start.Env = openclawEnv()
|
||||
if err := start.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s Warning: daemon start failed: %v%s\n", ansiYellow, err, ansiReset)
|
||||
} else if waitForPort(addr, 10*time.Second) {
|
||||
fmt.Fprintf(os.Stderr, "%sStarting gateway...%s\n", ansiGray, ansiReset)
|
||||
return func() {}, token, port, nil
|
||||
}
|
||||
}
|
||||
|
||||
cleanup := func() {}
|
||||
|
||||
// If the gateway still 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 nil, "", 0, fmt.Errorf("failed to start gateway: %w", err)
|
||||
}
|
||||
cleanup = 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) {
|
||||
cleanup()
|
||||
return nil, "", 0, fmt.Errorf("gateway did not start on %s", addr)
|
||||
}
|
||||
|
||||
return cleanup, token, port, nil
|
||||
}
|
||||
|
||||
// runChannelSetupPreflight prompts users to connect a messaging channel before
|
||||
// starting the built-in gateway+TUI flow. In interactive sessions, it loops
|
||||
// until a channel is configured, unless the user chooses "Set up later".
|
||||
@@ -335,9 +379,30 @@ func openclawEnv() []string {
|
||||
env = append(env, e)
|
||||
}
|
||||
}
|
||||
if _, ok := os.LookupEnv("OPENCLAW_PLUGIN_STAGE_DIR"); !ok {
|
||||
if dir := openclawPluginStageDir(); dir != "" {
|
||||
env = append(env, "OPENCLAW_PLUGIN_STAGE_DIR="+dir)
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func openclawInstallEnv() []string {
|
||||
env := openclawEnv()
|
||||
if _, ok := os.LookupEnv("OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS"); !ok {
|
||||
env = append(env, "OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS=1")
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func openclawPluginStageDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".openclaw", "plugin-runtime-deps")
|
||||
}
|
||||
|
||||
// portOpen checks if a TCP port is currently accepting connections.
|
||||
func portOpen(addr string) bool {
|
||||
conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
|
||||
@@ -561,6 +626,7 @@ func ensureOpenclawInstalled() (string, error) {
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\nInstalling OpenClaw...\n")
|
||||
cmd := exec.Command("npm", "install", "-g", "openclaw@latest")
|
||||
cmd.Env = openclawInstallEnv()
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
@@ -251,6 +251,359 @@ func TestOpenclawRun_SetupLaterContinuesToGatewayAndTUI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawRun_FirstLaunchOnboardUsesLaunchManagedHealthFlow(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)
|
||||
|
||||
bin := filepath.Join(tmpDir, "openclaw")
|
||||
script := fmt.Sprintf(`#!/bin/sh
|
||||
printf '%%s\n' "$*" >> "$HOME/invocations.log"
|
||||
if [ "$1" = "onboard" ]; then
|
||||
/usr/bin/env | /usr/bin/sort > "$HOME/onboard-env.log"
|
||||
/bin/mkdir -p "$HOME/.openclaw"
|
||||
/bin/cat > "$HOME/.openclaw/openclaw.json" <<'EOF'
|
||||
{"wizard":{"lastRunAt":"2026-01-01T00:00:00Z"},"gateway":{"port":18789,"mode":"local"}}
|
||||
EOF
|
||||
fi
|
||||
exit 0
|
||||
`)
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldConfirmPrompt := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
if prompt != "I understand the risks. Continue?" {
|
||||
t.Fatalf("unexpected prompt: %q", prompt)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||
|
||||
c := &Openclaw{}
|
||||
if err := c.Run("llama3.2", []string{"status"}); 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) < 2 {
|
||||
t.Fatalf("expected onboard + passthrough invocations, got %v", lines)
|
||||
}
|
||||
onboardInvocation := ""
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "onboard ") {
|
||||
onboardInvocation = line
|
||||
break
|
||||
}
|
||||
}
|
||||
if onboardInvocation == "" {
|
||||
t.Fatalf("expected onboard invocation, got %v", lines)
|
||||
}
|
||||
if !strings.Contains(onboardInvocation, "--skip-health") {
|
||||
t.Fatalf("expected onboard invocation to include --skip-health, got %q", onboardInvocation)
|
||||
}
|
||||
|
||||
envData, err := os.ReadFile(filepath.Join(tmpDir, "onboard-env.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := envSliceToMap(strings.Split(strings.TrimSpace(string(envData)), "\n"))
|
||||
if env["OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS"] != "1" {
|
||||
t.Fatalf("OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS = %q, want %q", env["OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS"], "1")
|
||||
}
|
||||
if env["OPENCLAW_PLUGIN_STAGE_DIR"] != filepath.Join(tmpDir, ".openclaw", "plugin-runtime-deps") {
|
||||
t.Fatalf("OPENCLAW_PLUGIN_STAGE_DIR = %q, want %q", env["OPENCLAW_PLUGIN_STAGE_DIR"], filepath.Join(tmpDir, ".openclaw", "plugin-runtime-deps"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawRun_FirstLaunchTUIArgsEnsureGatewayBeforePassthrough(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)
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
|
||||
bin := filepath.Join(tmpDir, "openclaw")
|
||||
script := fmt.Sprintf(`#!/bin/sh
|
||||
printf '%%s\n' "$*" >> "$HOME/invocations.log"
|
||||
if [ "$1" = "onboard" ]; then
|
||||
/bin/mkdir -p "$HOME/.openclaw"
|
||||
/bin/cat > "$HOME/.openclaw/openclaw.json" <<'EOF'
|
||||
{"wizard":{"lastRunAt":"2026-01-01T00:00:00Z"},"gateway":{"port":%d,"mode":"local"}}
|
||||
EOF
|
||||
fi
|
||||
exit 0
|
||||
`, port)
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldConfirmPrompt := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
if prompt != "I understand the risks. Continue?" {
|
||||
t.Fatalf("unexpected prompt: %q", prompt)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||
|
||||
c := &Openclaw{}
|
||||
if err := c.Run("llama3.2", []string{"tui"}); 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) < 3 {
|
||||
t.Fatalf("expected at least 3 invocations (update, onboard, daemon restart, tui), got %v", lines)
|
||||
}
|
||||
onboardIdx, daemonRestartIdx, tuiIdx := -1, -1, -1
|
||||
for i, line := range lines {
|
||||
if onboardIdx == -1 && strings.HasPrefix(line, "onboard ") {
|
||||
onboardIdx = i
|
||||
}
|
||||
if daemonRestartIdx == -1 && line == "daemon restart" {
|
||||
daemonRestartIdx = i
|
||||
}
|
||||
if tuiIdx == -1 && line == "tui" {
|
||||
tuiIdx = i
|
||||
}
|
||||
}
|
||||
if onboardIdx == -1 {
|
||||
t.Fatalf("expected an onboarding invocation, got %v", lines)
|
||||
}
|
||||
if daemonRestartIdx == -1 {
|
||||
t.Fatalf("expected a daemon restart before tui, got %v", lines)
|
||||
}
|
||||
if tuiIdx == -1 {
|
||||
t.Fatalf("expected a tui invocation, got %v", lines)
|
||||
}
|
||||
if !(onboardIdx < daemonRestartIdx && daemonRestartIdx < tuiIdx) {
|
||||
t.Fatalf("expected onboarding, then daemon restart, then tui; got %v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawEnsureGatewayReady_UsesDaemonStartFallback(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)
|
||||
|
||||
portProbe, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port := portProbe.Addr().(*net.TCPAddr).Port
|
||||
_ = portProbe.Close()
|
||||
|
||||
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(fmt.Sprintf(`{
|
||||
"wizard": {"lastRunAt": "2026-01-01T00:00:00Z"},
|
||||
"gateway": {"port": %d, "mode": "local"}
|
||||
}`, port)), 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)
|
||||
}
|
||||
|
||||
oldCanInstallDaemon := openclawCanInstallDaemon
|
||||
openclawCanInstallDaemon = func() bool { return true }
|
||||
defer func() { openclawCanInstallDaemon = oldCanInstallDaemon }()
|
||||
|
||||
triggeredBy := make(chan string, 1)
|
||||
listenerReady := make(chan net.Listener, 1)
|
||||
go func() {
|
||||
invocationsPath := filepath.Join(tmpDir, "invocations.log")
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
data, err := os.ReadFile(invocationsPath)
|
||||
if err == nil {
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
for _, line := range lines {
|
||||
if line != "daemon start" && line != "gateway run --force" {
|
||||
continue
|
||||
}
|
||||
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = conn.Close()
|
||||
}
|
||||
}()
|
||||
triggeredBy <- line
|
||||
listenerReady <- ln
|
||||
return
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
c := &Openclaw{}
|
||||
cleanup, _, gotPort, err := c.ensureGatewayReady(bin)
|
||||
if err != nil {
|
||||
t.Fatalf("ensureGatewayReady() error = %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
if gotPort != port {
|
||||
t.Fatalf("ensureGatewayReady() port = %d, want %d", gotPort, port)
|
||||
}
|
||||
|
||||
var ln net.Listener
|
||||
select {
|
||||
case which := <-triggeredBy:
|
||||
if which != "daemon start" {
|
||||
t.Fatalf("expected daemon start fallback, got %q", which)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for gateway startup trigger")
|
||||
}
|
||||
select {
|
||||
case ln = <-listenerReady:
|
||||
defer ln.Close()
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for test listener")
|
||||
}
|
||||
|
||||
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) == 0 || lines[0] != "daemon start" {
|
||||
t.Fatalf("expected daemon start invocation, got %v", lines)
|
||||
}
|
||||
for _, line := range lines {
|
||||
if line == "gateway run --force" {
|
||||
t.Fatalf("did not expect gateway run fallback when daemon start succeeds, got %v", lines)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawEnv_StagesBundledPluginRuntimeDeps(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("OPENAI_API_KEY", "should-be-cleared")
|
||||
|
||||
env := envSliceToMap(openclawEnv())
|
||||
|
||||
if env["OPENCLAW_PLUGIN_STAGE_DIR"] != filepath.Join(tmpDir, ".openclaw", "plugin-runtime-deps") {
|
||||
t.Fatalf("OPENCLAW_PLUGIN_STAGE_DIR = %q, want %q", env["OPENCLAW_PLUGIN_STAGE_DIR"], filepath.Join(tmpDir, ".openclaw", "plugin-runtime-deps"))
|
||||
}
|
||||
if _, ok := env["OPENAI_API_KEY"]; ok {
|
||||
t.Fatal("expected OPENAI_API_KEY to be cleared from openclaw environment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawInstallEnv_PreservesExplicitStageDirAndAddsEagerDeps(t *testing.T) {
|
||||
t.Setenv("OPENCLAW_PLUGIN_STAGE_DIR", "/tmp/custom-stage")
|
||||
|
||||
env := envSliceToMap(openclawInstallEnv())
|
||||
|
||||
if env["OPENCLAW_PLUGIN_STAGE_DIR"] != "/tmp/custom-stage" {
|
||||
t.Fatalf("OPENCLAW_PLUGIN_STAGE_DIR = %q, want %q", env["OPENCLAW_PLUGIN_STAGE_DIR"], "/tmp/custom-stage")
|
||||
}
|
||||
if env["OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS"] != "1" {
|
||||
t.Fatalf("OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS = %q, want %q", env["OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS"], "1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureOpenclawInstalled_UsesBundledPluginInstallEnv(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)
|
||||
|
||||
writeScript := func(path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
openclawPath := filepath.Join(tmpDir, "openclaw")
|
||||
npmScript := fmt.Sprintf(`#!/bin/sh
|
||||
/usr/bin/env | /usr/bin/sort > "$HOME/npm-env.log"
|
||||
/bin/cat > %q <<'EOF'
|
||||
#!/bin/sh
|
||||
exit 0
|
||||
EOF
|
||||
/bin/chmod +x %q
|
||||
exit 0
|
||||
`, openclawPath, openclawPath)
|
||||
writeScript(filepath.Join(tmpDir, "npm"), npmScript)
|
||||
writeScript(filepath.Join(tmpDir, "git"), "#!/bin/sh\nexit 0\n")
|
||||
|
||||
oldConfirmPrompt := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
if prompt != "OpenClaw is not installed. Install with npm?" {
|
||||
t.Fatalf("unexpected prompt: %q", prompt)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||
|
||||
openclawFreshInstall = false
|
||||
bin, err := ensureOpenclawInstalled()
|
||||
if err != nil {
|
||||
t.Fatalf("ensureOpenclawInstalled() error = %v", err)
|
||||
}
|
||||
if bin != "openclaw" {
|
||||
t.Fatalf("ensureOpenclawInstalled() bin = %q, want %q", bin, "openclaw")
|
||||
}
|
||||
|
||||
envData, err := os.ReadFile(filepath.Join(tmpDir, "npm-env.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := envSliceToMap(strings.Split(strings.TrimSpace(string(envData)), "\n"))
|
||||
if env["OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS"] != "1" {
|
||||
t.Fatalf("OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS = %q, want %q", env["OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS"], "1")
|
||||
}
|
||||
if env["OPENCLAW_PLUGIN_STAGE_DIR"] != filepath.Join(tmpDir, ".openclaw", "plugin-runtime-deps") {
|
||||
t.Fatalf("OPENCLAW_PLUGIN_STAGE_DIR = %q, want %q", env["OPENCLAW_PLUGIN_STAGE_DIR"], filepath.Join(tmpDir, ".openclaw", "plugin-runtime-deps"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawEdit(t *testing.T) {
|
||||
c := &Openclaw{}
|
||||
tmpDir := t.TempDir()
|
||||
@@ -1227,6 +1580,18 @@ func TestOpenclawChannelsConfigured(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func envSliceToMap(entries []string) map[string]string {
|
||||
env := make(map[string]string, len(entries))
|
||||
for _, entry := range entries {
|
||||
key, value, ok := strings.Cut(entry, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
env[key] = value
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func TestOpenclawChannelSetupPreflight(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell test binary")
|
||||
|
||||
@@ -632,8 +632,8 @@ func FromChatRequest(r ChatCompletionRequest) (*api.ChatRequest, error) {
|
||||
}
|
||||
|
||||
if effort != "" {
|
||||
if !slices.Contains([]string{"high", "medium", "low", "none"}, effort) {
|
||||
return nil, fmt.Errorf("invalid reasoning value: '%s' (must be \"high\", \"medium\", \"low\", or \"none\")", effort)
|
||||
if !slices.Contains([]string{"high", "medium", "low", "max", "none"}, effort) {
|
||||
return nil, fmt.Errorf("invalid reasoning value: '%s' (must be \"high\", \"medium\", \"low\", \"max\", or \"none\")", effort)
|
||||
}
|
||||
|
||||
if effort == "none" {
|
||||
|
||||
@@ -55,6 +55,57 @@ func TestFromChatRequest_Basic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromChatRequest_ReasoningEffort(t *testing.T) {
|
||||
effort := func(s string) *string { return &s }
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
effort *string
|
||||
want any // expected ThinkValue.Value; nil means req.Think should be nil
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "unset", effort: nil, want: nil},
|
||||
{name: "high", effort: effort("high"), want: "high"},
|
||||
{name: "medium", effort: effort("medium"), want: "medium"},
|
||||
{name: "low", effort: effort("low"), want: "low"},
|
||||
{name: "max", effort: effort("max"), want: "max"},
|
||||
{name: "none disables", effort: effort("none"), want: false},
|
||||
{name: "invalid", effort: effort("extreme"), wantErr: true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := ChatCompletionRequest{
|
||||
Model: "test-model",
|
||||
Messages: []Message{{Role: "user", Content: "hi"}},
|
||||
ReasoningEffort: tc.effort,
|
||||
}
|
||||
result, err := FromChatRequest(req)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for effort=%v, got none", *tc.effort)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tc.want == nil {
|
||||
if result.Think != nil {
|
||||
t.Fatalf("expected nil Think, got %+v", result.Think)
|
||||
}
|
||||
return
|
||||
}
|
||||
if result.Think == nil {
|
||||
t.Fatalf("expected Think=%v, got nil", tc.want)
|
||||
}
|
||||
if result.Think.Value != tc.want {
|
||||
t.Fatalf("got Think.Value=%v, want %v", result.Think.Value, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromChatRequest_WithImage(t *testing.T) {
|
||||
imgData, _ := base64.StdEncoding.DecodeString(image)
|
||||
|
||||
|
||||
@@ -525,6 +525,18 @@ func FromResponsesRequest(r ResponsesRequest) (*api.ChatRequest, error) {
|
||||
options["num_predict"] = *r.MaxOutputTokens
|
||||
}
|
||||
|
||||
var think *api.ThinkValue
|
||||
if effort := r.Reasoning.Effort; effort != "" {
|
||||
switch effort {
|
||||
case "none":
|
||||
think = &api.ThinkValue{Value: false}
|
||||
case "low", "medium", "high", "max":
|
||||
think = &api.ThinkValue{Value: effort}
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid reasoning value: %q (must be \"high\", \"medium\", \"low\", \"max\", or \"none\")", effort)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert tools from Responses API format to api.Tool format
|
||||
var tools []api.Tool
|
||||
for _, t := range r.Tools {
|
||||
@@ -552,6 +564,7 @@ func FromResponsesRequest(r ResponsesRequest) (*api.ChatRequest, error) {
|
||||
Options: options,
|
||||
Tools: tools,
|
||||
Format: format,
|
||||
Think: think,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -415,6 +415,86 @@ func TestFromResponsesRequest_Tools(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromResponsesRequest_ReasoningEffort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
effort string
|
||||
wantThink any
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "unset",
|
||||
},
|
||||
{
|
||||
name: "low",
|
||||
effort: "low",
|
||||
wantThink: "low",
|
||||
},
|
||||
{
|
||||
name: "medium",
|
||||
effort: "medium",
|
||||
wantThink: "medium",
|
||||
},
|
||||
{
|
||||
name: "high",
|
||||
effort: "high",
|
||||
wantThink: "high",
|
||||
},
|
||||
{
|
||||
name: "max",
|
||||
effort: "max",
|
||||
wantThink: "max",
|
||||
},
|
||||
{
|
||||
name: "none",
|
||||
effort: "none",
|
||||
wantThink: false,
|
||||
},
|
||||
{
|
||||
name: "invalid",
|
||||
effort: "extreme",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := ResponsesRequest{
|
||||
Model: "deepseek-v4-flash",
|
||||
Input: ResponsesInput{Text: "hi"},
|
||||
}
|
||||
if tt.effort != "" {
|
||||
req.Reasoning.Effort = tt.effort
|
||||
}
|
||||
|
||||
chatReq, err := FromResponsesRequest(req)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if tt.wantThink == nil {
|
||||
if chatReq.Think != nil {
|
||||
t.Fatalf("Think = %#v, want nil", chatReq.Think)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if chatReq.Think == nil {
|
||||
t.Fatalf("Think = nil, want %v", tt.wantThink)
|
||||
}
|
||||
if chatReq.Think.Value != tt.wantThink {
|
||||
t.Errorf("Think.Value = %v, want %v", chatReq.Think.Value, tt.wantThink)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromResponsesRequest_FunctionCallOutput(t *testing.T) {
|
||||
// Test a complete tool call round-trip:
|
||||
// 1. User message asking about weather
|
||||
|
||||
@@ -375,8 +375,16 @@ func (s *Server) GenerateHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
var builtinParser parsers.Parser
|
||||
if shouldUseHarmony(m) && m.Config.Parser == "" {
|
||||
m.Config.Parser = "harmony"
|
||||
if shouldUseHarmony(m) {
|
||||
// harmony's Reasoning field only understands low/medium/high; map "max" to "high"
|
||||
if req.Think != nil {
|
||||
if s, ok := req.Think.Value.(string); ok && s == "max" {
|
||||
req.Think.Value = "high"
|
||||
}
|
||||
}
|
||||
if m.Config.Parser == "" {
|
||||
m.Config.Parser = "harmony"
|
||||
}
|
||||
}
|
||||
|
||||
if !req.Raw && m.Config.Parser != "" {
|
||||
@@ -2320,8 +2328,16 @@ func (s *Server) ChatHandler(c *gin.Context) {
|
||||
}
|
||||
msgs = filterThinkTags(msgs, m)
|
||||
|
||||
if shouldUseHarmony(m) && m.Config.Parser == "" {
|
||||
m.Config.Parser = "harmony"
|
||||
if shouldUseHarmony(m) {
|
||||
// harmony's Reasoning field only understands low/medium/high; map "max" to "high"
|
||||
if req.Think != nil {
|
||||
if s, ok := req.Think.Value.(string); ok && s == "max" {
|
||||
req.Think.Value = "high"
|
||||
}
|
||||
}
|
||||
if m.Config.Parser == "" {
|
||||
m.Config.Parser = "harmony"
|
||||
}
|
||||
}
|
||||
|
||||
var builtinParser parsers.Parser
|
||||
|
||||
Reference in New Issue
Block a user