Skip to content

Commit 9ca1339

Browse files
Add Claude support
1 parent 91121bc commit 9ca1339

File tree

5 files changed

+277
-11
lines changed

5 files changed

+277
-11
lines changed

runbook.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ clj -M:main run \
148148
--host-dir /Users/slim/docker/labs-make-runbook \
149149
--user jimclark106 \
150150
--platform darwin \
151-
--prompts-file prompts/qrencode/README.md
151+
--prompts-file prompts/examples/qrencode.md
152152
```
153153

154154
```sh

src/claude.clj

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
(ns claude
2+
(:require
3+
[babashka.http-client :as http]
4+
[cheshire.core :as json]
5+
[clojure.core.async :as async]
6+
[clojure.java.io :as io]
7+
[clojure.pprint :refer [pprint]]
8+
[clojure.string :as string]
9+
[jsonrpc]))
10+
11+
(set! *warn-on-reflection* true)
12+
13+
(defn claude-api-key []
14+
(try
15+
(string/trim (slurp (io/file (or (System/getenv "CLAUDE_API_KEY_LOCATION") (System/getenv "HOME")) ".claude-api-key")))
16+
(catch Throwable _ nil)))
17+
18+
(defn parse-sse [s]
19+
(when (string/starts-with? s "data:")
20+
(string/replace s "data: " "")))
21+
22+
(defn prepare-system-messages [request]
23+
(let [system-message (->> request :messages (filter #(= "system" (:role %))) (map :content) (apply str))]
24+
(-> request
25+
(assoc :system system-message)
26+
(update-in [:messages] (fn [messages] (filter (complement #(= "system" (:role %))) messages))))))
27+
28+
(comment
29+
(prepare-system-messages {})
30+
(prepare-system-messages {:messages [{:role "system" :content "hello"}
31+
{:role "user" :content "world"}]}))
32+
33+
(defn map-to-claude [tools]
34+
(->> tools
35+
(map (fn [{:keys [function]}] (-> function
36+
(select-keys [:name :description])
37+
(assoc :input_schema (or (:parameters function) {:type "object" :properties {}}))
38+
(assoc-in [:input_schema :required] []))))))
39+
40+
;; tool messages in claude are user messages with a tool_use_id instead of a tool_call_id
41+
(defn filter-tool-messages [messages]
42+
(->> messages
43+
(map (fn [{:keys [role] :as m}]
44+
(if (= role "tool")
45+
{:role "user" :content [{:type "tool_result" :content (:content m) :tool_use_id (:tool_call_id m)}]}
46+
m)))))
47+
48+
(defn map-tool-use-messages [messages]
49+
(->> messages
50+
(map (fn [{:keys [tool_calls] :as message}]
51+
(if tool_calls
52+
{:role (:role message)
53+
:content (concat
54+
(when (:content message) [{:type "text" :text (:content message)}])
55+
(->> tool_calls
56+
(map (fn [{:keys [id function]}]
57+
{:type "tool_use"
58+
:id id
59+
:name (:name function)
60+
:input (or (json/parse-string (:arguments function) true) {})}))))}
61+
message)))))
62+
63+
(comment
64+
(filter-tool-messages [{:role "tool" :content "hello" :tool_call_id "1234"}]))
65+
66+
; tool
67+
(defn sample
68+
"get a response
69+
response stream handled by callback
70+
returns nil
71+
throws exception if response can't be initiated or if we get a non 200 status code"
72+
[request cb]
73+
(jsonrpc/notify :start {:level (or (:level request) 0) :role "assistant"})
74+
(let [b (merge
75+
{:model "claude-3-5-sonnet-20241022"
76+
:max_tokens 8192}
77+
(when (seq (:tools request))
78+
{:tool_choice {:type "auto"
79+
:disable_parallel_tool_use true}})
80+
(-> request
81+
(prepare-system-messages)
82+
(update-in [:messages] filter-tool-messages)
83+
(update-in [:messages] map-tool-use-messages)
84+
(update-in [:tools] map-to-claude)
85+
(dissoc :url :level)))
86+
87+
response
88+
(http/post
89+
(or (:url request) "https://api.anthropic.com/v1/messages")
90+
(merge
91+
{:body (json/encode b)
92+
:headers {"x-api-key" (or (claude-api-key)
93+
(System/getenv "CLAUDE_API_KEY"))
94+
"anthropic-version" "2023-06-01"
95+
"Content-Type" "application/json"}
96+
:throw false}
97+
(when (true? (:stream b))
98+
{:as :stream})))]
99+
(if (= 200 (:status response))
100+
(if (not (true? (:stream b)))
101+
(some-> (if (string? (:body response))
102+
(:body response)
103+
(slurp (:body response)))
104+
(json/parse-string true)
105+
(cb))
106+
(doseq [chunk (line-seq (io/reader (:body response)))]
107+
(some-> chunk
108+
(parse-sse)
109+
(json/parse-string true)
110+
(cb))))
111+
(let [s (if (string? (:body response))
112+
(:body response)
113+
(slurp (:body response)))]
114+
(jsonrpc/notify :message {:content s})
115+
(throw (ex-info "Failed to call Claude API" {:body s}))))))
116+
117+
(comment
118+
(sample {:messages [{:role "user" :content "hello"}]
119+
:stream true} println)
120+
(sample {:messages [{:role "user" :content "run the curl command for https://www.google.com"}]
121+
:tools [{:name "curl"
122+
:description "run the curl command"
123+
:input_schema {:type "object"
124+
:properties {:url {:type "string"}}}}]
125+
:stream true} println))
126+
127+
(def stop-reasons
128+
{:end_turn "stopped normally"
129+
:max_tokens "max response length reached"
130+
:stop_sequence "making tool calls"
131+
:tool_use "content filter applied"})
132+
133+
(defn update-tool-calls [m tool-calls]
134+
(reduce
135+
(fn [m {:keys [id name arguments]}]
136+
(if id
137+
(-> m
138+
(update-in [:tool-calls id :function] (constantly {:name name}))
139+
(assoc-in [:tool-calls id :id] id)
140+
(assoc :current-tool-call id))
141+
(update-in m [:tool-calls (:current-tool-call m) :function :arguments] (fnil str "") arguments)))
142+
m tool-calls))
143+
144+
(defn response-loop
145+
"handle one response stream that we read from input channel c
146+
adds content or tool_calls while streaming and call any functions when done
147+
returns channel that will emit the an event with a ::response"
148+
[c]
149+
(let [response (atom {})]
150+
(async/go-loop
151+
[]
152+
(let [e (async/<! c)]
153+
(if (:done e)
154+
(let [{calls :tool-calls content :content finish-reason :finish-reason} @response
155+
r {:messages [(merge
156+
{:role "assistant"
157+
:content (or content "")}
158+
(when (seq (vals calls))
159+
{:tool_calls (->> (vals calls)
160+
(map #(assoc % :type "function")))}))]
161+
:finish-reason finish-reason}]
162+
163+
(jsonrpc/notify :message {:debug (str @response)})
164+
(jsonrpc/notify :functions-done (or (vals calls) ""))
165+
;; make-tool-calls returns a channel with results of tool call messages
166+
;; so we can continue the conversation
167+
r)
168+
169+
(let [{:keys [content tool_calls finish-reason]} e]
170+
(when content
171+
(swap! response update-in [:content] (fnil str "") content)
172+
(jsonrpc/notify :message {:content content}))
173+
(when tool_calls
174+
(swap! response update-tool-calls tool_calls)
175+
(jsonrpc/notify :functions (->> @response :tool-calls vals)))
176+
(when finish-reason (swap! response assoc :finish-reason finish-reason))
177+
178+
(recur)))))))
179+
180+
(defn chunk-handler
181+
"sets up a response handler loop for use with an OpenAI API call
182+
returns [channel openai-handler] - channel will emit the complete fully streamed response"
183+
[]
184+
(let [c (async/chan 1)]
185+
[(response-loop c)
186+
(fn [{:keys [delta type] :as chunk}]
187+
(cond
188+
(and
189+
(= "message_delta" type)
190+
(:stop_reason delta))
191+
(async/>!! c {:finish-reason (if (= "tool_use" (:stop_reason delta))
192+
"tool_calls"
193+
(:stop_reason delta))})
194+
195+
(and
196+
(= "content_block_start" type)
197+
(= "text" (-> chunk :content_block :type)))
198+
(async/>!! c {:content (-> chunk :content_block :text)})
199+
200+
(and
201+
(= "content_block_delta" type)
202+
(= "text_delta" (-> delta :type)))
203+
(async/>!! c {:content (-> delta :text)})
204+
205+
(= "message_stop" type)
206+
(async/>!! c {:done true})
207+
208+
(and
209+
(= "content_block_delta" type)
210+
(= "input_json_delta" (-> delta :type)))
211+
;; partial_json
212+
(async/>!! c {:tool_calls [{:arguments (-> delta :partial_json)}]})
213+
214+
(and
215+
(= "content_block_start" type)
216+
(= "tool_use" (-> chunk :content_block :type)))
217+
; id, name and input
218+
(async/>!! c {:tool_calls [(:content_block chunk)]})))]))
219+
220+
(comment
221+
222+
(let [[c h] (chunk-handler)]
223+
(sample {:messages [{:role "user" :content "hello"}]
224+
:stream true} h)
225+
(println "post-stream:\n" (-> (async/<!! c)
226+
(pprint)
227+
(with-out-str))))
228+
229+
(let [[c h] (chunk-handler)]
230+
(sample {:messages [{:role "user" :content "run the curl command for https://www.google.com"}]
231+
:tools [{:name "curl"
232+
:description "run the curl command"
233+
:input_schema {:type "object"
234+
:properties {:url {:type "string"}}}}]
235+
:stream true} h)
236+
(println "post-stream:\n" (-> (async/<!! c)
237+
(pprint)
238+
(with-out-str)))))

src/graph.clj

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
(ns graph
22
(:require
3+
claude
34
[clojure.core.async :as async]
45
[clojure.core.match :refer [match]]
56
[clojure.pprint :as pprint]
7+
[clojure.string :as string]
68
git
79
jsonrpc
810
openai
@@ -19,6 +21,21 @@
1921
(async/>!! c {:messages [{:role "assistant" :content s}]
2022
:finish-reason "error"}))
2123

24+
(def providers {:openai [openai/chunk-handler openai/openai]
25+
:claude [claude/chunk-handler claude/sample]})
26+
(defn llm-provider [model]
27+
(cond
28+
(nil? model) :openai
29+
(some #(string/starts-with? model %) ["gpt"]) :openai
30+
(some #(string/starts-with? model %) ["claude"]) :claude
31+
:else :openai))
32+
33+
(comment
34+
(llm-provider nil)
35+
(llm-provider "gpt-4")
36+
(llm-provider "claude-3.5-sonnet-latest")
37+
(providers (llm-provider "claude-3.5-sonnet-latest")))
38+
2239
(defn run-llm
2340
"call openai compatible chat completion endpoint and handle tool requests
2441
params
@@ -28,7 +45,8 @@
2845
opts for running the model
2946
returns channel that will contain an coll of messages"
3047
[{:keys [messages functions metadata] {:keys [url model stream level]} :opts}]
31-
(let [[c h] (openai/chunk-handler)
48+
(let [[chunk-handler sample] (providers (llm-provider (or (:model metadata) model)))
49+
[c h] (chunk-handler)
3250
request (merge
3351
(dissoc metadata :agent :host-dir :workdir :prompt-format :description :name) ; TODO should we just select relevant keys instead of removing bad ones
3452
{:messages messages
@@ -41,7 +59,7 @@
4159
(when (and stream (nil? (:stream metadata))) {:stream stream}))]
4260
(try
4361
(if (seq messages)
44-
(openai/openai request h)
62+
(sample request h)
4563
(stop-looping c "This is an empty set of prompts. Define prompts using h1 sections (eg `# prompt user`)"))
4664
(catch ConnectException _
4765
(stop-looping c "I cannot connect to an openai compatible endpoint."))

src/openai.clj

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,6 @@
7272
:content_filter "content filter applied"
7373
:not_specified "not specified"})
7474

75-
(s/def ::role #{"user" "system" "assistant" "tool"})
76-
(s/def ::content string?)
77-
(s/def ::message (s/keys :req-un [::role]
78-
:opt-un [::content ::tool-calls]))
79-
(s/def ::messages (s/coll-of ::message))
80-
(s/def ::finish-reason any?)
81-
(s/def ::response (s/keys :req-un [::finish-reason ::messages]))
82-
8375
(defn response-loop
8476
"handle one response stream that we read from input channel c
8577
adds content or tool_calls while streaming and call any functions when done

src/schema.clj

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,21 @@
107107
{:name ""
108108
:arguments "serialized json"}}]})
109109

110+
;; response from both openai and claude sampling
111+
(comment
112+
(s/def ::name string?)
113+
(s/def ::arguments string?)
114+
(s/def ::id string?)
115+
(s/def :response/function (s/keys :req-un [::name ::arguments]))
116+
(s/def :function/type #{"function"})
117+
(s/def ::tool_call (s/keys :req-un [:response/function ::id :function/type]))
118+
(s/def ::tool_calls (s/coll-of ::tool_call))
119+
(s/def :response/role #{"assistant"})
120+
(s/def :response/content string?)
121+
(s/def ::message (s/keys :req-un [:response/role]
122+
:opt-un [:response/content ::tool_calls]))
123+
(s/def ::messages (s/coll-of ::message))
124+
;; Claude uses tool_use but we'll standardize on tool_calls (the openai term)
125+
(s/def ::finish-reason any?)
126+
(s/def ::response (s/keys :req-un [::finish-reason ::messages])))
127+

0 commit comments

Comments
 (0)