From c30067cb25bf9930d925a93c9e3ed2dd9662ffc7 Mon Sep 17 00:00:00 2001 From: Vincent Duarte Date: Thu, 19 Mar 2026 16:15:31 +0100 Subject: [PATCH 1/4] doc: complete full api docs - descriptions - tag - responses examples --- app/api/v1/responses/__init__.py | 11 ++ app/api/v1/responses/electricity_mix.py | 28 +++++ app/api/v1/responses/estimations.py | 149 ++++++++++++++++++++++++ app/api/v1/responses/models.py | 35 ++++++ app/api/v1/responses/providers.py | 19 +++ app/api/v1/router.py | 109 +++++++++++++---- app/core/config.py | 11 +- app/core/description.md | 27 +++++ app/main.py | 26 ++++- 9 files changed, 385 insertions(+), 30 deletions(-) create mode 100644 app/api/v1/responses/__init__.py create mode 100644 app/api/v1/responses/electricity_mix.py create mode 100644 app/api/v1/responses/estimations.py create mode 100644 app/api/v1/responses/models.py create mode 100644 app/api/v1/responses/providers.py create mode 100644 app/core/description.md diff --git a/app/api/v1/responses/__init__.py b/app/api/v1/responses/__init__.py new file mode 100644 index 0000000..e027f34 --- /dev/null +++ b/app/api/v1/responses/__init__.py @@ -0,0 +1,11 @@ +from app.api.v1.responses.electricity_mix import ELECTRICITY_MIX_RESPONSES +from app.api.v1.responses.estimations import ESTIMATIONS_RESPONSES +from app.api.v1.responses.models import MODELS_RESPONSES +from app.api.v1.responses.providers import PROVIDERS_RESPONSES + +__all__ = [ + "PROVIDERS_RESPONSES", + "MODELS_RESPONSES", + "ELECTRICITY_MIX_RESPONSES", + "ESTIMATIONS_RESPONSES", +] diff --git a/app/api/v1/responses/electricity_mix.py b/app/api/v1/responses/electricity_mix.py new file mode 100644 index 0000000..dab3961 --- /dev/null +++ b/app/api/v1/responses/electricity_mix.py @@ -0,0 +1,28 @@ +ELECTRICITY_MIX_RESPONSES = { + 200: { + "description": "Electricity mix composition for the specified zone.", + "content": { + "application/json": { + "example": { + "electricity_mix": { + "zone": "FRA", + "adpe": 4.858e-08, + "pe": 9.3135, + "gwp": 0.04418, + "wue": 3.6737, + } + } + } + }, + }, + 404: { + "description": "Zone not supported by EcoLogits.", + "content": { + "application/json": { + "example": { + "detail": "Electricity mix zone 'XYZ' is not supported by EcoLogits" + } + } + }, + }, +} diff --git a/app/api/v1/responses/estimations.py b/app/api/v1/responses/estimations.py new file mode 100644 index 0000000..4393937 --- /dev/null +++ b/app/api/v1/responses/estimations.py @@ -0,0 +1,149 @@ +ESTIMATIONS_RESPONSES = { + 200: { + "description": "Environmental impact estimation with min/max intervals.", + "content": { + "application/json": { + "example": { + "impacts": { + "energy": { + "type": "energy", + "name": "Energy", + "value": { + "min": 0.00001740232832301384, + "max": 0.000021502108309942407, + }, + "unit": "kWh", + }, + "gwp": { + "type": "GWP", + "name": "Global Warming Potential", + "value": { + "min": 0.000008448613075332601, + "max": 0.000010387850006949683, + }, + "unit": "kgCO2eq", + }, + "adpe": { + "type": "ADPe", + "name": "Abiotic Depletion Potential (elements)", + "value": { + "min": 1.2955834115064265e-11, + "max": 1.3011140147087931e-11, + }, + "unit": "kgSbeq", + }, + "pe": { + "type": "PE", + "name": "Primary Energy", + "value": { + "min": 0.0000476420806326274, + "max": 0.0000582486214368103, + }, + "unit": "MJ", + }, + "wcf": { + "type": "WCF", + "name": "Water Consumption Footprint", + "value": { + "min": 0.00009151188371940057, + "max": 0.00011307098675866313, + }, + "unit": "L", + }, + "usage": { + "type": "usage", + "name": "Usage", + "energy": { + "type": "energy", + "name": "Energy", + "value": { + "min": 0.00001740232832301384, + "max": 0.000021502108309942407, + }, + "unit": "kWh", + }, + "gwp": { + "type": "GWP", + "name": "Global Warming Potential", + "value": { + "min": 0.000008231475320068776, + "max": 0.000010170712251685857, + }, + "unit": "kgCO2eq", + }, + "adpe": { + "type": "ADPe", + "name": "Abiotic Depletion Potential (elements)", + "value": { + "min": 2.3475740907745673e-13, + "max": 2.9006344110112307e-13, + }, + "unit": "kgSbeq", + }, + "pe": { + "type": "PE", + "name": "Primary Energy", + "value": { + "min": 0.000045021563604469106, + "max": 0.000055628104408652, + }, + "unit": "MJ", + }, + "wcf": { + "type": "WCF", + "name": "Water Consumption Footprint", + "value": { + "min": 0.00009151188371940057, + "max": 0.00011307098675866313, + }, + "unit": "L", + }, + }, + "embodied": { + "type": "embodied", + "name": "Embodied", + "gwp": { + "type": "GWP", + "name": "Global Warming Potential", + "value": { + "min": 2.1713775526382546e-7, + "max": 2.1713775526382546e-7, + }, + "unit": "kgCO2eq", + }, + "adpe": { + "type": "ADPe", + "name": "Abiotic Depletion Potential (elements)", + "value": { + "min": 1.2721076705986809e-11, + "max": 1.2721076705986809e-11, + }, + "unit": "kgSbeq", + }, + "pe": { + "type": "PE", + "name": "Primary Energy", + "value": { + "min": 0.0000026205170281582953, + "max": 0.0000026205170281582953, + }, + "unit": "MJ", + }, + }, + "warnings": [ + { + "code": "model-arch-not-released", + "message": "The model architecture has not been released, expect lower precision.", + }, + { + "code": "model-arch-multimodal", + "message": "The model architecture is multimodal, expect lower precision.", + }, + ], + "errors": None, + } + } + } + }, + }, +} diff --git a/app/api/v1/responses/models.py b/app/api/v1/responses/models.py new file mode 100644 index 0000000..2380f84 --- /dev/null +++ b/app/api/v1/responses/models.py @@ -0,0 +1,35 @@ +MODELS_RESPONSES = { + 200: { + "description": "List of models for the provider.", + "content": { + "application/json": { + "example": { + "models": [ + { + "provider": "openai", + "name": "gpt-4o-mini", + "architecture": { + "type": "moe", + "parameters": { + "total": 440, + "active": {"min": 44, "max": 132}, + }, + }, + "warnings": [ + { + "code": "model-arch-not-released", + "message": "The model architecture has not been released, expect lower precision.", + } + ], + "sources": [], + } + ] + } + } + }, + }, + 404: { + "description": "Provider not found.", + "content": {"application/json": {"example": {"detail": "Provider not found"}}}, + }, +} diff --git a/app/api/v1/responses/providers.py b/app/api/v1/responses/providers.py new file mode 100644 index 0000000..7ca24f5 --- /dev/null +++ b/app/api/v1/responses/providers.py @@ -0,0 +1,19 @@ +PROVIDERS_RESPONSES = { + 200: { + "description": "List of provider identifiers.", + "content": { + "application/json": { + "example": { + "providers": [ + "anthropic", + "mistralai", + "openai", + "huggingface_hub", + "cohere", + "google_genai", + ] + } + } + }, + }, +} diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 1a86f12..fe47604 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -1,25 +1,45 @@ -from fastapi import APIRouter, HTTPException, Body -from ecologits.model_repository import Providers, models from ecologits.electricity_mix_repository import electricity_mixes +from ecologits.model_repository import Providers, models from ecologits.tracers.utils import llm_impacts +from fastapi import APIRouter, Body, HTTPException + +from app.api.v1.responses import ( + ELECTRICITY_MIX_RESPONSES, + ESTIMATIONS_RESPONSES, + MODELS_RESPONSES, + PROVIDERS_RESPONSES, +) api_router = APIRouter() -@api_router.get("/providers", response_model=dict, summary="Get all providers") + +@api_router.get( + "/providers", + response_model=dict, + tags=["Catalog"], + summary="List all supported providers", + responses=PROVIDERS_RESPONSES, +) def get_providers(): try: providers_list = [provider.value for provider in Providers] return { "providers": providers_list, } - except Exception as e: + except Exception: raise HTTPException(status_code=500, detail="Failed to retrieve providers") + @api_router.get( "/models/{provider_name}", response_model=dict, - summary="Get all models", - description="

The returned models may include warning and error indicators. For detailed information about interpreting these warning and error values, please refer to the documentation:
https://ecologits.ai/latest/tutorial/warnings_and_errors/

" + tags=["Catalog"], + summary="List models for a provider", + description=( + "Models may include **warning** and **error** indicators " + "([details](https://ecologits.ai/latest/tutorial/warnings_and_errors/))." + ), + responses=MODELS_RESPONSES, ) def get_models(provider_name: str): try: @@ -35,37 +55,76 @@ def get_models(provider_name: str): return { "models": filter_model, } - except Exception as e: + except Exception: raise HTTPException(status_code=500, detail="Failed to retrieve models") + @api_router.get( - "/electricity-mix-zones/{zone}", - response_model=dict, - summary="Get electricity mix data for a zone", - description="

Retrieve the electricity mix data for a specified zone using ISO 3166-1 alpha-3 country codes or special regional codes.

Supported zone types:

Response: Returns the electricity mix composition data for the zone, or 404 if the zone is not supported by EcoLogits.

The electricity mix data includes the breakdown of energy sources used for electricity generation in the specified region, which is essential for accurate carbon impact calculations.

" + "/electricity-mix-zones/{zone}", + response_model=dict, + tags=["Electricity mix"], + summary="Get electricity mix for a zone", + description=( + "Use ISO 3166-1 alpha-3 codes (`USA`, `FRA`, `DEU`) " + "or regional codes (`EEE` for Europe, `WOR` for World average)." + ), + responses=ELECTRICITY_MIX_RESPONSES, ) def get_electricity_mix_zones(zone: str): try: electricity_mix = electricity_mixes.find_electricity_mix(zone) - except Exception as e: - raise HTTPException(status_code=500, detail="Failed to retrieve electricity mix zone") - - if electricity_mix == None: - raise HTTPException(status_code=404, detail=f"Electricity mix zone '{zone}' is not supported by EcoLogits") - + except Exception: + raise HTTPException( + status_code=500, detail="Failed to retrieve electricity mix zone" + ) + + if electricity_mix is None: + raise HTTPException( + status_code=404, + detail=f"Electricity mix zone '{zone}' is not supported by EcoLogits", + ) + return {"electricity_mix": electricity_mix} + @api_router.post( "/estimations", response_model=dict, - summary="Estimate the impacts of an LLM generation requests" + tags=["Estimations"], + summary="Estimate environmental impacts of an LLM request", + responses=ESTIMATIONS_RESPONSES, ) def post_estimations( - provider: str = Body(..., embed=True, examples=["openai"], description="Name of the provider."), - model_name: str = Body(..., embed=True, examples=["gpt-4o-mini"], description="Name of the LLM used."), - output_token_count: int = Body(..., embed=True, examples=[300], description="Number of generated tokens."), - request_latency: float = Body(..., embed=True, examples=[1.5], description="Measured request latency in seconds."), - electricity_mix_zone: str | None = Body(default=None, embed=True, examples=["WOR"], description="ISO 3166-1 alpha-3 code of the electricity mix zone (WOR by default).") + provider: str = Body( + ..., + embed=True, + examples=["openai"], + description="Provider identifier (use `GET /v1/providers` to list valid values).", + ), + model_name: str = Body( + ..., + embed=True, + examples=["gpt-4o-mini"], + description="Model identifier as registered in EcoLogits (use `GET /v1/models/{provider}` to list valid values).", + ), + output_token_count: int = Body( + ..., + embed=True, + examples=[300], + description="Number of tokens generated by the model.", + ), + request_latency: float = Body( + ..., + embed=True, + examples=[1.5], + description="Measured request latency in seconds.", + ), + electricity_mix_zone: str | None = Body( + default=None, + embed=True, + examples=["WOR"], + description="ISO 3166-1 alpha-3 zone code for the electricity mix. Defaults to `WOR` (world average). (use `GET /v1/electricity-mix-zones/{zone}` to check zone availability)", + ), ): try: impacts = llm_impacts( @@ -77,5 +136,5 @@ def post_estimations( ) return {"impacts": impacts} - except Exception as e: - raise HTTPException(status_code=500, detail="Failed to Estimate impacts") \ No newline at end of file + except Exception: + raise HTTPException(status_code=500, detail="Failed to Estimate impacts") diff --git a/app/core/config.py b/app/core/config.py index 6d5ce3f..53b23f2 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,21 +1,24 @@ +from pathlib import Path from typing import List from pydantic_settings import BaseSettings, SettingsConfigDict +_DESCRIPTION_FILE = Path(__file__).parent / "description.md" + class Settings(BaseSettings): - model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8') + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") # API Configuration app_name: str = "EcoLogits API" app_version: str = "0.0.1beta" - description: str = "API to use the EcoLogits solution for AI environmental impact tracking" - + description: str = _DESCRIPTION_FILE.read_text() + # CORS Configuration allowed_origins: List[str] = ["*"] allowed_methods: List[str] = ["*"] allowed_headers: List[str] = ["*"] - + # API Configuration api_v1_prefix: str = "/v1" docs_url: str = "/docs" diff --git a/app/core/description.md b/app/core/description.md new file mode 100644 index 0000000..92b60d4 --- /dev/null +++ b/app/core/description.md @@ -0,0 +1,27 @@ +**EcoLogits API** provides a language-agnostic HTTP interface to the +[EcoLogits](https://ecologits.ai) library, so any stack — not just Python — can +estimate the environmental footprint of generative-AI inference. + +## What is EcoLogits? + +EcoLogits is an open-source project (part of the CodeCarbon non-profit) that estimates +the environmental impacts of AI model usage at inference time. +It follows Life Cycle Assessment (LCA) principles defined by ISO 14044. + +## Environmental metrics + +| Metric | Unit | Description | +|---|---|---| +| **Energy** | kWh | Energy consumed by the request | +| **GWP** (Global Warming Potential) | kgCO₂eq | Greenhouse gas emissions | +| **ADPe** (Abiotic Depletion Potential) | kgSbeq | Mineral & metal resource depletion | +| **PE** (Primary Energy) | MJ | Total primary energy consumed | +| **WCF** (Water Consumption Footprint) | L | Fresh water consumed by data centers and power generation, not returned to its source | + +Results are returned as **approximation intervals** (min/max range), not single point estimates. + +## Useful links + +- [EcoLogits documentation](https://ecologits.ai/dev/) +- [Methodology](https://ecologits.ai/dev/methodology/) +- [GitHub repository](https://github.com/mlco2/ecologits) diff --git a/app/main.py b/app/main.py index 00ddee9..09bdb5d 100644 --- a/app/main.py +++ b/app/main.py @@ -1,15 +1,39 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware + from app.api.v1.router import api_router from app.core.config import settings +# OpenAPI tag definitions +tags_metadata = [ + { + "name": "Estimations", + "description": "Estimate the environmental impacts of an LLM inference request.", + }, + { + "name": "Catalog", + "description": "Browse the AI providers and models supported by EcoLogits. " + "Use these endpoints to discover valid values for the estimation request.", + }, + { + "name": "Electricity mix", + "description": "Retrieve the electricity mix composition for a given geographic zone. " + "This data is used to calculate carbon impacts.", + }, +] + # Create FastAPI instance with metadata app = FastAPI( title=settings.app_name, description=settings.description, version=settings.app_version, docs_url=settings.docs_url, - redoc_url=settings.redoc_url + redoc_url=settings.redoc_url, + openapi_tags=tags_metadata, + contact={ + "name": "EcoLogits", + "url": "https://ecologits.ai", + }, ) # Add CORS middleware From 7141dae39e74b4dc6337b07308c214caa8e29a95 Mon Sep 17 00:00:00 2001 From: Vincent Duarte Date: Thu, 19 Mar 2026 14:38:54 +0100 Subject: [PATCH 2/4] feat: add ruff and makefile --- Makefile | 20 ++++++++++++++++++++ pyproject.toml | 1 + uv.lock | 27 +++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f7ba856 --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +sync-dev: + uv sync --group dev + +start: + uv run fastapi dev app/main.py + +build-docker: + docker build -t ecologits-api + +run-docker: + docker run -p 8000:80 ecologits-api + +test: + uv run pytest + +format: + uv run ruff check --select I --fix && uv run ruff format + +check-fix: + uv run ruff check --fix && uv run ruff check --select I --fix \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index df74e53..386568e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ dependencies = [ "fastapi[standard]>=0.128.4,<1.0.0", "ecologits>=0.9.2,<1.0.0", "pydantic-settings>=2.12.0", + "ruff>=0.15.6", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 6f58f04..3e3a67b 100644 --- a/uv.lock +++ b/uv.lock @@ -96,6 +96,7 @@ dependencies = [ { name = "ecologits" }, { name = "fastapi", extra = ["standard"] }, { name = "pydantic-settings" }, + { name = "ruff" }, ] [package.dev-dependencies] @@ -109,6 +110,7 @@ requires-dist = [ { name = "ecologits", specifier = ">=0.9.2,<1.0.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.128.4,<1.0.0" }, { name = "pydantic-settings", specifier = ">=2.12.0" }, + { name = "ruff", specifier = ">=0.15.6" }, ] [package.metadata.requires-dev] @@ -752,6 +754,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/9c/3881ad34f01942af0cf713e25e476bf851e04e389cc3ff146c3b459ab861/rignore-0.6.4-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:7e6c425603db2c147eace4f752ca3cd4551e7568c9d332175d586c68bcbe3d8d", size = 1122433, upload-time = "2025-07-19T19:24:43.973Z" }, ] +[[package]] +name = "ruff" +version = "0.15.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" }, + { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" }, + { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" }, + { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" }, + { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" }, + { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" }, + { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" }, + { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, +] + [[package]] name = "sentry-sdk" version = "2.34.1" From 885371353ca8db4dd8724b5eabacee764508152d Mon Sep 17 00:00:00 2001 From: Vincent Duarte Date: Thu, 19 Mar 2026 16:16:53 +0100 Subject: [PATCH 3/4] test: run ruff --- tests/v1/test_router.py | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/tests/v1/test_router.py b/tests/v1/test_router.py index 13b1bea..983b24e 100644 --- a/tests/v1/test_router.py +++ b/tests/v1/test_router.py @@ -1,56 +1,66 @@ -from ecologits.model_repository import Providers, models +from ecologits.model_repository import Providers from ecologits.tracers.utils import llm_impacts from fastapi.testclient import TestClient + from app.main import app - + client = TestClient(app) + def test_get_providers(): response = client.get("/v1/providers") assert response.status_code == 200 assert "providers" in response.json() assert response.json() == {"providers": [provider.value for provider in Providers]} + def test_get_models_valid_provider(): response = client.get("/v1/models/openai") assert response.status_code == 200 assert "models" in response.json() assert isinstance(response.json()["models"], list) + def test_get_models_invalid_provider(): response = client.get("/v1/models/invalid_provider") assert response.status_code == 404 assert response.json() == {"detail": "Provider not found"} + def test_find_electricity_mix_zones_valid(): """Test the GET /electricity-mix-zones/{zone} endpoint with a valid zone""" response = client.get("/v1/electricity-mix-zones/WOR") assert response.status_code == 200 - + response_data = response.json() assert "electricity_mix" in response_data assert response_data["electricity_mix"] is not None assert "zone" in response_data["electricity_mix"] assert response_data["electricity_mix"]["zone"] == "WOR" + def test_find_electricity_mix_zones_invalid(): """Test the GET /electricity-mix-zones/{zone} endpoint with an invalid zone""" response = client.get("/v1/electricity-mix-zones/INVALID") assert response.status_code == 404 - assert response.json() == {"detail": "Electricity mix zone 'INVALID' is not supported by EcoLogits"} + assert response.json() == { + "detail": "Electricity mix zone 'INVALID' is not supported by EcoLogits" + } + def test_find_electricity_mix_zones_other_valid_zones(): """Test the GET /electricity-mix-zones/{zone} endpoint with other valid zones""" valid_zones = ["WOR", "USA", "FRA"] - + for zone in valid_zones: response = client.get(f"/v1/electricity-mix-zones/{zone}") assert response.status_code == 200 - + response_data = response.json() assert "electricity_mix" in response_data assert response_data["electricity_mix"]["zone"] == zone + def test_post_estimations(): """Test the POST /estimations endpoint""" payload = { @@ -58,7 +68,7 @@ def test_post_estimations(): "model_name": "gpt-4o-mini", "output_token_count": 300, "request_latency": 1.5, - "electricity_mix_zone": "WOR" + "electricity_mix_zone": "WOR", } # Get the expected impacts directly from llm_impacts @@ -82,13 +92,14 @@ def test_post_estimations(): expected_impacts_dict = expected_impacts.model_dump() assert response_data["impacts"] == expected_impacts_dict + def test_post_estimations_default_electricity_mix(): """Test the POST /estimations endpoint without electricity_mix_zone (should use default)""" payload = { - "provider": "openai", + "provider": "openai", "model_name": "gpt-4o-mini", "output_token_count": 150, - "request_latency": 0.8 + "request_latency": 0.8, # electricity_mix_zone not provided, should default to "WOR" } @@ -99,10 +110,10 @@ def test_post_estimations_default_electricity_mix(): output_token_count=payload["output_token_count"], request_latency=payload["request_latency"], ) - + response = client.post("/v1/estimations", json=payload) assert response.status_code == 200 - + response_data = response.json() assert "impacts" in response_data assert response_data["impacts"] is not None @@ -111,13 +122,14 @@ def test_post_estimations_default_electricity_mix(): expected_impacts_dict = expected_impacts.model_dump() assert response_data["impacts"] == expected_impacts_dict + def test_post_estimations_missing_required_fields(): """Test the POST /estimations endpoint with missing required fields""" payload = { "provider": "openai", - "model_name": "gpt-4o-mini" + "model_name": "gpt-4o-mini", # Missing output_token_count and request_latency } - + response = client.post("/v1/estimations", json=payload) - assert response.status_code == 422 # Unprocessable Entity for validation errors \ No newline at end of file + assert response.status_code == 422 # Unprocessable Entity for validation errors From 898d3901d16155aa242a23baa23d397ae625c778 Mon Sep 17 00:00:00 2001 From: Vincent Duarte Date: Thu, 19 Mar 2026 16:26:25 +0100 Subject: [PATCH 4/4] refactor: rename v1 route into v1beta --- app/api/v1/responses/__init__.py | 11 ----------- app/api/{v1 => v1beta}/__init__.py | 0 app/api/v1beta/responses/__init__.py | 11 +++++++++++ .../responses/electricity_mix.py | 0 .../{v1 => v1beta}/responses/estimations.py | 0 app/api/{v1 => v1beta}/responses/models.py | 0 app/api/{v1 => v1beta}/responses/providers.py | 0 app/api/{v1 => v1beta}/router.py | 18 +++++++++--------- app/core/config.py | 1 - app/main.py | 4 ++-- tests/{v1 => v1beta}/__init__.py | 0 tests/{v1 => v1beta}/test_router.py | 18 +++++++++--------- 12 files changed, 31 insertions(+), 32 deletions(-) delete mode 100644 app/api/v1/responses/__init__.py rename app/api/{v1 => v1beta}/__init__.py (100%) create mode 100644 app/api/v1beta/responses/__init__.py rename app/api/{v1 => v1beta}/responses/electricity_mix.py (100%) rename app/api/{v1 => v1beta}/responses/estimations.py (100%) rename app/api/{v1 => v1beta}/responses/models.py (100%) rename app/api/{v1 => v1beta}/responses/providers.py (100%) rename app/api/{v1 => v1beta}/router.py (89%) rename tests/{v1 => v1beta}/__init__.py (100%) rename tests/{v1 => v1beta}/test_router.py (88%) diff --git a/app/api/v1/responses/__init__.py b/app/api/v1/responses/__init__.py deleted file mode 100644 index e027f34..0000000 --- a/app/api/v1/responses/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from app.api.v1.responses.electricity_mix import ELECTRICITY_MIX_RESPONSES -from app.api.v1.responses.estimations import ESTIMATIONS_RESPONSES -from app.api.v1.responses.models import MODELS_RESPONSES -from app.api.v1.responses.providers import PROVIDERS_RESPONSES - -__all__ = [ - "PROVIDERS_RESPONSES", - "MODELS_RESPONSES", - "ELECTRICITY_MIX_RESPONSES", - "ESTIMATIONS_RESPONSES", -] diff --git a/app/api/v1/__init__.py b/app/api/v1beta/__init__.py similarity index 100% rename from app/api/v1/__init__.py rename to app/api/v1beta/__init__.py diff --git a/app/api/v1beta/responses/__init__.py b/app/api/v1beta/responses/__init__.py new file mode 100644 index 0000000..471102f --- /dev/null +++ b/app/api/v1beta/responses/__init__.py @@ -0,0 +1,11 @@ +from app.api.v1beta.responses.electricity_mix import ELECTRICITY_MIX_RESPONSES +from app.api.v1beta.responses.estimations import ESTIMATIONS_RESPONSES +from app.api.v1beta.responses.models import MODELS_RESPONSES +from app.api.v1beta.responses.providers import PROVIDERS_RESPONSES + +__all__ = [ + "PROVIDERS_RESPONSES", + "MODELS_RESPONSES", + "ELECTRICITY_MIX_RESPONSES", + "ESTIMATIONS_RESPONSES", +] diff --git a/app/api/v1/responses/electricity_mix.py b/app/api/v1beta/responses/electricity_mix.py similarity index 100% rename from app/api/v1/responses/electricity_mix.py rename to app/api/v1beta/responses/electricity_mix.py diff --git a/app/api/v1/responses/estimations.py b/app/api/v1beta/responses/estimations.py similarity index 100% rename from app/api/v1/responses/estimations.py rename to app/api/v1beta/responses/estimations.py diff --git a/app/api/v1/responses/models.py b/app/api/v1beta/responses/models.py similarity index 100% rename from app/api/v1/responses/models.py rename to app/api/v1beta/responses/models.py diff --git a/app/api/v1/responses/providers.py b/app/api/v1beta/responses/providers.py similarity index 100% rename from app/api/v1/responses/providers.py rename to app/api/v1beta/responses/providers.py diff --git a/app/api/v1/router.py b/app/api/v1beta/router.py similarity index 89% rename from app/api/v1/router.py rename to app/api/v1beta/router.py index fe47604..8bdc14d 100644 --- a/app/api/v1/router.py +++ b/app/api/v1beta/router.py @@ -3,17 +3,17 @@ from ecologits.tracers.utils import llm_impacts from fastapi import APIRouter, Body, HTTPException -from app.api.v1.responses import ( +from app.api.v1beta.responses import ( ELECTRICITY_MIX_RESPONSES, ESTIMATIONS_RESPONSES, MODELS_RESPONSES, PROVIDERS_RESPONSES, ) -api_router = APIRouter() +api_router_v1beta = APIRouter(prefix="/v1beta") -@api_router.get( +@api_router_v1beta.get( "/providers", response_model=dict, tags=["Catalog"], @@ -30,7 +30,7 @@ def get_providers(): raise HTTPException(status_code=500, detail="Failed to retrieve providers") -@api_router.get( +@api_router_v1beta.get( "/models/{provider_name}", response_model=dict, tags=["Catalog"], @@ -59,7 +59,7 @@ def get_models(provider_name: str): raise HTTPException(status_code=500, detail="Failed to retrieve models") -@api_router.get( +@api_router_v1beta.get( "/electricity-mix-zones/{zone}", response_model=dict, tags=["Electricity mix"], @@ -87,7 +87,7 @@ def get_electricity_mix_zones(zone: str): return {"electricity_mix": electricity_mix} -@api_router.post( +@api_router_v1beta.post( "/estimations", response_model=dict, tags=["Estimations"], @@ -99,13 +99,13 @@ def post_estimations( ..., embed=True, examples=["openai"], - description="Provider identifier (use `GET /v1/providers` to list valid values).", + description="Provider identifier (use `GET /v1beta/providers` to list valid values).", ), model_name: str = Body( ..., embed=True, examples=["gpt-4o-mini"], - description="Model identifier as registered in EcoLogits (use `GET /v1/models/{provider}` to list valid values).", + description="Model identifier as registered in EcoLogits (use `GET /v1beta/models/{provider}` to list valid values).", ), output_token_count: int = Body( ..., @@ -123,7 +123,7 @@ def post_estimations( default=None, embed=True, examples=["WOR"], - description="ISO 3166-1 alpha-3 zone code for the electricity mix. Defaults to `WOR` (world average). (use `GET /v1/electricity-mix-zones/{zone}` to check zone availability)", + description="ISO 3166-1 alpha-3 zone code for the electricity mix. Defaults to `WOR` (world average). (use `GET /v1beta/electricity-mix-zones/{zone}` to check zone availability)", ), ): try: diff --git a/app/core/config.py b/app/core/config.py index 53b23f2..80ee510 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -20,7 +20,6 @@ class Settings(BaseSettings): allowed_headers: List[str] = ["*"] # API Configuration - api_v1_prefix: str = "/v1" docs_url: str = "/docs" redoc_url: str = "/redoc" diff --git a/app/main.py b/app/main.py index 09bdb5d..89c5711 100644 --- a/app/main.py +++ b/app/main.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.api.v1.router import api_router +from app.api.v1beta.router import api_router_v1beta from app.core.config import settings # OpenAPI tag definitions @@ -46,4 +46,4 @@ ) # Include API router with version prefix -app.include_router(api_router, prefix=settings.api_v1_prefix) +app.include_router(api_router_v1beta) diff --git a/tests/v1/__init__.py b/tests/v1beta/__init__.py similarity index 100% rename from tests/v1/__init__.py rename to tests/v1beta/__init__.py diff --git a/tests/v1/test_router.py b/tests/v1beta/test_router.py similarity index 88% rename from tests/v1/test_router.py rename to tests/v1beta/test_router.py index 983b24e..a588ea4 100644 --- a/tests/v1/test_router.py +++ b/tests/v1beta/test_router.py @@ -8,28 +8,28 @@ def test_get_providers(): - response = client.get("/v1/providers") + response = client.get("/v1beta/providers") assert response.status_code == 200 assert "providers" in response.json() assert response.json() == {"providers": [provider.value for provider in Providers]} def test_get_models_valid_provider(): - response = client.get("/v1/models/openai") + response = client.get("/v1beta/models/openai") assert response.status_code == 200 assert "models" in response.json() assert isinstance(response.json()["models"], list) def test_get_models_invalid_provider(): - response = client.get("/v1/models/invalid_provider") + response = client.get("/v1beta/models/invalid_provider") assert response.status_code == 404 assert response.json() == {"detail": "Provider not found"} def test_find_electricity_mix_zones_valid(): """Test the GET /electricity-mix-zones/{zone} endpoint with a valid zone""" - response = client.get("/v1/electricity-mix-zones/WOR") + response = client.get("/v1beta/electricity-mix-zones/WOR") assert response.status_code == 200 response_data = response.json() @@ -41,7 +41,7 @@ def test_find_electricity_mix_zones_valid(): def test_find_electricity_mix_zones_invalid(): """Test the GET /electricity-mix-zones/{zone} endpoint with an invalid zone""" - response = client.get("/v1/electricity-mix-zones/INVALID") + response = client.get("/v1beta/electricity-mix-zones/INVALID") assert response.status_code == 404 assert response.json() == { "detail": "Electricity mix zone 'INVALID' is not supported by EcoLogits" @@ -53,7 +53,7 @@ def test_find_electricity_mix_zones_other_valid_zones(): valid_zones = ["WOR", "USA", "FRA"] for zone in valid_zones: - response = client.get(f"/v1/electricity-mix-zones/{zone}") + response = client.get(f"/v1beta/electricity-mix-zones/{zone}") assert response.status_code == 200 response_data = response.json() @@ -81,7 +81,7 @@ def test_post_estimations(): ) # Call the API endpoint - response = client.post("/v1/estimations", json=payload) + response = client.post("/v1beta/estimations", json=payload) assert response.status_code == 200 response_data = response.json() @@ -111,7 +111,7 @@ def test_post_estimations_default_electricity_mix(): request_latency=payload["request_latency"], ) - response = client.post("/v1/estimations", json=payload) + response = client.post("/v1beta/estimations", json=payload) assert response.status_code == 200 response_data = response.json() @@ -131,5 +131,5 @@ def test_post_estimations_missing_required_fields(): # Missing output_token_count and request_latency } - response = client.post("/v1/estimations", json=payload) + response = client.post("/v1beta/estimations", json=payload) assert response.status_code == 422 # Unprocessable Entity for validation errors