provider.odin (12128B)
1 /* 2 Package provider answers a question. What answers it is not this tool's 3 business: a console API, a coding assistant on the path, or anything else 4 that takes a prompt and returns text. Findings are read out of that text 5 by the caller, so every provider answers the same way whether or not it 6 can enforce a schema. 7 */ 8 package provider 9 10 import "core:encoding/json" 11 import "core:fmt" 12 import "core:os" 13 import "core:strings" 14 import "core:time" 15 import "jm:sh" 16 17 // Answer is what a provider returned, with whatever it could say about 18 // the cost. A provider that reports no usage leaves the counts at zero 19 // rather than inventing them. replayed marks an answer the cache 20 // remembered rather than asked for: it cost nothing this run. 21 Answer :: struct { 22 text: string, 23 tokens_in: int, 24 tokens_out: int, 25 cached: int, 26 cost: f64, 27 replayed: bool, 28 } 29 30 // Kind is which way a provider is asked. 31 Kind :: enum { 32 Claude, 33 Pi, 34 Api, 35 Command, 36 } 37 38 // Provider is one way of asking: the coding assistant claude in its 39 // non-interactive mode, the assistant pi through one of its upstreams, 40 // the console API directly, or whatever command the environment names. 41 Provider :: struct { 42 kind: Kind, 43 model: string, 44 upstream: string, 45 argv: []string, 46 field: string, 47 } 48 49 // The default models: the middle one rather than the smallest. Measured 50 // against the eval set, the smallest reads the shortlist and reports the 51 // first duplicate it finds rather than all of them. 52 default_claude_model :: "sonnet" 53 default_api_model :: "claude-sonnet-5" 54 55 // build is the provider called by name, with the model given or its 56 // default. The command provider reads its command line from REVIEW_COMMAND 57 // and the field its answer arrives in from REVIEW_COMMAND_FIELD. 58 build :: proc(which, model: string, allocator := context.allocator) -> (p: Provider, ok: bool) { 59 switch which { 60 case "claude": 61 return Provider{kind = .Claude, model = model if model != "" else default_claude_model}, 62 true 63 case "pi": 64 return Provider { 65 kind = .Pi, 66 model = model, 67 upstream = os.get_env("REVIEW_PI_PROVIDER", allocator), 68 }, 69 true 70 case "api": 71 return Provider{kind = .Api, model = model if model != "" else default_api_model}, true 72 case "command": 73 return Provider { 74 kind = .Command, 75 argv = strings.fields(os.get_env("REVIEW_COMMAND", allocator), allocator), 76 field = os.get_env("REVIEW_COMMAND_FIELD", allocator), 77 }, 78 true 79 } 80 return {}, false 81 } 82 83 // name is how a provider is asked for, and what the answer cache keys on: 84 // it carries the model and the upstream, so that two asks through 85 // differently pointed providers are never confused. 86 name :: proc(p: Provider, allocator := context.allocator) -> string { 87 switch p.kind { 88 case .Claude: 89 return strings.concatenate({"claude/", p.model}, allocator) 90 case .Pi: 91 parts := make([dynamic]string, context.temp_allocator) 92 append(&parts, "pi") 93 if p.upstream != "" { 94 append(&parts, p.upstream) 95 } 96 if p.model != "" { 97 append(&parts, p.model) 98 } 99 return strings.join(parts[:], "/", allocator) 100 case .Api: 101 return strings.concatenate({"api/", p.model}, allocator) 102 case .Command: 103 if len(p.argv) == 0 { 104 return strings.clone("command", allocator) 105 } 106 return strings.concatenate( 107 {"command: ", strings.join(p.argv, " ", context.temp_allocator)}, 108 allocator, 109 ) 110 } 111 return "" 112 } 113 114 // ask puts one question and returns what came back, as text, or why it 115 // could not. 116 ask :: proc( 117 p: Provider, 118 system, user: string, 119 allocator := context.allocator, 120 ) -> ( 121 Answer, 122 string, 123 ) { 124 if strings.trim_space(user) == "" { 125 return {}, "nothing to ask: the prompt is empty" 126 } 127 switch p.kind { 128 case .Claude: 129 return ask_claude(p, system, user, allocator) 130 case .Pi: 131 return ask_pi(p, system, user, allocator) 132 case .Api: 133 return ask_api(p, system, user, findings_tool, allocator) 134 case .Command: 135 return ask_command(p, system, user, allocator) 136 } 137 return {}, "no such provider" 138 } 139 140 // ask_verdict is the second reading's ask: held to its shape where the 141 // provider can enforce one, described in the prompt where it cannot. 142 ask_verdict :: proc( 143 p: Provider, 144 system, user: string, 145 allocator := context.allocator, 146 ) -> ( 147 Answer, 148 string, 149 ) { 150 if strings.trim_space(user) == "" { 151 return {}, "nothing to ask: the prompt is empty" 152 } 153 if p.kind == .Api { 154 return ask_api(p, system, user, verdicts_tool, allocator) 155 } 156 return ask(p, system, user, allocator) 157 } 158 159 // ask_claude asks the coding assistant on the path, in its non-interactive 160 // mode, with whatever credentials it already holds. The tools are refused 161 // rather than left to judgement: these jobs are given everything they may 162 // read, and a reader that goes looking for more is answering a different 163 // question from the one asked. 164 ask_claude :: proc( 165 p: Provider, 166 system, user: string, 167 allocator := context.allocator, 168 ) -> ( 169 Answer, 170 string, 171 ) { 172 out, err := shell( 173 { 174 "claude", 175 "-p", 176 "--model", 177 p.model, 178 "--output-format", 179 "json", 180 "--disallowedTools", 181 "Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch,Task,NotebookEdit", 182 "--append-system-prompt", 183 system, 184 }, 185 user, 186 context.temp_allocator, 187 ) 188 if err != "" { 189 return {}, strings.clone(err, allocator) 190 } 191 Envelope :: struct { 192 result: string `json:"result"`, 193 is_error: bool `json:"is_error"`, 194 cost: f64 `json:"total_cost_usd"`, 195 usage: struct { 196 tokens_in: int `json:"input_tokens"`, 197 tokens_out: int `json:"output_tokens"`, 198 cached: int `json:"cache_read_input_tokens"`, 199 } `json:"usage"`, 200 } 201 envelope: Envelope 202 if json.unmarshal_string(out, &envelope, allocator = context.temp_allocator) != nil { 203 return {}, "reading the answer: not the JSON envelope expected" 204 } 205 if envelope.is_error { 206 return {}, strings.clone(strings.trim_space(envelope.result), allocator) 207 } 208 return Answer { 209 text = strings.clone(envelope.result, allocator), 210 tokens_in = envelope.usage.tokens_in, 211 tokens_out = envelope.usage.tokens_out, 212 cached = envelope.usage.cached, 213 cost = envelope.cost, 214 }, 215 "" 216 } 217 218 // ask_pi asks the assistant of that name, which speaks to several 219 // providers of its own; the upstream is named through the environment, 220 // since which one is reachable is a property of the machine. Its answer 221 // arrives as a stream of events, not as one envelope. 222 ask_pi :: proc( 223 p: Provider, 224 system, user: string, 225 allocator := context.allocator, 226 ) -> ( 227 Answer, 228 string, 229 ) { 230 args := make([dynamic]string, context.temp_allocator) 231 append( 232 &args, 233 "pi", 234 "-p", 235 "--mode", 236 "json", 237 "--no-session", 238 "--no-tools", 239 "--system-prompt", 240 system, 241 ) 242 if p.upstream != "" { 243 append(&args, "--provider", p.upstream) 244 } 245 if p.model != "" { 246 append(&args, "--model", p.model) 247 } 248 out, err := shell(args[:], user, context.temp_allocator) 249 if err != "" { 250 return {}, strings.clone(err, allocator) 251 } 252 return spoken(out, allocator) 253 } 254 255 // Pi_Event is one line of pi's event stream. 256 Pi_Event :: struct { 257 type: string `json:"type"`, 258 message: struct { 259 role: string `json:"role"`, 260 content: []struct { 261 type: string `json:"type"`, 262 text: string `json:"text"`, 263 } `json:"content"`, 264 usage: struct { 265 input: int `json:"input"`, 266 output: int `json:"output"`, 267 cache_read: int `json:"cacheRead"`, 268 cost: struct { 269 total: f64 `json:"total"`, 270 } `json:"cost"`, 271 } `json:"usage"`, 272 stop_reason: string `json:"stopReason"`, 273 error_message: string `json:"errorMessage"`, 274 } `json:"message"`, 275 } 276 277 // spoken reads pi's answer out of its event stream, one JSON object per 278 // line. The last assistant message is the answer; everything before it is 279 // the working. Whatever pi says outside the stream is the only clue to a 280 // refusal, so it is kept for the error. 281 spoken :: proc(out: string, allocator := context.allocator) -> (Answer, string) { 282 answer: Answer 283 said := false 284 prose := make([dynamic]string, context.temp_allocator) 285 rest := out 286 for raw in strings.split_lines_iterator(&rest) { 287 line := strings.trim_space(raw) 288 if line == "" { 289 continue 290 } 291 event: Pi_Event 292 if json.unmarshal_string(line, &event, allocator = context.temp_allocator) != nil { 293 append(&prose, line) 294 continue 295 } 296 if event.type != "message_end" || event.message.role != "assistant" { 297 continue 298 } 299 if event.message.stop_reason == "error" { 300 why := 301 event.message.error_message if event.message.error_message != "" else "the model stopped with an error" 302 return {}, strings.clone(why, allocator) 303 } 304 text := strings.builder_make(allocator) 305 for block in event.message.content { 306 if block.type == "text" { 307 strings.write_string(&text, block.text) 308 } 309 } 310 usage := event.message.usage 311 answer = Answer { 312 text = strings.to_string(text), 313 tokens_in = usage.input, 314 tokens_out = usage.output, 315 cached = usage.cache_read, 316 cost = usage.cost.total, 317 } 318 said = true 319 } 320 if !said { 321 why := first(strings.join(prose[:], " ", context.temp_allocator), 200) 322 return {}, strings.clone(why if why != "" else "no assistant message in the answer", allocator) 323 } 324 return answer, "" 325 } 326 327 // ask_command asks whatever the environment names, with the prompt on its 328 // input, and reads the answer whole or out of the field named. 329 ask_command :: proc( 330 p: Provider, 331 system, user: string, 332 allocator := context.allocator, 333 ) -> ( 334 Answer, 335 string, 336 ) { 337 if len(p.argv) == 0 { 338 return {}, "REVIEW_COMMAND names no command" 339 } 340 out, err := shell( 341 p.argv, 342 strings.concatenate({system, "\n\n", user}, context.temp_allocator), 343 allocator, 344 ) 345 if err != "" { 346 return {}, strings.clone(err, allocator) 347 } 348 if p.field == "" { 349 return Answer{text = out}, "" 350 } 351 return Answer{text = field(out, p.field, allocator)}, "" 352 } 353 354 // shell works a command with the prompt on its input, which every 355 // assistant here accepts and which keeps a long prompt out of the 356 // argument list. A run past the ask timeout is a failed ask. 357 shell :: proc( 358 argv: []string, 359 prompt: string, 360 allocator := context.allocator, 361 ) -> ( 362 out: string, 363 err: string, 364 ) { 365 r := sh.exec( 366 argv, 367 {stdin = prompt, timeout = time.Duration(ask_timeout_seconds()) * time.Second}, 368 allocator, 369 ) 370 if r.err != nil { 371 return "", fmt.aprintf("%s: %s", argv[0], os.error_string(r.err), allocator = allocator) 372 } 373 if r.timed_out { 374 return "", fmt.aprintf( 375 "%s: no answer within %d seconds", 376 argv[0], 377 ask_timeout_seconds(), 378 allocator = allocator, 379 ) 380 } 381 if !r.ok { 382 detail := strings.trim_space(r.stderr) 383 if detail == "" { 384 detail = strings.trim_space(r.stdout) 385 } 386 if len(detail) > 400 { 387 detail = detail[:400] 388 } 389 return "", fmt.aprintf("%s: exit %d: %s", argv[0], r.code, detail, allocator = allocator) 390 } 391 return r.stdout, "" 392 } 393 394 // ask_timeout_seconds bounds one ask. A gateway that accepts a 395 // reachability ask and then stalls on a real one must not hold the review 396 // forever; REVIEW_ASK_TIMEOUT, in seconds, can widen it. 397 ask_timeout_seconds :: proc() -> int { 398 if s := os.get_env("REVIEW_ASK_TIMEOUT", context.temp_allocator); s != "" { 399 n := 0 400 for i in 0 ..< len(s) { 401 if s[i] < '0' || s[i] > '9' { 402 n = 0 403 break 404 } 405 n = n * 10 + int(s[i] - '0') 406 } 407 if n > 0 { 408 return n 409 } 410 } 411 return 300 412 } 413 414 // field pulls the answer out of a JSON envelope, by the name given. An 415 // answer that is not JSON at all is returned whole, since plenty of 416 // commands simply print what they were asked for. 417 field :: proc(out: string, name: string, allocator := context.allocator) -> string { 418 value, err := json.parse_string(out, allocator = context.temp_allocator) 419 if err != nil { 420 return out 421 } 422 if envelope, is_object := value.(json.Object); is_object { 423 if text, is_string := envelope[name].(json.String); is_string && text != "" { 424 return strings.clone(string(text), allocator) 425 } 426 } 427 return out 428 } 429 430 // first is the start of a string, with an ellipsis where it was cut. 431 first :: proc(s: string, n: int) -> string { 432 trimmed := strings.trim_space(s) 433 if len(trimmed) > n { 434 return strings.concatenate({trimmed[:n], "…"}, context.temp_allocator) 435 } 436 return trimmed 437 }