okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petopenai_api: decode Responses streams that omit output_indexb3ecc04

main
19.9 KiB542 linesraw
1// OpenAI streaming wire-format errors and partial tool calls must never become executable output.
2import albedo/openai_api/sse
3import albedo/openai_api/stream
4import albedo/openai_api/types
5import gleam/dynamic/decode
6import gleam/json
7import gleam/list
8import gleam/option.{type Option, None, Some}
9
10pub fn responses_incomplete_never_exposes_executable_calls_test() -> Result(
11  #(stream.State, List(types.Event), Option(types.Turn)),
12  types.Error,
13) {
14  let data =
15    "{\"type\":\"response.incomplete\",\"response\":{\"output\":[{\"type\":\"function_call\",\"call_id\":\"danger\",\"name\":\"delete\",\"arguments\":\"{}\"}],\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}"
16  let assert Ok(#(
17    _,
18    [],
19    Some(types.Turn(None, [_], [], None, types.LengthLimit, None, [])),
20  )) = send(stream.new(types.Responses), "response.incomplete", data)
21}
22
23pub fn responses_provider_failures_and_malformed_fields_are_typed_test() -> Nil {
24  let failed =
25    "{\"type\":\"response.failed\",\"response\":{\"error\":{\"message\":\"capacity\"}}}"
26  assert send(stream.new(types.Responses), "response.failed", failed)
27    == Error(types.ProviderError("capacity"))
28  let malformed = "{\"type\":\"response.created\",\"response\":{\"id\":42}}"
29  let assert Error(types.InvalidEvent(_)) =
30    send(stream.new(types.Responses), "response.created", malformed)
31  let unsupported =
32    "{\"type\":\"response.custom_tool_call_input.delta\",\"delta\":\"rm\"}"
33  assert send(
34      stream.new(types.Responses),
35      "response.custom_tool_call_input.delta",
36      unsupported,
37    )
38    == Error(types.Unsupported(
39      "unsupported Responses semantic event: response.custom_tool_call_input.delta",
40    ))
41}
42
43pub fn responses_argument_deltas_name_the_tool_their_item_opened_with_test() -> Result(
44  #(stream.State, List(types.Event), Option(types.Turn)),
45  types.Error,
46) {
47  let added =
48    "{\"type\":\"response.output_item.added\",\"output_index\":1,\"item\":{\"type\":\"function_call\",\"call_id\":\"c\",\"name\":\"web_search\",\"arguments\":\"\"}}"
49  let assert Ok(#(state, [], None)) =
50    send(stream.new(types.Responses), "response.output_item.added", added)
51  let delta =
52    "{\"type\":\"response.function_call_arguments.delta\",\"output_index\":1,\"delta\":\"{\\\"q\"}"
53  let assert Ok(#(_, [types.ArgumentsDelta(1, "web_search", "{\"q")], None)) =
54    send(state, "response.function_call_arguments.delta", delta)
55}
56
57pub fn chat_accumulates_interleaved_tools_usage_and_native_replay_test() -> Nil {
58  let state = stream.new(types.ChatCompletions)
59  let first =
60    "{\"id\":\"chat_1\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"hel\",\"reasoning_content\":\"think \"},\"finish_reason\":null}]}"
61  let assert Ok(#(
62    state,
63    [
64      types.Started("chat_1"),
65      types.ThinkingDelta("think "),
66      types.TextDelta(0, 0, "hel"),
67    ],
68    None,
69  )) = send(state, "", first)
70  let one =
71    "{\"id\":\"chat_1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_b\",\"type\":\"function\",\"function\":{\"name\":\"beta\",\"arguments\":\"{\\\"b\\\":\"}},{\"index\":0,\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"alpha\",\"arguments\":\"{\\\"a\\\":\"}}]},\"finish_reason\":null}]}"
72  let assert Ok(#(
73    state,
74    [
75      types.ArgumentsDelta(1, "beta", "{\"b\":"),
76      types.ArgumentsDelta(0, "alpha", "{\"a\":"),
77    ],
78    None,
79  )) = send(state, "", one)
80  let two =
81    "{\"id\":\"chat_1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"lo\",\"refusal\":\"no\",\"reasoning_content\":\"carefully\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"1}\"}},{\"index\":1,\"function\":{\"arguments\":\"2}\"}}]},\"finish_reason\":\"tool_calls\"}]}"
82  let assert Ok(#(state, events, None)) = send(state, "", two)
83  assert events
84    == [
85      types.ThinkingDelta("carefully"),
86      types.TextDelta(0, 0, "lo"),
87      types.ArgumentsDelta(0, "alpha", "1}"),
88      types.ArgumentsDelta(1, "beta", "2}"),
89    ]
90  let usage =
91    "{\"id\":\"chat_1\",\"choices\":[],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":8,\"total_tokens\":13,\"prompt_tokens_details\":{\"cached_tokens\":0}}}"
92  let assert Ok(#(state, [], None)) = send(state, "", usage)
93  let assert Ok(#(
94    _,
95    [],
96    Some(types.Turn(
97      Some("chat_1"),
98      [message],
99      [
100        types.ToolCall("call_a", "alpha", "{\"a\":1}"),
101        types.ToolCall("call_b", "beta", "{\"b\":2}"),
102      ],
103      Some(types.Usage(5, 8, Some(0), None, None, None, None)),
104      types.ToolCalls,
105      None,
106      [#("call_a", 0), #("call_b", 1)],
107    )),
108  )) = send(state, "", "[DONE]")
109  let decoder = {
110    use content <- decode.field("content", decode.string)
111    use refusal <- decode.field("refusal", decode.string)
112    use reasoning <- decode.field("reasoning_content", decode.string)
113    use tool_ids <- decode.field(
114      "tool_calls",
115      decode.list({
116        use id <- decode.field("id", decode.string)
117        decode.success(id)
118      }),
119    )
120    decode.success(#(content, refusal, reasoning, tool_ids))
121  }
122  assert types.inspect_item(message, decoder)
123    == Ok(#("hello", "no", "think carefully", ["call_a", "call_b"]))
124}
125
126pub fn cache_usage_details_tolerate_null_and_reject_malformed_values_test() -> Result(
127  #(stream.State, List(types.Event), Option(types.Turn)),
128  types.Error,
129) {
130  let responses_null =
131    "{\"type\":\"response.completed\",\"response\":{\"output\":[],\"usage\":{\"input_tokens\":3,\"output_tokens\":2,\"input_tokens_details\":null}}}"
132  let assert Ok(#(
133    _,
134    [],
135    Some(types.Turn(
136      _,
137      [],
138      [],
139      Some(types.Usage(3, 2, None, None, None, None, None)),
140      _,
141      None,
142      [],
143    )),
144  )) = send(stream.new(types.Responses), "", responses_null)
145
146  let chat_null =
147    "{\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":2,\"prompt_tokens_details\":null}}"
148  let assert Ok(#(state, _, None)) =
149    send(stream.new(types.ChatCompletions), "", chat_null)
150  let assert Ok(#(
151    _,
152    [],
153    Some(types.Turn(
154      _,
155      _,
156      [],
157      Some(types.Usage(3, 2, None, None, None, None, None)),
158      _,
159      None,
160      [],
161    )),
162  )) = send(state, "", "[DONE]")
163
164  let responses_malformed =
165    "{\"type\":\"response.completed\",\"response\":{\"output\":[],\"usage\":{\"input_tokens\":3,\"output_tokens\":2,\"input_tokens_details\":{\"cached_tokens\":\"3\"}}}}"
166  let assert Error(types.InvalidEvent(_)) =
167    send(stream.new(types.Responses), "", responses_malformed)
168
169  let chat_malformed =
170    "{\"choices\":[],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":2,\"prompt_tokens_details\":{\"cached_tokens\":\"3\"}}}"
171  let assert Error(types.InvalidEvent(_)) =
172    send(stream.new(types.ChatCompletions), "", chat_malformed)
173}
174
175pub fn responses_separates_reasoning_summary_parts_test() -> Result(
176  #(stream.State, List(types.Event), Option(types.Turn)),
177  types.Error,
178) {
179  let part = fn(index) {
180    "{\"type\":\"response.reasoning_summary_part.added\",\"output_index\":0,\"summary_index\":"
181    <> index
182    <> ",\"part\":{\"type\":\"summary_text\",\"text\":\"\"}}"
183  }
184  let state = stream.new(types.Responses)
185  let assert Ok(#(state, [], None)) = send(state, "", part("0"))
186  let assert Ok(#(_state, [types.ThinkingDelta("\n\n")], None)) =
187    send(state, "", part("1"))
188}
189
190pub fn chat_rejects_multiple_choices_and_nonempty_unknown_semantics_test() -> Result(
191  #(stream.State, List(types.Event), Option(types.Turn)),
192  types.Error,
193) {
194  let multiple =
195    "{\"choices\":[{\"index\":0,\"delta\":{}},{\"index\":1,\"delta\":{}}]}"
196  assert send(stream.new(types.ChatCompletions), "", multiple)
197    == Error(types.Unsupported("multiple chat completion choices"))
198  let audio =
199    "{\"choices\":[{\"index\":0,\"delta\":{\"audio\":{\"id\":\"a\"}}}]}"
200  let assert Error(types.InvalidEvent(_)) =
201    send(stream.new(types.ChatCompletions), "", audio)
202}
203
204pub fn chat_ignores_only_empty_unknown_fields_test() -> Nil {
205  list.each(["null", "\"\"", "[]", "{}"], fn(value) {
206    let data =
207      "{\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\",\"audio\":"
208      <> value
209      <> "}}]}"
210    let assert Ok(#(_, [types.TextDelta(0, 0, "hello")], None)) =
211      send(stream.new(types.ChatCompletions), "", data)
212  })
213  list.each(["\"x\"", "[0]", "{\"id\":null}", "0", "false", "true"], fn(value) {
214    let data =
215      "{\"choices\":[{\"index\":0,\"delta\":{\"audio\":" <> value <> "}}]}"
216    let assert Error(types.InvalidEvent(_)) =
217      send(stream.new(types.ChatCompletions), "", data)
218  })
219}
220
221pub fn chat_rejects_non_object_deltas_test() -> Nil {
222  list.each(["null", "[]", "42", "\"x\"", "false"], fn(value) {
223    let data = "{\"choices\":[{\"index\":0,\"delta\":" <> value <> "}]}"
224    let assert Error(types.InvalidEvent(_)) =
225      send(stream.new(types.ChatCompletions), "", data)
226  })
227}
228
229pub fn chat_provider_errors_take_precedence_only_with_string_messages_test() -> Nil {
230  list.each(["", "capacity"], fn(message) {
231    let data =
232      json.object([
233        #("error", json.object([#("message", json.string(message))])),
234        #(
235          "choices",
236          json.array(["hello"], fn(content) {
237            json.object([
238              #("index", json.int(0)),
239              #("delta", json.object([#("content", json.string(content))])),
240            ])
241          }),
242        ),
243      ])
244      |> json.to_string
245    assert send(stream.new(types.ChatCompletions), "", data)
246      == Error(types.ProviderError(message))
247  })
248  list.each(
249    [
250      "null", "42", "\"x\"", "{}", "{\"message\":null}", "{\"message\":42}",
251      "{\"message\":false}", "{\"message\":[]}",
252    ],
253    fn(value) {
254      let data =
255        "{\"error\":"
256        <> value
257        <> ",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"}}]}"
258      let assert Ok(#(_, [types.TextDelta(0, 0, "hello")], None)) =
259        send(stream.new(types.ChatCompletions), "", data)
260    },
261  )
262  let assert Error(types.InvalidEvent(_)) =
263    send(stream.new(types.ChatCompletions), "", "{\"error\":{\"message\":42}}")
264  Nil
265}
266
267pub fn chat_requires_finish_and_suppresses_partial_tools_on_limits_test() -> Nil {
268  assert send(stream.new(types.ChatCompletions), "", "[DONE]")
269    == Error(types.InvalidEvent("[DONE] before chat finish reason"))
270  let partial =
271    "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_partial\",\"type\":\"function\",\"function\":{\"name\":\"unsafe\",\"arguments\":\"{\"}}]},\"finish_reason\":\"length\"}]}"
272  let assert Ok(#(state, _, None)) =
273    send(stream.new(types.ChatCompletions), "", partial)
274  let assert Ok(#(
275    _,
276    [],
277    Some(types.Turn(None, [message], [], None, types.LengthLimit, None, [])),
278  )) = send(state, "", "[DONE]")
279  let decoder = {
280    use calls <- decode.optional_field(
281      "tool_calls",
282      None,
283      decode.optional(decode.list(decode.dynamic)),
284    )
285    decode.success(calls)
286  }
287  assert types.inspect_item(message, decoder) == Ok(None)
288}
289
290pub fn malformed_json_and_content_filter_finish_are_typed_test() -> Result(
291  #(stream.State, List(types.Event), Option(types.Turn)),
292  types.Error,
293) {
294  let assert Error(types.InvalidEvent(_)) =
295    send(stream.new(types.ChatCompletions), "", "{")
296  let assert Error(types.InvalidEvent(_)) =
297    send(stream.new(types.Responses), "", "[]")
298
299  let filtered =
300    "{\"choices\":[{\"index\":0,\"delta\":{\"content\":\"safe prefix\"},\"finish_reason\":\"content_filter\"}]}"
301  let assert Ok(#(state, _, None)) =
302    send(stream.new(types.ChatCompletions), "", filtered)
303  let assert Ok(#(
304    _,
305    [],
306    Some(types.Turn(_, _, [], _, types.ContentFiltered, None, [])),
307  )) = send(state, "", "[DONE]")
308}
309
310pub fn chat_assembles_name_fragments_and_encodes_tool_only_content_as_null_test() -> Result(
311  #(stream.State, List(types.Event), Option(types.Turn)),
312  types.Error,
313) {
314  let first =
315    "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call1\",\"function\":{\"name\":\"read_\",\"arguments\":\"{\"}}]}}]}"
316  let second =
317    "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"file\",\"arguments\":\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}"
318  let assert Ok(#(state, _, None)) =
319    send(stream.new(types.ChatCompletions), "", first)
320  let assert Ok(#(state, _, None)) = send(state, "", second)
321  let assert Ok(#(state, _, Some(turn))) = send(state, "", "[DONE]")
322  assert turn.tool_calls == [types.ToolCall("call1", "read_file", "{}")]
323  let assert [item] = turn.output
324  let encoded = types.replay_json(item) |> json.to_string
325  assert json.parse(
326      encoded,
327      decode.at(["content"], decode.optional(decode.string)),
328    )
329    == Ok(None)
330  let assert Error(types.InvalidEvent(_)) = send(state, "", "[DONE]")
331}
332
333pub fn chat_rejects_data_after_finish_reason_test() -> Result(
334  #(stream.State, List(types.Event), Option(types.Turn)),
335  types.Error,
336) {
337  let first =
338    "{\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}"
339  let late = "{\"choices\":[{\"index\":0,\"delta\":{\"content\":\"late\"}}]}"
340  let assert Ok(#(state, _, None)) =
341    send(stream.new(types.ChatCompletions), "", first)
342  let assert Error(types.InvalidEvent(_)) = send(state, "", late)
343}
344
345pub fn responses_rejects_incomplete_call_in_completed_response_test() -> Result(
346  #(stream.State, List(types.Event), Option(types.Turn)),
347  types.Error,
348) {
349  let event =
350    "{\"type\":\"response.completed\",\"response\":{\"output\":[{\"type\":\"function_call\",\"call_id\":\"c\",\"name\":\"tool\",\"arguments\":\"{}\",\"status\":\"in_progress\"}]}}"
351  let assert Error(types.InvalidEvent(_)) =
352    send(stream.new(types.Responses), "", event)
353}
354
355pub fn compatible_reasoning_fields_are_preserved_without_renaming_test() -> Nil {
356  let first =
357    "{\"choices\":[{\"index\":0,\"delta\":{\"reasoning\":\"r1\",\"reasoning_content\":\"c1\"}}]}"
358  let second =
359    "{\"choices\":[{\"index\":0,\"delta\":{\"reasoning\":\"r2\",\"reasoning_content\":\"c2\",\"content\":\"answer\"},\"finish_reason\":\"stop\"}]}"
360  let assert Ok(#(state, _, None)) =
361    send(stream.new(types.ChatCompletions), "", first)
362  let assert Ok(#(state, _, None)) = send(state, "", second)
363  let assert Ok(#(_, _, Some(turn))) = send(state, "", "[DONE]")
364  let assert [item] = turn.output
365  assert types.inspect_item(item, decode.at(["reasoning"], decode.string))
366    == Ok("r1r2")
367  assert types.inspect_item(
368      item,
369      decode.at(["reasoning_content"], decode.string),
370    )
371    == Ok("c1c2")
372}
373
374pub fn reasoning_details_merge_fragments_by_index_and_keep_metadata_test() -> Nil {
375  let first =
376    "{\"choices\":[{\"index\":0,\"delta\":{\"reasoning\":\"a\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"index\":0,\"format\":\"unknown\",\"text\":\"a\"}]}}]}"
377  let second =
378    "{\"choices\":[{\"index\":0,\"delta\":{\"reasoning\":\"b\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"index\":0,\"format\":\"unknown\",\"text\":\"b\"}],\"content\":\"answer\"},\"finish_reason\":\"stop\"}]}"
379  let assert Ok(#(state, _, None)) =
380    send(stream.new(types.ChatCompletions), "", first)
381  let assert Ok(#(state, _, None)) = send(state, "", second)
382  let assert Ok(#(_, _, Some(turn))) = send(state, "", "[DONE]")
383  let assert [item] = turn.output
384  assert types.inspect_item(
385      item,
386      decode.at(
387        ["reasoning_details"],
388        decode.list(decode.at(["text"], decode.string)),
389      ),
390    )
391    == Ok(["ab"])
392  assert types.inspect_item(
393      item,
394      decode.at(
395        ["reasoning_details"],
396        decode.list(decode.at(["format"], decode.string)),
397      ),
398    )
399    == Ok(["unknown"])
400}
401
402pub fn responses_uses_streamed_done_items_when_terminal_output_is_empty_test() -> Nil {
403  let state = stream.new(types.Responses)
404  let done =
405    "{\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"call_1\",\"name\":\"python\",\"arguments\":\"{\\\"code\\\":\\\"20 + 22\\\",\\\"timeout_ms\\\":1000}\",\"status\":\"completed\"}}"
406  let assert Ok(#(state, [], None)) = send(state, "", done)
407  let completed =
408    "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"output\":[],\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}}"
409  let assert Ok(#(
410    _,
411    [types.Started("resp_1")],
412    Some(types.Turn(
413      Some("resp_1"),
414      [_],
415      [types.ToolCall("call_1", "python", arguments)],
416      _,
417      types.ToolCalls,
418      None,
419      [#("call_1", 0)],
420    )),
421  )) = send(state, "", completed)
422  assert arguments == "{\"code\":\"20 + 22\",\"timeout_ms\":1000}"
423}
424
425pub fn responses_places_events_by_item_id_when_the_server_omits_output_index_test() -> Nil {
426  let message =
427    json.object([
428      #("type", json.string("message")),
429      #("id", json.string("msg_a")),
430      #("role", json.string("assistant")),
431      #("status", json.string("completed")),
432      #(
433        "content",
434        json.array(
435          [
436            json.object([
437              #("type", json.string("output_text")),
438              #("text", json.string("Hi")),
439              #("annotations", json.array([], json.string)),
440            ]),
441          ],
442          fn(part) { part },
443        ),
444      ),
445    ])
446  let state = stream.new(types.Responses)
447  let added = item_event("response.output_item.added", "msg_a", [])
448  let assert Ok(#(state, [], None)) = send(state, "", added)
449  let delta =
450    event("response.output_text.delta", [
451      #("item_id", json.string("msg_a")),
452      #("delta", json.string("Hi")),
453    ])
454  let assert Ok(#(state, [types.TextDelta(0, 0, "Hi")], None)) =
455    send(state, "", delta)
456  let done = event("response.output_item.done", [#("item", message)])
457  let assert Ok(#(state, [], None)) = send(state, "", done)
458  let completed =
459    event("response.completed", [
460      #(
461        "response",
462        json.object([
463          #("id", json.string("resp_1")),
464          #("output", json.array([], json.string)),
465        ]),
466      ),
467    ])
468  let assert Ok(#(
469    _,
470    [types.Started("resp_1")],
471    Some(types.Turn(Some("resp_1"), [_], [], _, types.Complete, None, [])),
472  )) = send(state, "", completed)
473  Nil
474}
475
476pub fn responses_numbers_items_added_without_an_index_in_order_test() -> Nil {
477  let state = stream.new(types.Responses)
478  let assert Ok(#(state, [], None)) =
479    send(state, "", item_event("response.output_item.added", "msg_a", []))
480  let call = [
481    #("name", json.string("get_weather")),
482    #("call_id", json.string("c")),
483  ]
484  let assert Ok(#(state, [], None)) =
485    send(
486      state,
487      "",
488      item_event("response.output_item.added", "fc_b", [
489        #("type", json.string("function_call")),
490        ..call
491      ]),
492    )
493  let delta =
494    event("response.function_call_arguments.delta", [
495      #("item_id", json.string("fc_b")),
496      #("delta", json.string("{}")),
497    ])
498  let assert Ok(#(_, [types.ArgumentsDelta(1, "get_weather", "{}")], None)) =
499    send(state, "", delta)
500  Nil
501}
502
503pub fn responses_rejects_an_unplaceable_event_without_an_index_test() -> Nil {
504  let delta =
505    event("response.output_text.delta", [
506      #("item_id", json.string("never_added")),
507      #("delta", json.string("Hi")),
508    ])
509  let assert Error(types.InvalidEvent(_)) =
510    send(stream.new(types.Responses), "", delta)
511  let no_id =
512    event("response.output_text.delta", [#("delta", json.string("Hi"))])
513  let assert Error(types.InvalidEvent(_)) =
514    send(stream.new(types.Responses), "", no_id)
515  Nil
516}
517
518fn event(kind: String, fields: List(#(String, json.Json))) -> String {
519  json.object([#("type", json.string(kind)), ..fields]) |> json.to_string
520}
521
522/// An item event as llama.cpp's server sends it: the item names itself, and
523/// there is no output_index.
524fn item_event(
525  kind: String,
526  id: String,
527  item_fields: List(#(String, json.Json)),
528) -> String {
529  let item = case list.key_find(item_fields, "type") {
530    Ok(_) -> item_fields
531    Error(_) -> [#("type", json.string("message")), ..item_fields]
532  }
533  event(kind, [#("item", json.object([#("id", json.string(id)), ..item]))])
534}
535
536fn send(
537  state: stream.State,
538  name: String,
539  data: String,
540) -> Result(#(stream.State, List(types.Event), Option(types.Turn)), types.Error) {
541  stream.feed(state, sse.Event(name, data))
542}