discourse/plugins/discourse-ai/lib/completions/endpoints/open_ai_responses.rb
Rafael dos Santos Silva bc39aacc3d
FEATURE: Provider-native built-in tools for agents (web search) (#40809)
Adds a fourth kind of agent tool: provider-native built-in tools that
the
LLM provider executes server-side, rather than tools Discourse runs and
feeds back. The first one is web search, supported on Gemini (Google
Search
grounding), OpenAI (web search via the Responses API) and Anthropic
(Claude
web search).

Native tools are stored on the agent's `tools` column with a `native-`
prefix, flow to the prompt as a separate `native_tools` list (never as
runnable Tool classes), and each provider dialect renders them into its
own
request payload. Response processors already ignore the server-side
tool/grounding blocks, so the bot loop never tries to execute them.

They are only selectable when the agent forces a default LLM whose
provider
supports the tool; this is enforced both in the editor UI (filtered by
the
selected LLM's `supported_native_tools`) and by server-side validation.

Also fixes the Gemini endpoint sending `function_calling_config` without
any
`function_declarations`, which the API rejects when only native tools
are
present.

---------

Co-authored-by: Sam Saffron <sam.saffron@gmail.com>
2026-06-16 14:37:51 -03:00

86 lines
2.8 KiB
Ruby
Vendored

# frozen_string_literal: true
module DiscourseAi
module Completions
module Endpoints
class OpenAiResponses < Base
include OpenAiShared
def self.can_contact?(llm_model)
%w[open_ai azure].include?(llm_model.provider) &&
llm_model.url.to_s.include?("/v1/responses")
end
private
def srv_fallback_path
"/v1/responses"
end
def prepare_payload(prompt, model_params, dialect)
payload = default_options.merge(model_params).merge(messages: prompt)
reasoning_payload = { summary: "auto" }
reasoning_payload[:effort] = reasoning_effort if reasoning_effort
payload.merge!(reasoning: reasoning_payload)
payload[:service_tier] = service_tier if service_tier
if @streaming_mode
payload[:stream] = true
payload[:stream_options] = { include_usage: true } if llm_model.provider == "open_ai"
end
if !xml_tools_enabled?
if dialect.tools.present?
payload[:tools] = dialect.tools
if dialect.tool_choice.present?
if dialect.tool_choice == :none
payload[:tool_choice] = "none"
else
payload[:tool_choice] = { type: "function", name: dialect.tool_choice }
end
end
end
end
native_tools = dialect.native_tools
payload[:tools] = (payload[:tools] || []).concat(native_tools) if native_tools.present?
convert_payload_to_responses_api!(payload)
payload[:include] ||= []
payload[:include] << "reasoning.encrypted_content"
if native_tools.any? { |tool| tool[:type] == "web_search" }
payload[:include] << "web_search_call.action.sources"
end
payload
end
def convert_payload_to_responses_api!(payload)
payload[:input] = payload.delete(:messages)
completion_tokens = payload.delete(:max_completion_tokens) || payload.delete(:max_tokens)
payload[:max_output_tokens] = completion_tokens if completion_tokens
if payload[:response_format]
format = payload.delete(:response_format)
if format && format[:json_schema]
payload[:text] ||= {}
payload[:text][:format] = format[:json_schema]
payload[:text][:format][:type] ||= "json_schema"
end
end
payload.delete(:stream_options)
end
def processor
@processor ||=
OpenAiResponsesMessageProcessor.new(
partial_tool_calls: partial_tool_calls,
output_thinking: output_thinking,
)
end
end
end
end
end