summaryrefslogtreecommitdiffhomepage
path: root/lib
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-04-29 21:40:16 +0900
committerAdam Malczewski <[email protected]>2026-04-29 21:40:16 +0900
commit5c9b8f5142198bdf230d500b5101322a22235670 (patch)
treefa80010970db89f4ee03f0d493bed26d47eb0ce6 /lib
parent1ec2afaa21b8c3ef336982e80259b9bb79e3fb32 (diff)
downloaddispatch-adapter-copilot-5c9b8f5142198bdf230d500b5101322a22235670.tar.gz
dispatch-adapter-copilot-5c9b8f5142198bdf230d500b5101322a22235670.zip
update to match new interface
Diffstat (limited to 'lib')
-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
3 files changed, 87 insertions, 183 deletions
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