| 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 |