Skip to main content

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}