diff options
| -rw-r--r-- | .rubocop.yml | 6 | ||||
| -rw-r--r-- | .rules/changelog/2026-04/01/01.md | 26 | ||||
| -rw-r--r-- | .rules/plan/rate-limiting.md | 180 | ||||
| -rw-r--r-- | Gemfile.lock | 4 | ||||
| -rw-r--r-- | dispatch-adapter-copilot.gemspec | 1 | ||||
| -rw-r--r-- | lib/dispatch/adapter/copilot.rb | 53 | ||||
| -rw-r--r-- | lib/dispatch/adapter/rate_limiter.rb | 171 | ||||
| -rw-r--r-- | lib/dispatch/adapter/response.rb | 8 | ||||
| -rw-r--r-- | lib/dispatch/adapter/version.rb | 2 | ||||
| -rw-r--r-- | spec/dispatch/adapter/copilot_rate_limiting_spec.rb | 245 | ||||
| -rw-r--r-- | spec/dispatch/adapter/copilot_spec.rb | 611 | ||||
| -rw-r--r-- | spec/dispatch/adapter/errors_spec.rb | 8 | ||||
| -rw-r--r-- | spec/dispatch/adapter/rate_limiter_spec.rb | 407 |
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 |
