Module: Clacky::Providers

Defined in:
lib/clacky/providers.rb

Overview

Built-in model provider presets Provides default configurations for supported AI model providers

Constant Summary collapse

PRESETS =

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>" => { "" => 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).
{
  "openclacky" => {
    "name" => "OpenClacky",
    "base_url" => "https://api.openclacky.com",
    "api" => "bedrock",
    "default_model" => "abs-claude-sonnet-5",
    "models" => [
      "abs-claude-fable-5",
      "abs-claude-opus-5",
      "abs-claude-opus-4-8",
      "abs-claude-opus-4-7",
      "abs-claude-opus-4-6",
      "abs-claude-sonnet-5",
      "abs-claude-sonnet-4-6",
      "abs-claude-sonnet-4-5",
      "abs-claude-haiku-4-5",
      "dsk-deepseek-v4-pro",
      "dsk-deepseek-v4-flash",
      "dsk-deepseek-v4-flash-vision-exp",
      "or-gemini-3-1-pro",
      "or-gemini-3-7-flash",
      "or-gemini-3-6-flash",
      "or-gemini-3-5-flash"
    ],
    # Image generation models served by the openclacky platform
    # gateway. The gateway exposes a standard OpenAI-compatible
    # /v1/images/generations endpoint, so the same OpenAICompat
    # provider class handles them. `or-` prefix is a routing alias
    # only — the platform may dispatch to OpenRouter or Vertex AI
    # (Gemini Nano Banana family) depending on the model.
    "image_models" => [
      "or-gemini-3-pro-image",
      "or-gemini-3-1-flash-image",
      "or-gpt-image-2"
    ],
    "image_model_aliases" => {
      "or-gemini-3-pro-image"      => "Nano Banana Pro",
      "or-gemini-3-1-flash-image"  => "Nano Banana 2",
      "or-gpt-image-2"             => "GPT Image 2"
    },
    "default_image_model" => "or-gemini-3-1-flash-image",
    # Video generation models served by the openclacky gateway, which
    # routes them to Vertex AI Veo (async predictLongRunning under the
    # hood; the gateway hides the polling and returns the MP4 inline).
    "video_models" => [
      "or-veo-3-1",
      "or-veo-3-1-fast",
      "or-veo-3",
      "or-veo-3-fast"
    ],
    "video_model_aliases" => {
      "or-veo-3-1"      => "Veo 3.1",
      "or-veo-3-1-fast" => "Veo 3.1 Fast",
      "or-veo-3"        => "Veo 3",
      "or-veo-3-fast"   => "Veo 3 Fast"
    },
    "default_video_model" => "or-veo-3-1",
    # Text-to-speech models served by the openclacky gateway, which
    # routes them to Vertex AI Gemini 2.5 (responseModalities=["AUDIO"]).
    # The gateway returns WAV inline as base64.
    "audio_models" => [
      "or-tts-gemini-2-5-flash",
      "or-tts-gemini-2-5-pro"
    ],
    "audio_model_aliases" => {
      "or-tts-gemini-2-5-flash" => "Gemini 2.5 Flash TTS",
      "or-tts-gemini-2-5-pro"   => "Gemini 2.5 Pro TTS"
    },
    "default_audio_model" => "or-tts-gemini-2-5-flash",
    # Speech-to-text models served by the openclacky gateway, which
    # routes them to Vertex AI Gemini (generateContent with inline
    # audio parts). The gateway returns transcription text.
    "stt_models" => [
      "or-stt-gemini-3-7-flash",
      "or-stt-gemini-3-6-flash",
      "or-stt-gemini-3-5-flash",
      "or-stt-gemini-1-5-pro"
    ],
    "stt_model_aliases" => {
      "or-stt-gemini-3-7-flash" => "Gemini 3.7 Flash STT",
      "or-stt-gemini-3-6-flash" => "Gemini 3.6 Flash STT",
      "or-stt-gemini-3-5-flash" => "Gemini 3.5 Flash STT",
      "or-stt-gemini-1-5-pro"   => "Gemini 1.5 Pro STT"
    },
    "default_stt_model" => "or-stt-gemini-3-7-flash",
    # Video understanding models served by the openclacky gateway, which
    # routes video frames to Gemini (generateContent with inline image
    # parts). The gateway returns analysis text.
    "video_understanding_models" => [
      "or-gemini-3-7-flash",
      "or-gemini-3-6-flash",
      "or-gemini-3-5-flash",
      "or-gemini-3-1-pro"
    ],
    "video_understanding_model_aliases" => {
      "or-gemini-3-7-flash" => "Gemini 3.7 Flash",
      "or-gemini-3-6-flash" => "Gemini 3.6 Flash",
      "or-gemini-3-5-flash" => "Gemini 3.5 Flash",
      "or-gemini-3-1-pro"   => "Gemini 3.1 Pro"
    },
    "default_video_understanding_model" => "or-gemini-3-7-flash",
    # Default OCR sidecar — used when the primary model is text-only.
    # Candidates are derived from the provider's vision-capable models;
    # this just picks the cheap+fast default to surface in "auto" mode.
    "default_ocr_model" => "dsk-deepseek-v4-flash-vision-exp",
    # 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, except the flash-vision-exp variant which accepts
    # image input; images uploaded for text-only models 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,
      "dsk-deepseek-v4-flash-vision-exp" => { "vision" => true }.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 / 3-5-flash) ARE the lite tier
    # themselves, so they're intentionally not listed here as keys —
    # no injection happens when the default model is already lite-class.
    "lite_models" => {
      "abs-claude-fable-5"    => "abs-claude-haiku-4-5",
      "abs-claude-opus-5"     => "abs-claude-haiku-4-5",
      "abs-claude-opus-4-8"   => "abs-claude-haiku-4-5",
      "abs-claude-opus-4-7"   => "abs-claude-haiku-4-5",
      "abs-claude-opus-4-6"   => "abs-claude-haiku-4-5",
      "abs-claude-sonnet-5"   => "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",
      "or-gemini-3-1-pro"     => "or-gemini-3-6-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-fable-5"    => "abs-claude-opus-5",
      "abs-claude-opus-5"     => "abs-claude-opus-4-8",
      "abs-claude-sonnet-5"   => "abs-claude-sonnet-4-6",
      "abs-claude-sonnet-4-6" => "abs-claude-sonnet-4-5"
    },
    # Secondary gateway URL used when the primary base_url is unreachable
    # after max retries. The model name stays the same — only the endpoint
    # changes. Nil / absent means no URL fallback for this provider.
    "fallback_base_url" => "https://llm.1024code.com",
    # Two selectable endpoints exposed in the Base URL dropdown:
    #   Primary   — global CDN, lowest latency for most regions.
    #   Secondary — China-optimised relay (1024code.com), useful when
    #               the primary is unreachable from mainland China.
    "endpoint_variants" => [
      { "label" => "Primary (Global)",  "label_key" => "settings.models.baseurl.variant.openclacky_primary",   "base_url" => "https://api.openclacky.com" }.freeze,
      { "label" => "Secondary (China)", "label_key" => "settings.models.baseurl.variant.openclacky_secondary", "base_url" => "https://llm.1024code.com"   }.freeze
    ].freeze,
    "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-8",
      "anthropic/claude-opus-4-7",
      "anthropic/claude-opus-4-6",
      "anthropic/claude-haiku-4-5",
      "openai/gpt-5.6-sol",
      "openai/gpt-5.6-terra",
      "openai/gpt-5.6-luna",
      "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-8"   => "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.6-sol"         => "openai/gpt-5.6-luna",
      "openai/gpt-5.6-terra"       => "openai/gpt-5.6-luna",
      "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,
    # Image generation via OpenRouter is currently routed through the
    # openclacky platform gateway (see "openclacky" provider above) which
    # handles the OpenRouter chat-completions + modalities translation.
    # Direct OpenRouter image config is not exposed here — leave empty
    # until we ship a dedicated client-side adapter for that protocol.
    "image_models" => [],
    "default_image_model" => nil,
    "default_ocr_model" => "google/gemini-2.5-flash",
    "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-pro",
      "deepseek-v4-flash",
      "deepseek-v4-flash-vision-exp",
    ],
    # DeepSeek V4 API is text-only across all models, except the
    # flash-vision-exp variant which accepts image input.
    "capabilities" => { "vision" => false }.freeze,
    "model_capabilities" => {
      "deepseek-v4-flash-vision-exp" => { "vision" => true }.freeze
    }.freeze,
    "website_url" => "https://platform.deepseek.com/api_keys"
  }.freeze,

  "glm" => {
    "name" => "GLM (Z.ai / Zhipu)",
    "base_url" => "https://open.bigmodel.cn/api/paas/v4",
    "api" => "openai-completions",
    "default_model" => "glm-5.3",
    "models" => ["glm-5.3", "glm-5.2", "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,
    "default_ocr_model" => "glm-5v-turbo",
    "website_url" => "https://open.bigmodel.cn/console/overview"
  }.freeze,

  "kimi" => {
    "name" => "Kimi (Moonshot)",
    "base_url" => "https://api.moonshot.cn/v1",
    "api" => "openai-completions",
    "default_model" => "kimi-k3",
    "models" => ["kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", "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,
    # k3 / k2.7-code / k2.5 / k2.6 are multimodal; legacy k2 text-only models need model_capabilities override if added.
    "capabilities" => { "vision" => true }.freeze,
    "default_ocr_model" => "kimi-k3",
    "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,

  "minimax" => {
    "name" => "Minimax",
    "base_url" => "https://api.minimaxi.com/v1",
    "api" => "openai-completions",
    "default_model" => "MiniMax-M3",
    "models" => ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"],
    # 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.5/M2.7 are text-only on this endpoint. M3 (released 2026-06-01)
    # is natively multimodal — it accepts image input via OpenAI-style
    # image_url content parts — so it overrides the provider-level
    # vision=false below. M3 exposes a 1,000,000-token context window
    # (M2.7: 204,800; M2.5: 40,960).
    "capabilities" => { "vision" => false }.freeze,
    "model_capabilities" => {
      "MiniMax-M3" => { "vision" => true }.freeze
    }.freeze,
    "default_ocr_model" => "MiniMax-M3",
    "website_url" => "https://platform.minimax.io/"
  }.freeze,

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

  "openai" => {
    "name" => "OpenAI (GPT)",
    "base_url" => "https://api.openai.com/v1",
    "api" => "openai-completions",
    "default_model" => "gpt-5.5",
    "models" => [
      "gpt-5.6-sol",
      "gpt-5.6-terra",
      "gpt-5.6-luna",
      "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.
    # GPT-5.6 *-pro variants are priced identically to their base tier and
    # can be typed manually; only the three base tiers are listed.
    "lite_models" => {
      "gpt-5.6-sol"   => "gpt-5.6-luna",
      "gpt-5.6-terra" => "gpt-5.6-luna",
      "gpt-5.5" => "gpt-5.4-mini",
      "gpt-5.4" => "gpt-5.4-mini"
    },
    # OpenAI's image generation model — same /v1/images/generations
    # endpoint, so the OpenAICompat image provider handles it.
    "image_models" => [
      "gpt-image-2"
    ],
    "default_image_model" => "gpt-image-2",
    "default_ocr_model" => "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.7-max",
    "models" => [
      "qwen3.7-max",
      "qwen3.6-plus",
      "qwen3.6-max",
      "qwen3.6-27b",
      "qwen3.6-flash",
      "qwen-plus-latest",
    ],
    "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" => true }.freeze,
    "model_capabilities" => {
      "qwen3.7-max" => { "vision" => false }.freeze
    }.freeze,
    "default_ocr_model" => "qwen3.6-flash",
    "lite_models" => {
      "qwen3.7-max"      => "qwen3.6-flash",
      "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,

  "mimo" => {
    "name" => "MiMo (Xiaomi)",
    "base_url" => "https://api.xiaomimimo.com/v1",
    "api" => "openai-completions",
    "default_model" => "mimo-v2.5-pro",
    # The MiMo-V2 family (mimo-v2-pro / mimo-v2-omni) was retired on
    # 2026-06-30 and the model ids are no longer accepted by the API. The
    # current lineup is the V2.5 series:
    #   - mimo-v2.5-pro: text reasoning flagship, no vision
    #   - mimo-v2.5: native omni-modal (image/video/audio/text), vision-capable
    # Source: https://platform.xiaomimimo.com/docs/zh-CN/model
    "models" => ["mimo-v2.5-pro", "mimo-v2.5"],
    "capabilities" => { "vision" => false }.freeze,
    "model_capabilities" => {
      "mimo-v2.5" => { "vision" => true }.freeze
    }.freeze,
    "default_ocr_model" => "mimo-v2.5",
    # Xiaomi serves the same V2.5 lineup from two billing endpoints: the
    # pay-as-you-go API (api.xiaomimimo.com) and the Token Plan subscription
    # endpoint (token-plan-cn.xiaomimimo.com). Both accept identical model
    # ids and share one capability profile, so a single preset with
    # endpoint_variants recognises both. Without this, users on the Token
    # Plan endpoint fell through to "unknown provider" and the conservative
    # vision=true default applied to every model — sending images to the
    # text-only mimo-v2.5-pro, which rejects image input.
    "endpoint_variants" => [
      { "label" => "Pay-as-you-go", "label_key" => "settings.models.baseurl.variant.mimo_payg",        "base_url" => "https://api.xiaomimimo.com/v1",          "region" => "cn" }.freeze,
      { "label" => "Token Plan",    "label_key" => "settings.models.baseurl.variant.mimo_token_plan", "base_url" => "https://token-plan-cn.xiaomimimo.com/v1", "region" => "cn" }.freeze
    ].freeze,
    "website_url" => "https://platform.xiaomimimo.com/"
  }.freeze,

  # Volcengine Ark (Doubao) — ByteDance's model platform, OpenAI-compatible.
  # Exposes three functionally-equivalent endpoints (Pay-as-you-go / Coding
  # Plan / Agent Plan) that share the same model lineup and capability
  # profile, so a single preset with endpoint_variants is the right shape.
  # Without a preset match, base_urls like /api/coding/v3 fell through to the
  # conservative "assume vision=true" default and inlined images into
  # text-only models (e.g. glm-5.2), which Ark rejects. The vision matrix
  # below is the source of truth so text-only models route through the OCR
  # sidecar instead.
  "volcengine-ark" => {
    "name" => "Volcengine Ark (Doubao)",
    "name_key" => "provider.name.volcengine_ark",
    "base_url" => "https://ark.cn-beijing.volces.com/api/v3",
    "api" => "openai-completions",
    "default_model" => "doubao-seed-2.1-pro",
    "models" => [
      "doubao-seed-evolving",
      "doubao-seed-2.1-pro",
      "doubao-seed-2.1-turbo",
      "doubao-seed-2.0-lite",
      "minimax-m3",
      "minimax-m2.7",
      "kimi-k2.7-code",
      "kimi-k2.6",
      # GLM-5.2 is delisted from Ark on 2026-08-31, so new setups only
      # offer GLM-5.3. It is available on the Coding/Agent Plan endpoints;
      # the pay-as-you-go endpoint does not carry it yet (no versioned id),
      # so no payg alias is registered for it.
      "glm-5.3",
      "deepseek-v4-pro",
      "deepseek-v4-flash"
    ],
    "endpoint_variants" => [
      { "label" => "Pay-as-you-go", "label_key" => "settings.models.baseurl.variant.ark_payg",   "base_url" => "https://ark.cn-beijing.volces.com/api/v3", "alias_group" => "payg" }.freeze,
      { "label" => "Coding Plan",   "label_key" => "settings.models.baseurl.variant.ark_coding", "base_url" => "https://ark.cn-beijing.volces.com/api/coding/v3" }.freeze,
      { "label" => "Agent Plan",    "label_key" => "settings.models.baseurl.variant.ark_agent",  "base_url" => "https://ark.cn-beijing.volces.com/api/plan/v3" }.freeze
    ].freeze,
    # Pay-as-you-go billing endpoint requires versioned model ids, while the
    # Coding/Agent Plan endpoints accept the short display names. Users pick
    # the intuitive short name; on the payg endpoint we transparently swap
    # it for the versioned id the billing API expects. Only models whose ids
    # actually differ are listed here.
    "api_model_aliases" => {
      "payg" => {
        # glm-5.2 is delisted 2026-08-31 but existing configs must keep
        # working until then, so the alias stays.
        "glm-5.2"            => "glm-5-2-260617",
        "deepseek-v4-pro"    => "deepseek-v4-pro-260425",
        "deepseek-v4-flash"  => "deepseek-v4-flash-260425",
        "doubao-seed-2.1-pro"   => "doubao-seed-2-1-pro-260628",
        "doubao-seed-2.1-turbo" => "doubao-seed-2-1-turbo-260628",
        "doubao-seed-2.0-lite"  => "doubao-seed-2-0-lite-260428"
      }.freeze
    }.freeze,
    # Most Doubao/multimodal models accept image input; GLM-5.2/5.3 and
    # DeepSeek-V4 on Ark are text-only.
    "capabilities" => { "vision" => true }.freeze,
    "model_capabilities" => {
      "glm-5.3"           => { "vision" => false }.freeze,
      "glm-5.2"           => { "vision" => false }.freeze,
      "deepseek-v4-pro"   => { "vision" => false }.freeze,
      "deepseek-v4-flash" => { "vision" => false }.freeze,
      "minimax-m2.7"      => { "vision" => false }.freeze
    }.freeze,
    "default_ocr_model" => "doubao-seed-2.0-lite",
    "website_url" => "https://console.volcengine.com/ark/region:cn-beijing/overview"
  }.freeze,

  "ollama" => {
    "name" => "Ollama (Cloud)",
    # Ollama Cloud API — OpenAI-compatible endpoint serving both local and
    # cloud models. Cloud models are available immediately without pulling.
    # Authentication: API key via Authorization: Bearer <key> header.
    # Create API keys at: https://ollama.com/settings/keys
    "base_url" => "https://ollama.com",
    "api" => "openai-completions",
    "default_model" => "deepseek-v4-flash",
    # Curated list of cloud-enabled models from the Ollama library.
    # Users can type any Ollama model name manually; this list seeds the picker.
    "models" => [
      "glm-5.2",
      "kimi-k3",
      "gemma4",
      "qwen3.5",
      "glm-5.1",
      "minimax-m2.7",
      "nemotron-3-super",
      "minimax-m3",
      "kimi-k2.7-code",
      "kimi-k2.6",
      "deepseek-v4-flash",
      "deepseek-v4-pro",
      "nemotron-3-ultra",
      "gemini-3-flash-preview",
      "nemotron-3-nano",
      "mistral-large-3",
      "gpt-oss"
    ],
    # Provider-level default: many Ollama cloud models are vision-capable.
    "capabilities" => { "vision" => true }.freeze,
    # Model-level overrides: text-only models that don't accept image input.
    "model_capabilities" => {
      "glm-5.2"             => { "vision" => false }.freeze,
      "glm-5.1"             => { "vision" => false }.freeze,
      "minimax-m2.7"        => { "vision" => false }.freeze,
      "deepseek-v4-pro"     => { "vision" => false }.freeze,
      "deepseek-v4-flash"   => { "vision" => false }.freeze,
      "nemotron-3-super"    => { "vision" => false }.freeze,
      "nemotron-3-ultra"    => { "vision" => false }.freeze,
      "nemotron-3-nano"     => { "vision" => false }.freeze,
      "gpt-oss"             => { "vision" => false }.freeze
    }.freeze,
    # Per-primary lite pairing: subagents use smaller models for cheap/fast work.
    "lite_models" => {
      "glm-5.2"                => "glm-5.1",
      "kimi-k3"                => "kimi-k2.6",
      "gemma4"                 => "nemotron-3-nano",
      "qwen3.5"                => "gemini-3-flash-preview",
      "minimax-m3"             => "minimax-m2.7",
      "kimi-k2.7-code"         => "kimi-k2.6",
      "deepseek-v4-pro"        => "deepseek-v4-flash",
      "mistral-large-3"        => "nemotron-3-nano",
      "gpt-oss"                => "nemotron-3-nano",
      "gemini-3-flash-preview"  => "nemotron-3-nano"
    },
    "default_ocr_model" => "kimi-k2.7-code",
    "website_url" => "https://ollama.com/settings/keys"
  }.freeze,

  "orcarouter" => {
    "name" => "OrcaRouter",
    "base_url" => "https://api.orcarouter.ai/v1",
    "api" => "openai-completions",
    "default_model" => "openai/gpt-5.5",
    # Curated default lineup. OrcaRouter exposes 190+ models from OpenAI,
    # Anthropic, Google, DeepSeek, Qwen, MiniMax, Zhipu and others behind a
    # single OpenAI-compatible endpoint. 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 OrcaRouter model id
    # manually (e.g. "orcarouter/auto" for request-level auto-routing).
    "models" => [
      "anthropic/claude-sonnet-5",
      "anthropic/claude-opus-4.8",
      "anthropic/claude-haiku-4.5",
      "openai/gpt-5.5",
      "openai/gpt-5.4",
      "openai/gpt-5.4-mini",
      "google/gemini-3.5-flash",
      "deepseek/deepseek-v4-flash",
      "z-ai/glm-5.2",
      "orcarouter/auto"
    ],
    # Per-primary lite pairing — Claude family pairs with Haiku, GPT
    # family pairs with the mini variant. Mirrors the openrouter preset
    # so subagents on OrcaRouter get a sensible cheap/fast sidekick.
    "lite_models" => {
      "anthropic/claude-sonnet-5" => "anthropic/claude-haiku-4.5",
      "anthropic/claude-opus-4.8" => "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. OrcaRouter proxies Claude through a
    # native Anthropic /v1/messages endpoint (https://api.orcarouter.ai/v1/messages)
    # in addition to its OpenAI-compatible /chat/completions endpoint.
    # Routing "anthropic/*" via the native endpoint preserves cache_control
    # fidelity, matching what Claude Code CLI does internally (same rationale
    # as the openrouter preset). Non-Claude models keep the OpenAI shim.
    "model_api_overrides" => {
      /\Aanthropic\// => "anthropic-messages"
    }.freeze,
    # Most models on OrcaRouter are vision-capable; DeepSeek / GLM-5.2 and
    # the "orcarouter/auto" router are text-only.
    "capabilities" => { "vision" => true }.freeze,
    "model_capabilities" => {
      "deepseek/deepseek-v4-flash" => { "vision" => false }.freeze,
      "z-ai/glm-5.2"               => { "vision" => false }.freeze,
      "orcarouter/auto"            => { "vision" => false }.freeze
    }.freeze,
    "default_ocr_model" => "google/gemini-3.5-flash",
    "website_url" => "https://www.orcarouter.ai"
  }.freeze

}.freeze
MEDIA_KINDS =
%w[image video audio stt video_understanding].freeze
MODEL_MAX_OUTPUT =

Per-model maximum output token limits.

The Agent global default (@max_tokens = 16_384) is tuned for the lowest common denominator. Strong reasoning models (GLM-5.2, Kimi-K3, MiMo-V2.5) support 64K–128K output but are artificially throttled to 16K, causing truncation on long reasoning chains and code generation.

Entries are matched top-to-bottom; the first match wins. Models not listed here fall back to the global default.

[
  { pattern: /glm/i,           limit: 65_536 }, # GLM-5.2: 128K output ceiling; 64K ample for reasoning+answer
  { pattern: /kimi-k3/i,       limit: 65_536 }, # Kimi K3: max_completion_tokens=131072 (max 1M); 64K ample
  { pattern: /mimo-v2\.5-pro/i, limit: 65_536 }, # MiMo-V2.5-Pro: max_completion_tokens=131072; 64K ample
  { pattern: /mimo/i,           limit: 32_768 }, # MiMo-V2.5: max_completion_tokens=32768; full default ceiling
  { pattern: /gemini/i,        limit: 65_536 }  # Gemini 3.x: 64K max output ceiling
].freeze

Class Method Summary collapse

Class Method Details

.alias_group_for_base_url(preset, base_url) ⇒ Object

Find which alias group the given base_url belongs to by matching it against the preset's endpoint_variants. Uses exact base_url equality (after normalising trailing slash) so "/api/v3" never leaks into the "/api/coding/v3" match.



1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
# File 'lib/clacky/providers.rb', line 1119

def alias_group_for_base_url(preset, base_url)
  return nil if base_url.nil?
  variants = preset["endpoint_variants"]
  return nil unless variants.is_a?(Array)

  normalized = base_url.to_s.chomp("/")
  variant = variants.find do |v|
    v.is_a?(Hash) && v["base_url"].to_s.chomp("/") == normalized
  end
  variant && variant["alias_group"]
end

.anthropic_format_for_model?(provider_id, model_name) ⇒ Boolean

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.

Parameters:

  • provider_id (String)

    The provider identifier

  • model_name (String, nil)

    The specific model name

Returns:

  • (Boolean)


811
812
813
# File 'lib/clacky/providers.rb', line 811

def anthropic_format_for_model?(provider_id, model_name)
  api_type_for_model(provider_id, model_name) == "anthropic-messages"
end

.api_type(provider_id) ⇒ String?

Get the API type for a provider

Parameters:

  • provider_id (String)

    The provider identifier

Returns:

  • (String, nil)

    The API type or nil if provider not found



747
748
749
750
# File 'lib/clacky/providers.rb', line 747

def api_type(provider_id)
  preset = PRESETS[provider_id]
  preset&.dig("api")
end

.api_type_for_model(provider_id, model_name, user_override: nil) ⇒ String?

Resolve the API type for a specific provider+model pair.

Resolution order:

1. user_override — an explicit user-selected api_format on the model
 entry (e.g. "anthropic-messages" / "openai-completions").
2. PRESETS[provider_id]["model_api_overrides"] — first key (String or
 Regexp) that matches the model name wins.
3. PRESETS[provider_id]["api"] — the provider-wide default.
4. 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).

Parameters:

  • provider_id (String)

    The provider identifier

  • model_name (String, nil)

    The specific model name

  • user_override (String, nil) (defaults to: nil)

    Explicit user-selected API format, wins over all preset resolution when present

Returns:

  • (String, nil)

    The API type (e.g. "anthropic-messages")



783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
# File 'lib/clacky/providers.rb', line 783

def api_type_for_model(provider_id, model_name, user_override: nil)
  return user_override unless user_override.nil? || user_override.to_s.strip.empty?

  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

.audio_model_aliases(provider_id) ⇒ Object



855
856
857
858
# File 'lib/clacky/providers.rb', line 855

def audio_model_aliases(provider_id)
  preset = PRESETS[provider_id]
  preset&.dig("audio_model_aliases") || {}
end

.audio_models(provider_id) ⇒ Object

Audio generation models — same placeholder pattern as video_models.



900
901
902
903
# File 'lib/clacky/providers.rb', line 900

def audio_models(provider_id)
  preset = PRESETS[provider_id]
  preset&.dig("audio_models") || []
end

.base_url(provider_id) ⇒ String?

Get the base URL for a provider

Parameters:

  • provider_id (String)

    The provider identifier

Returns:

  • (String, nil)

    The base URL or nil if provider not found



739
740
741
742
# File 'lib/clacky/providers.rb', line 739

def base_url(provider_id)
  preset = PRESETS[provider_id]
  preset&.dig("base_url")
end

.capabilities(provider_id, model_name: nil) ⇒ Hash

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).

Parameters:

  • provider_id (String)

    The provider identifier

  • model_name (String, nil) (defaults to: nil)

    Optional specific model for override lookup

Returns:

  • (Hash)

    capabilities mapping (e.g. { "vision" => true })



1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
# File 'lib/clacky/providers.rb', line 1144

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

.default_media_model(provider_id, kind) ⇒ Object

Default media model for a kind under a provider. Falls back to the first declared model when no explicit default is set in the preset. Used by AgentConfig#derive_media_models! to pick which model to surface when the user is on "auto" mode.



949
950
951
952
953
954
955
# File 'lib/clacky/providers.rb', line 949

def default_media_model(provider_id, kind)
  preset = PRESETS[provider_id]
  return nil unless preset
  explicit = preset["default_#{kind}_model"]
  return explicit if explicit
  media_models(provider_id, kind).first
end

.default_model(provider_id) ⇒ String?

Get the default model for a provider

Parameters:

  • provider_id (String)

    The provider identifier

Returns:

  • (String, nil)

    The default model name or nil if provider not found



731
732
733
734
# File 'lib/clacky/providers.rb', line 731

def default_model(provider_id)
  preset = PRESETS[provider_id]
  preset&.dig("default_model")
end

.default_ocr_model(provider_id) ⇒ String?

Default OCR sidecar model for a provider. Falls back to the first vision-capable model if the preset doesn't pin an explicit default.

Parameters:

  • provider_id (String)

Returns:

  • (String, nil)

    nil when the provider has zero vision-capable models



922
923
924
925
926
927
928
# File 'lib/clacky/providers.rb', line 922

def default_ocr_model(provider_id)
  preset = PRESETS[provider_id]
  return nil unless preset
  explicit = preset["default_ocr_model"]
  return explicit if explicit && ocr_models(provider_id).include?(explicit)
  ocr_models(provider_id).first
end

.exists?(provider_id) ⇒ Boolean

Check if a provider preset exists

Parameters:

  • provider_id (String)

    The provider identifier (e.g., "anthropic", "openrouter")

Returns:

  • (Boolean)

    True if the preset exists



717
718
719
# File 'lib/clacky/providers.rb', line 717

def exists?(provider_id)
  PRESETS.key?(provider_id)
end

.fallback_base_url(provider_id) ⇒ String?

Get the fallback base URL for a provider (used when primary endpoint is unreachable). Returns nil if the provider has no secondary gateway configured.

Parameters:

  • provider_id (String)

    The provider identifier

Returns:

  • (String, nil)

    The fallback base URL or nil



1006
1007
1008
1009
1010
# File 'lib/clacky/providers.rb', line 1006

def fallback_base_url(provider_id)
  preset = PRESETS[provider_id]
  url = preset&.dig("fallback_base_url")
  url&.empty? ? nil : url
end

.fallback_model(provider_id, model) ⇒ String?

Get the fallback model for a given model within a provider. Returns nil if no fallback is defined for that model.

Parameters:

  • provider_id (String)

    The provider identifier

  • model (String)

    The primary model name

Returns:

  • (String, nil)

    The fallback model name or nil



997
998
999
1000
# File 'lib/clacky/providers.rb', line 997

def fallback_model(provider_id, model)
  preset = PRESETS[provider_id]
  preset&.dig("fallback_models", model)
end

.find_by_base_url(base_url) ⇒ String?

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).

Parameters:

  • base_url (String)

    The base URL to look up

Returns:

  • (String, nil)

    The provider ID or nil if not found



1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
# File 'lib/clacky/providers.rb', line 1025

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

.get(provider_id) ⇒ Hash?

Get a provider preset by ID

Parameters:

  • provider_id (String)

    The provider identifier

Returns:

  • (Hash, nil)

    The preset configuration or nil if not found



724
725
726
# File 'lib/clacky/providers.rb', line 724

def get(provider_id)
  PRESETS[provider_id]
end

.image_model_aliases(provider_id) ⇒ Object



845
846
847
848
# File 'lib/clacky/providers.rb', line 845

def image_model_aliases(provider_id)
  preset = PRESETS[provider_id]
  preset&.dig("image_model_aliases") || {}
end

.image_models(provider_id) ⇒ Array<String>

Get available image generation models for a provider. Returns an empty array when the provider doesn't declare any — callers should treat that as "image generation not supported by this provider".

Parameters:

  • provider_id (String)

    The provider identifier

Returns:

  • (Array<String>)

    List of image model names



840
841
842
843
# File 'lib/clacky/providers.rb', line 840

def image_models(provider_id)
  preset = PRESETS[provider_id]
  preset&.dig("image_models") || []
end

.listArray<Array(String, String)>

List all available providers with their names

Returns:

  • (Array<Array(String, String)>)

    Array of [id, name] pairs



823
824
825
# File 'lib/clacky/providers.rb', line 823

def list
  PRESETS.map { |id, config| [id, config["name"]] }
end

.lite_model(provider_id, primary_model = nil) ⇒ String?

Get the lite model for a provider.

Parameters:

  • provider_id (String)

    The provider identifier

  • primary_model (String, nil) (defaults to: 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.

Returns:

  • (String, nil)

    The lite model name, or nil when the primary is already lite-class (no entry) and no global lite_model is defined.



974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
# File 'lib/clacky/providers.rb', line 974

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

.max_output_for(model_name) ⇒ Integer?

Resolve the per-model maximum output token limit. Returns nil when no MODEL_MAX_OUTPUT entry matches — callers should then fall back to the global default (@max_tokens).

Parameters:

  • model_name (String)

    The model name (e.g. "glm-5.2")

Returns:

  • (Integer, nil)

    The max output limit, or nil if undeclared



758
759
760
761
762
# File 'lib/clacky/providers.rb', line 758

def max_output_for(model_name)
  return nil if model_name.nil? || model_name.to_s.empty?
  entry = MODEL_MAX_OUTPUT.find { |e| model_name.to_s.match?(e[:pattern]) }
  entry&.[](:limit)
end

.media_kindsObject

The set of media kinds Clacky knows about. Drives UI rendering and derivation loops — adding a new modality means listing it here plus adding the corresponding generator class.



960
961
962
# File 'lib/clacky/providers.rb', line 960

def media_kinds
  MEDIA_KINDS
end

.media_model_aliases(provider_id, kind) ⇒ Object



880
881
882
883
884
885
886
887
888
889
# File 'lib/clacky/providers.rb', line 880

def media_model_aliases(provider_id, kind)
  case kind.to_s
  when "image" then image_model_aliases(provider_id)
  when "video" then video_model_aliases(provider_id)
  when "audio" then audio_model_aliases(provider_id)
  when "stt"   then stt_model_aliases(provider_id)
  when "video_understanding" then video_understanding_model_aliases(provider_id)
  else {}
  end
end

.media_models(provider_id, kind) ⇒ Array<String>

Unified entry for media model lookup by kind.

Parameters:

  • provider_id (String)
  • kind (String)

    one of "image" / "video" / "audio" / "stt"

Returns:

  • (Array<String>)


934
935
936
937
938
939
940
941
942
943
# File 'lib/clacky/providers.rb', line 934

def media_models(provider_id, kind)
  case kind.to_s
  when "image" then image_models(provider_id)
  when "video" then video_models(provider_id)
  when "audio" then audio_models(provider_id)
  when "stt"   then stt_models(provider_id)
  when "video_understanding" then video_understanding_models(provider_id)
  else []
  end
end

.models(provider_id) ⇒ Array<String>

Get available models for a provider

Parameters:

  • provider_id (String)

    The provider identifier

Returns:

  • (Array<String>)

    List of model names (empty if dynamic)



830
831
832
833
# File 'lib/clacky/providers.rb', line 830

def models(provider_id)
  preset = PRESETS[provider_id]
  preset&.dig("models") || []
end

.ocr_models(provider_id) ⇒ Array<String>

OCR sidecar candidates: every chat model under this provider that's vision-capable. Derived from vision capability so we don't have to maintain a parallel list — a model that can see is by definition a candidate for "describe an image as text". Image-generation models are excluded (they take prompts and return pixels, not the other way).

Parameters:

  • provider_id (String)

Returns:

  • (Array<String>)


912
913
914
915
916
# File 'lib/clacky/providers.rb', line 912

def ocr_models(provider_id)
  preset = PRESETS[provider_id]
  return [] unless preset
  (preset["models"] || []).select { |m| supports?(provider_id, :vision, model_name: m) }
end

.preset?(id) ⇒ Boolean

True when id names a known preset. Callers use this to decide whether a model entry's provider_id can drive capability lookups, or whether it should fall through to base_url matching instead.

Returns:

  • (Boolean)


1158
1159
1160
# File 'lib/clacky/providers.rb', line 1158

def preset?(id)
  id.is_a?(String) && PRESETS.key?(id)
end

.provider_idsArray<String>

List all available provider IDs

Returns:

  • (Array<String>)

    List of provider identifiers



817
818
819
# File 'lib/clacky/providers.rb', line 817

def provider_ids
  PRESETS.keys
end

.resolve_api_model(base_url:, api_key: nil, model:) ⇒ String?

Translate a display model name into the real model id the target endpoint expects. Some providers expose the same model under different ids per billing endpoint (e.g. Volcengine Ark's pay-as-you-go API needs versioned ids like "doubao-seed-2-1-pro-260628" while the Coding/Agent Plan endpoints accept the short "doubao-seed-2.1-pro"). Users always see and pick the short name; this swaps it just before the request goes out.

Returns the original model unchanged when no alias applies (unknown provider, endpoint without an alias group, or model not in the map).

Parameters:

  • base_url (String, nil)

    the configured base_url (identifies endpoint)

  • api_key (String, nil) (defaults to: nil)

    the configured api_key (provider fallback)

  • model (String, nil)

    the display model name

Returns:

  • (String, nil)

    the real model id to send, or the input unchanged



1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
# File 'lib/clacky/providers.rb', line 1099

def resolve_api_model(base_url:, api_key: nil, model:)
  return model if model.nil?

  provider_id = resolve_provider(base_url: base_url, api_key: api_key)
  return model unless provider_id

  preset = PRESETS[provider_id]
  aliases = preset && preset["api_model_aliases"]
  return model unless aliases.is_a?(Hash)

  group = alias_group_for_base_url(preset, base_url)
  return model unless group

  aliases.dig(group, model.to_s) || model
end

.resolve_provider(base_url: nil, api_key: nil) ⇒ String?

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.

Parameters:

  • base_url (String, nil) (defaults to: nil)

    the configured base_url

  • api_key (String, nil) (defaults to: nil)

    the configured api_key

Returns:

  • (String, nil)

    provider id or nil if unresolvable



1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
# File 'lib/clacky/providers.rb', line 1065

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

  if base_url.is_a?(String) &&
     base_url.match?(%r{\Ahttps?://(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|/|\z)}i)
    return "openclacky"
  end

  nil
end

.stt_model_aliases(provider_id) ⇒ Object



865
866
867
868
# File 'lib/clacky/providers.rb', line 865

def stt_model_aliases(provider_id)
  preset = PRESETS[provider_id]
  preset&.dig("stt_model_aliases") || {}
end

.stt_models(provider_id) ⇒ Object



860
861
862
863
# File 'lib/clacky/providers.rb', line 860

def stt_models(provider_id)
  preset = PRESETS[provider_id]
  preset&.dig("stt_models") || []
end

.supports?(provider_id, capability, model_name: nil) ⇒ Boolean

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.

Parameters:

  • provider_id (String)

    The provider identifier

  • capability (String, Symbol)

    The capability name (e.g. :vision, "vision")

  • model_name (String, nil) (defaults to: nil)

    Optional specific model name

Returns:

  • (Boolean)

    true unless the preset explicitly says false



1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
# File 'lib/clacky/providers.rb', line 1171

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

.video_model_aliases(provider_id) ⇒ Object



850
851
852
853
# File 'lib/clacky/providers.rb', line 850

def video_model_aliases(provider_id)
  preset = PRESETS[provider_id]
  preset&.dig("video_model_aliases") || {}
end

.video_models(provider_id) ⇒ Object

Video generation models — placeholder. No provider supports video via Clacky yet; once they do, declare "video_models" alongside "image_models" in the relevant PRESETS entry and this returns it.



894
895
896
897
# File 'lib/clacky/providers.rb', line 894

def video_models(provider_id)
  preset = PRESETS[provider_id]
  preset&.dig("video_models") || []
end

.video_understanding_model_aliases(provider_id) ⇒ Object



875
876
877
878
# File 'lib/clacky/providers.rb', line 875

def video_understanding_model_aliases(provider_id)
  preset = PRESETS[provider_id]
  preset&.dig("video_understanding_model_aliases") || {}
end

.video_understanding_models(provider_id) ⇒ Object



870
871
872
873
# File 'lib/clacky/providers.rb', line 870

def video_understanding_models(provider_id)
  preset = PRESETS[provider_id]
  preset&.dig("video_understanding_models") || []
end