অধ্যায় 1 · LLM API ও প্রোভাইডার-নিরপেক্ষ ক্লায়েন্ট
অ্যাডাপ্টার, কনফিগারেশন ও এরর হ্যান্ডলিং
- পৃষ্ঠা 2 / 22
- 20 মিনিট পড়া
গত পাতায় EvidenceDesk একটা প্রোভাইডার-নিরপেক্ষ ক্লায়েন্ট পেয়েছে। তিনটা প্রশ্ন তখনো খোলা ছিল: মডেলের নাম আর কি (key) কোথায় থাকবে, কল ব্যর্থ হলে কী হবে, আর প্রোভাইডার বদলাতে থাকলে অ্যাডাপ্টার কীভাবে সৎ থাকবে? এই পাতা উত্তর দেয় config.py, errors.py আর একটা মিনি-ল্যাব দিয়ে: এমন একটা সামারাইজার, যা যেকোনো প্রোভাইডারের সঙ্গে চলে এবং কোনো প্রোভাইডার ছাড়াই টেস্ট পাস করে।
যা শিখবেন
- মডেল ID, টাইমআউট আর প্রোভাইডারের পছন্দ কীভাবে যাচাই করা সেটিংসে রাখবেন, আর সিক্রেট (secret) কীভাবে সোর্স কোডের বাইরে রাখবেন।
- রিট্রাই করা যায় এমন ব্যর্থতা আর অবৈধ রিকোয়েস্ট, অথেনটিকেশন ব্যর্থতা ও শেষ হয়ে যাওয়া কোটা কীভাবে আলাদা করবেন, এবং কড়া সীমা রেখে রিট্রাই করবেন।
- প্রোভাইডার কীভাবে তুলনা করবেন, ক্রেডেনশিয়াল ছাড়া কীভাবে টেস্ট চালাবেন, ভার্সন কীভাবে পিন করবেন আর ফিক্সচার কীভাবে রাখবেন।
মডেল ও অ্যাডাপ্টারের কনফিগারেশন লিখে রাখুন
বিজনেস কোডের ভেতরে মডেল ID, টাইমআউট বা if provider == ... থাকলে প্রতিটা পরিবর্তনে সেই কোডে হাত দিতে হয়। এগুলো একটা যাচাই করা অবজেক্টে রাখুন, যা স্টার্ট-আপে একবার বানানো হয়। load_settings এনভায়রনমেন্টকে Mapping হিসেবে নেয়, তাই টেস্ট একটা ডিকশনারি পাঠায়, os.environ-এ হাত দেয় না। ডিফল্ট provider="fake", ফলে কিছু কনফিগার করার আগেই প্রজেক্ট অফলাইনে চলে। আসল প্রোভাইডারকে নিজের মডেলের নাম দিতে হয়; ফাইলে কোনো মডেল ID নেই, কারণ ID বদলায়।
প্রোভাইডার একটা Literal, তাই বানান ভুল হলে স্টার্ট-আপেই ধরা পড়ে। মিনি-ল্যাব অ্যাডাপ্টার বেছে নেয় একটা রেজিস্ট্রি থেকে, ছড়ানো কন্ডিশনাল থেকে নয়।
# file: evidencedesk/config.py
"""Validated settings. The environment is passed in as a mapping, so tests never touch os.environ."""
from __future__ import annotations
from collections.abc import Mapping
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
PREFIX = "EVIDENCEDESK_"
NEEDS_KEY = ("openai", "anthropic", "gemini") # "local" and "fake" need no key
class Settings(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
provider: Literal["fake", "openai", "anthropic", "gemini", "local"] = "fake"
model: str = "fake-model"
timeout_s: float = Field(30.0, gt=0, le=600)
max_retries: int = Field(2, ge=0, le=5)
api_key: SecretStr | None = None # prints as ********** in repr and logs
@model_validator(mode="after")
def _check_provider(self) -> Settings:
if self.provider != "fake" and self.model == "fake-model":
raise ValueError(f"set {PREFIX}MODEL: copy the model id from the provider's documentation")
if self.provider in NEEDS_KEY and self.api_key is None:
raise ValueError(f"provider {self.provider!r} needs {PREFIX}API_KEY or {PREFIX}API_KEY_FILE")
return self
def load_settings(env: Mapping[str, str]) -> Settings:
"""Build Settings from an environment mapping. An empty value counts as not set."""
data: dict[str, str] = {}
for field in ("provider", "model", "timeout_s", "max_retries", "api_key"):
if env.get(PREFIX + field.upper()):
data[field] = env[PREFIX + field.upper()]
key_file = env.get(PREFIX + "API_KEY_FILE")
if "api_key" not in data and key_file:
data["api_key"] = Path(key_file).read_text(encoding="utf-8").strip()
return Settings(**data)একটা খালি এনভায়রনমেন্ট, একটা সম্পূর্ণ এনভায়রনমেন্ট আর দুটো ভাঙা এনভায়রনমেন্ট (এরর শুধু ফিল্ডের পথ আর বার্তা হিসেবে ছাপা):
from pydantic import ValidationError
from evidencedesk.config import load_settings
print(load_settings({}))
secret = "demo-key-not-real"
ready = load_settings({"EVIDENCEDESK_PROVIDER": "openai", "EVIDENCEDESK_MODEL": "model-id-from-the-docs",
"EVIDENCEDESK_API_KEY": secret})
print(ready)
print(secret in repr(ready), ready.api_key.get_secret_value() == secret)
for env in ({"EVIDENCEDESK_TIMEOUT_S": "0"}, {"EVIDENCEDESK_PROVIDER": "gemini", "EVIDENCEDESK_MODEL": "m1"}):
try:
load_settings(env)
except ValidationError as error:
print([(e["loc"], e["msg"]) for e in error.errors()])provider='fake' model='fake-model' timeout_s=30.0 max_retries=2 api_key=None
provider='openai' model='model-id-from-the-docs' timeout_s=30.0 max_retries=2 api_key=SecretStr('**********')
False True
[(('timeout_s',), 'Input should be greater than 0')]
[((), "Value error, provider 'gemini' needs EVIDENCEDESK_API_KEY or EVIDENCEDESK_API_KEY_FILE")]লক্ষ করুন: খালি এনভায়রনমেন্ট বৈধ; SecretStr কি লুকিয়ে রাখে, অথচ get_secret_value() সেটা ফেরত দেয়; ভুল মান তার নিজের ফিল্ডের নামে রিপোর্ট হয়; আর একাধিক ফিল্ড মিলিয়ে যে নিয়ম, তার পথ খালি।
সোর্স কোড থেকে সিক্রেট দূরে রাখুন
কমিট হওয়া কি মানেই ফাঁস হওয়া কি: পরে মুছে দিয়ে কমিট করলেও হিস্ট্রি সেটা ধরে রাখে। কি পড়ুন রান-টাইমে, একটা এনভায়রনমেন্ট ভেরিয়েবল (EVIDENCEDESK_API_KEY) থেকে, অথবা এমন ফাইল থেকে যার পথ এনভায়রনমেন্টে আছে (EVIDENCEDESK_API_KEY_FILE, কনটেইনারের সিক্রেট মাউন্টের মতো)। .env গিট থেকে বাইরে রাখুন, খালি মানসহ একটা .env.example কমিট করুন, আর কোনো কি কখনো ছাপবেন না।
# file: .gitignore
.env
.env.*
!.env.example
*.key
secrets/
.venv/
__pycache__/শুধু নিয়মে কাজ হয় না, তাই টেস্টে একটা কি ডিটেক্টর আছে: কি-র আকারের স্ট্রিং ধরার একটা রেগুলার এক্সপ্রেশন। একটা টেস্ট দেখায় বানানো একটা কি-তে সেটা কাজ করে; আরেকটা আমাদের সোর্স, টেস্ট আর ফিক্সচার স্ক্যান করে। এটা একটা নিরাপত্তা-জাল (safety net), পূর্ণাঙ্গ সিক্রেট স্ক্যানারের বিকল্প নয়। আসল কি কখনো কমিট হয়ে গেলে আগে সেটা বদলে ফেলুন (rotate), তারপর হিস্ট্রি পরিষ্কার করুন।
টাইমআউট, রেট লিমিট ও প্রোভাইডার এরর সামলান
ভুল রিকোয়েস্ট রিট্রাই করে কখনো ঠিক হয় না, আর শেষ হয়ে যাওয়া কোটা রিট্রাই করলে শুধু সময় যায়, তাই প্রতিক্রিয়া নির্ভর করে ব্যর্থতার ধরনের ওপর:
| ধরন | সাধারণ সংকেত | রিট্রাই? |
|---|---|---|
timeout | সময়মতো উত্তর আসেনি; HTTP 408, 504 | হ্যাঁ, সীমার মধ্যে |
rate_limited | HTTP 429, প্রায়ই Retry-After সহ | হ্যাঁ, অপেক্ষার পরে |
server | HTTP 500 ও তার ওপরে | হ্যাঁ, সীমার মধ্যে |
auth | HTTP 401, 403 | না: অপারেটরকে জানান |
invalid_request | HTTP 400, 404, 413, 422 | না: রিকোয়েস্ট ঠিক করুন |
quota_exhausted | HTTP 402, অথবা যে 429-এর বডিতে ক্রেডিট বা খরচের সীমার কথা আছে | না: মালিককে জানান |
unknown | বাকি সব | না: লগ করুন, স্পষ্টভাবে ব্যর্থ হন |
কঠিন সারি হলো 429: সাধারণত এটা রেট লিমিট, যা কেটে যায়; কিন্তু কিছু প্রোভাইডার ক্রেডিট শেষ হলেও এটা ব্যবহার করে, যা কাটে না। পার্থক্যটা বডিতে থাকে, তাই from_status কয়েকটা মার্কার স্ট্রিং খোঁজে। এই তালিকা আমাদের নিজেদের অনুমান-নির্ভর (heuristic) কৌশল; মার্কার ছাড়া 429 রেট লিমিট ধরা হয়, যার দাম বড়জোর সীমার মধ্যে কয়েকবার চেষ্টা।
প্রোভাইডার নোট (২০২৬-১০-১২ অনুযায়ী): প্রথম দুই প্রোভাইডারের SDK প্রতিটা স্ট্যাটাসের জন্য একটা করে এক্সেপশন ক্লাস তোলে, সঙ্গে কানেকশন ও টাইমআউটের ক্লাস। লেখার সময় দুটোই কানেকশন এরর, 408, 409, 429 এবং 500 ও তার ওপরের স্ট্যাটাস ডিফল্টে দুবার রিট্রাই করে, ১০ মিনিট পরে টাইমআউট হয় এবং
max_retriesনেয়। প্রথমটাRetry-Afterমানতে বলে, নইলে জিটারসহ ব্যাকঅফ আর একটা ঊর্ধ্বসীমা; আর বলে একটা 429-এর মানে হতে পারে শেষ হয়ে যাওয়া ক্রেডিট বা খরচের সীমা (credit_balance_exhausted): সেটা রিট্রাই করবেন না। দ্বিতীয়টা 402-কে বিলিং, 529-কে ওভারলোডেড, 504-কে টাইমআউট বলে, আর খরচের সীমার 429-এretry-afterথাকে না। তৃতীয়টা 429, 503, 504 বলে এবং কোনো রেট-লিমিট হেডার নথিভুক্ত করে না; এর এক্সেপশনের নাম, ডিফল্ট টাইমআউট এবং লোকাল রানটাইমের টাইমআউট ও রিট্রাই নিশ্চিত নয়: ধরে নিন বিল্ট-ইন রিট্রাই নেই। বর্তমান ডকুমেন্টেশন দেখে নিন।
রেসপন্স ও এরর একই রূপে আনুন
অ্যাডাপ্টার একটাই LLMResponse ফেরত দেয়; ব্যর্থতায় পাওয়া যায় একটাই LLMError, যার আছে kind, retryable আর ঐচ্ছিক retry_after। from_status(status, headers, body) শুধু HTTP-র তথ্য ব্যবহার করে, তাই সব প্রোভাইডারের কাজে লাগে। রিট্রাই হলো:
- সীমাবদ্ধ:
max_attemptsপ্রতিটা চেষ্টা গোনে; তারপর শেষ এররটা ছোড়া হয়। - জিটারসহ এক্সপোনেনশিয়াল ব্যাকঅফ: ০.৫, ১, ২ ... সেকেন্ড,
max_delayপর্যন্ত; প্রতিটা অপেক্ষা থেকে একটা এলোমেলো অংশ বাদ যায়, যাতে ক্লায়েন্টরা একসঙ্গে রিট্রাই না করে। - সার্ভারের কথাই শেষ কথা: বড়
Retry-Afterমানা হয়;max_delay-এর বেশি হলে হাল ছেড়ে দিন, সার্ভারকে চেপে ধরবেন না। - ইনজেক্ট করা
sleepওrng: টেস্ট দেয় একটা লিস্টেরappendআর সিড দেওয়াrandom.Random।
RetryingClient যেকোনো LLMClient-কে মুড়ে নেয় এবং নিজেও একটা LLMClient।
# file: evidencedesk/errors.py
from __future__ import annotations
import random
import time
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from evidencedesk.llm import LLMClient, LLMRequest, LLMResponse
RETRYABLE = {"timeout", "rate_limited", "server"}
QUOTA_MARKERS = ("credit_balance_exhausted", "spend_limit", "quota_exceeded") # our heuristic
class LLMError(Exception):
def __init__(self, kind: str, message: str = "", retry_after: float | None = None):
super().__init__(f"{kind}: {message}" if message else kind)
self.kind, self.retry_after, self.attempts = kind, retry_after, 1
@property
def retryable(self) -> bool:
return self.kind in RETRYABLE
def from_status(status: int, headers: Mapping[str, str] | None = None, body: str = "") -> LLMError:
lowered = {k.lower(): v for k, v in (headers or {}).items()}
try:
retry_after = max(0.0, float(lowered["retry-after"])) # seconds only; a date is ignored
except (KeyError, ValueError):
retry_after = None
if status == 429 and any(marker in body for marker in QUOTA_MARKERS):
kind = "quota_exhausted"
elif status in (408, 504):
kind = "timeout"
elif status == 429:
kind = "rate_limited"
elif status == 402:
kind = "quota_exhausted"
elif status in (401, 403):
kind = "auth"
elif status in (400, 404, 413, 422):
kind = "invalid_request"
elif status >= 500:
kind = "server"
else:
kind = "unknown"
return LLMError(kind, f"HTTP {status}", retry_after)
@dataclass(frozen=True)
class RetryPolicy:
max_attempts: int = 3 # every try counts, the first too
base_delay: float = 0.5
factor: float = 2.0
max_delay: float = 8.0 # the longest wait we accept
jitter: float = 0.5 # share of each wait that is random
def wait_for(self, attempt: int, error: LLMError, rng: random.Random) -> float | None:
backoff = min(self.max_delay, self.base_delay * self.factor ** (attempt - 1))
wait = backoff * (1 - self.jitter * rng.random())
wait = max(wait, error.retry_after or 0.0) # the server's word beats our guess
return wait if wait <= self.max_delay else None
def call_with_retry(fn: Callable, policy: RetryPolicy = RetryPolicy(),
sleep: Callable[[float], None] = time.sleep, rng: random.Random | None = None):
rng = rng or random.Random()
for attempt in range(1, policy.max_attempts + 1):
try:
return fn()
except LLMError as error:
error.attempts = attempt
wait = policy.wait_for(attempt, error, rng) if error.retryable else None
if wait is None or attempt == policy.max_attempts:
raise
sleep(wait)
raise ValueError("max_attempts must be at least 1")
class RetryingClient:
"""Wraps any LLMClient and is one itself."""
def __init__(self, inner: LLMClient, policy: RetryPolicy = RetryPolicy(),
sleep: Callable[[float], None] = time.sleep, rng: random.Random | None = None) -> None:
self.inner, self.policy, self.sleep, self.rng = inner, policy, sleep, rng
def complete(self, request: LLMRequest) -> LLMResponse:
return call_with_retry(lambda: self.inner.complete(request), self.policy, self.sleep, self.rng)দুটো 429, দুবার ব্যর্থ হয়ে তারপর সফল হওয়া একটা কল, আর তারপর এমন একটা কল যা কখনো ঠিক হয় না। এখানে sleep মানে শুধু লিস্টে যোগ করা:
import random
from evidencedesk.errors import LLMError, RetryPolicy, call_with_retry, from_status
print(from_status(429, {"Retry-After": "3"}).kind, from_status(429, {}, "credit_balance_exhausted").kind)
tries, waits = [], []
def flaky():
tries.append(1)
if len(tries) < 3:
raise from_status(503)
return "ok"
print(call_with_retry(flaky, RetryPolicy(), waits.append, random.Random(7)), len(tries), [round(w, 2) for w in waits])
def always_down():
raise from_status(500)
more = []
try:
call_with_retry(always_down, RetryPolicy(max_attempts=3), more.append, random.Random(7))
except LLMError as error:
print("gave up:", error, "after", error.attempts, "attempts;", len(more), "waits")rate_limited quota_exhausted
ok 3 [0.42, 0.92]
gave up: server: HTTP 500 after 3 attempts; 2 waitsলক্ষ করুন: বডি দুটো 429-কে আলাদা করেছে; ০.৪২ আর ০.৯২ হলো ০.৫ ও ১.০ সেকেন্ডের ব্যাকঅফ, সিড দেওয়া জিটারে কমানো; আর যে কল কখনো ঠিক হয় না, সেটা তিনবার চেষ্টার পরে থেমেছে।
প্রোভাইডারের সক্ষমতার ম্যাট্রিক্স ব্যবহার করুন
একই নামের ফিচার ভেতরের পার্থক্য লুকিয়ে রাখতে পারে। এই ম্যাট্রিক্স গুণগত: "নথিভুক্ত" মানে ডকুমেন্টেশনে বর্ণনা আছে, আচরণ বা সীমা মিলে যাওয়া নয়।
| সক্ষমতা | প্রথম | দ্বিতীয় | তৃতীয় | লোকাল রানটাইম |
|---|---|---|---|---|
| কাঠামোবদ্ধ আউটপুট | নথিভুক্ত; JSON Schema-র কড়া একটা উপসেট | নথিভুক্ত; আরেকটা উপসেট | নথিভুক্ত; আরেকটা উপসেট | নথিভুক্ত; তবু নিজে যাচাই করুন |
| স্ট্রিমিং, টুল কল | নথিভুক্ত; টুলের আর্গুমেন্ট JSON স্ট্রিং হিসেবে | নথিভুক্ত; টুলের আর্গুমেন্ট অবজেক্ট হিসেবে | নথিভুক্ত | নথিভুক্ত; টুল সাপোর্ট মডেলের ওপর নির্ভর করে [VERIFY] |
| এমবেডিং | আছে | নিজস্ব মডেল নেই | আছে | আছে |
| usage মেটাডেটা | নিজস্ব নাম; স্ট্রিমে [VERIFY] | নিজস্ব নাম; স্ট্রিমে ক্রমবর্ধমান (মোট) | API পথ ভেদে নাম আলাদা; স্ট্রিমে [VERIFY] | সাধারণ রেসপন্সে; স্ট্রিমে [VERIFY] |
তাই সবার জন্য এক চুক্তি শুধু এটুকু: ভেতরে টেক্সট, বাইরে টেক্সট আর usage; বাকিগুলো ঐচ্ছিক সক্ষমতা, যা অ্যাডাপ্টার ঘোষণা করে আর অ্যাপ্লিকেশন যাচাই করে নেয়।
প্রোভাইডার নোট (২০২৬-১০-১২ অনুযায়ী): দ্বিতীয় প্রোভাইডারের নিজস্ব এমবেডিং মডেল নেই, তারা একটা পার্টনার সার্ভিসের দিকে ইঙ্গিত করে; তাই পরের পাতাগুলোর এমবেডিং অ্যাডাপ্টার প্রথম ও তৃতীয় প্রোভাইডার আর একটা লোকাল রানটাইম ধরে। তৃতীয়টার দুটো API পথ আছে (Interactions API, যা দিয়ে তাদের ডকুমেন্টেশন শুরু হয়, আর পুরোনো
generateContent), এবং usageের ফিল্ডের নাম আলাদা। বর্তমান ডকুমেন্টেশন দেখে নিন।
ক্রেডেনশিয়াল ছাড়া পথ টেস্ট করুন
খালি এনভায়রনমেন্টে অ্যাপ্লিকেশন তৈরি হয় এবং প্রতিটা টেস্ট পাস করে।
# file: tests/test_config.py
import re
from pathlib import Path
import pytest
from pydantic import ValidationError
from evidencedesk.config import load_settings
ROOT = Path(__file__).resolve().parent.parent
KEY_PATTERN = re.compile(r"sk-[A-Za-z0-9_-]{16,}|AIza[0-9A-Za-z_-]{20,}|-----BEGIN [A-Z ]*PRIVATE KEY")
REAL = {"EVIDENCEDESK_PROVIDER": "anthropic", "EVIDENCEDESK_MODEL": "m1"}
def fields(env):
with pytest.raises(ValidationError) as info:
load_settings(env)
return [".".join(map(str, e["loc"])) for e in info.value.errors()]
def test_empty_environment_means_offline_fake():
s = load_settings({})
assert (s.provider, s.model, s.api_key, s.max_retries) == ("fake", "fake-model", None, 2)
def test_the_key_comes_from_env_or_file_and_never_appears_in_repr(tmp_path):
secret = "sk-" + "a" * 24 # made up
(tmp_path / "key.txt").write_text(secret, encoding="utf-8")
for source in ({"EVIDENCEDESK_API_KEY": secret}, {"EVIDENCEDESK_API_KEY_FILE": str(tmp_path / "key.txt")}):
s = load_settings({**REAL, **source})
assert secret not in repr(s) + str(s) + s.model_dump_json()
assert s.api_key.get_secret_value() == secret
def test_bad_settings_are_reported_by_field():
assert fields({"EVIDENCEDESK_PROVIDER": "nope"}) == ["provider"]
assert fields({"EVIDENCEDESK_TIMEOUT_S": "0"}) == ["timeout_s"]
assert fields(REAL) == [""] # a real provider without a key
assert fields({"EVIDENCEDESK_PROVIDER": "local"}) == [""] # a real provider without a model
def test_detector_finds_a_fake_key_and_the_source_tree_has_none():
assert KEY_PATTERN.search("API_KEY = 'sk-" + "b" * 20 + "'")
for path in [*ROOT.glob("evidencedesk/*.py"), *ROOT.glob("tests/*.py"), *ROOT.glob("fixtures/*.json")]:
assert not KEY_PATTERN.search(path.read_text(encoding="utf-8")), path.name# file: tests/test_errors.py
import pytest
from evidencedesk.errors import LLMError, RetryPolicy, call_with_retry, from_status
NO_JITTER = RetryPolicy(max_attempts=4, jitter=0)
@pytest.mark.parametrize("status, headers, body, kind, retryable, retry_after", [
(400, {}, "", "invalid_request", False, None),
(401, {}, "", "auth", False, None),
(429, {"Retry-After": "3"}, "", "rate_limited", True, 3.0),
(429, {}, "credit_balance_exhausted", "quota_exhausted", False, None),
(500, {}, "", "server", True, None),
])
def test_status_mapping(status, headers, body, kind, retryable, retry_after):
error = from_status(status, headers, body)
assert (error.kind, error.retryable, error.retry_after) == (kind, retryable, retry_after)
class Flaky:
def __init__(self, *failures):
self.failures, self.calls = list(failures), 0
def __call__(self):
self.calls += 1
if self.failures:
raise self.failures.pop(0)
return "ok"
def run(fn, policy=NO_JITTER):
slept = []
try:
return call_with_retry(fn, policy, slept.append), slept
except LLMError as error:
return error, slept
def test_recovers_and_never_really_sleeps():
fn = Flaky(from_status(503), from_status(503))
assert run(fn) == ("ok", [0.5, 1.0]) and fn.calls == 3 # base 0.5, doubled once
def test_attempts_are_bounded():
fn = Flaky(*[from_status(500)] * 10)
error, slept = run(fn)
assert fn.calls == 4 and len(slept) == 3 and error.attempts == 4
def test_permanent_errors_are_not_retried():
fn = Flaky(from_status(401), from_status(401))
assert run(fn)[1] == [] and fn.calls == 1
def test_server_wait_wins_but_only_up_to_the_cap():
assert run(Flaky(from_status(429, {"retry-after": "2"})))[1] == [2.0]
fn = Flaky(from_status(429, {"retry-after": "60"}))
assert run(fn)[1] == [] and fn.calls == 1 # 60 s is over max_delay: give up, do not hammerpython -m pytest -q tests/test_config.py tests/test_errors.py............. [100%]
13 passedএই পাতায় pytest-এর লাইন থেকে ঘড়ির সেকেন্ড বাদ দেওয়া হয়েছে (sed 's/ in [0-9.]*s//')। কোনো কি, নেটওয়ার্ক বা আসল sleep-এর দরকার হয়নি।
মডেল ও SDK ভার্সনের পরিবর্তন বুঝুন
SDK-র নাম ও ডিফল্ট বদলায়; মডেলের নাম বদলে যায় বা মডেল বন্ধ হয়। যা টেস্ট করেছেন সেটা পিন করুন, আর যা করেননি তা লিখে রাখুন। ভার্সন-সংবেদনশীল কোড আলাদা রাখুন: ফিল্ডের নাম থাকে শুধু মিনি-ল্যাবের ছোট from_... ফাংশন আর অ্যাডাপ্টারে, ফলে SDK বদলালে একটাই জায়গায় হাত পড়ে। মডেল ID কখনো কোডে লিখবেন না; সেটা আসে সেটিংস থেকে।
# file: requirements.txt
# Tested here with Python 3.13; the target is Python 3.12.
pydantic==2.14.1
pytest==9.1.1
# openai==3.29.0 (optional adapters: latest on 2026-10-12, NOT tested here)
# anthropic==1.13.0প্রোভাইডার নোট (২০২৬-১০-১২ অনুযায়ী): সেদিন PyPI-তে সর্বশেষ:
openai3.29.0,anthropic1.13.0,google-genai2.29.0,ollama0.6.3। দ্বিতীয় SDK-র ভার্সন ১ থেকেtemperature,top_pআরtop_kসরানো হয়েছে (দিলেTypeErrorওঠে); তৃতীয় SDK-র README বলে 3.0.0-র নিচে পিন করতে, কারণ ভবিষ্যতের একটা রিলিজ অটোমেটিক ফাংশন কলিং বদলাবে। দ্বিতীয় প্রোভাইডার কোনো মডেল বন্ধ করার আগে অন্তত ৬০ দিনের নোটিশের কথা বলে। বর্তমান ডকুমেন্টেশন দেখে নিন।
রেকর্ড করা প্রোভাইডার ফিক্সচার বানান
প্রোভাইডার ফিক্সচার হলো প্রোভাইডারের রেসপন্সের আদলে বানানো ছোট একটা রেসপন্স, যাতে প্রোভাইডার ছাড়াই ম্যাপিং কোড টেস্ট করা যায়। ছোট রাখুন, ব্যক্তিগত কিছু (id, কি) মুছে দিন, আর লিখে রাখুন এটা কীভাবে বানানো। এগুলো আমাদের অ্যাডাপ্টারের পড়া ফিল্ড দেখে হাতে লেখা; কোনো আসল সার্ভিস থেকে ধরা হয়নি, মানগুলো বানানো। কোনো অ্যাডাপ্টার আসলে একবার চলার পরে এগুলো পরিষ্কার করা আসল ক্যাপচার দিয়ে বদলে নিন।
# file: fixtures/provider_samples.json
{
"_note": "Hand-written. Not captured from a real service.",
"first": {"status": "completed", "output_text": "Returns: 30 days, full refund.",
"usage": {"input_tokens": 52, "output_tokens": 9}},
"second": {"stop_reason": "end_turn",
"content": [{"type": "text", "text": "Returns: 30 days, "}, {"type": "text", "text": "full refund."}],
"usage": {"input_tokens": 52, "output_tokens": 9}},
"errors": [
{"name": "rate limit with a wait", "status": 429, "headers": {"Retry-After": "3"},
"body": "{\"error\": {\"type\": \"rate_limit_error\"}}", "kind": "rate_limited"},
{"name": "credit used up", "status": 429, "headers": {},
"body": "{\"error\": {\"code\": \"credit_balance_exhausted\"}}", "kind": "quota_exhausted"},
{"name": "overloaded", "status": 529, "headers": {}, "body": "overloaded_error", "kind": "server"}
]
}একটা ন্যূনতম API তুলনা রিপোর্ট বানান
কার্যগত পার্থক্য, পরিচালনার সীমাবদ্ধতা আর অজানা বিষয় লিখে রাখুন; প্রতিটা অজানার পাশে [VERIFY] বসান।
| ক্ষেত্র | পর্যবেক্ষণ | অবস্থা |
|---|---|---|
| কার্যগত | সিস্টেম টেক্সট যায় instructions-এ (প্রথম), শীর্ষ স্তরের system-এ (দ্বিতীয়) বা একটা মেসেজে (লোকাল); স্টপ রিজনের শব্দ আলাদা | নথিভুক্ত |
| পরিচালনা | প্রথম ও দ্বিতীয় SDK ডিফল্টে দুবার রিট্রাই করে (তৃতীয়: গৌণ সূত্র); রেট-লিমিট হেডার শুধু প্রথম ও দ্বিতীয়র; দাম ও সীমা বদলায় | আংশিক [VERIFY] |
| অজানা | স্ট্রিমে usage; তৃতীয় SDK-র এক্সেপশন ও টাইমআউট; লোকালের টাইমআউট; লোকালের টুল সাপোর্ট | [VERIFY] |
মিনি-ল্যাব: প্রোভাইডার-নিরপেক্ষ টেক্সট সামারাইজার
summary.py-তে আছে summarise, প্রতিটা প্রোভাইডারের আকারের জন্য একটা করে নর্মালাইজার, guard (SDK-র ব্যর্থতাকে LLMError-এ রূপান্তর) আর রেজিস্ট্রিভিত্তিক build_client, যা একটা RetryingClient জুড়ে দেয়। summarise শব্দসীমা কোডেই জোর করে মানায়: প্রম্পট কোনো গার্ডরেইল নয়।
# file: evidencedesk/summary.py
from __future__ import annotations
from collections.abc import Callable
from evidencedesk.config import Settings
from evidencedesk.errors import LLMError, RetryingClient, RetryPolicy, from_status
from evidencedesk.llm import FakeClient, LLMClient, LLMRequest, LLMResponse, Message, Usage
DEMO_TEXT = ("You can return a new, unridden bike within 30 days of delivery for a full refund. We refund the "
"price of the bike; the delivery fee is refunded only if the bike arrived damaged.")
DEMO_FIXTURES = {DEMO_TEXT: {"text": "A new, unridden bike can be returned within 30 days for a full refund; "
"the delivery fee is refunded only if it arrived damaged.",
"usage": {"input_tokens": 52, "output_tokens": 24}}}
def summarise(client: LLMClient, text: str, max_words: int = 30) -> str:
"""The word limit is enforced here, in code, not only requested in the prompt."""
text = text.strip()
if not text:
raise ValueError("nothing to summarise")
system = f"Summarise the text in at most {max_words} words. Use only the text."
response = client.complete(LLMRequest((Message("system", system), Message("user", text))))
words = response.text.split()
return " ".join(words[:max_words]) + (" ..." if len(words) > max_words or response.stop_reason == "length" else "")
# Version-sensitive: only these functions know a provider's field names.
def split_system(request: LLMRequest) -> tuple[str, str]:
def join(role: str) -> str:
return "\n".join(m.content for m in request.messages if m.role == role)
return join("system"), join("user")
def from_first(r, model: str) -> LLMResponse: # r: an object with .output_text, .usage, .status
stop = "length" if r.status == "incomplete" else "end" if r.status == "completed" else "other"
return LLMResponse(r.output_text, model, Usage(r.usage.input_tokens, r.usage.output_tokens), stop)
def from_second(r, model: str) -> LLMResponse: # r: an object with .content blocks, .usage, .stop_reason
text = "".join(b.text for b in r.content if b.type == "text")
return LLMResponse(text, model, Usage(r.usage.input_tokens, r.usage.output_tokens),
{"end_turn": "end", "max_tokens": "length"}.get(r.stop_reason, "other"))
def guard(call: Callable[[], object], timeout_type: type[Exception]):
"""Run an SDK call and translate its failures."""
try:
return call()
except timeout_type as e:
raise LLMError("timeout", str(e)) from e
except Exception as e:
if getattr(e, "status_code", None) is None:
raise
raise from_status(e.status_code, dict(e.response.headers), e.response.text) from e
REGISTRY: dict[str, Callable[[Settings], LLMClient]] = {"fake": lambda s: FakeClient(DEMO_FIXTURES)}
def build_client(settings: Settings) -> LLMClient:
if settings.provider not in REGISTRY:
raise ValueError(f"no adapter registered for {settings.provider!r}")
return RetryingClient(REGISTRY[settings.provider](settings), RetryPolicy(max_attempts=settings.max_retries + 1))টেস্টে ব্যবহার হয় ফেক ক্লায়েন্ট, ফিক্সচার আর খালি এনভায়রনমেন্ট (ফেক সামারি একজন মানুষের লেখা টেক্সট, মডেলের আউটপুট নয়); obj একটা ফিক্সচারকে অ্যাট্রিবিউট দেয়, যেমন SDK-র ফলাফলে থাকে:
# file: tests/test_summariser.py
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from evidencedesk.config import load_settings
from evidencedesk.errors import LLMError, from_status
from evidencedesk.llm import FakeClient, LLMClient
from evidencedesk.summary import DEMO_TEXT, build_client, from_first, from_second, guard, summarise
SAMPLES = json.loads((Path(__file__).parent.parent / "fixtures" / "provider_samples.json").read_text(encoding="utf-8"))
def obj(d): # dictionary -> object with attributes, like an SDK result
return json.loads(json.dumps(d), object_hook=lambda x: SimpleNamespace(**x))
def test_empty_environment_gives_a_working_offline_summariser():
client = build_client(load_settings({}))
assert isinstance(client, LLMClient)
assert summarise(client, DEMO_TEXT).startswith("A new, unridden bike")
def test_word_limit_is_enforced_in_code():
long = {"text": "one two three four five six", "usage": {"input_tokens": 1, "output_tokens": 6}}
assert summarise(FakeClient({"x": long}), "x", max_words=4) == "one two three four ..."
def test_two_provider_shapes_become_one_result():
results = [from_first(obj(SAMPLES["first"]), "m"), from_second(obj(SAMPLES["second"]), "m")]
assert results[0] == results[1] # same text, usage and stop reason from two different shapes
assert results[0].text == "Returns: 30 days, full refund." and results[0].usage.total_tokens == 61
@pytest.mark.parametrize("case", SAMPLES["errors"], ids=lambda c: c["name"])
def test_recorded_failures_map_to_the_expected_kind(case):
assert from_status(case["status"], case["headers"], case["body"]).kind == case["kind"]
def test_guard_translates_sdk_failures():
class Boom(Exception):
status_code, response = 429, SimpleNamespace(headers={"retry-after": "2"}, text="")
def fail():
raise Boom()
with pytest.raises(LLMError) as info:
guard(fail, TimeoutError)
assert (info.value.kind, info.value.retry_after) == ("rate_limited", 2.0)python -m pytest -q tests/test_summariser.py....... [100%]
7 passedদুটো ঐচ্ছিক অ্যাডাপ্টার, প্রতিটা সর্বোচ্চ ১২ লাইনের। একটা ব্যবহার করতে চাইলে সেটা REGISTRY-তে যোগ করুন আর EVIDENCEDESK_PROVIDER, _MODEL ও কি সেট করুন। এগুলো এখানে চালানো যায় না, তাই কোনো আউটপুট নেই।
# not run here: needs the SDK, a key and internet
import openai
from evidencedesk.summary import from_first, guard, split_system
class FirstAdapter:
def __init__(self, s): # max_retries=0: our RetryingClient retries
self.model = s.model
self.api = openai.OpenAI(api_key=s.api_key.get_secret_value(), timeout=s.timeout_s, max_retries=0)
def complete(self, request):
system, user = split_system(request)
call = lambda: self.api.responses.create(model=self.model, instructions=system, input=user)
return from_first(guard(call, openai.APITimeoutError), self.model)# not run here: needs the SDK, a key and internet
import anthropic
from evidencedesk.summary import from_second, guard, split_system
class SecondAdapter:
def __init__(self, s):
self.model = s.model
self.api = anthropic.Anthropic(api_key=s.api_key.get_secret_value(), timeout=s.timeout_s, max_retries=0)
def complete(self, request):
system, user = split_system(request)
ask = dict(model=self.model, max_tokens=request.max_tokens, system=system, messages=[{"role": "user", "content": user}])
return from_second(guard(lambda: self.api.messages.create(**ask), anthropic.APITimeoutError), self.model)প্রোভাইডার নোট (২০২৬-১০-১২ অনুযায়ী): স্কেচগুলো প্রথম প্রোভাইডারের জন্য
responses.create(model=..., instructions=..., input=...)আর দ্বিতীয়টার জন্যmessages.create(model=..., max_tokens=..., system=..., messages=[...])ব্যবহার করে, এবংmax_retries=0দেয়।timeoutআর্গুমেন্টের নাম, আর দ্বিতীয় SDK-র স্ট্যাটাস এক্সেপশনে প্রথমটার মতোstatus_codeওresponseআছে কি না, এখানে নিশ্চিত করা হয়নি [VERIFY]। প্রোভাইডারের মডেল-পাতা থেকে বর্তমান মডেল ID কপি করুন। বর্তমান ডকুমেন্টেশন দেখে নিন।
এই টেস্টগুলো যা দেখাতে পারে না: আসল প্রোভাইডার এখনো হাতে লেখা ফিক্সচারের আকারেই উত্তর দেয় কি না। এরা ম্যাপিংয়ের ভুল ধরে, প্রোভাইডারের পরিবর্তন নয়; তাই প্রতিটা অ্যাডাপ্টার একবার আসলেই চালিয়ে দেখুন। পরের পাতা বানায় complete-এ যা যায়: নির্দেশ, ব্যবহারকারীর টেক্সট আর রিট্রিভ করা প্রমাণ।
কাজের জায়গায় এটা কোথায় দেখা যায়
রাতের একটা ব্যাচ রেট লিমিটে ঠেকে: হয় অপেক্ষা করে শেষ করে, নয়তো অর্ধেক কাজ করে মরে যায়। একটা ইনভয়েস হঠাৎ বেড়ে যায়, কারণ প্রোভাইডারের বিভ্রাটের সময় সীমাহীন রিট্রাই লুপ চলেছে।
সাধারণ ভুল
- প্রতিটা এরর রিট্রাই করা। ভুল কি বা অবৈধ রিকোয়েস্ট প্রতিবার একইভাবে ব্যর্থ হয়, আর প্রতিটা চেষ্টার দাম আপনি দেন। শুধু টাইমআউট, রেট লিমিট আর সার্ভার এরর রিট্রাই করুন।
- সীমা নেই, বিরতি নেই। অন্তহীন বা তাৎক্ষণিক রিট্রাই লুপ বিভ্রাট আরও খারাপ করে। সীমা দিন, ব্যাকঅফ করুন, জিটার যোগ করুন।
- একের ওপর আরেক রিট্রাই। SDK-ও রিট্রাই করে, কোডও করে: একটা ব্যর্থতা নয়টা কল হয়ে যায়। নীতি থাকবে একটাই স্তরের হাতে।
- কোডে, লগে বা টেস্টে সিক্রেট। এনভায়রনমেন্ট আর
SecretStrব্যবহার করুন; যে কি কখনো কমিট হয়েছে সেটা বদলে ফেলুন।
নিজে চেষ্টা করুন
- সহজ। দেখান যে
EVIDENCEDESK_MAX_RETRIES=9প্রত্যাখ্যাত হয়, আর এররটা কোন ফিল্ডের নাম বলে। - মাঝারি। প্রোভাইডার-নিরপেক্ষ LLM ক্লায়েন্ট পাতায় ফিরে যান। তার
ShapeBFakeকপি করুন যাতে সেটা একবার 429 ওRetry-After: 1দিয়ে ব্যর্থ হয়; সেটাকেRetryingClient-এ মুড়ুন, আর দেখুনanswer_questionকাজ করে এবং রেকর্ড করা অপেক্ষা ঠিক এক সেকেন্ড। - কঠিন। AI-এর জন্য Git, API ও ব্যাকএন্ড: বিগিনার টিউটোরিয়ালে ফিরে যান। আপনার নিজের API প্রোভাইডারের স্ট্যাটাস সরাসরি পাস করবে না (প্রোভাইডারের ভুল কি কলারের 401 নয়)।
public_status(error)লিখে টেস্ট করুন, যা প্রতিটাkind-কে আপনার সার্ভিসের স্ট্যাটাসে ম্যাপ করে।
উত্তর
সহজ: load_settings({"EVIDENCEDESK_MAX_RETRIES": "9"}) একটা ValidationError তোলে, max_retries ফিল্ডের জন্য (সীমা ৫)। মাঝারি আর কঠিন:
# file: tests/test_exercises_p02.py
from test_llm import Q_RETURN, ShapeBFake
from evidencedesk.errors import RetryingClient, RetryPolicy, from_status
from evidencedesk.llm import answer_question
class OnceLimited(ShapeBFake):
todo = [from_status(429, {"Retry-After": "1"})]
def complete(self, request):
if self.todo:
raise self.todo.pop()
return super().complete(request)
def test_medium_retry_wrapper_around_an_earlier_fake():
slept = []
client = RetryingClient(OnceLimited(), RetryPolicy(jitter=0), slept.append)
assert answer_question(client, Q_RETURN).startswith("You can return") and slept == [1.0]
def public_status(error): # never pass a provider 401 on: it is our key, not the caller's
return {"timeout": 504, "rate_limited": 503, "server": 503, "auth": 502, "quota_exhausted": 502}.get(error.kind, 500)
def test_hard_provider_errors_become_our_own_http_statuses():
assert [public_status(from_status(s)) for s in (401, 429, 400)] == [502, 503, 500]python -m pytest -q tests/test_exercises_p02.py চালান; দুটো টেস্ট পাস করা উচিত। পাসের চেকলিস্ট:
- সহজ: এররটা
max_retries-এর নাম বলে;config.pyবদলায়নি। - মাঝারি: কোনো কিছুই সত্যিকারের sleep করে না; অপেক্ষা ১.০, কারণ সার্ভারের মান আমাদের ০.৫ সেকেন্ডের অনুমানের চেয়ে বড়।
- কঠিন: auth ও কোটার ব্যর্থতা সার্ভার-পক্ষের স্ট্যাটাস হয় (401 নয়); অন্য যুক্তিসঙ্গত ম্যাপিংও চলবে, যদি ব্যাখ্যা করতে পারেন।
সারসংক্ষেপ
- প্রোভাইডার, মডেল, টাইমআউট আর রিট্রাইয়ের সীমা রাখুন ম্যাপিং থেকে বানানো যাচাই করা সেটিংসে; অফলাইন ডিফল্ট হলো ফেক।
- সিক্রেট রাখুন এনভায়রনমেন্টে বা সিক্রেট ফাইলে, লুকিয়ে রাখুন (
SecretStr), ফাইলগুলো ইগনোর করুন, কি-র আকারের স্ট্রিংয়ের জন্য টেস্ট রাখুন। - ব্যর্থতাকে একটাই
LLMError-এ আনুন: টাইমআউট, রেট লিমিট ও সার্ভার এরর রিট্রাই করুন সীমা ও জিটারসহ ব্যাকঅফে; অবৈধ রিকোয়েস্ট, অথেনটিকেশন ব্যর্থতা বা শেষ হয়ে যাওয়া কোটা কখনো রিট্রাই করবেন না। - সক্ষমতার ম্যাট্রিক্স আর রিপোর্ট দিয়ে প্রোভাইডার তুলনা করুন; অজানার পাশে [VERIFY] বসান।
- পরীক্ষিত ভার্সন পিন করুন, ভার্সন-সংবেদনশীল ম্যাপিং আলাদা রাখুন, সৎ ফিক্সচার রাখুন, ক্রেডেনশিয়াল ছাড়া টেস্ট চালান।
এরপর: কনটেক্সট সাজানো ও বিশ্বাসের সীমানা। নামযুক্ত উপাদান থেকে মডেলের ইনপুট বানাবেন এবং বিশ্বস্ত নির্দেশ ও অবিশ্বস্ত টেক্সট আলাদা রাখবেন।