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 | (ns ird.jobs
"Asynchronous processing jobs: enqueue, poll for status, then fetch the
result. Submitted content is never persisted; results are kept for 24
hours.
Every job type has a payload builder below (`domain-availability-bulk`,
`embedding`, `llm-chat`, `llm-raw-completion`, `nlp-analyze`,
`nlp-detect-language`); pass the built payload to `enqueue`, or use `run`
to enqueue, poll and fetch the result in one call. The client never
validates payloads itself, so job types or fields added server-side work
without a client update. The builders are plain synchronous functions;
the endpoint fns, `image-part` and `run` return channels (see ird.async)."
(:require [clojure.string :as str]
[ird.async :refer [go-try <?]]
[ird.errors :as err]
[ird.node :as node]
[ird.transport :as transport]))
;; ---------------------------------------------------------------------------
;; Endpoints
(defn enqueue
"POST /jobs — Enqueue a job. 202 with the queued job.
JOB-SPEC is the job payload including its :type (see the builders below)."
[client job-spec]
(transport/request client :post "/jobs" {:body job-spec}))
(defn get-job
"GET /jobs/{id} — Job status. :job :status is one of
queued/processing/succeeded/failed."
[client id]
(transport/request client :get ["jobs" id]))
(defn get-result
"GET /jobs/{id}/result — Job result. 409 while the job is still running,
410 once the result has expired (24h)."
[client id]
(transport/request client :get ["jobs" id "result"]))
;; ---------------------------------------------------------------------------
;; Payload builders (one per job type)
(defn domain-availability-bulk
"Payload for a domain-availability-bulk job: check up to 100 DOMAINS."
[domains]
{:type "domain-availability-bulk"
:domains (vec domains)})
(defn embedding
"Payload for an embedding job over INPUT text.
OPTS: :model (defaults server-side to BAAI/bge-m3); any other keys are
passed through verbatim so fields added server-side work without a client
update."
([input] (embedding input nil))
([input opts]
(merge opts {:type "embedding" :input input})))
(defn llm-chat
"Payload for an llm-chat job over MESSAGES
([{:role \"user\" :content \"...\"} ...]).
OPTS: :model, :max_tokens, :temperature; any other keys are passed
through verbatim."
([messages] (llm-chat messages nil))
([messages opts]
(merge opts {:type "llm-chat" :messages (vec messages)})))
(defn llm-raw-completion
"Payload for an llm-raw-completion job over PROMPT.
OPTS: :model, :max_tokens, :temperature; any other keys are passed
through verbatim."
([prompt] (llm-raw-completion prompt nil))
([prompt opts]
(merge opts {:type "llm-raw-completion" :prompt prompt})))
(defn nlp-analyze
"Payload for an nlp-analyze job over INPUT text.
OPTS: :language; any other keys are passed through verbatim."
([input] (nlp-analyze input nil))
([input opts]
(merge opts {:type "nlp-analyze" :input input})))
(defn nlp-detect-language
"Payload for an nlp-detect-language job over INPUT text."
[input]
{:type "nlp-detect-language" :input input})
;; ---------------------------------------------------------------------------
;; Multimodal message helpers
;;
;; The deployed chat model is vision-capable, but the public /jobs schema
;; currently accepts only plain-string :content. These helpers build
;; OpenAI-style content-part arrays so callers are ready the moment the API
;; starts accepting them; until then the server rejects such payloads.
(def ^:private mime-types
{".png" "image/png"
".jpg" "image/jpeg"
".jpeg" "image/jpeg"
".webp" "image/webp"
".gif" "image/gif"})
(defn- guess-mime
[path]
(when (string? path)
(some (fn [[ext mime]]
(when (str/ends-with? (str/lower-case path) ext) mime))
mime-types)))
(defn text-part
"OpenAI-style text content part."
[text]
{:type "text" :text text})
(defn image-part
"OpenAI-style image_url content part from SOURCE (a file path string, read
with node:fs, or bytes: Uint8Array/Buffer/ArrayBuffer), embedded as a
base64 data URL. MIME type is sniffed from the file extension
(png/jpg/jpeg/webp/gif) or passed explicitly as MIME-TYPE. Returns a
channel of the part (an :ird/config-error when the MIME type is unknown)."
([source]
(if-let [mime (guess-mime source)]
(image-part source mime)
(go-try (throw (err/config-error "Cannot guess image MIME type; pass it explicitly"
{:source (str source)})))))
([source mime-type]
(go-try
(let [bytes (if (string? source) (<? (node/read-file source)) source)]
{:type "image_url"
:image_url {:url (str "data:" mime-type ";base64," (node/base64-encode bytes))}}))))
(defn user-message
"A user message whose :content is a content-part array of TEXT plus any
IMAGE-PARTS (already-delivered values from `image-part`). Requires
server-side multimodal support — see the namespace docstring."
[text & image-parts]
{:role "user"
:content (into [(text-part text)] image-parts)})
;; ---------------------------------------------------------------------------
;; Run helper
(def terminal-status #{"succeeded" "failed"})
(defn run
"Convenience: enqueue JOB-SPEC, poll until the job reaches a terminal
status, then fetch and deliver the result map.
OPTS:
:interval-ms initial poll interval (default 1000, backing off to an 8s
cap — see ird.transport/poll-until)
:timeout-ms overall deadline (default 120000)
Delivers an :ird/job-failed error with the failed job map, or :ird/timeout
when the deadline passes first."
([client job-spec] (run client job-spec nil))
([client job-spec {:keys [interval-ms timeout-ms]}]
(go-try
(let [enqueued (<? (enqueue client job-spec))
id (get-in enqueued [:job :id])
{:keys [job]} (<? (transport/poll-until
#(get-job client id)
(fn [resp]
(contains? terminal-status (get-in resp [:job :status])))
{:interval-ms interval-ms
:timeout-ms (or timeout-ms 120000)
:message (str "Timed out waiting for job " id)
:sleep-fn (:sleep-fn client)}))]
(if (= "succeeded" (:status job))
(<? (get-result client id))
(throw (err/job-failed (str "Job " id " failed") job)))))))
|