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 }