ubersearch 1.5.0 → 1.6.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ubersearch",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "module": "src/index.ts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,6 +39,7 @@
39
39
  "zod": "^4.1.12"
40
40
  },
41
41
  "scripts": {
42
+ "prepare": "git config core.hooksPath scripts/hooks",
42
43
  "lint": "bunx biome check .",
43
44
  "lint:fix": "bunx biome check --write .",
44
45
  "format": "bunx biome format --write .",
package/src/cli.ts CHANGED
@@ -8,9 +8,9 @@
8
8
  import { bootstrapContainer, isLifecycleProvider } from "./bootstrap/container";
9
9
  import type { ProviderRegistry } from "./core/provider";
10
10
  import { ServiceKeys } from "./core/serviceKeys";
11
+ import { serve as serveMcp } from "./mcp-server";
11
12
  import type { UberSearchOutput } from "./tool/interface";
12
13
  import { getCreditStatus, uberSearch } from "./tool/uberSearchTool";
13
- import { serve as serveMcp } from "./mcp-server";
14
14
 
15
15
  // Parse command line arguments
16
16
  const args = process.argv.slice(2);
@@ -47,7 +47,7 @@ OPTIONS:
47
47
  --json Output results as JSON
48
48
  --engines <engine,list> Use specific engines (comma-separated)
49
49
  --categories <cat,list> SearXNG categories (comma-separated)
50
- --strategy <strategy> Search strategy: 'all' or 'first-success' (default: all)
50
+ --strategy <strategy> Search strategy: 'all' or 'first-success' (default: first-success)
51
51
  --limit <number> Maximum results per engine
52
52
  --include-raw Include raw provider responses
53
53
  --config <path> Path to configuration file
@@ -203,29 +203,37 @@ export interface LoadConfigOptions {
203
203
  */
204
204
  /**
205
205
  * Get default configuration when no config file is found
206
- * Prioritizes cloud providers if API keys are set, falls back to SearXNG
206
+ * SearXNG is always first (free, unlimited), then cloud providers by free tier generosity
207
207
  */
208
208
  function getDefaultConfig(): ExtendedSearchConfig {
209
209
  const engines: ExtendedSearchConfig["engines"] = [];
210
210
  const defaultEngineOrder: string[] = [];
211
211
 
212
- // Add cloud providers if API keys are available (preferred for bunx usage)
213
- if (process.env.TAVILY_API_KEY) {
214
- defaultEngineOrder.push("tavily");
215
- engines.push({
216
- id: "tavily",
217
- type: "tavily",
218
- enabled: true,
219
- displayName: "Tavily Search",
220
- apiKeyEnv: "TAVILY_API_KEY",
221
- endpoint: "https://api.tavily.com/search",
222
- searchDepth: "basic",
223
- monthlyQuota: 1000,
224
- creditCostPerSearch: 1,
225
- lowCreditThresholdPercent: 80,
226
- });
227
- }
228
-
212
+ // Always add SearXNG first - it's free and unlimited (requires Docker)
213
+ const packageRoot = getPackageRoot();
214
+ const composeFile = join(packageRoot, "providers", "searxng", "docker-compose.yml");
215
+
216
+ defaultEngineOrder.push("searchxng");
217
+ engines.push({
218
+ id: "searchxng",
219
+ type: "searchxng",
220
+ enabled: true,
221
+ displayName: "SearXNG (Local)",
222
+ apiKeyEnv: "SEARXNG_API_KEY",
223
+ endpoint: "http://localhost:8888/search",
224
+ composeFile,
225
+ containerName: "searxng",
226
+ healthEndpoint: "http://localhost:8888/healthz",
227
+ defaultLimit: 15,
228
+ monthlyQuota: 10000,
229
+ creditCostPerSearch: 0,
230
+ lowCreditThresholdPercent: 80,
231
+ autoStart: true,
232
+ autoStop: true,
233
+ initTimeoutMs: 60000,
234
+ });
235
+
236
+ // Add cloud providers in order of free tier generosity: Brave (2000/mo) > Tavily (1000/mo) > Linkup
229
237
  if (process.env.BRAVE_API_KEY) {
230
238
  defaultEngineOrder.push("brave");
231
239
  engines.push({
@@ -236,6 +244,22 @@ function getDefaultConfig(): ExtendedSearchConfig {
236
244
  apiKeyEnv: "BRAVE_API_KEY",
237
245
  endpoint: "https://api.search.brave.com/res/v1/web/search",
238
246
  defaultLimit: 15,
247
+ monthlyQuota: 2000,
248
+ creditCostPerSearch: 1,
249
+ lowCreditThresholdPercent: 80,
250
+ });
251
+ }
252
+
253
+ if (process.env.TAVILY_API_KEY) {
254
+ defaultEngineOrder.push("tavily");
255
+ engines.push({
256
+ id: "tavily",
257
+ type: "tavily",
258
+ enabled: true,
259
+ displayName: "Tavily Search",
260
+ apiKeyEnv: "TAVILY_API_KEY",
261
+ endpoint: "https://api.tavily.com/search",
262
+ searchDepth: "basic",
239
263
  monthlyQuota: 1000,
240
264
  creditCostPerSearch: 1,
241
265
  lowCreditThresholdPercent: 80,
@@ -257,32 +281,6 @@ function getDefaultConfig(): ExtendedSearchConfig {
257
281
  });
258
282
  }
259
283
 
260
- // If no cloud providers configured, fall back to SearXNG (requires Docker)
261
- if (engines.length === 0) {
262
- const packageRoot = getPackageRoot();
263
- const composeFile = join(packageRoot, "providers", "searxng", "docker-compose.yml");
264
-
265
- defaultEngineOrder.push("searchxng");
266
- engines.push({
267
- id: "searchxng",
268
- type: "searchxng",
269
- enabled: true,
270
- displayName: "SearXNG (Local)",
271
- apiKeyEnv: "SEARXNG_API_KEY",
272
- endpoint: "http://localhost:8888/search",
273
- composeFile,
274
- containerName: "searxng",
275
- healthEndpoint: "http://localhost:8888/healthz",
276
- defaultLimit: 15,
277
- monthlyQuota: 10000,
278
- creditCostPerSearch: 0,
279
- lowCreditThresholdPercent: 80,
280
- autoStart: true,
281
- autoStop: true,
282
- initTimeoutMs: 60000,
283
- });
284
- }
285
-
286
284
  return { defaultEngineOrder, engines };
287
285
  }
288
286
 
@@ -71,7 +71,7 @@ export class UberSearchOrchestrator {
71
71
  */
72
72
  async run(query: string, options: UberSearchOptions = {}): Promise<OrchestratorResult> {
73
73
  const order = this.getEngineOrder(options.engineOrderOverride);
74
- const strategyName = options.strategy ?? "all";
74
+ const strategyName = options.strategy ?? "first-success";
75
75
 
76
76
  if (order.length === 0) {
77
77
  throw new Error("No engines configured or selected");
@@ -7,6 +7,7 @@
7
7
  * the overall search.
8
8
  */
9
9
 
10
+ import { withRetry } from "../../providers/retry";
10
11
  import { createLogger } from "../logger";
11
12
  import type { SearchProvider } from "../provider";
12
13
  import type { EngineId, SearchResponse, SearchResultItem } from "../types";
@@ -87,13 +88,15 @@ export class AllProvidersStrategy implements ISearchStrategy {
87
88
  }
88
89
 
89
90
  try {
90
- // Execute search
91
- const response = await provider.search({
92
- query,
93
- limit: options.limit,
94
- includeRaw: options.includeRaw,
95
- categories: options.categories,
96
- });
91
+ // Execute search with retry logic for transient failures
92
+ const response = await withRetry(engineId, () =>
93
+ provider.search({
94
+ query,
95
+ limit: options.limit,
96
+ includeRaw: options.includeRaw,
97
+ categories: options.categories,
98
+ }),
99
+ );
97
100
 
98
101
  // Deduct credits
99
102
  if (!context.creditManager.charge(engineId)) {
@@ -168,16 +171,18 @@ export class AllProvidersStrategy implements ISearchStrategy {
168
171
  eligibleEngines.push({ engineId, provider });
169
172
  }
170
173
 
171
- // Execute all eligible searches in parallel
174
+ // Execute all eligible searches in parallel with retry logic
172
175
  const searchPromises = eligibleEngines.map(
173
176
  async ({ engineId, provider }): Promise<EngineSearchResult> => {
174
177
  try {
175
- const response = await provider.search({
176
- query,
177
- limit: options.limit,
178
- includeRaw: options.includeRaw,
179
- categories: options.categories,
180
- });
178
+ const response = await withRetry(engineId, () =>
179
+ provider.search({
180
+ query,
181
+ limit: options.limit,
182
+ includeRaw: options.includeRaw,
183
+ categories: options.categories,
184
+ }),
185
+ );
181
186
  return { engineId, response };
182
187
  } catch (error) {
183
188
  return { engineId, error: error instanceof Error ? error : new Error(String(error)) };
@@ -6,6 +6,7 @@
6
6
  * with insufficient credits and stops execution after the first success.
7
7
  */
8
8
 
9
+ import { withRetry } from "../../providers/retry";
9
10
  import { createLogger } from "../logger";
10
11
  import type { EngineId } from "../types";
11
12
  import { SearchError } from "../types";
@@ -59,13 +60,15 @@ export class FirstSuccessStrategy implements ISearchStrategy {
59
60
  }
60
61
 
61
62
  try {
62
- // Execute search
63
- const response = await provider.search({
64
- query,
65
- limit: options.limit,
66
- includeRaw: options.includeRaw,
67
- categories: options.categories,
68
- });
63
+ // Execute search with retry logic for transient failures
64
+ const response = await withRetry(engineId, () =>
65
+ provider.search({
66
+ query,
67
+ limit: options.limit,
68
+ includeRaw: options.includeRaw,
69
+ categories: options.categories,
70
+ }),
71
+ );
69
72
 
70
73
  // Deduct credits
71
74
  if (!context.creditManager.charge(engineId)) {
package/src/core/types.ts CHANGED
@@ -30,6 +30,7 @@ export interface SearchResponse {
30
30
  export type SearchFailureReason =
31
31
  | "network_error"
32
32
  | "api_error"
33
+ | "rate_limit"
33
34
  | "no_results"
34
35
  | "low_credit"
35
36
  | "config_error"
@@ -25,7 +25,7 @@ export interface RetryConfig {
25
25
  maxDelayMs?: number;
26
26
 
27
27
  /** Whether to retry on specific error types */
28
- retryableErrors?: Array<"network_error" | "api_error" | "timeout">;
28
+ retryableErrors?: Array<"network_error" | "api_error" | "rate_limit" | "no_results" | "timeout">;
29
29
  }
30
30
 
31
31
  /**
@@ -36,7 +36,7 @@ export const DEFAULT_RETRY_CONFIG: Required<RetryConfig> = {
36
36
  initialDelayMs: 1000,
37
37
  backoffMultiplier: 2,
38
38
  maxDelayMs: 10000,
39
- retryableErrors: ["network_error", "api_error"],
39
+ retryableErrors: ["network_error", "api_error", "rate_limit", "no_results"],
40
40
  };
41
41
 
42
42
  /**
@@ -53,6 +53,11 @@ export async function withRetry<T>(
53
53
  fn: () => Promise<T>,
54
54
  config: RetryConfig = {},
55
55
  ): Promise<T> {
56
+ // Skip retries in test environment to avoid timeouts
57
+ if (process.env.DISABLE_RETRY === "true") {
58
+ return fn();
59
+ }
60
+
56
61
  const {
57
62
  maxAttempts = DEFAULT_RETRY_CONFIG.maxAttempts,
58
63
  initialDelayMs = DEFAULT_RETRY_CONFIG.initialDelayMs,
@@ -74,7 +79,9 @@ export async function withRetry<T>(
74
79
  throw error;
75
80
  }
76
81
 
77
- const shouldRetry = retryableErrors.includes(error.reason as "network_error" | "api_error");
82
+ const shouldRetry = retryableErrors.includes(
83
+ error.reason as "network_error" | "api_error" | "rate_limit" | "no_results",
84
+ );
78
85
 
79
86
  if (!shouldRetry || attempt >= maxAttempts) {
80
87
  throw error;
@@ -116,10 +116,12 @@ export async function fetchWithErrorHandling<T>(
116
116
  // Ignore error body parsing failures
117
117
  }
118
118
 
119
+ // Detect rate limiting (HTTP 429)
120
+ const reason = response.status === 429 ? "rate_limit" : "api_error";
119
121
  const errorPrefix = providerDisplayName ? `${providerDisplayName} API error` : "API error";
120
122
  throw new SearchError(
121
123
  engineId,
122
- "api_error",
124
+ reason,
123
125
  `${errorPrefix}: HTTP ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ""}`,
124
126
  response.status,
125
127
  );
@@ -54,7 +54,7 @@ export async function uberSearch(
54
54
  limit: input.limit,
55
55
  engineOrderOverride: input.engines,
56
56
  includeRaw: input.includeRaw,
57
- strategy: input.strategy ?? "all",
57
+ strategy: input.strategy ?? "first-success",
58
58
  categories: input.categories,
59
59
  });
60
60