Commit c4507e7

mo khan <mo@mokhan.ca>
2026-01-08 05:02:12
fix: split user messages from system messages in Claude provider
1 parent 0c80f78
Changed files (1)
lib
net
lib/net/llm/claude.rb
@@ -28,12 +28,13 @@ module Net
       end
 
       def fetch(messages, tools = [], &block)
+        system_message, user_messages = extract_system_message(messages)
         anthropic_tools = tools.empty? ? nil : tools.map { |t| normalize_tool_for_anthropic(t) }
 
         if block_given?
-          fetch_streaming(messages, anthropic_tools, &block)
+          fetch_streaming(user_messages, anthropic_tools, system: system_message, &block)
         else
-          fetch_non_streaming(messages, anthropic_tools)
+          fetch_non_streaming(user_messages, anthropic_tools, system: system_message)
         end
       end
 
@@ -62,7 +63,7 @@ module Net
 
       def stream_request(payload, &block)
         http.post(endpoint, headers: headers, body: payload) do |response|
-          raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
+          raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
 
           buffer = ""
           response.read_body do |chunk|
@@ -102,8 +103,52 @@ module Net
         event
       end
 
-      def fetch_non_streaming(messages, tools)
-        result = self.messages(messages, tools: tools)
+      def extract_system_message(messages)
+        system_msg = messages.find { |m| m[:role] == "system" || m["role"] == "system" }
+        system_content = system_msg ? (system_msg[:content] || system_msg["content"]) : nil
+        other_messages = messages.reject { |m| m[:role] == "system" || m["role"] == "system" }
+        normalized_messages = normalize_messages_for_claude(other_messages)
+        [system_content, normalized_messages]
+      end
+
+      def normalize_messages_for_claude(messages)
+        messages.map do |msg|
+          role = msg[:role] || msg["role"]
+          tool_calls = msg[:tool_calls] || msg["tool_calls"]
+
+          if role == "tool"
+            {
+              role: "user",
+              content: [{
+                type: "tool_result",
+                tool_use_id: msg[:tool_call_id] || msg["tool_call_id"],
+                content: msg[:content] || msg["content"]
+              }]
+            }
+          elsif role == "assistant" && tool_calls&.any?
+            content = []
+            text = msg[:content] || msg["content"]
+            content << { type: "text", text: text } if text && !text.empty?
+            tool_calls.each do |tc|
+              func = tc[:function] || tc["function"] || {}
+              args = func[:arguments] || func["arguments"]
+              input = args.is_a?(String) ? (JSON.parse(args) rescue {}) : (args || {})
+              content << {
+                type: "tool_use",
+                id: tc[:id] || tc["id"],
+                name: func[:name] || func["name"] || tc[:name] || tc["name"],
+                input: input
+              }
+            end
+            { role: "assistant", content: content }
+          else
+            msg
+          end
+        end
+      end
+
+      def fetch_non_streaming(messages, tools, system: nil)
+        result = self.messages(messages, system: system, tools: tools)
         return result if result["code"]
 
         {
@@ -115,13 +160,13 @@ module Net
         }
       end
 
-      def fetch_streaming(messages, tools, &block)
+      def fetch_streaming(messages, tools, system: nil, &block)
         content = ""
         thinking = ""
         tool_calls = []
         stop_reason = :end_turn
 
-        self.messages(messages, tools: tools) do |event|
+        self.messages(messages, system: system, tools: tools) do |event|
           case event["type"]
           when "content_block_start"
             if event.dig("content_block", "type") == "tool_use"