llm.rb 15.2.2 → 15.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '01569fad6afcf4d422b666778989e7bafd1c6e994b6e2be7801ccb5842a7aa3d'
4
- data.tar.gz: f60ddf02e1a8405cf393e8c581cce61415f459d3207a7c8dc531cbe4e0e87f27
3
+ metadata.gz: a461691c878ffef8343a8f1ae2dad4298ebb631d379d901110ef8ab8641d9c68
4
+ data.tar.gz: 95bb85e3debb178ebbe6949cd3cafbb3625d6f20a72d617f08f1a600354911ba
5
5
  SHA512:
6
- metadata.gz: 8d9f88421f209e72e1631cbe3e4119d80af29aa72dc44a1917a6dfcbaa76b3d8014d70afe1c771177f6a755ea2d840ffaf92a850606fdd9d08db5c0d0283d0d6
7
- data.tar.gz: b9d2417b3d2ce4f54ae5c657791518261332508b7dcfe726f19f81d97a27f329220a81ea2ced411fd15625b11c3964c69e1bcfef5df799f9ee0d16f2342cdd56
6
+ metadata.gz: c9b6e723d1bc16d9cc515a9ae2a3fd34072ce88024804d5b7b0e713cff0b08c26e78e2008641763bbc9a3aabea6f262023fa1f243d9fe155f8d8fdf58215d24f
7
+ data.tar.gz: e125d9936d34b3550fb504d281760b73bc617aa4b5d42f1f4d1e3b9b3edd98a448c4a7509d010c62b67b1f57def5f3a9f1e088786b860e6d8f08c94bf64aa0f8
data/CHANGELOG.md CHANGED
@@ -17,6 +17,82 @@
17
17
 
18
18
  *No unreleased changes yet. Check back after the next release.*
19
19
 
20
+ ## v15.3.0
21
+
22
+ Changes since `v15.2.2`.
23
+
24
+ This release gives contexts, agents, and messages UUIDv7 ids that survive
25
+ persistence and back their `created_at`, scopes per-request headers through a
26
+ block on `LLM::Provider#with`, and pins an OpenRouter session per context. It
27
+ also fixes the OpenAI tool schema sent through OpenRouter and Azure, which
28
+ mixed the Responses and Chat Completions shapes and left nested objects open.
29
+
30
+ ### Core
31
+
32
+ * **context: give every context an id you can sort and trace** <br>
33
+ [`LLM::Context#id`](https://r.uby.dev/api-docs/llm.rb/LLM/Context.html#id-instance_method)
34
+ returns a UUIDv7 string that encodes its creation time, so contexts and their
35
+ agents sort by creation order and stay identifiable within and across
36
+ sessions. The id is generated on creation and restored with the runtime state
37
+ on load. [`LLM::Agent#id`](https://r.uby.dev/api-docs/llm.rb/LLM/Agent.html#id-instance_method)
38
+ delegates to the context it wraps; a model and its agent keep separate ids.
39
+
40
+ * **context: read the creation time without storing it** <br>
41
+ [`LLM::Context#created_at`](https://r.uby.dev/api-docs/llm.rb/LLM/Context.html#created_at-instance_method)
42
+ returns when the context was created, read from the timestamp its UUIDv7 id
43
+ already carries, so no separate timestamp has to be persisted or kept in sync.
44
+ `LLM::Agent#created_at` delegates to it. The time is recomputed rather than
45
+ stored, so it returns `nil` for a context whose id is not a UUIDv7 string.
46
+
47
+ * **message: give every message an id that survives persistence** <br>
48
+ [`LLM::Message#id`](https://r.uby.dev/api-docs/llm.rb/LLM/Message.html#id-instance_method)
49
+ returns a UUIDv7 string generated once when the message is created and restored
50
+ with the runtime state on load, so a message keeps the same identity across
51
+ saves and loads. `LLM::Message#created_at` is derived from the id rather than
52
+ stored. `LLM::Message#==` ignores the id, so two messages with the same role
53
+ and content are equal even when they were created at different times;
54
+ previously the timestamp took part in the comparison.
55
+
56
+ * **utils: decode a UUIDv7 timestamp once, in one place** <br>
57
+ [`LLM::Utils.timestamp`](https://r.uby.dev/api-docs/llm.rb/LLM/Utils.html#timestamp-instance_method)
58
+ returns the UTC time encoded in a UUIDv7 string, or `nil` when the value is not
59
+ a UUIDv7. It backs `created_at` on messages, contexts, and agents, so the
60
+ decoding logic lives in a single method instead of three.
61
+
62
+ ### Provider
63
+
64
+ * **provider: set a per-request header without leaking it** <br>
65
+ [`LLM::Provider#with`](https://r.uby.dev/api-docs/llm.rb/LLM/Provider.html#with-instance_method)
66
+ now takes a block: headers set inside apply to the current fiber only and are
67
+ restored when the block returns, so a header that varies per request can be set
68
+ for one call without leaking into the next. Without a block the headers are
69
+ merged into the provider's defaults as before. The method returns the provider
70
+ without a block and the block's value with one.
71
+
72
+ * **openrouter: keep one session so requests reuse a cached model** <br>
73
+ A context now sends its id as the `x-session-id` header on OpenRouter requests,
74
+ so consecutive requests share a session and OpenRouter can route them to the
75
+ same cached model instead of starting fresh each time. Other providers are
76
+ unaffected.
77
+
78
+ ### Fix
79
+
80
+ * **openai: make tools callable through OpenRouter and Azure again** <br>
81
+ Fix a bug where [`LLM::OpenAI`](https://r.uby.dev/api-docs/llm.rb/LLM/OpenAI.html)
82
+ sent a function schema that mixed the Responses and Chat Completions shapes,
83
+ carrying a top-level `name` next to a nested `function` object. OpenRouter and
84
+ OpenAI through Azure read it as a Responses function and could not describe the
85
+ tool correctly. The name, description, parameters, and `strict: false` now sit
86
+ under `function:`, the shape chat completions documents.
87
+
88
+ * **openai: stop Azure rejecting nested schema objects** <br>
89
+ Tool parameters and structured-output schemas now carry
90
+ `additionalProperties: false` on every object they contain, not just the root,
91
+ in the chat completions and Responses paths alike. OpenAI and Azure reject a
92
+ schema that leaves any object open, so nested objects were failing validation.
93
+ The chat completions structured-output schema, which was sent open, is now
94
+ closed too.
95
+
20
96
  ## v15.2.2
21
97
 
22
98
  Changes since `v15.2.1`.
data/lib/llm/agent.rb CHANGED
@@ -510,6 +510,22 @@ module LLM
510
510
  @ctx.messages
511
511
  end
512
512
 
513
+ ##
514
+ # Returns a stable id for this agent. The id is borrowed from
515
+ # the context it wraps, so it survives save and restore.
516
+ # @return [String]
517
+ def id
518
+ @ctx.id
519
+ end
520
+
521
+ ##
522
+ # Returns the time this agent was created. It is derived
523
+ # from the context id, so it is not persisted separately.
524
+ # @return [Time, nil]
525
+ def created_at
526
+ @ctx.created_at
527
+ end
528
+
513
529
  ##
514
530
  # @return [Integer]
515
531
  def retry_budget
@@ -25,6 +25,8 @@ class LLM::Context
25
25
  else
26
26
  LLM.json.load(string)
27
27
  end
28
+ @id = ctx["id"] || @id
29
+ @created_at = nil
28
30
  @messages.concat [*ctx["messages"]].map { deserialize_message(_1) }
29
31
  @compacted = !!ctx["compacted"]
30
32
  self
@@ -40,8 +42,8 @@ class LLM::Context
40
42
  usage = payload["usage"]
41
43
  reasoning_content = payload["reasoning_content"]
42
44
  compaction = payload["compaction"]
43
- created_at = payload["created_at"]
44
- extra = {tool_calls:, original_tool_calls:, tools: @params[:tools], usage:, reasoning_content:, compaction:, created_at:}.compact
45
+ id = payload["id"]
46
+ extra = {tool_calls:, original_tool_calls:, tools: @params[:tools], usage:, reasoning_content:, compaction:, id:}.compact
45
47
  content = returns.nil? ? deserialize_content(payload["content"]) : returns
46
48
  LLM::Message.new(payload["role"], content, extra)
47
49
  end
data/lib/llm/context.rb CHANGED
@@ -55,7 +55,7 @@ module LLM
55
55
  # @api private
56
56
  # @return [Array<Symbol>]
57
57
  def self.params
58
- %w[guard retry_budget concurrency transformer compactor record]
58
+ %w[guard retry_budget concurrency transformer compactor record id]
59
59
  end
60
60
 
61
61
  ##
@@ -78,6 +78,12 @@ module LLM
78
78
  # @return [Object, nil]
79
79
  attr_reader :record
80
80
 
81
+ ##
82
+ # Returns a stable id for this context. It is generated once
83
+ # on creation and restored with the runtime state on load.
84
+ # @return [String]
85
+ attr_reader :id
86
+
81
87
  ##
82
88
  # @param [LLM::Provider] llm
83
89
  # A provider
@@ -89,6 +95,8 @@ module LLM
89
95
  # Defaults to `:responses` for OpenAI, otherwise it defaults
90
96
  # to `:completions`.
91
97
  # @option params [String] :model Defaults to the provider's default model
98
+ # @option params [String] :id
99
+ # A stable id for the context. Defaults to a UUID.
92
100
  # @option params [Class<LLM::Compactor>, nil] :compactor
93
101
  # A compactor class to use for context compaction. Defaults to
94
102
  # {LLM::Compactor::Null}.
@@ -109,6 +117,7 @@ module LLM
109
117
  def initialize(llm, params = {})
110
118
  params = {}.merge!(params)
111
119
  @llm = llm
120
+ @id = params.delete(:id) || SecureRandom.uuid_v7
112
121
  @record = params.delete(:record)
113
122
  @mode = params.delete(:mode) || (llm.name == :openai ? :responses : :completions)
114
123
  tools = [*params.delete(:tools), *load_skills(params.delete(:skills))]
@@ -488,11 +497,20 @@ module LLM
488
497
  messages.find(&:assistant?)&.model || @params[:model] || @llm.default_model
489
498
  end
490
499
 
500
+ ##
501
+ # Returns the time this context was created, derived from the
502
+ # timestamp embedded in its UUIDv7 id, or nil when it is not.
503
+ # @return [Time, nil]
504
+ def created_at
505
+ @created_at ||= LLM::Utils.timestamp(@id)
506
+ end
507
+
491
508
  ##
492
509
  # @return [Hash]
493
510
  def to_h
494
511
  {
495
512
  schema_version: 1,
513
+ id: @id,
496
514
  model:,
497
515
  compacted:,
498
516
  messages: @messages.map { serialize_message(_1) }
@@ -597,33 +615,37 @@ module LLM
597
615
  # Executes a turn through the Responses API.
598
616
  # @api private
599
617
  def respond(prompt, params)
600
- history = @messages.to_a
601
- params = @params.merge(params).reject { self.class.params.include?(_1.to_s) }
602
- extra = params.slice(:model, :tools).merge!(ctx: self, tracer:, guard: @guard[:klass].new(self))
603
- params[:stream] = LLM::Stream.try(params[:stream], extra:)
604
- res_id = params[:store] == false ? nil : @messages.find(&:assistant?)&.response&.response_id
605
- input = res_id ? [] : history
606
- params[:input] = input
607
- messages = transform(prompt, params, key: :input)
608
- @stream = params[:stream]
609
- new_messages = messages[input.size..]
610
- params = params.merge(previous_response_id: res_id, input:).compact
611
- [new_messages, params, @llm.responses.create(messages, params)]
618
+ @llm.with(**headers) do
619
+ history = @messages.to_a
620
+ params = @params.merge(params).reject { self.class.params.include?(_1.to_s) }
621
+ extra = params.slice(:model, :tools).merge!(ctx: self, tracer:, guard: @guard[:klass].new(self))
622
+ params[:stream] = LLM::Stream.try(params[:stream], extra:)
623
+ res_id = params[:store] == false ? nil : @messages.find(&:assistant?)&.response&.response_id
624
+ input = res_id ? [] : history
625
+ params[:input] = input
626
+ messages = transform(prompt, params, key: :input)
627
+ @stream = params[:stream]
628
+ new_messages = messages[input.size..]
629
+ params = params.merge(previous_response_id: res_id, input:).compact
630
+ [new_messages, params, @llm.responses.create(messages, params)]
631
+ end
612
632
  end
613
633
 
614
634
  ##
615
635
  # Executes a turn through the chat completions API.
616
636
  # @api private
617
637
  def complete(prompt, params)
618
- history = @messages.to_a
619
- params = params.merge(messages: history)
620
- params = @params.merge(params).reject { self.class.params.include?(_1.to_s) }
621
- extra = params.slice(:model, :tools).merge!(ctx: self, tracer:, guard: @guard[:klass].new(self))
622
- params[:stream] = LLM::Stream.try(params[:stream], extra:)
623
- messages = transform(prompt, params)
624
- @stream = params[:stream]
625
- new_messages = messages[history.size..]
626
- [new_messages, params, @llm.complete(messages, params)]
638
+ @llm.with(**headers) do
639
+ history = @messages.to_a
640
+ params = params.merge(messages: history)
641
+ params = @params.merge(params).reject { self.class.params.include?(_1.to_s) }
642
+ extra = params.slice(:model, :tools).merge!(ctx: self, tracer:, guard: @guard[:klass].new(self))
643
+ params[:stream] = LLM::Stream.try(params[:stream], extra:)
644
+ messages = transform(prompt, params)
645
+ @stream = params[:stream]
646
+ new_messages = messages[history.size..]
647
+ [new_messages, params, @llm.complete(messages, params)]
648
+ end
627
649
  end
628
650
 
629
651
  ##
@@ -652,5 +674,15 @@ module LLM
652
674
  end
653
675
  messages << LLM::Message.new(@llm.tool_role, cancelled) unless cancelled.empty?
654
676
  end
677
+
678
+ ##
679
+ # @return [Hash]
680
+ def headers
681
+ if @llm.name == :openrouter
682
+ {"x-session-id" => @id}
683
+ else
684
+ {}
685
+ end
686
+ end
655
687
  end
656
688
  end
data/lib/llm/message.rb CHANGED
@@ -18,9 +18,10 @@ module LLM
18
18
  attr_reader :extra
19
19
 
20
20
  ##
21
- # Returns the time the message was created
22
- # @return [Time]
23
- attr_reader :created_at
21
+ # Returns a stable id for this message. It is generated once
22
+ # on creation and restored with the runtime state on load.
23
+ # @return [String]
24
+ attr_reader :id
24
25
 
25
26
  ##
26
27
  # Returns a new message
@@ -32,7 +33,15 @@ module LLM
32
33
  @role = role.to_s
33
34
  @content = content
34
35
  @extra = LLM::Object.from(extra)
35
- @created_at = extra[:created_at] ? Time.iso8601(extra[:created_at].to_s) : Time.now.utc
36
+ @id = extra[:id] || SecureRandom.uuid_v7
37
+ end
38
+
39
+ ##
40
+ # Returns the time this message was created, derived from the
41
+ # timestamp embedded in its UUIDv7 id, or nil when it is not.
42
+ # @return [Time, nil]
43
+ def created_at
44
+ @created_at ||= LLM::Utils.timestamp(@id)
36
45
  end
37
46
 
38
47
  ##
@@ -40,10 +49,10 @@ module LLM
40
49
  # @return [Hash]
41
50
  def to_h
42
51
  {
52
+ id: @id,
43
53
  role:,
44
54
  content:,
45
55
  reasoning_content:,
46
- created_at: created_at.utc.iso8601,
47
56
  compaction: extra.compaction,
48
57
  tools: extra.tool_calls&.map { LLM::Object === _1 ? _1.to_h : _1 },
49
58
  usage:,
@@ -58,13 +67,15 @@ module LLM
58
67
  end
59
68
 
60
69
  ##
61
- # Returns true when two objects have the same role and content
70
+ # Returns true when two objects have the same role and content.
71
+ # The id is ignored, because it identifies a message rather than
72
+ # describing it.
62
73
  # @param [Object] other
63
74
  # The other object to compare
64
75
  # @return [Boolean]
65
76
  def ==(other)
66
77
  if other.respond_to?(:to_h)
67
- to_h == other.to_h
78
+ to_h.except(:id) == other.to_h.except(:id)
68
79
  else
69
80
  false
70
81
  end
data/lib/llm/provider.rb CHANGED
@@ -263,11 +263,21 @@ class LLM::Provider
263
263
  end
264
264
 
265
265
  ##
266
- # Add one or more headers to all requests
267
- # @example
266
+ # Add one or more headers to all requests, or scope them to a block.
267
+ #
268
+ # Without a block the headers are merged into the provider's defaults
269
+ # permanently. With a block the headers apply only to the current fiber,
270
+ # for the duration of the block, then the previous headers are restored.
271
+ # This is useful for a header that varies per context, such as
272
+ # OpenRouter's `x-session-id`.
273
+ # @example Permanent
268
274
  # llm = LLM.openai(key: ENV["KEY"])
269
275
  # llm.with("OpenAI-Organization" => ENV["ORG"])
270
276
  # llm.with("OpenAI-Project" => ENV["PROJECT"])
277
+ # @example Scoped
278
+ # llm.with("x-session-id" => ctx.id) do
279
+ # llm.complete("hello")
280
+ # end
271
281
  # @param [Hash<String,String>] headers
272
282
  # One or more headers
273
283
  # @note
@@ -275,12 +285,26 @@ class LLM::Provider
275
285
  # provided via the `headers:` keyword argument,
276
286
  # or provided directly as a Hash without the
277
287
  # `headers:` key namespace.
288
+ # @yield
278
289
  # @return [LLM::Provider]
279
- # Returns self
280
- def with(**headers)
290
+ # Returns self without a block, the block's value with one
291
+ def with(**headers, &block)
281
292
  headers = headers.merge(headers.delete(:headers) || {})
282
- lock do
283
- tap { @headers.merge!(headers) }
293
+ if block
294
+ wm = weakmaps.header
295
+ previous = wm[self]
296
+ wm[self] = (previous || {}).merge(headers)
297
+ block.call
298
+ else
299
+ lock { tap { @headers.merge!(headers) } }
300
+ end
301
+ ensure
302
+ if block
303
+ if previous.nil?
304
+ wm.respond_to?(:delete) ? wm.delete(self) : wm[self] = nil
305
+ else
306
+ wm[self] = previous
307
+ end
284
308
  end
285
309
  end
286
310
 
@@ -349,7 +373,7 @@ class LLM::Provider
349
373
  # @return [LLM::Tracer]
350
374
  # Returns the current scoped tracer override or provider default tracer
351
375
  def tracer
352
- weakmap[self] || @tracer || LLM::Tracer::Null.new(self)
376
+ weakmaps.tracer[self] || @tracer || LLM::Tracer::Null.new(self)
353
377
  end
354
378
 
355
379
  ##
@@ -378,17 +402,18 @@ class LLM::Provider
378
402
  # @yield
379
403
  # @return [Object]
380
404
  def with_tracer(tracer)
381
- had_override = weakmap.key?(self)
382
- previous = weakmap[self]
383
- weakmap[self] = tracer || LLM::Tracer::Null.new(self)
405
+ wm = weakmaps.tracer
406
+ had_override = wm.key?(self)
407
+ previous = wm[self]
408
+ wm[self] = tracer || LLM::Tracer::Null.new(self)
384
409
  yield
385
410
  ensure
386
411
  if had_override
387
- weakmap[self] = previous
388
- elsif weakmap.respond_to?(:delete)
389
- weakmap.delete(self)
412
+ wm[self] = previous
413
+ elsif wm.respond_to?(:delete)
414
+ wm.delete(self)
390
415
  else
391
- weakmap[self] = nil
416
+ wm[self] = nil
392
417
  end
393
418
  end
394
419
 
@@ -504,8 +529,25 @@ class LLM::Provider
504
529
  end
505
530
 
506
531
  ##
532
+ # Returns the temporary headers set by {#with}
533
+ # for the current fiber.
507
534
  # @api private
508
- def weakmap
509
- thread[:"llm.provider.weakmap"] ||= ObjectSpace::WeakMap.new
535
+ # @return [Hash]
536
+ def temporary_headers
537
+ weakmaps.header[self] || {}
538
+ end
539
+
540
+ ##
541
+ # @return [LLM::Object]
542
+ def weakmaps
543
+ if thread["llm.#{name}.weakmaps"]
544
+ thread["llm.#{name}.weakmaps"]
545
+ else
546
+ weakmaps = LLM::Object.from(
547
+ tracer: ObjectSpace::WeakMap.new,
548
+ header: ObjectSpace::WeakMap.new
549
+ )
550
+ thread["llm.#{name}.weakmaps"] = weakmaps
551
+ end
510
552
  end
511
553
  end
@@ -141,7 +141,9 @@ module LLM
141
141
 
142
142
  def headers
143
143
  lock do
144
- (@headers || {}).merge(
144
+ (@headers || {})
145
+ .merge(temporary_headers)
146
+ .merge(
145
147
  "Content-Type" => "application/json",
146
148
  "x-api-key" => @key,
147
149
  "anthropic-version" => "2023-06-01",
@@ -183,7 +183,9 @@ module LLM
183
183
 
184
184
  def headers
185
185
  lock do
186
- (@headers || {}).merge("Content-Type" => "application/json")
186
+ (@headers || {})
187
+ .merge(temporary_headers)
188
+ .merge("Content-Type" => "application/json")
187
189
  end
188
190
  end
189
191
 
@@ -186,9 +186,9 @@ module LLM
186
186
 
187
187
  def headers
188
188
  lock do
189
- (@headers || {}).merge(
190
- "Content-Type" => "application/json"
191
- )
189
+ (@headers || {})
190
+ .merge(temporary_headers)
191
+ .merge("Content-Type" => "application/json")
192
192
  end
193
193
  end
194
194
 
@@ -136,7 +136,9 @@ module LLM
136
136
  # @api private
137
137
  def headers
138
138
  lock do
139
- (@headers || {}).merge(
139
+ (@headers || {})
140
+ .merge(temporary_headers)
141
+ .merge(
140
142
  "Authorization" => "Bearer #{@key}",
141
143
  "Content-Type" => "application/json"
142
144
  )
@@ -113,7 +113,9 @@ module LLM
113
113
 
114
114
  def headers
115
115
  lock do
116
- (@headers || {}).merge(
116
+ (@headers || {})
117
+ .merge(temporary_headers)
118
+ .merge(
117
119
  "Content-Type" => "application/json",
118
120
  "Authorization" => "Bearer #{@key}"
119
121
  )
@@ -36,7 +36,7 @@ class LLM::OpenAI
36
36
  {
37
37
  response_format: {
38
38
  type: "json_schema",
39
- json_schema: {name: "JSONSchema", schema:}
39
+ json_schema: {name: "JSONSchema", schema: LLM::Schema::Utils.close(schema)}
40
40
  }
41
41
  }
42
42
  end
@@ -98,7 +98,7 @@ class LLM::OpenAI
98
98
  def adapt_function(fn)
99
99
  {
100
100
  type: "function", name: fn.name, description: fn.description,
101
- parameters: fn.params.to_h.merge(additionalProperties: false), strict: false
101
+ parameters: LLM::Schema::Utils.close(fn.params), strict: false
102
102
  }.compact
103
103
  end
104
104
 
@@ -112,7 +112,7 @@ class LLM::OpenAI
112
112
  return {} unless params && params[:schema]
113
113
  schema = params.delete(:schema)
114
114
  schema = schema.respond_to?(:object) ? schema.object : schema
115
- schema = schema.to_h.merge(additionalProperties: false)
115
+ schema = LLM::Schema::Utils.close(schema)
116
116
  name = "JSONSchema"
117
117
  {text: {format: {type: "json_schema", name:, schema:}}}
118
118
  end
@@ -156,10 +156,10 @@ module LLM
156
156
  # @param [LLM::Function] fn
157
157
  # @return [Hash]
158
158
  def adapt_function(fn)
159
- params = fn.params.to_h
159
+ params = LLM::Schema::Utils.close(fn.params)
160
160
  {
161
- type: "function", name: fn.name,
162
- function: {name: fn.name, description: fn.description, parameters: params}
161
+ type: "function",
162
+ function: {name: fn.name, description: fn.description, parameters: params, strict: false}
163
163
  }.compact
164
164
  end
165
165
 
@@ -203,7 +203,9 @@ module LLM
203
203
 
204
204
  def headers
205
205
  lock do
206
- (@headers || {}).merge(
206
+ (@headers || {})
207
+ .merge(temporary_headers)
208
+ .merge(
207
209
  "Content-Type" => "application/json",
208
210
  "Authorization" => "Bearer #{@key}"
209
211
  )
data/lib/llm/schema.rb CHANGED
@@ -81,6 +81,30 @@ class LLM::Schema
81
81
  def fetch(properties, name)
82
82
  properties[name] || properties.fetch(name.to_s)
83
83
  end
84
+
85
+ ##
86
+ # Returns a copy of a JSON schema where every object is
87
+ # closed with "additionalProperties": false. OpenAI and
88
+ # Azure reject tool parameters that leave an object open,
89
+ # and they validate nested objects as well as the root.
90
+ # @param [Object] node
91
+ # A schema, a Hash of properties, or an Array of either
92
+ # @return [Object]
93
+ def close(node)
94
+ case node
95
+ when LLM::Schema::Leaf
96
+ close(node.to_h)
97
+ when LLM::Object
98
+ close(node.to_h)
99
+ when Hash
100
+ node.to_h { |key, value| [key, close(value)] }
101
+ .then { _1[:type] == "object" ? _1.merge(additionalProperties: false) : _1 }
102
+ when Array
103
+ node.map { close(_1) }
104
+ else
105
+ node
106
+ end
107
+ end
84
108
  end
85
109
 
86
110
  ##
data/lib/llm/utils.rb CHANGED
@@ -48,6 +48,19 @@ module LLM
48
48
  (defined?(::Sequel::Model) and ::Sequel::Model === obj)
49
49
  end
50
50
 
51
+ ##
52
+ # Returns the UTC time encoded in a UUIDv7, or nil when the
53
+ # given value is not a UUIDv7. The first 48 bits of a UUIDv7
54
+ # are a Unix millisecond timestamp.
55
+ # @param [Object] id
56
+ # @return [Time, nil]
57
+ def timestamp(id)
58
+ hex = id.to_s.delete("-")
59
+ return nil unless hex.match?(/\A\h{32}\z/)
60
+ return nil unless hex[12] == "7"
61
+ Time.at(hex[0, 12].to_i(16) / 1000.0).utc
62
+ end
63
+
51
64
  ##
52
65
  # Normalizes an HTTP API base path.
53
66
  #
data/lib/llm/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LLM
4
- VERSION = "15.2.2"
4
+ VERSION = "15.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm.rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 15.2.2
4
+ version: 15.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robert Gleeson