返回 DeepSeek-Reasonix
providerext.go
根目录 / internal / extension / providerext / providerext.go
1 // Package providerext adapts extension-hosted sidecar providers into the
2 // host's provider.Resolver surface (Extension Protocol v2, stage 7). Each
3 // started sidecar holds its own provider credentials and runs streams; the
4 // host only ever sees the credential-free wire DTOs. The Resolver merges the
5 // base resolver's catalog with every sidecar's declared catalog and routes
6 // plugin-namespaced refs (plugin/<plugin>/<provider>/<model>) to the owning
7 // sidecar's Provider. It mirrors the Remote broker's host-side semantics
8 // (internal/remote/broker) exactly: 1-based contiguous seq buffering, a
9 // bounded delivery queue, a gap timer on stream end, cancellation through
10 // stream/cancel, and interruption via StreamInterruptedError. A selected
11 // sidecar's crash fails its streams — the adapter never falls back to a
12 // different provider for plugin refs.
13 package providerext
14
15 import (
16 "context"
17 "fmt"
18 "log/slog"
19 "strings"
20 "sync"
21 "time"
22
23 "reasonix/internal/extension"
24 "reasonix/internal/extension/protocol"
25 "reasonix/internal/extension/providerconv"
26 "reasonix/internal/extension/sidecar"
27 "reasonix/internal/provider"
28 )
29
30 // ProviderClient is the slice of a live sidecar connection the adapter needs.
31 // *sidecar.Client satisfies it; tests substitute fakes.
32 type ProviderClient interface {
33 // PluginID returns the installed plugin package name this client serves.
34 PluginID() string
35 // Crashed reports whether the connection ended unexpectedly.
36 Crashed() bool
37 // Disconnected returns a channel closed when the connection's serve loop
38 // ends for any reason — crash, orderly shutdown, or transport failure.
39 Disconnected() <-chan struct{}
40 // Handshake returns the sidecar's validated initialize result; its
41 // Providers are the declaration of record for routing and conflicts.
42 Handshake() protocol.InitializeResult
43 // ProviderCatalog fetches the sidecar's full provider catalog.
44 ProviderCatalog(ctx context.Context) ([]protocol.ProviderDescriptor, error)
45 // ProviderStreamOpen opens one stream; chunks arrive as notifications.
46 ProviderStreamOpen(ctx context.Context, params protocol.StreamOpenParams) (protocol.StreamOpenResult, error)
47 // ProviderStreamCancel cancels one in-flight stream, best effort.
48 ProviderStreamCancel(streamID string)
49 }
50
51 // ProviderConflict records one sidecar provider ref that collides with a base
52 // catalog entry without the plugin owning the provider:<ref> replacement slot.
53 type ProviderConflict struct {
54 // Ref is the colliding provider ref (identical in both catalogs).
55 Ref string
56 // PluginID is the extension declaring Ref.
57 PluginID string
58 // Slot is the replacement slot the plugin must claim to override legally.
59 Slot extension.Slot
60 }
61
62 // ConflictError fails a build whose sidecar providers collide with the base
63 // catalog without a manifest replacement claim. Boot treats it as fatal, the
64 // same class as sidecar.RequiredStartError.
65 type ConflictError struct {
66 Conflicts []ProviderConflict
67 }
68
69 func (e *ConflictError) Error() string {
70 lines := make([]string, 0, len(e.Conflicts))
71 for _, c := range e.Conflicts {
72 lines = append(lines, fmt.Sprintf(
73 "plugin %q declares provider ref %q that the host provider catalog already serves; declare %q in the plugin manifest's runtime.replaces to override it",
74 c.PluginID, c.Ref, string(c.Slot)))
75 }
76 return "extension provider conflict: " + strings.Join(lines, "; ")
77 }
78
79 // Resolver merges the base provider.Resolver with the extension sidecars'
80 // provider catalogs. It also implements sidecar.StreamRouter: the sidecar
81 // clients deliver inbound stream/chunk and stream/end notifications here, and
82 // the Resolver routes them by stream ID to the owning buffered stream.
83 type Resolver struct {
84 base provider.Resolver
85 clients func() []ProviderClient
86 owner *extension.RuntimeOwner
87
88 // replaced maps a base catalog ref to the plugin ID whose claimed
89 // provider:<ref> slot lets its descriptor substitute the base entry.
90 replaced map[string]string
91
92 mu sync.Mutex
93 streams map[string]*extensionStream
94 catalogCache map[string][]provider.Descriptor
95 catalogCalls map[string]*catalogCall
96 idleTimeout time.Duration
97 }
98
99 // catalogCall is one in-flight catalog fetch shared by every concurrent
100 // Catalog caller for the same plugin. The first caller owns the sidecar RPC;
101 // followers wait for done and then receive defensive copies of the same
102 // result. This prevents duplicate first-use RPCs and last-completion-wins
103 // cache contents when a sidecar catalog is dynamic.
104 type catalogCall struct {
105 done chan struct{}
106 descriptors []provider.Descriptor
107 ok bool
108 }
109
110 var (
111 _ provider.Resolver = (*Resolver)(nil)
112 _ sidecar.StreamRouter = (*Resolver)(nil)
113 _ ProviderClient = (*sidecar.Client)(nil)
114 )
115
116 // catalogFetchTimeout bounds one sidecar's extension/provider/catalog call,
117 // mirroring the broker host's catalog budget.
118 const catalogFetchTimeout = 10 * time.Second
119 const defaultStreamIdleTimeout = 300 * time.Second
120
121 // New builds the merged resolver. clients supplies the live sidecar
122 // connections (typically the sidecar Manager's client list); claims is the
123 // build's frozen replacement-slot ownership table (the kernel snapshot's
124 // Replacements). A sidecar ref colliding exactly with a base catalog ref is
125 // legal only when the plugin owns the provider:<ref> slot — then the sidecar
126 // descriptor replaces the base entry — and is a *ConflictError otherwise.
127 func New(base provider.Resolver, clients func() []ProviderClient, claims map[extension.Slot]extension.ContributionSource, owners ...*extension.RuntimeOwner) (*Resolver, error) {
128 if base == nil {
129 base = &provider.StaticResolver{}
130 }
131 if clients == nil {
132 clients = func() []ProviderClient { return nil }
133 }
134 var owner *extension.RuntimeOwner
135 if len(owners) > 0 && owners[0] != nil {
136 owner = owners[0]
137 }
138 owner = extension.RuntimeOwnerOrDefault(owner)
139 r := &Resolver{
140 base: base,
141 clients: clients,
142 owner: owner,
143 replaced: map[string]string{},
144 streams: make(map[string]*extensionStream),
145 catalogCache: make(map[string][]provider.Descriptor),
146 catalogCalls: make(map[string]*catalogCall),
147 idleTimeout: defaultStreamIdleTimeout,
148 }
149 baseRefs := map[string]bool{}
150 for _, d := range base.Catalog() {
151 baseRefs[d.Ref] = true
152 }
153 var conflicts []ProviderConflict
154 for _, client := range clients() {
155 prefix := "plugin/" + client.PluginID() + "/"
156 for _, decl := range client.Handshake().Providers {
157 ref := strings.TrimSpace(decl.Ref)
158 if !strings.HasPrefix(ref, prefix) {
159 // The handshake validation already enforces the namespace;
160 // skip defensively rather than failing the build twice.
161 continue
162 }
163 if !baseRefs[ref] {
164 continue
165 }
166 slot := extension.SlotProviderRef(ref)
167 owner, claimed := claims[slot]
168 if !claimed || owner.PluginID != client.PluginID() {
169 conflicts = append(conflicts, ProviderConflict{Ref: ref, PluginID: client.PluginID(), Slot: slot})
170 continue
171 }
172 r.replaced[ref] = client.PluginID()
173 }
174 }
175 if len(conflicts) > 0 {
176 return nil, &ConflictError{Conflicts: conflicts}
177 }
178 return r, nil
179 }
180
181 // Catalog returns the base catalog minus claim-replaced entries plus every
182 // reachable sidecar's catalog. Sidecar catalogs are cached per client for the
183 // client's lifetime (its generation); a crashed sidecar's cache is dropped
184 // and its entries stop being offered. Fetch failures skip that sidecar for
185 // this call, mirroring the broker's best-effort catalog.
186 func (r *Resolver) Catalog() []provider.Descriptor {
187 base := r.base.Catalog()
188 out := make([]provider.Descriptor, 0, len(base))
189 for _, d := range base {
190 if _, replaced := r.replaced[d.Ref]; replaced {
191 continue
192 }
193 out = append(out, d)
194 }
195 for _, client := range r.clients() {
196 descriptors, ok := r.catalogFor(client)
197 if !ok {
198 continue
199 }
200 out = append(out, descriptors...)
201 }
202 return out
203 }
204
205 // catalogFor returns one sidecar's converted catalog, serving the per-client
206 // cache when warm. Entries outside the plugin's own namespace are skipped:
207 // the catalog RPC result must honor the same contract the handshake enforced.
208 func (r *Resolver) catalogFor(client ProviderClient) ([]provider.Descriptor, bool) {
209 pluginID := client.PluginID()
210 if client.Crashed() {
211 r.mu.Lock()
212 delete(r.catalogCache, pluginID)
213 r.mu.Unlock()
214 return nil, false
215 }
216 r.mu.Lock()
217 if cached, ok := r.catalogCache[pluginID]; ok {
218 out := append([]provider.Descriptor(nil), cached...)
219 r.mu.Unlock()
220 return out, true
221 }
222 if call := r.catalogCalls[pluginID]; call != nil {
223 r.mu.Unlock()
224 <-call.done
225 return append([]provider.Descriptor(nil), call.descriptors...), call.ok
226 }
227 call := &catalogCall{done: make(chan struct{})}
228 r.catalogCalls[pluginID] = call
229 r.mu.Unlock()
230
231 ctx, cancel := context.WithTimeout(context.Background(), catalogFetchTimeout)
232 defer cancel()
233 declared, err := client.ProviderCatalog(ctx)
234 if err != nil {
235 slog.Debug("providerext: sidecar catalog fetch failed", "plugin", pluginID, "err", err)
236 r.finishCatalogCall(pluginID, call, nil, false)
237 return nil, false
238 }
239 prefix := "plugin/" + pluginID + "/"
240 out := make([]provider.Descriptor, 0, len(declared))
241 for _, d := range declared {
242 if !strings.HasPrefix(d.Ref, prefix) {
243 slog.Debug("providerext: skipping catalog ref outside the plugin namespace", "plugin", pluginID, "ref", d.Ref)
244 continue
245 }
246 out = append(out, providerconv.DescriptorFromProtocol(d))
247 }
248 ok := !client.Crashed()
249 r.finishCatalogCall(pluginID, call, out, ok)
250 return append([]provider.Descriptor(nil), out...), ok
251 }
252
253 // finishCatalogCall publishes one fetch atomically before waking followers.
254 // Only a live client's successful result enters the generation-local cache.
255 func (r *Resolver) finishCatalogCall(pluginID string, call *catalogCall, descriptors []provider.Descriptor, ok bool) {
256 r.mu.Lock()
257 call.descriptors = append([]provider.Descriptor(nil), descriptors...)
258 call.ok = ok
259 if ok {
260 r.catalogCache[pluginID] = append([]provider.Descriptor(nil), descriptors...)
261 }
262 delete(r.catalogCalls, pluginID)
263 close(call.done)
264 r.mu.Unlock()
265 }
266
267 // Resolve routes a plugin-namespaced ref to the owning sidecar's Provider and
268 // everything else to the base resolver. A plugin ref whose plugin is not
269 // running, or that the plugin never declared, is an unknown-model-style
270 // error: the adapter NEVER falls back to a different provider for it.
271 func (r *Resolver) Resolve(selection provider.Selection) (provider.Provider, error) {
272 ref := strings.TrimSpace(selection.Ref)
273 if ref == "" {
274 return nil, fmt.Errorf("provider selection ref is required")
275 }
276 pluginID := PluginRefOwner(ref)
277 if pluginID == "" {
278 return r.base.Resolve(selection)
279 }
280 client := r.liveClient(pluginID)
281 if client == nil {
282 return nil, fmt.Errorf("unknown provider ref %q: extension plugin %q is not running", ref, pluginID)
283 }
284 descriptor, ok := declaredDescriptor(client, ref)
285 if !ok {
286 return nil, fmt.Errorf("unknown provider ref %q: extension plugin %q does not declare it", ref, pluginID)
287 }
288 effort := descriptor.DefaultEffort
289 if selection.Effort != nil {
290 effort = *selection.Effort
291 }
292 if err := provider.ReasoningOptions(descriptor.DefaultEffort, descriptor.Efforts...).Validate(descriptor.Model, effort); err != nil {
293 return nil, err
294 }
295 selection.Effort = &effort
296 return &Provider{
297 resolver: r,
298 client: client,
299 owner: pluginID,
300 ref: descriptor.Ref,
301 effort: selection.Effort,
302 descriptor: descriptor,
303 }, nil
304 }
305
306 // liveClient returns the current backend for pluginID, or nil when the plugin
307 // is not registered. Crashed clients are still returned so Stream can surface
308 // StreamInterruptedError; only a missing plugin yields "not running".
309 func (r *Resolver) liveClient(pluginID string) ProviderClient {
310 if r == nil || pluginID == "" {
311 return nil
312 }
313 for _, client := range r.clients() {
314 if client.PluginID() == pluginID {
315 return client
316 }
317 }
318 return nil
319 }
320
321 // PluginRefOwner extracts the plugin ID from a plugin-namespaced ref
322 // (plugin/<pluginID>/<rest...>). It re-exports the protocol package's
323 // canonical namespace helper; see protocol.PluginRefOwner.
324 func PluginRefOwner(ref string) string {
325 return protocol.PluginRefOwner(ref)
326 }
327
328 // declaredDescriptor finds the plugin's handshake declaration for ref: an
329 // exact match, or the broker-style prefix form where ref names a provider and
330 // the declaration adds the model segment. The returned descriptor carries the
331 // full declared ref.
332 func declaredDescriptor(client ProviderClient, ref string) (provider.Descriptor, bool) {
333 for _, decl := range client.Handshake().Providers {
334 if decl.Ref == ref || strings.HasPrefix(decl.Ref, ref+"/") {
335 return providerconv.DescriptorFromProtocol(decl), true
336 }
337 }
338 return provider.Descriptor{}, false
339 }
340
340 lines GO