diff options
| author | Adam Malczewski <[email protected]> | 2026-04-01 13:29:15 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-04-01 13:29:15 +0900 |
| commit | 9aacecab709bd7315a97675750e1874d858bdeef (patch) | |
| tree | ee3fd7fe236b5d1a99791f6c4d4f22b7baa12f1e /spec/dispatch/adapter/copilot_rate_limiting_spec.rb | |
| parent | 3e32dec579fbdedec8c7ddb881207b25bb342e60 (diff) | |
| download | dispatch-adapter-copilot-9aacecab709bd7315a97675750e1874d858bdeef.tar.gz dispatch-adapter-copilot-9aacecab709bd7315a97675750e1874d858bdeef.zip | |
add rate limiting
Diffstat (limited to 'spec/dispatch/adapter/copilot_rate_limiting_spec.rb')
| -rw-r--r-- | spec/dispatch/adapter/copilot_rate_limiting_spec.rb | 245 |
1 files changed, 245 insertions, 0 deletions
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 |
