diff options
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | Gemfile | 2 | ||||
| -rw-r--r-- | Gemfile.lock | 12 | ||||
| -rwxr-xr-x | bin/check | 32 | ||||
| -rwxr-xr-x | bin/install | 23 | ||||
| -rw-r--r-- | dispatch-adapter-copilot.gemspec | 14 | ||||
| -rw-r--r-- | lib/dispatch/adapter/base.rb | 31 | ||||
| -rw-r--r-- | lib/dispatch/adapter/copilot.rb | 23 | ||||
| -rw-r--r-- | lib/dispatch/adapter/errors.rb | 30 | ||||
| -rw-r--r-- | lib/dispatch/adapter/message.rb | 31 | ||||
| -rw-r--r-- | lib/dispatch/adapter/model_info.rb | 17 | ||||
| -rw-r--r-- | lib/dispatch/adapter/rate_limiter.rb | 2 | ||||
| -rw-r--r-- | lib/dispatch/adapter/response.rb | 23 | ||||
| -rw-r--r-- | lib/dispatch/adapter/tool_definition.rb | 7 | ||||
| -rw-r--r-- | spec/dispatch/adapter/base_spec.rb | 41 | ||||
| -rw-r--r-- | spec/dispatch/adapter/copilot_rate_limiting_spec.rb | 12 | ||||
| -rw-r--r-- | spec/dispatch/adapter/copilot_spec.rb | 260 | ||||
| -rw-r--r-- | spec/dispatch/adapter/errors_spec.rb | 69 | ||||
| -rw-r--r-- | spec/dispatch/adapter/rate_limiter_spec.rb | 10 | ||||
| -rw-r--r-- | spec/dispatch/adapter/structs_spec.rb | 211 |
20 files changed, 232 insertions, 621 deletions
@@ -10,6 +10,9 @@ # rspec failure tracking .rspec_status +# Test results +test_results.txt + # Built gems *.gem @@ -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 |
