diff --git a/lib/req_llm/provider.ex b/lib/req_llm/provider.ex index 8bd1d3ed0..e3253a68c 100644 --- a/lib/req_llm/provider.ex +++ b/lib/req_llm/provider.ex @@ -271,6 +271,15 @@ defmodule ReqLLM.Provider do @callback translate_options(operation(), LLMDB.Model.t(), keyword()) :: {keyword(), [String.t()]} + @doc """ + Returns tool call ID compatibility policy for this provider (optional). + + Providers can enforce tool call ID constraints when a context built on one + provider is sent to a different provider. + """ + @callback tool_call_id_policy(operation(), LLMDB.Model.t() | map(), keyword() | map()) :: + map() | keyword() + @doc """ Returns the default environment variable name for API authentication. @@ -603,6 +612,7 @@ defmodule ReqLLM.Provider do extract_usage: 2, default_env_key: 0, translate_options: 3, + tool_call_id_policy: 3, build_body: 1, decode_stream_event: 2, decode_stream_event: 3, diff --git a/lib/req_llm/provider/defaults.ex b/lib/req_llm/provider/defaults.ex index 5d203b80f..30ebe0e6f 100644 --- a/lib/req_llm/provider/defaults.ex +++ b/lib/req_llm/provider/defaults.ex @@ -423,6 +423,7 @@ defmodule ReqLLM.Provider.Defaults do :n, :tools, :tool_choice, + :tool_call_id_compat, :req_http_options, :stream, :frequency_penalty, diff --git a/lib/req_llm/provider/options.ex b/lib/req_llm/provider/options.ex index f7a23ad5b..d3b8e810f 100644 --- a/lib/req_llm/provider/options.ex +++ b/lib/req_llm/provider/options.ex @@ -139,6 +139,12 @@ defmodule ReqLLM.Provider.Options do default: false, doc: "Enable streaming responses" ], + tool_call_id_compat: [ + type: {:in, [:auto, :sanitize, :strict, :passthrough]}, + default: :auto, + doc: + "Tool call ID compatibility mode for cross-provider contexts" + ], # Provider-specific options container provider_options: [ diff --git a/lib/req_llm/providers/amazon_bedrock.ex b/lib/req_llm/providers/amazon_bedrock.ex index e7819c3d8..ac4c2592c 100644 --- a/lib/req_llm/providers/amazon_bedrock.ex +++ b/lib/req_llm/providers/amazon_bedrock.ex @@ -391,6 +391,20 @@ defmodule ReqLLM.Providers.AmazonBedrock do {endpoint, get_formatter_module(family), family} end + operation = opts[:operation] || :chat + compat_opts = Keyword.put(opts, :use_converse, use_converse) + + context = + ReqLLM.ToolCallIdCompat.apply_context( + __MODULE__, + operation, + model, + opts[:context], + compat_opts + ) + + opts = Keyword.put(opts, :context, context) + updated_request = request |> Map.put(:url, URI.parse(base_url <> endpoint_base)) @@ -415,7 +429,7 @@ defmodule ReqLLM.Providers.AmazonBedrock do model_body = formatter.format_request( model_id, - opts[:context], + context, opts ) @@ -546,6 +560,17 @@ defmodule ReqLLM.Providers.AmazonBedrock do {formatter, "/model/#{model_id}/invoke-with-response-stream"} end + translated_opts = Keyword.put(translated_opts, :use_converse, use_converse) + + context = + ReqLLM.ToolCallIdCompat.apply_context( + __MODULE__, + :chat, + model, + context, + translated_opts + ) + # Build request body with translated options body = formatter.format_request(model_id, context, translated_opts) @@ -990,6 +1015,33 @@ defmodule ReqLLM.Providers.AmazonBedrock do end end + @impl ReqLLM.Provider + def tool_call_id_policy(_operation, model, opts) do + model_id = model.provider_model_id || model.id + use_converse = determine_use_converse(model_id, opts) + family = get_model_family(model_id) + + cond do + use_converse -> + %{ + mode: :sanitize, + invalid_chars_regex: ~r/[^A-Za-z0-9_-]/, + max_length: 64, + enforce_turn_boundary: true + } + + family == "anthropic" -> + %{ + mode: :sanitize, + invalid_chars_regex: ~r/[^A-Za-z0-9_-]/, + enforce_turn_boundary: true + } + + true -> + %{mode: :passthrough} + end + end + # Move :thinking from top-level opts to additionalModelRequestFields for Bedrock defp move_thinking_to_additional_fields(opts) do case Keyword.pop(opts, :thinking) do diff --git a/lib/req_llm/providers/amazon_bedrock/converse.ex b/lib/req_llm/providers/amazon_bedrock/converse.ex index 6a696e4e5..cbe83a76a 100644 --- a/lib/req_llm/providers/amazon_bedrock/converse.ex +++ b/lib/req_llm/providers/amazon_bedrock/converse.ex @@ -101,6 +101,18 @@ defmodule ReqLLM.Providers.AmazonBedrock.Converse do {context, opts} end + context = + ReqLLM.ToolCallIdCompat.apply_context_with_policy( + context, + %{ + mode: :sanitize, + invalid_chars_regex: ~r/[^A-Za-z0-9_-]/, + max_length: 64, + enforce_turn_boundary: true + }, + opts + ) + request = %{} # Add messages diff --git a/lib/req_llm/providers/anthropic.ex b/lib/req_llm/providers/anthropic.ex index 5f77bb1df..2cc746bff 100644 --- a/lib/req_llm/providers/anthropic.ex +++ b/lib/req_llm/providers/anthropic.ex @@ -297,6 +297,18 @@ defmodule ReqLLM.Providers.Anthropic do context = request.options[:context] model_name = request.options[:model] opts = request.options + operation = request.options[:operation] || :chat + + model = + Req.Request.get_private(request, :req_llm_model) || + %{ + id: model_name, + model: model_name, + provider: :anthropic, + provider_model_id: nil + } + + context = ReqLLM.ToolCallIdCompat.apply_context(__MODULE__, operation, model, context, opts) body = build_request_body(context, model_name, opts) json_body = Jason.encode!(body) @@ -459,6 +471,17 @@ defmodule ReqLLM.Providers.Anthropic do beta_headers = build_beta_headers(translated_opts) all_headers = streaming_headers ++ beta_headers + operation = opts[:operation] || :chat + + context = + ReqLLM.ToolCallIdCompat.apply_context( + __MODULE__, + operation, + model, + context, + translated_opts + ) + body = build_request_body(context, get_api_model_id(model), translated_opts ++ [stream: true]) url = build_request_url(translated_opts) @@ -491,6 +514,15 @@ defmodule ReqLLM.Providers.Anthropic do {translated_opts, []} end + @impl ReqLLM.Provider + def tool_call_id_policy(_operation, _model, _opts) do + %{ + mode: :sanitize, + invalid_chars_regex: ~r/[^A-Za-z0-9_-]/, + enforce_turn_boundary: true + } + end + # Private implementation functions defp get_anthropic_version(user_opts) do diff --git a/lib/req_llm/providers/azure.ex b/lib/req_llm/providers/azure.ex index b9b61c083..553ee7459 100644 --- a/lib/req_llm/providers/azure.ex +++ b/lib/req_llm/providers/azure.ex @@ -390,6 +390,17 @@ defmodule ReqLLM.Providers.Azure do |> maybe_clean_thinking_after_translation(model_family, operation) |> maybe_warn_service_tier(model_family, model_id) + context = + ReqLLM.ToolCallIdCompat.apply_context( + __MODULE__, + operation, + model, + context, + processed_opts + ) + + processed_opts = Keyword.put(processed_opts, :context, context) + {api_version, deployment, base_url} = extract_azure_credentials(model, processed_opts) @@ -680,7 +691,17 @@ defmodule ReqLLM.Providers.Azure do headers = base_headers ++ extra_headers body = - formatter.format_request(model_id, context, Keyword.put(translated_opts, :stream, true)) + formatter.format_request( + model_id, + ReqLLM.ToolCallIdCompat.apply_context( + __MODULE__, + operation, + model, + context, + translated_opts + ), + Keyword.put(translated_opts, :stream, true) + ) |> maybe_add_model_for_foundry(deployment, base_url) finch_request = Finch.build(:post, url, headers, Jason.encode!(body)) @@ -764,6 +785,23 @@ defmodule ReqLLM.Providers.Azure do end end + @impl ReqLLM.Provider + def tool_call_id_policy(_operation, model, _opts) do + model_id = effective_model_id(model) + + case get_model_family(model_id) do + "claude" -> + %{ + mode: :sanitize, + invalid_chars_regex: ~r/[^A-Za-z0-9_-]/, + enforce_turn_boundary: true + } + + _ -> + %{mode: :passthrough} + end + end + @doc """ Pre-validates and transforms options before request building. @@ -895,6 +933,7 @@ defmodule ReqLLM.Providers.Azure do :n, :tools, :tool_choice, + :tool_call_id_compat, :req_http_options, :frequency_penalty, :system_prompt, diff --git a/lib/req_llm/providers/azure/anthropic.ex b/lib/req_llm/providers/azure/anthropic.ex index ea8224a3d..de65b4cfa 100644 --- a/lib/req_llm/providers/azure/anthropic.ex +++ b/lib/req_llm/providers/azure/anthropic.ex @@ -127,6 +127,15 @@ defmodule ReqLLM.Providers.Azure.Anthropic do {context, opts} end + context = + ReqLLM.ToolCallIdCompat.apply_context( + ReqLLM.Providers.Azure, + operation || :chat, + %{id: model_id, provider_model_id: model_id, provider: :azure}, + context, + opts + ) + model = %{model: model_id} body = Anthropic.Context.encode_request(context, model) diff --git a/lib/req_llm/providers/google.ex b/lib/req_llm/providers/google.ex index d28653b09..cd463f137 100644 --- a/lib/req_llm/providers/google.ex +++ b/lib/req_llm/providers/google.ex @@ -533,6 +533,7 @@ defmodule ReqLLM.Providers.Google do :fixture, :tools, :tool_choice, + :tool_call_id_compat, :n, :prompt, :size, diff --git a/lib/req_llm/providers/google_vertex.ex b/lib/req_llm/providers/google_vertex.ex index 6af3d3af3..46ccf1b0b 100644 --- a/lib/req_llm/providers/google_vertex.ex +++ b/lib/req_llm/providers/google_vertex.ex @@ -184,6 +184,11 @@ defmodule ReqLLM.Providers.GoogleVertex do # Add context to opts so it can be stored in request.options other_opts = Keyword.put(other_opts, :context, context) + context = + ReqLLM.ToolCallIdCompat.apply_context(__MODULE__, operation, model, context, other_opts) + + other_opts = Keyword.put(other_opts, :context, context) + # Build request body using formatter body = formatter.format_request(model.provider_model_id || model.id, context, other_opts) @@ -529,6 +534,27 @@ defmodule ReqLLM.Providers.GoogleVertex do end end + @impl ReqLLM.Provider + def tool_call_id_policy(_operation, model, _opts) do + case get_model_family(model.provider_model_id || model.id) do + "claude" -> + %{ + mode: :sanitize, + invalid_chars_regex: ~r/[^A-Za-z0-9_-]/, + enforce_turn_boundary: true + } + + "gemini" -> + %{ + mode: :drop, + drop_function_call_ids: true + } + + _ -> + %{mode: :passthrough} + end + end + @impl ReqLLM.Provider def attach_stream(model, context, opts, _finch_name) do # Process and validate options @@ -537,6 +563,11 @@ defmodule ReqLLM.Providers.GoogleVertex do {gcp_creds, other_opts, model_family, formatter} = process_and_validate_opts(opts, model, operation) + context = + ReqLLM.ToolCallIdCompat.apply_context(__MODULE__, operation, model, context, other_opts) + + other_opts = Keyword.put(other_opts, :context, context) + # Build request body using formatter (with stream: true) body = formatter.format_request( diff --git a/lib/req_llm/providers/google_vertex/gemini.ex b/lib/req_llm/providers/google_vertex/gemini.ex index a95aa0e60..7106e238e 100644 --- a/lib/req_llm/providers/google_vertex/gemini.ex +++ b/lib/req_llm/providers/google_vertex/gemini.ex @@ -28,58 +28,33 @@ defmodule ReqLLM.Providers.GoogleVertex.Gemini do @doc """ Formats a ReqLLM context into Gemini request format for Vertex AI. - Delegates to the native Google provider's encoding logic, then sanitizes - function call IDs which Vertex AI rejects. + Delegates to the native Google provider's encoding logic, then applies + shared tool call ID compatibility policy. """ def format_request(model_id, context, opts) do - # Options.process already hoists provider_options (like google_grounding) to top level opts_map = opts |> Map.new() |> Map.merge(%{context: context, model: model_id}) - # Create a temporary request structure that mimics what Google.encode_body expects temp_request = Req.new(method: :post, url: URI.parse("https://example.com/temp")) |> Map.put(:body, {:json, %{}}) |> Map.put(:options, opts_map) - # Let Google provider encode the body %Req.Request{body: encoded_body} = Google.encode_body(temp_request) - # Decode the JSON body body = Jason.decode!(encoded_body) - # Vertex AI has stricter validation: remove "id" from functionCall parts - sanitize_function_calls(body) - end - - # Removes "id" field from functionCall parts in contents - # Vertex AI Gemini API does not accept this field, while direct Google API includes it - defp sanitize_function_calls(%{"contents" => contents} = body) when is_list(contents) do - sanitized_contents = - Enum.map(contents, fn - %{"parts" => parts} = content when is_list(parts) -> - sanitized_parts = - Enum.map(parts, fn - %{"functionCall" => fc} = part -> - Map.put(part, "functionCall", Map.delete(fc, "id")) - - other -> - other - end) - - Map.put(content, "parts", sanitized_parts) - - other -> - other - end) - - Map.put(body, "contents", sanitized_contents) + ReqLLM.ToolCallIdCompat.apply_body( + ReqLLM.Providers.GoogleVertex, + opts[:operation] || :chat, + %{id: model_id, provider_model_id: model_id, provider: :google_vertex}, + body, + opts + ) end - defp sanitize_function_calls(body), do: body - @doc """ Parses a Gemini response from Vertex AI into ReqLLM format. @@ -89,7 +64,6 @@ defmodule ReqLLM.Providers.GoogleVertex.Gemini do operation = opts[:operation] context = opts[:context] || %ReqLLM.Context{messages: []} - # Create temporary request/response pair that mimics what Google.decode_response expects temp_req = %Req.Request{ options: %{ context: context, @@ -104,7 +78,6 @@ defmodule ReqLLM.Providers.GoogleVertex.Gemini do body: body } - # Let Google provider decode the response {_req, decoded_resp} = Google.decode_response({temp_req, temp_resp}) case decoded_resp do @@ -131,7 +104,6 @@ defmodule ReqLLM.Providers.GoogleVertex.Gemini do Gemini uses the same SSE format as the native Google provider. """ def decode_stream_event(event, model) do - # Delegate directly to Google provider's decode_stream_event Google.decode_stream_event(event, model) end end diff --git a/lib/req_llm/tool_call_id_compat.ex b/lib/req_llm/tool_call_id_compat.ex new file mode 100644 index 000000000..6b5a560ec --- /dev/null +++ b/lib/req_llm/tool_call_id_compat.ex @@ -0,0 +1,461 @@ +defmodule ReqLLM.ToolCallIdCompat do + @moduledoc """ + Tool call ID compatibility helpers for cross-provider conversations. + + This module normalizes tool call identifiers for providers with stricter + requirements while preserving passthrough behavior for OpenAI-compatible APIs. + """ + + alias ReqLLM.Context + alias ReqLLM.Message + alias ReqLLM.ToolCall + + @default_invalid_chars_regex ~r/[^A-Za-z0-9_-]/ + + @type mode :: :passthrough | :sanitize | :strict | :drop + + @type policy :: %{ + optional(:mode) => mode(), + optional(:invalid_chars_regex) => Regex.t(), + optional(:max_length) => pos_integer(), + optional(:enforce_turn_boundary) => boolean(), + optional(:drop_function_call_ids) => boolean() + } + + @spec apply_context(module(), atom(), LLMDB.Model.t() | map(), Context.t(), keyword() | map()) :: + Context.t() + def apply_context(provider_mod, operation, model, %Context{} = context, opts) + when is_atom(provider_mod) do + provider_policy = provider_policy(provider_mod, operation, model, opts) + apply_context_with_policy(context, provider_policy, opts) + end + + @spec apply_context_with_policy(Context.t(), policy() | keyword(), keyword() | map()) :: + Context.t() + def apply_context_with_policy(%Context{} = context, policy, opts \\ []) do + resolved_policy = + policy + |> normalize_policy() + |> override_mode(fetch_compat_mode(opts)) + + maybe_validate_turn_boundary(context, resolved_policy) + + case resolved_policy.mode do + :passthrough -> + context + + :drop -> + context + + :strict -> + validate_all_ids!(context, resolved_policy) + context + + :sanitize -> + sanitize_context(context, resolved_policy) + end + end + + @spec apply_body(module(), atom(), LLMDB.Model.t() | map(), map(), keyword() | map()) :: map() + def apply_body(provider_mod, operation, model, body, opts) + when is_atom(provider_mod) and is_map(body) do + provider_policy = provider_policy(provider_mod, operation, model, opts) + apply_body_with_policy(body, provider_policy, opts) + end + + @spec apply_body_with_policy(map(), policy() | keyword(), keyword() | map()) :: map() + def apply_body_with_policy(body, policy, opts \\ []) when is_map(body) do + resolved_policy = + policy + |> normalize_policy() + |> override_mode(fetch_compat_mode(opts)) + + if resolved_policy.drop_function_call_ids do + drop_function_call_ids(body) + else + body + end + end + + defp provider_policy(provider_mod, operation, model, opts) do + if function_exported?(provider_mod, :tool_call_id_policy, 3) do + provider_mod.tool_call_id_policy(operation, model, opts) + else + %{mode: :passthrough} + end + end + + defp normalize_policy(policy) when is_list(policy), do: normalize_policy(Map.new(policy)) + + defp normalize_policy(policy) when is_map(policy) do + %{ + mode: Map.get(policy, :mode, :passthrough), + invalid_chars_regex: Map.get(policy, :invalid_chars_regex, @default_invalid_chars_regex), + max_length: Map.get(policy, :max_length), + enforce_turn_boundary: Map.get(policy, :enforce_turn_boundary, false), + drop_function_call_ids: Map.get(policy, :drop_function_call_ids, false) + } + end + + defp override_mode(policy, :auto), do: policy + defp override_mode(policy, nil), do: policy + defp override_mode(policy, mode), do: %{policy | mode: mode} + + defp fetch_compat_mode(opts) when is_list(opts), + do: Keyword.get(opts, :tool_call_id_compat, :auto) + + defp fetch_compat_mode(opts) when is_map(opts), do: Map.get(opts, :tool_call_id_compat, :auto) + defp fetch_compat_mode(_opts), do: :auto + + defp maybe_validate_turn_boundary(%Context{} = context, %{enforce_turn_boundary: true}) do + if unresolved_tool_calls?(context.messages) do + raise ReqLLM.Error.Invalid.Parameter.exception( + parameter: + "Context ends with unresolved tool calls. Switch providers only after appending tool results for all assistant tool calls." + ) + end + end + + defp maybe_validate_turn_boundary(_context, _policy), do: :ok + + defp unresolved_tool_calls?(messages) do + pending = + Enum.reduce(messages, MapSet.new(), fn message, acc -> + acc + |> add_pending_tool_calls(message) + |> resolve_tool_result(message) + end) + + not MapSet.equal?(pending, MapSet.new()) + end + + defp add_pending_tool_calls(acc, %Message{role: :assistant} = message) do + call_ids = tool_call_ids_for_message(message) + + Enum.reduce(call_ids, acc, fn + id, set when is_binary(id) and id != "" -> MapSet.put(set, id) + _id, set -> set + end) + end + + defp add_pending_tool_calls(acc, _message), do: acc + + defp resolve_tool_result(acc, %Message{role: :tool, tool_call_id: id}) + when is_binary(id) and id != "" do + MapSet.delete(acc, id) + end + + defp resolve_tool_result(acc, _message), do: acc + + defp tool_call_ids_for_message(%Message{} = message) do + from_tool_calls = + message.tool_calls + |> List.wrap() + |> Enum.map(&tool_call_id/1) + + from_content_parts = + message.content + |> List.wrap() + |> Enum.flat_map(&content_part_tool_call_ids/1) + + from_tool_calls ++ from_content_parts + end + + defp validate_all_ids!(%Context{messages: messages}, policy) do + ids = + messages + |> Enum.flat_map(&message_ids/1) + |> Enum.uniq() + + invalid_ids = Enum.filter(ids, &(not valid_id?(&1, policy))) + + if invalid_ids != [] do + rendered = invalid_ids |> Enum.map(&inspect/1) |> Enum.join(", ") + + raise ReqLLM.Error.Invalid.Parameter.exception( + parameter: "tool_call_id values incompatible with provider policy: #{rendered}" + ) + end + end + + defp sanitize_context(%Context{messages: messages} = context, policy) do + {updated_messages, _state} = + Enum.map_reduce(messages, init_state(), fn message, state -> + sanitize_message(message, state, policy) + end) + + %{context | messages: updated_messages} + end + + defp init_state do + %{mapping: %{}, used: MapSet.new(), counters: %{}} + end + + defp sanitize_message(%Message{} = message, state, policy) do + {tool_calls, state} = sanitize_message_tool_calls(message.tool_calls, state, policy) + {tool_call_id, state} = sanitize_optional_id(message.tool_call_id, state, policy) + {content, state} = sanitize_content_parts(message.content, state, policy) + + {%{message | tool_calls: tool_calls, tool_call_id: tool_call_id, content: content}, state} + end + + defp sanitize_message_tool_calls(nil, state, _policy), do: {nil, state} + + defp sanitize_message_tool_calls(tool_calls, state, policy) when is_list(tool_calls) do + Enum.map_reduce(tool_calls, state, fn tool_call, acc -> + sanitize_tool_call(tool_call, acc, policy) + end) + end + + defp sanitize_message_tool_calls(other, state, _policy), do: {other, state} + + defp sanitize_tool_call(%ToolCall{id: id} = tool_call, state, policy) do + {updated_id, state} = sanitize_optional_id(id, state, policy) + {%{tool_call | id: updated_id}, state} + end + + defp sanitize_tool_call(tool_call, state, policy) when is_map(tool_call) do + {tool_call, state} = sanitize_map_key(tool_call, :id, state, policy) + {tool_call, state} = sanitize_map_key(tool_call, "id", state, policy) + sanitize_map_tool_id(tool_call, state, policy) + end + + defp sanitize_tool_call(other, state, _policy), do: {other, state} + + defp sanitize_content_parts(content, state, policy) when is_list(content) do + Enum.map_reduce(content, state, fn part, acc -> + sanitize_content_part(part, acc, policy) + end) + end + + defp sanitize_content_parts(content, state, _policy), do: {content, state} + + defp sanitize_content_part(part, state, policy) when is_map(part) do + {part, state} = sanitize_map_tool_id(part, state, policy) + {part, state} = sanitize_part_metadata_tool_id(part, state, policy) + {part, state} + end + + defp sanitize_content_part(part, state, _policy), do: {part, state} + + defp sanitize_part_metadata_tool_id(part, state, policy) do + part_type = map_get(part, :type) || map_get(part, "type") + + if part_type in [:tool_call, "tool_call"] do + metadata = map_get(part, :metadata) || map_get(part, "metadata") + metadata_key = if Map.has_key?(part, "metadata"), do: "metadata", else: :metadata + + case metadata do + value when is_map(value) -> + {updated_metadata, state} = sanitize_map_tool_id(value, state, policy) + {map_put(part, metadata_key, updated_metadata), state} + + _ -> + {part, state} + end + else + {part, state} + end + end + + defp sanitize_map_tool_id(map, state, policy) do + {map, state} = sanitize_map_key(map, :tool_call_id, state, policy) + {map, state} = sanitize_map_key(map, "tool_call_id", state, policy) + + type = map_get(map, :type) || map_get(map, "type") + + if type in [:tool_call, "tool_call"] do + {map, state} = sanitize_map_key(map, :id, state, policy) + sanitize_map_key(map, "id", state, policy) + else + {map, state} + end + end + + defp sanitize_map_key(map, key, state, policy) do + case map_get(map, key) do + value when is_binary(value) and value != "" -> + {updated, state} = sanitize_id(value, state, policy) + {map_put(map, key, updated), state} + + _ -> + {map, state} + end + end + + defp sanitize_optional_id(nil, state, _policy), do: {nil, state} + + defp sanitize_optional_id(id, state, policy) when is_binary(id) and id != "" do + sanitize_id(id, state, policy) + end + + defp sanitize_optional_id(other, state, _policy), do: {other, state} + + defp sanitize_id(id, state, policy) do + case Map.fetch(state.mapping, id) do + {:ok, mapped} -> + {mapped, state} + + :error -> + base = sanitize_id_base(id, policy) + {mapped, state} = allocate_unique_id(base, state, policy.max_length) + + state = %{ + state + | mapping: Map.put(state.mapping, id, mapped), + used: MapSet.put(state.used, mapped) + } + + {mapped, state} + end + end + + defp sanitize_id_base(id, policy) do + replaced = Regex.replace(policy.invalid_chars_regex, id, "_") + limited = enforce_max_length(replaced, policy.max_length) + + if limited == "" do + enforce_max_length("tool_call", policy.max_length) + else + limited + end + end + + defp allocate_unique_id(base, state, max_length) do + if MapSet.member?(state.used, base) do + next = Map.get(state.counters, base, 1) + allocate_unique_id(base, next, state, max_length) + else + {base, state} + end + end + + defp allocate_unique_id(base, counter, state, max_length) do + suffix = "_" <> Integer.to_string(counter) + trimmed = trim_for_suffix(base, suffix, max_length) + candidate = trimmed <> suffix + + if MapSet.member?(state.used, candidate) do + allocate_unique_id(base, counter + 1, state, max_length) + else + updated_state = %{state | counters: Map.put(state.counters, base, counter + 1)} + {candidate, updated_state} + end + end + + defp trim_for_suffix(base, _suffix, nil), do: base + + defp trim_for_suffix(base, suffix, max_length) when is_integer(max_length) and max_length > 0 do + keep = max(max_length - String.length(suffix), 0) + String.slice(base, 0, keep) + end + + defp trim_for_suffix(base, _suffix, _max_length), do: base + + defp enforce_max_length(id, nil), do: id + + defp enforce_max_length(id, max_length) when is_integer(max_length) and max_length > 0 do + if String.length(id) > max_length do + String.slice(id, 0, max_length) + else + id + end + end + + defp enforce_max_length(id, _max_length), do: id + + defp valid_id?(id, policy) when is_binary(id) do + id != "" and not Regex.match?(policy.invalid_chars_regex, id) and + within_max_length?(id, policy.max_length) + end + + defp valid_id?(_id, _policy), do: false + + defp within_max_length?(id, nil), do: id != "" + + defp within_max_length?(id, max_length) when is_integer(max_length) and max_length > 0 do + String.length(id) <= max_length + end + + defp within_max_length?(_id, _), do: true + + defp message_ids(%Message{} = message) do + tool_call_ids = + message.tool_calls + |> List.wrap() + |> Enum.map(&tool_call_id/1) + + tool_result_ids = + case message.tool_call_id do + id when is_binary(id) and id != "" -> [id] + _ -> [] + end + + content_ids = + message.content + |> List.wrap() + |> Enum.flat_map(&content_part_tool_call_ids/1) + + tool_call_ids ++ tool_result_ids ++ content_ids + end + + defp tool_call_id(%ToolCall{id: id}) when is_binary(id) and id != "", do: id + + defp tool_call_id(tool_call) when is_map(tool_call) do + case map_get(tool_call, :id) || map_get(tool_call, "id") do + id when is_binary(id) and id != "" -> id + _ -> nil + end + end + + defp tool_call_id(_), do: nil + + defp content_part_tool_call_ids(part) when is_map(part) do + direct = + [map_get(part, :tool_call_id), map_get(part, "tool_call_id")] + |> Enum.filter(&(is_binary(&1) and &1 != "")) + + nested = + case map_get(part, :metadata) || map_get(part, "metadata") do + metadata when is_map(metadata) -> + [map_get(metadata, :tool_call_id), map_get(metadata, "tool_call_id")] + |> Enum.filter(&(is_binary(&1) and &1 != "")) + + _ -> + [] + end + + direct ++ nested + end + + defp content_part_tool_call_ids(_), do: [] + + defp map_get(map, key) when is_map_key(map, key), do: Map.get(map, key) + defp map_get(_map, _key), do: nil + + defp map_put(map, key, value), do: Map.put(map, key, value) + + defp drop_function_call_ids(%{"contents" => contents} = body) when is_list(contents) do + sanitized_contents = + Enum.map(contents, fn + %{"parts" => parts} = content when is_list(parts) -> + sanitized_parts = + Enum.map(parts, fn + %{"functionCall" => function_call} = part when is_map(function_call) -> + Map.put(part, "functionCall", Map.delete(function_call, "id")) + + other -> + other + end) + + Map.put(content, "parts", sanitized_parts) + + other -> + other + end) + + Map.put(body, "contents", sanitized_contents) + end + + defp drop_function_call_ids(body), do: body +end diff --git a/test/coverage/cross_provider/tool_call_id_compat_integration_test.exs b/test/coverage/cross_provider/tool_call_id_compat_integration_test.exs new file mode 100644 index 000000000..dbdfdf3fd --- /dev/null +++ b/test/coverage/cross_provider/tool_call_id_compat_integration_test.exs @@ -0,0 +1,169 @@ +defmodule ReqLLM.Coverage.CrossProvider.ToolCallIdCompatIntegrationTest do + use ExUnit.Case, async: false + + @moduletag :coverage + @moduletag :integration + @moduletag timeout: 300_000 + + @openai_model System.get_env("REQ_LLM_COMPAT_OPENAI_MODEL") || "openai:gpt-4o" + @anthropic_model System.get_env("REQ_LLM_COMPAT_ANTHROPIC_MODEL") + + @live_ready ReqLLM.Test.Env.fixtures_mode() == :record and + is_binary(@openai_model) and @openai_model != "" and + is_binary(@anthropic_model) and @anthropic_model != "" + + if not @live_ready do + @moduletag skip: + "Run with REQ_LLM_FIXTURES_MODE=record and set REQ_LLM_COMPAT_ANTHROPIC_MODEL" + end + + setup_all do + LLMDB.load(allow: :all, custom: %{}) + :ok + end + + @tag provider: "openai" + @tag scenario: :tool_id_compat_openai_passthrough + test "OpenAI request preserves OpenAI-style tool call IDs" do + fixture_name = "tool_call_id_compat_openai_passthrough" + + {:ok, response} = + ReqLLM.generate_text( + @openai_model, + openai_shaped_context("functions.add:0"), + ReqLLM.Test.Helpers.fixture_opts(fixture_name, base_opts() ++ [tools: [add_tool()]]) + ) + + assert %ReqLLM.Response{} = response + + body = recorded_canonical_json(@openai_model, fixture_name) + encoded = Jason.encode!(body) + + assert encoded =~ "functions.add:0" + refute encoded =~ "functions_add_0" + end + + @tag provider: "anthropic" + @tag scenario: :tool_id_compat_openai_to_anthropic + test "Anthropic request sanitizes IDs from OpenAI-shaped context" do + fixture_name = "tool_call_id_compat_openai_to_anthropic" + + {:ok, response} = + ReqLLM.generate_text( + @anthropic_model, + openai_shaped_context("functions.add:0"), + ReqLLM.Test.Helpers.fixture_opts(fixture_name, base_opts() ++ [tools: [add_tool()]]) + ) + + assert %ReqLLM.Response{} = response + + body = recorded_canonical_json(@anthropic_model, fixture_name) + encoded = Jason.encode!(body) + {call_id, result_id} = anthropic_tool_ids(body) + + assert encoded =~ "functions_add_0" + refute encoded =~ "functions.add:0" + assert call_id == result_id + end + + @tag provider: "anthropic" + @tag scenario: :tool_id_compat_turn_boundary + test "Anthropic rejects unresolved tool turns" do + assert_raise ReqLLM.Error.Invalid.Parameter, + ~r/Switch providers only after appending tool results/, + fn -> + ReqLLM.generate_text( + @anthropic_model, + openai_shaped_unresolved_context("functions.add:0"), + ReqLLM.Test.Helpers.fixture_opts( + "tool_call_id_compat_anthropic_unresolved", + base_opts() ++ [tools: [add_tool()]] + ) + ) + end + end + + defp base_opts do + [temperature: 0.0, max_tokens: 48] + end + + defp add_tool do + ReqLLM.Tool.new!( + name: "add", + description: "Add two numbers", + parameter_schema: [ + a: [type: :integer, required: true], + b: [type: :integer, required: true] + ], + callback: fn _args -> {:ok, "3"} end + ) + end + + defp openai_shaped_context(tool_call_id) do + ReqLLM.Context.new([ + ReqLLM.Context.user("Use add to sum 1 and 2."), + ReqLLM.Context.assistant("", + tool_calls: [ + %{id: tool_call_id, name: "add", arguments: %{"a" => 1, "b" => 2}} + ] + ), + ReqLLM.Context.tool_result(tool_call_id, "add", "3"), + ReqLLM.Context.user("Reply with OK if the tool result was 3.") + ]) + end + + defp openai_shaped_unresolved_context(tool_call_id) do + ReqLLM.Context.new([ + ReqLLM.Context.user("Use add to sum 1 and 2."), + ReqLLM.Context.assistant("", + tool_calls: [ + %{id: tool_call_id, name: "add", arguments: %{"a" => 1, "b" => 2}} + ] + ) + ]) + end + + defp recorded_canonical_json(model_spec, fixture_name) do + {:ok, model} = ReqLLM.model(model_spec) + path = ReqLLM.Test.FixturePath.file(model, fixture_name) + transcript = ReqLLM.Test.VCR.load!(path) + + Map.get(transcript.request, "canonical_json") || + Map.get(transcript.request, :canonical_json) || + %{} + end + + defp anthropic_tool_ids(body) do + messages = Map.get(body, "messages", []) + + call_id = + messages + |> Enum.find_value(fn + %{"role" => "assistant", "content" => content} when is_list(content) -> + content + |> Enum.find_value(fn + %{"type" => "tool_use", "id" => id} when is_binary(id) -> id + _ -> nil + end) + + _ -> + nil + end) + + result_id = + messages + |> Enum.find_value(fn + %{"role" => "user", "content" => content} when is_list(content) -> + content + |> Enum.find_value(fn + %{"type" => "tool_result", "tool_use_id" => id} when is_binary(id) -> id + _ -> nil + end) + + _ -> + nil + end) + + {call_id, result_id} + end +end diff --git a/test/provider/azure/tools_test.exs b/test/provider/azure/tools_test.exs index d8d530956..f21422718 100644 --- a/test/provider/azure/tools_test.exs +++ b/test/provider/azure/tools_test.exs @@ -275,4 +275,55 @@ defmodule ReqLLM.Providers.Azure.ToolsTest do assert tool_result_content[:content] == "Result data here" end end + + describe "tool call ID compatibility" do + test "Claude: sanitizes incompatible tool call IDs from prior provider contexts" do + assistant_msg = + ReqLLM.Context.assistant("", + tool_calls: [ + %{id: "functions.add:0", name: "add", arguments: %{"a" => 1, "b" => 2}} + ] + ) + + tool_result = ReqLLM.Context.tool_result("functions.add:0", "add", "3") + + context = + ReqLLM.Context.new([ + ReqLLM.Context.user("Add numbers"), + assistant_msg, + tool_result + ]) + + body = Azure.Anthropic.format_request("claude-3-sonnet", context, stream: false) + + assistant_msg = Enum.find(body.messages, &(&1.role == "assistant")) + user_msg = Enum.find(body.messages, &(&1.role == "user" and is_list(&1.content))) + assistant_tool_use = Enum.find(assistant_msg.content, &(&1[:type] == "tool_use")) + user_tool_result = Enum.find(user_msg.content, &(&1[:type] == "tool_result")) + + assert assistant_tool_use[:id] == "functions_add_0" + assert user_tool_result[:tool_use_id] == "functions_add_0" + end + + test "Claude: rejects unresolved assistant tool calls" do + assistant_msg = + ReqLLM.Context.assistant("", + tool_calls: [ + %{id: "functions.add:0", name: "add", arguments: %{"a" => 1, "b" => 2}} + ] + ) + + context = + ReqLLM.Context.new([ + ReqLLM.Context.user("Add numbers"), + assistant_msg + ]) + + assert_raise ReqLLM.Error.Invalid.Parameter, + ~r/Switch providers only after appending tool results/, + fn -> + Azure.Anthropic.format_request("claude-3-sonnet", context, stream: false) + end + end + end end diff --git a/test/providers/anthropic_test.exs b/test/providers/anthropic_test.exs index 5d465bf4d..9e8937f57 100644 --- a/test/providers/anthropic_test.exs +++ b/test/providers/anthropic_test.exs @@ -404,6 +404,76 @@ defmodule ReqLLM.Providers.AnthropicTest do end) end) end + + test "encode_body sanitizes OpenAI-style tool call IDs for Anthropic" do + {:ok, model} = ReqLLM.model("anthropic:claude-sonnet-4-5-20250929") + + context = + ReqLLM.Context.new([ + ReqLLM.Context.user("Run a tool"), + ReqLLM.Context.assistant("", + tool_calls: [ + %{ + id: "functions.add:0", + name: "add", + arguments: %{"a" => 1, "b" => 2} + } + ] + ), + ReqLLM.Context.tool_result("functions.add:0", "3") + ]) + + mock_request = %Req.Request{ + options: [context: context, model: model.model, stream: false] + } + + updated_request = Anthropic.encode_body(mock_request) + decoded = Jason.decode!(updated_request.body) + messages = decoded["messages"] + + assistant_block = + messages + |> Enum.find(&(&1["role"] == "assistant")) + |> Map.fetch!("content") + |> Enum.find(&(&1["type"] == "tool_use")) + + tool_result_block = + messages + |> Enum.find(&(&1["role"] == "user" and is_list(&1["content"]))) + |> Map.fetch!("content") + |> Enum.find(&(&1["type"] == "tool_result")) + + assert assistant_block["id"] == "functions_add_0" + assert tool_result_block["tool_use_id"] == "functions_add_0" + end + + test "encode_body rejects contexts ending with unresolved tool calls" do + {:ok, model} = ReqLLM.model("anthropic:claude-sonnet-4-5-20250929") + + context = + ReqLLM.Context.new([ + ReqLLM.Context.user("Run a tool"), + ReqLLM.Context.assistant("", + tool_calls: [ + %{ + id: "functions.add:0", + name: "add", + arguments: %{"a" => 1, "b" => 2} + } + ] + ) + ]) + + mock_request = %Req.Request{ + options: [context: context, model: model.model, stream: false] + } + + assert_raise ReqLLM.Error.Invalid.Parameter, + ~r/Switch providers only after appending tool results/, + fn -> + Anthropic.encode_body(mock_request) + end + end end describe "response decoding & normalization" do diff --git a/test/providers/google_vertex_gemini_test.exs b/test/providers/google_vertex_gemini_test.exs index 75f45ded3..4a00f2278 100644 --- a/test/providers/google_vertex_gemini_test.exs +++ b/test/providers/google_vertex_gemini_test.exs @@ -107,6 +107,35 @@ defmodule ReqLLM.Providers.GoogleVertex.GeminiTest do end end + describe "format_request/3 tool call ID compatibility" do + test "drops functionCall.id fields for Vertex Gemini" do + context = + Context.new([ + Context.user("Add numbers"), + Context.assistant("", + tool_calls: [ + %{id: "functions.add:0", name: "add", arguments: %{"a" => 1, "b" => 2}} + ] + ) + ]) + + body = Gemini.format_request("gemini-2.5-flash", context, max_tokens: 1000) + + function_call = + body + |> Map.fetch!("contents") + |> Enum.flat_map(fn content -> Map.get(content, "parts", []) end) + |> Enum.find_value(fn + %{"functionCall" => call} -> call + _ -> nil + end) + + assert is_map(function_call) + assert function_call["name"] == "add" + refute Map.has_key?(function_call, "id") + end + end + describe "ResponseBuilder - streaming reasoning_details extraction" do alias ReqLLM.Providers.Google.ResponseBuilder diff --git a/test/providers/openai_test.exs b/test/providers/openai_test.exs index 2d6d8809e..4263abe4c 100644 --- a/test/providers/openai_test.exs +++ b/test/providers/openai_test.exs @@ -332,6 +332,39 @@ defmodule ReqLLM.Providers.OpenAITest do assert decoded["tool_choice"] == "required" end + test "encode_body preserves tool call IDs by default" do + {:ok, model} = ReqLLM.model("openai:gpt-4o") + + context = + ReqLLM.Context.new([ + ReqLLM.Context.user("Add numbers"), + ReqLLM.Context.assistant("", + tool_calls: [ + %{id: "functions.add:0", name: "add", arguments: %{"a" => 1, "b" => 2}} + ] + ), + ReqLLM.Context.tool_result("functions.add:0", "add", "3") + ]) + + mock_request = %Req.Request{ + options: [ + context: context, + model: model.model, + stream: false + ] + } + + updated_request = OpenAI.encode_body(mock_request) + decoded = Jason.decode!(updated_request.body) + messages = decoded["messages"] + + assistant_message = Enum.find(messages, &(&1["role"] == "assistant")) + tool_message = Enum.find(messages, &(&1["role"] == "tool")) + + assert get_in(assistant_message, ["tool_calls", Access.at(0), "id"]) == "functions.add:0" + assert tool_message["tool_call_id"] == "functions.add:0" + end + test "encode_body for o1 models uses max_completion_tokens" do {:ok, model} = ReqLLM.model("openai:o1-mini") context = context_fixture() diff --git a/test/req_llm/providers/amazon_bedrock/converse_test.exs b/test/req_llm/providers/amazon_bedrock/converse_test.exs index f9bcbcbac..0e05d9fad 100644 --- a/test/req_llm/providers/amazon_bedrock/converse_test.exs +++ b/test/req_llm/providers/amazon_bedrock/converse_test.exs @@ -109,7 +109,7 @@ defmodule ReqLLM.Providers.AmazonBedrock.ConverseTest do ] end - test "formats request with tool_call content" do + test "rejects contexts ending with unresolved tool calls" do tool_call = ReqLLM.ToolCall.new("call_123", "get_weather", Jason.encode!(%{location: "SF"})) context = %ReqLLM.Context{ @@ -122,22 +122,73 @@ defmodule ReqLLM.Providers.AmazonBedrock.ConverseTest do ] } + assert_raise ReqLLM.Error.Invalid.Parameter, fn -> + Converse.format_request("test-model", context, []) + end + end + + test "formats request with sanitized tool call IDs when tool results are present" do + tool_call = + ReqLLM.ToolCall.new("functions.add:0", "get_weather", Jason.encode!(%{location: "SF"})) + + context = %ReqLLM.Context{ + messages: [ + %Message{role: :user, content: "Call the tool"}, + %Message{role: :assistant, content: [], tool_calls: [tool_call]}, + %Message{role: :tool, tool_call_id: "functions.add:0", content: "Sunny"} + ] + } + result = Converse.format_request("test-model", context, []) + [_, assistant_msg, tool_result_msg] = result["messages"] - assert result["messages"] == [ - %{ - "role" => "assistant", - "content" => [ - %{ - "toolUse" => %{ - "toolUseId" => "call_123", - "name" => "get_weather", - "input" => %{"location" => "SF"} - } + assert %{ + "content" => [ + %{ + "toolUse" => %{ + "toolUseId" => assistant_id } - ] - } - ] + } + ] + } = assistant_msg + + assert %{ + "content" => [ + %{ + "toolResult" => %{ + "toolUseId" => tool_result_id + } + } + ] + } = tool_result_msg + + assert assistant_id == "functions_add_0" + assert tool_result_id == assistant_id + end + + test "formats request with tool call IDs capped to Converse max length" do + long_id = String.duplicate("a", 80) <> ":0" + tool_call = ReqLLM.ToolCall.new(long_id, "get_weather", Jason.encode!(%{location: "SF"})) + + context = %ReqLLM.Context{ + messages: [ + %Message{role: :user, content: "Call the tool"}, + %Message{role: :assistant, content: [], tool_calls: [tool_call]}, + %Message{role: :tool, tool_call_id: long_id, content: "Sunny"} + ] + } + + result = Converse.format_request("test-model", context, []) + [_, assistant_msg, tool_result_msg] = result["messages"] + + assistant_id = get_in(assistant_msg, ["content", Access.at(0), "toolUse", "toolUseId"]) + + tool_result_id = + get_in(tool_result_msg, ["content", Access.at(0), "toolResult", "toolUseId"]) + + assert assistant_id == tool_result_id + assert String.length(assistant_id) == 64 + refute String.contains?(assistant_id, ":") end test "formats request with tool_result content" do diff --git a/test/req_llm/tool_call_id_compat_test.exs b/test/req_llm/tool_call_id_compat_test.exs new file mode 100644 index 000000000..ff4738151 --- /dev/null +++ b/test/req_llm/tool_call_id_compat_test.exs @@ -0,0 +1,157 @@ +defmodule ReqLLM.ToolCallIdCompatTest do + use ExUnit.Case, async: true + + alias ReqLLM.Context + alias ReqLLM.Message + alias ReqLLM.ToolCall + alias ReqLLM.ToolCallIdCompat + + describe "apply_context_with_policy/3 with :sanitize" do + test "sanitizes invalid IDs with ':' and '.'" do + context = + %Context{ + messages: [ + %Message{ + role: :assistant, + content: [], + tool_calls: [ + ToolCall.new("functions.add:0", "add", ~s({"a":1,"b":2})), + ToolCall.new("calc.mul.1", "mul", ~s({"a":2,"b":3})) + ] + }, + %Message{role: :tool, tool_call_id: "functions.add:0", content: "3"}, + %Message{role: :tool, tool_call_id: "calc.mul.1", content: "6"} + ] + } + + updated = + ToolCallIdCompat.apply_context_with_policy(context, %{mode: :sanitize}, + tool_call_id_compat: :auto + ) + + [assistant_msg, tool_msg_1, tool_msg_2] = updated.messages + [first_call, second_call] = assistant_msg.tool_calls + + assert first_call.id == "functions_add_0" + assert second_call.id == "calc_mul_1" + assert tool_msg_1.tool_call_id == "functions_add_0" + assert tool_msg_2.tool_call_id == "calc_mul_1" + end + + test "keeps mapping stable across assistant tool calls and tool results" do + context = + %Context{ + messages: [ + %Message{ + role: :assistant, + content: [], + tool_calls: [ToolCall.new("functions.add:0", "add", ~s({"a":1,"b":2}))] + }, + %Message{role: :tool, tool_call_id: "functions.add:0", content: "3"} + ] + } + + updated = + ToolCallIdCompat.apply_context_with_policy(context, %{mode: :sanitize}, + tool_call_id_compat: :auto + ) + + [assistant_msg, tool_msg] = updated.messages + [tool_call] = assistant_msg.tool_calls + + assert tool_call.id == "functions_add_0" + assert tool_msg.tool_call_id == tool_call.id + end + + test "avoids collisions when multiple IDs sanitize to the same value" do + context = + %Context{ + messages: [ + %Message{ + role: :assistant, + content: [], + tool_calls: [ + ToolCall.new("a:b", "first", ~s({})), + ToolCall.new("a.b", "second", ~s({})) + ] + }, + %Message{role: :tool, tool_call_id: "a:b", content: "ok"}, + %Message{role: :tool, tool_call_id: "a.b", content: "ok"} + ] + } + + updated = + ToolCallIdCompat.apply_context_with_policy(context, %{mode: :sanitize}, + tool_call_id_compat: :auto + ) + + [assistant_msg, tool_msg_1, tool_msg_2] = updated.messages + [first_call, second_call] = assistant_msg.tool_calls + + assert first_call.id == "a_b" + assert second_call.id == "a_b_1" + assert tool_msg_1.tool_call_id == "a_b" + assert tool_msg_2.tool_call_id == "a_b_1" + end + + test "enforces max-length while preserving uniqueness" do + context = + %Context{ + messages: [ + %Message{ + role: :assistant, + content: [], + tool_calls: [ + ToolCall.new("abc:d", "first", ~s({})), + ToolCall.new("abc.d", "second", ~s({})) + ] + }, + %Message{role: :tool, tool_call_id: "abc:d", content: "ok"}, + %Message{role: :tool, tool_call_id: "abc.d", content: "ok"} + ] + } + + updated = + ToolCallIdCompat.apply_context_with_policy( + context, + %{mode: :sanitize, max_length: 5}, + tool_call_id_compat: :auto + ) + + [assistant_msg, tool_msg_1, tool_msg_2] = updated.messages + [first_call, second_call] = assistant_msg.tool_calls + + assert first_call.id == "abc_d" + assert second_call.id == "abc_1" + assert String.length(first_call.id) <= 5 + assert String.length(second_call.id) <= 5 + assert tool_msg_1.tool_call_id == first_call.id + assert tool_msg_2.tool_call_id == second_call.id + end + end + + describe "apply_context_with_policy/3 with :strict" do + test "raises when context contains incompatible IDs" do + context = + %Context{ + messages: [ + %Message{ + role: :assistant, + content: [], + tool_calls: [ToolCall.new("bad:id", "add", ~s({"a":1,"b":2}))] + } + ] + } + + assert_raise ReqLLM.Error.Invalid.Parameter, + ~r/tool_call_id values incompatible with provider policy/, + fn -> + ToolCallIdCompat.apply_context_with_policy( + context, + %{mode: :strict, invalid_chars_regex: ~r/[^A-Za-z0-9_-]/}, + tool_call_id_compat: :auto + ) + end + end + end +end diff --git a/test/support/fixtures/anthropic/claude_sonnet_4_5_20250929/tool_call_id_compat_openai_to_anthropic.json b/test/support/fixtures/anthropic/claude_sonnet_4_5_20250929/tool_call_id_compat_openai_to_anthropic.json new file mode 100644 index 000000000..6de36ebc7 --- /dev/null +++ b/test/support/fixtures/anthropic/claude_sonnet_4_5_20250929/tool_call_id_compat_openai_to_anthropic.json @@ -0,0 +1,197 @@ +{ + "model_spec": "anthropic:claude-sonnet-4-5-20250929", + "provider": "anthropic", + "request": { + "body": { + "b64": "eyJtYXhfdG9rZW5zIjo0OCwibWVzc2FnZXMiOlt7ImNvbnRlbnQiOiJVc2UgYWRkIHRvIHN1bSAxIGFuZCAyLiIsInJvbGUiOiJ1c2VyIn0seyJjb250ZW50IjpbeyJpZCI6ImZ1bmN0aW9uc19hZGRfMCIsImlucHV0Ijp7ImEiOjEsImIiOjJ9LCJuYW1lIjoiYWRkIiwidHlwZSI6InRvb2xfdXNlIn1dLCJyb2xlIjoiYXNzaXN0YW50In0seyJjb250ZW50IjpbeyJjb250ZW50IjoiMyIsInRvb2xfdXNlX2lkIjoiZnVuY3Rpb25zX2FkZF8wIiwidHlwZSI6InRvb2xfcmVzdWx0In1dLCJyb2xlIjoidXNlciJ9LHsiY29udGVudCI6IlJlcGx5IHdpdGggT0sgaWYgdGhlIHRvb2wgcmVzdWx0IHdhcyAzLiIsInJvbGUiOiJ1c2VyIn1dLCJtb2RlbCI6ImNsYXVkZS1zb25uZXQtNC01LTIwMjUwOTI5Iiwic3RyZWFtIjpmYWxzZSwidGVtcGVyYXR1cmUiOjAuMCwidG9vbHMiOlt7ImRlc2NyaXB0aW9uIjoiQWRkIHR3byBudW1iZXJzIiwiaW5wdXRfc2NoZW1hIjp7ImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZSwicHJvcGVydGllcyI6eyJhIjp7InR5cGUiOiJpbnRlZ2VyIn0sImIiOnsidHlwZSI6ImludGVnZXIifX0sInJlcXVpcmVkIjpbImEiLCJiIl0sInR5cGUiOiJvYmplY3QifSwibmFtZSI6ImFkZCJ9XX0=" + }, + "canonical_json": { + "max_tokens": 48, + "messages": [ + { + "content": "Use add to sum 1 and 2.", + "role": "user" + }, + { + "content": [ + { + "id": "functions_add_0", + "input": { + "a": 1, + "b": 2 + }, + "name": "add", + "type": "tool_use" + } + ], + "role": "assistant" + }, + { + "content": [ + { + "content": "3", + "tool_use_id": "functions_add_0", + "type": "tool_result" + } + ], + "role": "user" + }, + { + "content": "Reply with OK if the tool result was 3.", + "role": "user" + } + ], + "model": "claude-sonnet-4-5-20250929", + "stream": false, + "temperature": 0.0, + "tools": [ + { + "description": "Add two numbers", + "input_schema": { + "additionalProperties": false, + "properties": { + "a": { + "type": "integer" + }, + "b": { + "type": "integer" + } + }, + "required": [ + "a", + "b" + ], + "type": "object" + }, + "name": "add" + } + ] + }, + "headers": { + "accept-encoding": [ + "gzip" + ], + "anthropic-beta": [ + "tools-2024-05-16" + ], + "anthropic-version": [ + "2023-06-01" + ], + "content-type": [ + "application/json" + ], + "user-agent": [ + "req/0.5.17" + ], + "x-api-key": "[REDACTED:x-api-key]" + }, + "method": "post", + "url": "https://api.anthropic.com/v1/messages" + }, + "response": { + "body": { + "content": [ + { + "text": "OK", + "type": "text" + } + ], + "id": "msg_01DY2WLB1o6AW1u9T3x7Vp97", + "model": "claude-sonnet-4-5-20250929", + "role": "assistant", + "stop_reason": "end_turn", + "stop_sequence": null, + "type": "message", + "usage": { + "cache_creation": { + "ephemeral_1h_input_tokens": 0, + "ephemeral_5m_input_tokens": 0 + }, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "inference_geo": "not_available", + "input_tokens": 681, + "output_tokens": 4, + "service_tier": "standard" + } + }, + "headers": { + "anthropic-organization-id": [ + "a71659e7-a922-4ff7-99de-f3ba615face1" + ], + "anthropic-ratelimit-input-tokens-limit": [ + "800000" + ], + "anthropic-ratelimit-input-tokens-remaining": [ + "800000" + ], + "anthropic-ratelimit-input-tokens-reset": [ + "2026-02-11T22:12:07Z" + ], + "anthropic-ratelimit-output-tokens-limit": [ + "160000" + ], + "anthropic-ratelimit-output-tokens-remaining": [ + "160000" + ], + "anthropic-ratelimit-output-tokens-reset": [ + "2026-02-11T22:12:07Z" + ], + "anthropic-ratelimit-requests-limit": [ + "2000" + ], + "anthropic-ratelimit-requests-remaining": [ + "1999" + ], + "anthropic-ratelimit-requests-reset": [ + "2026-02-11T22:12:07Z" + ], + "anthropic-ratelimit-tokens-limit": [ + "960000" + ], + "anthropic-ratelimit-tokens-remaining": [ + "960000" + ], + "anthropic-ratelimit-tokens-reset": [ + "2026-02-11T22:12:07Z" + ], + "cf-cache-status": [ + "DYNAMIC" + ], + "cf-ray": [ + "9cc72f992c5feafb-ORD" + ], + "connection": [ + "keep-alive" + ], + "content-security-policy": [ + "default-src 'none'; frame-ancestors 'none'" + ], + "content-type": [ + "application/json" + ], + "date": [ + "Wed, 11 Feb 2026 22:12:07 GMT" + ], + "request-id": [ + "req_011CY33oUByXxa1bLhB4bh9C" + ], + "server": [ + "cloudflare" + ], + "strict-transport-security": [ + "max-age=31536000; includeSubDomains; preload" + ], + "transfer-encoding": [ + "chunked" + ], + "x-envoy-upstream-service-time": [ + "740" + ], + "x-robots-tag": [ + "none" + ] + }, + "status": 200 + } +} \ No newline at end of file diff --git a/test/support/fixtures/openai/gpt_4/tool_call_id_compat_openai_passthrough.json b/test/support/fixtures/openai/gpt_4/tool_call_id_compat_openai_passthrough.json new file mode 100644 index 000000000..d7b9bcf4e --- /dev/null +++ b/test/support/fixtures/openai/gpt_4/tool_call_id_compat_openai_passthrough.json @@ -0,0 +1,197 @@ +{ + "model_spec": "openai:gpt-4", + "provider": "openai", + "request": { + "body": { + "b64": "eyJtYXhfdG9rZW5zIjo0OCwibWVzc2FnZXMiOlt7ImNvbnRlbnQiOiJVc2UgYWRkIHRvIHN1bSAxIGFuZCAyLiIsInJvbGUiOiJ1c2VyIn0seyJjb250ZW50IjpbXSwicm9sZSI6ImFzc2lzdGFudCIsInRvb2xfY2FsbHMiOlt7ImZ1bmN0aW9uIjp7ImFyZ3VtZW50cyI6IntcImFcIjoxLFwiYlwiOjJ9IiwibmFtZSI6ImFkZCJ9LCJpZCI6ImZ1bmN0aW9ucy5hZGQ6MCIsInR5cGUiOiJmdW5jdGlvbiJ9XX0seyJjb250ZW50IjoiMyIsIm5hbWUiOiJhZGQiLCJyb2xlIjoidG9vbCIsInRvb2xfY2FsbF9pZCI6ImZ1bmN0aW9ucy5hZGQ6MCJ9LHsiY29udGVudCI6IlJlcGx5IHdpdGggT0sgaWYgdGhlIHRvb2wgcmVzdWx0IHdhcyAzLiIsInJvbGUiOiJ1c2VyIn1dLCJtb2RlbCI6ImdwdC00Iiwic3RyZWFtIjpmYWxzZSwidGVtcGVyYXR1cmUiOjAuMCwidG9vbHMiOlt7ImZ1bmN0aW9uIjp7ImRlc2NyaXB0aW9uIjoiQWRkIHR3byBudW1iZXJzIiwibmFtZSI6ImFkZCIsInBhcmFtZXRlcnMiOnsiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJwcm9wZXJ0aWVzIjp7ImEiOnsidHlwZSI6ImludGVnZXIifSwiYiI6eyJ0eXBlIjoiaW50ZWdlciJ9fSwicmVxdWlyZWQiOlsiYSIsImIiXSwidHlwZSI6Im9iamVjdCJ9fSwidHlwZSI6ImZ1bmN0aW9uIn1dfQ==" + }, + "canonical_json": { + "max_tokens": 48, + "messages": [ + { + "content": "Use add to sum 1 and 2.", + "role": "user" + }, + { + "content": [], + "role": "assistant", + "tool_calls": [ + { + "function": { + "arguments": "{\"a\":1,\"b\":2}", + "name": "add" + }, + "id": "functions.add:0", + "type": "function" + } + ] + }, + { + "content": "3", + "name": "add", + "role": "tool", + "tool_call_id": "functions.add:0" + }, + { + "content": "Reply with OK if the tool result was 3.", + "role": "user" + } + ], + "model": "gpt-4", + "stream": false, + "temperature": 0.0, + "tools": [ + { + "function": { + "description": "Add two numbers", + "name": "add", + "parameters": { + "additionalProperties": false, + "properties": { + "a": { + "type": "integer" + }, + "b": { + "type": "integer" + } + }, + "required": [ + "a", + "b" + ], + "type": "object" + } + }, + "type": "function" + } + ] + }, + "headers": { + "accept-encoding": [ + "gzip" + ], + "authorization": "[REDACTED:authorization]", + "content-type": [ + "application/json" + ], + "user-agent": [ + "req/0.5.17" + ] + }, + "method": "post", + "url": "https://api.openai.com/v1/chat/completions" + }, + "response": { + "body": { + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "logprobs": null, + "message": { + "annotations": [], + "content": "OK", + "refusal": null, + "role": "assistant" + } + } + ], + "created": 1770847929, + "id": "chatcmpl-D8CjJ6ygYZCqVTZhAfM9J6XerRYDU", + "model": "gpt-4-0613", + "object": "chat.completion", + "service_tier": "default", + "system_fingerprint": null, + "usage": { + "completion_tokens": 2, + "completion_tokens_details": { + "accepted_prediction_tokens": 0, + "audio_tokens": 0, + "reasoning_tokens": 0, + "rejected_prediction_tokens": 0 + }, + "prompt_tokens": 93, + "prompt_tokens_details": { + "audio_tokens": 0, + "cached_tokens": 0 + }, + "total_tokens": 95 + } + }, + "headers": { + "access-control-expose-headers": [ + "X-Request-ID" + ], + "alt-svc": [ + "h3=\":443\"; ma=86400" + ], + "cf-cache-status": [ + "DYNAMIC" + ], + "cf-ray": [ + "9cc72fa03ab022e0-ORD" + ], + "connection": [ + "keep-alive" + ], + "content-type": [ + "application/json" + ], + "date": [ + "Wed, 11 Feb 2026 22:12:09 GMT" + ], + "openai-organization": [ + "epic-creative-sogybe" + ], + "openai-processing-ms": [ + "577" + ], + "openai-project": [ + "proj_j5AhgU4eLMRmsoO9FDg7BRD3" + ], + "openai-version": [ + "2020-10-01" + ], + "server": [ + "cloudflare" + ], + "set-cookie": [ + "__cf_bm=5DYvZiAPscYJ41BztY37t4lJFpTfulUbHJhLUa8Key8-1770847928.352716-1.0.1.1-CeRZafqRpl30x3lImQvYGZ369KHd4szHo5TDXCBsVp2vrTGVQKfpo3x6iYkOMADkv3FT5ZgVsg1VWo_NiPGGyTFh0FBZeqTFuXj4Ian2LPpplqiAExiW44BH5kkEvkrf; HttpOnly; Secure; Path=/; Domain=api.openai.com; Expires=Wed, 11 Feb 2026 22:42:09 GMT" + ], + "strict-transport-security": [ + "max-age=31536000; includeSubDomains; preload" + ], + "transfer-encoding": [ + "chunked" + ], + "x-content-type-options": [ + "nosniff" + ], + "x-openai-proxy-wasm": [ + "v0.1" + ], + "x-ratelimit-limit-requests": [ + "10000" + ], + "x-ratelimit-limit-tokens": [ + "300000" + ], + "x-ratelimit-remaining-requests": [ + "9999" + ], + "x-ratelimit-remaining-tokens": [ + "299979" + ], + "x-ratelimit-reset-requests": [ + "6ms" + ], + "x-ratelimit-reset-tokens": [ + "4ms" + ], + "x-request-id": [ + "req_54dd1dbc18764a1dbe21005c3bd78997" + ] + }, + "status": 200 + } +} \ No newline at end of file