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