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 +7 -0
- data/CHANGELOG.md +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +102 -0
- data/lib/ruby_llm/providers/codex/command.rb +74 -0
- data/lib/ruby_llm/providers/codex/errors.rb +17 -0
- data/lib/ruby_llm/providers/codex/response.rb +99 -0
- data/lib/ruby_llm/providers/codex/runner.rb +102 -0
- data/lib/ruby_llm/providers/codex/session.rb +57 -0
- data/lib/ruby_llm/providers/codex/settings.rb +85 -0
- data/lib/ruby_llm/providers/codex/transcript.rb +100 -0
- data/lib/ruby_llm/providers/codex/version.rb +15 -0
- data/lib/ruby_llm/providers/codex.rb +188 -0
- metadata +83 -0
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
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: []
|