org-cost-mcp 0.0.12__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- org_cost_mcp-0.0.12/.gitignore +5 -0
- org_cost_mcp-0.0.12/Dockerfile +16 -0
- org_cost_mcp-0.0.12/Dockerfile.mcp +15 -0
- org_cost_mcp-0.0.12/PKG-INFO +152 -0
- org_cost_mcp-0.0.12/README.md +121 -0
- org_cost_mcp-0.0.12/org_cost_mcp/__init__.py +1 -0
- org_cost_mcp-0.0.12/org_cost_mcp/account_costs.py +28 -0
- org_cost_mcp-0.0.12/org_cost_mcp/agent.py +169 -0
- org_cost_mcp-0.0.12/org_cost_mcp/chat_app.py +262 -0
- org_cost_mcp-0.0.12/org_cost_mcp/client.py +281 -0
- org_cost_mcp-0.0.12/org_cost_mcp/config_discovery.py +135 -0
- org_cost_mcp-0.0.12/org_cost_mcp/llm_config.py +107 -0
- org_cost_mcp-0.0.12/org_cost_mcp/server.py +759 -0
- org_cost_mcp-0.0.12/org_cost_mcp/sessions.py +64 -0
- org_cost_mcp-0.0.12/org_cost_mcp/suggestions_enrich.py +209 -0
- org_cost_mcp-0.0.12/org_cost_mcp/tools.py +212 -0
- org_cost_mcp-0.0.12/org_cost_mcp/visualize.py +408 -0
- org_cost_mcp-0.0.12/pyproject.toml +47 -0
- org_cost_mcp-0.0.12/tests/__init__.py +0 -0
- org_cost_mcp-0.0.12/tests/test_account_costs.py +46 -0
- org_cost_mcp-0.0.12/tests/test_account_resolution.py +116 -0
- org_cost_mcp-0.0.12/tests/test_agent.py +83 -0
- org_cost_mcp-0.0.12/tests/test_chat_app.py +111 -0
- org_cost_mcp-0.0.12/tests/test_client.py +66 -0
- org_cost_mcp-0.0.12/tests/test_config_discovery.py +59 -0
- org_cost_mcp-0.0.12/tests/test_llm_config.py +115 -0
- org_cost_mcp-0.0.12/tests/test_smoke.py +59 -0
- org_cost_mcp-0.0.12/tests/test_suggestions_enrich.py +130 -0
- org_cost_mcp-0.0.12/tests/test_tools.py +310 -0
- org_cost_mcp-0.0.12/tests/test_transport.py +53 -0
- org_cost_mcp-0.0.12/uv.lock +1016 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1
|
|
2
|
+
|
|
3
|
+
FROM python:3.12-slim
|
|
4
|
+
WORKDIR /app
|
|
5
|
+
RUN adduser --disabled-password --gecos "" app
|
|
6
|
+
COPY pyproject.toml README.md ./
|
|
7
|
+
COPY org_cost_mcp ./org_cost_mcp
|
|
8
|
+
RUN pip install --no-cache-dir . \
|
|
9
|
+
&& chown -R app:app /app
|
|
10
|
+
USER app
|
|
11
|
+
EXPOSE 8090
|
|
12
|
+
ENV CHAT_HOST=0.0.0.0
|
|
13
|
+
ENV CHAT_PORT=8090
|
|
14
|
+
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
|
15
|
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8090/v1/chat/health')" || exit 1
|
|
16
|
+
CMD ["org-cost-chat"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1
|
|
2
|
+
|
|
3
|
+
FROM python:3.12-slim
|
|
4
|
+
WORKDIR /app
|
|
5
|
+
RUN adduser --disabled-password --gecos "" app
|
|
6
|
+
COPY pyproject.toml README.md ./
|
|
7
|
+
COPY org_cost_mcp ./org_cost_mcp
|
|
8
|
+
RUN pip install --no-cache-dir . \
|
|
9
|
+
&& chown -R app:app /app
|
|
10
|
+
USER app
|
|
11
|
+
EXPOSE 8000
|
|
12
|
+
ENV MCP_TRANSPORT=streamable-http
|
|
13
|
+
ENV MCP_HOST=0.0.0.0
|
|
14
|
+
ENV MCP_PORT=8000
|
|
15
|
+
CMD ["org-cost-mcp"]
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: org-cost-mcp
|
|
3
|
+
Version: 0.0.12
|
|
4
|
+
Summary: MCP server for AWS Org Cost Explorer (read-only dashboard API tools)
|
|
5
|
+
Project-URL: Homepage, https://github.com/kaskol10/org-cost-api
|
|
6
|
+
Project-URL: Documentation, https://github.com/kaskol10/org-cost-api#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/kaskol10/org-cost-api
|
|
8
|
+
Project-URL: Issues, https://github.com/kaskol10/org-cost-api/issues
|
|
9
|
+
Author: org-cost-api contributors
|
|
10
|
+
License: MIT
|
|
11
|
+
Keywords: aws,cost-explorer,finops,hermes,mcp
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: fastapi>=0.115.0
|
|
22
|
+
Requires-Dist: httpx>=0.27.0
|
|
23
|
+
Requires-Dist: litellm>=1.55.0
|
|
24
|
+
Requires-Dist: mcp<2,>=1.2.0
|
|
25
|
+
Requires-Dist: pyyaml>=6.0
|
|
26
|
+
Requires-Dist: uvicorn[standard]>=0.32.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: httpx; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# org-cost-api MCP server
|
|
33
|
+
|
|
34
|
+
Read-only [Model Context Protocol](https://modelcontextprotocol.io) server for [Hermes Agent](https://github.com/nousresearch/hermes-agent), Cursor, and other MCP hosts. Wraps the Go dashboard API so agents can explain AWS org costs **without AWS credentials in the LLM process**.
|
|
35
|
+
|
|
36
|
+
## Install (PyPI)
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install org-cost-mcp
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# Point at a running API (demo or production)
|
|
46
|
+
export ORG_COST_API_URL=http://localhost:8080
|
|
47
|
+
# When API auth is enabled:
|
|
48
|
+
# export ORG_COST_API_TOKEN=your-token
|
|
49
|
+
|
|
50
|
+
org-cost-mcp
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Try without AWS:** run the demo API first:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
docker compose -f docker-compose.demo.yml up --build
|
|
57
|
+
export ORG_COST_API_URL=http://localhost:8080
|
|
58
|
+
org-cost-mcp
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Development install
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
cd mcp-server
|
|
65
|
+
python3 -m venv .venv
|
|
66
|
+
source .venv/bin/activate
|
|
67
|
+
pip install -e ".[dev]"
|
|
68
|
+
pytest
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
See **[docs/integrate-quickstart.md](../docs/integrate-quickstart.md)** (15 min) · details: **[docs/mcp-setup.md](../docs/mcp-setup.md)**
|
|
72
|
+
|
|
73
|
+
Public demo API: `https://org-cost-api-demo.fly.dev` (no AWS, no token).
|
|
74
|
+
|
|
75
|
+
## Transport
|
|
76
|
+
|
|
77
|
+
| Mode | Command / endpoint |
|
|
78
|
+
|------|---------------------|
|
|
79
|
+
| **stdio** (default) | `org-cost-mcp` — Cursor, Hermes, Claude Desktop |
|
|
80
|
+
| **SSE** | `MCP_TRANSPORT=sse org-cost-mcp` → `http://host:8000/sse` |
|
|
81
|
+
| **Streamable HTTP** | `MCP_TRANSPORT=streamable-http org-cost-mcp` → `http://host:8000/mcp` |
|
|
82
|
+
|
|
83
|
+
Remote HTTP: `docker compose -f docker-compose.mcp-http.yml up` — see [mcp-setup.md § Remote MCP](../docs/mcp-setup.md#9-remote-mcp-httpsse).
|
|
84
|
+
|
|
85
|
+
## Tools
|
|
86
|
+
|
|
87
|
+
| Tool | Description |
|
|
88
|
+
|------|-------------|
|
|
89
|
+
| `check_mcp_alive` | Ping MCP process only (no API call) |
|
|
90
|
+
| `check_api_health` | API health, readiness, demo flag, and auth (when token set) |
|
|
91
|
+
| `check_chat_health` | Ask / LLM config status (no LLM call) |
|
|
92
|
+
| `list_accounts` | Configured account names and IDs |
|
|
93
|
+
| `get_org_summary` | Org totals + top services |
|
|
94
|
+
| `get_cost_trends` | Current vs prior period (history-first) |
|
|
95
|
+
| `get_cost_anomalies` | Daily spikes/drops vs 14-day baseline, account attribution |
|
|
96
|
+
| `get_forecast` | Projected end-of-month spend vs budgets |
|
|
97
|
+
| `get_cost_suggestions` | Ranked cost optimization suggestions |
|
|
98
|
+
| `get_cost_report` | Markdown + Mermaid; writes `~/.org-cost/reports/latest.html` |
|
|
99
|
+
| `get_account_costs` | One account's spend and top services |
|
|
100
|
+
| `get_waste_signals` | Unattached EBS, snapshots by account |
|
|
101
|
+
| `get_service_detail` | Region / usage type / tag Name breakdown |
|
|
102
|
+
| `get_service_tag_delta` | Tag-bucket spend changes vs prior period |
|
|
103
|
+
| `get_service_tag_totals` | Top tag buckets for a service |
|
|
104
|
+
| `ask_cost_question` | Natural-language Q&A (same API as browser Ask tab) |
|
|
105
|
+
|
|
106
|
+
**CE discipline:** default `refresh=false`. Use refresh sparingly — each refresh can trigger live AWS API calls in production mode.
|
|
107
|
+
|
|
108
|
+
## Conversational Ask (`org-cost-chat`)
|
|
109
|
+
|
|
110
|
+
HTTP chat agent for multi-turn browser Ask. Supports **org LiteLLM proxy**, **direct vLLM**, or any OpenAI-compatible endpoint:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
# LiteLLM org proxy (default)
|
|
114
|
+
export ORG_COST_API_URL=http://localhost:8080
|
|
115
|
+
export LLM_BASE_URL=https://litellm.internal/v1
|
|
116
|
+
export LLM_API_KEY=your-key
|
|
117
|
+
export LLM_MODEL=claude-sonnet-4-20250514
|
|
118
|
+
|
|
119
|
+
# Or local vLLM
|
|
120
|
+
export LLM_PROVIDER=vllm
|
|
121
|
+
export LLM_MODEL=meta-llama/Meta-Llama-3-8B-Instruct
|
|
122
|
+
export VLLM_BASE_URL=http://localhost:8000/v1
|
|
123
|
+
|
|
124
|
+
org-cost-chat
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Frontend: `VITE_CHAT_URL=http://localhost:8090`. Full guide: [docs/chat-setup.md](../docs/chat-setup.md).
|
|
128
|
+
|
|
129
|
+
Docker: `docker compose -f docker-compose.chat.yml up --build`
|
|
130
|
+
|
|
131
|
+
## Environment
|
|
132
|
+
|
|
133
|
+
| Variable | Default | Description |
|
|
134
|
+
|----------|---------|-------------|
|
|
135
|
+
| `ORG_COST_API_URL` | `http://localhost:8080` | Go API base URL |
|
|
136
|
+
| `ORG_COST_API_TOKEN` | (empty) | Bearer token when API auth is on |
|
|
137
|
+
| `ORG_COST_API_TIMEOUT_SECONDS` | `120` | Read timeout for slow dashboard builds |
|
|
138
|
+
| `MCP_TRANSPORT` | `stdio` | `stdio`, `sse`, or `streamable-http` |
|
|
139
|
+
| `MCP_HOST` | `127.0.0.1` | Bind address (`0.0.0.0` for HTTP transports) |
|
|
140
|
+
| `MCP_PORT` | `8000` | Port for HTTP transports |
|
|
141
|
+
| `MCP_ALLOWED_HOSTS` | (empty) | Comma-separated public Host headers for Streamable HTTP behind a proxy |
|
|
142
|
+
|
|
143
|
+
## Agent setup (Hermes / Cursor)
|
|
144
|
+
|
|
145
|
+
See **[docs/integrate-quickstart.md](../docs/integrate-quickstart.md)** — 15-minute MCP wiring for Hermes and Cursor.
|
|
146
|
+
Details: **[docs/mcp-setup.md](../docs/mcp-setup.md)** ([Hermes](../docs/mcp-setup.md#3-hermes-setup), [Cursor](../docs/mcp-setup.md#4-cursor-setup), [Remote MCP](../docs/mcp-setup.md#9-remote-mcp-httpsse)).
|
|
147
|
+
|
|
148
|
+
Org-hosted Hermes: [docs/hermes-nous-deployment.md](../docs/hermes-nous-deployment.md).
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT — see [LICENSE](../LICENSE).
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# org-cost-api MCP server
|
|
2
|
+
|
|
3
|
+
Read-only [Model Context Protocol](https://modelcontextprotocol.io) server for [Hermes Agent](https://github.com/nousresearch/hermes-agent), Cursor, and other MCP hosts. Wraps the Go dashboard API so agents can explain AWS org costs **without AWS credentials in the LLM process**.
|
|
4
|
+
|
|
5
|
+
## Install (PyPI)
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install org-cost-mcp
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Point at a running API (demo or production)
|
|
15
|
+
export ORG_COST_API_URL=http://localhost:8080
|
|
16
|
+
# When API auth is enabled:
|
|
17
|
+
# export ORG_COST_API_TOKEN=your-token
|
|
18
|
+
|
|
19
|
+
org-cost-mcp
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**Try without AWS:** run the demo API first:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
docker compose -f docker-compose.demo.yml up --build
|
|
26
|
+
export ORG_COST_API_URL=http://localhost:8080
|
|
27
|
+
org-cost-mcp
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Development install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
cd mcp-server
|
|
34
|
+
python3 -m venv .venv
|
|
35
|
+
source .venv/bin/activate
|
|
36
|
+
pip install -e ".[dev]"
|
|
37
|
+
pytest
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
See **[docs/integrate-quickstart.md](../docs/integrate-quickstart.md)** (15 min) · details: **[docs/mcp-setup.md](../docs/mcp-setup.md)**
|
|
41
|
+
|
|
42
|
+
Public demo API: `https://org-cost-api-demo.fly.dev` (no AWS, no token).
|
|
43
|
+
|
|
44
|
+
## Transport
|
|
45
|
+
|
|
46
|
+
| Mode | Command / endpoint |
|
|
47
|
+
|------|---------------------|
|
|
48
|
+
| **stdio** (default) | `org-cost-mcp` — Cursor, Hermes, Claude Desktop |
|
|
49
|
+
| **SSE** | `MCP_TRANSPORT=sse org-cost-mcp` → `http://host:8000/sse` |
|
|
50
|
+
| **Streamable HTTP** | `MCP_TRANSPORT=streamable-http org-cost-mcp` → `http://host:8000/mcp` |
|
|
51
|
+
|
|
52
|
+
Remote HTTP: `docker compose -f docker-compose.mcp-http.yml up` — see [mcp-setup.md § Remote MCP](../docs/mcp-setup.md#9-remote-mcp-httpsse).
|
|
53
|
+
|
|
54
|
+
## Tools
|
|
55
|
+
|
|
56
|
+
| Tool | Description |
|
|
57
|
+
|------|-------------|
|
|
58
|
+
| `check_mcp_alive` | Ping MCP process only (no API call) |
|
|
59
|
+
| `check_api_health` | API health, readiness, demo flag, and auth (when token set) |
|
|
60
|
+
| `check_chat_health` | Ask / LLM config status (no LLM call) |
|
|
61
|
+
| `list_accounts` | Configured account names and IDs |
|
|
62
|
+
| `get_org_summary` | Org totals + top services |
|
|
63
|
+
| `get_cost_trends` | Current vs prior period (history-first) |
|
|
64
|
+
| `get_cost_anomalies` | Daily spikes/drops vs 14-day baseline, account attribution |
|
|
65
|
+
| `get_forecast` | Projected end-of-month spend vs budgets |
|
|
66
|
+
| `get_cost_suggestions` | Ranked cost optimization suggestions |
|
|
67
|
+
| `get_cost_report` | Markdown + Mermaid; writes `~/.org-cost/reports/latest.html` |
|
|
68
|
+
| `get_account_costs` | One account's spend and top services |
|
|
69
|
+
| `get_waste_signals` | Unattached EBS, snapshots by account |
|
|
70
|
+
| `get_service_detail` | Region / usage type / tag Name breakdown |
|
|
71
|
+
| `get_service_tag_delta` | Tag-bucket spend changes vs prior period |
|
|
72
|
+
| `get_service_tag_totals` | Top tag buckets for a service |
|
|
73
|
+
| `ask_cost_question` | Natural-language Q&A (same API as browser Ask tab) |
|
|
74
|
+
|
|
75
|
+
**CE discipline:** default `refresh=false`. Use refresh sparingly — each refresh can trigger live AWS API calls in production mode.
|
|
76
|
+
|
|
77
|
+
## Conversational Ask (`org-cost-chat`)
|
|
78
|
+
|
|
79
|
+
HTTP chat agent for multi-turn browser Ask. Supports **org LiteLLM proxy**, **direct vLLM**, or any OpenAI-compatible endpoint:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# LiteLLM org proxy (default)
|
|
83
|
+
export ORG_COST_API_URL=http://localhost:8080
|
|
84
|
+
export LLM_BASE_URL=https://litellm.internal/v1
|
|
85
|
+
export LLM_API_KEY=your-key
|
|
86
|
+
export LLM_MODEL=claude-sonnet-4-20250514
|
|
87
|
+
|
|
88
|
+
# Or local vLLM
|
|
89
|
+
export LLM_PROVIDER=vllm
|
|
90
|
+
export LLM_MODEL=meta-llama/Meta-Llama-3-8B-Instruct
|
|
91
|
+
export VLLM_BASE_URL=http://localhost:8000/v1
|
|
92
|
+
|
|
93
|
+
org-cost-chat
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Frontend: `VITE_CHAT_URL=http://localhost:8090`. Full guide: [docs/chat-setup.md](../docs/chat-setup.md).
|
|
97
|
+
|
|
98
|
+
Docker: `docker compose -f docker-compose.chat.yml up --build`
|
|
99
|
+
|
|
100
|
+
## Environment
|
|
101
|
+
|
|
102
|
+
| Variable | Default | Description |
|
|
103
|
+
|----------|---------|-------------|
|
|
104
|
+
| `ORG_COST_API_URL` | `http://localhost:8080` | Go API base URL |
|
|
105
|
+
| `ORG_COST_API_TOKEN` | (empty) | Bearer token when API auth is on |
|
|
106
|
+
| `ORG_COST_API_TIMEOUT_SECONDS` | `120` | Read timeout for slow dashboard builds |
|
|
107
|
+
| `MCP_TRANSPORT` | `stdio` | `stdio`, `sse`, or `streamable-http` |
|
|
108
|
+
| `MCP_HOST` | `127.0.0.1` | Bind address (`0.0.0.0` for HTTP transports) |
|
|
109
|
+
| `MCP_PORT` | `8000` | Port for HTTP transports |
|
|
110
|
+
| `MCP_ALLOWED_HOSTS` | (empty) | Comma-separated public Host headers for Streamable HTTP behind a proxy |
|
|
111
|
+
|
|
112
|
+
## Agent setup (Hermes / Cursor)
|
|
113
|
+
|
|
114
|
+
See **[docs/integrate-quickstart.md](../docs/integrate-quickstart.md)** — 15-minute MCP wiring for Hermes and Cursor.
|
|
115
|
+
Details: **[docs/mcp-setup.md](../docs/mcp-setup.md)** ([Hermes](../docs/mcp-setup.md#3-hermes-setup), [Cursor](../docs/mcp-setup.md#4-cursor-setup), [Remote MCP](../docs/mcp-setup.md#9-remote-mcp-httpsse)).
|
|
116
|
+
|
|
117
|
+
Org-hosted Hermes: [docs/hermes-nous-deployment.md](../docs/hermes-nous-deployment.md).
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT — see [LICENSE](../LICENSE).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Read-only MCP tools for the AWS Org Cost Explorer Go API."""
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Account cost JSON shaping for MCP tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def shape_account_costs(acct: dict[str, Any]) -> dict[str, Any]:
|
|
9
|
+
"""Map /api/account-costs (or dashboard account) JSON to MCP output."""
|
|
10
|
+
costs = acct.get("costs") or {}
|
|
11
|
+
period = acct.get("period") or {"start": costs.get("start"), "end": costs.get("end")}
|
|
12
|
+
return {
|
|
13
|
+
"account_id": acct.get("account_id"),
|
|
14
|
+
"account_name": acct.get("account_name"),
|
|
15
|
+
"period": period,
|
|
16
|
+
"all_services_total_usd": costs.get("all_total"),
|
|
17
|
+
"ec2_other_usd": costs.get("total"),
|
|
18
|
+
"other_services_usd": costs.get("other_services_total"),
|
|
19
|
+
"top_services": costs.get("by_service") or [],
|
|
20
|
+
"usage_categories": [
|
|
21
|
+
{
|
|
22
|
+
"id": c.get("id"),
|
|
23
|
+
"label": c.get("label"),
|
|
24
|
+
"amount": c.get("amount"),
|
|
25
|
+
}
|
|
26
|
+
for c in (costs.get("usage_categories") or [])[:8]
|
|
27
|
+
],
|
|
28
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""LiteLLM tool-calling agent loop for conversational Ask."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import litellm
|
|
12
|
+
|
|
13
|
+
from org_cost_mcp.llm_config import llm_completion_kwargs
|
|
14
|
+
from org_cost_mcp.tools import TOOL_SCHEMAS, ce_calls_in_result, execute_tool
|
|
15
|
+
|
|
16
|
+
SYSTEM_PROMPT = """You are a read-only AWS organization cost assistant.
|
|
17
|
+
|
|
18
|
+
Rules:
|
|
19
|
+
- Always call tools before stating dollar amounts or trends.
|
|
20
|
+
- Default refresh=false; only pass refresh=true when the user explicitly asks for live AWS data.
|
|
21
|
+
- Cite the reporting period and data source when answering.
|
|
22
|
+
- Be concise; use markdown lists for breakdowns.
|
|
23
|
+
- For follow-up questions, use conversation context but re-fetch data when amounts may have changed.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _env_int(name: str, default: int) -> int:
|
|
28
|
+
raw = os.environ.get(name, "").strip()
|
|
29
|
+
if raw:
|
|
30
|
+
try:
|
|
31
|
+
return int(raw)
|
|
32
|
+
except ValueError:
|
|
33
|
+
pass
|
|
34
|
+
return default
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class AgentResult:
|
|
39
|
+
content: str = ""
|
|
40
|
+
tools_called: list[str] = field(default_factory=list)
|
|
41
|
+
ce_calls_used: int = 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class AgentEvent:
|
|
46
|
+
kind: str
|
|
47
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _message_content(msg: dict[str, Any]) -> str:
|
|
51
|
+
content = msg.get("content")
|
|
52
|
+
if content is None:
|
|
53
|
+
return ""
|
|
54
|
+
if isinstance(content, str):
|
|
55
|
+
return content
|
|
56
|
+
if isinstance(content, list):
|
|
57
|
+
parts: list[str] = []
|
|
58
|
+
for block in content:
|
|
59
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
60
|
+
parts.append(str(block.get("text", "")))
|
|
61
|
+
return "".join(parts)
|
|
62
|
+
return str(content)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _tool_calls(msg: dict[str, Any]) -> list[dict[str, Any]]:
|
|
66
|
+
raw = msg.get("tool_calls")
|
|
67
|
+
if not raw:
|
|
68
|
+
return []
|
|
69
|
+
return list(raw)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def run_agent(
|
|
73
|
+
messages: list[dict[str, Any]],
|
|
74
|
+
*,
|
|
75
|
+
default_refresh: bool = False,
|
|
76
|
+
stream: bool = False,
|
|
77
|
+
) -> AgentResult | Iterator[AgentEvent]:
|
|
78
|
+
if stream:
|
|
79
|
+
return _run_agent_stream(messages, default_refresh=default_refresh)
|
|
80
|
+
return _run_agent_sync(messages, default_refresh=default_refresh)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _apply_default_refresh(args: dict[str, Any], default_refresh: bool) -> dict[str, Any]:
|
|
84
|
+
out = dict(args)
|
|
85
|
+
if "refresh" not in out and default_refresh:
|
|
86
|
+
out["refresh"] = True
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _run_agent_sync(
|
|
91
|
+
messages: list[dict[str, Any]],
|
|
92
|
+
*,
|
|
93
|
+
default_refresh: bool,
|
|
94
|
+
) -> AgentResult:
|
|
95
|
+
working = [{"role": "system", "content": SYSTEM_PROMPT}, *messages]
|
|
96
|
+
max_rounds = _env_int("CHAT_MAX_TOOL_ROUNDS", 8)
|
|
97
|
+
tools_called: list[str] = []
|
|
98
|
+
ce_total = 0
|
|
99
|
+
llm_kwargs = llm_completion_kwargs()
|
|
100
|
+
|
|
101
|
+
for _ in range(max_rounds):
|
|
102
|
+
response = litellm.completion(
|
|
103
|
+
messages=working,
|
|
104
|
+
tools=TOOL_SCHEMAS,
|
|
105
|
+
tool_choice="auto",
|
|
106
|
+
stream=False,
|
|
107
|
+
**llm_kwargs,
|
|
108
|
+
)
|
|
109
|
+
choice = response.choices[0].message
|
|
110
|
+
assistant_msg = choice.model_dump(exclude_none=True)
|
|
111
|
+
working.append(assistant_msg)
|
|
112
|
+
|
|
113
|
+
calls = _tool_calls(assistant_msg)
|
|
114
|
+
if not calls:
|
|
115
|
+
return AgentResult(
|
|
116
|
+
content=_message_content(assistant_msg),
|
|
117
|
+
tools_called=tools_called,
|
|
118
|
+
ce_calls_used=ce_total,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
for call in calls:
|
|
122
|
+
fn = call.get("function") or {}
|
|
123
|
+
name = fn.get("name", "")
|
|
124
|
+
raw_args = fn.get("arguments") or "{}"
|
|
125
|
+
try:
|
|
126
|
+
args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
|
|
127
|
+
except json.JSONDecodeError:
|
|
128
|
+
args = {}
|
|
129
|
+
if not isinstance(args, dict):
|
|
130
|
+
args = {}
|
|
131
|
+
args = _apply_default_refresh(args, default_refresh)
|
|
132
|
+
tools_called.append(name)
|
|
133
|
+
result = execute_tool(name, args)
|
|
134
|
+
ce_total += ce_calls_in_result(result)
|
|
135
|
+
working.append(
|
|
136
|
+
{
|
|
137
|
+
"role": "tool",
|
|
138
|
+
"tool_call_id": call.get("id"),
|
|
139
|
+
"name": name,
|
|
140
|
+
"content": result,
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return AgentResult(
|
|
145
|
+
content="I reached the tool-call limit. Please try a narrower question.",
|
|
146
|
+
tools_called=tools_called,
|
|
147
|
+
ce_calls_used=ce_total,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _run_agent_stream(
|
|
152
|
+
messages: list[dict[str, Any]],
|
|
153
|
+
*,
|
|
154
|
+
default_refresh: bool,
|
|
155
|
+
) -> Iterator[AgentEvent]:
|
|
156
|
+
result = _run_agent_sync(messages, default_refresh=default_refresh)
|
|
157
|
+
if result.tools_called:
|
|
158
|
+
for name in result.tools_called:
|
|
159
|
+
yield AgentEvent("tool_end", {"name": name})
|
|
160
|
+
# v1: emit full answer as one delta (LiteLLM streaming + tools is complex)
|
|
161
|
+
if result.content:
|
|
162
|
+
yield AgentEvent("delta", {"text": result.content})
|
|
163
|
+
yield AgentEvent(
|
|
164
|
+
"done",
|
|
165
|
+
{
|
|
166
|
+
"tools_called": result.tools_called,
|
|
167
|
+
"ce_calls_used": result.ce_calls_used,
|
|
168
|
+
},
|
|
169
|
+
)
|