summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.rubocop.yml6
-rw-r--r--.rules/changelog/2026-04/01/01.md26
-rw-r--r--.rules/plan/rate-limiting.md180
-rw-r--r--Gemfile.lock4
-rw-r--r--dispatch-adapter-copilot.gemspec1
-rw-r--r--lib/dispatch/adapter/copilot.rb53
-rw-r--r--lib/dispatch/adapter/rate_limiter.rb171
-rw-r--r--lib/dispatch/adapter/response.rb8
-rw-r--r--lib/dispatch/adapter/version.rb2
-rw-r--r--spec/dispatch/adapter/copilot_rate_limiting_spec.rb245
-rw-r--r--spec/dispatch/adapter/copilot_spec.rb611
-rw-r--r--spec/dispatch/adapter/errors_spec.rb8
-rw-r--r--spec/dispatch/adapter/rate_limiter_spec.rb407
13 files changed, 1400 insertions, 322 deletions
diff --git a/.rubocop.yml b/.rubocop.yml
index 08bc621..4762417 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -17,3 +17,9 @@ Metrics/MethodLength:
Metrics/ClassLength:
Max: 500
+
+Layout/LineLength:
+ Enabled: false
+
+Style/Documentation:
+ Enabled: false
diff --git a/.rules/changelog/2026-04/01/01.md b/.rules/changelog/2026-04/01/01.md
new file mode 100644
index 0000000..5134518
--- /dev/null
+++ b/.rules/changelog/2026-04/01/01.md
@@ -0,0 +1,26 @@
+# Changelog — 2026-04-01 #01
+
+## Changes
+
+### New: Rate Limiter (`lib/dispatch/adapter/rate_limiter.rb`)
+- Implemented `Dispatch::Adapter::RateLimiter` class with cross-process rate limiting via filesystem locks (`flock`).
+- Supports per-request cooldown (`min_request_interval`) and sliding window limiting (`rate_limit`).
+- State stored as JSON in `{token_path_dir}/copilot_rate_limit` with `0600` permissions.
+- Handles missing, empty, or corrupt state files gracefully.
+- Validates constructor arguments with descriptive `ArgumentError` messages.
+
+### Modified: Copilot Adapter (`lib/dispatch/adapter/copilot.rb`)
+- Added `min_request_interval:` (default `3.0`) and `rate_limit:` (default `nil`) constructor parameters.
+- Instantiates a `RateLimiter` in `initialize`.
+- Calls `@rate_limiter.wait!` before HTTP requests in `chat_non_streaming`, `chat_streaming`, and `list_models`.
+- Added `require_relative "rate_limiter"`.
+
+### Version Bump (`lib/dispatch/adapter/version.rb`)
+- Bumped `VERSION` from `"0.1.0"` to `"0.2.0"`.
+
+### Updated Test (`spec/dispatch/adapter/copilot_spec.rb`)
+- Updated VERSION expectation from `"0.1.0"` to `"0.2.0"`.
+
+### RuboCop Config (`.rubocop.yml`)
+- Disabled `Layout/LineLength` cop.
+- Disabled `Style/Documentation` cop.
diff --git a/.rules/plan/rate-limiting.md b/.rules/plan/rate-limiting.md
new file mode 100644
index 0000000..56dfb2d
--- /dev/null
+++ b/.rules/plan/rate-limiting.md
@@ -0,0 +1,180 @@
+# Rate Limiting — Implementation Plan
+
+Cross-process, per-account rate limiting for the Copilot adapter. All processes sharing the same GitHub account (same `token_path` directory) share a single rate limit state via the filesystem.
+
+---
+
+## Overview
+
+Two rate limiting mechanisms, both enforced transparently (the adapter sleeps until allowed, never raises):
+
+1. **Per-request cooldown** — Minimum interval between consecutive requests. Default: 3 seconds.
+2. **Sliding window limit** — Maximum N requests within a time period. Default: disabled (`nil`).
+
+Both are configured via constructor parameters. Rate limit state is stored in a file next to the persisted GitHub token, using `flock` for cross-process atomic access.
+
+---
+
+## Configuration
+
+### Constructor Parameters
+
+```ruby
+Copilot.new(
+ model: "gpt-4.1",
+ github_token: nil,
+ token_path: nil,
+ max_tokens: 8192,
+ thinking: nil,
+ min_request_interval: 3.0, # seconds between requests (Float/Integer, nil to disable)
+ rate_limit: nil # sliding window config (Hash or nil to disable)
+)
+```
+
+#### `min_request_interval:` (default: `3.0`)
+
+- Minimum number of seconds that must elapse between the start of one request and the start of the next.
+- Set to `nil` or `0` to disable.
+- Applies system-wide across all processes sharing the same rate limit file.
+
+#### `rate_limit:` (default: `nil` — disabled)
+
+- A Hash with two keys: `{ requests: Integer, period: Integer }`.
+ - `requests` — Maximum number of requests allowed within the window.
+ - `period` — Window size in seconds.
+- Example: `{ requests: 10, period: 60 }` means at most 10 requests per 60-second sliding window.
+- Set to `nil` to disable sliding window limiting (only per-request cooldown applies).
+- Validation: both `requests` and `period` must be positive integers when provided. Raises `ArgumentError` otherwise.
+
+---
+
+## Behaviour
+
+When `chat` or `list_models` is called (any method that hits the Copilot API):
+
+1. **Acquire the rate limit file lock** (`flock(File::LOCK_EX)`).
+2. **Read the rate limit state** from the file.
+3. **Check per-request cooldown**: If less than `min_request_interval` seconds have elapsed since the last request timestamp, calculate the remaining wait time.
+4. **Check sliding window** (if configured): Count how many timestamps in the log fall within `[now - period, now]`. If the count >= `requests`, calculate the wait time until the oldest entry in the window expires.
+5. **Take the maximum** of both wait times (they can overlap).
+6. **Release the lock**, then **sleep** for the calculated wait time (if any).
+7. **Re-acquire the lock**, re-read state, re-check (the state may have changed while sleeping — another process may have made a request during our sleep).
+8. **Record the current timestamp** in the state file and release the lock.
+9. **Proceed** with the API request.
+
+The re-check-after-sleep loop is necessary because another process could slip in a request while we were sleeping. The loop converges quickly (at most a few iterations) because each process sleeps for the correct duration.
+
+### Thread Safety
+
+The existing `@mutex` protects the Copilot token refresh. Rate limiting uses a separate concern:
+
+- **Cross-process**: `flock` on the rate limit file.
+- **In-process threads**: The `flock` call itself is sufficient — Ruby's `File#flock` blocks the calling thread (does not hold the GVL while waiting), so concurrent threads in the same process will serialize correctly through the flock.
+
+---
+
+## File Format
+
+### Path
+
+```
+{token_path_directory}/copilot_rate_limit
+```
+
+Where `token_path_directory` is `File.dirname(@token_path)`. Since `@token_path` defaults to `~/.config/dispatch/copilot_github_token`, the rate limit file defaults to `~/.config/dispatch/copilot_rate_limit`.
+
+### Contents
+
+JSON with two fields:
+
+```json
+{
+ "last_request_at": 1743465600.123,
+ "request_log": [1743465590.0, 1743465595.0, 1743465600.123]
+}
+```
+
+- `last_request_at` — Unix timestamp (Float) of the most recent request. Used for per-request cooldown.
+- `request_log` — Array of Unix timestamps (Float) for recent requests. Used for sliding window. Entries older than the window `period` are pruned on every write to keep the file small.
+
+If sliding window is disabled, `request_log` is still maintained (empty array) so that enabling it later works immediately without losing the last-request timestamp.
+
+When the file does not exist or is empty/corrupt, treat it as fresh state (no previous requests).
+
+### File Permissions
+
+Created with `0600` (same as the token file) to prevent other users from reading/tampering.
+
+---
+
+## Implementation Structure
+
+### New File: `lib/dispatch/adapter/rate_limiter.rb`
+
+A standalone class `Dispatch::Adapter::RateLimiter` that encapsulates all rate limiting logic. The Copilot adapter delegates to it.
+
+```ruby
+class RateLimiter
+ def initialize(rate_limit_path:, min_request_interval:, rate_limit:)
+ # ...
+ end
+
+ def wait!
+ # Acquire lock, read state, compute wait, sleep, record, release.
+ end
+end
+```
+
+#### Public API
+
+- `#wait!` — Blocks until the rate limit allows a request, then records the request timestamp. Called by the adapter before every API call.
+
+#### Private Methods
+
+- `#read_state(file)` — Parse JSON from the locked file. Returns default state on missing/corrupt file.
+- `#write_state(file, state)` — Write JSON state back to the file.
+- `#compute_wait(state, now)` — Returns the number of seconds to sleep (Float, 0.0 if no wait needed).
+- `#prune_log(log, now, period)` — Remove timestamps older than `now - period`.
+- `#record_request(state, now)` — Append `now` to log, update `last_request_at`, prune old entries.
+
+### Changes to `Dispatch::Adapter::Copilot`
+
+1. Add constructor parameters `min_request_interval:` and `rate_limit:`.
+2. In `initialize`, create a `RateLimiter` instance.
+3. Call `@rate_limiter.wait!` at the start of `chat_non_streaming`, `chat_streaming`, and `list_models` — after `ensure_authenticated!` (authentication should not be rate-limited) but before the HTTP request.
+4. Validate `rate_limit:` hash structure in the constructor.
+
+### Changes to `Dispatch::Adapter::Base`
+
+No changes. Rate limiting is an implementation concern of the Copilot adapter, not part of the abstract interface. Other adapters may have different rate limiting strategies or none at all.
+
+---
+
+## Edge Cases
+
+| Scenario | Behaviour |
+|---|---|
+| Rate limit file does not exist | Treat as no previous requests. Create on first write. |
+| Rate limit file contains invalid JSON | Treat as no previous requests. Overwrite on next write. |
+| Rate limit file directory does not exist | Create it (same as `persist_token` does for the token file). |
+| `min_request_interval: nil` or `0` | Per-request cooldown disabled. |
+| `rate_limit: nil` | Sliding window disabled. Only cooldown applies. |
+| Both disabled | `wait!` is a no-op (returns immediately). |
+| `rate_limit:` missing `requests` or `period` key | Raises `ArgumentError` in constructor. |
+| `rate_limit: { requests: 0, ... }` or negative | Raises `ArgumentError` in constructor. |
+| Clock skew between processes | Handled — we use monotonic-ish `Time.now.to_f`. Minor skew (sub-second) is acceptable. Major skew (NTP jump) could cause one extra wait or one early request, which is acceptable. |
+| Process killed while holding lock | `flock` is automatically released by the OS when the file descriptor is closed (including process termination). No stale locks. |
+| Very long `request_log` after sustained use | Pruned on every write. Maximum size = `rate_limit[:requests]` entries. |
+
+---
+
+## Validation Rules
+
+In the constructor:
+
+- `min_request_interval` must be `nil`, or a `Numeric` >= 0. Raise `ArgumentError` otherwise.
+- `rate_limit` must be `nil` or a `Hash` with:
+ - `:requests` — positive `Integer`
+ - `:period` — positive `Integer` or `Float`
+ - No extra keys required; extra keys are ignored.
+- Raise `ArgumentError` with a descriptive message on invalid config.
diff --git a/Gemfile.lock b/Gemfile.lock
index 9762c17..ed7e3cb 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
- dispatch-adapter-copilot (0.1.0)
+ dispatch-adapter-copilot (0.2.0)
GEM
remote: https://rubygems.org/
@@ -106,7 +106,7 @@ CHECKSUMS
crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e
date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
- dispatch-adapter-copilot (0.1.0)
+ dispatch-adapter-copilot (0.2.0)
erb (6.0.2) sha256=9fe6264d44f79422c87490a1558479bd0e7dad4dd0e317656e67ea3077b5242b
hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
diff --git a/dispatch-adapter-copilot.gemspec b/dispatch-adapter-copilot.gemspec
index 7f6c756..3ef3cf4 100644
--- a/dispatch-adapter-copilot.gemspec
+++ b/dispatch-adapter-copilot.gemspec
@@ -15,6 +15,7 @@ Gem::Specification.new do |spec|
spec.required_ruby_version = ">= 3.2.0"
spec.metadata["homepage_uri"] = spec.homepage
spec.metadata["source_code_uri"] = spec.homepage
+ spec.metadata["rubygems_mfa_required"] = "true"
# Specify which files should be added to the gem when it is released.
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
diff --git a/lib/dispatch/adapter/copilot.rb b/lib/dispatch/adapter/copilot.rb
index 200fcb9..728ce99 100644
--- a/lib/dispatch/adapter/copilot.rb
+++ b/lib/dispatch/adapter/copilot.rb
@@ -12,6 +12,7 @@ require_relative "response"
require_relative "tool_definition"
require_relative "model_info"
require_relative "base"
+require_relative "rate_limiter"
require_relative "version"
@@ -55,7 +56,8 @@ 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: nil,
+ min_request_interval: 3.0, rate_limit: nil)
super()
@model = model
@github_token = github_token
@@ -66,9 +68,16 @@ module Dispatch
@copilot_token_expires_at = 0
@mutex = Mutex.new
validate_thinking_level!(@default_thinking)
+
+ rate_limit_path = File.join(File.dirname(@token_path), "copilot_rate_limit")
+ @rate_limiter = RateLimiter.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: min_request_interval,
+ rate_limit: rate_limit
+ )
end
- def chat(messages, system: nil, tools: [], stream: false, max_tokens: nil, thinking: :default, &block)
+ def chat(messages, system: nil, tools: [], stream: false, max_tokens: nil, thinking: :default, &)
ensure_authenticated!
wire_messages = build_wire_messages(messages, system)
wire_tools = build_wire_tools(tools)
@@ -86,7 +95,7 @@ module Dispatch
body[:reasoning_effort] = effective_thinking if effective_thinking
if stream
- chat_streaming(body, &block)
+ chat_streaming(body, &)
else
chat_non_streaming(body)
end
@@ -106,6 +115,7 @@ module Dispatch
def list_models
ensure_authenticated!
+ @rate_limiter.wait!
uri = URI("#{API_BASE}/v1/models")
request = Net::HTTP::Get.new(uri)
apply_headers!(request)
@@ -133,7 +143,8 @@ module Dispatch
return if VALID_THINKING_LEVELS.include?(level)
- raise ArgumentError, "Invalid thinking level: #{level.inspect}. Must be one of: #{VALID_THINKING_LEVELS.join(", ")}, or nil"
+ raise ArgumentError,
+ "Invalid thinking level: #{level.inspect}. Must be one of: #{VALID_THINKING_LEVELS.join(", ")}, or nil"
end
def default_token_path
@@ -184,10 +195,10 @@ module Dispatch
verification_uri = data["verification_uri"]
interval = (data["interval"] || 5).to_i
- $stderr.puts "\n=== GitHub Device Authorization ==="
- $stderr.puts "Open: #{verification_uri}"
- $stderr.puts "Enter code: #{user_code}"
- $stderr.puts "Waiting for authorization...\n\n"
+ warn "\n=== GitHub Device Authorization ==="
+ warn "Open: #{verification_uri}"
+ warn "Enter code: #{user_code}"
+ warn "Waiting for authorization...\n\n"
poll_for_access_token(device_code, interval)
end
@@ -273,7 +284,7 @@ module Dispatch
)
end
- def execute_streaming_request(uri, request, &block)
+ def execute_streaming_request(uri, request)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = (uri.scheme == "https")
http.open_timeout = 30
@@ -282,7 +293,7 @@ module Dispatch
http.start do |h|
h.request(request) do |response|
handle_error_response!(response) unless response.is_a?(Net::HTTPSuccess)
- block.call(response)
+ yield(response)
end
end
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT,
@@ -456,6 +467,7 @@ module Dispatch
# --- Chat (non-streaming) ---
def chat_non_streaming(body)
+ @rate_limiter.wait!
uri = URI("#{API_BASE}/chat/completions")
request = Net::HTTP::Post.new(uri)
apply_headers!(request)
@@ -522,6 +534,7 @@ module Dispatch
# --- Chat (streaming) ---
def chat_streaming(body, &block)
+ @rate_limiter.wait!
uri = URI("#{API_BASE}/chat/completions")
request = Net::HTTP::Post.new(uri)
apply_headers!(request)
@@ -552,7 +565,7 @@ module Dispatch
}
end
- def process_sse_buffer(buffer, collected, &block)
+ def process_sse_buffer(buffer, collected, &)
while (line_end = buffer.index("\n"))
line = buffer.slice!(0..line_end).strip
next if line.empty?
@@ -562,14 +575,14 @@ module Dispatch
next if data_str == "[DONE]"
data = JSON.parse(data_str)
- process_stream_chunk(data, collected, &block)
+ process_stream_chunk(data, collected, &)
end
rescue JSON::ParserError
# Incomplete JSON chunk, will be completed on next read
nil
end
- def process_stream_chunk(data, collected, &block)
+ def process_stream_chunk(data, collected, &)
collected[:model] = data["model"] if data["model"]
choice = data.dig("choices", 0)
@@ -578,20 +591,20 @@ module Dispatch
collected[:finish_reason] = choice["finish_reason"] if choice["finish_reason"]
delta = choice["delta"] || {}
- process_text_delta(delta, collected, &block)
- process_tool_call_deltas(delta, collected, &block)
+ process_text_delta(delta, collected, &)
+ process_tool_call_deltas(delta, collected, &)
process_usage(data, collected)
end
- def process_text_delta(delta, collected, &block)
+ def process_text_delta(delta, collected)
return unless delta["content"]
collected[:content] << delta["content"]
- block.call(StreamDelta.new(type: :text_delta, text: delta["content"]))
+ yield(StreamDelta.new(type: :text_delta, text: delta["content"]))
end
- def process_tool_call_deltas(delta, collected, &block)
+ def process_tool_call_deltas(delta, collected)
return unless delta["tool_calls"]
delta["tool_calls"].each do |tc_delta|
@@ -601,7 +614,7 @@ module Dispatch
if tc_delta["id"]
tc[:id] = tc_delta["id"]
tc[:name] = tc_delta.dig("function", "name") || ""
- block.call(StreamDelta.new(
+ yield(StreamDelta.new(
type: :tool_use_start,
tool_call_id: tc[:id],
tool_name: tc[:name]
@@ -612,7 +625,7 @@ module Dispatch
next if arg_frag.empty?
tc[:arguments] << arg_frag
- block.call(StreamDelta.new(
+ yield(StreamDelta.new(
type: :tool_use_delta,
tool_call_id: tc[:id],
argument_delta: arg_frag
diff --git a/lib/dispatch/adapter/rate_limiter.rb b/lib/dispatch/adapter/rate_limiter.rb
new file mode 100644
index 0000000..6f10905
--- /dev/null
+++ b/lib/dispatch/adapter/rate_limiter.rb
@@ -0,0 +1,171 @@
+# frozen_string_literal: true
+
+require "json"
+require "fileutils"
+
+module Dispatch
+ module Adapter
+ class RateLimiter
+ def initialize(rate_limit_path:, min_request_interval:, rate_limit:)
+ validate_min_request_interval!(min_request_interval)
+ validate_rate_limit!(rate_limit)
+
+ @rate_limit_path = rate_limit_path
+ @min_request_interval = min_request_interval
+ @rate_limit = rate_limit
+ end
+
+ def wait!
+ return if disabled?
+
+ loop do
+ wait_time = 0.0
+
+ File.open(rate_limit_file, File::RDWR | File::CREAT) do |file|
+ file.flock(File::LOCK_EX)
+ state = read_state(file)
+ now = Time.now.to_f
+ wait_time = compute_wait(state, now)
+
+ if wait_time <= 0
+ record_request(state, now)
+ write_state(file, state)
+ return
+ end
+ end
+
+ sleep(wait_time)
+ end
+ end
+
+ private
+
+ def disabled?
+ effective_min_interval.nil? && @rate_limit.nil?
+ end
+
+ def effective_min_interval
+ return nil if @min_request_interval.nil?
+ return nil if @min_request_interval.zero?
+
+ @min_request_interval
+ end
+
+ def rate_limit_file
+ FileUtils.mkdir_p(File.dirname(@rate_limit_path))
+ File.chmod(0o600, @rate_limit_path) if File.exist?(@rate_limit_path)
+ @rate_limit_path
+ end
+
+ def read_state(file)
+ file.rewind
+ content = file.read
+ return default_state if content.nil? || content.strip.empty?
+
+ parsed = JSON.parse(content)
+ {
+ "last_request_at" => parsed["last_request_at"]&.to_f,
+ "request_log" => Array(parsed["request_log"]).map(&:to_f)
+ }
+ rescue JSON::ParserError
+ default_state
+ end
+
+ def default_state
+ { "last_request_at" => nil, "request_log" => [] }
+ end
+
+ def write_state(file, state)
+ file.rewind
+ file.truncate(0)
+ file.write(JSON.generate(state))
+ file.flush
+
+ File.chmod(0o600, @rate_limit_path)
+ end
+
+ def compute_wait(state, now)
+ cooldown_wait = compute_cooldown_wait(state, now)
+ window_wait = compute_window_wait(state, now)
+ [cooldown_wait, window_wait].max
+ end
+
+ def compute_cooldown_wait(state, now)
+ interval = effective_min_interval
+ return 0.0 if interval.nil?
+
+ last = state["last_request_at"]
+ return 0.0 if last.nil?
+
+ elapsed = now - last
+ remaining = interval - elapsed
+ remaining > 0 ? remaining : 0.0
+ end
+
+ def compute_window_wait(state, now)
+ return 0.0 if @rate_limit.nil?
+
+ max_requests = @rate_limit[:requests]
+ period = @rate_limit[:period]
+ window_start = now - period
+
+ log = state["request_log"].select { |t| t > window_start }
+
+ return 0.0 if log.size < max_requests
+
+ oldest_in_window = log.min
+ wait = oldest_in_window + period - now
+ wait > 0 ? wait : 0.0
+ end
+
+ def record_request(state, now)
+ state["last_request_at"] = now
+ state["request_log"] << now
+ prune_log(state, now)
+ end
+
+ def prune_log(state, now)
+ if @rate_limit
+ period = @rate_limit[:period]
+ cutoff = now - period
+ state["request_log"] = state["request_log"].select { |t| t > cutoff }
+ else
+ state["request_log"] = []
+ end
+ end
+
+ def validate_min_request_interval!(value)
+ return if value.nil?
+
+ unless value.is_a?(Numeric)
+ raise ArgumentError,
+ "min_request_interval must be nil or a Numeric >= 0, got #{value.inspect}"
+ end
+
+ return unless value.negative?
+
+ raise ArgumentError,
+ "min_request_interval must be nil or a Numeric >= 0, got #{value.inspect}"
+ end
+
+ def validate_rate_limit!(value)
+ return if value.nil?
+
+ unless value.is_a?(Hash)
+ raise ArgumentError,
+ "rate_limit must be nil or a Hash with :requests and :period keys, got #{value.inspect}"
+ end
+
+ unless value.key?(:requests) && value[:requests].is_a?(Integer) && value[:requests].positive?
+ raise ArgumentError,
+ "rate_limit[:requests] must be a positive Integer, got #{value[:requests].inspect}"
+ end
+
+ return if value.key?(:period) && value[:period].is_a?(Numeric) && value[:period].positive?
+
+ raise ArgumentError,
+ "rate_limit[:period] must be a positive Numeric, got #{value[:period].inspect}"
+ end
+ end
+ end
+end
diff --git a/lib/dispatch/adapter/response.rb b/lib/dispatch/adapter/response.rb
index d3e4789..b4ba3eb 100644
--- a/lib/dispatch/adapter/response.rb
+++ b/lib/dispatch/adapter/response.rb
@@ -3,20 +3,20 @@
module Dispatch
module Adapter
Response = Struct.new(:content, :tool_calls, :model, :stop_reason, :usage, keyword_init: true) do
- def initialize(content: nil, tool_calls: [], model:, stop_reason:, usage:)
- super(content:, tool_calls:, model:, stop_reason:, usage:)
+ 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(input_tokens:, output_tokens:, cache_read_tokens:, cache_creation_tokens:)
+ 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(type:, text:, tool_call_id:, tool_name:, argument_delta:)
+ super
end
end
end
diff --git a/lib/dispatch/adapter/version.rb b/lib/dispatch/adapter/version.rb
index 3df9f5f..6df2da6 100644
--- a/lib/dispatch/adapter/version.rb
+++ b/lib/dispatch/adapter/version.rb
@@ -3,7 +3,7 @@
module Dispatch
module Adapter
module CopilotVersion
- VERSION = "0.1.0"
+ VERSION = "0.2.0"
end
end
end
diff --git a/spec/dispatch/adapter/copilot_rate_limiting_spec.rb b/spec/dispatch/adapter/copilot_rate_limiting_spec.rb
new file mode 100644
index 0000000..abd21ee
--- /dev/null
+++ b/spec/dispatch/adapter/copilot_rate_limiting_spec.rb
@@ -0,0 +1,245 @@
+# frozen_string_literal: true
+
+require "webmock/rspec"
+require "fileutils"
+require "tmpdir"
+
+RSpec.describe Dispatch::Adapter::Copilot, "rate limiting" do
+ let(:copilot_token) { "cop_test_token_abc" }
+ let(:github_token) { "gho_test_github_token" }
+ let(:tmpdir) { Dir.mktmpdir("copilot_rate_limit_test") }
+ let(:token_path) { File.join(tmpdir, "copilot_github_token") }
+
+ let(:chat_response_body) do
+ JSON.generate({
+ "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")] }
+
+ before do
+ stub_request(:get, "https://api.github.com/copilot_internal/v2/token")
+ .with(headers: { "Authorization" => "token #{github_token}" })
+ .to_return(
+ status: 200,
+ body: JSON.generate({
+ "token" => copilot_token,
+ "expires_at" => (Time.now.to_i + 3600)
+ }),
+ headers: { "Content-Type" => "application/json" }
+ )
+
+ stub_request(:post, "https://api.githubcopilot.com/chat/completions")
+ .to_return(
+ status: 200,
+ body: chat_response_body,
+ headers: { "Content-Type" => "application/json" }
+ )
+ end
+
+ after { FileUtils.rm_rf(tmpdir) }
+
+ describe "constructor rate limit parameters" do
+ it "accepts default rate limit parameters" do
+ adapter = described_class.new(
+ model: "gpt-4.1",
+ github_token: github_token,
+ token_path: token_path
+ )
+ expect(adapter).to be_a(described_class)
+ end
+
+ it "accepts custom min_request_interval" do
+ adapter = described_class.new(
+ model: "gpt-4.1",
+ github_token: github_token,
+ token_path: token_path,
+ min_request_interval: 5.0
+ )
+ expect(adapter).to be_a(described_class)
+ end
+
+ it "accepts nil min_request_interval to disable cooldown" do
+ adapter = described_class.new(
+ model: "gpt-4.1",
+ github_token: github_token,
+ token_path: token_path,
+ min_request_interval: nil
+ )
+ expect(adapter).to be_a(described_class)
+ end
+
+ it "accepts rate_limit hash for sliding window" do
+ adapter = described_class.new(
+ model: "gpt-4.1",
+ github_token: github_token,
+ token_path: token_path,
+ rate_limit: { requests: 10, period: 60 }
+ )
+ expect(adapter).to be_a(described_class)
+ end
+
+ it "raises ArgumentError for invalid min_request_interval" do
+ expect do
+ described_class.new(
+ model: "gpt-4.1",
+ github_token: github_token,
+ token_path: token_path,
+ min_request_interval: -1
+ )
+ end.to raise_error(ArgumentError)
+ end
+
+ it "raises ArgumentError for invalid rate_limit hash" do
+ expect do
+ described_class.new(
+ model: "gpt-4.1",
+ github_token: github_token,
+ token_path: token_path,
+ rate_limit: { requests: 0, period: 60 }
+ )
+ end.to raise_error(ArgumentError)
+ end
+ end
+
+ describe "#chat with rate limiting" do
+ context "with default 3s cooldown" do
+ let(:adapter) do
+ described_class.new(
+ model: "gpt-4.1",
+ github_token: github_token,
+ token_path: token_path
+ )
+ end
+
+ it "does not sleep on the first request" do
+ rate_limiter = instance_double(Dispatch::Adapter::RateLimiter)
+ allow(Dispatch::Adapter::RateLimiter).to receive(:new).and_return(rate_limiter)
+ allow(rate_limiter).to receive(:wait!)
+
+ fresh_adapter = described_class.new(
+ model: "gpt-4.1",
+ github_token: github_token,
+ token_path: token_path
+ )
+ fresh_adapter.chat(messages)
+
+ expect(rate_limiter).to have_received(:wait!).once
+ end
+
+ it "calls wait! before every chat request" do
+ rate_limiter = instance_double(Dispatch::Adapter::RateLimiter)
+ allow(Dispatch::Adapter::RateLimiter).to receive(:new).and_return(rate_limiter)
+ allow(rate_limiter).to receive(:wait!)
+
+ fresh_adapter = described_class.new(
+ model: "gpt-4.1",
+ github_token: github_token,
+ token_path: token_path
+ )
+ fresh_adapter.chat(messages)
+ fresh_adapter.chat(messages)
+ fresh_adapter.chat(messages)
+
+ expect(rate_limiter).to have_received(:wait!).exactly(3).times
+ end
+ end
+
+ context "with rate limiting disabled" do
+ let(:adapter) do
+ described_class.new(
+ model: "gpt-4.1",
+ github_token: github_token,
+ token_path: token_path,
+ min_request_interval: nil,
+ rate_limit: nil
+ )
+ end
+
+ it "does not sleep between rapid requests" do
+ rate_limiter = instance_double(Dispatch::Adapter::RateLimiter)
+ allow(Dispatch::Adapter::RateLimiter).to receive(:new).and_return(rate_limiter)
+ allow(rate_limiter).to receive(:wait!)
+
+ fresh_adapter = described_class.new(
+ model: "gpt-4.1",
+ github_token: github_token,
+ token_path: token_path,
+ min_request_interval: nil,
+ rate_limit: nil
+ )
+ fresh_adapter.chat(messages)
+ fresh_adapter.chat(messages)
+
+ expect(rate_limiter).to have_received(:wait!).twice
+ end
+ end
+ end
+
+ 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" }],
+ "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 } })}\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" })
+
+ rate_limiter = instance_double(Dispatch::Adapter::RateLimiter)
+ allow(Dispatch::Adapter::RateLimiter).to receive(:new).and_return(rate_limiter)
+ allow(rate_limiter).to receive(:wait!)
+
+ adapter = described_class.new(
+ model: "gpt-4.1",
+ github_token: github_token,
+ token_path: token_path
+ )
+ adapter.chat(messages, stream: true) { |_| }
+
+ expect(rate_limiter).to have_received(:wait!).once
+ end
+ end
+
+ describe "#list_models with rate limiting" do
+ it "calls wait! before list_models request" do
+ stub_request(:get, "https://api.githubcopilot.com/v1/models")
+ .to_return(
+ status: 200,
+ body: JSON.generate({ "data" => [{ "id" => "gpt-4.1", "object" => "model" }] }),
+ headers: { "Content-Type" => "application/json" }
+ )
+
+ rate_limiter = instance_double(Dispatch::Adapter::RateLimiter)
+ allow(Dispatch::Adapter::RateLimiter).to receive(:new).and_return(rate_limiter)
+ allow(rate_limiter).to receive(:wait!)
+
+ adapter = described_class.new(
+ model: "gpt-4.1",
+ github_token: github_token,
+ token_path: token_path
+ )
+ adapter.list_models
+
+ expect(rate_limiter).to have_received(:wait!).once
+ end
+ end
+
+ describe "rate limit file location" do
+ it "stores the rate limit file in the same directory as the token file" do
+ adapter = described_class.new(
+ model: "gpt-4.1",
+ github_token: github_token,
+ token_path: token_path
+ )
+ adapter.chat(messages)
+
+ rate_limit_path = File.join(tmpdir, "copilot_rate_limit")
+ expect(File.exist?(rate_limit_path)).to be(true)
+ end
+ end
+end
diff --git a/spec/dispatch/adapter/copilot_spec.rb b/spec/dispatch/adapter/copilot_spec.rb
index 13c37be..61766bf 100644
--- a/spec/dispatch/adapter/copilot_spec.rb
+++ b/spec/dispatch/adapter/copilot_spec.rb
@@ -21,9 +21,9 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "token" => copilot_token,
- "expires_at" => (Time.now.to_i + 3600)
- }),
+ "token" => copilot_token,
+ "expires_at" => (Time.now.to_i + 3600)
+ }),
headers: { "Content-Type" => "application/json" }
)
end
@@ -36,7 +36,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
describe "VERSION" do
it "is accessible" do
- expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.1.0")
+ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.2.0")
end
end
@@ -70,15 +70,15 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "id" => "chatcmpl-123",
- "model" => "gpt-4.1",
- "choices" => [{
- "index" => 0,
- "message" => { "role" => "assistant", "content" => "Hello there!" },
- "finish_reason" => "stop"
- }],
- "usage" => { "prompt_tokens" => 10, "completion_tokens" => 5 }
- }),
+ "id" => "chatcmpl-123",
+ "model" => "gpt-4.1",
+ "choices" => [{
+ "index" => 0,
+ "message" => { "role" => "assistant", "content" => "Hello there!" },
+ "finish_reason" => "stop"
+ }],
+ "usage" => { "prompt_tokens" => 10, "completion_tokens" => 5 }
+ }),
headers: { "Content-Type" => "application/json" }
)
end
@@ -103,26 +103,26 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "id" => "chatcmpl-456",
- "model" => "gpt-4.1",
- "choices" => [{
- "index" => 0,
- "message" => {
- "role" => "assistant",
- "content" => nil,
- "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 }
- }),
+ "id" => "chatcmpl-456",
+ "model" => "gpt-4.1",
+ "choices" => [{
+ "index" => 0,
+ "message" => {
+ "role" => "assistant",
+ "content" => nil,
+ "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" }
)
end
@@ -149,28 +149,28 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{
- "index" => 0,
- "message" => {
- "role" => "assistant",
- "content" => nil,
- "tool_calls" => [
- {
- "id" => "call_1",
- "type" => "function",
- "function" => { "name" => "get_weather", "arguments" => '{"city":"NYC"}' }
- },
- {
- "id" => "call_2",
- "type" => "function",
- "function" => { "name" => "get_time", "arguments" => '{"timezone":"EST"}' }
- }
- ]
- },
- "finish_reason" => "tool_calls"
- }],
- "usage" => { "prompt_tokens" => 20, "completion_tokens" => 15 }
- }),
+ "choices" => [{
+ "index" => 0,
+ "message" => {
+ "role" => "assistant",
+ "content" => nil,
+ "tool_calls" => [
+ {
+ "id" => "call_1",
+ "type" => "function",
+ "function" => { "name" => "get_weather", "arguments" => '{"city":"NYC"}' }
+ },
+ {
+ "id" => "call_2",
+ "type" => "function",
+ "function" => { "name" => "get_time", "arguments" => '{"timezone":"EST"}' }
+ }
+ ]
+ },
+ "finish_reason" => "tool_calls"
+ }],
+ "usage" => { "prompt_tokens" => 20, "completion_tokens" => 15 }
+ }),
headers: { "Content-Type" => "application/json" }
)
end
@@ -194,26 +194,26 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "id" => "chatcmpl-789",
- "model" => "gpt-4.1",
- "choices" => [{
- "index" => 0,
- "message" => {
- "role" => "assistant",
- "content" => "Let me check that for you.",
- "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 }
- }),
+ "id" => "chatcmpl-789",
+ "model" => "gpt-4.1",
+ "choices" => [{
+ "index" => 0,
+ "message" => {
+ "role" => "assistant",
+ "content" => "Let me check that for you.",
+ "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" }
)
end
@@ -231,16 +231,16 @@ RSpec.describe Dispatch::Adapter::Copilot do
context "with system: parameter" do
it "prepends system message in the wire format" do
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
- .with { |req|
- body = JSON.parse(req.body)
- body["messages"].first == { "role" => "system", "content" => "You are helpful." }
- }
+ .with do |req|
+ body = JSON.parse(req.body)
+ body["messages"].first == { "role" => "system", "content" => "You are helpful." }
+ end
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "OK" }, "finish_reason" => "stop" }],
- "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
- }),
+ "choices" => [{ "message" => { "content" => "OK" }, "finish_reason" => "stop" }],
+ "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
+ }),
headers: { "Content-Type" => "application/json" }
)
@@ -254,16 +254,16 @@ RSpec.describe Dispatch::Adapter::Copilot do
context "with max_tokens: per-call override" do
it "uses per-call max_tokens over constructor default" do
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
- .with { |req|
- body = JSON.parse(req.body)
- body["max_tokens"] == 100
- }
+ .with do |req|
+ body = JSON.parse(req.body)
+ body["max_tokens"] == 100
+ end
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "short" }, "finish_reason" => "stop" }],
- "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
- }),
+ "choices" => [{ "message" => { "content" => "short" }, "finish_reason" => "stop" }],
+ "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
+ }),
headers: { "Content-Type" => "application/json" }
)
@@ -275,16 +275,16 @@ RSpec.describe Dispatch::Adapter::Copilot do
it "uses constructor default when max_tokens not specified" do
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
- .with { |req|
- body = JSON.parse(req.body)
- body["max_tokens"] == 4096
- }
+ .with do |req|
+ body = JSON.parse(req.body)
+ body["max_tokens"] == 4096
+ end
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
- "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
- }),
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
+ }),
headers: { "Content-Type" => "application/json" }
)
@@ -304,23 +304,23 @@ RSpec.describe Dispatch::Adapter::Copilot do
)
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
- .with { |req|
- body = JSON.parse(req.body)
- body["tools"] == [{
- "type" => "function",
- "function" => {
- "name" => "get_weather",
- "description" => "Get weather for a city",
- "parameters" => { "type" => "object", "properties" => { "city" => { "type" => "string" } } }
- }
- }]
- }
+ .with do |req|
+ body = JSON.parse(req.body)
+ 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" }],
- "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
- }),
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
+ }),
headers: { "Content-Type" => "application/json" }
)
@@ -338,23 +338,23 @@ RSpec.describe Dispatch::Adapter::Copilot do
}
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
- .with { |req|
- body = JSON.parse(req.body)
- body["tools"] == [{
- "type" => "function",
- "function" => {
- "name" => "get_weather",
- "description" => "Get weather for a city",
- "parameters" => { "type" => "object", "properties" => { "city" => { "type" => "string" } } }
- }
- }]
- }
+ .with do |req|
+ body = JSON.parse(req.body)
+ 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" }],
- "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
- }),
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
+ }),
headers: { "Content-Type" => "application/json" }
)
@@ -372,23 +372,23 @@ RSpec.describe Dispatch::Adapter::Copilot do
}
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
- .with { |req|
- body = JSON.parse(req.body)
- body["tools"] == [{
- "type" => "function",
- "function" => {
- "name" => "get_weather",
- "description" => "Get weather for a city",
- "parameters" => { "type" => "object", "properties" => { "city" => { "type" => "string" } } }
- }
- }]
- }
+ .with do |req|
+ body = JSON.parse(req.body)
+ 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" }],
- "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
- }),
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
+ }),
headers: { "Content-Type" => "application/json" }
)
@@ -411,18 +411,18 @@ RSpec.describe Dispatch::Adapter::Copilot do
}
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
- .with { |req|
- body = JSON.parse(req.body)
- body["tools"].size == 2 &&
- body["tools"][0]["function"]["name"] == "get_weather" &&
- body["tools"][1]["function"]["name"] == "get_time"
- }
+ .with do |req|
+ body = JSON.parse(req.body)
+ body["tools"].size == 2 &&
+ body["tools"][0]["function"]["name"] == "get_weather" &&
+ body["tools"][1]["function"]["name"] == "get_time"
+ end
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
- "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
- }),
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
+ }),
headers: { "Content-Type" => "application/json" }
)
@@ -434,16 +434,16 @@ RSpec.describe Dispatch::Adapter::Copilot do
it "does not include tools key when tools array is empty" do
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
- .with { |req|
- body = JSON.parse(req.body)
- !body.key?("tools")
- }
+ .with do |req|
+ body = JSON.parse(req.body)
+ !body.key?("tools")
+ end
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
- "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
- }),
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
+ }),
headers: { "Content-Type" => "application/json" }
)
@@ -470,26 +470,27 @@ RSpec.describe Dispatch::Adapter::Copilot do
]
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
- .with { |req|
- body = JSON.parse(req.body)
- msgs = body["messages"]
- # user message
- msgs[0]["role"] == "user" &&
- # assistant with tool_calls
- msgs[1]["role"] == "assistant" &&
- msgs[1]["tool_calls"].is_a?(Array) &&
- msgs[1]["tool_calls"][0]["id"] == "call_1" &&
- # tool result
- msgs[2]["role"] == "tool" &&
- msgs[2]["tool_call_id"] == "call_1" &&
- msgs[2]["content"] == "72F and sunny"
- }
+ .with do |req|
+ body = JSON.parse(req.body)
+ msgs = body["messages"]
+ # user message
+ msgs[0]["role"] == "user" &&
+ # assistant with tool_calls
+ msgs[1]["role"] == "assistant" &&
+ msgs[1]["tool_calls"].is_a?(Array) &&
+ msgs[1]["tool_calls"][0]["id"] == "call_1" &&
+ # tool result
+ msgs[2]["role"] == "tool" &&
+ msgs[2]["tool_call_id"] == "call_1" &&
+ msgs[2]["content"] == "72F and sunny"
+ end
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "It's 72F and sunny in NYC!" }, "finish_reason" => "stop" }],
- "usage" => { "prompt_tokens" => 20, "completion_tokens" => 10 }
- }),
+ "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" }
)
@@ -516,17 +517,17 @@ RSpec.describe Dispatch::Adapter::Copilot do
messages = [Dispatch::Adapter::Message.new(role: "user", content: text_blocks)]
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
- .with { |req|
- body = JSON.parse(req.body)
- msgs = body["messages"]
- msgs[0]["content"] == "First paragraph.\nSecond paragraph."
- }
+ .with do |req|
+ body = JSON.parse(req.body)
+ msgs = body["messages"]
+ msgs[0]["content"] == "First paragraph.\nSecond paragraph."
+ end
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
- "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
- }),
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
+ }),
headers: { "Content-Type" => "application/json" }
)
@@ -555,18 +556,18 @@ RSpec.describe Dispatch::Adapter::Copilot do
]
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
- .with { |req|
- body = JSON.parse(req.body)
- msgs = body["messages"]
- tool_msg = msgs.find { |m| m["role"] == "tool" }
- tool_msg && tool_msg["content"] == "Result line 1\nResult line 2"
- }
+ .with do |req|
+ body = JSON.parse(req.body)
+ msgs = body["messages"]
+ tool_msg = msgs.find { |m| m["role"] == "tool" }
+ tool_msg && tool_msg["content"] == "Result line 1\nResult line 2"
+ end
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
- "usage" => { "prompt_tokens" => 10, "completion_tokens" => 1 }
- }),
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "usage" => { "prompt_tokens" => 10, "completion_tokens" => 1 }
+ }),
headers: { "Content-Type" => "application/json" }
)
@@ -591,18 +592,19 @@ RSpec.describe Dispatch::Adapter::Copilot do
]
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
- .with { |req|
- body = JSON.parse(req.body)
- msgs = body["messages"]
- tool_msg = msgs.find { |m| m["role"] == "tool" }
- tool_msg && tool_msg["content"] == "Something went wrong" && tool_msg["tool_call_id"] == "call_err"
- }
+ .with do |req|
+ body = JSON.parse(req.body)
+ msgs = body["messages"]
+ tool_msg = msgs.find { |m| m["role"] == "tool" }
+ tool_msg && tool_msg["content"] == "Something went wrong" && tool_msg["tool_call_id"] == "call_err"
+ end
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "I see the error" }, "finish_reason" => "stop" }],
- "usage" => { "prompt_tokens" => 10, "completion_tokens" => 3 }
- }),
+ "choices" => [{ "message" => { "content" => "I see the error" },
+ "finish_reason" => "stop" }],
+ "usage" => { "prompt_tokens" => 10, "completion_tokens" => 3 }
+ }),
headers: { "Content-Type" => "application/json" }
)
@@ -617,12 +619,12 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{
- "message" => { "content" => "truncated output..." },
- "finish_reason" => "length"
- }],
- "usage" => { "prompt_tokens" => 5, "completion_tokens" => 100 }
- }),
+ "choices" => [{
+ "message" => { "content" => "truncated output..." },
+ "finish_reason" => "length"
+ }],
+ "usage" => { "prompt_tokens" => 5, "completion_tokens" => 100 }
+ }),
headers: { "Content-Type" => "application/json" }
)
@@ -647,21 +649,21 @@ RSpec.describe Dispatch::Adapter::Copilot do
]
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
- .with { |req|
- body = JSON.parse(req.body)
- msgs = body["messages"]
- assistant = msgs.find { |m| m["role"] == "assistant" }
- assistant &&
- assistant["content"] == "Checking..." &&
- assistant["tool_calls"].is_a?(Array) &&
- assistant["tool_calls"][0]["id"] == "call_mixed"
- }
+ .with do |req|
+ body = JSON.parse(req.body)
+ msgs = body["messages"]
+ assistant = msgs.find { |m| m["role"] == "assistant" }
+ assistant &&
+ assistant["content"] == "Checking..." &&
+ assistant["tool_calls"].is_a?(Array) &&
+ assistant["tool_calls"][0]["id"] == "call_mixed"
+ end
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
- "usage" => { "prompt_tokens" => 10, "completion_tokens" => 1 }
- }),
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "usage" => { "prompt_tokens" => 10, "completion_tokens" => 1 }
+ }),
headers: { "Content-Type" => "application/json" }
)
@@ -678,17 +680,17 @@ RSpec.describe Dispatch::Adapter::Copilot do
]
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
- .with { |req|
- body = JSON.parse(req.body)
- msgs = body["messages"]
- msgs.size == 1 && msgs[0]["role"] == "user" && msgs[0]["content"].include?("First") && msgs[0]["content"].include?("Second")
- }
+ .with do |req|
+ body = JSON.parse(req.body)
+ msgs = body["messages"]
+ msgs.size == 1 && msgs[0]["role"] == "user" && msgs[0]["content"].include?("First") && msgs[0]["content"].include?("Second")
+ end
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
- "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
- }),
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
+ }),
headers: { "Content-Type" => "application/json" }
)
@@ -700,16 +702,17 @@ RSpec.describe Dispatch::Adapter::Copilot do
context "with thinking: parameter" do
it "sends reasoning_effort in the request body" do
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
- .with { |req|
- body = JSON.parse(req.body)
- body["reasoning_effort"] == "high"
- }
+ .with do |req|
+ body = JSON.parse(req.body)
+ body["reasoning_effort"] == "high"
+ end
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "thought deeply" }, "finish_reason" => "stop" }],
- "usage" => { "prompt_tokens" => 5, "completion_tokens" => 3 }
- }),
+ "choices" => [{ "message" => { "content" => "thought deeply" },
+ "finish_reason" => "stop" }],
+ "usage" => { "prompt_tokens" => 5, "completion_tokens" => 3 }
+ }),
headers: { "Content-Type" => "application/json" }
)
@@ -728,16 +731,16 @@ RSpec.describe Dispatch::Adapter::Copilot do
)
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
- .with { |req|
- body = JSON.parse(req.body)
- body["reasoning_effort"] == "medium"
- }
+ .with do |req|
+ body = JSON.parse(req.body)
+ body["reasoning_effort"] == "medium"
+ end
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
- "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
- }),
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
+ }),
headers: { "Content-Type" => "application/json" }
)
@@ -756,16 +759,16 @@ RSpec.describe Dispatch::Adapter::Copilot do
)
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
- .with { |req|
- body = JSON.parse(req.body)
- body["reasoning_effort"] == "low"
- }
+ .with do |req|
+ body = JSON.parse(req.body)
+ body["reasoning_effort"] == "low"
+ end
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
- "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
- }),
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
+ }),
headers: { "Content-Type" => "application/json" }
)
@@ -777,16 +780,16 @@ RSpec.describe Dispatch::Adapter::Copilot do
it "does not send reasoning_effort when thinking is nil" do
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
- .with { |req|
- body = JSON.parse(req.body)
- !body.key?("reasoning_effort")
- }
+ .with do |req|
+ body = JSON.parse(req.body)
+ !body.key?("reasoning_effort")
+ end
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
- "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
- }),
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
+ }),
headers: { "Content-Type" => "application/json" }
)
@@ -797,16 +800,16 @@ RSpec.describe Dispatch::Adapter::Copilot do
end
it "raises ArgumentError for invalid thinking level" do
- expect {
+ expect do
described_class.new(model: "o3", github_token: github_token, thinking: "extreme")
- }.to raise_error(ArgumentError, /Invalid thinking level/)
+ end.to raise_error(ArgumentError, /Invalid thinking level/)
end
it "raises ArgumentError for invalid per-call thinking level" do
messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
- expect {
+ expect do
adapter.chat(messages, thinking: "extreme")
- }.to raise_error(ArgumentError, /Invalid thinking level/)
+ end.to raise_error(ArgumentError, /Invalid thinking level/)
end
it "allows disabling constructor default with nil per-call" do
@@ -818,16 +821,16 @@ RSpec.describe Dispatch::Adapter::Copilot do
)
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
- .with { |req|
- body = JSON.parse(req.body)
- !body.key?("reasoning_effort")
- }
+ .with do |req|
+ body = JSON.parse(req.body)
+ !body.key?("reasoning_effort")
+ end
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
- "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
- }),
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
+ }),
headers: { "Content-Type" => "application/json" }
)
@@ -844,7 +847,8 @@ RSpec.describe Dispatch::Adapter::Copilot 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" }], "usage" => { "prompt_tokens" => 5, "completion_tokens" => 2 } })}\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
@@ -873,10 +877,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
@@ -909,7 +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" }], "usage" => { "prompt_tokens" => 42, "completion_tokens" => 7 } })}\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
@@ -929,11 +944,24 @@ 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
@@ -960,21 +988,22 @@ RSpec.describe Dispatch::Adapter::Copilot do
describe "authentication" do
it "reuses cached Copilot token for subsequent requests" do
token_stub = stub_request(:get, "https://api.github.com/copilot_internal/v2/token")
- .to_return(
- status: 200,
- body: JSON.generate({ "token" => copilot_token, "expires_at" => (Time.now.to_i + 3600) }),
- headers: { "Content-Type" => "application/json" }
- )
+ .to_return(
+ status: 200,
+ body: JSON.generate({ "token" => copilot_token, "expires_at" => (Time.now.to_i + 3600) }),
+ headers: { "Content-Type" => "application/json" }
+ )
chat_stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
- .to_return(
- status: 200,
- body: JSON.generate({
- "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
- "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
- }),
- headers: { "Content-Type" => "application/json" }
- )
+ .to_return(
+ status: 200,
+ body: JSON.generate({
+ "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")]
adapter.chat(messages)
@@ -1007,11 +1036,11 @@ RSpec.describe Dispatch::Adapter::Copilot do
.to_return(
status: 200,
body: JSON.generate({
- "data" => [
- { "id" => "gpt-4.1", "object" => "model" },
- { "id" => "gpt-4o", "object" => "model" }
- ]
- }),
+ "data" => [
+ { "id" => "gpt-4.1", "object" => "model" },
+ { "id" => "gpt-4o", "object" => "model" }
+ ]
+ }),
headers: { "Content-Type" => "application/json" }
)
diff --git a/spec/dispatch/adapter/errors_spec.rb b/spec/dispatch/adapter/errors_spec.rb
index 77c8389..6893ac8 100644
--- a/spec/dispatch/adapter/errors_spec.rb
+++ b/spec/dispatch/adapter/errors_spec.rb
@@ -19,9 +19,9 @@ RSpec.describe Dispatch::Adapter::Error do
end
it "can be rescued as StandardError" do
- expect {
+ expect do
raise described_class.new("test")
- }.to raise_error(StandardError)
+ end.to raise_error(StandardError)
end
end
@@ -44,9 +44,9 @@ RSpec.describe Dispatch::Adapter::RateLimitError do
end
it "is rescuable as Dispatch::Adapter::Error" do
- expect {
+ expect do
raise described_class.new("rate limited")
- }.to raise_error(Dispatch::Adapter::Error)
+ end.to raise_error(Dispatch::Adapter::Error)
end
end
diff --git a/spec/dispatch/adapter/rate_limiter_spec.rb b/spec/dispatch/adapter/rate_limiter_spec.rb
new file mode 100644
index 0000000..5fcf92f
--- /dev/null
+++ b/spec/dispatch/adapter/rate_limiter_spec.rb
@@ -0,0 +1,407 @@
+# frozen_string_literal: true
+
+require "fileutils"
+require "json"
+require "tempfile"
+
+RSpec.describe Dispatch::Adapter::RateLimiter do
+ let(:tmpdir) { Dir.mktmpdir("rate_limiter_test") }
+ let(:rate_limit_path) { File.join(tmpdir, "copilot_rate_limit") }
+
+ after { FileUtils.rm_rf(tmpdir) }
+
+ describe "#initialize" do
+ it "accepts valid min_request_interval and nil rate_limit" do
+ limiter = described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: 3.0,
+ rate_limit: nil
+ )
+ expect(limiter).to be_a(described_class)
+ end
+
+ it "accepts nil min_request_interval" do
+ limiter = described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: nil,
+ rate_limit: nil
+ )
+ expect(limiter).to be_a(described_class)
+ end
+
+ it "accepts zero min_request_interval" do
+ limiter = described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: 0,
+ rate_limit: nil
+ )
+ expect(limiter).to be_a(described_class)
+ end
+
+ it "accepts valid rate_limit hash" do
+ limiter = described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: nil,
+ rate_limit: { requests: 10, period: 60 }
+ )
+ expect(limiter).to be_a(described_class)
+ end
+
+ it "accepts both min_request_interval and rate_limit" do
+ limiter = described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: 2.0,
+ rate_limit: { requests: 5, period: 30 }
+ )
+ expect(limiter).to be_a(described_class)
+ end
+
+ it "raises ArgumentError for negative min_request_interval" do
+ expect do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: -1,
+ rate_limit: nil
+ )
+ end.to raise_error(ArgumentError, /min_request_interval/)
+ end
+
+ it "raises ArgumentError for non-numeric min_request_interval" do
+ expect do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: "fast",
+ rate_limit: nil
+ )
+ end.to raise_error(ArgumentError, /min_request_interval/)
+ end
+
+ it "raises ArgumentError when rate_limit is missing requests key" do
+ expect do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: nil,
+ rate_limit: { period: 60 }
+ )
+ end.to raise_error(ArgumentError, /requests/)
+ end
+
+ it "raises ArgumentError when rate_limit is missing period key" do
+ expect do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: nil,
+ rate_limit: { requests: 10 }
+ )
+ end.to raise_error(ArgumentError, /period/)
+ end
+
+ it "raises ArgumentError when rate_limit requests is zero" do
+ expect do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: nil,
+ rate_limit: { requests: 0, period: 60 }
+ )
+ end.to raise_error(ArgumentError, /requests/)
+ end
+
+ it "raises ArgumentError when rate_limit requests is negative" do
+ expect do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: nil,
+ rate_limit: { requests: -1, period: 60 }
+ )
+ end.to raise_error(ArgumentError, /requests/)
+ end
+
+ it "raises ArgumentError when rate_limit period is zero" do
+ expect do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: nil,
+ rate_limit: { requests: 10, period: 0 }
+ )
+ end.to raise_error(ArgumentError, /period/)
+ end
+
+ it "raises ArgumentError when rate_limit period is negative" do
+ expect do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: nil,
+ rate_limit: { requests: 10, period: -5 }
+ )
+ end.to raise_error(ArgumentError, /period/)
+ end
+
+ it "raises ArgumentError when rate_limit is not a Hash" do
+ expect do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: nil,
+ rate_limit: "10/60"
+ )
+ end.to raise_error(ArgumentError)
+ end
+ end
+
+ describe "#wait!" do
+ context "with both mechanisms disabled" do
+ let(:limiter) do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: nil,
+ rate_limit: nil
+ )
+ end
+
+ it "returns immediately without sleeping" do
+ expect(limiter).not_to receive(:sleep)
+ limiter.wait!
+ end
+
+ it "does not create a rate limit file" do
+ limiter.wait!
+ expect(File.exist?(rate_limit_path)).to be(false)
+ end
+ end
+
+ context "with per-request cooldown only" do
+ let(:limiter) do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: 1.0,
+ rate_limit: nil
+ )
+ end
+
+ it "does not sleep on the first request" do
+ expect(limiter).not_to receive(:sleep)
+ limiter.wait!
+ end
+
+ it "creates the rate limit file on first request" do
+ limiter.wait!
+ expect(File.exist?(rate_limit_path)).to be(true)
+ end
+
+ it "sets the rate limit file permissions to 0600" do
+ limiter.wait!
+ mode = File.stat(rate_limit_path).mode & 0o777
+ expect(mode).to eq(0o600)
+ end
+
+ it "records last_request_at in the state file" do
+ before = Time.now.to_f
+ limiter.wait!
+ after = Time.now.to_f
+
+ state = JSON.parse(File.read(rate_limit_path))
+ expect(state["last_request_at"]).to be_between(before, after)
+ end
+
+ it "sleeps for the remaining cooldown on a rapid second request" do
+ limiter.wait!
+
+ # Simulate that almost no time has passed
+ allow(limiter).to receive(:sleep) { |duration| expect(duration).to be > 0 }
+ limiter.wait!
+ end
+
+ it "does not sleep when enough time has elapsed between requests" do
+ limiter.wait!
+
+ # Write a past timestamp to simulate time passing
+ state = { "last_request_at" => Time.now.to_f - 2.0, "request_log" => [] }
+ File.write(rate_limit_path, JSON.generate(state))
+
+ expect(limiter).not_to receive(:sleep)
+ limiter.wait!
+ end
+ end
+
+ context "with sliding window only" do
+ let(:limiter) do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: nil,
+ rate_limit: { requests: 3, period: 10 }
+ )
+ end
+
+ it "allows requests up to the window limit without sleeping" do
+ expect(limiter).not_to receive(:sleep)
+ 3.times { limiter.wait! }
+ end
+
+ it "sleeps when the window limit is reached" do
+ now = Time.now.to_f
+ state = {
+ "last_request_at" => now,
+ "request_log" => [now - 2.0, now - 1.0, now]
+ }
+ File.write(rate_limit_path, JSON.generate(state))
+
+ allow(limiter).to receive(:sleep) { |duration| expect(duration).to be > 0 }
+ limiter.wait!
+ end
+
+ it "does not sleep when oldest entries have expired from the window" do
+ now = Time.now.to_f
+ state = {
+ "last_request_at" => now - 5.0,
+ "request_log" => [now - 15.0, now - 12.0, now - 5.0]
+ }
+ File.write(rate_limit_path, JSON.generate(state))
+
+ expect(limiter).not_to receive(:sleep)
+ limiter.wait!
+ end
+
+ it "prunes expired entries from the request_log on write" do
+ now = Time.now.to_f
+ state = {
+ "last_request_at" => now - 5.0,
+ "request_log" => [now - 20.0, now - 15.0, now - 5.0]
+ }
+ File.write(rate_limit_path, JSON.generate(state))
+
+ limiter.wait!
+
+ updated_state = JSON.parse(File.read(rate_limit_path))
+ # Old entries (20s and 15s ago) should be pruned (window is 10s)
+ # Only the 5s-ago entry and the new entry should remain
+ expect(updated_state["request_log"].size).to be <= 2
+ end
+ end
+
+ context "with both mechanisms enabled" do
+ let(:limiter) do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: 1.0,
+ rate_limit: { requests: 3, period: 10 }
+ )
+ end
+
+ it "uses the longer wait time when cooldown is the bottleneck" do
+ limiter.wait!
+
+ # Second request immediately — cooldown should be the bottleneck
+ # (only 1 of 3 window slots used, but cooldown not elapsed)
+ allow(limiter).to receive(:sleep) { |duration| expect(duration).to be > 0 }
+ limiter.wait!
+ end
+
+ it "uses the longer wait time when window limit is the bottleneck" 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
+ }
+ File.write(rate_limit_path, JSON.generate(state))
+
+ allow(limiter).to receive(:sleep) { |duration| expect(duration).to be > 0 }
+ limiter.wait!
+ end
+ end
+
+ context "with a missing or corrupt state file" do
+ let(:limiter) do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: 1.0,
+ rate_limit: nil
+ )
+ end
+
+ it "treats a non-existent file as fresh state" do
+ expect(File.exist?(rate_limit_path)).to be(false)
+ expect(limiter).not_to receive(:sleep)
+ limiter.wait!
+ end
+
+ it "treats an empty file as fresh state" do
+ FileUtils.mkdir_p(File.dirname(rate_limit_path))
+ File.write(rate_limit_path, "")
+
+ expect(limiter).not_to receive(:sleep)
+ limiter.wait!
+ end
+
+ it "treats a corrupt JSON file as fresh state" do
+ FileUtils.mkdir_p(File.dirname(rate_limit_path))
+ File.write(rate_limit_path, "not valid json{{{")
+
+ expect(limiter).not_to receive(:sleep)
+ limiter.wait!
+ end
+
+ it "overwrites corrupt state with valid state after a request" do
+ FileUtils.mkdir_p(File.dirname(rate_limit_path))
+ File.write(rate_limit_path, "garbage")
+
+ limiter.wait!
+
+ state = JSON.parse(File.read(rate_limit_path))
+ expect(state).to have_key("last_request_at")
+ expect(state["last_request_at"]).to be_a(Float)
+ end
+ end
+
+ context "with a missing parent directory" do
+ let(:nested_path) { File.join(tmpdir, "sub", "dir", "copilot_rate_limit") }
+
+ let(:limiter) do
+ described_class.new(
+ rate_limit_path: nested_path,
+ min_request_interval: 1.0,
+ rate_limit: nil
+ )
+ end
+
+ it "creates parent directories" do
+ limiter.wait!
+ expect(File.exist?(nested_path)).to be(true)
+ end
+ end
+
+ context "cross-process coordination" do
+ let(:limiter) do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: 1.0,
+ rate_limit: nil
+ )
+ end
+
+ 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] }
+ FileUtils.mkdir_p(File.dirname(rate_limit_path))
+ File.write(rate_limit_path, JSON.generate(state))
+
+ # Our limiter should see this and wait
+ allow(limiter).to receive(:sleep) { |duration| expect(duration).to be > 0 }
+ limiter.wait!
+ end
+
+ it "writes state that another process can read" do
+ limiter.wait!
+
+ # Another RateLimiter instance (simulating another process) reads the file
+ other_limiter = described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: 1.0,
+ rate_limit: nil
+ )
+
+ allow(other_limiter).to receive(:sleep) { |duration| expect(duration).to be > 0 }
+ other_limiter.wait!
+ end
+ end
+ end
+end