|
|
… |
21 unchanged lines |
| 22 |
22 |
|
/// Characters of context (prompt, description, diffs) a call may carry |
| 23 |
23 |
|
/// unless the assistant says otherwise. |
| 24 |
24 |
|
pub const DEFAULT_BUDGET: i64 = 120_000; |
| 25 |
|
- |
/// How long one call may take. |
| 26 |
|
- |
pub const CALL_TIMEOUT: Duration = Duration::from_secs(60); |
|
25 |
+ |
/// How long a call may take while a person waits for it (a draft, a |
|
26 |
+ |
/// connection test): inside Cloudflare's 100 seconds for a proxied request. |
|
27 |
+ |
pub const CALL_TIMEOUT: Duration = Duration::from_secs(90); |
|
28 |
+ |
/// How long a call answered in the background may take (a review, a |
|
29 |
+ |
/// summary): a model that thinks first needs minutes on a large proposal |
|
30 |
+ |
/// (oxygenhub#201). |
|
31 |
+ |
pub const BACKGROUND_TIMEOUT: Duration = Duration::from_secs(300); |
|
32 |
+ |
/// The longest answer asked for: room for a model that thinks before it |
|
33 |
+ |
/// writes (oxygenhub#201: 4096 was spent on thinking alone). |
|
34 |
+ |
pub const MAX_ANSWER_TOKENS: u32 = 16_000; |
| 27 |
35 |
|
/// The most an answer is kept. |
| 28 |
36 |
|
const MAX_ANSWER: usize = 64 * 1024; |
| 29 |
37 |
|
|
|
|
… |
401 unchanged lines |
| 431 |
439 |
|
/// Ask an assistant: the system prompt and the request, the answer's |
| 432 |
440 |
|
/// text. Errors are for the person who asked: a status and the first |
| 433 |
441 |
|
/// line of what the endpoint said, or why no answer came. |
| 434 |
|
- |
pub async fn ask(app: &Shared, a: &Assistant, system: &str, user: &str) -> Result<String, String> { |
|
442 |
+ |
pub async fn ask(app: &Shared, a: &Assistant, system: &str, user: &str, wait: Duration) -> Result<String, String> { |
| 435 |
443 |
|
let Some(token) = app.assistant_key.open(&a.token) else { |
| 436 |
444 |
|
return Err("its token cannot be read: set it again".into()); |
| 437 |
445 |
|
}; |
| 438 |
446 |
|
let client = reqwest::Client::builder() |
| 439 |
|
- |
.timeout(CALL_TIMEOUT) |
|
447 |
+ |
.timeout(wait) |
| 440 |
448 |
|
.user_agent(format!("oxygenhub/{}", env!("CARGO_PKG_VERSION"))) |
| 441 |
449 |
|
.build() |
| 442 |
450 |
|
.map_err(|e| e.to_string())?; |
|
|
… |
6 unchanged lines |
| 449 |
457 |
|
req = req.header("anthropic-version", "2023-06-01"); |
| 450 |
458 |
|
serde_json::json!({ |
| 451 |
459 |
|
"model": a.model, |
| 452 |
|
- |
"max_tokens": 4096, |
|
460 |
+ |
"max_tokens": MAX_ANSWER_TOKENS, |
| 453 |
461 |
|
"system": system, |
| 454 |
462 |
|
"messages": [{ "role": "user", "content": user }], |
| 455 |
463 |
|
}) |
|
|
… |
2 unchanged lines |
| 458 |
466 |
|
if !token.is_empty() { |
| 459 |
467 |
|
req = req.header("authorization", format!("Bearer {token}")); |
| 460 |
468 |
|
} |
|
469 |
+ |
// No length here: providers differ (`max_tokens`, or |
|
470 |
+ |
// `max_completion_tokens` for newer OpenAI models, with |
|
471 |
+ |
// different ceilings), and their defaults are generous. |
| 461 |
472 |
|
serde_json::json!({ |
| 462 |
473 |
|
"model": a.model, |
| 463 |
474 |
|
"messages": [ |
|
|
… |
8 unchanged lines |
| 472 |
483 |
|
} |
| 473 |
484 |
|
let r = req.json(&body).send().await.map_err(|e| { |
| 474 |
485 |
|
if e.is_timeout() { |
| 475 |
|
- |
"no answer within 60 seconds".to_string() |
|
486 |
+ |
format!("no answer within {} seconds", wait.as_secs()) |
| 476 |
487 |
|
} else { |
| 477 |
488 |
|
scrub(&e.to_string(), &token) |
| 478 |
489 |
|
} |
|
|
… |
8 unchanged lines |
| 487 |
498 |
|
return Err(scrub(&format!("{} {}", status.as_u16(), why.trim()), &token)); |
| 488 |
499 |
|
} |
| 489 |
500 |
|
let v: serde_json::Value = serde_json::from_str(&text).map_err(|_| "the answer was not JSON".to_string())?; |
| 490 |
|
- |
let answer = match a.api.as_str() { |
| 491 |
|
- |
"anthropic-messages" => v["content"] |
| 492 |
|
- |
.as_array() |
| 493 |
|
- |
.map(|parts| parts.iter().filter_map(|p| p["text"].as_str()).collect::<Vec<_>>().join("")) |
| 494 |
|
- |
.unwrap_or_default(), |
| 495 |
|
- |
_ => match &v["choices"][0]["message"]["content"] { |
| 496 |
|
- |
serde_json::Value::String(s) => s.clone(), |
| 497 |
|
- |
serde_json::Value::Array(parts) => parts.iter().filter_map(|p| p["text"].as_str()).collect::<Vec<_>>().join(""), |
| 498 |
|
- |
_ => String::new(), |
| 499 |
|
- |
}, |
| 500 |
|
- |
}; |
| 501 |
|
- |
if answer.trim().is_empty() { |
| 502 |
|
- |
return Err("the answer carried no text".into()); |
|
501 |
+ |
let (answer, stop) = answer_of(&a.api, &v); |
|
502 |
+ |
finish(answer, stop) |
|
503 |
+ |
} |
|
504 |
+ |
|
|
505 |
+ |
/// Why an answer ended, as the person who asked needs to know it. |
|
506 |
+ |
#[derive(Debug, PartialEq, Eq)] |
|
507 |
+ |
enum Stop { |
|
508 |
+ |
/// It finished. |
|
509 |
+ |
Done, |
|
510 |
+ |
/// It reached the length it was allowed (thinking counts against it). |
|
511 |
+ |
Length, |
|
512 |
+ |
/// The provider declined to answer, with its category if it gave one. |
|
513 |
+ |
Declined(Option<String>), |
|
514 |
+ |
} |
|
515 |
+ |
|
|
516 |
+ |
/// The text of a provider's answer (text parts only: thinking is not |
|
517 |
+ |
/// part of the answer), and why it ended. |
|
518 |
+ |
fn answer_of(api: &str, v: &serde_json::Value) -> (String, Stop) { |
|
519 |
+ |
match api { |
|
520 |
+ |
"anthropic-messages" => { |
|
521 |
+ |
let text = v["content"] |
|
522 |
+ |
.as_array() |
|
523 |
+ |
.map(|parts| { |
|
524 |
+ |
parts |
|
525 |
+ |
.iter() |
|
526 |
+ |
.filter(|p| p["type"].as_str().is_none_or(|t| t == "text")) |
|
527 |
+ |
.filter_map(|p| p["text"].as_str()) |
|
528 |
+ |
.collect::<Vec<_>>() |
|
529 |
+ |
.join("") |
|
530 |
+ |
}) |
|
531 |
+ |
.unwrap_or_default(); |
|
532 |
+ |
let stop = match v["stop_reason"].as_str() { |
|
533 |
+ |
Some("max_tokens") => Stop::Length, |
|
534 |
+ |
Some("refusal") => Stop::Declined(v["stop_details"]["category"].as_str().map(str::to_string)), |
|
535 |
+ |
_ => Stop::Done, |
|
536 |
+ |
}; |
|
537 |
+ |
(text, stop) |
|
538 |
+ |
} |
|
539 |
+ |
_ => { |
|
540 |
+ |
let choice = &v["choices"][0]; |
|
541 |
+ |
let text = match &choice["message"]["content"] { |
|
542 |
+ |
serde_json::Value::String(s) => s.clone(), |
|
543 |
+ |
serde_json::Value::Array(parts) => parts.iter().filter_map(|p| p["text"].as_str()).collect::<Vec<_>>().join(""), |
|
544 |
+ |
_ => String::new(), |
|
545 |
+ |
}; |
|
546 |
+ |
let stop = match choice["finish_reason"].as_str() { |
|
547 |
+ |
Some("length") => Stop::Length, |
|
548 |
+ |
Some("content_filter") => Stop::Declined(None), |
|
549 |
+ |
_ => Stop::Done, |
|
550 |
+ |
}; |
|
551 |
+ |
(text, stop) |
|
552 |
+ |
} |
| 503 |
553 |
|
} |
| 504 |
|
- |
Ok(answer) |
| 505 |
554 |
|
} |
| 506 |
555 |
|
|
|
556 |
+ |
/// The answer, or why there is none. An answer cut short is kept, with a |
|
557 |
+ |
/// note saying so. |
|
558 |
+ |
fn finish(answer: String, stop: Stop) -> Result<String, String> { |
|
559 |
+ |
match stop { |
|
560 |
+ |
Stop::Declined(category) => Err(match category { |
|
561 |
+ |
Some(c) => format!("it declined to answer ({c})"), |
|
562 |
+ |
None => "it declined to answer".into(), |
|
563 |
+ |
}), |
|
564 |
+ |
Stop::Length if answer.trim().is_empty() => { |
|
565 |
+ |
Err("it ran out of room before writing an answer (it spent its length limit thinking): try a smaller proposal".into()) |
|
566 |
+ |
} |
|
567 |
+ |
Stop::Length => Ok(format!("{}\n\n*(Cut short: the answer reached its length limit.)*", answer.trim_end())), |
|
568 |
+ |
Stop::Done if answer.trim().is_empty() => Err("the answer carried no text".into()), |
|
569 |
+ |
Stop::Done => Ok(answer), |
|
570 |
+ |
} |
|
571 |
+ |
} |
|
572 |
+ |
|
| 507 |
573 |
|
/// Providers the add form offers, with what they need (#109): |
| 508 |
574 |
|
/// (id, label, name, endpoint, api). |
| 509 |
575 |
|
pub const PRESETS: &[(&str, &str, &str, &str, &str)] = &[ |
|
|
… |
58 unchanged lines |
| 568 |
634 |
|
|
| 569 |
635 |
|
/// A tiny exchange, for the settings page's TEST: the start of the answer. |
| 570 |
636 |
|
pub async fn test(app: &Shared, a: &Assistant) -> Result<String, String> { |
| 571 |
|
- |
let answer = ask(app, a, "You are being connected to an OxygenHub site. Answer briefly.", "Reply with the single word OK.").await?; |
|
637 |
+ |
let answer = |
|
638 |
+ |
ask(app, a, "You are being connected to an OxygenHub site. Answer briefly.", "Reply with the single word OK.", CALL_TIMEOUT) |
|
639 |
+ |
.await?; |
| 572 |
640 |
|
Ok(answer.trim().chars().take(80).collect()) |
| 573 |
641 |
|
} |
| 574 |
642 |
|
|
|
|
… |
24 unchanged lines |
| 599 |
667 |
|
assert_eq!(k.open(&[1, 2, 3]), None); |
| 600 |
668 |
|
assert_eq!(k.seal(""), Vec::<u8>::new()); |
| 601 |
669 |
|
assert_eq!(k.open(&[]).as_deref(), Some("")); |
|
670 |
+ |
} |
|
671 |
+ |
|
|
672 |
+ |
#[test] |
|
673 |
+ |
fn answers_say_why_they_stopped() { |
|
674 |
+ |
let a = |v: serde_json::Value| { |
|
675 |
+ |
let (text, stop) = answer_of("anthropic-messages", &v); |
|
676 |
+ |
finish(text, stop) |
|
677 |
+ |
}; |
|
678 |
+ |
// #201: all of the length spent thinking. |
|
679 |
+ |
let thought = serde_json::json!({ "stop_reason": "max_tokens", "content": [{ "type": "thinking", "thinking": "" }] }); |
|
680 |
+ |
assert!(a(thought).unwrap_err().contains("ran out of room")); |
|
681 |
+ |
let cut = serde_json::json!({ "stop_reason": "max_tokens", "content": [{ "type": "thinking", "thinking": "" }, { "type": "text", "text": "Half" }] }); |
|
682 |
+ |
assert!(a(cut).unwrap().starts_with("Half\n\n*(Cut short")); |
|
683 |
+ |
let declined = serde_json::json!({ "stop_reason": "refusal", "stop_details": { "category": "cyber" }, "content": [] }); |
|
684 |
+ |
assert_eq!(a(declined).unwrap_err(), "it declined to answer (cyber)"); |
|
685 |
+ |
let ok = serde_json::json!({ "stop_reason": "end_turn", "content": [{ "type": "text", "text": "Fine." }] }); |
|
686 |
+ |
assert_eq!(a(ok).unwrap(), "Fine."); |
|
687 |
+ |
let chat = |v: serde_json::Value| { |
|
688 |
+ |
let (text, stop) = answer_of("chat-completions", &v); |
|
689 |
+ |
finish(text, stop) |
|
690 |
+ |
}; |
|
691 |
+ |
assert!(chat(serde_json::json!({ "choices": [{ "finish_reason": "length", "message": { "content": "" } }] })) |
|
692 |
+ |
.unwrap_err() |
|
693 |
+ |
.contains("ran out of room")); |
|
694 |
+ |
assert_eq!( |
|
695 |
+ |
chat(serde_json::json!({ "choices": [{ "finish_reason": "content_filter", "message": { "content": "" } }] })).unwrap_err(), |
|
696 |
+ |
"it declined to answer" |
|
697 |
+ |
); |
|
698 |
+ |
assert_eq!( |
|
699 |
+ |
chat(serde_json::json!({ "choices": [{ "finish_reason": "stop", "message": { "content": "Yes." } }] })).unwrap(), |
|
700 |
+ |
"Yes." |
|
701 |
+ |
); |
| 602 |
702 |
|
} |
| 603 |
703 |
|
|
| 604 |
704 |
|
#[test] |
|
|
… |
214 unchanged lines |
| 819 |
919 |
|
let prompts = prompt_for_repo(&app.db, row).await.map_err(|e| e.to_string())?; |
| 820 |
920 |
|
let system = review_system_prompt(a, &app.config.site.name, &prompts); |
| 821 |
921 |
|
let user = review_context(row, p, &loaded, budget.saturating_sub(system.chars().count())); |
| 822 |
|
- |
ask(app, a, &system, &user).await |
|
922 |
+ |
ask(app, a, &system, &user, BACKGROUND_TIMEOUT).await |
| 823 |
923 |
|
} |
| 824 |
924 |
|
|
| 825 |
925 |
|
// -------------------------------------------------------- summaries |
|
|
… |
64 unchanged lines |
| 890 |
990 |
|
let prompts = prompt_for_repo(&app.db, row).await.map_err(|e| e.to_string())?; |
| 891 |
991 |
|
let system = summary_system_prompt(a, &app.config.site.name, &prompts); |
| 892 |
992 |
|
let user = review_context(row, p, &loaded, budget.saturating_sub(system.chars().count())); |
| 893 |
|
- |
ask(app, a, &system, &user).await |
|
993 |
+ |
ask(app, a, &system, &user, BACKGROUND_TIMEOUT).await |
| 894 |
994 |
|
} |
| 895 |
995 |
|
|
| 896 |
996 |
|
// -------------------------------------------------------- drafting |
|
|
… |
44 unchanged lines |
| 941 |
1041 |
|
Ok(prompts) => { |
| 942 |
1042 |
|
let system = draft_system_prompt(a, &app.config.site.name, &prompts); |
| 943 |
1043 |
|
let user = draft_context(row, title, target, changes, diffs, budget.saturating_sub(system.chars().count())); |
| 944 |
|
- |
ask(app, a, &system, &user).await |
|
1044 |
+ |
ask(app, a, &system, &user, CALL_TIMEOUT).await |
| 945 |
1045 |
|
} |
| 946 |
1046 |
|
Err(e) => Err(e), |
| 947 |
1047 |
|
}; |
|
|
… |
3 unchanged lines |