Land-Develop-MCP / providers.py
razaali10's picture
Upload 13 files
c3e3226 verified
Raw
History Blame Contribute Delete
8.79 kB
"""Unified multi-provider LLM layer (requests-based — no provider SDKs).
PROVIDERS drives the selection cards; every provider goes through one
llm_call() signature. Model lists verified current as of July 2026; provider
model catalogs change often, so each card links its console for the live list.
Vision support varies per model and is declared per provider.
"""
from __future__ import annotations
import json
import requests
TIMEOUT = 300
PROVIDERS = {
"Claude (Anthropic)": {
"icon": "🟠", "note": "Best plan-reading vision", "free_tier": False,
# Anthropic API model strings (July 2026). See console for the live list.
"models": ["claude-opus-4-8", "claude-sonnet-5", "claude-haiku-4-5-20251001",
"claude-sonnet-4-6"],
"vision_models": "all",
"key_url": "https://console.anthropic.com/settings/keys",
"key_hint": "sk-ant-...",
"env": "ANTHROPIC_API_KEY",
"kind": "anthropic",
},
"ChatGPT (OpenAI)": {
"icon": "🟢", "note": "GPT-5 family + legacy 4o", "free_tier": False,
# GPT-4o retired from ChatGPT Feb 2026 but still callable via API for now;
# kept as legacy. GPT-5 family is the current generation.
"models": ["gpt-5.4", "gpt-5.4-mini", "gpt-5.3", "gpt-4.1", "gpt-4.1-mini",
"gpt-4o", "gpt-4o-mini"],
"vision_models": "all",
"key_url": "https://platform.openai.com/api-keys",
"key_hint": "sk-...",
"env": "OPENAI_API_KEY",
"kind": "openai",
},
"Gemini (Google)": {
"icon": "🔵", "note": "Free tier · Gemini 3.x", "free_tier": True,
# 2.5 line superseded; 3.x is current GA (July 2026).
"models": ["gemini-3.6-flash", "gemini-flash-latest", "gemini-3.5-flash-lite",
"gemini-3.1-pro-preview"],
"vision_models": "all",
"key_url": "https://aistudio.google.com/apikey",
"key_hint": "AIza...",
"env": "GEMINI_API_KEY",
"kind": "gemini",
},
"Groq (fast)": {
"icon": "🟣", "note": "Free · fast · text-only", "free_tier": True,
# Llama 3.3 / Llama 4 chat models decommissioned 2026; current = gpt-oss + qwen.
# These are TEXT-ONLY on Groq — review falls back to embedded page text.
"models": ["openai/gpt-oss-120b", "openai/gpt-oss-20b", "qwen/qwen3.6-27b"],
"vision_models": [],
"key_url": "https://console.groq.com/keys",
"key_hint": "gsk_...",
"env": "GROQ_API_KEY",
"kind": "openai_compat",
"base_url": "https://api.groq.com/openai/v1",
},
"Custom / Local (OpenAI-compatible)": {
"icon": "⚙️", "note": "Ollama · vLLM · LM Studio · any OpenAI-compatible URL",
"free_tier": True,
"models": ["__custom__"], # user types the model id in the box below
"vision_models": "all", # depends on the local model; user's call
"key_url": "https://github.com/ollama/ollama/blob/main/docs/openai.md",
"key_hint": "leave blank for local, or a token if your server needs one",
"env": "CUSTOM_LLM_API_KEY",
"kind": "openai_compat",
"base_url": "", # user supplies, e.g. http://host:11434/v1
"custom": True,
},
}
def supports_vision(provider: str, model: str) -> bool:
vm = PROVIDERS[provider]["vision_models"]
return vm == "all" or model in vm
def _raise_for_api(resp: requests.Response, provider: str):
if resp.status_code >= 400:
try:
detail = json.dumps(resp.json())[:500]
except Exception: # noqa: BLE001
detail = resp.text[:500]
raise RuntimeError(f"{provider} API error {resp.status_code}: {detail}")
def _uses_completion_tokens(model: str) -> bool:
"""GPT-5 family and o-series reasoning models renamed max_tokens ->
max_completion_tokens and reject the old field. Detect by model id."""
m = model.lower()
return (m.startswith("gpt-5") or m.startswith("o1") or m.startswith("o3")
or m.startswith("o4") or m.startswith("gpt-6"))
def _openai_compatible(url: str, api_key: str, model: str, system: str, user_text: str,
images_b64: list[str], history: list[dict], max_tokens: int,
provider: str) -> str:
content = [{"type": "text", "text": user_text}]
content += [{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b}"}}
for b in images_b64]
msgs = [{"role": "system", "content": system}]
msgs += [{"role": h["role"], "content": h["content"]} for h in history]
msgs.append({"role": "user", "content": content if images_b64 else user_text})
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
body = {"model": model, "messages": msgs}
tok_field = "max_completion_tokens" if _uses_completion_tokens(model) else "max_tokens"
body[tok_field] = max_tokens
r = requests.post(f"{url.rstrip('/')}/chat/completions", headers=headers,
json=body, timeout=TIMEOUT)
# Fallback: if a provider rejects the token field name, retry with the other.
if r.status_code == 400 and "max_tokens" in r.text and "max_completion_tokens" in r.text:
body.pop(tok_field, None)
alt = "max_tokens" if tok_field == "max_completion_tokens" else "max_completion_tokens"
body[alt] = max_tokens
r = requests.post(f"{url.rstrip('/')}/chat/completions", headers=headers,
json=body, timeout=TIMEOUT)
_raise_for_api(r, provider)
return r.json()["choices"][0]["message"]["content"] or ""
def llm_call(provider: str, model: str, api_key: str, system: str,
user_text: str, images_b64: list[str] | None = None,
history: list[dict] | None = None, max_tokens: int = 6000,
base_url: str | None = None) -> str:
"""One call, any provider. base_url overrides the endpoint for custom/local."""
images_b64 = images_b64 or []
history = history or []
pinfo = PROVIDERS.get(provider, {})
kind = pinfo.get("kind", "openai")
if kind == "anthropic":
content: list[dict] = [{"type": "text", "text": user_text}]
content += [{"type": "image",
"source": {"type": "base64", "media_type": "image/jpeg", "data": b}}
for b in images_b64]
msgs = [{"role": h["role"], "content": h["content"]} for h in history]
msgs.append({"role": "user", "content": content})
r = requests.post(
"https://api.anthropic.com/v1/messages",
headers={"x-api-key": api_key, "anthropic-version": "2023-06-01",
"content-type": "application/json"},
json={"model": model, "max_tokens": max_tokens, "system": system, "messages": msgs},
timeout=TIMEOUT)
_raise_for_api(r, provider)
return "".join(b.get("text", "") for b in r.json()["content"] if b.get("type") == "text")
if kind == "openai":
return _openai_compatible("https://api.openai.com/v1", api_key, model, system,
user_text, images_b64, history, max_tokens, provider)
if kind == "openai_compat":
url = base_url or pinfo.get("base_url") or ""
if not url:
raise RuntimeError(f"{provider}: no endpoint URL provided. Enter your "
f"server's base URL (e.g. http://localhost:11434/v1).")
return _openai_compatible(url, api_key, model, system, user_text,
images_b64, history, max_tokens, provider)
if kind == "gemini":
parts: list[dict] = [{"text": user_text}]
parts += [{"inline_data": {"mime_type": "image/jpeg", "data": b}} for b in images_b64]
contents = [{"role": "user" if h["role"] == "user" else "model",
"parts": [{"text": h["content"]}]} for h in history]
contents.append({"role": "user", "parts": parts})
r = requests.post(
f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent",
params={"key": api_key},
json={"system_instruction": {"parts": [{"text": system}]},
"contents": contents,
"generationConfig": {"maxOutputTokens": max_tokens}},
timeout=TIMEOUT)
_raise_for_api(r, provider)
cands = r.json().get("candidates", [])
if not cands:
raise RuntimeError(f"Gemini returned no candidates: {json.dumps(r.json())[:300]}")
return "".join(p.get("text", "") for p in cands[0]["content"]["parts"])
raise ValueError(f"Unknown provider: {provider}")