e71846aa Andy McCall 1 month ago ↶
1
2
3
4
5
6
7
8
9
10
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
21
pub const APIS : [ & str ; 2 ] = [ " chat-completions" , " anthropic-messages" ] ;
22
23
24
pub const DEFAULT_BUDGET : i64 = 120_000 ;
5360cea3 Andy McCall 22 days ago ↶
25
26
27
pub const CALL_TIMEOUT : Duration = Duration:: from_secs( 90 ) ;
28
29
30
31
pub const BACKGROUND_TIMEOUT : Duration = Duration:: from_secs( 300 ) ;
32
33
34
pub const MAX_ANSWER_TOKENS : u32 = 16_000 ;
e71846aa Andy McCall 1 month ago ↶
35
36
const MAX_ANSWER : usize = 64 * 1024 ;
37
2a83f530 Andy McCall 1 month ago ↶
38
pub use crate :: levels:: Scope;
e71846aa Andy McCall 1 month ago ↶
39
40
# [ derive ( Clone, Debug, FromRow ) ]
41
pub struct Assistant {
42
pub id : i64 ,
43
pub scope : String,
44
pub scope_id : i64 ,
45
pub name : String,
46
pub endpoint : String,
47
48
pub api : String,
49
pub model : String,
50
51
pub headers : String,
52
53
pub token : Vec< u8 > ,
54
pub enabled : bool ,
55
pub budget : i64 ,
56
pub created_ms : i64 ,
57
pub updated_ms : i64 ,
58
}
59
60
impl Assistant {
61
pub fn scope ( & self ) -> Scope {
62
match self .scope.as_str ( ) {
63
" org" => Scope:: Org( self .scope_id) ,
64
" repo" => Scope:: Repo( self .scope_id) ,
65
_ => Scope:: Site,
66
}
67
}
68
69
pub fn has_token ( & self ) -> bool {
70
! self .token.is_empty ( )
71
}
72
73
74
pub fn header_list ( & self ) -> Vec< ( String, String) > {
75
parse_headers ( & self .headers) .unwrap_or_default ( )
76
}
77
}
78
79
80
# [ derive ( Clone, Debug, Default ) ]
81
pub struct Fields {
82
pub name : String,
83
pub endpoint : String,
84
pub api : String,
85
pub model : String,
86
pub headers : String,
87
pub enabled : bool ,
88
pub budget : i64 ,
89
}
90
91
92
pub fn parse_headers ( text : & str ) -> Result< Vec< ( String, String) > , String> {
93
let mut out = vec! [ ] ;
94
for line in text.lines ( ) .map ( str :: trim) .filter ( | l | ! l.is_empty ( ) ) {
95
let Some ( ( name, value) ) = line.split_once ( ' :' ) else { return Err ( line.to_string ( ) ) } ;
96
let ( name, value) = ( name.trim ( ) , value.trim ( ) ) ;
97
let name_ok = ! name.is_empty ( ) & & name.chars ( ) .all ( | c | c.is_ascii_alphanumeric ( ) | | c = = ' -' | | c = = ' _' ) ;
98
if ! name_ok | | value.chars ( ) .any ( | c | c.is_control ( ) ) {
99
return Err ( line.to_string ( ) ) ;
100
}
101
out.push ( ( name.to_string ( ) , value.to_string ( ) ) ) ;
102
}
103
Ok ( out)
104
}
105
106
107
108
109
# [ derive ( Clone ) ]
110
pub struct SecretKey ( [u8 ; 32]) ;
111
112
impl SecretKey {
113
pub fn load_or_create ( keys : & Path) -> anyhow:: Result< SecretKey> {
4ac01a73 Andy McCall 1 month ago ↶
114
SecretKey:: load_or_create_named( keys, " assistants.key" )
115
}
116
117
118
119
pub fn load_or_create_named ( keys : & Path, file : & str ) -> anyhow:: Result< SecretKey> {
e71846aa Andy McCall 1 month ago ↶
120
std:: fs:: create_dir_all( keys) ? ;
4ac01a73 Andy McCall 1 month ago ↶
121
let path = keys.join ( file) ;
e71846aa Andy McCall 1 month ago ↶
122
if let Ok ( bytes) = std:: fs:: read( & path) {
123
if bytes.len ( ) = = 32 {
124
return Ok ( SecretKey( bytes.try_into ( ) .unwrap ( ) ) ) ;
125
}
126
}
127
let mut k = [ 0 u8 ; 32 ] ;
128
rand:: rngs:: OsRng.fill_bytes ( & mut k) ;
129
std:: fs:: write( & path, k) ? ;
130
# [ cfg ( unix ) ]
131
{
132
use std:: os:: unix:: fs:: PermissionsExt;
133
std:: fs:: set_permissions( & path, std:: fs:: Permissions:: from_mode( 0o600 ) ) ? ;
134
}
135
Ok ( SecretKey( k) )
136
}
137
138
fn key ( & self ) -> aead:: LessSafeKey {
139
aead:: LessSafeKey:: new( aead:: UnboundKey:: new( & aead:: CHACHA20_POLY1305 , & self .0 ) .expect ( " key length" ) )
140
}
141
142
143
144
pub fn seal ( & self , plain : & str ) -> Vec< u8 > {
145
if plain.is_empty ( ) {
146
return vec! [ ] ;
147
}
148
let mut nonce = [ 0 u8 ; 12 ] ;
149
rand:: rngs:: OsRng.fill_bytes ( & mut nonce) ;
150
let mut out = plain.as_bytes ( ) .to_vec ( ) ;
151
self .key ( ) .seal_in_place_append_tag ( aead:: Nonce:: assume_unique_for_key( nonce) , aead:: Aad:: empty( ) , & mut out) .expect ( " seal" ) ;
152
let mut sealed = nonce.to_vec ( ) ;
153
sealed.extend ( out) ;
154
sealed
155
}
156
157
158
159
pub fn open ( & self , sealed : & [u8 ]) -> Option< String> {
160
if sealed.is_empty ( ) {
161
return Some ( String :: new( ) ) ;
162
}
163
let ( nonce, rest) = sealed.split_at_checked ( 12 ) ? ;
164
let mut buf = rest.to_vec ( ) ;
165
let nonce = aead:: Nonce:: try_assume_unique_for_key( nonce) .ok ( ) ? ;
166
let plain = self .key ( ) .open_in_place ( nonce, aead:: Aad:: empty( ) , & mut buf) .ok ( ) ? ;
167
String :: from_utf8( plain.to_vec ( ) ) .ok ( )
168
}
169
}
170
171
172
173
pub async fn list ( pool : & Pool, scope : Scope) -> sqlx:: Result< Vec< Assistant> > {
5d90e0e0 Andy McCall 21 days ago
174
sqlx:: query_as( " SELECT * FROM assistants WHERE scope = $1 AND scope_id = $2 ORDER BY name" )
e71846aa Andy McCall 1 month ago ↶
175
.bind ( scope.kind ( ) )
176
.bind ( scope.id ( ) )
177
.fetch_all ( pool)
178
.await
179
}
180
181
pub async fn get ( pool : & Pool, id : i64 ) -> sqlx:: Result< Option< Assistant> > {
5d90e0e0 Andy McCall 21 days ago
182
sqlx:: query_as( " SELECT * FROM assistants WHERE id = $1" ) .bind ( id) .fetch_optional ( pool) .await
e71846aa Andy McCall 1 month ago ↶
183
}
184
185
186
pub async fn create ( pool : & Pool, scope : Scope, f : & Fields, token : & [u8 ]) -> sqlx:: Result< Option< Assistant> > {
187
let now = now_ms ( ) ;
188
let r = sqlx:: query_as(
189
" INSERT INTO assistants (scope, scope_id, name, endpoint, api, model, headers, token, enabled, budget, created_ms, updated_ms) \
5d90e0e0 Andy McCall 21 days ago
190
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT DO NOTHING RETURNING *" ,
e71846aa Andy McCall 1 month ago ↶
191
)
192
.bind ( scope.kind ( ) )
193
.bind ( scope.id ( ) )
194
.bind ( & f.name)
195
.bind ( & f.endpoint)
196
.bind ( & f.api)
197
.bind ( & f.model)
198
.bind ( & f.headers)
199
.bind ( token)
200
.bind ( f.enabled)
201
.bind ( f.budget)
202
.bind ( now)
203
.bind ( now)
204
.fetch_optional ( pool)
205
.await? ;
206
Ok ( r)
207
}
208
209
210
211
pub async fn update ( pool : & Pool, id : i64 , f : & Fields, token : Option< & [ u8 ] > ) -> sqlx:: Result< bool > {
212
let r = match token {
213
Some ( t) => sqlx:: query(
5d90e0e0 Andy McCall 21 days ago
214
" UPDATE assistants SET name = $1, endpoint = $2, api = $3, model = $4, headers = $5, enabled = $6, budget = $7, token = $8, updated_ms = $9 \
215
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)" ,
e71846aa Andy McCall 1 month ago ↶
216
)
217
.bind ( & f.name)
218
.bind ( & f.endpoint)
219
.bind ( & f.api)
220
.bind ( & f.model)
221
.bind ( & f.headers)
222
.bind ( f.enabled)
223
.bind ( f.budget)
224
.bind ( t)
225
.bind ( now_ms ( ) )
226
.bind ( id)
227
.bind ( & f.name)
228
.bind ( id)
229
.execute ( pool)
230
.await? ,
231
None => sqlx:: query(
5d90e0e0 Andy McCall 21 days ago
232
" UPDATE assistants SET name = $1, endpoint = $2, api = $3, model = $4, headers = $5, enabled = $6, budget = $7, updated_ms = $8 \
233
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)" ,
e71846aa Andy McCall 1 month ago ↶
234
)
235
.bind ( & f.name)
236
.bind ( & f.endpoint)
237
.bind ( & f.api)
238
.bind ( & f.model)
239
.bind ( & f.headers)
240
.bind ( f.enabled)
241
.bind ( f.budget)
242
.bind ( now_ms ( ) )
243
.bind ( id)
244
.bind ( & f.name)
245
.bind ( id)
246
.execute ( pool)
247
.await? ,
248
} ;
249
Ok ( r.rows_affected ( ) = = 1 )
250
}
251
ed89fae5 Andy McCall 1 month ago ↶
252
253
254
255
pub async fn rename_in_history ( pool : & Pool, scope : Scope, owner_hint : & str , old : & str , new : & str ) -> sqlx:: Result< ( ) > {
256
if old = = new | | old.is_empty ( ) | | new.is_empty ( ) {
257
return Ok ( ( ) ) ;
258
}
259
let like = format! ( " %{old} %" ) ;
260
match scope {
261
Scope:: Site => {
ffb67a81 Andy McCall 23 days ago
262
for table in [ " proposal_reviews" , " proposal_comments" ] {
5d90e0e0 Andy McCall 21 days ago
263
sqlx:: query( & format! ( " UPDATE {table} SET via = $1 WHERE via = $2" ) ) .bind ( new) .bind ( old) .execute ( pool) .await? ;
ffb67a81 Andy McCall 23 days ago
264
}
5d90e0e0 Andy McCall 21 days ago
265
sqlx:: query( " UPDATE proposal_events SET event = replace(event, $1, $2) WHERE event LIKE $3" )
ed89fae5 Andy McCall 1 month ago ↶
266
.bind ( old)
267
.bind ( new)
268
.bind ( & like)
269
.execute ( pool)
270
.await? ;
271
}
272
Scope:: Org( _ ) => {
5d90e0e0 Andy McCall 21 days ago
273
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)" ;
ffb67a81 Andy McCall 23 days ago
274
for table in [ " proposal_reviews" , " proposal_comments" ] {
5d90e0e0 Andy McCall 21 days ago
275
sqlx:: query( & format! ( " UPDATE {table} SET via = $1 WHERE via = $2 AND {in_org} " ) )
ffb67a81 Andy McCall 23 days ago
276
.bind ( new)
277
.bind ( old)
278
.bind ( owner_hint)
279
.execute ( pool)
280
.await? ;
281
}
5d90e0e0 Andy McCall 21 days ago
282
sqlx:: query( & format! ( " UPDATE proposal_events SET event = replace(event, $1, $2) WHERE event LIKE $3 AND {in_org} " ) )
ed89fae5 Andy McCall 1 month ago ↶
283
.bind ( old)
284
.bind ( new)
285
.bind ( & like)
286
.bind ( owner_hint)
287
.execute ( pool)
288
.await? ;
289
}
290
Scope:: Repo( id) => {
5d90e0e0 Andy McCall 21 days ago
291
let in_repo = " proposal_id IN (SELECT id FROM proposals WHERE repo_id = $1)" ;
ffb67a81 Andy McCall 23 days ago
292
for table in [ " proposal_reviews" , " proposal_comments" ] {
5d90e0e0 Andy McCall 21 days ago
293
sqlx:: query( & format! ( " UPDATE {table} SET via = $1 WHERE via = $2 AND {in_repo} " ) )
ffb67a81 Andy McCall 23 days ago
294
.bind ( new)
295
.bind ( old)
296
.bind ( id)
297
.execute ( pool)
298
.await? ;
299
}
5d90e0e0 Andy McCall 21 days ago
300
sqlx:: query( & format! ( " UPDATE proposal_events SET event = replace(event, $1, $2) WHERE event LIKE $3 AND {in_repo} " ) )
ed89fae5 Andy McCall 1 month ago ↶
301
.bind ( old)
302
.bind ( new)
303
.bind ( & like)
304
.bind ( id)
305
.execute ( pool)
306
.await? ;
307
}
308
}
309
Ok ( ( ) )
310
}
311
e71846aa Andy McCall 1 month ago ↶
312
pub async fn set_enabled ( pool : & Pool, id : i64 , enabled : bool ) -> sqlx:: Result< ( ) > {
5d90e0e0 Andy McCall 21 days ago
313
sqlx:: query( " UPDATE assistants SET enabled = $1, updated_ms = $2 WHERE id = $3" )
e71846aa Andy McCall 1 month ago ↶
314
.bind ( enabled)
315
.bind ( now_ms ( ) )
316
.bind ( id)
317
.execute ( pool)
318
.await? ;
319
Ok ( ( ) )
320
}
321
322
pub async fn delete ( pool : & Pool, id : i64 ) -> sqlx:: Result< ( ) > {
5d90e0e0 Andy McCall 21 days ago
323
sqlx:: query( " DELETE FROM assistants WHERE id = $1" ) .bind ( id) .execute ( pool) .await? ;
e71846aa Andy McCall 1 month ago ↶
324
Ok ( ( ) )
325
}
326
327
328
329
pub async fn delete_scope ( pool : & Pool, scope : Scope) -> sqlx:: Result< ( ) > {
5d90e0e0 Andy McCall 21 days ago
330
sqlx:: query( " DELETE FROM assistants WHERE scope = $1 AND scope_id = $2" ) .bind ( scope.kind ( ) ) .bind ( scope.id ( ) ) .execute ( pool) .await? ;
331
sqlx:: query( " DELETE FROM assistant_prompts WHERE scope = $1 AND scope_id = $2" )
332
.bind ( scope.kind ( ) )
333
.bind ( scope.id ( ) )
334
.execute ( pool)
335
.await? ;
e71846aa Andy McCall 1 month ago ↶
336
Ok ( ( ) )
337
}
338
339
340
pub async fn prompt ( pool : & Pool, scope : Scope) -> sqlx:: Result< String> {
5d90e0e0 Andy McCall 21 days ago
341
Ok ( sqlx:: query_scalar( " SELECT prompt FROM assistant_prompts WHERE scope = $1 AND scope_id = $2" )
e71846aa Andy McCall 1 month ago ↶
342
.bind ( scope.kind ( ) )
343
.bind ( scope.id ( ) )
344
.fetch_optional ( pool)
345
.await?
346
.unwrap_or_default ( ) )
347
}
348
349
pub async fn set_prompt ( pool : & Pool, scope : Scope, text : & str ) -> sqlx:: Result< ( ) > {
350
if text.trim ( ) .is_empty ( ) {
5d90e0e0 Andy McCall 21 days ago
351
sqlx:: query( " DELETE FROM assistant_prompts WHERE scope = $1 AND scope_id = $2" )
e71846aa Andy McCall 1 month ago ↶
352
.bind ( scope.kind ( ) )
353
.bind ( scope.id ( ) )
354
.execute ( pool)
355
.await? ;
356
} else {
357
sqlx:: query(
5d90e0e0 Andy McCall 21 days ago
358
" INSERT INTO assistant_prompts (scope, scope_id, prompt, updated_ms) VALUES ($1, $2, $3, $4) \
e71846aa Andy McCall 1 month ago ↶
359
ON CONFLICT (scope, scope_id) DO UPDATE SET prompt = excluded.prompt, updated_ms = excluded.updated_ms" ,
360
)
361
.bind ( scope.kind ( ) )
362
.bind ( scope.id ( ) )
363
.bind ( text.trim ( ) )
364
.bind ( now_ms ( ) )
365
.execute ( pool)
366
.await? ;
367
}
368
Ok ( ( ) )
369
}
370
371
372
373
374
375
# [ derive ( Clone, Debug ) ]
376
pub struct Available {
377
pub assistant : Assistant,
378
379
pub from : String,
380
381
pub off_above : bool ,
382
}
383
384
impl Available {
385
pub fn on ( & self ) -> bool {
386
self .assistant.enabled & & ! self .off_above
387
}
388
}
389
390
391
392
393
pub async fn available ( pool : & Pool, scope : Scope, owner : & str ) -> sqlx:: Result< Vec< Available> > {
2a83f530 Andy McCall 1 month ago ↶
394
let mut levels = crate :: levels:: above( pool, scope, owner) .await? ;
e71846aa Andy McCall 1 month ago ↶
395
levels.push ( ( scope, String :: new( ) ) ) ;
396
let mut out: Vec< Available> = vec! [ ] ;
397
let mut off: Vec< String> = vec! [ ] ;
398
for ( level, from) in levels {
399
for a in list ( pool, level) .await? {
400
let off_above = off.iter ( ) .any ( | n | n.eq_ignore_ascii_case ( & a.name) ) ;
401
if ! a.enabled & & ! off.iter ( ) .any ( | n | n.eq_ignore_ascii_case ( & a.name) ) {
402
off.push ( a.name.clone ( ) ) ;
403
}
404
out.retain ( | o | ! o.assistant.name.eq_ignore_ascii_case ( & a.name) ) ;
405
out.push ( Available { assistant: a, from: from.clone ( ) , off_above } ) ;
406
}
407
}
408
Ok ( out)
409
}
410
411
412
pub async fn enabled_for_repo ( pool : & Pool, repo : & crate:: db:: repos:: RepoRow) -> sqlx:: Result< Vec< Assistant> > {
413
Ok ( available ( pool, Scope:: Repo( repo.id) , & repo.owner) .await? .into_iter ( ) .filter ( Available:: on) .map ( | a | a.assistant ) .collect ( ) )
414
}
415
416
417
418
pub async fn inherited_prompts ( pool : & Pool, scope : Scope, owner : & str ) -> sqlx:: Result< Vec< ( String, String) > > {
419
let mut out = vec! [ ] ;
2a83f530 Andy McCall 1 month ago ↶
420
for ( level, from) in crate :: levels:: above( pool, scope, owner) .await? {
e71846aa Andy McCall 1 month ago ↶
421
let text = prompt ( pool, level) .await? ;
422
if ! text.is_empty ( ) {
423
out.push ( ( from, text) ) ;
424
}
425
}
426
Ok ( out)
427
}
428
429
430
431
pub async fn prompt_for_repo ( pool : & Pool, repo : & crate:: db:: repos:: RepoRow) -> sqlx:: Result< String> {
432
let scope = Scope:: Repo( repo.id) ;
433
let mut parts = inherited_prompts ( pool, scope, & repo.owner) .await? ;
434
let own = prompt ( pool, scope) .await? ;
435
if ! own.is_empty ( ) {
436
parts.push ( ( " this repository" .to_string ( ) , own) ) ;
437
}
438
Ok ( parts.into_iter ( ) .map ( | ( from , text ) | format! ( " ## Instructions from {from} \n \n {text} \n " ) ) .collect:: < Vec< _ > > ( ) .join ( " \n " ) )
439
}
440
441
442
443
444
445
5360cea3 Andy McCall 22 days ago ↶
446
pub async fn ask ( app : & Shared, a : & Assistant, system : & str , user : & str , wait : Duration) -> Result< String, String> {
e71846aa Andy McCall 1 month ago ↶
447
let Some ( token) = app.assistant_key.open ( & a.token) else {
448
return Err ( " its token cannot be read: set it again" .into ( ) ) ;
449
} ;
9a777fec Andy McCall 21 days ago ↶
450
let client = crate :: net:: client( & a.endpoint, & app.config.outbound.allow, wait) .await? ;
e71846aa Andy McCall 1 month ago ↶
451
let mut req = client.post ( & a.endpoint) .header ( " content-type" , " application/json" ) ;
452
let body = match a.api.as_str ( ) {
453
" anthropic-messages" => {
454
if ! token.is_empty ( ) {
455
req = req.header ( " x-api-key" , & token) ;
456
}
457
req = req.header ( " anthropic-version" , " 2023-06-01" ) ;
458
serde_json:: json! ( {
459
" model" : a.model,
5360cea3 Andy McCall 22 days ago ↶
460
" max_tokens" : MAX_ANSWER_TOKENS ,
e71846aa Andy McCall 1 month ago ↶
461
" system" : system,
462
" messages" : [ { " role" : " user" , " content" : user } ] ,
463
} )
464
}
465
_ => {
466
if ! token.is_empty ( ) {
467
req = req.header ( " authorization" , format! ( " Bearer {token} " ) ) ;
468
}
5360cea3 Andy McCall 22 days ago ↶
469
470
471
e71846aa Andy McCall 1 month ago ↶
472
serde_json:: json! ( {
473
" model" : a.model,
474
" messages" : [
475
{ " role" : " system" , " content" : system } ,
476
{ " role" : " user" , " content" : user } ,
477
] ,
478
} )
479
}
480
} ;
481
for ( name, value) in a.header_list ( ) {
482
req = req.header ( name, value) ;
483
}
484
let r = req.json ( & body) .send ( ) .await.map_err ( | e | {
485
if e.is_timeout ( ) {
5360cea3 Andy McCall 22 days ago ↶
486
format! ( " no answer within {} seconds" , wait.as_secs ( ) )
e71846aa Andy McCall 1 month ago ↶
487
} else {
488
scrub ( & e.to_string ( ) , & token)
489
}
490
} ) ? ;
491
let status = r.status ( ) ;
492
let text: String = r.text ( ) .await.unwrap_or_default ( ) .chars ( ) .take ( MAX_ANSWER ) .collect ( ) ;
493
if ! status.is_success ( ) {
494
let why = serde_json:: from_str:: < serde_json:: Value> ( & text)
495
.ok ( )
496
.and_then ( | v | v[ " error" ] [ " message" ] .as_str ( ) .or ( v[ " error" ] .as_str ( ) ) .or ( v[ " message" ] .as_str ( ) ) .map ( str :: to_string) )
497
.unwrap_or_else ( | | text.lines ( ) .next ( ) .unwrap_or ( " " ) .chars ( ) .take ( 160 ) .collect ( ) ) ;
498
return Err ( scrub ( & format! ( " {} {} " , status.as_u16 ( ) , why.trim ( ) ) , & token) ) ;
499
}
500
let v: serde_json:: Value = serde_json:: from_str( & text) .map_err ( | _| " the answer was not JSON" .to_string ( ) ) ? ;
5360cea3 Andy McCall 22 days ago ↶
501
let ( answer, stop) = answer_of ( & a.api, & v) ;
502
finish ( answer, stop)
503
}
504
505
506
# [ derive ( Debug, PartialEq, Eq ) ]
507
enum Stop {
508
509
Done,
510
511
Length,
512
513
Declined( Option< String> ) ,
514
}
515
516
517
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
}
e71846aa Andy McCall 1 month ago ↶
553
}
554
}
555
5360cea3 Andy McCall 22 days ago ↶
556
557
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
155cab14 Andy McCall 1 month ago ↶
573
574
575
pub const PRESETS : & [ ( & str , & str , & str , & str , & str ) ] = & [
576
( " anthropic" , " Anthropic (Claude)" , " Claude" , " https://api.anthropic.com/v1/messages" , " anthropic-messages" ) ,
577
( " openai" , " OpenAI (ChatGPT)" , " ChatGPT" , " https://api.openai.com/v1/chat/completions" , " chat-completions" ) ,
578
( " gemini" , " Google Gemini" , " Gemini" , " https://generativelanguage.googleapis.com/v1beta/openai/chat/completions" , " chat-completions" ) ,
579
( " deepseek" , " DeepSeek" , " DeepSeek" , " https://api.deepseek.com/chat/completions" , " chat-completions" ) ,
580
( " mistral" , " Mistral" , " Mistral" , " https://api.mistral.ai/v1/chat/completions" , " chat-completions" ) ,
581
( " perplexity" , " Perplexity" , " Perplexity" , " https://api.perplexity.ai/chat/completions" , " chat-completions" ) ,
582
( " ollama" , " Ollama (on your network)" , " Ollama" , " http://localhost:11434/v1/chat/completions" , " chat-completions" ) ,
583
] ;
584
585
586
587
pub fn models_url ( endpoint : & str ) -> Option< String> {
588
let e = endpoint.trim_end_matches ( ' /' ) ;
589
e.strip_suffix ( " /chat/completions" ) .or_else ( | | e.strip_suffix ( " /messages" ) ) .map ( | base | format! ( " {base} /models" ) )
590
}
591
592
593
594
9a777fec Andy McCall 21 days ago ↶
595
pub async fn list_models ( endpoint : & str , api : & str , token : & str , allow : & [String]) -> Result< Vec< String> , String> {
155cab14 Andy McCall 1 month ago ↶
596
let url = models_url ( endpoint) .ok_or ( " this endpoint has no model list next to it" ) ? ;
9a777fec Andy McCall 21 days ago ↶
597
let client = crate :: net:: client( & url, allow, std:: time:: Duration:: from_secs( 15 ) ) .await? ;
155cab14 Andy McCall 1 month ago ↶
598
let mut req = client.get ( & url) ;
599
if api = = " anthropic-messages" {
600
req = req.header ( " anthropic-version" , " 2023-06-01" ) ;
601
if ! token.is_empty ( ) {
602
req = req.header ( " x-api-key" , token) ;
603
}
604
} else if ! token.is_empty ( ) {
605
req = req.header ( " authorization" , format! ( " Bearer {token} " ) ) ;
606
}
607
let r = req.send ( ) .await.map_err ( | e | scrub ( & e.to_string ( ) , token) ) ? ;
608
let status = r.status ( ) ;
609
if ! status.is_success ( ) {
610
return Err ( format! ( " the model list answered {} " , status.as_u16 ( ) ) ) ;
611
}
612
let v: serde_json:: Value = r.json ( ) .await.map_err ( | _| " the model list was not JSON" .to_string ( ) ) ? ;
613
let skip = [ " embed" , " tts" , " whisper" , " dall-e" , " moderation" , " audio" , " image" , " realtime" , " transcribe" , " search-preview" ] ;
614
let mut out: Vec< String> = v[ " data" ]
615
.as_array ( )
616
.or ( v[ " models" ] .as_array ( ) )
617
.ok_or ( " the model list had no data" ) ?
618
.iter ( )
619
.filter_map ( | m | m[ " id" ] .as_str ( ) .or ( m[ " name" ] .as_str ( ) ) )
620
.map ( | id | id.strip_prefix ( " models/" ) .unwrap_or ( id) .to_string ( ) )
621
.filter ( | id | ! skip.iter ( ) .any ( | s | id.to_lowercase ( ) .contains ( s) ) )
622
.collect ( ) ;
623
out.sort ( ) ;
624
out.dedup ( ) ;
625
if out.is_empty ( ) {
626
return Err ( " the model list was empty" .into ( ) ) ;
627
}
628
Ok ( out)
629
}
630
e71846aa Andy McCall 1 month ago ↶
631
632
pub async fn test ( app : & Shared, a : & Assistant) -> Result< String, String> {
5360cea3 Andy McCall 22 days ago ↶
633
let answer =
634
ask ( app, a, " You are being connected to an OxygenHub site. Answer briefly." , " Reply with the single word OK." , CALL_TIMEOUT )
635
.await? ;
e71846aa Andy McCall 1 month ago ↶
636
Ok ( answer.trim ( ) .chars ( ) .take ( 80 ) .collect ( ) )
637
}
638
639
640
fn scrub ( text : & str , token : & str ) -> String {
641
if token.is_empty ( ) {
642
text.to_string ( )
643
} else {
644
text.replace ( token, " ***" )
645
}
646
}
647
648
# [ cfg ( test ) ]
649
mod tests {
650
use super :: * ;
651
652
# [ test ]
653
fn tokens_seal_and_open_only_under_their_key ( ) {
654
let dir = tempfile:: tempdir( ) .unwrap ( ) ;
655
let k = SecretKey:: load_or_create( dir.path ( ) ) .unwrap ( ) ;
656
let sealed = k.seal ( " sk-very-secret" ) ;
657
assert_ne! ( sealed, b " sk-very-secret" ) ;
658
assert! ( ! String :: from_utf8_lossy( & sealed) .contains ( " secret" ) ) ;
659
assert_eq! ( k.open ( & sealed) .as_deref ( ) , Some ( " sk-very-secret" ) ) ;
660
assert_eq! ( SecretKey:: load_or_create( dir.path ( ) ) .unwrap ( ) .open ( & sealed) .as_deref ( ) , Some ( " sk-very-secret" ) , " the key is kept" ) ;
661
let other = SecretKey:: load_or_create( tempfile:: tempdir( ) .unwrap ( ) .path ( ) ) .unwrap ( ) ;
662
assert_eq! ( other.open ( & sealed) , None ) ;
663
assert_eq! ( k.open ( & [ 1 , 2 , 3 ] ) , None ) ;
664
assert_eq! ( k.seal ( " " ) , Vec : : < u8 > :: new( ) ) ;
665
assert_eq! ( k.open ( & [ ] ) .as_deref ( ) , Some ( " " ) ) ;
5360cea3 Andy McCall 22 days ago ↶
666
}
667
668
# [ test ]
669
fn answers_say_why_they_stopped ( ) {
670
let a = | v : serde_json:: Value| {
671
let ( text, stop) = answer_of ( " anthropic-messages" , & v) ;
672
finish ( text, stop)
673
} ;
674
675
let thought = serde_json:: json! ( { " stop_reason" : " max_tokens" , " content" : [ { " type" : " thinking" , " thinking" : " " } ] } ) ;
676
assert! ( a ( thought) .unwrap_err ( ) .contains ( " ran out of room" ) ) ;
677
let cut = serde_json:: json! ( { " stop_reason" : " max_tokens" , " content" : [ { " type" : " thinking" , " thinking" : " " } , { " type" : " text" , " text" : " Half" } ] } ) ;
678
assert! ( a ( cut) .unwrap ( ) .starts_with ( " Half\n \n *(Cut short" ) ) ;
679
let declined = serde_json:: json! ( { " stop_reason" : " refusal" , " stop_details" : { " category" : " cyber" } , " content" : [ ] } ) ;
680
assert_eq! ( a ( declined) .unwrap_err ( ) , " it declined to answer (cyber)" ) ;
681
let ok = serde_json:: json! ( { " stop_reason" : " end_turn" , " content" : [ { " type" : " text" , " text" : " Fine." } ] } ) ;
682
assert_eq! ( a ( ok) .unwrap ( ) , " Fine." ) ;
683
let chat = | v : serde_json:: Value| {
684
let ( text, stop) = answer_of ( " chat-completions" , & v) ;
685
finish ( text, stop)
686
} ;
687
assert! ( chat ( serde_json:: json! ( { " choices" : [ { " finish_reason" : " length" , " message" : { " content" : " " } } ] } ) )
688
.unwrap_err ( )
689
.contains ( " ran out of room" ) ) ;
690
assert_eq! (
691
chat ( serde_json:: json! ( { " choices" : [ { " finish_reason" : " content_filter" , " message" : { " content" : " " } } ] } ) ) .unwrap_err ( ) ,
692
" it declined to answer"
693
) ;
694
assert_eq! (
695
chat ( serde_json:: json! ( { " choices" : [ { " finish_reason" : " stop" , " message" : { " content" : " Yes." } } ] } ) ) .unwrap ( ) ,
696
" Yes."
697
) ;
e71846aa Andy McCall 1 month ago ↶
698
}
699
700
# [ test ]
c08cec37 Andy McCall 20 days ago ↶
701
fn a_refusal_for_length_is_told_from_others ( ) {
702
for e in [
703
" 400 This model's maximum context length is 8192 tokens. Please reduce the length of the messages." ,
704
" 400 prompt is too long: 210000 tokens > 200000 maximum" ,
705
" 413 Request too large" ,
706
" 400 input length exceeds the context window" ,
707
] {
708
assert! ( super :: too_long( e) , " {e}" ) ;
709
}
710
for e in [ " 401 invalid api key" , " 500 too long a queue" , " no answer within 90 seconds" , " 400 model not found" ] {
711
assert! ( ! super :: too_long( e) , " {e}" ) ;
712
}
713
}
714
715
# [ test ]
e71846aa Andy McCall 1 month ago ↶
716
fn headers_parse_or_name_the_bad_line ( ) {
717
assert_eq! (
718
parse_headers ( " X-Org: acme\n \n OpenAI-Beta : v1 " ) .unwrap ( ) ,
719
vec! [ ( " X-Org" .into ( ) , " acme" .into ( ) ) , ( " OpenAI-Beta" .into ( ) , " v1" .into ( ) ) ]
720
) ;
721
assert_eq! ( parse_headers ( " no colon" ) .unwrap_err ( ) , " no colon" ) ;
722
assert_eq! ( parse_headers ( " bad name: x" ) .unwrap_err ( ) , " bad name: x" ) ;
723
}
724
}
7d2eb590 Andy McCall 1 month ago ↶
725
726
727
728
729
pub const CALLS_PER_HOUR : usize = 30 ;
730
731
732
# [ derive ( Default ) ]
733
pub struct Gate {
734
busy : std:: collections:: HashSet< ( i64 , String) > ,
735
recent : std:: collections:: HashMap< i64 , Vec< i64 > > ,
736
}
737
738
impl Gate {
739
740
fn take ( & mut self , repo_id : i64 , proposal_id : i64 , name : & str ) -> Result< ( ) , String> {
741
let key = ( proposal_id, name.to_lowercase ( ) ) ;
742
if self .busy.contains ( & key) {
743
return Err ( format! ( " {name} is already answering this proposal" ) ) ;
744
}
745
let now = now_ms ( ) ;
746
let calls = self .recent.entry ( repo_id) .or_default ( ) ;
747
calls.retain ( | t | now - * t < 3_600_000 ) ;
748
if calls.len ( ) > = CALLS_PER_HOUR {
749
return Err ( format! ( " this repository has asked assistants {CALLS_PER_HOUR} times in the last hour; try later" ) ) ;
750
}
751
calls.push ( now) ;
752
self .busy.insert ( key) ;
753
Ok ( ( ) )
754
}
755
756
fn release ( & mut self , proposal_id : i64 , name : & str ) {
757
self .busy.remove ( & ( proposal_id, name.to_lowercase ( ) ) ) ;
758
}
b5a0a69b Andy McCall 21 days ago ↶
759
760
761
pub fn answering ( & self , proposal_id : i64 ) -> Vec< String> {
762
let mut v: Vec< String> = self .busy.iter ( ) .filter ( | ( p , _) | * p = = proposal_id) .map ( | ( _, n ) | n.clone ( ) ) .collect ( ) ;
763
v.sort ( ) ;
764
v
765
}
7d2eb590 Andy McCall 1 month ago ↶
766
}
767
768
769
770
pub fn review_context (
771
row : & crate:: db:: repos:: RepoRow,
772
p : & crate:: db:: proposals:: ProposalRow,
773
loaded : & crate:: web:: proposals:: Loaded,
774
budget : usize ,
775
) -> String {
776
let mut head =
777
format! ( " # Proposal #{} : {} \n \n Repository {} /{} · into {} · by {} \n \n " , p.number, p.title, row.owner, row.name, p.target, p.author) ;
778
if ! p.body.trim ( ) .is_empty ( ) {
779
head.push_str ( " ## Description\n \n " ) ;
780
head.push_str ( p.body.trim ( ) ) ;
781
head.push_str ( " \n \n " ) ;
782
}
783
context_text ( head, & loaded.changes, & loaded.diffs, budget)
784
}
785
786
787
pub fn draft_context (
788
row : & crate:: db:: repos:: RepoRow,
789
title : & str ,
790
target : & str ,
791
changes : & [crate:: repos:: view:: ChangeView],
792
diffs : & [oxy_core:: repo:: FileDiff],
793
budget : usize ,
794
) -> String {
795
let head = format! (
796
" # Proposal: {} \n \n Repository {} /{} · into {} \n \n " ,
797
if title.trim ( ) .is_empty ( ) { " (untitled)" } else { title.trim ( ) } ,
798
row.owner,
799
row.name,
800
target
801
) ;
802
context_text ( head, changes, diffs, budget)
803
}
804
805
806
807
fn context_text ( mut text : String, changes : & [crate:: repos:: view:: ChangeView], diffs : & [oxy_core:: repo:: FileDiff], budget : usize ) -> String {
808
text.push_str ( & format! ( " ## Changes ({} )\n \n " , changes.len ( ) ) ) ;
809
for c in changes {
810
text.push_str ( & format! ( " - {} {} \n " , c.short, c.title) ) ;
811
let body = c.body.trim ( ) ;
812
if ! body.is_empty ( ) {
813
for line in body.lines ( ) .take ( 20 ) {
814
text.push_str ( " " ) ;
815
text.push_str ( line) ;
816
text.push ( ' \n ' ) ;
817
}
818
}
819
}
c08cec37 Andy McCall 20 days ago ↶
820
821
822
text.push_str ( & format! ( " \n ## Files ({} )\n \n " , diffs.len ( ) ) ) ;
823
let mut parts: Vec< ( String, String) > = Vec :: with_capacity( diffs.len ( ) ) ;
7d2eb590 Andy McCall 1 month ago ↶
824
for f in diffs {
c08cec37 Andy McCall 20 days ago ↶
825
let ( added, removed) = f.hunks.iter ( ) .fold ( ( 0 , 0 ) , | ( a , r ) , h | ( a + h.added.len ( ) , r + h.removed.len ( ) ) ) ;
826
text.push_str ( & format! ( " - {} ({} , +{added} -{removed} )\n " , f.path, f.kind) ) ;
827
let mut diff = format! ( " ### {} ({} )\n " , f.path, f.kind) ;
7d2eb590 Andy McCall 1 month ago ↶
828
if let Some ( from) = & f.from {
829
diff.push_str ( & format! ( " renamed from {from} \n " ) ) ;
830
}
831
let binary = f.hunks.iter ( ) .any ( | h | h.added.iter ( ) .chain ( h.removed.iter ( ) .map ( | ( _, l ) | l ) ) .any ( | l | l.contains ( & 0 ) ) ) ;
832
if binary {
833
diff.push_str ( " (binary)\n \n " ) ;
c08cec37 Andy McCall 20 days ago ↶
834
parts.push ( ( f.path.clone ( ) , diff) ) ;
7d2eb590 Andy McCall 1 month ago ↶
835
continue ;
836
}
837
diff.push_str ( " ```\n " ) ;
838
for h in & f.hunks {
839
diff.push_str ( & format! ( " @@ after line {} @@\n " , h.base_line) ) ;
840
for ( _ , l) in & h.removed {
841
diff.push ( ' -' ) ;
842
diff.push_str ( String :: from_utf8_lossy( l) .trim_end_matches ( ' \n ' ) ) ;
843
diff.push ( ' \n ' ) ;
844
}
845
for l in & h.added {
846
diff.push ( ' +' ) ;
847
diff.push_str ( String :: from_utf8_lossy( l) .trim_end_matches ( ' \n ' ) ) ;
848
diff.push ( ' \n ' ) ;
849
}
850
}
851
diff.push_str ( " ```\n \n " ) ;
c08cec37 Andy McCall 20 days ago ↶
852
parts.push ( ( f.path.clone ( ) , diff) ) ;
7d2eb590 Andy McCall 1 month ago ↶
853
}
c08cec37 Andy McCall 20 days ago ↶
854
text.push_str ( " \n ## Diff\n \n " ) ;
855
856
857
858
let head = text.chars ( ) .count ( ) ;
859
let mut room = budget.saturating_sub ( head + 200 ) ;
860
let mut left_out = Vec :: new( ) ;
861
for ( path, diff) in parts {
862
let n = diff.chars ( ) .count ( ) ;
863
if n < = room {
864
text.push_str ( & diff) ;
865
room - = n;
866
} else if room > = 1_500 {
867
let keep = room - 80 ;
868
text.extend ( diff.chars ( ) .take ( keep) ) ;
869
text.push_str ( " \n (the rest of this file was cut to fit)\n ```\n \n " ) ;
870
left_out.push ( format! ( " {path} (cut)" ) ) ;
871
room = 0 ;
872
} else {
873
left_out.push ( path) ;
874
}
875
}
876
if ! left_out.is_empty ( ) {
877
let names: String = left_out.join ( " , " ) .chars ( ) .take ( room.max ( 400 ) ) .collect ( ) ;
878
text.push_str ( & format! ( " (left out to fit the context budget, {} file(s): {names} )\n " , left_out.len ( ) ) ) ;
7d2eb590 Andy McCall 1 month ago ↶
879
}
880
text
881
}
882
883
884
885
pub fn review_system_prompt ( a : & Assistant, site : & str , prompts : & str ) -> String {
886
let mut s = format! (
887
" You are {name} , an AI assistant reviewing a proposal on {site} , an OxygenHub site. \
888
A person asked for your review; it is recorded as a review comment made via you, under their name. \
889
You cannot approve: only people approve. Answer in Markdown: start with a one-paragraph summary, \
890
then findings (correctness first, then clarity), each naming the file and quoting the lines it is about; \
891
keep praise out unless something is notably well done; say so plainly when you find nothing wrong." ,
892
name = a.name
893
) ;
894
if ! prompts.trim ( ) .is_empty ( ) {
895
s.push_str ( " \n \n " ) ;
896
s.push_str ( prompts.trim ( ) ) ;
897
}
898
s
899
}
900
901
902
903
904
905
pub async fn request_review (
906
app : & Shared,
907
row : & crate:: db:: repos:: RepoRow,
908
p : & crate:: db:: proposals:: ProposalRow,
909
by : & crate:: db:: accounts:: Account,
910
a : Assistant,
911
) -> Result< ( ) , String> {
912
app.assistant_gate.lock ( ) .unwrap_or_else ( | e | e.into_inner ( ) ) .take ( row.id, p.id, & a.name) ? ;
913
let pool = & app.db;
914
crate :: db:: proposals:: add_event( pool, p.id, & by.username, & format! ( " asked {} to review" , a.name) , " " ) .await.ok ( ) ;
b5a0a69b Andy McCall 21 days ago ↶
915
app.events.live.publish ( & crate :: events:: topic:: proposal( row.id, p.number) , " assistant" ) ;
7d2eb590 Andy McCall 1 month ago ↶
916
crate :: db:: accounts:: audit(
917
pool,
918
Some ( by) ,
919
" assistant.asked" ,
920
& format! ( " {} /{} " , row.owner, row.name) ,
921
& format! ( " {} #{} " , a.name, p.number) ,
922
)
923
.await
924
.ok ( ) ;
925
let ( app, row, p, by) = ( app.clone ( ) , row.clone ( ) , p.clone ( ) , by.clone ( ) ) ;
926
tokio:: spawn( async move {
927
let outcome = answer_review ( & app, & row, & p, & a) .await;
928
app.assistant_gate.lock ( ) .unwrap_or_else ( | e | e.into_inner ( ) ) .release ( p.id, & a.name) ;
929
match outcome {
930
Ok ( body) => {
931
if crate :: db:: proposals:: add_review_via( & app.db, p.id, by.id, & by.username, " comment" , & body, & a.name) .await.is_ok ( ) {
932
crate :: db:: proposals:: add_event( & app.db, p.id, & by.username, & format! ( " review.comment via {} " , a.name) , " " ) .await.ok ( ) ;
933
crate :: web:: proposals:: reviewed( & app, & row, & p, & by, " comment" ) .await;
934
crate :: hooks:: emit(
935
& app,
936
& row,
937
" review" ,
938
& by.username,
939
serde_json:: json! ( { " number" : p.number, " verdict" : " comment" , " via" : a.name } ) ,
940
)
941
.await;
942
}
943
}
944
Err ( why) => {
945
crate :: db:: proposals:: add_event( & app.db, p.id, & by.username, & format! ( " {} did not answer" , a.name) , & why) .await.ok ( ) ;
b5a0a69b Andy McCall 21 days ago ↶
946
app.events.live.publish ( & crate :: events:: topic:: proposal( row.id, p.number) , " assistant" ) ;
7d2eb590 Andy McCall 1 month ago ↶
947
}
948
}
949
} ) ;
950
Ok ( ( ) )
951
}
952
953
async fn answer_review (
954
app : & Shared,
955
row : & crate:: db:: repos:: RepoRow,
956
p : & crate:: db:: proposals:: ProposalRow,
957
a : & Assistant,
958
) -> Result< String, String> {
959
let repo = crate :: repos:: names:: RepoName:: new( & row.owner, & row.name) .ok_or ( " bad repository name" ) ? ;
960
let loaded = crate :: web:: proposals:: load( app, & repo, p.clone ( ) ) .await.map_err ( | e | format! ( " the proposal could not be loaded: {e} " ) ) ? ;
961
let budget = usize :: try_from( a.budget) .unwrap_or ( DEFAULT_BUDGET as usize ) .max ( 1_000 ) ;
962
let prompts = prompt_for_repo ( & app.db, row) .await.map_err ( | e | e.to_string ( ) ) ? ;
963
let system = review_system_prompt ( a, & app.config.site.name, & prompts) ;
119fd17d Andy McCall 20 days ago ↶
964
ask_within ( app, a, & system, budget, BACKGROUND_TIMEOUT , | room | review_context ( row, p, & loaded, room) , | _| { } ) .await
c08cec37 Andy McCall 20 days ago ↶
965
}
966
967
968
969
970
971
async fn ask_within (
972
app : & Shared,
973
a : & Assistant,
974
system : & str ,
975
budget : usize ,
976
wait : Duration,
977
context : impl Fn( usize ) -> String,
119fd17d Andy McCall 20 days ago ↶
978
mut part : impl FnMut( &str ) ,
c08cec37 Andy McCall 20 days ago ↶
979
) -> Result< String, String> {
980
let room = | b : usize | b.saturating_sub ( system.chars ( ) .count ( ) ) ;
119fd17d Andy McCall 20 days ago ↶
981
982
983
match ask_streaming ( app, a, system, & context ( room ( budget) ) , wait, & mut part) .await {
c08cec37 Andy McCall 20 days ago ↶
984
Err ( e) if too_long ( & e) => {
985
let smaller = ( budget / 3 ) .max ( 1_000 ) ;
986
tracing:: info! ( " assistant {}: too long at {budget} characters ({e}); asking again at {smaller}" , a.name) ;
119fd17d Andy McCall 20 days ago ↶
987
ask_streaming ( app, a, system, & context ( room ( smaller) ) , wait, & mut part) .await.map_err ( | e2 | {
c08cec37 Andy McCall 20 days ago ↶
988
format! (
989
" the proposal was too large for {} 's model at {budget} characters and again at {smaller} ({e2} ): lower this assistant's context budget" ,
990
a.name
991
)
992
} )
993
}
994
Err ( e) if e.starts_with ( " no answer within" ) => Err ( format! (
995
" {e} : a large proposal on a slow or local model can take longer; lower this assistant's context budget, or use a faster model"
996
) ) ,
997
other => other,
998
}
999
}
1000
1001
1002
fn too_long ( e : & str ) -> bool {
1003
let l = e.to_lowercase ( ) ;
1004
( l.starts_with ( " 400" ) | | l.starts_with ( " 413" ) | | l.starts_with ( " 422" ) )
1005
& & [
1006
" too long" ,
1007
" too large" ,
1008
" context length" ,
1009
" context window" ,
1010
" maximum context" ,
1011
" too many tokens" ,
1012
" exceeds" ,
1013
" reduce the length" ,
1014
" input length" ,
1015
]
1016
.iter ( )
1017
.any ( | k | l.contains ( k) )
ffb67a81 Andy McCall 23 days ago
1018
}
1019
1020
1021
1022
1023
pub fn summary_system_prompt ( a : & Assistant, site : & str , prompts : & str ) -> String {
1024
let mut s = format! (
1025
" You are {name} , an AI assistant working on {site} , an OxygenHub site, with a next-generation version control system \
1026
called Oxygen (its command is oxy). A proposal is Oxygen's equivalent of a merge request or pull request. \
1027
Please summarise the proposal and comment on it. A person asked for this; it is posted as a comment made via you, \
1028
under their name. Answer in Markdown: a short summary of what the proposal changes and why, then anything a reviewer \
1029
should look at closely; keep to what the description and the diff show." ,
1030
name = a.name
1031
) ;
1032
if ! prompts.trim ( ) .is_empty ( ) {
1033
s.push_str ( " \n \n " ) ;
1034
s.push_str ( prompts.trim ( ) ) ;
1035
}
1036
s
1037
}
1038
1039
1040
1041
pub async fn request_summary (
1042
app : & Shared,
1043
row : & crate:: db:: repos:: RepoRow,
1044
p : & crate:: db:: proposals:: ProposalRow,
1045
by : & crate:: db:: accounts:: Account,
1046
a : Assistant,
1047
) -> Result< ( ) , String> {
1048
app.assistant_gate.lock ( ) .unwrap_or_else ( | e | e.into_inner ( ) ) .take ( row.id, p.id, & a.name) ? ;
1049
let pool = & app.db;
1050
crate :: db:: proposals:: add_event( pool, p.id, & by.username, & format! ( " asked {} for a summary" , a.name) , " " ) .await.ok ( ) ;
b5a0a69b Andy McCall 21 days ago ↶
1051
app.events.live.publish ( & crate :: events:: topic:: proposal( row.id, p.number) , " assistant" ) ;
ffb67a81 Andy McCall 23 days ago
1052
crate :: db:: accounts:: audit(
1053
pool,
1054
Some ( by) ,
1055
" assistant.asked" ,
1056
& format! ( " {} /{} " , row.owner, row.name) ,
1057
& format! ( " {} #{} summary" , a.name, p.number) ,
1058
)
1059
.await
1060
.ok ( ) ;
1061
let ( app, row, p, by) = ( app.clone ( ) , row.clone ( ) , p.clone ( ) , by.clone ( ) ) ;
1062
tokio:: spawn( async move {
1063
let outcome = answer_summary ( & app, & row, & p, & a) .await;
1064
app.assistant_gate.lock ( ) .unwrap_or_else ( | e | e.into_inner ( ) ) .release ( p.id, & a.name) ;
1065
match outcome {
1066
Ok ( body) => {
1067
crate :: db:: proposals:: add_comment_via( & app.db, p.id, by.id, & by.username, & body, & a.name) .await.ok ( ) ;
b5a0a69b Andy McCall 21 days ago ↶
1068
app.events.live.publish ( & crate :: events:: topic:: proposal( row.id, p.number) , " comment" ) ;
ffb67a81 Andy McCall 23 days ago
1069
}
1070
Err ( why) => {
1071
crate :: db:: proposals:: add_event( & app.db, p.id, & by.username, & format! ( " {} did not answer" , a.name) , & why) .await.ok ( ) ;
b5a0a69b Andy McCall 21 days ago ↶
1072
app.events.live.publish ( & crate :: events:: topic:: proposal( row.id, p.number) , " assistant" ) ;
ffb67a81 Andy McCall 23 days ago
1073
}
1074
}
1075
} ) ;
1076
Ok ( ( ) )
1077
}
1078
1079
async fn answer_summary (
1080
app : & Shared,
1081
row : & crate:: db:: repos:: RepoRow,
1082
p : & crate:: db:: proposals:: ProposalRow,
1083
a : & Assistant,
1084
) -> Result< String, String> {
1085
let repo = crate :: repos:: names:: RepoName:: new( & row.owner, & row.name) .ok_or ( " bad repository name" ) ? ;
1086
let loaded = crate :: web:: proposals:: load( app, & repo, p.clone ( ) ) .await.map_err ( | e | format! ( " the proposal could not be loaded: {e} " ) ) ? ;
1087
let budget = usize :: try_from( a.budget) .unwrap_or ( DEFAULT_BUDGET as usize ) .max ( 1_000 ) ;
1088
let prompts = prompt_for_repo ( & app.db, row) .await.map_err ( | e | e.to_string ( ) ) ? ;
1089
let system = summary_system_prompt ( a, & app.config.site.name, & prompts) ;
119fd17d Andy McCall 20 days ago ↶
1090
ask_within ( app, a, & system, budget, BACKGROUND_TIMEOUT , | room | review_context ( row, p, & loaded, room) , | _| { } ) .await
7d2eb590 Andy McCall 1 month ago ↶
1091
}
1092
1093
1094
1095
1096
pub fn draft_system_prompt ( a : & Assistant, site : & str , prompts : & str ) -> String {
1097
let mut s = format! (
1098
" You are {name} , an AI assistant drafting the description of a proposal on {site} , an OxygenHub site, \
1099
for the person who will open or edit it; they read and change your draft before saving, and the draft is theirs. \
1100
Write Markdown for the description field only: no title line, no greeting, no sign-off. \
1101
Say what the changes do and why, what a reviewer should look at, and how to verify them; \
1102
keep to what the diff shows, and keep it short." ,
1103
name = a.name
1104
) ;
1105
if ! prompts.trim ( ) .is_empty ( ) {
1106
s.push_str ( " \n \n " ) ;
1107
s.push_str ( prompts.trim ( ) ) ;
1108
}
1109
s
1110
}
1111
1112
1113
pub struct Draft < 'a > {
1114
1115
1116
pub slot : i64 ,
1117
pub title : & 'a str ,
1118
pub target : & 'a str ,
1119
pub changes : & 'a [crate:: repos:: view:: ChangeView],
1120
pub diffs : & 'a [oxy_core:: repo:: FileDiff],
1121
}
1122
1123
1124
1125
pub async fn draft_description (
119fd17d Andy McCall 20 days ago ↶
1126
app : & Shared,
1127
row : & crate:: db:: repos:: RepoRow,
1128
by : & crate:: db:: accounts:: Account,
1129
a : & Assistant,
1130
d : Draft< '_ > ,
1131
) -> Result< String, String> {
1132
draft_description_streaming ( app, row, by, a, d, | _| { } ) .await
1133
}
1134
1135
1136
1137
pub async fn draft_description_streaming (
7d2eb590 Andy McCall 1 month ago ↶
1138
app : & Shared,
1139
row : & crate:: db:: repos:: RepoRow,
1140
by : & crate:: db:: accounts:: Account,
1141
a : & Assistant,
1142
d : Draft< '_ > ,
119fd17d Andy McCall 20 days ago ↶
1143
part : impl FnMut( &str ) ,
7d2eb590 Andy McCall 1 month ago ↶
1144
) -> Result< String, String> {
1145
let Draft { slot, title, target, changes, diffs } = d;
1146
app.assistant_gate.lock ( ) .unwrap_or_else ( | e | e.into_inner ( ) ) .take ( row.id, slot, & a.name) ? ;
1147
crate :: db:: accounts:: audit( & app.db, Some ( by) , " assistant.drafted" , & format! ( " {} /{} " , row.owner, row.name) , & a.name) .await.ok ( ) ;
1148
let budget = usize :: try_from( a.budget) .unwrap_or ( DEFAULT_BUDGET as usize ) .max ( 1_000 ) ;
1149
let prompts = prompt_for_repo ( & app.db, row) .await.map_err ( | e | e.to_string ( ) ) ;
1150
let outcome = match prompts {
1151
Ok ( prompts) => {
1152
let system = draft_system_prompt ( a, & app.config.site.name, & prompts) ;
119fd17d Andy McCall 20 days ago ↶
1153
ask_within ( app, a, & system, budget, CALL_TIMEOUT , | room | draft_context ( row, title, target, changes, diffs, room) , part) .await
7d2eb590 Andy McCall 1 month ago ↶
1154
}
1155
Err ( e) => Err ( e) ,
1156
} ;
1157
app.assistant_gate.lock ( ) .unwrap_or_else ( | e | e.into_inner ( ) ) .release ( slot, & a.name) ;
1158
outcome.map ( | t | t.trim ( ) .to_string ( ) )
1159
}