ruby_llm-providers-codex 0.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7149ed6c601a6f26fc7d68cfe3a483584df57c32f48d221d9ad310e8852a533f
4
+ data.tar.gz: 079a0430624bc19c0aad5dd6f43c85a46db6fcf57aab6e37024985bf1596ba1d
5
+ SHA512:
6
+ metadata.gz: 621b30e493e839e04d344c9be10b31ed76010f6b5077937bfea55f40b0c26a3571e8ca292e98884bcf8078a47073ca095a9b24668ef3223f7cf5766223179361
7
+ data.tar.gz: 7110983729be2b679856e5bc561b0340663c30037174cf2a675703129f75788d8b3ef0a0aa54d9c5bd9eb8413e98baf2fa3c77bc67a39ba615042c16bfec73c7
data/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-11-06
4
+
5
+ - Initial release: `codex exec` as a RubyLLM provider with resumable threads,
6
+ per-message streaming, structured output, reasoning effort and attachment notes.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Dewayne VanHoozer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # ruby_llm-providers-codex
2
+
3
+ A [RubyLLM](https://rubyllm.com) provider that uses the **OpenAI Codex agent**
4
+ as the model. Each completion runs `codex exec` non-interactively, writes the
5
+ prompt to its stdin and reads the JSON event stream from stdout.
6
+
7
+ This is deliberately *not* an isolated stand-in for the OpenAI API. The agent
8
+ keeps what makes it an agent: its own tools, sandbox, config and MCP servers,
9
+ and it works in the current directory. Multi-turn chats resume the same Codex
10
+ thread, so the agent keeps its context between turns.
11
+
12
+ ```ruby
13
+ require 'ruby_llm/providers/codex'
14
+
15
+ chat = RubyLLM.chat(model: 'default', provider: :codex)
16
+ chat.ask('What does the Rakefile in this directory do?')
17
+ chat.ask('Add a `lint` task to it') { |chunk| print chunk.content }
18
+ ```
19
+
20
+ With [aia](https://github.com/madbomber/aia) the provider is picked from the
21
+ model prefix:
22
+
23
+ ```bash
24
+ aia --model codex/default review_prompt
25
+ aia --model claude_code/sonnet=implementer,codex/default=reviewer --consensus review
26
+ ```
27
+
28
+ ## Requirements
29
+
30
+ - Ruby 3.2 or newer
31
+ - RubyLLM 2.1 or newer
32
+ - The Codex CLI on your PATH (`codex --version`), logged in with
33
+ `codex login`. No API key is read by this gem.
34
+
35
+ ## Installation
36
+
37
+ ```ruby
38
+ gem 'ruby_llm-providers-codex', require: 'ruby_llm/providers/codex'
39
+ ```
40
+
41
+ The provider registers itself as `:codex` when required.
42
+
43
+ ## Models
44
+
45
+ The part after the slash goes straight to `codex --model`. The id `default`
46
+ (or an empty id) sends no `--model` flag and lets the CLI choose.
47
+
48
+ ## Sessions
49
+
50
+ RubyLLM hands the provider the whole transcript on every turn. When the
51
+ transcript minus the newest message matches what the Codex thread already
52
+ saw, only the new message is sent with `codex exec resume <thread id>`. Any
53
+ other shape (rewound or edited history, a cleared chat, a changed system
54
+ prompt) starts a fresh thread with the conversation replayed as a tagged
55
+ transcript.
56
+
57
+ ```ruby
58
+ chat.with_provider_options(codex: { resume: false }) # fresh thread every turn
59
+ chat.with_provider_options(codex: { session_id: 'uuid' }) # continue a known thread
60
+ ```
61
+
62
+ ## Configuration
63
+
64
+ Global options are `RubyLLM.config.codex_*`; the same keys can be set per
65
+ chat with `with_provider_options(codex: { ... })`.
66
+
67
+ | Option | Default | Meaning |
68
+ |---|---|---|
69
+ | `command` | `codex` | CLI executable |
70
+ | `cwd` | current directory | Directory the agent works in |
71
+ | `timeout` | `600` | Seconds before the process group is killed; `nil` or `0` disables |
72
+ | `sandbox` | CLI default | `--sandbox`: `read-only`, `workspace-write` or `danger-full-access` (new threads only) |
73
+ | `args` | none | Extra CLI arguments, array or shell string (`--profile`, `-c key=value`, ...) |
74
+ | `env` | none | Extra environment variables for the process |
75
+ | `resume` | `true` | Reuse the thread across turns |
76
+ | `session_id` | none | Per-request only: resume this thread id |
77
+
78
+ ## What is supported
79
+
80
+ | Feature | How |
81
+ |---|---|
82
+ | Text, system prompts, multi-turn | Instructions go in as `developer_instructions`; see Sessions |
83
+ | Streaming | Each completed agent message is yielded as one chunk; the last one is the reply |
84
+ | Structured output (`with_schema`) | `--output-schema` via a temporary file |
85
+ | `with_thinking(effort:)` | `model_reasoning_effort` (minimal, low, medium, high, xhigh) |
86
+ | Token counts | From `turn.completed`: input, cached input, output and reasoning tokens |
87
+ | Attachments | Noted in the prompt as `[Attached file 'name': path]`; the agent opens local files itself |
88
+ | RubyLLM tools (`with_tools`) | **Not supported**: raises `UnsupportedFeatureError`. The provider class answers `accepts_tools?` with `false` so hosts such as aia can skip attaching tools |
89
+ | temperature, max tokens | Ignored (logged at debug level) |
90
+
91
+ ## Safety
92
+
93
+ Codex runs model-generated commands under its sandbox policy. Set `sandbox`
94
+ explicitly for anything beyond read-only work.
95
+
96
+ ## Development
97
+
98
+ ```bash
99
+ bundle install
100
+ asgard test_verbose # offline: the CLI is replaced by a fake runner
101
+ asgard quality # tests, rubocop, flog, flay, reek, ...
102
+ ```
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RubyLLM
6
+ module Providers
7
+ class Codex < Provider
8
+ # Builds the argv for one `codex exec` (or `codex exec resume`) call.
9
+ # The prompt itself always arrives on stdin, signalled by the final "-".
10
+ class Command
11
+ EFFORTS = %w[minimal low medium high xhigh].freeze
12
+
13
+ def initialize(settings)
14
+ @settings = settings
15
+ end
16
+
17
+ # @param model [String, nil] model id; "default" means the CLI's own choice
18
+ # @param resume_id [String, nil] thread id to continue
19
+ # @param system_prompt [String, nil] sent as developer_instructions when starting a thread
20
+ # @param schema_path [String, nil] file holding a JSON Schema for --output-schema
21
+ # @param effort [String, nil] one of EFFORTS
22
+ # @return [Array<String>]
23
+ def build(model: nil, resume_id: nil, system_prompt: nil, schema_path: nil, effort: nil)
24
+ argv = [@settings.command, 'exec']
25
+ argv << 'resume' if resume_id
26
+ argv.push('--json', '--skip-git-repo-check')
27
+ argv.push('--model', model.to_s) if self.class.named?(model)
28
+ argv.concat(fresh_thread_args(system_prompt, schema_path)) unless resume_id
29
+ argv.push('--config', self.class.toml_assignment('model_reasoning_effort', effort)) if effort
30
+ argv.concat(@settings.args)
31
+ argv << resume_id if resume_id
32
+ argv << '-'
33
+ end
34
+
35
+ class << self
36
+ def named?(model)
37
+ !(model.nil? || model.to_s.empty? || model.to_s == DEFAULT_MODEL)
38
+ end
39
+
40
+ # key=value for codex --config, with the value as a TOML literal.
41
+ def toml_assignment(key, value)
42
+ "#{key}=#{toml_literal(value)}"
43
+ end
44
+
45
+ def toml_literal(value)
46
+ case value
47
+ when String, Symbol then JSON.generate(value.to_s)
48
+ when true, false, Integer, Float then value.to_s
49
+ when Array then "[#{value.map { |item| toml_literal(item) }.join(', ')}]"
50
+ else raise ArgumentError, "unsupported codex config value: #{value.inspect}"
51
+ end
52
+ end
53
+
54
+ # The bare JSON Schema from RubyLLM's payload.
55
+ def schema_definition(schema)
56
+ schema[:schema] || schema['schema'] || schema
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def fresh_thread_args(system_prompt, schema_path)
63
+ argv = []
64
+ argv.push('--sandbox', @settings.sandbox) if @settings.sandbox
65
+ if system_prompt && !system_prompt.strip.empty?
66
+ argv.push('--config', self.class.toml_assignment('developer_instructions', system_prompt))
67
+ end
68
+ argv.push('--output-schema', schema_path) if schema_path
69
+ argv
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Codex < Provider
6
+ # Raised when the codex CLI cannot be run, exits without a result, or
7
+ # reports an error of its own.
8
+ class Error < RubyLLM::Error; end
9
+
10
+ # Raised when the CLI runs past the configured timeout and is killed.
11
+ class TimeoutError < Error; end
12
+
13
+ # Raised for RubyLLM features the CLI cannot honour, such as tools.
14
+ class UnsupportedFeatureError < Error; end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RubyLLM
6
+ module Providers
7
+ class Codex < Provider
8
+ # Reads `codex exec --json` events line by line. Each completed
9
+ # agent_message item is streamed as a Chunk; the last one is the reply.
10
+ # turn.completed carries the token usage.
11
+ class Response
12
+ attr_reader :session_id, :model_id, :events
13
+
14
+ def initialize(model:, schema: nil)
15
+ @model = model
16
+ @schema = schema
17
+ @events = []
18
+ @text = nil
19
+ @usage = {}
20
+ @error = nil
21
+ end
22
+
23
+ # @param line [String] one line of stdout
24
+ # @yield [RubyLLM::Chunk] a completed agent message
25
+ # @return [Hash, nil] the parsed event, or nil for non-JSON lines
26
+ def ingest(line)
27
+ event = self.class.parse(line)
28
+ return nil unless event
29
+
30
+ @events << event
31
+ case event['type']
32
+ when 'thread.started' then @session_id = event['thread_id']
33
+ when 'item.completed'
34
+ text = agent_text(event)
35
+ if text
36
+ @text = text
37
+ yield chunk(text) if block_given?
38
+ end
39
+ when 'turn.completed' then @usage = event['usage'] || {}
40
+ when 'turn.failed' then @error = event.dig('error', 'message') || event['error'].to_s
41
+ when 'error' then @error = event['message'].to_s
42
+ end
43
+ event
44
+ end
45
+
46
+ # @param run [Runner::Result, nil] used for diagnostics when no reply arrived
47
+ # @return [RubyLLM::Message]
48
+ def message(run = nil)
49
+ raise Error, "codex exec failed: #{@error}" if @error
50
+
51
+ raise_without_result!(run) if @text.nil?
52
+
53
+ total_input = @usage['input_tokens']
54
+ cached = @usage['cached_input_tokens']
55
+ Message.new(
56
+ role: :assistant,
57
+ content: @text,
58
+ model: @model,
59
+ input_tokens: total_input && [total_input.to_i - cached.to_i, 0].max,
60
+ output_tokens: @usage['output_tokens'],
61
+ cache_read_tokens: cached,
62
+ cache_write_tokens: @usage['cache_write_input_tokens'],
63
+ thinking_tokens: @usage['reasoning_output_tokens'],
64
+ finish_reason: :stop,
65
+ raw: { 'thread_id' => @session_id, 'usage' => @usage, 'events' => @events }
66
+ )
67
+ end
68
+
69
+ def self.parse(line)
70
+ text = line.to_s.strip
71
+ return nil unless text.start_with?('{')
72
+
73
+ JSON.parse(text)
74
+ rescue JSON::ParserError
75
+ RubyLLM.logger.debug { "codex: non-JSON output #{text.inspect}" }
76
+ nil
77
+ end
78
+
79
+ private
80
+
81
+ def agent_text(event)
82
+ item = event['item'] || {}
83
+ item['text'] if item['type'] == 'agent_message'
84
+ end
85
+
86
+ def raise_without_result!(run)
87
+ status = run&.status&.exitstatus
88
+ stderr = run&.stderr.to_s.strip
89
+ raise Error,
90
+ "codex exec exited#{" (#{status})" if status} without a reply#{": #{stderr}" unless stderr.empty?}"
91
+ end
92
+
93
+ def chunk(text)
94
+ Chunk.new(role: :assistant, content: text, model: @model)
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module RubyLLM
6
+ module Providers
7
+ class Codex < Provider
8
+ # Spawns the CLI, feeds it stdin, and yields each stdout line on the
9
+ # caller's thread as it arrives. The process group is killed when the
10
+ # timeout passes.
11
+ class Runner
12
+ # What the process produced: full stdout, full stderr, and its exit status.
13
+ Result = Data.define(:stdout, :stderr, :status)
14
+ GRACE_SECONDS = 2
15
+ EOF = Object.new.freeze
16
+
17
+ # @param argv [Array<String>] command and arguments
18
+ # @param stdin [String] text written to the process's stdin
19
+ # @param cwd [String, nil] working directory (default: Dir.pwd)
20
+ # @param timeout [Numeric, nil] seconds before the process is killed
21
+ # @param env [Hash] extra environment variables
22
+ # @yield [String] each line of stdout
23
+ # @return [Result]
24
+ def run(argv, stdin: '', cwd: nil, timeout: nil, env: {}, &block)
25
+ Open3.popen3(env.to_h, *argv, chdir: cwd || Dir.pwd, pgroup: true) do |input, output, error, wait|
26
+ collect(input, output, error, wait, stdin:, timeout:, &block)
27
+ rescue TimeoutError
28
+ terminate(wait)
29
+ raise TimeoutError, "#{argv.first} did not finish within #{timeout}s"
30
+ end
31
+ rescue Errno::ENOENT => e
32
+ raise Error, "cannot run #{argv.first.inspect}: #{e.message}"
33
+ end
34
+
35
+ private
36
+
37
+ # Drives one spawned process: feeds stdin and reads stderr on their own
38
+ # threads, pumps stdout through a queue so each line can be yielded
39
+ # before the process exits, and assembles the Result once it does.
40
+ def collect(input, output, error, wait, stdin:, timeout:)
41
+ feeder = Thread.new { feed(input, stdin) }
42
+ errors = Thread.new { error.read }
43
+ queue = Queue.new
44
+ reader = Thread.new { pump(output, queue) }
45
+ lines = []
46
+ drain(queue, deadline(timeout)) do |line|
47
+ lines << line
48
+ yield line if block_given?
49
+ end
50
+ [feeder, reader].each(&:join)
51
+ Result.new(stdout: lines.join, stderr: errors.value.to_s, status: wait.value)
52
+ end
53
+
54
+ def deadline(timeout)
55
+ timeout && (now + timeout)
56
+ end
57
+
58
+ def feed(input, text)
59
+ input.binmode
60
+ input.write(text.to_s)
61
+ rescue Errno::EPIPE, IOError
62
+ nil
63
+ ensure
64
+ input.close unless input.closed?
65
+ end
66
+
67
+ def pump(output, queue)
68
+ output.each_line { |line| queue << line }
69
+ ensure
70
+ queue << EOF
71
+ end
72
+
73
+ # Pops lines until EOF, raising TimeoutError once the deadline passes.
74
+ def drain(queue, deadline)
75
+ loop do
76
+ remaining = deadline && [deadline - now, 0].max
77
+ line = remaining ? queue.pop(timeout: remaining) : queue.pop
78
+ raise TimeoutError if line.nil?
79
+ break if line.equal?(EOF)
80
+
81
+ yield line
82
+ end
83
+ end
84
+
85
+ def terminate(wait)
86
+ pgid = -wait.pid
87
+ Process.kill('TERM', pgid)
88
+ return if wait.join(GRACE_SECONDS)
89
+
90
+ Process.kill('KILL', pgid)
91
+ wait.join
92
+ rescue Errno::ESRCH, Errno::ECHILD
93
+ nil
94
+ end
95
+
96
+ def now
97
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Codex < Provider
6
+ # Decides, per completion, whether to resume the CLI session that
7
+ # answered the previous turn or to start a new one. A session is resumed
8
+ # only when the transcript RubyLLM hands over, minus the newest message,
9
+ # is exactly what that session already saw. A rewound, edited or cleared
10
+ # history gets a fresh session replayed from the whole transcript.
11
+ class Session
12
+ Plan = Data.define(:messages, :resume_id) do
13
+ def resume?
14
+ !resume_id.nil?
15
+ end
16
+ end
17
+
18
+ attr_reader :id
19
+
20
+ def initialize
21
+ reset!
22
+ end
23
+
24
+ # @param messages [Array<RubyLLM::Message>] the full RubyLLM transcript
25
+ # @param session_id [String, nil] a caller-chosen session to continue
26
+ # @param resume [Boolean] false forces a fresh session every time
27
+ # @return [Plan] the messages to send and the session to resume, if any
28
+ def plan(messages, session_id: nil, resume: true)
29
+ raise ArgumentError, 'no messages to send' if messages.empty?
30
+ return Plan.new(messages: [messages.last], resume_id: session_id) if session_id
31
+ return Plan.new(messages: messages, resume_id: nil) unless resume && continues?(messages)
32
+
33
+ Plan.new(messages: [messages.last], resume_id: @id)
34
+ end
35
+
36
+ # Remember which session answered, and what it has now seen.
37
+ def record(messages, reply, id:)
38
+ @id = id
39
+ @fingerprint = id ? Transcript.fingerprint(messages + [reply]) : nil
40
+ end
41
+
42
+ def reset!
43
+ @id = nil
44
+ @fingerprint = nil
45
+ end
46
+
47
+ private
48
+
49
+ def continues?(messages)
50
+ return false unless @id && @fingerprint && messages.size > 1
51
+
52
+ Transcript.fingerprint(messages[0...-1]) == @fingerprint
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+
5
+ module RubyLLM
6
+ module Providers
7
+ class Codex < Provider
8
+ # The settings for one completion. Each value comes from, in order of
9
+ # precedence, chat.with_provider_options(codex: {...}), the
10
+ # RubyLLM.config.codex_* option, then the default here.
11
+ class Settings
12
+ DEFAULT_COMMAND = 'codex'
13
+ DEFAULT_TIMEOUT = 600
14
+ SANDBOXES = %w[read-only workspace-write danger-full-access].freeze
15
+
16
+ attr_reader :command, :cwd, :timeout, :sandbox, :args, :env, :session_id
17
+
18
+ def initialize(config, provider_options = {})
19
+ opts = self.class.namespaced(provider_options)
20
+ @command = opts.fetch(:command) { config.codex_command } || DEFAULT_COMMAND
21
+ @cwd = opts.fetch(:cwd) { config.codex_cwd }
22
+ @timeout = self.class.positive_or_nil(opts.fetch(:timeout) { timeout_default(config) })
23
+ @sandbox = self.class.sandbox(opts.fetch(:sandbox) { config.codex_sandbox })
24
+ @args = self.class.split_args(opts.fetch(:args) { config.codex_args })
25
+ @env = self.class.stringify(opts.fetch(:env) { config.codex_env })
26
+ @session_id = opts[:session_id]
27
+ @resume = opts.fetch(:resume) { config.codex_resume.nil? || config.codex_resume }
28
+ end
29
+
30
+ # False when every completion must start a fresh CLI session.
31
+ def resume?
32
+ @resume ? true : false
33
+ end
34
+
35
+ class << self
36
+ # The codex: sub-hash of provider_options with symbol keys.
37
+ def namespaced(provider_options)
38
+ return {} unless provider_options.is_a?(Hash)
39
+
40
+ raw = provider_options[:codex] || provider_options['codex'] || {}
41
+ raise ArgumentError, 'provider_options[:codex] must be a Hash' unless raw.is_a?(Hash)
42
+
43
+ raw.transform_keys(&:to_sym)
44
+ end
45
+
46
+ # A positive number of seconds, or nil for "no timeout".
47
+ def positive_or_nil(value)
48
+ return nil if value.nil?
49
+
50
+ number = Float(value)
51
+ number.positive? ? number : nil
52
+ end
53
+
54
+ # Extra CLI arguments given as an array or a shell-style string.
55
+ def split_args(value)
56
+ case value
57
+ when nil then []
58
+ when String then Shellwords.split(value)
59
+ else Array(value).map(&:to_s)
60
+ end
61
+ end
62
+
63
+ def sandbox(value)
64
+ return nil if value.nil?
65
+
66
+ text = value.to_s
67
+ return text if SANDBOXES.include?(text)
68
+
69
+ raise ArgumentError, "codex sandbox must be one of #{SANDBOXES.join(', ')}"
70
+ end
71
+
72
+ def stringify(value)
73
+ (value || {}).to_h.to_h { |key, item| [key.to_s, item.to_s] }
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def timeout_default(config)
80
+ config.codex_timeout.nil? ? DEFAULT_TIMEOUT : config.codex_timeout
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+
6
+ module RubyLLM
7
+ module Providers
8
+ class Codex < Provider
9
+ # Renders RubyLLM messages into the text the CLI reads from stdin, and
10
+ # fingerprints transcripts so Session can tell whether a CLI session
11
+ # already holds the earlier turns.
12
+ class Transcript
13
+ REPLAY_PREAMBLE = "The conversation so far follows. Continue it by writing the assistant's next reply " \
14
+ "to the last turn. Do not repeat the tags.\n\n"
15
+ EMPTY = '(empty message)'
16
+
17
+ class << self
18
+ def fingerprint(messages)
19
+ Digest::SHA256.hexdigest(JSON.generate(messages.map { |message| summary(message) }))
20
+ end
21
+
22
+ def summary(message)
23
+ {
24
+ role: message.role.to_s,
25
+ content: message.content.to_s,
26
+ tool_call_id: message.tool_call_id,
27
+ tool_calls: tool_calls(message).map { |call| [call.id, call.name, call.arguments] },
28
+ attachments: attachments(message).map { |attachment| attachment_note(attachment) }
29
+ }
30
+ end
31
+
32
+ # The message text plus a note per attachment naming its location;
33
+ # the agent can open local files with its own tools.
34
+ def text_of(message)
35
+ parts = [message.content.to_s]
36
+ attachments(message).each { |attachment| parts << attachment_note(attachment) }
37
+ parts.reject(&:empty?).join("\n").strip
38
+ end
39
+
40
+ def attachment_note(attachment)
41
+ source = attachment.respond_to?(:source) ? attachment.source : attachment
42
+ name = attachment.respond_to?(:filename) ? attachment.filename : nil
43
+ location = source.respond_to?(:to_path) ? source.to_path : source.to_s
44
+ "[Attached file#{" '#{name}'" if name}: #{location}]"
45
+ end
46
+
47
+ def attachments(message)
48
+ message.respond_to?(:attachments) ? Array(message.attachments) : []
49
+ end
50
+
51
+ def tool_calls(message)
52
+ calls = message.respond_to?(:tool_calls) ? message.tool_calls : nil
53
+ calls.respond_to?(:values) ? calls.values : Array(calls)
54
+ end
55
+ end
56
+
57
+ def initialize(messages)
58
+ @system, @turns = messages.partition { |message| message.role == :system }
59
+ end
60
+
61
+ # All system messages joined, or nil when there are none.
62
+ def system_prompt
63
+ text = @system.map { |message| self.class.text_of(message) }.reject(&:empty?).join("\n\n")
64
+ text.empty? ? nil : text
65
+ end
66
+
67
+ # A lone user turn goes through as-is; anything longer becomes a
68
+ # tagged replay of the conversation.
69
+ def prompt
70
+ return EMPTY if @turns.empty?
71
+ return single_turn if @turns.one? && @turns.first.role == :user
72
+
73
+ REPLAY_PREAMBLE + @turns.map { |message| turn(message) }.join("\n")
74
+ end
75
+
76
+ private
77
+
78
+ def single_turn
79
+ text = self.class.text_of(@turns.first)
80
+ text.empty? ? EMPTY : text
81
+ end
82
+
83
+ def turn(message)
84
+ body = self.class.text_of(message)
85
+ case message.role
86
+ when :tool then "<tool_result id=\"#{message.tool_call_id}\">\n#{body}\n</tool_result>"
87
+ when :assistant then "<assistant>\n#{body}#{tool_calls_text(message)}\n</assistant>"
88
+ else "<user>\n#{body}\n</user>"
89
+ end
90
+ end
91
+
92
+ def tool_calls_text(message)
93
+ self.class.tool_calls(message).map do |call|
94
+ "\n<tool_call id=\"#{call.id}\" name=\"#{call.name}\">#{JSON.generate(call.arguments)}</tool_call>"
95
+ end.join
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ # Version of the ruby_llm-providers-codex gem. Kept in its own file
6
+ # so the gemspec can read the literal without loading the provider.
7
+ class Codex < Provider
8
+ VERSION = '0.0.1'
9
+
10
+ def self.version
11
+ VERSION
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm'
4
+ require 'tempfile'
5
+ require 'ruby_llm/providers/codex/version'
6
+ require 'ruby_llm/providers/codex/errors'
7
+ require 'ruby_llm/providers/codex/settings'
8
+ require 'ruby_llm/providers/codex/command'
9
+ require 'ruby_llm/providers/codex/transcript'
10
+ require 'ruby_llm/providers/codex/session'
11
+ require 'ruby_llm/providers/codex/runner'
12
+ require 'ruby_llm/providers/codex/response'
13
+
14
+ module RubyLLM
15
+ module Providers
16
+ # Codex: the OpenAI Codex agent (`codex exec`) as a RubyLLM provider.
17
+ #
18
+ # Each completion spawns the codex CLI non-interactively, writes the
19
+ # prompt to its stdin and reads its JSON event stream from stdout. The
20
+ # agent keeps its own tools, config and MCP servers and works in the
21
+ # current directory (or config.codex_cwd). Multi-turn chats resume the
22
+ # same thread (`codex exec resume`) as long as the RubyLLM transcript
23
+ # still matches what that thread has seen; otherwise a fresh thread is
24
+ # started from the whole transcript.
25
+ #
26
+ # require 'ruby_llm/providers/codex'
27
+ # chat = RubyLLM.chat(model: 'default', provider: :codex)
28
+ # chat.ask('Summarise the README in this directory')
29
+ class Codex < Provider
30
+ DEFAULT_MODEL = 'default'
31
+ MODELS = %w[default].freeze
32
+ CAPABILITIES = %w[streaming structured_output].freeze
33
+
34
+ # Satisfies RubyLLM's protocol lookups; every request goes through
35
+ # #complete below, never through a wire protocol.
36
+ protocol :codex, RubyLLM::Protocol
37
+
38
+ class << self
39
+ # Anything responding to #run like Runner; tests inject fakes here.
40
+ attr_accessor :runner
41
+
42
+ def configuration_options
43
+ %i[codex_command codex_cwd codex_timeout codex_sandbox codex_args codex_env codex_resume]
44
+ end
45
+
46
+ def configuration_requirements
47
+ []
48
+ end
49
+
50
+ def local?
51
+ true
52
+ end
53
+
54
+ # Any id goes straight to --model, so there is no catalog to check.
55
+ def assume_models_exist?
56
+ true
57
+ end
58
+
59
+ # The agent runs its own tools; RubyLLM tools cannot be handed to it.
60
+ # Hosts such as aia check this before attaching tools or MCP servers.
61
+ def accepts_tools?
62
+ false
63
+ end
64
+
65
+ def display_name
66
+ 'Codex'
67
+ end
68
+
69
+ def model_id(model)
70
+ model.respond_to?(:id) ? model.id : model.to_s
71
+ end
72
+
73
+ def effort_for(thinking)
74
+ return nil unless thinking.respond_to?(:effort) && thinking.effort
75
+
76
+ value = thinking.effort.to_s
77
+ Command::EFFORTS.include?(value) ? value : nil
78
+ end
79
+ end
80
+
81
+ attr_reader :session
82
+
83
+ def initialize(config)
84
+ super
85
+ @session = Session.new
86
+ end
87
+
88
+ # Never contacted; the transport layer only needs a base URL.
89
+ def api_base
90
+ 'cli://codex'
91
+ end
92
+
93
+ # Messages go to the CLI as text, so there is nothing to upload or
94
+ # convert beforehand.
95
+ def preprocess_messages(messages, **)
96
+ messages
97
+ end
98
+
99
+ def discard_missing_uploads(_messages, _error, **)
100
+ []
101
+ end
102
+
103
+ # :reek:LongParameterList -- the keyword set is RubyLLM's Provider#complete contract
104
+ def complete(messages, model:, tools: {}, provider_options: {}, schema: nil, thinking: nil,
105
+ temperature: nil, max_output_tokens: nil, before_request: [], **, &block)
106
+ reject_tools!(tools)
107
+ log_ignored(temperature:, max_output_tokens:)
108
+ settings = Settings.new(@config, provider_options)
109
+ plan = session.plan(messages, session_id: settings.session_id, resume: settings.resume?)
110
+ transcript = Transcript.new(plan.messages)
111
+ with_schema_file(schema) do |schema_path|
112
+ argv = build_argv(settings, model, transcript, plan, schema_path, thinking)
113
+ before_request.each { |hook| hook.call({ argv: argv, prompt: transcript.prompt }) }
114
+ response = Response.new(model: model, schema: schema)
115
+ run = runner.run(argv, stdin: transcript.prompt, cwd: settings.cwd, timeout: settings.timeout,
116
+ env: settings.env) do |line|
117
+ response.ingest(line) { |chunk| block&.call(chunk) }
118
+ end
119
+ message = response.message(run)
120
+ session.record(messages, message, id: response.session_id)
121
+ message
122
+ end
123
+ end
124
+
125
+ # A static catalog: "default" stands for the CLI's own model choice;
126
+ # any other id is passed through to --model.
127
+ def list_models
128
+ MODELS.map { |id| static_model(id) }
129
+ end
130
+
131
+ private
132
+
133
+ def runner
134
+ self.class.runner || Runner.new
135
+ end
136
+
137
+ def build_argv(settings, model, transcript, plan, schema_path, thinking)
138
+ Command.new(settings).build(
139
+ model: self.class.model_id(model),
140
+ resume_id: plan.resume_id,
141
+ system_prompt: transcript.system_prompt,
142
+ schema_path: schema_path,
143
+ effort: self.class.effort_for(thinking)
144
+ )
145
+ end
146
+
147
+ # codex reads --output-schema from a file, so the schema is written to
148
+ # a temporary one for the length of the call.
149
+ def with_schema_file(schema)
150
+ return yield nil unless schema
151
+
152
+ Tempfile.create(['ruby_llm-codex-schema-', '.json']) do |file|
153
+ file.write(JSON.generate(Command.schema_definition(schema)))
154
+ file.flush
155
+ yield file.path
156
+ end
157
+ end
158
+
159
+ def static_model(id)
160
+ Model.new(
161
+ id: id,
162
+ name: "Codex (#{id})",
163
+ provider: slug,
164
+ capabilities: CAPABILITIES,
165
+ modalities: { input: %w[text], output: %w[text] },
166
+ metadata: { source: 'ruby_llm-providers-codex' }
167
+ )
168
+ end
169
+
170
+ def reject_tools!(tools)
171
+ return if tools.nil? || tools.empty?
172
+
173
+ raise UnsupportedFeatureError,
174
+ 'RubyLLM tools cannot be handed to the codex CLI; the agent brings its own tools. ' \
175
+ 'Remove with_tools or choose another provider.'
176
+ end
177
+
178
+ def log_ignored(**options)
179
+ ignored = options.compact.keys
180
+ return if ignored.empty?
181
+
182
+ RubyLLM.logger.debug { "codex: ignoring #{ignored.join(', ')} (not supported by codex exec)" }
183
+ end
184
+ end
185
+ end
186
+ end
187
+
188
+ RubyLLM::Provider.register :codex, RubyLLM::Providers::Codex unless RubyLLM::Provider.resolve(:codex)
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby_llm-providers-codex
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Dewayne VanHoozer
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ruby_llm
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ -
17
+ - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: "2.1"
20
+ -
21
+ - "<"
22
+ - !ruby/object:Gem::Version
23
+ version: "3"
24
+ type: :runtime
25
+ prerelease: false
26
+ version_requirements: !ruby/object:Gem::Requirement
27
+ requirements:
28
+ -
29
+ - ">="
30
+ - !ruby/object:Gem::Version
31
+ version: "2.1"
32
+ -
33
+ - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: "3"
36
+ description: "Adds a codex provider to RubyLLM. Prompts are written to the Codex CLI's stdin in non-interactive mode and its JSON events are read back from stdout, so the agent (with its own tools, config and resumable threads) can be used wherever RubyLLM expects a model, e.g. `--model codex/default` in aia."
37
+ email:
38
+ - dvanhoozer@gmail.com
39
+ executables: []
40
+ extensions: []
41
+ extra_rdoc_files: []
42
+ files:
43
+ - CHANGELOG.md
44
+ - LICENSE.txt
45
+ - README.md
46
+ - lib/ruby_llm/providers/codex.rb
47
+ - lib/ruby_llm/providers/codex/command.rb
48
+ - lib/ruby_llm/providers/codex/errors.rb
49
+ - lib/ruby_llm/providers/codex/response.rb
50
+ - lib/ruby_llm/providers/codex/runner.rb
51
+ - lib/ruby_llm/providers/codex/session.rb
52
+ - lib/ruby_llm/providers/codex/settings.rb
53
+ - lib/ruby_llm/providers/codex/transcript.rb
54
+ - lib/ruby_llm/providers/codex/version.rb
55
+ homepage: "https://github.com/madbomber/ruby_llm-providers-codex"
56
+ licenses:
57
+ - MIT
58
+ metadata:
59
+ homepage_uri: "https://github.com/madbomber/ruby_llm-providers-codex"
60
+ source_code_uri: "https://github.com/madbomber/ruby_llm-providers-codex"
61
+ changelog_uri: "https://github.com/madbomber/ruby_llm-providers-codex/blob/main/CHANGELOG.md"
62
+ bug_tracker_uri: "https://github.com/madbomber/ruby_llm-providers-codex/issues"
63
+ rubygems_mfa_required: "true"
64
+ rdoc_options: []
65
+ require_paths:
66
+ - lib
67
+ required_ruby_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ -
70
+ - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: "3.2"
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ -
76
+ - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: "0"
79
+ requirements: []
80
+ rubygems_version: 4.1.0.beta3
81
+ specification_version: 4
82
+ summary: RubyLLM provider that drives the OpenAI Codex agent through `codex exec`.
83
+ test_files: []