Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@
# HF_BASE_URL=https://router.huggingface.co/v1 # Override default base URL
# OPENCODE_GO_BASE_URL=https://opencode.ai/zen/go/v1 # Override default base URL

# DeepInfra — 100+ top open models, pay-per-use.
# Get your key at: https://deepinfra.com/dash/api_keys
# DEEPINFRA_API_KEY=

# =============================================================================
# LLM PROVIDER (Qwen OAuth)
# =============================================================================
Expand Down
72 changes: 66 additions & 6 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,10 @@ def _get_aux_model_for_provider(provider_id: str) -> str:
"kilocode": "google/gemini-3-flash-preview",
"ollama-cloud": "nemotron-3-nano:30b",
"tencent-tokenhub": "hy3-preview",
# NB: no "deepinfra" entry — its aux model lives on the ProviderProfile
# (plugins/model-providers/deepinfra: default_aux_model), which
# _get_aux_model_for_provider() reads first. Duplicating it here would be
# dead data that drifts when the profile's value is bumped.
}

# Legacy alias — callers that haven't been updated to _get_aux_model_for_provider()
Expand All @@ -441,6 +445,33 @@ def _get_aux_model_for_provider(provider_id: str) -> str:
"zai": "glm-5v-turbo",
}


def _resolve_provider_vision_default(provider: str) -> Optional[str]:
"""Return the provider's preferred default vision model id, or None.

Static entries in :data:`_PROVIDER_VISION_MODELS` win first (xiaomi /
zai have dedicated vision-only model names that don't live in any
discoverable catalog). Otherwise the provider's :class:`ProviderProfile`
gets a chance to supply one via its ``default_vision_model()`` hook —
that's where catalog-backed providers (DeepInfra) resolve a live default,
keeping the discovery logic inside their plugin instead of a name-check
branch here.
"""
static = _PROVIDER_VISION_MODELS.get(provider)
if static:
return static
try:
from providers import get_provider_profile
profile = get_provider_profile(provider)
except Exception:
return None
if profile is None:
return None
try:
return profile.default_vision_model()
except Exception:
return None

# Providers whose endpoint does not accept image input, even though the
# provider's broader ecosystem has vision models available elsewhere. When
# `auxiliary.vision.provider: auto` sees one of these as the main provider,
Expand Down Expand Up @@ -4842,6 +4873,7 @@ def get_async_text_auxiliary_client(task: str = "", *, main_runtime: Optional[Di
_VISION_AUTO_PROVIDER_ORDER = (
"openrouter",
"nous",
"deepinfra",
)


Expand Down Expand Up @@ -4898,6 +4930,21 @@ def _resolve_strict_vision_backend(
return resolve_provider_client("openai-codex", model, is_vision=True)
if provider == "anthropic":
return _try_anthropic()
if provider == "deepinfra":
# DeepInfra exposes vision-capable models (Llama-4 Scout/Maverick,
# Qwen3-VL, Gemma 3, Gemini) on the same OpenAI-compatible endpoint
# as its chat models. The default is discovered live via the profile's
# default_vision_model() hook (key-gated, chat-surface + vision tag) so
# we don't pin a hardcoded id that may rot when DeepInfra retires a
# model, and this module stays provider-agnostic.
vision_model = model or _resolve_provider_vision_default("deepinfra")
if not vision_model:
logger.debug(
"Vision auto-detect: deepinfra catalog unreachable or "
"returned no vision-tagged models — skipping"
)
return None, None
return resolve_provider_client("deepinfra", vision_model, is_vision=True)
if provider == "custom":
return _try_custom_endpoint()
return None, None
Expand Down Expand Up @@ -4983,16 +5030,29 @@ def _finalize(resolved_provider: str, sync_client: Any, default_model: Optional[
# _PROVIDER_VISION_MODELS provides per-provider vision model
# overrides when the provider has a dedicated multimodal model
# that differs from the chat model (e.g. xiaomi → mimo-v2-omni,
# zai → glm-5v-turbo). Nous is the exception: it has a dedicated
# strict vision backend with tier-aware defaults, so it must not
# fall through to the user's text chat model here.
# 2. OpenRouter (vision-capable aggregator fallback)
# zai → glm-5v-turbo). DeepInfra is similar but resolves its
# default vision model live from the catalog (see
# :func:`_resolve_provider_vision_default`). Nous is the
# exception: it has a dedicated strict vision backend with
# tier-aware defaults, so it must not fall through to the
# user's text chat model here.
# 2. OpenRouter (vision-capable aggregator fallback)
# 3. Nous Portal (vision-capable aggregator fallback)
# 4. Stop
# 4. DeepInfra (OpenAI-compatible; vision model discovered
# live from the catalog — tried when
# DEEPINFRA_API_KEY is set)
# 5. Stop
main_provider = _read_main_provider()
main_model = _read_main_model()
if main_provider and main_provider not in {"auto", ""}:
vision_model = _PROVIDER_VISION_MODELS.get(main_provider, main_model)
# A provider-specific vision default wins over the user's chat model:
# static overrides (xiaomi/zai) and catalog-backed discovery (the
# DeepInfra profile hook) both yield a *known* vision-capable model,
# whereas the pinned chat model is usually NOT multimodal (e.g. the
# DeepSeek-V4-Flash default) and _main_model_supports_vision can't be
# trusted to catch that. Only fall back to the chat model when no
# provider default is available (catalog unreachable).
vision_model = _resolve_provider_vision_default(main_provider) or main_model
if main_provider == "nous":
sync_client, default_model = _resolve_strict_vision_backend(
main_provider, vision_model
Expand Down
23 changes: 21 additions & 2 deletions agent/model_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def _resolve_requests_verify() -> bool | str:
# are preserved so the full model name reaches cache lookups and server queries.
_PROVIDER_PREFIXES: frozenset[str] = frozenset({
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
"gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek",
"gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek", "deepinfra",
"opencode-zen", "opencode-go", "kilocode", "alibaba", "novita",
"qwen-oauth",
"xiaomi",
Expand All @@ -58,7 +58,7 @@ def _resolve_requests_verify() -> bool | str:
# Common aliases
"google", "google-gemini", "google-ai-studio",
"glm", "z-ai", "z.ai", "zhipu", "github", "github-copilot",
"github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek",
"github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek", "deep-infra",
"ollama",
"stepfun", "opencode", "zen", "go", "kilo", "dashscope", "aliyun", "qwen",
"mimo", "xiaomi-mimo",
Expand Down Expand Up @@ -437,6 +437,7 @@ def _is_custom_endpoint(base_url: str) -> bool:
"generativelanguage.googleapis.com": "gemini",
"inference-api.nousresearch.com": "nous",
"api.deepseek.com": "deepseek",
"api.deepinfra.com": "deepinfra",
"api.githubcopilot.com": "copilot",
# Enterprise Copilot endpoints look like api.enterprise.githubcopilot.com,
# api.business.githubcopilot.com, etc. Match the suffix so context-window
Expand Down Expand Up @@ -740,6 +741,24 @@ def _extract_pricing(payload: Dict[str, Any]) -> Dict[str, Any]:
pricing["completion"] = str(float(novita_output) / 10_000 / 1_000_000)
return pricing

# DeepInfra ships pricing under ``metadata.pricing`` with $/MTok values:
# ``input_tokens``, ``output_tokens``, ``cache_read_tokens``. Convert to
# per-token strings so the generic cost machinery (usage_pricing.py)
# consumes them through the same path as OpenRouter / OpenAI.
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else None
deepinfra_pricing = metadata.get("pricing") if metadata else None
if isinstance(deepinfra_pricing, dict) and any(
k in deepinfra_pricing for k in ("input_tokens", "output_tokens", "cache_read_tokens")
):
result: Dict[str, Any] = {}
if deepinfra_pricing.get("input_tokens") is not None:
result["prompt"] = str(float(deepinfra_pricing["input_tokens"]) / 1_000_000)
if deepinfra_pricing.get("output_tokens") is not None:
result["completion"] = str(float(deepinfra_pricing["output_tokens"]) / 1_000_000)
if deepinfra_pricing.get("cache_read_tokens") is not None:
result["cache_read"] = str(float(deepinfra_pricing["cache_read_tokens"]) / 1_000_000)
return result

alias_map = {
"prompt": ("prompt", "input", "input_cost_per_token", "prompt_token_cost"),
"completion": ("completion", "output", "output_cost_per_token", "completion_token_cost"),
Expand Down
1 change: 1 addition & 0 deletions agent/tts_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"neutts",
"kittentts",
"piper",
"deepinfra",
})


Expand Down
Loading