1use std::time::Duration;
37
38use crate::serde_json as crate_json;
39use crate::serde_json::Value as JsonValue;
40
41use crate::runtime::ask_pipeline::{extract_tokens as heuristic_extract_tokens, TokenSet};
42use crate::runtime::statement_frame::EffectiveScope;
43
44pub const DEFAULT_MAX_TOKENS: usize = 32;
47
48pub const DEFAULT_TIMEOUT_MS: u32 = 5_000;
51
52pub const NER_CAPABILITY: &str = "ai:ner:read";
54
55#[derive(Debug, Clone)]
60pub enum NerProvider {
61 OpenAiCompat { endpoint: String, model: String },
65 AnthropicNative { endpoint: String, model: String },
67 Stub(StubBehavior),
70}
71
72#[derive(Debug, Clone)]
74pub enum StubBehavior {
75 Empty,
77 Echo,
80 Canned(TokenSet),
83 SlowDuration(Duration),
86 RawJson(String),
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum HeuristicFallback {
96 UseHeuristic,
100 EmptyOnFail,
104 Propagate,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum NerError {
112 NetworkTimeout,
114 ProviderRejected { status: u16, body_excerpt: String },
117 ResponseMalformed { reason: String },
119 ResponseExceedsTokenLimit { count: usize, max: usize },
121 SecretInResponse { pattern: String },
125 AuthDenied,
127}
128
129impl std::fmt::Display for NerError {
130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 match self {
132 NerError::NetworkTimeout => write!(f, "ner: network timeout"),
133 NerError::ProviderRejected { status, .. } => {
134 write!(f, "ner: provider rejected (status={status})")
135 }
136 NerError::ResponseMalformed { reason } => {
137 write!(f, "ner: malformed response ({reason})")
138 }
139 NerError::ResponseExceedsTokenLimit { count, max } => {
140 write!(f, "ner: response exceeds token limit ({count} > {max})")
141 }
142 NerError::SecretInResponse { pattern } => {
143 write!(f, "ner: secret pattern in response ({pattern})")
144 }
145 NerError::AuthDenied => write!(f, "ner: auth denied (missing {NER_CAPABILITY})"),
146 }
147 }
148}
149
150impl std::error::Error for NerError {}
151
152pub trait AuthContext: std::fmt::Debug + Send + Sync {
159 fn has_capability(&self, capability: &str) -> bool;
160}
161
162#[derive(Debug, Clone, Default)]
166pub struct StubAuthContext {
167 capabilities: Vec<String>,
168}
169
170impl StubAuthContext {
171 pub fn new(caps: impl IntoIterator<Item = impl Into<String>>) -> Self {
172 Self {
173 capabilities: caps.into_iter().map(Into::into).collect(),
174 }
175 }
176
177 pub fn allow_all() -> Self {
178 Self::new([NER_CAPABILITY])
179 }
180
181 pub fn deny_all() -> Self {
182 Self::default()
183 }
184}
185
186impl AuthContext for StubAuthContext {
187 fn has_capability(&self, capability: &str) -> bool {
188 self.capabilities.iter().any(|c| c == capability)
189 }
190}
191
192#[derive(Debug, Clone)]
196pub struct LlmNer {
197 pub provider: NerProvider,
198 pub fallback: HeuristicFallback,
199 pub timeout_ms: u32,
200 pub max_tokens_returned: usize,
201}
202
203impl LlmNer {
204 pub fn new(provider: NerProvider, fallback: HeuristicFallback) -> Self {
206 Self {
207 provider,
208 fallback,
209 timeout_ms: DEFAULT_TIMEOUT_MS,
210 max_tokens_returned: DEFAULT_MAX_TOKENS,
211 }
212 }
213
214 pub async fn extract(
222 &self,
223 question: &str,
224 scope: &EffectiveScope,
225 auth: &dyn AuthContext,
226 ) -> Result<TokenSet, NerError> {
227 if !auth.has_capability(NER_CAPABILITY) {
231 return Err(NerError::AuthDenied);
232 }
233
234 let result = match &self.provider {
239 NerProvider::Stub(behavior) => self.run_stub(behavior, question),
240 NerProvider::OpenAiCompat { endpoint, model } => {
241 self.run_openai_compat(endpoint, model, question, scope)
242 .await
243 }
244 NerProvider::AnthropicNative { endpoint, model } => {
245 self.run_anthropic(endpoint, model, question, scope).await
246 }
247 };
248
249 match result {
250 Ok(tokens) => Ok(tokens),
251 Err(err) => self.handle_failure(err, question),
252 }
253 }
254
255 fn handle_failure(&self, err: NerError, question: &str) -> Result<TokenSet, NerError> {
257 if matches!(err, NerError::AuthDenied) {
259 return Err(err);
260 }
261 match self.fallback {
262 HeuristicFallback::UseHeuristic => Ok(heuristic_extract_tokens(question)),
263 HeuristicFallback::EmptyOnFail => Ok(TokenSet::default()),
264 HeuristicFallback::Propagate => Err(err),
265 }
266 }
267
268 fn run_stub(&self, behavior: &StubBehavior, question: &str) -> Result<TokenSet, NerError> {
270 match behavior {
271 StubBehavior::Empty => Ok(TokenSet::default()),
272 StubBehavior::Echo => {
273 let trimmed = question.trim().to_lowercase();
274 if trimmed.is_empty() {
275 Ok(TokenSet::default())
276 } else {
277 Ok(TokenSet {
278 keywords: vec![trimmed],
279 literals: vec![],
280 })
281 }
282 }
283 StubBehavior::Canned(tokens) => Ok(tokens.clone()),
284 StubBehavior::SlowDuration(d) => {
285 if d.as_millis() as u32 > self.timeout_ms {
289 Err(NerError::NetworkTimeout)
290 } else {
291 Ok(TokenSet::default())
292 }
293 }
294 StubBehavior::RawJson(raw) => parse_and_sanitize(raw, self.max_tokens_returned),
295 }
296 }
297
298 #[cfg(feature = "ai-ner-network")]
309 async fn run_openai_compat(
310 &self,
311 endpoint: &str,
312 model: &str,
313 question: &str,
314 scope: &EffectiveScope,
315 ) -> Result<TokenSet, NerError> {
316 let body = crate::json!({
317 "model": model,
318 "response_format": crate::json!({ "type": "json_object" }),
319 "messages": vec![
320 crate::json!({ "role": "system", "content": NER_SYSTEM_PROMPT }),
321 crate::json!({ "role": "user", "content": build_prompt(question, scope) }),
322 ],
323 });
324 let raw = http_post_json(endpoint, &body, self.timeout_ms).await?;
325 let payload = extract_openai_payload(&raw)?;
326 parse_and_sanitize(&payload, self.max_tokens_returned)
327 }
328
329 #[cfg(not(feature = "ai-ner-network"))]
330 async fn run_openai_compat(
331 &self,
332 _endpoint: &str,
333 _model: &str,
334 _question: &str,
335 _scope: &EffectiveScope,
336 ) -> Result<TokenSet, NerError> {
337 Err(NerError::NetworkTimeout)
340 }
341
342 #[cfg(feature = "ai-ner-network")]
343 async fn run_anthropic(
344 &self,
345 endpoint: &str,
346 model: &str,
347 question: &str,
348 scope: &EffectiveScope,
349 ) -> Result<TokenSet, NerError> {
350 let body = crate::json!({
351 "model": model,
352 "max_tokens": 1024,
353 "system": NER_SYSTEM_PROMPT,
354 "messages": vec![
355 crate::json!({ "role": "user", "content": build_prompt(question, scope) }),
356 ],
357 });
358 let raw = http_post_json(endpoint, &body, self.timeout_ms).await?;
359 let payload = extract_anthropic_payload(&raw)?;
360 parse_and_sanitize(&payload, self.max_tokens_returned)
361 }
362
363 #[cfg(not(feature = "ai-ner-network"))]
364 async fn run_anthropic(
365 &self,
366 _endpoint: &str,
367 _model: &str,
368 _question: &str,
369 _scope: &EffectiveScope,
370 ) -> Result<TokenSet, NerError> {
371 Err(NerError::NetworkTimeout)
372 }
373}
374
375const NER_SYSTEM_PROMPT: &str = "\
379You are an entity extraction service for a database query pipeline. \
380Read the user's question and return a JSON object with two fields: \
381'keywords' (array of lowercase content words, length >= 2) and \
382'literals' (array of identifier-shaped tokens kept in original case). \
383Return JSON only — no prose, no markdown.";
384
385#[allow(dead_code)] fn build_prompt(question: &str, scope: &EffectiveScope) -> String {
389 use crate::runtime::statement_frame::ReadFrame;
390 let visible: Vec<&str> = scope
391 .visible_collections()
392 .map(|set| set.iter().map(String::as_str).collect())
393 .unwrap_or_default();
394 format!(
395 "Question: {q}\nVisible collections: {v:?}\nReturn JSON only.",
396 q = question,
397 v = visible
398 )
399}
400
401#[cfg(feature = "ai-ner-network")]
402async fn http_post_json(
403 endpoint: &str,
404 body: &crate_json::Value,
405 timeout_ms: u32,
406) -> Result<String, NerError> {
407 let client = reqwest::Client::builder()
408 .timeout(Duration::from_millis(timeout_ms as u64))
409 .build()
410 .map_err(|e| NerError::ResponseMalformed {
411 reason: format!("client build: {e}"),
412 })?;
413 let resp = client
414 .post(endpoint)
415 .header("content-type", "application/json")
416 .body(body.to_string_compact())
417 .send()
418 .await
419 .map_err(|e| {
420 if e.is_timeout() {
421 NerError::NetworkTimeout
422 } else {
423 NerError::ResponseMalformed {
424 reason: format!("transport: {e}"),
425 }
426 }
427 })?;
428 let status = resp.status().as_u16();
429 let text = resp.text().await.map_err(|e| NerError::ResponseMalformed {
430 reason: format!("body read: {e}"),
431 })?;
432 if !(200..300).contains(&status) {
433 return Err(NerError::ProviderRejected {
434 status,
435 body_excerpt: scrub_excerpt(&text),
436 });
437 }
438 Ok(text)
439}
440
441#[cfg(feature = "ai-ner-network")]
442fn extract_openai_payload(raw: &str) -> Result<String, NerError> {
443 let v: JsonValue = crate_json::from_str(raw).map_err(|e| NerError::ResponseMalformed {
444 reason: format!("outer json: {e}"),
445 })?;
446 v["choices"]
447 .as_array()
448 .and_then(|choices| choices.first())
449 .and_then(|choice| choice["message"]["content"].as_str())
450 .map(str::to_owned)
451 .ok_or_else(|| NerError::ResponseMalformed {
452 reason: "missing choices[0].message.content".into(),
453 })
454}
455
456#[cfg(feature = "ai-ner-network")]
457fn extract_anthropic_payload(raw: &str) -> Result<String, NerError> {
458 let v: JsonValue = crate_json::from_str(raw).map_err(|e| NerError::ResponseMalformed {
459 reason: format!("outer json: {e}"),
460 })?;
461 v["content"]
462 .as_array()
463 .and_then(|content| content.first())
464 .and_then(|item| item["text"].as_str())
465 .map(str::to_owned)
466 .ok_or_else(|| NerError::ResponseMalformed {
467 reason: "missing content[0].text".into(),
468 })
469}
470
471#[allow(dead_code)] fn scrub_excerpt(s: &str) -> String {
473 let trimmed: String = s
474 .chars()
475 .take(256)
476 .filter(|c| !c.is_control() || *c == ' ')
477 .collect();
478 trimmed
479}
480
481fn parse_and_sanitize(raw: &str, max_tokens: usize) -> Result<TokenSet, NerError> {
487 let parsed: JsonValue = crate_json::from_str(raw).map_err(|e| NerError::ResponseMalformed {
488 reason: format!("json parse: {e}"),
489 })?;
490 let obj = parsed
491 .as_object()
492 .ok_or_else(|| NerError::ResponseMalformed {
493 reason: "expected JSON object at root".into(),
494 })?;
495
496 let keywords = collect_string_array(obj.get("keywords"), "keywords")?;
497 let literals = collect_string_array(obj.get("literals"), "literals")?;
498
499 let total = keywords.len() + literals.len();
500 if total > max_tokens {
501 return Err(NerError::ResponseExceedsTokenLimit {
502 count: total,
503 max: max_tokens,
504 });
505 }
506
507 for token in keywords.iter().chain(literals.iter()) {
508 validate_token(token)?;
509 }
510
511 Ok(TokenSet { keywords, literals })
512}
513
514fn collect_string_array(v: Option<&JsonValue>, field: &str) -> Result<Vec<String>, NerError> {
517 let arr = match v {
518 Some(JsonValue::Array(a)) => a,
519 Some(JsonValue::Null) | None => return Ok(Vec::new()),
520 Some(other) => {
521 return Err(NerError::ResponseMalformed {
522 reason: format!("{field}: expected array, got {}", json_kind(other)),
523 });
524 }
525 };
526 let mut out = Vec::with_capacity(arr.len());
527 for (i, item) in arr.iter().enumerate() {
528 match item {
529 JsonValue::String(s) => out.push(s.clone()),
530 other => {
531 return Err(NerError::ResponseMalformed {
532 reason: format!("{field}[{i}]: expected string, got {}", json_kind(other)),
533 });
534 }
535 }
536 }
537 Ok(out)
538}
539
540fn json_kind(v: &JsonValue) -> &'static str {
541 match v {
542 JsonValue::Null => "null",
543 JsonValue::Bool(_) => "bool",
544 JsonValue::Integer(_) => "number",
545 JsonValue::Number(_) => "number",
546 JsonValue::Decimal(_) => "number",
547 JsonValue::String(_) => "string",
548 JsonValue::Array(_) => "array",
549 JsonValue::Object(_) => "object",
550 }
551}
552
553fn validate_token(token: &str) -> Result<(), NerError> {
557 if let Some(pattern) = match_secret_pattern(token) {
558 return Err(NerError::SecretInResponse {
559 pattern: pattern.into(),
560 });
561 }
562 if token.is_empty() {
563 return Err(NerError::ResponseMalformed {
564 reason: "empty token".into(),
565 });
566 }
567 if token.len() > 256 {
568 return Err(NerError::ResponseMalformed {
569 reason: format!("token too long ({} bytes)", token.len()),
570 });
571 }
572 for (i, byte) in token.as_bytes().iter().enumerate() {
573 match byte {
574 0x00 => {
576 return Err(NerError::ResponseMalformed {
577 reason: format!("NUL byte at offset {i}"),
578 });
579 }
580 b'\n' | b'\r' => {
581 return Err(NerError::ResponseMalformed {
582 reason: format!("CR/LF at offset {i}"),
583 });
584 }
585 b'"' | b'\'' | b'`' => {
587 return Err(NerError::ResponseMalformed {
588 reason: format!("quote injection at offset {i}"),
589 });
590 }
591 b if *b < 0x20 && *b != b'\t' => {
593 return Err(NerError::ResponseMalformed {
594 reason: format!("control byte 0x{b:02x} at offset {i}"),
595 });
596 }
597 _ => {}
598 }
599 }
600 Ok(())
601}
602
603fn match_secret_pattern(token: &str) -> Option<&'static str> {
608 const PATTERNS: &[(&str, &str)] = &[
611 ("sk_", "sk_prefix"),
612 ("rs_", "rs_prefix"),
613 ("reddb_", "reddb_prefix"),
614 ("Bearer ", "bearer"),
615 ("bearer ", "bearer"),
616 ];
617 for (prefix, label) in PATTERNS {
618 if token.starts_with(prefix) {
619 return Some(label);
620 }
621 }
622 if looks_like_jwt(token) {
627 return Some("jwt");
628 }
629 if token.contains("://") && token.contains(':') && token.contains('@') {
631 if let Some(scheme_end) = token.find("://") {
632 let rest = &token[scheme_end + 3..];
633 if let Some(at) = rest.find('@') {
634 let userpass = &rest[..at];
635 if userpass.contains(':') {
636 return Some("conn_string_credentials");
637 }
638 }
639 }
640 }
641 None
642}
643
644fn looks_like_jwt(token: &str) -> bool {
645 let parts: Vec<&str> = token.split('.').collect();
646 if parts.len() != 3 {
647 return false;
648 }
649 parts.iter().all(|p| {
650 p.len() >= 4
651 && p.bytes()
652 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
653 })
654}
655
656#[cfg(test)]
661mod tests {
662 use super::*;
663 use crate::runtime::ask_pipeline::TokenSet;
664
665 fn make_scope() -> EffectiveScope {
668 use crate::storage::transaction::snapshot::Snapshot;
672 use std::collections::HashSet;
673 EffectiveScope {
674 captured_snapshot: None,
675 tenant: None,
676 identity: None,
677 auth_principal: None,
678 snapshot: Snapshot {
679 xid: 0,
680 in_progress: HashSet::new(),
681 },
682 visible_collections: None,
683 }
684 }
685
686 fn allow() -> StubAuthContext {
687 StubAuthContext::allow_all()
688 }
689
690 fn deny() -> StubAuthContext {
691 StubAuthContext::deny_all()
692 }
693
694 #[tokio::test]
697 async fn stub_empty_returns_empty_token_set() {
698 let ner = LlmNer::new(
699 NerProvider::Stub(StubBehavior::Empty),
700 HeuristicFallback::Propagate,
701 );
702 let out = ner
703 .extract("anything", &make_scope(), &allow())
704 .await
705 .unwrap();
706 assert!(out.is_empty());
707 }
708
709 #[tokio::test]
710 async fn stub_echo_returns_lowercased_keyword() {
711 let ner = LlmNer::new(
712 NerProvider::Stub(StubBehavior::Echo),
713 HeuristicFallback::Propagate,
714 );
715 let out = ner
716 .extract(" Hello WORLD ", &make_scope(), &allow())
717 .await
718 .unwrap();
719 assert_eq!(out.keywords, vec!["hello world".to_string()]);
720 assert!(out.literals.is_empty());
721 }
722
723 #[tokio::test]
724 async fn stub_echo_empty_question_yields_empty_set() {
725 let ner = LlmNer::new(
726 NerProvider::Stub(StubBehavior::Echo),
727 HeuristicFallback::Propagate,
728 );
729 let out = ner.extract(" ", &make_scope(), &allow()).await.unwrap();
730 assert!(out.is_empty());
731 }
732
733 #[tokio::test]
734 async fn stub_canned_returns_provided_tokens() {
735 let canned = TokenSet {
736 keywords: vec!["passport".into()],
737 literals: vec!["FDD-1".into()],
738 };
739 let ner = LlmNer::new(
740 NerProvider::Stub(StubBehavior::Canned(canned.clone())),
741 HeuristicFallback::Propagate,
742 );
743 let out = ner.extract("q?", &make_scope(), &allow()).await.unwrap();
744 assert_eq!(out, canned);
745 }
746
747 #[tokio::test]
750 async fn slow_stub_within_budget_succeeds() {
751 let mut ner = LlmNer::new(
752 NerProvider::Stub(StubBehavior::SlowDuration(Duration::from_millis(10))),
753 HeuristicFallback::Propagate,
754 );
755 ner.timeout_ms = 100;
756 assert!(ner.extract("q?", &make_scope(), &allow()).await.is_ok());
757 }
758
759 #[tokio::test]
760 async fn slow_stub_over_budget_times_out_and_propagates() {
761 let mut ner = LlmNer::new(
762 NerProvider::Stub(StubBehavior::SlowDuration(Duration::from_millis(500))),
763 HeuristicFallback::Propagate,
764 );
765 ner.timeout_ms = 50;
766 let err = ner
767 .extract("q?", &make_scope(), &allow())
768 .await
769 .unwrap_err();
770 assert_eq!(err, NerError::NetworkTimeout);
771 }
772
773 #[tokio::test]
776 async fn malformed_not_json_is_rejected() {
777 let ner = LlmNer::new(
778 NerProvider::Stub(StubBehavior::RawJson("not-json".into())),
779 HeuristicFallback::Propagate,
780 );
781 let err = ner
782 .extract("q?", &make_scope(), &allow())
783 .await
784 .unwrap_err();
785 assert!(matches!(err, NerError::ResponseMalformed { .. }));
786 }
787
788 #[tokio::test]
789 async fn malformed_wrong_root_type_is_rejected() {
790 let ner = LlmNer::new(
791 NerProvider::Stub(StubBehavior::RawJson("[1,2,3]".into())),
792 HeuristicFallback::Propagate,
793 );
794 let err = ner
795 .extract("q?", &make_scope(), &allow())
796 .await
797 .unwrap_err();
798 assert!(matches!(err, NerError::ResponseMalformed { .. }));
799 }
800
801 #[tokio::test]
802 async fn malformed_keywords_not_array_is_rejected() {
803 let ner = LlmNer::new(
804 NerProvider::Stub(StubBehavior::RawJson(r#"{"keywords":"oops"}"#.into())),
805 HeuristicFallback::Propagate,
806 );
807 let err = ner
808 .extract("q?", &make_scope(), &allow())
809 .await
810 .unwrap_err();
811 assert!(matches!(err, NerError::ResponseMalformed { .. }));
812 }
813
814 fn adversarial_corpus() -> Vec<(&'static str, String)> {
820 let sk_prefix = format!("{}{}", "sk_", "live_DEADBEEFcafe");
822 let rs_prefix = format!("{}{}", "rs_", "test_TOKENtoken");
823 let reddb_prefix = format!("{}{}", "reddb_", "internal_secret_X");
824 let bearer = format!("{}{}", "Bearer ", "ABC.DEF.GHI");
825 let jwt = format!("{}.{}.{}", "abcd1234", "wxyz5678", "qrst9012");
826 let conn = "postgres://user:pwd@host:5432/db".to_string();
827
828 vec![
829 (
830 "crlf_in_keyword",
831 "{\"keywords\":[\"foo\\r\\nbar\"]}".into(),
832 ),
833 (
834 "nul_in_literal",
835 "{\"literals\":[\"foo\\u0000bar\"]}".into(),
836 ),
837 ("dquote_injection", "{\"keywords\":[\"foo\\\"bar\"]}".into()),
838 ("squote_injection", "{\"keywords\":[\"foo'bar\"]}".into()),
839 ("backtick_injection", "{\"keywords\":[\"foo`bar\"]}".into()),
840 (
841 "control_byte_low",
842 "{\"keywords\":[\"foo\\u0007bar\"]}".into(),
843 ),
844 ("sk_live", format!(r#"{{"keywords":["{sk_prefix}"]}}"#)),
845 ("rs_test", format!(r#"{{"keywords":["{rs_prefix}"]}}"#)),
846 (
847 "reddb_internal",
848 format!(r#"{{"literals":["{reddb_prefix}"]}}"#),
849 ),
850 ("bearer_token", format!(r#"{{"keywords":["{bearer}"]}}"#)),
851 ("jwt_shape", format!(r#"{{"literals":["{jwt}"]}}"#)),
852 ("conn_string", format!(r#"{{"keywords":["{conn}"]}}"#)),
853 ]
854 }
855
856 #[tokio::test]
857 async fn adversarial_corpus_is_fully_rejected() {
858 let corpus = adversarial_corpus();
859 assert!(corpus.len() >= 10, "corpus must be ≥10 payloads");
860 for (label, raw) in corpus {
861 let ner = LlmNer::new(
862 NerProvider::Stub(StubBehavior::RawJson(raw)),
863 HeuristicFallback::Propagate,
864 );
865 let err = ner
866 .extract("q?", &make_scope(), &allow())
867 .await
868 .expect_err(&format!("payload {label} should have been rejected"));
869 assert!(
870 matches!(
871 err,
872 NerError::ResponseMalformed { .. } | NerError::SecretInResponse { .. }
873 ),
874 "payload {label}: unexpected error variant {err:?}"
875 );
876 }
877 }
878
879 #[tokio::test]
880 async fn secret_in_response_reports_pattern_label() {
881 let raw = format!(r#"{{"keywords":["{}{}"]}}"#, "sk_", "live_zzzz");
882 let ner = LlmNer::new(
883 NerProvider::Stub(StubBehavior::RawJson(raw)),
884 HeuristicFallback::Propagate,
885 );
886 match ner
887 .extract("q?", &make_scope(), &allow())
888 .await
889 .unwrap_err()
890 {
891 NerError::SecretInResponse { pattern } => assert_eq!(pattern, "sk_prefix"),
892 other => panic!("expected SecretInResponse, got {other:?}"),
893 }
894 }
895
896 #[tokio::test]
899 async fn token_cap_excess_is_rejected() {
900 let kws: Vec<String> = (0..33).map(|i| format!("kw{i}")).collect();
902 let raw = crate_json::json!({ "keywords": kws }).to_string();
903 let ner = LlmNer::new(
904 NerProvider::Stub(StubBehavior::RawJson(raw)),
905 HeuristicFallback::Propagate,
906 );
907 let err = ner
908 .extract("q?", &make_scope(), &allow())
909 .await
910 .unwrap_err();
911 match err {
912 NerError::ResponseExceedsTokenLimit { count, max } => {
913 assert_eq!(count, 33);
914 assert_eq!(max, DEFAULT_MAX_TOKENS);
915 }
916 other => panic!("expected ResponseExceedsTokenLimit, got {other:?}"),
917 }
918 }
919
920 #[tokio::test]
921 async fn token_cap_at_limit_succeeds() {
922 let kws: Vec<String> = (0..DEFAULT_MAX_TOKENS).map(|i| format!("kw{i}")).collect();
923 let raw = crate_json::json!({ "keywords": kws }).to_string();
924 let ner = LlmNer::new(
925 NerProvider::Stub(StubBehavior::RawJson(raw)),
926 HeuristicFallback::Propagate,
927 );
928 let out = ner.extract("q?", &make_scope(), &allow()).await.unwrap();
929 assert_eq!(out.keywords.len(), DEFAULT_MAX_TOKENS);
930 }
931
932 #[tokio::test]
935 async fn auth_gate_denies_without_capability() {
936 let ner = LlmNer::new(
937 NerProvider::Stub(StubBehavior::Empty),
938 HeuristicFallback::UseHeuristic,
939 );
940 let err = ner.extract("q?", &make_scope(), &deny()).await.unwrap_err();
941 assert_eq!(err, NerError::AuthDenied);
942 }
943
944 #[tokio::test]
945 async fn auth_gate_denial_does_not_fall_back() {
946 let ner = LlmNer::new(
949 NerProvider::Stub(StubBehavior::Empty),
950 HeuristicFallback::UseHeuristic,
951 );
952 let err = ner
953 .extract("FDD-1", &make_scope(), &deny())
954 .await
955 .unwrap_err();
956 assert_eq!(err, NerError::AuthDenied);
957 }
958
959 #[tokio::test]
962 async fn fallback_use_heuristic_runs_extract_tokens() {
963 let ner = LlmNer::new(
965 NerProvider::Stub(StubBehavior::RawJson("not-json".into())),
966 HeuristicFallback::UseHeuristic,
967 );
968 let out = ner
969 .extract("show order 987654321 details", &make_scope(), &allow())
970 .await
971 .unwrap();
972 assert!(out.literals.iter().any(|l| l == "987654321"));
974 }
975
976 #[tokio::test]
977 async fn fallback_empty_on_fail_returns_empty() {
978 let ner = LlmNer::new(
979 NerProvider::Stub(StubBehavior::RawJson("not-json".into())),
980 HeuristicFallback::EmptyOnFail,
981 );
982 let out = ner
983 .extract("show order 987654321 details", &make_scope(), &allow())
984 .await
985 .unwrap();
986 assert!(out.is_empty());
987 }
988
989 #[tokio::test]
990 async fn fallback_propagate_returns_error() {
991 let ner = LlmNer::new(
992 NerProvider::Stub(StubBehavior::RawJson("not-json".into())),
993 HeuristicFallback::Propagate,
994 );
995 let err = ner
996 .extract("show order 987654321 details", &make_scope(), &allow())
997 .await
998 .unwrap_err();
999 assert!(matches!(err, NerError::ResponseMalformed { .. }));
1000 }
1001
1002 #[test]
1005 fn jwt_detector_matches_three_segments() {
1006 assert!(looks_like_jwt("abcd.efgh.ijkl"));
1007 assert!(!looks_like_jwt("abcd.efgh"));
1008 assert!(!looks_like_jwt("abc.def.ghi.jkl"));
1009 assert!(!looks_like_jwt("ab.cd.ef")); }
1011
1012 #[test]
1013 fn scrub_excerpt_drops_control_bytes() {
1014 let s = format!("ok\x07bad\nstill");
1015 let cleaned = scrub_excerpt(&s);
1016 assert!(!cleaned.contains('\x07'));
1017 assert!(!cleaned.contains('\n'));
1018 }
1019
1020 #[test]
1021 fn validate_token_accepts_normal_strings() {
1022 assert!(validate_token("passport").is_ok());
1023 assert!(validate_token("FDD-12313").is_ok());
1024 assert!(validate_token("foo_bar.baz").is_ok());
1025 }
1026}