lib/clacky/providers.rb



# frozen_string_literal: true

module Clacky
  # Built-in model provider presets
  # Provides default configurations for supported AI model providers
  module Providers
    # Provider preset definitions
    # Each preset includes:
    # - name: Human-readable provider name
    # - base_url: Default API endpoint
    # - api: API type (anthropic-messages, openai-responses, openai-completions)
    # - default_model: Recommended default model
    # - capabilities (optional): provider-level capability hash (e.g.
    #   { "vision" => false }). Applies to all models under this provider
    #   unless overridden by model_capabilities below.
    # - model_capabilities (optional): per-model capability override map,
    #   { "<model_name>" => { "<cap>" => bool, ... } }. Use this when a
    #   single provider hosts models with different capabilities (e.g.
    #   openclacky hosts both vision-capable Claude and text-only DeepSeek).
    # - model_api_overrides (optional): per-model API-type override map,
    #   { <Regexp|String> => "anthropic-messages" | "openai-completions" | ... }.
    #   Keys can be a plain model name or a Regexp matched against the model.
    #   The first key that matches wins; if none match, the provider's top-level
    #   "api" is used. Used so e.g. OpenRouter can keep "openai-responses" as
    #   its default while routing Claude models through the native Anthropic
    #   endpoint (which preserves cache_control fidelity).
    PRESETS = {
      "openclacky" => {
        "name" => "OpenClacky",
        "base_url" => "https://api.openclacky.com",
        "api" => "bedrock",
        "default_model" => "abs-claude-sonnet-4-6",
        "models" => [
          "abs-claude-opus-4-7",
          "abs-claude-opus-4-6",
          "abs-claude-sonnet-4-6",
          "abs-claude-sonnet-4-5",
          "abs-claude-haiku-4-5",
          "dsk-deepseek-v4-pro",
          "dsk-deepseek-v4-flash",
          "or-gemini-3-1-pro"
        ],
        # Provider-level default: the Claude family served here is vision-capable.
        "capabilities" => { "vision" => true }.freeze,
        # Model-level overrides: DeepSeek models routed through this provider
        # are text-only; images uploaded for them must be downgraded to disk refs.
        # Gemini 3.1 Pro keeps the provider-default vision=true (it accepts
        # image/audio/video input natively via OpenRouter).
        "model_capabilities" => {
          "dsk-deepseek-v4-pro"   => { "vision" => false }.freeze,
          "dsk-deepseek-v4-flash" => { "vision" => false }.freeze
        }.freeze,
        # Per-primary lite pairing: keys are "strong" primary models, values
        # are the lite sidekick to auto-inject when that primary is the
        # default. Lite is consumed by some subagents for cheap/fast work;
        # weak models (haiku / v4-flash) ARE the lite tier themselves, so
        # they're intentionally not listed here — no injection happens when
        # the default model is already lite-class.
        #
        # or-gemini-3-1-pro is intentionally absent: Gemini has no lite
        # sibling wired up (yet) on this provider; subagents using the
        # Gemini default will just reuse it for lite work until we add one.
        "lite_models" => {
          "abs-claude-opus-4-7"   => "abs-claude-haiku-4-5",
          "abs-claude-opus-4-6"   => "abs-claude-haiku-4-5",
          "abs-claude-sonnet-4-6" => "abs-claude-haiku-4-5",
          "abs-claude-sonnet-4-5" => "abs-claude-haiku-4-5",
          "dsk-deepseek-v4-pro"   => "dsk-deepseek-v4-flash"
        },
        # Fallback chain: if a model is unavailable, try the next one in order.
        # Keys are primary model names; values are the fallback model to use instead.
        "fallback_models" => {
          "abs-claude-sonnet-4-6" => "abs-claude-sonnet-4-5"
        },
        "website_url" => "https://www.openclacky.com/ai-keys"
      }.freeze,

      "openrouter" => {
        "name" => "OpenRouter",
        "base_url" => "https://openrouter.ai/api/v1",
        "api" => "openai-responses",
        "default_model" => "anthropic/claude-sonnet-4-6",
        # Curated default lineup. OpenRouter's full catalogue is enormous
        # (hundreds of models) and the live /models endpoint isn't always
        # reachable from every region — shipping a small list of the
        # mainstream Claude + GPT entries gives users a working dropdown
        # out of the box. Users can still type any other OpenRouter model
        # ID manually; this list only seeds the picker.
        "models" => [
          "anthropic/claude-sonnet-4-6",
          "anthropic/claude-opus-4-7",
          "anthropic/claude-opus-4-6",
          "anthropic/claude-haiku-4-5",
          "openai/gpt-5.5",
          "openai/gpt-5.4",
          "openai/gpt-5.4-mini"
        ],
        # Per-primary lite pairing — Claude family pairs with Haiku, GPT
        # family pairs with the mini variant. Mirrors the openclacky and
        # openai presets above so subagents on OpenRouter get a sensible
        # cheap/fast sidekick automatically.
        "lite_models" => {
          "anthropic/claude-sonnet-4-6" => "anthropic/claude-haiku-4-5",
          "anthropic/claude-opus-4-7"   => "anthropic/claude-haiku-4-5",
          "anthropic/claude-opus-4-6"   => "anthropic/claude-haiku-4-5",
          "openai/gpt-5.5"              => "openai/gpt-5.4-mini",
          "openai/gpt-5.4"              => "openai/gpt-5.4-mini"
        },
        # Per-model API type overrides. Matched by Regexp against the model name.
        # Why this exists: OpenRouter proxies Claude via both its OpenAI-compatible
        # /chat/completions endpoint AND a native Anthropic /v1/messages endpoint.
        # The OpenAI shim is lossy for Claude's cache_control semantics — prefix
        # rewrites inside the proxy cause ~10% prompt-cache misses. Pinning
        # "anthropic/*" (and any direct "claude-*" alias) to the native Anthropic
        # endpoint preserves cache_control byte-for-byte and matches what Claude
        # Code CLI does internally. Non-Claude models (Gemini, GPT, etc.) keep
        # the OpenAI shim — that's what OpenRouter documents as their primary.
        "model_api_overrides" => {
          /\Aanthropic\// => "anthropic-messages",
          /\Aclaude[-.]/  => "anthropic-messages"
        }.freeze,
        "website_url" => "https://openrouter.ai/keys"
      }.freeze,

      "deepseekv4" => {
        "name" => "DeepSeek V4",
        # DeepSeek API is compatible with both OpenAI and Anthropic formats.
        # We use the OpenAI-compatible endpoint here (matches kimi/minimax/glm style).
        # For Anthropic-format usage, point base_url at https://api.deepseek.com/anthropic
        # and change "api" to "anthropic-messages".
        "base_url" => "https://api.deepseek.com",
        "api" => "openai-completions",
        "default_model" => "deepseek-v4-pro",
        "lite_model" => "deepseek-v4-flash",
        # Note: deepseek-chat and deepseek-reasoner are legacy aliases being
        # deprecated on 2026-07-24; they map to deepseek-v4-flash's non-thinking
        # and thinking modes respectively. Prefer deepseek-v4-flash / deepseek-v4-pro.
        "models" => [
          "deepseek-v4-flash",
          "deepseek-v4-pro",
        ],
        # DeepSeek V4 API does not accept image inputs — text-only across all models.
        "capabilities" => { "vision" => false }.freeze,
        "website_url" => "https://platform.deepseek.com/api_keys"
      }.freeze,

      "minimax" => {
        "name" => "Minimax",
        "base_url" => "https://api.minimaxi.com/v1",
        "api" => "openai-completions",
        "default_model" => "MiniMax-M2.7",
        "models" => ["MiniMax-M2.5", "MiniMax-M2.7"],
        # MiniMax operates two regional endpoints with identical APIs & model
        # lineup — mainland China (.com) and international (.io). Listing both
        # lets find_by_base_url identify either one as provider "minimax",
        # so capability checks (vision=false) fire correctly regardless of
        # which endpoint the user configured.
        "endpoint_variants" => [
          { "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn",    "base_url" => "https://api.minimaxi.com/v1", "region" => "cn"   }.freeze,
          { "label" => "International",  "label_key" => "settings.models.baseurl.variant.international",  "base_url" => "https://api.minimax.io/v1",   "region" => "intl" }.freeze
        ].freeze,
        # MiniMax M2.x does not support multimodal/vision input on this endpoint.
        "capabilities" => { "vision" => false }.freeze,
        "website_url" => "https://www.minimaxi.com/user-center/basic-information/interface-key"
      }.freeze,

      "kimi" => {
        "name" => "Kimi (Moonshot)",
        "base_url" => "https://api.moonshot.cn/v1",
        "api" => "openai-completions",
        "default_model" => "kimi-k2.6",
        "models" => ["kimi-k2.6", "kimi-k2.5"],
        # Moonshot operates two regional endpoints with identical APIs & model
        # lineup — mainland China (.cn) and international (.ai). These are the
        # pay-as-you-go Open Platform endpoints; the subscription-billed
        # Coding Plan lives at api.kimi.com/coding with the unified
        # `kimi-for-coding` model alias and is exposed as a separate
        # top-level "kimi-coding" preset (different domain, distinct billing
        # model, marketed by Moonshot as the standalone Kimi Code product).
        # Listing both PAYG variants here lets find_by_base_url identify
        # either one as provider "kimi", so downstream capability checks,
        # fallback chains, and provider-specific behaviours work regardless
        # of which endpoint the user configured.
        "endpoint_variants" => [
          { "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn",   "base_url" => "https://api.moonshot.cn/v1", "region" => "cn"   }.freeze,
          { "label" => "International",  "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://api.moonshot.ai/v1", "region" => "intl" }.freeze
        ].freeze,
        # k2.5 / k2.6 are multimodal; legacy k2 text-only models need model_capabilities override if added.
        "capabilities" => { "vision" => true }.freeze,
        "website_url" => "https://platform.moonshot.cn/console/api-keys"
      }.freeze,

      "kimi-coding" => {
        "name" => "Kimi Code (Coding Plan)",
        # Subscription-billed Kimi Code endpoint — separate product from the
        # PAYG Moonshot Open Platform (api.moonshot.cn/v1 / .ai/v1). Uses the
        # unified `kimi-for-coding` model alias which the Coding Plan backend
        # routes to the appropriate K2 variant (Kimi-k2.6 today; 262K context,
        # 32K max output, supports vision/video/reasoning).
        #
        # Why anthropic-messages: Moonshot exposes the Coding Plan via two
        # URLs on the same domain — an Anthropic-format endpoint at
        # api.kimi.com/coding/ (used by Claude Code via ANTHROPIC_BASE_URL)
        # and an OpenAI-compatible endpoint at api.kimi.com/coding/v1 (used
        # by Roo Code etc.). We route through anthropic-messages so
        # cache_control fields round-trip byte-for-byte (the OpenAI shim is
        # lossy for cache_control semantics — see OpenRouter preset above
        # for the same reason). Verified against the live endpoint: response
        # payload includes cache_creation_input_tokens / cache_read_input_tokens,
        # so the cache layer is real on this backend.
        #
        # User-Agent gate: this endpoint enforces a UA-prefix whitelist
        # limited to first-party coding agents (Kimi CLI, Claude Code, Roo
        # Code, Kilo Code, ...). Requests carrying openclacky's default
        # Faraday UA are rejected with HTTP 403 access_terminated_error.
        # Client#anthropic_connection injects a Claude Code-shaped UA when
        # @provider_id == "kimi-coding" — see the comment in client.rb for
        # the policy rationale.
        #
        # Source: https://www.kimi.com/code/docs/third-party-tools/other-coding-agents.html
        "base_url" => "https://api.kimi.com/coding",
        "api" => "anthropic-messages",
        "default_model" => "kimi-for-coding",
        "models" => ["kimi-for-coding"],
        # K2.6 backend behind the alias is multimodal (image + video input,
        # reasoning). Same vision capability as the PAYG kimi preset.
        "capabilities" => { "vision" => true }.freeze,
        "website_url" => "https://www.kimi.com/code"
      }.freeze,

      "anthropic" => {
        "name" => "Anthropic (Claude)",
        "base_url" => "https://api.anthropic.com",
        "api" => "anthropic-messages",
        "default_model" => "claude-sonnet-4.6",
        "models" => ["claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4.6", "claude-haiku-4.5"],
        "website_url" => "https://console.anthropic.com/settings/keys"
      }.freeze,

      "clackyai-sea" => {
        "name" => "ClackyAI(Sea)",
        "base_url" => "https://api.clacky.ai",
        "api" => "bedrock",
        "default_model" => "abs-claude-sonnet-4-5",
        "models" => [
          "abs-claude-opus-4-6",
          "abs-claude-sonnet-4-6",
          "abs-claude-sonnet-4-5",
          "abs-claude-haiku-4-5"
        ],
        # Claude family — all vision-capable.
        "capabilities" => { "vision" => true }.freeze,
        # Per-primary lite pairing — see openclacky preset for rationale.
        "lite_models" => {
          "abs-claude-opus-4-6"   => "abs-claude-haiku-4-5",
          "abs-claude-sonnet-4-6" => "abs-claude-haiku-4-5",
          "abs-claude-sonnet-4-5" => "abs-claude-haiku-4-5"
        },
        # Fallback chain: if a model is unavailable, try the next one in order.
        # Keys are primary model names; values are the fallback model to use instead.
        "fallback_models" => {
          "abs-claude-sonnet-4-6" => "abs-claude-sonnet-4-5"
        },
        "website_url" => "https://clacky.ai"
      }.freeze,

      "mimo" => {
        "name" => "MiMo (Xiaomi)",
        "base_url" => "https://api.xiaomimimo.com/v1",
        "api" => "openai-completions",
        "default_model" => "mimo-v2.5-pro",
        "models" => ["mimo-v2.5-pro", "mimo-v2-pro", "mimo-v2-omni"],
        # MiMo-V2-Pro is text-only; MiMo-V2-Omni supports vision (omni = multimodal).
        "capabilities" => { "vision" => false }.freeze,
        "model_capabilities" => {
          "mimo-v2-omni" => { "vision" => true }.freeze
        }.freeze,
        "website_url" => "https://platform.xiaomimimo.com/"
      }.freeze,

      "glm" => {
        "name" => "GLM (Z.ai / Zhipu)",
        "base_url" => "https://open.bigmodel.cn/api/paas/v4",
        "api" => "openai-completions",
        "default_model" => "glm-5.1",
        "models" => ["glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7"],
        # Zhipu / Z.ai expose four functionally-equivalent endpoints:
        # two regional sites (mainland open.bigmodel.cn + international api.z.ai)
        # each with a general-billing and a Coding-Plan subpath. They share the
        # same model lineup & identical capability profile, so a single preset
        # with endpoint_variants is the right shape — one source of truth for
        # vision/model_capabilities, four URLs recognised by find_by_base_url.
        # Without this, users pointing at api.z.ai or the /coding/ path fell
        # through to the conservative "assume vision=true" default and got
        # hallucinated image descriptions on text-only GLM models (C-5563).
        "endpoint_variants" => [
          { "label" => "Mainland · Pay-as-you-go",      "label_key" => "settings.models.baseurl.variant.mainland_cn_payg",    "base_url" => "https://open.bigmodel.cn/api/paas/v4",        "region" => "cn"   }.freeze,
          { "label" => "Mainland · Coding Plan",        "label_key" => "settings.models.baseurl.variant.mainland_cn_coding",  "base_url" => "https://open.bigmodel.cn/api/coding/paas/v4", "region" => "cn"   }.freeze,
          { "label" => "International · Pay-as-you-go", "label_key" => "settings.models.baseurl.variant.international_payg",  "base_url" => "https://api.z.ai/api/paas/v4",                "region" => "intl" }.freeze,
          { "label" => "International · Coding Plan",   "label_key" => "settings.models.baseurl.variant.international_coding","base_url" => "https://api.z.ai/api/coding/paas/v4",         "region" => "intl" }.freeze
        ].freeze,
        # GLM models are text-only except glm-5v-turbo which is vision-capable ("v" = visual).
        "capabilities" => { "vision" => false }.freeze,
        "model_capabilities" => {
          "glm-5v-turbo" => { "vision" => true }.freeze
        }.freeze,
        "website_url" => "https://open.bigmodel.cn/usercenter/apikeys"
      }.freeze,

      "openai" => {
        "name" => "OpenAI (GPT)",
        "base_url" => "https://api.openai.com/v1",
        "api" => "openai-completions",
        "default_model" => "gpt-5.5",
        "models" => [
          "gpt-5.5",
          "gpt-5.4",
          "gpt-5.4-mini",
          "gpt-5.4-nano",
          "o4-mini",
          "o3"
        ],
        # GPT-5.x and o-series models are multimodal (text + image input).
        "capabilities" => { "vision" => true }.freeze,
        # Per-primary lite pairing: subagents use mini/nano for cheap/fast work.
        # o4-mini and o3 are reasoning models without a lite-tier sibling here.
        "lite_models" => {
          "gpt-5.5" => "gpt-5.4-mini",
          "gpt-5.4" => "gpt-5.4-mini"
        },
        "website_url" => "https://platform.openai.com/api-keys"
      }.freeze,

      "qwen" => {
        "name" => "Qwen (Alibaba)",
        "base_url" => "https://dashscope.aliyuncs.com/compatible-mode/v1",
        "api" => "openai-completions",
        "default_model" => "qwen3.6-plus",
        "models" => [
          "qwen3.6-plus",
          "qwen3.6-max",
          "qwen3.6-27b",
          "qwen3.6-flash",
          "qwen-plus-latest",
          "qwen-vl-plus",
          "qwen-vl-max"
        ],
        "endpoint_variants" => [
          { "label" => "Mainland China",  "label_key" => "settings.models.baseurl.variant.mainland_cn",   "base_url" => "https://dashscope.aliyuncs.com/compatible-mode/v1",     "region" => "cn"   }.freeze,
          { "label" => "Singapore",       "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "region" => "intl" }.freeze,
          { "label" => "US (Virginia)",   "label_key" => "settings.models.baseurl.variant.us",            "base_url" => "https://dashscope-us.aliyuncs.com/compatible-mode/v1",   "region" => "us"   }.freeze
        ].freeze,
        "capabilities" => { "vision" => false }.freeze,
        "model_capabilities" => {
          "qwen3.6-27b"  => { "vision" => true }.freeze,
          "qwen-vl-plus" => { "vision" => true }.freeze,
          "qwen-vl-max"  => { "vision" => true }.freeze
        }.freeze,
        "lite_models" => {
          "qwen3.6-plus"     => "qwen3.6-flash",
          "qwen3.6-max"      => "qwen3.6-flash",
          "qwen3.6-27b"      => "qwen3.6-flash",
          "qwen-plus-latest" => "qwen3.6-flash"
        },
        "website_url" => "https://bailian.console.aliyun.com/?apiKey=1"
      }.freeze

    }.freeze

    class << self
      # Check if a provider preset exists
      # @param provider_id [String] The provider identifier (e.g., "anthropic", "openrouter")
      # @return [Boolean] True if the preset exists
      def exists?(provider_id)
        PRESETS.key?(provider_id)
      end

      # Get a provider preset by ID
      # @param provider_id [String] The provider identifier
      # @return [Hash, nil] The preset configuration or nil if not found
      def get(provider_id)
        PRESETS[provider_id]
      end

      # Get the default model for a provider
      # @param provider_id [String] The provider identifier
      # @return [String, nil] The default model name or nil if provider not found
      def default_model(provider_id)
        preset = PRESETS[provider_id]
        preset&.dig("default_model")
      end

      # Get the base URL for a provider
      # @param provider_id [String] The provider identifier
      # @return [String, nil] The base URL or nil if provider not found
      def base_url(provider_id)
        preset = PRESETS[provider_id]
        preset&.dig("base_url")
      end

      # Get the API type for a provider
      # @param provider_id [String] The provider identifier
      # @return [String, nil] The API type or nil if provider not found
      def api_type(provider_id)
        preset = PRESETS[provider_id]
        preset&.dig("api")
      end

      # Resolve the API type for a specific provider+model pair.
      #
      # Resolution order:
      #   1. PRESETS[provider_id]["model_api_overrides"] — first key (String or
      #      Regexp) that matches the model name wins.
      #   2. PRESETS[provider_id]["api"] — the provider-wide default.
      #   3. nil — unknown provider.
      #
      # Use this instead of api_type when you need the precise transport for a
      # given model (e.g. routing OpenRouter's Claude requests to the native
      # /v1/messages endpoint to preserve prompt-cache fidelity).
      #
      # @param provider_id [String] The provider identifier
      # @param model_name [String, nil] The specific model name
      # @return [String, nil] The API type (e.g. "anthropic-messages")
      def api_type_for_model(provider_id, model_name)
        preset = PRESETS[provider_id]
        return nil unless preset

        overrides = preset["model_api_overrides"]
        if overrides.is_a?(Hash) && model_name
          name = model_name.to_s
          matched = overrides.find do |pattern, _api|
            case pattern
            when Regexp then pattern.match?(name)
            when String then pattern == name
            else false
            end
          end
          return matched[1] if matched
        end

        preset["api"]
      end

      # Returns true when the provider+model should be talked to using the
      # native Anthropic /v1/messages format. This is the single source of
      # truth for deciding anthropic_format at Client construction time.
      # @param provider_id [String] The provider identifier
      # @param model_name [String, nil] The specific model name
      # @return [Boolean]
      def anthropic_format_for_model?(provider_id, model_name)
        api_type_for_model(provider_id, model_name) == "anthropic-messages"
      end

      # List all available provider IDs
      # @return [Array<String>] List of provider identifiers
      def provider_ids
        PRESETS.keys
      end

      # List all available providers with their names
      # @return [Array<Array(String, String)>] Array of [id, name] pairs
      def list
        PRESETS.map { |id, config| [id, config["name"]] }
      end

      # Get available models for a provider
      # @param provider_id [String] The provider identifier
      # @return [Array<String>] List of model names (empty if dynamic)
      def models(provider_id)
        preset = PRESETS[provider_id]
        preset&.dig("models") || []
      end

      # Get the lite model for a provider.
      # @param provider_id [String] The provider identifier
      # @param primary_model [String, nil] The currently-selected primary model name.
      #   When given, look it up in the provider's `lite_models` table first
      #   (so one provider can host multiple model families, each with its own
      #   lite sidekick — e.g. Claude Opus/Sonnet → Haiku, DeepSeek Pro → Flash).
      #   Falls back to the global `lite_model` field for old-style presets
      #   (e.g. deepseekv4) that declare a single provider-wide lite.
      # @return [String, nil] The lite model name, or nil when the primary is
      #   already lite-class (no entry) and no global `lite_model` is defined.
      def lite_model(provider_id, primary_model = nil)
        preset = PRESETS[provider_id]
        return nil unless preset

        if primary_model && preset["lite_models"].is_a?(Hash)
          mapped = preset["lite_models"][primary_model]
          return mapped if mapped
          # When a `lite_models` table is defined but the current primary
          # isn't listed, it means the primary is already a lite-class model
          # (e.g. haiku / v4-flash) — do NOT fall back to the legacy single
          # field, because that would incorrectly inject a lite for a model
          # that doesn't need one.
          return nil if preset["lite_models"].any?
        end

        preset["lite_model"]
      end

      # Get the fallback model for a given model within a provider.
      # Returns nil if no fallback is defined for that model.
      # @param provider_id [String] The provider identifier
      # @param model [String] The primary model name
      # @return [String, nil] The fallback model name or nil
      def fallback_model(provider_id, model)
        preset = PRESETS[provider_id]
        preset&.dig("fallback_models", model)
      end

      # Find provider ID by base URL.
      # Matches if the given URL starts with the provider's base_url (after normalisation),
      # so both exact matches and sub-path variants (e.g. "/v1") are recognised.
      #
      # Also scans `endpoint_variants` (when present) so providers that operate
      # multiple regional / billing-plan endpoints under the same identity
      # (e.g. GLM on open.bigmodel.cn + api.z.ai, MiniMax on .com + .io) are
      # all recognised as that single provider — one capability definition,
      # N entry URLs. Without this, users configured with a non-default
      # variant fall back to the "unknown provider" path and miss capability
      # enforcement (see C-5563).
      # @param base_url [String] The base URL to look up
      # @return [String, nil] The provider ID or nil if not found
      def find_by_base_url(base_url)
        return nil if base_url.nil? || base_url.empty?
        normalized = base_url.to_s.chomp("/")
        PRESETS.find do |_id, preset|
          # Collect every URL this preset claims: the canonical base_url plus
          # any declared endpoint_variants. Dedup so the canonical one showing
          # up in both lists doesn't change behaviour.
          candidates = [preset["base_url"]]
          variants = preset["endpoint_variants"]
          if variants.is_a?(Array)
            variants.each { |v| candidates << v["base_url"] if v.is_a?(Hash) }
          end
          candidates.compact.uniq.any? do |candidate|
            preset_base = candidate.to_s.chomp("/")
            next false if preset_base.empty?
            normalized == preset_base || normalized.start_with?("#{preset_base}/")
          end
        end&.first
      end

      # Resolve the provider id for a model entry, trying base_url first and
      # then falling back to an api_key hint for the openclacky family.
      #
      # Why the api_key fallback exists:
      #   For local-debug / self-hosted proxy setups, users sometimes point
      #   an "abs-claude-*" or "dsk-deepseek-*" model at http://localhost:XXXX
      #   while still using a real `clacky-...` api key. Pure base_url matching
      #   would report "unknown provider" and downstream logic (lite pairing,
      #   fallback_models, capability detection) silently degrades. Recognising
      #   the `clacky-` key prefix keeps those flows working without forcing
      #   the user to edit base_url.
      #
      # Not generalised to other providers: the `sk-...` prefix is used by
      # OpenAI, DeepSeek, Moonshot, and many others, so it can't uniquely
      # identify a provider. We only special-case `clacky-` because it's
      # unique to us and the debug-proxy scenario is specifically ours.
      #
      # @param base_url [String, nil] the configured base_url
      # @param api_key  [String, nil] the configured api_key
      # @return [String, nil] provider id or nil if unresolvable
      def resolve_provider(base_url: nil, api_key: nil)
        id = find_by_base_url(base_url)
        return id if id

        # Local-debug fallback: clacky-* api keys belong to the openclacky
        # family. Both "openclacky" and "clackyai-sea" share the same key
        # namespace and an identical model lineup/lite mapping, so picking
        # "openclacky" is equivalent for downstream lookups.
        if api_key.is_a?(String) && api_key.start_with?("clacky-")
          return "openclacky"
        end

        nil
      end

      # Resolve the capabilities hash for a given provider+model.
      #
      # Resolution order (most specific wins):
      #   1. PRESETS[provider_id]["model_capabilities"][model_name] — per-model
      #      override, used when a single provider hosts a mix of capabilities
      #      (e.g. openclacky serves both Claude [vision] and DeepSeek [text]).
      #   2. PRESETS[provider_id]["capabilities"] — provider-wide defaults,
      #      used when the whole lineup shares the same capabilities.
      #   3. {} — no declaration; callers get the conservative default (true)
      #      via `supports?`.
      #
      # Returns a plain Hash (always safe to inspect; never nil).
      # @param provider_id [String] The provider identifier
      # @param model_name [String, nil] Optional specific model for override lookup
      # @return [Hash] capabilities mapping (e.g. { "vision" => true })
      def capabilities(provider_id, model_name: nil)
        preset = PRESETS[provider_id]
        return {} unless preset

        provider_caps = preset["capabilities"] || {}
        return provider_caps.dup unless model_name

        model_caps = preset.dig("model_capabilities", model_name) || {}
        provider_caps.merge(model_caps)
      end

      # Check if a provider+model supports a capability.
      # Unknown provider / missing capability declaration → returns true
      # (conservative default: assume supported unless we explicitly say otherwise).
      # This keeps custom base_urls working and avoids over-aggressive downgrades.
      #
      # @param provider_id [String] The provider identifier
      # @param capability [String, Symbol] The capability name (e.g. :vision, "vision")
      # @param model_name [String, nil] Optional specific model name
      # @return [Boolean] true unless the preset explicitly says false
      def supports?(provider_id, capability, model_name: nil)
        preset = PRESETS[provider_id]
        return true unless preset

        key = capability.to_s
        caps = capabilities(provider_id, model_name: model_name)
        # When the capability is not declared at either level, default to true.
        return true unless caps.key?(key)
        caps[key] != false
      end
    end
  end
end