ruby_llm-codex 0.2.1 → 0.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: 60eb34388c3f961cfbfa0604dfc757b407b781108bf9b33c1280bbb07c4a76e4
4
- data.tar.gz: 5c4c9e0a38ced5c2bfd8d67cad4d135100bc55f4107479fcea3bb3e0cca37bfc
3
+ metadata.gz: b423cd9ecec786bde4858cfd86a5f2d010853925f341668a9a27ee46ce1e13c6
4
+ data.tar.gz: 1e9b7ee463ee62cd2a916979e36df16d3cd2270ea26d521b6f5bc8c16a85a11d
5
5
  SHA512:
6
- metadata.gz: b22b44093a68b3c5d9c9c73fe533ef304d1aa7aa2408ede897940b78fc2cf2e23953237de4107b3a95f56a3b25cc72a06b9a2d67d554e7c81a968b5854d3122a
7
- data.tar.gz: f6db93982d737c0769485f00c15c05ce48f182b394b594ea87bf2426add6e0f25eacca7e876ada5abdd8020a809ffe029193ee018bdc360929f7f2d6f310a173
6
+ metadata.gz: 5802a69971bc7798b5abe3317cc125db21f2bd7db1be86bf27cb4afaec3c4d57408d948d486268552a24d1726b3bddfc5f6b13ec24172d376b364d38e8b8883e
7
+ data.tar.gz: 877c1bb90fcf5e7c6a1f522a6adc163fcc6be5268e1baed14169dc40e0cd640f469a698a451b6e3e527198ef6cc098a1e0151ea86c9dacd4e4328bfe115932a6
data/CHANGELOG.md CHANGED
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.0] - 2026-10-12
9
+
10
+
11
+ ### feat
12
+
13
+ - migrate Codex provider to RubyLLM 2.1
14
+
15
+
16
+
8
17
  ## [0.2.1] - 2026-07-17
9
18
 
10
19
 
data/README.md CHANGED
@@ -19,8 +19,8 @@ supported mapping but not an identical wire-level request.
19
19
  ## Requirements
20
20
 
21
21
  - Ruby 3.3 or newer
22
- - RubyLLM 1.16.0 or newer, below 2.0
23
- - Codex CLI 0.144.5 or newer, available as `codex`
22
+ - RubyLLM 2.1.1 or newer, below 2.2
23
+ - Codex CLI 0.162.1 or newer, available as `codex`
24
24
  - Linux or macOS (timeout cleanup relies on POSIX process groups)
25
25
  - A completed ChatGPT login: `codex login`
26
26
 
@@ -34,6 +34,8 @@ codex login
34
34
  Check the model list in your installed Codex version before a large run. Model
35
35
  availability depends on the account and currently installed CLI.
36
36
 
37
+ For migration steps from RubyLLM 1.x, see [UPGRADE.md](UPGRADE.md).
38
+
37
39
  ## Installation
38
40
 
39
41
  Add the gem to your application's `Gemfile`:
@@ -55,7 +57,7 @@ Require the gem and use Codex as an explicit RubyLLM provider:
55
57
  ```ruby
56
58
  require "ruby_llm-codex"
57
59
 
58
- class SummarySchema < RubyLLM::Schema
60
+ class SummarySchema < Schematist::Schema
59
61
  string :summary
60
62
  end
61
63
 
@@ -65,11 +67,11 @@ response = RubyLLM.chat(model: ENV.fetch("CODEX_MODEL"), provider: :codex)
65
67
  .with_thinking(effort: :medium)
66
68
  .ask("The text to summarize")
67
69
 
68
- pp response.content
70
+ pp response.parsed
69
71
  ```
70
72
 
71
- `response.content` is normalized by RubyLLM into the same Hash shape returned
72
- for structured outputs from its other providers.
73
+ `response.content` contains the JSON string. Read `response.parsed` for the
74
+ structured Hash, and `response.tokens` for token counts.
73
75
 
74
76
  ## Isolation and reproducibility
75
77
 
@@ -97,7 +99,7 @@ saved session history from quietly affecting a request.
97
99
  To deliberately let Codex inspect a repository or use selected configuration:
98
100
 
99
101
  ```ruby
100
- chat.with_params(
102
+ chat.with_provider_options(
101
103
  codex: {
102
104
  working_directory: "/path/to/project",
103
105
  sandbox: "read-only",
@@ -126,8 +128,8 @@ RubyLLM.configure do |config|
126
128
  end
127
129
  ```
128
130
 
129
- Only the nested `codex` namespace is accepted by `with_params`; unsupported
130
- generic params raise an error instead of being silently ignored. Use
131
+ Only the nested `codex` namespace is accepted by `with_provider_options`; unsupported
132
+ generic provider options raise an error instead of being silently ignored. Use
131
133
  `with_thinking` for reasoning effort and `with_instructions` for developer
132
134
  instructions. Those settings, plus `features.shell_snapshot` and
133
135
  `shell_environment_policy.inherit`, are reserved from the arbitrary
data/UPGRADE.md ADDED
@@ -0,0 +1,28 @@
1
+ # Upgrade Guide
2
+
3
+ ## RubyLLM 2.1
4
+
5
+ This provider now requires RubyLLM `~> 2.1.1` and Codex CLI 0.162.1 or newer.
6
+ RubyLLM 1.x and 2.0 are no longer supported. Upgrade the CLI with
7
+ `npm install -g @openai/codex` and check `codex --version`.
8
+
9
+ Host applications must:
10
+
11
+ - Replace `with_params(codex: ...)` with `with_provider_options(codex: ...)`.
12
+ - Replace `RubyLLM::Schema` with `Schematist::Schema`.
13
+ - Read structured results through `response.parsed`; `response.content` is
14
+ now a JSON string.
15
+ - Read the model ID from `response.model` and usage from `response.tokens`:
16
+ `input`, `output`, `cache_read`, and `thinking`. Input excludes cached tokens;
17
+ output includes thinking tokens. Unreported counts remain `nil`.
18
+
19
+ Plain Ruby consumers can update directly after adapting these calls. Rails
20
+ hosts using RubyLLM persistence must complete the
21
+ [2.0 upgrade](https://github.com/crmne/ruby_llm/blob/v2.0.0/docs/_reference/upgrading.md)
22
+ on 2.0, including cleanup, before following the
23
+ [2.1 upgrade](https://rubyllm.com/upgrading/). On Rails below 8.1.4, pin `json`
24
+ to `< 3` before updating.
25
+
26
+ Each Codex invocation records successful or failed usage attempts in RubyLLM.
27
+ Timeouts cover process exit and pipe draining, including descendants that keep
28
+ output pipes open after the CLI exits. Process-group cleanup remains bounded.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RubyLLM
4
4
  module Codex
5
- VERSION = "0.2.1"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -13,6 +13,8 @@ module RubyLLM
13
13
  # This provider is deliberately narrow: non-streaming text generation and
14
14
  # structured output are supported; RubyLLM tools and attachments are not.
15
15
  class Codex < Provider
16
+ protocol :codex, ::RubyLLM::Protocol
17
+
16
18
  class Error < RubyLLM::Error; end
17
19
  class TimeoutError < Error; end
18
20
  class UnsupportedFeatureError < Error; end
@@ -20,7 +22,7 @@ module RubyLLM
20
22
  DEFAULT_SANDBOX = "read-only"
21
23
  DEFAULT_TIMEOUT = 300
22
24
  DEFAULT_SHELL_ENVIRONMENT_INHERIT = "none"
23
- MINIMUM_CLI_VERSION = "0.144.5"
25
+ MINIMUM_CLI_VERSION = "0.162.1"
24
26
  RESERVED_CONFIG_KEYS = %w[
25
27
  developer_instructions
26
28
  features.shell_snapshot
@@ -37,11 +39,23 @@ module RubyLLM
37
39
  end
38
40
 
39
41
  # rubocop:disable Metrics/ParameterLists
40
- def complete(messages, tools:, temperature:, model:, params: {}, headers: {},
41
- schema: nil, thinking: nil, tool_prefs: nil, &stream)
42
+ def complete(messages, tools:, temperature:, model:, provider_options: {}, headers: {},
43
+ schema: nil, thinking: nil, tool_prefs: nil, max_output_tokens: nil,
44
+ citations: false, caching: nil, protocol: nil, before_request: [],
45
+ usage_recorder: nil, provider_tools: [], compaction: nil, end_user: nil, &stream)
42
46
  validate_request!(tools:, temperature:, headers:, stream:)
43
47
 
44
- options = codex_options(params)
48
+ unsupported = {
49
+ max_output_tokens:, citations: citations || nil, caching:,
50
+ provider_tools: provider_tools.empty? ? nil : provider_tools,
51
+ compaction:, end_user:, tool_prefs: (tool_prefs if tool_prefs&.values&.any?)
52
+ }.compact
53
+ unless unsupported.empty?
54
+ raise UnsupportedFeatureError,
55
+ "Unsupported Codex request options: #{unsupported.keys.join(", ")}"
56
+ end
57
+
58
+ options = codex_options(provider_options)
45
59
  developer_instructions, prompt = build_prompt(messages)
46
60
  request = {
47
61
  model: model.id,
@@ -51,9 +65,22 @@ module RubyLLM
51
65
  thinking:
52
66
  }
53
67
 
68
+ before_request.each { |hook| hook.call(request) }
69
+ tracker = Accounting::Usage::Tracker.new(
70
+ operation: :chat, provider: self, model:, config: @config, on_finish: usage_recorder
71
+ )
54
72
  with_working_directory(options) do |working_directory|
55
- run_codex(request, working_directory:, options:)
73
+ ensure_cli_compatible!(working_directory, options)
74
+ tracker.start
75
+ response = run_codex(request, working_directory:, options:)
76
+ tracker.succeed(response)
56
77
  end
78
+ rescue Errno::ENOENT => error
79
+ tracker&.fail_pending(error)
80
+ raise Error, "Could not execute Codex CLI: #{error.message}. Install it or set codex_cli_path."
81
+ rescue => error
82
+ tracker&.fail_pending(error)
83
+ raise
57
84
  end
58
85
  # rubocop:enable Metrics/ParameterLists
59
86
 
@@ -109,11 +136,11 @@ module RubyLLM
109
136
  "Per-request HTTP headers do not apply to the local Codex provider"
110
137
  end
111
138
 
112
- def codex_options(params)
113
- validate_params!(params)
114
- raw = params.is_a?(Hash) ? (params[:codex] || params["codex"] || {}) : {}
139
+ def codex_options(provider_options)
140
+ validate_provider_options!(provider_options)
141
+ raw = provider_options.is_a?(Hash) ? (provider_options[:codex] || provider_options["codex"] || {}) : {}
115
142
  unless raw.is_a?(Hash)
116
- raise ArgumentError, "Codex params must be a Hash"
143
+ raise ArgumentError, "Codex provider_options must be a Hash"
117
144
  end
118
145
 
119
146
  raw = raw.transform_keys(&:to_sym)
@@ -144,16 +171,16 @@ module RubyLLM
144
171
  }
145
172
  end
146
173
 
147
- def validate_params!(params)
148
- unless params.is_a?(Hash)
149
- raise ArgumentError, "RubyLLM params must be a Hash"
174
+ def validate_provider_options!(provider_options)
175
+ unless provider_options.is_a?(Hash)
176
+ raise ArgumentError, "RubyLLM provider_options must be a Hash"
150
177
  end
151
178
 
152
- unsupported = params.keys.reject { |key| key.to_s == "codex" }
179
+ unsupported = provider_options.keys.reject { |key| key.to_s == "codex" }
153
180
  return if unsupported.empty?
154
181
 
155
182
  raise UnsupportedFeatureError,
156
- "Unsupported params for the local Codex provider: #{unsupported.map(&:inspect).join(", ")}"
183
+ "Unsupported provider_options for the local Codex provider: #{unsupported.map(&:inspect).join(", ")}"
157
184
  end
158
185
 
159
186
  def validate_config!(config)
@@ -168,7 +195,7 @@ module RubyLLM
168
195
  value = raw.key?(:timeout) ? raw[:timeout] : @config.codex_timeout
169
196
  value = DEFAULT_TIMEOUT if value.nil?
170
197
  timeout = Float(value)
171
- raise ArgumentError, "Codex timeout must be greater than zero" unless timeout.positive?
198
+ raise ArgumentError, "Codex timeout must be greater than zero" unless timeout.finite? && timeout.positive?
172
199
 
173
200
  timeout
174
201
  rescue TypeError, ArgumentError
@@ -200,8 +227,6 @@ module RubyLLM
200
227
  end
201
228
 
202
229
  def run_codex(request, working_directory:, options:)
203
- ensure_cli_compatible!(working_directory, options)
204
-
205
230
  Tempfile.create(["ruby-llm-codex-output-", ".txt"]) do |output_file|
206
231
  with_schema_file(request[:schema]) do |schema_path|
207
232
  output_file.close
@@ -295,7 +320,7 @@ module RubyLLM
295
320
  chdir: working_directory,
296
321
  pgroup: true
297
322
  )
298
- threads << writer = Thread.new do
323
+ threads << Thread.new do
299
324
  stdin.write(prompt)
300
325
  rescue Errno::EPIPE, IOError
301
326
  nil
@@ -306,12 +331,15 @@ module RubyLLM
306
331
  threads << stderr_reader = Thread.new { stderr.read }
307
332
  threads.each { |thread| thread.report_on_exception = false }
308
333
 
309
- unless wait_thread.join(options[:timeout])
310
- terminate_process_group(wait_thread)
311
- raise TimeoutError, "Codex CLI timed out after #{options[:timeout]} seconds"
334
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + options[:timeout]
335
+ [wait_thread, *threads].each do |thread|
336
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
337
+ unless thread.join([remaining, 0].max)
338
+ terminate_process_group(wait_thread, threads)
339
+ raise TimeoutError, "Codex CLI timed out after #{options[:timeout]} seconds"
340
+ end
312
341
  end
313
342
 
314
- writer.join
315
343
  [stdout_reader.value, stderr_reader.value, wait_thread.value]
316
344
  ensure
317
345
  [stdin, stdout, stderr].compact.each do |io|
@@ -322,10 +350,13 @@ module RubyLLM
322
350
  threads.each { |thread| thread.kill if thread.alive? }
323
351
  end
324
352
 
325
- def terminate_process_group(wait_thread)
353
+ def terminate_process_group(wait_thread, threads)
326
354
  Process.kill("TERM", -wait_thread.pid)
327
- return if wait_thread.join(2)
328
-
355
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
356
+ [wait_thread, *threads].each do |thread|
357
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
358
+ thread.join([remaining, 0].max)
359
+ end
329
360
  Process.kill("KILL", -wait_thread.pid)
330
361
  wait_thread.join
331
362
  rescue Errno::ESRCH, Errno::ECHILD
@@ -335,7 +366,7 @@ module RubyLLM
335
366
  def add_reasoning_config(command, thinking)
336
367
  return unless thinking
337
368
 
338
- effort = thinking.respond_to?(:effort) ? thinking.effort : thinking
369
+ effort = thinking.effort
339
370
  if effort
340
371
  add_config(command, "model_reasoning_effort", effort.to_s)
341
372
  return
@@ -387,7 +418,7 @@ module RubyLLM
387
418
  end
388
419
 
389
420
  def raise_invocation_error!(status, events, stderr)
390
- event = events.reverse.find { |item| %w[turn.failed error].include?(item["type"]) }
421
+ event = events.reverse_each.find { |item| %w[turn.failed error].include?(item["type"]) }
391
422
  detail = event && (event["message"] || event["error"] || event).to_s
392
423
  detail = stderr.strip if detail.to_s.strip.empty?
393
424
  detail = "Codex exited with status #{status.exitstatus}" if detail.to_s.strip.empty?
@@ -395,14 +426,14 @@ module RubyLLM
395
426
  end
396
427
 
397
428
  def final_agent_message(events)
398
- event = events.reverse.find do |item|
429
+ event = events.reverse_each.find do |item|
399
430
  item["type"] == "item.completed" && item.dig("item", "type") == "agent_message"
400
431
  end
401
432
  event&.dig("item", "text")
402
433
  end
403
434
 
404
435
  def build_message(content, model, events, stderr)
405
- usage = events.reverse.find { |event| event["type"] == "turn.completed" }&.fetch("usage", {}) || {}
436
+ usage = events.reverse_each.find { |event| event["type"] == "turn.completed" }&.fetch("usage", {}) || {}
406
437
  cached = integer_or_nil(usage["cached_input_tokens"])
407
438
  total_input = integer_or_nil(usage["input_tokens"])
408
439
  uncached_input = total_input && [total_input - cached.to_i, 0].max
@@ -410,11 +441,14 @@ module RubyLLM
410
441
  Message.new(
411
442
  role: :assistant,
412
443
  content: content,
413
- model_id: model,
414
- input_tokens: uncached_input,
415
- cached_tokens: cached,
416
- output_tokens: integer_or_nil(usage["output_tokens"]),
417
- thinking_tokens: integer_or_nil(usage["reasoning_output_tokens"]),
444
+ model:,
445
+ tokens: Tokens.new(
446
+ input: uncached_input,
447
+ cache_read: cached,
448
+ output: integer_or_nil(usage["output_tokens"]),
449
+ thinking: integer_or_nil(usage["reasoning_output_tokens"])
450
+ ),
451
+ finish_reason: :stop,
418
452
  raw: {events:, stderr: stderr.empty? ? nil : stderr}
419
453
  )
420
454
  end
@@ -449,23 +483,12 @@ module RubyLLM
449
483
  end
450
484
 
451
485
  def text_content(message)
452
- content = message.content
453
- return content if content.is_a?(String)
454
-
455
- if content.is_a?(RubyLLM::Content)
456
- unless content.attachments.empty?
457
- raise UnsupportedFeatureError,
458
- "Attachments are not supported by the Codex comparison provider"
459
- end
460
- return content.text.to_s
461
- end
462
-
463
- if defined?(RubyLLM::Content::Raw) && content.is_a?(RubyLLM::Content::Raw)
486
+ unless message.attachments.empty?
464
487
  raise UnsupportedFeatureError,
465
- "Raw provider content is not supported by the Codex comparison provider"
488
+ "Attachments are not supported by the Codex comparison provider"
466
489
  end
467
490
 
468
- content.respond_to?(:to_json) ? content.to_json : content.to_s
491
+ message.content
469
492
  end
470
493
 
471
494
  def present?(value)
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ruby_llm"
4
- require "ruby_llm/schema"
5
4
  require_relative "ruby_llm/codex/version"
6
5
  require_relative "ruby_llm/providers/codex"
7
6
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_llm-codex
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paulo Fidalgo
@@ -14,22 +14,16 @@ dependencies:
14
14
  name: ruby_llm
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - ">="
18
- - !ruby/object:Gem::Version
19
- version: '1.16'
20
- - - "<"
17
+ - - "~>"
21
18
  - !ruby/object:Gem::Version
22
- version: '2.0'
19
+ version: 2.1.1
23
20
  type: :runtime
24
21
  prerelease: false
25
22
  version_requirements: !ruby/object:Gem::Requirement
26
23
  requirements:
27
- - - ">="
28
- - !ruby/object:Gem::Version
29
- version: '1.16'
30
- - - "<"
24
+ - - "~>"
31
25
  - !ruby/object:Gem::Version
32
- version: '2.0'
26
+ version: 2.1.1
33
27
  - !ruby/object:Gem::Dependency
34
28
  name: minitest
35
29
  requirement: !ruby/object:Gem::Requirement
@@ -85,6 +79,7 @@ files:
85
79
  - CHANGELOG.md
86
80
  - LICENSE
87
81
  - README.md
82
+ - UPGRADE.md
88
83
  - lib/ruby_llm-codex.rb
89
84
  - lib/ruby_llm/codex/version.rb
90
85
  - lib/ruby_llm/providers/codex.rb
@@ -115,7 +110,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
115
110
  - !ruby/object:Gem::Version
116
111
  version: '0'
117
112
  requirements: []
118
- rubygems_version: 4.0.16
113
+ rubygems_version: 4.0.20
119
114
  specification_version: 4
120
115
  summary: Use a local, ChatGPT-authenticated Codex CLI as a RubyLLM provider
121
116
  test_files: []