review

review patchsets using your default editor
Log | Files | Refs

api.odin (6291B)


      1 package provider
      2 
      3 // The console API is the one provider that can enforce the answer's shape
      4 // rather than request it, so a malformed answer is impossible here rather
      5 // than merely unlikely: the job answers through a strict tool, and the
      6 // tool's input is the answer.
      7 
      8 import "core:encoding/json"
      9 import "core:fmt"
     10 import "core:os"
     11 import "core:strings"
     12 import "core:time"
     13 import "jm:http"
     14 
     15 api_url     :: "https://api.anthropic.com/v1/messages"
     16 api_version :: "2023-06-01"
     17 
     18 // Tool is the strict shape one ask is held to.
     19 Tool :: struct {
     20 	name, description, schema: string,
     21 }
     22 
     23 // findings_tool is the shape a finding takes.
     24 findings_tool := Tool {
     25 	"report_findings",
     26 	"Report what this reading found, or an empty list.",
     27 	`{"type":"object","properties":{"findings":{"type":"array","items":{"type":"object","properties":{"rule":{"type":"string","description":"the rule id from the criteria"},"severity":{"type":"string","enum":["must-fix","consider","note"]},"file":{"type":"string"},"line":{"type":"integer"},"symbol":{"type":"string"},"message":{"type":"string","description":"one sentence stating the finding"},"fix":{"type":"string","description":"the concrete change to make"}},"required":["rule","severity","message","fix","file","line","symbol"],"additionalProperties":false}}},"required":["findings"],"additionalProperties":false}`,
     28 }
     29 
     30 // verdicts_tool is the shape the second reading answers in.
     31 verdicts_tool := Tool {
     32 	"report_verdicts",
     33 	"Report whether each finding holds.",
     34 	`{"type":"object","properties":{"verdicts":{"type":"array","items":{"type":"object","properties":{"index":{"type":"integer","description":"the number of the finding, as it was listed"},"holds":{"type":"boolean","description":"whether the finding stands against the criteria"},"reason":{"type":"string","description":"why it holds or falls, one sentence"}},"required":["index","holds","reason"],"additionalProperties":false}}},"required":["verdicts"],"additionalProperties":false}`,
     35 }
     36 
     37 // request is the body of one ask: the criteria as the system prompt,
     38 // where the API caches them; the temperature pinned, because a loop
     39 // between two models cannot converge if one of them answers differently
     40 // each time it is asked.
     41 request :: proc(
     42 	model, system, user: string,
     43 	tool: Tool,
     44 	allocator := context.allocator,
     45 ) -> string {
     46 	return strings.concatenate(
     47 		{
     48 			`{"model":`,
     49 			quote(model),
     50 			`,"max_tokens":4096,"temperature":0,"system":[{"type":"text","text":`,
     51 			quote(system),
     52 			`,"cache_control":{"type":"ephemeral"}}],"tools":[{"name":`,
     53 			quote(tool.name),
     54 			`,"description":`,
     55 			quote(tool.description),
     56 			`,"strict":true,"input_schema":`,
     57 			tool.schema,
     58 			`}],"messages":[{"role":"user","content":[{"type":"text","text":`,
     59 			quote(user),
     60 			`}]}]}`,
     61 		},
     62 		allocator,
     63 	)
     64 }
     65 
     66 // quote is a string as JSON writes it.
     67 quote :: proc(s: string) -> string {
     68 	data, err := json.marshal(s, allocator = context.temp_allocator)
     69 	if err != nil {
     70 		return `""`
     71 	}
     72 	return string(data)
     73 }
     74 
     75 // ask_api puts one question with one strict tool. The key is the
     76 // environment's, as the SDKs read it.
     77 ask_api :: proc(
     78 	p: Provider,
     79 	system, user: string,
     80 	tool: Tool,
     81 	allocator := context.allocator,
     82 ) -> (
     83 	Answer,
     84 	string,
     85 ) {
     86 	key := os.get_env("ANTHROPIC_API_KEY", context.temp_allocator)
     87 	if key == "" {
     88 		return {}, "no Anthropic credentials found: ANTHROPIC_API_KEY is not set"
     89 	}
     90 	body := request(p.model, system, user, tool, context.temp_allocator)
     91 	res, err := http.post(
     92 		api_url,
     93 		body,
     94 		"application/json",
     95 		{
     96 			headers = {
     97 				strings.concatenate({"x-api-key: ", key}, context.temp_allocator),
     98 				strings.concatenate({"anthropic-version: ", api_version}, context.temp_allocator),
     99 			},
    100 			timeout = time.Duration(ask_timeout_seconds()) * time.Second,
    101 		},
    102 		context.temp_allocator,
    103 	)
    104 	if err != .None {
    105 		return {}, fmt.aprintf("api: %v", err, allocator = allocator)
    106 	}
    107 	if !res.ok {
    108 		return {}, fmt.aprintf("api: HTTP %d: %s", res.status, first(error_of(res.body), 200), allocator = allocator)
    109 	}
    110 	return reply(res.body, allocator)
    111 }
    112 
    113 // error_of is the message an API error carries, or the body whole.
    114 error_of :: proc(body: string) -> string {
    115 	Failure :: struct {
    116 		error: struct {
    117 			message: string `json:"message"`,
    118 		} `json:"error"`,
    119 	}
    120 	f: Failure
    121 	if json.unmarshal_string(body, &f, allocator = context.temp_allocator) == nil &&
    122 	   f.error.message != "" {
    123 		return f.error.message
    124 	}
    125 	return body
    126 }
    127 
    128 // reply reads the tool's input out of the API's answer, which is the
    129 // answer, and the usage beside it.
    130 reply :: proc(body: string, allocator := context.allocator) -> (Answer, string) {
    131 	Response :: struct {
    132 		content: []struct {
    133 			type: string `json:"type"`,
    134 		} `json:"content"`,
    135 		usage:   struct {
    136 			tokens_in:  int `json:"input_tokens"`,
    137 			tokens_out: int `json:"output_tokens"`,
    138 			cached:     int `json:"cache_read_input_tokens"`,
    139 		} `json:"usage"`,
    140 	}
    141 	r: Response
    142 	if json.unmarshal_string(body, &r, allocator = context.temp_allocator) != nil {
    143 		return {}, "api: the answer is not the JSON expected"
    144 	}
    145 	answer := Answer {
    146 		tokens_in  = r.usage.tokens_in,
    147 		tokens_out = r.usage.tokens_out,
    148 		cached     = r.usage.cached,
    149 	}
    150 	for block in r.content {
    151 		if block.type == "tool_use" {
    152 			if input, found := tool_input(body); found {
    153 				answer.text = strings.clone(input, allocator)
    154 				return answer, ""
    155 			}
    156 		}
    157 	}
    158 	return answer, "the model answered without reporting"
    159 }
    160 
    161 // tool_input is the text of the first "input" object in the answer, as
    162 // the API wrote it, which is the report the tool was held to.
    163 tool_input :: proc(body: string) -> (string, bool) {
    164 	marker :: `"input":`
    165 	at := strings.index(body, marker)
    166 	if at < 0 {
    167 		return "", false
    168 	}
    169 	start := at + len(marker)
    170 	for start < len(body) && (body[start] == ' ' || body[start] == '\n' || body[start] == '\t') {
    171 		start += 1
    172 	}
    173 	if start >= len(body) || body[start] != '{' {
    174 		return "", false
    175 	}
    176 	depth := 0
    177 	in_string := false
    178 	for i := start; i < len(body); i += 1 {
    179 		c := body[i]
    180 		switch {
    181 		case in_string:
    182 			if c == '\\' {
    183 				i += 1
    184 			} else if c == '"' {
    185 				in_string = false
    186 			}
    187 		case c == '"':
    188 			in_string = true
    189 		case c == '{':
    190 			depth += 1
    191 		case c == '}':
    192 			depth -= 1
    193 			if depth == 0 {
    194 				return body[start:i + 1], true
    195 			}
    196 		}
    197 	}
    198 	return "", false
    199 }