first commit

This commit is contained in:
2026-06-27 09:32:54 -03:00
commit 029adfafc3
23 changed files with 1205 additions and 0 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

84
src/allocate_costs.py Normal file
View 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
View 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,
}

View 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
View 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
View 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
View 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
View 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
View 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())