research-assistant 0.1.0 → 0.2.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/README-ZH.md CHANGED
@@ -66,6 +66,7 @@ research-assistant locate <md_path> "<q>" # 锚点式相关性扫描
66
66
 
67
67
  # 管理
68
68
  research-assistant setup # 配置 + 安装 skill/agent
69
+ research-assistant config fields # 查看各 provider 需填的配置字段
69
70
  research-assistant doctor [--show-config] # 连通性诊断
70
71
  research-assistant skills status # 受管 skill/agent 新鲜度
71
72
  ```
@@ -86,7 +87,7 @@ base_url = "https://api.exa.ai"
86
87
 
87
88
  [[provider]]
88
89
  type = "openai_compat" # 驱动 `ask`
89
- base_url = "https://api.example.com/v1"
90
+ base_url = "https://api.openai.com/v1" # 默认;用兼容渠道时换成其地址(需含 /v1)
90
91
  api_key = "..."
91
92
  model = "grok-..."
92
93
 
package/README.md CHANGED
@@ -73,6 +73,7 @@ research-assistant locate <md_path> "<q>" # anchor-based relevance scan
73
73
 
74
74
  # Management
75
75
  research-assistant setup # configure + install skill/agent
76
+ research-assistant config fields # show each provider's required config fields
76
77
  research-assistant doctor [--show-config] # connectivity diagnostics
77
78
  research-assistant skills status # managed skill/agent freshness
78
79
  ```
@@ -93,7 +94,7 @@ base_url = "https://api.exa.ai"
93
94
 
94
95
  [[provider]]
95
96
  type = "openai_compat" # drives `ask`
96
- base_url = "https://api.example.com/v1"
97
+ base_url = "https://api.openai.com/v1" # default; for a compatible gateway, use its URL with /v1
97
98
  api_key = "..."
98
99
  model = "grok-..."
99
100
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "research-assistant",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Search/research sub-agent + concurrent search/fetch/locate CLI atomic tools for AI agents (Exa, Tavily, Firecrawl, Context7, OpenAI-compatible).",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/arsonist-g/research-assistant-cli#readme",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "research-assistant"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Search/research sub-agent + a set of concurrent search/fetch/locate CLI atomic tools for AI agents."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -76,7 +76,7 @@ tier genuinely cannot return the content.
76
76
  | Tier | Tool | Strength | Use when |
77
77
  |------|------|----------|----------|
78
78
  | 1. API | `exa`, `tavily` | Structured search APIs; clean JSON, no rendering. `tavily extract` returns ready markdown, `tavily map`/`crawl` cover a whole site; `exa` adds `similar`, `contents`, `answer`, and async `research`. Fast and cheap. | Simple or public pages; search with snippets; fast fetch of a known URL; site map or crawl. |
79
- | 2. Firecrawl | `firecrawl` | Residential or home-bandwidth infrastructure; `scrape` and `search` are **keyless**, the other endpoints (`map`, `crawl`, `extract`, `interact`, `agent`, `monitor`, `parse`) need a key. More likely than Tier 1 to succeed on lightly protected pages, and the only tier with site-wide crawl and structured extraction. | A page refuses raw HTTP but shows no hard Cloudflare challenge. Batch scrape many URLs, crawl a whole site, or extract structured data with a schema. |
79
+ | 2. Firecrawl | `firecrawl` | Residential or home-bandwidth infrastructure; `scrape`/`search`/`interact`/`parse` are **keyless**, the other endpoints (`map`, `crawl`, `extract`, `agent`, `monitor`) need a key. More likely than Tier 1 to succeed on lightly protected pages, and the only tier with site-wide crawl and structured extraction. | A page refuses raw HTTP but shows no hard Cloudflare challenge. Batch scrape many URLs, crawl a whole site, or extract structured data with a schema. |
80
80
  | 3. Browser | `fetch`, `browser` | Drives the user's real Edge/Chromium, same engine as the daily browser so login cookies stay valid. `fetch` auto-escalates: normal API, then a headed browser with Cloudflare auto-detect (no challenge means solve returns instantly). `browser` is the direct browser platform when you already know you want the browser: `browser fetch` (headed + CF auto-detect, skips the API) and `browser search` (headless search engine: Bing CN/intl, Google). | Everything above failed; or the page is behind Cloudflare, needs JS, or needs login cookies; or you want a browser search engine directly. |
81
81
 
82
82
  Notes:
@@ -97,7 +97,8 @@ Notes:
97
97
 
98
98
  `ctx7 library "<name>" "<question>"` resolves a library id. `ctx7 docs /org/repo
99
99
  "<question>"` returns up-to-date official docs as markdown. It hits the authoritative source
100
- directly, so it is fast and accurate.
100
+ directly, so it is fast and accurate. On Windows git bash / WSL, MSYS rewrites the leading `/`
101
+ of `/org/repo` into a Windows path; write `//org/repo` there (PowerShell and cmd are unaffected).
101
102
 
102
103
  **For any library, framework, SDK, CLI, or cloud-service question, prefer `ctx7` over web
103
104
  search.** Do not grep the open web for API syntax, config keys, or version-migration notes
@@ -156,27 +157,32 @@ and accept `--async-submit` to return the job id immediately instead of waiting.
156
157
 
157
158
  ```sh
158
159
  # Tier 1: structured search APIs. Clean JSON, fastest, cheapest. Start here for simple or public pages.
159
- research-assistant exa search "<query>" [--num-results N] [--type auto|keyword|neural|fast] [--text] [--highlights] [--category CAT] [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--include-domains D ...] [--exclude-domains D ...]
160
- research-assistant exa similar <url> [--num-results N] # pages semantically similar to a URL
160
+ research-assistant exa search "<query>" [--num-results N (1-100)] [--type auto|keyword|neural|fast] [--text] [--highlights] [--category CAT] [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--include-domains D ...] [--exclude-domains D ...]
161
+ research-assistant exa similar <url> [--num-results N (1-100)] # pages semantically similar to a URL ("more like this")
161
162
  research-assistant exa contents <id>... [--text] [--highlights] # fetch body for Exa IDs or URLs
162
- research-assistant exa answer "<question>" [--text] # LLM answer grounded in Exa results
163
- research-assistant exa research "<instructions>" [--model exa-research|exa-research-pro] [--output-schema JSON | --infer-schema] [--poll-timeout N] [--async-submit] # async in-depth research
164
-
165
- research-assistant tavily search "<query>" [--depth ultra-fast|fast|basic|advanced] [--max-results N] [--topic general|news|finance] [--time-range day|week|month|year] [--include-answer basic|true|advanced] [--country C] [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--chunks-per-source N] [--include-raw-content markdown|text] [--include-images] [--include-domains D ...] [--exclude-domains D ...] [--auto-parameters]
166
- research-assistant tavily extract <url>... [--extract-depth basic|advanced] [--format markdown|text] # ready markdown, no rendering
167
- research-assistant tavily map <url> [--max-depth N] [--max-breadth N] [--limit N] [--select-paths REGEX ...] [--instructions TEXT] [--timeout N] # list a site's reachable URLs
168
- research-assistant tavily crawl <url> [--max-depth N] [--limit N] [--instructions TEXT] [--extract-depth basic|advanced] [--no-external] [--timeout N] # recursive crawl, markdown per page
169
-
170
- # Tier 2: Firecrawl. Residential bandwidth. scrape and search are keyless; the rest need a key.
171
- research-assistant firecrawl scrape <url>... [--format markdown|html] [--only-main-content] [--wait-for MS] # batch markdown, keyless
172
- research-assistant firecrawl search "<query>" [--limit N] [--sources web|news] [--scrape] # keyless; --scrape returns full markdown per result
173
- research-assistant firecrawl map <url> [--limit N] [--include-subdomains] # needs key
174
- research-assistant firecrawl crawl <url> [--limit N] [--max-depth N] [--include-paths REGEX ...] [--allow-subdomains] [--prompt TEXT] [--poll-timeout N] [--async-submit] # async recursive crawl
175
- research-assistant firecrawl extract <url>... [--prompt TEXT | --schema JSON] [--enable-web-search] [--agent] [--poll-timeout N] [--async-submit] # LLM structured extraction, async
176
- research-assistant firecrawl interact "<prompt>" [--scrape-id ID | --url URL] [--code TEXT --language node|python|bash] [--timeout S] [--stop] # live browser session bound to a scrape
177
- research-assistant firecrawl agent "<prompt>" [--model spark-1-mini|spark-1-pro] [--urls U ...] [--schema JSON] [--max-credits N] [--strict] [--poll-timeout N] [--async-submit] # autonomous extraction
178
- research-assistant firecrawl monitor # list active crawl jobs
179
- research-assistant firecrawl parse <file> [--format markdown|html|json ...] [--pdf-mode fast|auto|ocr] [--max-pages N] # parse a local document
163
+ research-assistant exa answer "<question>" [--text] # LLM answer grounded in Exa results (treat as a lead; verify with fetch)
164
+ research-assistant exa research "<instructions>" [--model exa-research|exa-research-pro] [--output-schema JSON | --infer-schema] [--poll-timeout N] [--async-submit] # async in-depth research; --infer-schema lets the model pick fields, --output-schema pins a rigid shape
165
+
166
+ research-assistant tavily search "<query>" [--depth ultra-fast|fast|basic|advanced] [--max-results N] [--topic general|news|finance] [--time-range day|week|month|year] [--include-answer basic|true|advanced] [--country C] [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--chunks-per-source N (1-3, advanced)] [--include-raw-content markdown|text] [--include-images] [--include-domains D ...] [--exclude-domains D ...] [--auto-parameters]
167
+ research-assistant tavily extract <url>... [--extract-depth basic|advanced] [--format markdown|text] # ready markdown, no rendering (fastest fetch of known URLs)
168
+ research-assistant tavily map <url> [--max-depth N (1-5)] [--max-breadth N (1-500)] [--limit N] [--select-paths REGEX ...] [--instructions TEXT] [--timeout N (10-150)] # list a site's reachable URLs (sitemap-aware, returns URLs only)
169
+ research-assistant tavily crawl <url> [--max-depth N (1-5)] [--limit N] [--instructions TEXT] [--extract-depth basic|advanced] [--no-external] [--timeout N (10-150)] # recursive crawl, markdown per page (depth-bounded)
170
+
171
+ # Tier 2: Firecrawl. Residential bandwidth. scrape/search/interact/parse are keyless; map/crawl/extract/agent/monitor need a key.
172
+ # Keyless commands default to auto: try keyless first, fall back to your key on rate-limit/IP failure
173
+ # (a keyed call bills credits, so keyless-first saves them). --use-key skips keyless (needs api_key);
174
+ # --keyless forces keyless (no fallback).
175
+ # Keyless endpoints are sensitive to IP quality: residential/home IPs work; datacenter IPs may
176
+ # hit 403 ("IP looks suspicious") and need a home-bandwidth proxy.
177
+ research-assistant firecrawl scrape <url>... [--format markdown|html] [--only-main-content] [--wait-for MS] # keyless; single or batch URL → markdown/html (auto: keyless first, falls back to key)
178
+ research-assistant firecrawl search "<query>" [--limit N (1-100)] [--sources web|news] [--scrape] # keyless; web search; --scrape returns full markdown per result (bills more credits)
179
+ research-assistant firecrawl map <url> [--limit N] [--include-subdomains] # needs key; discover all reachable URLs of a site (sitemap + link crawl), URLs only; no page content
180
+ research-assistant firecrawl crawl <url> [--limit N] [--max-depth N] [--include-paths REGEX ...] [--allow-subdomains] [--prompt TEXT] [--poll-timeout N] [--async-submit] # needs key; async recursive crawl following links, markdown per page; --prompt lets an LLM generate crawl options
181
+ research-assistant firecrawl extract <url>... [--prompt TEXT | --schema JSON] [--enable-web-search] [--agent] [--poll-timeout N] [--async-submit] # needs key; LLM pulls structured data from URLs by --prompt (free-form) or --schema (rigid shape); --agent navigates pages first; async
182
+ research-assistant firecrawl interact "<prompt>" [--scrape-id ID | --url URL] [--code TEXT --language node|python|bash] [--timeout S (1-300)] [--stop] # keyless; live browser session bound to a scrape: run a prompt or code in the already-loaded page
183
+ research-assistant firecrawl agent "<prompt>" [--model spark-1-mini|spark-1-pro] [--urls U ...] [--schema JSON] [--max-credits N] [--strict] [--poll-timeout N] [--async-submit] # needs key; autonomous agent: give a goal, it browses sites and extracts the data (spark model); async, bills credits
184
+ research-assistant firecrawl monitor # needs key; list your team's currently active crawl jobs
185
+ research-assistant firecrawl parse <file> [--format markdown|html|json ...] [--pdf-mode fast|auto|ocr] [--max-pages N (1-10000)] # keyless; upload a local PDF/DOCX/HTML → markdown/json (no URL needed)
180
186
 
181
187
  # Tier 3: real browser. fetch auto-escalates (normal API, then headed browser with CF auto-detect).
182
188
  research-assistant fetch <url> [<url>...] [--concurrency N] [--login|--no-login] [--no-browser] [--output PATH]
@@ -194,7 +200,7 @@ research-assistant search "<query>" [--providers exa,tavily,firecrawl,browser] [
194
200
 
195
201
  ```sh
196
202
  research-assistant ctx7 library "<name>" "<question>" # resolve a library id
197
- research-assistant ctx7 docs /org/repo "<question>" # up-to-date official docs
203
+ research-assistant ctx7 docs /org/repo "<question>" # up-to-date official docs (git bash/WSL: use //org/repo)
198
204
  ```
199
205
 
200
206
  ### C. Ask a web-enabled LLM in natural language (`ask`)
@@ -215,6 +221,7 @@ research-assistant locate <md_path> "<query>" [--top N] [--scope lines|paragraph
215
221
 
216
222
  ```sh
217
223
  research-assistant setup [--non-interactive | --config-inline TOML] [--provider TYPE[,k=v,...]] ... [--proxy URL] [--browser-channel msedge|chrome] [--daemon-port N] [--install-skills claude,codex,...] # configure providers/proxy/browser; --install-skills writes the skill+agent files
224
+ research-assistant config fields # each provider's config fields (api_key/model required, default base_url, keyless tier, notes); read-only
218
225
  research-assistant doctor [--show-config] [--target <name>] # connectivity diagnostics; --show-config prints config (masked), --target checks one provider or builtin
219
226
  research-assistant skills status [--targets claude,codex,...] [--skills-root PATH] # managed skill/agent freshness
220
227
  research-assistant skills update [--targets claude,codex,...] [--skills-root PATH] # refresh managed files
@@ -80,8 +80,9 @@ def _build_parser(config: Any) -> argparse.ArgumentParser:
80
80
  from .commands import setup as setup_cmd
81
81
  from .commands import skills as skills_cmd
82
82
  from .commands import doctor as doctor_cmd
83
+ from .commands import config as config_cmd
83
84
 
84
- for cmd in (fetch_cmd, locate_cmd, search_cmd, ask_cmd, setup_cmd, skills_cmd, doctor_cmd):
85
+ for cmd in (fetch_cmd, locate_cmd, search_cmd, ask_cmd, config_cmd, setup_cmd, skills_cmd, doctor_cmd):
85
86
  cmd.register(subs)
86
87
 
87
88
  return parser
@@ -0,0 +1,80 @@
1
+ """provider 配置元数据(setup 交互式 + config fields 共享,单源)。
2
+
3
+ 集中描述每个 provider type 在 config.toml 里该填什么:是否必填 api_key / model、
4
+ base_url 默认值、免 key 层说明、配置提示。setup 的 _PROVIDER_TYPES 从此派生,避免两处漂移。
5
+
6
+ 字段:
7
+ type config.toml 的 type 键
8
+ label 人类可读名
9
+ needs_model 是否必填 model
10
+ default_base base_url 默认值(空=用户必填)
11
+ api_key "required" | "optional"
12
+ keyless 免 key 层说明(空=无免 key 层)
13
+ notes 配置提示
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+
20
+
21
+ @dataclass
22
+ class ProviderMeta:
23
+ type: str
24
+ label: str
25
+ needs_model: bool
26
+ default_base: str
27
+ api_key: str # "required" | "optional"
28
+ keyless: str = ""
29
+ notes: str = ""
30
+
31
+
32
+ # 顺序即展示顺序(LLM 主干 → 搜索 API → 文档 → firecrawl)。
33
+ PROVIDER_META: dict[str, ProviderMeta] = {
34
+ "openai_compat": ProviderMeta(
35
+ type="openai_compat",
36
+ label="OpenAI-compatible (drives ask)",
37
+ needs_model=True,
38
+ default_base="https://api.openai.com/v1",
39
+ api_key="required",
40
+ notes="base_url defaults to OpenAI (with /v1/). For a compatible gateway, set its URL including /v1/. Pick a model with built-in web search.",
41
+ ),
42
+ "locate": ProviderMeta(
43
+ type="locate",
44
+ label="locate model (small OpenAI-compatible model for long-doc locate)",
45
+ needs_model=True,
46
+ default_base="https://api.openai.com/v1",
47
+ api_key="required",
48
+ notes="Reuses the OpenAI-compatible protocol; a cheap small model is enough. base_url defaults to OpenAI; a gateway URL must include /v1/.",
49
+ ),
50
+ "exa": ProviderMeta(
51
+ type="exa",
52
+ label="Exa (structured search API)",
53
+ needs_model=False,
54
+ default_base="https://api.exa.ai",
55
+ api_key="required",
56
+ ),
57
+ "tavily": ProviderMeta(
58
+ type="tavily",
59
+ label="Tavily (search + extract + map + crawl)",
60
+ needs_model=False,
61
+ default_base="https://api.tavily.com",
62
+ api_key="required",
63
+ ),
64
+ "firecrawl": ProviderMeta(
65
+ type="firecrawl",
66
+ label="Firecrawl (keyless scrape/search/interact/parse; map/crawl/extract/agent/monitor need key)",
67
+ needs_model=False,
68
+ default_base="https://api.firecrawl.dev/v2",
69
+ api_key="optional",
70
+ keyless="scrape/search/interact/parse are keyless (IP-rate-limited); map/crawl/extract/agent/monitor need a key.",
71
+ notes="Keyless commands default to auto (keyless first, fall back to key on rate-limit/IP failure). Configure a key to unlock map/crawl/extract/agent/monitor.",
72
+ ),
73
+ "context7": ProviderMeta(
74
+ type="context7",
75
+ label="Context7 (library/framework/SDK/CLI/cloud docs)",
76
+ needs_model=False,
77
+ default_base="https://context7.com",
78
+ api_key="required",
79
+ ),
80
+ }
@@ -0,0 +1,47 @@
1
+ """config 命令:配置相关的只读查询。
2
+
3
+ research-assistant config fields # 打印各 provider 配置字段表(只读,不连网,不动配置)
4
+
5
+ 未来可扩展更多子命令(如 config show 打印当前值,与 doctor --show-config 区分)。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ from typing import Any
12
+
13
+ from ..config import Config
14
+ from ._provider_meta import PROVIDER_META
15
+
16
+ NAME = "config"
17
+ ALIASES: list[str] = []
18
+ HELP = "Read-only configuration queries (provider field reference)."
19
+
20
+
21
+ def register(subparsers: argparse._SubParsersAction) -> None:
22
+ p = subparsers.add_parser(NAME, help=HELP, description=HELP)
23
+ sub = p.add_subparsers(dest="subcommand", required=True, metavar="<fields>")
24
+ f = sub.add_parser(
25
+ "fields",
26
+ help="Print each provider type's config fields (api_key/model required, default base_url, keyless tier, notes).",
27
+ )
28
+ f.set_defaults(_handler=run_fields)
29
+
30
+
31
+ async def run_fields(args: argparse.Namespace, config: Config) -> dict[str, Any]:
32
+ """各 provider 配置字段表({provider_type: fields})。只读:不读 config.toml,不连网。
33
+
34
+ 返回 dict-of-dict 而非 list,使 markdown 渲染走普通 dict 路径(每字段 200 字符宽),
35
+ notes 不被 list 的 80 字符截断。
36
+ """
37
+ out: dict[str, Any] = {}
38
+ for ptype, m in PROVIDER_META.items():
39
+ out[m.type] = {
40
+ "label": m.label,
41
+ "api_key": m.api_key,
42
+ "model": "required" if m.needs_model else "n/a",
43
+ "default_base_url": m.default_base or "(set by user)",
44
+ "keyless": m.keyless or "-",
45
+ "notes": m.notes or "-",
46
+ }
47
+ return out
@@ -143,6 +143,21 @@ def _minimal_namespace(cap_name: str) -> argparse.Namespace:
143
143
  "ids": [],
144
144
  "url": "https://example.com",
145
145
  "urls": ["https://example.com"],
146
+ # search provider 完整字段:exa/tavily 等 handler 直接访问 ns.<attr>,缺就 AttributeError。
147
+ # 这里给全 superset,doctor 测任何 provider 的 search 能力都不缺字段。
148
+ "end_date": None,
149
+ "include_text": None,
150
+ "exclude_text": None,
151
+ "include_raw_content": None,
152
+ "chunks_per_source": None,
153
+ "country": None,
154
+ "days": None,
155
+ "include_images": False,
156
+ "include_image_descriptions": False,
157
+ "include_favicon": False,
158
+ "auto_parameters": False,
159
+ "engine": "bing-intl",
160
+ "max_pages": 10,
146
161
  }
147
162
  return argparse.Namespace(**base)
148
163
 
@@ -18,19 +18,16 @@ from ..config import Config, ProviderConfig, load, save
18
18
  from ..errors import ArgsError, ResearchAssistantError
19
19
  from .. import targets as targets_mod
20
20
  from .. import installer
21
+ from ._provider_meta import PROVIDER_META
21
22
 
22
23
  NAME = "setup"
23
24
  ALIASES: list[str] = ["init"]
24
25
  HELP = "Configure providers/proxy/browser and optionally install managed skill/agent."
25
26
 
26
- # 已知 provider 类型 + 是否需要 model
27
+ # 已知 provider 类型 + 是否需要 model(从共享元数据派生,单源;setup 只用 needs_model/label/default_base)
27
28
  _PROVIDER_TYPES: dict[str, dict[str, Any]] = {
28
- "openai_compat": {"needs_model": True, "label": "OpenAI-compatible (search/LLM)", "default_base": ""},
29
- "locate": {"needs_model": True, "label": "locate 模型 (openai 兼容小模型)", "default_base": ""},
30
- "exa": {"needs_model": False, "label": "Exa", "default_base": "https://api.exa.ai"},
31
- "tavily": {"needs_model": False, "label": "Tavily", "default_base": "https://api.tavily.com"},
32
- "firecrawl": {"needs_model": False, "label": "Firecrawl", "default_base": "https://api.firecrawl.dev/v2"},
33
- "context7": {"needs_model": False, "label": "Context7", "default_base": "https://context7.com"},
29
+ t: {"needs_model": m.needs_model, "label": m.label, "default_base": m.default_base}
30
+ for t, m in PROVIDER_META.items()
34
31
  }
35
32
 
36
33
 
@@ -51,7 +51,7 @@ class BrowserProvider(Provider):
51
51
  help="Search engine (default bing-intl; intl avoids CN-sensitive-word filtering).",
52
52
  ),
53
53
  ArgSpec(["--limit"], type=int, default=10, help="Min results to collect (default 10)."),
54
- ArgSpec(["--max-pages"], type=int, default=10, help="Max pages to paginate (default 10)."),
54
+ ArgSpec(["--max-pages"], type=int, default=10, help="Max pages to paginate (default 10; engine may offer fewer)."),
55
55
  ],
56
56
  handler=self.search,
57
57
  ),
@@ -87,7 +87,7 @@ class ExaProvider(Provider):
87
87
  help="Search the web with Exa (auto/keyword/neural/fast).",
88
88
  args=[
89
89
  ArgSpec(["query"], kind="positional", help="Search query."),
90
- ArgSpec(["--num-results"], type=int, default=10, help="Number of results (default 10)."),
90
+ ArgSpec(["--num-results"], type=int, default=10, help="Number of results (1-100, default 10)."),
91
91
  ArgSpec(
92
92
  ["--type"],
93
93
  choices=["auto", "keyword", "neural", "fast"],
@@ -124,7 +124,7 @@ class ExaProvider(Provider):
124
124
  help="Find pages similar to a URL.",
125
125
  args=[
126
126
  ArgSpec(["url"], kind="positional", help="Source URL."),
127
- ArgSpec(["--num-results"], type=int, default=10, help="Number of results."),
127
+ ArgSpec(["--num-results"], type=int, default=10, help="Number of results (1-100)."),
128
128
  ],
129
129
  handler=self.similar,
130
130
  ),
@@ -49,12 +49,26 @@ from .registry import register
49
49
  DEFAULT_BASE_URL = "https://api.firecrawl.dev/v2"
50
50
 
51
51
 
52
+ def _keyless_flags() -> list[ArgSpec]:
53
+ """keyless 命令(scrape/search/interact/parse)共享的 key 策略 flags。
54
+
55
+ 默认 auto:配了 key 先走免 key,失败(限流/IP/suspicious)再带 key 重试一次。
56
+ --use-key 强制带 key(跳过免 key);--keyless 强制免 key(失败不 fallback)。二者互斥。
57
+ """
58
+ return [
59
+ ArgSpec(["--use-key"], action="store_true",
60
+ help="Force the keyed tier; skip the keyless attempt (needs api_key)."),
61
+ ArgSpec(["--keyless"], action="store_true",
62
+ help="Force the keyless tier; do not fall back to key on failure."),
63
+ ]
64
+
65
+
52
66
  @register
53
67
  class FirecrawlProvider(Provider):
54
68
  type = "firecrawl"
55
69
  command = "firecrawl"
56
70
  aliases = ["fc"]
57
- help = "Firecrawl scrape / search / map (scrape+search work keyless; map needs a key)."
71
+ help = "Firecrawl scrape/search/interact/parse (keyless) + map/crawl/extract/agent/monitor (need key)."
58
72
 
59
73
  def capabilities(self) -> list[Capability]:
60
74
  return [
@@ -66,7 +80,7 @@ class FirecrawlProvider(Provider):
66
80
  ArgSpec(["--format"], choices=["markdown", "html"], default="markdown", help="Output format."),
67
81
  ArgSpec(["--only-main-content"], action="store_true", help="Drop nav/footer boilerplate."),
68
82
  ArgSpec(["--wait-for"], type=int, metavar="MS", help="Wait N ms before scraping."),
69
- ],
83
+ ] + _keyless_flags(),
70
84
  handler=self.scrape,
71
85
  ),
72
86
  Capability(
@@ -74,10 +88,10 @@ class FirecrawlProvider(Provider):
74
88
  help="Web search (keyless); with --scrape returns full markdown per result.",
75
89
  args=[
76
90
  ArgSpec(["query"], kind="positional", help="Search query."),
77
- ArgSpec(["--limit"], type=int, default=10, help="Max results."),
91
+ ArgSpec(["--limit"], type=int, default=10, help="Max results (1-100)."),
78
92
  ArgSpec(["--sources"], nargs="+", choices=["web", "news"], help="Result sources."),
79
- ArgSpec(["--scrape"], action="store_true", help="Scrape full content for each result (needs key on keyless tier)."),
80
- ],
93
+ ArgSpec(["--scrape"], action="store_true", help="Scrape full content for each result."),
94
+ ] + _keyless_flags(),
81
95
  handler=self.search,
82
96
  ),
83
97
  Capability(
@@ -124,7 +138,7 @@ class FirecrawlProvider(Provider):
124
138
  ),
125
139
  Capability(
126
140
  name="interact",
127
- help="Run a prompt or code in a live browser session bound to a scrape (needs a key).",
141
+ help="Run a prompt or code in a live browser session bound to a scrape (keyless).",
128
142
  args=[
129
143
  ArgSpec(["prompt"], kind="positional", nargs="?", metavar="TEXT",
130
144
  help="AI prompt to run in the browser session (mode 1; exclusive with --code)."),
@@ -140,7 +154,7 @@ class FirecrawlProvider(Provider):
140
154
  help="Execution timeout seconds (1-300, default 30)."),
141
155
  ArgSpec(["--stop"], action="store_true",
142
156
  help="Stop and tear down the session (requires --scrape-id)."),
143
- ],
157
+ ] + _keyless_flags(),
144
158
  handler=self.interact,
145
159
  ),
146
160
  Capability(
@@ -174,7 +188,7 @@ class FirecrawlProvider(Provider):
174
188
  ),
175
189
  Capability(
176
190
  name="parse",
177
- help="Upload a local document (.pdf/.docx/.html/...) and parse to markdown/json (needs a key).",
191
+ help="Upload a local document (.pdf/.docx/.html/...) and parse to markdown/json (keyless).",
178
192
  args=[
179
193
  ArgSpec(["file"], kind="positional", metavar="PATH",
180
194
  help="Local file path (.html/.htm/.pdf/.docx/.doc/.odt/.rtf/.xlsx/.xls)."),
@@ -194,7 +208,7 @@ class FirecrawlProvider(Provider):
194
208
  help="PDF parser mode (sets options.parsers[0].mode)."),
195
209
  ArgSpec(["--max-pages"], type=int, metavar="N",
196
210
  help="Max PDF pages to parse (1-10000, needs --pdf-mode)."),
197
- ],
211
+ ] + _keyless_flags(),
198
212
  handler=self.parse,
199
213
  ),
200
214
  ]
@@ -224,14 +238,106 @@ class FirecrawlProvider(Provider):
224
238
  msg = data.get("error") or "firecrawl 返回 success:false"
225
239
  raise ProviderError(f"firecrawl {endpoint}: {msg}", provider="firecrawl", details=data)
226
240
 
227
- async def _scrape_one(self, client: Any, url: str, fmt: str, args: Any) -> dict[str, Any]:
241
+ def _require_keyed(self, cfg: ProviderConfig, endpoint: str) -> None:
242
+ """keyed 命令(map/crawl/extract/agent/monitor)必须配 key,否则前置失败,不发请求。"""
243
+ if not cfg.api_key:
244
+ from ..errors import ArgsError
245
+ raise ArgsError(
246
+ f"firecrawl {endpoint}: 此命令需要 api_key(免 key 层仅支持 scrape/search/interact/parse)",
247
+ provider="firecrawl",
248
+ )
249
+
250
+ def _resolve_mode(self, args: Any, cfg: ProviderConfig) -> str:
251
+ """解析 keyless 命令的 key 模式:keyless | use_key | auto。"""
252
+ from ..errors import ArgsError
253
+ if getattr(args, "keyless", False) and getattr(args, "use_key", False):
254
+ raise ArgsError("firecrawl: --keyless 与 --use-key 互斥", provider="firecrawl")
255
+ if getattr(args, "keyless", False):
256
+ return "keyless"
257
+ if getattr(args, "use_key", False):
258
+ if not cfg.api_key:
259
+ raise ArgsError("firecrawl: --use-key 需要 api_key,但未配置", provider="firecrawl")
260
+ return "use_key"
261
+ return "auto" # 默认:有 key 先免 key 失败再带 key,无 key 只免 key
262
+
263
+ @staticmethod
264
+ def _is_keyless_retryable(err: Exception) -> bool:
265
+ """免 key 失败是否值得带 key 重试(限流/IP/suspicious/quota 类;网络错误不算)。"""
266
+ from ..errors import ProviderError
267
+ if not isinstance(err, ProviderError):
268
+ return False
269
+ details = getattr(err, "details", None) or {}
270
+ status = details.get("status_code")
271
+ if status in (403, 429):
272
+ return True
273
+ msg = (getattr(err, "message", "") or "").lower()
274
+ keywords = ("rate limit", "ratelimit", "too many", "suspicious", "quota", "limit reached", "ip ")
275
+ return any(k in msg for k in keywords)
276
+
277
+ async def _do_request(
278
+ self, client: Any, method: str, path: str, *,
279
+ json_body: Any = None, files: Any = None,
280
+ headers: dict[str, str] | None, endpoint: str,
281
+ ) -> dict[str, Any]:
282
+ """单次 firecrawl 请求(JSON 或 multipart),按 headers 决定是否带 key;统一 _check。"""
283
+ if files is not None:
284
+ import httpx
285
+ from ..errors import NetworkError, wrap_provider_http_error
286
+ try:
287
+ resp = await client.request(method, path, files=files, headers=headers)
288
+ except httpx.HTTPError as e:
289
+ raise wrap_provider_http_error("firecrawl", e) from e
290
+ if resp.status_code >= 400:
291
+ raise http_mod.handle_http_error("firecrawl", httpx.HTTPStatusError(
292
+ f"firecrawl HTTP {resp.status_code}", request=resp.request, response=resp
293
+ ))
294
+ try:
295
+ data = resp.json()
296
+ except ValueError as e:
297
+ raise NetworkError(f"firecrawl: 响应非 JSON ({e})", provider="firecrawl") from e
298
+ else:
299
+ data = await http_mod.request_json(
300
+ client, method, path, provider="firecrawl", json_body=json_body, headers=headers
301
+ )
302
+ self._check(data, endpoint=endpoint)
303
+ return data
304
+
305
+ async def _request_keyless(
306
+ self, client: Any, method: str, path: str, *,
307
+ cfg: ProviderConfig, mode: str,
308
+ json_body: Any = None, files: Any = None, endpoint: str,
309
+ ) -> dict[str, Any]:
310
+ """keyless 命令请求调度:按 mode 决定是否带 key;auto 下免 key 失败(可重试)带 key 重试一次。
311
+
312
+ - keyless:永不带 key,失败即抛。
313
+ - use_key:必带 key(无 key 在 _resolve_mode 已挡)。
314
+ - auto:先免 key;失败且可重试(限流/IP/suspicious)且有 key → 带 key 重试一次。
315
+ """
316
+ from ..errors import ProviderError
317
+ key_headers = {"Authorization": f"Bearer {cfg.api_key}"} if cfg.api_key else None
318
+
319
+ if mode == "use_key":
320
+ return await self._do_request(client, method, path, json_body=json_body, files=files,
321
+ headers=key_headers, endpoint=endpoint)
322
+
323
+ # keyless 或 auto:先免 key
324
+ try:
325
+ return await self._do_request(client, method, path, json_body=json_body, files=files,
326
+ headers=None, endpoint=endpoint)
327
+ except ProviderError as e:
328
+ if mode == "keyless" or not cfg.api_key or not self._is_keyless_retryable(e):
329
+ raise
330
+ # auto + 有 key + 可重试:带 key 再来一次
331
+ return await self._do_request(client, method, path, json_body=json_body, files=files,
332
+ headers=key_headers, endpoint=endpoint)
333
+
334
+ async def _scrape_one(self, client: Any, url: str, fmt: str, args: Any, cfg: ProviderConfig, mode: str) -> dict[str, Any]:
228
335
  body: dict[str, Any] = {"url": url, "formats": [fmt]}
229
336
  if getattr(args, "only_main_content", False):
230
337
  body["onlyMainContent"] = True
231
338
  if getattr(args, "wait_for", None):
232
339
  body["waitFor"] = args.wait_for
233
- data = await http_mod.request_json(client, "POST", "scrape", provider="firecrawl", json_body=body)
234
- self._check(data, endpoint="scrape")
340
+ data = await self._request_keyless(client, "POST", "scrape", cfg=cfg, mode=mode, json_body=body, endpoint="scrape")
235
341
  inner = data.get("data") or {}
236
342
  out: dict[str, Any] = {}
237
343
  if fmt == "markdown":
@@ -246,21 +352,22 @@ class FirecrawlProvider(Provider):
246
352
 
247
353
  async def scrape(self, args: Any) -> dict[str, Any]:
248
354
  cfg = self._cfg()
355
+ mode = self._resolve_mode(args, cfg)
249
356
  urls = args.urls
250
357
  async with http_mod.make_client(
251
358
  proxy_url=resolve_proxy(self.config.proxy.url),
252
359
  timeout=cfg.timeout,
253
360
  base_url=cfg.base_url,
254
- headers=self._headers(cfg),
255
361
  ) as client:
256
362
  if len(urls) == 1:
257
- return {"data": await self._scrape_one(client, urls[0], args.format, args)}
363
+ return {"data": await self._scrape_one(client, urls[0], args.format, args, cfg, mode)}
258
364
  # 批量并发
259
- items = await asyncio.gather(*[self._scrape_one(client, u, args.format, args) for u in urls])
365
+ items = await asyncio.gather(*[self._scrape_one(client, u, args.format, args, cfg, mode) for u in urls])
260
366
  return {"results": [{"url": u, "data": d} for u, d in zip(urls, items)]}
261
367
 
262
368
  async def search(self, args: Any) -> dict[str, Any]:
263
369
  cfg = self._cfg()
370
+ mode = self._resolve_mode(args, cfg)
264
371
  body: dict[str, Any] = {"query": args.query, "limit": args.limit}
265
372
  if args.scrape:
266
373
  body["scrapeOptions"] = {"formats": [{"type": "markdown"}]}
@@ -270,10 +377,8 @@ class FirecrawlProvider(Provider):
270
377
  proxy_url=resolve_proxy(self.config.proxy.url),
271
378
  timeout=cfg.timeout,
272
379
  base_url=cfg.base_url,
273
- headers=self._headers(cfg),
274
380
  ) as client:
275
- data = await http_mod.request_json(client, "POST", "search", provider="firecrawl", json_body=body)
276
- self._check(data, endpoint="search")
381
+ data = await self._request_keyless(client, "POST", "search", cfg=cfg, mode=mode, json_body=body, endpoint="search")
277
382
  out: dict[str, Any] = {}
278
383
  if args.scrape:
279
384
  arr = data.get("data") or []
@@ -293,6 +398,7 @@ class FirecrawlProvider(Provider):
293
398
 
294
399
  async def map(self, args: Any) -> dict[str, Any]:
295
400
  cfg = self._cfg()
401
+ self._require_keyed(cfg, "map")
296
402
  body: dict[str, Any] = {"url": args.url, "limit": args.limit}
297
403
  if args.include_subdomains:
298
404
  body["includeSubdomains"] = True
@@ -323,6 +429,7 @@ class FirecrawlProvider(Provider):
323
429
 
324
430
  async def crawl(self, args: Any) -> dict[str, Any]:
325
431
  cfg = self._cfg()
432
+ self._require_keyed(cfg, "crawl")
326
433
  body: dict[str, Any] = {
327
434
  "url": args.url,
328
435
  "limit": args.limit,
@@ -376,6 +483,7 @@ class FirecrawlProvider(Provider):
376
483
  from ..errors import ArgsError
377
484
 
378
485
  cfg = self._cfg()
486
+ self._require_keyed(cfg, "extract")
379
487
  body: dict[str, Any] = {"urls": args.urls}
380
488
  if args.prompt:
381
489
  body["prompt"] = args.prompt
@@ -418,6 +526,7 @@ class FirecrawlProvider(Provider):
418
526
  from ..errors import ArgsError, ProviderError
419
527
 
420
528
  cfg = self._cfg()
529
+ mode = self._resolve_mode(args, cfg)
421
530
  # --stop:配合 --scrape-id,DELETE 会话
422
531
  if args.stop:
423
532
  if not args.scrape_id:
@@ -426,12 +535,11 @@ class FirecrawlProvider(Provider):
426
535
  proxy_url=resolve_proxy(self.config.proxy.url),
427
536
  timeout=cfg.timeout,
428
537
  base_url=cfg.base_url,
429
- headers=self._headers(cfg),
430
538
  ) as client:
431
- data = await http_mod.request_json(
432
- client, "DELETE", f"scrape/{args.scrape_id}/interact", provider="firecrawl"
539
+ await self._request_keyless(
540
+ client, "DELETE", f"scrape/{args.scrape_id}/interact",
541
+ cfg=cfg, mode=mode, endpoint="interact",
433
542
  )
434
- self._check(data, endpoint="interact")
435
543
  return {"stopped": True, "scrapeId": args.scrape_id}
436
544
 
437
545
  # 模式校验:prompt 与 --code 二选一
@@ -444,7 +552,6 @@ class FirecrawlProvider(Provider):
444
552
  proxy_url=resolve_proxy(self.config.proxy.url),
445
553
  timeout=cfg.timeout,
446
554
  base_url=cfg.base_url,
447
- headers=self._headers(cfg),
448
555
  ) as client:
449
556
  # 解析会话句柄:--scrape-id 优先;否则用 --url 发起 scrape 拿 metadata.scrapeId
450
557
  scrape_id = args.scrape_id
@@ -453,11 +560,10 @@ class FirecrawlProvider(Provider):
453
560
  raise ArgsError(
454
561
  "firecrawl interact: 需要 --scrape-id 或 --url(用于新建会话)", provider="firecrawl"
455
562
  )
456
- seed = await http_mod.request_json(
457
- client, "POST", "scrape", provider="firecrawl",
458
- json_body={"url": args.url, "formats": ["markdown"]},
563
+ seed = await self._request_keyless(
564
+ client, "POST", "scrape", cfg=cfg, mode=mode,
565
+ json_body={"url": args.url, "formats": ["markdown"]}, endpoint="scrape",
459
566
  )
460
- self._check(seed, endpoint="scrape")
461
567
  seed_inner = seed.get("data") or {}
462
568
  meta = seed_inner.get("metadata") or {}
463
569
  scrape_id = meta.get("scrapeId")
@@ -474,10 +580,10 @@ class FirecrawlProvider(Provider):
474
580
  else:
475
581
  body = {"prompt": args.prompt}
476
582
 
477
- data = await http_mod.request_json(
478
- client, "POST", f"scrape/{scrape_id}/interact", provider="firecrawl", json_body=body
583
+ data = await self._request_keyless(
584
+ client, "POST", f"scrape/{scrape_id}/interact",
585
+ cfg=cfg, mode=mode, json_body=body, endpoint="interact",
479
586
  )
480
- self._check(data, endpoint="interact")
481
587
 
482
588
  # 透传会话句柄 + 执行结果(prompt→output,code→stdout/result/stderr/exitCode/killed)
483
589
  out: dict[str, Any] = {"scrapeId": scrape_id}
@@ -492,6 +598,7 @@ class FirecrawlProvider(Provider):
492
598
  from ..errors import ArgsError
493
599
 
494
600
  cfg = self._cfg()
601
+ self._require_keyed(cfg, "agent")
495
602
  body: dict[str, Any] = {"prompt": args.prompt, "model": args.model}
496
603
  if args.urls:
497
604
  body["urls"] = args.urls
@@ -538,6 +645,7 @@ class FirecrawlProvider(Provider):
538
645
  OpenAPI 的 required 误写为 data,实际响应字段是 crawls,两个字段都兼容。
539
646
  """
540
647
  cfg = self._cfg()
648
+ self._require_keyed(cfg, "monitor")
541
649
  async with http_mod.make_client(
542
650
  proxy_url=resolve_proxy(self.config.proxy.url),
543
651
  timeout=cfg.timeout,
@@ -558,18 +666,17 @@ class FirecrawlProvider(Provider):
558
666
  }
559
667
 
560
668
  async def parse(self, args: Any) -> dict[str, Any]:
561
- """上传本地文件解析(POST /v2/parse,multipart/form-data)。
669
+ """上传本地文件解析(POST /v2/parse,multipart/form-data,keyless)。
562
670
 
563
- request_json 只支持 JSON body;parse 需要 multipart 文件上传,这里直接用 client.request,
564
- 复用 handle_http_error / wrap_provider_http_error 保持与其他 handler 一致的错误映射。
671
+ _request_keyless multipart 分支(_do_request files=...),享受与 scrape 等一致的
672
+ key 策略(auto/keyless/use_key)与错误映射。
565
673
  """
566
674
  from pathlib import Path
567
675
 
568
- import httpx
569
-
570
- from ..errors import ArgsError, NetworkError, wrap_provider_http_error
676
+ from ..errors import ArgsError
571
677
 
572
678
  cfg = self._cfg()
679
+ mode = self._resolve_mode(args, cfg)
573
680
  path = Path(args.file)
574
681
  if not path.is_file():
575
682
  raise ArgsError(f"firecrawl parse: 文件不存在: {args.file}", provider="firecrawl")
@@ -610,24 +717,10 @@ class FirecrawlProvider(Provider):
610
717
  proxy_url=resolve_proxy(self.config.proxy.url),
611
718
  timeout=cfg.timeout,
612
719
  base_url=cfg.base_url,
613
- headers=self._headers(cfg),
614
720
  ) as client:
615
- try:
616
- resp = await client.request("POST", "parse", files=files)
617
- except httpx.HTTPError as e:
618
- raise wrap_provider_http_error("firecrawl", e) from e
619
- if resp.status_code >= 400:
620
- raise http_mod.handle_http_error(
621
- "firecrawl",
622
- httpx.HTTPStatusError(
623
- f"firecrawl HTTP {resp.status_code}", request=resp.request, response=resp
624
- ),
625
- )
626
- try:
627
- data = resp.json()
628
- except ValueError as e:
629
- raise NetworkError(f"firecrawl: 响应非 JSON ({e})", provider="firecrawl") from e
630
- self._check(data, endpoint="parse")
721
+ data = await self._request_keyless(
722
+ client, "POST", "parse", cfg=cfg, mode=mode, files=files, endpoint="parse",
723
+ )
631
724
 
632
725
  # ScrapeResponse 结构:markdown 映射到 content(对齐 api-contract §2.1)
633
726
  inner = data.get("data") or {}
@@ -42,8 +42,9 @@ def resolve_cfg(config: Config, provider_type: str = "openai_compat") -> Provide
42
42
  cfg = config.require_provider(provider_type)
43
43
  if not cfg.api_key:
44
44
  raise ArgsError(f"{provider_type}: 缺少 api_key", provider=provider_type)
45
+ # base_url 默认 OpenAI 官方(含 /v1/);用 OpenAI 兼容渠道时 base_url 需自带 /v1/(如 https://host/v1)
45
46
  if not cfg.base_url:
46
- raise ArgsError(f"{provider_type}: 缺少 base_url", provider=provider_type)
47
+ cfg.base_url = "https://api.openai.com/v1"
47
48
  if not cfg.model:
48
49
  raise ArgsError(f"{provider_type}: 缺少 model", provider=provider_type)
49
50
  return cfg
@@ -107,8 +108,12 @@ async def _stream_chat(client: httpx.AsyncClient, body: dict[str, Any], provider
107
108
  search_results: list[Any] = []
108
109
  usage: dict[str, Any] | None = None
109
110
  role = "assistant"
111
+ raw_non_sse: list[str] = [] # 非 data: 行:网关可能 HTTP 200 却返回 body JSON error(非 SSE)
110
112
  async for line in resp.aiter_lines():
111
- if not line or not line.startswith("data:"):
113
+ if not line:
114
+ continue
115
+ if not line.startswith("data:"):
116
+ raw_non_sse.append(line)
112
117
  continue
113
118
  data_str = line[5:].strip()
114
119
  if data_str == "[DONE]":
@@ -141,6 +146,25 @@ async def _stream_chat(client: httpx.AsyncClient, body: dict[str, Any], provider
141
146
 
142
147
  raise wrap_provider_http_error(provider_type, e) from e
143
148
 
149
+ # 网关 HTTP 200 却没给 SSE chunk:body 可能是 JSON error 或 HTML 错误页(上游不可用等)。
150
+ # 别静默返回空 content(否则用户以为 research-assistant 坏,实际是 model 上游)。
151
+ if not content_parts and raw_non_sse:
152
+ raw = "\n".join(raw_non_sse)
153
+ msg: str | None = None
154
+ try:
155
+ err_body = json.loads(raw)
156
+ if isinstance(err_body, dict) and err_body.get("error"):
157
+ err = err_body["error"]
158
+ msg = err.get("message") if isinstance(err, dict) else str(err)
159
+ except json.JSONDecodeError:
160
+ pass
161
+ if msg:
162
+ raise ProviderError(f"{provider_type}: 上游返回错误 — {msg}", provider=provider_type)
163
+ raise ProviderError(
164
+ f"{provider_type}: 上游返回非 SSE 响应(HTTP 200,可能错误页/上游不可用): {raw[:120]}",
165
+ provider=provider_type,
166
+ )
167
+
144
168
  message: dict[str, Any] = {"role": role, "content": "".join(content_parts)}
145
169
  if citations:
146
170
  message["citations"] = citations