crw_server/routes/capabilities.rs
1//! `GET /v1/capabilities` — surface what this opencore instance supports.
2//!
3//! SaaS / dashboard frontends call this on boot to decide which provider
4//! buttons / formats to surface. Closes the "SaaS UI shipped before
5//! opencore rollout" silent-failure mode by giving callers a way to ask
6//! "do you actually do this?" before making a real request.
7//!
8//! CONTRACT — every boolean here is DERIVED from the real build (cargo
9//! features) and the effective config. A capability is `true` only when this
10//! instance can perform the operation for a well-formed request that supplies
11//! NO extra credentials. Nothing is hardcoded to `true`.
12//!
13//! Credentials are reported separately rather than folded into the booleans:
14//!
15//! * `llm.serverKeyConfigured` — a server-side LLM key is present.
16//! * `formats.llmRequired` — the formats that need an LLM: a server key, or a
17//! per-request `llmApiKey` (BYOK is always accepted and cannot be disabled).
18//!
19//! So a BYOK-only deploy reports `search.answer: false` (it cannot answer
20//! without a caller-supplied key) alongside `search.supported: true` — a caller
21//! that sends `llmApiKey` still gets answers.
22
23use axum::Json;
24use axum::extract::State;
25use serde::Serialize;
26
27use crate::state::AppState;
28
29#[derive(Debug, Serialize)]
30#[serde(rename_all = "camelCase")]
31pub struct Capabilities {
32 pub version: &'static str,
33 pub llm: LlmCapabilities,
34 pub formats: FormatCapabilities,
35 pub search: SearchCapabilities,
36 pub screenshot: ScreenshotCapabilities,
37 pub renderers: RendererCapabilities,
38 pub extract: ExtractCapabilities,
39 pub documents: DocumentCapabilities,
40 pub limits: Limits,
41}
42
43#[derive(Debug, Serialize)]
44#[serde(rename_all = "camelCase")]
45pub struct ExtractCapabilities {
46 /// `POST /v1/extract` (async multi-URL structured extraction) works for a
47 /// request that carries NO credentials of its own. The route is ALWAYS
48 /// mounted, but it needs an LLM: it rejects a keyless request when no
49 /// server LLM key is configured, and it rejects one regardless of the
50 /// server key when `llm.requireByokHeader` is set. Same gate as
51 /// `search.answer`, plus the header guard.
52 ///
53 /// `false` does not mean "not implemented" — a request carrying `llmApiKey`
54 /// still extracts. It means "not usable without your own key".
55 pub supported: bool,
56 /// Max URLs accepted per request (`crawler.max_extract_urls`).
57 pub max_urls: usize,
58 /// Per-field `basis` attribution: `basis: true` on `/v1/extract` and on a
59 /// `formats:["json"]` scrape returns an evidence record per top-level scalar
60 /// schema property. Reports the truth of the running build.
61 pub per_field_attribution: bool,
62 /// The engine's effective per-leg output-token cap for structured
63 /// extraction (`extraction.llm.max_tokens`). A budget estimator pins its
64 /// worst-case leg cost to this exact number, so it is reported here rather
65 /// than assumed — an ops change to `max_tokens` moves this value with it.
66 pub max_output_tokens: u32,
67}
68
69#[derive(Debug, Serialize)]
70#[serde(rename_all = "camelCase")]
71pub struct DocumentCapabilities {
72 /// Document parser types this instance can apply. `["pdf"]` when PDF
73 /// support is compiled in and enabled; empty otherwise. The SaaS gates the
74 /// `parsers` option and the upload UI on this.
75 pub parsers: Vec<&'static str>,
76 /// File-upload availability + limits.
77 pub file_upload: FileUploadCapabilities,
78}
79
80#[derive(Debug, Serialize)]
81#[serde(rename_all = "camelCase")]
82pub struct FileUploadCapabilities {
83 pub supported: bool,
84 /// Upload path, as a value a client can join onto its API base. `/v2/parse`
85 /// is the one path served by EVERY surface: the engine mounts it at the
86 /// root and under `/firecrawl`, and the hosted API proxies `/v1/*` and
87 /// `/v2/*` only — a `/firecrawl/...` value would 404 there.
88 pub endpoint: &'static str,
89 /// The ENFORCED body cap — the same value the `/v2/parse` body-limit layer
90 /// applies (`document.max_upload_bytes`, clamped by the hard ceiling).
91 pub max_bytes: usize,
92 pub types: Vec<&'static str>,
93 /// pdf-inspector has no OCR — scanned/image PDFs yield empty/partial text.
94 pub ocr: bool,
95}
96
97#[derive(Debug, Serialize)]
98#[serde(rename_all = "camelCase")]
99pub struct LlmCapabilities {
100 /// Provider tags the server's dispatch accepts. Sourced from
101 /// `crw_extract::llm::SUPPORTED_PROVIDERS`, the same list the dispatcher
102 /// validates against, so the advertisement cannot drift from reality.
103 pub providers: Vec<&'static str>,
104 /// The dispatcher accepts a custom `baseUrl`, but ONLY as part of a BYOK
105 /// request: `baseUrl` is read while building the per-request LLM config,
106 /// which is only built when the request also carries `llmApiKey`. Sending
107 /// `baseUrl` without a key leaves the server's own endpoint in force. The
108 /// server would otherwise be pointing its OWN key at a caller-chosen host,
109 /// so this is deliberate.
110 ///
111 /// `/v1/extract` rejects `baseUrl` outright; configure
112 /// `[extraction.llm.base_url]` server-side for that route.
113 ///
114 /// Build-invariant: the dispatcher always compiles this in, so unlike the
115 /// other fields here there is no config that turns it off.
116 pub supports_base_url: bool,
117 /// True when a server-wide LLM key is configured (self-hosted /
118 /// no-SaaS deploys). SaaS-fronted deploys set
119 /// `CRW_DISABLE_SERVER_LLM_KEY=1` and rely on per-request BYOK.
120 pub server_key_configured: bool,
121 /// Configured server-side fan-out cap for LLM calls. 0 when no
122 /// server-side LLM config is present.
123 pub max_concurrency: usize,
124 /// Header name the server will look for on LLM-touching requests
125 /// (`None` means no header guard).
126 #[serde(skip_serializing_if = "Option::is_none")]
127 pub require_byok_header: Option<String>,
128}
129
130#[derive(Debug, Serialize)]
131#[serde(rename_all = "camelCase")]
132pub struct FormatCapabilities {
133 /// Formats this build/config can actually produce. `screenshot` appears
134 /// only when a screenshot-capable renderer is compiled AND configured.
135 pub supported: Vec<&'static str>,
136 /// Formats that additionally need an LLM — a server key
137 /// (`llm.serverKeyConfigured`) or a per-request `llmApiKey`. Without one,
138 /// requesting them is a hard error, never a silent downgrade.
139 ///
140 /// When `llm.requireByokHeader` is set, the server key alone is NOT enough:
141 /// the scrape and extract paths reject these formats unless the request
142 /// carries `llmApiKey`, even on a deploy that has a server key.
143 pub llm_required: Vec<&'static str>,
144 /// Change-tracking diff modes this instance supports. The SaaS
145 /// capability-gate checks `supported` contains `"changeTracking"` before
146 /// emitting monitor scrapes.
147 pub change_tracking_modes: Vec<&'static str>,
148 /// Change-tracking modes that need an LLM, on the same terms as
149 /// `llmRequired`. `gitDiff` is deterministic and needs none; `json` mode is
150 /// a hard error without a server key or a per-request `llmApiKey`.
151 pub change_tracking_modes_llm_required: Vec<&'static str>,
152}
153
154#[derive(Debug, Serialize)]
155#[serde(rename_all = "camelCase")]
156pub struct SearchCapabilities {
157 /// `/v1/search` is usable: `search.enabled` AND `search.searxng_url` are
158 /// set. Configured, not health-probed — a configured-but-unreachable
159 /// backend still reports `true`.
160 pub supported: bool,
161 /// Answer synthesis works WITHOUT a caller-supplied LLM key (search
162 /// configured AND a server LLM key present). When this is `false` but
163 /// `supported` is `true`, a request carrying `llmApiKey` still gets an
164 /// answer.
165 pub answer: bool,
166 /// Same gate as `answer`.
167 pub summarize_results: bool,
168}
169
170#[derive(Debug, Serialize)]
171#[serde(rename_all = "camelCase")]
172pub struct ScreenshotCapabilities {
173 /// A screenshot-capable renderer (chrome / chrome_proxy / playwright) is
174 /// compiled in AND configured. LightPanda and Camoufox cannot capture, so
175 /// an instance that only has those reports `false` — and the scrape path
176 /// fails closed on a screenshot request rather than returning an empty one.
177 pub supported: bool,
178 /// Full-page capture (`screenshot@fullPage` / `screenshotFullPage`). Same
179 /// gate as `supported`: the CDP capture path serves both.
180 pub full_page: bool,
181}
182
183#[derive(Debug, Serialize)]
184#[serde(rename_all = "camelCase")]
185pub struct RendererCapabilities {
186 /// JS renderer tiers this instance actually constructed, in fallback order.
187 /// Reflects BOTH the build features (a CDP-less build constructs none) and
188 /// the config (a tier whose `ws_url` / `base_url` is unset is never built).
189 /// A `renderer` pin naming a tier outside this list is rejected; the pin
190 /// also accepts `"auto"`, which is not a tier and so is not listed here.
191 ///
192 /// "Constructible / pinnable", not "always in the auto ladder": `camoufox`
193 /// is built whenever its endpoint is set even when `include_in_auto` is
194 /// false, and `chrome_proxy` is held out of the auto ladder as a
195 /// hard-block recovery arm when `auto_egress_escalation` is on.
196 pub available: Vec<String>,
197 /// Effective `renderer.mode`.
198 pub mode: crw_core::config::RendererMode,
199 /// Effective `renderer.render_js_default`; omitted when unset (auto-detect).
200 #[serde(skip_serializing_if = "Option::is_none")]
201 pub render_js_default: Option<bool>,
202}
203
204#[derive(Debug, Serialize)]
205#[serde(rename_all = "camelCase")]
206pub struct Limits {
207 /// Max URLs per batch-scrape submission (`crawler.max_batch_urls`).
208 pub max_batch_urls: usize,
209 /// Max URLs per `/v1/extract` request (`crawler.max_extract_urls`).
210 pub max_extract_urls: usize,
211 /// `/v1/search` `limit` default when the request omits it.
212 pub search_default_limit: u32,
213 /// Hard cap on `/v1/search` `limit`.
214 pub search_max_limit: u32,
215 /// Enforced `/v2/parse` upload cap, in bytes.
216 pub max_upload_bytes: usize,
217}
218
219/// Formats every build produces, independent of features and config.
220const BASE_FORMATS: &[&str] = &[
221 "markdown",
222 "html",
223 "rawHtml",
224 "plainText",
225 "links",
226 "json",
227 "summary",
228 "changeTracking",
229];
230
231/// Formats that additionally need an LLM (server key or per-request BYOK key).
232const LLM_REQUIRED_FORMATS: &[&str] = &["json", "summary"];
233
234/// Change-tracking modes. `gitDiff` is deterministic; `json` calls an LLM.
235const CHANGE_TRACKING_MODES: &[&str] = &["gitDiff", "json"];
236const LLM_REQUIRED_CHANGE_TRACKING_MODES: &[&str] = &["json"];
237
238pub async fn capabilities(State(state): State<AppState>) -> Json<Capabilities> {
239 let llm_cfg = state.config.extraction.llm.as_ref();
240 let server_key_configured = llm_cfg.map(|c| !c.api_key.is_empty()).unwrap_or(false);
241 // With the BYOK header guard on, the scrape and extract paths reject
242 // LLM-backed work unless the request brings its own key — the server key
243 // does not count. `search` has no such guard, hence the split.
244 let byok_header_required = llm_cfg.is_some_and(|c| c.require_byok_header.is_some());
245 let llm_ready_without_caller_key = server_key_configured && !byok_header_required;
246
247 // Search is usable exactly when the SearXNG client was constructed, which
248 // happens only when `search.enabled && search.searxng_url.is_some()`.
249 let search_supported = state.searxng.is_some();
250 // Answer / summarize additionally need an LLM. Report what works with NO
251 // caller-supplied key; BYOK still enables them per request.
252 let search_llm_ready = search_supported && server_key_configured;
253
254 // Screenshot capture needs a renderer that can actually capture. The
255 // predicate is shared with the request-time filter in crw-renderer, so this
256 // can never advertise a screenshot the scrape path would refuse.
257 let screenshot_supported = state.renderer.supports_screenshot();
258
259 let mut formats: Vec<&'static str> = BASE_FORMATS.to_vec();
260 if screenshot_supported {
261 formats.push("screenshot");
262 }
263
264 let pdf_on = crw_extract::pdf::PDF_SUPPORTED && state.config.document.enabled;
265 let max_upload_bytes = crate::routes::v2::parse::effective_max_upload_bytes(&state.config);
266 // A zero cap means the body-limit layer rejects every upload, so uploads are
267 // not supported however the parser is compiled.
268 let upload_on = pdf_on && max_upload_bytes > 0;
269
270 Json(Capabilities {
271 version: env!("CARGO_PKG_VERSION"),
272 llm: LlmCapabilities {
273 providers: crw_extract::llm::SUPPORTED_PROVIDERS.to_vec(),
274 supports_base_url: true,
275 server_key_configured,
276 max_concurrency: llm_cfg.map(|c| c.max_concurrency).unwrap_or(0),
277 require_byok_header: llm_cfg.and_then(|c| c.require_byok_header.clone()),
278 },
279 formats: FormatCapabilities {
280 supported: formats,
281 llm_required: LLM_REQUIRED_FORMATS.to_vec(),
282 change_tracking_modes: CHANGE_TRACKING_MODES.to_vec(),
283 change_tracking_modes_llm_required: LLM_REQUIRED_CHANGE_TRACKING_MODES.to_vec(),
284 },
285 search: SearchCapabilities {
286 supported: search_supported,
287 answer: search_llm_ready,
288 summarize_results: search_llm_ready,
289 },
290 screenshot: ScreenshotCapabilities {
291 supported: screenshot_supported,
292 full_page: screenshot_supported,
293 },
294 renderers: RendererCapabilities {
295 available: state
296 .renderer
297 .js_renderer_names()
298 .into_iter()
299 .map(String::from)
300 .collect(),
301 mode: state.config.renderer.mode,
302 render_js_default: state.config.renderer.render_js_default,
303 },
304 extract: ExtractCapabilities {
305 supported: llm_ready_without_caller_key,
306 max_urls: state.config.crawler.max_extract_urls,
307 // True from the build that shipped `basis`. Scoped exactly like
308 // `supported` above: it reports what this binary implements, not
309 // whether an LLM happens to be configured (extraction of any kind
310 // needs one, and reports that per request).
311 per_field_attribution: true,
312 // The cap the basis leg is actually bounded by. When no extraction
313 // LLM is configured basis cannot run, but the effective default is
314 // still reported so a consumer never reads a 0. Matches
315 // `config::default_llm_max_tokens()`.
316 max_output_tokens: state
317 .config
318 .extraction
319 .llm
320 .as_ref()
321 .map_or(4096, |c| c.max_tokens),
322 },
323 documents: DocumentCapabilities {
324 parsers: if pdf_on { vec!["pdf"] } else { vec![] },
325 file_upload: FileUploadCapabilities {
326 supported: upload_on,
327 endpoint: "/v2/parse",
328 max_bytes: max_upload_bytes,
329 types: if upload_on {
330 vec!["application/pdf"]
331 } else {
332 vec![]
333 },
334 ocr: false,
335 },
336 },
337 limits: Limits {
338 max_batch_urls: state.config.crawler.max_batch_urls,
339 max_extract_urls: state.config.crawler.max_extract_urls,
340 search_default_limit: state.config.search.default_limit,
341 search_max_limit: state.config.search.max_limit,
342 max_upload_bytes,
343 },
344 })
345}