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 /lib | |
| parent | 1ec2afaa21b8c3ef336982e80259b9bb79e3fb32 (diff) | |
| download | dispatch-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.rb | 95 | ||||
| -rw-r--r-- | lib/dispatch/adapter/rate_limiter.rb | 173 | ||||
| -rw-r--r-- | lib/dispatch/adapter/version.rb | 2 |
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 |
