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
8use axum::Json;
9use axum::extract::State;
10use serde::Serialize;
11
12use crate::state::AppState;
13
14#[derive(Debug, Serialize)]
15#[serde(rename_all = "camelCase")]
16pub struct Capabilities {
17    pub version: &'static str,
18    pub llm: LlmCapabilities,
19    pub formats: FormatCapabilities,
20    pub search: SearchCapabilities,
21    pub extract: ExtractCapabilities,
22    pub documents: DocumentCapabilities,
23}
24
25#[derive(Debug, Serialize)]
26#[serde(rename_all = "camelCase")]
27pub struct ExtractCapabilities {
28    /// Native `POST /v1/extract` (async multi-URL structured extraction) is live.
29    pub supported: bool,
30    /// Max URLs accepted per request (`crawler.max_extract_urls`).
31    pub max_urls: usize,
32    /// Per-field `basis` attribution (Phase 2b). False until 2b ships.
33    pub per_field_attribution: bool,
34}
35
36#[derive(Debug, Serialize)]
37#[serde(rename_all = "camelCase")]
38pub struct DocumentCapabilities {
39    /// Document parser types this instance can apply. `["pdf"]` when PDF
40    /// support is compiled in and enabled; empty otherwise. The SaaS gates the
41    /// `parsers` option and the upload UI on this.
42    pub parsers: Vec<&'static str>,
43    /// File-upload (`POST /v2/parse`) availability + limits.
44    pub file_upload: FileUploadCapabilities,
45}
46
47#[derive(Debug, Serialize)]
48#[serde(rename_all = "camelCase")]
49pub struct FileUploadCapabilities {
50    pub supported: bool,
51    pub endpoint: &'static str,
52    pub max_bytes: usize,
53    pub types: Vec<&'static str>,
54    /// pdf-inspector has no OCR — scanned/image PDFs yield empty/partial text.
55    pub ocr: bool,
56}
57
58#[derive(Debug, Serialize)]
59#[serde(rename_all = "camelCase")]
60pub struct LlmCapabilities {
61    /// Provider tags the server's dispatch knows about.
62    pub providers: Vec<&'static str>,
63    pub supports_base_url: bool,
64    /// True when a server-wide LLM key is configured (self-hosted /
65    /// no-SaaS deploys). SaaS-fronted deploys set
66    /// `CRW_DISABLE_SERVER_LLM_KEY=1` and rely on per-request BYOK.
67    pub server_key_configured: bool,
68    /// Configured server-side fan-out cap for LLM calls. 0 when no
69    /// server-side LLM config is present.
70    pub max_concurrency: usize,
71    /// Header name the server will look for on LLM-touching requests
72    /// (`None` means no header guard).
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub require_byok_header: Option<String>,
75}
76
77#[derive(Debug, Serialize)]
78#[serde(rename_all = "camelCase")]
79pub struct FormatCapabilities {
80    pub supported: Vec<&'static str>,
81    /// Change-tracking diff modes this instance supports. Empty when the
82    /// `changeTracking` format is unavailable. The SaaS capability-gate checks
83    /// `supported` contains `"changeTracking"` before emitting monitor scrapes.
84    pub change_tracking_modes: Vec<&'static str>,
85}
86
87#[derive(Debug, Serialize)]
88#[serde(rename_all = "camelCase")]
89pub struct SearchCapabilities {
90    pub answer: bool,
91    pub summarize_results: bool,
92}
93
94pub async fn capabilities(State(state): State<AppState>) -> Json<Capabilities> {
95    let llm_cfg = state.config.extraction.llm.as_ref();
96    Json(Capabilities {
97        version: env!("CARGO_PKG_VERSION"),
98        llm: LlmCapabilities {
99            providers: vec![
100                "anthropic",
101                "openai",
102                "deepseek",
103                "openai-compatible",
104                "azure",
105            ],
106            supports_base_url: true,
107            server_key_configured: llm_cfg.map(|c| !c.api_key.is_empty()).unwrap_or(false),
108            max_concurrency: llm_cfg.map(|c| c.max_concurrency).unwrap_or(0),
109            require_byok_header: llm_cfg.and_then(|c| c.require_byok_header.clone()),
110        },
111        formats: FormatCapabilities {
112            supported: vec![
113                "markdown",
114                "html",
115                "rawHtml",
116                "plainText",
117                "links",
118                "json",
119                "summary",
120                "changeTracking",
121            ],
122            change_tracking_modes: vec!["gitDiff", "json"],
123        },
124        search: SearchCapabilities {
125            answer: true,
126            summarize_results: true,
127        },
128        extract: ExtractCapabilities {
129            supported: true,
130            max_urls: state.config.crawler.max_extract_urls,
131            per_field_attribution: false,
132        },
133        documents: {
134            let pdf_on = crw_extract::pdf::PDF_SUPPORTED && state.config.document.enabled;
135            DocumentCapabilities {
136                parsers: if pdf_on { vec!["pdf"] } else { vec![] },
137                file_upload: FileUploadCapabilities {
138                    supported: pdf_on,
139                    endpoint: "/v2/parse",
140                    max_bytes: state.config.document.max_upload_bytes,
141                    types: if pdf_on {
142                        vec!["application/pdf"]
143                    } else {
144                        vec![]
145                    },
146                    ocr: false,
147                },
148            }
149        },
150    })
151}