Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/sync-manifest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,9 @@ scripts:
- source: tools/langchain_client.py
description: "LangChain client builder - multi-provider client with slot-based fallback and configuration"

- source: tools/llm_registry.py
description: "LLM model registry helper - shared slot/model selection and blocked-model enforcement"

- source: tools/embedding_provider.py
description: "Embedding provider registry used by synced semantic matching helpers"

Expand Down
228 changes: 132 additions & 96 deletions templates/consumer-repo/tools/langchain_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,41 @@
from __future__ import annotations

import contextlib
import json
import logging
import os
from dataclasses import dataclass
from pathlib import Path

from tools import llm_registry as _llm_registry
from tools.llm_provider import DEFAULT_MODEL, GITHUB_MODELS_BASE_URL
from tools.llm_registry import (
PROVIDER_ANTHROPIC,
PROVIDER_GITHUB,
PROVIDER_OPENAI,
ModelRegistryEntry,
SlotDefinition,
apply_slot_env_overrides,
default_slots,
is_model_blocked,
load_model_registry,
load_slot_config,
normalize_provider,
registry_entry_for,
resolve_slots,
select_model_for_tier,
)

logger = logging.getLogger(__name__)

ENV_PROVIDER = "LANGCHAIN_PROVIDER"
ENV_MODEL = "LANGCHAIN_MODEL"
ENV_TIMEOUT = "LANGCHAIN_TIMEOUT"
ENV_MAX_RETRIES = "LANGCHAIN_MAX_RETRIES"
ENV_SLOT_CONFIG = "LANGCHAIN_SLOT_CONFIG"
ENV_SLOT_CONFIG = _llm_registry.ENV_SLOT_CONFIG
ENV_MODEL_REGISTRY_CONFIG = _llm_registry.ENV_MODEL_REGISTRY_CONFIG
ENV_SLOT_PREFIX = "LANGCHAIN_SLOT"
ENV_ANTHROPIC_KEY = "CLAUDE_API_STRANSKE"

PROVIDER_OPENAI = "openai"
PROVIDER_ANTHROPIC = "anthropic"
PROVIDER_GITHUB = "github-models"

DEFAULT_SLOT_CONFIG_PATH = Path(__file__).resolve().parent.parent / "config" / "llm_slots.json"
DEFAULT_SLOT_CONFIG_PATH = _llm_registry.DEFAULT_SLOT_CONFIG_PATH
DEFAULT_MODEL_REGISTRY_CONFIG_PATH = _llm_registry.DEFAULT_MODEL_REGISTRY_CONFIG_PATH


def _env_int(name: str, default: int) -> int:
Expand Down Expand Up @@ -59,24 +71,8 @@ def provider_label(self) -> str:
return f"{self.provider}/{self.model}"


@dataclass(frozen=True)
class SlotDefinition:
name: str
provider: str
model: str


def _normalize_provider(value: str | None) -> str | None:
if not value:
return None
normalized = value.strip().lower()
if normalized in {"github", "github_models", "github-models"}:
return PROVIDER_GITHUB
if normalized in {"anthropic", "claude"}:
return PROVIDER_ANTHROPIC
if normalized in {"openai"}:
return PROVIDER_OPENAI
return None
return normalize_provider(value)


def _resolve_provider(provider: str | None, *, force_openai: bool) -> tuple[str | None, bool]:
Expand All @@ -93,69 +89,81 @@ def _resolve_model(model: str | None) -> str:
return model or env_model or DEFAULT_MODEL


def _load_model_registry() -> list[ModelRegistryEntry]:
return load_model_registry()


def _registry_entry_for(
provider: str, model: str, registry: list[ModelRegistryEntry] | None = None
) -> ModelRegistryEntry | None:
return registry_entry_for(provider, model, registry=registry)


def _is_model_blocked(
provider: str, model: str, registry: list[ModelRegistryEntry] | None = None
) -> bool:
return is_model_blocked(provider, model, registry=registry)


def _select_model_for_tier(
*,
provider: str,
tier: str,
registry: list[ModelRegistryEntry] | None = None,
) -> str | None:
return select_model_for_tier(provider=provider, tier=tier, registry=registry)


def _default_slots() -> list[SlotDefinition]:
return [
SlotDefinition(name="slot1", provider=PROVIDER_OPENAI, model="gpt-5.4"),
SlotDefinition(name="slot2", provider=PROVIDER_ANTHROPIC, model="claude-sonnet-4-6"),
SlotDefinition(name="slot3", provider=PROVIDER_GITHUB, model=DEFAULT_MODEL),
]
return default_slots(github_default_model=DEFAULT_MODEL)


def _load_slot_config() -> list[SlotDefinition]:
config_path = os.environ.get(ENV_SLOT_CONFIG)
path = Path(config_path) if config_path else DEFAULT_SLOT_CONFIG_PATH
if not path.is_file():
return _default_slots()
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return _default_slots()

slots: list[SlotDefinition] = []
for idx, entry in enumerate(payload.get("slots", []), start=1):
provider = _normalize_provider(str(entry.get("provider", "")))
model = str(entry.get("model", "")).strip()
if not provider or not model:
continue
name = str(entry.get("name") or f"slot{idx}").strip() or f"slot{idx}"
slots.append(SlotDefinition(name=name, provider=provider, model=model))

return slots or _default_slots()
return load_slot_config(github_default_model=DEFAULT_MODEL)


def _apply_slot_env_overrides(slots: list[SlotDefinition]) -> list[SlotDefinition]:
updated: list[SlotDefinition] = []
for idx, slot in enumerate(slots, start=1):
provider_key = f"{ENV_SLOT_PREFIX}{idx}_PROVIDER"
model_key = f"{ENV_SLOT_PREFIX}{idx}_MODEL"
provider_override = _normalize_provider(os.environ.get(provider_key))
model_override = os.environ.get(model_key)
if idx == 1:
model_override = model_override or os.environ.get(ENV_MODEL)
updated.append(
SlotDefinition(
name=slot.name,
provider=provider_override or slot.provider,
model=(model_override or slot.model).strip(),
)
)
return updated
return apply_slot_env_overrides(
slots,
env_model_name=ENV_MODEL,
env_slot_prefix=ENV_SLOT_PREFIX,
)


def _resolve_slots() -> list[SlotDefinition]:
return _apply_slot_env_overrides(_load_slot_config())
return resolve_slots(
github_default_model=DEFAULT_MODEL,
env_model_name=ENV_MODEL,
env_slot_prefix=ENV_SLOT_PREFIX,
)


def _is_reasoning_model(model: str) -> bool:
"""Return True if the model is an OpenAI reasoning model that rejects temperature.

Supported naming pattern: `o` + digits, with optional suffixes. Examples: `o1`,
`o1-preview`, `o1-preview-2024-09-12`, `o3`, `o3-mini`, `o3-pro`, `o4-mini`,
`o4-mini-deep-research`. Non-matching examples: `o`, `o-1`, `openai-o1`, `oasis-1`.
"""
name = model.lower().strip()
# o-series reasoning models use an `o` prefix followed by digits with optional
# hyphen-separated suffixes: o1, o1-preview, o1-preview-2024-09-12, o3, o3-mini,
# o3-pro, o4-mini, o4-mini-deep-research.
return bool(__import__("re").fullmatch(r"o[0-9]+(?:-[a-z0-9]+)*", name))


def _build_openai_client(
chat_openai: type, *, model: str, token: str, timeout: int, max_retries: int
) -> object:
return chat_openai(
model=model,
api_key=token,
temperature=0.1,
timeout=timeout,
max_retries=max_retries,
)
kwargs: dict = {
"model": model,
"api_key": token,
"timeout": timeout,
"max_retries": max_retries,
}
if not _is_reasoning_model(model):
kwargs["temperature"] = 0.1
return chat_openai(**kwargs)


def _build_anthropic_client(
Expand All @@ -173,14 +181,16 @@ def _build_anthropic_client(
def _build_github_client(
chat_openai: type, *, model: str, token: str, timeout: int, max_retries: int
) -> object:
return chat_openai(
model=model,
base_url=GITHUB_MODELS_BASE_URL,
api_key=token,
temperature=0.1,
timeout=timeout,
max_retries=max_retries,
)
kwargs: dict = {
"model": model,
"base_url": GITHUB_MODELS_BASE_URL,
"api_key": token,
"timeout": timeout,
"max_retries": max_retries,
}
if not _is_reasoning_model(model):
kwargs["temperature"] = 0.1
return chat_openai(**kwargs)


def build_chat_client(
Expand All @@ -197,9 +207,11 @@ def build_chat_client(
return None

try:
from langchain_anthropic import ChatAnthropic as ChatAnthropicClass
from langchain_anthropic import ChatAnthropic
except ImportError:
ChatAnthropicClass = None
chat_anthropic_cls = None
else:
chat_anthropic_cls = ChatAnthropic

github_token = os.environ.get("GITHUB_TOKEN")
openai_token = os.environ.get("OPENAI_API_KEY")
Expand All @@ -214,6 +226,9 @@ def build_chat_client(
selected_provider, provider_explicit = _resolve_provider(provider, force_openai=force_openai)
if provider_explicit and selected_provider is None:
return None
if selected_provider and _is_model_blocked(selected_provider, selected_model):
logger.warning("Refusing blocked LLM model: %s/%s", selected_provider, selected_model)
return None

if selected_provider == PROVIDER_GITHUB:
if not github_token:
Expand Down Expand Up @@ -246,11 +261,11 @@ def build_chat_client(
return None

if selected_provider == PROVIDER_ANTHROPIC:
if not anthropic_token or not ChatAnthropicClass:
if not anthropic_token or not chat_anthropic_cls:
return None
try:
client = _build_anthropic_client(
ChatAnthropicClass,
chat_anthropic_cls,
model=selected_model,
token=anthropic_token,
timeout=selected_timeout,
Expand All @@ -263,6 +278,13 @@ def build_chat_client(
# Auto-select: slot order (OpenAI -> Claude -> GitHub Models by default).
slots = _resolve_slots()
model_override = model or os.environ.get(ENV_MODEL)
if model_override:
override_provider = selected_provider or (slots[0].provider if slots else "")
if override_provider and _is_model_blocked(override_provider, model_override):
logger.warning(
"Refusing blocked LLM model override: %s/%s", override_provider, model_override
)
return None
used_override = False
for slot in slots:
slot_model = model_override if model_override and not used_override else slot.model
Expand All @@ -277,10 +299,10 @@ def build_chat_client(
)
used_override = True
return ClientInfo(client=client, provider=PROVIDER_OPENAI, model=slot_model)
if slot.provider == PROVIDER_ANTHROPIC and anthropic_token and ChatAnthropicClass:
if slot.provider == PROVIDER_ANTHROPIC and anthropic_token and chat_anthropic_cls:
with contextlib.suppress(Exception):
client = _build_anthropic_client(
ChatAnthropicClass,
chat_anthropic_cls,
model=slot_model,
token=anthropic_token,
timeout=selected_timeout,
Expand Down Expand Up @@ -317,9 +339,11 @@ def build_chat_clients(
return []

try:
from langchain_anthropic import ChatAnthropic as ChatAnthropicClass
from langchain_anthropic import ChatAnthropic
except ImportError:
ChatAnthropicClass = None
chat_anthropic_cls = None
else:
chat_anthropic_cls = ChatAnthropic

github_token = os.environ.get("GITHUB_TOKEN")
openai_token = os.environ.get("OPENAI_API_KEY")
Expand All @@ -336,6 +360,15 @@ def build_chat_clients(
selected_provider, provider_explicit = _resolve_provider(provider, force_openai=False)
if provider_explicit and selected_provider is None:
return []
registry = _load_model_registry()
if selected_provider:
blocked_models = [candidate for candidate in (first_model, second_model) if candidate]
if any(
_is_model_blocked(selected_provider, candidate, registry=registry)
for candidate in blocked_models
):
logger.warning("Refusing blocked LLM model for provider %s", selected_provider)
return []

clients: list[ClientInfo] = []

Expand Down Expand Up @@ -400,12 +433,12 @@ def build_chat_clients(
model=second_model,
)
)
elif selected_provider == PROVIDER_ANTHROPIC and anthropic_token and ChatAnthropicClass:
elif selected_provider == PROVIDER_ANTHROPIC and anthropic_token and chat_anthropic_cls:
with contextlib.suppress(Exception):
clients.append(
ClientInfo(
client=_build_anthropic_client(
ChatAnthropicClass,
chat_anthropic_cls,
model=first_model,
token=anthropic_token,
timeout=selected_timeout,
Expand All @@ -420,7 +453,7 @@ def build_chat_clients(
clients.append(
ClientInfo(
client=_build_anthropic_client(
ChatAnthropicClass,
chat_anthropic_cls,
model=second_model,
token=anthropic_token,
timeout=selected_timeout,
Expand All @@ -439,7 +472,7 @@ def build_chat_clients(
if any(
(
slot.provider == PROVIDER_OPENAI and openai_token,
slot.provider == PROVIDER_ANTHROPIC and anthropic_token and ChatAnthropicClass,
slot.provider == PROVIDER_ANTHROPIC and anthropic_token and chat_anthropic_cls,
slot.provider == PROVIDER_GITHUB and github_token,
)
):
Expand All @@ -453,6 +486,9 @@ def build_chat_clients(
for idx, slot in enumerate(candidate_slots):
slot_model = model_overrides[idx] if idx < len(model_overrides) else None
slot_model = slot_model or slot.model
if _is_model_blocked(slot.provider, slot_model, registry=registry):
logger.warning("Skipping blocked LLM model override: %s/%s", slot.provider, slot_model)
continue
if slot.provider == PROVIDER_OPENAI and openai_token:
with contextlib.suppress(Exception):
clients.append(
Expand All @@ -468,12 +504,12 @@ def build_chat_clients(
model=slot_model,
)
)
if slot.provider == PROVIDER_ANTHROPIC and anthropic_token and ChatAnthropicClass:
if slot.provider == PROVIDER_ANTHROPIC and anthropic_token and chat_anthropic_cls:
with contextlib.suppress(Exception):
clients.append(
ClientInfo(
client=_build_anthropic_client(
ChatAnthropicClass,
chat_anthropic_cls,
model=slot_model,
token=anthropic_token,
timeout=selected_timeout,
Expand Down
Loading
Loading