Skip to content

Commit d7e9d6d

Browse files
chore: Refine model fixtures and improve OpenRouter/Google coverage (#102)
* Continue refining fixtures * Remove cruft * Fix xAI grok-2 * More models working
1 parent 52b60c7 commit d7e9d6d

1,086 files changed

Lines changed: 100100 additions & 44133 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

lib/req_llm/provider/defaults.ex

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,15 @@ defmodule ReqLLM.Provider.Defaults do
818818
end
819819
end
820820

821+
# Handle malformed tool call deltas (some APIs send incomplete structures)
822+
defp decode_openai_tool_call_delta(%{"type" => "function", "function" => %{"name" => nil}}) do
823+
nil
824+
end
825+
826+
defp decode_openai_tool_call_delta(%{"type" => "function", "function" => %{}}) do
827+
nil
828+
end
829+
821830
defp decode_openai_tool_call_delta(_), do: nil
822831

823832
defp build_openai_message_from_chunks(chunks) when is_list(chunks) and chunks != [] do
@@ -1179,14 +1188,14 @@ defmodule ReqLLM.Provider.Defaults do
11791188
nil
11801189

11811190
tool_calls ->
1182-
case Enum.find(tool_calls, &(&1.function.name == "structured_output")) do
1191+
case Enum.find(tool_calls, &(&1.name == "structured_output")) do
11831192
nil ->
11841193
nil
11851194

1186-
%{function: %{arguments: object}} when is_map(object) ->
1195+
%{arguments: object} when is_map(object) ->
11871196
object
11881197

1189-
%{function: %{arguments: json_string}} when is_binary(json_string) ->
1198+
%{arguments: json_string} when is_binary(json_string) ->
11901199
case Jason.decode(json_string) do
11911200
{:ok, object} -> object
11921201
{:error, _} -> nil

lib/req_llm/provider/generated/valid_providers.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ defmodule ReqLLM.Provider.Generated.ValidProviders do
3939
:moonshotai,
4040
:moonshotai_cn,
4141
:morph,
42+
:nebius,
4243
:nvidia,
4344
:openai,
4445
:opencode,

lib/req_llm/providers/anthropic.ex

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -677,7 +677,10 @@ defmodule ReqLLM.Providers.Anthropic do
677677
response
678678
|> ReqLLM.Response.tool_calls()
679679
|> Enum.find_value(fn
680-
%{function: %{name: "structured_output", arguments: args}} ->
680+
%{name: "structured_output", arguments: args} when is_map(args) ->
681+
args
682+
683+
%{name: "structured_output", arguments: args} when is_binary(args) ->
681684
case Jason.decode(args) do
682685
{:ok, obj} -> obj
683686
_ -> nil

lib/req_llm/providers/openai/chat_api.ex

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -308,9 +308,13 @@ defmodule ReqLLM.Providers.OpenAI.ChatAPI do
308308

309309
updated_params =
310310
if is_map_key(params, :properties) do
311-
Map.put(params, :required, all_property_names)
311+
params
312+
|> Map.put(:required, all_property_names)
313+
|> Map.put(:additionalProperties, false)
312314
else
313-
Map.put(params, "required", Enum.map(all_property_names, &to_string/1))
315+
params
316+
|> Map.put("required", Enum.map(all_property_names, &to_string/1))
317+
|> Map.put("additionalProperties", false)
314318
end
315319

316320
if is_map_key(function, :parameters) do

lib/req_llm/providers/openai/responses_api.ex

Lines changed: 246 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,17 @@ defmodule ReqLLM.Providers.OpenAI.ResponsesAPI do
151151

152152
"response.completed" ->
153153
usage_data = get_in(data, ["response", "usage"])
154+
response_id = get_in(data, ["response", "id"])
154155

155156
meta = %{terminal?: true, finish_reason: :stop}
156157

158+
meta =
159+
if response_id do
160+
Map.put(meta, :response_id, response_id)
161+
else
162+
meta
163+
end
164+
157165
meta =
158166
if usage_data do
159167
raw_usage = %{
@@ -214,13 +222,15 @@ defmodule ReqLLM.Providers.OpenAI.ResponsesAPI do
214222
opts_map = if is_map(opts), do: opts, else: Map.new(opts)
215223
provider_opts = opts_map[:provider_options] || []
216224

217-
previous_response_id = provider_opts[:previous_response_id]
225+
previous_response_id =
226+
provider_opts[:previous_response_id] ||
227+
extract_previous_response_id_from_context(context)
218228

219-
input =
220-
Enum.flat_map(context.messages, fn msg ->
229+
{input, tool_messages} =
230+
Enum.reduce(context.messages, {[], []}, fn msg, {input_acc, tool_acc} ->
221231
case msg.role do
222232
:tool ->
223-
[]
233+
{input_acc, [msg | tool_acc]}
224234

225235
_ ->
226236
content =
@@ -232,17 +242,33 @@ defmodule ReqLLM.Providers.OpenAI.ResponsesAPI do
232242
end)
233243

234244
if content == [] and msg.tool_calls == nil do
235-
[]
245+
{input_acc, tool_acc}
236246
else
237247
if msg.role == :assistant and msg.tool_calls != nil and msg.tool_calls != [] do
238-
[]
248+
{input_acc, tool_acc}
239249
else
240-
[%{"role" => Atom.to_string(msg.role), "content" => content}]
250+
{input_acc ++ [%{"role" => Atom.to_string(msg.role), "content" => content}],
251+
tool_acc}
241252
end
242253
end
243254
end
244255
end)
245256

257+
tool_outputs_from_context = extract_tool_outputs_from_messages(Enum.reverse(tool_messages))
258+
259+
tool_outputs =
260+
case provider_opts[:tool_outputs] do
261+
nil -> tool_outputs_from_context
262+
[] -> tool_outputs_from_context
263+
explicit_outputs -> explicit_outputs
264+
end
265+
266+
input =
267+
case tool_outputs do
268+
[] -> input
269+
outputs -> input ++ encode_tool_outputs(outputs)
270+
end
271+
246272
max_output_tokens =
247273
opts_map[:max_output_tokens] ||
248274
opts_map[:max_completion_tokens] ||
@@ -381,6 +407,58 @@ defmodule ReqLLM.Providers.OpenAI.ResponsesAPI do
381407
defp maybe_put_string(map, _key, nil), do: map
382408
defp maybe_put_string(map, key, value), do: Map.put(map, key, value)
383409

410+
defp extract_previous_response_id_from_context(context) do
411+
context.messages
412+
|> Enum.reverse()
413+
|> Enum.find_value(fn msg ->
414+
case msg do
415+
%{role: :assistant, tool_calls: tool_calls, metadata: %{response_id: id}}
416+
when not is_nil(tool_calls) and tool_calls != [] ->
417+
id
418+
419+
_ ->
420+
nil
421+
end
422+
end)
423+
end
424+
425+
defp extract_tool_outputs_from_messages(tool_messages) do
426+
Enum.map(tool_messages, fn msg ->
427+
output_text =
428+
msg.content
429+
|> Enum.find_value(fn part ->
430+
if part.type == :text, do: part.text
431+
end) || ""
432+
433+
%{
434+
call_id: msg.tool_call_id,
435+
output: output_text
436+
}
437+
end)
438+
end
439+
440+
defp encode_tool_outputs(outputs) when is_list(outputs) do
441+
Enum.map(outputs, fn output ->
442+
call_id = output[:call_id] || output["call_id"]
443+
raw_output = output[:output] || output["output"]
444+
445+
output_string =
446+
cond do
447+
is_binary(raw_output) -> raw_output
448+
is_map(raw_output) or is_list(raw_output) -> Jason.encode!(raw_output)
449+
true -> to_string(raw_output)
450+
end
451+
452+
%{
453+
"type" => "function_call_output",
454+
"call_id" => call_id,
455+
"output" => output_string
456+
}
457+
end)
458+
end
459+
460+
defp encode_tool_outputs(_), do: []
461+
384462
defp encode_tools_if_any(request) do
385463
case request.options[:tools] do
386464
nil -> nil
@@ -564,7 +642,8 @@ defmodule ReqLLM.Providers.OpenAI.ResponsesAPI do
564642
msg = %ReqLLM.Message{
565643
role: :assistant,
566644
content: content_parts,
567-
tool_calls: if(tool_calls != [], do: tool_calls)
645+
tool_calls: if(tool_calls != [], do: tool_calls),
646+
metadata: %{response_id: body["id"]}
568647
}
569648

570649
response = %ReqLLM.Response{
@@ -670,13 +749,27 @@ defmodule ReqLLM.Providers.OpenAI.ResponsesAPI do
670749
segments
671750
|> Enum.filter(&(&1["type"] == "function_call"))
672751
|> Enum.map(fn seg ->
673-
args_json = seg["arguments"] || "{}"
674-
id = seg["call_id"]
752+
args_json = normalize_arguments_json(seg["arguments"])
753+
id = seg["call_id"] || seg["id"]
675754
name = seg["name"] || "unknown"
676755
ReqLLM.ToolCall.new(id, name, args_json)
677756
end)
678757
end
679758

759+
defp normalize_arguments_json(nil), do: "{}"
760+
defp normalize_arguments_json(""), do: "{}"
761+
762+
defp normalize_arguments_json(json) when is_binary(json) do
763+
trimmed = String.trim(json)
764+
765+
case Jason.decode(trimmed) do
766+
{:ok, _} -> trimmed
767+
{:error, _} -> trimmed
768+
end
769+
end
770+
771+
defp normalize_arguments_json(_), do: "{}"
772+
680773
defp build_content_parts(text, thinking) do
681774
parts = []
682775

@@ -733,4 +826,147 @@ defmodule ReqLLM.Providers.OpenAI.ResponsesAPI do
733826
defp normalize_finish_reason("tool_calls"), do: :tool_calls
734827
defp normalize_finish_reason("content_filter"), do: :content_filter
735828
defp normalize_finish_reason(_), do: :error
829+
830+
@doc false
831+
def build_responses_body_from_chunks(chunks, model) do
832+
state =
833+
Enum.reduce(
834+
chunks,
835+
%{
836+
text: "",
837+
reasoning: "",
838+
tool_calls: %{},
839+
tool_call_order: [],
840+
usage: nil,
841+
finish_reason: nil,
842+
response_id: nil
843+
},
844+
&accumulate_chunk_to_state/2
845+
)
846+
847+
output_segments = []
848+
849+
output_segments =
850+
if state.reasoning == "" do
851+
output_segments
852+
else
853+
[
854+
%{
855+
"type" => "reasoning",
856+
"content" => [%{"type" => "text", "text" => state.reasoning}]
857+
}
858+
| output_segments
859+
]
860+
end
861+
862+
tool_segments =
863+
Enum.map(state.tool_call_order, fn key ->
864+
tc = state.tool_calls[key]
865+
866+
%{
867+
"type" => "function_call",
868+
"id" => tc.id || "call_#{key}",
869+
"name" => tc.name || "unknown",
870+
"arguments" => tc.arguments || "{}"
871+
}
872+
end)
873+
874+
output_segments = output_segments ++ tool_segments
875+
876+
response_id = state.response_id || "resp_stream_#{System.unique_integer([:positive])}"
877+
878+
body = %{
879+
"id" => response_id,
880+
"model" => model,
881+
"status" => if(state.finish_reason == :stop, do: "completed", else: "incomplete"),
882+
"output" => output_segments
883+
}
884+
885+
body =
886+
if state.text == "" do
887+
body
888+
else
889+
Map.put(body, "output_text", state.text)
890+
end
891+
892+
body =
893+
if state.usage do
894+
Map.put(body, "usage", state.usage)
895+
else
896+
body
897+
end
898+
899+
body
900+
end
901+
902+
defp accumulate_chunk_to_state(%ReqLLM.StreamChunk{type: :content, text: text}, state) do
903+
%{state | text: state.text <> text}
904+
end
905+
906+
defp accumulate_chunk_to_state(%ReqLLM.StreamChunk{type: :thinking, text: text}, state) do
907+
%{state | reasoning: state.reasoning <> text}
908+
end
909+
910+
defp accumulate_chunk_to_state(%ReqLLM.StreamChunk{type: :tool_call} = chunk, state) do
911+
# Get tool call ID from metadata
912+
tool_id = chunk.metadata[:id] || chunk.metadata[:call_id]
913+
key = chunk.metadata[:index] || tool_id || 0
914+
915+
existing = Map.get(state.tool_calls, key, %{})
916+
917+
updated = %{
918+
id: tool_id || existing[:id],
919+
name: chunk.name || existing[:name],
920+
arguments: merge_tool_arguments(existing[:arguments], chunk.arguments)
921+
}
922+
923+
order =
924+
if key in state.tool_call_order,
925+
do: state.tool_call_order,
926+
else: state.tool_call_order ++ [key]
927+
928+
%{state | tool_calls: Map.put(state.tool_calls, key, updated), tool_call_order: order}
929+
end
930+
931+
defp accumulate_chunk_to_state(%ReqLLM.StreamChunk{type: :meta, metadata: meta}, state) do
932+
state
933+
|> maybe_put_usage(meta[:usage])
934+
|> maybe_put_finish(meta[:finish_reason])
935+
|> maybe_put_response_id(meta[:response_id])
936+
end
937+
938+
defp accumulate_chunk_to_state(_chunk, state), do: state
939+
940+
defp merge_tool_arguments(nil, new), do: new
941+
defp merge_tool_arguments(existing, nil), do: existing
942+
943+
defp merge_tool_arguments(existing, new) when is_binary(existing) and is_binary(new) do
944+
existing <> new
945+
end
946+
947+
defp merge_tool_arguments(existing, new) when is_map(new) do
948+
merge_tool_arguments(existing, Jason.encode!(new))
949+
end
950+
951+
defp merge_tool_arguments(existing, _new), do: existing
952+
953+
defp maybe_put_usage(state, nil), do: state
954+
955+
defp maybe_put_usage(state, usage) do
956+
normalized =
957+
Map.update(
958+
usage,
959+
:reasoning_tokens,
960+
usage[:reasoning] || usage[:thinking_tokens] || 0,
961+
& &1
962+
)
963+
964+
%{state | usage: normalized}
965+
end
966+
967+
defp maybe_put_finish(state, nil), do: state
968+
defp maybe_put_finish(state, reason), do: %{state | finish_reason: reason}
969+
970+
defp maybe_put_response_id(state, nil), do: state
971+
defp maybe_put_response_id(state, id), do: %{state | response_id: id}
736972
end

0 commit comments

Comments
 (0)