返回 DeepSeek-Reasonix
providerext_test.go
根目录 / internal / extension / providerext / providerext_test.go
1 package providerext
2
3 import (
4 "context"
5 "errors"
6 "strings"
7 "sync"
8 "sync/atomic"
9 "testing"
10 "time"
11
12 "reasonix/internal/extension"
13 "reasonix/internal/extension/protocol"
14 "reasonix/internal/provider"
15 )
16
17 // fakeClient implements ProviderClient with programmable behavior; streams are
18 // driven by the test calling the Resolver's router methods directly.
19 type fakeClient struct {
20 pluginID string
21 crashed atomic.Bool
22 disconnected chan struct{}
23 handshake protocol.InitializeResult
24
25 mu sync.Mutex
26 catalog []protocol.ProviderDescriptor
27 catalogErr error
28 catalogFn func(context.Context) ([]protocol.ProviderDescriptor, error)
29 fetches int
30 openErr error
31 accept bool
32 opened []protocol.StreamOpenParams
33 cancels []string
34 cancelWake chan struct{}
35 }
36
37 func newFakeClient(pluginID string, providers ...protocol.ProviderDescriptor) *fakeClient {
38 return &fakeClient{
39 pluginID: pluginID,
40 disconnected: make(chan struct{}),
41 handshake: protocol.InitializeResult{Providers: providers},
42 accept: true,
43 cancelWake: make(chan struct{}, 16),
44 }
45 }
46
47 func (f *fakeClient) PluginID() string { return f.pluginID }
48 func (f *fakeClient) Crashed() bool { return f.crashed.Load() }
49 func (f *fakeClient) Disconnected() <-chan struct{} { return f.disconnected }
50 func (f *fakeClient) Handshake() protocol.InitializeResult { return f.handshake }
51
52 // kill simulates a mid-stream sidecar crash: the connection drops without any
53 // further stream notifications.
54 func (f *fakeClient) kill() {
55 f.crashed.Store(true)
56 close(f.disconnected)
57 }
58
59 func (f *fakeClient) ProviderCatalog(ctx context.Context) ([]protocol.ProviderDescriptor, error) {
60 f.mu.Lock()
61 f.fetches++
62 fn := f.catalogFn
63 catalog := append([]protocol.ProviderDescriptor(nil), f.catalog...)
64 err := f.catalogErr
65 f.mu.Unlock()
66 if fn != nil {
67 return fn(ctx)
68 }
69 return catalog, err
70 }
71
72 func (f *fakeClient) fetchCount() int {
73 f.mu.Lock()
74 defer f.mu.Unlock()
75 return f.fetches
76 }
77
78 func (f *fakeClient) ProviderStreamOpen(_ context.Context, params protocol.StreamOpenParams) (protocol.StreamOpenResult, error) {
79 f.mu.Lock()
80 defer f.mu.Unlock()
81 f.opened = append(f.opened, params)
82 if f.openErr != nil {
83 return protocol.StreamOpenResult{}, f.openErr
84 }
85 return protocol.StreamOpenResult{Accepted: f.accept}, nil
86 }
87
88 func (f *fakeClient) ProviderStreamCancel(streamID string) {
89 f.mu.Lock()
90 f.cancels = append(f.cancels, streamID)
91 f.mu.Unlock()
92 f.cancelWake <- struct{}{}
93 }
94
95 func (f *fakeClient) openedParams(t *testing.T) protocol.StreamOpenParams {
96 t.Helper()
97 f.mu.Lock()
98 defer f.mu.Unlock()
99 if len(f.opened) != 1 {
100 t.Fatalf("stream opens = %d, want 1", len(f.opened))
101 }
102 return f.opened[0]
103 }
104
105 func (f *fakeClient) waitCancel(t *testing.T, streamID string) {
106 t.Helper()
107 deadline := time.Now().Add(testBudget)
108 for time.Now().Before(deadline) {
109 f.mu.Lock()
110 for _, id := range f.cancels {
111 if id == streamID {
112 f.mu.Unlock()
113 return
114 }
115 }
116 f.mu.Unlock()
117 select {
118 case <-f.cancelWake:
119 case <-time.After(10 * time.Millisecond):
120 }
121 }
122 t.Fatalf("stream cancel for %q never arrived", streamID)
123 }
124
125 // testBudget bounds every wait in these tests; the gap-timer test needs just
126 // over a second, so this stays comfortably above it.
127 const testBudget = 5 * time.Second
128
129 func testResolver(t *testing.T, base provider.Resolver, claims map[extension.Slot]extension.ContributionSource, clients ...ProviderClient) *Resolver {
130 t.Helper()
131 r, err := New(base, func() []ProviderClient { return clients }, claims)
132 if err != nil {
133 t.Fatalf("New: %v", err)
134 }
135 return r
136 }
137
138 func baseCatalog() *provider.StaticResolver {
139 return &provider.StaticResolver{
140 Descriptors: []provider.Descriptor{{Ref: "deepseek/deepseek-chat", DisplayName: "deepseek", Model: "deepseek-chat"}},
141 Providers: map[string]provider.Provider{"deepseek/deepseek-chat": staticProvider("deepseek")},
142 }
143 }
144
145 type staticProvider string
146
147 func (s staticProvider) Name() string { return string(s) }
148 func (s staticProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
149 return nil, errors.New("static provider does not stream")
150 }
151
152 func demoDescriptor() protocol.ProviderDescriptor {
153 return protocol.ProviderDescriptor{
154 Ref: "plugin/demo/fake/x", DisplayName: "Fake Demo", Model: "x",
155 ContextWindow: 64_000, Tools: true, Reasoning: true,
156 }
157 }
158
159 func TestCatalogMergesBaseAndSidecar(t *testing.T) {
160 fc := newFakeClient("demo", demoDescriptor())
161 fc.catalog = []protocol.ProviderDescriptor{demoDescriptor()}
162 r := testResolver(t, baseCatalog(), nil, fc)
163
164 catalog := r.Catalog()
165 if len(catalog) != 2 {
166 t.Fatalf("catalog = %v, want base + sidecar entries", catalog)
167 }
168 if catalog[0].Ref != "deepseek/deepseek-chat" || catalog[1].Ref != "plugin/demo/fake/x" {
169 t.Fatalf("catalog refs = %q, %q", catalog[0].Ref, catalog[1].Ref)
170 }
171 if catalog[1].DisplayName != "Fake Demo" || catalog[1].ContextWindow != 64_000 || !catalog[1].Tools || !catalog[1].Reasoning {
172 t.Fatalf("sidecar descriptor did not convert: %+v", catalog[1])
173 }
174 }
175
176 func TestCatalogSkipsEntriesOutsideNamespace(t *testing.T) {
177 fc := newFakeClient("demo", demoDescriptor())
178 fc.catalog = []protocol.ProviderDescriptor{
179 demoDescriptor(),
180 {Ref: "plugin/other/fake/x", Model: "x"},
181 {Ref: "plain/ref", Model: "ref"},
182 }
183 r := testResolver(t, baseCatalog(), nil, fc)
184
185 catalog := r.Catalog()
186 if len(catalog) != 2 || catalog[1].Ref != "plugin/demo/fake/x" {
187 t.Fatalf("catalog = %v, want only the namespaced sidecar entry", catalog)
188 }
189 }
190
191 func TestCatalogCachesPerClientAndDropsCrashed(t *testing.T) {
192 fc := newFakeClient("demo", demoDescriptor())
193 fc.catalog = []protocol.ProviderDescriptor{demoDescriptor()}
194 r := testResolver(t, baseCatalog(), nil, fc)
195
196 if got := len(r.Catalog()); got != 2 {
197 t.Fatalf("first catalog size = %d", got)
198 }
199 if got := len(r.Catalog()); got != 2 {
200 t.Fatalf("second catalog size = %d", got)
201 }
202 if fetches := fc.fetchCount(); fetches != 1 {
203 t.Fatalf("catalog fetches = %d, want 1 (cached per client)", fetches)
204 }
205
206 fc.kill()
207 catalog := r.Catalog()
208 if len(catalog) != 1 || catalog[0].Ref != "deepseek/deepseek-chat" {
209 t.Fatalf("catalog after crash = %v, want base only", catalog)
210 }
211 }
212
213 // TestCatalogCoalescesConcurrentFirstFetch forces every caller through the
214 // same cold-cache window. Exactly one sidecar RPC may run; followers must
215 // receive that call's result rather than racing duplicate dynamic catalogs
216 // into the cache with last-completion-wins behavior.
217 func TestCatalogCoalescesConcurrentFirstFetch(t *testing.T) {
218 fc := newFakeClient("demo", demoDescriptor())
219 started := make(chan struct{})
220 release := make(chan struct{})
221 var startOnce sync.Once
222 fc.catalogFn = func(ctx context.Context) ([]protocol.ProviderDescriptor, error) {
223 startOnce.Do(func() { close(started) })
224 select {
225 case <-release:
226 return []protocol.ProviderDescriptor{demoDescriptor()}, nil
227 case <-ctx.Done():
228 return nil, ctx.Err()
229 }
230 }
231 r := testResolver(t, baseCatalog(), nil, fc)
232
233 const callers = 32
234 results := make(chan []provider.Descriptor, callers)
235 for range callers {
236 go func() { results <- r.Catalog() }()
237 }
238 select {
239 case <-started:
240 case <-time.After(testBudget):
241 t.Fatal("catalog fetch never started")
242 }
243 if got := fc.fetchCount(); got != 1 {
244 t.Fatalf("catalog fetches while first call is blocked = %d, want 1", got)
245 }
246 close(release)
247 for range callers {
248 select {
249 case catalog := <-results:
250 if len(catalog) != 2 || catalog[1].Ref != demoDescriptor().Ref {
251 t.Fatalf("catalog = %+v, want base plus the shared sidecar result", catalog)
252 }
253 case <-time.After(testBudget):
254 t.Fatal("concurrent Catalog caller did not receive the shared result")
255 }
256 }
257 if got := fc.fetchCount(); got != 1 {
258 t.Fatalf("catalog fetches = %d, want exactly 1", got)
259 }
260 }
261
262 func TestCatalogSkipsFailedFetch(t *testing.T) {
263 fc := newFakeClient("demo", demoDescriptor())
264 fc.catalogErr = errors.New("sidecar unavailable")
265 r := testResolver(t, baseCatalog(), nil, fc)
266
267 catalog := r.Catalog()
268 if len(catalog) != 1 {
269 t.Fatalf("catalog = %v, want base only on fetch failure", catalog)
270 }
271 // A failed fetch is not cached: the next call retries.
272 fc.catalogErr = nil
273 fc.catalog = []protocol.ProviderDescriptor{demoDescriptor()}
274 if got := len(r.Catalog()); got != 2 {
275 t.Fatalf("catalog after recovery = %d, want 2", got)
276 }
277 }
278
279 func TestConflictWithoutClaimFails(t *testing.T) {
280 base := &provider.StaticResolver{
281 Descriptors: []provider.Descriptor{{Ref: "plugin/demo/fake/x", DisplayName: "host copy"}},
282 }
283 fc := newFakeClient("demo", demoDescriptor())
284 _, err := New(base, func() []ProviderClient { return []ProviderClient{fc} }, nil)
285 if err == nil {
286 t.Fatal("New succeeded with an unclaimed provider conflict")
287 }
288 var conflictErr *ConflictError
289 if !errors.As(err, &conflictErr) {
290 t.Fatalf("error %v is not a ConflictError", err)
291 }
292 if len(conflictErr.Conflicts) != 1 {
293 t.Fatalf("conflicts = %+v", conflictErr.Conflicts)
294 }
295 conflict := conflictErr.Conflicts[0]
296 if conflict.Ref != "plugin/demo/fake/x" || conflict.PluginID != "demo" {
297 t.Fatalf("conflict = %+v", conflict)
298 }
299 if conflict.Slot != extension.SlotProviderRef("plugin/demo/fake/x") {
300 t.Fatalf("conflict slot = %q", conflict.Slot)
301 }
302 // The diagnostic names both sources so the user can act on it.
303 msg := err.Error()
304 if !strings.Contains(msg, `"demo"`) || !strings.Contains(msg, "plugin/demo/fake/x") || !strings.Contains(msg, "host provider catalog") {
305 t.Fatalf("conflict message = %q", msg)
306 }
307 }
308
309 func TestConflictClaimedByOtherPluginFails(t *testing.T) {
310 base := &provider.StaticResolver{
311 Descriptors: []provider.Descriptor{{Ref: "plugin/demo/fake/x"}},
312 }
313 fc := newFakeClient("demo", demoDescriptor())
314 claims := map[extension.Slot]extension.ContributionSource{
315 extension.SlotProviderRef("plugin/demo/fake/x"): {PluginID: "someone-else"},
316 }
317 _, err := New(base, func() []ProviderClient { return []ProviderClient{fc} }, claims)
318 var conflictErr *ConflictError
319 if !errors.As(err, &conflictErr) {
320 t.Fatalf("error %v is not a ConflictError", err)
321 }
322 }
323
324 func TestConflictWithClaimSidecarReplacesBase(t *testing.T) {
325 base := &provider.StaticResolver{
326 Descriptors: []provider.Descriptor{
327 {Ref: "plugin/demo/fake/x", DisplayName: "host copy", Model: "x"},
328 {Ref: "deepseek/deepseek-chat", DisplayName: "deepseek"},
329 },
330 }
331 fc := newFakeClient("demo", demoDescriptor())
332 fc.catalog = []protocol.ProviderDescriptor{demoDescriptor()}
333 claims := map[extension.Slot]extension.ContributionSource{
334 extension.SlotProviderRef("plugin/demo/fake/x"): {PluginID: "demo"},
335 }
336 r := testResolver(t, base, claims, fc)
337
338 catalog := r.Catalog()
339 if len(catalog) != 2 {
340 t.Fatalf("catalog = %v, want the untouched base entry plus the sidecar replacement", catalog)
341 }
342 byRef := map[string]provider.Descriptor{}
343 for _, d := range catalog {
344 byRef[d.Ref] = d
345 }
346 replaced, ok := byRef["plugin/demo/fake/x"]
347 if !ok {
348 t.Fatalf("catalog lost the contested ref: %v", catalog)
349 }
350 if replaced.DisplayName != "Fake Demo" {
351 t.Fatalf("contested ref descriptor = %+v, want the sidecar's (claim winner)", replaced)
352 }
353 if _, ok := byRef["deepseek/deepseek-chat"]; !ok {
354 t.Fatalf("catalog lost the uncontested base entry: %v", catalog)
355 }
356 }
357
358 func TestResolveRoutesPluginRefToSidecar(t *testing.T) {
359 fc := newFakeClient("demo", demoDescriptor())
360 r := testResolver(t, baseCatalog(), nil, fc)
361
362 p, err := r.Resolve(provider.Selection{Ref: "plugin/demo/fake/x"})
363 if err != nil {
364 t.Fatalf("Resolve: %v", err)
365 }
366 ext, ok := p.(*Provider)
367 if !ok {
368 t.Fatalf("Resolve returned %T, want *providerext.Provider", p)
369 }
370 if ext.ref != "plugin/demo/fake/x" || ext.client != fc {
371 t.Fatalf("provider = %+v", ext)
372 }
373 if p.Name() != "plugin" {
374 t.Fatalf("Name() = %q, want the ref's first segment", p.Name())
375 }
376 }
377
378 func TestResolvePluginPrefixRefMatchesDeclaration(t *testing.T) {
379 fc := newFakeClient("demo", demoDescriptor())
380 r := testResolver(t, baseCatalog(), nil, fc)
381
382 // Broker-style partial ref: the provider without its model segment.
383 p, err := r.Resolve(provider.Selection{Ref: "plugin/demo/fake"})
384 if err != nil {
385 t.Fatalf("Resolve prefix: %v", err)
386 }
387 if p.(*Provider).ref != "plugin/demo/fake/x" {
388 t.Fatalf("provider ref = %q", p.(*Provider).ref)
389 }
390 }
391
392 func TestResolvePluginRefNotRunning(t *testing.T) {
393 r := testResolver(t, baseCatalog(), nil) // no sidecars at all
394 _, err := r.Resolve(provider.Selection{Ref: "plugin/demo/fake/x"})
395 if err == nil || !strings.Contains(err.Error(), `"demo"`) {
396 t.Fatalf("Resolve error = %v, want unknown-ref naming the plugin", err)
397 }
398 }
399
400 func TestResolvePluginRefNotDeclared(t *testing.T) {
401 fc := newFakeClient("demo") // declares no providers
402 r := testResolver(t, baseCatalog(), nil, fc)
403 _, err := r.Resolve(provider.Selection{Ref: "plugin/demo/fake/x"})
404 if err == nil || !strings.Contains(err.Error(), "does not declare") {
405 t.Fatalf("Resolve error = %v, want not-declared", err)
406 }
407 }
408
409 func TestResolveNonPluginRefUsesBase(t *testing.T) {
410 fc := newFakeClient("demo", demoDescriptor())
411 r := testResolver(t, baseCatalog(), nil, fc)
412
413 p, err := r.Resolve(provider.Selection{Ref: "deepseek/deepseek-chat"})
414 if err != nil {
415 t.Fatalf("Resolve: %v", err)
416 }
417 if _, ok := p.(staticProvider); !ok {
418 t.Fatalf("Resolve returned %T, want the base provider", p)
419 }
420 }
421
422 func TestResolveTwoSegmentPluginRefUsesBase(t *testing.T) {
423 // "plugin/x" is an ordinary two-segment ref, not the plugin namespace.
424 base := &provider.StaticResolver{
425 Descriptors: []provider.Descriptor{{Ref: "plugin/x"}},
426 Providers: map[string]provider.Provider{"plugin/x": staticProvider("base-plugin")},
427 }
428 r := testResolver(t, base, nil)
429 p, err := r.Resolve(provider.Selection{Ref: "plugin/x"})
430 if err != nil {
431 t.Fatalf("Resolve: %v", err)
432 }
433 if _, ok := p.(staticProvider); !ok {
434 t.Fatalf("Resolve returned %T, want the base provider", p)
435 }
436 }
437
438 func TestResolveNeverFallsBackForPluginRefs(t *testing.T) {
439 // The base resolver would happily serve a prefix/suffix match for the
440 // plugin-shaped ref; the merged resolver must not let a plugin ref reach it.
441 base := &provider.StaticResolver{
442 Descriptors: []provider.Descriptor{{Ref: "fake/x"}},
443 Providers: map[string]provider.Provider{"fake/x": staticProvider("fake")},
444 }
445 fc := newFakeClient("demo", demoDescriptor())
446 r := testResolver(t, base, nil, fc)
447 fc.kill()
448
449 p, err := r.Resolve(provider.Selection{Ref: "plugin/demo/fake/x"})
450 if err != nil {
451 t.Fatalf("Resolve: %v", err)
452 }
453 if _, ok := p.(*Provider); !ok {
454 t.Fatalf("Resolve fell back to %T after the crash", p)
455 }
456 _, err = p.Stream(context.Background(), provider.Request{})
457 if !provider.IsStreamInterrupted(err) {
458 t.Fatalf("Stream error = %v, want fail-fast interruption", err)
459 }
460 }
461
462 func TestNewWithNoSidecarProvidersBehavesLikeBase(t *testing.T) {
463 r := testResolver(t, baseCatalog(), nil)
464 catalog := r.Catalog()
465 if len(catalog) != 1 || catalog[0].Ref != "deepseek/deepseek-chat" {
466 t.Fatalf("catalog = %v", catalog)
467 }
468 if _, err := r.Resolve(provider.Selection{Ref: "deepseek/deepseek-chat"}); err != nil {
469 t.Fatalf("Resolve: %v", err)
470 }
471 }
472
473 func TestNewNilBaseTolerated(t *testing.T) {
474 fc := newFakeClient("demo", demoDescriptor())
475 fc.catalog = []protocol.ProviderDescriptor{demoDescriptor()}
476 r := testResolver(t, nil, nil, fc)
477 catalog := r.Catalog()
478 if len(catalog) != 1 || catalog[0].Ref != "plugin/demo/fake/x" {
479 t.Fatalf("catalog = %v", catalog)
480 }
481 }
482
482 lines GO