diff --git a/src/providers/firecrawl.ts b/src/providers/firecrawl.ts index 4876fc1..216ef92 100644 --- a/src/providers/firecrawl.ts +++ b/src/providers/firecrawl.ts @@ -59,6 +59,8 @@ export function validateFirecrawlSearchRequest(providerName: string, request: No } export function validateFirecrawlFetchRequest(providerName: string, request: NormalizedFetchRequest) { + // Keep this defensive check here even though runtime validation also rejects it, + // so direct provider callers still get the same provider-specific error. if (request.highlights) { throw createProviderValidationError(providerName, 'does not support generic fetch option "highlights".'); } diff --git a/src/runtime.test.ts b/src/runtime.test.ts index e8656a8..91be336 100644 --- a/src/runtime.test.ts +++ b/src/runtime.test.ts @@ -398,3 +398,67 @@ test("search starts with the explicitly requested provider and still follows its assert.equal(result.execution.requestedProviderName, "firecrawl-main"); assert.equal(result.execution.actualProviderName, "exa-fallback"); }); + +test("search records provider factory failures and follows fallbacks", async () => { + const calls: string[] = []; + + const runtime = createWebSearchRuntime({ + loadConfig: async () => ({ + path: "test.json", + defaultProviderName: "firecrawl-main", + defaultProvider: { + name: "firecrawl-main", + type: "firecrawl", + apiKey: "fc", + fallbackProviders: ["exa-fallback"], + }, + providers: [ + { + name: "firecrawl-main", + type: "firecrawl", + apiKey: "fc", + fallbackProviders: ["exa-fallback"], + }, + { name: "exa-fallback", type: "exa", apiKey: "exa" }, + ], + providersByName: new Map([ + [ + "firecrawl-main", + { name: "firecrawl-main", type: "firecrawl", apiKey: "fc", fallbackProviders: ["exa-fallback"] }, + ], + ["exa-fallback", { name: "exa-fallback", type: "exa", apiKey: "exa" }], + ]), + }), + createProvider(providerConfig) { + if (providerConfig.name === "firecrawl-main") { + throw new Error("factory boom:firecrawl-main"); + } + + return createProvider(providerConfig.name, providerConfig.type, { + search: async () => { + calls.push(providerConfig.name); + return { + providerName: providerConfig.name, + results: [{ title: "Exa hit", url: "https://exa.ai" }], + }; + }, + }); + }, + }); + + const result = await runtime.search({ query: "pi docs", provider: "firecrawl-main" }); + + assert.deepEqual(calls, ["exa-fallback"]); + assert.deepEqual(result.execution.attempts, [ + { + providerName: "firecrawl-main", + status: "failed", + reason: "factory boom:firecrawl-main", + }, + { + providerName: "exa-fallback", + status: "succeeded", + }, + ]); + assert.equal(result.execution.actualProviderName, "exa-fallback"); +}); diff --git a/src/runtime.ts b/src/runtime.ts index 52003b2..85ea565 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -118,7 +118,24 @@ export function createWebSearchRuntime( validateFetchRequestForProvider(providerName, providerConfig, request as NormalizedFetchRequest); } - const provider = createProvider(providerConfig); + let provider: WebProvider; + try { + provider = createProvider(providerConfig); + } catch (error) { + attempts.push({ + providerName, + status: "failed", + reason: (error as Error).message, + }); + lastError = error; + + for (const fallbackProviderName of providerConfig.fallbackProviders ?? []) { + if (!visited.has(fallbackProviderName)) { + pendingProviderNames.push(fallbackProviderName); + } + } + continue; + } try { const response = await provider[operation]({