idun-sdk 0.1.0__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.
- idun_sdk-0.1.0/PKG-INFO +11 -0
- idun_sdk-0.1.0/README.md +120 -0
- idun_sdk-0.1.0/idun/__init__.py +6 -0
- idun_sdk-0.1.0/idun/auth.py +75 -0
- idun_sdk-0.1.0/idun/client.py +130 -0
- idun_sdk-0.1.0/idun_cli.py +80 -0
- idun_sdk-0.1.0/idun_mcp.py +122 -0
- idun_sdk-0.1.0/idun_sdk.egg-info/PKG-INFO +11 -0
- idun_sdk-0.1.0/idun_sdk.egg-info/SOURCES.txt +13 -0
- idun_sdk-0.1.0/idun_sdk.egg-info/dependency_links.txt +1 -0
- idun_sdk-0.1.0/idun_sdk.egg-info/entry_points.txt +2 -0
- idun_sdk-0.1.0/idun_sdk.egg-info/top_level.txt +3 -0
- idun_sdk-0.1.0/setup.cfg +4 -0
- idun_sdk-0.1.0/setup.py +18 -0
- idun_sdk-0.1.0/tests/test_sdk.py +65 -0
idun_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: idun-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Thin client + CLI for Azure AI Foundry agent NatureLM-Idun-5-MoE
|
|
5
|
+
Classifier: Programming Language :: Python :: 3
|
|
6
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
7
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Dynamic: classifier
|
|
10
|
+
Dynamic: requires-python
|
|
11
|
+
Dynamic: summary
|
idun_sdk-0.1.0/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Idun SDK
|
|
2
|
+
|
|
3
|
+
Thin client + CLI for the **NatureLM-Idun-5-MoE** agent on **Azure AI Foundry**.
|
|
4
|
+
Stdlib-only (no httpx / azure.identity) so it runs headless on Termux/Android.
|
|
5
|
+
|
|
6
|
+
Idun is a **tool agent** (it calls `web_search`, `memory_search`). This SDK
|
|
7
|
+
surfaces the full agent trajectory — not just the final chat text — so you can
|
|
8
|
+
see every reasoning step and tool call instead of a black-box chatbot wheel.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
cd idun_sdk
|
|
14
|
+
pip install -e . # provides the `idun` command
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Authenticate (device-code, Entra)
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
idun login
|
|
21
|
+
# opens https://microsoft.com/devicelogin — enter the printed code,
|
|
22
|
+
# sign in with your QMFI-Research admin account.
|
|
23
|
+
# Token is saved to ~/foundry_token.txt (FOUNDRY_TOKEN).
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Use
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# final answer only
|
|
30
|
+
idun chat "Fasse in einem Satz zusammen, was Contoso im Bereich Nachhaltigkeit kommuniziert."
|
|
31
|
+
|
|
32
|
+
# full agent trajectory (reasoning + web_search tool steps)
|
|
33
|
+
idun trace "Use web_search to find the current CEO of Contoso and report the name."
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Python
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from idun import IdunClient
|
|
40
|
+
res = IdunClient().complete("Your prompt here")
|
|
41
|
+
print(res.text) # final answer
|
|
42
|
+
for s in res.steps: # agent trajectory
|
|
43
|
+
if s.kind == "tool":
|
|
44
|
+
print("TOOL", s.tool, s.status, s.query)
|
|
45
|
+
else:
|
|
46
|
+
print("REASON", s.text[:80])
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Request shape (verified working)
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
POST {base}/api/projects/{project}/agents/{agent}/endpoint/protocols/openai/responses?api-version=2025-05-15-preview
|
|
53
|
+
Authorization: Bearer <FOUNDRY_TOKEN>
|
|
54
|
+
Content-Type: application/json
|
|
55
|
+
|
|
56
|
+
{"model": "model-router", "input": "<prompt string>", "max_output_tokens": 4096}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Notes:
|
|
60
|
+
- `model` MUST be `"model-router"` (the agent id is already in the URL).
|
|
61
|
+
- Do **not** send a `tools` key — the agent owns its capabilities; doing so
|
|
62
|
+
returns `400 invalid_payload`.
|
|
63
|
+
- The answer is in `output[].content[].text`; tool calls appear as
|
|
64
|
+
`web_search_call` items with `action.queries` and `status`.
|
|
65
|
+
|
|
66
|
+
## Files
|
|
67
|
+
|
|
68
|
+
- `idun/client.py` — `IdunClient` (sync `complete()`) + `_normalize_output()`
|
|
69
|
+
- `idun/auth.py` — stdlib device-code `login()` + `load_token()`
|
|
70
|
+
- `idun_cli.py` — `idun login | chat | trace`
|
|
71
|
+
|
|
72
|
+
## MCP — agent + docs
|
|
73
|
+
|
|
74
|
+
Idun is available as an MCP server **and** has a GitMCP docs mirror, so other
|
|
75
|
+
agents can both call Idun and read its documentation without hallucinating.
|
|
76
|
+
|
|
77
|
+
### 1. Idun MCP server (stdlib-only, local)
|
|
78
|
+
|
|
79
|
+
`idun_mcp.py` is a zero-dependency stdio MCP server — no FastMCP / httpx
|
|
80
|
+
needed (runs on bare Python, ideal for Termux/Android).
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
python3 idun_mcp.py # stdio MCP server
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Tools exposed:
|
|
87
|
+
- `idun_chat(prompt)` — final answer text
|
|
88
|
+
- `idun_trace(prompt)` — full agent trajectory (steps + text)
|
|
89
|
+
|
|
90
|
+
Add to any MCP client (e.g. Cursor `~/.cursor/mcp.json`):
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"mcpServers": {
|
|
95
|
+
"idun": { "command": "python3", "args": ["/abs/path/idun-sdk/idun_mcp.py"] }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### 2. GitMCP docs mirror (remote, zero-setup)
|
|
101
|
+
|
|
102
|
+
Point an MCP client at the GitMCP URL to give it live access to this repo's
|
|
103
|
+
docs + code (prefers `llms.txt`):
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
https://gitmcp.io/qapdex-maker/idun-sdk/sse
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
For stdio-only clients (Claude Desktop, Cline, Msty):
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{ "mcpServers": { "idun-docs": { "command": "npx", "args": ["mcp-remote", "https://gitmcp.io/qapdex-maker/idun-sdk/sse"] } } }
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Recommended combo for a foreign agent:** both `idun` (calls the agent) and
|
|
116
|
+
`idun-docs` (reads the SDK docs) — it can invoke Idun *and* look up the exact
|
|
117
|
+
`IdunClient` signature on its own.
|
|
118
|
+
|
|
119
|
+
[](https://gitmcp.io/qapdex-maker/idun-sdk)
|
|
120
|
+
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Stdlib-only Entra device-code login for Idun (no azure.identity needed).
|
|
2
|
+
|
|
3
|
+
Flow: POST devicecode -> show user code -> poll token endpoint until granted
|
|
4
|
+
-> save FOUNDRY_TOKEN to ~/foundry_token.txt. Same endpoint/params the Azure
|
|
5
|
+
CLI uses, so it works headless on Termux.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
TENANT = "885f01ab-7364-4484-be0a-231d541c9e7f"
|
|
17
|
+
SCOPE = "https://ai.azure.com/.default"
|
|
18
|
+
CLIENT_ID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" # Azure CLI first-party app
|
|
19
|
+
TOKEN_FILE = os.path.join(os.path.expanduser("~"), "foundry_token.txt")
|
|
20
|
+
CODE_FILE = os.path.join(os.path.expanduser("~"), "foundry_code.txt")
|
|
21
|
+
AUTH_ENDPOINT = f"https://login.microsoftonline.com/{TENANT}/oauth2/v2.0"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _post(url: str, form: dict, headers=None) -> dict:
|
|
25
|
+
data = urllib.parse.urlencode(form).encode()
|
|
26
|
+
req = urllib.request.Request(url, data=data, headers=headers or {"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
|
|
27
|
+
with urllib.request.urlopen(req, timeout=60) as r:
|
|
28
|
+
return json.loads(r.read())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def login() -> str:
|
|
32
|
+
# 1) request device code
|
|
33
|
+
dc = _post(f"{AUTH_ENDPOINT}/devicecode", {"client_id": CLIENT_ID, "scope": SCOPE})
|
|
34
|
+
msg = (f"To sign in, use a web browser to open {dc['verification_uri']} "
|
|
35
|
+
f"and enter the code {dc['user_code']} to authenticate.")
|
|
36
|
+
with open(CODE_FILE, "w") as f:
|
|
37
|
+
f.write(msg + "\n")
|
|
38
|
+
print(msg, flush=True)
|
|
39
|
+
print(f"(code also saved to {CODE_FILE})", flush=True)
|
|
40
|
+
|
|
41
|
+
# 2) poll for token
|
|
42
|
+
interval = int(dc.get("interval", 5))
|
|
43
|
+
expires = time.time() + float(dc.get("expires_in", 900))
|
|
44
|
+
form = {
|
|
45
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
46
|
+
"client_id": CLIENT_ID,
|
|
47
|
+
"device_code": dc["device_code"],
|
|
48
|
+
}
|
|
49
|
+
while time.time() < expires:
|
|
50
|
+
try:
|
|
51
|
+
tok = _post(f"{AUTH_ENDPOINT}/token", form)
|
|
52
|
+
if "access_token" in tok:
|
|
53
|
+
token = tok["access_token"]
|
|
54
|
+
with open(TOKEN_FILE, "w") as f:
|
|
55
|
+
f.write(token)
|
|
56
|
+
print(f"TOKEN_OK len={len(token)}")
|
|
57
|
+
print(f"saved to {TOKEN_FILE}")
|
|
58
|
+
return token
|
|
59
|
+
except urllib.error.HTTPError as e:
|
|
60
|
+
err = json.loads(e.read().decode("utf-8", "replace"))
|
|
61
|
+
if err.get("error") == "authorization_pending":
|
|
62
|
+
time.sleep(interval)
|
|
63
|
+
continue
|
|
64
|
+
if err.get("error") == "slow_down":
|
|
65
|
+
interval += 5
|
|
66
|
+
time.sleep(interval)
|
|
67
|
+
continue
|
|
68
|
+
raise RuntimeError(f"Login failed: {err.get('error_description', err)}")
|
|
69
|
+
raise RuntimeError("Login timed out. Re-run `idun login`.")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_token() -> Optional[str]:
|
|
73
|
+
if os.path.exists(TOKEN_FILE):
|
|
74
|
+
return open(TOKEN_FILE).read().strip()
|
|
75
|
+
return None
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Idun SDK — thin client + CLI for Azure AI Foundry agent NatureLM-Idun-5-MoE.
|
|
2
|
+
|
|
3
|
+
Stdlib-only (urllib, no httpx / azure.identity) so it runs on Termux/Android.
|
|
4
|
+
Mirrors the working request shape discovered during integration:
|
|
5
|
+
POST {base}/api/projects/{project}/agents/{agent}/endpoint/protocols/openai/responses?api-version={ver}
|
|
6
|
+
body: {"model": "model-router", "input": "<prompt string>", "max_output_tokens": N}
|
|
7
|
+
auth: Entra Bearer token (FOUNDRY_TOKEN), scope https://ai.azure.com/.default
|
|
8
|
+
|
|
9
|
+
Returns both the final text AND the agent trajectory ("steps"):
|
|
10
|
+
- kind=reasoning -> assistant plan / reasoning text
|
|
11
|
+
- kind=tool -> web_search call: {tool, query, status}
|
|
12
|
+
This is what makes Idun a visible tool-agent, not a chatbot wheel.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import time
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.request
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import List, Optional
|
|
23
|
+
|
|
24
|
+
# --- defaults (verified working for qmfi-research-project, 2026-07-25) ---
|
|
25
|
+
FOUNDRY_BASE_DEFAULT = "https://qmfi-research-project-resource.services.ai.azure.com"
|
|
26
|
+
FOUNDRY_PROJECT_DEFAULT = "qmfi-research-project"
|
|
27
|
+
FOUNDRY_AGENT_DEFAULT = "NatureLM-Idun-5-MoE"
|
|
28
|
+
FOUNDRY_API_VERSION_DEFAULT = "2025-05-15-preview"
|
|
29
|
+
FOUNDRY_SCOPE = "https://ai.azure.com/.default"
|
|
30
|
+
FOUNDRY_TENANT = "885f01ab-7364-4484-be0a-231d541c9e7f"
|
|
31
|
+
TOKEN_FILE = os.path.join(os.path.expanduser("~"), "foundry_token.txt")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Step:
|
|
36
|
+
kind: str # "reasoning" | "tool" | "message"
|
|
37
|
+
text: str = ""
|
|
38
|
+
tool: str = ""
|
|
39
|
+
query: str = ""
|
|
40
|
+
status: str = ""
|
|
41
|
+
id: str = ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class IdunResult:
|
|
46
|
+
text: str
|
|
47
|
+
steps: List[Step] = field(default_factory=list)
|
|
48
|
+
model: str = ""
|
|
49
|
+
raw: dict = field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _normalize_output(data: dict) -> IdunResult:
|
|
53
|
+
"""Convert Foundry Responses output[] into (final_text, steps[])."""
|
|
54
|
+
text = ""
|
|
55
|
+
steps: List[Step] = []
|
|
56
|
+
for o in data.get("output", []):
|
|
57
|
+
otype = o.get("type")
|
|
58
|
+
if otype == "message" and o.get("role") == "assistant":
|
|
59
|
+
t = "".join(c.get("text", "") for c in o.get("content", []) if c.get("type") == "output_text")
|
|
60
|
+
if t:
|
|
61
|
+
text += t + "\n\n"
|
|
62
|
+
steps.append(Step(kind="reasoning", text=t))
|
|
63
|
+
elif otype == "web_search_call":
|
|
64
|
+
action = o.get("action") or {}
|
|
65
|
+
q = action.get("query") or ""
|
|
66
|
+
if action.get("queries"):
|
|
67
|
+
q = action["queries"][0]
|
|
68
|
+
steps.append(Step(kind="tool", tool="web_search", query=q,
|
|
69
|
+
status=o.get("status", "unknown"), id=o.get("id")))
|
|
70
|
+
elif otype == "message":
|
|
71
|
+
t = "".join(c.get("text", "") for c in o.get("content", []) if c.get("type") == "output_text")
|
|
72
|
+
if t:
|
|
73
|
+
text += t + "\n\n"
|
|
74
|
+
steps.append(Step(kind="message", text=t))
|
|
75
|
+
return IdunResult(text=text.strip(), steps=steps,
|
|
76
|
+
model=data.get("model", ""), raw=data)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class IdunClient:
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
token: Optional[str] = None,
|
|
83
|
+
base: str = FOUNDRY_BASE_DEFAULT,
|
|
84
|
+
project: str = FOUNDRY_PROJECT_DEFAULT,
|
|
85
|
+
agent: str = FOUNDRY_AGENT_DEFAULT,
|
|
86
|
+
api_version: str = FOUNDRY_API_VERSION_DEFAULT,
|
|
87
|
+
timeout: int = 600,
|
|
88
|
+
) -> None:
|
|
89
|
+
self.token = token or os.environ.get("FOUNDRY_TOKEN")
|
|
90
|
+
self.base = base.rstrip("/")
|
|
91
|
+
self.project = project
|
|
92
|
+
self.agent = agent
|
|
93
|
+
self.api_version = api_version
|
|
94
|
+
self.timeout = timeout
|
|
95
|
+
|
|
96
|
+
def _url(self) -> str:
|
|
97
|
+
return (f"{self.base}/api/projects/{self.project}/agents/{self.agent}"
|
|
98
|
+
f"/endpoint/protocols/openai/responses?api-version={self.api_version}")
|
|
99
|
+
|
|
100
|
+
def _headers(self) -> dict:
|
|
101
|
+
if not self.token:
|
|
102
|
+
raise RuntimeError("No FOUNDRY_TOKEN set. Run `idun login` or export FOUNDRY_TOKEN.")
|
|
103
|
+
return {"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"}
|
|
104
|
+
|
|
105
|
+
def _build_payload(self, prompt: str, max_output_tokens: int = 4096) -> dict:
|
|
106
|
+
"""Verified working request shape for the agent-in-URL endpoint.
|
|
107
|
+
|
|
108
|
+
Model MUST stay 'model-router' (agent name -> invalid_payload).
|
|
109
|
+
No 'tools' key (agent owns capabilities -> 400 invalid_payload).
|
|
110
|
+
"""
|
|
111
|
+
return {
|
|
112
|
+
"model": "model-router",
|
|
113
|
+
"input": prompt,
|
|
114
|
+
"max_output_tokens": max_output_tokens,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
def complete(self, prompt: str, max_output_tokens: int = 4096) -> IdunResult:
|
|
118
|
+
"""Synchronous completion. Returns final text + agent trajectory."""
|
|
119
|
+
payload = self._build_payload(prompt, max_output_tokens)
|
|
120
|
+
req = urllib.request.Request(
|
|
121
|
+
self._url(), data=json.dumps(payload).encode("utf-8"),
|
|
122
|
+
headers=self._headers(), method="POST",
|
|
123
|
+
)
|
|
124
|
+
try:
|
|
125
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
126
|
+
data = json.loads(resp.read())
|
|
127
|
+
except urllib.error.HTTPError as e:
|
|
128
|
+
body = e.read().decode("utf-8", "replace")[:400]
|
|
129
|
+
raise RuntimeError(f"Foundry HTTP {e.code}: {body}") from e
|
|
130
|
+
return _normalize_output(data)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Idun CLI — terminal client for NatureLM-Idun-5-MoE on Azure AI Foundry.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
idun login # device-code Entra login -> ~/foundry_token.txt
|
|
6
|
+
idun chat "your prompt" # print final answer
|
|
7
|
+
idun trace "your prompt" # print agent trajectory (reasoning + web_search steps)
|
|
8
|
+
|
|
9
|
+
Stdlib-only; needs FOUNDRY_TOKEN (from `idun login` or env).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from idun import IdunClient, login as do_login, load_token
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _client() -> IdunClient:
|
|
21
|
+
tok = load_token() or os.environ.get("FOUNDRY_TOKEN")
|
|
22
|
+
if not tok:
|
|
23
|
+
sys.exit("No token. Run `idun login` first (or export FOUNDRY_TOKEN).")
|
|
24
|
+
return IdunClient(token=tok)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cmd_login(_args):
|
|
28
|
+
do_login()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cmd_chat(args):
|
|
32
|
+
c = _client()
|
|
33
|
+
res = c.complete(args.prompt, max_output_tokens=args.max_tokens)
|
|
34
|
+
print(res.text)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def cmd_trace(args):
|
|
38
|
+
c = _client()
|
|
39
|
+
res = c.complete(args.prompt, max_output_tokens=args.max_tokens)
|
|
40
|
+
print(f"Model: {res.model}\n")
|
|
41
|
+
print("AGENT TRACE ({})".format(len(res.steps)))
|
|
42
|
+
print("=" * 60)
|
|
43
|
+
for i, s in enumerate(res.steps, 1):
|
|
44
|
+
if s.kind == "tool":
|
|
45
|
+
print(f" {i:>2}. TOOL web_search [{s.status}]")
|
|
46
|
+
print(f" query: {s.query}")
|
|
47
|
+
else:
|
|
48
|
+
head = s.text.replace("\n", " ").strip()[:90]
|
|
49
|
+
label = "REASON" if s.kind == "reasoning" else "MSG"
|
|
50
|
+
print(f" {i:>2}. {label} {head}{'…' if len(s.text) > 90 else ''}")
|
|
51
|
+
print("=" * 60)
|
|
52
|
+
print("\nFINAL ANSWER:")
|
|
53
|
+
print(res.text)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
57
|
+
p = argparse.ArgumentParser(prog="idun", description="NatureLM-Idun-5-MoE CLI")
|
|
58
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
59
|
+
|
|
60
|
+
sub.add_parser("login", help="device-code Entra login").set_defaults(func=cmd_login)
|
|
61
|
+
|
|
62
|
+
pc = sub.add_parser("chat", help="print final answer")
|
|
63
|
+
pc.add_argument("prompt")
|
|
64
|
+
pc.add_argument("--max-tokens", type=int, default=4096, dest="max_tokens")
|
|
65
|
+
pc.set_defaults(func=cmd_chat)
|
|
66
|
+
|
|
67
|
+
pt = sub.add_parser("trace", help="print agent trajectory (steps)")
|
|
68
|
+
pt.add_argument("prompt")
|
|
69
|
+
pt.add_argument("--max-tokens", type=int, default=4096, dest="max_tokens")
|
|
70
|
+
pt.set_defaults(func=cmd_trace)
|
|
71
|
+
return p
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main():
|
|
75
|
+
args = build_parser().parse_args()
|
|
76
|
+
args.func(args)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
main()
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Idun MCP server (stdio, stdlib-only).
|
|
3
|
+
|
|
4
|
+
Exposes the NatureLM-Idun-5-MoE agent as MCP tools so other agents/clients
|
|
5
|
+
can call it. No FastMCP / httpx / pydantic: implements the minimal MCP
|
|
6
|
+
JSON-RPC-over-stdio wire contract with only the standard library.
|
|
7
|
+
|
|
8
|
+
Tools:
|
|
9
|
+
idun_chat(prompt) -> final answer text
|
|
10
|
+
idun_trace(prompt) -> full agent trajectory (steps + text)
|
|
11
|
+
|
|
12
|
+
Auth: reads FOUNDRY_TOKEN / FOUNDRY_RESOURCE / FOUNDRY_AGENT from the
|
|
13
|
+
environment (same as the CLI). Entra device-code login is the caller's job
|
|
14
|
+
(`idun login`); this server only relays.
|
|
15
|
+
|
|
16
|
+
Run:
|
|
17
|
+
python3 idun_mcp.py # stdio MCP server
|
|
18
|
+
"""
|
|
19
|
+
import sys, os, json
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
22
|
+
from idun.client import IdunClient
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
TOOLS = [
|
|
26
|
+
{
|
|
27
|
+
"name": "idun_chat",
|
|
28
|
+
"description": "Ask the NatureLM-Idun-5-MoE agent a question and return the final answer text.",
|
|
29
|
+
"inputSchema": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"properties": {"prompt": {"type": "string",
|
|
32
|
+
"description": "The user prompt for the Idun agent."}},
|
|
33
|
+
"required": ["prompt"],
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "idun_trace",
|
|
38
|
+
"description": ("Ask the Idun agent and return the FULL trajectory (reasoning + "
|
|
39
|
+
"tool calls) plus the final text — for auditable, visible tool-agent use."),
|
|
40
|
+
"inputSchema": {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"properties": {"prompt": {"type": "string",
|
|
43
|
+
"description": "The user prompt for the Idun agent."}},
|
|
44
|
+
"required": ["prompt"],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _tool_chat(prompt):
|
|
51
|
+
cli = IdunClient()
|
|
52
|
+
res = dict(cli.complete(prompt))
|
|
53
|
+
return res.get("text", "")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _tool_trace(prompt):
|
|
57
|
+
cli = IdunClient()
|
|
58
|
+
res = dict(cli.complete(prompt))
|
|
59
|
+
return {
|
|
60
|
+
"text": res.get("text", ""),
|
|
61
|
+
"steps": res.get("steps", []),
|
|
62
|
+
"model": res.get("model", ""),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _dispatch(req):
|
|
67
|
+
method = req.get("method")
|
|
68
|
+
rid = req.get("id")
|
|
69
|
+
params = req.get("params") or {}
|
|
70
|
+
|
|
71
|
+
if method == "initialize":
|
|
72
|
+
return {
|
|
73
|
+
"jsonrpc": "2.0", "id": rid,
|
|
74
|
+
"result": {
|
|
75
|
+
"protocolVersion": "2024-11-05",
|
|
76
|
+
"capabilities": {"tools": {}},
|
|
77
|
+
"serverInfo": {"name": "idun-mcp", "version": "0.1.0"},
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
if method == "notifications/initialized":
|
|
81
|
+
return None # notification: no response
|
|
82
|
+
if method == "tools/list":
|
|
83
|
+
return {"jsonrpc": "2.0", "id": rid, "result": {"tools": TOOLS}}
|
|
84
|
+
if method == "tools/call":
|
|
85
|
+
name = params.get("name")
|
|
86
|
+
args = params.get("arguments") or {}
|
|
87
|
+
try:
|
|
88
|
+
if name == "idun_chat":
|
|
89
|
+
out = _tool_chat(args.get("prompt", ""))
|
|
90
|
+
content = [{"type": "text", "text": str(out)}]
|
|
91
|
+
elif name == "idun_trace":
|
|
92
|
+
out = _tool_trace(args.get("prompt", ""))
|
|
93
|
+
content = [{"type": "text", "text": json.dumps(out, ensure_ascii=False, indent=2)}]
|
|
94
|
+
else:
|
|
95
|
+
raise ValueError(f"unknown tool: {name}")
|
|
96
|
+
return {"jsonrpc": "2.0", "id": rid, "result": {"content": content}}
|
|
97
|
+
except Exception as e:
|
|
98
|
+
return {"jsonrpc": "2.0", "id": rid,
|
|
99
|
+
"error": {"code": -32603, "message": str(e)[:400]}}
|
|
100
|
+
if rid is not None:
|
|
101
|
+
return {"jsonrpc": "2.0", "id": rid,
|
|
102
|
+
"error": {"code": -32601, "message": f"method not found: {method}"}}
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main():
|
|
107
|
+
for line in sys.stdin:
|
|
108
|
+
line = line.strip()
|
|
109
|
+
if not line:
|
|
110
|
+
continue
|
|
111
|
+
try:
|
|
112
|
+
req = json.loads(line)
|
|
113
|
+
except json.JSONDecodeError:
|
|
114
|
+
continue
|
|
115
|
+
resp = _dispatch(req)
|
|
116
|
+
if resp is not None:
|
|
117
|
+
sys.stdout.write(json.dumps(resp, ensure_ascii=False) + "\n")
|
|
118
|
+
sys.stdout.flush()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == "__main__":
|
|
122
|
+
main()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: idun-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Thin client + CLI for Azure AI Foundry agent NatureLM-Idun-5-MoE
|
|
5
|
+
Classifier: Programming Language :: Python :: 3
|
|
6
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
7
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Dynamic: classifier
|
|
10
|
+
Dynamic: requires-python
|
|
11
|
+
Dynamic: summary
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
idun_cli.py
|
|
3
|
+
idun_mcp.py
|
|
4
|
+
setup.py
|
|
5
|
+
idun/__init__.py
|
|
6
|
+
idun/auth.py
|
|
7
|
+
idun/client.py
|
|
8
|
+
idun_sdk.egg-info/PKG-INFO
|
|
9
|
+
idun_sdk.egg-info/SOURCES.txt
|
|
10
|
+
idun_sdk.egg-info/dependency_links.txt
|
|
11
|
+
idun_sdk.egg-info/entry_points.txt
|
|
12
|
+
idun_sdk.egg-info/top_level.txt
|
|
13
|
+
tests/test_sdk.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
idun_sdk-0.1.0/setup.cfg
ADDED
idun_sdk-0.1.0/setup.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name="idun-sdk",
|
|
5
|
+
version="0.1.0",
|
|
6
|
+
description="Thin client + CLI for Azure AI Foundry agent NatureLM-Idun-5-MoE",
|
|
7
|
+
packages=find_packages(),
|
|
8
|
+
py_modules=["idun_cli", "idun_mcp"],
|
|
9
|
+
python_requires=">=3.8",
|
|
10
|
+
# stdlib-only: no runtime dependencies. Works headless on Termux.
|
|
11
|
+
install_requires=[],
|
|
12
|
+
entry_points={"console_scripts": ["idun=idun_cli:main"]},
|
|
13
|
+
classifiers=[
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
17
|
+
],
|
|
18
|
+
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""pytest suite for idun-sdk (offline; no live Foundry call).
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- trajectory normalization (output array -> steps + text)
|
|
5
|
+
- request payload shape (model-router, no tools key)
|
|
6
|
+
- CLI entrypoint exposes login/chat/trace
|
|
7
|
+
- package importable as `idun`
|
|
8
|
+
"""
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
13
|
+
|
|
14
|
+
from idun.client import IdunClient, _normalize_output
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
SAMPLE = {
|
|
18
|
+
"model": "gpt-5.4-2026-03-05",
|
|
19
|
+
"output": [
|
|
20
|
+
{"type": "message", "role": "assistant",
|
|
21
|
+
"content": [{"type": "output_text", "text": "Ich habe recherchiert: "}]},
|
|
22
|
+
{"type": "reasoning", "text": "Ich pruefe, ob Contoso eine reale Marke ist."},
|
|
23
|
+
{"type": "message", "role": "assistant",
|
|
24
|
+
"content": [{"type": "output_text", "text": "Contoso setzt auf Kreislauf."}]},
|
|
25
|
+
{"type": "web_search_call", "action": {"query": "Contoso Nachhaltigkeit"},
|
|
26
|
+
"status": "completed", "id": "call_1"},
|
|
27
|
+
],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_normalize_trajectory():
|
|
32
|
+
out = _normalize_output(SAMPLE)
|
|
33
|
+
assert out.text.startswith("Ich habe recherchiert")
|
|
34
|
+
assert "Contoso setzt auf Kreislauf" in out.text
|
|
35
|
+
assert out.model == "gpt-5.4-2026-03-05"
|
|
36
|
+
steps = out.steps
|
|
37
|
+
kinds = [s.kind for s in steps]
|
|
38
|
+
assert "reasoning" in kinds
|
|
39
|
+
assert "tool" in kinds
|
|
40
|
+
tool = next(s for s in steps if s.kind == "tool")
|
|
41
|
+
assert tool.tool == "web_search"
|
|
42
|
+
assert tool.query == "Contoso Nachhaltigkeit"
|
|
43
|
+
assert tool.status == "completed"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_request_payload_shape():
|
|
47
|
+
cli = IdunClient.__new__(IdunClient)
|
|
48
|
+
payload = cli._build_payload("Hallo")
|
|
49
|
+
assert payload["model"] == "model-router"
|
|
50
|
+
assert payload["input"] == "Hallo"
|
|
51
|
+
assert payload["max_output_tokens"] == 4096
|
|
52
|
+
assert "tools" not in payload
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_cli_entrypoint():
|
|
56
|
+
from idun_cli import build_parser
|
|
57
|
+
parser = build_parser()
|
|
58
|
+
args = parser.parse_args(["trace", "Was macht Contoso?"])
|
|
59
|
+
assert args.command == "trace"
|
|
60
|
+
assert args.prompt == "Was macht Contoso?"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_package_importable():
|
|
64
|
+
import idun
|
|
65
|
+
assert hasattr(idun, "__version__")
|