summaryrefslogtreecommitdiffhomepage
path: root/lib
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-03-31 20:22:45 +0900
committerAdam Malczewski <[email protected]>2026-03-31 20:22:45 +0900
commit3e32dec579fbdedec8c7ddb881207b25bb342e60 (patch)
tree9db02b8f744fa2c1203b78e6c84a62f770bc1e73 /lib
parenta1eed75083df6afd4895a4438309319d2a9e5523 (diff)
downloaddispatch-adapter-copilot-3e32dec579fbdedec8c7ddb881207b25bb342e60.tar.gz
dispatch-adapter-copilot-3e32dec579fbdedec8c7ddb881207b25bb342e60.zip
imp
Diffstat (limited to 'lib')
-rw-r--r--lib/dispatch/adapter/base.rb31
-rw-r--r--lib/dispatch/adapter/copilot.rb651
-rw-r--r--lib/dispatch/adapter/errors.rb30
-rw-r--r--lib/dispatch/adapter/message.rb31
-rw-r--r--lib/dispatch/adapter/model_info.rb11
-rw-r--r--lib/dispatch/adapter/response.rb23
-rw-r--r--lib/dispatch/adapter/tool_definition.rb7
-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