summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-04-29 21:40:58 +0900
committerAdam Malczewski <[email protected]>2026-04-29 21:40:58 +0900
commit27af03cb3540539f065334c199fdb42c48776fc5 (patch)
tree9dcaecc59f4383d88933519b5b049793e772427b
parent07277435c0688ad9f5fa682633b86b99ef5bb854 (diff)
downloaddispatch-adapter-interface-27af03cb3540539f065334c199fdb42c48776fc5.tar.gz
dispatch-adapter-interface-27af03cb3540539f065334c199fdb42c48776fc5.zip
update to support claude
-rw-r--r--.rubocop.yml22
-rw-r--r--CHANGELOG.md46
-rw-r--r--dispatch-adapter-interface-0.2.0.gembin0 -> 12288 bytes
-rw-r--r--dispatch-adapter-interface.gemspec3
-rw-r--r--lib/dispatch/adapter/interface.rb3
-rw-r--r--lib/dispatch/adapter/interface/base.rb61
-rw-r--r--lib/dispatch/adapter/interface/message.rb23
-rw-r--r--lib/dispatch/adapter/interface/model_info.rb14
-rw-r--r--lib/dispatch/adapter/interface/pricing.rb34
-rw-r--r--lib/dispatch/adapter/interface/rate_limiter.rb174
-rw-r--r--lib/dispatch/adapter/interface/response.rb33
-rw-r--r--lib/dispatch/adapter/interface/tool_definition.rb14
-rw-r--r--lib/dispatch/adapter/interface/usage_report.rb43
-rw-r--r--lib/dispatch/adapter/interface/version.rb2
-rw-r--r--spec/dispatch/adapter/interface/base_spec.rb62
-rw-r--r--spec/dispatch/adapter/interface/pricing_spec.rb90
-rw-r--r--spec/dispatch/adapter/interface/rate_limiter_spec.rb407
-rw-r--r--spec/dispatch/adapter/interface/structs_spec.rb168
-rw-r--r--spec/dispatch/adapter/interface/usage_report_spec.rb171
-rw-r--r--spec/model_info_spec.rb61
20 files changed, 1417 insertions, 14 deletions
diff --git a/.rubocop.yml b/.rubocop.yml
index bbf7073..345b764 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -12,16 +12,30 @@ Style/FrozenStringLiteralComment:
Enabled: true
EnforcedStyle: always
+Metrics/MethodLength:
+ Enabled: false
+
+Metrics/ClassLength:
+ Enabled: false
+
+Metrics/ModuleLength:
+ Enabled: false
+
Metrics/BlockLength:
- Exclude:
- - "spec/**/*"
+ Enabled: false
-Metrics/MethodLength:
- Max: 40
+Metrics/BlockNesting:
+ Enabled: false
Metrics/AbcSize:
Enabled: false
+Metrics/CyclomaticComplexity:
+ Enabled: false
+
+Metrics/PerceivedComplexity:
+ Enabled: false
+
Style/Documentation:
Enabled: false
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..4b3983d
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,46 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+## 0.2.0 — 2025-01-01
+
+### Added
+
+- **`UsageWindow`** — struct for rolling-window quota definitions (id, label, duration_ms, resets_at).
+- **`UsageAmount`** — struct for utilisation values with unit, used/limit/remaining, and fraction fields.
+ Supported units: `:percent | :tokens | :requests | :usd | :minutes | :bytes | :unknown`.
+- **`UsageLimitEntry`** — struct combining a scope, window, amount, and status for a single quota limit.
+ Supported statuses: `:ok | :warning | :exhausted | :unknown`.
+- **`UsageReport`** — top-level struct returned by `Base#usage_report`; carries provider, fetched_at,
+ limits, metadata, and raw response.
+- **`Base#usage_report`** — returns `nil` by default; subscription-aware adapters override this.
+- **`Base#authenticate!`** — idempotent login lifecycle hook; returns `nil` by default.
+- **`Base#authenticated?`** — returns `true` by default.
+- **`Base#logout!`** — drops cached credentials; returns `nil` by default.
+- **`ThinkingBlock`** — content block for Claude extended-thinking output; defaults `type` to
+ `"thinking"`, carries `thinking:` text and optional `signature:`.
+- **`RedactedThinkingBlock`** — redacted variant; defaults `type` to `"redacted_thinking"`, carries `data:`.
+- **`StreamDelta` thinking events** — `:thinking_start`, `:thinking_delta`, `:thinking_end` added to the
+ documented `:type` vocabulary; `:thinking_delta` reuses the existing `text:` field.
+- **`TextBlock#cache_control`** — optional keyword (default `nil`) for prompt-cache breakpoints.
+ Convention: `nil | { type: :ephemeral } | { type: :ephemeral, ttl: :"5m" } | { type: :ephemeral, ttl: :"1h" }`.
+- **`ToolDefinition#cache_control`** — same cache-breakpoint keyword on tool definitions.
+- **`Base#chat` extended kwargs** — added `tool_choice:`, `cache_retention:`, `metadata:`, `betas:`
+ (all `nil` by default); `thinking:` now accepts a Hash in addition to String. All new kwargs are
+ optional and adapters MAY ignore them.
+- **Stop-reason vocabulary** — documented comment in `response.rb` listing the seven canonical
+ `:stop_reason` values: `:end_turn`, `:max_tokens`, `:tool_use`, `:pause_turn`, `:refusal`,
+ `:sensitive`, `:error`.
+- **`RateLimiter`** — migrated `Dispatch::Adapter::RateLimiter` from `dispatch-adapter-copilot` into
+ this gem so other adapters can depend on it independently. The Copilot gem re-exports the constant
+ for backwards compatibility.
+
+## 0.1.0 — Initial release
+
+- `Base` class with `chat`, `model_name`, `count_tokens`, `list_models`, `provider_name`,
+ `max_context_tokens`.
+- `Message`, `TextBlock`, `ImageBlock`, `ToolUseBlock`, `ToolResultBlock`, `ToolDefinition`.
+- `Response`, `Usage`, `UsageCost`, `StreamDelta`.
+- `ModelInfo`, `ModelPricing`, `Pricing.calculate`.
+- Error hierarchy: `Error`, `AuthenticationError`, `RateLimitError`, `ServerError`, `RequestError`,
+ `ConnectionError`.
diff --git a/dispatch-adapter-interface-0.2.0.gem b/dispatch-adapter-interface-0.2.0.gem
new file mode 100644
index 0000000..e3f5200
--- /dev/null
+++ b/dispatch-adapter-interface-0.2.0.gem
Binary files differ
diff --git a/dispatch-adapter-interface.gemspec b/dispatch-adapter-interface.gemspec
index 0cd449a..a240145 100644
--- a/dispatch-adapter-interface.gemspec
+++ b/dispatch-adapter-interface.gemspec
@@ -9,7 +9,8 @@ Gem::Specification.new do |spec|
spec.email = ["[email protected]"]
spec.summary = "Shared interface for Dispatch LLM adapter gems"
- spec.description = "Defines the base class, data structs, and error hierarchy shared by all " \
+ spec.description = "Defines the base class, data structs, error hierarchy, and shared utilities " \
+ "(RateLimiter, UsageReport, cache-control, thinking blocks) used by all " \
"Dispatch adapter gems (Copilot, Claude, Tester, etc.)."
spec.homepage = "https://github.com/realtradam/dispatch-adapter-interface"
spec.license = "MIT"
diff --git a/lib/dispatch/adapter/interface.rb b/lib/dispatch/adapter/interface.rb
index 54692c0..e809a46 100644
--- a/lib/dispatch/adapter/interface.rb
+++ b/lib/dispatch/adapter/interface.rb
@@ -7,6 +7,9 @@ require_relative "interface/message"
require_relative "interface/response"
require_relative "interface/tool_definition"
require_relative "interface/model_info"
+require_relative "interface/pricing"
+require_relative "interface/usage_report"
+require_relative "interface/rate_limiter"
require_relative "interface/base"
module Dispatch
diff --git a/lib/dispatch/adapter/interface/base.rb b/lib/dispatch/adapter/interface/base.rb
index 4b7a6ed..682fae0 100644
--- a/lib/dispatch/adapter/interface/base.rb
+++ b/lib/dispatch/adapter/interface/base.rb
@@ -3,7 +3,43 @@
module Dispatch
module Adapter
class Base
- def chat(_messages, system: nil, tools: [], stream: false, max_tokens: nil, thinking: nil, &_block)
+ # Send a chat request.
+ #
+ # @param _messages [Array<Message>] the conversation messages
+ # @param system [String, Array<TextBlock, Hash>, nil] system prompt;
+ # a String or an array of TextBlock / Hash content blocks (for
+ # providers that support cached system prompts).
+ # @param tools [Array<ToolDefinition, Hash>] tool definitions
+ # @param stream [Boolean] whether to stream the response
+ # @param max_tokens [Integer, nil] maximum tokens to generate
+ # @param thinking [String, Hash, nil] extended thinking config;
+ # adapters do their own validation.
+ # - String: "low" | "medium" | "high"
+ # - Hash: e.g. { enabled: true, budget_tokens: 10_000 }
+ # @param tool_choice [Symbol, Hash, nil] tool-selection policy:
+ # :auto | :any | :none | { type: :tool, name: "fn" }
+ # Adapters MAY ignore this.
+ # @param cache_retention [Symbol, nil] caching hint:
+ # :none | :short | :long | nil
+ # Adapters MAY ignore this.
+ # @param metadata [Hash, nil] arbitrary passthrough metadata (e.g. { user_id: "u1" }).
+ # Adapters MAY ignore this.
+ # @param betas [Array<String>, String, nil] extra provider-beta entries.
+ # Adapters MAY ignore this.
+ # @return [Response]
+ def chat(
+ _messages,
+ system: nil,
+ tools: [],
+ stream: false,
+ max_tokens: nil,
+ thinking: nil,
+ tool_choice: nil, # rubocop:disable Lint/UnusedMethodArgument
+ cache_retention: nil, # rubocop:disable Lint/UnusedMethodArgument
+ metadata: nil, # rubocop:disable Lint/UnusedMethodArgument
+ betas: nil, # rubocop:disable Lint/UnusedMethodArgument
+ &_block
+ )
raise NotImplementedError, "#{self.class}#chat must be implemented"
end
@@ -26,6 +62,29 @@ module Dispatch
def max_context_tokens
nil
end
+
+ # Subscription quota / utilisation. Return nil if the provider has no
+ # such concept (raw API-key tier, etc.).
+ # @return [Dispatch::Adapter::UsageReport, nil]
+ def usage_report
+ nil
+ end
+
+ # Idempotent — perform any interactive login required (device flow,
+ # OAuth PKCE, etc). Safe to call before the first chat/usage_report.
+ def authenticate!
+ nil
+ end
+
+ # True iff cached credentials are present and presumed valid.
+ def authenticated?
+ true
+ end
+
+ # Drop cached credentials.
+ def logout!
+ nil
+ end
end
end
end
diff --git a/lib/dispatch/adapter/interface/message.rb b/lib/dispatch/adapter/interface/message.rb
index eb51c99..dfe7301 100644
--- a/lib/dispatch/adapter/interface/message.rb
+++ b/lib/dispatch/adapter/interface/message.rb
@@ -4,9 +4,14 @@ 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:)
+ # +cache_control+ values:
+ # nil — no cache breakpoint (default)
+ # { type: :ephemeral } — provider default TTL
+ # { type: :ephemeral, ttl: :"5m" } — short-lived cache
+ # { type: :ephemeral, ttl: :"1h" } — long-lived cache
+ TextBlock = Struct.new(:type, :text, :cache_control, keyword_init: true) do
+ def initialize(text:, cache_control: nil, type: "text")
+ super(type:, text:, cache_control:)
end
end
@@ -27,5 +32,17 @@ module Dispatch
super(type:, tool_use_id:, content:, is_error:)
end
end
+
+ ThinkingBlock = Struct.new(:type, :thinking, :signature, keyword_init: true) do
+ def initialize(thinking:, signature: nil, type: "thinking")
+ super(type:, thinking:, signature:)
+ end
+ end
+
+ RedactedThinkingBlock = Struct.new(:type, :data, keyword_init: true) do
+ def initialize(data:, type: "redacted_thinking")
+ super(type:, data:)
+ end
+ end
end
end
diff --git a/lib/dispatch/adapter/interface/model_info.rb b/lib/dispatch/adapter/interface/model_info.rb
index 8ba2977..29228a8 100644
--- a/lib/dispatch/adapter/interface/model_info.rb
+++ b/lib/dispatch/adapter/interface/model_info.rb
@@ -2,14 +2,26 @@
module Dispatch
module Adapter
+ ModelPricing = Struct.new(
+ :input_per_mtok, :output_per_mtok,
+ :cache_read_per_mtok, :cache_write_per_mtok,
+ keyword_init: true
+ ) do
+ def initialize(input_per_mtok:, output_per_mtok:,
+ cache_read_per_mtok: 0.0, cache_write_per_mtok: 0.0)
+ super
+ end
+ end
+
ModelInfo = Struct.new(
:id, :name, :max_context_tokens,
:supports_vision, :supports_tool_use, :supports_streaming,
:premium_request_multiplier,
+ :pricing,
keyword_init: true
) do
def initialize(id:, name:, max_context_tokens:, supports_vision:, supports_tool_use:, supports_streaming:,
- premium_request_multiplier: nil)
+ premium_request_multiplier: nil, pricing: nil)
super
end
end
diff --git a/lib/dispatch/adapter/interface/pricing.rb b/lib/dispatch/adapter/interface/pricing.rb
new file mode 100644
index 0000000..a3ec9eb
--- /dev/null
+++ b/lib/dispatch/adapter/interface/pricing.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Dispatch
+ module Adapter
+ module Pricing
+ module_function
+
+ # Calculates UsageCost from a Usage and ModelInfo.
+ #
+ # NOTE: Reasoning tokens are NOT separately priced here.
+ # Anthropic bills them as output tokens; OpenAI o-series likewise.
+ # It is assumed that output_tokens includes reasoning_tokens if applicable.
+ def calculate(usage, model_info)
+ return nil unless model_info&.pricing
+
+ p = model_info.pricing
+ mtok = ->(tokens, rate) { (rate.to_f / 1_000_000.0) * tokens.to_i }
+
+ input = mtok.call(usage.input_tokens, p.input_per_mtok)
+ output = mtok.call(usage.output_tokens, p.output_per_mtok)
+ cread = mtok.call(usage.cache_read_tokens, p.cache_read_per_mtok)
+ cwrite = mtok.call(usage.cache_creation_tokens, p.cache_write_per_mtok)
+
+ UsageCost.new(
+ input: input,
+ output: output,
+ cache_read: cread,
+ cache_write: cwrite,
+ total: input + output + cread + cwrite
+ )
+ end
+ end
+ end
+end
diff --git a/lib/dispatch/adapter/interface/rate_limiter.rb b/lib/dispatch/adapter/interface/rate_limiter.rb
new file mode 100644
index 0000000..1b05582
--- /dev/null
+++ b/lib/dispatch/adapter/interface/rate_limiter.rb
@@ -0,0 +1,174 @@
+# 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
diff --git a/lib/dispatch/adapter/interface/response.rb b/lib/dispatch/adapter/interface/response.rb
index b4ba3eb..391b033 100644
--- a/lib/dispatch/adapter/interface/response.rb
+++ b/lib/dispatch/adapter/interface/response.rb
@@ -2,18 +2,47 @@
module Dispatch
module Adapter
+ # stop_reason ∈
+ # :end_turn — natural completion
+ # :max_tokens — output truncated by max_tokens
+ # :tool_use — assistant emitted tool calls
+ # :pause_turn — provider asked us to resubmit (Anthropic)
+ # :refusal — provider refused to answer
+ # :sensitive — output blocked by safety filters
+ # :error — adapter-level failure
Response = Struct.new(:content, :tool_calls, :model, :stop_reason, :usage, keyword_init: true) do
def initialize(model:, stop_reason:, usage:, content: nil, tool_calls: [])
super
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)
+ UsageCost = Struct.new(
+ :input, :output, :cache_read, :cache_write, :total,
+ keyword_init: true
+ ) do
+ def initialize(input: 0.0, output: 0.0, cache_read: 0.0,
+ cache_write: 0.0, total: 0.0)
super
end
end
+ Usage = Struct.new(
+ :input_tokens, :output_tokens,
+ :cache_read_tokens, :cache_creation_tokens,
+ :reasoning_tokens, :premium_requests, :cost,
+ keyword_init: true
+ ) do
+ def initialize(input_tokens:, output_tokens:,
+ cache_read_tokens: 0, cache_creation_tokens: 0,
+ reasoning_tokens: 0, premium_requests: nil, cost: nil)
+ super
+ end
+ end
+
+ # Recognised :type values:
+ # :text_start, :text_delta, :text_end
+ # :thinking_start, :thinking_delta, :thinking_end
+ # :tool_use_start, :tool_use_delta, :tool_use_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
diff --git a/lib/dispatch/adapter/interface/tool_definition.rb b/lib/dispatch/adapter/interface/tool_definition.rb
index 7b435a3..4a8ca5a 100644
--- a/lib/dispatch/adapter/interface/tool_definition.rb
+++ b/lib/dispatch/adapter/interface/tool_definition.rb
@@ -2,6 +2,18 @@
module Dispatch
module Adapter
- ToolDefinition = Struct.new(:name, :description, :parameters, keyword_init: true)
+ # +cache_control+ values:
+ # nil — no cache breakpoint (default)
+ # { type: :ephemeral } — provider default TTL
+ # { type: :ephemeral, ttl: :"5m" } — short-lived cache
+ # { type: :ephemeral, ttl: :"1h" } — long-lived cache
+ ToolDefinition = Struct.new(
+ :name, :description, :parameters, :cache_control,
+ keyword_init: true
+ ) do
+ def initialize(name:, description:, parameters:, cache_control: nil)
+ super
+ end
+ end
end
end
diff --git a/lib/dispatch/adapter/interface/usage_report.rb b/lib/dispatch/adapter/interface/usage_report.rb
new file mode 100644
index 0000000..132c484
--- /dev/null
+++ b/lib/dispatch/adapter/interface/usage_report.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Dispatch
+ module Adapter
+ UsageWindow = Struct.new(:id, :label, :duration_ms, :resets_at, keyword_init: true) do
+ def initialize(id:, label:, duration_ms: nil, resets_at: nil)
+ super
+ end
+ end
+
+ UsageAmount = Struct.new(
+ :used, :limit, :remaining,
+ :used_fraction, :remaining_fraction,
+ :unit, keyword_init: true
+ ) do
+ # unit ∈ :percent | :tokens | :requests | :usd | :minutes | :bytes | :unknown
+ def initialize(unit:, used: nil, limit: nil, remaining: nil,
+ used_fraction: nil, remaining_fraction: nil)
+ super
+ end
+ end
+
+ UsageLimitEntry = Struct.new(
+ :id, :label, :scope, :window, :amount, :status, :notes,
+ keyword_init: true
+ ) do
+ # status ∈ :ok | :warning | :exhausted | :unknown
+ def initialize(id:, label:, scope:, amount:, window: nil,
+ status: :unknown, notes: [])
+ super
+ end
+ end
+
+ UsageReport = Struct.new(
+ :provider, :fetched_at, :limits, :metadata, :raw,
+ keyword_init: true
+ ) do
+ def initialize(provider:, limits:, fetched_at: Time.now, metadata: {}, raw: nil)
+ super
+ end
+ end
+ end
+end
diff --git a/lib/dispatch/adapter/interface/version.rb b/lib/dispatch/adapter/interface/version.rb
index 0a53b34..44b41db 100644
--- a/lib/dispatch/adapter/interface/version.rb
+++ b/lib/dispatch/adapter/interface/version.rb
@@ -3,7 +3,7 @@
module Dispatch
module Adapter
module Interface
- VERSION = "0.1.0"
+ VERSION = "0.3.0"
end
end
end
diff --git a/spec/dispatch/adapter/interface/base_spec.rb b/spec/dispatch/adapter/interface/base_spec.rb
index 8c3a279..f8b4a6e 100644
--- a/spec/dispatch/adapter/interface/base_spec.rb
+++ b/spec/dispatch/adapter/interface/base_spec.rb
@@ -7,6 +7,44 @@ RSpec.describe Dispatch::Adapter::Base do
it "raises NotImplementedError" do
expect { base.chat([]) }.to raise_error(NotImplementedError, /chat must be implemented/)
end
+
+ it "accepts system: as a String without raising" do
+ expect { base.chat([], system: "You are helpful.") }.to raise_error(NotImplementedError)
+ end
+
+ it "accepts system: as an Array of TextBlock without raising" do
+ blocks = [Dispatch::Adapter::TextBlock.new(text: "prompt")]
+ expect { base.chat([], system: blocks) }.to raise_error(NotImplementedError)
+ end
+
+ it "accepts tool_choice: without raising" do
+ expect { base.chat([], tool_choice: :auto) }.to raise_error(NotImplementedError)
+ expect { base.chat([], tool_choice: { type: :tool, name: "fn" }) }.to raise_error(NotImplementedError)
+ end
+
+ it "accepts cache_retention: without raising" do
+ expect { base.chat([], cache_retention: :long) }.to raise_error(NotImplementedError)
+ end
+
+ it "accepts metadata: without raising" do
+ expect { base.chat([], metadata: { user_id: "u1" }) }.to raise_error(NotImplementedError)
+ end
+
+ it "accepts betas: as Array without raising" do
+ expect { base.chat([], betas: ["interleaved-thinking-2025-05-14"]) }.to raise_error(NotImplementedError)
+ end
+
+ it "accepts betas: as String without raising" do
+ expect { base.chat([], betas: "interleaved-thinking-2025-05-14") }.to raise_error(NotImplementedError)
+ end
+
+ it "accepts thinking: as String without raising" do
+ expect { base.chat([], thinking: "high") }.to raise_error(NotImplementedError)
+ end
+
+ it "accepts thinking: as Hash without raising" do
+ expect { base.chat([], thinking: { enabled: true, budget_tokens: 10_000 }) }.to raise_error(NotImplementedError)
+ end
end
describe "#model_name" do
@@ -38,4 +76,28 @@ RSpec.describe Dispatch::Adapter::Base do
expect(base.max_context_tokens).to be_nil
end
end
+
+ describe "#usage_report" do
+ it "returns nil" do
+ expect(base.usage_report).to be_nil
+ end
+ end
+
+ describe "#authenticate!" do
+ it "returns nil" do
+ expect(base.authenticate!).to be_nil
+ end
+ end
+
+ describe "#authenticated?" do
+ it "returns true" do
+ expect(base.authenticated?).to be(true)
+ end
+ end
+
+ describe "#logout!" do
+ it "returns nil" do
+ expect(base.logout!).to be_nil
+ end
+ end
end
diff --git a/spec/dispatch/adapter/interface/pricing_spec.rb b/spec/dispatch/adapter/interface/pricing_spec.rb
new file mode 100644
index 0000000..c37c5e7
--- /dev/null
+++ b/spec/dispatch/adapter/interface/pricing_spec.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+RSpec.describe Dispatch::Adapter::Pricing do
+ let(:pricing) do
+ Dispatch::Adapter::ModelPricing.new(
+ input_per_mtok: 3.0,
+ output_per_mtok: 15.0,
+ cache_read_per_mtok: 0.3,
+ cache_write_per_mtok: 3.75
+ )
+ end
+
+ let(:model_info) do
+ Dispatch::Adapter::ModelInfo.new(
+ id: "claude-3-5-sonnet-20241022",
+ name: "Claude 3.5 Sonnet",
+ max_context_tokens: 200_000,
+ supports_vision: true,
+ supports_tool_use: true,
+ supports_streaming: true,
+ pricing: pricing
+ )
+ end
+
+ describe ".calculate" do
+ it "returns nil if model_info is nil" do
+ usage = Dispatch::Adapter::Usage.new(input_tokens: 100, output_tokens: 50)
+ expect(described_class.calculate(usage, nil)).to be_nil
+ end
+
+ it "returns nil if model_info.pricing is nil" do
+ info = Dispatch::Adapter::ModelInfo.new(
+ id: "test", name: "test", max_context_tokens: 100,
+ supports_vision: false, supports_tool_use: false, supports_streaming: false
+ )
+ usage = Dispatch::Adapter::Usage.new(input_tokens: 100, output_tokens: 50)
+ expect(described_class.calculate(usage, info)).to be_nil
+ end
+
+ it "calculates cost correctly for fixed numbers" do
+ # input: 1,000,000 tokens * $3.00 / 1M = $3.00
+ # output: 2,000,000 tokens * $15.00 / 1M = $30.00
+ # cache_read: 1,000,000 tokens * $0.30 / 1M = $0.30
+ # cache_write: 1,000,000 tokens * $3.75 / 1M = $3.75
+ # total: 3.00 + 30.00 + 0.30 + 3.75 = 37.05
+ usage = Dispatch::Adapter::Usage.new(
+ input_tokens: 1_000_000,
+ output_tokens: 2_000_000,
+ cache_read_tokens: 1_000_000,
+ cache_creation_tokens: 1_000_000
+ )
+
+ cost = described_class.calculate(usage, model_info)
+
+ expect(cost.input).to eq(3.0)
+ expect(cost.output).to eq(30.0)
+ expect(cost.cache_read).to eq(0.3)
+ expect(cost.cache_write).to eq(3.75)
+ expect(cost.total).to eq(37.05)
+ end
+
+ it "handles smaller token counts" do
+ # input: 1,000 tokens * $3.00 / 1M = $0.003
+ # output: 500 tokens * $15.00 / 1M = $0.0075
+ # total: 0.0105
+ usage = Dispatch::Adapter::Usage.new(
+ input_tokens: 1_000,
+ output_tokens: 500
+ )
+
+ cost = described_class.calculate(usage, model_info)
+
+ expect(cost.input).to eq(0.003)
+ expect(cost.output).to eq(0.0075)
+ expect(cost.total).to eq(0.0105)
+ end
+
+ it "ignores reasoning_tokens (as they should be included in output_tokens by the adapter)" do
+ usage = Dispatch::Adapter::Usage.new(
+ input_tokens: 1_000,
+ output_tokens: 500,
+ reasoning_tokens: 200
+ )
+
+ cost = described_class.calculate(usage, model_info)
+ # output cost should still be 500 * 15 / 1M = 0.0075
+ expect(cost.output).to eq(0.0075)
+ end
+ end
+end
diff --git a/spec/dispatch/adapter/interface/rate_limiter_spec.rb b/spec/dispatch/adapter/interface/rate_limiter_spec.rb
new file mode 100644
index 0000000..1e4e501
--- /dev/null
+++ b/spec/dispatch/adapter/interface/rate_limiter_spec.rb
@@ -0,0 +1,407 @@
+# frozen_string_literal: true
+
+require "fileutils"
+require "json"
+require "tempfile"
+
+RSpec.describe Dispatch::Adapter::RateLimiter do
+ let(:tmpdir) { Dir.mktmpdir("rate_limiter_test") }
+ let(:rate_limit_path) { File.join(tmpdir, "copilot_rate_limit") }
+
+ after { FileUtils.rm_rf(tmpdir) }
+
+ describe "#initialize" do
+ it "accepts valid min_request_interval and nil rate_limit" do
+ limiter = described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: 3.0,
+ rate_limit: nil
+ )
+ expect(limiter).to be_a(described_class)
+ end
+
+ it "accepts nil min_request_interval" do
+ limiter = described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: nil,
+ rate_limit: nil
+ )
+ expect(limiter).to be_a(described_class)
+ end
+
+ it "accepts zero min_request_interval" do
+ limiter = described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: 0,
+ rate_limit: nil
+ )
+ expect(limiter).to be_a(described_class)
+ end
+
+ it "accepts valid rate_limit hash" do
+ limiter = described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: nil,
+ rate_limit: { requests: 10, period: 60 }
+ )
+ expect(limiter).to be_a(described_class)
+ end
+
+ it "accepts both min_request_interval and rate_limit" do
+ limiter = described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: 2.0,
+ rate_limit: { requests: 5, period: 30 }
+ )
+ expect(limiter).to be_a(described_class)
+ end
+
+ it "raises ArgumentError for negative min_request_interval" do
+ expect do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: -1,
+ rate_limit: nil
+ )
+ end.to raise_error(ArgumentError, /min_request_interval/)
+ end
+
+ it "raises ArgumentError for non-numeric min_request_interval" do
+ expect do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: "fast",
+ rate_limit: nil
+ )
+ end.to raise_error(ArgumentError, /min_request_interval/)
+ end
+
+ it "raises ArgumentError when rate_limit is missing requests key" do
+ expect do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: nil,
+ rate_limit: { period: 60 }
+ )
+ end.to raise_error(ArgumentError, /requests/)
+ end
+
+ it "raises ArgumentError when rate_limit is missing period key" do
+ expect do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: nil,
+ rate_limit: { requests: 10 }
+ )
+ end.to raise_error(ArgumentError, /period/)
+ end
+
+ it "raises ArgumentError when rate_limit requests is zero" do
+ expect do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: nil,
+ rate_limit: { requests: 0, period: 60 }
+ )
+ end.to raise_error(ArgumentError, /requests/)
+ end
+
+ it "raises ArgumentError when rate_limit requests is negative" do
+ expect do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: nil,
+ rate_limit: { requests: -1, period: 60 }
+ )
+ end.to raise_error(ArgumentError, /requests/)
+ end
+
+ it "raises ArgumentError when rate_limit period is zero" do
+ expect do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: nil,
+ rate_limit: { requests: 10, period: 0 }
+ )
+ end.to raise_error(ArgumentError, /period/)
+ end
+
+ it "raises ArgumentError when rate_limit period is negative" do
+ expect do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: nil,
+ rate_limit: { requests: 10, period: -5 }
+ )
+ end.to raise_error(ArgumentError, /period/)
+ end
+
+ it "raises ArgumentError when rate_limit is not a Hash" do
+ expect do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: nil,
+ rate_limit: "10/60"
+ )
+ end.to raise_error(ArgumentError)
+ end
+ end
+
+ describe "#wait!" do
+ context "with both mechanisms disabled" do
+ let(:limiter) do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: nil,
+ rate_limit: nil
+ )
+ end
+
+ it "returns immediately without sleeping" do
+ expect(limiter).not_to receive(:sleep)
+ limiter.wait!
+ end
+
+ it "does not create a rate limit file" do
+ limiter.wait!
+ expect(File.exist?(rate_limit_path)).to be(false)
+ end
+ end
+
+ context "with per-request cooldown only" do
+ let(:limiter) do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: 1.0,
+ rate_limit: nil
+ )
+ end
+
+ it "does not sleep on the first request" do
+ expect(limiter).not_to receive(:sleep)
+ limiter.wait!
+ end
+
+ it "creates the rate limit file on first request" do
+ limiter.wait!
+ expect(File.exist?(rate_limit_path)).to be(true)
+ end
+
+ it "sets the rate limit file permissions to 0600" do
+ limiter.wait!
+ mode = File.stat(rate_limit_path).mode & 0o777
+ expect(mode).to eq(0o600)
+ end
+
+ it "records last_request_at in the state file" do
+ before = Time.now.to_f
+ limiter.wait!
+ after = Time.now.to_f
+
+ state = JSON.parse(File.read(rate_limit_path))
+ expect(state["last_request_at"]).to be_between(before, after)
+ end
+
+ it "sleeps for the remaining cooldown on a rapid second request" do
+ limiter.wait!
+
+ # Simulate that almost no time has passed
+ allow(limiter).to receive(:sleep) { |duration| expect(duration).to be > 0 }
+ limiter.wait!
+ end
+
+ it "does not sleep when enough time has elapsed between requests" do
+ limiter.wait!
+
+ # Write a past timestamp to simulate time passing
+ state = { "last_request_at" => Time.now.to_f - 2.0, "request_log" => [] }
+ File.write(rate_limit_path, JSON.generate(state))
+
+ expect(limiter).not_to receive(:sleep)
+ limiter.wait!
+ end
+ end
+
+ context "with sliding window only" do
+ let(:limiter) do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: nil,
+ rate_limit: { requests: 3, period: 10 }
+ )
+ end
+
+ it "allows requests up to the window limit without sleeping" do
+ expect(limiter).not_to receive(:sleep)
+ 3.times { limiter.wait! }
+ end
+
+ it "sleeps when the window limit is reached" do
+ now = Time.now.to_f
+ state = {
+ "last_request_at" => now,
+ "request_log" => [ now - 2.0, now - 1.0, now ]
+ }
+ File.write(rate_limit_path, JSON.generate(state))
+
+ allow(limiter).to receive(:sleep) { |duration| expect(duration).to be > 0 }
+ limiter.wait!
+ end
+
+ it "does not sleep when oldest entries have expired from the window" do
+ now = Time.now.to_f
+ state = {
+ "last_request_at" => now - 5.0,
+ "request_log" => [ now - 15.0, now - 12.0, now - 5.0 ]
+ }
+ File.write(rate_limit_path, JSON.generate(state))
+
+ expect(limiter).not_to receive(:sleep)
+ limiter.wait!
+ end
+
+ it "prunes expired entries from the request_log on write" do
+ now = Time.now.to_f
+ state = {
+ "last_request_at" => now - 5.0,
+ "request_log" => [ now - 20.0, now - 15.0, now - 5.0 ]
+ }
+ File.write(rate_limit_path, JSON.generate(state))
+
+ limiter.wait!
+
+ updated_state = JSON.parse(File.read(rate_limit_path))
+ # Old entries (20s and 15s ago) should be pruned (window is 10s)
+ # Only the 5s-ago entry and the new entry should remain
+ expect(updated_state["request_log"].size).to be <= 2
+ end
+ end
+
+ context "with both mechanisms enabled" do
+ let(:limiter) do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: 1.0,
+ rate_limit: { requests: 3, period: 10 }
+ )
+ end
+
+ it "uses the longer wait time when cooldown is the bottleneck" do
+ limiter.wait!
+
+ # Second request immediately — cooldown should be the bottleneck
+ # (only 1 of 3 window slots used, but cooldown not elapsed)
+ allow(limiter).to receive(:sleep) { |duration| expect(duration).to be > 0 }
+ limiter.wait!
+ end
+
+ it "uses the longer wait time when window limit is the bottleneck" 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
+ }
+ File.write(rate_limit_path, JSON.generate(state))
+
+ allow(limiter).to receive(:sleep) { |duration| expect(duration).to be > 0 }
+ limiter.wait!
+ end
+ end
+
+ context "with a missing or corrupt state file" do
+ let(:limiter) do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: 1.0,
+ rate_limit: nil
+ )
+ end
+
+ it "treats a non-existent file as fresh state" do
+ expect(File.exist?(rate_limit_path)).to be(false)
+ expect(limiter).not_to receive(:sleep)
+ limiter.wait!
+ end
+
+ it "treats an empty file as fresh state" do
+ FileUtils.mkdir_p(File.dirname(rate_limit_path))
+ File.write(rate_limit_path, "")
+
+ expect(limiter).not_to receive(:sleep)
+ limiter.wait!
+ end
+
+ it "treats a corrupt JSON file as fresh state" do
+ FileUtils.mkdir_p(File.dirname(rate_limit_path))
+ File.write(rate_limit_path, "not valid json{{{")
+
+ expect(limiter).not_to receive(:sleep)
+ limiter.wait!
+ end
+
+ it "overwrites corrupt state with valid state after a request" do
+ FileUtils.mkdir_p(File.dirname(rate_limit_path))
+ File.write(rate_limit_path, "garbage")
+
+ limiter.wait!
+
+ state = JSON.parse(File.read(rate_limit_path))
+ expect(state).to have_key("last_request_at")
+ expect(state["last_request_at"]).to be_a(Float)
+ end
+ end
+
+ context "with a missing parent directory" do
+ let(:nested_path) { File.join(tmpdir, "sub", "dir", "copilot_rate_limit") }
+
+ let(:limiter) do
+ described_class.new(
+ rate_limit_path: nested_path,
+ min_request_interval: 1.0,
+ rate_limit: nil
+ )
+ end
+
+ it "creates parent directories" do
+ limiter.wait!
+ expect(File.exist?(nested_path)).to be(true)
+ end
+ end
+
+ context "cross-process coordination" do
+ let(:limiter) do
+ described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: 1.0,
+ rate_limit: nil
+ )
+ end
+
+ 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 ] }
+ FileUtils.mkdir_p(File.dirname(rate_limit_path))
+ File.write(rate_limit_path, JSON.generate(state))
+
+ # Our limiter should see this and wait
+ allow(limiter).to receive(:sleep) { |duration| expect(duration).to be > 0 }
+ limiter.wait!
+ end
+
+ it "writes state that another process can read" do
+ limiter.wait!
+
+ # Another RateLimiter instance (simulating another process) reads the file
+ other_limiter = described_class.new(
+ rate_limit_path: rate_limit_path,
+ min_request_interval: 1.0,
+ rate_limit: nil
+ )
+
+ allow(other_limiter).to receive(:sleep) { |duration| expect(duration).to be > 0 }
+ other_limiter.wait!
+ end
+ end
+ end
+end
diff --git a/spec/dispatch/adapter/interface/structs_spec.rb b/spec/dispatch/adapter/interface/structs_spec.rb
index ef8ec73..43b9db8 100644
--- a/spec/dispatch/adapter/interface/structs_spec.rb
+++ b/spec/dispatch/adapter/interface/structs_spec.rb
@@ -22,6 +22,28 @@ RSpec.describe Dispatch::Adapter do
expect(block.type).to eq("text")
expect(block.text).to eq("hello")
end
+
+ it "defaults cache_control to nil" do
+ block = Dispatch::Adapter::TextBlock.new(text: "hello")
+ expect(block.cache_control).to be_nil
+ end
+
+ it "accepts cache_control with ttl" do
+ block = Dispatch::Adapter::TextBlock.new(text: "x", cache_control: { type: :ephemeral, ttl: :"1h" })
+ expect(block.cache_control[:ttl]).to eq(:"1h")
+ end
+
+ it "accepts cache_control without ttl" do
+ block = Dispatch::Adapter::TextBlock.new(text: "x", cache_control: { type: :ephemeral })
+ expect(block.cache_control[:type]).to eq(:ephemeral)
+ expect(block.cache_control[:ttl]).to be_nil
+ end
+
+ it "serializes cache_control via to_h" do
+ block = Dispatch::Adapter::TextBlock.new(text: "y", cache_control: { type: :ephemeral, ttl: :"5m" })
+ h = block.to_h
+ expect(h[:cache_control]).to eq({ type: :ephemeral, ttl: :"5m" })
+ end
end
describe "ImageBlock" do
@@ -69,6 +91,36 @@ RSpec.describe Dispatch::Adapter do
expect(td.description).to eq("Search the web")
expect(td.parameters).to be_a(Hash)
end
+
+ it "defaults cache_control to nil" do
+ td = Dispatch::Adapter::ToolDefinition.new(
+ name: "search",
+ description: "Search",
+ parameters: {}
+ )
+ expect(td.cache_control).to be_nil
+ end
+
+ it "accepts cache_control" do
+ td = Dispatch::Adapter::ToolDefinition.new(
+ name: "search",
+ description: "Search",
+ parameters: {},
+ cache_control: { type: :ephemeral }
+ )
+ expect(td.cache_control).to eq({ type: :ephemeral })
+ end
+
+ it "serializes cache_control via to_h" do
+ td = Dispatch::Adapter::ToolDefinition.new(
+ name: "lookup",
+ description: "Look up",
+ parameters: {},
+ cache_control: { type: :ephemeral, ttl: :"1h" }
+ )
+ h = td.to_h
+ expect(h[:cache_control]).to eq({ type: :ephemeral, ttl: :"1h" })
+ end
end
describe "Response" do
@@ -114,6 +166,59 @@ RSpec.describe Dispatch::Adapter do
expect(usage.cache_read_tokens).to eq(10)
expect(usage.cache_creation_tokens).to eq(5)
end
+
+ it "defaults reasoning_tokens to 0" do
+ usage = Dispatch::Adapter::Usage.new(input_tokens: 100, output_tokens: 50)
+ expect(usage.reasoning_tokens).to eq(0)
+ end
+
+ it "defaults premium_requests to nil" do
+ usage = Dispatch::Adapter::Usage.new(input_tokens: 100, output_tokens: 50)
+ expect(usage.premium_requests).to be_nil
+ end
+
+ it "defaults cost to nil" do
+ usage = Dispatch::Adapter::Usage.new(input_tokens: 100, output_tokens: 50)
+ expect(usage.cost).to be_nil
+ end
+
+ it "accepts a UsageCost for cost" do
+ cost = Dispatch::Adapter::UsageCost.new(input: 0.01, output: 0.02, total: 0.03)
+ usage = Dispatch::Adapter::Usage.new(input_tokens: 100, output_tokens: 50, cost: cost)
+ expect(usage.cost).to be_a(Dispatch::Adapter::UsageCost)
+ expect(usage.cost.total).to eq(0.03)
+ end
+
+ it "accepts reasoning_tokens and premium_requests" do
+ usage = Dispatch::Adapter::Usage.new(
+ input_tokens: 100,
+ output_tokens: 50,
+ reasoning_tokens: 30,
+ premium_requests: 2.5
+ )
+ expect(usage.reasoning_tokens).to eq(30)
+ expect(usage.premium_requests).to eq(2.5)
+ end
+ end
+
+ describe "UsageCost" do
+ it "defaults all fields to 0.0" do
+ cost = Dispatch::Adapter::UsageCost.new
+ expect(cost.input).to eq(0.0)
+ expect(cost.output).to eq(0.0)
+ expect(cost.cache_read).to eq(0.0)
+ expect(cost.cache_write).to eq(0.0)
+ expect(cost.total).to eq(0.0)
+ end
+
+ it "accepts keyword args" do
+ cost = Dispatch::Adapter::UsageCost.new(input: 0.005, output: 0.015, total: 0.02)
+ expect(cost.input).to eq(0.005)
+ expect(cost.output).to eq(0.015)
+ expect(cost.cache_read).to eq(0.0)
+ expect(cost.cache_write).to eq(0.0)
+ expect(cost.total).to eq(0.02)
+ end
end
describe "StreamDelta" do
@@ -136,6 +241,24 @@ RSpec.describe Dispatch::Adapter do
expect(delta.type).to eq(:tool_use_delta)
expect(delta.argument_delta).to eq('{"q":')
end
+
+ it "creates a thinking_start" do
+ delta = Dispatch::Adapter::StreamDelta.new(type: :thinking_start)
+ expect(delta.type).to eq(:thinking_start)
+ expect(delta.text).to be_nil
+ end
+
+ it "creates a thinking_delta with text payload" do
+ delta = Dispatch::Adapter::StreamDelta.new(type: :thinking_delta, text: "I am reasoning about this")
+ expect(delta.type).to eq(:thinking_delta)
+ expect(delta.text).to eq("I am reasoning about this")
+ end
+
+ it "creates a thinking_end" do
+ delta = Dispatch::Adapter::StreamDelta.new(type: :thinking_end)
+ expect(delta.type).to eq(:thinking_end)
+ expect(delta.text).to be_nil
+ end
end
describe "ModelInfo" do
@@ -183,6 +306,51 @@ RSpec.describe Dispatch::Adapter do
end
end
+ describe "ThinkingBlock" do
+ it "defaults type to 'thinking'" do
+ block = Dispatch::Adapter::ThinkingBlock.new(thinking: "Let me consider this...")
+ expect(block.type).to eq("thinking")
+ expect(block.thinking).to eq("Let me consider this...")
+ expect(block.signature).to be_nil
+ end
+
+ it "accepts an optional signature" do
+ block = Dispatch::Adapter::ThinkingBlock.new(thinking: "deep thought", signature: "abc123")
+ expect(block.signature).to eq("abc123")
+ end
+
+ it "serializes correctly via to_h" do
+ block = Dispatch::Adapter::ThinkingBlock.new(thinking: "analysis", signature: "sig42")
+ h = block.to_h
+ expect(h[:type]).to eq("thinking")
+ expect(h[:thinking]).to eq("analysis")
+ expect(h[:signature]).to eq("sig42")
+ end
+
+ it "serializes with nil signature via to_h" do
+ block = Dispatch::Adapter::ThinkingBlock.new(thinking: "just thinking")
+ h = block.to_h
+ expect(h[:type]).to eq("thinking")
+ expect(h[:thinking]).to eq("just thinking")
+ expect(h[:signature]).to be_nil
+ end
+ end
+
+ describe "RedactedThinkingBlock" do
+ it "defaults type to 'redacted_thinking'" do
+ block = Dispatch::Adapter::RedactedThinkingBlock.new(data: "base64encodeddata==")
+ expect(block.type).to eq("redacted_thinking")
+ expect(block.data).to eq("base64encodeddata==")
+ end
+
+ it "serializes correctly via to_h" do
+ block = Dispatch::Adapter::RedactedThinkingBlock.new(data: "encodedblob")
+ h = block.to_h
+ expect(h[:type]).to eq("redacted_thinking")
+ expect(h[:data]).to eq("encodedblob")
+ end
+ end
+
describe "Struct equality" do
it "considers structs with same values equal" do
a = Dispatch::Adapter::Message.new(role: "user", content: "hello")
diff --git a/spec/dispatch/adapter/interface/usage_report_spec.rb b/spec/dispatch/adapter/interface/usage_report_spec.rb
new file mode 100644
index 0000000..6cb3079
--- /dev/null
+++ b/spec/dispatch/adapter/interface/usage_report_spec.rb
@@ -0,0 +1,171 @@
+# frozen_string_literal: true
+
+RSpec.describe "UsageReport and friends" do
+ describe Dispatch::Adapter::UsageWindow do
+ it "constructs with required keywords only" do
+ w = described_class.new(id: "daily", label: "Daily")
+ expect(w.id).to eq("daily")
+ expect(w.label).to eq("Daily")
+ expect(w.duration_ms).to be_nil
+ expect(w.resets_at).to be_nil
+ end
+
+ it "accepts optional duration_ms and resets_at" do
+ t = Time.now
+ w = described_class.new(id: "hourly", label: "Hourly", duration_ms: 3_600_000, resets_at: t)
+ expect(w.duration_ms).to eq(3_600_000)
+ expect(w.resets_at).to eq(t)
+ end
+
+ it "round-trips through to_h" do
+ w = described_class.new(id: "daily", label: "Daily", duration_ms: 86_400_000)
+ h = w.to_h
+ expect(h[:id]).to eq("daily")
+ expect(h[:label]).to eq("Daily")
+ expect(h[:duration_ms]).to eq(86_400_000)
+ expect(h[:resets_at]).to be_nil
+ end
+ end
+
+ describe Dispatch::Adapter::UsageAmount do
+ it "constructs with required unit keyword only" do
+ a = described_class.new(unit: :tokens)
+ expect(a.unit).to eq(:tokens)
+ expect(a.used).to be_nil
+ expect(a.limit).to be_nil
+ expect(a.remaining).to be_nil
+ expect(a.used_fraction).to be_nil
+ expect(a.remaining_fraction).to be_nil
+ end
+
+ it "accepts all optional fields" do
+ a = described_class.new(
+ unit: :requests,
+ used: 250,
+ limit: 1000,
+ remaining: 750,
+ used_fraction: 0.25,
+ remaining_fraction: 0.75
+ )
+ expect(a.used).to eq(250)
+ expect(a.limit).to eq(1000)
+ expect(a.remaining).to eq(750)
+ expect(a.used_fraction).to eq(0.25)
+ expect(a.remaining_fraction).to eq(0.75)
+ end
+
+ it "round-trips through to_h" do
+ a = described_class.new(unit: :usd, used: 1.5, limit: 10.0)
+ h = a.to_h
+ expect(h[:unit]).to eq(:usd)
+ expect(h[:used]).to eq(1.5)
+ expect(h[:limit]).to eq(10.0)
+ expect(h[:remaining]).to be_nil
+ end
+
+ it "supports all documented unit symbols" do
+ %i[percent tokens requests usd minutes bytes unknown].each do |u|
+ expect { described_class.new(unit: u) }.not_to raise_error
+ end
+ end
+ end
+
+ describe Dispatch::Adapter::UsageLimitEntry do
+ let(:amount) { Dispatch::Adapter::UsageAmount.new(unit: :tokens, used: 100, limit: 1000) }
+
+ it "constructs with required keywords only" do
+ entry = described_class.new(id: "token_limit", label: "Token Limit", scope: "org", amount: amount)
+ expect(entry.id).to eq("token_limit")
+ expect(entry.label).to eq("Token Limit")
+ expect(entry.scope).to eq("org")
+ expect(entry.amount).to eq(amount)
+ expect(entry.window).to be_nil
+ expect(entry.status).to eq(:unknown)
+ expect(entry.notes).to eq([])
+ end
+
+ it "accepts optional window, status, and notes" do
+ window = Dispatch::Adapter::UsageWindow.new(id: "daily", label: "Daily")
+ entry = described_class.new(
+ id: "req_limit",
+ label: "Request Limit",
+ scope: "user",
+ amount: amount,
+ window: window,
+ status: :ok,
+ notes: ["All good"]
+ )
+ expect(entry.window).to eq(window)
+ expect(entry.status).to eq(:ok)
+ expect(entry.notes).to eq(["All good"])
+ end
+
+ it "supports all documented status values" do
+ %i[ok warning exhausted unknown].each do |s|
+ entry = described_class.new(id: "x", label: "X", scope: "org", amount: amount, status: s)
+ expect(entry.status).to eq(s)
+ end
+ end
+
+ it "round-trips through to_h" do
+ entry = described_class.new(id: "e1", label: "E1", scope: "workspace", amount: amount)
+ h = entry.to_h
+ expect(h[:id]).to eq("e1")
+ expect(h[:label]).to eq("E1")
+ expect(h[:scope]).to eq("workspace")
+ expect(h[:status]).to eq(:unknown)
+ expect(h[:notes]).to eq([])
+ end
+ end
+
+ describe Dispatch::Adapter::UsageReport do
+ let(:amount) { Dispatch::Adapter::UsageAmount.new(unit: :requests, used: 5, limit: 100) }
+ let(:entry) do
+ Dispatch::Adapter::UsageLimitEntry.new(
+ id: "req", label: "Requests", scope: "account", amount: amount
+ )
+ end
+
+ it "constructs with required keywords only" do
+ report = described_class.new(provider: "acme", limits: [entry])
+ expect(report.provider).to eq("acme")
+ expect(report.limits).to eq([entry])
+ expect(report.fetched_at).to be_a(Time)
+ expect(report.metadata).to eq({})
+ expect(report.raw).to be_nil
+ end
+
+ it "accepts all optional fields" do
+ t = Time.now
+ raw = { "foo" => "bar" }
+ report = described_class.new(
+ provider: "acme",
+ limits: [entry],
+ fetched_at: t,
+ metadata: { source: "api" },
+ raw: raw
+ )
+ expect(report.fetched_at).to eq(t)
+ expect(report.metadata).to eq({ source: "api" })
+ expect(report.raw).to eq(raw)
+ end
+
+ it "round-trips through to_h" do
+ t = Time.now
+ report = described_class.new(provider: "test_provider", limits: [], fetched_at: t)
+ h = report.to_h
+ expect(h[:provider]).to eq("test_provider")
+ expect(h[:limits]).to eq([])
+ expect(h[:fetched_at]).to eq(t)
+ expect(h[:metadata]).to eq({})
+ expect(h[:raw]).to be_nil
+ end
+
+ it "struct equality holds when values are the same" do
+ t = Time.now
+ a = described_class.new(provider: "p", limits: [], fetched_at: t)
+ b = described_class.new(provider: "p", limits: [], fetched_at: t)
+ expect(a).to eq(b)
+ end
+ end
+end
diff --git a/spec/model_info_spec.rb b/spec/model_info_spec.rb
new file mode 100644
index 0000000..25930f4
--- /dev/null
+++ b/spec/model_info_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe Dispatch::Adapter::ModelInfo do
+ let(:base_kwargs) do
+ {
+ id: "gpt-4",
+ name: "GPT-4",
+ max_context_tokens: 8192,
+ supports_vision: false,
+ supports_tool_use: true,
+ supports_streaming: true
+ }
+ end
+
+ it "still works without pricing (existing shape unchanged)" do
+ info = described_class.new(**base_kwargs)
+ expect(info.id).to eq("gpt-4")
+ expect(info.name).to eq("GPT-4")
+ expect(info.max_context_tokens).to eq(8192)
+ expect(info.supports_vision).to be(false)
+ expect(info.supports_tool_use).to be(true)
+ expect(info.supports_streaming).to be(true)
+ expect(info.premium_request_multiplier).to be_nil
+ expect(info.pricing).to be_nil
+ end
+
+ it "accepts a ModelPricing and exposes it on #pricing" do
+ pricing = Dispatch::Adapter::ModelPricing.new(input_per_mtok: 3.0, output_per_mtok: 15.0)
+ info = described_class.new(**base_kwargs, pricing: pricing)
+ expect(info.pricing).to be_a(Dispatch::Adapter::ModelPricing)
+ expect(info.pricing.input_per_mtok).to eq(3.0)
+ expect(info.pricing.output_per_mtok).to eq(15.0)
+ end
+end
+
+RSpec.describe Dispatch::Adapter::ModelPricing do
+ it "requires input_per_mtok and output_per_mtok" do
+ pricing = described_class.new(input_per_mtok: 3.0, output_per_mtok: 15.0)
+ expect(pricing.input_per_mtok).to eq(3.0)
+ expect(pricing.output_per_mtok).to eq(15.0)
+ end
+
+ it "defaults cache_read_per_mtok and cache_write_per_mtok to 0.0" do
+ pricing = described_class.new(input_per_mtok: 1.0, output_per_mtok: 2.0)
+ expect(pricing.cache_read_per_mtok).to eq(0.0)
+ expect(pricing.cache_write_per_mtok).to eq(0.0)
+ end
+
+ it "accepts explicit cache pricing" do
+ pricing = described_class.new(
+ input_per_mtok: 3.0,
+ output_per_mtok: 15.0,
+ cache_read_per_mtok: 0.3,
+ cache_write_per_mtok: 3.75
+ )
+ expect(pricing.cache_read_per_mtok).to eq(0.3)
+ expect(pricing.cache_write_per_mtok).to eq(3.75)
+ end
+end