# 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