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
11 changes: 6 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -160,17 +160,18 @@ TELEGRAM_ENABLED=false
# intentionally switching to Mem0 Platform in custom code.
#
# Production setup:
# - OPENROUTER_API_KEY powers Mem0's LLM extraction via LiteLLM.
# - OPENROUTER_API_KEY powers Mem0's LLM extraction through OpenRouter's
# OpenAI-compatible API.
# - GOOGLE_API_KEY powers Gemini embedding generation.
# - Qdrant Cloud stores and searches the generated vectors.
#
# Qdrant does not calculate embeddings in this Mem0 flow. If you change the
# embedding model or dimensions, create a new collection and re-embed memories.
#
# LLM Configuration (uses OPENROUTER_API_KEY by default through LiteLLM)
# MEM0_LLM_PROVIDER=litellm
# MEM0_LLM_MODEL=openrouter/google/gemini-2.0-flash-001
# MEM0_LLM_API_KEY= # Optional override; defaults to OPENROUTER_API_KEY
# LLM Configuration (uses OPENROUTER_API_KEY by default through OpenRouter)
# MEM0_LLM_PROVIDER=openai
# MEM0_LLM_MODEL=google/gemini-2.0-flash-001
# MEM0_LLM_API_KEY= # Optional; mem0's OpenAI client prefers OPENROUTER_API_KEY
# MEM0_LLM_TEMPERATURE=0.1
# MEM0_LLM_MAX_TOKENS=1000
#
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ observable foundation that can run on bare metal, a VPS, or a private cloud.
### Key Technologies
* **Language:** Python 3.13+
* **Framework:** Google ADK (`google-adk`)
* **Model Interface:** LiteLLM (supports Google, OpenRouter, etc.)
* **Model Interface:** OpenRouter (OpenAI-compatible HTTP) with native Google ADK Gemini fallback
* **Server:** FastAPI
* **Database:** PostgreSQL (via `asyncpg`)
* **Observability:** OpenTelemetry (OTel) with vendor-neutral OTLP configuration
Expand Down Expand Up @@ -219,7 +219,7 @@ When asked to write or modify tests, you **MUST** adhere to the following strict

1. **Real Code Over Mocks**:
* **Do not mock** internal logic (e.g., `LlmAgent`, `Prompt`, `Tool`). Use the real classes.
* **Only mock** external boundaries (e.g., `LiteLLM`, `asyncpg`, `Network APIs`).
* **Only mock** external boundaries (e.g., `OpenAI SDK`, `asyncpg`, `Network APIs`).
* **Why?** This ensures we test the integration of components, not just isolated units.

2. **Pytest Best Practices**:
Expand Down
6 changes: 3 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ python -m blacki.server
Telegram long polling, and exposes `/live`, `/ready`, and `/health`.
It also mounts the package-backed private observability dashboard at
`/dashboard` with APIs for aggregate statistics, users, sessions, local logs,
and local traces. LiteLLM-backed requests additionally write content-free
and local traces. OpenRouter-backed requests additionally write content-free
usage and cost records to the local SQLite ledger so the dashboard can show
per-user and per-session spend without parsing prompts or responses.

`src/blacki/agent.py` creates the `LlmAgent`, selects a native Gemini model or a
LiteLLM/OpenRouter model from the environment, registers tools, and assembles
`src/blacki/agent.py` creates the `LlmAgent`, selects a native Gemini model or an
OpenRouter-routed model from the environment, registers tools, and assembles
the ADK application plugins.

## Application composition
Expand Down
5 changes: 3 additions & 2 deletions docs/base-infra/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,9 @@ ALLOW_ORIGINS=["http://127.0.0.1","http://127.0.0.1:8080"]
OPENROUTER_API_KEY=replace-me
```

Blacki builds a LiteLLM model and normalizes common model identifiers to
OpenRouter form when this key is present.
Blacki builds an OpenRouter adapter and normalizes common model
identifiers to OpenRouter form when this key is present. Both
`openrouter/author/slug` and `author/slug` are accepted.

Set `ROOT_AGENT_REASONING_EFFORT` to configure the process-wide reasoning
fallback. Supported values are `max`, `xhigh`, `high`, `medium`, `low`,
Expand Down
5 changes: 3 additions & 2 deletions docs/base-infra/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,13 @@ Telegram `/reset` starts the next versioned ADK session and leaves earlier
session rows available. The append-only log and trace files are not deleted by
`/reset`, so the dashboard continues to show that history.

For LiteLLM-backed requests, Blacki also writes a content-free SQLite usage
For OpenRouter-backed requests, Blacki also writes a content-free SQLite usage
ledger at `{AGENT_DIR}/.adk/costs.db` (override with
`BLACKI_COST_LEDGER_PATH`). The ledger stores identity, model, token, provider
response, and fixed-point cost fields, but never prompts, responses, or tool
arguments. Provider-reported OpenRouter account cost and upstream inference
cost are kept separately; a LiteLLM catalog calculation is labelled estimated.
cost are kept separately; historical gateway estimates remain labelled
estimated, while new OpenRouter rows use provider-reported costs only.
The dashboard uses UTC calendar months for monthly totals and averages. The
average is across users with an exact or estimated cost in the current month;
users with unavailable cost are excluded and the reported coverage is shown.
Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ dependencies = [
"opentelemetry-instrumentation-logging>=0.63b0,<0.64",
"pydantic>=2.11.0,<3.0.0",
"python-dotenv>=1.0.0,<2.0.0",
"litellm>=1.60.0",
"openai>=2.20,<3",
"aiosqlite>=0.20.0",
"greenlet>=3.0.0",
"openinference-instrumentation-google-adk==0.1.17",
Expand All @@ -40,7 +40,8 @@ server = "blacki.server:main"

[dependency-groups]
dev = [
"google-adk[eval]==2.5.0",
# `google-adk[eval]` is intentionally omitted: it pulls LiteLLM through
# google-cloud-aiplatform[evaluation]. Install it explicitly for `adk eval`.
"ruff>=0.12.7,<1.0.0",
"mypy>=1.17.1,<2.0.0",
"pytest>=8.3.4,<9.0.0",
Expand Down
4 changes: 2 additions & 2 deletions src/blacki/dashboard/static/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,9 @@
setText("metric-cost-coverage", formatPercent(costCoverage));
const costKind = first(metrics, ["cost_kind"], first(cost, ["kind"]));
if (costKind === "estimated") {
setText("performance-note", "Some costs are LiteLLM estimates; provider-reported OpenRouter costs are preferred when available.");
setText("performance-note", "Some historical costs are gateway estimates; provider-reported OpenRouter costs are preferred when available.");
} else if (costKind === "unavailable" || (monthlyCost === undefined && cumulativeCost === undefined)) {
setText("performance-note", "Exact cost is unavailable for records without provider cost metadata. New LiteLLM responses are recorded going forward.");
setText("performance-note", "Exact cost is unavailable for records without provider cost metadata. New OpenRouter responses are recorded going forward.");
} else if (costCoverage !== undefined && Number(costCoverage) < 1) {
setText("performance-note", `Provider-reported cost coverage: ${formatPercent(costCoverage)}. Older or incomplete records remain unavailable.`);
}
Expand Down
32 changes: 28 additions & 4 deletions src/blacki/memory/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,17 @@
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse

from blacki.models.openrouter_ids import (
canonical_openrouter_model_id,
is_openrouter_model_id,
)

if TYPE_CHECKING:
from mem0 import Memory

logger = logging.getLogger(__name__)

DEFAULT_LLM_MODEL = "openrouter/openai/gpt-oss-20b"
DEFAULT_LLM_MODEL = "openai/gpt-oss-20b"
DEFAULT_EMBEDDER_PROVIDER = "gemini"
DEFAULT_EMBEDDER_MODEL = "gemini-embedding-001"
DEFAULT_EMBEDDER_DIMS = 768
Expand Down Expand Up @@ -87,12 +92,31 @@ def _get_llm_config() -> dict[str, Any]:
else:
return {}

llm_provider = _get_env_value("MEM0_LLM_PROVIDER")
if not llm_provider:
llm_provider = "litellm" if openrouter_key else "openai"
llm_provider = _get_env_value("MEM0_LLM_PROVIDER") or "openai"
if llm_provider.lower() == "litellm":
if not openrouter_key:
raise ValueError(
"MEM0_LLM_PROVIDER=litellm is no longer supported. "
"Set MEM0_LLM_PROVIDER=openai or remove the override."
)
logger.warning(
"Mapping legacy MEM0_LLM_PROVIDER=litellm to openai for OpenRouter"
)
llm_provider = "openai"

if llm_provider.lower() == "openai":
llm_model = canonical_openrouter_model_id(llm_model)

llm_config: dict[str, Any] = {"model": llm_model}

if llm_provider.lower() == "openai" and openrouter_key:
if not is_openrouter_model_id(llm_model):
raise ValueError(
"MEM0_LLM_MODEL must be an OpenRouter model ID (author/slug) "
"when OPENROUTER_API_KEY is set."
)
llm_config["openrouter_base_url"] = "https://openrouter.ai/api/v1"

api_key = _get_env_value("MEM0_LLM_API_KEY")
if not api_key:
api_key = openrouter_key
Expand Down
27 changes: 16 additions & 11 deletions src/blacki/models/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

import httpx

from .openrouter_ids import OPENROUTER_PREFIX, canonical_openrouter_model_id

logger = logging.getLogger(__name__)

OPENROUTER_MODELS_BASE_URL = "https://openrouter.ai/api/v1/model"
Expand Down Expand Up @@ -71,24 +73,27 @@ def normalize_openrouter_model_id(
) -> str | None:
"""Return the OpenRouter API identifier for a model reference.

LiteLLM model strings use ``openrouter/<author>/<slug>`` while the
OpenRouter Models API uses ``<author>/<slug>``. Exactly one transport
prefix is removed. Bare provider IDs are accepted only when the caller
explicitly states that the model is routed through OpenRouter; this avoids
making a native Google or future provider lookup by accident.
Blacki model references historically use ``openrouter/<author>/<slug>``
while the OpenRouter Models API uses ``<author>/<slug>``. Exactly one
transport prefix is removed. Dynamic router identifiers such as
``openrouter/auto`` are already valid API IDs and are preserved. Bare
provider IDs are accepted only when the caller explicitly states that the
model is routed through OpenRouter; this avoids making a native Google or
future provider lookup by accident.
"""

if not isinstance(model_id, str):
return None

normalized = model_id.strip()
if not normalized:
stripped = model_id.strip()
if not stripped:
return None

prefix = "openrouter/"
if normalized.lower().startswith(prefix):
normalized = normalized[len(prefix) :]
elif not openrouter_routed:
if stripped.lower().startswith(OPENROUTER_PREFIX):
normalized = canonical_openrouter_model_id(stripped)
elif openrouter_routed:
normalized = stripped
else:
return None

# OpenRouter model IDs are author/slug. Keep variant suffixes (for
Expand Down
68 changes: 40 additions & 28 deletions src/blacki/models/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,25 @@

import logging
import os
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING

from blacki.utils.exceptions import ConfigurationError

from .openrouter_ids import is_openrouter_model_id

logger = logging.getLogger("blacki.agent")

if TYPE_CHECKING:
from google.adk.models.lite_llm import LiteLlm
from .openrouter import OpenRouterLlm


def normalize_model_for_openrouter(model_name: str) -> str:
"""Map common model IDs to the OpenRouter/LiteLLM form."""
"""Map common model IDs to the OpenRouter transport form.

The ``openrouter/`` prefix is retained for compatibility with stored
Telegram preferences and documented deployment samples. The adapter
strips it before calling OpenRouter's API.
"""
normalized = model_name.strip()
lower = normalized.lower()
if lower.startswith("openrouter/"):
Expand All @@ -25,34 +34,37 @@ def normalize_model_for_openrouter(model_name: str) -> str:
return normalized


def build_model() -> str | LiteLlm:
"""Build the configured native or LiteLLM model."""
openrouter_api_key = os.getenv("OPENROUTER_API_KEY")

model_name = os.getenv("ROOT_AGENT_MODEL", "gemini-2.5-flash")
model: str | LiteLlm = model_name
def build_model() -> str | OpenRouterLlm:
"""Build the configured native Gemini or OpenRouter model."""
openrouter_api_key = os.getenv("OPENROUTER_API_KEY", "").strip()
model_name = os.getenv("ROOT_AGENT_MODEL", "gemini-2.5-flash").strip()

use_litellm = openrouter_api_key is not None or "/" in model_name.lower()
if openrouter_api_key:
model_name = normalize_model_for_openrouter(model_name)

if use_litellm:
try:
from google.adk.models import LiteLlm
normalized = normalize_model_for_openrouter(model_name)
if not is_openrouter_model_id(normalized):
raise ConfigurationError(
f"ROOT_AGENT_MODEL={model_name!r} is not a valid OpenRouter "
"model ID. Use an author/slug identifier such as "
"google/gemini-2.5-flash."
)
from .openrouter import OpenRouterLlm

litellm_kwargs: dict[str, Any] = {}
if model_name.lower().startswith("openrouter/") and openrouter_api_key:
litellm_kwargs["api_key"] = openrouter_api_key
from blacki.observability.costs import CostAwareLiteLLMClient
logger.info("Using OpenRouter model: %s", normalized)
return OpenRouterLlm(model=normalized, api_key=openrouter_api_key)

litellm_kwargs["llm_client"] = CostAwareLiteLLMClient()
if model_name.lower().startswith("openrouter/") or (
"/" in model_name and not model_name.startswith("models/")
):
raise ConfigurationError(
f"OPENROUTER_API_KEY is required for model {model_name!r}. "
"Set the key or use a native Gemini model such as "
"gemini-2.5-flash."
)

logger.info("Using LiteLlm for model: %s", model_name)
return LiteLlm(model=model_name, **litellm_kwargs)
except ImportError:
logger.warning(
"LiteLlm not available, falling back to string model name. "
"OpenRouter models may not work."
)
if not model_name.lower().startswith(("gemini-", "gemma-", "models/")):
raise ConfigurationError(
f"ROOT_AGENT_MODEL={model_name!r} is not a native Gemini model. "
"Set OPENROUTER_API_KEY for OpenRouter models."
)

return model
return model_name
10 changes: 5 additions & 5 deletions src/blacki/models/inference.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""Request-scoped model and reasoning controls for ADK turns.

The ADK ``LlmRequest`` does not expose a provider-neutral field for arbitrary
LiteLLM parameters. OpenRouter's reasoning object is therefore carried in
``GenerateContentConfig.http_options.extra_body`` and translated by ADK's
LiteLLM adapter at the request boundary. This module keeps the profile
immutable and request-scoped so concurrent Telegram turns cannot change one
another's model settings.
OpenRouter parameters. OpenRouter's reasoning object is therefore carried in
``GenerateContentConfig.http_options.extra_body`` and merged into the request
body by Blacki's OpenRouter adapter. This module keeps the profile immutable
and request-scoped so concurrent Telegram turns cannot change one another's
model settings.
"""

from __future__ import annotations
Expand Down
Loading
Loading