Compare commits

...

7 Commits

Author SHA1 Message Date
Eva Ho 52b7205e5e clean up 2026-07-09 17:35:01 -07:00
Eva Ho 2363cf3ffa Merge remote-tracking branch 'origin/main' into hoyyeva/wire-up-context-length
# Conflicts:
#	cmd/launch/claude.go
#	cmd/launch/claude_test.go
#	cmd/launch/selector_hooks.go
#	cmd/tui/confirm.go
2026-07-09 17:01:38 -07:00
Eva Ho 0f3b96d5d4 clean up 2026-07-09 16:56:34 -07:00
Eva Ho 07274664a7 wip 2026-07-09 10:23:27 -07:00
Eva Ho 3946d0317c clean up 2026-07-07 18:19:58 -07:00
Eva Ho cc8e8f6c72 launch: wire local context length into claude and opencode 2026-07-06 14:59:22 -04:00
Eva Ho a1968d285d wip 2026-07-06 12:53:51 -04:00
10 changed files with 711 additions and 15 deletions
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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"])
}
+72
View File
@@ -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)
}
}
+92
View File
@@ -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
View File
@@ -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) {
+75
View File
@@ -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).
+184
View File
@@ -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"))
+5
View File
@@ -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,
})
}
+48
View File
@@ -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())