ruby_llm-providers-copilot 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 +103 -0
- data/lib/ruby_llm/providers/copilot/command.rb +46 -0
- data/lib/ruby_llm/providers/copilot/errors.rb +17 -0
- data/lib/ruby_llm/providers/copilot/response.rb +105 -0
- data/lib/ruby_llm/providers/copilot/runner.rb +102 -0
- data/lib/ruby_llm/providers/copilot/session.rb +57 -0
- data/lib/ruby_llm/providers/copilot/settings.rb +80 -0
- data/lib/ruby_llm/providers/copilot/transcript.rb +100 -0
- data/lib/ruby_llm/providers/copilot/version.rb +15 -0
- data/lib/ruby_llm/providers/copilot.rb +192 -0
- metadata +83 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ca732cce7361f198dca083229e205cd488da6e9dc39e8f37c85db61e714b86b2
|
|
4
|
+
data.tar.gz: db1ca71d41292caa7d029ab0441215e6c9f9a844986d0114bbcda160f5205e3e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b1aa9714488725e3ea95fd671e4cd89f7f20102c03c2d3299f25969143d5f6702ac131aeee9b27b9ee674a5de8e5982bbfcf716f3c2ffb09fe70716de95cee9f
|
|
7
|
+
data.tar.gz: cfca36186324fb9eddd04335406433689805c3b74c9ddf32e2a19d1ad2a15921eaaaa54020852606cf8a30264ab9da67e2b2130ba70551a0e8a3f2a12f7ea9f1
|
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,103 @@
|
|
|
1
|
+
# ruby_llm-providers-copilot
|
|
2
|
+
|
|
3
|
+
A [RubyLLM](https://rubyllm.com) provider that uses the **GitHub Copilot CLI
|
|
4
|
+
agent** as the model. Each completion runs `copilot` in non-interactive mode,
|
|
5
|
+
pipes the prompt on stdin and reads the JSON event stream from stdout.
|
|
6
|
+
|
|
7
|
+
This is deliberately *not* an isolated stand-in for a model API. The agent
|
|
8
|
+
keeps its own tools, MCP servers and instructions, and it works in the
|
|
9
|
+
current directory. Multi-turn chats resume the same Copilot session, so the
|
|
10
|
+
agent keeps its context between turns.
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
require 'ruby_llm/providers/copilot'
|
|
14
|
+
|
|
15
|
+
chat = RubyLLM.chat(model: 'default', provider: :copilot)
|
|
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 copilot/default review_prompt
|
|
25
|
+
aia --model claude_code/sonnet=implementer,copilot/auto=reviewer --consensus review
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Requirements
|
|
29
|
+
|
|
30
|
+
- Ruby 3.2 or newer
|
|
31
|
+
- RubyLLM 2.1 or newer
|
|
32
|
+
- The GitHub Copilot CLI on your PATH (`copilot --version`), logged in with
|
|
33
|
+
`copilot login`. No API key is read by this gem.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
gem 'ruby_llm-providers-copilot', require: 'ruby_llm/providers/copilot'
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The provider registers itself as `:copilot` when required.
|
|
42
|
+
|
|
43
|
+
## Models
|
|
44
|
+
|
|
45
|
+
The part after the slash goes straight to `copilot --model`. The id `default`
|
|
46
|
+
(or an empty id) sends no `--model` flag; `auto` lets Copilot route.
|
|
47
|
+
|
|
48
|
+
## Sessions
|
|
49
|
+
|
|
50
|
+
Copilot lets the caller choose the session id, so every new session gets a
|
|
51
|
+
UUID from this gem (`--session-id`). When the transcript minus the newest
|
|
52
|
+
message matches what that session already saw, only the new message is sent
|
|
53
|
+
with `--resume <id>`. Any other shape (rewound or edited history, a cleared
|
|
54
|
+
chat, a changed system prompt) starts a fresh session with the conversation
|
|
55
|
+
replayed as a tagged transcript.
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
chat.with_provider_options(copilot: { resume: false }) # fresh session every turn
|
|
59
|
+
chat.with_provider_options(copilot: { session_id: 'uuid' }) # continue a known session
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Configuration
|
|
63
|
+
|
|
64
|
+
Global options are `RubyLLM.config.copilot_*`; the same keys can be set per
|
|
65
|
+
chat with `with_provider_options(copilot: { ... })`.
|
|
66
|
+
|
|
67
|
+
| Option | Default | Meaning |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `command` | `copilot` | 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
|
+
| `allow_all_tools` | `false` | Pass `--allow-all-tools` so the agent runs tools without confirmation |
|
|
73
|
+
| `args` | none | Extra CLI arguments, array or shell string (`--add-dir`, `--allow-tool`, `--agent`, ...) |
|
|
74
|
+
| `env` | none | Extra environment variables for the process |
|
|
75
|
+
| `resume` | `true` | Reuse the session across turns |
|
|
76
|
+
| `session_id` | none | Per-request only: resume this session id |
|
|
77
|
+
|
|
78
|
+
## What is supported
|
|
79
|
+
|
|
80
|
+
| Feature | How |
|
|
81
|
+
|---|---|
|
|
82
|
+
| Text, system prompts, multi-turn | Copilot has no system-prompt flag, so instructions lead the first prompt in an `<instructions>` block; see Sessions |
|
|
83
|
+
| Streaming | `assistant.message_delta` events become chunks |
|
|
84
|
+
| `with_thinking(effort:)` | `--reasoning-effort` (none, minimal, low, medium, high, xhigh, max) |
|
|
85
|
+
| Token counts | Summed from the session usage checkpoint: input, output, cache read and write |
|
|
86
|
+
| Attachments | Noted in the prompt as `[Attached file 'name': path]`; the agent opens local files itself |
|
|
87
|
+
| 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 |
|
|
88
|
+
| Structured output (`with_schema`) | **Not supported**: the CLI has no schema option |
|
|
89
|
+
| temperature, max tokens | Ignored (logged at debug level) |
|
|
90
|
+
|
|
91
|
+
## Safety
|
|
92
|
+
|
|
93
|
+
Without `allow_all_tools` the CLI applies its own confirmation rules, and in
|
|
94
|
+
non-interactive mode an unapproved tool simply is not run. Turn it on only in
|
|
95
|
+
a directory you are happy to let the agent change.
|
|
96
|
+
|
|
97
|
+
## Development
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
bundle install
|
|
101
|
+
asgard test_verbose # offline: the CLI is replaced by a fake runner
|
|
102
|
+
asgard quality # tests, rubocop, flog, flay, reek, ...
|
|
103
|
+
```
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Providers
|
|
5
|
+
class Copilot < Provider
|
|
6
|
+
# Builds the argv for one non-interactive `copilot` run. The prompt is
|
|
7
|
+
# piped on stdin; copilot has no system-prompt flag, so instructions are
|
|
8
|
+
# folded into the prompt by the provider.
|
|
9
|
+
class Command
|
|
10
|
+
EFFORTS = %w[none minimal low medium high xhigh max].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(settings)
|
|
13
|
+
@settings = settings
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @param model [String, nil] model id; "default" means the CLI's own choice
|
|
17
|
+
# @param session_id [String] id for a new session (copilot lets the caller pick it)
|
|
18
|
+
# @param resume_id [String, nil] session id to continue instead
|
|
19
|
+
# @param effort [String, nil] one of EFFORTS
|
|
20
|
+
# @return [Array<String>]
|
|
21
|
+
def build(session_id:, model: nil, resume_id: nil, effort: nil)
|
|
22
|
+
argv = [@settings.command, '--silent', '--output-format', 'json']
|
|
23
|
+
argv.push('--model', model.to_s) if self.class.named?(model)
|
|
24
|
+
argv.push(*(resume_id ? ['--resume', resume_id] : ['--session-id', session_id]))
|
|
25
|
+
argv.push('--reasoning-effort', effort) if effort
|
|
26
|
+
argv << '--allow-all-tools' if @settings.allow_all_tools?
|
|
27
|
+
argv.concat(@settings.args)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
def named?(model)
|
|
32
|
+
!(model.nil? || model.to_s.empty? || model.to_s == DEFAULT_MODEL)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Instructions have no flag of their own, so they lead the prompt
|
|
36
|
+
# of a new session.
|
|
37
|
+
def prompt_with_instructions(prompt, system_prompt)
|
|
38
|
+
return prompt if system_prompt.nil? || system_prompt.strip.empty?
|
|
39
|
+
|
|
40
|
+
"<instructions>\n#{system_prompt}\n</instructions>\n\n#{prompt}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Providers
|
|
5
|
+
class Copilot < Provider
|
|
6
|
+
# Raised when the copilot 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,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module Providers
|
|
7
|
+
class Copilot < Provider
|
|
8
|
+
# Reads `copilot --output-format json` events line by line. Message
|
|
9
|
+
# deltas are streamed as Chunks, the last assistant.message is the
|
|
10
|
+
# reply, and session.usage_checkpoint carries the token usage.
|
|
11
|
+
class Response
|
|
12
|
+
USAGE_KEYS = {
|
|
13
|
+
'inputTokens' => :input, 'outputTokens' => :output,
|
|
14
|
+
'cacheReadTokens' => :cache_read, 'cacheWriteTokens' => :cache_write, 'reasoningTokens' => :thinking
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :session_id, :model_id
|
|
18
|
+
|
|
19
|
+
def initialize(model:, session_id:)
|
|
20
|
+
@model = model
|
|
21
|
+
@session_id = session_id
|
|
22
|
+
@content = nil
|
|
23
|
+
@usage = nil
|
|
24
|
+
@error = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param line [String] one line of stdout
|
|
28
|
+
# @yield [RubyLLM::Chunk] a streamed piece of assistant text
|
|
29
|
+
# @return [Hash, nil] the parsed event, or nil for non-JSON lines
|
|
30
|
+
def ingest(line)
|
|
31
|
+
event = self.class.parse(line)
|
|
32
|
+
return nil unless event
|
|
33
|
+
|
|
34
|
+
data = event['data'] || {}
|
|
35
|
+
case event['type']
|
|
36
|
+
when 'assistant.message_delta'
|
|
37
|
+
text = data['deltaContent']
|
|
38
|
+
yield chunk(text) if text && !text.empty? && block_given?
|
|
39
|
+
when 'assistant.message'
|
|
40
|
+
@content = data['content'].to_s
|
|
41
|
+
@model_id = data['model']
|
|
42
|
+
when 'session.usage_checkpoint' then @usage = self.class.usage_from(data)
|
|
43
|
+
when 'error', 'session.error', 'assistant.error' then @error = (data['message'] || data['error'] || data).to_s
|
|
44
|
+
end
|
|
45
|
+
event
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param run [Runner::Result, nil] used for diagnostics when no reply arrived
|
|
49
|
+
# @return [RubyLLM::Message]
|
|
50
|
+
def message(run = nil)
|
|
51
|
+
raise Error, "copilot failed: #{@error}" if @error
|
|
52
|
+
|
|
53
|
+
raise_without_result!(run) if @content.nil?
|
|
54
|
+
|
|
55
|
+
Message.new(
|
|
56
|
+
role: :assistant,
|
|
57
|
+
content: @content,
|
|
58
|
+
model: @model,
|
|
59
|
+
tokens: Tokens.new(**(@usage || {})),
|
|
60
|
+
finish_reason: :stop,
|
|
61
|
+
raw: { 'session_id' => @session_id, 'model' => @model_id, 'usage' => @usage }
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class << self
|
|
66
|
+
def parse(line)
|
|
67
|
+
text = line.to_s.strip
|
|
68
|
+
return nil unless text.start_with?('{')
|
|
69
|
+
|
|
70
|
+
JSON.parse(text)
|
|
71
|
+
rescue JSON::ParserError
|
|
72
|
+
RubyLLM.logger.debug { "copilot: non-JSON output #{text.inspect}" }
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Sums the per-model usage in a usage checkpoint into Tokens kwargs.
|
|
77
|
+
def usage_from(data)
|
|
78
|
+
metrics = data.dig('accountingSnapshot', 'modelMetrics') || {}
|
|
79
|
+
totals = Hash.new(0)
|
|
80
|
+
metrics.each_value do |entry|
|
|
81
|
+
(entry['usage'] || {}).each do |key, value|
|
|
82
|
+
field = USAGE_KEYS[key]
|
|
83
|
+
totals[field] += value.to_i if field
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
totals.empty? ? nil : totals
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def raise_without_result!(run)
|
|
93
|
+
status = run&.status&.exitstatus
|
|
94
|
+
stderr = run&.stderr.to_s.strip
|
|
95
|
+
raise Error,
|
|
96
|
+
"copilot exited#{" (#{status})" if status} without a reply#{": #{stderr}" unless stderr.empty?}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def chunk(text)
|
|
100
|
+
Chunk.new(role: :assistant, content: text, model: @model)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module Providers
|
|
7
|
+
class Copilot < 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 Copilot < 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,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module Providers
|
|
7
|
+
class Copilot < Provider
|
|
8
|
+
# The settings for one completion. Each value comes from, in order of
|
|
9
|
+
# precedence, chat.with_provider_options(copilot: {...}), the
|
|
10
|
+
# RubyLLM.config.copilot_* option, then the default here.
|
|
11
|
+
class Settings
|
|
12
|
+
DEFAULT_COMMAND = 'copilot'
|
|
13
|
+
DEFAULT_TIMEOUT = 600
|
|
14
|
+
|
|
15
|
+
attr_reader :command, :cwd, :timeout, :args, :env, :session_id
|
|
16
|
+
|
|
17
|
+
def initialize(config, provider_options = {})
|
|
18
|
+
opts = self.class.namespaced(provider_options)
|
|
19
|
+
@command = opts.fetch(:command) { config.copilot_command } || DEFAULT_COMMAND
|
|
20
|
+
@cwd = opts.fetch(:cwd) { config.copilot_cwd }
|
|
21
|
+
@timeout = self.class.positive_or_nil(opts.fetch(:timeout) { timeout_default(config) })
|
|
22
|
+
@allow_all_tools = opts.fetch(:allow_all_tools) { config.copilot_allow_all_tools }
|
|
23
|
+
@args = self.class.split_args(opts.fetch(:args) { config.copilot_args })
|
|
24
|
+
@env = self.class.stringify(opts.fetch(:env) { config.copilot_env })
|
|
25
|
+
@session_id = opts[:session_id]
|
|
26
|
+
@resume = opts.fetch(:resume) { config.copilot_resume.nil? || config.copilot_resume }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# True when the agent may run every tool without confirmation.
|
|
30
|
+
def allow_all_tools?
|
|
31
|
+
@allow_all_tools ? true : false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# False when every completion must start a fresh CLI session.
|
|
35
|
+
def resume?
|
|
36
|
+
@resume ? true : false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
# The copilot: sub-hash of provider_options with symbol keys.
|
|
41
|
+
def namespaced(provider_options)
|
|
42
|
+
return {} unless provider_options.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
raw = provider_options[:copilot] || provider_options['copilot'] || {}
|
|
45
|
+
raise ArgumentError, 'provider_options[:copilot] must be a Hash' unless raw.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
raw.transform_keys(&:to_sym)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A positive number of seconds, or nil for "no timeout".
|
|
51
|
+
def positive_or_nil(value)
|
|
52
|
+
return nil if value.nil?
|
|
53
|
+
|
|
54
|
+
number = Float(value)
|
|
55
|
+
number.positive? ? number : nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Extra CLI arguments given as an array or a shell-style string.
|
|
59
|
+
def split_args(value)
|
|
60
|
+
case value
|
|
61
|
+
when nil then []
|
|
62
|
+
when String then Shellwords.split(value)
|
|
63
|
+
else Array(value).map(&:to_s)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def stringify(value)
|
|
68
|
+
(value || {}).to_h.to_h { |key, item| [key.to_s, item.to_s] }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def timeout_default(config)
|
|
75
|
+
config.copilot_timeout.nil? ? DEFAULT_TIMEOUT : config.copilot_timeout
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
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 Copilot < 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-copilot gem. Kept in its own file
|
|
6
|
+
# so the gemspec can read the literal without loading the provider.
|
|
7
|
+
class Copilot < Provider
|
|
8
|
+
VERSION = '0.0.1'
|
|
9
|
+
|
|
10
|
+
def self.version
|
|
11
|
+
VERSION
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ruby_llm'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'ruby_llm/providers/copilot/version'
|
|
6
|
+
require 'ruby_llm/providers/copilot/errors'
|
|
7
|
+
require 'ruby_llm/providers/copilot/settings'
|
|
8
|
+
require 'ruby_llm/providers/copilot/command'
|
|
9
|
+
require 'ruby_llm/providers/copilot/transcript'
|
|
10
|
+
require 'ruby_llm/providers/copilot/session'
|
|
11
|
+
require 'ruby_llm/providers/copilot/runner'
|
|
12
|
+
require 'ruby_llm/providers/copilot/response'
|
|
13
|
+
|
|
14
|
+
module RubyLLM
|
|
15
|
+
module Providers
|
|
16
|
+
# Copilot: the GitHub Copilot CLI agent as a RubyLLM provider.
|
|
17
|
+
#
|
|
18
|
+
# Each completion spawns `copilot` in non-interactive mode, pipes the
|
|
19
|
+
# prompt on stdin and reads its JSON event stream from stdout. The agent
|
|
20
|
+
# keeps its own tools, MCP servers and instructions and works in the
|
|
21
|
+
# current directory (or config.copilot_cwd). Multi-turn chats resume the
|
|
22
|
+
# same session (--resume) as long as the RubyLLM transcript still matches
|
|
23
|
+
# what that session has seen; otherwise a fresh session is started from
|
|
24
|
+
# the whole transcript.
|
|
25
|
+
#
|
|
26
|
+
# require 'ruby_llm/providers/copilot'
|
|
27
|
+
# chat = RubyLLM.chat(model: 'default', provider: :copilot)
|
|
28
|
+
# chat.ask('Summarise the README in this directory')
|
|
29
|
+
class Copilot < Provider
|
|
30
|
+
DEFAULT_MODEL = 'default'
|
|
31
|
+
MODELS = %w[default auto].freeze
|
|
32
|
+
CAPABILITIES = %w[streaming].freeze
|
|
33
|
+
|
|
34
|
+
# Satisfies RubyLLM's protocol lookups; every request goes through
|
|
35
|
+
# #complete below, never through a wire protocol.
|
|
36
|
+
protocol :copilot, 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[copilot_command copilot_cwd copilot_timeout copilot_allow_all_tools copilot_args copilot_env
|
|
44
|
+
copilot_resume]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def configuration_requirements
|
|
48
|
+
[]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def local?
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Any id goes straight to --model, so there is no catalog to check.
|
|
56
|
+
def assume_models_exist?
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The agent runs its own tools; RubyLLM tools cannot be handed to it.
|
|
61
|
+
# Hosts such as aia check this before attaching tools or MCP servers.
|
|
62
|
+
def accepts_tools?
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def display_name
|
|
67
|
+
'GitHub Copilot CLI'
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def model_id(model)
|
|
71
|
+
model.respond_to?(:id) ? model.id : model.to_s
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def effort_for(thinking)
|
|
75
|
+
return nil unless thinking.respond_to?(:effort) && thinking.effort
|
|
76
|
+
|
|
77
|
+
value = thinking.effort.to_s
|
|
78
|
+
Command::EFFORTS.include?(value) ? value : nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def new_session_id
|
|
82
|
+
SecureRandom.uuid
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
attr_reader :session
|
|
87
|
+
|
|
88
|
+
def initialize(config)
|
|
89
|
+
super
|
|
90
|
+
@session = Session.new
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Never contacted; the transport layer only needs a base URL.
|
|
94
|
+
def api_base
|
|
95
|
+
'cli://copilot'
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Messages go to the CLI as text, so there is nothing to upload or
|
|
99
|
+
# convert beforehand.
|
|
100
|
+
def preprocess_messages(messages, **)
|
|
101
|
+
messages
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def discard_missing_uploads(_messages, _error, **)
|
|
105
|
+
[]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# :reek:LongParameterList -- the keyword set is RubyLLM's Provider#complete contract
|
|
109
|
+
def complete(messages, model:, tools: {}, provider_options: {}, schema: nil, thinking: nil,
|
|
110
|
+
temperature: nil, max_output_tokens: nil, before_request: [], **, &block)
|
|
111
|
+
reject_tools!(tools)
|
|
112
|
+
reject_schema!(schema)
|
|
113
|
+
log_ignored(temperature:, max_output_tokens:)
|
|
114
|
+
settings = Settings.new(@config, provider_options)
|
|
115
|
+
plan = session.plan(messages, session_id: settings.session_id, resume: settings.resume?)
|
|
116
|
+
transcript = Transcript.new(plan.messages)
|
|
117
|
+
session_id = plan.resume_id || self.class.new_session_id
|
|
118
|
+
argv = build_argv(settings, model, plan, session_id, thinking)
|
|
119
|
+
prompt = if plan.resume?
|
|
120
|
+
transcript.prompt
|
|
121
|
+
else
|
|
122
|
+
Command.prompt_with_instructions(transcript.prompt,
|
|
123
|
+
transcript.system_prompt)
|
|
124
|
+
end
|
|
125
|
+
before_request.each { |hook| hook.call({ argv: argv, prompt: prompt }) }
|
|
126
|
+
response = Response.new(model: model, session_id: session_id)
|
|
127
|
+
run = runner.run(argv, stdin: prompt, cwd: settings.cwd, timeout: settings.timeout,
|
|
128
|
+
env: settings.env) do |line|
|
|
129
|
+
response.ingest(line) { |chunk| block&.call(chunk) }
|
|
130
|
+
end
|
|
131
|
+
message = response.message(run)
|
|
132
|
+
session.record(messages, message, id: response.session_id)
|
|
133
|
+
message
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# A static catalog: "default" sends no --model flag, "auto" lets
|
|
137
|
+
# Copilot route; any other id is passed through to --model.
|
|
138
|
+
def list_models
|
|
139
|
+
MODELS.map { |id| static_model(id) }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def runner
|
|
145
|
+
self.class.runner || Runner.new
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def build_argv(settings, model, plan, session_id, thinking)
|
|
149
|
+
Command.new(settings).build(
|
|
150
|
+
model: self.class.model_id(model),
|
|
151
|
+
session_id: session_id,
|
|
152
|
+
resume_id: plan.resume_id,
|
|
153
|
+
effort: self.class.effort_for(thinking)
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def static_model(id)
|
|
158
|
+
Model.new(
|
|
159
|
+
id: id,
|
|
160
|
+
name: "GitHub Copilot CLI (#{id})",
|
|
161
|
+
provider: slug,
|
|
162
|
+
capabilities: CAPABILITIES,
|
|
163
|
+
modalities: { input: %w[text], output: %w[text] },
|
|
164
|
+
metadata: { source: 'ruby_llm-providers-copilot' }
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def reject_tools!(tools)
|
|
169
|
+
return if tools.nil? || tools.empty?
|
|
170
|
+
|
|
171
|
+
raise UnsupportedFeatureError,
|
|
172
|
+
'RubyLLM tools cannot be handed to the copilot CLI; the agent brings its own tools. ' \
|
|
173
|
+
'Remove with_tools or choose another provider.'
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def reject_schema!(schema)
|
|
177
|
+
return if schema.nil?
|
|
178
|
+
|
|
179
|
+
raise UnsupportedFeatureError, 'the copilot CLI has no structured-output option; remove with_schema'
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def log_ignored(**options)
|
|
183
|
+
ignored = options.compact.keys
|
|
184
|
+
return if ignored.empty?
|
|
185
|
+
|
|
186
|
+
RubyLLM.logger.debug { "copilot: ignoring #{ignored.join(', ')} (not supported by the copilot CLI)" }
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
RubyLLM::Provider.register :copilot, RubyLLM::Providers::Copilot unless RubyLLM::Provider.resolve(:copilot)
|
metadata
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ruby_llm-providers-copilot
|
|
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 copilot provider to RubyLLM. Prompts are piped to the GitHub Copilot CLI's stdin and its JSON events are read back from stdout, so the agent (with its own tools, MCP servers and resumable sessions) can be used wherever RubyLLM expects a model, e.g. `--model copilot/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/copilot.rb
|
|
47
|
+
- lib/ruby_llm/providers/copilot/command.rb
|
|
48
|
+
- lib/ruby_llm/providers/copilot/errors.rb
|
|
49
|
+
- lib/ruby_llm/providers/copilot/response.rb
|
|
50
|
+
- lib/ruby_llm/providers/copilot/runner.rb
|
|
51
|
+
- lib/ruby_llm/providers/copilot/session.rb
|
|
52
|
+
- lib/ruby_llm/providers/copilot/settings.rb
|
|
53
|
+
- lib/ruby_llm/providers/copilot/transcript.rb
|
|
54
|
+
- lib/ruby_llm/providers/copilot/version.rb
|
|
55
|
+
homepage: "https://github.com/madbomber/ruby_llm-providers-copilot"
|
|
56
|
+
licenses:
|
|
57
|
+
- MIT
|
|
58
|
+
metadata:
|
|
59
|
+
homepage_uri: "https://github.com/madbomber/ruby_llm-providers-copilot"
|
|
60
|
+
source_code_uri: "https://github.com/madbomber/ruby_llm-providers-copilot"
|
|
61
|
+
changelog_uri: "https://github.com/madbomber/ruby_llm-providers-copilot/blob/main/CHANGELOG.md"
|
|
62
|
+
bug_tracker_uri: "https://github.com/madbomber/ruby_llm-providers-copilot/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 GitHub Copilot CLI agent in non-interactive mode.
|
|
83
|
+
test_files: []
|