mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-08 01:48:26 -10:00
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:
1 parent
c80385d663
commit
8b2a05c514
16 files changed
+486
-68
No files matched your search
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user