mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-07-01 20:12:15 +00:00
241 lines
14 KiB
Ruby
241 lines
14 KiB
Ruby
|
# frozen_string_literal: true
|
||
|
|
||
|
RSpec.describe DiscourseAi::Completions::Endpoints::OpenAi do
|
||
|
subject(:endpoint) { described_class.new(model) }
|
||
|
|
||
|
fab!(:model) do
|
||
|
Fabricate(
|
||
|
:llm_model,
|
||
|
provider: "open_ai",
|
||
|
url: "https://api.openai.com/v1/responses",
|
||
|
provider_params: {
|
||
|
enable_responses_api: true,
|
||
|
},
|
||
|
)
|
||
|
end
|
||
|
|
||
|
let(:prompt_with_tools) do
|
||
|
prompt = DiscourseAi::Completions::Prompt.new("echo: Hello")
|
||
|
prompt.tools = [
|
||
|
DiscourseAi::Completions::ToolDefinition.new(
|
||
|
name: "echo",
|
||
|
description: "Used for testing of llms, will echo the param given to it",
|
||
|
parameters: [
|
||
|
DiscourseAi::Completions::ToolDefinition::ParameterDefinition.from_hash(
|
||
|
{ name: "string", description: "string to echo", type: :string, required: true },
|
||
|
),
|
||
|
],
|
||
|
),
|
||
|
]
|
||
|
prompt
|
||
|
end
|
||
|
|
||
|
it "can perform simple streaming completion" do
|
||
|
response_payload = <<~TEXT
|
||
|
event: response.created
|
||
|
data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_6848d84bee44819d98e5f4f5103562090333bc932679b022","object":"response","created_at":1749604427,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4.1-nano-2025-04-14","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}}
|
||
|
|
||
|
event: response.in_progress
|
||
|
data: {"type":"response.in_progress","sequence_number":1,"response":{"id":"resp_6848d84bee44819d98e5f4f5103562090333bc932679b022","object":"response","created_at":1749604427,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4.1-nano-2025-04-14","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}}
|
||
|
|
||
|
event: response.output_item.added
|
||
|
data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"msg_6848d84c3bc8819dace0eadec6e205090333bc932679b022","type":"message","status":"in_progress","content":[],"role":"assistant"}}
|
||
|
|
||
|
event: response.content_part.added
|
||
|
data: {"type":"response.content_part.added","sequence_number":3,"item_id":"msg_6848d84c3bc8819dace0eadec6e205090333bc932679b022","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"text":""}}
|
||
|
|
||
|
event: response.output_text.delta
|
||
|
data: {"type":"response.output_text.delta","sequence_number":4,"item_id":"msg_6848d84c3bc8819dace0eadec6e205090333bc932679b022","output_index":0,"content_index":0,"delta":"Hello"}
|
||
|
|
||
|
event: response.output_text.delta
|
||
|
data: {"type":"response.output_text.delta","sequence_number":5,"item_id":"msg_6848d84c3bc8819dace0eadec6e205090333bc932679b022","output_index":0,"content_index":0,"delta":" "}
|
||
|
|
||
|
event: response.output_text.delta
|
||
|
data: {"type":"response.output_text.delta","sequence_number":5,"item_id":"msg_6848d84c3bc8819dace0eadec6e205090333bc932679b022","output_index":0,"content_index":0,"delta":"World"}
|
||
|
|
||
|
event: response.output_text.done
|
||
|
data: {"type":"response.output_text.done","sequence_number":5,"item_id":"msg_6848d84c3bc8819dace0eadec6e205090333bc932679b022","output_index":0,"content_index":0,"text":"Hello World"}
|
||
|
|
||
|
event: response.content_part.done
|
||
|
data: {"type":"response.content_part.done","sequence_number":6,"item_id":"msg_6848d84c3bc8819dace0eadec6e205090333bc932679b022","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"text":"Hello World"}}
|
||
|
|
||
|
event: response.output_item.done
|
||
|
data: {"type":"response.output_item.done","sequence_number":7,"output_index":0,"item":{"id":"msg_6848d84c3bc8819dace0eadec6e205090333bc932679b022","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"text":"Hello World"}],"role":"assistant"}}
|
||
|
|
||
|
event: response.completed
|
||
|
data: {"type":"response.completed","sequence_number":8,"response":{"id":"resp_6848d84bee44819d98e5f4f5103562090333bc932679b022","object":"response","created_at":1749604427,"status":"completed","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4.1-nano-2025-04-14","output":[{"id":"msg_6848d84c3bc8819dace0eadec6e205090333bc932679b022","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"text":"Hello"}],"role":"assistant"}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":35,"input_tokens_details":{"cached_tokens":5},"output_tokens":9,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":37},"user":null,"metadata":{}}}
|
||
|
TEXT
|
||
|
|
||
|
partials = []
|
||
|
|
||
|
stub_request(:post, "https://api.openai.com/v1/responses").to_return(
|
||
|
status: 200,
|
||
|
body: response_payload,
|
||
|
)
|
||
|
|
||
|
model
|
||
|
.to_llm
|
||
|
.generate("Say: Hello World", user: Discourse.system_user) { |partial| partials << partial }
|
||
|
|
||
|
expect(partials).to eq(["Hello", " ", "World"])
|
||
|
|
||
|
log = AiApiAuditLog.last
|
||
|
|
||
|
# note: our report counts cache and request tokens separately see: DiscourseAi::Completions::Report
|
||
|
expect(log).to be_present
|
||
|
expect(log.request_tokens).to eq(30)
|
||
|
expect(log.response_tokens).to eq(9)
|
||
|
expect(log.cached_tokens).to eq(5)
|
||
|
end
|
||
|
|
||
|
it "can properly stream tool calls" do
|
||
|
response_payload = <<~TEXT
|
||
|
event: response.created
|
||
|
data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_684910c81eec81a3a9222aa336d9fcf202d35c1819a50f63","object":"response","created_at":1749618888,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4.1-nano-2025-04-14","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":{"type":"function","name":"echo"},"tools":[{"type":"function","description":"Used for testing of llms, will echo the param given to it","name":"echo","parameters":{"type":"object","properties":{"string":{"type":"string","description":"string to echo"}},"required":["string"]},"strict":true}],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}}
|
||
|
|
||
|
event: response.in_progress
|
||
|
data: {"type":"response.in_progress","sequence_number":1,"response":{"id":"resp_684910c81eec81a3a9222aa336d9fcf202d35c1819a50f63","object":"response","created_at":1749618888,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4.1-nano-2025-04-14","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":{"type":"function","name":"echo"},"tools":[{"type":"function","description":"Used for testing of llms, will echo the param given to it","name":"echo","parameters":{"type":"object","properties":{"string":{"type":"string","description":"string to echo"}},"required":["string"]},"strict":true}],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}}
|
||
|
|
||
|
event: response.output_item.added
|
||
|
data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"fc_684910c8b68881a3b43610e1d57ef00702d35c1819a50f63","type":"function_call","status":"in_progress","arguments":"","call_id":"call_TQyfNmFnKblzXl5rlcGeIsg5","name":"echo"}}
|
||
|
|
||
|
event: response.function_call_arguments.delta
|
||
|
data: {"type":"response.function_call_arguments.delta","sequence_number":3,"item_id":"fc_684910c8b68881a3b43610e1d57ef00702d35c1819a50f63","output_index":0,"delta":"{\\""}
|
||
|
|
||
|
event: response.function_call_arguments.delta
|
||
|
data: {"type":"response.function_call_arguments.delta","sequence_number":4,"item_id":"fc_684910c8b68881a3b43610e1d57ef00702d35c1819a50f63","output_index":0,"delta":"string"}
|
||
|
|
||
|
event: response.function_call_arguments.delta
|
||
|
data: {"type":"response.function_call_arguments.delta","sequence_number":5,"item_id":"fc_684910c8b68881a3b43610e1d57ef00702d35c1819a50f63","output_index":0,"delta":"\\":\\""}
|
||
|
|
||
|
event: response.function_call_arguments.delta
|
||
|
data: {"type":"response.function_call_arguments.delta","sequence_number":6,"item_id":"fc_684910c8b68881a3b43610e1d57ef00702d35c1819a50f63","output_index":0,"delta":"hello"}
|
||
|
|
||
|
event: response.function_call_arguments.delta
|
||
|
data: {"type":"response.function_call_arguments.delta","sequence_number":7,"item_id":"fc_684910c8b68881a3b43610e1d57ef00702d35c1819a50f63","output_index":0,"delta":"\\"}"}
|
||
|
|
||
|
event: response.function_call_arguments.done
|
||
|
data: {"type":"response.function_call_arguments.done","sequence_number":8,"item_id":"fc_684910c8b68881a3b43610e1d57ef00702d35c1819a50f63","output_index":0,"arguments":"{\\"string\\":\\"hello\\"}"}
|
||
|
|
||
|
event: response.output_item.done
|
||
|
data: {"type":"response.output_item.done","sequence_number":9,"output_index":0,"item":{"id":"fc_684910c8b68881a3b43610e1d57ef00702d35c1819a50f63","type":"function_call","status":"completed","arguments":"{\\"string\\":\\"hello\\"}","call_id":"call_TQyfNmFnKblzXl5rlcGeIsg5","name":"echo"}}
|
||
|
|
||
|
event: response.completed
|
||
|
data: {"type":"response.completed","sequence_number":10,"response":{"id":"resp_684910c81eec81a3a9222aa336d9fcf202d35c1819a50f63","object":"response","created_at":1749618888,"status":"completed","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4.1-nano-2025-04-14","output":[{"id":"fc_684910c8b68881a3b43610e1d57ef00702d35c1819a50f63","type":"function_call","status":"completed","arguments":"{\\"string\\":\\"hello\\"}","call_id":"call_TQyfNmFnKblzXl5rlcGeIsg5","name":"echo"}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":{"type":"function","name":"echo"},"tools":[{"type":"function","description":"Used for testing of llms, will echo the param given to it","name":"echo","parameters":{"type":"object","properties":{"string":{"type":"string","description":"string to echo"}},"required":["string"]},"strict":true}],"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":71,"input_tokens_details":{"cached_tokens":0},"output_tokens":6,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":77},"user":null,"metadata":{}}}
|
||
|
|
||
|
TEXT
|
||
|
|
||
|
partials = []
|
||
|
|
||
|
stub_request(:post, "https://api.openai.com/v1/responses").to_return(
|
||
|
status: 200,
|
||
|
body: response_payload,
|
||
|
)
|
||
|
|
||
|
model
|
||
|
.to_llm
|
||
|
.generate(
|
||
|
prompt_with_tools,
|
||
|
user: Discourse.system_user,
|
||
|
partial_tool_calls: true,
|
||
|
) { |partial| partials << partial.dup }
|
||
|
|
||
|
# the partial tools are deduped
|
||
|
expect(partials.length).to eq(1)
|
||
|
|
||
|
expect(partials.first).to be_a(DiscourseAi::Completions::ToolCall)
|
||
|
expect(partials.first.name).to eq("echo")
|
||
|
expect(partials.first.parameters).to eq({ string: "hello" })
|
||
|
expect(partials.first.id).to eq("call_TQyfNmFnKblzXl5rlcGeIsg5")
|
||
|
end
|
||
|
|
||
|
it "can handle non streaming tool calls" do
|
||
|
response_object = {
|
||
|
id: "resp_68491ed72974819f94652a73fb58109c08901d75ebf6c66e",
|
||
|
object: "response",
|
||
|
created_at: 1_749_622_487,
|
||
|
status: "completed",
|
||
|
background: false,
|
||
|
error: nil,
|
||
|
incomplete_details: nil,
|
||
|
instructions: nil,
|
||
|
max_output_tokens: nil,
|
||
|
model: "gpt-4.1-nano-2025-04-14",
|
||
|
output: [
|
||
|
{
|
||
|
id: "fc_68491ed75e0c819f87462ff642c58d2e08901d75ebf6c66e",
|
||
|
type: "function_call",
|
||
|
status: "completed",
|
||
|
arguments: "{\"string\":\"sam\"}",
|
||
|
call_id: "call_UdxBpinIVc5nRZ0VnWJIgneA",
|
||
|
name: "echo",
|
||
|
},
|
||
|
],
|
||
|
parallel_tool_calls: true,
|
||
|
previous_response_id: nil,
|
||
|
reasoning: {
|
||
|
effort: nil,
|
||
|
summary: nil,
|
||
|
},
|
||
|
service_tier: "default",
|
||
|
store: true,
|
||
|
temperature: 1.0,
|
||
|
text: {
|
||
|
format: {
|
||
|
type: "text",
|
||
|
},
|
||
|
},
|
||
|
tool_choice: {
|
||
|
type: "function",
|
||
|
name: "echo",
|
||
|
},
|
||
|
tools: [
|
||
|
{
|
||
|
type: "function",
|
||
|
description: "Used for testing of llms, will echo the param given to it",
|
||
|
name: "echo",
|
||
|
parameters: {
|
||
|
type: "object",
|
||
|
properties: {
|
||
|
string: {
|
||
|
type: "string",
|
||
|
description: "string to echo",
|
||
|
},
|
||
|
},
|
||
|
required: ["string"],
|
||
|
},
|
||
|
strict: true,
|
||
|
},
|
||
|
],
|
||
|
top_p: 1.0,
|
||
|
truncation: "disabled",
|
||
|
usage: {
|
||
|
input_tokens: 73,
|
||
|
input_tokens_details: {
|
||
|
cached_tokens: 0,
|
||
|
},
|
||
|
output_tokens: 6,
|
||
|
output_tokens_details: {
|
||
|
reasoning_tokens: 0,
|
||
|
},
|
||
|
total_tokens: 79,
|
||
|
},
|
||
|
user: nil,
|
||
|
metadata: {
|
||
|
},
|
||
|
}
|
||
|
|
||
|
stub_request(:post, "https://api.openai.com/v1/responses").to_return(
|
||
|
status: 200,
|
||
|
body: response_object.to_json,
|
||
|
)
|
||
|
|
||
|
result = model.to_llm.generate(prompt_with_tools, user: Discourse.system_user)
|
||
|
|
||
|
expect(result).to be_a(DiscourseAi::Completions::ToolCall)
|
||
|
expect(result.name).to eq("echo")
|
||
|
expect(result.parameters).to eq({ string: "sam" })
|
||
|
expect(result.id).to eq("call_UdxBpinIVc5nRZ0VnWJIgneA")
|
||
|
end
|
||
|
end
|