# frozen_string_literal: true require "webmock/rspec" RSpec.describe Dispatch::Adapter::Claude, "#list_models" do let(:model_id) { "claude-sonnet-4-6" } let(:api_key) { "sk-ant-api03-test" } let(:base_url) { "https://api.anthropic.com" } let(:models_url) { "#{base_url}/v1/models?limit=200" } subject(:adapter) do described_class.new( model: model_id, api_key: api_key, base_url: base_url ) end before { WebMock.disable_net_connect! } after { WebMock.reset! } # A known bundled model ID (must exist in pricing table) let(:known_id) { "claude-sonnet-4-6" } # A fake runtime-only model let(:unknown_id) { "claude-future-99" } def stub_models_api(entries) stub_request(:get, models_url) .to_return( status: 200, body: JSON.generate({ "data" => entries }), headers: { "Content-Type" => "application/json" } ) end # ── Runtime overlay adds new entries ───────────────────────────────────── describe "runtime entries added to bundled list" do let(:runtime_entries) do [ { "id" => known_id, "display_name" => "Claude Sonnet 4.6 (Latest)" }, { "id" => unknown_id, "display_name" => "Claude Future 99" } ] end before { stub_models_api(runtime_entries) } it "returns an Array of ModelInfo objects" do result = adapter.list_models expect(result).to all(be_a(Dispatch::Adapter::ModelInfo)) end it "includes the known model with display_name override" do result = adapter.list_models info = result.find { |m| m.id == known_id } expect(info).not_to be_nil expect(info.name).to eq("Claude Sonnet 4.6 (Latest)") end it "known model retains bundled pricing" do result = adapter.list_models info = result.find { |m| m.id == known_id } expect(info.pricing).not_to be_nil end it "includes the unknown (unrated) model" do result = adapter.list_models info = result.find { |m| m.id == unknown_id } expect(info).not_to be_nil end it "unknown model has nil pricing" do result = adapter.list_models info = result.find { |m| m.id == unknown_id } expect(info.pricing).to be_nil end it "unknown model has '(unrated)' prefix in name" do result = adapter.list_models info = result.find { |m| m.id == unknown_id } expect(info.name).to start_with("(unrated)") end it "unknown model has a default max_context_tokens of 200_000" do result = adapter.list_models info = result.find { |m| m.id == unknown_id } expect(info.max_context_tokens).to eq(200_000) end end # ── Failure falls back to bundled list ──────────────────────────────────── describe "network failure falls back to bundled list" do before do stub_request(:get, models_url).to_raise(Errno::ECONNREFUSED) end it "does not raise" do expect { adapter.list_models }.not_to raise_error end it "returns an Array of ModelInfo objects" do expect(adapter.list_models).to all(be_a(Dispatch::Adapter::ModelInfo)) end it "returns the bundled models" do result = adapter.list_models ids = result.map(&:id) expect(ids).to include(known_id) end end describe "HTTP error falls back to bundled list" do before do stub_request(:get, models_url) .to_return( status: 500, body: JSON.generate({ "error" => { "message" => "oops" } }), headers: { "Content-Type" => "application/json" } ) end it "does not raise on server error" do expect { adapter.list_models }.not_to raise_error end it "returns bundled models on server error" do result = adapter.list_models expect(result.map(&:id)).to include(known_id) end end # ── In-memory cache ─────────────────────────────────────────────────────── describe "in-memory cache" do before do stub_models_api([ { "id" => known_id, "display_name" => "Cached Name" } ]) end it "returns the same array object on a second call (cached)" do first = adapter.list_models second = adapter.list_models expect(second).to equal(first) # object identity end it "only calls the API once within the TTL" do adapter.list_models adapter.list_models expect(WebMock).to have_requested(:get, models_url).once end it "re-fetches when the cache has expired" do # Force cache expiry by backdating the cache timestamp adapter.list_models adapter.instance_variable_set(:@models_cache_at, adapter.send(:current_time_ms) - described_class::MODELS_CACHE_TTL_MS - 1) adapter.list_models expect(WebMock).to have_requested(:get, models_url).twice end end # ── Bundled-only models appended if missing from runtime ────────────────── describe "bundled models not in runtime response are appended" do # Runtime only knows about one model; bundled knows more before do stub_models_api([ { "id" => unknown_id, "display_name" => "Future Model" } ]) end it "appends bundled models not present in the runtime response" do result = adapter.list_models ids = result.map(&:id) expect(ids).to include(known_id) # bundled model added expect(ids).to include(unknown_id) # runtime model present end end # ── ModelCatalog.build_from_api ─────────────────────────────────────────── describe "ModelCatalog.build_from_api" do let(:catalog) { Dispatch::Adapter::Claude::ModelCatalog } context "with a known model id" do let(:entry) { { "id" => known_id, "display_name" => "My Name" } } it "uses the display_name" do info = catalog.build_from_api(entry) expect(info.name).to eq("My Name") end it "assigns bundled pricing" do info = catalog.build_from_api(entry) expect(info.pricing).not_to be_nil end it "falls back to id when display_name is empty" do info = catalog.build_from_api("id" => known_id, "display_name" => "") expect(info.name).to eq(known_id) end end context "with an unknown model id" do let(:entry) { { "id" => "totally-new-model", "display_name" => "New Model" } } it "prefixes name with '(unrated)'" do info = catalog.build_from_api(entry) expect(info.name).to eq("(unrated) New Model") end it "has nil pricing" do info = catalog.build_from_api(entry) expect(info.pricing).to be_nil end it "has default max_context_tokens of 200_000" do info = catalog.build_from_api(entry) expect(info.max_context_tokens).to eq(200_000) end end end end