summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-04-28 14:10:04 +0900
committerAdam Malczewski <[email protected]>2026-04-28 14:10:04 +0900
commit1ec2afaa21b8c3ef336982e80259b9bb79e3fb32 (patch)
tree7cf43fbd4efc6d6834744d800822255fd3d44d05
parente6c2f23b58a1e87088ee75632c74fee4f15f6a94 (diff)
downloaddispatch-adapter-copilot-dev.tar.gz
dispatch-adapter-copilot-dev.zip
updatesdev
-rw-r--r--.gitignore3
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock12
-rwxr-xr-xbin/check32
-rwxr-xr-xbin/install23
-rw-r--r--dispatch-adapter-copilot.gemspec14
-rw-r--r--lib/dispatch/adapter/base.rb31
-rw-r--r--lib/dispatch/adapter/copilot.rb23
-rw-r--r--lib/dispatch/adapter/errors.rb30
-rw-r--r--lib/dispatch/adapter/message.rb31
-rw-r--r--lib/dispatch/adapter/model_info.rb17
-rw-r--r--lib/dispatch/adapter/rate_limiter.rb2
-rw-r--r--lib/dispatch/adapter/response.rb23
-rw-r--r--lib/dispatch/adapter/tool_definition.rb7
-rw-r--r--spec/dispatch/adapter/base_spec.rb41
-rw-r--r--spec/dispatch/adapter/copilot_rate_limiting_spec.rb12
-rw-r--r--spec/dispatch/adapter/copilot_spec.rb260
-rw-r--r--spec/dispatch/adapter/errors_spec.rb69
-rw-r--r--spec/dispatch/adapter/rate_limiter_spec.rb10
-rw-r--r--spec/dispatch/adapter/structs_spec.rb211
20 files changed, 232 insertions, 621 deletions
diff --git a/.gitignore b/.gitignore
index acb6f1e..e9c6a8d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,9 @@
# rspec failure tracking
.rspec_status
+# Test results
+test_results.txt
+
# Built gems
*.gem
diff --git a/Gemfile b/Gemfile
index d6c5fb8..f9e6c7d 100644
--- a/Gemfile
+++ b/Gemfile
@@ -5,6 +5,8 @@ source "https://rubygems.org"
# Specify your gem's dependencies in dispatch-adapter-copilot.gemspec
gemspec
+gem "dispatch-adapter-interface", path: "../dispatch-adapter-interface"
+
gem "irb"
gem "rake", "~> 13.0"
diff --git a/Gemfile.lock b/Gemfile.lock
index ed7e3cb..cbb9a56 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,7 +1,13 @@
PATH
+ remote: ../dispatch-adapter-interface
+ specs:
+ dispatch-adapter-interface (0.1.0)
+
+PATH
remote: .
specs:
- dispatch-adapter-copilot (0.2.0)
+ dispatch-adapter-copilot (0.3.0)
+ dispatch-adapter-interface (~> 0.1)
GEM
remote: https://rubygems.org/
@@ -93,6 +99,7 @@ PLATFORMS
DEPENDENCIES
dispatch-adapter-copilot!
+ dispatch-adapter-interface!
irb
rake (~> 13.0)
rspec (~> 3.0)
@@ -106,7 +113,8 @@ CHECKSUMS
crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e
date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
- dispatch-adapter-copilot (0.2.0)
+ dispatch-adapter-copilot (0.3.0)
+ dispatch-adapter-interface (0.1.0)
erb (6.0.2) sha256=9fe6264d44f79422c87490a1558479bd0e7dad4dd0e317656e67ea3077b5242b
hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
diff --git a/bin/check b/bin/check
new file mode 100755
index 0000000..98a0d79
--- /dev/null
+++ b/bin/check
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+RESULTS_FILE="test_results.txt"
+
+{
+ echo "============================================"
+ echo "dispatch-adapter-copilot — $(date)"
+ echo "============================================"
+ echo ""
+
+ echo "--- bundle install ---"
+ bundle install 2>&1
+ echo ""
+
+ echo "--- rubocop --autocorrect-all ---"
+ bundle exec rubocop --autocorrect-all 2>&1 || true
+ echo ""
+
+ echo "--- rspec ---"
+ bundle exec rspec 2>&1 || true
+ echo ""
+
+ echo "============================================"
+ echo "Done."
+ echo "============================================"
+} | tee "$RESULTS_FILE"
+
+echo ""
+echo "Results written to $RESULTS_FILE"
diff --git a/bin/install b/bin/install
new file mode 100755
index 0000000..5986c4a
--- /dev/null
+++ b/bin/install
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+GEM_NAME="dispatch-adapter-copilot"
+
+echo "--- Building $GEM_NAME ---"
+gem build "$GEM_NAME.gemspec"
+
+GEM_FILE=$(ls -t "$GEM_NAME"-*.gem 2>/dev/null | head -1)
+
+if [ -z "$GEM_FILE" ]; then
+ echo "ERROR: No .gem file found after build."
+ exit 1
+fi
+
+echo ""
+echo "--- Installing $GEM_FILE ---"
+gem install "$GEM_FILE" --local
+
+echo ""
+echo "Done. $GEM_NAME installed."
diff --git a/dispatch-adapter-copilot.gemspec b/dispatch-adapter-copilot.gemspec
index 3ef3cf4..73c29c3 100644
--- a/dispatch-adapter-copilot.gemspec
+++ b/dispatch-adapter-copilot.gemspec
@@ -5,12 +5,12 @@ require_relative "lib/dispatch/adapter/version"
Gem::Specification.new do |spec|
spec.name = "dispatch-adapter-copilot"
spec.version = Dispatch::Adapter::CopilotVersion::VERSION
- spec.authors = ["Adam Malczewski"]
- spec.email = ["[email protected]"]
+ spec.authors = [ "Adam Malczewski" ]
+ spec.email = [ "[email protected]" ]
spec.summary = "GitHub Copilot adapter for Dispatch LLM framework"
- spec.description = "Provider-agnostic LLM adapter interface with a concrete GitHub Copilot implementation using the Copilot API directly over HTTP."
- spec.homepage = "https://github.com/tradam/dispatch-adapter-copilot"
+ spec.description = "GitHub Copilot adapter for the Dispatch LLM framework, implementing the dispatch-adapter-interface to provide chat completions via the Copilot API over HTTP."
+ spec.homepage = "https://github.com/realtradam/dispatch-adapter-copilot"
spec.license = "MIT"
spec.required_ruby_version = ">= 3.2.0"
spec.metadata["homepage_uri"] = spec.homepage
@@ -25,13 +25,13 @@ Gem::Specification.new do |spec|
(f == gemspec) ||
f.start_with?(*%w[bin/ Gemfile .gitignore .rspec spec/ .rubocop.yml])
end
- end
+ end.select { |f| File.exist?(File.join(__dir__, f)) }
spec.bindir = "exe"
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
- spec.require_paths = ["lib"]
+ spec.require_paths = [ "lib" ]
# Uncomment to register a new dependency of your gem
- # spec.add_dependency "example-gem", "~> 1.0"
+ spec.add_dependency "dispatch-adapter-interface", "~> 0.1"
# For more information and examples about making a new gem, check out our
# guide at: https://bundler.io/guides/creating_gem.html
diff --git a/lib/dispatch/adapter/base.rb b/lib/dispatch/adapter/base.rb
deleted file mode 100644
index 4b7a6ed..0000000
--- a/lib/dispatch/adapter/base.rb
+++ /dev/null
@@ -1,31 +0,0 @@
-# frozen_string_literal: true
-
-module Dispatch
- module Adapter
- class Base
- def chat(_messages, system: nil, tools: [], stream: false, max_tokens: nil, thinking: nil, &_block)
- raise NotImplementedError, "#{self.class}#chat must be implemented"
- end
-
- def model_name
- raise NotImplementedError, "#{self.class}#model_name must be implemented"
- end
-
- def count_tokens(_messages, system: nil, tools: []) # rubocop:disable Lint/UnusedMethodArgument
- -1
- end
-
- def list_models
- raise NotImplementedError, "#{self.class}#list_models must be implemented"
- end
-
- def provider_name
- self.class.name
- end
-
- def max_context_tokens
- nil
- end
- end
- end
-end
diff --git a/lib/dispatch/adapter/copilot.rb b/lib/dispatch/adapter/copilot.rb
index d8a54a0..b14c5c0 100644
--- a/lib/dispatch/adapter/copilot.rb
+++ b/lib/dispatch/adapter/copilot.rb
@@ -6,12 +6,7 @@ require "json"
require "securerandom"
require "fileutils"
-require_relative "errors"
-require_relative "message"
-require_relative "response"
-require_relative "tool_definition"
-require_relative "model_info"
-require_relative "base"
+require "dispatch/adapter/interface"
require_relative "rate_limiter"
require_relative "version"
@@ -56,7 +51,7 @@ module Dispatch
VALID_THINKING_LEVELS = %w[low medium high].freeze
- def initialize(model: "gpt-4.1", github_token: nil, token_path: nil, max_tokens: 8192, thinking: nil,
+ def initialize(model: "gpt-4.1", github_token: nil, token_path: nil, max_tokens: 8192, thinking: "high",
min_request_interval: 3.0, rate_limit: nil)
super()
@model = model
@@ -88,9 +83,13 @@ module Dispatch
body = {
model: @model,
messages: wire_messages,
- max_tokens: effective_max_tokens,
stream: stream
}
+ if uses_max_completion_tokens?
+ body[:max_completion_tokens] = effective_max_tokens
+ else
+ body[:max_tokens] = effective_max_tokens
+ end
body[:tools] = wire_tools unless wire_tools.empty?
body[:reasoning_effort] = effective_thinking if effective_thinking
@@ -178,6 +177,10 @@ module Dispatch
)
end
+ def uses_max_completion_tokens?
+ @model.match?(/o[1-9]|gpt-5|gemini/)
+ end
+
def default_token_path
File.join(Dir.home, ".config", "dispatch", "copilot_github_token")
end
@@ -457,13 +460,13 @@ module Dispatch
def merge_consecutive_roles(messages)
return messages if messages.empty?
- merged = [messages.first.dup]
+ merged = [ messages.first.dup ]
messages[1..].each do |msg|
prev = merged.last
if prev[:role] == msg[:role] && prev[:role] != "tool" && !msg.key?(:tool_calls) && !prev.key?(:tool_calls)
- prev[:content] = [prev[:content], msg[:content]].compact.join("\n\n")
+ prev[:content] = [ prev[:content], msg[:content] ].compact.join("\n\n")
else
merged << msg.dup
end
diff --git a/lib/dispatch/adapter/errors.rb b/lib/dispatch/adapter/errors.rb
deleted file mode 100644
index 86f9c14..0000000
--- a/lib/dispatch/adapter/errors.rb
+++ /dev/null
@@ -1,30 +0,0 @@
-# frozen_string_literal: true
-
-module Dispatch
- module Adapter
- class Error < StandardError
- attr_reader :status_code, :provider
-
- def initialize(message = nil, status_code: nil, provider: nil)
- @status_code = status_code
- @provider = provider
- super(message)
- end
- end
-
- class AuthenticationError < Error; end
-
- class RateLimitError < Error
- attr_reader :retry_after
-
- def initialize(message = nil, status_code: nil, provider: nil, retry_after: nil)
- @retry_after = retry_after
- super(message, status_code:, provider:)
- end
- end
-
- class ServerError < Error; end
- class RequestError < Error; end
- class ConnectionError < Error; end
- end
-end
diff --git a/lib/dispatch/adapter/message.rb b/lib/dispatch/adapter/message.rb
deleted file mode 100644
index eb51c99..0000000
--- a/lib/dispatch/adapter/message.rb
+++ /dev/null
@@ -1,31 +0,0 @@
-# frozen_string_literal: true
-
-module Dispatch
- module Adapter
- Message = Struct.new(:role, :content, keyword_init: true)
-
- TextBlock = Struct.new(:type, :text, keyword_init: true) do
- def initialize(text:, type: "text")
- super(type:, text:)
- end
- end
-
- ImageBlock = Struct.new(:type, :source, :media_type, keyword_init: true) do
- def initialize(source:, media_type:, type: "image")
- super(type:, source:, media_type:)
- end
- end
-
- ToolUseBlock = Struct.new(:type, :id, :name, :arguments, keyword_init: true) do
- def initialize(id:, name:, arguments:, type: "tool_use")
- super(type:, id:, name:, arguments:)
- end
- end
-
- ToolResultBlock = Struct.new(:type, :tool_use_id, :content, :is_error, keyword_init: true) do
- def initialize(tool_use_id:, content:, is_error: false, type: "tool_result")
- super(type:, tool_use_id:, content:, is_error:)
- end
- end
- end
-end
diff --git a/lib/dispatch/adapter/model_info.rb b/lib/dispatch/adapter/model_info.rb
deleted file mode 100644
index 8ba2977..0000000
--- a/lib/dispatch/adapter/model_info.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-module Dispatch
- module Adapter
- ModelInfo = Struct.new(
- :id, :name, :max_context_tokens,
- :supports_vision, :supports_tool_use, :supports_streaming,
- :premium_request_multiplier,
- keyword_init: true
- ) do
- def initialize(id:, name:, max_context_tokens:, supports_vision:, supports_tool_use:, supports_streaming:,
- premium_request_multiplier: nil)
- super
- end
- end
- end
-end
diff --git a/lib/dispatch/adapter/rate_limiter.rb b/lib/dispatch/adapter/rate_limiter.rb
index 8d0789e..1b05582 100644
--- a/lib/dispatch/adapter/rate_limiter.rb
+++ b/lib/dispatch/adapter/rate_limiter.rb
@@ -90,7 +90,7 @@ module Dispatch
def compute_wait(state, now)
cooldown_wait = compute_cooldown_wait(state, now)
window_wait = compute_window_wait(state, now)
- [cooldown_wait, window_wait].max
+ [ cooldown_wait, window_wait ].max
end
def compute_cooldown_wait(state, now)
diff --git a/lib/dispatch/adapter/response.rb b/lib/dispatch/adapter/response.rb
deleted file mode 100644
index b4ba3eb..0000000
--- a/lib/dispatch/adapter/response.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-module Dispatch
- module Adapter
- Response = Struct.new(:content, :tool_calls, :model, :stop_reason, :usage, keyword_init: true) do
- def initialize(model:, stop_reason:, usage:, content: nil, tool_calls: [])
- super
- end
- end
-
- Usage = Struct.new(:input_tokens, :output_tokens, :cache_read_tokens, :cache_creation_tokens, keyword_init: true) do
- def initialize(input_tokens:, output_tokens:, cache_read_tokens: 0, cache_creation_tokens: 0)
- super
- end
- end
-
- StreamDelta = Struct.new(:type, :text, :tool_call_id, :tool_name, :argument_delta, keyword_init: true) do
- def initialize(type:, text: nil, tool_call_id: nil, tool_name: nil, argument_delta: nil)
- super
- end
- end
- end
-end
diff --git a/lib/dispatch/adapter/tool_definition.rb b/lib/dispatch/adapter/tool_definition.rb
deleted file mode 100644
index 7b435a3..0000000
--- a/lib/dispatch/adapter/tool_definition.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-# frozen_string_literal: true
-
-module Dispatch
- module Adapter
- ToolDefinition = Struct.new(:name, :description, :parameters, keyword_init: true)
- end
-end
diff --git a/spec/dispatch/adapter/base_spec.rb b/spec/dispatch/adapter/base_spec.rb
deleted file mode 100644
index 8c3a279..0000000
--- a/spec/dispatch/adapter/base_spec.rb
+++ /dev/null
@@ -1,41 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.describe Dispatch::Adapter::Base do
- subject(:base) { described_class.new }
-
- describe "#chat" do
- it "raises NotImplementedError" do
- expect { base.chat([]) }.to raise_error(NotImplementedError, /chat must be implemented/)
- end
- end
-
- describe "#model_name" do
- it "raises NotImplementedError" do
- expect { base.model_name }.to raise_error(NotImplementedError, /model_name must be implemented/)
- end
- end
-
- describe "#count_tokens" do
- it "returns -1" do
- expect(base.count_tokens([])).to eq(-1)
- end
- end
-
- describe "#list_models" do
- it "raises NotImplementedError" do
- expect { base.list_models }.to raise_error(NotImplementedError, /list_models must be implemented/)
- end
- end
-
- describe "#provider_name" do
- it "returns the class name" do
- expect(base.provider_name).to eq("Dispatch::Adapter::Base")
- end
- end
-
- describe "#max_context_tokens" do
- it "returns nil" do
- expect(base.max_context_tokens).to be_nil
- end
- end
-end
diff --git a/spec/dispatch/adapter/copilot_rate_limiting_spec.rb b/spec/dispatch/adapter/copilot_rate_limiting_spec.rb
index a51ed2b..6a7ef2b 100644
--- a/spec/dispatch/adapter/copilot_rate_limiting_spec.rb
+++ b/spec/dispatch/adapter/copilot_rate_limiting_spec.rb
@@ -12,12 +12,12 @@ RSpec.describe Dispatch::Adapter::Copilot, "rate limiting" do
let(:chat_response_body) do
JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
})
end
- let(:messages) { [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] }
+ let(:messages) { [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ] }
before do
stub_request(:get, "https://api.github.com/copilot_internal/v2/token")
@@ -181,8 +181,8 @@ RSpec.describe Dispatch::Adapter::Copilot, "rate limiting" do
describe "#chat streaming with rate limiting" do
it "calls wait! before a streaming request" do
sse_body = [
- "data: #{JSON.generate({ "choices" => [{ "delta" => { "content" => "hi" }, "index" => 0 }] })}\n\n",
- "data: #{JSON.generate({ "choices" => [{ "delta" => {}, "index" => 0, "finish_reason" => "stop" }],
+ "data: #{JSON.generate({ "choices" => [ { "delta" => { "content" => "hi" }, "index" => 0 } ] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [ { "delta" => {}, "index" => 0, "finish_reason" => "stop" } ],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 } })}\n\n",
"data: [DONE]\n\n"
].join
@@ -211,12 +211,12 @@ RSpec.describe Dispatch::Adapter::Copilot, "rate limiting" do
.to_return(
status: 200,
body: JSON.generate({
- "data" => [{
+ "data" => [ {
"id" => "gpt-4.1",
"name" => "GPT 4.1",
"model_picker_enabled" => true,
"capabilities" => { "type" => "chat", "supports" => {} }
- }]
+ } ]
}),
headers: { "Content-Type" => "application/json" }
)
diff --git a/spec/dispatch/adapter/copilot_spec.rb b/spec/dispatch/adapter/copilot_spec.rb
index 6e14728..f3ccb37 100644
--- a/spec/dispatch/adapter/copilot_spec.rb
+++ b/spec/dispatch/adapter/copilot_spec.rb
@@ -37,7 +37,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
describe "VERSION" do
it "is accessible" do
- expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.2.0")
+expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
end
end
@@ -73,11 +73,11 @@ RSpec.describe Dispatch::Adapter::Copilot do
body: JSON.generate({
"id" => "chatcmpl-123",
"model" => "gpt-4.1",
- "choices" => [{
+ "choices" => [ {
"index" => 0,
"message" => { "role" => "assistant", "content" => "Hello there!" },
"finish_reason" => "stop"
- }],
+ } ],
"usage" => { "prompt_tokens" => 10, "completion_tokens" => 5 }
}),
headers: { "Content-Type" => "application/json" }
@@ -85,7 +85,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
end
it "returns a Response with content" do
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
response = adapter.chat(messages)
expect(response).to be_a(Dispatch::Adapter::Response)
@@ -106,22 +106,22 @@ RSpec.describe Dispatch::Adapter::Copilot do
body: JSON.generate({
"id" => "chatcmpl-456",
"model" => "gpt-4.1",
- "choices" => [{
+ "choices" => [ {
"index" => 0,
"message" => {
"role" => "assistant",
"content" => nil,
- "tool_calls" => [{
+ "tool_calls" => [ {
"id" => "call_abc",
"type" => "function",
"function" => {
"name" => "get_weather",
"arguments" => '{"city":"New York"}'
}
- }]
+ } ]
},
"finish_reason" => "tool_calls"
- }],
+ } ],
"usage" => { "prompt_tokens" => 15, "completion_tokens" => 10 }
}),
headers: { "Content-Type" => "application/json" }
@@ -129,7 +129,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
end
it "returns a Response with tool_calls as ToolUseBlock array" do
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "What's the weather?")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "What's the weather?") ]
response = adapter.chat(messages)
expect(response.content).to be_nil
@@ -150,7 +150,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{
+ "choices" => [ {
"index" => 0,
"message" => {
"role" => "assistant",
@@ -169,7 +169,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
]
},
"finish_reason" => "tool_calls"
- }],
+ } ],
"usage" => { "prompt_tokens" => 20, "completion_tokens" => 15 }
}),
headers: { "Content-Type" => "application/json" }
@@ -177,7 +177,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
end
it "returns multiple ToolUseBlocks" do
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "weather and time?")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "weather and time?") ]
response = adapter.chat(messages)
expect(response.tool_calls.size).to eq(2)
@@ -197,22 +197,22 @@ RSpec.describe Dispatch::Adapter::Copilot do
body: JSON.generate({
"id" => "chatcmpl-789",
"model" => "gpt-4.1",
- "choices" => [{
+ "choices" => [ {
"index" => 0,
"message" => {
"role" => "assistant",
"content" => "Let me check that for you.",
- "tool_calls" => [{
+ "tool_calls" => [ {
"id" => "call_def",
"type" => "function",
"function" => {
"name" => "search",
"arguments" => '{"query":"Ruby gems"}'
}
- }]
+ } ]
},
"finish_reason" => "tool_calls"
- }],
+ } ],
"usage" => { "prompt_tokens" => 20, "completion_tokens" => 15 }
}),
headers: { "Content-Type" => "application/json" }
@@ -220,7 +220,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
end
it "returns both content and tool_calls" do
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Search for Ruby gems")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Search for Ruby gems") ]
response = adapter.chat(messages)
expect(response.content).to eq("Let me check that for you.")
@@ -239,13 +239,13 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "OK" }, "finish_reason" => "stop" }],
+ "choices" => [ { "message" => { "content" => "OK" }, "finish_reason" => "stop" } ],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
adapter.chat(messages, system: "You are helpful.")
expect(stub).to have_been_requested
@@ -262,13 +262,13 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "short" }, "finish_reason" => "stop" }],
+ "choices" => [ { "message" => { "content" => "short" }, "finish_reason" => "stop" } ],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
adapter.chat(messages, max_tokens: 100)
expect(stub).to have_been_requested
@@ -283,13 +283,13 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
adapter.chat(messages)
expect(stub).to have_been_requested
@@ -307,26 +307,26 @@ RSpec.describe Dispatch::Adapter::Copilot do
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.with do |req|
body = JSON.parse(req.body)
- body["tools"] == [{
+ body["tools"] == [ {
"type" => "function",
"function" => {
"name" => "get_weather",
"description" => "Get weather for a city",
"parameters" => { "type" => "object", "properties" => { "city" => { "type" => "string" } } }
}
- }]
+ } ]
end
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "weather?")]
- adapter.chat(messages, tools: [tool])
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "weather?") ]
+ adapter.chat(messages, tools: [ tool ])
expect(stub).to have_been_requested
end
@@ -341,26 +341,26 @@ RSpec.describe Dispatch::Adapter::Copilot do
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.with do |req|
body = JSON.parse(req.body)
- body["tools"] == [{
+ body["tools"] == [ {
"type" => "function",
"function" => {
"name" => "get_weather",
"description" => "Get weather for a city",
"parameters" => { "type" => "object", "properties" => { "city" => { "type" => "string" } } }
}
- }]
+ } ]
end
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "weather?")]
- adapter.chat(messages, tools: [tool_hash])
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "weather?") ]
+ adapter.chat(messages, tools: [ tool_hash ])
expect(stub).to have_been_requested
end
@@ -375,26 +375,26 @@ RSpec.describe Dispatch::Adapter::Copilot do
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.with do |req|
body = JSON.parse(req.body)
- body["tools"] == [{
+ body["tools"] == [ {
"type" => "function",
"function" => {
"name" => "get_weather",
"description" => "Get weather for a city",
"parameters" => { "type" => "object", "properties" => { "city" => { "type" => "string" } } }
}
- }]
+ } ]
end
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "weather?")]
- adapter.chat(messages, tools: [tool_hash])
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "weather?") ]
+ adapter.chat(messages, tools: [ tool_hash ])
expect(stub).to have_been_requested
end
@@ -421,14 +421,14 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "both?")]
- adapter.chat(messages, tools: [tool_struct, tool_hash])
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "both?") ]
+ adapter.chat(messages, tools: [ tool_struct, tool_hash ])
expect(stub).to have_been_requested
end
@@ -442,13 +442,13 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
adapter.chat(messages)
expect(stub).to have_been_requested
@@ -466,8 +466,8 @@ RSpec.describe Dispatch::Adapter::Copilot do
messages = [
Dispatch::Adapter::Message.new(role: "user", content: "What's the weather?"),
- Dispatch::Adapter::Message.new(role: "assistant", content: [tool_use]),
- Dispatch::Adapter::Message.new(role: "user", content: [tool_result])
+ Dispatch::Adapter::Message.new(role: "assistant", content: [ tool_use ]),
+ Dispatch::Adapter::Message.new(role: "user", content: [ tool_result ])
]
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
@@ -488,8 +488,8 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "It's 72F and sunny in NYC!" },
- "finish_reason" => "stop" }],
+ "choices" => [ { "message" => { "content" => "It's 72F and sunny in NYC!" },
+ "finish_reason" => "stop" } ],
"usage" => { "prompt_tokens" => 20, "completion_tokens" => 10 }
}),
headers: { "Content-Type" => "application/json" }
@@ -503,7 +503,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
context "with ImageBlock" do
it "raises NotImplementedError" do
image = Dispatch::Adapter::ImageBlock.new(source: "base64data", media_type: "image/png")
- messages = [Dispatch::Adapter::Message.new(role: "user", content: [image])]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: [ image ]) ]
expect { adapter.chat(messages) }.to raise_error(NotImplementedError, /ImageBlock/)
end
@@ -515,7 +515,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
Dispatch::Adapter::TextBlock.new(text: "First paragraph."),
Dispatch::Adapter::TextBlock.new(text: "Second paragraph.")
]
- messages = [Dispatch::Adapter::Message.new(role: "user", content: text_blocks)]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: text_blocks) ]
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.with do |req|
@@ -526,7 +526,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
@@ -552,8 +552,8 @@ RSpec.describe Dispatch::Adapter::Copilot do
messages = [
Dispatch::Adapter::Message.new(role: "user", content: "search"),
- Dispatch::Adapter::Message.new(role: "assistant", content: [tool_use]),
- Dispatch::Adapter::Message.new(role: "user", content: [tool_result])
+ Dispatch::Adapter::Message.new(role: "assistant", content: [ tool_use ]),
+ Dispatch::Adapter::Message.new(role: "user", content: [ tool_result ])
]
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
@@ -566,7 +566,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
"usage" => { "prompt_tokens" => 10, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
@@ -588,8 +588,8 @@ RSpec.describe Dispatch::Adapter::Copilot do
messages = [
Dispatch::Adapter::Message.new(role: "user", content: "do it"),
- Dispatch::Adapter::Message.new(role: "assistant", content: [tool_use]),
- Dispatch::Adapter::Message.new(role: "user", content: [tool_result])
+ Dispatch::Adapter::Message.new(role: "assistant", content: [ tool_use ]),
+ Dispatch::Adapter::Message.new(role: "user", content: [ tool_result ])
]
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
@@ -602,8 +602,8 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "I see the error" },
- "finish_reason" => "stop" }],
+ "choices" => [ { "message" => { "content" => "I see the error" },
+ "finish_reason" => "stop" } ],
"usage" => { "prompt_tokens" => 10, "completion_tokens" => 3 }
}),
headers: { "Content-Type" => "application/json" }
@@ -620,16 +620,16 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{
+ "choices" => [ {
"message" => { "content" => "truncated output..." },
"finish_reason" => "length"
- }],
+ } ],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 100 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Write a long essay")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Write a long essay") ]
response = adapter.chat(messages)
expect(response.stop_reason).to eq(:max_tokens)
@@ -646,7 +646,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
messages = [
Dispatch::Adapter::Message.new(role: "user", content: "lookup 42"),
- Dispatch::Adapter::Message.new(role: "assistant", content: [text, tool_use])
+ Dispatch::Adapter::Message.new(role: "assistant", content: [ text, tool_use ])
]
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
@@ -662,7 +662,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
"usage" => { "prompt_tokens" => 10, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
@@ -689,7 +689,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
@@ -710,14 +710,14 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "thought deeply" },
- "finish_reason" => "stop" }],
+ "choices" => [ { "message" => { "content" => "thought deeply" },
+ "finish_reason" => "stop" } ],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 3 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Think hard")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Think hard") ]
adapter.chat(messages, thinking: "high")
expect(stub).to have_been_requested
@@ -739,13 +739,13 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
thinking_adapter.chat(messages)
expect(stub).to have_been_requested
@@ -767,13 +767,13 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
thinking_adapter.chat(messages, thinking: "low")
expect(stub).to have_been_requested
@@ -788,13 +788,13 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
adapter.chat(messages)
expect(stub).to have_been_requested
@@ -807,7 +807,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
end
it "raises ArgumentError for invalid per-call thinking level" do
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
expect do
adapter.chat(messages, thinking: "extreme")
end.to raise_error(ArgumentError, /Invalid thinking level/)
@@ -829,13 +829,13 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
thinking_adapter.chat(messages, thinking: nil)
expect(stub).to have_been_requested
@@ -846,9 +846,9 @@ RSpec.describe Dispatch::Adapter::Copilot do
describe "#chat with streaming" do
it "yields StreamDelta objects and returns Response" do
sse_body = [
- "data: #{JSON.generate({ "choices" => [{ "delta" => { "content" => "Hello" }, "index" => 0 }] })}\n\n",
- "data: #{JSON.generate({ "choices" => [{ "delta" => { "content" => " world" }, "index" => 0 }] })}\n\n",
- "data: #{JSON.generate({ "choices" => [{ "delta" => {}, "index" => 0, "finish_reason" => "stop" }],
+ "data: #{JSON.generate({ "choices" => [ { "delta" => { "content" => "Hello" }, "index" => 0 } ] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [ { "delta" => { "content" => " world" }, "index" => 0 } ] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [ { "delta" => {}, "index" => 0, "finish_reason" => "stop" } ],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 2 } })}\n\n",
"data: [DONE]\n\n"
].join
@@ -861,7 +861,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
headers: { "Content-Type" => "text/event-stream" }
)
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
deltas = []
response = adapter.chat(messages, stream: true) { |delta| deltas << delta }
@@ -878,20 +878,20 @@ RSpec.describe Dispatch::Adapter::Copilot do
it "yields tool_use_start and tool_use_delta for tool call streams" do
sse_body = [
- "data: #{JSON.generate({ "choices" => [{
- "delta" => { "tool_calls" => [{ "index" => 0, "id" => "call_1", "type" => "function",
- "function" => { "name" => "search", "arguments" => "" } }] }, "index" => 0
- }] })}\n\n",
- "data: #{JSON.generate({ "choices" => [{
- "delta" => { "tool_calls" => [{ "index" => 0,
- "function" => { "arguments" => "{\"q\":" } }] }, "index" => 0
- }] })}\n\n",
- "data: #{JSON.generate({ "choices" => [{
- "delta" => { "tool_calls" => [{ "index" => 0,
- "function" => { "arguments" => "\"test\"}" } }] }, "index" => 0
- }] })}\n\n",
- "data: #{JSON.generate({ "choices" => [{ "delta" => {}, "index" => 0,
- "finish_reason" => "tool_calls" }] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [ {
+ "delta" => { "tool_calls" => [ { "index" => 0, "id" => "call_1", "type" => "function",
+ "function" => { "name" => "search", "arguments" => "" } } ] }, "index" => 0
+ } ] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [ {
+ "delta" => { "tool_calls" => [ { "index" => 0,
+ "function" => { "arguments" => "{\"q\":" } } ] }, "index" => 0
+ } ] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [ {
+ "delta" => { "tool_calls" => [ { "index" => 0,
+ "function" => { "arguments" => "\"test\"}" } } ] }, "index" => 0
+ } ] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [ { "delta" => {}, "index" => 0,
+ "finish_reason" => "tool_calls" } ] })}\n\n",
"data: [DONE]\n\n"
].join
@@ -902,7 +902,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
headers: { "Content-Type" => "text/event-stream" }
)
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "search")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "search") ]
deltas = []
response = adapter.chat(messages, stream: true) { |delta| deltas << delta }
@@ -923,8 +923,8 @@ RSpec.describe Dispatch::Adapter::Copilot do
it "captures usage from streaming response" do
sse_body = [
- "data: #{JSON.generate({ "choices" => [{ "delta" => { "content" => "hi" }, "index" => 0 }] })}\n\n",
- "data: #{JSON.generate({ "choices" => [{ "delta" => {}, "index" => 0, "finish_reason" => "stop" }],
+ "data: #{JSON.generate({ "choices" => [ { "delta" => { "content" => "hi" }, "index" => 0 } ] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [ { "delta" => {}, "index" => 0, "finish_reason" => "stop" } ],
"usage" => { "prompt_tokens" => 42, "completion_tokens" => 7 } })}\n\n",
"data: [DONE]\n\n"
].join
@@ -936,7 +936,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
headers: { "Content-Type" => "text/event-stream" }
)
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
response = adapter.chat(messages, stream: true) { |_delta| nil }
expect(response.usage.input_tokens).to eq(42)
@@ -945,31 +945,31 @@ RSpec.describe Dispatch::Adapter::Copilot do
it "handles multiple parallel tool calls in a stream" do
sse_body = [
- "data: #{JSON.generate({ "choices" => [{
- "delta" => { "tool_calls" => [{ "index" => 0, "id" => "call_a", "type" => "function",
- "function" => { "name" => "tool_a", "arguments" => "" } }] }, "index" => 0
- }] })}\n\n",
- "data: #{JSON.generate({ "choices" => [{
- "delta" => { "tool_calls" => [{ "index" => 1, "id" => "call_b", "type" => "function",
- "function" => { "name" => "tool_b", "arguments" => "" } }] }, "index" => 0
- }] })}\n\n",
- "data: #{JSON.generate({ "choices" => [{
- "delta" => { "tool_calls" => [{ "index" => 0,
- "function" => { "arguments" => "{\"x\":1}" } }] }, "index" => 0
- }] })}\n\n",
- "data: #{JSON.generate({ "choices" => [{
- "delta" => { "tool_calls" => [{ "index" => 1,
- "function" => { "arguments" => "{\"y\":2}" } }] }, "index" => 0
- }] })}\n\n",
- "data: #{JSON.generate({ "choices" => [{ "delta" => {}, "index" => 0,
- "finish_reason" => "tool_calls" }] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [ {
+ "delta" => { "tool_calls" => [ { "index" => 0, "id" => "call_a", "type" => "function",
+ "function" => { "name" => "tool_a", "arguments" => "" } } ] }, "index" => 0
+ } ] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [ {
+ "delta" => { "tool_calls" => [ { "index" => 1, "id" => "call_b", "type" => "function",
+ "function" => { "name" => "tool_b", "arguments" => "" } } ] }, "index" => 0
+ } ] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [ {
+ "delta" => { "tool_calls" => [ { "index" => 0,
+ "function" => { "arguments" => "{\"x\":1}" } } ] }, "index" => 0
+ } ] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [ {
+ "delta" => { "tool_calls" => [ { "index" => 1,
+ "function" => { "arguments" => "{\"y\":2}" } } ] }, "index" => 0
+ } ] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [ { "delta" => {}, "index" => 0,
+ "finish_reason" => "tool_calls" } ] })}\n\n",
"data: [DONE]\n\n"
].join
stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.to_return(status: 200, body: sse_body, headers: { "Content-Type" => "text/event-stream" })
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "do both")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "do both") ]
deltas = []
response = adapter.chat(messages, stream: true) { |d| deltas << d }
@@ -999,14 +999,14 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" },
- "finish_reason" => "stop" }],
+ "choices" => [ { "message" => { "content" => "ok" },
+ "finish_reason" => "stop" } ],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
adapter.chat(messages)
adapter.chat(messages)
@@ -1025,7 +1025,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
)
fresh_adapter = described_class.new(model: "gpt-4.1", github_token: "bad_token", max_tokens: 4096)
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
expect { fresh_adapter.chat(messages) }.to raise_error(Dispatch::Adapter::AuthenticationError)
end
@@ -1277,7 +1277,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.to_return(status: 401, body: JSON.generate({ "error" => { "message" => "Unauthorized" } }))
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::AuthenticationError) { |e|
expect(e.status_code).to eq(401)
expect(e.provider).to eq("GitHub Copilot")
@@ -1288,7 +1288,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.to_return(status: 403, body: JSON.generate({ "error" => { "message" => "Forbidden" } }))
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::AuthenticationError)
end
@@ -1300,7 +1300,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
headers: { "Retry-After" => "30" }
)
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::RateLimitError) { |e|
expect(e.status_code).to eq(429)
expect(e.retry_after).to eq(30)
@@ -1311,7 +1311,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.to_return(status: 400, body: JSON.generate({ "error" => { "message" => "Bad request" } }))
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::RequestError) { |e|
expect(e.status_code).to eq(400)
}
@@ -1321,7 +1321,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.to_return(status: 422, body: JSON.generate({ "error" => { "message" => "Unprocessable" } }))
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::RequestError)
end
@@ -1329,7 +1329,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.to_return(status: 500, body: JSON.generate({ "error" => { "message" => "Internal error" } }))
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ServerError) { |e|
expect(e.status_code).to eq(500)
}
@@ -1339,7 +1339,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.to_return(status: 502, body: "Bad Gateway")
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ServerError)
end
@@ -1347,7 +1347,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.to_return(status: 503, body: "Service Unavailable")
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ServerError)
end
@@ -1355,7 +1355,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.to_raise(Errno::ECONNREFUSED.new("Connection refused"))
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ConnectionError) { |e|
expect(e.provider).to eq("GitHub Copilot")
}
@@ -1365,7 +1365,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.to_raise(Net::OpenTimeout.new("execution expired"))
- messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ConnectionError)
end
end
diff --git a/spec/dispatch/adapter/errors_spec.rb b/spec/dispatch/adapter/errors_spec.rb
deleted file mode 100644
index 906a4c2..0000000
--- a/spec/dispatch/adapter/errors_spec.rb
+++ /dev/null
@@ -1,69 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.describe Dispatch::Adapter::Error do
- it "carries message, status_code, and provider" do
- error = described_class.new("test error", status_code: 500, provider: "TestProvider")
- expect(error.message).to eq("test error")
- expect(error.status_code).to eq(500)
- expect(error.provider).to eq("TestProvider")
- end
-
- it "defaults status_code and provider to nil" do
- error = described_class.new("simple error")
- expect(error.status_code).to be_nil
- expect(error.provider).to be_nil
- end
-
- it "inherits from StandardError" do
- expect(described_class.ancestors).to include(StandardError)
- end
-
- it "can be rescued as StandardError" do
- expect do
- raise described_class, "test"
- end.to raise_error(StandardError)
- end
-end
-
-RSpec.describe Dispatch::Adapter::AuthenticationError do
- it "inherits from Error" do
- expect(described_class.ancestors).to include(Dispatch::Adapter::Error)
- end
-end
-
-RSpec.describe Dispatch::Adapter::RateLimitError do
- it "carries retry_after" do
- error = described_class.new("rate limited", status_code: 429, provider: "Test", retry_after: 30)
- expect(error.retry_after).to eq(30)
- expect(error.status_code).to eq(429)
- end
-
- it "defaults retry_after to nil" do
- error = described_class.new("rate limited")
- expect(error.retry_after).to be_nil
- end
-
- it "is rescuable as Dispatch::Adapter::Error" do
- expect do
- raise described_class, "rate limited"
- end.to raise_error(Dispatch::Adapter::Error)
- end
-end
-
-RSpec.describe Dispatch::Adapter::ServerError do
- it "inherits from Error" do
- expect(described_class.ancestors).to include(Dispatch::Adapter::Error)
- end
-end
-
-RSpec.describe Dispatch::Adapter::RequestError do
- it "inherits from Error" do
- expect(described_class.ancestors).to include(Dispatch::Adapter::Error)
- end
-end
-
-RSpec.describe Dispatch::Adapter::ConnectionError do
- it "inherits from Error" do
- expect(described_class.ancestors).to include(Dispatch::Adapter::Error)
- end
-end
diff --git a/spec/dispatch/adapter/rate_limiter_spec.rb b/spec/dispatch/adapter/rate_limiter_spec.rb
index 5fcf92f..1e4e501 100644
--- a/spec/dispatch/adapter/rate_limiter_spec.rb
+++ b/spec/dispatch/adapter/rate_limiter_spec.rb
@@ -240,7 +240,7 @@ RSpec.describe Dispatch::Adapter::RateLimiter do
now = Time.now.to_f
state = {
"last_request_at" => now,
- "request_log" => [now - 2.0, now - 1.0, now]
+ "request_log" => [ now - 2.0, now - 1.0, now ]
}
File.write(rate_limit_path, JSON.generate(state))
@@ -252,7 +252,7 @@ RSpec.describe Dispatch::Adapter::RateLimiter do
now = Time.now.to_f
state = {
"last_request_at" => now - 5.0,
- "request_log" => [now - 15.0, now - 12.0, now - 5.0]
+ "request_log" => [ now - 15.0, now - 12.0, now - 5.0 ]
}
File.write(rate_limit_path, JSON.generate(state))
@@ -264,7 +264,7 @@ RSpec.describe Dispatch::Adapter::RateLimiter do
now = Time.now.to_f
state = {
"last_request_at" => now - 5.0,
- "request_log" => [now - 20.0, now - 15.0, now - 5.0]
+ "request_log" => [ now - 20.0, now - 15.0, now - 5.0 ]
}
File.write(rate_limit_path, JSON.generate(state))
@@ -299,7 +299,7 @@ RSpec.describe Dispatch::Adapter::RateLimiter do
now = Time.now.to_f
state = {
"last_request_at" => now - 2.0, # cooldown elapsed
- "request_log" => [now - 3.0, now - 2.5, now - 2.0] # window full
+ "request_log" => [ now - 3.0, now - 2.5, now - 2.0 ] # window full
}
File.write(rate_limit_path, JSON.generate(state))
@@ -380,7 +380,7 @@ RSpec.describe Dispatch::Adapter::RateLimiter do
it "reads state written by another process" do
# Simulate another process having made a request just now
now = Time.now.to_f
- state = { "last_request_at" => now, "request_log" => [now] }
+ state = { "last_request_at" => now, "request_log" => [ now ] }
FileUtils.mkdir_p(File.dirname(rate_limit_path))
File.write(rate_limit_path, JSON.generate(state))
diff --git a/spec/dispatch/adapter/structs_spec.rb b/spec/dispatch/adapter/structs_spec.rb
deleted file mode 100644
index ef8ec73..0000000
--- a/spec/dispatch/adapter/structs_spec.rb
+++ /dev/null
@@ -1,211 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.describe Dispatch::Adapter do
- describe "Message" do
- it "creates with keyword args" do
- msg = Dispatch::Adapter::Message.new(role: "user", content: "Hello")
- expect(msg.role).to eq("user")
- expect(msg.content).to eq("Hello")
- end
-
- it "accepts array content" do
- blocks = [Dispatch::Adapter::TextBlock.new(text: "hi")]
- msg = Dispatch::Adapter::Message.new(role: "user", content: blocks)
- expect(msg.content).to be_an(Array)
- expect(msg.content.first.text).to eq("hi")
- end
- end
-
- describe "TextBlock" do
- it "defaults type to 'text'" do
- block = Dispatch::Adapter::TextBlock.new(text: "hello")
- expect(block.type).to eq("text")
- expect(block.text).to eq("hello")
- end
- end
-
- describe "ImageBlock" do
- it "defaults type to 'image'" do
- block = Dispatch::Adapter::ImageBlock.new(source: "data:image/png;base64,abc", media_type: "image/png")
- expect(block.type).to eq("image")
- expect(block.source).to eq("data:image/png;base64,abc")
- expect(block.media_type).to eq("image/png")
- end
- end
-
- describe "ToolUseBlock" do
- it "defaults type to 'tool_use'" do
- block = Dispatch::Adapter::ToolUseBlock.new(id: "call_1", name: "get_weather", arguments: { "city" => "NYC" })
- expect(block.type).to eq("tool_use")
- expect(block.id).to eq("call_1")
- expect(block.name).to eq("get_weather")
- expect(block.arguments).to eq({ "city" => "NYC" })
- end
- end
-
- describe "ToolResultBlock" do
- it "defaults type to 'tool_result' and is_error to false" do
- block = Dispatch::Adapter::ToolResultBlock.new(tool_use_id: "call_1", content: "72F")
- expect(block.type).to eq("tool_result")
- expect(block.tool_use_id).to eq("call_1")
- expect(block.content).to eq("72F")
- expect(block.is_error).to be(false)
- end
-
- it "accepts is_error flag" do
- block = Dispatch::Adapter::ToolResultBlock.new(tool_use_id: "call_1", content: "Error", is_error: true)
- expect(block.is_error).to be(true)
- end
- end
-
- describe "ToolDefinition" do
- it "creates with keyword args" do
- td = Dispatch::Adapter::ToolDefinition.new(
- name: "search",
- description: "Search the web",
- parameters: { "type" => "object", "properties" => {} }
- )
- expect(td.name).to eq("search")
- expect(td.description).to eq("Search the web")
- expect(td.parameters).to be_a(Hash)
- end
- end
-
- describe "Response" do
- it "creates with defaults" do
- usage = Dispatch::Adapter::Usage.new(input_tokens: 10, output_tokens: 20)
- resp = Dispatch::Adapter::Response.new(model: "gpt-4", stop_reason: :end_turn, usage: usage)
- expect(resp.content).to be_nil
- expect(resp.tool_calls).to eq([])
- expect(resp.model).to eq("gpt-4")
- expect(resp.stop_reason).to eq(:end_turn)
- expect(resp.usage).to eq(usage)
- end
-
- it "creates with all fields" do
- usage = Dispatch::Adapter::Usage.new(input_tokens: 10, output_tokens: 20)
- tool_call = Dispatch::Adapter::ToolUseBlock.new(id: "1", name: "test", arguments: {})
- resp = Dispatch::Adapter::Response.new(
- content: "Hello",
- tool_calls: [tool_call],
- model: "gpt-4",
- stop_reason: :tool_use,
- usage: usage
- )
- expect(resp.content).to eq("Hello")
- expect(resp.tool_calls.size).to eq(1)
- end
- end
-
- describe "Usage" do
- it "defaults cache tokens to 0" do
- usage = Dispatch::Adapter::Usage.new(input_tokens: 100, output_tokens: 50)
- expect(usage.cache_read_tokens).to eq(0)
- expect(usage.cache_creation_tokens).to eq(0)
- end
-
- it "accepts cache tokens" do
- usage = Dispatch::Adapter::Usage.new(
- input_tokens: 100,
- output_tokens: 50,
- cache_read_tokens: 10,
- cache_creation_tokens: 5
- )
- expect(usage.cache_read_tokens).to eq(10)
- expect(usage.cache_creation_tokens).to eq(5)
- end
- end
-
- describe "StreamDelta" do
- it "creates a text_delta" do
- delta = Dispatch::Adapter::StreamDelta.new(type: :text_delta, text: "Hello")
- expect(delta.type).to eq(:text_delta)
- expect(delta.text).to eq("Hello")
- expect(delta.tool_call_id).to be_nil
- end
-
- it "creates a tool_use_start" do
- delta = Dispatch::Adapter::StreamDelta.new(type: :tool_use_start, tool_call_id: "1", tool_name: "search")
- expect(delta.type).to eq(:tool_use_start)
- expect(delta.tool_call_id).to eq("1")
- expect(delta.tool_name).to eq("search")
- end
-
- it "creates a tool_use_delta" do
- delta = Dispatch::Adapter::StreamDelta.new(type: :tool_use_delta, tool_call_id: "1", argument_delta: '{"q":')
- expect(delta.type).to eq(:tool_use_delta)
- expect(delta.argument_delta).to eq('{"q":')
- end
- end
-
- describe "ModelInfo" do
- it "creates with all fields" do
- info = Dispatch::Adapter::ModelInfo.new(
- id: "gpt-4",
- name: "GPT-4",
- max_context_tokens: 8192,
- supports_vision: false,
- supports_tool_use: true,
- supports_streaming: true
- )
- expect(info.id).to eq("gpt-4")
- expect(info.name).to eq("GPT-4")
- expect(info.max_context_tokens).to eq(8192)
- expect(info.supports_vision).to be(false)
- expect(info.supports_tool_use).to be(true)
- expect(info.supports_streaming).to be(true)
- expect(info.premium_request_multiplier).to be_nil
- end
-
- it "accepts premium_request_multiplier" do
- info = Dispatch::Adapter::ModelInfo.new(
- id: "o3",
- name: "o3",
- max_context_tokens: 200_000,
- supports_vision: false,
- supports_tool_use: true,
- supports_streaming: true,
- premium_request_multiplier: 30.0
- )
- expect(info.premium_request_multiplier).to eq(30.0)
- end
-
- it "defaults premium_request_multiplier to nil" do
- info = Dispatch::Adapter::ModelInfo.new(
- id: "gpt-4.1-nano",
- name: "GPT 4.1 Nano",
- max_context_tokens: 1_047_576,
- supports_vision: false,
- supports_tool_use: true,
- supports_streaming: true
- )
- expect(info.premium_request_multiplier).to be_nil
- end
- end
-
- describe "Struct equality" do
- it "considers structs with same values equal" do
- a = Dispatch::Adapter::Message.new(role: "user", content: "hello")
- b = Dispatch::Adapter::Message.new(role: "user", content: "hello")
- expect(a).to eq(b)
- end
-
- it "considers structs with different values not equal" do
- a = Dispatch::Adapter::Message.new(role: "user", content: "hello")
- b = Dispatch::Adapter::Message.new(role: "user", content: "goodbye")
- expect(a).not_to eq(b)
- end
-
- it "Usage structs are equal with same tokens" do
- a = Dispatch::Adapter::Usage.new(input_tokens: 10, output_tokens: 20)
- b = Dispatch::Adapter::Usage.new(input_tokens: 10, output_tokens: 20)
- expect(a).to eq(b)
- end
-
- it "ToolUseBlock structs are equal with same fields" do
- a = Dispatch::Adapter::ToolUseBlock.new(id: "1", name: "test", arguments: { "k" => "v" })
- b = Dispatch::Adapter::ToolUseBlock.new(id: "1", name: "test", arguments: { "k" => "v" })
- expect(a).to eq(b)
- end
- end
-end