review

review patchsets using your default editor
Log | Files | Refs

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 }