Skip to content

Commit 65946a1

Browse files
authored
Merge pull request #7163 from senamakel/search-auth-and-limit
fix(search): start re-auth on a rejected session and name rejected search keys
2 parents 26a6829 + 6a331ff commit 65946a1

3 files changed

Lines changed: 150 additions & 7 deletions

File tree

‎crates/openhuman-core/src/search/tools.rs‎

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,18 +137,67 @@ pub fn user_facing_error(error: &str) -> String {
137137
"Web search is rate limited right now. Wait a moment and try again.".to_string()
138138
}
139139
Some(code) if code == errors::UNAVAILABLE => SEARCH_EXHAUSTED_MESSAGE.to_string(),
140+
Some(code) if code == errors::PROVIDER_UNAUTHORIZED => {
141+
// The detail names the provider and its HTTP status; the module
142+
// never puts the key or the query in it.
143+
format!(
144+
"Web search is unavailable: {}. Update the key under Connections → Search, or \
145+
remove it to use managed search.",
146+
code_detail(error, code)
147+
)
148+
}
149+
Some(code) if code == errors::BACKEND_UNAUTHORIZED => backend_unauthorized_message(false),
140150
Some(code) if code == errors::INVALID_ARGUMENTS => {
141-
let marker = format!("{}{code}: ", errors::PREFIX);
142-
let detail = error
143-
.split_once(marker.as_str())
144-
.map(|(_, detail)| detail)
145-
.unwrap_or(error);
146-
format!("The search request was rejected: {detail}")
151+
format!(
152+
"The search request was rejected: {}",
153+
code_detail(error, code)
154+
)
147155
}
148156
_ => format!("Web search failed: {error}"),
149157
}
150158
}
151159

160+
/// The module's message after its `tinysearch.<code>: ` prefix.
161+
fn code_detail<'a>(error: &'a str, code: &str) -> &'a str {
162+
let marker = format!("{}{code}: ", errors::PREFIX);
163+
error
164+
.split_once(marker.as_str())
165+
.map(|(_, detail)| detail)
166+
.unwrap_or(error)
167+
}
168+
169+
/// What the model is told when the managed backend rejects the credential.
170+
pub(crate) fn backend_unauthorized_message(api_key: bool) -> String {
171+
if api_key {
172+
"Web search is unavailable: the TinyHumans backend rejected the configured API key. \
173+
Check the key, or add your own provider key under Connections → Search."
174+
.to_string()
175+
} else {
176+
"Web search is unavailable: the OpenHuman sign-in has expired. Sign in again to \
177+
continue searching."
178+
.to_string()
179+
}
180+
}
181+
182+
/// Recover from the managed backend rejecting the TinyHumans credential.
183+
///
184+
/// A rejected session is dead for every backend call, not just search, so
185+
/// publish `SessionExpired`: the credentials subscriber clears the token and
186+
/// the UI asks the user to sign in, the same recovery a backend 401 on
187+
/// inference or integrations drives. Before this, a user on their own
188+
/// inference key never saw that prompt and search kept failing on the stale
189+
/// token. A rejected API key has no session to expire, so it never signs the
190+
/// user out (`API_KEY_REJECTED` semantics).
191+
fn on_backend_unauthorized(tool: &str, api_key: bool) {
192+
if api_key || crate::cron::scheduler_gate::is_signed_out() {
193+
return;
194+
}
195+
crate::core::bus::BUS.publish(crate::core::events::DomainEvent::SessionExpired {
196+
source: format!("search.{tool}"),
197+
reason: "managed search backend rejected the session (401)".to_string(),
198+
});
199+
}
200+
152201
/// The classified error code in a module error message, if any.
153202
pub fn error_code(error: &str) -> Option<&'static str> {
154203
errors::code_of(error)
@@ -216,8 +265,10 @@ impl Tool for TinySearchTool {
216265
return Ok(ToolResult::failed(SEARCH_EXHAUSTED_MESSAGE.to_string()));
217266
}
218267
let subject = super::render::subject(&args);
268+
// `limit` is the module's alias for `max_results`.
219269
let max_results = args
220270
.get("max_results")
271+
.or_else(|| args.get("limit"))
221272
.and_then(Value::as_u64)
222273
.map(|n| n as usize)
223274
.unwrap_or(config.search.max_results)
@@ -254,6 +305,16 @@ impl Tool for TinySearchTool {
254305
if exhausts_providers(&error) {
255306
self.mark_exhausted_for(signature);
256307
}
308+
if error_code(&error) == Some(errors::BACKEND_UNAUTHORIZED) {
309+
let api_key = crate::security::credentials::api_key::has_api_key(&config);
310+
on_backend_unauthorized(&self.spec.name, api_key);
311+
tracing::warn!(
312+
tool = %self.spec.name,
313+
api_key,
314+
"[search][tool] managed backend rejected the TinyHumans credential"
315+
);
316+
return Ok(ToolResult::error(backend_unauthorized_message(api_key)));
317+
}
257318
tracing::warn!(
258319
tool = %self.spec.name,
259320
code = error_code(&error).unwrap_or("unclassified"),

‎crates/openhuman-core/src/search/tools_tests.rs‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,85 @@ async fn a_refused_call_does_not_reach_the_module() {
235235
result.text()
236236
);
237237
}
238+
239+
#[test]
240+
fn a_rejected_provider_key_names_the_provider_and_the_fix() {
241+
let message = user_facing_error(
242+
"search ExecuteTool failed: tinysearch.provider_unauthorized: tavily rejected the \
243+
configured API key (HTTP 401)",
244+
);
245+
assert_eq!(
246+
message,
247+
"Web search is unavailable: tavily rejected the configured API key (HTTP 401). Update \
248+
the key under Connections → Search, or remove it to use managed search."
249+
);
250+
assert!(!exhausts_providers(
251+
"tinysearch.provider_unauthorized: tavily rejected the configured API key (HTTP 401)"
252+
));
253+
}
254+
255+
#[test]
256+
fn a_rejected_backend_credential_asks_for_the_right_fix() {
257+
assert!(
258+
user_facing_error("tinysearch.backend_unauthorized: rejected")
259+
.contains("sign-in has expired")
260+
);
261+
assert!(backend_unauthorized_message(false).contains("Sign in again"));
262+
let key = backend_unauthorized_message(true);
263+
assert!(key.contains("rejected the configured API key"), "{key}");
264+
assert!(!key.contains("Sign in again"), "{key}");
265+
assert!(!exhausts_providers(
266+
"tinysearch.backend_unauthorized: rejected"
267+
));
268+
}
269+
270+
/// Drain `rx` for a `SessionExpired` whose source is `source`.
271+
fn session_expired_from(
272+
rx: &mut tinybus::EventReceiver<crate::core::events::DomainEvent>,
273+
source: &str,
274+
) -> bool {
275+
loop {
276+
match rx.try_recv() {
277+
Ok(crate::core::events::DomainEvent::SessionExpired { source: seen, .. })
278+
if seen == source =>
279+
{
280+
return true;
281+
}
282+
Ok(_) | Err(tinybus::TryRecvError::Lagged(_)) => continue,
283+
Err(_) => return false,
284+
}
285+
}
286+
}
287+
288+
/// A user on their own inference key never reached a backend call that
289+
/// noticed a dead session, so search kept failing on it with no sign-in
290+
/// prompt. A managed-search 401 on the session now starts re-auth itself.
291+
#[tokio::test]
292+
async fn a_rejected_session_starts_re_authentication() {
293+
crate::core::bus::init().await.expect("bus init");
294+
let mut rx = crate::core::bus::BUS
295+
.get()
296+
.expect("event bus initialized")
297+
.receiver();
298+
on_backend_unauthorized("test_marker_session_search", false);
299+
assert!(session_expired_from(
300+
&mut rx,
301+
"search.test_marker_session_search"
302+
));
303+
}
304+
305+
/// A rejected TinyHumans API key has no session to expire, so it must never
306+
/// sign the user out.
307+
#[tokio::test]
308+
async fn a_rejected_api_key_does_not_sign_the_user_out() {
309+
crate::core::bus::init().await.expect("bus init");
310+
let mut rx = crate::core::bus::BUS
311+
.get()
312+
.expect("event bus initialized")
313+
.receiver();
314+
on_backend_unauthorized("test_marker_api_key_search", true);
315+
assert!(!session_expired_from(
316+
&mut rx,
317+
"search.test_marker_api_key_search"
318+
));
319+
}

0 commit comments

Comments
 (0)