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