Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52b7205e5e | |||
| 2363cf3ffa | |||
| 0f3b96d5d4 | |||
| 07274664a7 | |||
| 3946d0317c | |||
| cc8e8f6c72 | |||
| a1968d285d |
+2
-1
@@ -865,7 +865,8 @@ type CloudStatus struct {
|
||||
|
||||
// StatusResponse is the response from [Client.CloudStatusExperimental].
|
||||
type StatusResponse struct {
|
||||
Cloud CloudStatus `json:"cloud"`
|
||||
Cloud CloudStatus `json:"cloud"`
|
||||
ContextLength int `json:"context_length,omitempty"`
|
||||
}
|
||||
|
||||
// WebSearchRequest is the request for [Client.WebSearchExperimental].
|
||||
|
||||
+60
-5
@@ -1,6 +1,7 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -15,6 +16,8 @@ import (
|
||||
// Claude implements Runner for Claude Code integration.
|
||||
type Claude struct{}
|
||||
|
||||
const claudeCodeAutoCompactMinContext = 100_000
|
||||
|
||||
func (c *Claude) String() string { return "Claude Code" }
|
||||
|
||||
func (c *Claude) args(model string, extra []string) []string {
|
||||
@@ -49,22 +52,27 @@ func (c *Claude) findPath() (string, error) {
|
||||
return "", fmt.Errorf("claude binary not found")
|
||||
}
|
||||
|
||||
func (c *Claude) Run(model string, _ []LaunchModel, args []string) error {
|
||||
func (c *Claude) Run(model string, models []LaunchModel, args []string) error {
|
||||
claudePath, err := ensureClaudeInstalled()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
contextLength := 0
|
||||
if len(models) > 0 {
|
||||
contextLength = models[0].ContextLength
|
||||
}
|
||||
|
||||
cmd := exec.Command(claudePath, c.args(model, args)...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
cmd.Env = append(os.Environ(), c.envVars(model)...)
|
||||
cmd.Env = append(os.Environ(), c.envVars(model, contextLength)...)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func (c *Claude) envVars(model string) []string {
|
||||
func (c *Claude) envVars(model string, contextLength int) []string {
|
||||
env := []string{
|
||||
"ANTHROPIC_BASE_URL=" + envconfig.Host().String(),
|
||||
"ANTHROPIC_API_KEY=",
|
||||
@@ -77,7 +85,7 @@ func (c *Claude) envVars(model string) []string {
|
||||
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1",
|
||||
}
|
||||
|
||||
env = append(env, c.modelEnvVars(model)...)
|
||||
env = append(env, c.modelEnvVars(model, contextLength)...)
|
||||
return env
|
||||
}
|
||||
|
||||
@@ -162,8 +170,53 @@ func claudeInstallerCommand(goos string) (string, []string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Claude) prepareRunLaunchModels(ctx context.Context, client *launcherClient, model string, models []LaunchModel) ([]LaunchModel, error) {
|
||||
if model == "" || isCloudModelName(model) {
|
||||
return models, nil
|
||||
}
|
||||
|
||||
contextLength, ok := client.localServerContextLength(ctx)
|
||||
if !ok {
|
||||
return models, nil
|
||||
}
|
||||
|
||||
models = launchModelsWithContextLength(model, models, contextLength)
|
||||
if contextLength >= claudeCodeAutoCompactMinContext {
|
||||
return models, nil
|
||||
}
|
||||
|
||||
if err := confirmLocalContextWarning(c.String(), contextLength, claudeCodeAutoCompactMinContext); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
func launchModelsWithContextLength(primary string, models []LaunchModel, contextLength int) []LaunchModel {
|
||||
if contextLength <= 0 {
|
||||
return models
|
||||
}
|
||||
if len(models) == 0 && primary != "" {
|
||||
models = launchModelsFromNames([]string{primary})
|
||||
}
|
||||
|
||||
out := cloneLaunchModels(models)
|
||||
for i := range out {
|
||||
if launchModelMatches(out[i].Name, primary) {
|
||||
out[i].ContextLength = contextLength
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
if primary != "" {
|
||||
model := fallbackLaunchModel(primary)
|
||||
model.ContextLength = contextLength
|
||||
out = append([]LaunchModel{model}, out...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// modelEnvVars returns Claude Code env vars that route all model tiers through Ollama.
|
||||
func (c *Claude) modelEnvVars(model string) []string {
|
||||
func (c *Claude) modelEnvVars(model string, contextLength int) []string {
|
||||
env := []string{
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL=" + model,
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL=" + model,
|
||||
@@ -175,6 +228,8 @@ func (c *Claude) modelEnvVars(model string) []string {
|
||||
if l, ok := lookupCloudModelLimit(model); ok {
|
||||
env = append(env, "CLAUDE_CODE_AUTO_COMPACT_WINDOW="+strconv.Itoa(l.Context))
|
||||
}
|
||||
} else if contextLength >= claudeCodeAutoCompactMinContext {
|
||||
env = append(env, "CLAUDE_CODE_AUTO_COMPACT_WINDOW="+strconv.Itoa(contextLength))
|
||||
}
|
||||
|
||||
return env
|
||||
|
||||
+142
-5
@@ -1,6 +1,8 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -346,7 +348,7 @@ func TestClaudeEnvVars(t *testing.T) {
|
||||
return m
|
||||
}
|
||||
|
||||
got := envMap(c.envVars("llama3.2"))
|
||||
got := envMap(c.envVars("llama3.2", 0))
|
||||
for key, want := range map[string]string{
|
||||
"ANTHROPIC_BASE_URL": envconfig.Host().String(),
|
||||
"ANTHROPIC_API_KEY": "",
|
||||
@@ -368,6 +370,127 @@ func TestClaudeEnvVars(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudePrepareRunLaunchModelsWarnsForLowLocalContext(t *testing.T) {
|
||||
client, _ := testLauncherClientWithStatus(t, 32*1024)
|
||||
|
||||
oldConfirm := DefaultConfirmPrompt
|
||||
defer func() { DefaultConfirmPrompt = oldConfirm }()
|
||||
|
||||
var gotPrompt string
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
gotPrompt = prompt
|
||||
if options.Default != ConfirmDefaultNo {
|
||||
t.Fatal("expected warning prompt to default to no")
|
||||
}
|
||||
if options.YesLabel != "Launch anyway" || options.NoLabel != "Cancel" {
|
||||
t.Fatalf("labels = %q/%q, want Launch anyway/Cancel", options.YesLabel, options.NoLabel)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
_, err := client.prepareLaunchModelsForRun(context.Background(), &Claude{}, "llama3.2", []LaunchModel{{Name: "llama3.2"}})
|
||||
if !errors.Is(err, ErrCancelled) {
|
||||
t.Fatalf("error = %v, want ErrCancelled", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Claude Code works best with at least 100k context. Current local context: 32k.",
|
||||
"Launch Claude Code anyway?",
|
||||
} {
|
||||
if !strings.Contains(gotPrompt, want) {
|
||||
t.Fatalf("prompt missing %q:\n%s", want, gotPrompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudePrepareRunLaunchModelsSetsHighLocalContextWithoutWarning(t *testing.T) {
|
||||
client, _ := testLauncherClientWithStatus(t, 128*1024)
|
||||
|
||||
oldConfirm := DefaultConfirmPrompt
|
||||
defer func() { DefaultConfirmPrompt = oldConfirm }()
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
t.Fatalf("did not expect prompt, got %q", prompt)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
models, err := client.prepareLaunchModelsForRun(context.Background(), &Claude{}, "llama3.2", []LaunchModel{{Name: "llama3.2"}})
|
||||
if err != nil {
|
||||
t.Fatalf("prepareLaunchModelsForRun error = %v", err)
|
||||
}
|
||||
if len(models) != 1 || models[0].ContextLength != 128*1024 {
|
||||
t.Fatalf("models = %+v, want local context length set", models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudePrepareRunLaunchModelsMatchesLatestSuffix(t *testing.T) {
|
||||
client, _ := testLauncherClientWithStatus(t, 128*1024)
|
||||
|
||||
models, err := client.prepareLaunchModelsForRun(context.Background(), &Claude{}, "gemma4", []LaunchModel{{Name: "gemma4:latest"}})
|
||||
if err != nil {
|
||||
t.Fatalf("prepareLaunchModelsForRun error = %v", err)
|
||||
}
|
||||
if len(models) != 1 {
|
||||
t.Fatalf("models = %+v, want existing model updated without fallback", models)
|
||||
}
|
||||
if models[0].Name != "gemma4:latest" || models[0].ContextLength != 128*1024 {
|
||||
t.Fatalf("model = %+v, want latest-suffixed model updated with context length", models[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudePrepareRunLaunchModelsYesPrintsShortWarning(t *testing.T) {
|
||||
client, _ := testLauncherClientWithStatus(t, 32*1024)
|
||||
|
||||
oldConfirm := DefaultConfirmPrompt
|
||||
defer func() { DefaultConfirmPrompt = oldConfirm }()
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
t.Fatalf("did not expect prompt with --yes, got %q", prompt)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true})
|
||||
defer restoreConfirm()
|
||||
|
||||
output := captureContextWarningStderr(t, func() {
|
||||
models, err := client.prepareLaunchModelsForRun(context.Background(), &Claude{}, "llama3.2", []LaunchModel{{Name: "llama3.2"}})
|
||||
if err != nil {
|
||||
t.Fatalf("prepareLaunchModelsForRun error = %v", err)
|
||||
}
|
||||
if len(models) != 1 || models[0].ContextLength != 32*1024 {
|
||||
t.Fatalf("models = %+v, want local context length set", models)
|
||||
}
|
||||
})
|
||||
|
||||
for _, want := range []string{
|
||||
"Warning: Claude Code works best with at least 100k context; current local context is 32k.",
|
||||
"Continuing because --yes was provided.",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("stderr missing %q:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudePrepareRunLaunchModelsSkipsCloudModels(t *testing.T) {
|
||||
client, statusCalls := testLauncherClientWithStatus(t, 32*1024)
|
||||
|
||||
oldConfirm := DefaultConfirmPrompt
|
||||
defer func() { DefaultConfirmPrompt = oldConfirm }()
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
t.Fatalf("did not expect prompt, got %q", prompt)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
models, err := client.prepareLaunchModelsForRun(context.Background(), &Claude{}, "glm-5:cloud", []LaunchModel{{Name: "glm-5:cloud", Remote: true}})
|
||||
if err != nil {
|
||||
t.Fatalf("prepareLaunchModelsForRun error = %v", err)
|
||||
}
|
||||
if *statusCalls != 0 {
|
||||
t.Fatalf("status calls = %d, want 0", *statusCalls)
|
||||
}
|
||||
if len(models) != 1 || models[0].ContextLength != 0 {
|
||||
t.Fatalf("models = %+v, want unchanged cloud model", models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeModelEnvVars(t *testing.T) {
|
||||
c := &Claude{}
|
||||
|
||||
@@ -381,7 +504,7 @@ func TestClaudeModelEnvVars(t *testing.T) {
|
||||
}
|
||||
|
||||
t.Run("maps all Claude model env vars to the provided model", func(t *testing.T) {
|
||||
got := envMap(c.modelEnvVars("llama3.2"))
|
||||
got := envMap(c.modelEnvVars("llama3.2", 0))
|
||||
if got["ANTHROPIC_DEFAULT_OPUS_MODEL"] != "llama3.2" {
|
||||
t.Errorf("OPUS = %q, want llama3.2", got["ANTHROPIC_DEFAULT_OPUS_MODEL"])
|
||||
}
|
||||
@@ -400,7 +523,7 @@ func TestClaudeModelEnvVars(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("supports empty model", func(t *testing.T) {
|
||||
got := envMap(c.modelEnvVars(""))
|
||||
got := envMap(c.modelEnvVars("", 0))
|
||||
if got["ANTHROPIC_DEFAULT_OPUS_MODEL"] != "" {
|
||||
t.Errorf("OPUS = %q, want empty", got["ANTHROPIC_DEFAULT_OPUS_MODEL"])
|
||||
}
|
||||
@@ -419,14 +542,28 @@ func TestClaudeModelEnvVars(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("sets auto compact window for known cloud models", func(t *testing.T) {
|
||||
got := envMap(c.modelEnvVars("glm-5:cloud"))
|
||||
got := envMap(c.modelEnvVars("glm-5:cloud", 0))
|
||||
if got["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] != "202752" {
|
||||
t.Errorf("AUTO_COMPACT_WINDOW = %q, want 202752", got["CLAUDE_CODE_AUTO_COMPACT_WINDOW"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sets auto compact window for local models at Claude minimum", func(t *testing.T) {
|
||||
got := envMap(c.modelEnvVars("llama3.2", 131072))
|
||||
if got["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] != "131072" {
|
||||
t.Errorf("AUTO_COMPACT_WINDOW = %q, want 131072", got["CLAUDE_CODE_AUTO_COMPACT_WINDOW"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does not set auto compact window for local models below Claude minimum", func(t *testing.T) {
|
||||
got := envMap(c.modelEnvVars("llama3.2", 32768))
|
||||
if got["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] != "" {
|
||||
t.Errorf("AUTO_COMPACT_WINDOW = %q, want empty", got["CLAUDE_CODE_AUTO_COMPACT_WINDOW"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does not set auto compact window for unknown cloud models", func(t *testing.T) {
|
||||
got := envMap(c.modelEnvVars("unknown-model:cloud"))
|
||||
got := envMap(c.modelEnvVars("unknown-model:cloud", 0))
|
||||
if got["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] != "" {
|
||||
t.Errorf("AUTO_COMPACT_WINDOW = %q, want empty", got["CLAUDE_CODE_AUTO_COMPACT_WINDOW"])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (c *launcherClient) localServerContextLength(ctx context.Context) (int, bool) {
|
||||
if c == nil || c.apiClient == nil {
|
||||
return 0, false
|
||||
}
|
||||
status, err := c.apiClient.CloudStatusExperimental(ctx)
|
||||
if err != nil || status.ContextLength <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return status.ContextLength, true
|
||||
}
|
||||
|
||||
func confirmLocalContextWarning(integration string, current, recommended int) error {
|
||||
shortWarning := fmt.Sprintf(
|
||||
"Warning: %s works best with at least %s context; current local context is %s.",
|
||||
integration,
|
||||
formatContextLength(recommended),
|
||||
formatContextLength(current),
|
||||
)
|
||||
if currentLaunchConfirmPolicy.yes {
|
||||
fmt.Fprintf(os.Stderr, "%s\nContinuing because --yes was provided.\n", shortWarning)
|
||||
return nil
|
||||
}
|
||||
if currentLaunchConfirmPolicy.requireYesMessage {
|
||||
return fmt.Errorf("%s Re-run with --yes to continue", shortWarning)
|
||||
}
|
||||
|
||||
ok, err := ConfirmPromptWithOptions(localContextLengthPrompt(integration, current, recommended), ConfirmOptions{
|
||||
YesLabel: "Launch anyway",
|
||||
NoLabel: "Cancel",
|
||||
Default: ConfirmDefaultNo,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrCancelled
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func localContextLengthPrompt(integration string, current, recommended int) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%s works best with at least %s context. ", integration, formatContextLength(recommended))
|
||||
fmt.Fprintf(&b, "Current local context: %s. ", formatContextLength(current))
|
||||
b.WriteString("Adjust Context length in Ollama Settings and restart to change this:\n")
|
||||
b.WriteString(" https://docs.ollama.com/context-length")
|
||||
fmt.Fprintf(&b, "\n\nLaunch %s anyway?", integration)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func formatContextLength(tokens int) string {
|
||||
switch {
|
||||
case tokens <= 0:
|
||||
return strconv.Itoa(tokens)
|
||||
case tokens%1024 == 0:
|
||||
return strconv.Itoa(tokens/1024) + "k"
|
||||
case tokens%1000 == 0:
|
||||
return strconv.Itoa(tokens/1000) + "k"
|
||||
default:
|
||||
return strconv.Itoa(tokens)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func testLauncherClientWithStatus(t *testing.T, contextLength int) (*launcherClient, *int) {
|
||||
t.Helper()
|
||||
|
||||
statusCalls := 0
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path != "/api/status" {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":"not found"}`)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}
|
||||
statusCalls++
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(fmt.Sprintf(`{"cloud":{"disabled":false,"source":"none"},"context_length":%d}`, contextLength))),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})}
|
||||
|
||||
u, err := url.Parse("http://ollama.test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &launcherClient{apiClient: api.NewClient(u, client)}, &statusCalls
|
||||
}
|
||||
|
||||
func captureContextWarningStderr(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
|
||||
old := os.Stderr
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stderr = w
|
||||
defer func() {
|
||||
os.Stderr = old
|
||||
r.Close()
|
||||
}()
|
||||
|
||||
fn()
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestFormatContextLength(t *testing.T) {
|
||||
tests := map[int]string{
|
||||
32 * 1024: "32k",
|
||||
64 * 1024: "64k",
|
||||
100_000: "100k",
|
||||
100_001: "100001",
|
||||
}
|
||||
for tokens, want := range tests {
|
||||
if got := formatContextLength(tokens); got != want {
|
||||
t.Fatalf("formatContextLength(%d) = %q, want %q", tokens, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalContextLengthPrompt(t *testing.T) {
|
||||
got := localContextLengthPrompt("Claude Code", 32*1024, 100_000)
|
||||
want := "Claude Code works best with at least 100k context. " +
|
||||
"Current local context: 32k. " +
|
||||
"Adjust Context length in Ollama Settings and restart to change this:\n" +
|
||||
" https://docs.ollama.com/context-length\n\n" +
|
||||
"Launch Claude Code anyway?"
|
||||
if got != want {
|
||||
t.Fatalf("prompt = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
+31
-4
@@ -744,7 +744,7 @@ func (c *launcherClient) launchSingleIntegration(ctx context.Context, name strin
|
||||
}
|
||||
}
|
||||
|
||||
return launchAfterConfiguration(name, runner, target, c.resolveRunModels(ctx, []string{target}), req)
|
||||
return c.launchAfterConfiguration(ctx, name, runner, target, c.resolveRunModels(ctx, []string{target}), req)
|
||||
}
|
||||
|
||||
func (c *launcherClient) launchEditorIntegration(ctx context.Context, name string, runner Runner, editor Editor, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error {
|
||||
@@ -778,6 +778,7 @@ func (c *launcherClient) launchEditorIntegration(ctx context.Context, name strin
|
||||
liveConfigMatches := slices.Equal(editor.Models(), models)
|
||||
if needsConfigure || req.ModelOverride != "" || !savedMatchesModels(saved, models) || !liveConfigMatches {
|
||||
launchModels = c.modelInventory().Resolve(ctx, models)
|
||||
launchModels = c.prepareLaunchModelsForConfig(ctx, editor, models[0], launchModels)
|
||||
if err := prepareEditorIntegration(name, editor, launchModels); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -785,7 +786,7 @@ func (c *launcherClient) launchEditorIntegration(ctx context.Context, name strin
|
||||
launchModels = c.resolveRunModels(ctx, models)
|
||||
}
|
||||
|
||||
return launchAfterConfiguration(name, runner, models[0], launchModels, req)
|
||||
return c.launchAfterConfiguration(ctx, name, runner, models[0], launchModels, req)
|
||||
}
|
||||
|
||||
func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, name string, runner Runner, managed ManagedSingleModel, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error {
|
||||
@@ -1460,7 +1461,29 @@ func runIntegration(runner Runner, modelName string, models []LaunchModel, args
|
||||
return runner.Run(modelName, models, args)
|
||||
}
|
||||
|
||||
func launchAfterConfiguration(name string, runner Runner, model string, models []LaunchModel, req IntegrationLaunchRequest) error {
|
||||
type launchModelRunPreparer interface {
|
||||
prepareRunLaunchModels(context.Context, *launcherClient, string, []LaunchModel) ([]LaunchModel, error)
|
||||
}
|
||||
|
||||
type launchModelConfigPreparer interface {
|
||||
prepareConfigLaunchModels(context.Context, *launcherClient, string, []LaunchModel) []LaunchModel
|
||||
}
|
||||
|
||||
func (c *launcherClient) prepareLaunchModelsForRun(ctx context.Context, runner Runner, model string, models []LaunchModel) ([]LaunchModel, error) {
|
||||
if preparer, ok := runner.(launchModelRunPreparer); ok {
|
||||
return preparer.prepareRunLaunchModels(ctx, c, model, models)
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
func (c *launcherClient) prepareLaunchModelsForConfig(ctx context.Context, editor Editor, primary string, models []LaunchModel) []LaunchModel {
|
||||
if preparer, ok := editor.(launchModelConfigPreparer); ok {
|
||||
return preparer.prepareConfigLaunchModels(ctx, c, primary, models)
|
||||
}
|
||||
return models
|
||||
}
|
||||
|
||||
func (c *launcherClient) launchAfterConfiguration(ctx context.Context, name string, runner Runner, model string, models []LaunchModel, req IntegrationLaunchRequest) error {
|
||||
if req.ConfigureOnly {
|
||||
launch, err := ConfirmPrompt(fmt.Sprintf("Launch %s now?", runner))
|
||||
if err != nil {
|
||||
@@ -1473,7 +1496,11 @@ func launchAfterConfiguration(name string, runner Runner, model string, models [
|
||||
if err := EnsureIntegrationInstalled(name, runner); err != nil {
|
||||
return err
|
||||
}
|
||||
return runIntegration(runner, model, models, req.ExtraArgs)
|
||||
preparedModels, err := c.prepareLaunchModelsForRun(ctx, runner, model, models)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runIntegration(runner, model, preparedModels, req.ExtraArgs)
|
||||
}
|
||||
|
||||
func loadStoredIntegrationConfig(name string) (*config.IntegrationConfig, error) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
)
|
||||
|
||||
const openCodeInstallScript = "curl -fsSL https://opencode.ai/install | bash"
|
||||
const openCodeRecommendedContext = 64 * 1024
|
||||
|
||||
var openCodeGOOS = runtime.GOOS
|
||||
|
||||
@@ -137,6 +139,79 @@ func openCodeInstallerCommand(goos string) (string, []string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func (o *OpenCode) prepareRunLaunchModels(ctx context.Context, client *launcherClient, primary string, models []LaunchModel) ([]LaunchModel, error) {
|
||||
return o.prepareLaunchModels(ctx, client, primary, models, true)
|
||||
}
|
||||
|
||||
func (o *OpenCode) prepareConfigLaunchModels(ctx context.Context, client *launcherClient, primary string, models []LaunchModel) []LaunchModel {
|
||||
prepared, _ := o.prepareLaunchModels(ctx, client, primary, models, false)
|
||||
return prepared
|
||||
}
|
||||
|
||||
func (o *OpenCode) prepareLaunchModels(ctx context.Context, client *launcherClient, primary string, models []LaunchModel, warn bool) ([]LaunchModel, error) {
|
||||
if primary == "" {
|
||||
return models, nil
|
||||
}
|
||||
|
||||
if !hasLocalLaunchModel(primary, models) {
|
||||
return models, nil
|
||||
}
|
||||
|
||||
contextLength, ok := client.localServerContextLength(ctx)
|
||||
if !ok {
|
||||
return models, nil
|
||||
}
|
||||
|
||||
models = launchModelsWithOpenCodeLocalLimits(primary, models, contextLength)
|
||||
if warn && !isCloudModelName(primary) && contextLength < openCodeRecommendedContext {
|
||||
if err := confirmLocalContextWarning(o.String(), contextLength, openCodeRecommendedContext); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
func launchModelsWithOpenCodeLocalLimits(primary string, models []LaunchModel, contextLength int) []LaunchModel {
|
||||
if contextLength <= 0 {
|
||||
return models
|
||||
}
|
||||
if len(models) == 0 && primary != "" {
|
||||
models = launchModelsFromNames([]string{primary})
|
||||
}
|
||||
|
||||
out := cloneLaunchModels(models)
|
||||
for i := range out {
|
||||
if isCloudModelName(out[i].Name) {
|
||||
continue
|
||||
}
|
||||
out[i].ContextLength = contextLength
|
||||
out[i].MaxOutputTokens = openCodeLocalMaxOutputTokens(contextLength)
|
||||
}
|
||||
if primary != "" && !isCloudModelName(primary) && !hasLaunchModel(out, primary) {
|
||||
model := fallbackLaunchModel(primary)
|
||||
model.ContextLength = contextLength
|
||||
model.MaxOutputTokens = openCodeLocalMaxOutputTokens(contextLength)
|
||||
out = append([]LaunchModel{model}, out...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func openCodeLocalMaxOutputTokens(contextLength int) int {
|
||||
return min(8192, max(2048, contextLength/4))
|
||||
}
|
||||
|
||||
func hasLocalLaunchModel(primary string, models []LaunchModel) bool {
|
||||
if primary != "" && !isCloudModelName(primary) {
|
||||
return true
|
||||
}
|
||||
for _, model := range models {
|
||||
if model.Name != "" && !isCloudModelName(model.Name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveContent returns the inline config to send via OPENCODE_CONFIG_CONTENT.
|
||||
// Returns content built by Edit if available, otherwise builds from model.json
|
||||
// with the requested model as primary (e.g. re-launch with saved config).
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -226,6 +228,188 @@ func TestOpenCodeEdit(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenCodeRunPassesConfigContent(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell fake binary")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
binDir := filepath.Join(tmpDir, "bin")
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
capturePath := filepath.Join(tmpDir, "opencode-config.json")
|
||||
t.Setenv("CAPTURE_PATH", capturePath)
|
||||
t.Setenv("PATH", binDir)
|
||||
|
||||
fakeOpenCode := filepath.Join(binDir, "opencode")
|
||||
script := "#!/bin/sh\nprintf '%s' \"$OPENCODE_CONFIG_CONTENT\" > \"$CAPTURE_PATH\"\n"
|
||||
if err := os.WriteFile(fakeOpenCode, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("failed to write fake opencode: %v", err)
|
||||
}
|
||||
|
||||
models := []LaunchModel{{
|
||||
Name: "gemma4",
|
||||
ContextLength: 32_768,
|
||||
MaxOutputTokens: 8_192,
|
||||
}}
|
||||
o := &OpenCode{}
|
||||
if err := o.Run("gemma4", models, nil); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(capturePath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read captured config: %v", err)
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
t.Fatalf("captured config is not valid JSON: %v\n%s", err, string(data))
|
||||
}
|
||||
if cfg["model"] != "ollama/gemma4" {
|
||||
t.Fatalf("model = %v, want ollama/gemma4", cfg["model"])
|
||||
}
|
||||
|
||||
provider, _ := cfg["provider"].(map[string]any)
|
||||
ollama, _ := provider["ollama"].(map[string]any)
|
||||
cfgModels, _ := ollama["models"].(map[string]any)
|
||||
entry, _ := cfgModels["gemma4"].(map[string]any)
|
||||
limit, _ := entry["limit"].(map[string]any)
|
||||
if limit["context"] != float64(32_768) {
|
||||
t.Fatalf("limit.context = %v, want 32768", limit["context"])
|
||||
}
|
||||
if limit["output"] != float64(8_192) {
|
||||
t.Fatalf("limit.output = %v, want 8192", limit["output"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodePrepareLaunchModelsSetsLocalLimits(t *testing.T) {
|
||||
client, _ := testLauncherClientWithStatus(t, 32*1024)
|
||||
|
||||
models := client.prepareLaunchModelsForConfig(context.Background(), &OpenCode{}, "llama3.2", []LaunchModel{
|
||||
{Name: "llama3.2", ContextLength: 131072},
|
||||
{Name: "glm-5:cloud", ContextLength: 202752, MaxOutputTokens: 131072},
|
||||
})
|
||||
|
||||
if len(models) != 2 {
|
||||
t.Fatalf("models = %+v, want 2", models)
|
||||
}
|
||||
if models[0].ContextLength != 32*1024 || models[0].MaxOutputTokens != 8192 {
|
||||
t.Fatalf("local model = %+v, want server context and derived output", models[0])
|
||||
}
|
||||
if models[1].ContextLength != 202752 || models[1].MaxOutputTokens != 131072 {
|
||||
t.Fatalf("cloud model = %+v, want unchanged cloud limits", models[1])
|
||||
}
|
||||
|
||||
entries := buildModelEntries(models)
|
||||
local, _ := entries["llama3.2"].(map[string]any)
|
||||
limit, _ := local["limit"].(map[string]any)
|
||||
if limit["context"] != 32*1024 || limit["output"] != 8192 {
|
||||
t.Fatalf("local limit = %v, want context/output from server context", limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeLocalMaxOutputTokens(t *testing.T) {
|
||||
tests := map[int]int{
|
||||
4096: 2048,
|
||||
16 * 1024: 4096,
|
||||
32 * 1024: 8192,
|
||||
128 * 1024: 8192,
|
||||
}
|
||||
for contextLength, want := range tests {
|
||||
if got := openCodeLocalMaxOutputTokens(contextLength); got != want {
|
||||
t.Fatalf("openCodeLocalMaxOutputTokens(%d) = %d, want %d", contextLength, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodePrepareLaunchModelsWarnsForLowLocalContext(t *testing.T) {
|
||||
client, _ := testLauncherClientWithStatus(t, 32*1024)
|
||||
|
||||
oldConfirm := DefaultConfirmPrompt
|
||||
defer func() { DefaultConfirmPrompt = oldConfirm }()
|
||||
|
||||
var gotPrompt string
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
gotPrompt = prompt
|
||||
if options.Default != ConfirmDefaultNo {
|
||||
t.Fatal("expected warning prompt to default to no")
|
||||
}
|
||||
if options.YesLabel != "Launch anyway" || options.NoLabel != "Cancel" {
|
||||
t.Fatalf("labels = %q/%q, want Launch anyway/Cancel", options.YesLabel, options.NoLabel)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
_, err := client.prepareLaunchModelsForRun(context.Background(), &OpenCode{}, "llama3.2", []LaunchModel{{Name: "llama3.2"}})
|
||||
if !errors.Is(err, ErrCancelled) {
|
||||
t.Fatalf("error = %v, want ErrCancelled", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"OpenCode works best with at least 64k context. Current local context: 32k.",
|
||||
"Launch OpenCode anyway?",
|
||||
} {
|
||||
if !strings.Contains(gotPrompt, want) {
|
||||
t.Fatalf("prompt missing %q:\n%s", want, gotPrompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodePrepareLaunchModelsAppliesLocalLimitsWithCloudPrimary(t *testing.T) {
|
||||
client, _ := testLauncherClientWithStatus(t, 32*1024)
|
||||
|
||||
oldConfirm := DefaultConfirmPrompt
|
||||
defer func() { DefaultConfirmPrompt = oldConfirm }()
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
t.Fatalf("did not expect prompt for cloud primary, got %q", prompt)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
models, err := client.prepareLaunchModelsForRun(context.Background(), &OpenCode{}, "glm-5:cloud", []LaunchModel{
|
||||
{Name: "glm-5:cloud", Remote: true, ContextLength: 202752, MaxOutputTokens: 131072},
|
||||
{Name: "llama3.2", ContextLength: 131072},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("prepareLaunchModelsForRun error = %v", err)
|
||||
}
|
||||
if len(models) != 2 {
|
||||
t.Fatalf("models = %+v, want 2", models)
|
||||
}
|
||||
if models[0].ContextLength != 202752 || models[0].MaxOutputTokens != 131072 {
|
||||
t.Fatalf("cloud model = %+v, want unchanged cloud limits", models[0])
|
||||
}
|
||||
if models[1].ContextLength != 32*1024 || models[1].MaxOutputTokens != 8192 {
|
||||
t.Fatalf("local model = %+v, want server context and derived output", models[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodePrepareLaunchModelsSkipsAllCloudModels(t *testing.T) {
|
||||
client, statusCalls := testLauncherClientWithStatus(t, 32*1024)
|
||||
|
||||
models, err := client.prepareLaunchModelsForRun(context.Background(), &OpenCode{}, "glm-5:cloud", []LaunchModel{{Name: "glm-5:cloud", Remote: true}})
|
||||
if err != nil {
|
||||
t.Fatalf("prepareLaunchModelsForRun error = %v", err)
|
||||
}
|
||||
if *statusCalls != 0 {
|
||||
t.Fatalf("status calls = %d, want 0", *statusCalls)
|
||||
}
|
||||
if len(models) != 1 || models[0].ContextLength != 0 {
|
||||
t.Fatalf("models = %+v, want unchanged cloud model", models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchModelsWithOpenCodeLocalLimitsDoesNotAppendMissingCloudPrimary(t *testing.T) {
|
||||
models := launchModelsWithOpenCodeLocalLimits("glm-5:cloud", []LaunchModel{{Name: "llama3.2"}}, 32*1024)
|
||||
if len(models) != 1 {
|
||||
t.Fatalf("models = %+v, want no fallback cloud primary appended", models)
|
||||
}
|
||||
if models[0].Name != "llama3.2" || models[0].ContextLength != 32*1024 || models[0].MaxOutputTokens != 8192 {
|
||||
t.Fatalf("local model = %+v, want local limits applied", models[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelEntries(t *testing.T) {
|
||||
t.Run("defaults to model name without capabilities", func(t *testing.T) {
|
||||
models := buildModelEntries(testLaunchModels("llama3.2"))
|
||||
|
||||
@@ -2141,11 +2141,16 @@ func streamResponse(c *gin.Context, ch chan any) {
|
||||
|
||||
func (s *Server) StatusHandler(c *gin.Context) {
|
||||
disabled, source := internalcloud.Status()
|
||||
contextLength := int(envconfig.ContextLength())
|
||||
if contextLength == 0 {
|
||||
contextLength = s.defaultNumCtx
|
||||
}
|
||||
c.JSON(http.StatusOK, api.StatusResponse{
|
||||
Cloud: api.CloudStatus{
|
||||
Disabled: disabled,
|
||||
Source: source,
|
||||
},
|
||||
ContextLength: contextLength,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,54 @@ func TestStatusHandler(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusHandlerContextLength(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
setTestHome(t, t.TempDir())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
envContextLen string
|
||||
defaultNumCtx int
|
||||
wantContextLen int
|
||||
}{
|
||||
{
|
||||
name: "env context length wins",
|
||||
envContextLen: "8192",
|
||||
defaultNumCtx: 32768,
|
||||
wantContextLen: 8192,
|
||||
},
|
||||
{
|
||||
name: "default context length is used when env is unset",
|
||||
defaultNumCtx: 32768,
|
||||
wantContextLen: 32768,
|
||||
},
|
||||
{
|
||||
name: "zero when no context length is known",
|
||||
wantContextLen: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("OLLAMA_CONTEXT_LENGTH", tt.envContextLen)
|
||||
|
||||
s := Server{defaultNumCtx: tt.defaultNumCtx}
|
||||
w := createRequest(t, s.StatusHandler, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp api.StatusResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.ContextLength != tt.wantContextLen {
|
||||
t.Fatalf("context_length = %d, want %d", resp.ContextLength, tt.wantContextLen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudDisabledBlocksRemoteOperations(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
setTestHome(t, t.TempDir())
|
||||
|
||||
Reference in New Issue
Block a user