keywright-router 0.0.1__py3-none-any.whl

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.
@@ -0,0 +1,9 @@
1
+ """Keywright router: watches a mailbox through Keywright and acts on what arrives.
2
+
3
+ The router is an ordinary Keywright agent. It holds no mail password and no provider key:
4
+ it reads and sends mail through the brokered `email` MCP server, asks the LLM gateway only
5
+ when a rule needs a label or a draft, and every call it makes is checked against the rules
6
+ the human set for its token. Routing itself is plain boolean logic over the message.
7
+ """
8
+
9
+ __version__ = "0.0.1"
@@ -0,0 +1,83 @@
1
+ """keywright-router run|check|dry-run [--config PATH]
2
+
3
+ run poll forever (the container's command)
4
+ check validate the rules file and exit
5
+ access print the standing access the rules need (as Keywright qualifies the calls) and ask
6
+ for what is missing in one request; approve it with Always allow
7
+ dry-run one pass over today's unread mail: route each message and print the calls it would
8
+ make, without making them or recording anything. Reads and labels still go through
9
+ Keywright, so they are audited like any other call.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import asyncio
16
+ import json
17
+ import logging
18
+ import sys
19
+
20
+ from .config import ConfigError, load
21
+
22
+
23
+ def main(argv: list[str] | None = None) -> int:
24
+ ap = argparse.ArgumentParser(prog="keywright-router", description=__doc__,
25
+ formatter_class=argparse.RawDescriptionHelpFormatter)
26
+ ap.add_argument("command", choices=["run", "check", "access", "dry-run"])
27
+ ap.add_argument("--config", default="/config/rules.yaml")
28
+ ap.add_argument("--log-level", default="INFO")
29
+ a = ap.parse_args(argv)
30
+ logging.basicConfig(level=a.log_level.upper(), format="%(asctime)s %(levelname)s %(message)s")
31
+ try:
32
+ cfg = load(a.config)
33
+ except ConfigError as e:
34
+ print(f"config error: {e}", file=sys.stderr)
35
+ return 2
36
+ if a.command == "check":
37
+ print(f"ok: {len(cfg.rules)} rules, {len(cfg.unmatched)} unmatched actions, "
38
+ f"llm {'used (' + str(cfg.model) + ')' if cfg.uses_llm else 'not used'}")
39
+ return 0
40
+
41
+ from .router import Router
42
+ if a.command == "access":
43
+ return asyncio.run(_access(cfg))
44
+ if a.command == "run":
45
+ asyncio.run(Router(cfg).run_forever())
46
+ return 0
47
+ return asyncio.run(_dry_run(cfg))
48
+
49
+
50
+ async def _access(cfg) -> int:
51
+ from . import access
52
+ from .kw import Keywright
53
+
54
+ status, rid, patterns = await access.ensure(Keywright(cfg.kw_url, cfg.kw_token, cfg.session), cfg)
55
+ for p in patterns:
56
+ print(p)
57
+ print(f"{status}{' ' + rid if rid else ''}", file=sys.stderr)
58
+ return 0 if status in ("approved", "pending") else 1
59
+
60
+
61
+ async def _dry_run(cfg) -> int:
62
+ from .kw import Pending
63
+ from .router import Router
64
+
65
+ router = Router(cfg)
66
+ for meta in await router.unread():
67
+ msg = await router.fetch(str(meta["email_id"]))
68
+ if msg is None:
69
+ continue
70
+ try:
71
+ job = await router.plan(msg)
72
+ except Pending as p:
73
+ print(f"{msg.key}: needs approval first ({p.request_id})")
74
+ continue
75
+ seen = " (already handled)" if router.store.has(msg.key) else ""
76
+ print(f"\n{msg.key}{seen} {msg.subject!r} from {msg.sender_address}")
77
+ for s in job.steps:
78
+ print(" ", json.dumps({k: v for k, v in s.items() if k != "reason"})[:400])
79
+ return 0
80
+
81
+
82
+ if __name__ == "__main__":
83
+ raise SystemExit(main())
@@ -0,0 +1,114 @@
1
+ """The standing access the rules need, asked for in one request instead of rule by rule.
2
+
3
+ The paths are built the way Keywright qualifies the calls themselves (the same `classify_mcp`,
4
+ the resource's own argument keys), so an "Always allow" on the request is exactly as wide as the
5
+ rules file and no wider. Two things are left out on purpose:
6
+
7
+ - a send or forward whose resource does not qualify calls by `recipients`: allowing the tool
8
+ would allow any recipient, so those calls keep asking one by one;
9
+ - replies (the recipient is the sender of each message) and templated argument values (they
10
+ differ per message): the paths that would name them are dropped, so the human sees them per call.
11
+
12
+ Model access is never part of it: an LLM approval is a timed lease with a spend ceiling, and a
13
+ standing model rule is made in the UI.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from typing import Any
20
+
21
+ from keywright.classify import classify_mcp
22
+
23
+ from .config import PLACEHOLDER, Config
24
+ from .kw import Keywright
25
+
26
+ log = logging.getLogger("keywright_router")
27
+
28
+ RECIPIENT_KEYS = ("recipients", "cc", "bcc")
29
+
30
+
31
+ def needed(cfg: Config, arg_keys: dict[str, list[str]]) -> list[str]:
32
+ """Every path the rules will call with fixed arguments, qualified by each server's argument keys."""
33
+ acct = {"account_name": cfg.account}
34
+ calls: list[tuple[str, str, dict[str, Any]]] = [
35
+ (cfg.server, "list_emails_metadata", acct),
36
+ (cfg.server, "get_emails_content", acct),
37
+ ]
38
+ actions = [a for r in cfg.rules for a in r.do] + list(cfg.unmatched)
39
+ if actions:
40
+ calls.append((cfg.server, "mark_emails_as_read", acct))
41
+ if cfg.done_folder or any("move" in a for a in actions):
42
+ calls.append((cfg.server, "move_emails", acct))
43
+ email_keys = set(arg_keys.get(cfg.server) or [])
44
+ for action in actions:
45
+ kind, spec = next(iter(action.items()))
46
+ if kind in ("forward", "send"):
47
+ if "recipients" not in email_keys:
48
+ log.info("%s is not qualified by recipients on %s: %s calls will ask each time",
49
+ kind, cfg.server, kind)
50
+ continue
51
+ tool = "forward_email" if kind == "forward" else "send_email"
52
+ calls.append((cfg.server, tool, {**acct, "recipients": list(spec["to"])}))
53
+ elif kind == "mcp":
54
+ server, tool = spec["tool"].split("__", 1)
55
+ raw, fixed = spec.get("args", {}), _literal(spec.get("args", {})) or {}
56
+ for key in arg_keys.get(server) or []:
57
+ if _lookup(raw, key) is not None and _lookup(fixed, key) is None:
58
+ log.warning("%s qualifies %s by %s, which this rule fills from the message: "
59
+ "that call will ask every time", server, tool, key)
60
+ calls.append((server, tool, fixed))
61
+ out: list[str] = []
62
+ for server, tool, args in calls:
63
+ keys = arg_keys.get(server)
64
+ if keys is None:
65
+ log.warning("no MCP resource named %r in Keywright; skipping %s", server, tool)
66
+ continue
67
+ for p in classify_mcp(server, tool, args, keys):
68
+ if p not in out:
69
+ out.append(p)
70
+ return out
71
+
72
+
73
+ def _lookup(args: dict[str, Any], key: str) -> Any:
74
+ cur: Any = args
75
+ for part in key.split("."):
76
+ if not isinstance(cur, dict) or part not in cur:
77
+ return None
78
+ cur = cur[part]
79
+ return cur
80
+
81
+
82
+ def _literal(value: Any) -> Any:
83
+ """Arguments as the human can pre-approve them: a templated value is unknown until a message
84
+ arrives, so it is removed and never qualifies the path."""
85
+ if isinstance(value, str):
86
+ return None if PLACEHOLDER.search(value) else value
87
+ if isinstance(value, dict):
88
+ return {k: v for k, v in ((k, _literal(v)) for k, v in value.items()) if v is not None}
89
+ if isinstance(value, list):
90
+ return [v for v in (_literal(v) for v in value) if v is not None] or None
91
+ return value
92
+
93
+
94
+ async def argument_keys(kw: Keywright) -> dict[str, list[str]]:
95
+ data = await kw.call_access("access_list_resources", {})
96
+ out = {}
97
+ for r in data.get("resources") or []:
98
+ if r.get("kind") == "mcp":
99
+ out[str(r["resource"]).split("/", 1)[1]] = list(r.get("argument_keys") or [])
100
+ return out
101
+
102
+
103
+ async def ensure(kw: Keywright, cfg: Config) -> tuple[str, str | None, list[str]]:
104
+ """Ask once for whatever the rules need that is not yet allowed. Returns (status, request_id, patterns):
105
+ approved when nothing is missing, pending with the request to watch, or denied."""
106
+ patterns = needed(cfg, await argument_keys(kw))
107
+ if not patterns:
108
+ return "approved", None, []
109
+ data = await kw.call_access("access_request", {
110
+ "patterns": patterns, "wait_seconds": 0,
111
+ "reason": f"mail router: standing access for its {len(cfg.rules)} rule(s). Choose Always allow "
112
+ "to make these rules; anything else it does keeps asking per call.",
113
+ })
114
+ return str(data.get("status") or data.get("error") or "unknown"), data.get("request_id"), patterns
@@ -0,0 +1,241 @@
1
+ """The rules file: where the mail is, which model labels it, and what to do when a rule matches.
2
+
3
+ Loaded once at start and validated completely, so a typo fails the container at boot instead
4
+ of on the first message that happens to reach that rule.
5
+
6
+ Recipients and tool names are literals. Message text reaches a call only through `{{field}}`
7
+ placeholders in free-text values (a subject, a body, a tool argument), never as an address or
8
+ a tool: a message cannot choose who hears about it or what runs.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import yaml
21
+
22
+ PREDICATES = {"from", "to", "subject", "subject_matches", "body", "body_matches", "has_attachment", "label"}
23
+ COMBINATORS = {"all", "any", "not"}
24
+ ACTIONS = {"reply", "send", "forward", "mcp", "attention", "move", "mark_read"}
25
+ FIELDS = {"from", "to", "subject", "date", "body", "label", "email_id", "rule"}
26
+ PLACEHOLDER = re.compile(r"\{\{\s*([a-z_]+)\s*\}\}")
27
+ ADDRESS = re.compile(r"^[^@\s<>{}]+@[^@\s<>{}]+\.[^@\s<>{}]+$")
28
+
29
+
30
+ class ConfigError(ValueError):
31
+ pass
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Rule:
36
+ name: str
37
+ when: Any
38
+ do: list[dict[str, Any]]
39
+ stop: bool = False
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Config:
44
+ kw_url: str
45
+ kw_token: str
46
+ session: str
47
+ server: str
48
+ account: str
49
+ mailbox: str
50
+ poll_seconds: int
51
+ done_folder: str | None
52
+ since: str | None
53
+ gateway: str | None
54
+ model: str | None
55
+ labels: tuple[str, ...]
56
+ max_body_chars: int
57
+ rules: tuple[Rule, ...]
58
+ unmatched: tuple[dict[str, Any], ...]
59
+ state_path: Path
60
+ pending_hours: float
61
+ uses_llm: bool = field(default=False)
62
+
63
+
64
+ def load(path: str | os.PathLike[str], env: dict[str, str] | None = None) -> Config:
65
+ env = dict(os.environ if env is None else env)
66
+ try:
67
+ raw = yaml.safe_load(Path(path).read_text()) or {}
68
+ except (OSError, yaml.YAMLError) as e:
69
+ raise ConfigError(f"{path}: {e}") from e
70
+ if not isinstance(raw, dict):
71
+ raise ConfigError(f"{path}: expected a mapping at the top level")
72
+ kw = raw.get("keywright") or {}
73
+ mail = raw.get("email") or {}
74
+ llm = raw.get("llm") or {}
75
+
76
+ token = env.get("KW_TOKEN", "")
77
+ if not token:
78
+ raise ConfigError("KW_TOKEN is not set; the router's Keywright token comes from the environment only")
79
+ labels = tuple(str(x) for x in llm.get("labels") or [])
80
+ if labels and "unclear" not in labels:
81
+ labels = (*labels, "unclear")
82
+
83
+ if mail.get("since"):
84
+ try:
85
+ datetime.fromisoformat(str(mail["since"]))
86
+ except ValueError as e:
87
+ raise ConfigError(f"email.since: {e} (use 2026-09-28 or 2026-09-28T09:00:00+00:00)") from e
88
+ rules = tuple(_rule(i, r, labels) for i, r in enumerate(raw.get("rules") or []))
89
+ names = [r.name for r in rules]
90
+ if len(set(names)) != len(names):
91
+ raise ConfigError("rule names must be unique")
92
+ unmatched = tuple(_action("unmatched", i, a) for i, a in enumerate(raw.get("unmatched") or []))
93
+
94
+ drafts = any(isinstance(spec, dict) and "llm" in spec
95
+ for a in [*(a for r in rules for a in r.do), *unmatched] for spec in a.values())
96
+ uses_llm = drafts or any(_mentions_label(r.when) for r in rules)
97
+ gateway, model = llm.get("gateway"), llm.get("model")
98
+ if uses_llm and not (gateway and model):
99
+ raise ConfigError("a rule uses `label` or an `llm:` draft, so llm.gateway and llm.model are required")
100
+ if any(_mentions_label(r.when) for r in rules) and not labels:
101
+ raise ConfigError("a rule tests `label`, so llm.labels must list the labels the model may choose")
102
+
103
+ return Config(
104
+ kw_url=env.get("KW_URL") or kw.get("url") or "http://keywright:8700",
105
+ kw_token=token,
106
+ session=str(kw.get("session") or "keywright-router"),
107
+ server=str(mail.get("server") or "email"),
108
+ account=_required(mail, "account", "email"),
109
+ mailbox=str(mail.get("mailbox") or "INBOX"),
110
+ poll_seconds=max(15, int(mail.get("poll_seconds") or 60)),
111
+ done_folder=mail.get("done_folder") or None,
112
+ since=str(mail["since"]) if mail.get("since") else None,
113
+ gateway=gateway,
114
+ model=model,
115
+ labels=labels,
116
+ max_body_chars=int(llm.get("max_body_chars") or 6000),
117
+ rules=rules,
118
+ unmatched=unmatched,
119
+ state_path=Path(env.get("ROUTER_STATE") or raw.get("state") or "/data/router.db"),
120
+ pending_hours=float(raw.get("pending_hours") or 6),
121
+ uses_llm=uses_llm,
122
+ )
123
+
124
+
125
+ def _required(section: dict, key: str, where: str) -> str:
126
+ value = section.get(key)
127
+ if not value:
128
+ raise ConfigError(f"{where}.{key} is required")
129
+ return str(value)
130
+
131
+
132
+ def _rule(i: int, raw: Any, labels: tuple[str, ...]) -> Rule:
133
+ if not isinstance(raw, dict) or not raw.get("name"):
134
+ raise ConfigError(f"rules[{i}]: each rule needs a name")
135
+ name = str(raw["name"])
136
+ if "when" not in raw:
137
+ raise ConfigError(f"rule {name}: `when` is required (`when: {{all: []}}` matches every message)")
138
+ _condition(name, raw["when"], labels)
139
+ do = raw.get("do") or []
140
+ if not isinstance(do, list) or not do:
141
+ raise ConfigError(f"rule {name}: `do` must be a non-empty list of actions")
142
+ return Rule(name=name, when=raw["when"], do=[_action(name, j, a) for j, a in enumerate(do)],
143
+ stop=bool(raw.get("stop", False)))
144
+
145
+
146
+ def _condition(rule: str, cond: Any, labels: tuple[str, ...]) -> None:
147
+ if not isinstance(cond, dict) or not cond:
148
+ raise ConfigError(f"rule {rule}: a condition is a non-empty mapping, got {cond!r}")
149
+ for key, value in cond.items():
150
+ if key in ("all", "any"):
151
+ if not isinstance(value, list):
152
+ raise ConfigError(f"rule {rule}: `{key}` takes a list of conditions")
153
+ for c in value:
154
+ _condition(rule, c, labels)
155
+ elif key == "not":
156
+ _condition(rule, value, labels)
157
+ elif key in ("subject_matches", "body_matches"):
158
+ try:
159
+ re.compile(str(value))
160
+ except re.error as e:
161
+ raise ConfigError(f"rule {rule}: bad regex in {key}: {e}") from e
162
+ elif key == "has_attachment":
163
+ if not isinstance(value, bool):
164
+ raise ConfigError(f"rule {rule}: has_attachment is true or false")
165
+ elif key == "label":
166
+ wanted = value if isinstance(value, list) else [value]
167
+ unknown = [w for w in wanted if w not in labels]
168
+ if unknown:
169
+ raise ConfigError(f"rule {rule}: label {unknown} is not in llm.labels {list(labels)}")
170
+ elif key not in PREDICATES:
171
+ raise ConfigError(f"rule {rule}: unknown condition `{key}` "
172
+ f"(known: {sorted(PREDICATES | COMBINATORS)})")
173
+
174
+
175
+ def _mentions_label(cond: Any) -> bool:
176
+ if not isinstance(cond, dict):
177
+ return False
178
+ for key, value in cond.items():
179
+ if key == "label":
180
+ return True
181
+ if key in ("all", "any") and any(_mentions_label(c) for c in value):
182
+ return True
183
+ if key == "not" and _mentions_label(value):
184
+ return True
185
+ return False
186
+
187
+
188
+ def _action(rule: str, j: int, raw: Any) -> dict[str, Any]:
189
+ where = f"rule {rule} action {j}"
190
+ if not isinstance(raw, dict) or len(raw) != 1:
191
+ raise ConfigError(f"{where}: an action is a one-key mapping such as `reply: {{text: ...}}`")
192
+ kind, spec = next(iter(raw.items()))
193
+ if kind not in ACTIONS:
194
+ raise ConfigError(f"{where}: unknown action `{kind}` (known: {sorted(ACTIONS)})")
195
+ if kind in ("reply", "send", "forward"):
196
+ if not isinstance(spec, dict):
197
+ raise ConfigError(f"{where}: `{kind}` takes a mapping")
198
+ if kind in ("send", "forward"):
199
+ to = spec.get("to")
200
+ to = [to] if isinstance(to, str) else to
201
+ if not to or not all(isinstance(a, str) and ADDRESS.match(a) for a in to):
202
+ raise ConfigError(f"{where}: `{kind}.to` must be one or more literal addresses; "
203
+ "a recipient never comes from the message")
204
+ spec = {**spec, "to": list(to)}
205
+ if kind == "send" and not spec.get("subject"):
206
+ raise ConfigError(f"{where}: `send.subject` is required")
207
+ if kind in ("reply", "send") and ("text" in spec) == ("llm" in spec):
208
+ raise ConfigError(f"{where}: `{kind}` needs exactly one of `text` (a template) or `llm` (an instruction)")
209
+ _templates(where, spec, skip={"to"})
210
+ elif kind == "mcp":
211
+ if not isinstance(spec, dict) or not isinstance(spec.get("tool"), str) or "__" not in spec["tool"]:
212
+ raise ConfigError(f"{where}: `mcp.tool` is the brokered name, <server>__<tool>")
213
+ if PLACEHOLDER.search(spec["tool"]):
214
+ raise ConfigError(f"{where}: `mcp.tool` must be literal")
215
+ if not isinstance(spec.get("args", {}), dict):
216
+ raise ConfigError(f"{where}: `mcp.args` is a mapping")
217
+ _templates(where, spec.get("args", {}))
218
+ elif kind == "attention":
219
+ if not isinstance(spec, str) or not spec:
220
+ raise ConfigError(f"{where}: `attention` is the message template")
221
+ _templates(where, {"attention": spec})
222
+ elif kind == "move":
223
+ if not isinstance(spec, str) or not spec or PLACEHOLDER.search(spec):
224
+ raise ConfigError(f"{where}: `move` takes a literal folder name")
225
+ elif kind == "mark_read":
226
+ spec = True
227
+ return {kind: spec}
228
+
229
+
230
+ def _templates(where: str, spec: Any, skip: set[str] = frozenset()) -> None:
231
+ if isinstance(spec, dict):
232
+ for k, v in spec.items():
233
+ if k not in skip:
234
+ _templates(where, v)
235
+ elif isinstance(spec, list):
236
+ for v in spec:
237
+ _templates(where, v)
238
+ elif isinstance(spec, str):
239
+ for name in PLACEHOLDER.findall(spec):
240
+ if name not in FIELDS:
241
+ raise ConfigError(f"{where}: unknown placeholder {{{{{name}}}}} (known: {sorted(FIELDS)})")
keywright_router/kw.py ADDED
@@ -0,0 +1,99 @@
1
+ """The router's only way out: brokered MCP calls and the LLM gateway, both through Keywright.
2
+
3
+ Calls never wait on the human (`_wait_seconds: 0`, `X-Keywright-Wait: 0`). A call that needs
4
+ approval comes back as Pending with the request id; the router parks the job and asks
5
+ `access_status` about that id on later polls instead of repeating the call, so one message
6
+ puts one prompt on the phone however long it takes to answer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ import httpx
16
+ from keywright import transport
17
+ from keywright.config import Profile
18
+
19
+
20
+ class KwError(Exception):
21
+ """The call was refused, denied, locked or failed; retrying the same call will not help."""
22
+
23
+ def __init__(self, kind: str, detail: dict[str, Any] | None = None):
24
+ self.kind = kind
25
+ self.detail = detail or {}
26
+ super().__init__(f"{kind}: {json.dumps(self.detail)[:300]}")
27
+
28
+
29
+ class Pending(Exception):
30
+ """Waiting on the human; `request_id` is the one to poll."""
31
+
32
+ def __init__(self, request_id: str, patterns: list[str] | None = None):
33
+ self.request_id = request_id
34
+ self.patterns = patterns or []
35
+ super().__init__(f"approval pending: {request_id}")
36
+
37
+
38
+ @dataclass
39
+ class Keywright:
40
+ url: str
41
+ token: str
42
+ session: str
43
+ timeout: float = 120.0
44
+
45
+ @property
46
+ def profile(self) -> Profile:
47
+ return Profile(name="router", url=self.url, token=self.token)
48
+
49
+ async def call(self, tool: str, args: dict[str, Any], reason: str) -> dict[str, Any]:
50
+ """One brokered tool call. Returns the structured payload; raises Pending or KwError."""
51
+ result = await transport.acall(self.profile, self.session, tool,
52
+ {**args, "_reason": reason, "_wait_seconds": 0},
53
+ read_timeout=self.timeout)
54
+ data = transport.payload(result)
55
+ if getattr(result, "is_error", False):
56
+ kind = data.get("error") if isinstance(data.get("error"), str) else "upstream_error"
57
+ if kind == "approval_required" and data.get("request_id"):
58
+ raise Pending(data["request_id"], data.get("patterns"))
59
+ raise KwError(kind, data)
60
+ return data
61
+
62
+ async def call_access(self, tool: str, args: dict[str, Any]) -> dict[str, Any]:
63
+ """One of Keywright's own tools (access_list_resources, access_request): the payload as it
64
+ comes, errors included, since their answers are states rather than failures."""
65
+ result = await transport.acall(self.profile, self.session, tool, args, read_timeout=30.0)
66
+ return transport.payload(result)
67
+
68
+ async def status(self, request_id: str) -> str:
69
+ """pending | approved | denied | expired | not_found."""
70
+ result = await transport.acall(self.profile, self.session, "access_status", {"request_id": request_id},
71
+ read_timeout=30.0)
72
+ data = transport.payload(result)
73
+ return str(data.get("status") or data.get("error") or "not_found")
74
+
75
+ async def attention(self, message: str, context: str) -> None:
76
+ await transport.acall(self.profile, self.session, "request_attention",
77
+ {"message": message[:500], "context": context[:200]}, read_timeout=30.0)
78
+
79
+ async def chat(self, gateway: str, model: str, messages: list[dict[str, str]], reason: str,
80
+ max_tokens: int = 800) -> str:
81
+ headers = {"Authorization": f"Bearer {self.token}", "X-Keywright-Session": self.session,
82
+ "X-Keywright-Wait": "0", "X-Keywright-Reason": reason[:200]}
83
+ body = {"model": model, "messages": messages, "temperature": 0, "max_tokens": max_tokens}
84
+ async with httpx.AsyncClient(timeout=self.timeout) as http:
85
+ r = await http.post(f"{self.url.rstrip('/')}/llm/{gateway}/v1/chat/completions",
86
+ json=body, headers=headers)
87
+ try:
88
+ data = r.json()
89
+ except ValueError:
90
+ data = {}
91
+ if r.status_code != 200:
92
+ err = data.get("error") if isinstance(data.get("error"), dict) else {}
93
+ if err.get("code") == "approval_required" and err.get("request_id"):
94
+ raise Pending(err["request_id"], [f"llm/{gateway}/model/{model}"])
95
+ raise KwError(str(err.get("code") or f"http_{r.status_code}"), {"message": err.get("message")})
96
+ try:
97
+ return str(data["choices"][0]["message"]["content"] or "")
98
+ except (KeyError, IndexError, TypeError) as e:
99
+ raise KwError("bad_llm_response", {"body": str(data)[:300]}) from e