ubersearch 1.6.2 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/package.json +5 -5
- package/providers/searxng/.env.example +1 -1
- package/src/bootstrap/container.ts +4 -17
- package/src/cli.ts +7 -14
- package/src/config/load.ts +12 -33
- package/src/config/validation.ts +22 -1
- package/src/core/credits/CreditManager.ts +18 -0
- package/src/core/credits/FileCreditStateProvider.ts +1 -1
- package/src/core/docker/dockerComposeHelper.ts +21 -14
- package/src/core/docker/dockerLifecycleManager.ts +60 -40
- package/src/core/errorUtils.ts +6 -0
- package/src/core/orchestrator.ts +2 -28
- package/src/core/paths.ts +56 -16
- package/src/core/provider.ts +1 -14
- package/src/core/retry.ts +102 -0
- package/src/core/strategy/AllProvidersStrategy.ts +52 -42
- package/src/core/strategy/FirstSuccessStrategy.ts +8 -12
- package/src/core/strategy/ISearchStrategy.ts +2 -2
- package/src/core/types.ts +1 -0
- package/src/mcp-server.ts +128 -28
- package/src/plugin/PluginRegistry.ts +17 -6
- package/src/plugin/builtin.ts +8 -10
- package/src/plugin/types.ts +3 -4
- package/src/providers/brave.ts +2 -7
- package/src/providers/helpers/resultMappers.ts +2 -2
- package/src/providers/linkup.ts +15 -47
- package/src/providers/retry.ts +1 -102
- package/src/providers/searchxng.ts +25 -46
- package/src/providers/tavily.ts +10 -16
- package/src/tool/interface.ts +3 -1
package/src/core/orchestrator.ts
CHANGED
|
@@ -7,32 +7,11 @@
|
|
|
7
7
|
import type { UberSearchConfig } from "../config/types";
|
|
8
8
|
import type { CreditManager, CreditSnapshot } from "./credits";
|
|
9
9
|
import type { ProviderRegistry } from "./provider";
|
|
10
|
-
import type { StrategyContext } from "./strategy/ISearchStrategy";
|
|
10
|
+
import type { EngineAttempt, StrategyContext, UberSearchOptions } from "./strategy/ISearchStrategy";
|
|
11
11
|
import { StrategyFactory } from "./strategy/StrategyFactory";
|
|
12
12
|
import type { EngineId, SearchResultItem } from "./types";
|
|
13
13
|
|
|
14
|
-
export
|
|
15
|
-
/** Override the default engine order */
|
|
16
|
-
engineOrderOverride?: EngineId[];
|
|
17
|
-
|
|
18
|
-
/** Maximum results per provider */
|
|
19
|
-
limit?: number;
|
|
20
|
-
|
|
21
|
-
/** Include raw provider responses */
|
|
22
|
-
includeRaw?: boolean;
|
|
23
|
-
|
|
24
|
-
/** Search strategy */
|
|
25
|
-
strategy?: "all" | "first-success";
|
|
26
|
-
|
|
27
|
-
/** SearXNG categories to search (e.g., ["general", "it", "science"]) */
|
|
28
|
-
categories?: string[];
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface EngineAttempt {
|
|
32
|
-
engineId: EngineId;
|
|
33
|
-
success: boolean;
|
|
34
|
-
reason?: string;
|
|
35
|
-
}
|
|
14
|
+
export type { UberSearchOptions, EngineAttempt };
|
|
36
15
|
|
|
37
16
|
export interface OrchestratorResult {
|
|
38
17
|
/** Original query */
|
|
@@ -89,11 +68,6 @@ export class UberSearchOrchestrator {
|
|
|
89
68
|
// Execute strategy
|
|
90
69
|
const { results, attempts } = await strategy.execute(query, order, options, context);
|
|
91
70
|
|
|
92
|
-
// For 'all' strategy, sort results by score (descending)
|
|
93
|
-
if (strategyName === "all") {
|
|
94
|
-
results.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
|
95
|
-
}
|
|
96
|
-
|
|
97
71
|
// Get credit snapshots
|
|
98
72
|
const credits = this.credits.listSnapshots();
|
|
99
73
|
|
package/src/core/paths.ts
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
|
-
import { dirname, join } from "node:path";
|
|
10
|
+
import { dirname, join, resolve } from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
12
|
|
|
13
13
|
const APP_NAME = "ubersearch";
|
|
@@ -108,17 +108,58 @@ export function getPackageRoot(): string {
|
|
|
108
108
|
return dirname(dirname(currentDir));
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Get candidate runtime roots where bundled SearXNG assets may live.
|
|
113
|
+
*
|
|
114
|
+
* This covers:
|
|
115
|
+
* - source checkouts (`<root>/providers/searxng`)
|
|
116
|
+
* - built JS output (`<root>/dist/providers/searxng`)
|
|
117
|
+
* - compiled binaries with copied assets next to the executable
|
|
118
|
+
*/
|
|
119
|
+
function getRuntimeRoots(): string[] {
|
|
120
|
+
const roots = new Set<string>();
|
|
121
|
+
|
|
122
|
+
roots.add(resolve(getPackageRoot()));
|
|
123
|
+
roots.add(resolve(dirname(process.execPath)));
|
|
124
|
+
roots.add(resolve(process.cwd()));
|
|
125
|
+
|
|
126
|
+
return [...roots];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function getBundledSearxngAssetCandidates(relativePath: string): string[] {
|
|
130
|
+
const candidates = new Set<string>();
|
|
131
|
+
|
|
132
|
+
for (const root of getRuntimeRoots()) {
|
|
133
|
+
candidates.add(join(root, "providers", "searxng", relativePath));
|
|
134
|
+
candidates.add(join(root, "dist", "providers", "searxng", relativePath));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return [...candidates];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function resolveBundledSearxngAsset(relativePath: string): string {
|
|
141
|
+
const candidates = getBundledSearxngAssetCandidates(relativePath);
|
|
142
|
+
const existing = candidates.find((candidate) => existsSync(candidate));
|
|
143
|
+
|
|
144
|
+
if (existing) {
|
|
145
|
+
return existing;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return candidates[0] ?? join(getPackageRoot(), "providers", "searxng", relativePath);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get the bundled SearXNG docker-compose file path.
|
|
153
|
+
*/
|
|
154
|
+
export function getBundledSearxngComposePath(): string {
|
|
155
|
+
return resolveBundledSearxngAsset("docker-compose.yml");
|
|
156
|
+
}
|
|
157
|
+
|
|
111
158
|
/**
|
|
112
159
|
* Get the bundled default settings.yml path
|
|
113
160
|
*/
|
|
114
161
|
export function getDefaultSettingsPath(): string {
|
|
115
|
-
|
|
116
|
-
// In bundled mode, look in dist/providers/searxng/
|
|
117
|
-
// In dev mode, look in providers/searxng/
|
|
118
|
-
if (packageRoot.endsWith("/dist")) {
|
|
119
|
-
return join(packageRoot, "providers", "searxng", "config", "settings.yml");
|
|
120
|
-
}
|
|
121
|
-
return join(packageRoot, "providers", "searxng", "config", "settings.yml");
|
|
162
|
+
return resolveBundledSearxngAsset(join("config", "settings.yml"));
|
|
122
163
|
}
|
|
123
164
|
|
|
124
165
|
/**
|
|
@@ -129,11 +170,6 @@ export function bootstrapSearxngConfig(): boolean {
|
|
|
129
170
|
const { configDir } = getSearxngPaths();
|
|
130
171
|
const targetSettings = join(configDir, "settings.yml");
|
|
131
172
|
|
|
132
|
-
// If settings already exist, don't overwrite
|
|
133
|
-
if (existsSync(targetSettings)) {
|
|
134
|
-
return false;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
173
|
const defaultSettings = getDefaultSettingsPath();
|
|
138
174
|
|
|
139
175
|
// If default settings don't exist, we can't bootstrap
|
|
@@ -145,12 +181,16 @@ export function bootstrapSearxngConfig(): boolean {
|
|
|
145
181
|
return false;
|
|
146
182
|
}
|
|
147
183
|
|
|
148
|
-
//
|
|
184
|
+
// Use exclusive flag to prevent TOCTOU race and avoid overwriting
|
|
149
185
|
try {
|
|
150
|
-
|
|
186
|
+
const content = readFileSync(defaultSettings);
|
|
187
|
+
writeFileSync(targetSettings, content, { flag: "wx" });
|
|
151
188
|
console.log(`[SearXNG] Bootstrapped default config to ${targetSettings}`);
|
|
152
189
|
return true;
|
|
153
190
|
} catch (error) {
|
|
191
|
+
if ((error as NodeJS.ErrnoException)?.code === "EEXIST") {
|
|
192
|
+
return false; // File already exists, no action needed
|
|
193
|
+
}
|
|
154
194
|
console.error(`[SearXNG] Failed to bootstrap config:`, error);
|
|
155
195
|
return false;
|
|
156
196
|
}
|
package/src/core/provider.ts
CHANGED
|
@@ -23,20 +23,7 @@ export interface SearchProvider {
|
|
|
23
23
|
getMissingConfigMessage(): string;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
* Interface for providers that manage lifecycle (init, healthcheck, shutdown)
|
|
28
|
-
*/
|
|
29
|
-
export interface ILifecycleProvider {
|
|
30
|
-
init(): Promise<void>;
|
|
31
|
-
healthcheck(): Promise<boolean>;
|
|
32
|
-
shutdown(): Promise<void>;
|
|
33
|
-
validateConfig(): Promise<{
|
|
34
|
-
valid: boolean;
|
|
35
|
-
errors: string[];
|
|
36
|
-
warnings: string[];
|
|
37
|
-
}>;
|
|
38
|
-
isLifecycleManaged(): boolean;
|
|
39
|
-
}
|
|
26
|
+
export type { ILifecycleProvider } from "./provider/ILifecycleProvider";
|
|
40
27
|
|
|
41
28
|
/**
|
|
42
29
|
* Registry to manage all available providers
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry Logic for Provider Operations
|
|
3
|
+
*
|
|
4
|
+
* Provides exponential backoff retry functionality for resilient
|
|
5
|
+
* provider operations
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { EngineId } from "./types";
|
|
9
|
+
import { SearchError } from "./types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Retry configuration
|
|
13
|
+
*/
|
|
14
|
+
export interface RetryConfig {
|
|
15
|
+
/** Maximum number of retry attempts */
|
|
16
|
+
maxAttempts?: number;
|
|
17
|
+
|
|
18
|
+
/** Initial delay between retries in milliseconds */
|
|
19
|
+
initialDelayMs?: number;
|
|
20
|
+
|
|
21
|
+
/** Multiplier for exponential backoff */
|
|
22
|
+
backoffMultiplier?: number;
|
|
23
|
+
|
|
24
|
+
/** Maximum delay between retries in milliseconds */
|
|
25
|
+
maxDelayMs?: number;
|
|
26
|
+
|
|
27
|
+
/** Whether to retry on specific error types */
|
|
28
|
+
retryableErrors?: Array<"network_error" | "api_error" | "rate_limit" | "no_results" | "timeout">;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Default retry configuration
|
|
33
|
+
*/
|
|
34
|
+
export const DEFAULT_RETRY_CONFIG: Required<RetryConfig> = {
|
|
35
|
+
maxAttempts: 3,
|
|
36
|
+
initialDelayMs: 1000,
|
|
37
|
+
backoffMultiplier: 2,
|
|
38
|
+
maxDelayMs: 10000,
|
|
39
|
+
retryableErrors: ["network_error", "api_error", "rate_limit", "no_results"],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Execute a function with retry logic and exponential backoff
|
|
44
|
+
*
|
|
45
|
+
* @param engineId - Engine ID for error messages
|
|
46
|
+
* @param fn - Function to execute with retry logic
|
|
47
|
+
* @param config - Retry configuration
|
|
48
|
+
* @returns Result of the function
|
|
49
|
+
* @throws SearchError if all retry attempts fail
|
|
50
|
+
*/
|
|
51
|
+
export async function withRetry<T>(
|
|
52
|
+
engineId: EngineId,
|
|
53
|
+
fn: () => Promise<T>,
|
|
54
|
+
config: RetryConfig = {},
|
|
55
|
+
): Promise<T> {
|
|
56
|
+
// Skip retries in test environment to avoid timeouts
|
|
57
|
+
if (process.env.DISABLE_RETRY === "true") {
|
|
58
|
+
return fn();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const {
|
|
62
|
+
maxAttempts = DEFAULT_RETRY_CONFIG.maxAttempts,
|
|
63
|
+
initialDelayMs = DEFAULT_RETRY_CONFIG.initialDelayMs,
|
|
64
|
+
backoffMultiplier = DEFAULT_RETRY_CONFIG.backoffMultiplier,
|
|
65
|
+
maxDelayMs = DEFAULT_RETRY_CONFIG.maxDelayMs,
|
|
66
|
+
retryableErrors = DEFAULT_RETRY_CONFIG.retryableErrors,
|
|
67
|
+
} = config;
|
|
68
|
+
|
|
69
|
+
let lastError: unknown;
|
|
70
|
+
let delay = initialDelayMs;
|
|
71
|
+
|
|
72
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
73
|
+
try {
|
|
74
|
+
return await fn();
|
|
75
|
+
} catch (error) {
|
|
76
|
+
lastError = error;
|
|
77
|
+
|
|
78
|
+
if (!(error instanceof SearchError)) {
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const shouldRetry = retryableErrors.includes(
|
|
83
|
+
error.reason as "network_error" | "api_error" | "rate_limit" | "no_results",
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
if (!shouldRetry || attempt >= maxAttempts) {
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const nextDelay = Math.min(delay * backoffMultiplier, maxDelayMs);
|
|
91
|
+
|
|
92
|
+
console.warn(
|
|
93
|
+
`[${engineId}] Attempt ${attempt}/${maxAttempts} failed: ${error.message}. Retrying in ${nextDelay}ms...`,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
await new Promise((resolve) => setTimeout(resolve, nextDelay));
|
|
97
|
+
delay = nextDelay;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
throw lastError;
|
|
102
|
+
}
|
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
* the overall search.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { getErrorMessage } from "../errorUtils";
|
|
11
11
|
import { createLogger } from "../logger";
|
|
12
12
|
import type { SearchProvider } from "../provider";
|
|
13
|
+
import { withRetry } from "../retry";
|
|
13
14
|
import type { EngineId, SearchResponse, SearchResultItem } from "../types";
|
|
14
15
|
import { SearchError } from "../types";
|
|
15
16
|
import type {
|
|
@@ -28,7 +29,7 @@ const log = createLogger("AllProviders");
|
|
|
28
29
|
interface EngineSearchResult {
|
|
29
30
|
engineId: EngineId;
|
|
30
31
|
response?: SearchResponse;
|
|
31
|
-
error?:
|
|
32
|
+
error?: unknown;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
/**
|
|
@@ -81,8 +82,8 @@ export class AllProvidersStrategy implements ISearchStrategy {
|
|
|
81
82
|
continue;
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
//
|
|
85
|
-
if (!context.creditManager.
|
|
85
|
+
// Charge credits before search (atomic check+deduct to avoid race conditions)
|
|
86
|
+
if (!context.creditManager.charge(engineId)) {
|
|
86
87
|
attempts.push({ engineId, success: false, reason: "out_of_credit" });
|
|
87
88
|
continue;
|
|
88
89
|
}
|
|
@@ -98,23 +99,14 @@ export class AllProvidersStrategy implements ISearchStrategy {
|
|
|
98
99
|
}),
|
|
99
100
|
);
|
|
100
101
|
|
|
101
|
-
// Deduct credits
|
|
102
|
-
if (!context.creditManager.charge(engineId)) {
|
|
103
|
-
// This shouldn't happen because we checked above, but handle gracefully
|
|
104
|
-
attempts.push({ engineId, success: false, reason: "out_of_credit" });
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
102
|
// Record success
|
|
109
103
|
attempts.push({ engineId, success: true });
|
|
110
104
|
|
|
111
|
-
|
|
112
|
-
if (options.limit !== undefined) {
|
|
113
|
-
results.push(...response.items.slice(0, options.limit - results.length));
|
|
114
|
-
} else {
|
|
115
|
-
results.push(...response.items);
|
|
116
|
-
}
|
|
105
|
+
results.push(...response.items);
|
|
117
106
|
} catch (error) {
|
|
107
|
+
// Refund credits for failed search
|
|
108
|
+
context.creditManager.refund(engineId);
|
|
109
|
+
|
|
118
110
|
// Record failure
|
|
119
111
|
if (error instanceof SearchError) {
|
|
120
112
|
attempts.push({ engineId, success: false, reason: error.reason });
|
|
@@ -123,18 +115,11 @@ export class AllProvidersStrategy implements ISearchStrategy {
|
|
|
123
115
|
}
|
|
124
116
|
|
|
125
117
|
// Log debug message but continue with other providers
|
|
126
|
-
log.debug(
|
|
127
|
-
`Search failed for ${engineId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
128
|
-
);
|
|
118
|
+
log.debug(`Search failed for ${engineId}: ${getErrorMessage(error)}`);
|
|
129
119
|
}
|
|
130
120
|
}
|
|
131
121
|
|
|
132
|
-
|
|
133
|
-
if (options.limit !== undefined && results.length > options.limit) {
|
|
134
|
-
results.splice(options.limit);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return { results, attempts };
|
|
122
|
+
return { results: this.finalizeResults(results, options), attempts };
|
|
138
123
|
}
|
|
139
124
|
|
|
140
125
|
/**
|
|
@@ -163,7 +148,8 @@ export class AllProvidersStrategy implements ISearchStrategy {
|
|
|
163
148
|
continue;
|
|
164
149
|
}
|
|
165
150
|
|
|
166
|
-
|
|
151
|
+
// Charge credits before search (atomic check+deduct to avoid race conditions)
|
|
152
|
+
if (!context.creditManager.charge(engineId)) {
|
|
167
153
|
ineligibleAttempts.push({ engineId, success: false, reason: "out_of_credit" });
|
|
168
154
|
continue;
|
|
169
155
|
}
|
|
@@ -185,7 +171,7 @@ export class AllProvidersStrategy implements ISearchStrategy {
|
|
|
185
171
|
);
|
|
186
172
|
return { engineId, response };
|
|
187
173
|
} catch (error) {
|
|
188
|
-
return { engineId, error
|
|
174
|
+
return { engineId, error };
|
|
189
175
|
}
|
|
190
176
|
},
|
|
191
177
|
);
|
|
@@ -207,31 +193,35 @@ export class AllProvidersStrategy implements ISearchStrategy {
|
|
|
207
193
|
|
|
208
194
|
if (settledResult.status === "rejected") {
|
|
209
195
|
// Promise itself was rejected (shouldn't happen with our try/catch, but handle it)
|
|
210
|
-
|
|
196
|
+
context.creditManager.refund(engineId);
|
|
197
|
+
const error = settledResult.reason;
|
|
198
|
+
if (error instanceof SearchError) {
|
|
199
|
+
attempts.push({ engineId, success: false, reason: error.reason });
|
|
200
|
+
} else {
|
|
201
|
+
attempts.push({ engineId, success: false, reason: "unknown" });
|
|
202
|
+
}
|
|
211
203
|
log.debug(`Search failed for ${engineId}: Promise rejected`);
|
|
212
204
|
continue;
|
|
213
205
|
}
|
|
214
206
|
|
|
215
|
-
|
|
207
|
+
if ("error" in settledResult.value) {
|
|
208
|
+
// Refund credits for failed search
|
|
209
|
+
context.creditManager.refund(engineId);
|
|
216
210
|
|
|
217
|
-
if (error) {
|
|
218
211
|
// Search threw an error
|
|
212
|
+
const error = settledResult.value.error;
|
|
219
213
|
if (error instanceof SearchError) {
|
|
220
214
|
attempts.push({ engineId, success: false, reason: error.reason });
|
|
221
215
|
} else {
|
|
222
216
|
attempts.push({ engineId, success: false, reason: "unknown" });
|
|
223
217
|
}
|
|
224
|
-
log.debug(`Search failed for ${engineId}: ${error
|
|
218
|
+
log.debug(`Search failed for ${engineId}: ${getErrorMessage(error)}`);
|
|
225
219
|
continue;
|
|
226
220
|
}
|
|
227
221
|
|
|
228
|
-
|
|
229
|
-
// Deduct credits
|
|
230
|
-
if (!context.creditManager.charge(engineId)) {
|
|
231
|
-
attempts.push({ engineId, success: false, reason: "out_of_credit" });
|
|
232
|
-
continue;
|
|
233
|
-
}
|
|
222
|
+
const { response } = settledResult.value;
|
|
234
223
|
|
|
224
|
+
if (response) {
|
|
235
225
|
// Record success
|
|
236
226
|
attempts.push({ engineId, success: true });
|
|
237
227
|
|
|
@@ -249,11 +239,31 @@ export class AllProvidersStrategy implements ISearchStrategy {
|
|
|
249
239
|
(a, b) => (engineOrder.get(a.engineId) ?? 0) - (engineOrder.get(b.engineId) ?? 0),
|
|
250
240
|
);
|
|
251
241
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
242
|
+
return { results: this.finalizeResults(results, options), attempts };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Deduplicate results by URL, sort by score, and apply limit.
|
|
247
|
+
*/
|
|
248
|
+
private finalizeResults(
|
|
249
|
+
results: SearchResultItem[],
|
|
250
|
+
options: UberSearchOptions,
|
|
251
|
+
): SearchResultItem[] {
|
|
252
|
+
const seen = new Set<string>();
|
|
253
|
+
const deduped: SearchResultItem[] = [];
|
|
254
|
+
for (const item of results) {
|
|
255
|
+
if (!seen.has(item.url)) {
|
|
256
|
+
seen.add(item.url);
|
|
257
|
+
deduped.push(item);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
|
262
|
+
|
|
263
|
+
if (options.limit !== undefined && deduped.length > options.limit) {
|
|
264
|
+
deduped.splice(options.limit);
|
|
255
265
|
}
|
|
256
266
|
|
|
257
|
-
return
|
|
267
|
+
return deduped;
|
|
258
268
|
}
|
|
259
269
|
}
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* with insufficient credits and stops execution after the first success.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { getErrorMessage } from "../errorUtils";
|
|
10
10
|
import { createLogger } from "../logger";
|
|
11
|
+
import { withRetry } from "../retry";
|
|
11
12
|
import type { EngineId } from "../types";
|
|
12
13
|
import { SearchError } from "../types";
|
|
13
14
|
import type {
|
|
@@ -53,8 +54,8 @@ export class FirstSuccessStrategy implements ISearchStrategy {
|
|
|
53
54
|
continue;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
//
|
|
57
|
-
if (!context.creditManager.
|
|
57
|
+
// Charge credits before search (atomic check+deduct to avoid race conditions)
|
|
58
|
+
if (!context.creditManager.charge(engineId)) {
|
|
58
59
|
attempts.push({ engineId, success: false, reason: "out_of_credit" });
|
|
59
60
|
continue;
|
|
60
61
|
}
|
|
@@ -70,18 +71,15 @@ export class FirstSuccessStrategy implements ISearchStrategy {
|
|
|
70
71
|
}),
|
|
71
72
|
);
|
|
72
73
|
|
|
73
|
-
// Deduct credits
|
|
74
|
-
if (!context.creditManager.charge(engineId)) {
|
|
75
|
-
attempts.push({ engineId, success: false, reason: "out_of_credit" });
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
74
|
// Record success
|
|
80
75
|
attempts.push({ engineId, success: true });
|
|
81
76
|
|
|
82
77
|
// Return immediately with results from this provider
|
|
83
78
|
return { results: response.items, attempts };
|
|
84
79
|
} catch (error) {
|
|
80
|
+
// Refund credits for failed search
|
|
81
|
+
context.creditManager.refund(engineId);
|
|
82
|
+
|
|
85
83
|
// Record failure and continue to next provider
|
|
86
84
|
if (error instanceof SearchError) {
|
|
87
85
|
attempts.push({ engineId, success: false, reason: error.reason });
|
|
@@ -90,9 +88,7 @@ export class FirstSuccessStrategy implements ISearchStrategy {
|
|
|
90
88
|
}
|
|
91
89
|
|
|
92
90
|
// Log debug message and continue
|
|
93
|
-
log.debug(
|
|
94
|
-
`Search failed for ${engineId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
95
|
-
);
|
|
91
|
+
log.debug(`Search failed for ${engineId}: ${getErrorMessage(error)}`);
|
|
96
92
|
}
|
|
97
93
|
}
|
|
98
94
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { CreditManager } from "../credits";
|
|
9
9
|
import type { ProviderRegistry } from "../provider";
|
|
10
|
-
import type { EngineId, SearchResultItem } from "../types";
|
|
10
|
+
import type { EngineId, SearchFailureReason, SearchResultItem } from "../types";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Context object providing dependencies to search strategies
|
|
@@ -58,7 +58,7 @@ export interface EngineAttempt {
|
|
|
58
58
|
success: boolean;
|
|
59
59
|
|
|
60
60
|
/** Reason for failure (if success=false) */
|
|
61
|
-
reason?:
|
|
61
|
+
reason?: SearchFailureReason;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/**
|