web-search-plus-plugin 1.2.1 → 1.2.3
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/.env.template +3 -0
- package/README.md +7 -4
- package/index.ts +47 -3
- package/openclaw.plugin.json +4 -2
- package/package.json +3 -3
- package/scripts/search.py +227 -36
- package/scripts/setup.py +12 -2
package/.env.template
CHANGED
|
@@ -4,6 +4,9 @@ SERPER_API_KEY=your-serper-key-here
|
|
|
4
4
|
# Tavily (Research Search) — https://tavily.com
|
|
5
5
|
TAVILY_API_KEY=your-tavily-key-here
|
|
6
6
|
|
|
7
|
+
# Querit (Multilingual AI Search) — https://querit.ai
|
|
8
|
+
QUERIT_API_KEY=your-querit-api-key-here
|
|
9
|
+
|
|
7
10
|
# Exa (Neural/Deep Search) — https://exa.ai
|
|
8
11
|
EXA_API_KEY=your-exa-key-here
|
|
9
12
|
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ A standalone OpenClaw plugin that registers `web_search_plus` as a first-class t
|
|
|
7
7
|
## ✨ Features
|
|
8
8
|
|
|
9
9
|
- **Intelligent auto-routing** — analyzes query intent and picks the best provider automatically
|
|
10
|
-
- **
|
|
10
|
+
- **7 search providers** — use one or all, graceful fallback if any is down
|
|
11
11
|
- **Local result caching** — saves API costs on repeated queries
|
|
12
12
|
- **Interactive setup wizard** — guided configuration via `python3 scripts/setup.py`
|
|
13
13
|
- **Native OpenClaw tool** — registers as `web_search_plus`, not a skill
|
|
@@ -18,6 +18,7 @@ A standalone OpenClaw plugin that registers `web_search_plus` as a first-class t
|
|
|
18
18
|
|----------|----------|-----------|
|
|
19
19
|
| **Serper** (Google) | Facts, news, shopping, local businesses | 2,500 queries/month |
|
|
20
20
|
| **Tavily** | Deep research, analysis, explanations | 1,000 queries/month |
|
|
21
|
+
| **Querit** | Multi-lingual AI search with rich metadata and real-time info | 1,000 queries/month |
|
|
21
22
|
| **Exa** (Neural) | Semantic discovery, finding similar content | 1,000 queries/month |
|
|
22
23
|
| **Perplexity** | AI-synthesized answers with citations | Via API key |
|
|
23
24
|
| **You.com** | Real-time RAG, LLM-ready snippets | Limited free tier |
|
|
@@ -31,6 +32,7 @@ The plugin analyzes your query and picks the best provider:
|
|
|
31
32
|
|-------|-----------|-----|
|
|
32
33
|
| "iPhone 16 Pro price" | Serper | Shopping intent detected |
|
|
33
34
|
| "how does TCP/IP work" | Tavily | Research/explanation intent |
|
|
35
|
+
| "latest multilingual EV market updates" | Querit | Real-time AI search with metadata-rich results |
|
|
34
36
|
| "companies like Stripe" | Exa | Discovery/semantic intent |
|
|
35
37
|
| "what is quantum computing" | Perplexity | Direct answer intent |
|
|
36
38
|
| "latest news AI regulation" | Serper | News intent |
|
|
@@ -87,6 +89,7 @@ Copy `.env.template` to `.env` and add at least one API key:
|
|
|
87
89
|
|----------|----------|---------|
|
|
88
90
|
| `SERPER_API_KEY` | Serper (Google) | [console.serper.dev](https://console.serper.dev) |
|
|
89
91
|
| `TAVILY_API_KEY` | Tavily | [tavily.com](https://tavily.com) |
|
|
92
|
+
| `QUERIT_API_KEY` | Querit | [querit.ai](https://querit.ai) |
|
|
90
93
|
| `EXA_API_KEY` | Exa | [exa.ai](https://exa.ai) |
|
|
91
94
|
| `PERPLEXITY_API_KEY` | Perplexity | [perplexity.ai](https://docs.perplexity.ai) |
|
|
92
95
|
| `KILOCODE_API_KEY` | Perplexity via Kilo | [kilocode.ai](https://kilocode.ai) |
|
|
@@ -119,7 +122,7 @@ The registered `web_search_plus` tool accepts:
|
|
|
119
122
|
| Parameter | Type | Required | Description |
|
|
120
123
|
|-----------|------|----------|-------------|
|
|
121
124
|
| `query` | string | ✅ | Search query |
|
|
122
|
-
| `provider` | string | ❌ | Force a provider: `serper`, `tavily`, `exa`, `perplexity`, `you`, `searxng`, or `auto` (default) |
|
|
125
|
+
| `provider` | string | ❌ | Force a provider: `serper`, `tavily`, `querit`, `exa`, `perplexity`, `you`, `searxng`, or `auto` (default) |
|
|
123
126
|
| `count` | number | ❌ | Number of results (default: 5) |
|
|
124
127
|
|
|
125
128
|
## 🧪 Test Directly
|
|
@@ -139,7 +142,7 @@ python3 scripts/search.py -q "your query" --max-results 10
|
|
|
139
142
|
|
|
140
143
|
## ❓ FAQ
|
|
141
144
|
|
|
142
|
-
### Do I need all
|
|
145
|
+
### Do I need all 7 providers?
|
|
143
146
|
No. The plugin works with just one API key. Configure whichever providers you have — the auto-router will use what's available and skip what's not.
|
|
144
147
|
|
|
145
148
|
### What's the difference between this plugin and the `web-search-plus` skill?
|
|
@@ -149,7 +152,7 @@ The **plugin** registers a native tool that any agent can use directly. The **sk
|
|
|
149
152
|
Yes, Python 3 is required. The search logic runs as a Python script. Most Linux servers and macOS have Python 3 pre-installed.
|
|
150
153
|
|
|
151
154
|
### How does auto-routing work?
|
|
152
|
-
The router scores each provider based on query signals — keywords like "price" or "buy" boost Serper,
|
|
155
|
+
The router scores each provider based on query signals — keywords like "price" or "buy" boost Serper, deep explanation queries boost Tavily, multilingual or metadata-rich real-time search can favor Querit, semantic/discovery queries boost Exa, and direct questions boost Perplexity. The highest-scoring provider wins.
|
|
153
156
|
|
|
154
157
|
### Does it cache results?
|
|
155
158
|
Yes. Results are cached locally in a `.cache/` directory inside the plugin folder. Identical queries return cached results instantly and don't consume API credits. Cache is file-based and survives restarts.
|
package/index.ts
CHANGED
|
@@ -41,6 +41,7 @@ export default function (api: any) {
|
|
|
41
41
|
const configKeyMap: Record<string, string> = {
|
|
42
42
|
serperApiKey: "SERPER_API_KEY",
|
|
43
43
|
tavilyApiKey: "TAVILY_API_KEY",
|
|
44
|
+
queritApiKey: "QUERIT_API_KEY",
|
|
44
45
|
exaApiKey: "EXA_API_KEY",
|
|
45
46
|
perplexityApiKey: "PERPLEXITY_API_KEY",
|
|
46
47
|
kilocodeApiKey: "KILOCODE_API_KEY",
|
|
@@ -56,7 +57,7 @@ export default function (api: any) {
|
|
|
56
57
|
{
|
|
57
58
|
name: "web_search_plus",
|
|
58
59
|
description:
|
|
59
|
-
"Search the web using multi-provider intelligent routing (Serper/Google, Tavily/Research, Exa/Neural+Deep, Perplexity, You.com, SearXNG). Automatically selects the best provider based on query intent. Use for ALL web searches. Set depth='deep' for multi-source synthesis, 'deep-reasoning' for complex cross-document analysis.",
|
|
60
|
+
"Search the web using multi-provider intelligent routing (Serper/Google, Tavily/Research, Querit/Multilingual AI Search, Exa/Neural+Deep, Perplexity, You.com, SearXNG). Automatically selects the best provider based on query intent. Use for ALL web searches. Set depth='deep' for multi-source synthesis, 'deep-reasoning' for complex cross-document analysis.",
|
|
60
61
|
parameters: Type.Object({
|
|
61
62
|
query: Type.String({ description: "Search query" }),
|
|
62
63
|
provider: Type.Optional(
|
|
@@ -64,6 +65,7 @@ export default function (api: any) {
|
|
|
64
65
|
[
|
|
65
66
|
Type.Literal("serper"),
|
|
66
67
|
Type.Literal("tavily"),
|
|
68
|
+
Type.Literal("querit"),
|
|
67
69
|
Type.Literal("exa"),
|
|
68
70
|
Type.Literal("perplexity"),
|
|
69
71
|
Type.Literal("you"),
|
|
@@ -88,10 +90,36 @@ export default function (api: any) {
|
|
|
88
90
|
],
|
|
89
91
|
{
|
|
90
92
|
description:
|
|
91
|
-
"Exa search depth: 'deep' synthesizes across sources (4-12s), 'deep-reasoning' for complex cross-reference analysis (12-50s).
|
|
93
|
+
"Exa search depth: 'deep' synthesizes across sources (4-12s), 'deep-reasoning' for complex cross-reference analysis (12-50s). When provider is auto, depth may be auto-selected based on query complexity.",
|
|
92
94
|
},
|
|
93
95
|
),
|
|
94
96
|
),
|
|
97
|
+
time_range: Type.Optional(
|
|
98
|
+
Type.Union(
|
|
99
|
+
[
|
|
100
|
+
Type.Literal("day"),
|
|
101
|
+
Type.Literal("week"),
|
|
102
|
+
Type.Literal("month"),
|
|
103
|
+
Type.Literal("year"),
|
|
104
|
+
],
|
|
105
|
+
{
|
|
106
|
+
description:
|
|
107
|
+
"Filter results by recency. Applies to Serper (as tbs), Perplexity (as search_recency_filter), Tavily/You.com (as freshness). Useful for news and current events.",
|
|
108
|
+
},
|
|
109
|
+
),
|
|
110
|
+
),
|
|
111
|
+
include_domains: Type.Optional(
|
|
112
|
+
Type.Array(Type.String(), {
|
|
113
|
+
description:
|
|
114
|
+
"Only include results from these domains (e.g. ['arxiv.org', 'github.com']). Supported by Tavily and Exa.",
|
|
115
|
+
}),
|
|
116
|
+
),
|
|
117
|
+
exclude_domains: Type.Optional(
|
|
118
|
+
Type.Array(Type.String(), {
|
|
119
|
+
description:
|
|
120
|
+
"Exclude results from these domains (e.g. ['reddit.com', 'pinterest.com']). Supported by Tavily and Exa.",
|
|
121
|
+
}),
|
|
122
|
+
),
|
|
95
123
|
}),
|
|
96
124
|
async execute(
|
|
97
125
|
_id: string,
|
|
@@ -100,6 +128,9 @@ export default function (api: any) {
|
|
|
100
128
|
provider?: string;
|
|
101
129
|
count?: number;
|
|
102
130
|
depth?: string;
|
|
131
|
+
time_range?: string;
|
|
132
|
+
include_domains?: string[];
|
|
133
|
+
exclude_domains?: string[];
|
|
103
134
|
},
|
|
104
135
|
) {
|
|
105
136
|
const args = [scriptPath, "--query", params.query, "--compact"];
|
|
@@ -119,6 +150,19 @@ export default function (api: any) {
|
|
|
119
150
|
args.push("--exa-depth", params.depth);
|
|
120
151
|
}
|
|
121
152
|
|
|
153
|
+
if (params.time_range) {
|
|
154
|
+
args.push("--time-range", params.time_range);
|
|
155
|
+
args.push("--freshness", params.time_range);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (params.include_domains?.length) {
|
|
159
|
+
args.push("--include-domains", ...params.include_domains);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (params.exclude_domains?.length) {
|
|
163
|
+
args.push("--exclude-domains", ...params.exclude_domains);
|
|
164
|
+
}
|
|
165
|
+
|
|
122
166
|
const envPaths = [
|
|
123
167
|
path.join(PLUGIN_DIR, ".env"),
|
|
124
168
|
path.join(PLUGIN_DIR, "..", "web-search-plus", ".env"),
|
|
@@ -131,7 +175,7 @@ export default function (api: any) {
|
|
|
131
175
|
|
|
132
176
|
try {
|
|
133
177
|
const child = spawnSync("python3", args, {
|
|
134
|
-
timeout:
|
|
178
|
+
timeout: 75000,
|
|
135
179
|
env: childEnv,
|
|
136
180
|
shell: false,
|
|
137
181
|
encoding: "utf8",
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
"id": "web-search-plus-plugin",
|
|
3
3
|
"kind": "skill",
|
|
4
4
|
"name": "Web Search Plus",
|
|
5
|
-
"version": "1.2.
|
|
6
|
-
"description": "Multi-provider web search (Serper/Google, Tavily, Exa/Neural+Deep, Perplexity, You.com, SearXNG) with intelligent auto-routing",
|
|
5
|
+
"version": "1.2.3",
|
|
6
|
+
"description": "Multi-provider web search (Serper/Google, Tavily, Querit/Multilingual AI Search, Exa/Neural+Deep, Perplexity, You.com, SearXNG) with intelligent auto-routing",
|
|
7
7
|
"configSchema": {
|
|
8
8
|
"type": "object",
|
|
9
9
|
"additionalProperties": false,
|
|
10
10
|
"properties": {
|
|
11
11
|
"serperApiKey": { "type": "string" },
|
|
12
12
|
"tavilyApiKey": { "type": "string" },
|
|
13
|
+
"queritApiKey": { "type": "string" },
|
|
13
14
|
"exaApiKey": { "type": "string" },
|
|
14
15
|
"perplexityApiKey": { "type": "string" },
|
|
15
16
|
"kilocodeApiKey": { "type": "string" },
|
|
@@ -20,6 +21,7 @@
|
|
|
20
21
|
"uiHints": {
|
|
21
22
|
"serperApiKey": { "label": "Serper API Key", "placeholder": "sk-...", "sensitive": true },
|
|
22
23
|
"tavilyApiKey": { "label": "Tavily API Key", "placeholder": "tvly-...", "sensitive": true },
|
|
24
|
+
"queritApiKey": { "label": "Querit API Key", "placeholder": "querit-sk-...", "sensitive": true },
|
|
23
25
|
"exaApiKey": { "label": "Exa API Key", "placeholder": "exa-...", "sensitive": true },
|
|
24
26
|
"perplexityApiKey": { "label": "Perplexity API Key", "placeholder": "pplx-...", "sensitive": true },
|
|
25
27
|
"kilocodeApiKey": { "label": "Kilo Gateway API Key", "placeholder": "...", "sensitive": true },
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "web-search-plus-plugin",
|
|
3
|
-
"version": "1.2.
|
|
4
|
-
"description": "OpenClaw plugin: multi-provider web search (Serper/Google, Tavily, Exa/Neural+Deep, Perplexity, You.com, SearXNG) with intelligent auto-routing",
|
|
3
|
+
"version": "1.2.3",
|
|
4
|
+
"description": "OpenClaw plugin: multi-provider web search (Serper/Google, Tavily, Querit/Multilingual AI Search, Exa/Neural+Deep, Perplexity, You.com, SearXNG) with intelligent auto-routing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
7
7
|
"files": [
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"README.md",
|
|
13
13
|
"LICENSE"
|
|
14
14
|
],
|
|
15
|
-
"keywords": ["openclaw", "plugin", "search", "serper", "tavily", "exa", "exa-deep", "perplexity", "you", "searxng", "web-search", "auto-routing"],
|
|
15
|
+
"keywords": ["openclaw", "plugin", "search", "serper", "tavily", "querit", "exa", "exa-deep", "perplexity", "you", "searxng", "web-search", "auto-routing"],
|
|
16
16
|
"repository": {
|
|
17
17
|
"type": "git",
|
|
18
18
|
"url": "https://github.com/robbyczgw-cla/web-search-plus-plugin"
|
package/scripts/search.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
3
|
Web Search Plus — Unified Multi-Provider Search with Intelligent Auto-Routing
|
|
4
|
-
Supports: Serper (Google), Tavily (Research),
|
|
4
|
+
Supports: Serper (Google), Tavily (Research), Querit (Multilingual AI Search),
|
|
5
|
+
Exa (Neural), Perplexity (Direct Answers)
|
|
5
6
|
|
|
6
7
|
Smart Routing uses multi-signal analysis:
|
|
7
8
|
- Query intent classification (shopping, research, discovery)
|
|
@@ -12,7 +13,7 @@ Smart Routing uses multi-signal analysis:
|
|
|
12
13
|
|
|
13
14
|
Usage:
|
|
14
15
|
python3 search.py --query "..." # Auto-route based on query
|
|
15
|
-
python3 search.py --provider [serper|tavily|exa] --query "..." [options]
|
|
16
|
+
python3 search.py --provider [serper|tavily|querit|exa] --query "..." [options]
|
|
16
17
|
|
|
17
18
|
Examples:
|
|
18
19
|
python3 search.py -q "iPhone 16 Pro price" # → Serper (shopping intent)
|
|
@@ -21,6 +22,7 @@ Examples:
|
|
|
21
22
|
"""
|
|
22
23
|
|
|
23
24
|
import argparse
|
|
25
|
+
from http.client import IncompleteRead
|
|
24
26
|
import hashlib
|
|
25
27
|
import json
|
|
26
28
|
import os
|
|
@@ -275,7 +277,7 @@ DEFAULT_CONFIG = {
|
|
|
275
277
|
"auto_routing": {
|
|
276
278
|
"enabled": True,
|
|
277
279
|
"fallback_provider": "serper",
|
|
278
|
-
"provider_priority": ["tavily", "exa", "perplexity", "serper", "you", "searxng"],
|
|
280
|
+
"provider_priority": ["tavily", "querit", "exa", "perplexity", "serper", "you", "searxng"],
|
|
279
281
|
"disabled_providers": [],
|
|
280
282
|
"confidence_threshold": 0.3, # Below this, note low confidence
|
|
281
283
|
},
|
|
@@ -288,6 +290,11 @@ DEFAULT_CONFIG = {
|
|
|
288
290
|
"depth": "basic",
|
|
289
291
|
"topic": "general"
|
|
290
292
|
},
|
|
293
|
+
"querit": {
|
|
294
|
+
"base_url": "https://api.querit.ai",
|
|
295
|
+
"base_path": "/v1/search",
|
|
296
|
+
"timeout": 10
|
|
297
|
+
},
|
|
291
298
|
"exa": {
|
|
292
299
|
"type": "neural",
|
|
293
300
|
"depth": "normal",
|
|
@@ -358,6 +365,7 @@ def get_api_key(provider: str, config: Dict[str, Any] = None) -> Optional[str]:
|
|
|
358
365
|
key_map = {
|
|
359
366
|
"serper": "SERPER_API_KEY",
|
|
360
367
|
"tavily": "TAVILY_API_KEY",
|
|
368
|
+
"querit": "QUERIT_API_KEY",
|
|
361
369
|
"exa": "EXA_API_KEY",
|
|
362
370
|
"you": "YOU_API_KEY",
|
|
363
371
|
}
|
|
@@ -459,24 +467,23 @@ def validate_api_key(provider: str, config: Dict[str, Any] = None) -> str:
|
|
|
459
467
|
],
|
|
460
468
|
"provider": provider
|
|
461
469
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
470
|
+
raise ProviderConfigError(json.dumps(error_msg))
|
|
471
|
+
|
|
465
472
|
# Validate URL format
|
|
466
473
|
if not key.startswith(("http://", "https://")):
|
|
467
|
-
|
|
474
|
+
raise ProviderConfigError(json.dumps({
|
|
468
475
|
"error": "SearXNG instance URL must start with http:// or https://",
|
|
469
476
|
"provided": key,
|
|
470
477
|
"provider": provider
|
|
471
|
-
}
|
|
472
|
-
sys.exit(1)
|
|
478
|
+
}))
|
|
473
479
|
|
|
474
480
|
return key
|
|
475
481
|
|
|
476
482
|
if not key:
|
|
477
483
|
env_var = {
|
|
478
484
|
"serper": "SERPER_API_KEY",
|
|
479
|
-
"tavily": "TAVILY_API_KEY",
|
|
485
|
+
"tavily": "TAVILY_API_KEY",
|
|
486
|
+
"querit": "QUERIT_API_KEY",
|
|
480
487
|
"exa": "EXA_API_KEY",
|
|
481
488
|
"you": "YOU_API_KEY",
|
|
482
489
|
"perplexity": "KILOCODE_API_KEY"
|
|
@@ -485,6 +492,7 @@ def validate_api_key(provider: str, config: Dict[str, Any] = None) -> str:
|
|
|
485
492
|
urls = {
|
|
486
493
|
"serper": "https://serper.dev",
|
|
487
494
|
"tavily": "https://tavily.com",
|
|
495
|
+
"querit": "https://querit.ai",
|
|
488
496
|
"exa": "https://exa.ai",
|
|
489
497
|
"you": "https://api.you.com",
|
|
490
498
|
"perplexity": "https://api.kilo.ai"
|
|
@@ -500,16 +508,14 @@ def validate_api_key(provider: str, config: Dict[str, Any] = None) -> str:
|
|
|
500
508
|
],
|
|
501
509
|
"provider": provider
|
|
502
510
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
511
|
+
raise ProviderConfigError(json.dumps(error_msg))
|
|
512
|
+
|
|
506
513
|
if len(key) < 10:
|
|
507
|
-
|
|
514
|
+
raise ProviderConfigError(json.dumps({
|
|
508
515
|
"error": f"API key for {provider} appears invalid (too short)",
|
|
509
516
|
"provider": provider
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
|
|
517
|
+
}))
|
|
518
|
+
|
|
513
519
|
return key
|
|
514
520
|
|
|
515
521
|
|
|
@@ -1165,6 +1171,7 @@ class QueryAnalyzer:
|
|
|
1165
1171
|
provider_scores = {
|
|
1166
1172
|
"serper": shopping_score + local_news_score + (recency_score * 0.35),
|
|
1167
1173
|
"tavily": research_score + (complexity["complexity_score"] if not complexity["is_complex"] else 0) + (0.2 * recency_score),
|
|
1174
|
+
"querit": (research_score * 0.65) + (rag_score * 0.35) + (recency_score * 0.45),
|
|
1168
1175
|
"exa": discovery_score + (1.0 if re.search(r"\b(similar|alternatives?|examples?)\b", query, re.IGNORECASE) else 0.0) + (exa_deep_score * 0.5) + (exa_deep_reasoning_score * 0.5),
|
|
1169
1176
|
"perplexity": direct_answer_score + (local_news_score * 0.4) + (recency_score * 0.55),
|
|
1170
1177
|
"you": rag_score + (recency_score * 0.25), # You.com good for real-time + RAG
|
|
@@ -1175,6 +1182,7 @@ class QueryAnalyzer:
|
|
|
1175
1182
|
provider_matches = {
|
|
1176
1183
|
"serper": shopping_matches + local_news_matches,
|
|
1177
1184
|
"tavily": research_matches,
|
|
1185
|
+
"querit": research_matches,
|
|
1178
1186
|
"exa": discovery_matches + exa_deep_matches + exa_deep_reasoning_matches,
|
|
1179
1187
|
"perplexity": direct_answer_matches,
|
|
1180
1188
|
"you": rag_matches,
|
|
@@ -1225,7 +1233,7 @@ class QueryAnalyzer:
|
|
|
1225
1233
|
total_score = sum(available.values()) or 1.0
|
|
1226
1234
|
|
|
1227
1235
|
# Handle ties using priority
|
|
1228
|
-
priority = self.auto_config.get("provider_priority", ["tavily", "exa", "perplexity", "serper", "you", "searxng"])
|
|
1236
|
+
priority = self.auto_config.get("provider_priority", ["tavily", "querit", "exa", "perplexity", "serper", "you", "searxng"])
|
|
1229
1237
|
winners = [p for p, s in available.items() if s == max_score]
|
|
1230
1238
|
|
|
1231
1239
|
if len(winners) > 1:
|
|
@@ -1341,6 +1349,7 @@ def explain_routing(query: str, config: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1341
1349
|
"intent_breakdown": {
|
|
1342
1350
|
"shopping_signals": len(analysis["provider_matches"]["serper"]),
|
|
1343
1351
|
"research_signals": len(analysis["provider_matches"]["tavily"]),
|
|
1352
|
+
"querit_signals": len(analysis["provider_matches"]["querit"]),
|
|
1344
1353
|
"discovery_signals": len(analysis["provider_matches"]["exa"]),
|
|
1345
1354
|
"rag_signals": len(analysis["provider_matches"]["you"]),
|
|
1346
1355
|
"exa_deep_score": round(analysis.get("exa_deep_score", 0), 2),
|
|
@@ -1362,7 +1371,7 @@ def explain_routing(query: str, config: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1362
1371
|
if matches
|
|
1363
1372
|
},
|
|
1364
1373
|
"available_providers": [
|
|
1365
|
-
p for p in ["serper", "tavily", "exa", "perplexity", "you", "searxng"]
|
|
1374
|
+
p for p in ["serper", "tavily", "querit", "exa", "perplexity", "you", "searxng"]
|
|
1366
1375
|
if get_api_key(p, config) and p not in config.get("auto_routing", {}).get("disabled_providers", [])
|
|
1367
1376
|
]
|
|
1368
1377
|
}
|
|
@@ -1370,6 +1379,11 @@ def explain_routing(query: str, config: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1370
1379
|
|
|
1371
1380
|
|
|
1372
1381
|
|
|
1382
|
+
class ProviderConfigError(Exception):
|
|
1383
|
+
"""Raised when a provider is missing or has an invalid API key/config."""
|
|
1384
|
+
pass
|
|
1385
|
+
|
|
1386
|
+
|
|
1373
1387
|
class ProviderRequestError(Exception):
|
|
1374
1388
|
"""Structured provider error with retry/cooldown metadata."""
|
|
1375
1389
|
|
|
@@ -1437,6 +1451,24 @@ def reset_provider_health(provider: str) -> None:
|
|
|
1437
1451
|
_save_provider_health(state)
|
|
1438
1452
|
|
|
1439
1453
|
|
|
1454
|
+
def _title_from_url(url: str) -> str:
|
|
1455
|
+
"""Derive a readable title from a URL when none is provided."""
|
|
1456
|
+
try:
|
|
1457
|
+
parsed = urlparse(url)
|
|
1458
|
+
domain = parsed.netloc.replace("www.", "")
|
|
1459
|
+
# Use last meaningful path segment as context
|
|
1460
|
+
segments = [s for s in parsed.path.strip("/").split("/") if s]
|
|
1461
|
+
if segments:
|
|
1462
|
+
last = segments[-1].replace("-", " ").replace("_", " ")
|
|
1463
|
+
# Strip file extensions
|
|
1464
|
+
last = re.sub(r'\.\w{2,4}$', '', last)
|
|
1465
|
+
if last:
|
|
1466
|
+
return f"{domain} — {last[:80]}"
|
|
1467
|
+
return domain
|
|
1468
|
+
except Exception:
|
|
1469
|
+
return url[:60]
|
|
1470
|
+
|
|
1471
|
+
|
|
1440
1472
|
def normalize_result_url(url: str) -> str:
|
|
1441
1473
|
if not url:
|
|
1442
1474
|
return ""
|
|
@@ -1504,6 +1536,12 @@ def make_request(url: str, headers: dict, body: dict, timeout: int = 30) -> dict
|
|
|
1504
1536
|
reason = str(getattr(e, "reason", e))
|
|
1505
1537
|
is_timeout = "timed out" in reason.lower()
|
|
1506
1538
|
raise ProviderRequestError(f"Network error: {reason}. Check your internet connection.", transient=is_timeout)
|
|
1539
|
+
except IncompleteRead as e:
|
|
1540
|
+
partial_len = len(getattr(e, "partial", b"") or b"")
|
|
1541
|
+
raise ProviderRequestError(
|
|
1542
|
+
f"Connection interrupted while reading response ({partial_len} bytes received). Please retry.",
|
|
1543
|
+
transient=True,
|
|
1544
|
+
)
|
|
1507
1545
|
except TimeoutError:
|
|
1508
1546
|
raise ProviderRequestError(f"Request timed out after {timeout}s. Try again or reduce max_results.", transient=True)
|
|
1509
1547
|
|
|
@@ -1653,6 +1691,114 @@ def search_tavily(
|
|
|
1653
1691
|
}
|
|
1654
1692
|
|
|
1655
1693
|
|
|
1694
|
+
# =============================================================================
|
|
1695
|
+
# Querit (Multi-lingual search API for AI, with rich metadata and real-time information)
|
|
1696
|
+
# =============================================================================
|
|
1697
|
+
|
|
1698
|
+
def _map_querit_time_range(time_range: Optional[str]) -> Optional[str]:
|
|
1699
|
+
"""Map generic time ranges to Querit's compact date filter format."""
|
|
1700
|
+
if not time_range:
|
|
1701
|
+
return None
|
|
1702
|
+
return {
|
|
1703
|
+
"day": "d1",
|
|
1704
|
+
"week": "w1",
|
|
1705
|
+
"month": "m1",
|
|
1706
|
+
"year": "y1",
|
|
1707
|
+
}.get(time_range, time_range)
|
|
1708
|
+
|
|
1709
|
+
|
|
1710
|
+
def search_querit(
|
|
1711
|
+
query: str,
|
|
1712
|
+
api_key: str,
|
|
1713
|
+
max_results: int = 5,
|
|
1714
|
+
language: str = "en",
|
|
1715
|
+
country: str = "us",
|
|
1716
|
+
time_range: Optional[str] = None,
|
|
1717
|
+
include_domains: Optional[List[str]] = None,
|
|
1718
|
+
exclude_domains: Optional[List[str]] = None,
|
|
1719
|
+
base_url: str = "https://api.querit.ai",
|
|
1720
|
+
base_path: str = "/v1/search",
|
|
1721
|
+
timeout: int = 30,
|
|
1722
|
+
) -> dict:
|
|
1723
|
+
"""Search using Querit.
|
|
1724
|
+
|
|
1725
|
+
Mirrors the Querit Python SDK payload shape:
|
|
1726
|
+
- query
|
|
1727
|
+
- count
|
|
1728
|
+
- optional filters: languages, geo, sites, timeRange
|
|
1729
|
+
"""
|
|
1730
|
+
endpoint = base_url.rstrip("/") + base_path
|
|
1731
|
+
|
|
1732
|
+
filters: Dict[str, Any] = {}
|
|
1733
|
+
if language:
|
|
1734
|
+
filters["languages"] = {"include": [language.lower()]}
|
|
1735
|
+
if country:
|
|
1736
|
+
filters["geo"] = {"countries": {"include": [country.upper()]}}
|
|
1737
|
+
if include_domains or exclude_domains:
|
|
1738
|
+
sites: Dict[str, List[str]] = {}
|
|
1739
|
+
if include_domains:
|
|
1740
|
+
sites["include"] = include_domains
|
|
1741
|
+
if exclude_domains:
|
|
1742
|
+
sites["exclude"] = exclude_domains
|
|
1743
|
+
filters["sites"] = sites
|
|
1744
|
+
|
|
1745
|
+
querit_time_range = _map_querit_time_range(time_range)
|
|
1746
|
+
if querit_time_range:
|
|
1747
|
+
filters["timeRange"] = {"date": querit_time_range}
|
|
1748
|
+
|
|
1749
|
+
body: Dict[str, Any] = {
|
|
1750
|
+
"query": query,
|
|
1751
|
+
"count": max_results,
|
|
1752
|
+
}
|
|
1753
|
+
if filters:
|
|
1754
|
+
body["filters"] = filters
|
|
1755
|
+
|
|
1756
|
+
headers = {
|
|
1757
|
+
"Authorization": f"Bearer {api_key}",
|
|
1758
|
+
"Content-Type": "application/json",
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
data = make_request(endpoint, headers, body, timeout=timeout)
|
|
1762
|
+
|
|
1763
|
+
error_code = data.get("error_code")
|
|
1764
|
+
error_msg = data.get("error_msg")
|
|
1765
|
+
if error_msg or (error_code not in (None, 0, 200)):
|
|
1766
|
+
message = error_msg or f"Querit request failed with error_code={error_code}"
|
|
1767
|
+
raise ProviderRequestError(message)
|
|
1768
|
+
|
|
1769
|
+
raw_results = ((data.get("results") or {}).get("result")) or []
|
|
1770
|
+
results = []
|
|
1771
|
+
for i, item in enumerate(raw_results[:max_results]):
|
|
1772
|
+
snippet = item.get("snippet") or item.get("page_age") or ""
|
|
1773
|
+
result = {
|
|
1774
|
+
"title": item.get("title") or _title_from_url(item.get("url", "")),
|
|
1775
|
+
"url": item.get("url", ""),
|
|
1776
|
+
"snippet": snippet,
|
|
1777
|
+
"score": round(1.0 - i * 0.05, 3),
|
|
1778
|
+
}
|
|
1779
|
+
if item.get("page_time") is not None:
|
|
1780
|
+
result["page_time"] = item["page_time"]
|
|
1781
|
+
if item.get("page_age"):
|
|
1782
|
+
result["date"] = item["page_age"]
|
|
1783
|
+
if item.get("language") is not None:
|
|
1784
|
+
result["language"] = item["language"]
|
|
1785
|
+
results.append(result)
|
|
1786
|
+
|
|
1787
|
+
answer = results[0]["snippet"] if results else ""
|
|
1788
|
+
|
|
1789
|
+
return {
|
|
1790
|
+
"provider": "querit",
|
|
1791
|
+
"query": query,
|
|
1792
|
+
"results": results,
|
|
1793
|
+
"images": [],
|
|
1794
|
+
"answer": answer,
|
|
1795
|
+
"metadata": {
|
|
1796
|
+
"search_id": data.get("search_id"),
|
|
1797
|
+
"time_range": querit_time_range,
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
|
|
1656
1802
|
# =============================================================================
|
|
1657
1803
|
# Exa (Neural/Semantic/Deep Search)
|
|
1658
1804
|
# =============================================================================
|
|
@@ -1759,7 +1905,7 @@ def search_exa(
|
|
|
1759
1905
|
results.append({
|
|
1760
1906
|
"title": f"Exa {exa_depth.replace('-', ' ').title()} Synthesis",
|
|
1761
1907
|
"url": "",
|
|
1762
|
-
"snippet": synthesized_text
|
|
1908
|
+
"snippet": synthesized_text,
|
|
1763
1909
|
"full_synthesis": synthesized_text,
|
|
1764
1910
|
"score": 1.0,
|
|
1765
1911
|
"grounding": grounding_citations[:10],
|
|
@@ -1781,7 +1927,7 @@ def search_exa(
|
|
|
1781
1927
|
"type": "source",
|
|
1782
1928
|
})
|
|
1783
1929
|
|
|
1784
|
-
answer = synthesized_text
|
|
1930
|
+
answer = synthesized_text if synthesized_text else (results[1]["snippet"] if len(results) > 1 else "")
|
|
1785
1931
|
|
|
1786
1932
|
return {
|
|
1787
1933
|
"provider": "exa",
|
|
@@ -1876,13 +2022,17 @@ def search_perplexity(
|
|
|
1876
2022
|
message = choices[0].get("message", {}) if choices else {}
|
|
1877
2023
|
answer = (message.get("content") or "").strip()
|
|
1878
2024
|
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
2025
|
+
# Prefer the structured citations array from Perplexity API response
|
|
2026
|
+
api_citations = data.get("citations", [])
|
|
2027
|
+
|
|
2028
|
+
# Fallback: extract URLs from answer text if API doesn't provide citations
|
|
2029
|
+
if not api_citations:
|
|
2030
|
+
api_citations = []
|
|
2031
|
+
seen = set()
|
|
2032
|
+
for u in re.findall(r"https?://[^\s)\]}>\"']+", answer):
|
|
2033
|
+
if u not in seen:
|
|
2034
|
+
seen.add(u)
|
|
2035
|
+
api_citations.append(u)
|
|
1886
2036
|
|
|
1887
2037
|
results = []
|
|
1888
2038
|
|
|
@@ -1897,12 +2047,19 @@ def search_perplexity(
|
|
|
1897
2047
|
"score": 1.0,
|
|
1898
2048
|
})
|
|
1899
2049
|
|
|
1900
|
-
#
|
|
1901
|
-
for i,
|
|
2050
|
+
# Source results from citations
|
|
2051
|
+
for i, citation in enumerate(api_citations[:max_results - 1]):
|
|
2052
|
+
# citations can be plain URL strings or dicts with url/title
|
|
2053
|
+
if isinstance(citation, str):
|
|
2054
|
+
url = citation
|
|
2055
|
+
title = _title_from_url(url)
|
|
2056
|
+
else:
|
|
2057
|
+
url = citation.get("url", "")
|
|
2058
|
+
title = citation.get("title") or _title_from_url(url)
|
|
1902
2059
|
results.append({
|
|
1903
|
-
"title":
|
|
1904
|
-
"url":
|
|
1905
|
-
"snippet": "
|
|
2060
|
+
"title": title,
|
|
2061
|
+
"url": url,
|
|
2062
|
+
"snippet": f"Source cited in Perplexity answer [citation {i+1}]",
|
|
1906
2063
|
"score": round(0.9 - i * 0.1, 3),
|
|
1907
2064
|
})
|
|
1908
2065
|
|
|
@@ -2243,6 +2400,9 @@ Intelligent Auto-Routing:
|
|
|
2243
2400
|
|
|
2244
2401
|
Research Intent → Tavily
|
|
2245
2402
|
"how does", "explain", "what is", analysis, pros/cons, tutorials
|
|
2403
|
+
|
|
2404
|
+
Multilingual + Real-Time AI Search → Querit
|
|
2405
|
+
multilingual search, metadata-rich results, current information for AI workflows
|
|
2246
2406
|
|
|
2247
2407
|
Discovery Intent → Exa (Neural)
|
|
2248
2408
|
"similar to", "companies like", "alternatives", URLs, startups, papers
|
|
@@ -2263,7 +2423,7 @@ Full docs: See README.md and SKILL.md
|
|
|
2263
2423
|
# Common arguments
|
|
2264
2424
|
parser.add_argument(
|
|
2265
2425
|
"--provider", "-p",
|
|
2266
|
-
choices=["serper", "tavily", "exa", "perplexity", "you", "searxng", "auto"],
|
|
2426
|
+
choices=["serper", "tavily", "querit", "exa", "perplexity", "you", "searxng", "auto"],
|
|
2267
2427
|
help="Search provider (auto=intelligent routing)"
|
|
2268
2428
|
)
|
|
2269
2429
|
parser.add_argument(
|
|
@@ -2323,6 +2483,19 @@ Full docs: See README.md and SKILL.md
|
|
|
2323
2483
|
)
|
|
2324
2484
|
parser.add_argument("--raw-content", action="store_true")
|
|
2325
2485
|
|
|
2486
|
+
# Querit-specific
|
|
2487
|
+
querit_config = config.get("querit", {})
|
|
2488
|
+
parser.add_argument(
|
|
2489
|
+
"--querit-base-url",
|
|
2490
|
+
default=querit_config.get("base_url", "https://api.querit.ai"),
|
|
2491
|
+
help="Querit API base URL"
|
|
2492
|
+
)
|
|
2493
|
+
parser.add_argument(
|
|
2494
|
+
"--querit-base-path",
|
|
2495
|
+
default=querit_config.get("base_path", "/v1/search"),
|
|
2496
|
+
help="Querit API path"
|
|
2497
|
+
)
|
|
2498
|
+
|
|
2326
2499
|
# Exa-specific
|
|
2327
2500
|
exa_config = config.get("exa", {})
|
|
2328
2501
|
parser.add_argument(
|
|
@@ -2490,13 +2663,15 @@ Full docs: See README.md and SKILL.md
|
|
|
2490
2663
|
|
|
2491
2664
|
# Build provider fallback list
|
|
2492
2665
|
auto_config = config.get("auto_routing", {})
|
|
2493
|
-
provider_priority = auto_config.get("provider_priority", ["tavily", "exa", "perplexity", "serper"])
|
|
2666
|
+
provider_priority = auto_config.get("provider_priority", ["tavily", "querit", "exa", "perplexity", "serper", "you", "searxng"])
|
|
2494
2667
|
disabled_providers = auto_config.get("disabled_providers", [])
|
|
2495
2668
|
|
|
2496
2669
|
# Start with the selected provider, then try others in priority order
|
|
2670
|
+
# Only include providers that have a configured API key (except the primary,
|
|
2671
|
+
# which gets a clear error if unconfigured and no fallback succeeds)
|
|
2497
2672
|
providers_to_try = [provider]
|
|
2498
2673
|
for p in provider_priority:
|
|
2499
|
-
if p not in providers_to_try and p not in disabled_providers:
|
|
2674
|
+
if p not in providers_to_try and p not in disabled_providers and get_api_key(p, config):
|
|
2500
2675
|
providers_to_try.append(p)
|
|
2501
2676
|
|
|
2502
2677
|
# Skip providers currently in cooldown
|
|
@@ -2538,6 +2713,20 @@ Full docs: See README.md and SKILL.md
|
|
|
2538
2713
|
include_images=args.images,
|
|
2539
2714
|
include_raw_content=args.raw_content,
|
|
2540
2715
|
)
|
|
2716
|
+
elif prov == "querit":
|
|
2717
|
+
return search_querit(
|
|
2718
|
+
query=args.query,
|
|
2719
|
+
api_key=key,
|
|
2720
|
+
max_results=args.max_results,
|
|
2721
|
+
language=args.language,
|
|
2722
|
+
country=args.country,
|
|
2723
|
+
time_range=args.time_range or args.freshness,
|
|
2724
|
+
include_domains=args.include_domains,
|
|
2725
|
+
exclude_domains=args.exclude_domains,
|
|
2726
|
+
base_url=args.querit_base_url,
|
|
2727
|
+
base_path=args.querit_base_path,
|
|
2728
|
+
timeout=int(querit_config.get("timeout", 30)),
|
|
2729
|
+
)
|
|
2541
2730
|
elif prov == "exa":
|
|
2542
2731
|
# CLI --exa-depth overrides; fallback to auto-routing suggestion
|
|
2543
2732
|
exa_depth = args.exa_depth
|
|
@@ -2621,6 +2810,8 @@ Full docs: See README.md and SKILL.md
|
|
|
2621
2810
|
"locale": f"{args.country}:{args.language}",
|
|
2622
2811
|
"freshness": args.freshness,
|
|
2623
2812
|
"time_range": args.time_range,
|
|
2813
|
+
"include_domains": sorted(args.include_domains) if args.include_domains else None,
|
|
2814
|
+
"exclude_domains": sorted(args.exclude_domains) if args.exclude_domains else None,
|
|
2624
2815
|
"topic": args.topic,
|
|
2625
2816
|
"search_engines": sorted(args.engines) if args.engines else None,
|
|
2626
2817
|
"include_news": not args.no_news,
|
package/scripts/setup.py
CHANGED
|
@@ -65,6 +65,14 @@ def print_provider_info():
|
|
|
65
65
|
"signup": "https://tavily.com",
|
|
66
66
|
"strengths": ["AI-synthesized answers", "Full page content", "Domain filtering", "Academic research"]
|
|
67
67
|
},
|
|
68
|
+
{
|
|
69
|
+
"name": "Querit",
|
|
70
|
+
"emoji": "🗂️",
|
|
71
|
+
"best_for": "Multi-lingual AI search with rich metadata and real-time information",
|
|
72
|
+
"free_tier": "1,000 queries/month",
|
|
73
|
+
"signup": "https://querit.ai",
|
|
74
|
+
"strengths": ["Multi-lingual search", "Rich metadata", "Real-time information", "AI-ready results"]
|
|
75
|
+
},
|
|
68
76
|
{
|
|
69
77
|
"name": "Exa",
|
|
70
78
|
"emoji": "🧠",
|
|
@@ -294,11 +302,12 @@ def run_setup(skill_dir: Path, force_reset: bool = False):
|
|
|
294
302
|
"auto_routing": {"enabled": True, "fallback_provider": "serper"},
|
|
295
303
|
"serper": {},
|
|
296
304
|
"tavily": {},
|
|
305
|
+
"querit": {},
|
|
297
306
|
"exa": {}
|
|
298
307
|
}
|
|
299
308
|
|
|
300
309
|
# Remove any existing API keys from example
|
|
301
|
-
for provider in ["serper", "tavily", "exa"]:
|
|
310
|
+
for provider in ["serper", "tavily", "querit", "exa"]:
|
|
302
311
|
if provider in config:
|
|
303
312
|
config[provider].pop("api_key", None)
|
|
304
313
|
|
|
@@ -314,6 +323,7 @@ def run_setup(skill_dir: Path, force_reset: bool = False):
|
|
|
314
323
|
providers_info = {
|
|
315
324
|
"serper": ("Serper", "https://serper.dev", "Google results, shopping, local"),
|
|
316
325
|
"tavily": ("Tavily", "https://tavily.com", "Research, explanations, analysis"),
|
|
326
|
+
"querit": ("Querit", "https://querit.ai", "Multi-lingual AI search, rich metadata, real-time info"),
|
|
317
327
|
"exa": ("Exa", "https://exa.ai", "Semantic search, similar content"),
|
|
318
328
|
"you": ("You.com", "https://api.you.com", "RAG applications, real-time info"),
|
|
319
329
|
"searxng": ("SearXNG", "https://docs.searxng.org/admin/installation.html", "Privacy-first, self-hosted, $0 cost")
|
|
@@ -393,7 +403,7 @@ def run_setup(skill_dir: Path, force_reset: bool = False):
|
|
|
393
403
|
config["defaults"]["max_results"] = max_results
|
|
394
404
|
|
|
395
405
|
# Set disabled providers
|
|
396
|
-
all_providers = ["serper", "tavily", "exa", "you", "searxng"]
|
|
406
|
+
all_providers = ["serper", "tavily", "querit", "exa", "you", "searxng"]
|
|
397
407
|
disabled = [p for p in all_providers if p not in enabled_providers]
|
|
398
408
|
config["auto_routing"]["disabled_providers"] = disabled
|
|
399
409
|
|