diff options
| author | Adam Malczewski <[email protected]> | 2026-03-31 20:22:45 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-03-31 20:22:45 +0900 |
| commit | 3e32dec579fbdedec8c7ddb881207b25bb342e60 (patch) | |
| tree | 9db02b8f744fa2c1203b78e6c84a62f770bc1e73 /lib | |
| parent | a1eed75083df6afd4895a4438309319d2a9e5523 (diff) | |
| download | dispatch-adapter-copilot-3e32dec579fbdedec8c7ddb881207b25bb342e60.tar.gz dispatch-adapter-copilot-3e32dec579fbdedec8c7ddb881207b25bb342e60.zip | |
imp
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/dispatch/adapter/base.rb | 31 | ||||
| -rw-r--r-- | lib/dispatch/adapter/copilot.rb | 651 | ||||
| -rw-r--r-- | lib/dispatch/adapter/errors.rb | 30 | ||||
| -rw-r--r-- | lib/dispatch/adapter/message.rb | 31 | ||||
| -rw-r--r-- | lib/dispatch/adapter/model_info.rb | 11 | ||||
| -rw-r--r-- | lib/dispatch/adapter/response.rb | 23 | ||||
| -rw-r--r-- | lib/dispatch/adapter/tool_definition.rb | 7 | ||||
| -rw-r--r-- | lib/dispatch/adapter/version.rb (renamed from lib/dispatch/adapter/copilot/version.rb) | 2 |
8 files changed, 781 insertions, 5 deletions
diff --git a/lib/dispatch/adapter/base.rb b/lib/dispatch/adapter/base.rb new file mode 100644 index 0000000..dbd136a --- /dev/null +++ b/lib/dispatch/adapter/base.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + class Base + def chat(_messages, system: nil, tools: [], stream: false, max_tokens: nil, thinking: nil, &_block) + raise NotImplementedError, "#{self.class}#chat must be implemented" + end + + def model_name + raise NotImplementedError, "#{self.class}#model_name must be implemented" + end + + def count_tokens(_messages, system: nil, tools: []) + -1 + end + + def list_models + raise NotImplementedError, "#{self.class}#list_models must be implemented" + end + + def provider_name + self.class.name + end + + def max_context_tokens + nil + end + end + end +end diff --git a/lib/dispatch/adapter/copilot.rb b/lib/dispatch/adapter/copilot.rb index 66e6fff..200fcb9 100644 --- a/lib/dispatch/adapter/copilot.rb +++ b/lib/dispatch/adapter/copilot.rb @@ -1,12 +1,655 @@ # frozen_string_literal: true -require_relative "copilot/version" +require "net/http" +require "uri" +require "json" +require "securerandom" +require "fileutils" + +require_relative "errors" +require_relative "message" +require_relative "response" +require_relative "tool_definition" +require_relative "model_info" +require_relative "base" + +require_relative "version" module Dispatch module Adapter - module Copilot - class Error < StandardError; end - # Your code goes here... + class Copilot < Base + VERSION = CopilotVersion::VERSION + + API_BASE = "https://api.githubcopilot.com" + GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code" + GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" + COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token" + CLIENT_ID = "Iv1.b507a08c87ecfe98" + + MODEL_CONTEXT_WINDOWS = { + "gpt-4.1" => 1_047_576, + "gpt-4.1-mini" => 1_047_576, + "gpt-4.1-nano" => 1_047_576, + "gpt-4o" => 128_000, + "gpt-4o-mini" => 128_000, + "gpt-4" => 8_192, + "gpt-4-turbo" => 128_000, + "gpt-3.5-turbo" => 16_385, + "o1" => 200_000, + "o1-mini" => 128_000, + "o1-preview" => 128_000, + "o3" => 200_000, + "o3-mini" => 200_000, + "o4-mini" => 200_000, + "claude-3.5-sonnet" => 200_000, + "claude-3.7-sonnet" => 200_000, + "gemini-2.0-flash-001" => 1_048_576 + }.freeze + + STOP_REASON_MAP = { + "stop" => :end_turn, + "tool_calls" => :tool_use, + "length" => :max_tokens, + "content_filter" => :end_turn + }.freeze + + VALID_THINKING_LEVELS = %w[low medium high].freeze + + def initialize(model: "gpt-4.1", github_token: nil, token_path: nil, max_tokens: 8192, thinking: nil) + super() + @model = model + @github_token = github_token + @token_path = token_path || default_token_path + @default_max_tokens = max_tokens + @default_thinking = thinking + @copilot_token = nil + @copilot_token_expires_at = 0 + @mutex = Mutex.new + validate_thinking_level!(@default_thinking) + end + + def chat(messages, system: nil, tools: [], stream: false, max_tokens: nil, thinking: :default, &block) + ensure_authenticated! + wire_messages = build_wire_messages(messages, system) + wire_tools = build_wire_tools(tools) + effective_max_tokens = max_tokens || @default_max_tokens + effective_thinking = thinking == :default ? @default_thinking : thinking + validate_thinking_level!(effective_thinking) + + body = { + model: @model, + messages: wire_messages, + max_tokens: effective_max_tokens, + stream: stream + } + body[:tools] = wire_tools unless wire_tools.empty? + body[:reasoning_effort] = effective_thinking if effective_thinking + + if stream + chat_streaming(body, &block) + else + chat_non_streaming(body) + end + end + + def model_name + @model + end + + def provider_name + "GitHub Copilot" + end + + def max_context_tokens + MODEL_CONTEXT_WINDOWS[@model] + end + + def list_models + ensure_authenticated! + uri = URI("#{API_BASE}/v1/models") + request = Net::HTTP::Get.new(uri) + apply_headers!(request) + + response = execute_request(uri, request) + data = parse_response!(response) + models = data["data"] || [] + + models.map do |m| + ModelInfo.new( + id: m["id"], + name: m["id"], + max_context_tokens: MODEL_CONTEXT_WINDOWS.fetch(m["id"], 0), + supports_vision: false, + supports_tool_use: true, + supports_streaming: true + ) + end + end + + private + + def validate_thinking_level!(level) + return if level.nil? + + return if VALID_THINKING_LEVELS.include?(level) + + raise ArgumentError, "Invalid thinking level: #{level.inspect}. Must be one of: #{VALID_THINKING_LEVELS.join(", ")}, or nil" + end + + def default_token_path + File.join(Dir.home, ".config", "dispatch", "copilot_github_token") + end + + # --- Authentication --- + + def ensure_authenticated! + ensure_github_token! + ensure_copilot_token! + end + + def ensure_github_token! + return if @github_token + + @github_token = load_persisted_token + return if @github_token + + @github_token = perform_device_flow + persist_token(@github_token) + end + + def load_persisted_token + return nil unless File.exist?(@token_path) + + token = File.read(@token_path).strip + token.empty? ? nil : token + end + + def persist_token(token) + FileUtils.mkdir_p(File.dirname(@token_path)) + File.write(@token_path, token) + File.chmod(0o600, @token_path) + end + + def perform_device_flow + uri = URI(GITHUB_DEVICE_CODE_URL) + request = Net::HTTP::Post.new(uri) + request["Accept"] = "application/json" + request.set_form_data("client_id" => CLIENT_ID, "scope" => "copilot") + + response = execute_request(uri, request) + data = parse_json_body(response) + + device_code = data["device_code"] + user_code = data["user_code"] + verification_uri = data["verification_uri"] + interval = (data["interval"] || 5).to_i + + $stderr.puts "\n=== GitHub Device Authorization ===" + $stderr.puts "Open: #{verification_uri}" + $stderr.puts "Enter code: #{user_code}" + $stderr.puts "Waiting for authorization...\n\n" + + poll_for_access_token(device_code, interval) + end + + def poll_for_access_token(device_code, interval) + loop do + sleep(interval) + + uri = URI(GITHUB_ACCESS_TOKEN_URL) + request = Net::HTTP::Post.new(uri) + request["Accept"] = "application/json" + request.set_form_data( + "client_id" => CLIENT_ID, + "device_code" => device_code, + "grant_type" => "urn:ietf:params:oauth:grant-type:device_code" + ) + + response = execute_request(uri, request) + data = parse_json_body(response) + + if data["access_token"] + return data["access_token"] + elsif data["error"] == "authorization_pending" + next + elsif data["error"] == "slow_down" + interval += 5 + else + raise AuthenticationError.new( + "Device flow failed: #{data["error_description"] || data["error"]}", + provider: "GitHub Copilot" + ) + end + end + end + + def ensure_copilot_token! + @mutex.synchronize do + return if @copilot_token && Time.now.to_i < @copilot_token_expires_at - 60 + + uri = URI(COPILOT_TOKEN_URL) + request = Net::HTTP::Get.new(uri) + request["Authorization"] = "token #{@github_token}" + request["Accept"] = "application/json" + + response = execute_request(uri, request) + + unless response.is_a?(Net::HTTPSuccess) + raise AuthenticationError.new( + "Failed to obtain Copilot token: #{response.code} #{response.body}", + status_code: response.code.to_i, + provider: "GitHub Copilot" + ) + end + + data = parse_json_body(response) + @copilot_token = data["token"] + @copilot_token_expires_at = data["expires_at"].to_i + end + end + + # --- HTTP helpers --- + + def apply_headers!(request) + 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" + end + + def execute_request(uri, request) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = (uri.scheme == "https") + http.open_timeout = 30 + http.read_timeout = 120 + http.start { |h| h.request(request) } + rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, + Net::OpenTimeout, Net::ReadTimeout, SocketError => e + raise ConnectionError.new( + "Connection failed: #{e.message}", + provider: "GitHub Copilot" + ) + end + + def execute_streaming_request(uri, request, &block) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = (uri.scheme == "https") + http.open_timeout = 30 + http.read_timeout = 300 + + http.start do |h| + h.request(request) do |response| + handle_error_response!(response) unless response.is_a?(Net::HTTPSuccess) + block.call(response) + end + end + rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, + Net::OpenTimeout, Net::ReadTimeout, SocketError => e + raise ConnectionError.new( + "Connection failed: #{e.message}", + provider: "GitHub Copilot" + ) + end + + def parse_response!(response) + handle_error_response!(response) unless response.is_a?(Net::HTTPSuccess) + parse_json_body(response) + end + + def parse_json_body(response) + JSON.parse(response.body) + rescue JSON::ParserError => e + raise RequestError.new( + "Invalid JSON response: #{e.message}", + provider: "GitHub Copilot" + ) + end + + def handle_error_response!(response) + code = response.code.to_i + body = response.body.to_s + message = begin + JSON.parse(body).dig("error", "message") || body + rescue JSON::ParserError + body + end + + case code + when 401, 403 + raise AuthenticationError.new(message, status_code: code, provider: "GitHub Copilot") + when 429 + retry_after = response["Retry-After"]&.to_i + raise RateLimitError.new(message, status_code: code, provider: "GitHub Copilot", retry_after: retry_after) + when 400, 422 + raise RequestError.new(message, status_code: code, provider: "GitHub Copilot") + when 500, 502, 503 + raise ServerError.new(message, status_code: code, provider: "GitHub Copilot") + else + raise Error.new(message, status_code: code, provider: "GitHub Copilot") + end + end + + # --- Message conversion --- + + def build_wire_messages(messages, system) + wire = [] + wire << { role: "system", content: system } if system + + messages.each do |msg| + wire_msg = convert_message(msg) + if wire_msg.is_a?(Array) + wire.concat(wire_msg) + else + wire << wire_msg + end + end + + merge_consecutive_roles(wire) + end + + def convert_message(msg) + case msg.content + when String + { role: msg.role, content: msg.content } + when Array + convert_content_blocks(msg) + else + { role: msg.role, content: msg.content.to_s } + end + end + + def convert_content_blocks(msg) + results = [] + text_parts = [] + tool_calls = [] + + msg.content.each do |block| + case block + when TextBlock + text_parts << block.text + when ImageBlock + raise NotImplementedError, "ImageBlock is not yet supported by the Copilot adapter" + when ToolUseBlock + tool_calls << { + id: block.id, + type: "function", + function: { + name: block.name, + arguments: JSON.generate(block.arguments) + } + } + when ToolResultBlock + results << { + role: "tool", + tool_call_id: block.tool_use_id, + content: tool_result_content(block) + } + end + end + + if msg.role == "assistant" && !tool_calls.empty? + assistant_msg = { role: "assistant" } + assistant_msg[:content] = text_parts.join("\n") unless text_parts.empty? + assistant_msg[:tool_calls] = tool_calls + results.unshift(assistant_msg) + elsif !text_parts.empty? + results.unshift({ role: msg.role, content: text_parts.join("\n") }) + end + + results + end + + def tool_result_content(block) + case block.content + when String + block.content + when Array + block.content.map(&:text).join("\n") + else + block.content.to_s + end + end + + def merge_consecutive_roles(messages) + return messages if messages.empty? + + 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") + else + merged << msg.dup + end + end + + merged + end + + # --- Tool conversion --- + + def build_wire_tools(tools) + tools.map do |td| + { + type: "function", + function: { + name: tool_attr(td, :name), + description: tool_attr(td, :description), + parameters: tool_attr(td, :parameters) + } + } + end + end + + def tool_attr(tool, key) + if tool.respond_to?(key) + tool.public_send(key) + elsif tool.is_a?(Hash) + tool[key] || tool[key.to_s] + end + end + + # --- Chat (non-streaming) --- + + def chat_non_streaming(body) + uri = URI("#{API_BASE}/chat/completions") + request = Net::HTTP::Post.new(uri) + apply_headers!(request) + request.body = JSON.generate(body) + + response = execute_request(uri, request) + data = parse_response!(response) + + build_response(data) + end + + def build_response(data) + choice = data["choices"]&.first + return empty_response(data) unless choice + + message = choice["message"] || {} + content = message["content"] + tool_calls = (message["tool_calls"] || []).map do |tc| + func = tc["function"] + ToolUseBlock.new( + id: tc["id"], + name: func["name"], + arguments: parse_tool_arguments(func["arguments"]) + ) + end + + stop_reason = STOP_REASON_MAP.fetch(choice["finish_reason"], :end_turn) + + usage_data = data["usage"] || {} + usage = Usage.new( + input_tokens: usage_data["prompt_tokens"] || 0, + output_tokens: usage_data["completion_tokens"] || 0 + ) + + Response.new( + content: content, + tool_calls: tool_calls, + model: data["model"] || @model, + stop_reason: stop_reason, + usage: usage + ) + end + + def empty_response(data) + usage_data = data["usage"] || {} + Response.new( + model: data["model"] || @model, + stop_reason: :end_turn, + usage: Usage.new( + input_tokens: usage_data["prompt_tokens"] || 0, + output_tokens: usage_data["completion_tokens"] || 0 + ) + ) + end + + def parse_tool_arguments(args_string) + return {} if args_string.nil? || args_string.empty? + + JSON.parse(args_string) + rescue JSON::ParserError + {} + end + + # --- Chat (streaming) --- + + def chat_streaming(body, &block) + uri = URI("#{API_BASE}/chat/completions") + request = Net::HTTP::Post.new(uri) + apply_headers!(request) + request.body = JSON.generate(body) + + collected = new_stream_collector + + execute_streaming_request(uri, request) do |response| + buffer = +"" + + response.read_body do |chunk| + buffer << chunk + process_sse_buffer(buffer, collected, &block) + end + end + + build_streaming_response(collected) + end + + def new_stream_collector + { + content: +"", + tool_calls: {}, + model: @model, + finish_reason: nil, + input_tokens: 0, + output_tokens: 0 + } + end + + def process_sse_buffer(buffer, collected, &block) + while (line_end = buffer.index("\n")) + line = buffer.slice!(0..line_end).strip + next if line.empty? + next unless line.start_with?("data: ") + + data_str = line.sub(/\Adata: /, "") + next if data_str == "[DONE]" + + data = JSON.parse(data_str) + process_stream_chunk(data, collected, &block) + end + rescue JSON::ParserError + # Incomplete JSON chunk, will be completed on next read + nil + end + + def process_stream_chunk(data, collected, &block) + collected[:model] = data["model"] if data["model"] + + choice = data.dig("choices", 0) + return unless choice + + collected[:finish_reason] = choice["finish_reason"] if choice["finish_reason"] + delta = choice["delta"] || {} + + process_text_delta(delta, collected, &block) + process_tool_call_deltas(delta, collected, &block) + + process_usage(data, collected) + end + + def process_text_delta(delta, collected, &block) + return unless delta["content"] + + collected[:content] << delta["content"] + block.call(StreamDelta.new(type: :text_delta, text: delta["content"])) + end + + def process_tool_call_deltas(delta, collected, &block) + return unless delta["tool_calls"] + + delta["tool_calls"].each do |tc_delta| + index = tc_delta["index"] + tc = (collected[:tool_calls][index] ||= { id: nil, name: +"", arguments: +"" }) + + if tc_delta["id"] + tc[:id] = tc_delta["id"] + tc[:name] = tc_delta.dig("function", "name") || "" + block.call(StreamDelta.new( + type: :tool_use_start, + tool_call_id: tc[:id], + tool_name: tc[:name] + )) + end + + next unless (arg_frag = tc_delta.dig("function", "arguments")) + next if arg_frag.empty? + + tc[:arguments] << arg_frag + block.call(StreamDelta.new( + type: :tool_use_delta, + tool_call_id: tc[:id], + argument_delta: arg_frag + )) + end + end + + def process_usage(data, collected) + return unless data["usage"] + + collected[:input_tokens] = data["usage"]["prompt_tokens"] || collected[:input_tokens] + collected[:output_tokens] = data["usage"]["completion_tokens"] || collected[:output_tokens] + end + + def build_streaming_response(collected) + tool_calls = collected[:tool_calls].values.map do |tc| + ToolUseBlock.new( + id: tc[:id], + name: tc[:name], + arguments: parse_tool_arguments(tc[:arguments]) + ) + end + + stop_reason = STOP_REASON_MAP.fetch(collected[:finish_reason], :end_turn) + content = collected[:content].empty? ? nil : collected[:content] + + Response.new( + content: content, + tool_calls: tool_calls, + model: collected[:model], + stop_reason: stop_reason, + usage: Usage.new( + input_tokens: collected[:input_tokens], + output_tokens: collected[:output_tokens] + ) + ) + end end end end diff --git a/lib/dispatch/adapter/errors.rb b/lib/dispatch/adapter/errors.rb new file mode 100644 index 0000000..86f9c14 --- /dev/null +++ b/lib/dispatch/adapter/errors.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + class Error < StandardError + attr_reader :status_code, :provider + + def initialize(message = nil, status_code: nil, provider: nil) + @status_code = status_code + @provider = provider + super(message) + end + end + + class AuthenticationError < Error; end + + class RateLimitError < Error + attr_reader :retry_after + + def initialize(message = nil, status_code: nil, provider: nil, retry_after: nil) + @retry_after = retry_after + super(message, status_code:, provider:) + end + end + + class ServerError < Error; end + class RequestError < Error; end + class ConnectionError < Error; end + end +end diff --git a/lib/dispatch/adapter/message.rb b/lib/dispatch/adapter/message.rb new file mode 100644 index 0000000..eb51c99 --- /dev/null +++ b/lib/dispatch/adapter/message.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + Message = Struct.new(:role, :content, keyword_init: true) + + TextBlock = Struct.new(:type, :text, keyword_init: true) do + def initialize(text:, type: "text") + super(type:, text:) + end + end + + ImageBlock = Struct.new(:type, :source, :media_type, keyword_init: true) do + def initialize(source:, media_type:, type: "image") + super(type:, source:, media_type:) + end + end + + ToolUseBlock = Struct.new(:type, :id, :name, :arguments, keyword_init: true) do + def initialize(id:, name:, arguments:, type: "tool_use") + super(type:, id:, name:, arguments:) + end + end + + ToolResultBlock = Struct.new(:type, :tool_use_id, :content, :is_error, keyword_init: true) do + def initialize(tool_use_id:, content:, is_error: false, type: "tool_result") + super(type:, tool_use_id:, content:, is_error:) + end + end + end +end diff --git a/lib/dispatch/adapter/model_info.rb b/lib/dispatch/adapter/model_info.rb new file mode 100644 index 0000000..73d8a35 --- /dev/null +++ b/lib/dispatch/adapter/model_info.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + ModelInfo = Struct.new( + :id, :name, :max_context_tokens, + :supports_vision, :supports_tool_use, :supports_streaming, + keyword_init: true + ) + end +end diff --git a/lib/dispatch/adapter/response.rb b/lib/dispatch/adapter/response.rb new file mode 100644 index 0000000..d3e4789 --- /dev/null +++ b/lib/dispatch/adapter/response.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + Response = Struct.new(:content, :tool_calls, :model, :stop_reason, :usage, keyword_init: true) do + def initialize(content: nil, tool_calls: [], model:, stop_reason:, usage:) + super(content:, tool_calls:, model:, stop_reason:, usage:) + end + end + + Usage = Struct.new(:input_tokens, :output_tokens, :cache_read_tokens, :cache_creation_tokens, keyword_init: true) do + def initialize(input_tokens:, output_tokens:, cache_read_tokens: 0, cache_creation_tokens: 0) + super(input_tokens:, output_tokens:, cache_read_tokens:, cache_creation_tokens:) + end + end + + StreamDelta = Struct.new(:type, :text, :tool_call_id, :tool_name, :argument_delta, keyword_init: true) do + def initialize(type:, text: nil, tool_call_id: nil, tool_name: nil, argument_delta: nil) + super(type:, text:, tool_call_id:, tool_name:, argument_delta:) + end + end + end +end diff --git a/lib/dispatch/adapter/tool_definition.rb b/lib/dispatch/adapter/tool_definition.rb new file mode 100644 index 0000000..7b435a3 --- /dev/null +++ b/lib/dispatch/adapter/tool_definition.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + ToolDefinition = Struct.new(:name, :description, :parameters, keyword_init: true) + end +end diff --git a/lib/dispatch/adapter/copilot/version.rb b/lib/dispatch/adapter/version.rb index 2de061c..3df9f5f 100644 --- a/lib/dispatch/adapter/copilot/version.rb +++ b/lib/dispatch/adapter/version.rb @@ -2,7 +2,7 @@ module Dispatch module Adapter - module Copilot + module CopilotVersion VERSION = "0.1.0" end end |
