summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.rubocop.yml11
-rw-r--r--COPILOT_RATE_LIMIT_RESEARCH.md197
-rw-r--r--Gemfile.lock10
-rw-r--r--README.md58
-rw-r--r--dispatch-adapter-copilot.gemspec8
-rw-r--r--lib/dispatch/adapter/copilot.rb95
-rw-r--r--lib/dispatch/adapter/rate_limiter.rb173
-rw-r--r--lib/dispatch/adapter/version.rb2
-rw-r--r--spec/dispatch/adapter/copilot_rate_limiting_spec.rb12
-rw-r--r--spec/dispatch/adapter/copilot_spec.rb481
-rw-r--r--spec/dispatch/adapter/rate_limiter_spec.rb10
-rw-r--r--spec/spec_helper.rb25
12 files changed, 745 insertions, 337 deletions
diff --git a/.rubocop.yml b/.rubocop.yml
index 37b7a19..ff78dd3 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -13,14 +13,19 @@ Style/FrozenStringLiteralComment:
EnforcedStyle: always
Metrics/MethodLength:
- Max: 40
+ Enabled: false
Metrics/ClassLength:
Enabled: false
+Metrics/ModuleLength:
+ Enabled: false
+
Metrics/BlockLength:
- Exclude:
- - "spec/**/*"
+ Enabled: false
+
+Metrics/BlockNesting:
+ Enabled: false
Metrics/ParameterLists:
Enabled: false
diff --git a/COPILOT_RATE_LIMIT_RESEARCH.md b/COPILOT_RATE_LIMIT_RESEARCH.md
new file mode 100644
index 0000000..70e66cd
--- /dev/null
+++ b/COPILOT_RATE_LIMIT_RESEARCH.md
@@ -0,0 +1,197 @@
+# GitHub Copilot Rate Limit / Quota Reset — Research Findings
+
+How the official Copilot CLI, VS Code Copilot Chat, and the CodeCompanion Neovim
+plugin retrieve and display rate-limit / quota reset information, and how to
+replicate it in this adapter.
+
+## TL;DR
+
+The reset countdown comes from **two different places** depending on which
+limit you hit:
+
+1. **Proactive / "how much do I have left"** → `GET https://api.github.com/copilot_internal/user`
+ returns structured remaining counts and an ISO reset date.
+2. **Reactive / on a 429 from `chat/completions`** → either a `Retry-After`
+ header (short-window throttles) or a **plain-text prose body** containing
+ the reset time (weekly/monthly quota). There is **no structured reset
+ timestamp on the 429 response itself** for monthly/weekly limits.
+
+The official CLI and VS Code extension display the prose verbatim. CodeCompanion
+proactively polls `/copilot_internal/user` for its quota UI.
+
+---
+
+## 1. Response headers on 429
+
+When `https://api.githubcopilot.com/chat/completions` (or the
+`*.business.githubcopilot.com` variant) returns 429, the response includes:
+
+- **`retry-after`** — integer seconds (e.g. `retry-after: 60`).
+ - Present for short-window throttles only.
+ - **NOT** present for the weekly/monthly quota 429 — those are returned as
+ `text/plain` and treated as terminal by the official clients.
+- **`x-ratelimit-exceeded`** — identifies which limit tripped (e.g.
+ `global-chat`).
+- **`x-github-request-id`** — opaque, useful for support tickets.
+
+The current adapter (`lib/dispatch/adapter/copilot.rb:414`) already reads
+`Retry-After` for 429. That covers short-window throttles only.
+
+## 2. Response body on 429
+
+JSON body structure (from `vscode-copilot-release` issue threads and
+Cline/CodeCompanion reports):
+
+```json
+{
+ "error": {
+ "message": "Sorry, you have exceeded your Copilot token usage. …",
+ "code": "rate_limited"
+ }
+}
+```
+
+Known `error.code` values:
+
+- **`rate_limited`** — short-window throttle.
+- **`quota_exceeded`** — "You have no quota."
+
+For weekly/monthly quota the body is often **`text/plain`** with literal text
+such as:
+
+- `"Sorry, you've hit a rate limit that restricts the number of Copilot model requests… Please try again in 2 hours."`
+- `"You've reached your weekly rate limit. Please wait for your limit to reset on April 20, 2026 at 2:00 AM or switch to auto model to continue."`
+
+**The reset time is embedded as prose in the message string** — there is no
+structured reset timestamp on the 429 itself.
+
+## 3. CodeCompanion (Neovim plugin)
+
+`olimorris/codecompanion.nvim` does **almost nothing** with the 429 itself. In
+`lua/codecompanion/adapters/http/copilot/init.lua` ~lines 288-293 it only
+string-matches `"quota"` + `"exceeded"` in the response and returns:
+
+> `"Your Copilot quota has been exceeded for this conversation"`
+
+No header parsing, no countdown.
+
+The reset-aware UI lives in
+**`lua/codecompanion/adapters/http/copilot/stats.lua`**, which calls a
+*separate* endpoint (see §6) **before** hitting the limit, not in response to
+a 429.
+
+## 4. Official Copilot CLI (`github/copilot-cli`)
+
+The repository is a **closed-source binary** — only issues and discussions are
+public. Observable behavior from issues #2828, #2336, #2742:
+
+- It surfaces the prose message directly from the 429 plain-text body
+ (e.g. *"Please wait for your limit to reset on April 20, 2026 at 2:00 AM"*).
+- Parses internal `error.code` values like `rate_limited`, `quota_exceeded`.
+- The "Resets in X hours" countdown the user sees comes from the **prose** in
+ the API response — there is no documented header for it.
+
+## 5. VS Code Copilot Chat (`microsoft/vscode-copilot-chat`)
+
+- Reads `Retry-After` for status 429.
+- Treats `text/plain` 429s (weekly quota) as terminal — no retries, just shows
+ the message.
+- Same prose-extraction pattern as the CLI.
+- No header gives a structured reset timestamp.
+
+## 6. Quota endpoint — `GET https://api.github.com/copilot_internal/user`
+
+**This is the actual answer for showing a reset countdown proactively.**
+
+Headers:
+
+```
+Authorization: Bearer <oauth_token>
+Accept: */*
+```
+
+Note: this uses the **GitHub OAuth token**, not the short-lived Copilot bearer
+token returned from `/copilot_internal/v2/token`.
+
+Source: `codecompanion.nvim/lua/codecompanion/adapters/http/copilot/stats.lua`.
+
+### Response fields
+
+**Limited (Free) users**
+
+- `access_type_sku`
+- `monthly_quotas.chat`
+- `monthly_quotas.completions`
+- `limited_user_quotas.chat` (remaining)
+- `limited_user_quotas.completions` (remaining)
+- `limited_user_reset_date` (ISO `YYYY-MM-DD`)
+
+**Premium (paid) users**
+
+- `quota_snapshots.premium_interactions.entitlement`
+- `quota_snapshots.premium_interactions.remaining`
+- `quota_snapshots.premium_interactions.percent_remaining`
+- `quota_snapshots.premium_interactions.unlimited`
+- `quota_snapshots.premium_interactions.overage_permitted`
+- `quota_snapshots.chat.{entitlement,remaining,unlimited}`
+- `quota_snapshots.completions.{entitlement,remaining,unlimited}`
+- `quota_reset_date` (ISO `YYYY-MM-DD`)
+
+### CodeCompanion countdown logic
+
+```lua
+local y, m, d = reset_date:match("^(%d+)-(%d+)-(%d+)$")
+local days_left = (os.time({year=tonumber(y), month=tonumber(m), day=tonumber(d)}) - os.time()) / 86400
+```
+
+### Reset semantics (from GitHub docs)
+
+- **Pro / Pro+ premium counters** reset on the **1st of each month at 00:00 UTC**.
+- **Free plan** resets on the user's billing date.
+
+## 7. Token endpoint — `GET https://api.github.com/copilot_internal/v2/token`
+
+Response fields actually consumed (per CodeCompanion `token.lua` `CopilotToken`
+typedef):
+
+```
+token: string
+expires_at: number (unix seconds; this adapter uses it at copilot.rb:300)
+chat_enabled: boolean
+annotations_enabled: boolean
+endpoints: { api, proxy, telemetry, "origin-tracker" }
+```
+
+**No `quota_reset_date` or `limited_user_quotas` here.** Quota info is only on
+`/copilot_internal/user`.
+
+---
+
+## Recommendations for this adapter
+
+1. Add a fetcher for `GET /copilot_internal/user` (with the **OAuth GitHub
+ token**, not the Copilot bearer token) to get structured `quota_reset_date`
+ / `limited_user_reset_date` and remaining counts. Cache the response.
+2. In `handle_error_response!` (`lib/dispatch/adapter/copilot.rb:401`):
+ - Read `Retry-After` (already done).
+ - Parse `error.code` (`rate_limited` vs `quota_exceeded`).
+ - Fall back to passing the plain-text body verbatim to the user — it
+ contains the only authoritative reset prose for weekly/monthly limits.
+ - On `quota_exceeded` or text-body 429, *also* fetch
+ `/copilot_internal/user` to render a structured "resets in N days/hours"
+ message.
+
+---
+
+## Sources
+
+- [CodeCompanion Copilot adapter init.lua](https://github.com/olimorris/codecompanion.nvim/blob/main/lua/codecompanion/adapters/http/copilot/init.lua)
+- [CodeCompanion stats.lua (quota fetcher)](https://github.com/olimorris/codecompanion.nvim/blob/main/lua/codecompanion/adapters/http/copilot/stats.lua)
+- [CodeCompanion token.lua](https://github.com/olimorris/codecompanion.nvim/blob/main/lua/codecompanion/adapters/http/copilot/token.lua)
+- [github/copilot-cli issue #2828 — Weekly rate limiting prose](https://github.com/github/copilot-cli/issues/2828)
+- [github/copilot-cli issue #2336 — "Please try again in 2 hours"](https://github.com/github/copilot-cli/issues/2336)
+- [github/copilot-cli issue #2742 — Persistent 429 on Pro+](https://github.com/github/copilot-cli/issues/2742)
+- [vscode-copilot-release issue #6451 — exhausted model rate limit](https://github.com/microsoft/vscode-copilot-release/issues/6451)
+- [GitHub Docs — Requests in GitHub Copilot (premium request reset semantics)](https://docs.github.com/en/copilot/concepts/billing/copilot-requests)
+- [GitHub Docs — Monitoring Copilot usage and entitlements](https://docs.github.com/copilot/how-tos/monitoring-your-copilot-usage-and-entitlements)
+- [ericc-ch/copilot-api (`/usage` endpoint mirrors `/copilot_internal/user`)](https://github.com/ericc-ch/copilot-api)
diff --git a/Gemfile.lock b/Gemfile.lock
index cbb9a56..f7595b0 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,13 +1,13 @@
PATH
remote: ../dispatch-adapter-interface
specs:
- dispatch-adapter-interface (0.1.0)
+ dispatch-adapter-interface (0.2.0)
PATH
remote: .
specs:
- dispatch-adapter-copilot (0.3.0)
- dispatch-adapter-interface (~> 0.1)
+ dispatch-adapter-copilot (0.4.0)
+ dispatch-adapter-interface (~> 0.2)
GEM
remote: https://rubygems.org/
@@ -113,8 +113,8 @@ CHECKSUMS
crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e
date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
- dispatch-adapter-copilot (0.3.0)
- dispatch-adapter-interface (0.1.0)
+ dispatch-adapter-copilot (0.4.0)
+ dispatch-adapter-interface (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/README.md b/README.md
index 30cdddf..aa66591 100644
--- a/README.md
+++ b/README.md
@@ -204,6 +204,64 @@ All communication uses these structs (under `Dispatch::Adapter`):
| `StreamDelta` | Incremental streaming chunk |
| `ModelInfo` | Model metadata |
+## Premium Requests / Billing Behavior
+
+GitHub Copilot bills your monthly *premium request* quota only for chat
+completions sent with the HTTP header `X-Initiator: user`. Continuations sent
+with `X-Initiator: agent` (e.g. follow-up calls inside an agent tool loop) are
+**not** counted against your premium quota.
+
+This adapter automatically chooses the correct value for `X-Initiator` on
+every request using a "savings" strategy:
+
+* The **first** send of a conversation — i.e. only `system` and `user`
+ messages are present — is sent as `X-Initiator: user` and **is** billed as a
+ premium request.
+* **Every subsequent send** — anything that contains a prior `assistant` or
+ `tool` message in the wire payload — is sent as `X-Initiator: agent` and is
+ **not** billed.
+
+In practice this means: in a typical agent-loop usage of this gem (initial
+user prompt → model returns tool calls → your code executes them and sends
+results back → repeat until the model gives a final answer), exactly **one**
+premium request is consumed per top-level user prompt, regardless of how many
+tool round-trips occur in between.
+
+This behavior is more aggressive (i.e. cheaper) than the official VS Code
+Copilot extension and matches the default mode of
+[`ericc-ch/copilot-api`](https://github.com/ericc-ch/copilot-api). It assumes
+the gem is being used for automation, where every send after the first is
+part of the same agent task.
+
+### Wire-level parity with codecompanion.nvim
+
+The HTTP request headers this adapter sends to `api.githubcopilot.com` are
+identical to those sent by
+[codecompanion.nvim](https://github.com/olimorris/codecompanion.nvim)'s
+Copilot adapter:
+
+| Header | Value |
+|---|---|
+| `Authorization` | `Bearer <copilot-token>` |
+| `Content-Type` | `application/json` |
+| `Copilot-Integration-Id` | `vscode-chat` |
+| `Editor-Version` | `Neovim/0.10.4` (default — override via `editor_version:`) |
+| `X-Initiator` | `user` or `agent` (see above) |
+
+Notably, `Openai-Intent` is **not** sent (codecompanion does not send it).
+
+If you want the request to claim a specific Neovim version (e.g. the one
+you actually run), pass it to the constructor:
+
+```ruby
+adapter = Dispatch::Adapter::Copilot.new(editor_version: "Neovim/0.12.1")
+```
+
+References:
+- [GitHub: What are premium requests?](https://docs.github.com/en/copilot/concepts/billing/copilot-requests#what-are-premium-requests)
+- [codecompanion.nvim Discussion #1717 / PR #1738](https://github.com/olimorris/codecompanion.nvim/discussions/1717)
+- [ericc-ch/copilot-api PR #85](https://github.com/ericc-ch/copilot-api/pull/85)
+
## Error Handling
All errors inherit from `Dispatch::Adapter::Error` (which inherits from `StandardError`):
diff --git a/dispatch-adapter-copilot.gemspec b/dispatch-adapter-copilot.gemspec
index 73c29c3..4dbdb1e 100644
--- a/dispatch-adapter-copilot.gemspec
+++ b/dispatch-adapter-copilot.gemspec
@@ -5,8 +5,8 @@ require_relative "lib/dispatch/adapter/version"
Gem::Specification.new do |spec|
spec.name = "dispatch-adapter-copilot"
spec.version = Dispatch::Adapter::CopilotVersion::VERSION
- spec.authors = [ "Adam Malczewski" ]
- spec.email = [ "[email protected]" ]
+ spec.authors = ["Adam Malczewski"]
+ spec.email = ["[email protected]"]
spec.summary = "GitHub Copilot adapter for Dispatch LLM framework"
spec.description = "GitHub Copilot adapter for the Dispatch LLM framework, implementing the dispatch-adapter-interface to provide chat completions via the Copilot API over HTTP."
@@ -28,10 +28,10 @@ Gem::Specification.new do |spec|
end.select { |f| File.exist?(File.join(__dir__, f)) }
spec.bindir = "exe"
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
- spec.require_paths = [ "lib" ]
+ spec.require_paths = ["lib"]
# Uncomment to register a new dependency of your gem
- spec.add_dependency "dispatch-adapter-interface", "~> 0.1"
+ spec.add_dependency "dispatch-adapter-interface", "~> 0.2"
# For more information and examples about making a new gem, check out our
# guide at: https://bundler.io/guides/creating_gem.html
diff --git a/lib/dispatch/adapter/copilot.rb b/lib/dispatch/adapter/copilot.rb
index b14c5c0..7355df8 100644
--- a/lib/dispatch/adapter/copilot.rb
+++ b/lib/dispatch/adapter/copilot.rb
@@ -51,14 +51,22 @@ module Dispatch
VALID_THINKING_LEVELS = %w[low medium high].freeze
+ # Default Editor-Version header value. Mimics what codecompanion.nvim
+ # sends so that requests are indistinguishable on the wire from the
+ # well-known Neovim Copilot adapter (which is widely used and trusted).
+ # Override via the `editor_version:` constructor option if you need a
+ # different value (e.g. your actual running Neovim version).
+ DEFAULT_EDITOR_VERSION = "Neovim/0.10.4"
+
def initialize(model: "gpt-4.1", github_token: nil, token_path: nil, max_tokens: 8192, thinking: "high",
- min_request_interval: 3.0, rate_limit: nil)
+ min_request_interval: 3.0, rate_limit: nil, editor_version: DEFAULT_EDITOR_VERSION)
super()
@model = model
@github_token = github_token
@token_path = token_path || default_token_path
@default_max_tokens = max_tokens
@default_thinking = thinking
+ @editor_version = editor_version
@copilot_token = nil
@copilot_token_expires_at = 0
@mutex = Mutex.new
@@ -295,13 +303,51 @@ module Dispatch
# --- HTTP helpers ---
- def apply_headers!(request)
+ # Apply the request headers used for Copilot chat completions.
+ #
+ # Header set is intentionally identical to what codecompanion.nvim's
+ # Copilot adapter sends (see lua/codecompanion/adapters/http/copilot/init.lua):
+ #
+ # - Authorization: Bearer <copilot-token>
+ # - Content-Type: application/json
+ # - Copilot-Integration-Id: vscode-chat
+ # - Editor-Version: Neovim/x.y.z (configurable)
+ # - X-Initiator: user|agent (only added by callers via apply_headers!)
+ #
+ # We deliberately DO NOT send `Openai-Intent` because codecompanion
+ # does not, and matching that wire profile is the goal.
+ def apply_headers!(request, initiator: "user")
request["Authorization"] = "Bearer #{@copilot_token}"
request["Content-Type"] = "application/json"
request["Accept"] = "application/json"
request["Copilot-Integration-Id"] = "vscode-chat"
- request["Editor-Version"] = "dispatch/#{VERSION}"
- request["Openai-Intent"] = "conversation-panel"
+ request["Editor-Version"] = @editor_version
+ request["X-Initiator"] = initiator
+ end
+
+ # Decides the value of the `X-Initiator` header that GitHub Copilot uses
+ # to classify a request as a billable premium request ("user") or a
+ # non-billable agent continuation ("agent").
+ #
+ # Strategy: "savings" mode (matches ericc-ch/copilot-api default and is
+ # more aggressive than codecompanion.nvim / VS Code).
+ #
+ # * If the wire payload contains ANY assistant or tool message, it means
+ # the model has already produced at least one turn — therefore this
+ # send is part of an ongoing agent loop (typically a tool-result
+ # follow-up) and is NOT a fresh user-initiated turn. → "agent".
+ # * Otherwise this is the very first send for a conversation (only
+ # system + user messages present). → "user".
+ #
+ # Only the initial user prompt of an automation should be billed as a
+ # premium request; every subsequent tool-loop continuation is free.
+ #
+ # Rationale & references:
+ # - codecompanion.nvim PR #1738 / Discussion #1717
+ # - ericc-ch/copilot-api PR #85 ("savings" vs "per-user-prompt" modes)
+ # - https://docs.github.com/en/copilot/concepts/billing/copilot-requests
+ def x_initiator_for(wire_messages)
+ wire_messages.any? { |m| %w[assistant tool].include?(m[:role].to_s) } ? "agent" : "user"
end
def execute_request(uri, request)
@@ -460,13 +506,13 @@ module Dispatch
def merge_consecutive_roles(messages)
return messages if messages.empty?
- merged = [ messages.first.dup ]
+ merged = [messages.first.dup]
messages[1..].each do |msg|
prev = merged.last
if prev[:role] == msg[:role] && prev[:role] != "tool" && !msg.key?(:tool_calls) && !prev.key?(:tool_calls)
- prev[:content] = [ prev[:content], msg[:content] ].compact.join("\n\n")
+ prev[:content] = [prev[:content], msg[:content]].compact.join("\n\n")
else
merged << msg.dup
end
@@ -504,8 +550,8 @@ module Dispatch
@rate_limiter.wait!
uri = URI("#{API_BASE}/chat/completions")
request = Net::HTTP::Post.new(uri)
- apply_headers!(request)
- request.body = JSON.generate(body)
+ apply_headers!(request, initiator: x_initiator_for(body[:messages] || []))
+ request.body = JSON.generate(deep_utf8(body))
response = execute_request(uri, request)
data = parse_response!(response)
@@ -557,6 +603,35 @@ module Dispatch
)
end
+ # Recursively coerces every String inside a wire-body to valid UTF-8.
+ #
+ # Tool results (grep output, file reads, shell stdout) frequently arrive
+ # tagged as US-ASCII or BINARY/ASCII-8BIT even though the bytes are
+ # legitimate UTF-8 (e.g. an em-dash \xE2\x80\x94 inside a source
+ # comment). `JSON.generate` then raises
+ # `Encoding::InvalidByteSequenceError: "\xE2" on US-ASCII` because it
+ # tries to re-encode the mistagged string.
+ #
+ # We force_encoding to UTF-8 (no byte rewrite) and then `scrub` to
+ # replace any genuinely invalid sequences with the Unicode replacement
+ # character so JSON.generate can never fail on user-provided text.
+ def deep_utf8(obj)
+ case obj
+ when String
+ s = obj.dup
+ s.force_encoding(Encoding::UTF_8)
+ s.valid_encoding? ? s : s.scrub("\uFFFD")
+ when Array
+ obj.map { |v| deep_utf8(v) }
+ when Hash
+ obj.each_with_object({}) { |(k, v), h| h[k] = deep_utf8(v) }
+ when Symbol
+ obj
+ else
+ obj
+ end
+ end
+
def parse_tool_arguments(args_string)
return {} if args_string.nil? || args_string.empty?
@@ -571,8 +646,8 @@ module Dispatch
@rate_limiter.wait!
uri = URI("#{API_BASE}/chat/completions")
request = Net::HTTP::Post.new(uri)
- apply_headers!(request)
- request.body = JSON.generate(body)
+ apply_headers!(request, initiator: x_initiator_for(body[:messages] || []))
+ request.body = JSON.generate(deep_utf8(body))
collected = new_stream_collector
diff --git a/lib/dispatch/adapter/rate_limiter.rb b/lib/dispatch/adapter/rate_limiter.rb
index 1b05582..7b0e3ed 100644
--- a/lib/dispatch/adapter/rate_limiter.rb
+++ b/lib/dispatch/adapter/rate_limiter.rb
@@ -1,174 +1,3 @@
# 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
- done = false
-
- 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)
- done = true
- end
- end
-
- return if done
-
- 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.positive? ? 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.positive? ? 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
+require "dispatch/adapter/interface/rate_limiter"
diff --git a/lib/dispatch/adapter/version.rb b/lib/dispatch/adapter/version.rb
index cde8614..967c0c3 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.3.0"
+ VERSION = "0.5.0"
end
end
end
diff --git a/spec/dispatch/adapter/copilot_rate_limiting_spec.rb b/spec/dispatch/adapter/copilot_rate_limiting_spec.rb
index 6a7ef2b..a51ed2b 100644
--- a/spec/dispatch/adapter/copilot_rate_limiting_spec.rb
+++ b/spec/dispatch/adapter/copilot_rate_limiting_spec.rb
@@ -12,12 +12,12 @@ RSpec.describe Dispatch::Adapter::Copilot, "rate limiting" do
let(:chat_response_body) do
JSON.generate({
- "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
})
end
- let(:messages) { [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ] }
+ let(:messages) { [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] }
before do
stub_request(:get, "https://api.github.com/copilot_internal/v2/token")
@@ -181,8 +181,8 @@ RSpec.describe Dispatch::Adapter::Copilot, "rate limiting" do
describe "#chat streaming with rate limiting" do
it "calls wait! before a streaming request" do
sse_body = [
- "data: #{JSON.generate({ "choices" => [ { "delta" => { "content" => "hi" }, "index" => 0 } ] })}\n\n",
- "data: #{JSON.generate({ "choices" => [ { "delta" => {}, "index" => 0, "finish_reason" => "stop" } ],
+ "data: #{JSON.generate({ "choices" => [{ "delta" => { "content" => "hi" }, "index" => 0 }] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [{ "delta" => {}, "index" => 0, "finish_reason" => "stop" }],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 } })}\n\n",
"data: [DONE]\n\n"
].join
@@ -211,12 +211,12 @@ RSpec.describe Dispatch::Adapter::Copilot, "rate limiting" do
.to_return(
status: 200,
body: JSON.generate({
- "data" => [ {
+ "data" => [{
"id" => "gpt-4.1",
"name" => "GPT 4.1",
"model_picker_enabled" => true,
"capabilities" => { "type" => "chat", "supports" => {} }
- } ]
+ }]
}),
headers: { "Content-Type" => "application/json" }
)
diff --git a/spec/dispatch/adapter/copilot_spec.rb b/spec/dispatch/adapter/copilot_spec.rb
index f3ccb37..15bdcb0 100644
--- a/spec/dispatch/adapter/copilot_spec.rb
+++ b/spec/dispatch/adapter/copilot_spec.rb
@@ -6,6 +6,32 @@ RSpec.describe Dispatch::Adapter::Copilot do
let(:copilot_token) { "cop_test_token_abc" }
let(:github_token) { "gho_test_github_token" }
+ # ---------------------------------------------------------------------------
+ # SAFETY: this MUST be the first test in the file. If WebMock is not
+ # globally blocking real network access, every other test in this gem could
+ # potentially hit the real GitHub Copilot API and consume premium-request
+ # quota / leak credentials. If this test fails, STOP and fix spec_helper
+ # before running any other spec.
+ # ---------------------------------------------------------------------------
+ describe "!! network safety !!" do
+ it "globally blocks real outbound HTTP via WebMock" do
+ expect(WebMock.net_connect_allowed?).to be(false)
+ end
+
+ it "raises when an unstubbed request is attempted" do
+ expect do
+ Net::HTTP.get(URI("https://api.githubcopilot.com/never-should-fire"))
+ end.to raise_error(WebMock::NetConnectNotAllowedError)
+ end
+
+ it "forbids localhost as well (no accidental dev-server contact)" do
+ # The spec_helper passes allow_localhost: false. Verify it.
+ expect do
+ Net::HTTP.get(URI("http://127.0.0.1:1/should-not-fire"))
+ end.to raise_error(WebMock::NetConnectNotAllowedError)
+ end
+ end
+
let(:adapter) do
described_class.new(
model: "gpt-4.1",
@@ -37,7 +63,7 @@ RSpec.describe Dispatch::Adapter::Copilot do
describe "VERSION" do
it "is accessible" do
-expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
+ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.4.0")
end
end
@@ -73,11 +99,11 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
body: JSON.generate({
"id" => "chatcmpl-123",
"model" => "gpt-4.1",
- "choices" => [ {
+ "choices" => [{
"index" => 0,
"message" => { "role" => "assistant", "content" => "Hello there!" },
"finish_reason" => "stop"
- } ],
+ }],
"usage" => { "prompt_tokens" => 10, "completion_tokens" => 5 }
}),
headers: { "Content-Type" => "application/json" }
@@ -85,7 +111,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
end
it "returns a Response with content" do
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
response = adapter.chat(messages)
expect(response).to be_a(Dispatch::Adapter::Response)
@@ -106,22 +132,22 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
body: JSON.generate({
"id" => "chatcmpl-456",
"model" => "gpt-4.1",
- "choices" => [ {
+ "choices" => [{
"index" => 0,
"message" => {
"role" => "assistant",
"content" => nil,
- "tool_calls" => [ {
+ "tool_calls" => [{
"id" => "call_abc",
"type" => "function",
"function" => {
"name" => "get_weather",
"arguments" => '{"city":"New York"}'
}
- } ]
+ }]
},
"finish_reason" => "tool_calls"
- } ],
+ }],
"usage" => { "prompt_tokens" => 15, "completion_tokens" => 10 }
}),
headers: { "Content-Type" => "application/json" }
@@ -129,7 +155,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
end
it "returns a Response with tool_calls as ToolUseBlock array" do
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "What's the weather?") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "What's the weather?")]
response = adapter.chat(messages)
expect(response.content).to be_nil
@@ -150,7 +176,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [ {
+ "choices" => [{
"index" => 0,
"message" => {
"role" => "assistant",
@@ -169,7 +195,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
]
},
"finish_reason" => "tool_calls"
- } ],
+ }],
"usage" => { "prompt_tokens" => 20, "completion_tokens" => 15 }
}),
headers: { "Content-Type" => "application/json" }
@@ -177,7 +203,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
end
it "returns multiple ToolUseBlocks" do
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "weather and time?") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "weather and time?")]
response = adapter.chat(messages)
expect(response.tool_calls.size).to eq(2)
@@ -197,22 +223,22 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
body: JSON.generate({
"id" => "chatcmpl-789",
"model" => "gpt-4.1",
- "choices" => [ {
+ "choices" => [{
"index" => 0,
"message" => {
"role" => "assistant",
"content" => "Let me check that for you.",
- "tool_calls" => [ {
+ "tool_calls" => [{
"id" => "call_def",
"type" => "function",
"function" => {
"name" => "search",
"arguments" => '{"query":"Ruby gems"}'
}
- } ]
+ }]
},
"finish_reason" => "tool_calls"
- } ],
+ }],
"usage" => { "prompt_tokens" => 20, "completion_tokens" => 15 }
}),
headers: { "Content-Type" => "application/json" }
@@ -220,7 +246,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
end
it "returns both content and tool_calls" do
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Search for Ruby gems") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Search for Ruby gems")]
response = adapter.chat(messages)
expect(response.content).to eq("Let me check that for you.")
@@ -239,13 +265,13 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [ { "message" => { "content" => "OK" }, "finish_reason" => "stop" } ],
+ "choices" => [{ "message" => { "content" => "OK" }, "finish_reason" => "stop" }],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
adapter.chat(messages, system: "You are helpful.")
expect(stub).to have_been_requested
@@ -262,13 +288,13 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [ { "message" => { "content" => "short" }, "finish_reason" => "stop" } ],
+ "choices" => [{ "message" => { "content" => "short" }, "finish_reason" => "stop" }],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
adapter.chat(messages, max_tokens: 100)
expect(stub).to have_been_requested
@@ -283,13 +309,13 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
adapter.chat(messages)
expect(stub).to have_been_requested
@@ -307,26 +333,26 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.with do |req|
body = JSON.parse(req.body)
- body["tools"] == [ {
+ body["tools"] == [{
"type" => "function",
"function" => {
"name" => "get_weather",
"description" => "Get weather for a city",
"parameters" => { "type" => "object", "properties" => { "city" => { "type" => "string" } } }
}
- } ]
+ }]
end
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "weather?") ]
- adapter.chat(messages, tools: [ tool ])
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "weather?")]
+ adapter.chat(messages, tools: [tool])
expect(stub).to have_been_requested
end
@@ -341,26 +367,26 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.with do |req|
body = JSON.parse(req.body)
- body["tools"] == [ {
+ body["tools"] == [{
"type" => "function",
"function" => {
"name" => "get_weather",
"description" => "Get weather for a city",
"parameters" => { "type" => "object", "properties" => { "city" => { "type" => "string" } } }
}
- } ]
+ }]
end
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "weather?") ]
- adapter.chat(messages, tools: [ tool_hash ])
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "weather?")]
+ adapter.chat(messages, tools: [tool_hash])
expect(stub).to have_been_requested
end
@@ -375,26 +401,26 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.with do |req|
body = JSON.parse(req.body)
- body["tools"] == [ {
+ body["tools"] == [{
"type" => "function",
"function" => {
"name" => "get_weather",
"description" => "Get weather for a city",
"parameters" => { "type" => "object", "properties" => { "city" => { "type" => "string" } } }
}
- } ]
+ }]
end
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "weather?") ]
- adapter.chat(messages, tools: [ tool_hash ])
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "weather?")]
+ adapter.chat(messages, tools: [tool_hash])
expect(stub).to have_been_requested
end
@@ -421,14 +447,14 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "both?") ]
- adapter.chat(messages, tools: [ tool_struct, tool_hash ])
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "both?")]
+ adapter.chat(messages, tools: [tool_struct, tool_hash])
expect(stub).to have_been_requested
end
@@ -442,13 +468,13 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
adapter.chat(messages)
expect(stub).to have_been_requested
@@ -466,8 +492,8 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
messages = [
Dispatch::Adapter::Message.new(role: "user", content: "What's the weather?"),
- Dispatch::Adapter::Message.new(role: "assistant", content: [ tool_use ]),
- Dispatch::Adapter::Message.new(role: "user", content: [ tool_result ])
+ Dispatch::Adapter::Message.new(role: "assistant", content: [tool_use]),
+ Dispatch::Adapter::Message.new(role: "user", content: [tool_result])
]
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
@@ -488,8 +514,8 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [ { "message" => { "content" => "It's 72F and sunny in NYC!" },
- "finish_reason" => "stop" } ],
+ "choices" => [{ "message" => { "content" => "It's 72F and sunny in NYC!" },
+ "finish_reason" => "stop" }],
"usage" => { "prompt_tokens" => 20, "completion_tokens" => 10 }
}),
headers: { "Content-Type" => "application/json" }
@@ -503,7 +529,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
context "with ImageBlock" do
it "raises NotImplementedError" do
image = Dispatch::Adapter::ImageBlock.new(source: "base64data", media_type: "image/png")
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: [ image ]) ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: [image])]
expect { adapter.chat(messages) }.to raise_error(NotImplementedError, /ImageBlock/)
end
@@ -515,7 +541,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
Dispatch::Adapter::TextBlock.new(text: "First paragraph."),
Dispatch::Adapter::TextBlock.new(text: "Second paragraph.")
]
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: text_blocks) ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: text_blocks)]
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.with do |req|
@@ -526,7 +552,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
@@ -552,8 +578,8 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
messages = [
Dispatch::Adapter::Message.new(role: "user", content: "search"),
- Dispatch::Adapter::Message.new(role: "assistant", content: [ tool_use ]),
- Dispatch::Adapter::Message.new(role: "user", content: [ tool_result ])
+ Dispatch::Adapter::Message.new(role: "assistant", content: [tool_use]),
+ Dispatch::Adapter::Message.new(role: "user", content: [tool_result])
]
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
@@ -566,7 +592,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
"usage" => { "prompt_tokens" => 10, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
@@ -588,8 +614,8 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
messages = [
Dispatch::Adapter::Message.new(role: "user", content: "do it"),
- Dispatch::Adapter::Message.new(role: "assistant", content: [ tool_use ]),
- Dispatch::Adapter::Message.new(role: "user", content: [ tool_result ])
+ Dispatch::Adapter::Message.new(role: "assistant", content: [tool_use]),
+ Dispatch::Adapter::Message.new(role: "user", content: [tool_result])
]
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
@@ -602,8 +628,8 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [ { "message" => { "content" => "I see the error" },
- "finish_reason" => "stop" } ],
+ "choices" => [{ "message" => { "content" => "I see the error" },
+ "finish_reason" => "stop" }],
"usage" => { "prompt_tokens" => 10, "completion_tokens" => 3 }
}),
headers: { "Content-Type" => "application/json" }
@@ -620,16 +646,16 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [ {
+ "choices" => [{
"message" => { "content" => "truncated output..." },
"finish_reason" => "length"
- } ],
+ }],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 100 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Write a long essay") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Write a long essay")]
response = adapter.chat(messages)
expect(response.stop_reason).to eq(:max_tokens)
@@ -646,7 +672,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
messages = [
Dispatch::Adapter::Message.new(role: "user", content: "lookup 42"),
- Dispatch::Adapter::Message.new(role: "assistant", content: [ text, tool_use ])
+ Dispatch::Adapter::Message.new(role: "assistant", content: [text, tool_use])
]
stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
@@ -662,7 +688,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
"usage" => { "prompt_tokens" => 10, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
@@ -689,7 +715,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
@@ -710,14 +736,14 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [ { "message" => { "content" => "thought deeply" },
- "finish_reason" => "stop" } ],
+ "choices" => [{ "message" => { "content" => "thought deeply" },
+ "finish_reason" => "stop" }],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 3 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Think hard") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Think hard")]
adapter.chat(messages, thinking: "high")
expect(stub).to have_been_requested
@@ -739,13 +765,13 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
thinking_adapter.chat(messages)
expect(stub).to have_been_requested
@@ -767,13 +793,13 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
thinking_adapter.chat(messages, thinking: "low")
expect(stub).to have_been_requested
@@ -788,14 +814,17 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
- adapter.chat(messages)
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ # Explicitly disable thinking for this single call. The default adapter
+ # is constructed with thinking: "high", so we must pass thinking: nil
+ # per-call to suppress reasoning_effort.
+ adapter.chat(messages, thinking: nil)
expect(stub).to have_been_requested
end
@@ -807,7 +836,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
end
it "raises ArgumentError for invalid per-call thinking level" do
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
expect do
adapter.chat(messages, thinking: "extreme")
end.to raise_error(ArgumentError, /Invalid thinking level/)
@@ -829,13 +858,13 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [ { "message" => { "content" => "ok" }, "finish_reason" => "stop" } ],
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
thinking_adapter.chat(messages, thinking: nil)
expect(stub).to have_been_requested
@@ -843,12 +872,202 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
end
end
+ describe "X-Initiator header (premium request billing)" do
+ # GitHub Copilot only bills requests sent with `X-Initiator: user` as
+ # premium requests. Continuations inside a tool/agent loop must be sent
+ # with `X-Initiator: agent` to avoid being billed.
+ #
+ # This adapter uses the "savings" strategy: the very first send for a
+ # conversation (only system + user) is `user`; every subsequent send
+ # (containing any assistant or tool message) is `agent`.
+
+ let(:ok_response) do
+ {
+ status: 200,
+ body: JSON.generate({
+ "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }],
+ "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
+ }),
+ headers: { "Content-Type" => "application/json" }
+ }
+ end
+
+ it "sends X-Initiator: user for the first request (user message only)" do
+ stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
+ .with(headers: { "X-Initiator" => "user" })
+ .to_return(**ok_response)
+
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ adapter.chat(messages)
+
+ expect(stub).to have_been_requested
+ end
+
+ it "sends X-Initiator: user when only a system + user message are present" do
+ stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
+ .with(headers: { "X-Initiator" => "user" })
+ .to_return(**ok_response)
+
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ adapter.chat(messages, system: "You are a helpful assistant.")
+
+ expect(stub).to have_been_requested
+ end
+
+ it "sends X-Initiator: agent when sending a tool result back to the model" do
+ # The classic agent-loop continuation: prior assistant tool_use +
+ # current tool_result. This MUST NOT be billed as premium.
+ tool_use = Dispatch::Adapter::ToolUseBlock.new(
+ id: "call_1", name: "get_weather", arguments: { "city" => "NYC" }
+ )
+ tool_result = Dispatch::Adapter::ToolResultBlock.new(
+ tool_use_id: "call_1", content: "72F and sunny"
+ )
+
+ messages = [
+ Dispatch::Adapter::Message.new(role: "user", content: "What's the weather?"),
+ Dispatch::Adapter::Message.new(role: "assistant", content: [tool_use]),
+ Dispatch::Adapter::Message.new(role: "user", content: [tool_result])
+ ]
+
+ stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
+ .with(headers: { "X-Initiator" => "agent" })
+ .to_return(**ok_response)
+
+ adapter.chat(messages)
+
+ expect(stub).to have_been_requested
+ end
+
+ it "sends X-Initiator: agent when an assistant text turn is present (multi-turn)" do
+ # Savings semantics: any prior assistant turn in history flips this to
+ # `agent`, even if the latest message is a fresh user prompt. This is
+ # intentional and more aggressive than VS Code.
+ messages = [
+ Dispatch::Adapter::Message.new(role: "user", content: "Hi"),
+ Dispatch::Adapter::Message.new(role: "assistant", content: "Hello!"),
+ Dispatch::Adapter::Message.new(role: "user", content: "And again?")
+ ]
+
+ stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
+ .with(headers: { "X-Initiator" => "agent" })
+ .to_return(**ok_response)
+
+ adapter.chat(messages)
+
+ expect(stub).to have_been_requested
+ end
+
+ it "sends codecompanion-equivalent fingerprint headers alongside X-Initiator" do
+ # We mimic codecompanion.nvim's wire profile exactly:
+ # Copilot-Integration-Id: vscode-chat
+ # Editor-Version: Neovim/<version>
+ # X-Initiator: user|agent
+ # We DO NOT send Openai-Intent (codecompanion does not).
+ stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
+ .with(headers: {
+ "Copilot-Integration-Id" => "vscode-chat",
+ "Editor-Version" => "Neovim/0.10.4",
+ "X-Initiator" => "user"
+ })
+ .to_return(**ok_response)
+
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ adapter.chat(messages)
+
+ expect(stub).to have_been_requested
+ end
+
+ it "does not send the Openai-Intent header (codecompanion parity)" do
+ captured_headers = nil
+ stub_request(:post, "https://api.githubcopilot.com/chat/completions")
+ .with do |req|
+ captured_headers = req.headers
+ true
+ end
+ .to_return(**ok_response)
+
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ adapter.chat(messages)
+
+ expect(captured_headers.keys.map(&:downcase)).not_to include("openai-intent")
+ end
+
+ it "allows overriding Editor-Version via constructor" do
+ custom_adapter = described_class.new(
+ model: "gpt-4.1",
+ github_token: github_token,
+ max_tokens: 4096,
+ min_request_interval: 0,
+ editor_version: "Neovim/0.12.1"
+ )
+
+ stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
+ .with(headers: { "Editor-Version" => "Neovim/0.12.1" })
+ .to_return(**ok_response)
+
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ custom_adapter.chat(messages)
+
+ expect(stub).to have_been_requested
+ end
+
+ context "in streaming mode" do
+ let(:sse_ok) do
+ {
+ status: 200,
+ body: [
+ "data: #{JSON.generate({ "choices" => [{ "delta" => { "content" => "ok" }, "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,
+ headers: { "Content-Type" => "text/event-stream" }
+ }
+ end
+
+ it "sends X-Initiator: user for the initial streaming request" do
+ stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
+ .with(headers: { "X-Initiator" => "user" })
+ .to_return(**sse_ok)
+
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
+ adapter.chat(messages, stream: true) { |_d| }
+
+ expect(stub).to have_been_requested
+ end
+
+ it "sends X-Initiator: agent for streaming tool-result continuations" do
+ tool_use = Dispatch::Adapter::ToolUseBlock.new(
+ id: "call_1", name: "search", arguments: { "q" => "x" }
+ )
+ tool_result = Dispatch::Adapter::ToolResultBlock.new(
+ tool_use_id: "call_1", content: "result"
+ )
+
+ messages = [
+ Dispatch::Adapter::Message.new(role: "user", content: "search x"),
+ Dispatch::Adapter::Message.new(role: "assistant", content: [tool_use]),
+ Dispatch::Adapter::Message.new(role: "user", content: [tool_result])
+ ]
+
+ stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions")
+ .with(headers: { "X-Initiator" => "agent" })
+ .to_return(**sse_ok)
+
+ adapter.chat(messages, stream: true) { |_d| }
+
+ expect(stub).to have_been_requested
+ end
+ end
+ end
+
describe "#chat with streaming" do
it "yields StreamDelta objects and returns Response" do
sse_body = [
- "data: #{JSON.generate({ "choices" => [ { "delta" => { "content" => "Hello" }, "index" => 0 } ] })}\n\n",
- "data: #{JSON.generate({ "choices" => [ { "delta" => { "content" => " world" }, "index" => 0 } ] })}\n\n",
- "data: #{JSON.generate({ "choices" => [ { "delta" => {}, "index" => 0, "finish_reason" => "stop" } ],
+ "data: #{JSON.generate({ "choices" => [{ "delta" => { "content" => "Hello" }, "index" => 0 }] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [{ "delta" => { "content" => " world" }, "index" => 0 }] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [{ "delta" => {}, "index" => 0, "finish_reason" => "stop" }],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 2 } })}\n\n",
"data: [DONE]\n\n"
].join
@@ -861,7 +1080,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
headers: { "Content-Type" => "text/event-stream" }
)
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
deltas = []
response = adapter.chat(messages, stream: true) { |delta| deltas << delta }
@@ -878,20 +1097,20 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
it "yields tool_use_start and tool_use_delta for tool call streams" do
sse_body = [
- "data: #{JSON.generate({ "choices" => [ {
- "delta" => { "tool_calls" => [ { "index" => 0, "id" => "call_1", "type" => "function",
- "function" => { "name" => "search", "arguments" => "" } } ] }, "index" => 0
- } ] })}\n\n",
- "data: #{JSON.generate({ "choices" => [ {
- "delta" => { "tool_calls" => [ { "index" => 0,
- "function" => { "arguments" => "{\"q\":" } } ] }, "index" => 0
- } ] })}\n\n",
- "data: #{JSON.generate({ "choices" => [ {
- "delta" => { "tool_calls" => [ { "index" => 0,
- "function" => { "arguments" => "\"test\"}" } } ] }, "index" => 0
- } ] })}\n\n",
- "data: #{JSON.generate({ "choices" => [ { "delta" => {}, "index" => 0,
- "finish_reason" => "tool_calls" } ] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [{
+ "delta" => { "tool_calls" => [{ "index" => 0, "id" => "call_1", "type" => "function",
+ "function" => { "name" => "search", "arguments" => "" } }] }, "index" => 0
+ }] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [{
+ "delta" => { "tool_calls" => [{ "index" => 0,
+ "function" => { "arguments" => "{\"q\":" } }] }, "index" => 0
+ }] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [{
+ "delta" => { "tool_calls" => [{ "index" => 0,
+ "function" => { "arguments" => "\"test\"}" } }] }, "index" => 0
+ }] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [{ "delta" => {}, "index" => 0,
+ "finish_reason" => "tool_calls" }] })}\n\n",
"data: [DONE]\n\n"
].join
@@ -902,7 +1121,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
headers: { "Content-Type" => "text/event-stream" }
)
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "search") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "search")]
deltas = []
response = adapter.chat(messages, stream: true) { |delta| deltas << delta }
@@ -923,8 +1142,8 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
it "captures usage from streaming response" do
sse_body = [
- "data: #{JSON.generate({ "choices" => [ { "delta" => { "content" => "hi" }, "index" => 0 } ] })}\n\n",
- "data: #{JSON.generate({ "choices" => [ { "delta" => {}, "index" => 0, "finish_reason" => "stop" } ],
+ "data: #{JSON.generate({ "choices" => [{ "delta" => { "content" => "hi" }, "index" => 0 }] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [{ "delta" => {}, "index" => 0, "finish_reason" => "stop" }],
"usage" => { "prompt_tokens" => 42, "completion_tokens" => 7 } })}\n\n",
"data: [DONE]\n\n"
].join
@@ -936,7 +1155,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
headers: { "Content-Type" => "text/event-stream" }
)
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
response = adapter.chat(messages, stream: true) { |_delta| nil }
expect(response.usage.input_tokens).to eq(42)
@@ -945,31 +1164,31 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
it "handles multiple parallel tool calls in a stream" do
sse_body = [
- "data: #{JSON.generate({ "choices" => [ {
- "delta" => { "tool_calls" => [ { "index" => 0, "id" => "call_a", "type" => "function",
- "function" => { "name" => "tool_a", "arguments" => "" } } ] }, "index" => 0
- } ] })}\n\n",
- "data: #{JSON.generate({ "choices" => [ {
- "delta" => { "tool_calls" => [ { "index" => 1, "id" => "call_b", "type" => "function",
- "function" => { "name" => "tool_b", "arguments" => "" } } ] }, "index" => 0
- } ] })}\n\n",
- "data: #{JSON.generate({ "choices" => [ {
- "delta" => { "tool_calls" => [ { "index" => 0,
- "function" => { "arguments" => "{\"x\":1}" } } ] }, "index" => 0
- } ] })}\n\n",
- "data: #{JSON.generate({ "choices" => [ {
- "delta" => { "tool_calls" => [ { "index" => 1,
- "function" => { "arguments" => "{\"y\":2}" } } ] }, "index" => 0
- } ] })}\n\n",
- "data: #{JSON.generate({ "choices" => [ { "delta" => {}, "index" => 0,
- "finish_reason" => "tool_calls" } ] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [{
+ "delta" => { "tool_calls" => [{ "index" => 0, "id" => "call_a", "type" => "function",
+ "function" => { "name" => "tool_a", "arguments" => "" } }] }, "index" => 0
+ }] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [{
+ "delta" => { "tool_calls" => [{ "index" => 1, "id" => "call_b", "type" => "function",
+ "function" => { "name" => "tool_b", "arguments" => "" } }] }, "index" => 0
+ }] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [{
+ "delta" => { "tool_calls" => [{ "index" => 0,
+ "function" => { "arguments" => "{\"x\":1}" } }] }, "index" => 0
+ }] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [{
+ "delta" => { "tool_calls" => [{ "index" => 1,
+ "function" => { "arguments" => "{\"y\":2}" } }] }, "index" => 0
+ }] })}\n\n",
+ "data: #{JSON.generate({ "choices" => [{ "delta" => {}, "index" => 0,
+ "finish_reason" => "tool_calls" }] })}\n\n",
"data: [DONE]\n\n"
].join
stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.to_return(status: 200, body: sse_body, headers: { "Content-Type" => "text/event-stream" })
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "do both") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "do both")]
deltas = []
response = adapter.chat(messages, stream: true) { |d| deltas << d }
@@ -999,14 +1218,14 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
.to_return(
status: 200,
body: JSON.generate({
- "choices" => [ { "message" => { "content" => "ok" },
- "finish_reason" => "stop" } ],
+ "choices" => [{ "message" => { "content" => "ok" },
+ "finish_reason" => "stop" }],
"usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 }
}),
headers: { "Content-Type" => "application/json" }
)
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
adapter.chat(messages)
adapter.chat(messages)
@@ -1025,7 +1244,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
)
fresh_adapter = described_class.new(model: "gpt-4.1", github_token: "bad_token", max_tokens: 4096)
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
expect { fresh_adapter.chat(messages) }.to raise_error(Dispatch::Adapter::AuthenticationError)
end
@@ -1277,7 +1496,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.to_return(status: 401, body: JSON.generate({ "error" => { "message" => "Unauthorized" } }))
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::AuthenticationError) { |e|
expect(e.status_code).to eq(401)
expect(e.provider).to eq("GitHub Copilot")
@@ -1288,7 +1507,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.to_return(status: 403, body: JSON.generate({ "error" => { "message" => "Forbidden" } }))
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::AuthenticationError)
end
@@ -1300,7 +1519,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
headers: { "Retry-After" => "30" }
)
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::RateLimitError) { |e|
expect(e.status_code).to eq(429)
expect(e.retry_after).to eq(30)
@@ -1311,7 +1530,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.to_return(status: 400, body: JSON.generate({ "error" => { "message" => "Bad request" } }))
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::RequestError) { |e|
expect(e.status_code).to eq(400)
}
@@ -1321,7 +1540,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.to_return(status: 422, body: JSON.generate({ "error" => { "message" => "Unprocessable" } }))
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::RequestError)
end
@@ -1329,7 +1548,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.to_return(status: 500, body: JSON.generate({ "error" => { "message" => "Internal error" } }))
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ServerError) { |e|
expect(e.status_code).to eq(500)
}
@@ -1339,7 +1558,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.to_return(status: 502, body: "Bad Gateway")
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ServerError)
end
@@ -1347,7 +1566,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.to_return(status: 503, body: "Service Unavailable")
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ServerError)
end
@@ -1355,7 +1574,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.to_raise(Errno::ECONNREFUSED.new("Connection refused"))
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ConnectionError) { |e|
expect(e.provider).to eq("GitHub Copilot")
}
@@ -1365,7 +1584,7 @@ expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.3.0")
stub_request(:post, "https://api.githubcopilot.com/chat/completions")
.to_raise(Net::OpenTimeout.new("execution expired"))
- messages = [ Dispatch::Adapter::Message.new(role: "user", content: "Hi") ]
+ messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")]
expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ConnectionError)
end
end
diff --git a/spec/dispatch/adapter/rate_limiter_spec.rb b/spec/dispatch/adapter/rate_limiter_spec.rb
index 1e4e501..5fcf92f 100644
--- a/spec/dispatch/adapter/rate_limiter_spec.rb
+++ b/spec/dispatch/adapter/rate_limiter_spec.rb
@@ -240,7 +240,7 @@ RSpec.describe Dispatch::Adapter::RateLimiter do
now = Time.now.to_f
state = {
"last_request_at" => now,
- "request_log" => [ now - 2.0, now - 1.0, now ]
+ "request_log" => [now - 2.0, now - 1.0, now]
}
File.write(rate_limit_path, JSON.generate(state))
@@ -252,7 +252,7 @@ RSpec.describe Dispatch::Adapter::RateLimiter do
now = Time.now.to_f
state = {
"last_request_at" => now - 5.0,
- "request_log" => [ now - 15.0, now - 12.0, now - 5.0 ]
+ "request_log" => [now - 15.0, now - 12.0, now - 5.0]
}
File.write(rate_limit_path, JSON.generate(state))
@@ -264,7 +264,7 @@ RSpec.describe Dispatch::Adapter::RateLimiter do
now = Time.now.to_f
state = {
"last_request_at" => now - 5.0,
- "request_log" => [ now - 20.0, now - 15.0, now - 5.0 ]
+ "request_log" => [now - 20.0, now - 15.0, now - 5.0]
}
File.write(rate_limit_path, JSON.generate(state))
@@ -299,7 +299,7 @@ RSpec.describe Dispatch::Adapter::RateLimiter do
now = Time.now.to_f
state = {
"last_request_at" => now - 2.0, # cooldown elapsed
- "request_log" => [ now - 3.0, now - 2.5, now - 2.0 ] # window full
+ "request_log" => [now - 3.0, now - 2.5, now - 2.0] # window full
}
File.write(rate_limit_path, JSON.generate(state))
@@ -380,7 +380,7 @@ RSpec.describe Dispatch::Adapter::RateLimiter do
it "reads state written by another process" do
# Simulate another process having made a request just now
now = Time.now.to_f
- state = { "last_request_at" => now, "request_log" => [ now ] }
+ state = { "last_request_at" => now, "request_log" => [now] }
FileUtils.mkdir_p(File.dirname(rate_limit_path))
File.write(rate_limit_path, JSON.generate(state))
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index d722f8e..b7a25ec 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -2,6 +2,23 @@
require "dispatch/adapter/copilot"
+# ---------------------------------------------------------------------------
+# CRITICAL SAFETY: BLOCK ALL REAL NETWORK ACCESS DURING TESTS.
+#
+# This gem talks to GitHub Copilot's billed API. A leaked real request during
+# a test run could:
+# * authenticate against a real GitHub account if a token is in the env,
+# * consume premium-request quota,
+# * trigger device-flow prompts,
+# * leave persisted token files on disk.
+#
+# We unconditionally disable all outbound HTTP from spec processes here, in
+# the shared spec_helper, so that any spec file (current or future) is
+# protected even if it forgets to `require "webmock/rspec"` itself.
+# ---------------------------------------------------------------------------
+require "webmock/rspec"
+WebMock.disable_net_connect!(allow_localhost: false, allow: nil)
+
RSpec.configure do |config|
config.example_status_persistence_file_path = ".rspec_status"
config.disable_monkey_patching!
@@ -9,4 +26,12 @@ RSpec.configure do |config|
config.expect_with :rspec do |c|
c.syntax = :expect
end
+
+ # Defense in depth: re-assert net-block before EVERY example, in case some
+ # test (or a future contributor) called WebMock.allow_net_connect! and
+ # forgot to reset it. Also clears any leftover stubs.
+ config.before(:each) do
+ WebMock.reset!
+ WebMock.disable_net_connect!(allow_localhost: false, allow: nil)
+ end
end