অধ্যায় 4 · AI সার্ভিস ও নিরাপত্তা
স্ট্রিমিংসহ LLM ব্যাকএন্ড বানানো
- পৃষ্ঠা 15 / 22
- 23 মিনিট পড়া
একটা স্ক্রিপ্ট থেকে মডেল কল করা সহজ (AI-এর জন্য Python দেখুন)। কিন্তু সেই কল আসল ব্যবহারকারীদের জন্য চালানো একেবারে আলাদা কাজ: প্রোভাইডারের কি গোপন রাখতে হবে, একজন ব্যবহারকারী যেন সবার বাজেট খরচ করে না ফেলে, ধীর মডেল যেন আপনার সার্ভারকে জিম্মি করে না রাখে, যিনি ট্যাব বন্ধ করেছেন তাঁর জন্য যেন আর টাকা খরচ না হয়, আর কোন প্রম্পট ও কোন মডেল কোন উত্তর দিয়েছে এবং তার খরচ কত, সেটা আপনাকে জানতে হবে। এর সমাধান ক্লায়েন্ট ও মডেলের মাঝখানে একটা পাতলা ব্যাকএন্ড, LLM গেটওয়ে। এখানকার মডেলটা প্র্যাকটিস API-র নকল /v1/chat, তাই সবকিছু অফলাইনে চলে এবং প্রতিবার একই আউটপুট দেয়।
যা শিখবেন
- LLM ব্যাকএন্ডের আর্কিটেকচার এবং একটা প্রোভাইডার অ্যাডাপ্টার ইন্টারফেস, সঙ্গে OpenAI (Responses) ও Anthropic (Messages) অ্যাডাপ্টার।
- সেটিংস থেকে কি, সার্ভারের হাতে থাকা প্রম্পট ও মডেল কনফিগ, টাইমআউট, নিরাপদ রিট্রাই, এবং এরর ও ডিসকানেক্টসহ SSE স্ট্রিমিং।
- টোকেন ও খরচের লগ, প্রম্পট ও মডেলের ভার্সন, প্রতি ব্যবহারকারীর লিমিট, ইনপুট রিড্যাক্ট করা, এবং ব্যবহারের সারসংক্ষেপ এন্ডপয়েন্ট।
- মক করা প্রোভাইডার দিয়ে টেস্ট, প্রথম মূল্যায়ন (eval) স্ক্রিপ্ট, এবং হেলথ চেক।
আর্কিটেকচার
FastAPI আর্কিটেকচার: রাউটার, স্কিমা ও এরর হ্যান্ডলিং পাতার লেয়ারগুলো এখানেও রাখুন: রাউট HTTP-র সঙ্গে কথা বলে, সার্ভিস নিয়মগুলো ধরে রাখে, অ্যাডাপ্টার মডেল কোম্পানির সঙ্গে কথা বলে। তিনটা জিনিস ইচ্ছা করেই সার্ভারে থাকে: প্রোভাইডারের কি (ব্রাউজারে থাকা কি মানে সবার হাতে থাকা কি), সিস্টেম প্রম্পট ও মডেল (ক্লায়েন্ট নির্দেশনা বদলাতে বা দামি মডেল বেছে নিতে পারে না), আর লিমিট ও লগ (প্রতিটা রিকোয়েস্ট শুধু সার্ভারই দেখে)।
client --> routes: auth, schema check, daily-limit check (refuse BEFORE spending)
--> ChatService: redact, add system prompt, call provider, log, record
--> RetryingProvider --> PracticeProvider | OpenAIProvider | AnthropicProvider
--> UsageStore: tokens, cost, status per callপ্রজেক্ট সেট আপ
একটা নতুন ফোল্ডার, ডিপেন্ডেন্সি, লাইফস্প্যান, মিডলওয়্যার ও সেটিংস পাতার টুল দিয়ে। httpx2 হলো httpx-এর রক্ষণাবেক্ষণ করা ফর্ক (OpenAI ও Anthropic SDK এটা ব্যবহার করে); কোডে লেখা আছে import httpx, যা FastAPI-র স্ট্যান্ডার্ড ইনস্টলেই পাওয়া যায়।
mkdir -p ~/projects/llm-gateway
cd ~/projects/llm-gateway
uv init --bare -q
uv add -q "fastapi[standard]" pydantic-settings httpx2
uv add -q --dev pytest
# with pip: python -m venv .venv && source .venv/bin/activate && pip install -q "fastapi[standard]" pydantic-settings httpx2 pytest
mkdir -p app tests
touch app/__init__.py
cat >> pyproject.toml <<'EOF'
[tool.fastapi]
entrypoint = "app.main:app"
EOFসেটিংস: কি ও মডেল কনফিগ
প্রোভাইডারের কি একটা বাধ্যতামূলক SecretStr, কোনো ডিফল্ট নেই, তাই কি না থাকলে অ্যাপ স্টার্টআপেই থেমে যায়, প্রথম গ্রাহকের রিকোয়েস্টে নয়। মডেলের নাম, আউটপুটের সীমা ও টাইমআউট মিলে একটা ModelConfig হয়, যা এনভায়রনমেন্ট অনুযায়ী বদলানো যায় (LLM_LLM__TIMEOUT_S=5 ভেতরের ফিল্ডে পৌঁছায়)। দামগুলো কাল্পনিক; আসল দাম আপনার প্রোভাইডারের প্রাইসিং পাতা থেকে নিন।
# file: app/settings.py
from pydantic import BaseModel, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class ModelConfig(BaseModel):
name: str = "practice-llm-1"
max_output_tokens: int = 300
timeout_s: float = 10.0
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="LLM_", env_nested_delimiter="__", env_file=".env", extra="ignore")
provider_url: str = "http://localhost:8800"
provider_api_key: SecretStr # required: no default
llm: ModelConfig = ModelConfig()
prompt_version: str = "tutor-v2"
daily_token_limit: int = 120 # per user, per UTC day
price_in_per_m: float = 3.0 # made-up USD per million tokens
price_out_per_m: float = 15.0
api_keys: dict[str, str] = {"gw-nadia": "nadia", "gw-rafi": "rafi", "gw-admin": "admin"}চ্যাট স্কিমা ও মেসেজ
চ্যাট হলো রোলসহ মেসেজের তালিকা: system-এ থাকে আপনার নির্দেশনা, user-এ ব্যক্তি যা লিখেছেন, assistant-এ আগের উত্তর। মডেলের কোনো স্মৃতি নেই: প্রতিবার পুরো ইতিহাস পাঠাতে হয়, আর তার দামও প্রতিবার দিতে হয়। স্কিমা শুধু user ও assistant নেয়, দৈর্ঘ্য ও সংখ্যার সীমা দেয়, আর model-এর মতো বাড়তি ফিল্ড নিষিদ্ধ করে, ফলে ব্যবহারকারী system রোল লিখতে পারে না (প্রম্পট ইনজেকশনের বিরুদ্ধে প্রথম প্রতিরক্ষা)।
# file: app/schemas.py
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class ChatMessageIn(BaseModel):
role: Literal["user", "assistant"] # clients can never send a "system" message
content: str = Field(min_length=1, max_length=2000)
class ChatIn(BaseModel):
model_config = ConfigDict(extra="forbid") # no "model" field: the server decides which model to pay for
messages: list[ChatMessageIn] = Field(min_length=1, max_length=20)
class ChatOut(BaseModel):
answer: str
model: str
prompt_version: str
usage: dict[str, int | float]প্রোভাইডার ইন্টারফেস ও তার অ্যাডাপ্টার
প্রতিটা কোম্পানির রিকোয়েস্টের গঠন, টোকেনের নাম ও এরর আলাদা। এগুলোকে তিনটা মেথডের আড়ালে রাখুন: complete() উত্তর ও টোকেন ব্যবহার ফেরত দেয়, stream() লেখার টুকরো দেয় এবং শেষে ব্যবহারের হিসাব দেয়, ping() বলে প্রোভাইডারকে পাওয়া যাচ্ছে কি না। এরর একবারই, একেবারে সীমানায় অনুবাদ হয়: ProviderError শুধু বলে রিট্রাই কাজে লাগবে কি না (429, 500, 502, 503 ও 504-এ লাগে; 400 বা 401-এ লাগে না) এবং প্রোভাইডার কতক্ষণ অপেক্ষা করতে বলেছে (Retry-After)।
# file: app/providers.py
import asyncio
import json
import random
from collections.abc import AsyncIterator, Awaitable, Callable
from dataclasses import asdict, dataclass
from typing import Protocol
import httpx
from app.settings import ModelConfig
@dataclass
class Message:
role: str # "system", "user" or "assistant"
content: str
@dataclass
class Usage:
prompt_tokens: int
completion_tokens: int
@dataclass
class Completion:
text: str
usage: Usage
@dataclass
class StreamChunk:
text: str = "" # a piece of the answer ...
usage: Usage | None = None # ... or, last of all, the token counts
class ProviderError(Exception):
def __init__(self, message: str, *, retryable: bool = False, retry_after: float | None = None,
timeout: bool = False) -> None:
super().__init__(message)
self.retryable, self.retry_after, self.timeout = retryable, retry_after, timeout
class LLMProvider(Protocol):
async def complete(self, messages: list[Message], config: ModelConfig) -> Completion: ...
def stream(self, messages: list[Message], config: ModelConfig) -> AsyncIterator[StreamChunk]: ...
async def ping(self) -> bool: ...
def sdk_error(exc: Exception, retryable: set[int]) -> ProviderError:
status = getattr(exc, "status_code", None) # None: the network failed, not the API
return ProviderError(f"status {status}", retryable=status is None or status in retryable,
timeout=type(exc).__name__ == "APITimeoutError")
def http_error(exc: httpx.HTTPError) -> ProviderError:
return ProviderError("provider failed", retryable=True, timeout=isinstance(exc, httpx.TimeoutException))
def check_status(response: httpx.Response) -> None:
if response.status_code >= 400:
after = response.headers.get("Retry-After", "")
raise ProviderError(f"status {response.status_code}", retryable=response.status_code in (429, 500, 502, 503, 504),
retry_after=float(after) if after.isdigit() else None)
class PracticeProvider:
def __init__(self, client: httpx.AsyncClient) -> None:
self.client = client
@staticmethod
def _body(messages: list[Message], config: ModelConfig, stream: bool) -> dict:
return {"model": config.name, "messages": [asdict(m) for m in messages], "stream": stream}
async def complete(self, messages: list[Message], config: ModelConfig) -> Completion:
try:
response = await self.client.post("/v1/chat", json=self._body(messages, config, False))
except httpx.TransportError as exc:
raise http_error(exc) from exc
check_status(response)
data = response.json()
return Completion(data["message"]["content"], Usage(data["usage"]["prompt_tokens"], data["usage"]["completion_tokens"]))
async def stream(self, messages: list[Message], config: ModelConfig) -> AsyncIterator[StreamChunk]:
try:
async with self.client.stream("POST", "/v1/chat", json=self._body(messages, config, True)) as response:
if response.status_code >= 400:
await response.aread()
check_status(response)
async for line in response.aiter_lines():
if line.startswith("data: "):
data = json.loads(line.removeprefix("data: "))
if data.get("done"):
yield StreamChunk(usage=Usage(data["usage"]["prompt_tokens"], data["usage"]["completion_tokens"]))
else:
yield StreamChunk(text=data["delta"])
except httpx.TransportError as exc:
raise http_error(exc) from exc
async def ping(self) -> bool:
try:
return (await self.client.get("/health", timeout=2.0)).status_code == 200
except httpx.HTTPError:
return False
class RetryingProvider:
def __init__(self, inner: LLMProvider, *, max_attempts: int = 3, base_delay: float = 0.5,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
jitter: Callable[[], float] = lambda: random.uniform(0.5, 1.0)) -> None:
self.inner, self.max_attempts, self.base_delay = inner, max_attempts, base_delay
self.sleep, self.jitter = sleep, jitter
async def _pause(self, attempt: int, exc: ProviderError) -> None:
wanted = exc.retry_after if exc.retry_after is not None else self.base_delay * 2 ** (attempt - 1)
await self.sleep(min(8.0, wanted) * self.jitter())
async def complete(self, messages: list[Message], config: ModelConfig) -> Completion:
for attempt in range(1, self.max_attempts + 1):
try:
return await self.inner.complete(messages, config)
except ProviderError as exc:
if not exc.retryable or attempt == self.max_attempts:
raise
await self._pause(attempt, exc)
raise AssertionError("unreachable")
async def stream(self, messages: list[Message], config: ModelConfig) -> AsyncIterator[StreamChunk]:
for attempt in range(1, self.max_attempts + 1):
started = False
try:
async for chunk in self.inner.stream(messages, config):
started = True
yield chunk
return
except ProviderError as exc:
if started or not exc.retryable or attempt == self.max_attempts:
raise # the client already has part of the answer: never start over
await self._pause(attempt, exc)
async def ping(self) -> bool:
return await self.inner.ping()RetryingProvider-এ তিনটা নিয়ম আছে। শুধু সেটাই রিট্রাই করুন, যা রিট্রাইয়ে ঠিক হয়। ব্যাকঅফ করুন এবং প্রোভাইডারের কথা মানুন: বিরতি দ্বিগুণ হয়, একটা সীমায় থামে, জিটার থাকে এবং Retry-After মানে (দেখুন মজবুত API ডিজাইন: পেজিনেশন, রেট লিমিট, রিট্রাই ও ভার্সনিং)। শুরু হয়ে যাওয়া স্ট্রিম কখনো রিট্রাই করবেন না: ক্লায়েন্টের হাতে তখনই অর্ধেক উত্তর আছে। অফিশিয়াল SDK ব্যবহার করলে তার নিজের রিট্রাই শূন্য করে দিন (max_retries=0), যাতে দুই স্তর একই কল দুইবার রিট্রাই না করে।
আসল অ্যাডাপ্টার (এখানে চালানো হয় না)
এগুলোর জন্য পেইড কি লাগে, তাই এখানে চালানো হয় না। OpenAI নতুন প্রজেক্টের জন্য Responses API সুপারিশ করে (Chat Completions এখনো সমর্থিত): নির্দেশনা ইনপুট থেকে আলাদা থাকে, আর স্ট্রিমে response.output_text.delta ও response.completed-এর মতো টাইপ করা ইভেন্ট আসে। মডেলের নাম নিন আপনার প্রোভাইডারের তালিকা থেকে, আর temperature-এর মতো স্যাম্পলিং প্যারামিটার বাদ রাখুন, যদি না মডেল সেটা মানে।
# app/provider_openai.py (uv add openai)
from openai import APIConnectionError, APIStatusError, AsyncOpenAI
from app.providers import Completion, ProviderError, StreamChunk, Usage, sdk_error
RETRYABLE = {429, 500, 502, 503, 504}
class OpenAIProvider:
def __init__(self, api_key: str, timeout_s: float) -> None:
self.client = AsyncOpenAI(api_key=api_key, timeout=timeout_s, max_retries=0)
@staticmethod
def _args(messages, config) -> dict:
return {
"model": config.name,
"instructions": "\n".join(m.content for m in messages if m.role == "system"),
"input": [{"role": m.role, "content": m.content} for m in messages if m.role != "system"],
"max_output_tokens": config.max_output_tokens,
}
async def complete(self, messages, config) -> Completion:
try:
response = await self.client.responses.create(**self._args(messages, config))
except (APIStatusError, APIConnectionError) as exc:
raise sdk_error(exc, RETRYABLE) from exc
return Completion(response.output_text, Usage(response.usage.input_tokens, response.usage.output_tokens))
async def stream(self, messages, config):
try:
events = await self.client.responses.create(**self._args(messages, config), stream=True)
async for event in events:
if event.type == "response.output_text.delta":
yield StreamChunk(text=event.delta)
elif event.type == "response.completed":
usage = event.response.usage
yield StreamChunk(usage=Usage(usage.input_tokens, usage.output_tokens))
elif event.type == "error":
raise ProviderError(event.message)
except (APIStatusError, APIConnectionError) as exc:
raise sdk_error(exc, RETRYABLE) from excAnthropic-এর Messages API system আলাদা নেয় এবং max_tokens বাধ্যতামূলক। এর স্ট্রিম ইভেন্ট হলো message_start, content_block_start, content_block_delta, content_block_stop, message_delta ও message_stop, সঙ্গে ping ও error; SDK-র text_stream লেখার ডেল্টাগুলো পড়ে এবং error ইভেন্টে এরর তোলে। এখানে শুধু যেটুকু আলাদা সেটুকু দেখানো হয়েছে; complete() ও ping() একই ধাঁচে চলে, যথাক্রমে client.messages.create ও client.models.list দিয়ে।
# app/provider_anthropic.py (uv add anthropic)
from anthropic import APIConnectionError, APIStatusError, AsyncAnthropic
from app.providers import StreamChunk, Usage, sdk_error
RETRYABLE = {429, 500, 502, 503, 504, 529} # 529: Anthropic is overloaded
class AnthropicProvider:
def __init__(self, api_key: str, timeout_s: float) -> None:
self.client = AsyncAnthropic(api_key=api_key, timeout=timeout_s, max_retries=0)
@staticmethod
def _args(messages, config) -> dict:
return {
"model": config.name,
"max_tokens": config.max_output_tokens,
"system": "\n".join(m.content for m in messages if m.role == "system"),
"messages": [{"role": m.role, "content": m.content} for m in messages if m.role != "system"],
}
async def stream(self, messages, config):
try:
async with self.client.messages.stream(**self._args(messages, config)) as stream:
async for text in stream.text_stream:
yield StreamChunk(text=text)
final = await stream.get_final_message()
except (APIStatusError, APIConnectionError) as exc:
raise sdk_error(exc, RETRYABLE) from exc
yield StreamChunk(usage=Usage(final.usage.input_tokens, final.usage.output_tokens))প্রোভাইডার বদলাতে lifespan-এ একটা সেটিং থেকে অন্য অ্যাডাপ্টার বানান; আর কিছু বদলায় না। অনেক ভেন্ডর ও লোকাল সার্ভার OpenAI-সামঞ্জস্যপূর্ণ Chat Completions এন্ডপয়েন্টও দেয়, যা তৃতীয় একটা সহজ অ্যাডাপ্টার; এমন লেয়ার কী উপেক্ষা করে তা যাচাই করে নিন।
সংবেদনশীল ইনপুট রিড্যাক্ট করা
ব্যবহারকারীরা চ্যাটে ইমেইল, ফোন নম্বর, কখনো কখনো API কি পেস্ট করেন। সেই লেখা একবার তৃতীয় পক্ষের প্রোভাইডারে বা লগে পৌঁছালে আর ফেরানো যায় না, তাই দরজাতেই রিড্যাক্ট করুন। প্যাটার্ন ধরে ফরম্যাট, অর্থ নয় (নাম ফসকে যায়): এটা ন্যূনতম সুরক্ষা, গ্যারান্টি নয়।
# file: app/redact.py
import re
PATTERNS = {
"EMAIL": re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"),
"KEY": re.compile(r"\b(?:sk|gw|token)-[A-Za-z0-9_-]{8,}"),
"PHONE": re.compile(r"(?<!\w)(?:\+?880|0)1\d{9}\b"), # Bangladesh mobile numbers
}
def redact(text: str) -> str:
for label, pattern in PATTERNS.items():
text = pattern.sub(f"[{label}]", text)
return textসার্ভিস: যেখানে নিয়মগুলো থাকে
prepare() টাকা খরচের আগে চলে: প্রতি ব্যবহারকারীর দৈনিক টোকেন লিমিট, রিড্যাকশন, আর সার্ভারের সিস্টেম প্রম্পট, যা ভার্সন কি (tutor-v2) দিয়ে খুঁজে নেওয়া হয় (প্রম্পটের ভার্সন হয় কোডের মতো, জায়গায় বসে কখনো বদলানো হয় না)। complete() একটা ডেডলাইন যোগ করে (asyncio.timeout), টোকেন ও খরচ রেকর্ড করে, এবং প্রোভাইডারের এররকে নিরাপদ গেটওয়ে এররে (504, 503 বা 502) বদলে দেয়: ক্লায়েন্ট প্রোভাইডারের নিজের বার্তা কখনো দেখে না। stream() প্রথমে token ইভেন্ট দেয়, তারপর ব্যবহার ও খরচসহ done, অথবা একটা error ইভেন্ট। এর finally চলে স্ট্রিম শেষ হলে, ব্যর্থ হলে অথবা ক্লায়েন্ট ডিসকানেক্ট করলে, তাই বাতিল হওয়া উত্তরও রেকর্ড হয়, cancelled চিহ্নিত একটা আনুমানিক হিসাব হিসেবে।
# file: app/service.py
import asyncio
import json
import logging
from collections.abc import AsyncIterator
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
from app.providers import LLMProvider, Message, ProviderError, Usage
from app.redact import redact
from app.schemas import ChatOut
from app.settings import Settings
logging.basicConfig(format="%(message)s") # root stays at WARNING, so other libraries stay quiet
log = logging.getLogger("llm")
log.setLevel(logging.INFO)
PROMPTS = {
"tutor-v1": "You are a BitByte tutor. Answer in one short paragraph.",
"tutor-v2": "You are a BitByte tutor. Answer in one short paragraph and never repeat personal data.",
}
class GatewayError(Exception):
def __init__(self, status: int, code: str, message: str, headers: dict[str, str] | None = None) -> None:
super().__init__(message)
self.status, self.code, self.message, self.headers = status, code, message, headers or {}
def to_gateway_error(exc: ProviderError | TimeoutError) -> GatewayError:
if isinstance(exc, TimeoutError) or exc.timeout:
return GatewayError(504, "provider_timeout", "The model took too long to answer")
if exc.retryable:
return GatewayError(503, "provider_busy", "The model provider is busy, try again shortly")
return GatewayError(502, "provider_error", "The model provider could not handle this request")
@dataclass
class CallRecord:
user: str
model: str
prompt_version: str
prompt_tokens: int
completion_tokens: int
cost_usd: float
status: str # "ok", "error" or "cancelled"
day: str = field(default_factory=lambda: datetime.now(UTC).date().isoformat())
def totals(records: list[CallRecord]) -> dict:
return {"requests": len(records), "cancelled": sum(r.status == "cancelled" for r in records),
"prompt_tokens": sum(r.prompt_tokens for r in records),
"completion_tokens": sum(r.completion_tokens for r in records),
"cost_usd": round(sum(r.cost_usd for r in records), 6)}
class UsageStore:
def __init__(self) -> None:
self.records: list[CallRecord] = []
def today(self, user: str) -> list[CallRecord]:
day = datetime.now(UTC).date().isoformat()
return [r for r in self.records if r.user == user and r.day == day]
def tokens_today(self, user: str) -> int:
return sum(r.prompt_tokens + r.completion_tokens for r in self.today(user))
def by_prompt_version(self) -> dict[str, dict]:
groups: dict[str, list[CallRecord]] = {}
for record in self.records:
groups.setdefault(f"{record.prompt_version} / {record.model}", []).append(record)
return {key: totals(records) for key, records in groups.items()}
@dataclass
class Prepared:
user: str
messages: list[Message]
class ChatService:
def __init__(self, provider: LLMProvider, store: UsageStore, settings: Settings) -> None:
self.provider, self.store, self.settings = provider, store, settings
def prepare(self, user: str, incoming: list[Message]) -> Prepared:
if self.store.tokens_today(user) >= self.settings.daily_token_limit:
now = datetime.now(UTC)
wait = 86400 - (now.hour * 3600 + now.minute * 60 + now.second) # seconds until the next UTC day
raise GatewayError(429, "usage_limit_exceeded", f"Daily limit of {self.settings.daily_token_limit} tokens reached",
{"Retry-After": str(wait)})
system = Message("system", PROMPTS[self.settings.prompt_version])
return Prepared(user, [system, *(Message(m.role, redact(m.content)) for m in incoming)])
def _usage_out(self, usage: Usage) -> dict:
cost = (usage.prompt_tokens * self.settings.price_in_per_m
+ usage.completion_tokens * self.settings.price_out_per_m) / 1_000_000
return {"prompt_tokens": usage.prompt_tokens, "completion_tokens": usage.completion_tokens,
"total_tokens": usage.prompt_tokens + usage.completion_tokens, "cost_usd": round(cost, 6)}
def _record(self, prepared: Prepared, usage: Usage, status: str) -> None:
out = self._usage_out(usage)
record = CallRecord(prepared.user, self.settings.llm.name, self.settings.prompt_version, usage.prompt_tokens,
usage.completion_tokens, out["cost_usd"], status)
self.store.records.append(record)
log.info(json.dumps({"event": "llm_call", **asdict(record)}))
async def complete(self, prepared: Prepared) -> ChatOut:
try:
async with asyncio.timeout(self.settings.llm.timeout_s):
result = await self.provider.complete(prepared.messages, self.settings.llm)
except (ProviderError, TimeoutError) as exc:
self._record(prepared, Usage(0, 0), "error")
raise to_gateway_error(exc) from exc
self._record(prepared, result.usage, "ok")
return ChatOut(answer=result.text, model=self.settings.llm.name, prompt_version=self.settings.prompt_version,
usage=self._usage_out(result.usage))
async def stream(self, prepared: Prepared) -> AsyncIterator[tuple[str, dict]]:
usage, pieces, status = None, 0, "cancelled" # stays so if the client leaves
try:
async for chunk in self.provider.stream(prepared.messages, self.settings.llm):
if chunk.usage:
usage = chunk.usage
else:
pieces += 1
yield "token", {"text": chunk.text}
status = "ok"
yield "done", {"usage": self._usage_out(usage or Usage(0, pieces)),
"prompt_version": self.settings.prompt_version}
except ProviderError as exc:
status = "error"
error = to_gateway_error(exc)
yield "error", {"code": error.code, "message": error.message}
finally: # also runs when the client disconnects
if usage is None: # no final count: estimate from the words
usage = Usage(sum(len(m.content.split()) for m in prepared.messages), pieces)
self._record(prepared, usage, status)খোলাখুলি সীমাবদ্ধতা: লিমিট চেক এক রিকোয়েস্ট বেশি অনুমোদন করে ফেলতে পারে এবং একাধিক ওয়ার্কারে রেস কন্ডিশন হয় (অ্যাটমিক Redis কাউন্টার দিয়ে টোকেন আগেই রিজার্ভ করুন, যেমন Redis দিয়ে ক্যাশিং, রেট লিমিটিং ও নির্ভরযোগ্যতা পাতায়), আর বিলিংয়ের ডেটা মেমরিতে নয়, ডেটাবেসে রাখা উচিত (SQLAlchemy ও Alembic দিয়ে ডেটাবেস)।
রাউট
মূল কৌশল হলো admitted, একটা ডিপেন্ডেন্সি, যা রাউট শুরুর আগে লিমিট চেক চালায়: স্ট্রিম প্রথম বাইট পাঠানোর পর স্ট্যাটাস আগেই 200 হয়ে যায়, তাই আসল 429 পেতে প্রত্যাখ্যান আগে আসতে হবে। স্ট্রিমিংয়ে ওয়েবহুক ও সার্ভার-সেন্ট ইভেন্ট দিয়ে স্ট্রিমিং পাতার বিল্ট-ইন SSE ব্যবহার হয়েছে। /health বলে প্রসেস বেঁচে আছে; /health/ready বলে সে কাজ করতে পারবে (এখানে: প্রোভাইডার সাড়া দিচ্ছে), যা লোড ব্যালান্সারের দরকার।
# file: app/main.py
from collections.abc import AsyncIterable
from contextlib import asynccontextmanager
from typing import Annotated
import httpx
from fastapi import APIRouter, Depends, FastAPI, Header, Request
from fastapi.responses import JSONResponse
from fastapi.sse import EventSourceResponse, ServerSentEvent
from app.providers import LLMProvider, Message, PracticeProvider, RetryingProvider
from app.schemas import ChatIn, ChatOut
from app.service import ChatService, GatewayError, Prepared, UsageStore, totals
from app.settings import Settings
router = APIRouter()
def get_settings(request: Request) -> Settings:
return request.app.state.settings
def get_provider(request: Request) -> LLMProvider:
return request.app.state.provider
SettingsDep = Annotated[Settings, Depends(get_settings)]
ProviderDep = Annotated[LLMProvider, Depends(get_provider)]
def get_service(request: Request, provider: ProviderDep, settings: SettingsDep) -> ChatService:
return ChatService(provider, request.app.state.store, settings)
ServiceDep = Annotated[ChatService, Depends(get_service)]
def current_user(settings: SettingsDep, authorization: Annotated[str | None, Header()] = None) -> str:
name = settings.api_keys.get((authorization or "").removeprefix("Bearer "))
if name is None:
raise GatewayError(401, "unauthorized", "Missing or invalid API key", {"WWW-Authenticate": "Bearer"})
return name
UserDep = Annotated[str, Depends(current_user)]
def admitted(body: ChatIn, user: UserDep, service: ServiceDep) -> Prepared:
"""Runs before the route, so a refusal is a real 429, not an error inside a stream that already said 200."""
return service.prepare(user, [Message(m.role, m.content) for m in body.messages])
AdmittedDep = Annotated[Prepared, Depends(admitted)]
@router.get("/health")
def health():
return {"status": "ok"}
@router.get("/health/ready")
async def ready(provider: ProviderDep):
if not await provider.ping():
raise GatewayError(503, "provider_unavailable", "The model provider is not reachable")
return {"status": "ready"}
@router.post("/v1/chat")
async def chat(prepared: AdmittedDep, service: ServiceDep) -> ChatOut:
return await service.complete(prepared)
@router.post("/v1/chat/stream", response_class=EventSourceResponse)
async def chat_stream(prepared: AdmittedDep, service: ServiceDep) -> AsyncIterable[ServerSentEvent]:
async for name, data in service.stream(prepared):
yield ServerSentEvent(data=data, event=name)
@router.get("/v1/usage")
def my_usage(user: UserDep, service: ServiceDep):
left = service.settings.daily_token_limit - service.store.tokens_today(user)
return {"user": user, **totals(service.store.today(user)), "tokens_left": max(0, left)}
@router.get("/v1/usage/summary")
def usage_summary(user: UserDep, service: ServiceDep):
if user != "admin":
raise GatewayError(403, "forbidden", "Only the admin key can read the summary")
return service.store.by_prompt_version()
def create_app(settings: Settings | None = None) -> FastAPI:
@asynccontextmanager
async def lifespan(app: FastAPI):
cfg = settings or Settings() # fails here, at startup, without a key
headers = {"Authorization": f"Bearer {cfg.provider_api_key.get_secret_value()}"}
timeout = httpx.Timeout(cfg.llm.timeout_s, connect=3.0)
async with httpx.AsyncClient(base_url=cfg.provider_url, headers=headers, timeout=timeout) as client:
app.state.settings = cfg
app.state.provider = RetryingProvider(PracticeProvider(client))
app.state.store = UsageStore()
yield
app = FastAPI(title="LLM Gateway", lifespan=lifespan)
app.include_router(router)
@app.exception_handler(GatewayError)
async def handle_gateway_error(request: Request, exc: GatewayError) -> JSONResponse:
body = {"error": {"code": exc.code, "message": exc.message}}
return JSONResponse(body, status_code=exc.status, headers=exc.headers)
return app
app = create_app()চালিয়ে দেখুন
সেটিংস আসে এনভায়রনমেন্ট থেকে: প্র্যাকটিস API-র টোকেনটাই এখানে প্রোভাইডারের কি।
export LLM_PROVIDER_URL=http://localhost:8800
export LLM_PROVIDER_API_KEY=token-nadia-001
export LLM_DAILY_TOKEN_LIMIT=200
uv run fastapi run --port 8000 > server.log 2>&1 &
echo $! > server.pid
curl -s --retry 20 --retry-connrefused --retry-delay 0 http://localhost:8000/health; echo
curl -s http://localhost:8000/health/ready; echo{"status":"ok"}
{"status":"ready"}নাদিয়া হিসেবে গেটওয়ের কি দিয়ে একটা প্রশ্ন করুন, তারপর এমন একটা প্রশ্ন, যাতে ইমেইল ঠিকানা আছে:
curl -s -X POST http://localhost:8000/v1/chat \
-H "Authorization: Bearer gw-nadia" -H "Content-Type: application/json" \
-d '{"messages": [{"role": "user", "content": "What is RAG?"}]}'; echo
curl -s -X POST http://localhost:8000/v1/chat \
-H "Authorization: Bearer gw-nadia" -H "Content-Type: application/json" \
-d '{"messages": [{"role": "user", "content": "Write to nadia@example.com about RAG?"}]}'; echo{"answer":"This is a practice answer about: What is RAG. A real model would say more.","model":"practice-llm-1","prompt_version":"tutor-v2","usage":{"prompt_tokens":18,"completion_tokens":15,"total_tokens":33,"cost_usd":0.000279}}
{"answer":"This is a practice answer about: Write to [EMAIL] about RAG. A real model would say more.","model":"practice-llm-1","prompt_version":"tutor-v2","usage":{"prompt_tokens":20,"completion_tokens":17,"total_tokens":37,"cost_usd":0.000315}}প্রতিটা উত্তরের সঙ্গে মডেল, প্রম্পট ভার্সন, টোকেন ও খরচ আসে। "মডেল" ঠিকানাটা কখনো দেখেনি: সে [EMAIL] নিয়ে উত্তর দিয়েছে। এবার স্ট্রিম করুন; paste ও sed প্রতিটা ইভেন্টকে এক লাইনে আনে এবং প্রথম দুটো ও শেষটা রাখে:
curl -sN -X POST http://localhost:8000/v1/chat/stream \
-H "Authorization: Bearer gw-nadia" -H "Content-Type: application/json" \
-d '{"messages": [{"role": "user", "content": "What is RAG?"}]}' | grep -v '^$' | paste -d' ' - - | sed -n '1,2p;$p'event: token data: {"text": "This "}
event: token data: {"text": "is "}
event: done data: {"usage": {"prompt_tokens": 18, "completion_tokens": 15, "total_tokens": 33, "cost_usd": 0.000279}, "prompt_version": "tutor-v2"}প্রতিটা কল একটা স্ট্রাকচার্ড লগ লাইনও লিখেছে (আসল লগে ল্যাটেন্সিও থাকে):
grep llm_call server.log | python -c "
import json, sys
for line in sys.stdin:
r = json.loads(line)
print(r['user'], r['prompt_version'], r['prompt_tokens'], r['completion_tokens'], r['cost_usd'], r['status'])
"nadia tutor-v2 18 15 0.000279 ok
nadia tutor-v2 20 17 0.000315 ok
nadia tutor-v2 18 15 0.000279 okব্যবহারকারী, ভার্সন, সংখ্যা ও স্ট্যাটাস, কখনো প্রম্পটের লেখা নয়। নাদিয়া শুধু নিজের ব্যবহার দেখে; অ্যাডমিন কি দেখে প্রম্পট ভার্সন ও মডেল অনুযায়ী মোট হিসাব (tutor-v3 চালু করলে দুটোর তুলনা করা যায়); অন্যদের ফিরিয়ে দেওয়া হয়:
curl -s http://localhost:8000/v1/usage -H "Authorization: Bearer gw-nadia"; echo
curl -s http://localhost:8000/v1/usage/summary -H "Authorization: Bearer gw-admin"; echo
curl -s http://localhost:8000/v1/usage/summary -H "Authorization: Bearer gw-nadia"; echo{"user":"nadia","requests":3,"cancelled":0,"prompt_tokens":56,"completion_tokens":47,"cost_usd":0.000873,"tokens_left":97}
{"tutor-v2 / practice-llm-1":{"requests":3,"cancelled":0,"prompt_tokens":56,"completion_tokens":47,"cost_usd":0.000873}}
{"error":{"code":"forbidden","message":"Only the admin key can read the summary"}}ডিসকানেক্ট ও লিমিট
একজন ব্যবহারকারী তিনটা টোকেনের পর "স্টপ" চাপলেন: গেটওয়েকে সেটা টের পেতে হবে এবং কতটা ব্যবহার হয়েছে তা রেকর্ড করতে হবে। তারপর রাফি একই প্রশ্ন করতে থাকে, যতক্ষণ না তার দৈনিক বাজেট শেষ হয়।
import time
import httpx
GATEWAY = "http://localhost:8000"
NADIA = {"Authorization": "Bearer gw-nadia"}
RAFI = {"Authorization": "Bearer gw-rafi"}
ASK = {"messages": [{"role": "user", "content": "What is RAG?"}]}
with httpx.stream("POST", f"{GATEWAY}/v1/chat/stream", json=ASK, headers=NADIA) as response:
tokens = 0
for line in response.iter_lines():
tokens += line.startswith("event: token")
if tokens == 3:
break
print("client left after", tokens, "tokens")
time.sleep(1) # the server notices a moment after we leave
usage = httpx.get(f"{GATEWAY}/v1/usage", headers=NADIA).json()
print("requests:", usage["requests"], "| cancelled:", usage["cancelled"])
codes = []
while not codes or codes[-1] == 200:
codes.append(httpx.post(f"{GATEWAY}/v1/chat", json=ASK, headers=RAFI).status_code)
print(codes)
print(httpx.post(f"{GATEWAY}/v1/chat", json=ASK, headers=RAFI).json())client left after 3 tokens
requests: 4 | cancelled: 1
[200, 200, 200, 200, 200, 200, 200, 429]
{'error': {'code': 'usage_limit_exceeded', 'message': 'Daily limit of 200 tokens reached'}}বাতিল হওয়া উত্তরও রেকর্ড হয়েছে এবং নাদিয়ার বাজেটে গণনা হয়েছে (প্রোভাইডার এর জন্য টাকা নিয়েছে)। রাফি পেয়েছে স্পষ্ট কোডসহ একটা 429 এবং একটা Retry-After হেডার। সার্ভার বন্ধ করুন:
kill $(cat server.pid)
while curl -s -o /dev/null http://localhost:8000/health; do sleep 0.1; done
echo stoppedstoppedআসল মডেল ছাড়া টেস্ট
স্বয়ংক্রিয় টেস্টে কখনো পেইড, ধীর, এলোমেলো API কল করবেন না। প্রোভাইডার যেহেতু ইন্টারফেসের পেছনে, তাই টেস্ট যেভাবে চায় ঠিক সেভাবে ব্যর্থ হওয়া একটা FakeProvider app.dependency_overrides দিয়ে তার জায়গা নেয় (দেখুন পেশাদার API টেস্ট করা), আর রিট্রাই র্যাপারকে দেওয়া হয় নকল sleep, যাতে টেস্ট মুহূর্তে চলে। respx দিয়ে HTTP মক করাও চলে: প্রতি অ্যাডাপ্টারের জন্য এমন একটা টেস্ট রাখুন।
# file: tests/test_chat.py
import asyncio
from fastapi.testclient import TestClient
from app.main import create_app, get_provider
from app.providers import Completion, ProviderError, RetryingProvider, StreamChunk, Usage
from app.settings import Settings
QUESTION = {"messages": [{"role": "user", "content": "What is RAG?"}]}
NADIA = {"Authorization": "Bearer gw-nadia"}
class FakeProvider:
def __init__(self, fail=0, delay=0.0, up=True):
self.fail, self.delay, self.up, self.calls = fail, delay, up, []
async def complete(self, messages, config):
self.calls.append(messages)
if self.fail > 0:
self.fail -= 1
raise ProviderError("overloaded", retryable=True)
await asyncio.sleep(self.delay)
return Completion("fake answer", Usage(10, 4))
async def stream(self, messages, config):
yield StreamChunk(text="fake ")
yield StreamChunk(text="answer ")
yield StreamChunk(usage=Usage(10, 2))
async def ping(self):
return self.up
def make_client(provider, **overrides):
app = create_app(Settings(provider_api_key="test-key", **overrides))
app.dependency_overrides[get_provider] = lambda: provider
return TestClient(app)
def test_answer_cost_and_server_owned_prompt():
fake = FakeProvider()
with make_client(fake) as client:
body = client.post("/v1/chat", json=QUESTION, headers=NADIA).json()
assert body["answer"] == "fake answer"
assert body["usage"] == {"prompt_tokens": 10, "completion_tokens": 4, "total_tokens": 14, "cost_usd": 0.00009}
assert fake.calls[0][0].role == "system"
def test_retries_then_succeeds_or_gives_up():
sleeps = []
async def fake_sleep(seconds):
sleeps.append(seconds)
for failures, expected in ((2, 200), (9, 503)):
provider = RetryingProvider(FakeProvider(fail=failures), sleep=fake_sleep, jitter=lambda: 1.0)
with make_client(provider) as client:
assert client.post("/v1/chat", json=QUESTION, headers=NADIA).status_code == expected
assert sleeps == [0.5, 1.0, 0.5, 1.0] # backoff doubles; three attempts means two pauses
def test_slow_provider_hits_the_deadline():
with make_client(FakeProvider(delay=1.0), llm={"timeout_s": 0.05}) as client:
assert client.post("/v1/chat", json=QUESTION, headers=NADIA).status_code == 504
def test_stream_and_readiness():
with make_client(FakeProvider()) as client:
text = client.post("/v1/chat/stream", json=QUESTION, headers=NADIA).text
assert text.count("event: token") == 2 and "event: done" in text
with make_client(FakeProvider(up=False)) as client:
assert client.get("/health").status_code == 200
assert client.get("/health/ready").status_code == 503uv run python -m pytest -q.... [100%]
4 passed in 1.94sমূল্যায়নের গোড়ার কথা
টেস্ট প্রমাণ করে কোড কাজ করছে; eval মাপে উত্তরগুলো যথেষ্ট ভালো কি না। ছোট করে শুরু করুন: যাচাই করা যায় এমন প্রত্যাশাসহ কিছু প্রশ্ন, প্রতিটা প্রম্পট বা মডেল বদলের পর চালানো, আর পাস-রেট সময়ের সঙ্গে নজরে রাখা। এটা যাচাই করে বিষয়টা উত্তরে আছে কি না, ইমেইল কখনো ফিরে আসে কি না, আর উত্তর ছোট কি না:
# file: evals.py
import logging
from fastapi.testclient import TestClient
from app.main import create_app
logging.getLogger("llm").setLevel(logging.WARNING) # keep the call log out of the report
CASES = [
("mentions-topic", "What is RAG?", lambda answer: "RAG" in answer),
("hides-email", "I am nadia@example.com. What is RAG?", lambda answer: "nadia@example.com" not in answer),
("is-short", "Explain embeddings", lambda answer: len(answer.split()) <= 10),
]
with TestClient(create_app()) as client:
passed = 0
for name, question, check in CASES:
body = {"messages": [{"role": "user", "content": question}]}
answer = client.post("/v1/chat", json=body, headers={"Authorization": "Bearer gw-nadia"}).json()["answer"]
ok = check(answer)
passed += ok
print("PASS" if ok else "FAIL", name)
print(f"{passed}/{len(CASES)} passed")uv run python evals.pyPASS mentions-topic
PASS hides-email
FAIL is-short
2/3 passedনকল মডেল বাচাল, তাই শেষ কেসটা ব্যর্থ হয়: ব্যবহারকারীদের আগেই আপনি যে ব্যর্থতা দেখতে পান। CI-তে পাস-রেট কমলে বিল্ড ফেল করাবেন। আসল eval-এ যোগ হয় আসল (রিড্যাক্ট করা) ট্রাফিক থেকে আরও কেস, এবং বিচারক হিসেবে দ্বিতীয় একটা মডেল বা মানুষের রেটিং।
বাস্তব ব্যবহার
- চ্যাট ও সাপোর্ট অ্যাসিস্ট্যান্ট এমন গেটওয়ে দিয়ে টোকেন স্ট্রিম করে, সঙ্গে যোগ হয় অথ, লিমিট ও লগিং।
- খরচ নিয়ন্ত্রণ ও নিরাপদ আপগ্রেড: কল লগ বলে দেয় "সামারাইজার গত সপ্তাহে কত খরচ করেছে?", আর মডেল বদলানো একটা সেটিং, যা আপনি মূল্যায়ন করে দেখতে ও ফেরত নিতে পারেন।
সাধারণ ভুল
- প্রোভাইডারের কি ক্লায়েন্টে, রিপোজিটরিতে বা লগে রাখা। সেটা চুরি হবে ও ব্যবহার হবে। কি রাখুন সেটিংসে
SecretStrহিসেবে, ফাঁস হলে বদলে ফেলুন, আর ক্লায়েন্টদের দিন আপনার নিজের কি। একই কারণে ক্লায়েন্টকে কখনো মডেল বা সিস্টেম প্রম্পট বেছে নিতে দেবেন না। - সবকিছু রিট্রাই করা, চলমান স্ট্রিমসহ। খরচ ও আউটপুট দুটোই দ্বিগুণ হয়। শুধু সাময়িক এরর রিট্রাই করুন ব্যাকঅফসহ, প্রথম টোকেনের পর কখনো নয়।
- "ডিবাগের জন্য" প্রম্পট ও উত্তর লগ করা। এতে আপনি এমন ব্যক্তিগত ডেটা জমান, যা রক্ষা ও মুছে ফেলার দায় আপনার। লগ করুন আইডি, ভার্সন, টোকেন সংখ্যা ও স্ট্যাটাস।
নিজে চেষ্টা করুন
- সহজ:
"Call 01712345678. Key: sk-test12345678."-এর ওপরredactচালান এবং ফলাফল প্রিন্ট করুন। - মাঝারি: যাচাই করুন
redactদেশের কোডসহ ফোন নম্বর (+8801712345678) এবং স্থানীয় নম্বর দুটোই ধরে কি না, আর বলুন প্যাটার্ন কেন(?<!\w)দিয়ে শুরু হয়। - কঠিন: টেস্ট থেকে
make_clientনিয়ে এবং দৈনিক লিমিট ১ টোকেন রেখে একই স্ট্রিমিং রিকোয়েস্ট দুইবার পাঠান। দেখান দ্বিতীয়টা আসল429পায় এবং মাত্র একটা কল রেকর্ড হয়েছে।
উত্তর
# 1. Easy
from app.redact import redact
print(redact("Call 01712345678. Key: sk-test12345678."))
# 2. Medium (the lookbehind stops it matching inside a longer word or number)
print(redact("Call +8801712345678 or 01712345678."))
# 3. Hard
from tests.test_chat import NADIA, QUESTION, FakeProvider, make_client
with make_client(FakeProvider(), daily_token_limit=1) as client:
first = client.post("/v1/chat/stream", json=QUESTION, headers=NADIA)
second = client.post("/v1/chat/stream", json=QUESTION, headers=NADIA)
usage = client.get("/v1/usage", headers=NADIA).json()
print(first.status_code, second.status_code, second.json()["error"]["code"], usage["requests"])Call [PHONE]. Key: [KEY].
Call [PHONE] or [PHONE].
200 429 usage_limit_exceeded 1সারসংক্ষেপ
- LLM ব্যাকএন্ড হলো একটা গেটওয়ে: অথ, লিমিট, রিড্যাকশন, সার্ভারের হাতে থাকা প্রম্পট ও মডেল, প্রোভাইডার কল এবং রেকর্ড, যেখানে রাউট পাতলা আর নিয়মগুলো থাকে সার্ভিসে।
- প্রতিটা ভেন্ডরকে একটা ছোট ইন্টারফেসের (
complete,stream,ping) আড়ালে রাখুন; কি রাখুন সেটিংসে, ডেডলাইন দিন, শুধু সাময়িক এরর রিট্রাই করুন, শুরু হওয়া স্ট্রিম কখনো নয়। - SSE দিয়ে স্ট্রিম করুন, এরর পাঠান ইভেন্ট হিসেবে, ব্যবহার রেকর্ড করুন
finally-তে যাতে ডিসকানেক্টও গোনা যায়, আর লিমিট পেরোনো ব্যবহারকারীকে রাউট শুরুর আগেই ফিরিয়ে দিন। - প্রতি কলে টোকেন, খরচ, প্রম্পট ভার্সন ও মডেল লগ করুন, প্রম্পট কখনো নয়; নকল প্রোভাইডার দিয়ে টেস্ট করুন; ছোট একটা eval সেট দিয়ে মান মাপুন।
এরপর: API নিরাপত্তা ও OWASP API Top 10 দেখায় এমন একটা API কীভাবে আক্রান্ত হয় (ভাঙা অথরাইজেশন, রিসোর্স নিঃশেষ, SSRF, প্রম্পট ইনজেকশন) এবং প্রতিটার সমাধান কী।