Spaces:
Running on Zero
Running on Zero
File size: 7,284 Bytes
98acb70 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | -- backend/http.lua - Anthropic Messages API.
--
-- The capability demo: same loop, same protocol, same trace format, but a
-- model that can actually finish the task. Swapping this for
-- backend/local.lua is one flag in main.lua, which is the argument that the
-- loop -- not the model -- is the artifact.
--
--
-- WHAT THIS BACKEND DELIBERATELY DOES NOT DO
--
-- It does not use the provider's native tool-use API. It sends the same text
-- protocol from protocol.lua that the local backend uses, and parses the
-- reply with the same parser.
--
-- In production you should do the opposite: the native API is more reliable
-- and you do not have to maintain a grammar. It is avoided here because
-- native tool use hides the exact mechanism this repo exists to show, and
-- because sharing one protocol is what makes the backends interchangeable.
-- Read grammar.lua to see what the native API is doing on your behalf.
--
-- Two consequences of talking to a current model that are worth knowing:
--
-- * Assistant prefill is rejected on current models, so this backend
-- cannot scaffold "THOUGHT: " the way backend/local.lua does. The format
-- has to be requested in the system prompt and honoured voluntarily --
-- which a capable model does, and which is exactly the capability the
-- grammar substitutes for on a 15M model.
-- * Thinking is on by default on Opus-tier models, so `content` is an
-- array that may hold thinking blocks before the text. Filter by type;
-- never take content[1] blindly.
--
--
-- Requires ANTHROPIC_API_KEY and curl on PATH. No Lua HTTP library, which
-- keeps the zero-dependency property intact.
local trace = require('trace')
local tmpfile = require('tmpfile')
local protocol = require('protocol')
local backend = {}
local Http = {}
Http.__index = Http
local ENDPOINT = "https://api.anthropic.com/v1/messages"
local API_VERSION = "2023-06-01"
-- opts:
-- model default claude-opus-5
-- max_tokens default 1024 (a turn here is one thought and one call)
-- temperature default 0
-- api_key defaults to $ANTHROPIC_API_KEY
-- opts.transport lets a test supply the HTTP call.
-- transport(body_json, headers) -> ok, raw_response_string, exit_code
-- Defaults to curl. This exists because the two things most likely to be
-- wrong here -- the shape of the request and the parsing of the response --
-- are pure functions of data, and testing them should not require a network,
-- an account, or spending anyone's tokens.
function backend.new(opts)
opts = opts or {}
local transport = opts.transport
local key = opts.api_key or os.getenv("ANTHROPIC_API_KEY")
if not transport and (not key or key == "") then
error("backend/http: set ANTHROPIC_API_KEY (or pass api_key)", 0)
end
return setmetatable({
key = key or "test-key",
transport = transport,
model = opts.model or "claude-opus-5",
max_tokens = opts.max_tokens or 1024,
temperature = opts.temperature or 0,
}, Http)
end
-- The system prompt carries the format contract. On the local backend the
-- grammar enforces this; here it is a request the model honours.
local SYSTEM = table.concat({
"You are an agent driving a tool loop. Reply with exactly two lines "
.. "and nothing else:",
protocol.THOUGHT_PREFIX .. "<one sentence>",
protocol.CALL_PREFIX .. '{"tool":"<name>","args":{"<key>":"<value>"}}',
"The CALL line must be a single line of valid JSON. Every argument "
.. "value is a string. Do not wrap it in a code fence.",
}, "\n")
-- Build the request body. Pure; separated so it can be asserted against.
function Http:build_request(prompt)
return {
model = self.model,
max_tokens = self.max_tokens,
temperature = self.temperature,
system = SYSTEM,
stop_sequences = protocol.STOP,
messages = { { role = "user", content = prompt } },
}
end
-- Turn a raw response body into text + meta, or raise with a message the
-- agent loop will surface. Pure; every branch here is a real failure mode of
-- the Messages API and none of them are reachable by a happy-path test.
function backend.parse_response(raw)
local resp, derr = trace.decode(raw)
if not resp then
error("could not parse the API response: " .. tostring(derr), 0)
end
if resp.error then
error(string.format("API %s: %s",
tostring(resp.error.type), tostring(resp.error.message)), 0)
end
-- A policy decline arrives as HTTP 200 with stop_reason "refusal", not
-- as an error status. Check it before reading content.
if resp.stop_reason == "refusal" then
local why = resp.stop_details and resp.stop_details.category or "unspecified"
error("the model declined this request (" .. tostring(why) .. ")", 0)
end
-- content is an array and may hold thinking blocks before the text --
-- thinking is on by default on Opus-tier models -- so filter by type
-- rather than taking content[1].
local parts = {}
for _, block in ipairs(resp.content or {}) do
if block.type == "text" then parts[#parts + 1] = block.text end
end
return table.concat(parts), {
tokens = resp.usage and resp.usage.output_tokens,
input_tokens = resp.usage and resp.usage.input_tokens,
stop_reason = resp.stop_reason,
model = resp.model,
constrained = false,
}
end
-- The default transport.
--
-- The request goes through files rather than the command line: the prompt
-- contains model output, newlines and quotes, and interpolating that into a
-- shell command is both fragile and a command-injection hole. The API key
-- goes in a header file for the same reason -- as an argument it would be
-- visible in the process list.
function Http:curl(body_json)
local req_path, err = tmpfile.write(body_json, ".req")
if not req_path then return false, err, -1 end
local hdr_path, herr = tmpfile.write(table.concat({
"x-api-key: " .. self.key,
"anthropic-version: " .. API_VERSION,
"content-type: application/json",
}, "\n") .. "\n", ".hdr")
if not hdr_path then
tmpfile.remove(req_path)
return false, herr, -1
end
local out_path = tmpfile.name(".out")
local cmd = string.format(
'curl -sS --fail-with-body -X POST %s -H @"%s" -d @"%s" -o "%s"',
ENDPOINT, hdr_path, req_path, out_path)
local ok, _, code = os.execute(cmd)
local raw = tmpfile.slurp(out_path) or ""
tmpfile.remove(req_path, hdr_path, out_path)
return ok, raw, code
end
function Http:complete(prompt, opts)
local body_json = trace.encode(self:build_request(prompt))
local ok, raw, code
if self.transport then
ok, raw, code = self.transport(body_json)
else
ok, raw, code = self:curl(body_json)
end
if not ok then
error(string.format("request failed (exit %s): %s",
tostring(code), tostring(raw):sub(1, 400)), 0)
end
return backend.parse_response(raw)
end
return backend
|