diff options
| author | Adam Malczewski <[email protected]> | 2026-04-29 21:40:16 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-04-29 21:40:16 +0900 |
| commit | 5c9b8f5142198bdf230d500b5101322a22235670 (patch) | |
| tree | fa80010970db89f4ee03f0d493bed26d47eb0ce6 | |
| parent | 1ec2afaa21b8c3ef336982e80259b9bb79e3fb32 (diff) | |
| download | dispatch-adapter-copilot-5c9b8f5142198bdf230d500b5101322a22235670.tar.gz dispatch-adapter-copilot-5c9b8f5142198bdf230d500b5101322a22235670.zip | |
update to match new interface
| -rw-r--r-- | .rubocop.yml | 11 | ||||
| -rw-r--r-- | COPILOT_RATE_LIMIT_RESEARCH.md | 197 | ||||
| -rw-r--r-- | Gemfile.lock | 10 | ||||
| -rw-r--r-- | README.md | 58 | ||||
| -rw-r--r-- | dispatch-adapter-copilot.gemspec | 8 | ||||
| -rw-r--r-- | lib/dispatch/adapter/copilot.rb | 95 | ||||
| -rw-r--r-- | lib/dispatch/adapter/rate_limiter.rb | 173 | ||||
| -rw-r--r-- | lib/dispatch/adapter/version.rb | 2 | ||||
| -rw-r--r-- | spec/dispatch/adapter/copilot_rate_limiting_spec.rb | 12 | ||||
| -rw-r--r-- | spec/dispatch/adapter/copilot_spec.rb | 481 | ||||
| -rw-r--r-- | spec/dispatch/adapter/rate_limiter_spec.rb | 10 | ||||
| -rw-r--r-- | spec/spec_helper.rb | 25 |
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 @@ -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 |
