File size: 5,214 Bytes
929f2ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aeb8c1c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
929f2ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aeb8c1c
929f2ac
aeb8c1c
 
929f2ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
"""
LLM wrapper with token accounting and CO2 emission estimation.

Wraps an Azure OpenAI-compatible chat/completions call and returns:
- content: the generated text
- tokens: prompt / completion / cached / total
- energy_kwh, co2_grams: environmental impact for the call

CO2 emissions are estimated with `ecologits`, which uses a model registry
(parameter counts, hardware assumptions) plus output token count and request
latency to compute global warming potential (kgCO2eq) and energy (kWh).
We chose `ecologits` over `codecarbon` because the LLM runs on a remote
endpoint — `codecarbon` measures local process energy and would only count
the client overhead, not the inference itself.
"""

import logging
import time
from typing import Optional

import requests
from fastapi import HTTPException

logger = logging.getLogger(__name__)

try:
    from ecologits.tracers.utils import llm_impacts
    _ECOLOGITS_AVAILABLE = True
except ImportError:
    _ECOLOGITS_AVAILABLE = False
    logger.warning("ecologits not installed — CO2 emission will be reported as None.")


def _to_scalar(value) -> Optional[float]:
    """
    Normalize an ecologits impact value to a single float.

    ecologits returns either a plain float or a RangeValue(min, max) depending
    on the model registry entry. For RangeValue we return the midpoint so a
    single representative number flows through the API/UI.
    """
    if value is None:
        return None
    if hasattr(value, "min") and hasattr(value, "max"):
        return (float(value.min) + float(value.max)) / 2.0
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


def call_llm(
    prompt: str,
    *,
    endpoint_url: str,
    api_key: str,
    model: str,
    max_completion_tokens: int = 512,
    temperature: float = 0.7,
    top_p: float = 0.95,
    provider: str = "openai",
    timeout: Optional[float] = None,
) -> dict:
    """
    Call an Azure OpenAI-compatible chat/completions endpoint and return the
    response together with token counts and CO2 emission estimate.

    Returns a dict:
        {
            "content": str,
            "tokens": {
                "prompt": int,
                "completion": int,
                "cached": int,
                "total": int,
            },
            "energy_kwh": float | None,
            "co2_grams": float | None,
            "latency_s": float,
        }
    """
    headers = {
        "api-key": api_key,
        "Content-Type": "application/json",
    }
    payload = {
        "model": model,
        "messages": [{"role": "user", "content": prompt}],
        "max_completion_tokens": max_completion_tokens,
        "temperature": temperature,
        "top_p": top_p,
    }

    start = time.perf_counter()
    try:
        resp = requests.post(endpoint_url, headers=headers, json=payload, timeout=timeout)
        resp.raise_for_status()
        data = resp.json()
    except requests.exceptions.HTTPError as e:
        logger.error(f"LLM API call failed: {e}{resp.text}")
        raise HTTPException(status_code=503, detail=f"LLM service unavailable: {str(e)}")
    except (requests.exceptions.JSONDecodeError, ValueError):
        logger.error(
            f"LLM API returned non-JSON response (status {resp.status_code}): {repr(resp.text)}"
        )
        raise HTTPException(status_code=502, detail="LLM service returned an invalid response")
    latency_s = time.perf_counter() - start

    try:
        content = data["choices"][0]["message"]["content"].strip()
    except (KeyError, IndexError) as e:
        logger.error(f"Unexpected LLM response format: {e} — body: {data!r}")
        raise HTTPException(status_code=502, detail="Unexpected response from LLM service")

    usage = data.get("usage") or {}
    prompt_tokens = usage.get("prompt_tokens", 0)
    completion_tokens = usage.get("completion_tokens", 0)
    total_tokens = usage.get("total_tokens", prompt_tokens + completion_tokens)

    prompt_details = usage.get("prompt_tokens_details") or {}
    cached_tokens = prompt_details.get("cached_tokens", usage.get("cached_tokens", 0))

    energy_kwh: Optional[float] = None
    co2_grams: Optional[float] = None
    if _ECOLOGITS_AVAILABLE:
        try:
            impacts = llm_impacts(
                provider=provider,
                model_name=model,
                output_token_count=completion_tokens,
                request_latency=latency_s,
            )
            if impacts is not None:
                energy_kwh = _to_scalar(impacts.energy.value)
                # ecologits returns gwp in kgCO2eq; convert to grams
                gwp_kg = _to_scalar(impacts.gwp.value)
                co2_grams = gwp_kg * 1000.0 if gwp_kg is not None else None
        except Exception as e:
            logger.warning(f"ecologits impact calc failed for model={model}: {e}")

    return {
        "content": content,
        "tokens": {
            "prompt": prompt_tokens,
            "completion": completion_tokens,
            "cached": cached_tokens,
            "total": total_tokens,
        },
        "energy_kwh": energy_kwh,
        "co2_grams": co2_grams,
        "latency_s": latency_s,
    }