refactor: replace litellm with native openai + anthropic SDKs
- Remove litellm dependency entirely (supply chain risk mitigation) - Add AnthropicProvider (native SDK) and OpenAICompatProvider (unified) - Merge CustomProvider into OpenAICompatProvider, delete custom_provider.py - Add ProviderSpec.backend field for declarative provider routing - Remove _resolve_model, find_gateway, find_by_model (dead heuristics) - Pass resolved spec directly into provider — zero internal lookups - Stub out litellm-dependent model database (cli/models.py) - Add anthropic>=0.45.0 to dependencies, remove litellm - 593 tests passed, net -1034 lines
This commit is contained in:
+46
-37
@@ -376,61 +376,61 @@ def _onboard_plugins(config_path: Path) -> None:
|
||||
|
||||
|
||||
def _make_provider(config: Config):
|
||||
"""Create the appropriate LLM provider from config."""
|
||||
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
||||
"""Create the appropriate LLM provider from config.
|
||||
|
||||
Routing is driven by ``ProviderSpec.backend`` in the registry.
|
||||
"""
|
||||
from nanobot.providers.base import GenerationSettings
|
||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
||||
from nanobot.providers.registry import find_by_name
|
||||
|
||||
model = config.agents.defaults.model
|
||||
provider_name = config.get_provider_name(model)
|
||||
p = config.get_provider(model)
|
||||
spec = find_by_name(provider_name) if provider_name else None
|
||||
backend = spec.backend if spec else "openai_compat"
|
||||
|
||||
# OpenAI Codex (OAuth)
|
||||
if provider_name == "openai_codex" or model.startswith("openai-codex/"):
|
||||
provider = OpenAICodexProvider(default_model=model)
|
||||
# Custom: direct OpenAI-compatible endpoint, bypasses LiteLLM
|
||||
elif provider_name == "custom":
|
||||
from nanobot.providers.custom_provider import CustomProvider
|
||||
provider = CustomProvider(
|
||||
api_key=p.api_key if p else "no-key",
|
||||
api_base=config.get_api_base(model) or "http://localhost:8000/v1",
|
||||
default_model=model,
|
||||
extra_headers=p.extra_headers if p else None,
|
||||
)
|
||||
# Azure OpenAI: direct Azure OpenAI endpoint with deployment name
|
||||
elif provider_name == "azure_openai":
|
||||
# --- validation ---
|
||||
if backend == "azure_openai":
|
||||
if not p or not p.api_key or not p.api_base:
|
||||
console.print("[red]Error: Azure OpenAI requires api_key and api_base.[/red]")
|
||||
console.print("Set them in ~/.nanobot/config.json under providers.azure_openai section")
|
||||
console.print("Use the model field to specify the deployment name.")
|
||||
raise typer.Exit(1)
|
||||
elif backend == "openai_compat" and not model.startswith("bedrock/"):
|
||||
needs_key = not (p and p.api_key)
|
||||
exempt = spec and (spec.is_oauth or spec.is_local or spec.is_direct)
|
||||
if needs_key and not exempt:
|
||||
console.print("[red]Error: No API key configured.[/red]")
|
||||
console.print("Set one in ~/.nanobot/config.json under providers section")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# --- instantiation by backend ---
|
||||
if backend == "openai_codex":
|
||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
||||
provider = OpenAICodexProvider(default_model=model)
|
||||
elif backend == "azure_openai":
|
||||
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key=p.api_key,
|
||||
api_base=p.api_base,
|
||||
default_model=model,
|
||||
)
|
||||
# OpenVINO Model Server: direct OpenAI-compatible endpoint at /v3
|
||||
elif provider_name == "ovms":
|
||||
from nanobot.providers.custom_provider import CustomProvider
|
||||
provider = CustomProvider(
|
||||
api_key=p.api_key if p else "no-key",
|
||||
api_base=config.get_api_base(model) or "http://localhost:8000/v3",
|
||||
default_model=model,
|
||||
)
|
||||
else:
|
||||
from nanobot.providers.litellm_provider import LiteLLMProvider
|
||||
from nanobot.providers.registry import find_by_name
|
||||
spec = find_by_name(provider_name)
|
||||
if not model.startswith("bedrock/") and not (p and p.api_key) and not (spec and (spec.is_oauth or spec.is_local)):
|
||||
console.print("[red]Error: No API key configured.[/red]")
|
||||
console.print("Set one in ~/.nanobot/config.json under providers section")
|
||||
raise typer.Exit(1)
|
||||
provider = LiteLLMProvider(
|
||||
elif backend == "anthropic":
|
||||
from nanobot.providers.anthropic_provider import AnthropicProvider
|
||||
provider = AnthropicProvider(
|
||||
api_key=p.api_key if p else None,
|
||||
api_base=config.get_api_base(model),
|
||||
default_model=model,
|
||||
extra_headers=p.extra_headers if p else None,
|
||||
provider_name=provider_name,
|
||||
)
|
||||
else:
|
||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
provider = OpenAICompatProvider(
|
||||
api_key=p.api_key if p else None,
|
||||
api_base=config.get_api_base(model),
|
||||
default_model=model,
|
||||
extra_headers=p.extra_headers if p else None,
|
||||
spec=spec,
|
||||
)
|
||||
|
||||
defaults = config.agents.defaults
|
||||
@@ -1203,11 +1203,20 @@ def _login_openai_codex() -> None:
|
||||
def _login_github_copilot() -> None:
|
||||
import asyncio
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
console.print("[cyan]Starting GitHub Copilot device flow...[/cyan]\n")
|
||||
|
||||
async def _trigger():
|
||||
from litellm import acompletion
|
||||
await acompletion(model="github_copilot/gpt-4o", messages=[{"role": "user", "content": "hi"}], max_tokens=1)
|
||||
client = AsyncOpenAI(
|
||||
api_key="dummy",
|
||||
base_url="https://api.githubcopilot.com",
|
||||
)
|
||||
await client.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
max_tokens=1,
|
||||
)
|
||||
|
||||
try:
|
||||
asyncio.run(_trigger())
|
||||
|
||||
+7
-207
@@ -1,229 +1,29 @@
|
||||
"""Model information helpers for the onboard wizard.
|
||||
|
||||
Provides model context window lookup and autocomplete suggestions using litellm.
|
||||
Model database / autocomplete is temporarily disabled while litellm is
|
||||
being replaced. All public function signatures are preserved so callers
|
||||
continue to work without changes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _litellm():
|
||||
"""Lazy accessor for litellm (heavy import deferred until actually needed)."""
|
||||
import litellm as _ll
|
||||
|
||||
return _ll
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_model_cost_map() -> dict[str, Any]:
|
||||
"""Get litellm's model cost map (cached)."""
|
||||
return getattr(_litellm(), "model_cost", {})
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_all_models() -> list[str]:
|
||||
"""Get all known model names from litellm.
|
||||
"""
|
||||
models = set()
|
||||
|
||||
# From model_cost (has pricing info)
|
||||
cost_map = _get_model_cost_map()
|
||||
for k in cost_map.keys():
|
||||
if k != "sample_spec":
|
||||
models.add(k)
|
||||
|
||||
# From models_by_provider (more complete provider coverage)
|
||||
for provider_models in getattr(_litellm(), "models_by_provider", {}).values():
|
||||
if isinstance(provider_models, (set, list)):
|
||||
models.update(provider_models)
|
||||
|
||||
return sorted(models)
|
||||
|
||||
|
||||
def _normalize_model_name(model: str) -> str:
|
||||
"""Normalize model name for comparison."""
|
||||
return model.lower().replace("-", "_").replace(".", "")
|
||||
return []
|
||||
|
||||
|
||||
def find_model_info(model_name: str) -> dict[str, Any] | None:
|
||||
"""Find model info with fuzzy matching.
|
||||
|
||||
Args:
|
||||
model_name: Model name in any common format
|
||||
|
||||
Returns:
|
||||
Model info dict or None if not found
|
||||
"""
|
||||
cost_map = _get_model_cost_map()
|
||||
if not cost_map:
|
||||
return None
|
||||
|
||||
# Direct match
|
||||
if model_name in cost_map:
|
||||
return cost_map[model_name]
|
||||
|
||||
# Extract base name (without provider prefix)
|
||||
base_name = model_name.split("/")[-1] if "/" in model_name else model_name
|
||||
base_normalized = _normalize_model_name(base_name)
|
||||
|
||||
candidates = []
|
||||
|
||||
for key, info in cost_map.items():
|
||||
if key == "sample_spec":
|
||||
continue
|
||||
|
||||
key_base = key.split("/")[-1] if "/" in key else key
|
||||
key_base_normalized = _normalize_model_name(key_base)
|
||||
|
||||
# Score the match
|
||||
score = 0
|
||||
|
||||
# Exact base name match (highest priority)
|
||||
if base_normalized == key_base_normalized:
|
||||
score = 100
|
||||
# Base name contains model
|
||||
elif base_normalized in key_base_normalized:
|
||||
score = 80
|
||||
# Model contains base name
|
||||
elif key_base_normalized in base_normalized:
|
||||
score = 70
|
||||
# Partial match
|
||||
elif base_normalized[:10] in key_base_normalized:
|
||||
score = 50
|
||||
|
||||
if score > 0:
|
||||
# Prefer models with max_input_tokens
|
||||
if info.get("max_input_tokens"):
|
||||
score += 10
|
||||
candidates.append((score, key, info))
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# Return the best match
|
||||
candidates.sort(key=lambda x: (-x[0], x[1]))
|
||||
return candidates[0][2]
|
||||
|
||||
|
||||
def get_model_context_limit(model: str, provider: str = "auto") -> int | None:
|
||||
"""Get the maximum input context tokens for a model.
|
||||
|
||||
Args:
|
||||
model: Model name (e.g., "claude-3.5-sonnet", "gpt-4o")
|
||||
provider: Provider name for informational purposes (not yet used for filtering)
|
||||
|
||||
Returns:
|
||||
Maximum input tokens, or None if unknown
|
||||
|
||||
Note:
|
||||
The provider parameter is currently informational only. Future versions may
|
||||
use it to prefer provider-specific model variants in the lookup.
|
||||
"""
|
||||
# First try fuzzy search in model_cost (has more accurate max_input_tokens)
|
||||
info = find_model_info(model)
|
||||
if info:
|
||||
# Prefer max_input_tokens (this is what we want for context window)
|
||||
max_input = info.get("max_input_tokens")
|
||||
if max_input and isinstance(max_input, int):
|
||||
return max_input
|
||||
|
||||
# Fall back to litellm's get_max_tokens (returns max_output_tokens typically)
|
||||
try:
|
||||
result = _litellm().get_max_tokens(model)
|
||||
if result and result > 0:
|
||||
return result
|
||||
except (KeyError, ValueError, AttributeError):
|
||||
# Model not found in litellm's database or invalid response
|
||||
pass
|
||||
|
||||
# Last resort: use max_tokens from model_cost
|
||||
if info:
|
||||
max_tokens = info.get("max_tokens")
|
||||
if max_tokens and isinstance(max_tokens, int):
|
||||
return max_tokens
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_provider_keywords() -> dict[str, list[str]]:
|
||||
"""Build provider keywords mapping from nanobot's provider registry.
|
||||
|
||||
Returns:
|
||||
Dict mapping provider name to list of keywords for model filtering.
|
||||
"""
|
||||
try:
|
||||
from nanobot.providers.registry import PROVIDERS
|
||||
|
||||
mapping = {}
|
||||
for spec in PROVIDERS:
|
||||
if spec.keywords:
|
||||
mapping[spec.name] = list(spec.keywords)
|
||||
return mapping
|
||||
except ImportError:
|
||||
return {}
|
||||
def get_model_context_limit(model: str, provider: str = "auto") -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_model_suggestions(partial: str, provider: str = "auto", limit: int = 20) -> list[str]:
|
||||
"""Get autocomplete suggestions for model names.
|
||||
|
||||
Args:
|
||||
partial: Partial model name typed by user
|
||||
provider: Provider name for filtering (e.g., "openrouter", "minimax")
|
||||
limit: Maximum number of suggestions to return
|
||||
|
||||
Returns:
|
||||
List of matching model names
|
||||
"""
|
||||
all_models = get_all_models()
|
||||
if not all_models:
|
||||
return []
|
||||
|
||||
partial_lower = partial.lower()
|
||||
partial_normalized = _normalize_model_name(partial)
|
||||
|
||||
# Get provider keywords from registry
|
||||
provider_keywords = _get_provider_keywords()
|
||||
|
||||
# Filter by provider if specified
|
||||
allowed_keywords = None
|
||||
if provider and provider != "auto":
|
||||
allowed_keywords = provider_keywords.get(provider.lower())
|
||||
|
||||
matches = []
|
||||
|
||||
for model in all_models:
|
||||
model_lower = model.lower()
|
||||
|
||||
# Apply provider filter
|
||||
if allowed_keywords:
|
||||
if not any(kw in model_lower for kw in allowed_keywords):
|
||||
continue
|
||||
|
||||
# Match against partial input
|
||||
if not partial:
|
||||
matches.append(model)
|
||||
continue
|
||||
|
||||
if partial_lower in model_lower:
|
||||
# Score by position of match (earlier = better)
|
||||
pos = model_lower.find(partial_lower)
|
||||
score = 100 - pos
|
||||
matches.append((score, model))
|
||||
elif partial_normalized in _normalize_model_name(model):
|
||||
score = 50
|
||||
matches.append((score, model))
|
||||
|
||||
# Sort by score if we have scored matches
|
||||
if matches and isinstance(matches[0], tuple):
|
||||
matches.sort(key=lambda x: (-x[0], x[1]))
|
||||
matches = [m[1] for m in matches]
|
||||
else:
|
||||
matches.sort()
|
||||
|
||||
return matches[:limit]
|
||||
return []
|
||||
|
||||
|
||||
def format_token_count(tokens: int) -> str:
|
||||
|
||||
Reference in New Issue
Block a user