OXYGENHUB

Coverage / crates/hub/src/assistants.rs 85.8%

912 of 1063 lines run, 179 of 459 functions · run #502 on main · run missed

1
//! AI assistants (hub spec §13.4, #97): chat-completion endpoints the hub
2
//! may ask to review a proposal or draft its description. Assistants are
3
//! kept at three levels, site, organisation and repository, and combined
4
//! downwards: a repository sees its organisation's and the site's beside
5
//! its own, the innermost of a name wins, and one disabled at an outer
6
//! level is off for everything inside it. Each level adds a prompt; the
7
//! prompt in force for a repository is the site's, the organisation's and
8
//! the repository's own, each under a heading. Tokens are sealed with
9
//! ChaCha20-Poly1305 under `keys/assistants.key`, shown masked, and never
10
//! leave the server.
11
12
use crate::db::{now_ms, Pool};
13
use crate::Shared;
14
use rand::RngCore;
15
use ring::aead;
16
use sqlx::FromRow;
17
use std::path::Path;
18
use std::time::Duration;
19
20
/// The request shapes an assistant may speak.
21
pub const APIS: [&str; 2] = ["chat-completions", "anthropic-messages"];
22
/// Characters of context (prompt, description, diffs) a call may carry
23
/// unless the assistant says otherwise.
24
pub const DEFAULT_BUDGET: i64 = 120_000;
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;
35
/// The most an answer is kept.
36
const MAX_ANSWER: usize = 64 * 1024;
37
/// The longest a streamed answer may take in all, however steadily it
38
/// comes: the limit a call is given is for silence (oxygenhub#248).
39
const STREAM_LIMIT: Duration = Duration::from_secs(15 * 60);
40
41
pub use crate::levels::Scope;
42
43
#[derive(Clone, Debug, FromRow)]
44
pub struct Assistant {
45
    pub id: i64,
46
    pub scope: String,
47
    pub scope_id: i64,
48
    pub name: String,
49
    pub endpoint: String,
50
    /// One of [`APIS`].
51
    pub api: String,
52
    pub model: String,
53
    /// Extra request headers, one `Name: value` per line.
54
    pub headers: String,
55
    /// The sealed token; empty when the endpoint needs none.
56
    pub token: Vec<u8>,
57
    pub enabled: bool,
58
    pub budget: i64,
59
    pub created_ms: i64,
60
    pub updated_ms: i64,
61
}
62
63
impl Assistant {
646×
    pub fn scope(&self) -> Scope {
656×
        match self.scope.as_str() {
666×
            "org" => Scope::Org(self.scope_id),
676×
            "repo" => Scope::Repo(self.scope_id),
686×
            _ => Scope::Site,
69
        }
706×
    }
71
7236×
    pub fn has_token(&self) -> bool {
7336×
        !self.token.is_empty()
7436×
    }
75
76
    /// The extra headers as pairs; malformed lines are skipped.
7714×
    pub fn header_list(&self) -> Vec<(String, String)> {
7814×
        parse_headers(&self.headers).unwrap_or_default()
7914×
    }
80
}
81
82
/// What a settings form supplies for an assistant.
83
#[derive(Clone, Debug, Default)]
84
pub struct Fields {
85
    pub name: String,
86
    pub endpoint: String,
87
    pub api: String,
88
    pub model: String,
89
    pub headers: String,
90
    pub enabled: bool,
91
    pub budget: i64,
92
}
93
94
/// `Name: value` lines to pairs: `Err(line)` names the first bad one.
9530×
pub fn parse_headers(text: &str) -> Result<Vec<(String, String)>, String> {
9630×
    let mut out = vec![];
9730×
    for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
984×
        let Some((name, value)) = line.split_once(':') else { return Err(line.to_string()) };
993×
        let (name, value) = (name.trim(), value.trim());
10020×
        let name_ok = !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
1016×
        if !name_ok || value.chars().any(|c| c.is_control()) {
1021×
            return Err(line.to_string());
1032×
        }
1042×
        out.push((name.to_string(), value.to_string()));
105
    }
10628×
    Ok(out)
10730×
}
108
109
// -------------------------------------------------------------- sealing
110
111
/// The key that seals tokens, kept in `<data>/keys/assistants.key`.
112
#[derive(Clone)]
113
pub struct SecretKey([u8; 32]);
114
115
impl SecretKey {
116197×
    pub fn load_or_create(keys: &Path) -> anyhow::Result<SecretKey> {
117197×
        SecretKey::load_or_create_named(keys, "assistants.key")
118197×
    }
119
120
    /// The same, for another key file beside it (`secrets.key` seals Flow
121
    /// secrets, #107).
122391×
    pub fn load_or_create_named(keys: &Path, file: &str) -> anyhow::Result<SecretKey> {
123391×
        std::fs::create_dir_all(keys)?;
124391×
        let path = keys.join(file);
125391×
        if let Ok(bytes) = std::fs::read(&path) {
1261×
            if bytes.len() == 32 {
1271×
                return Ok(SecretKey(bytes.try_into().unwrap()));
1280×
            }
129390×
        }
130390×
        let mut k = [0u8; 32];
131390×
        rand::rngs::OsRng.fill_bytes(&mut k);
132390×
        std::fs::write(&path, k)?;
133
        #[cfg(unix)]
134
        {
135
            use std::os::unix::fs::PermissionsExt;
136390×
            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
137
        }
138390×
        Ok(SecretKey(k))
139391×
    }
140
14199×
    fn key(&self) -> aead::LessSafeKey {
14299×
        aead::LessSafeKey::new(aead::UnboundKey::new(&aead::CHACHA20_POLY1305, &self.0).expect("key length"))
14399×
    }
144
145
    /// Seal a token: a random nonce, then the ciphertext and tag. An
146
    /// empty token seals to nothing, meaning "no token".
14722×
    pub fn seal(&self, plain: &str) -> Vec<u8> {
14822×
        if plain.is_empty() {
1491×
            return vec![];
15021×
        }
15121×
        let mut nonce = [0u8; 12];
15221×
        rand::rngs::OsRng.fill_bytes(&mut nonce);
15321×
        let mut out = plain.as_bytes().to_vec();
15421×
        self.key().seal_in_place_append_tag(aead::Nonce::assume_unique_for_key(nonce), aead::Aad::empty(), &mut out).expect("seal");
15521×
        let mut sealed = nonce.to_vec();
15621×
        sealed.extend(out);
15721×
        sealed
15822×
    }
159
160
    /// The token back, or `None` when the bytes were sealed under another
161
    /// key or damaged. Nothing opens to an empty token.
162397×
    pub fn open(&self, sealed: &[u8]) -> Option<String> {
163397×
        if sealed.is_empty() {
164318×
            return Some(String::new());
16579×
        }
16679×
        let (nonce, rest) = sealed.split_at_checked(12)?;
16778×
        let mut buf = rest.to_vec();
16878×
        let nonce = aead::Nonce::try_assume_unique_for_key(nonce).ok()?;
16978×
        let plain = self.key().open_in_place(nonce, aead::Aad::empty(), &mut buf).ok()?;
17077×
        String::from_utf8(plain.to_vec()).ok()
171397×
    }
172
}
173
174
// ------------------------------------------------------------- storage
175
176635×
pub async fn list(pool: &Pool, scope: Scope) -> sqlx::Result<Vec<Assistant>> {
177635×
    sqlx::query_as("SELECT * FROM assistants WHERE scope = $1 AND scope_id = $2 ORDER BY name")
178635×
        .bind(scope.kind())
179635×
        .bind(scope.id())
180635×
        .fetch_all(pool)
181635×
        .await
182635×
}
183
1847×
pub async fn get(pool: &Pool, id: i64) -> sqlx::Result<Option<Assistant>> {
1857×
    sqlx::query_as("SELECT * FROM assistants WHERE id = $1").bind(id).fetch_optional(pool).await
1867×
}
187
188
/// Add an assistant; `None` when the level already has one of that name.
1899×
pub async fn create(pool: &Pool, scope: Scope, f: &Fields, token: &[u8]) -> sqlx::Result<Option<Assistant>> {
1909×
    let now = now_ms();
1919×
    let r = sqlx::query_as(
1929×
        "INSERT INTO assistants (scope, scope_id, name, endpoint, api, model, headers, token, enabled, budget, created_ms, updated_ms) \
1939×
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT DO NOTHING RETURNING *",
1949×
    )
1959×
    .bind(scope.kind())
1969×
    .bind(scope.id())
1979×
    .bind(&f.name)
1989×
    .bind(&f.endpoint)
1999×
    .bind(&f.api)
2009×
    .bind(&f.model)
2019×
    .bind(&f.headers)
2029×
    .bind(token)
2039×
    .bind(f.enabled)
2049×
    .bind(f.budget)
2059×
    .bind(now)
2069×
    .bind(now)
2079×
    .fetch_optional(pool)
2089×
    .await?;
2099×
    Ok(r)
2109×
}
211
212
/// Change an assistant's details; `token` is `None` to keep the stored
213
/// one. False when the name is taken at that level.
2143×
pub async fn update(pool: &Pool, id: i64, f: &Fields, token: Option<&[u8]>) -> sqlx::Result<bool> {
2153×
    let r = match token {
2160×
        Some(t) => sqlx::query(
2170×
            "UPDATE assistants SET name = $1, endpoint = $2, api = $3, model = $4, headers = $5, enabled = $6, budget = $7, token = $8, updated_ms = $9 \
2180×
             WHERE id = $10 AND NOT EXISTS (SELECT 1 FROM assistants o WHERE o.scope = assistants.scope AND o.scope_id = assistants.scope_id AND o.name = $11 AND o.id <> $12)",
2190×
        )
2200×
        .bind(&f.name)
2210×
        .bind(&f.endpoint)
2220×
        .bind(&f.api)
2230×
        .bind(&f.model)
2240×
        .bind(&f.headers)
2250×
        .bind(f.enabled)
2260×
        .bind(f.budget)
2270×
        .bind(t)
2280×
        .bind(now_ms())
2290×
        .bind(id)
2300×
        .bind(&f.name)
2310×
        .bind(id)
2320×
        .execute(pool)
2330×
        .await?,
2343×
        None => sqlx::query(
2353×
            "UPDATE assistants SET name = $1, endpoint = $2, api = $3, model = $4, headers = $5, enabled = $6, budget = $7, updated_ms = $8 \
2363×
             WHERE id = $9 AND NOT EXISTS (SELECT 1 FROM assistants o WHERE o.scope = assistants.scope AND o.scope_id = assistants.scope_id AND o.name = $10 AND o.id <> $11)",
2373×
        )
2383×
        .bind(&f.name)
2393×
        .bind(&f.endpoint)
2403×
        .bind(&f.api)
2413×
        .bind(&f.model)
2423×
        .bind(&f.headers)
2433×
        .bind(f.enabled)
2443×
        .bind(f.budget)
2453×
        .bind(now_ms())
2463×
        .bind(id)
2473×
        .bind(&f.name)
2483×
        .bind(id)
2493×
        .execute(pool)
2503×
        .await?,
251
    };
2523×
    Ok(r.rows_affected() == 1)
2533×
}
254
255
/// A renamed assistant keeps its name on the reviews it answered and the
256
/// events that mention it, for the repositories its level covers: the
257
/// name is what people read, not a key.
2581×
pub async fn rename_in_history(pool: &Pool, scope: Scope, owner_hint: &str, old: &str, new: &str) -> sqlx::Result<()> {
2591×
    if old == new || old.is_empty() || new.is_empty() {
2600×
        return Ok(());
2611×
    }
2621×
    let like = format!("%{old}%");
2631×
    match scope {
264
        Scope::Site => {
2652×
            for table in ["proposal_reviews", "proposal_comments"] {
2662×
                sqlx::query(&format!("UPDATE {table} SET via = $1 WHERE via = $2")).bind(new).bind(old).execute(pool).await?;
267
            }
2681×
            sqlx::query("UPDATE proposal_events SET event = replace(event, $1, $2) WHERE event LIKE $3")
2691×
                .bind(old)
2701×
                .bind(new)
2711×
                .bind(&like)
2721×
                .execute(pool)
2731×
                .await?;
274
        }
275
        Scope::Org(_) => {
2760×
            let in_org = "proposal_id IN (SELECT p.id FROM proposals p JOIN repositories r ON r.id = p.repo_id WHERE r.owner = $1)";
2770×
            for table in ["proposal_reviews", "proposal_comments"] {
2780×
                sqlx::query(&format!("UPDATE {table} SET via = $1 WHERE via = $2 AND {in_org}"))
2790×
                    .bind(new)
2800×
                    .bind(old)
2810×
                    .bind(owner_hint)
2820×
                    .execute(pool)
2830×
                    .await?;
284
            }
2850×
            sqlx::query(&format!("UPDATE proposal_events SET event = replace(event, $1, $2) WHERE event LIKE $3 AND {in_org}"))
2860×
                .bind(old)
2870×
                .bind(new)
2880×
                .bind(&like)
2890×
                .bind(owner_hint)
2900×
                .execute(pool)
2910×
                .await?;
292
        }
2930×
        Scope::Repo(id) => {
2940×
            let in_repo = "proposal_id IN (SELECT id FROM proposals WHERE repo_id = $1)";
2950×
            for table in ["proposal_reviews", "proposal_comments"] {
2960×
                sqlx::query(&format!("UPDATE {table} SET via = $1 WHERE via = $2 AND {in_repo}"))
2970×
                    .bind(new)
2980×
                    .bind(old)
2990×
                    .bind(id)
3000×
                    .execute(pool)
3010×
                    .await?;
302
            }
3030×
            sqlx::query(&format!("UPDATE proposal_events SET event = replace(event, $1, $2) WHERE event LIKE $3 AND {in_repo}"))
3040×
                .bind(old)
3050×
                .bind(new)
3060×
                .bind(&like)
3070×
                .bind(id)
3080×
                .execute(pool)
3090×
                .await?;
310
        }
311
    }
3121×
    Ok(())
3131×
}
314
3152×
pub async fn set_enabled(pool: &Pool, id: i64, enabled: bool) -> sqlx::Result<()> {
3162×
    sqlx::query("UPDATE assistants SET enabled = $1, updated_ms = $2 WHERE id = $3")
3172×
        .bind(enabled)
3182×
        .bind(now_ms())
3192×
        .bind(id)
3202×
        .execute(pool)
3212×
        .await?;
3222×
    Ok(())
3232×
}
324
3251×
pub async fn delete(pool: &Pool, id: i64) -> sqlx::Result<()> {
3261×
    sqlx::query("DELETE FROM assistants WHERE id = $1").bind(id).execute(pool).await?;
3271×
    Ok(())
3281×
}
329
330
/// Forget a level's assistants and prompt, when the organisation or
331
/// repository goes.
3321×
pub async fn delete_scope(pool: &Pool, scope: Scope) -> sqlx::Result<()> {
3331×
    sqlx::query("DELETE FROM assistants WHERE scope = $1 AND scope_id = $2").bind(scope.kind()).bind(scope.id()).execute(pool).await?;
3341×
    sqlx::query("DELETE FROM assistant_prompts WHERE scope = $1 AND scope_id = $2")
3351×
        .bind(scope.kind())
3361×
        .bind(scope.id())
3371×
        .execute(pool)
3381×
        .await?;
3391×
    Ok(())
3401×
}
341
342
/// The prompt a level adds, empty when none.
343452×
pub async fn prompt(pool: &Pool, scope: Scope) -> sqlx::Result<String> {
344452×
    Ok(sqlx::query_scalar("SELECT prompt FROM assistant_prompts WHERE scope = $1 AND scope_id = $2")
345452×
        .bind(scope.kind())
346452×
        .bind(scope.id())
347452×
        .fetch_optional(pool)
348452×
        .await?
349452×
        .unwrap_or_default())
350452×
}
351
3525×
pub async fn set_prompt(pool: &Pool, scope: Scope, text: &str) -> sqlx::Result<()> {
3535×
    if text.trim().is_empty() {
3540×
        sqlx::query("DELETE FROM assistant_prompts WHERE scope = $1 AND scope_id = $2")
3550×
            .bind(scope.kind())
3560×
            .bind(scope.id())
3570×
            .execute(pool)
3580×
            .await?;
359
    } else {
3605×
        sqlx::query(
3615×
            "INSERT INTO assistant_prompts (scope, scope_id, prompt, updated_ms) VALUES ($1, $2, $3, $4) \
3625×
             ON CONFLICT (scope, scope_id) DO UPDATE SET prompt = excluded.prompt, updated_ms = excluded.updated_ms",
3635×
        )
3645×
        .bind(scope.kind())
3655×
        .bind(scope.id())
3665×
        .bind(text.trim())
3675×
        .bind(now_ms())
3685×
        .execute(pool)
3695×
        .await?;
370
    }
3715×
    Ok(())
3725×
}
373
374
// ----------------------------------------------------- what applies where
375
376
/// An assistant seen from a level: where it comes from, and whether it is
377
/// on once outer levels have had their say.
378
#[derive(Clone, Debug)]
379
pub struct Available {
380
    pub assistant: Assistant,
381
    /// `the site`, `the acme organisation`, or empty for the level's own.
382
    pub from: String,
383
    /// Off because a level above disabled an assistant of this name.
384
    pub off_above: bool,
385
}
386
387
impl Available {
38855×
    pub fn on(&self) -> bool {
38955×
        self.assistant.enabled && !self.off_above
39055×
    }
391
}
392
393
/// Every assistant a level sees, outermost first, one per name: the
394
/// innermost definition of a name wins, and a name disabled at an outer
395
/// level is off inside it whatever the inner level says.
396354×
pub async fn available(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<Available>> {
397354×
    let mut levels = crate::levels::above(pool, scope, owner).await?;
398354×
    levels.push((scope, String::new()));
399354×
    let mut out: Vec<Available> = vec![];
400354×
    let mut off: Vec<String> = vec![];
401626×
    for (level, from) in levels {
402626×
        for a in list(pool, level).await? {
40365×
            let off_above = off.iter().any(|n| n.eq_ignore_ascii_case(&a.name));
40465×
            if !a.enabled && !off.iter().any(|n| n.eq_ignore_ascii_case(&a.name)) {
4054×
                off.push(a.name.clone());
40661×
            }
40765×
            out.retain(|o| !o.assistant.name.eq_ignore_ascii_case(&a.name));
40865×
            out.push(Available { assistant: a, from: from.clone(), off_above });
409
        }
410
    }
411354×
    Ok(out)
412354×
}
413
414
/// The assistants a repository may ask, by name.
41592×
pub async fn enabled_for_repo(pool: &Pool, repo: &crate::db::repos::RepoRow) -> sqlx::Result<Vec<Assistant>> {
41692×
    Ok(available(pool, Scope::Repo(repo.id), &repo.owner).await?.into_iter().filter(Available::on).map(|a| a.assistant).collect())
41792×
}
418
419
/// The prompts a level inherits, outermost first: (where from, text),
420
/// only the levels that set one.
421269×
pub async fn inherited_prompts(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<(String, String)>> {
422269×
    let mut out = vec![];
423269×
    for (level, from) in crate::levels::above(pool, scope, owner).await? {
424183×
        let text = prompt(pool, level).await?;
425183×
        if !text.is_empty() {
42617×
            out.push((from, text));
427166×
        }
428
    }
429269×
    Ok(out)
430269×
}
431
432
/// The prompt in force for a repository: each level's text under a
433
/// heading, outermost first, so a project adds to what the site says.
4347×
pub async fn prompt_for_repo(pool: &Pool, repo: &crate::db::repos::RepoRow) -> sqlx::Result<String> {
4357×
    let scope = Scope::Repo(repo.id);
4367×
    let mut parts = inherited_prompts(pool, scope, &repo.owner).await?;
4377×
    let own = prompt(pool, scope).await?;
4387×
    if !own.is_empty() {
4390×
        parts.push(("this repository".to_string(), own));
4407×
    }
4417×
    Ok(parts.into_iter().map(|(from, text)| format!("## Instructions from {from}\n\n{text}\n")).collect::<Vec<_>>().join("\n"))
4427×
}
443
444
// -------------------------------------------------------------- calling
445
446
/// Ask an assistant: the system prompt and the request, the answer's
447
/// text. Errors are for the person who asked: a status and the first
448
/// line of what the endpoint said, or why no answer came. The answer is
449
/// asked for as a stream, and `wait` is the longest the provider may be
450
/// silent, before it starts or between parts, so a long answer that keeps
451
/// coming is not cut off (oxygenhub#248); a provider that answers whole
452
/// instead is read whole within `wait`.
4537×
pub async fn ask(app: &Shared, a: &Assistant, system: &str, user: &str, wait: Duration) -> Result<String, String> {
4547×
    ask_streaming(app, a, system, user, wait, |_| {}).await
4557×
}
456
457
/// [`ask`], handing each part of the answer to `part` as it arrives.
45814×
pub async fn ask_streaming(
45914×
    app: &Shared,
46014×
    a: &Assistant,
46114×
    system: &str,
46214×
    user: &str,
46314×
    wait: Duration,
46414×
    mut part: impl FnMut(&str),
46514×
) -> Result<String, String> {
46614×
    let Some(token) = app.assistant_key.open(&a.token) else {
4670×
        return Err("its token cannot be read: set it again".into());
468
    };
46914×
    let client = crate::net::client(&a.endpoint, &app.config.outbound.allow, STREAM_LIMIT.max(wait)).await?;
47014×
    let mut req = client.post(&a.endpoint).header("content-type", "application/json");
47114×
    let body = match a.api.as_str() {
47214×
        "anthropic-messages" => {
4730×
            if !token.is_empty() {
4740×
                req = req.header("x-api-key", &token);
4750×
            }
4760×
            req = req.header("anthropic-version", "2023-06-01");
4770×
            serde_json::json!({
4780×
                "model": a.model,
4790×
                "max_tokens": MAX_ANSWER_TOKENS,
4800×
                "system": system,
4810×
                "messages": [{ "role": "user", "content": user }],
4820×
                "stream": true,
483
            })
484
        }
485
        _ => {
48614×
            if !token.is_empty() {
48714×
                req = req.header("authorization", format!("Bearer {token}"));
48814×
            }
489
            // No length here: providers differ (`max_tokens`, or
490
            // `max_completion_tokens` for newer OpenAI models, with
491
            // different ceilings), and their defaults are generous.
49214×
            serde_json::json!({
49314×
                "model": a.model,
49414×
                "messages": [
49514×
                    { "role": "system", "content": system },
49614×
                    { "role": "user", "content": user },
497
                ],
49814×
                "stream": true,
499
            })
500
        }
501
    };
50214×
    for (name, value) in a.header_list() {
5030×
        req = req.header(name, value);
5040×
    }
50514×
    let quiet = || format!("no answer within {} seconds", wait.as_secs());
50614×
    let r = tokio::time::timeout(wait, req.json(&body).send()).await.map_err(|_| quiet())?.map_err(|e| {
5070×
        if e.is_timeout() {
5080×
            quiet()
509
        } else {
5100×
            scrub(&e.to_string(), &token)
511
        }
5120×
    })?;
51314×
    let status = r.status();
51414×
    let streamed =
51514×
        r.headers().get(reqwest::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).is_some_and(|t| t.starts_with("text/event-stream"));
51614×
    if status.is_success() && streamed {
5170×
        let (answer, stop) = read_stream(&a.api, r, wait, &mut part).await.map_err(|e| scrub(&e, &token))?;
5180×
        return finish(answer, stop);
51914×
    }
52014×
    let text: String =
52114×
        tokio::time::timeout(wait, r.text()).await.map_err(|_| quiet())?.unwrap_or_default().chars().take(MAX_ANSWER).collect();
52214×
    if !status.is_success() {
5231×
        let why = serde_json::from_str::<serde_json::Value>(&text)
5241×
            .ok()
5251×
            .and_then(|v| v["error"]["message"].as_str().or(v["error"].as_str()).or(v["message"].as_str()).map(str::to_string))
5261×
            .unwrap_or_else(|| text.lines().next().unwrap_or("").chars().take(160).collect());
5271×
        return Err(scrub(&format!("{} {}", status.as_u16(), why.trim()), &token));
52813×
    }
52913×
    let v: serde_json::Value = serde_json::from_str(&text).map_err(|_| "the answer was not JSON".to_string())?;
53013×
    let (answer, stop) = answer_of(&a.api, &v);
53113×
    if !answer.is_empty() {
53213×
        part(&answer);
53313×
    }
53413×
    finish(answer, stop)
53514×
}
536
537
/// Read a streamed answer (server-sent events), handing out its text as it
538
/// comes; at most `quiet` between parts.
5392×
async fn read_stream(api: &str, mut r: reqwest::Response, quiet: Duration, part: &mut impl FnMut(&str)) -> Result<(String, Stop), String> {
5402×
    let mut events = Events::new(api);
541
    loop {
54210×
        let chunk = match tokio::time::timeout(quiet, r.chunk()).await {
5431×
            Err(_) if events.text.is_empty() => return Err(format!("no answer within {} seconds", quiet.as_secs())),
544
            // It went quiet part of the way through: what came is kept.
5451×
            Err(_) => return Ok((events.text, Stop::Quiet)),
5460×
            Ok(Err(e)) if events.text.is_empty() => return Err(e.to_string()),
5470×
            Ok(Err(_)) => return Ok((events.text, Stop::Quiet)),
5480×
            Ok(Ok(None)) => return Ok((events.text, events.stop.unwrap_or(Stop::Done))),
5499×
            Ok(Ok(Some(c))) => c,
550
        };
5519×
        let before = events.text.len();
5529×
        events.feed(&chunk)?;
5539×
        if events.text.len() > before {
5548×
            part(&events.text[before..]);
5558×
        }
5569×
        if events.done || events.text.len() >= MAX_ANSWER {
5571×
            return Ok((events.text, events.stop.unwrap_or(Stop::Done)));
5588×
        }
559
    }
5602×
}
561
562
/// Server-sent events of a streamed answer, as Anthropic's messages API
563
/// and the chat-completions APIs send them: text deltas, why it stopped,
564
/// and errors. Fed bytes as they come; a line is read once it is whole.
565
struct Events {
566
    anthropic: bool,
567
    buf: Vec<u8>,
568
    text: String,
569
    stop: Option<Stop>,
570
    done: bool,
571
}
572
573
impl Events {
57410×
    fn new(api: &str) -> Events {
57510×
        Events { anthropic: api == "anthropic-messages", buf: Vec::new(), text: String::new(), stop: None, done: false }
57610×
    }
577
578676×
    fn feed(&mut self, bytes: &[u8]) -> Result<(), String> {
579676×
        self.buf.extend_from_slice(bytes);
58026322×
        while let Some(end) = self.buf.iter().position(|b| *b == b'\n') {
58193×
            let line: Vec<u8> = self.buf.drain(..=end).collect();
58293×
            let line = String::from_utf8_lossy(&line);
58393×
            let Some(data) = line.trim_end_matches(['\r', '\n']).strip_prefix("data:") else {
58448×
                continue;
585
            };
58645×
            let data = data.trim_start();
58745×
            if data == "[DONE]" {
5881×
                self.done = true;
5891×
                continue;
59044×
            }
59144×
            let Ok(v) = serde_json::from_str::<serde_json::Value>(data) else {
5920×
                continue;
593
            };
59444×
            self.event(&v)?;
595
        }
596675×
        Ok(())
597676×
    }
598
59944×
    fn event(&mut self, v: &serde_json::Value) -> Result<(), String> {
60044×
        if self.anthropic {
60141×
            match v["type"].as_str() {
60241×
                Some("content_block_delta") if v["delta"]["type"] == "text_delta" => {
60318×
                    self.text.push_str(v["delta"]["text"].as_str().unwrap_or(""));
60418×
                }
60518×
                Some("message_delta") => {
6066×
                    self.stop = match v["delta"]["stop_reason"].as_str() {
6076×
                        Some("max_tokens") => Some(Stop::Length),
6085×
                        Some("refusal") => Some(Stop::Declined(v["delta"]["stop_details"]["category"].as_str().map(str::to_string))),
6095×
                        Some(_) => Some(Stop::Done),
6100×
                        None => self.stop.take(),
611
                    };
612
                }
61312×
                Some("message_stop") => self.done = true,
6146×
                Some("error") => {
6151×
                    return Err(v["error"]["message"].as_str().unwrap_or("the provider reported an error").to_string());
616
                }
61710×
                _ => {}
618
            }
619
        } else {
6203×
            if let Some(e) = v["error"]["message"].as_str() {
6210×
                return Err(e.to_string());
6223×
            }
6233×
            let choice = &v["choices"][0];
6243×
            if let Some(t) = choice["delta"]["content"].as_str() {
6252×
                self.text.push_str(t);
6262×
            }
6273×
            match choice["finish_reason"].as_str() {
6281×
                Some("length") => self.stop = Some(Stop::Length),
6290×
                Some("content_filter") => self.stop = Some(Stop::Declined(None)),
6300×
                Some(_) => self.stop = Some(Stop::Done),
6312×
                None => {}
632
            }
633
        }
63443×
        Ok(())
63544×
    }
636
}
637
638
/// Why an answer ended, as the person who asked needs to know it.
639
#[derive(Debug, PartialEq, Eq)]
640
enum Stop {
641
    /// It finished.
642
    Done,
643
    /// It reached the length it was allowed (thinking counts against it).
644
    Length,
645
    /// The provider declined to answer, with its category if it gave one.
646
    Declined(Option<String>),
647
    /// The provider went silent part of the way through a stream.
648
    Quiet,
649
}
650
651
/// The text of a provider's answer (text parts only: thinking is not
652
/// part of the answer), and why it ended.
65320×
fn answer_of(api: &str, v: &serde_json::Value) -> (String, Stop) {
65420×
    match api {
65520×
        "anthropic-messages" => {
6564×
            let text = v["content"]
6574×
                .as_array()
6584×
                .map(|parts| {
6594×
                    parts
6604×
                        .iter()
6614×
                        .filter(|p| p["type"].as_str().is_none_or(|t| t == "text"))
6624×
                        .filter_map(|p| p["text"].as_str())
6634×
                        .collect::<Vec<_>>()
6644×
                        .join("")
6654×
                })
6664×
                .unwrap_or_default();
6674×
            let stop = match v["stop_reason"].as_str() {
6684×
                Some("max_tokens") => Stop::Length,
6692×
                Some("refusal") => Stop::Declined(v["stop_details"]["category"].as_str().map(str::to_string)),
6701×
                _ => Stop::Done,
671
            };
6724×
            (text, stop)
673
        }
674
        _ => {
67516×
            let choice = &v["choices"][0];
67616×
            let text = match &choice["message"]["content"] {
67716×
                serde_json::Value::String(s) => s.clone(),
6780×
                serde_json::Value::Array(parts) => parts.iter().filter_map(|p| p["text"].as_str()).collect::<Vec<_>>().join(""),
6790×
                _ => String::new(),
680
            };
68116×
            let stop = match choice["finish_reason"].as_str() {
6823×
                Some("length") => Stop::Length,
6832×
                Some("content_filter") => Stop::Declined(None),
68414×
                _ => Stop::Done,
685
            };
68616×
            (text, stop)
687
        }
688
    }
68920×
}
690
691
/// The answer, or why there is none. An answer cut short is kept, with a
692
/// note saying so.
69321×
fn finish(answer: String, stop: Stop) -> Result<String, String> {
69415×
    match stop {
6952×
        Stop::Declined(category) => Err(match category {
6961×
            Some(c) => format!("it declined to answer ({c})"),
6971×
            None => "it declined to answer".into(),
698
        }),
6993×
        Stop::Length if answer.trim().is_empty() => {
7002×
            Err("it ran out of room before writing an answer (it spent its length limit thinking): try a smaller proposal".into())
701
        }
7021×
        Stop::Length => Ok(format!("{}\n\n*(Cut short: the answer reached its length limit.)*", answer.trim_end())),
7031×
        Stop::Quiet if answer.trim().is_empty() => Err("it went quiet before writing an answer".into()),
7041×
        Stop::Quiet => Ok(format!("{}\n\n*(Cut short: the assistant went quiet part of the way through.)*", answer.trim_end())),
70515×
        Stop::Done if answer.trim().is_empty() => Err("the answer carried no text".into()),
70615×
        Stop::Done => Ok(answer),
707
    }
70821×
}
709
710
/// Providers the add form offers, with what they need (#109):
711
/// (id, label, name, endpoint, api).
712
pub const PRESETS: &[(&str, &str, &str, &str, &str)] = &[
713
    ("anthropic", "Anthropic (Claude)", "Claude", "https://api.anthropic.com/v1/messages", "anthropic-messages"),
714
    ("openai", "OpenAI (ChatGPT)", "ChatGPT", "https://api.openai.com/v1/chat/completions", "chat-completions"),
715
    ("gemini", "Google Gemini", "Gemini", "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", "chat-completions"),
716
    ("deepseek", "DeepSeek", "DeepSeek", "https://api.deepseek.com/chat/completions", "chat-completions"),
717
    ("mistral", "Mistral", "Mistral", "https://api.mistral.ai/v1/chat/completions", "chat-completions"),
718
    ("perplexity", "Perplexity", "Perplexity", "https://api.perplexity.ai/chat/completions", "chat-completions"),
719
    ("ollama", "Ollama (on your network)", "Ollama", "http://localhost:11434/v1/chat/completions", "chat-completions"),
720
];
721
722
/// Where an endpoint lists its models: `…/chat/completions` → `…/models`,
723
/// `…/messages` → `…/models`.
72415×
pub fn models_url(endpoint: &str) -> Option<String> {
72515×
    let e = endpoint.trim_end_matches('/');
72615×
    e.strip_suffix("/chat/completions").or_else(|| e.strip_suffix("/messages")).map(|base| format!("{base}/models"))
72715×
}
728
729
/// The chat models an endpoint offers for this token, sorted; `Err` when
730
/// it cannot say (no list, refused, unreachable). Embedding, speech and
731
/// image models are left out.
73215×
pub async fn list_models(endpoint: &str, api: &str, token: &str, allow: &[String]) -> Result<Vec<String>, String> {
73315×
    let url = models_url(endpoint).ok_or("this endpoint has no model list next to it")?;
73415×
    let client = crate::net::client(&url, allow, std::time::Duration::from_secs(15)).await?;
73515×
    let mut req = client.get(&url);
73615×
    if api == "anthropic-messages" {
7370×
        req = req.header("anthropic-version", "2023-06-01");
7380×
        if !token.is_empty() {
7390×
            req = req.header("x-api-key", token);
7400×
        }
74115×
    } else if !token.is_empty() {
74215×
        req = req.header("authorization", format!("Bearer {token}"));
74315×
    }
74415×
    let r = req.send().await.map_err(|e| scrub(&e.to_string(), token))?;
74515×
    let status = r.status();
74615×
    if !status.is_success() {
74711×
        return Err(format!("the model list answered {}", status.as_u16()));
7484×
    }
7494×
    let v: serde_json::Value = r.json().await.map_err(|_| "the model list was not JSON".to_string())?;
7504×
    let skip = ["embed", "tts", "whisper", "dall-e", "moderation", "audio", "image", "realtime", "transcribe", "search-preview"];
7514×
    let mut out: Vec<String> = v["data"]
7524×
        .as_array()
7534×
        .or(v["models"].as_array())
7544×
        .ok_or("the model list had no data")?
7554×
        .iter()
75612×
        .filter_map(|m| m["id"].as_str().or(m["name"].as_str()))
75712×
        .map(|id| id.strip_prefix("models/").unwrap_or(id).to_string())
75884×
        .filter(|id| !skip.iter().any(|s| id.to_lowercase().contains(s)))
7594×
        .collect();
7604×
    out.sort();
7614×
    out.dedup();
7624×
    if out.is_empty() {
7630×
        return Err("the model list was empty".into());
7644×
    }
7654×
    Ok(out)
76615×
}
767
768
/// A tiny exchange, for the settings page's TEST: the start of the answer.
7697×
pub async fn test(app: &Shared, a: &Assistant) -> Result<String, String> {
7707×
    let answer =
7717×
        ask(app, a, "You are being connected to an OxygenHub site. Answer briefly.", "Reply with the single word OK.", CALL_TIMEOUT)
7727×
            .await?;
7737×
    Ok(answer.trim().chars().take(80).collect())
7747×
}
775
776
/// Never let the token through in an error message.
7771×
fn scrub(text: &str, token: &str) -> String {
7781×
    if token.is_empty() {
7790×
        text.to_string()
780
    } else {
7811×
        text.replace(token, "***")
782
    }
7831×
}
784
785
#[cfg(test)]
786
mod tests {
787
    use super::*;
788
789
    /// #316: a new proposal's draft begins with its title line, kept apart
790
    /// from the description; a draft without one is all description.
791
    #[test]
7921×
    fn a_draft_s_title_line_is_split_off() {
7931×
        assert_eq!(
7941×
            split_title("Title: feat(flow): hand jobs back\n\n**Summary.** Runners leave."),
7951×
            (Some("feat(flow): hand jobs back".to_string()), "**Summary.** Runners leave.".to_string())
796
        );
7971×
        assert_eq!(split_title("  title: \"Quoted\"\nBody"), (Some("Quoted".to_string()), "Body".to_string()));
7981×
        assert_eq!(split_title("**Summary.** No title here."), (None, "**Summary.** No title here.".to_string()));
7991×
        assert_eq!(split_title("Title:\nBody"), (None, "Body".to_string()), "an empty title is none");
8001×
    }
801
802
    /// Anthropic's events, split anywhere across chunks, give the text,
803
    /// leave thinking out, and say why it stopped (oxygenhub#248).
804
    #[test]
8051×
    fn anthropic_events_give_the_text_whole() {
8061×
        let sse = "event: message_start\ndata: {\"type\":\"message_start\"}\n\n\
8071×
                   data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"hmm\"}}\n\n\
8081×
                   data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"Adds a \"}}\n\n\
8091×
                   data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"line — café.\"}}\n\n\
8101×
                   data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\n\
8111×
                   data: {\"type\":\"message_stop\"}\n\n";
8125×
        for size in [1, 3, 7, 64, sse.len()] {
8135×
            let mut e = Events::new("anthropic-messages");
814626×
            for chunk in sse.as_bytes().chunks(size) {
815626×
                e.feed(chunk).unwrap();
816626×
            }
8175×
            assert_eq!((e.text.as_str(), e.stop.as_ref(), e.done), ("Adds a line — café.", Some(&Stop::Done), true), "chunks of {size}");
818
        }
8191×
        let mut cut = Events::new("anthropic-messages");
8201×
        cut.feed(b"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"max_tokens\"}}\n").unwrap();
8211×
        assert_eq!(cut.stop, Some(Stop::Length));
8221×
        let mut bad = Events::new("anthropic-messages");
8231×
        let e = bad.feed(b"data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"Overloaded\"}}\n").unwrap_err();
8241×
        assert_eq!(e, "Overloaded");
8251×
    }
826
827
    #[test]
8281×
    fn chat_completion_events_give_the_text() {
8291×
        let sse = "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\n\
8301×
                   data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n\
8311×
                   data: {\"choices\":[{\"delta\":{\"content\":\", world\"},\"finish_reason\":\"length\"}]}\n\n\
8321×
                   data: [DONE]\n\n";
8331×
        let mut e = Events::new("chat-completions");
83439×
        for chunk in sse.as_bytes().chunks(5) {
83539×
            e.feed(chunk).unwrap();
83639×
        }
8371×
        assert_eq!((e.text.as_str(), e.stop.as_ref(), e.done), ("Hello, world", Some(&Stop::Length), true));
8381×
    }
839
840
    /// A stream that takes longer than the silence limit in all, but never
841
    /// pauses that long, is read whole; one that stops talking is cut off
842
    /// with what it said kept.
843
    #[tokio::test]
8441×
    async fn a_slow_steady_stream_is_read_and_a_silent_one_cut() {
8452×
        async fn serve(parts: usize, gap_ms: u64, then_silent: bool) -> String {
8462×
            let router = axum::Router::new().route(
8472×
                "/v1/messages",
8482×
                axum::routing::post(move || async move {
8492×
                    let (tx, rx) = tokio::sync::mpsc::channel::<Result<String, std::io::Error>>(4);
8502×
                    tokio::spawn(async move {
8518×
                        for i in 0..parts {
8528×
                            tokio::time::sleep(Duration::from_millis(gap_ms)).await;
8538×
                            let ev = format!(
854
                                "data: {{\"type\":\"content_block_delta\",\"delta\":{{\"type\":\"text_delta\",\"text\":\"{i} \"}}}}\n\n"
855
                            );
8568×
                            if tx.send(Ok(ev)).await.is_err() {
8570×
                                return;
8588×
                            }
859
                        }
8602×
                        if then_silent {
8611×
                            tokio::time::sleep(Duration::from_secs(5)).await;
8621×
                        }
8631×
                        let _ = tx.send(Ok("data: {\"type\":\"message_stop\"}\n\n".into())).await;
8641×
                    });
8652×
                    let body = axum::body::Body::from_stream(tokio_stream::wrappers::ReceiverStream::new(rx));
8662×
                    ([(axum::http::header::CONTENT_TYPE, "text/event-stream")], body)
8674×
                }),
868
            );
8692×
            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8702×
            let port = listener.local_addr().unwrap().port();
8712×
            tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
8722×
            format!("http://127.0.0.1:{port}/v1/messages")
8732×
        }
8741×
        let quiet = Duration::from_millis(400);
875
        // Six parts 200 ms apart: 1.2 s in all, three times the limit.
8761×
        let url = serve(6, 200, false).await;
8771×
        let r = reqwest::Client::new().post(&url).send().await.unwrap();
8781×
        let mut parts = Vec::new();
8796×
        let (text, stop) = read_stream("anthropic-messages", r, quiet, &mut |p: &str| parts.push(p.to_string())).await.unwrap();
8801×
        assert_eq!((text.as_str(), stop), ("0 1 2 3 4 5 ", Stop::Done));
8811×
        assert_eq!(parts.len(), 6, "each part handed out as it came");
882
        // Two parts, then silence: cut off, the two kept.
8831×
        let url = serve(2, 50, true).await;
8841×
        let r = reqwest::Client::new().post(&url).send().await.unwrap();
8852×
        let (text, stop) = read_stream("anthropic-messages", r, quiet, &mut |_: &str| {}).await.unwrap();
8861×
        assert_eq!((text.as_str(), stop), ("0 1 ", Stop::Quiet));
8871×
        assert!(finish(text, Stop::Quiet).unwrap().contains("went quiet"));
8881×
    }
889
890
    #[test]
8911×
    fn tokens_seal_and_open_only_under_their_key() {
8921×
        let dir = tempfile::tempdir().unwrap();
8931×
        let k = SecretKey::load_or_create(dir.path()).unwrap();
8941×
        let sealed = k.seal("sk-very-secret");
8951×
        assert_ne!(sealed, b"sk-very-secret");
8961×
        assert!(!String::from_utf8_lossy(&sealed).contains("secret"));
8971×
        assert_eq!(k.open(&sealed).as_deref(), Some("sk-very-secret"));
8981×
        assert_eq!(SecretKey::load_or_create(dir.path()).unwrap().open(&sealed).as_deref(), Some("sk-very-secret"), "the key is kept");
8991×
        let other = SecretKey::load_or_create(tempfile::tempdir().unwrap().path()).unwrap();
9001×
        assert_eq!(other.open(&sealed), None);
9011×
        assert_eq!(k.open(&[1, 2, 3]), None);
9021×
        assert_eq!(k.seal(""), Vec::<u8>::new());
9031×
        assert_eq!(k.open(&[]).as_deref(), Some(""));
9041×
    }
905
906
    #[test]
9071×
    fn answers_say_why_they_stopped() {
9084×
        let a = |v: serde_json::Value| {
9094×
            let (text, stop) = answer_of("anthropic-messages", &v);
9104×
            finish(text, stop)
9114×
        };
912
        // #201: all of the length spent thinking.
9131×
        let thought = serde_json::json!({ "stop_reason": "max_tokens", "content": [{ "type": "thinking", "thinking": "" }] });
9141×
        assert!(a(thought).unwrap_err().contains("ran out of room"));
9151×
        let cut = serde_json::json!({ "stop_reason": "max_tokens", "content": [{ "type": "thinking", "thinking": "" }, { "type": "text", "text": "Half" }] });
9161×
        assert!(a(cut).unwrap().starts_with("Half\n\n*(Cut short"));
9171×
        let declined = serde_json::json!({ "stop_reason": "refusal", "stop_details": { "category": "cyber" }, "content": [] });
9181×
        assert_eq!(a(declined).unwrap_err(), "it declined to answer (cyber)");
9191×
        let ok = serde_json::json!({ "stop_reason": "end_turn", "content": [{ "type": "text", "text": "Fine." }] });
9201×
        assert_eq!(a(ok).unwrap(), "Fine.");
9213×
        let chat = |v: serde_json::Value| {
9223×
            let (text, stop) = answer_of("chat-completions", &v);
9233×
            finish(text, stop)
9243×
        };
9251×
        assert!(chat(serde_json::json!({ "choices": [{ "finish_reason": "length", "message": { "content": "" } }] }))
9261×
            .unwrap_err()
9271×
            .contains("ran out of room"));
9281×
        assert_eq!(
9291×
            chat(serde_json::json!({ "choices": [{ "finish_reason": "content_filter", "message": { "content": "" } }] })).unwrap_err(),
930
            "it declined to answer"
931
        );
9321×
        assert_eq!(
9331×
            chat(serde_json::json!({ "choices": [{ "finish_reason": "stop", "message": { "content": "Yes." } }] })).unwrap(),
934
            "Yes."
935
        );
9361×
    }
937
938
    #[test]
9391×
    fn a_refusal_for_length_is_told_from_others() {
9404×
        for e in [
9411×
            "400 This model's maximum context length is 8192 tokens. Please reduce the length of the messages.",
9421×
            "400 prompt is too long: 210000 tokens > 200000 maximum",
9431×
            "413 Request too large",
9441×
            "400 input length exceeds the context window",
9451×
        ] {
9464×
            assert!(super::too_long(e), "{e}");
947
        }
9484×
        for e in ["401 invalid api key", "500 too long a queue", "no answer within 90 seconds", "400 model not found"] {
9494×
            assert!(!super::too_long(e), "{e}");
950
        }
9511×
    }
952
953
    #[test]
9541×
    fn headers_parse_or_name_the_bad_line() {
9551×
        assert_eq!(
9561×
            parse_headers("X-Org: acme\n\n OpenAI-Beta : v1 ").unwrap(),
9571×
            vec![("X-Org".into(), "acme".into()), ("OpenAI-Beta".into(), "v1".into())]
958
        );
9591×
        assert_eq!(parse_headers("no colon").unwrap_err(), "no colon");
9601×
        assert_eq!(parse_headers("bad name: x").unwrap_err(), "bad name: x");
9611×
    }
962
}
963
964
// ------------------------------------------------------- reviewing
965
966
/// At most this many assistant calls per repository per hour.
967
pub const CALLS_PER_HOUR: usize = 30;
968
969
/// Who is answering what, and recent calls per repository.
970
#[derive(Default)]
971
pub struct Gate {
972
    busy: std::collections::HashSet<(i64, String)>,
973
    recent: std::collections::HashMap<i64, Vec<i64>>,
974
}
975
976
impl Gate {
977
    /// Take the slot for (proposal, assistant), or say why not.
9786×
    fn take(&mut self, repo_id: i64, proposal_id: i64, name: &str) -> Result<(), String> {
9796×
        let key = (proposal_id, name.to_lowercase());
9806×
        if self.busy.contains(&key) {
9810×
            return Err(format!("{name} is already answering this proposal"));
9826×
        }
9836×
        let now = now_ms();
9846×
        let calls = self.recent.entry(repo_id).or_default();
98510×
        calls.retain(|t| now - *t < 3_600_000);
9866×
        if calls.len() >= CALLS_PER_HOUR {
9870×
            return Err(format!("this repository has asked assistants {CALLS_PER_HOUR} times in the last hour; try later"));
9886×
        }
9896×
        calls.push(now);
9906×
        self.busy.insert(key);
9916×
        Ok(())
9926×
    }
993
9946×
    fn release(&mut self, proposal_id: i64, name: &str) {
9956×
        self.busy.remove(&(proposal_id, name.to_lowercase()));
9966×
    }
997
998
    /// The assistants answering on a proposal now (lower-case names, sorted).
99975×
    pub fn answering(&self, proposal_id: i64) -> Vec<String> {
100075×
        let mut v: Vec<String> = self.busy.iter().filter(|(p, _)| *p == proposal_id).map(|(_, n)| n.clone()).collect();
100175×
        v.sort();
100275×
        v
100375×
    }
1004
}
1005
1006
/// What a reviewer is told about a proposal: its title, description,
1007
/// changes and diff, cut to `budget` characters.
10082×
pub fn review_context(
10092×
    row: &crate::db::repos::RepoRow,
10102×
    p: &crate::db::proposals::ProposalRow,
10112×
    loaded: &crate::web::proposals::Loaded,
10122×
    budget: usize,
10132×
) -> String {
10142×
    let mut head =
10152×
        format!("# Proposal #{}: {}\n\nRepository {}/{} · into {} · by {}\n\n", p.number, p.title, row.owner, row.name, p.target, p.author);
10162×
    if !p.body.trim().is_empty() {
10172×
        head.push_str("## Description\n\n");
10182×
        head.push_str(p.body.trim());
10192×
        head.push_str("\n\n");
10202×
    }
10212×
    context_text(head, &loaded.changes, &loaded.diffs, budget)
10222×
}
1023
1024
/// What a drafter is told: the title so far, the changes and the diff.
10255×
pub fn draft_context(
10265×
    row: &crate::db::repos::RepoRow,
10275×
    title: &str,
10285×
    target: &str,
10295×
    changes: &[crate::repos::view::ChangeView],
10305×
    diffs: &[oxy_core::repo::FileDiff],
10315×
    budget: usize,
10325×
) -> String {
10335×
    let head = format!(
1034
        "# Proposal: {}\n\nRepository {}/{} · into {}\n\n",
10355×
        if title.trim().is_empty() { "(untitled)" } else { title.trim() },
1036
        row.owner,
1037
        row.name,
1038
        target
1039
    );
10405×
    context_text(head, changes, diffs, budget)
10415×
}
1042
1043
/// `head`, the list of changes, then the diff as `-`/`+` lines per hunk,
1044
/// binary files noted, the diff cut so the whole fits `budget`.
10457×
fn context_text(mut text: String, changes: &[crate::repos::view::ChangeView], diffs: &[oxy_core::repo::FileDiff], budget: usize) -> String {
10467×
    text.push_str(&format!("## Changes ({})\n\n", changes.len()));
10477×
    for c in changes {
10487×
        text.push_str(&format!("- {} {}\n", c.short, c.title));
10497×
        let body = c.body.trim();
10507×
        if !body.is_empty() {
10510×
            for line in body.lines().take(20) {
10520×
                text.push_str("  ");
10530×
                text.push_str(line);
10540×
                text.push('\n');
10550×
            }
10567×
        }
1057
    }
1058
    // Every file with its size first, so a model told only part of the
1059
    // diff still knows the whole (oxygenhub#248).
10607×
    text.push_str(&format!("\n## Files ({})\n\n", diffs.len()));
10617×
    let mut parts: Vec<(String, String)> = Vec::with_capacity(diffs.len());
10627×
    for f in diffs {
10637×
        let (added, removed) = f.hunks.iter().fold((0, 0), |(a, r), h| (a + h.added.len(), r + h.removed.len()));
10647×
        text.push_str(&format!("- {} ({}, +{added} -{removed})\n", f.path, f.kind));
10657×
        let mut diff = format!("### {} ({})\n", f.path, f.kind);
10667×
        if let Some(from) = &f.from {
10670×
            diff.push_str(&format!("renamed from {from}\n"));
10687×
        }
1069405×
        let binary = f.hunks.iter().any(|h| h.added.iter().chain(h.removed.iter().map(|(_, l)| l)).any(|l| l.contains(&0)));
10707×
        if binary {
10710×
            diff.push_str("(binary)\n\n");
10720×
            parts.push((f.path.clone(), diff));
10730×
            continue;
10747×
        }
10757×
        diff.push_str("```\n");
10767×
        for h in &f.hunks {
10777×
            diff.push_str(&format!("@@ after line {} @@\n", h.base_line));
10787×
            for (_, l) in &h.removed {
10790×
                diff.push('-');
10800×
                diff.push_str(String::from_utf8_lossy(l).trim_end_matches('\n'));
10810×
                diff.push('\n');
10820×
            }
1083405×
            for l in &h.added {
1084405×
                diff.push('+');
1085405×
                diff.push_str(String::from_utf8_lossy(l).trim_end_matches('\n'));
1086405×
                diff.push('\n');
1087405×
            }
1088
        }
10897×
        diff.push_str("```\n\n");
10907×
        parts.push((f.path.clone(), diff));
1091
    }
10927×
    text.push_str("\n## Diff\n\n");
1093
    // Whole files, in order, while they fit. One that doesn't is cut where
1094
    // the room ends if there is a fair amount left, else left out (a
1095
    // smaller one after it may still go in); either way it is named.
10967×
    let head = text.chars().count();
10977×
    let mut room = budget.saturating_sub(head + 200);
10987×
    let mut left_out = Vec::new();
10997×
    for (path, diff) in parts {
11007×
        let n = diff.chars().count();
11017×
        if n <= room {
11025×
            text.push_str(&diff);
11035×
            room -= n;
11045×
        } else if room >= 1_500 {
11052×
            let keep = room - 80;
11062×
            text.extend(diff.chars().take(keep));
11072×
            text.push_str("\n(the rest of this file was cut to fit)\n```\n\n");
11082×
            left_out.push(format!("{path} (cut)"));
11092×
            room = 0;
11102×
        } else {
11110×
            left_out.push(path);
11120×
        }
1113
    }
11147×
    if !left_out.is_empty() {
11152×
        let names: String = left_out.join(", ").chars().take(room.max(400)).collect();
11162×
        text.push_str(&format!("(left out to fit the context budget, {} file(s): {names})\n", left_out.len()));
11175×
    }
11187×
    text
11197×
}
1120
1121
/// The system prompt for a review: who the assistant is, what a review
1122
/// here is, then the prompts the levels set.
11231×
pub fn review_system_prompt(a: &Assistant, site: &str, prompts: &str) -> String {
11241×
    let mut s = format!(
1125
        "You are {name}, an AI assistant reviewing a proposal on {site}, an OxygenHub site. \
1126
         A person asked for your review; it is recorded as a review comment made via you, under their name. \
1127
         You cannot approve: only people approve. Answer in Markdown: start with a one-paragraph summary, \
1128
         then findings (correctness first, then clarity), each naming the file and quoting the lines it is about; \
1129
         keep praise out unless something is notably well done; say so plainly when you find nothing wrong.",
1130
        name = a.name
1131
    );
11321×
    if !prompts.trim().is_empty() {
11331×
        s.push_str("\n\n");
11341×
        s.push_str(prompts.trim());
11351×
    }
11361×
    s
11371×
}
1138
1139
/// Ask `a` to review proposal `p` for `by`: records the request, calls the
1140
/// endpoint in the background, and stores the answer as a `comment` review
1141
/// via the assistant (or an event saying why none came). `Err` is for the
1142
/// person who asked, when nothing was started.
11431×
pub async fn request_review(
11441×
    app: &Shared,
11451×
    row: &crate::db::repos::RepoRow,
11461×
    p: &crate::db::proposals::ProposalRow,
11471×
    by: &crate::db::accounts::Account,
11481×
    a: Assistant,
11491×
) -> Result<(), String> {
11501×
    app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).take(row.id, p.id, &a.name)?;
11511×
    let pool = &app.db;
11521×
    crate::db::proposals::add_event(pool, p.id, &by.username, &format!("asked {} to review", a.name), "").await.ok();
11531×
    app.bus.live(&crate::events::topic::proposal(row.id, p.number), "assistant");
11541×
    crate::db::accounts::audit(
11551×
        pool,
11561×
        Some(by),
11571×
        "assistant.asked",
11581×
        &format!("{}/{}", row.owner, row.name),
11591×
        &format!("{} #{}", a.name, p.number),
11601×
    )
11611×
    .await
11621×
    .ok();
11631×
    let (app, row, p, by) = (app.clone(), row.clone(), p.clone(), by.clone());
11641×
    tokio::spawn(async move {
11651×
        let outcome = answer_review(&app, &row, &p, &a).await;
11661×
        app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).release(p.id, &a.name);
11671×
        match outcome {
11681×
            Ok(body) => {
11691×
                if crate::db::proposals::add_review_via(&app.db, p.id, by.id, &by.username, "comment", &body, &a.name).await.is_ok() {
11701×
                    crate::db::proposals::add_event(&app.db, p.id, &by.username, &format!("review.comment via {}", a.name), "").await.ok();
11711×
                    crate::web::proposals::reviewed(&app, &row, &p, &by, "comment").await;
11721×
                    crate::hooks::emit(
11731×
                        &app,
11741×
                        &row,
11751×
                        "review",
11761×
                        &by.username,
11771×
                        serde_json::json!({ "number": p.number, "verdict": "comment", "via": a.name }),
11781×
                    )
11791×
                    .await;
11800×
                }
1181
            }
11820×
            Err(why) => {
11830×
                crate::db::proposals::add_event(&app.db, p.id, &by.username, &format!("{} did not answer", a.name), &why).await.ok();
11840×
                app.bus.live(&crate::events::topic::proposal(row.id, p.number), "assistant");
1185
            }
1186
        }
11871×
    });
11881×
    Ok(())
11891×
}
1190
11911×
async fn answer_review(
11921×
    app: &Shared,
11931×
    row: &crate::db::repos::RepoRow,
11941×
    p: &crate::db::proposals::ProposalRow,
11951×
    a: &Assistant,
11961×
) -> Result<String, String> {
11971×
    let repo = crate::repos::names::RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
11981×
    let loaded = crate::web::proposals::load(app, &repo, p.clone()).await.map_err(|e| format!("the proposal could not be loaded: {e}"))?;
11991×
    let budget = usize::try_from(a.budget).unwrap_or(DEFAULT_BUDGET as usize).max(1_000);
12001×
    let prompts = prompt_for_repo(&app.db, row).await.map_err(|e| e.to_string())?;
12011×
    let system = review_system_prompt(a, &app.config.site.name, &prompts);
12021×
    ask_within(app, a, &system, budget, BACKGROUND_TIMEOUT, |room| review_context(row, p, &loaded, room), |_| {}).await
12031×
}
1204
1205
/// Ask with the context cut to fit `budget` characters, system prompt
1206
/// included (oxygenhub#248). A provider that says the prompt is too long
1207
/// for its model is asked once more with a third of the room; whatever
1208
/// fails is said plainly, with what to do about it.
12096×
async fn ask_within(
12106×
    app: &Shared,
12116×
    a: &Assistant,
12126×
    system: &str,
12136×
    budget: usize,
12146×
    wait: Duration,
12156×
    context: impl Fn(usize) -> String,
12166×
    mut part: impl FnMut(&str),
12176×
) -> Result<String, String> {
12187×
    let room = |b: usize| b.saturating_sub(system.chars().count());
1219
    // A refusal for length comes before any part, so asking again starts
1220
    // the answer afresh.
12216×
    match ask_streaming(app, a, system, &context(room(budget)), wait, &mut part).await {
12221×
        Err(e) if too_long(&e) => {
12231×
            let smaller = (budget / 3).max(1_000);
12241×
            tracing::info!("assistant {}: too long at {budget} characters ({e}); asking again at {smaller}", a.name);
12251×
            ask_streaming(app, a, system, &context(room(smaller)), wait, &mut part).await.map_err(|e2| {
12260×
                format!(
1227
                    "the proposal was too large for {}'s model at {budget} characters and again at {smaller} ({e2}): lower this assistant's context budget",
1228
                    a.name
1229
                )
12300×
            })
1231
        }
12320×
        Err(e) if e.starts_with("no answer within") => Err(format!(
12330×
            "{e}: a large proposal on a slow or local model can take longer; lower this assistant's context budget, or use a faster model"
12340×
        )),
12355×
        other => other,
1236
    }
12376×
}
1238
1239
/// Whether a provider's refusal says the prompt is too long for its model.
12409×
fn too_long(e: &str) -> bool {
12419×
    let l = e.to_lowercase();
12429×
    (l.starts_with("400") || l.starts_with("413") || l.starts_with("422"))
12436×
        && [
12446×
            "too long",
12456×
            "too large",
12466×
            "context length",
12476×
            "context window",
12486×
            "maximum context",
12496×
            "too many tokens",
12506×
            "exceeds",
12516×
            "reduce the length",
12526×
            "input length",
12536×
        ]
12546×
        .iter()
125522×
        .any(|k| l.contains(k))
12569×
}
1257
1258
// -------------------------------------------------------- summaries
1259
1260
/// The system prompt for a summary posted as a comment (oxygenhub#190).
12611×
pub fn summary_system_prompt(a: &Assistant, site: &str, prompts: &str) -> String {
12621×
    let mut s = format!(
1263
        "You are {name}, an AI assistant working on {site}, an OxygenHub site, with a next-generation version control system \
1264
         called Oxygen (its command is oxy). A proposal is Oxygen's equivalent of a merge request or pull request. \
1265
         Please summarise the proposal and comment on it. A person asked for this; it is posted as a comment made via you, \
1266
         under their name. Answer in Markdown: a short summary of what the proposal changes and why, then anything a reviewer \
1267
         should look at closely; keep to what the description and the diff show.",
1268
        name = a.name
1269
    );
12701×
    if !prompts.trim().is_empty() {
12711×
        s.push_str("\n\n");
12721×
        s.push_str(prompts.trim());
12731×
    }
12741×
    s
12751×
}
1276
1277
/// Ask `a` to summarise proposal `p` for `by`: like [`request_review`],
1278
/// but the answer is a conversation comment via the assistant.
12791×
pub async fn request_summary(
12801×
    app: &Shared,
12811×
    row: &crate::db::repos::RepoRow,
12821×
    p: &crate::db::proposals::ProposalRow,
12831×
    by: &crate::db::accounts::Account,
12841×
    a: Assistant,
12851×
) -> Result<(), String> {
12861×
    app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).take(row.id, p.id, &a.name)?;
12871×
    let pool = &app.db;
12881×
    crate::db::proposals::add_event(pool, p.id, &by.username, &format!("asked {} for a summary", a.name), "").await.ok();
12891×
    app.bus.live(&crate::events::topic::proposal(row.id, p.number), "assistant");
12901×
    crate::db::accounts::audit(
12911×
        pool,
12921×
        Some(by),
12931×
        "assistant.asked",
12941×
        &format!("{}/{}", row.owner, row.name),
12951×
        &format!("{} #{} summary", a.name, p.number),
12961×
    )
12971×
    .await
12981×
    .ok();
12991×
    let (app, row, p, by) = (app.clone(), row.clone(), p.clone(), by.clone());
13001×
    tokio::spawn(async move {
13011×
        let outcome = answer_summary(&app, &row, &p, &a).await;
13021×
        app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).release(p.id, &a.name);
13031×
        match outcome {
13041×
            Ok(body) => {
13051×
                crate::db::proposals::add_comment_via(&app.db, p.id, by.id, &by.username, &body, &a.name).await.ok();
13061×
                app.bus.live(&crate::events::topic::proposal(row.id, p.number), "comment");
1307
            }
13080×
            Err(why) => {
13090×
                crate::db::proposals::add_event(&app.db, p.id, &by.username, &format!("{} did not answer", a.name), &why).await.ok();
13100×
                app.bus.live(&crate::events::topic::proposal(row.id, p.number), "assistant");
1311
            }
1312
        }
13131×
    });
13141×
    Ok(())
13151×
}
1316
13171×
async fn answer_summary(
13181×
    app: &Shared,
13191×
    row: &crate::db::repos::RepoRow,
13201×
    p: &crate::db::proposals::ProposalRow,
13211×
    a: &Assistant,
13221×
) -> Result<String, String> {
13231×
    let repo = crate::repos::names::RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
13241×
    let loaded = crate::web::proposals::load(app, &repo, p.clone()).await.map_err(|e| format!("the proposal could not be loaded: {e}"))?;
13251×
    let budget = usize::try_from(a.budget).unwrap_or(DEFAULT_BUDGET as usize).max(1_000);
13261×
    let prompts = prompt_for_repo(&app.db, row).await.map_err(|e| e.to_string())?;
13271×
    let system = summary_system_prompt(a, &app.config.site.name, &prompts);
13281×
    ask_within(app, a, &system, budget, BACKGROUND_TIMEOUT, |room| review_context(row, p, &loaded, room), |_| {}).await
13291×
}
1330
1331
// -------------------------------------------------------- drafting
1332
1333
/// The system prompt for drafting a proposal's description; `with_title`,
1334
/// for a proposal not yet opened, asks for a title line first (#316).
13354×
pub fn draft_system_prompt(a: &Assistant, site: &str, prompts: &str, with_title: bool) -> String {
13364×
    let shape = if with_title {
13372×
        "Begin with one line `Title: ` and a title of at most 72 characters, in the style of the changes' own titles \
13382×
         (Conventional Commits if they use them), then a blank line, then the description in Markdown: \
13392×
         no greeting, no sign-off."
1340
    } else {
13412×
        "Write Markdown for the description field only: no title line, no greeting, no sign-off."
1342
    };
13434×
    let mut s = format!(
1344
        "You are {name}, an AI assistant drafting the description of a proposal on {site}, an OxygenHub site, \
1345
         for the person who will open or edit it; they read and change your draft before saving, and the draft is theirs. \
1346
         {shape} \
1347
         Say what the changes do and why, what a reviewer should look at, and how to verify them; \
1348
         keep to what the diff shows, and keep it short.",
1349
        name = a.name
1350
    );
13514×
    if !prompts.trim().is_empty() {
13523×
        s.push_str("\n\n");
13533×
        s.push_str(prompts.trim());
13543×
    }
13554×
    s
13564×
}
1357
1358
/// What a description is drafted from.
1359
pub struct Draft<'a> {
1360
    /// The proposal's id, or minus the repository's for one not yet opened:
1361
    /// one draft per assistant at a time for it.
1362
    pub slot: i64,
1363
    pub title: &'a str,
1364
    pub target: &'a str,
1365
    pub changes: &'a [crate::repos::view::ChangeView],
1366
    pub diffs: &'a [oxy_core::repo::FileDiff],
1367
    /// A proposal not yet opened: the draft begins with a title line (#316).
1368
    pub with_title: bool,
1369
}
1370
1371
/// A draft's `Title: …` first line, if it has one, and the description
1372
/// after it (#316).
13736×
pub fn split_title(text: &str) -> (Option<String>, String) {
13746×
    let text = text.trim_start();
13756×
    let (first, rest) = text.split_once('\n').unwrap_or((text, ""));
13766×
    let Some(t) = first.trim().strip_prefix("Title:").or_else(|| first.trim().strip_prefix("title:")) else {
13773×
        return (None, text.trim().to_string());
1378
    };
13796×
    let t: String = t.trim().trim_matches(|c| c == '"' || c == '`' || c == '*').trim().chars().take(120).collect();
13803×
    ((!t.is_empty()).then_some(t), rest.trim().to_string())
13816×
}
1382
1383
/// Ask `a` for a description, now; the answer goes into the editor,
1384
/// nothing is saved. `Err` is for the person.
13853×
pub async fn draft_description(
13863×
    app: &Shared,
13873×
    row: &crate::db::repos::RepoRow,
13883×
    by: &crate::db::accounts::Account,
13893×
    a: &Assistant,
13903×
    d: Draft<'_>,
13913×
) -> Result<String, String> {
13923×
    draft_description_streaming(app, row, by, a, d, |_| {}).await
13933×
}
1394
1395
/// [`draft_description`], handing each part of the draft to `part` as it
1396
/// comes, for a page that shows it being written (oxygenhub#248).
13974×
pub async fn draft_description_streaming(
13984×
    app: &Shared,
13994×
    row: &crate::db::repos::RepoRow,
14004×
    by: &crate::db::accounts::Account,
14014×
    a: &Assistant,
14024×
    d: Draft<'_>,
14034×
    part: impl FnMut(&str),
14044×
) -> Result<String, String> {
14054×
    let Draft { slot, title, target, changes, diffs, with_title } = d;
14064×
    app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).take(row.id, slot, &a.name)?;
14074×
    crate::db::accounts::audit(&app.db, Some(by), "assistant.drafted", &format!("{}/{}", row.owner, row.name), &a.name).await.ok();
14084×
    let budget = usize::try_from(a.budget).unwrap_or(DEFAULT_BUDGET as usize).max(1_000);
14094×
    let prompts = prompt_for_repo(&app.db, row).await.map_err(|e| e.to_string());
14104×
    let outcome = match prompts {
14114×
        Ok(prompts) => {
14124×
            let system = draft_system_prompt(a, &app.config.site.name, &prompts, with_title);
14135×
            ask_within(app, a, &system, budget, CALL_TIMEOUT, |room| draft_context(row, title, target, changes, diffs, room), part).await
1414
        }
14150×
        Err(e) => Err(e),
1416
    };
14174×
    app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).release(slot, &a.name);
14184×
    outcome.map(|t| t.trim().to_string())
14194×
}