fix(remote): a brokered pane takes its models from the broker, not the host's config

A host whose provider is "local" resolves every request on the machine
that connected to it, yet the pane's model surface still read the host's
own config. A pane opened with no model started on the host's
default_model, the picker listed the host's providers, and the broker
at home refused the first turn with `unknown model` for a model that
works fine when the host is used directly (#10448).

The broker catalog could not have said otherwise: it listed one
descriptor per provider, by bare name for any provider declaring
`models`, and named no default. It now lists one ref per chat model and
marks the one a new session starts on; the broker side offers only
models it holds a key for. A build given a resolver starts on that
default, the serve launched for a bootstrapped host leaves its model
implicit, and /models on such a pane lists the resolver's catalog
without writing the host's default_model on a switch.

The pane also lost the broker on every rebuild. A model switch, an
extension reload and a workspace move built from options that never
carried the hub's resolver, so the replacement resolved through the
host's config and credentials — the exact thing provider = "local"
exists to avoid. The server now keeps the resolver the hub gave it and
every rebuild passes it on.

A ref the broker's machine does not carry now crosses as its own kind
and arrives as provider.ErrUnknownModel naming where the lookup ran, so
a resumed session recorded against the host's own model says which
machine lacks it and what to change.

Both sides need this build: a host kernel from before it still applies
its own default_model.
This commit is contained in:
esengine committed 2026-09-17 01:59:46 -07:00
1 parent c80385d663
commit 8b2a05c514
16 files changed
+486 -68

No files matched your search

+2 -4
View File
@@ -61,7 +61,7 @@ import (
// ErrUnknownModel is returned by Build when the configured model can't be
// resolved to a provider — e.g. a default_model left over from a renamed or
// removed provider. Callers can detect it (errors.Is) to re-run setup.
var ErrUnknownModel = errors.New("unknown model")
var ErrUnknownModel = provider.ErrUnknownModel
func agentKeepPolicy(keep []string) agent.KeepPolicy {
if keep == nil {
@@ -258,9 +258,7 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
// explicit opts.Model; explicit choices still fail loudly.
modelName := opts.Model
if modelName == "" {
if resolved, _, ok := cfg.ResolveNewSessionChatModel(); ok {
modelName = resolved
}
modelName = newSessionModel(opts.ProviderResolver, cfg)
}
config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, modelName)
agentPreset := strings.TrimSpace(opts.AgentPreset)
+82 -22
View File
@@ -22,38 +22,85 @@ func NewLocalProviderResolver(cfg *config.Config, proxy netclient.ProxySpec) *Lo
return &LocalProviderResolver{cfg: cfg, proxy: proxy}
}
// Catalog lists every chat model the config declares, one ref per model, and
// marks the one a new session starts on. A broker's remote reads nothing else
// about this machine's models, so a provider listed once by name would leave
// it unable to recognise any ref but that provider's first.
func (r *LocalProviderResolver) Catalog() []provider.Descriptor {
if r == nil || r.cfg == nil {
return nil
}
out := make([]provider.Descriptor, 0, len(r.cfg.Providers))
defaultRef := ""
if def, _, ok := r.cfg.ResolveNewSessionChatModel(); ok {
if e, found := r.cfg.ResolveModel(def); found {
defaultRef = modelRefFromEntry(e)
}
}
var out []provider.Descriptor
seen := map[string]bool{}
for i := range r.cfg.Providers {
e := &r.cfg.Providers[i]
ref := modelRefFromEntry(e)
d := provider.Descriptor{
Ref: ref, DisplayName: e.Name, Model: e.Model,
ContextWindow: e.ContextWindow, Vision: config.EffectiveVision(e),
Tools: true, DefaultEffort: config.EffectiveEffort(e),
p := &r.cfg.Providers[i]
models := p.ChatModelList()
if len(models) == 0 {
models = p.ModelList()
}
if price := e.PriceForModel(e.Model); price != nil {
d.PricingCurrency = price.Currency
d.CacheHitPerMillion = price.CacheHit
d.InputPerMillion = price.Input
d.OutputPerMillion = price.Output
for _, model := range models {
e, ok := r.cfg.ResolveModel(p.Name + "/" + model)
if !ok {
continue
}
ref := modelRefFromEntry(e)
if seen[ref] {
continue
}
seen[ref] = true
d := descriptorFor(e, ref)
d.Default = ref == defaultRef
out = append(out, d)
}
if len(e.SupportedEfforts) > 0 {
d.Efforts = append([]string(nil), e.SupportedEfforts...)
d.Reasoning = true
}
if config.ReasoningProtocolForEntry(e) == config.ReasoningProtocolDeepSeek {
d.ToolCallReasoning = true
d.Reasoning = true
}
out = append(out, d)
}
return out
}
func descriptorFor(e *config.ProviderEntry, ref string) provider.Descriptor {
d := provider.Descriptor{
Ref: ref, DisplayName: e.Name, Model: e.Model,
ContextWindow: e.ContextWindow, Vision: config.EffectiveVision(e),
Tools: true, DefaultEffort: config.EffectiveEffort(e),
}
if price := e.PriceForModel(e.Model); price != nil {
d.PricingCurrency = price.Currency
d.CacheHitPerMillion = price.CacheHit
d.InputPerMillion = price.Input
d.OutputPerMillion = price.Output
}
if len(e.SupportedEfforts) > 0 {
d.Efforts = append([]string(nil), e.SupportedEfforts...)
d.Reasoning = true
}
if config.ReasoningProtocolForEntry(e) == config.ReasoningProtocolDeepSeek {
d.ToolCallReasoning = true
d.Reasoning = true
}
return d
}
// newSessionModel names the model a build starts on when its caller named none.
// A caller-owned resolver answers first: its models are the only ones this
// session reaches, and a default read from this machine's config can name one
// it cannot run. A resolver that marks none leaves the config to answer.
func newSessionModel(resolver provider.Resolver, cfg *config.Config) string {
if resolver != nil {
if ref := provider.DefaultRef(resolver.Catalog()); ref != "" {
return ref
}
}
if resolved, _, ok := cfg.ResolveNewSessionChatModel(); ok {
return resolved
}
return ""
}
func (r *LocalProviderResolver) Resolve(selection provider.Selection) (provider.Provider, error) {
if r == nil || r.cfg == nil {
return nil, fmt.Errorf("local provider resolver is not configured")
@@ -86,8 +133,21 @@ func (LiveProviderResolver) current() *LocalProviderResolver {
return NewLocalProviderResolver(cfg, cfg.NetworkProxySpec())
}
// Catalog offers what this machine can answer with now. A provider without a
// stored key is still declared, but every request the far side sent it would
// fail here, so it stays out of the list that side picks from.
func (r LiveProviderResolver) Catalog() []provider.Descriptor {
return r.current().Catalog()
cur := r.current()
if cur == nil {
return nil
}
var out []provider.Descriptor
for _, d := range cur.Catalog() {
if e, ok := cur.cfg.ResolveModel(d.Ref); ok && e.Configured() {
out = append(out, d)
}
}
return out
}
func (r LiveProviderResolver) Resolve(selection provider.Selection) (provider.Provider, error) {
+105
View File
@@ -1,6 +1,10 @@
package boot
import (
"context"
"os"
"path/filepath"
"slices"
"testing"
"reasonix/internal/config"
@@ -29,3 +33,104 @@ func TestRemoteResolverMetadataOverridesHostProviderWithSameRef(t *testing.T) {
t.Fatalf("resolved pricing = %+v", entry.Price)
}
}
func writeCatalogConfig(t *testing.T) {
t.Helper()
isolateConfigHome(t)
t.Setenv("REASONIX_HOME", robustTempDir(t))
t.Chdir(robustTempDir(t))
path := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
body := `default_model = "multi/model-a"
[[providers]]
name = "multi"
kind = "openai"
base_url = "http://127.0.0.1:1/v1"
models = ["model-a", "model-b"]
default = "model-b"
[[providers]]
name = "keyless"
kind = "openai"
base_url = "https://example.invalid/v1"
models = ["model-k"]
api_key_env = "REASONIX_TEST_KEY_UNSET"
`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
func catalogRefs(catalog []provider.Descriptor) (refs []string, def string) {
for _, d := range catalog {
refs = append(refs, d.Ref)
}
return refs, provider.DefaultRef(catalog)
}
// A broker's far side knows this machine's models only through the catalog, so
// every model of a multi-model provider has to be a ref of its own, and the
// default has to be the one default_model names rather than the provider's own.
func TestLocalCatalogListsEveryModelAndMarksTheDefault(t *testing.T) {
writeCatalogConfig(t)
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
refs, def := catalogRefs(NewLocalProviderResolver(cfg, cfg.NetworkProxySpec()).Catalog())
if want := []string{"multi/model-a", "multi/model-b", "keyless/model-k"}; !slices.Equal(refs, want) {
t.Fatalf("catalog refs = %v, want %v", refs, want)
}
if def != "multi/model-a" {
t.Fatalf("default = %q, want multi/model-a", def)
}
}
// What the broker offers is what it can answer: a declared provider with no key
// would fail every request the far side sent it.
func TestLiveCatalogOffersOnlyModelsWithCredentials(t *testing.T) {
writeCatalogConfig(t)
refs, def := catalogRefs(LiveProviderResolver{}.Catalog())
if want := []string{"multi/model-a", "multi/model-b"}; !slices.Equal(refs, want) {
t.Fatalf("live catalog refs = %v, want %v", refs, want)
}
if def != "multi/model-a" {
t.Fatalf("default = %q, want multi/model-a", def)
}
}
// A build given a resolver starts on that resolver's default. This machine's
// default_model names a provider the resolver does not serve, which is exactly
// the session a bootstrapped host used to start and could never run.
func TestBuildStartsOnTheCallerResolversDefault(t *testing.T) {
writeCatalogConfig(t)
home := silentProvider{}
resolver := &provider.StaticResolver{
Descriptors: []provider.Descriptor{
{Ref: "home/chat-a", DisplayName: "home", Model: "chat-a"},
{Ref: "home/chat-b", DisplayName: "home", Model: "chat-b", Default: true},
},
Providers: map[string]provider.Provider{"home/chat-a": home, "home/chat-b": home},
}
res, err := BuildRuntime(context.Background(), Options{WorkspaceRoot: robustTempDir(t), ProviderResolver: resolver})
if err != nil {
t.Fatalf("BuildRuntime: %v", err)
}
defer res.Controller.Close()
if got := res.Controller.ModelRef(); got != "home/chat-b" {
t.Fatalf("model = %q, want the resolver's default home/chat-b", got)
}
}
type silentProvider struct{}
func (silentProvider) Name() string { return "home" }
func (silentProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
ch := make(chan provider.Chunk)
close(ch)
return ch, nil
}
+11
View File
@@ -44,6 +44,17 @@ func resolveModelForCLI(explicitRef string, cfg *config.Config) (ref string, fal
return ref, fallback, nil
}
// serveStartModel is the model a serve's first pane starts on. Explicit flags
// and a resumed session's model are kept verbatim; a brokered serve leaves the
// rest implicit, because its default belongs to the broker.
func serveStartModel(model, resume string, cfg *config.Config, brokered bool) string {
model = modelForResumePath(model, resume, cfg)
if brokered {
return model
}
return resolveServeModel(model)
}
// resolveServeModel keeps serve's implicit model scoped to the user config.
// A project reasonix.toml may configure the served workspace, but it must not
// replace the account-level model used for new serve sessions.
+5 -9
View File
@@ -358,15 +358,6 @@ func runServeWithOptions(args []string, opts serveRunOptions) int {
return 1
}
}
*model = modelForResumePath(*model, *resume, cfg)
// Serve always resolves an implicit model from the user-global config,
// ignoring project-level default_model overrides. Explicit flags and
// resumable session models remain strict and are preserved verbatim.
*model = resolveServeModel(*model)
// Keep the browser reachable when the selected provider has no saved key.
// The loopback-only provider setup surface stores the missing credential and
// rebuilds this controller in place before the normal web UI is exposed.
paneSink := reporter.Wrap(bc)
// A bootstrapped serve resolves providers over the tunnel back to the
// machine that started it, so this host needs no key and no egress of its
// own. Unset leaves boot on its ordinary config-backed path.
@@ -375,6 +366,11 @@ func runServeWithOptions(args []string, opts serveRunOptions) int {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 2
}
*model = serveStartModel(*model, *resume, cfg, providerResolver != nil)
// Keep the browser reachable when the selected provider has no saved key.
// The loopback-only provider setup surface stores the missing credential and
// rebuilds this controller in place before the normal web UI is exposed.
paneSink := reporter.Wrap(bc)
ctrl, err := setupProfileWithOverrides(ctx, *model, *maxSteps, false, paneSink, profile, cliBuildOverrides{
Version: opts.version, OnSessionRecovered: cliSessionRecoveredHandler(leases),
ProviderResolver: providerResolver,
+21
View File
@@ -1,10 +1,16 @@
package provider
import (
"errors"
"fmt"
"strings"
)
// ErrUnknownModel is a ref no catalog the resolver reads carries. It is one
// identity on both sides of a broker, so a caller tells it apart the same way
// whether the resolver answered here or on the machine holding the keys.
var ErrUnknownModel = errors.New("unknown model")
// Descriptor is the non-sensitive provider/model metadata shared across
// process boundaries. It intentionally contains no endpoint, credential,
// header, proxy, or environment-variable information.
@@ -25,6 +31,21 @@ type Descriptor struct {
ToolCallReasoning bool `json:"toolCallReasoning,omitempty"`
ReasoningRoundTrip bool `json:"reasoningRoundTrip,omitempty"`
WarnOnMissingToolCallReasoning bool `json:"warnOnMissingToolCallReasoning,omitempty"`
// Default marks the model a new session starts on when nobody names one.
// The catalog's owner decides it, because only the owner knows which of
// its models can answer.
Default bool `json:"default,omitempty"`
}
// DefaultRef is the ref the catalog marks as its default, or "" when it marks
// none.
func DefaultRef(catalog []Descriptor) string {
for _, d := range catalog {
if d.Default {
return strings.TrimSpace(d.Ref)
}
}
return ""
}
// Selection identifies a catalog provider and an optional session-local
+42
View File
@@ -3,6 +3,7 @@ package providerbroker
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
@@ -301,6 +302,47 @@ func TestUnknownRefFailsAtTheStream(t *testing.T) {
}
}
// unknownModelResolver refuses every ref the way a config-backed resolver does.
type unknownModelResolver struct{}
func (unknownModelResolver) Catalog() []provider.Descriptor { return nil }
func (unknownModelResolver) Resolve(sel provider.Selection) (provider.Provider, error) {
return nil, fmt.Errorf("%w %q", provider.ErrUnknownModel, sel.Ref)
}
// The host reporting a missing model keeps a config of its own, so the refusal
// has to arrive as the identity it is and say which machine had no such model —
// a bare "unknown model" there reads as that host's config being wrong.
func TestUnknownModelCrossesWithItsIdentityAndRef(t *testing.T) {
client := newPair(t, unknownModelResolver{})
p, err := client.Resolve(provider.Selection{Ref: "paratera/DeepSeek-V4-Flash"})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
_, err = p.Stream(context.Background(), provider.Request{})
if !errors.Is(err, provider.ErrUnknownModel) {
t.Fatalf("Stream error = %#v, want provider.ErrUnknownModel", err)
}
var unknown *UnknownModelError
if !errors.As(err, &unknown) || unknown.Ref != "paratera/DeepSeek-V4-Flash" {
t.Fatalf("Stream error = %#v, want *UnknownModelError naming the ref", err)
}
}
// The far side starts a session on whatever the catalog marks, so the mark has
// to survive the wire.
func TestCatalogCarriesItsDefault(t *testing.T) {
fake := &fakeProvider{name: "home"}
client := newPair(t, staticResolver(fake,
provider.Descriptor{Ref: "home/a"},
provider.Descriptor{Ref: "home/b", Default: true},
))
if got := provider.DefaultRef(client.Catalog()); got != "home/b" {
t.Fatalf("default across the wire = %q, want home/b", got)
}
}
// Cancelling the remote turn has to abort the local provider's request, or the
// tunnel keeps paying for an answer nobody is reading.
func TestCancelReachesTheLocalProvider(t *testing.T) {
+14
View File
@@ -209,6 +209,20 @@ func emit(ctx context.Context, out chan<- provider.Chunk, c provider.Chunk) bool
}
}
// UnknownModelError is a ref the broker's machine has no model for. It says
// where the lookup ran, because the host reporting it keeps a config of its
// own that may well carry the ref — and that config is not where it resolves.
type UnknownModelError struct {
Ref string
}
func (e *UnknownModelError) Error() string {
return fmt.Sprintf("model %q is not configured on the machine this host resolves models through; "+
"choose one of that machine's models, or set this host's provider to %q to use its own", e.Ref, "remote")
}
func (e *UnknownModelError) Unwrap() error { return provider.ErrUnknownModel }
func decodeWireError(resp *http.Response) error {
data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if err != nil || len(bytes.TrimSpace(data)) == 0 {
+8 -2
View File
@@ -76,7 +76,9 @@ func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
}
prov, err := s.resolver.Resolve(req.Selection)
if err != nil {
writeWireError(w, http.StatusNotFound, err)
we := encodeError(err)
we.Ref = strings.TrimSpace(req.Selection.Ref)
writeWire(w, http.StatusNotFound, we)
return
}
ctx, cancel := context.WithCancel(r.Context())
@@ -113,7 +115,11 @@ func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
// separates "it did not start" from "it started and then failed"; the body
// carries the identity, so a 502 here still arrives as *provider.APIError.
func writeWireError(w http.ResponseWriter, status int, err error) {
writeWire(w, status, encodeError(err))
}
func writeWire(w http.ResponseWriter, status int, we *wireError) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(encodeError(err))
_ = json.NewEncoder(w).Encode(we)
}
+7
View File
@@ -74,6 +74,8 @@ const (
errKindCanceled errKind = "canceled"
errKindDeadline errKind = "deadline"
errKindOpaque errKind = "opaque"
// errKindUnknownModel is a ref the broker's own catalog does not carry.
errKindUnknownModel errKind = "unknown_model"
)
// wireError is the tagged union of every provider error a caller matches on.
@@ -91,6 +93,7 @@ type wireError struct {
KeySource string `json:"keySource,omitempty"`
HasKey bool `json:"hasKey,omitempty"`
Reason string `json:"reason,omitempty"`
Ref string `json:"ref,omitempty"`
Wrapped *wireError `json:"wrapped,omitempty"`
}
@@ -131,6 +134,8 @@ func encodeError(err error) *wireError {
}
}
switch {
case errors.Is(err, provider.ErrUnknownModel):
return &wireError{Kind: errKindUnknownModel, Message: err.Error()}
case errors.Is(err, context.Canceled):
return &wireError{Kind: errKindCanceled, Message: err.Error()}
case errors.Is(err, context.DeadlineExceeded):
@@ -166,6 +171,8 @@ func (w *wireError) decode() error {
return context.Canceled
case errKindDeadline:
return context.DeadlineExceeded
case errKindUnknownModel:
return &UnknownModelError{Ref: w.Ref}
}
return errors.New(w.Message)
}
+2
View File
@@ -211,6 +211,7 @@ func (h *Hub) Adopt(srv *Server, bc *Broadcaster) (*Runtime, error) {
srv.auth = h.auth
srv.surface = h.surface()
srv.stance = h.stance
srv.resolver = h.opts.ProviderResolver
// The posture the host launched in, which is the one every later pane
// inherits until someone changes it on the composer.
h.stance.set(srv.Controller().ToolApprovalMode())
@@ -286,6 +287,7 @@ func (h *Hub) Open(ctx context.Context, req OpenRequest) (*Runtime, error) {
srv.AdoptRuntime(built)
srv.auth = h.auth
srv.surface = h.surface()
srv.resolver = h.opts.ProviderResolver
if h.opts.Grant != nil {
h.opts.Grant(srv)
}
+106
View File
@@ -0,0 +1,106 @@
package serve
import (
"context"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"reasonix/internal/config"
"reasonix/internal/provider"
"reasonix/internal/testenv"
)
type homeProvider struct{}
func (homeProvider) Name() string { return "home" }
func (homeProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
ch := make(chan provider.Chunk)
close(ch)
return ch, nil
}
func homeResolver() *provider.StaticResolver {
return &provider.StaticResolver{
Descriptors: []provider.Descriptor{
{Ref: "home/chat-a", DisplayName: "home", Model: "chat-a"},
{Ref: "home/chat-b", DisplayName: "home", Model: "chat-b", Default: true},
},
Providers: map[string]provider.Provider{"home/chat-a": homeProvider{}, "home/chat-b": homeProvider{}},
}
}
type resolverModelsBody struct {
Current string `json:"current"`
Default string `json:"default"`
Models []struct {
Ref string `json:"ref"`
Default bool `json:"default"`
} `json:"models"`
}
// A pane a hub opens through a resolver lives on that resolver end to end: it
// starts on the resolver's default, offers the resolver's models, and a switch
// rebuilds onto the same resolver. This machine's config names other models —
// the ones a bootstrapped host carries of its own — and none of them may leak
// in, because the resolver is where every request of this pane is answered.
func TestHubResolverPaneNeverFallsBackToThisMachinesConfig(t *testing.T) {
writeServeModelConfig(t)
closeSharedCatalogsOnCleanup(t)
h := NewHub(HubOptions{ProviderResolver: homeResolver()})
defer h.Shutdown()
srv := httptest.NewServer(h.Handler())
defer srv.Close()
rt, err := h.Open(context.Background(), OpenRequest{Root: testenv.TempDir(t)})
if err != nil {
t.Fatal(err)
}
if got := rt.Server.Controller().ModelRef(); got != "home/chat-b" {
t.Fatalf("pane started on %q, want the resolver's default home/chat-b", got)
}
body := hubGet[resolverModelsBody](t, srv, "/rt/"+rt.ID+"/models")
var refs []string
for _, m := range body.Models {
refs = append(refs, m.Ref)
}
if want := []string{"home/chat-a", "home/chat-b"}; !slices.Equal(refs, want) {
t.Fatalf("picker offered %v, want only the resolver's %v", refs, want)
}
if body.Default != "home/chat-b" || body.Current != "home/chat-b" {
t.Fatalf("default/current = %q/%q, want home/chat-b", body.Default, body.Current)
}
resp, err := http.Post(srv.URL+"/rt/"+rt.ID+"/model", "application/json", strings.NewReader(`{"ref":"home/chat-a"}`))
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("POST /model = %d", resp.StatusCode)
}
switched := rt.Server.Controller()
if got := switched.ModelRef(); got != "home/chat-a" {
t.Fatalf("after the switch the pane is on %q, want home/chat-a", got)
}
refs, _ = descriptorRefs(switched.ProviderCatalog())
if want := []string{"home/chat-a", "home/chat-b"}; !slices.Equal(refs, want) {
t.Fatalf("the rebuilt pane resolves through %v, want the resolver's %v", refs, want)
}
if got := config.LoadForEdit(config.UserConfigPath()).DefaultModel; got != "default/shared-chat" {
t.Fatalf("default_model = %q: a resolver pane's switch wrote this machine's config", got)
}
}
func descriptorRefs(catalog []provider.Descriptor) ([]string, string) {
var refs []string
for _, d := range catalog {
refs = append(refs, d.Ref)
}
return refs, provider.DefaultRef(catalog)
}
+57
View File
@@ -0,0 +1,57 @@
package serve
import (
"net/http"
"strings"
"reasonix/internal/provider"
)
// resolverModels answers /models for a pane whose models resolve through a
// resolver the hub was given. That resolver's catalog is the whole offer: this
// machine's config names models the pane cannot reach, and a picker listing
// them sends each choice to a refusal from the machine doing the resolving.
func (s *Server) resolverModels(w http.ResponseWriter) {
ctrl := s.ctl()
current := currentModelRef(ctrl)
catalog := ctrl.ProviderCatalog()
out := []modelEntry{}
for _, d := range catalog {
if entry, ok := catalogModelEntry(d, current); ok {
out = append(out, entry)
}
}
writeJSON(w, map[string]any{"current": current, "label": ctrl.Label(), "default": provider.DefaultRef(catalog), "models": out})
}
// isExtensionModelRef reports a plugin/<plugin>/<provider>/<model> ref.
func isExtensionModelRef(ref string) bool {
parts := strings.Split(strings.TrimSpace(ref), "/")
return len(parts) >= 4 && parts[0] == "plugin"
}
// catalogModelEntry describes one catalog descriptor for the picker. Its
// capability fields are what the descriptor declares and nothing more.
func catalogModelEntry(d provider.Descriptor, current string) (modelEntry, bool) {
ref := strings.TrimSpace(d.Ref)
if ref == "" {
return modelEntry{}, false
}
entry := modelEntry{
Ref: ref, Active: ref == current, Default: d.Default,
Vision: d.Vision, Efforts: d.Efforts, Effort: d.DefaultEffort, ContextWindow: d.ContextWindow,
}
if d.InputPerMillion > 0 || d.OutputPerMillion > 0 {
entry.Price = &modelPrice{Input: d.InputPerMillion, Output: d.OutputPerMillion, CacheHit: d.CacheHitPerMillion, Currency: d.PricingCurrency}
}
if isExtensionModelRef(ref) {
parts := strings.Split(ref, "/")
entry.Provider, entry.Model, entry.Kind = strings.Join(parts[:3], "/"), parts[len(parts)-1], "extension"
} else {
entry.Provider, entry.Model, _ = strings.Cut(ref, "/")
}
if model := strings.TrimSpace(d.Model); model != "" {
entry.Model = model
}
return entry, true
}
+12 -24
View File
@@ -86,6 +86,9 @@ type Server struct {
// stance is the hub's Ask/Auto/YOLO posture, shared by every pane it drives.
// Nil for a server outside a hub, which speaks only for itself.
stance *approvalStance
// resolver is what every rebuild of this pane resolves models through, set
// by the hub. Nil leaves boot reading this machine's own config.
resolver provider.Resolver
}
// New builds a Server. bc must be what the controller's events reach; a host
@@ -859,6 +862,10 @@ func (s *Server) branches(w http.ResponseWriter, _ *http.Request) {
// models lists configured chat models for the browser model picker.
func (s *Server) models(w http.ResponseWriter, _ *http.Request) {
if s.resolver != nil {
s.resolverModels(w)
return
}
cfg, err := config.Load()
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
@@ -922,36 +929,17 @@ func (s *Server) models(w http.ResponseWriter, _ *http.Request) {
out = append(out, entry)
}
}
// ProviderCatalog is the controller-generation's authoritative merged view.
// Add descriptors not already represented by configured providers; this is
// where plugin/<plugin>/<provider>/<model> refs enter the Serve picker.
// ProviderCatalog is the controller-generation's authoritative merged view;
// its config-backed base was listed above, so only plugin refs enter here.
for _, d := range ctrl.ProviderCatalog() {
ref := strings.TrimSpace(d.Ref)
if ref == "" {
continue
}
if _, ok := seen[ref]; ok {
if _, ok := seen[ref]; ok || !isExtensionModelRef(ref) {
continue
}
seen[ref] = struct{}{}
parts := strings.Split(ref, "/")
if len(parts) < 4 || parts[0] != "plugin" {
// ProviderCatalog also contains the config-backed base. Configured
// base refs were handled above; do not resurrect unconfigured ones.
continue
if entry, ok := catalogModelEntry(d, current); ok {
out = append(out, entry)
}
providerName := strings.Join(parts[:3], "/")
model := strings.TrimSpace(d.Model)
if model == "" {
model = parts[len(parts)-1]
}
out = append(out, modelEntry{
Ref: ref,
Provider: providerName,
Model: model,
Kind: "extension",
Active: ref == current,
})
}
out = collapseModelRoutes(out, routes)
if out == nil {
+9 -6
View File
@@ -42,12 +42,15 @@ func (s *Server) model(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusInternalServerError, err)
return
}
catalog := s.ctl().ProviderCatalog()
// The switch only rebuilt the running controller. Without this the next
// launch boots from default_model and lands back on whatever was there
// before, which reads as the choice not having been saved at all — the CLI
// and the old desktop have both persisted it since they had a picker.
persistDefaultModel(ref, catalog)
// A pane resolving through the hub's resolver takes its default from there;
// this machine's default_model is one it never reads.
if s.resolver == nil {
// The switch only rebuilt the running controller. Without this the next
// launch boots from default_model and lands back on whatever was there
// before, which reads as the choice not having been saved at all — the CLI
// and the old desktop have both persisted it since they had a picker.
persistDefaultModel(ref, s.ctl().ProviderCatalog())
}
w.WriteHeader(http.StatusNoContent)
}
+3 -1
View File
@@ -274,7 +274,7 @@ func (s *Server) statsSurface() surface.Surface { return s.surface.Or(surface.Se
// process working directory and sessions fall back to the global dir, so the
// switch would quietly serve another project's conversations.
func (s *Server) rebuildOptions(cur control.SessionAPI, ref string) boot.Options {
opts := boot.Options{Model: ref, Sink: s.rebuildSink(), Stderr: os.Stderr, StatsSource: s.statsSurface()}
opts := boot.Options{Model: ref, Sink: s.rebuildSink(), Stderr: os.Stderr, StatsSource: s.statsSurface(), ProviderResolver: s.resolver}
if cur == nil {
return opts
}
@@ -386,5 +386,7 @@ func (s *Server) workspaceOptions(dir, ref string) boot.Options {
Sink: s.rebuildSink(),
Stderr: os.Stderr,
StatsSource: s.statsSurface(),
ProviderResolver: s.resolver,
}
}