mirror of
https://github.com/hoshikawa2/oci-litellm-cost-per-api.git
synced 2026-07-09 17:54:21 +00:00
first commit
This commit is contained in:
BIN
src/__pycache__/api_service.cpython-313.pyc
Normal file
BIN
src/__pycache__/api_service.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/__pycache__/ledger.cpython-313.pyc
Normal file
BIN
src/__pycache__/ledger.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/__pycache__/oci_usage_rest.cpython-313.pyc
Normal file
BIN
src/__pycache__/oci_usage_rest.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/__pycache__/settings.cpython-313.pyc
Normal file
BIN
src/__pycache__/settings.cpython-313.pyc
Normal file
Binary file not shown.
84
src/allocate_costs.py
Normal file
84
src/allocate_costs.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ledger import Ledger
|
||||
from oci_usage_rest import fetch_monthly_genai_cost_rest
|
||||
from settings import Env, load_config
|
||||
|
||||
|
||||
def compute_allocations(rows: list[dict[str, Any]], total_cost: float, metric: str, input_weight: float, output_weight: float) -> list[dict[str, Any]]:
|
||||
enriched = []
|
||||
for r in rows:
|
||||
weighted_tokens = (r['input_tokens'] or 0) * input_weight + (r['output_tokens'] or 0) * output_weight
|
||||
r = dict(r)
|
||||
r['weighted_tokens'] = weighted_tokens
|
||||
enriched.append(r)
|
||||
|
||||
denominator = sum(float(r[metric] or 0) for r in enriched) or 1.0
|
||||
for r in enriched:
|
||||
share = float(r[metric] or 0) / denominator
|
||||
r['allocation_metric'] = metric
|
||||
r['share_pct'] = round(share * 100, 6)
|
||||
r['allocated_cost'] = round(total_cost * share, 6)
|
||||
return enriched
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--month', help='YYYY-MM; default vem de config/apis.yaml')
|
||||
parser.add_argument('--oci-cost', type=float, help='Permite testar sem chamar OCI Usage API')
|
||||
parser.add_argument('--out', default='./storage/allocation_report.csv')
|
||||
args = parser.parse_args()
|
||||
|
||||
env = Env()
|
||||
cfg = load_config(env.config_file)
|
||||
month = args.month or cfg.month
|
||||
rows = Ledger(env.ledger_db).monthly_usage(month)
|
||||
|
||||
if args.oci_cost is None:
|
||||
cost_result = fetch_monthly_genai_cost_rest(month)
|
||||
total_cost = cost_result.amount
|
||||
currency = cost_result.currency or cfg.currency
|
||||
Path('./storage/oci_usage_raw.json').write_text(json.dumps(cost_result.raw, indent=2, ensure_ascii=False), encoding='utf-8')
|
||||
else:
|
||||
total_cost = args.oci_cost
|
||||
currency = cfg.currency
|
||||
|
||||
allocations = compute_allocations(
|
||||
rows=rows,
|
||||
total_cost=total_cost,
|
||||
metric=cfg.allocation_metric,
|
||||
input_weight=cfg.model.input_weight,
|
||||
output_weight=cfg.model.output_weight,
|
||||
)
|
||||
|
||||
fieldnames = [
|
||||
'month', 'api_id', 'api_name', 'requests', 'input_tokens', 'output_tokens', 'total_tokens',
|
||||
'weighted_tokens', 'litellm_estimated_cost', 'allocation_metric', 'share_pct', 'allocated_cost',
|
||||
'avg_latency_ms'
|
||||
]
|
||||
out = Path(args.out)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
with out.open('w', newline='', encoding='utf-8') as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
for r in allocations:
|
||||
writer.writerow({k: r.get(k) for k in fieldnames})
|
||||
|
||||
print(json.dumps({
|
||||
'month': month,
|
||||
'oci_total_cost': total_cost,
|
||||
'currency': currency,
|
||||
'allocation_metric': cfg.allocation_metric,
|
||||
'report': str(out),
|
||||
'allocations': allocations,
|
||||
}, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
68
src/api_service.py
Normal file
68
src/api_service.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Header, HTTPException
|
||||
from openai import OpenAI
|
||||
from pydantic import BaseModel
|
||||
|
||||
from settings import Env, load_config
|
||||
|
||||
|
||||
env = Env()
|
||||
config = load_config(env.config_file)
|
||||
api_by_id = {a.id: a for a in config.apis}
|
||||
|
||||
app = FastAPI(title='LLM Consumer API Simulator')
|
||||
|
||||
|
||||
class PromptRequest(BaseModel):
|
||||
prompt: str | None = None
|
||||
max_tokens: int = 256
|
||||
|
||||
|
||||
def client_for(api_id: str) -> OpenAI:
|
||||
api_def = api_by_id.get(api_id)
|
||||
if not api_def:
|
||||
raise HTTPException(status_code=404, detail=f'API {api_id} não cadastrada em {env.config_file}')
|
||||
return OpenAI(api_key=api_def.virtual_key, base_url=env.litellm_base_url)
|
||||
|
||||
|
||||
@app.get('/apis')
|
||||
def list_apis() -> list[dict[str, Any]]:
|
||||
return [a.model_dump(exclude={'virtual_key'}) for a in config.apis]
|
||||
|
||||
|
||||
@app.post('/apis/{api_id}/ask')
|
||||
def ask(api_id: str, body: PromptRequest, x_request_id: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
api_def = api_by_id.get(api_id)
|
||||
if not api_def:
|
||||
raise HTTPException(status_code=404, detail='api_id inválido')
|
||||
|
||||
prompt = body.prompt or f'Responda em uma frase: qual é uma boa métrica para FinOps de LLM? rand={random.randint(1, 9999)}'
|
||||
t0 = time.perf_counter()
|
||||
response = client_for(api_id).chat.completions.create(
|
||||
model=config.model.alias,
|
||||
messages=[
|
||||
{'role': 'system', 'content': 'Você é um assistente técnico objetivo.'},
|
||||
{'role': 'user', 'content': prompt},
|
||||
],
|
||||
max_tokens=body.max_tokens,
|
||||
metadata={
|
||||
'api_id': api_def.id,
|
||||
'api_name': api_def.name,
|
||||
'owner': api_def.owner,
|
||||
'external_request_id': x_request_id,
|
||||
},
|
||||
)
|
||||
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
||||
usage = response.usage.model_dump() if response.usage else {}
|
||||
return {
|
||||
'api_id': api_id,
|
||||
'model': config.model.alias,
|
||||
'latency_ms': elapsed_ms,
|
||||
'usage': usage,
|
||||
'answer': response.choices[0].message.content,
|
||||
}
|
||||
33
src/create_litellm_keys.py
Normal file
33
src/create_litellm_keys.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from settings import Env, load_config
|
||||
|
||||
|
||||
def main() -> None:
|
||||
env = Env()
|
||||
cfg = load_config(env.config_file)
|
||||
headers = {'Authorization': f'Bearer {env.litellm_master_key}'}
|
||||
|
||||
with httpx.Client(timeout=30) as client:
|
||||
for api in cfg.apis:
|
||||
payload = {
|
||||
'key': api.virtual_key,
|
||||
'aliases': {cfg.model.alias: cfg.model.alias},
|
||||
'models': [cfg.model.alias],
|
||||
'metadata': {'api_id': api.id, 'api_name': api.name, 'owner': api.owner},
|
||||
'team_id': api.owner or api.id,
|
||||
'user_id': api.id,
|
||||
}
|
||||
r = client.post(f'{env.litellm_base_url.replace("/v1", "")}/key/generate', headers=headers, json=payload)
|
||||
if r.status_code in (200, 201):
|
||||
print(f'OK key criada/registrada: {api.id}')
|
||||
elif r.status_code == 400 and 'already' in r.text.lower():
|
||||
print(f'OK key já existe: {api.id}')
|
||||
else:
|
||||
print(f'ERRO {api.id}: {r.status_code} {r.text}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
149
src/custom_callbacks.py
Normal file
149
src/custom_callbacks.py
Normal file
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
|
||||
# Este callback roda dentro do LiteLLM Proxy.
|
||||
# Ele persiste um ledger local por API consumidora usando metadata enviada na chamada.
|
||||
try:
|
||||
from ledger import Ledger, now_iso
|
||||
except Exception: # pragma: no cover - fallback quando import path muda no container
|
||||
from src.ledger import Ledger, now_iso
|
||||
|
||||
def _extract_metadata(kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
candidates = []
|
||||
|
||||
candidates.append(kwargs.get("metadata"))
|
||||
|
||||
litellm_params = kwargs.get("litellm_params") or {}
|
||||
candidates.append(litellm_params.get("metadata"))
|
||||
|
||||
standard_logging_object = kwargs.get("standard_logging_object") or {}
|
||||
candidates.append(standard_logging_object.get("metadata"))
|
||||
|
||||
proxy_server_request = kwargs.get("proxy_server_request") or {}
|
||||
candidates.append(proxy_server_request.get("metadata"))
|
||||
|
||||
for candidate in candidates:
|
||||
if isinstance(candidate, dict) and candidate:
|
||||
return candidate
|
||||
|
||||
return {}
|
||||
|
||||
def _month_from_ts(dt: datetime) -> str:
|
||||
return dt.strftime('%Y-%m')
|
||||
|
||||
def _json_safe(value):
|
||||
"""Converte objetos não serializáveis do LiteLLM em tipos seguros para JSON."""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
|
||||
if isinstance(value, dict):
|
||||
return {str(k): _json_safe(v) for k, v in value.items()}
|
||||
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [_json_safe(v) for v in value]
|
||||
|
||||
# Pydantic / dataclass-like
|
||||
if hasattr(value, "model_dump"):
|
||||
try:
|
||||
return _json_safe(value.model_dump())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if hasattr(value, "dict"):
|
||||
try:
|
||||
return _json_safe(value.dict())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Último fallback: vira string
|
||||
return str(value)
|
||||
|
||||
def _usage_get(response_obj: Any, key: str, default: int = 0) -> int:
|
||||
usage = getattr(response_obj, 'usage', None)
|
||||
if usage is None and isinstance(response_obj, dict):
|
||||
usage = response_obj.get('usage')
|
||||
if usage is None:
|
||||
return default
|
||||
if isinstance(usage, dict):
|
||||
return int(usage.get(key, default) or default)
|
||||
return int(getattr(usage, key, default) or default)
|
||||
|
||||
|
||||
def _response_id(response_obj: Any) -> str | None:
|
||||
if isinstance(response_obj, dict):
|
||||
return response_obj.get('id')
|
||||
return getattr(response_obj, 'id', None)
|
||||
|
||||
|
||||
class CostAllocationCallback(CustomLogger):
|
||||
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
|
||||
metadata = _extract_metadata(kwargs)
|
||||
|
||||
api_id = (
|
||||
metadata.get("api_id")
|
||||
or metadata.get("user_api_key_metadata", {}).get("api_id")
|
||||
or kwargs.get("user")
|
||||
or "unknown"
|
||||
)
|
||||
|
||||
api_name = (
|
||||
metadata.get("api_name")
|
||||
or metadata.get("user_api_key_metadata", {}).get("api_name")
|
||||
or api_id
|
||||
)
|
||||
|
||||
prompt_tokens = _usage_get(response_obj, 'prompt_tokens')
|
||||
completion_tokens = _usage_get(response_obj, 'completion_tokens')
|
||||
total_tokens = _usage_get(response_obj, 'total_tokens', prompt_tokens + completion_tokens)
|
||||
latency_ms = int((end_time - start_time).total_seconds() * 1000)
|
||||
|
||||
# LiteLLM calcula response_cost quando conhece o modelo/preço. Para rateio final,
|
||||
# a fonte da verdade será o custo total da OCI Usage API.
|
||||
response_cost = float(kwargs.get('response_cost') or 0)
|
||||
|
||||
ts = end_time if isinstance(end_time, datetime) else datetime.now(timezone.utc)
|
||||
ledger = Ledger(os.environ.get('LEDGER_DB', '/app/storage/ledger.sqlite'))
|
||||
|
||||
model = (
|
||||
kwargs.get("model")
|
||||
or kwargs.get("model_name")
|
||||
or (kwargs.get("standard_logging_object") or {}).get("model")
|
||||
or (kwargs.get("response_cost_information") or {}).get("model")
|
||||
or "unknown"
|
||||
)
|
||||
|
||||
safe_metadata = {
|
||||
"api_id": api_id,
|
||||
"api_name": api_name,
|
||||
"owner": metadata.get("owner"),
|
||||
"model": model,
|
||||
}
|
||||
|
||||
ledger.insert_usage({
|
||||
'ts': ts.isoformat(),
|
||||
'month': _month_from_ts(ts),
|
||||
'api_id': api_id,
|
||||
'api_name': api_name,
|
||||
'virtual_key_hash': str(kwargs.get('litellm_api_key') or kwargs.get('api_key') or '')[:12],
|
||||
'model': kwargs.get('model'),
|
||||
'request_id': _response_id(response_obj),
|
||||
'prompt_tokens': prompt_tokens,
|
||||
'completion_tokens': completion_tokens,
|
||||
'total_tokens': total_tokens,
|
||||
'response_cost': response_cost,
|
||||
'latency_ms': latency_ms,
|
||||
'status': 'success',
|
||||
'metadata_json': json.dumps(safe_metadata, ensure_ascii=False),
|
||||
})
|
||||
|
||||
|
||||
proxy_handler_instance = CostAllocationCallback()
|
||||
82
src/ledger.py
Normal file
82
src/ledger.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Any
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS llm_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL,
|
||||
month TEXT NOT NULL,
|
||||
api_id TEXT NOT NULL,
|
||||
api_name TEXT,
|
||||
virtual_key_hash TEXT,
|
||||
model TEXT,
|
||||
request_id TEXT,
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
response_cost REAL NOT NULL DEFAULT 0,
|
||||
latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'success',
|
||||
metadata_json TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_usage_month_api ON llm_usage(month, api_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_llm_usage_request ON llm_usage(request_id) WHERE request_id IS NOT NULL;
|
||||
"""
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class Ledger:
|
||||
def __init__(self, db_path: str | Path):
|
||||
self.db_path = Path(db_path)
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.init()
|
||||
|
||||
@contextmanager
|
||||
def connect(self) -> Iterator[sqlite3.Connection]:
|
||||
con = sqlite3.connect(self.db_path)
|
||||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield con
|
||||
con.commit()
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
def init(self) -> None:
|
||||
with self.connect() as con:
|
||||
con.executescript(SCHEMA)
|
||||
|
||||
def insert_usage(self, row: dict[str, Any]) -> None:
|
||||
keys = ','.join(row.keys())
|
||||
params = ','.join([f':{k}' for k in row.keys()])
|
||||
with self.connect() as con:
|
||||
con.execute(f'INSERT OR IGNORE INTO llm_usage ({keys}) VALUES ({params})', row)
|
||||
|
||||
def monthly_usage(self, month: str) -> list[dict[str, Any]]:
|
||||
sql = """
|
||||
SELECT
|
||||
month,
|
||||
api_id,
|
||||
MAX(api_name) AS api_name,
|
||||
COUNT(*) AS requests,
|
||||
SUM(prompt_tokens) AS input_tokens,
|
||||
SUM(completion_tokens) AS output_tokens,
|
||||
SUM(total_tokens) AS total_tokens,
|
||||
SUM(response_cost) AS litellm_estimated_cost,
|
||||
AVG(latency_ms) AS avg_latency_ms
|
||||
FROM llm_usage
|
||||
WHERE month = ? AND status = 'success'
|
||||
GROUP BY month, api_id
|
||||
ORDER BY total_tokens DESC
|
||||
"""
|
||||
with self.connect() as con:
|
||||
return [dict(r) for r in con.execute(sql, (month,)).fetchall()]
|
||||
86
src/oci_usage_rest.py
Normal file
86
src/oci_usage_rest.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import oci
|
||||
import requests
|
||||
from oci.config import from_file
|
||||
from oci.signer import Signer
|
||||
|
||||
from settings import Env
|
||||
|
||||
|
||||
@dataclass
|
||||
class OciCostResult:
|
||||
amount: float
|
||||
currency: str
|
||||
raw: dict[str, Any]
|
||||
|
||||
|
||||
def _make_signer() -> Signer:
|
||||
cfg = from_file()
|
||||
return Signer(
|
||||
tenancy=cfg['tenancy'],
|
||||
user=cfg['user'],
|
||||
fingerprint=cfg['fingerprint'],
|
||||
private_key_file_location=cfg['key_file'],
|
||||
pass_phrase=cfg.get('pass_phrase'),
|
||||
)
|
||||
|
||||
|
||||
def _iso_z(d: date) -> str:
|
||||
return datetime(d.year, d.month, d.day, tzinfo=timezone.utc).isoformat().replace('+00:00', 'Z')
|
||||
|
||||
|
||||
def fetch_monthly_genai_cost_rest(month: str) -> OciCostResult:
|
||||
"""Consulta a OCI Usage API via REST assinado.
|
||||
|
||||
month: YYYY-MM
|
||||
|
||||
Observação: em alguns tenancies o nome exato de service/productDescription pode variar.
|
||||
Ajuste OCI_USAGE_SERVICE_FILTER e OCI_USAGE_PRODUCT_FILTER no .env conforme a saída real.
|
||||
"""
|
||||
env = Env()
|
||||
year, mon = [int(x) for x in month.split('-')]
|
||||
start = date(year, mon, 1)
|
||||
end = date(year + (mon == 12), 1 if mon == 12 else mon + 1, 1)
|
||||
|
||||
endpoint = env.oci_usage_endpoint.rstrip('/')
|
||||
url = f'{endpoint}/{env.oci_usage_api_version}/usage'
|
||||
|
||||
# RequestSummarizedUsagesDetails. A granularidade mensal reduz ruído de hora/dia.
|
||||
# GroupBy opcional ajuda a auditar qual produto/sku veio na resposta.
|
||||
payload: dict[str, Any] = {
|
||||
'tenantId': from_file()['tenancy'],
|
||||
'timeUsageStarted': _iso_z(start),
|
||||
'timeUsageEnded': _iso_z(end),
|
||||
'granularity': 'MONTHLY',
|
||||
'queryType': 'COST',
|
||||
'groupBy': ['service', 'productDescription'],
|
||||
'isAggregateByTime': True,
|
||||
}
|
||||
|
||||
filters: list[dict[str, Any]] = []
|
||||
if env.oci_usage_service_filter:
|
||||
filters.append({'dimension': 'service', 'operator': 'CONTAINS', 'value': env.oci_usage_service_filter})
|
||||
if env.oci_usage_product_filter:
|
||||
filters.append({'dimension': 'productDescription', 'operator': 'CONTAINS', 'value': env.oci_usage_product_filter})
|
||||
if filters:
|
||||
payload['filter'] = {'operator': 'AND', 'dimensions': filters}
|
||||
|
||||
r = requests.post(url, auth=_make_signer(), headers={'content-type': 'application/json'}, data=json.dumps(payload), timeout=60)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
# OCI pode retornar itens em data/items dependendo do cliente/endpoint/revisão.
|
||||
items = data.get('items') or data.get('data') or []
|
||||
amount = 0.0
|
||||
currency = 'USD'
|
||||
for item in items:
|
||||
amount += float(item.get('computedAmount') or item.get('cost') or item.get('amount') or 0)
|
||||
currency = item.get('currency') or item.get('currencyCode') or currency
|
||||
|
||||
return OciCostResult(amount=amount, currency=currency, raw=data)
|
||||
51
src/settings.py
Normal file
51
src/settings.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Env(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file='.env', extra='ignore')
|
||||
|
||||
litellm_base_url: str = 'http://localhost:4000/v1'
|
||||
litellm_master_key: str = 'sk-master-poc'
|
||||
litellm_model_alias: str = 'oci-gemini-25-pro'
|
||||
ledger_db: str = './storage/ledger.sqlite'
|
||||
config_file: str = './config/apis.yaml'
|
||||
|
||||
oci_usage_endpoint: str = 'https://usageapi.us-ashburn-1.oci.oraclecloud.com'
|
||||
oci_usage_api_version: str = '20200107'
|
||||
oci_usage_service_filter: str = 'generative_ai'
|
||||
oci_usage_product_filter: str | None = None
|
||||
|
||||
|
||||
class ApiDef(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
virtual_key: str
|
||||
owner: str | None = None
|
||||
|
||||
|
||||
class ModelDef(BaseModel):
|
||||
alias: str
|
||||
oci_model_name: str
|
||||
input_weight: float = 1.0
|
||||
output_weight: float = 5.0
|
||||
|
||||
|
||||
class AppConfig(BaseModel):
|
||||
month: str
|
||||
allocation_metric: Literal['weighted_tokens', 'total_tokens', 'requests'] = 'weighted_tokens'
|
||||
currency: str = 'USD'
|
||||
model: ModelDef
|
||||
apis: list[ApiDef] = Field(default_factory=list)
|
||||
|
||||
|
||||
def load_config(path: str | Path) -> AppConfig:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return AppConfig.model_validate(yaml.safe_load(f))
|
||||
55
src/simulate_load.py
Normal file
55
src/simulate_load.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import random
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
|
||||
from settings import Env, load_config
|
||||
|
||||
|
||||
PROMPTS = [
|
||||
'Explique em uma frase o que é rateio de custo de LLM.',
|
||||
'Gere uma resposta curta para cliente perguntando segunda via de fatura.',
|
||||
'Classifique a intenção: quero trocar meu plano de internet.',
|
||||
'Resuma em tópicos uma política de uso responsável de IA.',
|
||||
'Dê 3 métricas para monitorar consumo de tokens por API.',
|
||||
]
|
||||
|
||||
|
||||
async def call_one(client: httpx.AsyncClient, base_url: str, api_id: str) -> None:
|
||||
prompt = random.choice(PROMPTS) + f' request={uuid4()}'
|
||||
try:
|
||||
r = await client.post(
|
||||
f'{base_url}/apis/{api_id}/ask',
|
||||
headers={'x-request-id': str(uuid4())},
|
||||
json={'prompt': prompt, 'max_tokens': random.choice([64, 128, 256])},
|
||||
)
|
||||
print(api_id, r.status_code, r.json().get('usage'))
|
||||
except Exception as e:
|
||||
print(api_id, 'ERROR', repr(e))
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--calls', type=int, default=30)
|
||||
parser.add_argument('--concurrency', type=int, default=5)
|
||||
parser.add_argument('--api-base', default='http://localhost:8080')
|
||||
args = parser.parse_args()
|
||||
|
||||
env = Env()
|
||||
cfg = load_config(env.config_file)
|
||||
api_ids = [a.id for a in cfg.apis]
|
||||
sem = asyncio.Semaphore(args.concurrency)
|
||||
|
||||
async with httpx.AsyncClient(timeout=180) as client:
|
||||
async def guarded(i: int) -> None:
|
||||
async with sem:
|
||||
await call_one(client, args.api_base, random.choice(api_ids))
|
||||
await asyncio.gather(*(guarded(i) for i in range(args.calls)))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user