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.
Files changed (31) hide show
  1. org_cost_mcp-0.0.12/.gitignore +5 -0
  2. org_cost_mcp-0.0.12/Dockerfile +16 -0
  3. org_cost_mcp-0.0.12/Dockerfile.mcp +15 -0
  4. org_cost_mcp-0.0.12/PKG-INFO +152 -0
  5. org_cost_mcp-0.0.12/README.md +121 -0
  6. org_cost_mcp-0.0.12/org_cost_mcp/__init__.py +1 -0
  7. org_cost_mcp-0.0.12/org_cost_mcp/account_costs.py +28 -0
  8. org_cost_mcp-0.0.12/org_cost_mcp/agent.py +169 -0
  9. org_cost_mcp-0.0.12/org_cost_mcp/chat_app.py +262 -0
  10. org_cost_mcp-0.0.12/org_cost_mcp/client.py +281 -0
  11. org_cost_mcp-0.0.12/org_cost_mcp/config_discovery.py +135 -0
  12. org_cost_mcp-0.0.12/org_cost_mcp/llm_config.py +107 -0
  13. org_cost_mcp-0.0.12/org_cost_mcp/server.py +759 -0
  14. org_cost_mcp-0.0.12/org_cost_mcp/sessions.py +64 -0
  15. org_cost_mcp-0.0.12/org_cost_mcp/suggestions_enrich.py +209 -0
  16. org_cost_mcp-0.0.12/org_cost_mcp/tools.py +212 -0
  17. org_cost_mcp-0.0.12/org_cost_mcp/visualize.py +408 -0
  18. org_cost_mcp-0.0.12/pyproject.toml +47 -0
  19. org_cost_mcp-0.0.12/tests/__init__.py +0 -0
  20. org_cost_mcp-0.0.12/tests/test_account_costs.py +46 -0
  21. org_cost_mcp-0.0.12/tests/test_account_resolution.py +116 -0
  22. org_cost_mcp-0.0.12/tests/test_agent.py +83 -0
  23. org_cost_mcp-0.0.12/tests/test_chat_app.py +111 -0
  24. org_cost_mcp-0.0.12/tests/test_client.py +66 -0
  25. org_cost_mcp-0.0.12/tests/test_config_discovery.py +59 -0
  26. org_cost_mcp-0.0.12/tests/test_llm_config.py +115 -0
  27. org_cost_mcp-0.0.12/tests/test_smoke.py +59 -0
  28. org_cost_mcp-0.0.12/tests/test_suggestions_enrich.py +130 -0
  29. org_cost_mcp-0.0.12/tests/test_tools.py +310 -0
  30. org_cost_mcp-0.0.12/tests/test_transport.py +53 -0
  31. org_cost_mcp-0.0.12/uv.lock +1016 -0
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ build/
@@ -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
+ )