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
187
188
189
190 | (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."
(:require
[clojure.java.io :as io]
[clojure.string :as str]
[ird.transport :as transport])
(:import
[java.io ByteArrayOutputStream File InputStream]
[java.util Base64]))
;; ---------------------------------------------------------------------------
;; Endpoints
(defn enqueue
"POST /jobs — Enqueue a job. Returns 202 with the queued job.
JOB-SPEC is the job payload including its :type (see the builders below).
Returns the parsed response map."
[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.
Returns the parsed response map."
[client id]
(transport/request client :get (str "/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).
Returns the parsed response map."
[client id]
(transport/request client :get (str "/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 so fields added server-side work without a client
update."
([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 so fields added server-side work without a client
update."
([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 so fields
added server-side work without a client update."
([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]
(some (fn [[ext mime]]
(when (str/ends-with? (str/lower-case (str path)) ext) mime))
mime-types))
(defn- source->bytes
^bytes [source]
(cond
(bytes? source) source
(instance? InputStream source) (let [out (ByteArrayOutputStream.)]
(io/copy source out)
(.toByteArray out))
(or (string? source) (instance? File source))
(let [out (ByteArrayOutputStream.)]
(io/copy (io/file source) out)
(.toByteArray out))
:else (throw (ex-info "Unsupported image source; expected a file path, File, InputStream, or byte array"
{:type :ird/config-error
:source-class (some-> source class str)}))))
(defn text-part
"OpenAI-style text content part."
[text]
{:type "text" :text text})
(defn image-part
"OpenAI-style image_url content part from SOURCE (file path, File,
InputStream, or byte array), 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."
([source]
(image-part source (or (guess-mime source)
(throw (ex-info "Cannot guess image MIME type; pass it explicitly"
{:type :ird/config-error
:source (str source)})))))
([source mime-type]
{:type "image_url"
:image_url {:url (str "data:" mime-type ";base64,"
(.encodeToString (Base64/getEncoder)
(source->bytes source)))}}))
(defn user-message
"A user message whose :content is a content-part array of TEXT plus any
IMAGE-PARTS (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 return 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)
Throws ex-info :ird/job-failed 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]}]
(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)})]
(if (= "succeeded" (:status job))
(get-result client id)
(throw (ex-info (str "Job " id " failed")
{:type :ird/job-failed :job job}))))))
|