llm.rb 11.1.0 → 11.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +141 -12
  3. data/README.md +104 -69
  4. data/lib/llm/a2a/transport/http.rb +9 -8
  5. data/lib/llm/a2a.rb +14 -7
  6. data/lib/llm/agent.rb +31 -7
  7. data/lib/llm/context.rb +20 -6
  8. data/lib/llm/error.rb +4 -0
  9. data/lib/llm/function/array.rb +6 -0
  10. data/lib/llm/function.rb +26 -0
  11. data/lib/llm/json_adapter.rb +8 -2
  12. data/lib/llm/mcp/transport/http.rb +7 -5
  13. data/lib/llm/mcp.rb +6 -7
  14. data/lib/llm/provider.rb +1 -18
  15. data/lib/llm/providers/anthropic/error_handler.rb +2 -0
  16. data/lib/llm/providers/anthropic/files.rb +6 -6
  17. data/lib/llm/providers/anthropic/models.rb +1 -1
  18. data/lib/llm/providers/anthropic.rb +1 -1
  19. data/lib/llm/providers/bedrock/error_handler.rb +1 -1
  20. data/lib/llm/providers/bedrock/models.rb +4 -4
  21. data/lib/llm/providers/bedrock/signature.rb +3 -3
  22. data/lib/llm/providers/bedrock.rb +1 -1
  23. data/lib/llm/providers/google/error_handler.rb +2 -0
  24. data/lib/llm/providers/google/files.rb +5 -5
  25. data/lib/llm/providers/google/images.rb +1 -1
  26. data/lib/llm/providers/google/models.rb +1 -1
  27. data/lib/llm/providers/google.rb +2 -2
  28. data/lib/llm/providers/ollama/error_handler.rb +2 -0
  29. data/lib/llm/providers/ollama/models.rb +1 -1
  30. data/lib/llm/providers/ollama.rb +2 -2
  31. data/lib/llm/providers/openai/audio.rb +3 -3
  32. data/lib/llm/providers/openai/error_handler.rb +2 -0
  33. data/lib/llm/providers/openai/files.rb +5 -5
  34. data/lib/llm/providers/openai/images.rb +3 -3
  35. data/lib/llm/providers/openai/models.rb +1 -1
  36. data/lib/llm/providers/openai/moderations.rb +1 -1
  37. data/lib/llm/providers/openai/responses.rb +3 -3
  38. data/lib/llm/providers/openai/vector_stores.rb +11 -11
  39. data/lib/llm/providers/openai.rb +2 -2
  40. data/lib/llm/skill.rb +1 -1
  41. data/lib/llm/tool.rb +21 -0
  42. data/lib/llm/transport/curb.rb +246 -0
  43. data/lib/llm/transport/execution.rb +1 -1
  44. data/lib/llm/transport/http.rb +9 -4
  45. data/lib/llm/transport/net_http_adapter.rb +61 -0
  46. data/lib/llm/transport/persistent_http.rb +10 -5
  47. data/lib/llm/transport/request.rb +121 -0
  48. data/lib/llm/transport/response/curb.rb +112 -0
  49. data/lib/llm/transport/response.rb +1 -0
  50. data/lib/llm/transport/utils.rb +42 -17
  51. data/lib/llm/transport.rb +17 -45
  52. data/lib/llm/version.rb +1 -1
  53. data/llm.gemspec +6 -5
  54. metadata +25 -8
@@ -39,7 +39,7 @@ class LLM::Google
39
39
  # @return [LLM::Response]
40
40
  def all(**params)
41
41
  query = URI.encode_www_form(params.merge!(key: key))
42
- req = Net::HTTP::Get.new("/v1beta/models?#{query}", headers)
42
+ req = LLM::Transport::Request.get("/v1beta/models?#{query}", headers)
43
43
  res, span, tracer = execute(request: req, operation: "request")
44
44
  res = ResponseAdapter.adapt(res, type: :models)
45
45
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -56,7 +56,7 @@ module LLM
56
56
  def embed(input, model: "gemini-embedding-001", **params)
57
57
  model = model.respond_to?(:id) ? model.id : model
58
58
  path = ["/v1beta/models/#{model}", "embedContent?key=#{@key}"].join(":")
59
- req = Net::HTTP::Post.new(path, headers)
59
+ req = LLM::Transport::Request.post(path, headers)
60
60
  req.body = LLM.json.dump({content: {parts: [{text: input}]}})
61
61
  res, span, tracer = execute(request: req, operation: "embeddings", model:)
62
62
  res = ResponseAdapter.adapt(res, type: :embedding)
@@ -205,7 +205,7 @@ module LLM
205
205
  action = stream ? "streamGenerateContent?key=#{@key}&alt=sse" : "generateContent?key=#{@key}"
206
206
  model.respond_to?(:id) ? model.id : model
207
207
  path = ["/v1beta/models/#{model}", action].join(":")
208
- req = Net::HTTP::Post.new(path, headers)
208
+ req = LLM::Transport::Request.post(path, headers)
209
209
  messages = build_complete_messages(prompt, params, role)
210
210
  body = LLM.json.dump({contents: adapt(messages)}.merge!(params))
211
211
  transport.set_body_stream(req, StringIO.new(body))
@@ -49,6 +49,8 @@ class LLM::Ollama
49
49
  LLM::UnauthorizedError.new("Authentication error").tap { _1.response = res }
50
50
  elsif res.rate_limited?
51
51
  LLM::RateLimitError.new("Too many requests").tap { _1.response = res }
52
+ elsif res.not_found?
53
+ LLM::NotFoundError.new("Server response: not found (404)").tap { _1.response = res }
52
54
  else
53
55
  LLM::Error.new("Unexpected response").tap { _1.response = res }
54
56
  end
@@ -40,7 +40,7 @@ class LLM::Ollama
40
40
  # @return [LLM::Response]
41
41
  def all(**params)
42
42
  query = URI.encode_www_form(params)
43
- req = Net::HTTP::Get.new("/api/tags?#{query}", headers)
43
+ req = LLM::Transport::Request.get("/api/tags?#{query}", headers)
44
44
  res, span, tracer = execute(request: req, operation: "request")
45
45
  res = ResponseAdapter.adapt(res, type: :models)
46
46
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -48,7 +48,7 @@ module LLM
48
48
  # @return [LLM::Response]
49
49
  def embed(input, model: default_model, **params)
50
50
  params = {model:}.merge!(params)
51
- req = Net::HTTP::Post.new("/v1/embeddings", headers)
51
+ req = LLM::Transport::Request.post("/v1/embeddings", headers)
52
52
  req.body = LLM.json.dump({input:}.merge!(params))
53
53
  res, span, tracer = execute(request: req, operation: "embeddings", model:)
54
54
  res = ResponseAdapter.adapt(res, type: :embedding)
@@ -129,7 +129,7 @@ module LLM
129
129
  def build_complete_request(prompt, params, role)
130
130
  messages = build_complete_messages(prompt, params, role)
131
131
  body = LLM.json.dump({messages: [adapt(messages)].flatten}.merge!(params))
132
- req = Net::HTTP::Post.new("/api/chat", headers)
132
+ req = LLM::Transport::Request.post("/api/chat", headers)
133
133
  transport.set_body_stream(req, StringIO.new(body))
134
134
  req
135
135
  end
@@ -32,7 +32,7 @@ class LLM::OpenAI
32
32
  # @raise (see LLM::Provider#request)
33
33
  # @return [LLM::Response]
34
34
  def create_speech(input:, voice: "alloy", model: "gpt-4o-mini-tts", response_format: "mp3", **params)
35
- req = Net::HTTP::Post.new(path("/audio/speech"), headers)
35
+ req = LLM::Transport::Request.post(path("/audio/speech"), headers)
36
36
  req.body = LLM.json.dump({input:, voice:, model:, response_format:}.merge!(params))
37
37
  io = StringIO.new("".b)
38
38
  res, span, tracer = execute(request: req, operation: "request") { _1.read_body { |chunk| io << chunk } }
@@ -55,7 +55,7 @@ class LLM::OpenAI
55
55
  # @return [LLM::Response]
56
56
  def create_transcription(file:, model: "whisper-1", **params)
57
57
  multi = LLM::Multipart.new(params.merge!(file: LLM.File(file), model:))
58
- req = Net::HTTP::Post.new(path("/audio/transcriptions"), headers)
58
+ req = LLM::Transport::Request.post(path("/audio/transcriptions"), headers)
59
59
  req["content-type"] = multi.content_type
60
60
  transport.set_body_stream(req, multi.body)
61
61
  res, span, tracer = execute(request: req, operation: "request")
@@ -79,7 +79,7 @@ class LLM::OpenAI
79
79
  # @return [LLM::Response]
80
80
  def create_translation(file:, model: "whisper-1", **params)
81
81
  multi = LLM::Multipart.new(params.merge!(file: LLM.File(file), model:))
82
- req = Net::HTTP::Post.new(path("/audio/translations"), headers)
82
+ req = LLM::Transport::Request.post(path("/audio/translations"), headers)
83
83
  req["content-type"] = multi.content_type
84
84
  transport.set_body_stream(req, multi.body)
85
85
  res, span, tracer = execute(request: req, operation: "request")
@@ -55,6 +55,8 @@ class LLM::OpenAI
55
55
  LLM::UnauthorizedError.new("Authentication error").tap { _1.response = res }
56
56
  elsif res.rate_limited?
57
57
  LLM::RateLimitError.new("Too many requests").tap { _1.response = res }
58
+ elsif res.not_found?
59
+ LLM::NotFoundError.new("Server response: not found (404)").tap { _1.response = res }
58
60
  else
59
61
  error = body["error"] || {}
60
62
  case error["type"]
@@ -40,7 +40,7 @@ class LLM::OpenAI
40
40
  # @return [LLM::Response]
41
41
  def all(**params)
42
42
  query = URI.encode_www_form(params)
43
- req = Net::HTTP::Get.new(path("/files?#{query}"), headers)
43
+ req = LLM::Transport::Request.get(path("/files?#{query}"), headers)
44
44
  res, span, tracer = execute(request: req, operation: "request")
45
45
  res = ResponseAdapter.adapt(res, type: :enumerable)
46
46
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -60,7 +60,7 @@ class LLM::OpenAI
60
60
  # @return [LLM::Response]
61
61
  def create(file:, purpose: "assistants", **params)
62
62
  multi = LLM::Multipart.new(params.merge!(file: LLM.File(file), purpose:))
63
- req = Net::HTTP::Post.new(path("/files"), headers)
63
+ req = LLM::Transport::Request.post(path("/files"), headers)
64
64
  req["content-type"] = multi.content_type
65
65
  transport.set_body_stream(req, multi.body)
66
66
  res, span, tracer = execute(request: req, operation: "request")
@@ -83,7 +83,7 @@ class LLM::OpenAI
83
83
  def get(file:, **params)
84
84
  file_id = file.respond_to?(:id) ? file.id : file
85
85
  query = URI.encode_www_form(params)
86
- req = Net::HTTP::Get.new(path("/files/#{file_id}?#{query}"), headers)
86
+ req = LLM::Transport::Request.get(path("/files/#{file_id}?#{query}"), headers)
87
87
  res, span, tracer = execute(request: req, operation: "request")
88
88
  res = ResponseAdapter.adapt(res, type: :file)
89
89
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -105,7 +105,7 @@ class LLM::OpenAI
105
105
  def download(file:, **params)
106
106
  query = URI.encode_www_form(params)
107
107
  file_id = file.respond_to?(:id) ? file.id : file
108
- req = Net::HTTP::Get.new(path("/files/#{file_id}/content?#{query}"), headers)
108
+ req = LLM::Transport::Request.get(path("/files/#{file_id}/content?#{query}"), headers)
109
109
  io = StringIO.new("".b)
110
110
  res, span, tracer = execute(request: req, operation: "request") { |res| res.read_body { |chunk| io << chunk } }
111
111
  res = LLM::Response.new(res).tap { _1.define_singleton_method(:file) { io } }
@@ -125,7 +125,7 @@ class LLM::OpenAI
125
125
  # @return [LLM::Response]
126
126
  def delete(file:)
127
127
  file_id = file.respond_to?(:id) ? file.id : file
128
- req = Net::HTTP::Delete.new(path("/files/#{file_id}"), headers)
128
+ req = LLM::Transport::Request.delete(path("/files/#{file_id}"), headers)
129
129
  res, span, tracer = execute(request: req, operation: "request")
130
130
  res = LLM::Response.new(res)
131
131
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -50,7 +50,7 @@ class LLM::OpenAI
50
50
  # @raise (see LLM::Provider#request)
51
51
  # @return [LLM::Response]
52
52
  def create(prompt:, model: "dall-e-3", response_format: "b64_json", **params)
53
- req = Net::HTTP::Post.new(path("/images/generations"), headers)
53
+ req = LLM::Transport::Request.post(path("/images/generations"), headers)
54
54
  req.body = LLM.json.dump({prompt:, n: 1, model:, response_format:}.merge!(params))
55
55
  res, span, tracer = execute(request: req, operation: "request")
56
56
  res = ResponseAdapter.adapt(res, type: :image)
@@ -76,7 +76,7 @@ class LLM::OpenAI
76
76
  def create_variation(image:, model: "dall-e-2", response_format: "b64_json", **params)
77
77
  image = LLM.File(image)
78
78
  multi = LLM::Multipart.new(params.merge!(image:, model:, response_format:))
79
- req = Net::HTTP::Post.new(path("/images/variations"), headers)
79
+ req = LLM::Transport::Request.post(path("/images/variations"), headers)
80
80
  req["content-type"] = multi.content_type
81
81
  transport.set_body_stream(req, multi.body)
82
82
  res, span, tracer = execute(request: req, operation: "request")
@@ -102,7 +102,7 @@ class LLM::OpenAI
102
102
  def edit(image:, prompt:, model: "dall-e-2", response_format: "b64_json", **params)
103
103
  image = LLM.File(image)
104
104
  multi = LLM::Multipart.new(params.merge!(image:, prompt:, model:, response_format:))
105
- req = Net::HTTP::Post.new(path("/images/edits"), headers)
105
+ req = LLM::Transport::Request.post(path("/images/edits"), headers)
106
106
  req["content-type"] = multi.content_type
107
107
  transport.set_body_stream(req, multi.body)
108
108
  res, span, tracer = execute(request: req, operation: "request")
@@ -39,7 +39,7 @@ class LLM::OpenAI
39
39
  # @return [LLM::Response]
40
40
  def all(**params)
41
41
  query = URI.encode_www_form(params)
42
- req = Net::HTTP::Get.new(path("/models?#{query}"), headers)
42
+ req = LLM::Transport::Request.get(path("/models?#{query}"), headers)
43
43
  res, span, tracer = execute(request: req, operation: "request")
44
44
  res = ResponseAdapter.adapt(res, type: :models)
45
45
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -47,7 +47,7 @@ class LLM::OpenAI
47
47
  # @param [String, LLM::Model] model The model to use
48
48
  # @return [LLM::Response]
49
49
  def create(input:, model: "omni-moderation-latest", **params)
50
- req = Net::HTTP::Post.new(path("/moderations"), headers)
50
+ req = LLM::Transport::Request.post(path("/moderations"), headers)
51
51
  input = RequestAdapter::Moderation.new(input).adapt
52
52
  req.body = LLM.json.dump({input:, model:}.merge!(params))
53
53
  res, span, tracer = execute(request: req, operation: "request")
@@ -40,7 +40,7 @@ class LLM::OpenAI
40
40
  params = [params, adapt_schema(params), adapt_tools(tools)].inject({}, &:merge!).compact
41
41
  role, stream = params.delete(:role), params.delete(:stream)
42
42
  params[:stream] = true if @provider.streamable?(stream) || stream == true
43
- req = Net::HTTP::Post.new(path("/responses"), headers)
43
+ req = LLM::Transport::Request.post(path("/responses"), headers)
44
44
  messages = build_complete_messages(prompt, params, role)
45
45
  @provider.tracer.set_request_metadata(user_input: extract_user_input(messages, fallback: prompt))
46
46
  body = LLM.json.dump({input: [adapt(messages, mode: :response)].flatten}.merge!(params))
@@ -61,7 +61,7 @@ class LLM::OpenAI
61
61
  def get(response, **params)
62
62
  response_id = response.respond_to?(:id) ? response.id : response
63
63
  query = URI.encode_www_form(params)
64
- req = Net::HTTP::Get.new(path("/responses/#{response_id}?#{query}"), headers)
64
+ req = LLM::Transport::Request.get(path("/responses/#{response_id}?#{query}"), headers)
65
65
  res, span, tracer = execute(request: req, operation: "request")
66
66
  res = ResponseAdapter.adapt(res, type: :responds)
67
67
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -76,7 +76,7 @@ class LLM::OpenAI
76
76
  # @return [LLM::Object] Response body
77
77
  def delete(response)
78
78
  response_id = response.respond_to?(:id) ? response.id : response
79
- req = Net::HTTP::Delete.new(path("/responses/#{response_id}"), headers)
79
+ req = LLM::Transport::Request.delete(path("/responses/#{response_id}"), headers)
80
80
  res, span, tracer = execute(request: req, operation: "request")
81
81
  res = LLM::Response.new(res)
82
82
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -31,7 +31,7 @@ class LLM::OpenAI
31
31
  # @return [LLM::Response]
32
32
  def all(**params)
33
33
  query = URI.encode_www_form(params)
34
- req = Net::HTTP::Get.new(path("/vector_stores?#{query}"), headers)
34
+ req = LLM::Transport::Request.get(path("/vector_stores?#{query}"), headers)
35
35
  res, span, tracer = execute(request: req, operation: "request")
36
36
  res = ResponseAdapter.adapt(res, type: :enumerable)
37
37
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -47,7 +47,7 @@ class LLM::OpenAI
47
47
  # @return [LLM::Response]
48
48
  # @see https://platform.openai.com/docs/api-reference/vector_stores/create OpenAI docs
49
49
  def create(name:, file_ids: nil, **params)
50
- req = Net::HTTP::Post.new(path("/vector_stores"), headers)
50
+ req = LLM::Transport::Request.post(path("/vector_stores"), headers)
51
51
  req.body = LLM.json.dump(params.merge({name:, file_ids:}).compact)
52
52
  res, span, tracer = execute(request: req, operation: "request")
53
53
  res = LLM::Response.new(res)
@@ -72,7 +72,7 @@ class LLM::OpenAI
72
72
  # @see https://platform.openai.com/docs/api-reference/vector_stores/retrieve OpenAI docs
73
73
  def get(vector:)
74
74
  vector_id = vector.respond_to?(:id) ? vector.id : vector
75
- req = Net::HTTP::Get.new(path("/vector_stores/#{vector_id}"), headers)
75
+ req = LLM::Transport::Request.get(path("/vector_stores/#{vector_id}"), headers)
76
76
  res, span, tracer = execute(request: req, operation: "request")
77
77
  res = LLM::Response.new(res)
78
78
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -89,7 +89,7 @@ class LLM::OpenAI
89
89
  # @see https://platform.openai.com/docs/api-reference/vector_stores/modify OpenAI docs
90
90
  def modify(vector:, name: nil, **params)
91
91
  vector_id = vector.respond_to?(:id) ? vector.id : vector
92
- req = Net::HTTP::Post.new(path("/vector_stores/#{vector_id}"), headers)
92
+ req = LLM::Transport::Request.post(path("/vector_stores/#{vector_id}"), headers)
93
93
  req.body = LLM.json.dump(params.merge({name:}).compact)
94
94
  res, span, tracer = execute(request: req, operation: "request")
95
95
  res = LLM::Response.new(res)
@@ -105,7 +105,7 @@ class LLM::OpenAI
105
105
  # @see https://platform.openai.com/docs/api-reference/vector_stores/delete OpenAI docs
106
106
  def delete(vector:)
107
107
  vector_id = vector.respond_to?(:id) ? vector.id : vector
108
- req = Net::HTTP::Delete.new(path("/vector_stores/#{vector_id}"), headers)
108
+ req = LLM::Transport::Request.delete(path("/vector_stores/#{vector_id}"), headers)
109
109
  res, span, tracer = execute(request: req, operation: "request")
110
110
  res = LLM::Response.new(res)
111
111
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -122,7 +122,7 @@ class LLM::OpenAI
122
122
  # @see https://platform.openai.com/docs/api-reference/vector_stores/search OpenAI docs
123
123
  def search(vector:, query:, **params)
124
124
  vector_id = vector.respond_to?(:id) ? vector.id : vector
125
- req = Net::HTTP::Post.new(path("/vector_stores/#{vector_id}/search"), headers)
125
+ req = LLM::Transport::Request.post(path("/vector_stores/#{vector_id}/search"), headers)
126
126
  req.body = LLM.json.dump(params.merge({query:}).compact)
127
127
  res, span, tracer = execute(request: req, operation: "retrieval")
128
128
  res = ResponseAdapter.adapt(res, type: :enumerable)
@@ -140,7 +140,7 @@ class LLM::OpenAI
140
140
  def all_files(vector:, **params)
141
141
  vector_id = vector.respond_to?(:id) ? vector.id : vector
142
142
  query = URI.encode_www_form(params)
143
- req = Net::HTTP::Get.new(path("/vector_stores/#{vector_id}/files?#{query}"), headers)
143
+ req = LLM::Transport::Request.get(path("/vector_stores/#{vector_id}/files?#{query}"), headers)
144
144
  res, span, tracer = execute(request: req, operation: "request")
145
145
  res = ResponseAdapter.adapt(res, type: :enumerable)
146
146
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -159,7 +159,7 @@ class LLM::OpenAI
159
159
  def add_file(vector:, file:, attributes: nil, **params)
160
160
  vector_id = vector.respond_to?(:id) ? vector.id : vector
161
161
  file_id = file.respond_to?(:id) ? file.id : file
162
- req = Net::HTTP::Post.new(path("/vector_stores/#{vector_id}/files"), headers)
162
+ req = LLM::Transport::Request.post(path("/vector_stores/#{vector_id}/files"), headers)
163
163
  req.body = LLM.json.dump(params.merge({file_id:, attributes:}).compact)
164
164
  res, span, tracer = execute(request: req, operation: "request")
165
165
  res = LLM::Response.new(res)
@@ -190,7 +190,7 @@ class LLM::OpenAI
190
190
  def update_file(vector:, file:, attributes:, **params)
191
191
  vector_id = vector.respond_to?(:id) ? vector.id : vector
192
192
  file_id = file.respond_to?(:id) ? file.id : file
193
- req = Net::HTTP::Post.new(path("/vector_stores/#{vector_id}/files/#{file_id}"), headers)
193
+ req = LLM::Transport::Request.post(path("/vector_stores/#{vector_id}/files/#{file_id}"), headers)
194
194
  req.body = LLM.json.dump(params.merge({attributes:}).compact)
195
195
  res, span, tracer = execute(request: req, operation: "request")
196
196
  res = LLM::Response.new(res)
@@ -209,7 +209,7 @@ class LLM::OpenAI
209
209
  vector_id = vector.respond_to?(:id) ? vector.id : vector
210
210
  file_id = file.respond_to?(:id) ? file.id : file
211
211
  query = URI.encode_www_form(params)
212
- req = Net::HTTP::Get.new(path("/vector_stores/#{vector_id}/files/#{file_id}?#{query}"), headers)
212
+ req = LLM::Transport::Request.get(path("/vector_stores/#{vector_id}/files/#{file_id}?#{query}"), headers)
213
213
  res, span, tracer = execute(request: req, operation: "request")
214
214
  res = LLM::Response.new(res)
215
215
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -226,7 +226,7 @@ class LLM::OpenAI
226
226
  def delete_file(vector:, file:)
227
227
  vector_id = vector.respond_to?(:id) ? vector.id : vector
228
228
  file_id = file.respond_to?(:id) ? file.id : file
229
- req = Net::HTTP::Delete.new(path("/vector_stores/#{vector_id}/files/#{file_id}"), headers)
229
+ req = LLM::Transport::Request.delete(path("/vector_stores/#{vector_id}/files/#{file_id}"), headers)
230
230
  res, span, tracer = execute(request: req, operation: "request")
231
231
  res = LLM::Response.new(res)
232
232
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -52,7 +52,7 @@ module LLM
52
52
  # @raise (see LLM::Provider#request)
53
53
  # @return (see LLM::Provider#embed)
54
54
  def embed(input, model: "text-embedding-3-small", **params)
55
- req = Net::HTTP::Post.new(path("/embeddings"), headers)
55
+ req = LLM::Transport::Request.post(path("/embeddings"), headers)
56
56
  req.body = LLM.json.dump({input:, model:}.merge!(params))
57
57
  res, span, tracer = execute(request: req, operation: "embeddings", model:)
58
58
  res = ResponseAdapter.adapt(res, type: :embedding)
@@ -222,7 +222,7 @@ module LLM
222
222
  def build_complete_request(prompt, params, role)
223
223
  messages = build_complete_messages(prompt, params, role)
224
224
  body = LLM.json.dump({messages: adapt(messages, mode: :complete).flatten}.merge!(params))
225
- req = Net::HTTP::Post.new(completions_path, headers)
225
+ req = LLM::Transport::Request.post(completions_path, headers)
226
226
  transport.set_body_stream(req, StringIO.new(body))
227
227
  [req, messages]
228
228
  end
data/lib/llm/skill.rb CHANGED
@@ -159,7 +159,7 @@ module LLM
159
159
  params[:concurrency] = concurrency if concurrency
160
160
  agent = Class.new(LLM::Agent) do
161
161
  instructions(instructions)
162
- tools(inherit_tools ? ctx.params[:tools] : tools)
162
+ tools(inherit_tools ? [*ctx.params[:tools]].reject(&:skill?) : tools)
163
163
  tracer(tracer)
164
164
  end.new(ctx.llm, params)
165
165
  agent.messages.concat(messages_for(ctx))
data/lib/llm/tool.rb CHANGED
@@ -202,6 +202,13 @@ class LLM::Tool
202
202
  false
203
203
  end
204
204
 
205
+ ##
206
+ # Returns true if the tool is a skill
207
+ # @return [Boolean]
208
+ def self.skill?
209
+ false
210
+ end
211
+
205
212
  ##
206
213
  # Returns a function bound to this tool instance.
207
214
  # @return [LLM::Function]
@@ -216,6 +223,20 @@ class LLM::Tool
216
223
  self.class.mcp?
217
224
  end
218
225
 
226
+ ##
227
+ # Returns true if the tool is an A2A tool
228
+ # @return [Boolean]
229
+ def a2a?
230
+ self.class.a2a?
231
+ end
232
+
233
+ ##
234
+ # Returns true if the tool is a skill
235
+ # @return [Boolean]
236
+ def skill?
237
+ self.class.skill?
238
+ end
239
+
219
240
  ##
220
241
  # Called when an in-flight tool run is interrupted.
221
242
  # Tools can override this to implement cooperative cleanup.
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Transport
4
+ ##
5
+ # The {LLM::Transport::Curb LLM::Transport::Curb} transport is an
6
+ # optional adapter for libcurl via the
7
+ # [curb](https://github.com/taf2/curb) gem.
8
+ #
9
+ # Curb is a C extension around libcurl. It releases the GVL during
10
+ # I/O so other Ruby threads can run while requests are in flight. Its
11
+ # timeout handling is built into libcurl itself — no thread-based
12
+ # timeout library required. It supports HTTP/2, connection reuse, and
13
+ # a wider range of network protocols out of the box.
14
+ #
15
+ # Unlike the built-in Net::HTTP transports, this transport does not
16
+ # require any Ruby standard library HTTP client and can be used on
17
+ # platforms where Net::HTTP is not available or desired.
18
+ #
19
+ # @example
20
+ # LLM.openai(key: ENV["KEY"], transport: :curb)
21
+ #
22
+ # @api private
23
+ class Curb < self
24
+ INTERRUPT_ERRORS = [::IOError, ::EOFError, Errno::EBADF].freeze
25
+ ActiveRequest = Struct.new(:easy, keyword_init: true)
26
+
27
+ ##
28
+ # @param [String] host
29
+ # @param [Integer] port
30
+ # @param [Integer] timeout
31
+ # @param [Boolean] ssl
32
+ # @return [LLM::Transport::Curb]
33
+ def initialize(host:, port:, timeout:, ssl:)
34
+ @host = host
35
+ @port = port
36
+ @timeout = timeout
37
+ @ssl = ssl
38
+ @base_uri = URI("#{ssl ? "https" : "http"}://#{host}:#{port}/")
39
+ @monitor = Monitor.new
40
+ end
41
+
42
+ ##
43
+ # Returns the current request owner.
44
+ # @return [Object]
45
+ def request_owner
46
+ return Fiber.current unless defined?(::Async)
47
+ Async::Task.current? ? Async::Task.current : Fiber.current
48
+ end
49
+
50
+ ##
51
+ # @return [Array<Class<Exception>>]
52
+ def interrupt_errors
53
+ [*INTERRUPT_ERRORS, *optional_interrupt_errors]
54
+ end
55
+
56
+ ##
57
+ # Interrupt an active request, if any.
58
+ #
59
+ # Sets the interrupt flag so the on_body callback can raise
60
+ # LLM::Interrupt on the next chunk.
61
+ #
62
+ # @param [Fiber] owner
63
+ # @return [nil]
64
+ def interrupt!(owner)
65
+ request_for(owner) or return
66
+ lock { (@interrupts ||= {})[owner] = true }
67
+ rescue *interrupt_errors
68
+ nil
69
+ end
70
+
71
+ ##
72
+ # Returns whether an execution owner was interrupted.
73
+ # @param [Fiber] owner
74
+ # @return [Boolean, nil]
75
+ def interrupted?(owner)
76
+ lock { @interrupts&.delete(owner) }
77
+ end
78
+
79
+ ##
80
+ # Performs a request through curb and returns a transport response
81
+ # wrapper so the provider layer can stay transport-agnostic.
82
+ #
83
+ # @param [LLM::Transport::Request] request
84
+ # @param [Fiber] owner
85
+ # @param [LLM::Object, nil] stream
86
+ # @yieldparam [LLM::Transport::Response] response
87
+ # @return [Object]
88
+ def request(request, owner:, stream: nil, &b)
89
+ easy = build_easy(request)
90
+ set_request(ActiveRequest.new(easy:), owner)
91
+ if stream
92
+ perform_streaming(easy, owner, stream)
93
+ elsif b
94
+ res = perform_blocking(easy, owner)
95
+ if LLM::Transport::Response === res
96
+ res.success? ? b.call(res) : res
97
+ else
98
+ res
99
+ end
100
+ else
101
+ perform_blocking(easy, owner)
102
+ end
103
+ ensure
104
+ clear_request(owner)
105
+ end
106
+
107
+ ##
108
+ # @return [String]
109
+ def inspect
110
+ "#<#{LLM::Utils.object_id(self)}>"
111
+ end
112
+
113
+ private
114
+
115
+ attr_reader :host, :port, :timeout, :ssl, :base_uri
116
+
117
+ def build_easy(request)
118
+ LLM.require "curb" unless defined?(::Curl)
119
+ easy = ::Curl::Easy.new(request_url(request))
120
+ easy.timeout = timeout
121
+ easy.connect_timeout = timeout
122
+ request.headers.each { |k, v| easy.headers[k] = v }
123
+ easy.follow_location = true
124
+ easy.ssl_verify_peer = false if !ssl
125
+ set_body(easy, request)
126
+ easy
127
+ end
128
+
129
+ def request_url(request)
130
+ path = request.path
131
+ return path if path.start_with?("http://", "https://")
132
+ scheme = ssl ? "https" : "http"
133
+ default_port = ssl ? 443 : 80
134
+ authority = port && port.to_i > 0 && port.to_i != default_port \
135
+ ? "#{host}:#{port}" : host
136
+ "#{scheme}://#{authority}#{path}"
137
+ end
138
+
139
+ def set_body(easy, request)
140
+ case request.method
141
+ when "POST"
142
+ easy.post_body = request.body if request.body
143
+ when "PUT"
144
+ easy.put_data = request.body if request.body
145
+ when "DELETE"
146
+ easy.delete = true
147
+ end
148
+ end
149
+
150
+ def perform_blocking(easy, owner)
151
+ check_interrupted(owner)
152
+ easy.on_body { |chunk|
153
+ check_interrupted(owner)
154
+ chunk.bytesize
155
+ }
156
+ easy.perform
157
+ build_response(easy)
158
+ end
159
+
160
+ def perform_streaming(easy, owner, stream)
161
+ res = nil
162
+ raw_body = +""
163
+ decoder = stream.decoder.new(stream.parser.new(stream.streamer))
164
+ easy.on_body do |chunk|
165
+ raise LLM::Interrupt, "request interrupted" if interrupted?(owner)
166
+ if (res ||= build_response_from_headers(easy))&.success? \
167
+ && res["content-type"].to_s.include?("text/event-stream")
168
+ decoder << chunk
169
+ else
170
+ raw_body << chunk
171
+ end
172
+ chunk.bytesize
173
+ end
174
+ easy.perform
175
+ res ||= build_response(easy)
176
+ if raw_body.empty?
177
+ body = decoder.body
178
+ res.body = (Hash === body || Array === body) \
179
+ ? LLM::Object.from(body) : body
180
+ else
181
+ res.body = raw_body
182
+ end
183
+ res
184
+ ensure
185
+ decoder&.free
186
+ end
187
+
188
+ def build_response(easy)
189
+ LLM::Transport::Response::Curb.new(
190
+ easy.response_code.to_i,
191
+ parse_headers(easy.header_str.to_s),
192
+ easy.body_str.to_s
193
+ )
194
+ end
195
+
196
+ def build_response_from_headers(easy)
197
+ return nil if easy.header_str.to_s.empty?
198
+ LLM::Transport::Response::Curb.new(
199
+ easy.response_code.to_i,
200
+ parse_headers(easy.header_str.to_s),
201
+ +""
202
+ )
203
+ end
204
+
205
+ def parse_headers(header_str)
206
+ headers = {}
207
+ header_str.each_line do |line|
208
+ line = line.strip
209
+ next if line.empty? || line.start_with?("HTTP/")
210
+ key, value = line.split(/: \s*/, 2)
211
+ headers[key.downcase] = value if key && value
212
+ end
213
+ headers
214
+ end
215
+
216
+ def check_interrupted(owner)
217
+ raise LLM::Interrupt, "request interrupted" if interrupted?(owner)
218
+ end
219
+
220
+ def request_for(owner)
221
+ lock do
222
+ @requests ||= {}
223
+ @requests[owner]
224
+ end
225
+ end
226
+
227
+ def set_request(req, owner)
228
+ lock do
229
+ @requests ||= {}
230
+ @requests[owner] = req
231
+ end
232
+ end
233
+
234
+ def clear_request(owner)
235
+ lock { @requests&.delete(owner) }
236
+ end
237
+
238
+ def lock(&)
239
+ @monitor.synchronize(&)
240
+ end
241
+
242
+ def optional_interrupt_errors
243
+ defined?(::Async::Stop) ? [Async::Stop] : []
244
+ end
245
+ end
246
+ end
@@ -13,7 +13,7 @@ class LLM::Transport
13
13
 
14
14
  ##
15
15
  # Executes a HTTP request
16
- # @param [Net::HTTPRequest] request
16
+ # @param [LLM::Transport::Request] request
17
17
  # The request to send
18
18
  # @param [Proc] b
19
19
  # A block to yield the response to (optional)