mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
566c58c9dc | ||
|
|
df9684bcf3 | ||
|
|
259f27b2ba |
No files matched your search
@@ -85,6 +85,104 @@ func Discover(ctx context.Context, profile config.ProviderProfile, options Optio
|
||||
}
|
||||
}
|
||||
|
||||
// DiscoverOllamaContextWindow asks a local Ollama daemon for a model's context
|
||||
// length via its native /api/show endpoint. The generic /v1/models probe
|
||||
// (parseModelsResponse) only ever returns id/description — OpenAI-compatible
|
||||
// listings don't carry context-window metadata, and a custom/local model tag
|
||||
// (e.g. a user's own Ollama pull, including a ":cloud"-tagged model proxied
|
||||
// through the local daemon) has no curated-catalog entry to borrow one from
|
||||
// either, so modelContextWindow has nothing to show for it without this. Only
|
||||
// meaningful for the local Ollama provider (baseURL like
|
||||
// http://localhost:11434/v1) — Ollama Cloud's hosted API is a different
|
||||
// service and isn't assumed to expose the same endpoint.
|
||||
func DiscoverOllamaContextWindow(ctx context.Context, baseURL string, model string, options Options) (int, error) {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
return 0, fmt.Errorf("model name is required")
|
||||
}
|
||||
endpoint, err := ollamaShowEndpoint(baseURL)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
payload, err := json.Marshal(struct {
|
||||
Model string `json:"model"`
|
||||
}{Model: model})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(payload)))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
|
||||
client := options.HTTPClient
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return 0, fmt.Errorf("ollama show endpoint returned %s: %s", response.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return parseOllamaShowContextWindow(body)
|
||||
}
|
||||
|
||||
// ollamaShowEndpoint derives the native Ollama API root from the
|
||||
// OpenAI-compatible base URL Zero stores for the provider (".../v1") and
|
||||
// appends /api/show.
|
||||
func ollamaShowEndpoint(baseURL string) (string, error) {
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
if baseURL == "" {
|
||||
return "", fmt.Errorf("provider base URL is required for ollama model discovery")
|
||||
}
|
||||
parsed, err := url.Parse(baseURL)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return "", fmt.Errorf("invalid provider base URL %q", baseURL)
|
||||
}
|
||||
path := strings.TrimRight(parsed.Path, "/")
|
||||
path = strings.TrimSuffix(path, "/v1")
|
||||
parsed.Path = strings.TrimRight(path, "/") + "/api/show"
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
// parseOllamaShowContextWindow extracts the context length from an
|
||||
// /api/show response. Ollama reports it under model_info with an
|
||||
// architecture-prefixed key (e.g. "llama.context_length",
|
||||
// "qwen2.context_length") — the prefix varies by model family, so this scans
|
||||
// for any key ending in ".context_length" rather than hardcoding one.
|
||||
func parseOllamaShowContextWindow(body []byte) (int, error) {
|
||||
// model_info mixes value types (strings like "general.architecture", numbers
|
||||
// like "*.context_length"), so decode values generically and only try to
|
||||
// read a number out of the one key we actually care about.
|
||||
var payload struct {
|
||||
ModelInfo map[string]any `json:"model_info"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return 0, fmt.Errorf("decode ollama show response: %w", err)
|
||||
}
|
||||
for key, value := range payload.ModelInfo {
|
||||
if !strings.HasSuffix(key, ".context_length") {
|
||||
continue
|
||||
}
|
||||
if window, ok := value.(float64); ok && window > 0 {
|
||||
return int(window), nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("ollama show response did not report a context length")
|
||||
}
|
||||
|
||||
func discoverOpenAIModels(ctx context.Context, profile config.ProviderProfile, options Options) ([]Model, error) {
|
||||
endpoint, err := modelsEndpoint(profile.BaseURL)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package providermodeldiscovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -267,3 +268,61 @@ func modelIDs(models []Model) []string {
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// TestDiscoverOllamaContextWindowFetchesFromNativeShowEndpoint: the generic
|
||||
// /v1/models probe never carries context-window metadata (parseModelsResponse
|
||||
// only extracts id/description), so a custom/local Ollama model tag with no
|
||||
// curated-catalog match has no other source for it. This exercises the
|
||||
// Ollama-native /api/show fallback that fills that gap.
|
||||
func TestDiscoverOllamaContextWindowFetchesFromNativeShowEndpoint(t *testing.T) {
|
||||
var gotPath, gotMethod, gotBody string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotMethod = r.Method
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
gotBody = string(body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"model_info": {
|
||||
"general.architecture": "qwen2",
|
||||
"qwen2.context_length": 131072
|
||||
}
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
window, err := DiscoverOllamaContextWindow(context.Background(), server.URL+"/v1", "kimi-k2.7-code:cloud", Options{HTTPClient: server.Client()})
|
||||
if err != nil {
|
||||
t.Fatalf("DiscoverOllamaContextWindow returned error: %v", err)
|
||||
}
|
||||
if window != 131072 {
|
||||
t.Fatalf("context window = %d, want 131072", window)
|
||||
}
|
||||
if gotPath != "/api/show" {
|
||||
t.Fatalf("requested path = %q, want /api/show (not under /v1)", gotPath)
|
||||
}
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Fatalf("method = %q, want POST", gotMethod)
|
||||
}
|
||||
if !strings.Contains(gotBody, `"kimi-k2.7-code:cloud"`) {
|
||||
t.Fatalf("request body = %q, want it to name the model", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverOllamaContextWindowRequiresModelName(t *testing.T) {
|
||||
if _, err := DiscoverOllamaContextWindow(context.Background(), "http://localhost:11434/v1", "", Options{}); err == nil {
|
||||
t.Fatal("expected an error for an empty model name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverOllamaContextWindowErrorsWhenShowOmitsContextLength(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"model_info": {"general.architecture": "qwen2"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if _, err := DiscoverOllamaContextWindow(context.Background(), server.URL+"/v1", "some-model", Options{HTTPClient: server.Client()}); err == nil {
|
||||
t.Fatal("expected an error when no *.context_length key is present")
|
||||
}
|
||||
}
|
||||
@@ -630,6 +630,16 @@ func (session *execSession) collect(ctx context.Context, wait time.Duration) (st
|
||||
if session.output.consumeTruncated() {
|
||||
truncated = true
|
||||
}
|
||||
// A background process that keeps writing output faster than this
|
||||
// loop can drain it (a chatty dev server, a watch-mode rebuild loop,
|
||||
// a crash-restart spew) would otherwise never let drainString return
|
||||
// empty, so the deadline check below — only reached on an empty
|
||||
// drain — would never fire. That turns `wait` into an unbounded
|
||||
// block regardless of what the caller asked for. Check it here too,
|
||||
// so continuous output can never starve the timeout.
|
||||
if time.Now().After(deadline) {
|
||||
return finish()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if session.doneClosed() {
|
||||
|
||||
@@ -450,6 +450,65 @@ func TestExecToolResultSurfacesBufferTruncationOutsideByteBudget(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectRespectsDeadlineUnderContinuousOutput asserts collect() returns
|
||||
// close to its requested deadline even while output keeps arriving. Before
|
||||
// the corresponding fix, the deadline was only checked in the branch reached
|
||||
// when drainString returns empty — a background process producing output
|
||||
// fast/continuously enough to keep the buffer perpetually non-empty could
|
||||
// theoretically starve that check indefinitely. This synthetic writer
|
||||
// (8 goroutines, tight loop) did not reliably reproduce that starvation under
|
||||
// Go's scheduler in practice — the reader still won the race often enough —
|
||||
// so this test doesn't prove the old code could hang; it just pins down the
|
||||
// intended behavior (bounded by wait) going forward.
|
||||
func TestCollectRespectsDeadlineUnderContinuousOutput(t *testing.T) {
|
||||
session := &execSession{
|
||||
id: 1000,
|
||||
output: newExecOutputBuffer(),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
// Several concurrent writers, each pushing a decent-sized chunk in a tight
|
||||
// loop: a single slow writer lets the reader win the race often enough
|
||||
// (the runtime schedules a gap between writes) to mask the bug. Enough
|
||||
// parallel writers keep the buffer non-empty essentially continuously,
|
||||
// closer to a real chatty process whose PTY reads land in bursts.
|
||||
const writers = 8
|
||||
chunk := []byte(strings.Repeat("x", 256))
|
||||
wg.Add(writers)
|
||||
for i := 0; i < writers; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
session.output.Write(chunk)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
const wait = 200 * time.Millisecond
|
||||
start := time.Now()
|
||||
_, _ = session.collect(context.Background(), wait)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Generous slack over `wait` for scheduling jitter under a continuously
|
||||
// writing goroutine — this must stay a small multiple of wait, not
|
||||
// "however long the writer keeps going" (which is what the bug produced:
|
||||
// this test would hang past the 30s test timeout without the fix).
|
||||
if elapsed > 3*wait {
|
||||
t.Fatalf("collect took %v under continuous output, want close to the %v deadline", elapsed, wait)
|
||||
}
|
||||
}
|
||||
|
||||
// resilientTempDir is like t.TempDir() but tolerates the Windows handle-release
|
||||
// lag: a SIGKILL'd child process that had the dir as its cwd may not have
|
||||
// released it the instant it is reaped, so the immediate RemoveAll t.TempDir()
|
||||
|
||||
@@ -490,16 +490,16 @@ func (m model) handleModelCommand(args string) (model, string) {
|
||||
// picker calls this when a model from a non-active provider is chosen, so the
|
||||
// picker can list every saved provider and switch across them (like a unified
|
||||
// provider+model selector). The key is loaded from the encrypted store / env.
|
||||
func (m model) switchProviderModel(providerName, modelID string) (model, string) {
|
||||
func (m model) switchProviderModel(providerName, modelID string) (model, string, tea.Cmd) {
|
||||
if m.pending {
|
||||
return m, "Model\nCannot switch providers while a run is active."
|
||||
return m, "Model\nCannot switch providers while a run is active.", nil
|
||||
}
|
||||
if m.newProvider == nil {
|
||||
return m, "Model\nProvider rebuild is not available for this TUI session."
|
||||
return m, "Model\nProvider rebuild is not available for this TUI session.", nil
|
||||
}
|
||||
target, ok := m.savedProviderByName(providerName)
|
||||
if !ok {
|
||||
return m, "Model\nunknown provider " + strconv.Quote(providerName)
|
||||
return m, "Model\nunknown provider " + strconv.Quote(providerName), nil
|
||||
}
|
||||
target = m.profileWithCredential(target)
|
||||
target.Model = strings.TrimSpace(modelID)
|
||||
@@ -508,11 +508,11 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string)
|
||||
// was deleted/unreadable the marker can still be set, and building a keyless
|
||||
// provider would only fail later with a 401. Local/no-auth providers need no key.
|
||||
if strings.TrimSpace(target.APIKey) == "" && strings.TrimSpace(target.AuthHeaderValue) == "" && !(hasDescriptor && descriptor.Local) {
|
||||
return m, "Model\nprovider " + strconv.Quote(providerName) + " has no usable credential — run setup or `zero auth login " + providerName + "`."
|
||||
return m, "Model\nprovider " + strconv.Quote(providerName) + " has no usable credential — run setup or `zero auth login " + providerName + "`.", nil
|
||||
}
|
||||
next, err := m.newProvider(target)
|
||||
if err != nil {
|
||||
return m, "Model\n" + redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{target.APIKey}})
|
||||
return m, "Model\n" + redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{target.APIKey}}), nil
|
||||
}
|
||||
m.provider = next
|
||||
m.providerProfile = target
|
||||
@@ -524,7 +524,19 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string)
|
||||
_, _ = config.SetActiveProvider(m.userConfigPath, target.Name)
|
||||
_, _ = config.SetProviderModel(m.userConfigPath, target.Name, target.Model)
|
||||
}
|
||||
return m, fmt.Sprintf("Model\nSwitched to %s · %s", target.Name, target.Model)
|
||||
// Warm discovery for the provider we just switched to, same as Init() does
|
||||
// for the provider active at launch — otherwise the context-usage gauge has
|
||||
// no window for this provider until /model happens to be opened separately.
|
||||
var cmds []tea.Cmd
|
||||
if hasDescriptor {
|
||||
if cmd := m.modelPickerProviderDiscoveryCmd(descriptor, target); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
if cmd := m.ollamaContextWindowDiscoveryCmd(descriptor, target.BaseURL, target.Model); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
return m, fmt.Sprintf("Model\nSwitched to %s · %s", target.Name, target.Model), tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// profileWithCredential fills a profile's APIKey for provider construction the same
|
||||
|
||||
@@ -27,3 +27,29 @@ func TestContextWindowSegment(t *testing.T) {
|
||||
t.Fatalf("gauge = %q, want 161K/200K · 81%%", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatusLineDoesNotDuplicateTokenFigureNextToGauge: the gauge's "used"
|
||||
// figure and the plain usage segment's token count both read the exact same
|
||||
// latestUsageTokens value — once the gauge is showing it, the usage segment
|
||||
// must fall back to cost-only, or the same number renders twice side by
|
||||
// side in the footer (e.g. "◔ 161K/200K · 81% │ 161K tok").
|
||||
func TestStatusLineDoesNotDuplicateTokenFigureNextToGauge(t *testing.T) {
|
||||
m := newModel(t.Context(), Options{ModelName: "claude-sonnet-4.5"})
|
||||
if _, err := m.usageTracker.Record(usage.RecordInput{
|
||||
ModelID: "claude-sonnet-4.5",
|
||||
Usage: zeroruntime.Usage{InputTokens: 160_000, OutputTokens: 1000},
|
||||
}); err != nil {
|
||||
t.Fatalf("Record: %v", err)
|
||||
}
|
||||
|
||||
status := plainRender(t, m.statusLine(110))
|
||||
if !strings.Contains(status, "161K/200K") {
|
||||
t.Fatalf("status line = %q, expected the context gauge to render", status)
|
||||
}
|
||||
if strings.Count(status, "161K") != 1 {
|
||||
t.Fatalf("status line = %q, the 161K token figure must appear exactly once, not duplicated next to the gauge", status)
|
||||
}
|
||||
if strings.Contains(status, "tok") {
|
||||
t.Fatalf("status line = %q, the plain token segment should be suppressed once the gauge shows the same figure", status)
|
||||
}
|
||||
}
|
||||
+110
-86
@@ -57,40 +57,41 @@ const dragEdgeScrollInterval = 70 * time.Millisecond
|
||||
const dragEdgeScrollStep = 1
|
||||
|
||||
type model struct {
|
||||
ctx context.Context
|
||||
cwd string
|
||||
userCommands []usercommands.Command // file-sourced /commands (.zero/commands)
|
||||
userConfigPath string
|
||||
doctorUserConfigPath string
|
||||
projectConfigPath string
|
||||
gitBranch string
|
||||
providerName string
|
||||
modelName string
|
||||
providerProfile config.ProviderProfile
|
||||
savedProviders []config.ProviderProfile
|
||||
provider zeroruntime.Provider
|
||||
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
registry *tools.Registry
|
||||
sessionStore *sessions.Store
|
||||
sandboxStore *sandbox.GrantStore
|
||||
mcpConfig config.MCPConfig
|
||||
mcpPermissionStore *internalmcp.PermissionStore
|
||||
mcpTokenStore *internalmcp.TokenStore
|
||||
mcpCommand func(context.Context, []string) MCPCommandResult
|
||||
sandboxSetupCommand func(context.Context) SandboxSetupCommandResult
|
||||
mcpViewStateCache MCPViewState
|
||||
mcpViewStateReady bool
|
||||
mcpCommandSeq int
|
||||
mcpCommandCancel context.CancelFunc
|
||||
sandboxSetupSeq int
|
||||
sandboxSetupInFlight bool
|
||||
doctorCommandSeq int
|
||||
doctorInFlight bool
|
||||
doctorFrame int
|
||||
activeSession sessions.Metadata
|
||||
sessionEvents []sessions.Event
|
||||
ctx context.Context
|
||||
cwd string
|
||||
userCommands []usercommands.Command // file-sourced /commands (.zero/commands)
|
||||
userConfigPath string
|
||||
doctorUserConfigPath string
|
||||
projectConfigPath string
|
||||
gitBranch string
|
||||
providerName string
|
||||
modelName string
|
||||
providerProfile config.ProviderProfile
|
||||
savedProviders []config.ProviderProfile
|
||||
provider zeroruntime.Provider
|
||||
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
discoverOllamaContextWindow func(ctx context.Context, baseURL string, model string) (int, error)
|
||||
registry *tools.Registry
|
||||
sessionStore *sessions.Store
|
||||
sandboxStore *sandbox.GrantStore
|
||||
mcpConfig config.MCPConfig
|
||||
mcpPermissionStore *internalmcp.PermissionStore
|
||||
mcpTokenStore *internalmcp.TokenStore
|
||||
mcpCommand func(context.Context, []string) MCPCommandResult
|
||||
sandboxSetupCommand func(context.Context) SandboxSetupCommandResult
|
||||
mcpViewStateCache MCPViewState
|
||||
mcpViewStateReady bool
|
||||
mcpCommandSeq int
|
||||
mcpCommandCancel context.CancelFunc
|
||||
sandboxSetupSeq int
|
||||
sandboxSetupInFlight bool
|
||||
doctorCommandSeq int
|
||||
doctorInFlight bool
|
||||
doctorFrame int
|
||||
activeSession sessions.Metadata
|
||||
sessionEvents []sessions.Event
|
||||
// titledSessions records session ids for which a model-generated title has
|
||||
// already been attempted this process, so a finished turn re-fires the title
|
||||
// generator at most once per session (even before its async result lands).
|
||||
@@ -347,6 +348,12 @@ type model struct {
|
||||
// catalog descriptor ID), so /model shows each provider's real current models —
|
||||
// the same list the provider-setup wizard discovers — not the static catalog.
|
||||
modelPickerLiveByProvider map[string][]providermodeldiscovery.Model
|
||||
// ollamaContextWindowByModel holds context-window sizes fetched from a local
|
||||
// Ollama daemon's native /api/show endpoint (keyed by model name), for
|
||||
// custom/local models that have no curated-catalog entry and whose
|
||||
// OpenAI-compatible /v1/models listing doesn't carry that metadata at all —
|
||||
// see modelContextWindow.
|
||||
ollamaContextWindowByModel map[string]int
|
||||
|
||||
// pendingImages holds image attachments staged by /image for the next user
|
||||
// turn; pendingImageLabels are their display names (base(path)) for the chip
|
||||
@@ -668,56 +675,57 @@ func newModel(ctx context.Context, options Options) model {
|
||||
notifier.SetFocused(true)
|
||||
|
||||
m := model{
|
||||
ctx: ctx,
|
||||
cwd: cwd,
|
||||
swarmDoneAt: map[string]time.Time{},
|
||||
userCommands: loadedUserCommands,
|
||||
composerCursorVisible: true,
|
||||
userConfigPath: options.UserConfigPath,
|
||||
doctorUserConfigPath: doctorUserConfigPath,
|
||||
projectConfigPath: options.ProjectConfigPath,
|
||||
savedProviders: options.SavedProviders,
|
||||
gitBranch: gitBranch(cwd),
|
||||
providerName: options.ProviderName,
|
||||
modelName: options.ModelName,
|
||||
providerProfile: options.ProviderProfile,
|
||||
favoriteModels: favoriteModelSet(options.FavoriteModels),
|
||||
recapsEnabled: options.RecapsEnabled,
|
||||
provider: options.Provider,
|
||||
newProvider: options.NewProvider,
|
||||
probeProviderHealth: options.ProbeProviderHealth,
|
||||
discoverProviderModels: options.DiscoverProviderModels,
|
||||
registry: registry,
|
||||
sessionStore: sessionStore,
|
||||
sandboxStore: sandboxStore,
|
||||
mcpConfig: options.MCPConfig,
|
||||
mcpPermissionStore: options.MCPPermissionStore,
|
||||
mcpTokenStore: options.MCPTokenStore,
|
||||
mcpCommand: options.MCPCommand,
|
||||
sandboxSetupCommand: options.SandboxSetupCommand,
|
||||
agentOptions: options.AgentOptions,
|
||||
sessionCompactor: options.SessionCompactor,
|
||||
runtimeMessageSink: options.RuntimeMessageSink,
|
||||
permissionMode: permissionMode,
|
||||
reasoningEffort: options.ReasoningEffort,
|
||||
responseStyle: defaultedResponseStyle(options.ResponseStyle),
|
||||
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
|
||||
hasDarkBg: true,
|
||||
userAgent: options.UserAgent,
|
||||
usageTracker: usageTracker,
|
||||
transcript: initialTranscript(),
|
||||
transcriptBodyHeights: newTranscriptBodyHeightCache(defaultTranscriptBodyHeightCacheMaxEntries),
|
||||
prService: prService,
|
||||
prState: prService.GetState(),
|
||||
input: input,
|
||||
spinner: runSpinner,
|
||||
now: time.Now,
|
||||
notifier: notifier,
|
||||
altScreen: options.AltScreen,
|
||||
liveUsageCounts: map[int]int{},
|
||||
swarmSessionMap: map[string]string{},
|
||||
setup: newSetupState(options.Setup),
|
||||
setupSave: options.Setup.Save,
|
||||
ctx: ctx,
|
||||
cwd: cwd,
|
||||
swarmDoneAt: map[string]time.Time{},
|
||||
userCommands: loadedUserCommands,
|
||||
composerCursorVisible: true,
|
||||
userConfigPath: options.UserConfigPath,
|
||||
doctorUserConfigPath: doctorUserConfigPath,
|
||||
projectConfigPath: options.ProjectConfigPath,
|
||||
savedProviders: options.SavedProviders,
|
||||
gitBranch: gitBranch(cwd),
|
||||
providerName: options.ProviderName,
|
||||
modelName: options.ModelName,
|
||||
providerProfile: options.ProviderProfile,
|
||||
favoriteModels: favoriteModelSet(options.FavoriteModels),
|
||||
recapsEnabled: options.RecapsEnabled,
|
||||
provider: options.Provider,
|
||||
newProvider: options.NewProvider,
|
||||
probeProviderHealth: options.ProbeProviderHealth,
|
||||
discoverProviderModels: options.DiscoverProviderModels,
|
||||
discoverOllamaContextWindow: options.DiscoverOllamaContextWindow,
|
||||
registry: registry,
|
||||
sessionStore: sessionStore,
|
||||
sandboxStore: sandboxStore,
|
||||
mcpConfig: options.MCPConfig,
|
||||
mcpPermissionStore: options.MCPPermissionStore,
|
||||
mcpTokenStore: options.MCPTokenStore,
|
||||
mcpCommand: options.MCPCommand,
|
||||
sandboxSetupCommand: options.SandboxSetupCommand,
|
||||
agentOptions: options.AgentOptions,
|
||||
sessionCompactor: options.SessionCompactor,
|
||||
runtimeMessageSink: options.RuntimeMessageSink,
|
||||
permissionMode: permissionMode,
|
||||
reasoningEffort: options.ReasoningEffort,
|
||||
responseStyle: defaultedResponseStyle(options.ResponseStyle),
|
||||
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
|
||||
hasDarkBg: true,
|
||||
userAgent: options.UserAgent,
|
||||
usageTracker: usageTracker,
|
||||
transcript: initialTranscript(),
|
||||
transcriptBodyHeights: newTranscriptBodyHeightCache(defaultTranscriptBodyHeightCacheMaxEntries),
|
||||
prService: prService,
|
||||
prState: prService.GetState(),
|
||||
input: input,
|
||||
spinner: runSpinner,
|
||||
now: time.Now,
|
||||
notifier: notifier,
|
||||
altScreen: options.AltScreen,
|
||||
liveUsageCounts: map[int]int{},
|
||||
swarmSessionMap: map[string]string{},
|
||||
setup: newSetupState(options.Setup),
|
||||
setupSave: options.Setup.Save,
|
||||
}
|
||||
// Apply an explicit theme immediately; auto stays on the dark default until
|
||||
// Init's terminal background probe resolves it (see Init / BackgroundColorMsg).
|
||||
@@ -796,6 +804,13 @@ func (m model) Init() tea.Cmd {
|
||||
if cmd := m.modelPickerProviderDiscoveryCmd(descriptor, m.providerProfile); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
// The generic discovery above has no source for a local Ollama model's
|
||||
// context window (see ollamaContextWindowDiscoveryCmd); probe its
|
||||
// native /api/show separately so the gauge works for custom/local
|
||||
// Ollama models too, not just ones in the curated catalog.
|
||||
if cmd := m.ollamaContextWindowDiscoveryCmd(descriptor, m.providerProfile.BaseURL, m.modelName); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
if m.prService != nil && m.runtimeMessageSink != nil {
|
||||
service := m.prService
|
||||
@@ -1988,6 +2003,14 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m.applySetupOAuthDeviceCode(msg)
|
||||
case modelPickerModelsDiscoveredMsg:
|
||||
return m.applyModelPickerModelsDiscovered(msg), nil
|
||||
case ollamaContextWindowDiscoveredMsg:
|
||||
if msg.err == nil && msg.contextWindow > 0 {
|
||||
if m.ollamaContextWindowByModel == nil {
|
||||
m.ollamaContextWindowByModel = map[string]int{}
|
||||
}
|
||||
m.ollamaContextWindowByModel[msg.modelName] = msg.contextWindow
|
||||
}
|
||||
return m, nil
|
||||
case mcpCommandResultMsg:
|
||||
return m.applyMCPCommandResultMessage(msg), nil
|
||||
}
|
||||
@@ -3360,12 +3383,13 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
switch picker.kind {
|
||||
case pickerModel:
|
||||
text := ""
|
||||
if owner := strings.TrimSpace(item.OwnerProvider); owner != "" && !strings.EqualFold(owner, strings.TrimSpace(m.providerName)) {
|
||||
// A model from another saved provider: switch provider + model together.
|
||||
m, text = m.switchProviderModel(owner, item.Value)
|
||||
m, text, cmd = m.switchProviderModel(owner, item.Value)
|
||||
} else {
|
||||
m, text = m.handleModelCommand(item.Value)
|
||||
}
|
||||
@@ -3395,7 +3419,7 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
|
||||
return m, tea.RequestBackgroundColor
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m model) chooseSuggestion() (tea.Model, tea.Cmd) {
|
||||
|
||||
@@ -84,6 +84,13 @@ func (m model) modelContextWindow(modelName string) int {
|
||||
return window
|
||||
}
|
||||
}
|
||||
// A custom/local Ollama model tag has no curated-catalog entry, and its
|
||||
// OpenAI-compatible /v1/models listing (the discoveredContextWindow source
|
||||
// above) doesn't carry context-window metadata at all — see
|
||||
// ollamaContextWindowDiscoveryCmd, the only other source for this.
|
||||
if window := m.ollamaContextWindowByModel[trimmed]; window > 0 {
|
||||
return window
|
||||
}
|
||||
// Unknown: report 0 so display gauges show no denominator. Compaction enablement
|
||||
// applies its own positive fallback via modelregistry.AgentContextWindow.
|
||||
return 0
|
||||
|
||||
+27
-26
@@ -20,32 +20,33 @@ import (
|
||||
|
||||
// Options configures the reusable Zero terminal UI shell.
|
||||
type Options struct {
|
||||
Cwd string
|
||||
UserConfigPath string
|
||||
DoctorUserConfigPath string
|
||||
ProjectConfigPath string
|
||||
ProviderName string
|
||||
ModelName string
|
||||
ProviderProfile config.ProviderProfile
|
||||
SavedProviders []config.ProviderProfile // all configured providers, for the /model multi-provider list
|
||||
FavoriteModels []string
|
||||
RecapsEnabled bool
|
||||
Provider zeroruntime.Provider
|
||||
NewProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
ProbeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
DiscoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
RuntimeMessageSink func(tea.Msg)
|
||||
Registry *tools.Registry
|
||||
SessionStore *sessions.Store
|
||||
SandboxStore *sandbox.GrantStore
|
||||
MCPConfig config.MCPConfig
|
||||
MCPPermissionStore *mcp.PermissionStore
|
||||
MCPTokenStore *mcp.TokenStore
|
||||
MCPCommand func(context.Context, []string) MCPCommandResult
|
||||
SandboxSetupCommand func(context.Context) SandboxSetupCommandResult
|
||||
UsageTracker *usage.Tracker
|
||||
SessionCompactor SessionCompactor
|
||||
PrService *PrService
|
||||
Cwd string
|
||||
UserConfigPath string
|
||||
DoctorUserConfigPath string
|
||||
ProjectConfigPath string
|
||||
ProviderName string
|
||||
ModelName string
|
||||
ProviderProfile config.ProviderProfile
|
||||
SavedProviders []config.ProviderProfile // all configured providers, for the /model multi-provider list
|
||||
FavoriteModels []string
|
||||
RecapsEnabled bool
|
||||
Provider zeroruntime.Provider
|
||||
NewProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
ProbeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
DiscoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
DiscoverOllamaContextWindow func(ctx context.Context, baseURL string, model string) (int, error)
|
||||
RuntimeMessageSink func(tea.Msg)
|
||||
Registry *tools.Registry
|
||||
SessionStore *sessions.Store
|
||||
SandboxStore *sandbox.GrantStore
|
||||
MCPConfig config.MCPConfig
|
||||
MCPPermissionStore *mcp.PermissionStore
|
||||
MCPTokenStore *mcp.TokenStore
|
||||
MCPCommand func(context.Context, []string) MCPCommandResult
|
||||
SandboxSetupCommand func(context.Context) SandboxSetupCommandResult
|
||||
UsageTracker *usage.Tracker
|
||||
SessionCompactor SessionCompactor
|
||||
PrService *PrService
|
||||
|
||||
AgentOptions agent.Options
|
||||
PermissionMode agent.PermissionMode
|
||||
|
||||
@@ -565,6 +565,46 @@ type modelPickerModelsDiscoveredMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
// ollamaContextWindowDiscoveredMsg carries the result of an async /api/show
|
||||
// probe against a local Ollama daemon (see ollamaContextWindowDiscoveryCmd).
|
||||
// A zero contextWindow (or a non-nil err) means the probe found nothing
|
||||
// usable and the map is left untouched, not zeroed out.
|
||||
type ollamaContextWindowDiscoveredMsg struct {
|
||||
modelName string
|
||||
contextWindow int
|
||||
err error
|
||||
}
|
||||
|
||||
// ollamaContextWindowDiscoveryCmd probes a local Ollama daemon's native
|
||||
// /api/show endpoint for modelName's context length. Scoped to the local
|
||||
// Ollama provider only (catalog ID "ollama", not "ollama-cloud" — a
|
||||
// different hosted service this endpoint isn't assumed to exist on): the
|
||||
// generic /v1/models discovery has no source for this metadata for
|
||||
// custom/local model tags, so without this the context gauge just never
|
||||
// shows for them (see modelContextWindow).
|
||||
func (m model) ollamaContextWindowDiscoveryCmd(descriptor providercatalog.Descriptor, baseURL string, modelName string) tea.Cmd {
|
||||
if descriptor.ID != "ollama" {
|
||||
return nil
|
||||
}
|
||||
modelName = strings.TrimSpace(modelName)
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
if modelName == "" || baseURL == "" {
|
||||
return nil
|
||||
}
|
||||
discover := m.discoverOllamaContextWindow
|
||||
if discover == nil {
|
||||
discover = func(ctx context.Context, baseURL string, model string) (int, error) {
|
||||
return providermodeldiscovery.DiscoverOllamaContextWindow(ctx, baseURL, model, providermodeldiscovery.Options{})
|
||||
}
|
||||
}
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(m.ctx, 8*time.Second)
|
||||
defer cancel()
|
||||
window, err := discover(ctx, baseURL, modelName)
|
||||
return ollamaContextWindowDiscoveredMsg{modelName: modelName, contextWindow: window, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) normalizeProfileForProvider(provider providercatalog.Descriptor) config.ProviderProfile {
|
||||
profile := m.providerProfile
|
||||
normalizeIdentity := profileMatchesProviderBaseURL(profile, provider) ||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
@@ -878,4 +879,100 @@ func TestModelContextWindowResolution(t *testing.T) {
|
||||
if got := m.modelContextWindow(""); got != 0 {
|
||||
t.Fatalf("empty model name should yield 0, got %d", got)
|
||||
}
|
||||
// A custom/local Ollama model tag has no curated-catalog entry and its
|
||||
// generic /v1/models listing carries no window either — the only source is
|
||||
// the Ollama-specific /api/show probe, landed via ollamaContextWindowByModel.
|
||||
m.ollamaContextWindowByModel = map[string]int{"kimi-k2.7-code:cloud": 131072}
|
||||
if got := m.modelContextWindow("kimi-k2.7-code:cloud"); got != 131072 {
|
||||
t.Fatalf("ollama-discovered window should be used for an unregistered model, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOllamaContextWindowDiscoveryCmdScopedToLocalOllama: the /api/show probe
|
||||
// only makes sense against a local Ollama daemon (catalog ID "ollama") — a
|
||||
// different provider like "ollama-cloud" is a distinct hosted service this
|
||||
// endpoint isn't assumed to exist on, and every other provider already has
|
||||
// its context window covered by the curated catalog or /v1/models discovery.
|
||||
func TestOllamaContextWindowDiscoveryCmdScopedToLocalOllama(t *testing.T) {
|
||||
m := model{ctx: context.Background()}
|
||||
|
||||
ollama, ok := providercatalog.Get("ollama")
|
||||
if !ok {
|
||||
t.Fatal("expected \"ollama\" to be a registered catalog descriptor")
|
||||
}
|
||||
if cmd := m.ollamaContextWindowDiscoveryCmd(ollama, "http://localhost:11434/v1", "kimi-k2.7-code:cloud"); cmd == nil {
|
||||
t.Fatal("expected a discovery command for the local ollama provider")
|
||||
}
|
||||
|
||||
ollamaCloud, ok := providercatalog.Get("ollama-cloud")
|
||||
if !ok {
|
||||
t.Fatal("expected \"ollama-cloud\" to be a registered catalog descriptor")
|
||||
}
|
||||
if cmd := m.ollamaContextWindowDiscoveryCmd(ollamaCloud, "https://ollama.com/v1", "qwen3-coder:480b"); cmd != nil {
|
||||
t.Fatal("ollama-cloud is a different hosted service; must not probe /api/show against it")
|
||||
}
|
||||
|
||||
if cmd := m.ollamaContextWindowDiscoveryCmd(ollama, "", "kimi-k2.7-code:cloud"); cmd != nil {
|
||||
t.Fatal("an empty base URL should yield no command")
|
||||
}
|
||||
if cmd := m.ollamaContextWindowDiscoveryCmd(ollama, "http://localhost:11434/v1", ""); cmd != nil {
|
||||
t.Fatal("an empty model name should yield no command")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOllamaContextWindowDiscoveredMsgPopulatesMap verifies the Update()
|
||||
// handler for the async probe's result actually reaches modelContextWindow's
|
||||
// fallback source, and that a failed/zero probe leaves the map untouched
|
||||
// rather than caching a bogus zero.
|
||||
func TestOllamaContextWindowDiscoveredMsgPopulatesMap(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
|
||||
updated, cmd := m.Update(ollamaContextWindowDiscoveredMsg{modelName: "kimi-k2.7-code:cloud", contextWindow: 131072})
|
||||
next := updated.(model)
|
||||
if cmd != nil {
|
||||
t.Fatal("applying a discovered window should not schedule further work")
|
||||
}
|
||||
if got := next.modelContextWindow("kimi-k2.7-code:cloud"); got != 131072 {
|
||||
t.Fatalf("modelContextWindow after discovery = %d, want 131072", got)
|
||||
}
|
||||
|
||||
updated, _ = next.Update(ollamaContextWindowDiscoveredMsg{modelName: "other-model", contextWindow: 0, err: errors.New("boom")})
|
||||
next = updated.(model)
|
||||
if got := next.modelContextWindow("other-model"); got != 0 {
|
||||
t.Fatalf("a failed probe must not populate a window, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwitchProviderModelWarmsDiscoveryForTheNewProvider: switching providers
|
||||
// mid-session (e.g. via the /model picker) previously fired no discovery at
|
||||
// all for the newly active provider — Init() only warms the provider active
|
||||
// at launch — so the context gauge stayed blank for the rest of the session
|
||||
// unless /model happened to be reopened separately. Switching to Ollama
|
||||
// specifically must also warm the /api/show probe, the only source of a
|
||||
// context window for custom/local Ollama models.
|
||||
func TestSwitchProviderModelWarmsDiscoveryForTheNewProvider(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{
|
||||
ProviderName: "openai",
|
||||
ModelName: "gpt-5.1",
|
||||
Provider: &fakeProvider{},
|
||||
ProviderProfile: config.ProviderProfile{Name: "openai", CatalogID: "openai", Model: "gpt-5.1"},
|
||||
SavedProviders: []config.ProviderProfile{
|
||||
{Name: "openai", CatalogID: "openai", Model: "gpt-5.1"},
|
||||
{Name: "ollama", CatalogID: "ollama", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "http://localhost:11434/v1", Model: "kimi-k2.7-code:cloud"},
|
||||
},
|
||||
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return &fakeProvider{}, nil
|
||||
},
|
||||
})
|
||||
|
||||
next, text, cmd := m.switchProviderModel("ollama", "kimi-k2.7-code:cloud")
|
||||
if !strings.Contains(text, "Switched to ollama") {
|
||||
t.Fatalf("switch notice = %q, want it to confirm the switch", text)
|
||||
}
|
||||
if next.modelName != "kimi-k2.7-code:cloud" || next.providerName != "ollama" {
|
||||
t.Fatalf("model/provider not switched: modelName=%q providerName=%q", next.modelName, next.providerName)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("switching to ollama should warm both the generic and ollama-specific discovery commands")
|
||||
}
|
||||
}
|
||||
@@ -223,15 +223,19 @@ func (m model) statusLine(width int) string {
|
||||
// Context-fill gauge: surface it down to the narrow tier (where it matters
|
||||
// most), but skip it when the context sidebar is already showing the % so the
|
||||
// figure isn't duplicated.
|
||||
gaugeShown := false
|
||||
if tier >= tierNarrow && !m.sidebarActive() {
|
||||
if gauge := m.contextWindowSegment(); gauge != "" {
|
||||
rightGroups = append(rightGroups, gauge)
|
||||
gaugeShown = true
|
||||
}
|
||||
}
|
||||
// The sidebar pins the token readout at its floor, so when it's open keep
|
||||
// only the cost here — otherwise the token figure shows on both sides.
|
||||
// The sidebar pins the token readout at its floor, and the gauge's "used"
|
||||
// figure is the exact same number (both read latestUsageTokens) — either
|
||||
// one showing means the plain usage segment must drop its own token count
|
||||
// to just the cost, or the count renders twice side by side.
|
||||
usage := m.usageStatusSegment()
|
||||
if m.sidebarActive() {
|
||||
if m.sidebarActive() || gaugeShown {
|
||||
usage = m.usageCostSegment()
|
||||
}
|
||||
if usage != "" {
|
||||
|
||||
Reference in new issue
Block a user