mulebot 0.2.0__tar.gz → 0.3.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.
Files changed (36) hide show
  1. {mulebot-0.2.0 → mulebot-0.3.0}/PKG-INFO +1 -1
  2. mulebot-0.3.0/mulebot/__init__.py +5 -0
  3. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot/agent.py +29 -24
  4. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot/cli.py +4 -2
  5. mulebot-0.3.0/mulebot/providers.py +171 -0
  6. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot/server.py +12 -9
  7. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot/tools.py +5 -2
  8. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot/ui/static/app.js +7 -3
  9. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot/ui/templates/index.html +1 -1
  10. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot.egg-info/PKG-INFO +1 -1
  11. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot.egg-info/SOURCES.txt +1 -0
  12. {mulebot-0.2.0 → mulebot-0.3.0}/setup.py +1 -1
  13. mulebot-0.2.0/mulebot/__init__.py +0 -4
  14. {mulebot-0.2.0 → mulebot-0.3.0}/LICENSE +0 -0
  15. {mulebot-0.2.0 → mulebot-0.3.0}/MANIFEST.in +0 -0
  16. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot/__main__.py +0 -0
  17. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot/assets.py +0 -0
  18. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot/docs/datasets.md +0 -0
  19. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot/docs/docs.md +0 -0
  20. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot/docs/index.yaml +0 -0
  21. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot/docs/products.md +0 -0
  22. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot/docs/readme.md +0 -0
  23. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot/docs/schema.md +0 -0
  24. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot/docs/sec-filings-lookup.md +0 -0
  25. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot/docs/sec_filings_archive.md +0 -0
  26. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot/docs/sec_filings_notifications.md +0 -0
  27. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot/docs/transfer_to_your_cloud.md +0 -0
  28. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot/docs/utils.md +0 -0
  29. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot/tool.py +0 -0
  30. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot/ui/static/style.css +0 -0
  31. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot.egg-info/dependency_links.txt +0 -0
  32. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot.egg-info/entry_points.txt +0 -0
  33. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot.egg-info/requires.txt +0 -0
  34. {mulebot-0.2.0 → mulebot-0.3.0}/mulebot.egg-info/top_level.txt +0 -0
  35. {mulebot-0.2.0 → mulebot-0.3.0}/pyproject.toml +0 -0
  36. {mulebot-0.2.0 → mulebot-0.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mulebot
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A simple chatbot for Datamule's APIs
5
5
  Home-page: https://github.com/john-friedman/mulebot
6
6
  Author: John Friedman
@@ -0,0 +1,5 @@
1
+ from .agent import Agent
2
+ from .tool import Tool, tool
3
+ from .providers import DEFAULT_MODELS
4
+
5
+ __all__ = ["Agent", "Tool", "tool", "DEFAULT_MODELS"]
@@ -7,9 +7,10 @@ from time import perf_counter, time
7
7
 
8
8
  from .tools import Tools, documentation_context
9
9
  from .tool import validate_tools
10
+ from .providers import DEFAULT_MODELS, build_request, credentials, normalize_response, resolve_model
10
11
 
11
12
 
12
- DEFAULT_MODEL = "deepseek/deepseek-v4.1-flash-1017:nitro"
13
+ DEFAULT_MODEL = DEFAULT_MODELS["openrouter"]
13
14
  TOOL_LIMIT = 50
14
15
 
15
16
 
@@ -63,15 +64,13 @@ def reasoning_text(response):
63
64
 
64
65
 
65
66
  class Agent:
66
- def __init__(self, api_key=None, model=None, model_api_key=None, allowed_tables=None):
67
- self.model = model or os.environ.get("MULEBOT_MODEL", DEFAULT_MODEL)
68
- self.model_api_key = model_api_key or os.environ.get("OPENROUTER_API_KEY")
69
- api_key = api_key or os.environ.get("DATAMULE_API_KEY")
70
- if not api_key or not self.model_api_key:
71
- raise ValueError("Set DATAMULE_API_KEY and OPENROUTER_API_KEY.")
72
- self._tool_state = Tools(api_key, allowed_tables=allowed_tables)
67
+ def __init__(self, provider, *, model=None, allowed_tables=None):
68
+ self.provider = provider
69
+ self.model = resolve_model(provider, model)
70
+ self.model_api_key = credentials(provider)
71
+ self._tool_state = Tools(allowed_tables=allowed_tables)
73
72
  self.instructions = INSTRUCTIONS
74
- self.doc = documentation_context()
73
+ self.documentation = documentation_context()
75
74
  self.messages = []
76
75
  self._update_system_message()
77
76
 
@@ -100,17 +99,17 @@ class Agent:
100
99
  raise TypeError("Instructions must be a string.")
101
100
  return self.set_instructions("\n\n".join(part for part in (self.instructions, text) if part))
102
101
 
103
- def set_doc(self, text):
102
+ def set_documentation(self, text):
104
103
  if not isinstance(text, str):
105
104
  raise TypeError("Documentation must be a string.")
106
- self.doc = text
105
+ self.documentation = text
107
106
  self._update_system_message()
108
107
  return self
109
108
 
110
- def append_doc(self, text):
109
+ def append_documentation(self, text):
111
110
  if not isinstance(text, str):
112
111
  raise TypeError("Documentation must be a string.")
113
- return self.set_doc("\n\n".join(part for part in (self.doc, text) if part))
112
+ return self.set_documentation("\n\n".join(part for part in (self.documentation, text) if part))
114
113
 
115
114
  def set_tools(self, tools):
116
115
  self._tool_state.registry = validate_tools(tools)
@@ -123,7 +122,7 @@ class Agent:
123
122
 
124
123
  def _update_system_message(self):
125
124
  content = (f"Today is {date.today().isoformat()}.\n\n<documentation>\n"
126
- + self.doc + "\n</documentation>\n\n" + self.instructions)
125
+ + self.documentation + "\n</documentation>\n\n" + self.instructions)
127
126
  self.messages[:1] = [{"role": "system", "content": content}]
128
127
 
129
128
  def export_state(self):
@@ -168,6 +167,13 @@ class Agent:
168
167
  entry["reasoning_details"] = message["reasoning_details"]
169
168
  elif isinstance(message.get("reasoning"), str):
170
169
  entry["reasoning"] = message["reasoning"]
170
+ if "provider_data" in message:
171
+ data = message["provider_data"]
172
+ if (not isinstance(data, dict) or data.get("provider") not in ("gemini", "anthropic")
173
+ or not isinstance(data.get("content"), list)
174
+ or not all(isinstance(block, dict) for block in data["content"])):
175
+ raise ValueError("Invalid saved provider content.")
176
+ entry["provider_data"] = data
171
177
  restored.append(entry)
172
178
  for result in results.values():
173
179
  if (not isinstance(result, dict) or not isinstance(result.get("sql"), str)
@@ -199,17 +205,14 @@ class Agent:
199
205
  "These results are already saved and remain available even if older chat was trimmed. "
200
206
  "Use query(result_id=...) or plot.sql to read them locally instead of paying to fetch "
201
207
  "the same data again. Local SQL reads the table data using SQLite.\n" + json.dumps(saved)})
202
- payload = {
203
- "model": self.model, "messages": messages,
204
- "max_tokens": 8192,
205
- "reasoning": {"effort": os.environ.get("MULEBOT_REASONING_EFFORT", "medium"), "exclude": False},
206
- }
207
- if self._tool_state.registry:
208
- payload.update(tools=self._tool_state.definitions, tool_choice=tool_choice, parallel_tool_calls=False)
208
+ url, headers, payload = build_request(
209
+ self.provider, self.model, self.model_api_key, messages, self._tool_state.definitions,
210
+ tool_choice, os.environ.get("MULEBOT_REASONING_EFFORT"),
211
+ )
209
212
  request = urllib.request.Request(
210
- "https://openrouter.ai/api/v1/chat/completions",
213
+ url,
211
214
  data=json.dumps(payload).encode("utf-8"),
212
- headers={"Authorization": f"Bearer {self.model_api_key}", "Content-Type": "application/json"},
215
+ headers=headers,
213
216
  )
214
217
  try:
215
218
  with urllib.request.urlopen(request, timeout=180) as response:
@@ -217,7 +220,7 @@ class Agent:
217
220
  except urllib.error.HTTPError as exc:
218
221
  detail = exc.read().decode("utf-8", errors="replace")[:2000]
219
222
  raise RuntimeError(self._redact(f"Model request failed ({exc.code}): {detail}")) from None
220
- return result
223
+ return normalize_response(self.provider, result)
221
224
 
222
225
  def run(self, message):
223
226
  """Yield JSON-compatible events, independent of the CLI or browser UI."""
@@ -272,6 +275,8 @@ class Agent:
272
275
  assistant["reasoning_details"] = response["reasoning_details"]
273
276
  elif response.get("reasoning"):
274
277
  assistant["reasoning"] = response["reasoning"]
278
+ if response.get("provider_data"):
279
+ assistant["provider_data"] = response["provider_data"]
275
280
  if not calls and not assistant["content"]:
276
281
  raise RuntimeError("The model returned no answer. Try again or choose another model.")
277
282
  self.messages.append(assistant)
@@ -3,13 +3,15 @@ import json
3
3
  import sys
4
4
 
5
5
  from .agent import Agent
6
+ from .providers import DEFAULT_MODELS
6
7
 
7
8
 
8
9
  def main():
9
10
  if hasattr(sys.stdout, "reconfigure"):
10
11
  sys.stdout.reconfigure(encoding="utf-8")
11
12
  parser = argparse.ArgumentParser(description="Chat with Datamule's APIs.")
12
- parser.add_argument("--model", help="OpenRouter model; defaults to MULEBOT_MODEL.")
13
+ parser.add_argument("--provider", required=True, choices=DEFAULT_MODELS, help="Model provider.")
14
+ parser.add_argument("--model", help="Model ID; defaults to MULEBOT_MODEL or the provider's default.")
13
15
  parser.add_argument("--tables", help="Optional comma-separated table allowlist.")
14
16
  commands = parser.add_subparsers(dest="command", required=True)
15
17
  chat = commands.add_parser("chat", help="Print raw events as newline-delimited JSON.")
@@ -17,7 +19,7 @@ def main():
17
19
  serve = commands.add_parser("serve", help="Run the local browser interface.")
18
20
  serve.add_argument("--port", type=int, default=8000)
19
21
  args = parser.parse_args()
20
- settings = {"model": args.model, "allowed_tables": None}
22
+ settings = {"provider": args.provider, "model": args.model, "allowed_tables": None}
21
23
  if args.tables is not None:
22
24
  settings["allowed_tables"] = [name.strip() for name in args.tables.split(",") if name.strip()]
23
25
  try:
@@ -0,0 +1,171 @@
1
+ """Translate the shared agent history to each provider's HTTP API."""
2
+
3
+ import json
4
+ import os
5
+ from urllib.parse import quote
6
+ from uuid import uuid4
7
+
8
+
9
+ DEFAULT_MODELS = {
10
+ "openrouter": "deepseek/deepseek-v4.1-flash-1017:nitro",
11
+ "openai": "gpt-5.4-mini",
12
+ "gemini": "gemini-3.8-flash",
13
+ "anthropic": "claude-sonnet-5-5",
14
+ }
15
+ KEY_VARIABLES = {
16
+ "openrouter": "OPENROUTER_API_KEY", "openai": "OPENAI_API_KEY",
17
+ "gemini": "GEMINI_API_KEY", "anthropic": "ANTHROPIC_API_KEY",
18
+ }
19
+
20
+
21
+ def validate_provider(provider):
22
+ if not isinstance(provider, str) or provider not in DEFAULT_MODELS:
23
+ raise ValueError("Choose a provider: " + ", ".join(DEFAULT_MODELS) + ".")
24
+ return provider
25
+
26
+
27
+ def resolve_model(provider, model=None):
28
+ validate_provider(provider)
29
+ model = model if model is not None else os.environ.get("MULEBOT_MODEL") or DEFAULT_MODELS[provider]
30
+ if not isinstance(model, str) or not model.strip() or len(model) > 200 or any(c.isspace() for c in model.strip()):
31
+ raise ValueError("Enter a model ID of at most 200 characters.")
32
+ return model.strip()
33
+
34
+
35
+ def credentials(provider):
36
+ validate_provider(provider)
37
+ missing = [name for name in ("DATAMULE_API_KEY", KEY_VARIABLES[provider]) if not os.environ.get(name, "").strip()]
38
+ if missing:
39
+ raise ValueError("Set " + " and ".join(missing) + ".")
40
+ return os.environ[KEY_VARIABLES[provider]]
41
+
42
+
43
+ def _native_content(message, provider):
44
+ data = message.get("provider_data") or {}
45
+ return data.get("content") if data.get("provider") == provider else None
46
+
47
+
48
+ def build_request(provider, model, key, messages, tools, tool_choice, reasoning_effort):
49
+ headers = {"Content-Type": "application/json"}
50
+ if provider in ("openrouter", "openai"):
51
+ history = []
52
+ for message in messages:
53
+ entry = {k: v for k, v in message.items() if k in ("role", "content", "tool_calls", "tool_call_id")}
54
+ if provider == "openrouter":
55
+ entry.update({k: v for k, v in message.items() if k in ("reasoning", "reasoning_details")})
56
+ history.append(entry)
57
+ payload = {"model": model, "messages": history}
58
+ if tools:
59
+ payload.update(tools=tools, tool_choice=tool_choice, parallel_tool_calls=False)
60
+ if provider == "openrouter":
61
+ payload.update(max_tokens=8192, reasoning={"effort": reasoning_effort or "medium", "exclude": False})
62
+ url = "https://openrouter.ai/api/v1/chat/completions"
63
+ else:
64
+ payload["max_completion_tokens"] = 8192
65
+ if reasoning_effort:
66
+ payload["reasoning_effort"] = reasoning_effort
67
+ url = "https://api.openai.com/v1/chat/completions"
68
+ headers["Authorization"] = f"Bearer {key}"
69
+ return url, headers, payload
70
+
71
+ system = "\n\n".join(message["content"] for message in messages if message["role"] == "system")
72
+ history, calls = [], {}
73
+ for message in messages:
74
+ role = message["role"]
75
+ if role == "system":
76
+ continue
77
+ for call in message.get("tool_calls", []):
78
+ calls[call["id"]] = call
79
+ native = _native_content(message, provider) if role == "assistant" else None
80
+ if provider == "anthropic":
81
+ target_role = "assistant" if role == "assistant" else "user"
82
+ if role == "tool":
83
+ content = [{"type": "tool_result", "tool_use_id": message["tool_call_id"], "content": message["content"]}]
84
+ elif native is not None:
85
+ content = native
86
+ else:
87
+ content = [{"type": "text", "text": message["content"]}] if message["content"] else []
88
+ content += [{"type": "tool_use", "id": call["id"], "name": call["function"]["name"],
89
+ "input": json.loads(call["function"]["arguments"])} for call in message.get("tool_calls", [])]
90
+ else:
91
+ target_role = "model" if role == "assistant" else "user"
92
+ if role == "tool":
93
+ call = calls[message["tool_call_id"]]
94
+ response = json.loads(message["content"])
95
+ result = {"name": call["function"]["name"], "response": response if isinstance(response, dict) else {"result": response}}
96
+ # Older Gemini versions don't assign function-call IDs.
97
+ if not call["id"].startswith("mulebot_"):
98
+ result["id"] = call["id"]
99
+ content = [{"functionResponse": result}]
100
+ elif native is not None:
101
+ content = native
102
+ else:
103
+ content = [{"text": message["content"]}] if message["content"] else []
104
+ content += [{"functionCall": {"name": call["function"]["name"], "args": json.loads(call["function"]["arguments"])}}
105
+ for call in message.get("tool_calls", [])]
106
+ field = "content" if provider == "anthropic" else "parts"
107
+ if content:
108
+ # Group parallel results into one user message, as required by Claude.
109
+ if history and history[-1]["role"] == target_role:
110
+ history[-1][field].extend(content)
111
+ else:
112
+ history.append({"role": target_role, field: list(content)})
113
+
114
+ if provider == "anthropic":
115
+ headers.update({"x-api-key": key, "anthropic-version": "2023-06-01"})
116
+ payload = {"model": model, "system": system, "messages": history, "max_tokens": 8192}
117
+ if tools:
118
+ payload["tools"] = [{"name": tool["function"]["name"], "description": tool["function"]["description"],
119
+ "input_schema": tool["function"]["parameters"]} for tool in tools]
120
+ payload["tool_choice"] = {"type": tool_choice}
121
+ if tool_choice == "auto":
122
+ payload["tool_choice"]["disable_parallel_tool_use"] = True
123
+ return "https://api.anthropic.com/v1/messages", headers, payload
124
+
125
+ headers["x-goog-api-key"] = key
126
+ payload = {"systemInstruction": {"parts": [{"text": system}]}, "contents": history,
127
+ "generationConfig": {"maxOutputTokens": 8192}}
128
+ if tools:
129
+ payload["tools"] = [{"functionDeclarations": [{"name": tool["function"]["name"],
130
+ "description": tool["function"]["description"],
131
+ "parametersJsonSchema": tool["function"]["parameters"]} for tool in tools]}]
132
+ payload["toolConfig"] = {"functionCallingConfig": {"mode": tool_choice.upper()}}
133
+ return f"https://generativelanguage.googleapis.com/v1beta/models/{quote(model, safe='')}:generateContent", headers, payload
134
+
135
+
136
+ def normalize_response(provider, response):
137
+ if provider in ("openrouter", "openai") or response.get("error"):
138
+ return response
139
+ calls, text, reasoning = [], [], []
140
+ if provider == "anthropic":
141
+ content = response.get("content") or []
142
+ for block in content:
143
+ if block["type"] == "text":
144
+ text.append(block["text"])
145
+ elif block["type"] == "thinking":
146
+ reasoning.append(block["thinking"])
147
+ elif block["type"] == "tool_use":
148
+ calls.append({"id": block["id"], "type": "function", "function": {
149
+ "name": block["name"], "arguments": json.dumps(block["input"])}})
150
+ usage = dict(response.get("usage") or {})
151
+ usage.update(prompt_tokens=usage.get("input_tokens", 0) + usage.get("cache_creation_input_tokens", 0) + usage.get("cache_read_input_tokens", 0),
152
+ completion_tokens=usage.get("output_tokens", 0))
153
+ model, response_id = response.get("model"), response.get("id")
154
+ else:
155
+ candidate = (response.get("candidates") or [{}])[0]
156
+ content = (candidate.get("content") or {}).get("parts") or []
157
+ for part in content:
158
+ if "text" in part:
159
+ (reasoning if part.get("thought") else text).append(part["text"])
160
+ if "functionCall" in part:
161
+ call = part["functionCall"]
162
+ calls.append({"id": call.get("id") or "mulebot_" + uuid4().hex, "type": "function", "function": {
163
+ "name": call["name"], "arguments": json.dumps(call.get("args") or {})}})
164
+ usage = dict(response.get("usageMetadata") or {})
165
+ usage.update(prompt_tokens=usage.get("promptTokenCount", 0),
166
+ completion_tokens=usage.get("candidatesTokenCount", 0) + usage.get("thoughtsTokenCount", 0))
167
+ model, response_id = response.get("modelVersion"), response.get("responseId")
168
+ usage["total_tokens"] = usage["prompt_tokens"] + usage["completion_tokens"]
169
+ message = {"content": "\n\n".join(text), "tool_calls": calls, "reasoning": "\n\n".join(reasoning),
170
+ "provider_data": {"provider": provider, "content": content}}
171
+ return {"id": response_id, "model": model, "usage": usage, "choices": [{"message": message}]}
@@ -1,10 +1,10 @@
1
1
  import json
2
- import os
3
2
  import sys
4
3
  from contextlib import closing
5
4
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
6
5
 
7
- from .agent import Agent, DEFAULT_MODEL
6
+ from .agent import Agent
7
+ from .providers import credentials, resolve_model
8
8
  from .assets import resource
9
9
 
10
10
 
@@ -43,7 +43,7 @@ class Handler(BaseHTTPRequestHandler):
43
43
  "/static/style.css": ("static/style.css", "text/css; charset=utf-8"),
44
44
  }
45
45
  if self.path == "/api/config":
46
- self.send_json(200, {"model": self.server.default_model})
46
+ self.send_json(200, {"provider": self.server.provider, "model": self.server.default_model})
47
47
  elif self.path in assets:
48
48
  name, content_type = assets[self.path]
49
49
  body = resource("ui", *name.split("/")).read_text(encoding="utf-8")
@@ -75,7 +75,7 @@ class Handler(BaseHTTPRequestHandler):
75
75
  raise ValueError("Enter a message of at most 16,000 characters.")
76
76
  model = body.get("model", self.server.default_model)
77
77
  if not isinstance(model, str) or not model.strip() or len(model) > 200 or any(c.isspace() for c in model.strip()):
78
- raise ValueError("Enter an OpenRouter model ID of at most 200 characters.")
78
+ raise ValueError("Enter a model ID of at most 200 characters.")
79
79
  bot = self.server.agent_factory(model=model.strip())
80
80
  bot.load_state(body.get("state"))
81
81
  except (ValueError, UnicodeError) as exc:
@@ -94,15 +94,18 @@ class Handler(BaseHTTPRequestHandler):
94
94
  pass
95
95
 
96
96
 
97
- def create_server(port=8000, model=None, allowed_tables=None):
97
+ def create_server(provider, *, port=8000, model=None, allowed_tables=None):
98
+ default_model = resolve_model(provider, model)
99
+ credentials(provider)
98
100
  server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
99
- server.default_model = model or os.environ.get("MULEBOT_MODEL", DEFAULT_MODEL)
100
- server.agent_factory = lambda **settings: Agent(allowed_tables=allowed_tables, **settings)
101
+ server.provider = provider
102
+ server.default_model = default_model
103
+ server.agent_factory = lambda **settings: Agent(provider, allowed_tables=allowed_tables, **settings)
101
104
  return server
102
105
 
103
106
 
104
- def serve(port=8000, model=None, allowed_tables=None):
105
- with create_server(port, model, allowed_tables) as server:
107
+ def serve(provider, *, port=8000, model=None, allowed_tables=None):
108
+ with create_server(provider, port=port, model=model, allowed_tables=allowed_tables) as server:
106
109
  print(f"Mulebot: http://localhost:{server.server_port}", file=sys.stderr, flush=True)
107
110
  try:
108
111
  server.serve_forever()
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import math
3
+ import os
3
4
  import re
4
5
  import sqlite3
5
6
  import urllib.error
@@ -115,8 +116,10 @@ TOOL_DEFINITIONS = [
115
116
 
116
117
 
117
118
  class Tools:
118
- def __init__(self, api_key, allowed_tables=None):
119
- self.api_key = api_key
119
+ def __init__(self, *, allowed_tables=None):
120
+ self.api_key = os.environ.get("DATAMULE_API_KEY", "")
121
+ if not self.api_key.strip():
122
+ raise ValueError("Set DATAMULE_API_KEY.")
120
123
  self.allowed_tables = None if allowed_tables is None else {t.lower() for t in allowed_tables}
121
124
  self.catalog = None
122
125
  self.results = {}
@@ -25,6 +25,7 @@ const toolCalls = new Map();
25
25
  const runningTools = new Set();
26
26
  let busy = false;
27
27
  let selectedModel = '';
28
+ let selectedProvider = '';
28
29
  let agentState = null;
29
30
  const CHAT_STORAGE = 'mulebot.chat';
30
31
  let currentTools = null;
@@ -56,7 +57,7 @@ function debugHistory() {
56
57
 
57
58
  function saveChat() {
58
59
  try {
59
- sessionStorage.setItem(CHAT_STORAGE, JSON.stringify({model: selectedModel, state: agentState, events: chatHistory}));
60
+ sessionStorage.setItem(CHAT_STORAGE, JSON.stringify({provider: selectedProvider, model: selectedModel, state: agentState, events: chatHistory}));
60
61
  } catch {
61
62
  settingsStatus.textContent = 'Chat is available in this tab but could not be saved for refresh.';
62
63
  }
@@ -522,7 +523,7 @@ function saveModel(choice) {
522
523
  if (!choice || choice === CUSTOM_MODEL) return;
523
524
  if (choice === selectedModel) { showSelectedModel(); setBusy(false); return; }
524
525
  if (choice.length > 200 || /\s/.test(choice)) {
525
- settingsStatus.textContent = 'Enter an OpenRouter model ID of at most 200 characters.';
526
+ settingsStatus.textContent = 'Enter a model ID of at most 200 characters.';
526
527
  return;
527
528
  }
528
529
  selectedModel = choice; showSelectedModel(); clearChat(); settingsStatus.textContent = 'New chat started';
@@ -586,10 +587,13 @@ setBusy(true);
586
587
  fetch('/api/config').then(async response => {
587
588
  if (!response.ok) throw new Error('Could not load chat.');
588
589
  const config = await response.json(); selectedModel = config.model;
590
+ selectedProvider = config.provider;
591
+ if (selectedProvider !== 'openrouter') model.querySelector('optgroup')?.remove();
592
+ settingsStatus.textContent = `Provider: ${selectedProvider}`;
589
593
  let saved = null;
590
594
  try { saved = JSON.parse(sessionStorage.getItem(CHAT_STORAGE) || 'null'); }
591
595
  catch { settingsStatus.textContent = 'Could not restore the saved chat.'; }
592
- if (saved && typeof saved.model === 'string' && Array.isArray(saved.events)) {
596
+ if (saved && saved.provider === selectedProvider && typeof saved.model === 'string' && Array.isArray(saved.events)) {
593
597
  selectedModel = saved.model; agentState = saved.state;
594
598
  for (const event of saved.events) apply(event);
595
599
  finishTools();
@@ -22,7 +22,7 @@
22
22
  </select>
23
23
  </label>
24
24
  <form id="custom-model-form" class="custom-model" hidden>
25
- <label for="custom-model">OpenRouter model ID <input id="custom-model" placeholder="provider/model" maxlength="200" required></label>
25
+ <label for="custom-model">Model ID <input id="custom-model" placeholder="model-id" maxlength="200" required></label>
26
26
  <button id="use-custom-model" type="submit">Use model</button>
27
27
  <button id="cancel-custom-model" type="button">Cancel</button>
28
28
  </form>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mulebot
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A simple chatbot for Datamule's APIs
5
5
  Home-page: https://github.com/john-friedman/mulebot
6
6
  Author: John Friedman
@@ -7,6 +7,7 @@ mulebot/__main__.py
7
7
  mulebot/agent.py
8
8
  mulebot/assets.py
9
9
  mulebot/cli.py
10
+ mulebot/providers.py
10
11
  mulebot/server.py
11
12
  mulebot/tool.py
12
13
  mulebot/tools.py
@@ -31,7 +31,7 @@ class Sdist(sdist):
31
31
  setup(
32
32
  name="mulebot",
33
33
  author="John Friedman",
34
- version="0.2.0",
34
+ version="0.3.0",
35
35
  description="A simple chatbot for Datamule's APIs",
36
36
  url="https://github.com/john-friedman/mulebot",
37
37
  license="MIT",
@@ -1,4 +0,0 @@
1
- from .agent import Agent
2
- from .tool import Tool, tool
3
-
4
- __all__ = ["Agent", "Tool", "tool"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes