|
8 | 8 |
|
9 | 9 | (def ^:dynamic *thinking-tag* "think") |
10 | 10 |
|
| 11 | +;; Matches the real OpenAI streaming contract: a `usage` chunk is only emitted |
| 12 | +;; when the request opted in via `stream_options.include_usage = true`. |
| 13 | +(def ^:dynamic *include-usage?* false) |
| 14 | + |
11 | 15 | (defn set-thinking-tag! [tag] |
12 | 16 | (alter-var-root #'*thinking-tag* (constantly tag))) |
13 | 17 |
|
|
16 | 20 | [ch m] |
17 | 21 | (hk/send! ch (str "data: " (json/generate-string m) "\n\n") false)) |
18 | 22 |
|
| 23 | +(defn ^:private send-usage! |
| 24 | + "Send a usage SSE chunk only when the client requested it." |
| 25 | + [ch payload] |
| 26 | + (when *include-usage?* |
| 27 | + (send-sse! ch {:usage payload}))) |
| 28 | + |
19 | 29 | (defn ^:private messages->normalized-input |
20 | 30 | "Transforms OpenAI Chat messages into the canonical ECA :input + :instructions format |
21 | 31 | used by tests for assertions. We extract the first system message as :instructions |
|
49 | 59 | ;; Stream two content chunks, then a usage chunk, then a finish chunk |
50 | 60 | (send-sse! ch {:choices [{:delta {:content "Knock"}}]}) |
51 | 61 | (send-sse! ch {:choices [{:delta {:content " knock!"}}]}) |
52 | | - (send-sse! ch {:usage {:prompt_tokens 10 :completion_tokens 20}}) |
| 62 | + (send-usage! ch {:prompt_tokens 10 :completion_tokens 20}) |
53 | 63 | (send-sse! ch {:choices [{:delta {} :finish_reason "stop"}]}) |
54 | 64 | (hk/close ch)) |
55 | 65 |
|
56 | 66 | (defn ^:private simple-text-1 [ch] |
57 | 67 | (send-sse! ch {:choices [{:delta {:content "Foo"}}]}) |
58 | | - (send-sse! ch {:usage {:prompt_tokens 10 :completion_tokens 5}}) |
| 68 | + (send-usage! ch {:prompt_tokens 10 :completion_tokens 5}) |
59 | 69 | (send-sse! ch {:choices [{:delta {} :finish_reason "stop"}]}) |
60 | 70 | (hk/close ch)) |
61 | 71 |
|
|
64 | 74 | (send-sse! ch {:choices [{:delta {:content " bar!"}}]}) |
65 | 75 | (send-sse! ch {:choices [{:delta {:content "\n\n"}}]}) |
66 | 76 | (send-sse! ch {:choices [{:delta {:content "Ha!"}}]}) |
67 | | - (send-sse! ch {:usage {:prompt_tokens 5 :completion_tokens 15}}) |
| 77 | + (send-usage! ch {:prompt_tokens 5 :completion_tokens 15}) |
68 | 78 | (send-sse! ch {:choices [{:delta {} :finish_reason "stop"}]}) |
69 | 79 | (hk/close ch)) |
70 | 80 |
|
|
75 | 85 | (send-sse! ch {:choices [{:delta {:content (str "</" *thinking-tag* ">")}}]}) |
76 | 86 | (send-sse! ch {:choices [{:delta {:content "hello"}}]}) |
77 | 87 | (send-sse! ch {:choices [{:delta {:content " there!"}}]}) |
78 | | - (send-sse! ch {:usage {:prompt_tokens 10 :completion_tokens 20}}) |
| 88 | + (send-usage! ch {:prompt_tokens 10 :completion_tokens 20}) |
79 | 89 | (send-sse! ch {:choices [{:delta {} :finish_reason "stop"}]}) |
80 | 90 | (hk/close ch)) |
81 | 91 |
|
|
86 | 96 | (send-sse! ch {:choices [{:delta {:content (str "</" *thinking-tag* ">")}}]}) |
87 | 97 | (send-sse! ch {:choices [{:delta {:content "I'm "}}]}) |
88 | 98 | (send-sse! ch {:choices [{:delta {:content " fine"}}]}) |
89 | | - (send-sse! ch {:usage {:prompt_tokens 10 :completion_tokens 20}}) |
| 99 | + (send-usage! ch {:prompt_tokens 10 :completion_tokens 20}) |
90 | 100 | (send-sse! ch {:choices [{:delta {} :finish_reason "stop"}]}) |
91 | 101 | (hk/close ch)) |
92 | 102 |
|
|
115 | 125 | :function {:arguments "{\"pat"}}]}}]}) |
116 | 126 | (send-sse! ch {:choices [{:delta {:tool_calls [{:index 0 |
117 | 127 | :function {:arguments (str "h\":\"" (h/json-escape-path path) "\"}")}}]}}]}) |
118 | | - (send-sse! ch {:usage {:prompt_tokens 5 :completion_tokens 30}}) |
| 128 | + (send-usage! ch {:prompt_tokens 5 :completion_tokens 30}) |
119 | 129 | (send-sse! ch {:choices [{:delta {} :finish_reason "tool_calls"}]}) |
120 | 130 | (hk/close ch)) |
121 | 131 |
|
122 | 132 | (defn ^:private tool-calling-with-thought-signature-1 [ch] |
123 | 133 | ;; Second stage response after tool output |
124 | 134 | (send-sse! ch {:choices [{:delta {:content "The files I see:\n"}}]}) |
125 | 135 | (send-sse! ch {:choices [{:delta {:content "file1\nfile2\n"}}]}) |
126 | | - (send-sse! ch {:usage {:prompt_tokens 5 :completion_tokens 30}}) |
| 136 | + (send-usage! ch {:prompt_tokens 5 :completion_tokens 30}) |
127 | 137 | (send-sse! ch {:choices [{:delta {} :finish_reason "stop"}]}) |
128 | 138 | (hk/close ch)) |
129 | 139 |
|
|
132 | 142 | (let [body (some-> (slurp (:body req)) (json/parse-string true)) |
133 | 143 | messages (:messages body) |
134 | 144 | normalized (messages->normalized-input messages) |
135 | | - normalized-body (merge normalized (select-keys body [:tools]))] |
| 145 | + normalized-body (merge normalized (select-keys body [:tools])) |
| 146 | + include-usage? (boolean (get-in body [:stream_options :include_usage]))] |
136 | 147 | (hk/as-channel |
137 | 148 | req |
138 | 149 | {:on-open (fn [ch] |
| 150 | + (binding [*include-usage?* include-usage?] |
139 | 151 | ;; Send initial response headers for SSE |
140 | | - (hk/send! ch {:status 200 |
141 | | - :headers {"Content-Type" "text/event-stream; charset=utf-8" |
142 | | - "Cache-Control" "no-cache" |
143 | | - "Connection" "keep-alive"}} |
144 | | - false) |
145 | | - (if (string/includes? (:content (first (:messages body))) llm.mocks/chat-title-generator-str) |
146 | | - (chat-title-text-0 ch) |
147 | | - (do |
148 | | - (llm.mocks/set-req-body! llm.mocks/*case* normalized-body) |
149 | | - (llm.mocks/set-raw-messages! llm.mocks/*case* messages) |
150 | | - (let [has-tool-message? (some #(= "tool" (:role %)) messages)] |
151 | | - (case llm.mocks/*case* |
152 | | - :simple-text-0 (simple-text-0 ch) |
153 | | - :simple-text-1 (simple-text-1 ch) |
154 | | - :simple-text-2 (simple-text-2 ch) |
155 | | - :reasoning-0 (reasoning-text-0 ch) |
156 | | - :reasoning-1 (reasoning-text-1 ch) |
157 | | - :tool-calling-with-thought-signature-0 |
158 | | - (if has-tool-message? |
159 | | - (tool-calling-with-thought-signature-1 ch) |
160 | | - (tool-calling-with-thought-signature-0 ch (h/project-path->canon-path "resources"))) |
161 | | - ;; default fallback |
162 | | - (do |
163 | | - (send-sse! ch {:choices [{:delta {:content "hello"}}]}) |
164 | | - (send-sse! ch {:choices [{:delta {} :finish_reason "stop"}]}) |
165 | | - (hk/close ch)))))))}))) |
| 152 | + (hk/send! ch {:status 200 |
| 153 | + :headers {"Content-Type" "text/event-stream; charset=utf-8" |
| 154 | + "Cache-Control" "no-cache" |
| 155 | + "Connection" "keep-alive"}} |
| 156 | + false) |
| 157 | + (if (string/includes? (:content (first (:messages body))) llm.mocks/chat-title-generator-str) |
| 158 | + (chat-title-text-0 ch) |
| 159 | + (do |
| 160 | + (llm.mocks/set-req-body! llm.mocks/*case* normalized-body) |
| 161 | + (llm.mocks/set-raw-messages! llm.mocks/*case* messages) |
| 162 | + (let [has-tool-message? (some #(= "tool" (:role %)) messages)] |
| 163 | + (case llm.mocks/*case* |
| 164 | + :simple-text-0 (simple-text-0 ch) |
| 165 | + :simple-text-1 (simple-text-1 ch) |
| 166 | + :simple-text-2 (simple-text-2 ch) |
| 167 | + :reasoning-0 (reasoning-text-0 ch) |
| 168 | + :reasoning-1 (reasoning-text-1 ch) |
| 169 | + :tool-calling-with-thought-signature-0 |
| 170 | + (if has-tool-message? |
| 171 | + (tool-calling-with-thought-signature-1 ch) |
| 172 | + (tool-calling-with-thought-signature-0 ch (h/project-path->canon-path "resources"))) |
| 173 | + ;; default fallback |
| 174 | + (do |
| 175 | + (send-sse! ch {:choices [{:delta {:content "hello"}}]}) |
| 176 | + (send-sse! ch {:choices [{:delta {} :finish_reason "stop"}]}) |
| 177 | + (hk/close ch))))))))}))) |
0 commit comments