main
  1# frozen_string_literal: true
  2
  3module Net
  4  module Llm
  5    class OpenAI
  6      attr_reader :api_key, :base_url, :model, :http
  7
  8      def initialize(api_key: ENV.fetch("OPENAI_API_KEY"), base_url: ENV.fetch("OPENAI_BASE_URL", "https://api.openai.com/v1"), model: "gpt-4o-mini", http: Net::Llm.http)
  9        @api_key = api_key
 10        @base_url = base_url
 11        @model = model
 12        @http = http
 13      end
 14
 15      def chat(messages, tools)
 16        handle_response(http.post(
 17          "#{base_url}/chat/completions",
 18          headers: headers,
 19          body: { model: model, messages: messages, tools: tools, tool_choice: "auto" }
 20        ))
 21      end
 22
 23      def fetch(messages, tools = [], &block)
 24        if block_given?
 25          fetch_streaming(messages, tools, &block)
 26        else
 27          fetch_non_streaming(messages, tools)
 28        end
 29      end
 30
 31      def models
 32        handle_response(http.get("#{base_url}/models", headers: headers))
 33      end
 34
 35      def embeddings(input, model: "text-embedding-ada-002")
 36        handle_response(http.post(
 37          "#{base_url}/embeddings",
 38          headers: headers,
 39          body: { model: model, input: input },
 40        ))
 41      end
 42
 43      private
 44
 45      def headers
 46        { "Authorization" => Net::Hippie.bearer_auth(api_key) }
 47      end
 48
 49      def handle_response(response)
 50        if response.is_a?(Net::HTTPSuccess)
 51          JSON.parse(response.body)
 52        else
 53          { "code" => response.code, "body" => response.body }
 54        end
 55      end
 56
 57      def fetch_non_streaming(messages, tools)
 58        body = { model: model, messages: messages }
 59        body[:tools] = tools unless tools.empty?
 60        body[:tool_choice] = "auto" unless tools.empty?
 61
 62        result = handle_response(http.post("#{base_url}/chat/completions", headers: headers, body: body))
 63        return result if result["code"]
 64
 65        msg = result.dig("choices", 0, "message") || {}
 66        {
 67          type: :complete,
 68          content: msg["content"],
 69          thinking: nil,
 70          tool_calls: normalize_tool_calls(msg["tool_calls"]),
 71          stop_reason: map_stop_reason(result.dig("choices", 0, "finish_reason"))
 72        }
 73      end
 74
 75      def fetch_streaming(messages, tools, &block)
 76        body = { model: model, messages: messages, stream: true }
 77        body[:tools] = tools unless tools.empty?
 78        body[:tool_choice] = "auto" unless tools.empty?
 79
 80        content = ""
 81        tool_calls = {}
 82        stop_reason = :end_turn
 83
 84        http.post("#{base_url}/chat/completions", headers: headers, body: body) do |response|
 85          raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
 86
 87          buffer = ""
 88          response.read_body do |chunk|
 89            buffer += chunk
 90
 91            while (line = extract_line(buffer))
 92              next if line.empty? || !line.start_with?("data: ")
 93
 94              data = line[6..]
 95              break if data == "[DONE]"
 96
 97              json = JSON.parse(data)
 98              delta = json.dig("choices", 0, "delta") || {}
 99
100              if delta["content"]
101                content += delta["content"]
102                block.call({ type: :delta, content: delta["content"], thinking: nil, tool_calls: nil })
103              end
104
105              if delta["tool_calls"]
106                delta["tool_calls"].each do |tc|
107                  idx = tc["index"]
108                  tool_calls[idx] ||= { id: nil, name: nil, arguments_json: "" }
109                  tool_calls[idx][:id] = tc["id"] if tc["id"]
110                  tool_calls[idx][:name] = tc.dig("function", "name") if tc.dig("function", "name")
111                  tool_calls[idx][:arguments_json] += tc.dig("function", "arguments") || ""
112                end
113              end
114
115              if json.dig("choices", 0, "finish_reason")
116                stop_reason = map_stop_reason(json.dig("choices", 0, "finish_reason"))
117              end
118            end
119          end
120        end
121
122        final_tool_calls = tool_calls.values.map do |tc|
123          args = begin
124            JSON.parse(tc[:arguments_json])
125          rescue
126            {}
127          end
128          { id: tc[:id], name: tc[:name], arguments: args }
129        end
130
131        block.call({
132          type: :complete,
133          content: content,
134          thinking: nil,
135          tool_calls: final_tool_calls,
136          stop_reason: stop_reason
137        })
138      end
139
140      def extract_line(buffer)
141        line_end = buffer.index("\n")
142        return nil unless line_end
143
144        line = buffer[0...line_end]
145        buffer.replace(buffer[(line_end + 1)..] || "")
146        line
147      end
148
149      def normalize_tool_calls(tool_calls)
150        return [] if tool_calls.nil? || tool_calls.empty?
151
152        tool_calls.map do |tc|
153          args = tc.dig("function", "arguments")
154          {
155            id: tc["id"],
156            name: tc.dig("function", "name"),
157            arguments: args.is_a?(String) ? (JSON.parse(args) rescue {}) : (args || {})
158          }
159        end
160      end
161
162      def map_stop_reason(reason)
163        case reason
164        when "stop" then :end_turn
165        when "tool_calls" then :tool_use
166        when "length" then :max_tokens
167        else :end_turn
168        end
169      end
170    end
171  end
172end