Skip to content
Closed
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
1 change: 1 addition & 0 deletions backend/routes/allowlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"/v1/workflows/",
"/project/",
"/memory/",
"/v2/memory/",
"/mcp/",
# Control plane (see the List Endpoints + Tables standard). Every resource
# eventually moves under this prefix, so allowlist it once rather than
Expand Down
101 changes: 101 additions & 0 deletions deploy/memory-pilot/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Memory gateway pilot on Render

Run this branch as an isolated forwarding gateway. Colleagues keep their existing
upstream LiteLLM key and model name and change only their gateway base URL. They
need no plugin or client-side memory tools. Choosing the pilot URL opts them into
the pilot; returning to the original URL stops using and collecting pilot memory.

Every preparation and answer call uses that caller's upstream key. The upstream
gateway continues to enforce its model permissions, budgets, rate limits, and
guardrails. The pilot checks the key against the upstream model catalog, then
registers its hash as a local virtual key so LiteLLM's normal authentication and
memory authorization still apply. No upstream key or provider credential is
configured on Render. The administrator credential belongs only to this pilot.

The forwarding pilot isolates memories by virtual key. Upstream management APIs
may deny ordinary keys access to user/team/org details, so the pilot does not
infer those identities from client metadata. Install the feature directly in an
organization's gateway to use its existing user/team/project/org policies.
Never connect this pilot to an older gateway's production database.

## Create the service

1. Create a separate Render Postgres 16 database in the same region as the web
service. Restrict public database access; use its internal connection URL.
2. Create a Python web service from this repository and the Memory V2 branch.
A Standard service and the smallest paid Postgres plan are sufficient starting
points for a small pilot. They incur Render hosting charges.
3. Set the build command to `bash deploy/memory-pilot/build.sh`, the start command
to `bash deploy/memory-pilot/start.sh`, and the health path to
`/health/readiness`. The build includes the dashboard from this branch.
4. Set these environment variables in Render:

| Variable | Value |
| --- | --- |
| `DATABASE_URL` | The new database's internal connection URL |
| `UPSTREAM_LITELLM_BASE_URL` | Your original gateway URL, without `/v1` |
| `LITELLM_MASTER_KEY` | A new random `sk-` administrator key |
| `LITELLM_SALT_KEY` | A separate random encryption secret; preserve it across deploys |
| `PYTHON_VERSION` | `3.12.14` |
| `NODE_VERSION` | `24.19.0` |
| `NEXT_TELEMETRY_DISABLED` | `1` |
| `PORT` | `4000` |

5. After deployment, open `/ui/memory`, sign in as `admin` using the pilot's master
key, and save a policy for **Whole gateway**, **Enabled automatically**,
**Private to each virtual key**. This policy persists across restarts. Memory
stays disabled until an administrator enables it.

Equivalent activation through the API, with secrets supplied in shell variables:

```bash
curl --fail-with-body "$PILOT_URL/v2/memory/policies" \
-H "Authorization: Bearer $PILOT_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-X PUT \
-d '{"target_type":"gateway","target_id":"*","activation":"automatic","scope":"key"}'
```

Administrators can instead require opt-in, disable a particular registered key,
or disable the whole gateway. Under an opt-in policy, callers set their preference
with `PUT /v2/memory/preference` and `{"enabled":true}` using their own key.

## Try it

Set an OpenAI-compatible client's base URL to `https://YOUR-SERVICE.onrender.com/v1`.
For Claude Code, set `ANTHROPIC_BASE_URL` to `https://YOUR-SERVICE.onrender.com`.
Retain the same gateway key and model setting.

In one conversation, say “Remember that my demo project is Cobalt Heron and its
staging port is 8347.” In a **new conversation**, ask “What is my demo project and
its staging port?” Check actual saved entries with `GET /v2/memory/entries` using
the same key. An unrelated key must not see them. Administrators can inspect,
correct, or delete entries in Memory; callers can use the self-service API.

## Behavior and limits

- Supported surfaces: Chat Completions, Responses, and Anthropic Messages,
including their native streaming responses and client tool continuation.
- The selected model must support function calling. Memory preparation adds up
to three billed model calls before the visible answer, with a 60-second bound.
It uses the original conversation, so long coding sessions can add substantial
prompt-token usage and latency. Existing upstream quotas apply to these calls.
- Preparation stores durable facts supported by the conversation, then searches
and reads relevant entries. Search is bounded keyword matching in Postgres.
There is no vector database, extraction model, scheduler, or nightly process.
- On gateway/backend deployments without shared Redis, first-time activation
can take up to 30 seconds to reach another process. Policy revocation is
checked against the primary database before memory operations.
- Each memory scope can hold up to 1,000 entries. Creation checks this limit
atomically; correction and deletion remain available when the scope is full.
- Stored references are untrusted data. They cannot grant API permissions or
change the namespace derived from authentication. Current user corrections
take precedence. Replacements require the current revision.
- Memory/model preparation errors fail the request rather than silently claiming
successful memory. Administrators can disable memory to restore ordinary calls.
- Switching away or disabling memory stops automatic use; it does not delete
existing entries. Delete memories explicitly through Memory or the API.
- Shared upstream keys share a pilot namespace. Give each person a distinct key
when their memories must be private from each other.
- Other API surfaces are outside this forwarding pilot. Use the original gateway
for embeddings, images, realtime, batches, and administration.
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
Comment thread
moe-berri marked this conversation as resolved.
Resolved
19 changes: 19 additions & 0 deletions deploy/memory-pilot/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
# Render's preinstalled Rust toolchain directory is read-only. Maturin needs
# writable homes to install the repository's pinned toolchain during uv sync.
export RUSTUP_HOME="$PWD/.memory-pilot-rustup"
export CARGO_HOME="$PWD/.memory-pilot-cargo"
export PRISMA_BINARY_CACHE_DIR="$PWD/.memory-pilot-prisma"
python -m pip install uv==0.11.7
export UV_PROJECT_ENVIRONMENT="$PWD/.memory-pilot-venv"
uv sync --frozen --extra proxy --extra extra_proxy --no-default-groups
export PATH="$UV_PROJECT_ENVIRONMENT/bin:$PATH"
prisma generate --schema schema.prisma
(
cd ui/litellm-dashboard
npm ci
npm run build
)
rm -rf litellm/proxy/_experimental/out
cp -R ui/litellm-dashboard/out litellm/proxy/_experimental/out
1 change: 1 addition & 0 deletions deploy/memory-pilot/hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from pilot import forward_credential as forward_credential
144 changes: 144 additions & 0 deletions deploy/memory-pilot/pilot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""An isolated office pilot that preserves upstream gateway credentials."""

import asyncio
import hashlib
import os
import secrets
from contextvars import ContextVar
from typing import Final

import httpx
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Receive, Scope, Send

from litellm.caching.caching import DualCache
from litellm.integrations.custom_logger import CustomLogger
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.proxy._types import UI_TEAM_ID, UserAPIKeyAuth
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken
from litellm.proxy.auth.user_api_key_auth import _get_bearer_token_or_received_api_key
from litellm.repositories.verification_token_repository import VerificationTokenRepository
from litellm.types.llms.custom_http import httpxSpecialProvider
from litellm.types.utils import CallTypesLiteral

_UPSTREAM: Final = os.environ["UPSTREAM_LITELLM_BASE_URL"].rstrip("/")
_CREDENTIAL: Final[ContextVar[str | None]] = ContextVar("memory_pilot_credential", default=None)
_INFERENCE: Final = frozenset(
("/chat/completions", "/v1/chat/completions", "/responses", "/v1/responses", "/v1/messages")
)
_SELF_SERVICE: Final = frozenset(("/v2/memory/status", "/v2/memory/preference", "/v2/memory/entries"))


class ForwardCredential(CustomLogger):
async def async_pre_call_hook(
self, user_api_key_dict: UserAPIKeyAuth, cache: DualCache, data: dict[str, object], call_type: CallTypesLiteral
) -> dict[str, object]:
credential: Final = _CREDENTIAL.get()
if credential is None:
raise HTTPException(status_code=403, detail="Use your upstream gateway key for model calls")
return {**data, "api_key": credential, "api_base": _UPSTREAM}


forward_credential: Final = ForwardCredential()


class PilotGateway:
def __init__(self, app: ASGIApp) -> None:
self.app = app
self.validation_slots = asyncio.Semaphore(16)
self.upstream = get_async_httpx_client(
httpxSpecialProvider.PassThroughEndpoint,
params={"timeout": 20, "client_alias": "memory-pilot-upstream"},
)

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "lifespan":
try:
await self.app(scope, receive, send)
finally:
await self.upstream.close()
return
if scope["type"] != "http":
await self.app(scope, receive, send)
return
request: Final = Request(scope, receive)
credential: Final = _get_bearer_token_or_received_api_key(
request.headers.get("x-litellm-api-key")
or request.headers.get("authorization")
or request.headers.get("x-api-key")
or ""
)
from litellm.proxy.proxy_server import master_key, prisma_client

if not credential or master_key and secrets.compare_digest(credential, master_key):
await self.app(scope, receive, send)
return
if prisma_client is None:
await JSONResponse({"error": "Pilot database unavailable"}, status_code=503)(scope, receive, send)
return
digest: Final = hashlib.sha256(credential.encode()).hexdigest()
tokens: Final = VerificationTokenRepository(prisma_client)
local_key: Final = await tokens.find_by_id(digest)
if local_key and local_key.team_id == UI_TEAM_ID:
await self.app(scope, receive, send)
return
if (
not credential.startswith("sk-")
and ExperimentalUIJWTToken.get_key_object_from_ui_hash_key(credential) is not None
):
await self.app(scope, receive, send)
return
path: Final = request.url.path.rstrip("/")
if path not in _INFERENCE | _SELF_SERVICE | {"/models", "/v1/models"} and not path.startswith(
"/v2/memory/entries/"
):
await JSONResponse(
{"error": "Upstream keys can only use inference and their own memories"}, status_code=403
)(scope, receive, send)
return
try:
await asyncio.wait_for(self.validation_slots.acquire(), timeout=0.05)
except TimeoutError:
await JSONResponse(
{"error": "Pilot credential validation is busy; retry shortly"},
status_code=503,
headers={"Retry-After": "1"},
)(scope, receive, send)
return
try:
models: Final = await self.upstream.get(
Comment thread
veria-ai[bot] marked this conversation as resolved.
Resolved
_UPSTREAM + "/v1/models", headers={"Authorization": "Bearer " + credential}
)
except httpx.HTTPError:
await JSONResponse({"error": "Upstream gateway unavailable"}, status_code=503)(scope, receive, send)
return
finally:
self.validation_slots.release()
if models.is_error:
await JSONResponse({"error": "Upstream gateway rejected this key"}, status_code=models.status_code)(
scope, receive, send
)
return
if path in ("/models", "/v1/models"):
Comment thread
moe-berri marked this conversation as resolved.
Resolved
await JSONResponse(models.json())(scope, receive, send)
return
await tokens.table.upsert(
where={"token": digest},
data={
"create": {"token": digest, "models": [], "key_alias": "Memory pilot " + digest[:8]},
"update": {},
},
)
token: Final = _CREDENTIAL.set(credential)
try:
await self.app(scope, receive, send)
finally:
_CREDENTIAL.reset(token)
Comment thread
moe-berri marked this conversation as resolved.
Resolved


def create_app() -> PilotGateway:
from litellm.proxy.proxy_server import app

return PilotGateway(app)
15 changes: 15 additions & 0 deletions deploy/memory-pilot/proxy_config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
model_list:
- model_name: "*"
litellm_params:
model: litellm_proxy/*
api_base: os.environ/UPSTREAM_LITELLM_BASE_URL
model_info:
supports_function_calling: true
litellm_settings:
callbacks:
- hooks.forward_credential
drop_params: true
turn_off_message_logging: true
general_settings:
master_key: os.environ/LITELLM_MASTER_KEY
store_model_in_db: true
9 changes: 9 additions & 0 deletions deploy/memory-pilot/start.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
export PATH="$PWD/.memory-pilot-venv/bin:$PATH"
export PRISMA_BINARY_CACHE_DIR="$PWD/.memory-pilot-prisma"
export PRISMA_CLI_PATH="$PRISMA_BINARY_CACHE_DIR/node_modules/.bin/prisma"
prisma migrate deploy --schema litellm-proxy-extras/litellm_proxy_extras/schema.prisma
export WORKER_CONFIG="$PWD/deploy/memory-pilot/proxy_config.yaml"
export PYTHONPATH="$PWD/deploy/memory-pilot${PYTHONPATH:+:$PYTHONPATH}"
exec uvicorn pilot:create_app --factory --host 0.0.0.0 --port "${PORT:-4000}"
3 changes: 3 additions & 0 deletions gateway/routes/allowlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"/containers",
"/v1/evals",
"/v1/memory",
"/v2/memory/entries",
"/queue/chat/",
# Google data plane (v1beta is the Google AI Studio version)
"/v1beta/",
Expand Down Expand Up @@ -123,6 +124,8 @@
"/redoc",
"/test",
"/debug/memory/summary",
"/v2/memory/status",
"/v2/memory/preference",
}
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
ALTER TABLE "LiteLLM_MemoryTable" ADD COLUMN IF NOT EXISTS "namespace" TEXT;

CREATE INDEX IF NOT EXISTS "LiteLLM_MemoryTable_namespace_updated_at_idx"
ON "LiteLLM_MemoryTable"("namespace", "updated_at");

CREATE TABLE IF NOT EXISTS "LiteLLM_MemoryPolicy" (
"policy_id" TEXT NOT NULL,
"target_type" TEXT NOT NULL,
"target_id" TEXT NOT NULL,
"activation" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_by" TEXT NOT NULL,
CONSTRAINT "LiteLLM_MemoryPolicy_pkey" PRIMARY KEY ("policy_id")
);

CREATE UNIQUE INDEX IF NOT EXISTS "LiteLLM_MemoryPolicy_target_type_target_id_key"
ON "LiteLLM_MemoryPolicy"("target_type", "target_id");

CREATE TABLE IF NOT EXISTS "LiteLLM_MemoryPreference" (
"subject" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LiteLLM_MemoryPreference_pkey" PRIMARY KEY ("subject")
);
21 changes: 21 additions & 0 deletions litellm-proxy-extras/litellm_proxy_extras/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -1428,6 +1428,7 @@ model LiteLLM_ClaudeCodePluginTable {
// participate in the unique constraint.
model LiteLLM_MemoryTable {
memory_id String @id @default(uuid())
namespace String?
key String @unique
value String
metadata Json?
Expand All @@ -1440,6 +1441,26 @@ model LiteLLM_MemoryTable {

@@index([user_id])
@@index([team_id])
@@index([namespace, updated_at])
}

model LiteLLM_MemoryPolicy {
policy_id String @id
target_type String
target_id String
activation String
scope String
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
updated_by String

@@unique([target_type, target_id])
}

model LiteLLM_MemoryPreference {
subject String @id
enabled Boolean @default(false)
updated_at DateTime @default(now()) @updatedAt
}

// Per-(router, request_type, model) Beta posterior for the adaptive router.
Expand Down
Loading
Loading