netconverter-cli 0.4.0__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.
- netconverter_cli/__init__.py +3 -0
- netconverter_cli/activity.py +83 -0
- netconverter_cli/api.py +257 -0
- netconverter_cli/backend.py +107 -0
- netconverter_cli/branding.py +79 -0
- netconverter_cli/catalog.py +162 -0
- netconverter_cli/clerk.py +602 -0
- netconverter_cli/cli.py +521 -0
- netconverter_cli/commands.py +63 -0
- netconverter_cli/connect.py +465 -0
- netconverter_cli/controller.py +331 -0
- netconverter_cli/credentials.py +94 -0
- netconverter_cli/guided.py +365 -0
- netconverter_cli/mcp_server.py +640 -0
- netconverter_cli/palette.py +16 -0
- netconverter_cli/presentation.py +254 -0
- netconverter_cli/privacy.py +105 -0
- netconverter_cli/providers.py +246 -0
- netconverter_cli/remote_mcp.py +175 -0
- netconverter_cli/setup.py +179 -0
- netconverter_cli/tui.py +607 -0
- netconverter_cli/update.py +344 -0
- netconverter_cli/workspace.py +148 -0
- netconverter_cli-0.4.0.dist-info/METADATA +167 -0
- netconverter_cli-0.4.0.dist-info/RECORD +28 -0
- netconverter_cli-0.4.0.dist-info/WHEEL +4 -0
- netconverter_cli-0.4.0.dist-info/entry_points.txt +2 -0
- netconverter_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Human-readable local results; all server strings are literal, never markup."""
|
|
2
|
+
import re
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from rich import box
|
|
7
|
+
from rich.console import Group
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from .catalog import FIELD_LABELS, label
|
|
13
|
+
from .palette import ACCENT, BG, DIM, ERROR, FG, MUTED, SUCCESS, WARNING
|
|
14
|
+
|
|
15
|
+
BLUE = ACCENT # 0.1.x name, still imported by callers
|
|
16
|
+
|
|
17
|
+
# A card is a left accent bar; the bottom edge is blank, so cards space themselves.
|
|
18
|
+
_BAR = box.Box("▍ \n▍ \n▍ \n▍ \n▍ \n▍ \n▍ \n \n")
|
|
19
|
+
_KINDS = {
|
|
20
|
+
"info": (ACCENT, ""),
|
|
21
|
+
"success": (SUCCESS, "✓ "),
|
|
22
|
+
"warning": (WARNING, "! "),
|
|
23
|
+
"error": (ERROR, "✕ "),
|
|
24
|
+
"muted": (DIM, ""),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def literal(value, style=''):
|
|
29
|
+
value = str(value)
|
|
30
|
+
value = re.sub(r'[\x00-\x08\x0b-\x1f\x7f]', '', value)
|
|
31
|
+
return Text(value, style=style)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def card(title, body, kind='info'):
|
|
35
|
+
color, glyph = _KINDS.get(kind, _KINDS['info'])
|
|
36
|
+
heading_text = literal(glyph + str(title), f'bold {color if kind != "info" else FG}')
|
|
37
|
+
return Panel(body, box=_BAR, title=heading_text, title_align='left', border_style=color,
|
|
38
|
+
padding=(0, 1), expand=True)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def heading(title, body):
|
|
42
|
+
return card(title, body)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def verdict_style(verdict):
|
|
46
|
+
value = str(verdict or '').lower()
|
|
47
|
+
if value in {'passed', 'completed'}:
|
|
48
|
+
return SUCCESS
|
|
49
|
+
if value in {'failed', 'blocked', 'cancelled'}:
|
|
50
|
+
return ERROR
|
|
51
|
+
if value in {'passed_with_warnings', 'needs_review'}:
|
|
52
|
+
return WARNING
|
|
53
|
+
return MUTED
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def badge(verdict):
|
|
57
|
+
value = str(verdict or 'unknown').replace('_', ' ').upper()
|
|
58
|
+
color = verdict_style(verdict)
|
|
59
|
+
if color == MUTED:
|
|
60
|
+
return literal(value.lower(), MUTED)
|
|
61
|
+
# A styled span, not a base style, so table padding does not stretch the pill.
|
|
62
|
+
return Text.assemble((f' {value} ', f'bold {BG} on {color}'))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def commands_table(rows):
|
|
66
|
+
table = Table.grid(padding=(0, 2))
|
|
67
|
+
table.add_column(style=ACCENT, no_wrap=True, min_width=12)
|
|
68
|
+
table.add_column(style=FG)
|
|
69
|
+
for command, description in rows:
|
|
70
|
+
table.add_row(command, description)
|
|
71
|
+
return table
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
HELP_SECTIONS = [
|
|
75
|
+
('Work', [
|
|
76
|
+
('/convert', 'Migrate a configuration to another vendor'),
|
|
77
|
+
('/validate', 'Check a configuration: readiness, findings, parse gaps'),
|
|
78
|
+
('/optimize', 'Cleanup and optimization findings'),
|
|
79
|
+
('/open', 'Choose a configuration without submitting'),
|
|
80
|
+
]),
|
|
81
|
+
('Results', [
|
|
82
|
+
('/jobs', 'Your server jobs'),
|
|
83
|
+
('/resume JOB_ID', 'Follow a job and fetch its files'),
|
|
84
|
+
('/status /download', 'Refresh the active job · save its files'),
|
|
85
|
+
('/target', 'Select the generated configuration, then /validate it'),
|
|
86
|
+
]),
|
|
87
|
+
('Questions', [
|
|
88
|
+
('/nat /policy /routes', 'Summaries from the active job'),
|
|
89
|
+
('/interfaces /unused_objects', 'More summaries'),
|
|
90
|
+
('/flow /dependencies', 'Structured traffic and object questions'),
|
|
91
|
+
]),
|
|
92
|
+
('Your AI', [
|
|
93
|
+
('/activity', 'What your AI assistant did through the bridge'),
|
|
94
|
+
('/connect', 'Wire Claude Code or Desktop, Cursor, VS Code or Codex here'),
|
|
95
|
+
('/model', 'Connect a model for plain-language questions here'),
|
|
96
|
+
]),
|
|
97
|
+
('Session', [
|
|
98
|
+
('/allowance', 'Refresh server capabilities and limits'),
|
|
99
|
+
('/clear /home', 'Clear the conversation · show the start card'),
|
|
100
|
+
]),
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def help_view():
|
|
105
|
+
table = Table.grid(padding=(0, 2))
|
|
106
|
+
table.add_column(style=ACCENT, no_wrap=True)
|
|
107
|
+
table.add_column(style=FG)
|
|
108
|
+
for index, (section, rows) in enumerate(HELP_SECTIONS):
|
|
109
|
+
if index:
|
|
110
|
+
table.add_row('', '')
|
|
111
|
+
table.add_row(literal(section.upper(), f'bold {MUTED}'), '')
|
|
112
|
+
for command, description in rows:
|
|
113
|
+
table.add_row(command, description)
|
|
114
|
+
hint = literal('↑ ↓ choose · 1–9 jump · Enter continue · Esc cancel · Ctrl+Q quit', MUTED)
|
|
115
|
+
return card('Commands', Group(table, Text(''), hint))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _value(value):
|
|
119
|
+
return literal(label(value))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def details_view(value, depth=0):
|
|
123
|
+
if depth >= 4:
|
|
124
|
+
return literal('Further detail is available in the saved report.', MUTED)
|
|
125
|
+
if isinstance(value, dict):
|
|
126
|
+
table = Table.grid(padding=(0, 2), expand=True)
|
|
127
|
+
table.add_column(style=MUTED, no_wrap=True, min_width=10, max_width=26)
|
|
128
|
+
table.add_column(ratio=1)
|
|
129
|
+
for key, child in list(value.items())[:40]:
|
|
130
|
+
name = FIELD_LABELS.get(key) or str(key).replace('_', ' ').capitalize()
|
|
131
|
+
table.add_row(literal(name), details_view(child, depth + 1))
|
|
132
|
+
if len(value) > 40:
|
|
133
|
+
table.add_row('', literal('More fields in the saved report.', MUTED))
|
|
134
|
+
return table
|
|
135
|
+
if isinstance(value, list):
|
|
136
|
+
if not value:
|
|
137
|
+
return literal('None', MUTED)
|
|
138
|
+
rows = []
|
|
139
|
+
for item in value[:12]:
|
|
140
|
+
rows.append(details_view(item, depth + 1))
|
|
141
|
+
if len(value) > 12:
|
|
142
|
+
rows.append(literal(f'{len(value)-12} more items in the saved report.', MUTED))
|
|
143
|
+
return Group(*rows)
|
|
144
|
+
if value is None:
|
|
145
|
+
return literal('—', MUTED)
|
|
146
|
+
return _value(value)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def ago(timestamp):
|
|
150
|
+
if not isinstance(timestamp, (int, float)):
|
|
151
|
+
return '—'
|
|
152
|
+
seconds = max(0, time.time() - timestamp)
|
|
153
|
+
for unit, size in (('d', 86400), ('h', 3600), ('m', 60)):
|
|
154
|
+
if seconds >= size:
|
|
155
|
+
return f'{int(seconds // size)}{unit} ago'
|
|
156
|
+
return 'just now'
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _when(job, local):
|
|
160
|
+
created = job.get('created_at')
|
|
161
|
+
if isinstance(created, str) and created:
|
|
162
|
+
from datetime import datetime
|
|
163
|
+
try:
|
|
164
|
+
return ago(datetime.fromisoformat(created.replace('Z', '+00:00')).timestamp())
|
|
165
|
+
except ValueError:
|
|
166
|
+
return created[:16]
|
|
167
|
+
return ago(local.get('submitted_at'))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def jobs_view(jobs, state=None, compact=False, catalog=None):
|
|
171
|
+
state = state or {}
|
|
172
|
+
name = catalog.name if catalog else (lambda code: code or '—')
|
|
173
|
+
if not jobs:
|
|
174
|
+
return literal('No jobs yet. Start with /convert or /validate.', MUTED)
|
|
175
|
+
table = Table(box=None, padding=(0, 2), expand=True, header_style=f'bold {MUTED}')
|
|
176
|
+
table.add_column('Job', style=ACCENT, no_wrap=True)
|
|
177
|
+
table.add_column('Type', no_wrap=True)
|
|
178
|
+
if not compact:
|
|
179
|
+
table.add_column('Source → target', ratio=1, overflow='ellipsis', no_wrap=True)
|
|
180
|
+
table.add_column('Verdict', no_wrap=True)
|
|
181
|
+
if not compact:
|
|
182
|
+
table.add_column('When', style=MUTED, no_wrap=True, justify='right')
|
|
183
|
+
for job in jobs:
|
|
184
|
+
if not isinstance(job, dict):
|
|
185
|
+
continue
|
|
186
|
+
identity = job.get('job_id') or job.get('id') or '—'
|
|
187
|
+
local = state.get('jobs', {}).get(identity, {})
|
|
188
|
+
kind = job.get('job_type') or job.get('operation') or local.get('operation') or '—'
|
|
189
|
+
row = [literal(identity), literal(label(kind))]
|
|
190
|
+
if not compact:
|
|
191
|
+
options = local.get('options', {})
|
|
192
|
+
source = job.get('source_vendor') or state.get('configurations', {}).get(
|
|
193
|
+
job.get('configuration_id') or local.get('configuration_id'), {}).get('vendor')
|
|
194
|
+
target = job.get('target_vendor') or options.get('target_vendor')
|
|
195
|
+
route = name(source) + (f' → {name(target)}' if target and target != 'n/a' else '')
|
|
196
|
+
row.append(literal(route))
|
|
197
|
+
verdict = job.get('translation_status') or job.get('status', '—')
|
|
198
|
+
row.append(badge(verdict))
|
|
199
|
+
if not compact:
|
|
200
|
+
row.append(literal(_when(job, local)))
|
|
201
|
+
table.add_row(*row)
|
|
202
|
+
return Group(table, Text(''), literal('/resume JOB_ID follows a job and fetches its files.', MUTED))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def artifacts_view(paths):
|
|
206
|
+
table = Table(box=None, padding=(0, 2), expand=True, header_style=f'bold {MUTED}')
|
|
207
|
+
table.add_column('File', style=ACCENT, ratio=4, overflow='fold')
|
|
208
|
+
table.add_column('Size', justify='right', no_wrap=True)
|
|
209
|
+
table.add_column('Type', no_wrap=True)
|
|
210
|
+
kinds = {'.xlsx': 'Evidence workbook', '.yaml': 'YAML', '.yml': 'YAML', '.txt': 'Text',
|
|
211
|
+
'.json': 'JSON', '.xml': 'XML', '.set': 'SET configuration'}
|
|
212
|
+
parents = []
|
|
213
|
+
for value in paths:
|
|
214
|
+
path = Path(value)
|
|
215
|
+
try:
|
|
216
|
+
size = f'{path.stat().st_size:,} B'
|
|
217
|
+
except OSError:
|
|
218
|
+
size = 'unavailable'
|
|
219
|
+
table.add_row(literal(path.name), literal(size), literal(kinds.get(path.suffix, 'Artifact')))
|
|
220
|
+
if str(path.parent) not in parents:
|
|
221
|
+
parents.append(str(path.parent))
|
|
222
|
+
return Group(table, Text(''), *(literal('Saved in ' + _home(p), MUTED) for p in parents))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _home(path):
|
|
226
|
+
home = str(Path.home())
|
|
227
|
+
return '~' + path[len(home):] if path.startswith(home + '/') else path
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def activity_line(entry):
|
|
231
|
+
"""One bridge call as a single line: time · client · tool · what."""
|
|
232
|
+
when = time.strftime('%H:%M', time.localtime(entry.get('ts', 0)))
|
|
233
|
+
text = literal(when + ' ', MUTED)
|
|
234
|
+
text.append(literal((entry.get('client') or 'AI') + ' ', ACCENT))
|
|
235
|
+
tool = entry.get('tool', '?')
|
|
236
|
+
text.append(literal(tool.replace('_', ' ') + ' ', FG))
|
|
237
|
+
if tool == 'submit_file':
|
|
238
|
+
detail = f"{entry.get('path', '?')} · {entry.get('bytes', 0):,} B · sha {str(entry.get('sha256', ''))[:8]}…"
|
|
239
|
+
if entry.get('job_id'):
|
|
240
|
+
detail += f" → {entry['job_id']}"
|
|
241
|
+
elif tool == 'download':
|
|
242
|
+
detail = f"{entry.get('files', 0)} file(s) → {entry.get('path', '.')}"
|
|
243
|
+
else:
|
|
244
|
+
detail = f"{entry.get('job_id', '')} {str(entry.get('status', '')).replace('_', ' ')}".strip()
|
|
245
|
+
text.append(literal(detail, MUTED))
|
|
246
|
+
return text
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def activity_view(entries):
|
|
250
|
+
if not entries:
|
|
251
|
+
return literal('No bridge activity in this folder yet. Connect your AI with /connect, '
|
|
252
|
+
'then ask it to validate a file here.', MUTED)
|
|
253
|
+
return Group(*(activity_line(e) for e in entries),
|
|
254
|
+
Text(''), literal('Only paths, hashes, sizes and job IDs are recorded — never configuration text.', MUTED))
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Explicit model projection; detailed renderer data is never chat history."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
OPERATIONS = (
|
|
8
|
+
"analyze",
|
|
9
|
+
"optimize",
|
|
10
|
+
"convert",
|
|
11
|
+
"unused_objects",
|
|
12
|
+
"routes",
|
|
13
|
+
"interfaces",
|
|
14
|
+
"policy",
|
|
15
|
+
"nat",
|
|
16
|
+
"dependencies",
|
|
17
|
+
"flow",
|
|
18
|
+
"status",
|
|
19
|
+
"artifacts",
|
|
20
|
+
)
|
|
21
|
+
COUNT_FIELDS = frozenset(
|
|
22
|
+
{
|
|
23
|
+
"total_objects",
|
|
24
|
+
"total_unused",
|
|
25
|
+
"unused_network_count",
|
|
26
|
+
"unused_network_group_count",
|
|
27
|
+
"unused_service_count",
|
|
28
|
+
"unused_service_group_count",
|
|
29
|
+
"rules",
|
|
30
|
+
"routes",
|
|
31
|
+
"findings",
|
|
32
|
+
"total",
|
|
33
|
+
"shown",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
STATUSES = frozenset(
|
|
37
|
+
{
|
|
38
|
+
"queued",
|
|
39
|
+
"processing",
|
|
40
|
+
"running",
|
|
41
|
+
"completed",
|
|
42
|
+
"failed",
|
|
43
|
+
"cancelled",
|
|
44
|
+
"passed",
|
|
45
|
+
"passed_with_warnings",
|
|
46
|
+
"needs_review",
|
|
47
|
+
"blocked",
|
|
48
|
+
"unavailable",
|
|
49
|
+
"needs_input",
|
|
50
|
+
"allowed",
|
|
51
|
+
"denied",
|
|
52
|
+
"unknown",
|
|
53
|
+
"partial",
|
|
54
|
+
"full",
|
|
55
|
+
"empty",
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
_IP = re.compile(
|
|
59
|
+
r"(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?:/\d{1,2})?|(?<![\w:])(?:[0-9a-fA-F]{0,4}:){2,}[0-9a-fA-F:.]+(?:/\d{1,3})?"
|
|
60
|
+
)
|
|
61
|
+
_CONFIG = re.compile(
|
|
62
|
+
r"(?im)^\s*(?:object(?:-group)?\s|access-list\s|interface\s|hostname\s|set\s+(?:rulebase|network|address|deviceconfig|shared)|<\??(?:xml|config)\b)"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def model_summary(result: dict) -> dict:
|
|
67
|
+
"""Never accept a server-supplied prose/summary blob as model context."""
|
|
68
|
+
out: dict = {}
|
|
69
|
+
for key in ("status", "translation_status", "verdict", "data_quality"):
|
|
70
|
+
value = str(result.get(key, "")).lower()
|
|
71
|
+
if value in STATUSES:
|
|
72
|
+
out[key] = value
|
|
73
|
+
for key in ("job_id", "configuration_id", "query_id"):
|
|
74
|
+
value = result.get(key)
|
|
75
|
+
if isinstance(value, str) and re.fullmatch(
|
|
76
|
+
r"(?:tra|psc|opt|cfg|qry)_[a-f0-9]{8,40}", value
|
|
77
|
+
):
|
|
78
|
+
out[key] = value
|
|
79
|
+
counts = result.get("counts", {})
|
|
80
|
+
if isinstance(counts, dict):
|
|
81
|
+
out["counts"] = {
|
|
82
|
+
key: value
|
|
83
|
+
for key, value in counts.items()
|
|
84
|
+
if key in COUNT_FIELDS and type(value) is int and 0 <= value <= 10**9
|
|
85
|
+
}
|
|
86
|
+
if result.get("requires_input") is True:
|
|
87
|
+
out["status"] = "needs_input"
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def safe_prompt(text: str, identifiers: list[str]) -> tuple[str, dict[str, str]]:
|
|
92
|
+
if len(text) > 4000 or _CONFIG.search(text) or "```" in text:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
"Select a config with /open; configuration text is not sent to the conversational model"
|
|
95
|
+
)
|
|
96
|
+
bindings: dict[str, str] = {}
|
|
97
|
+
values = sorted(
|
|
98
|
+
set([v for v in identifiers if v] + _IP.findall(text)), key=len, reverse=True
|
|
99
|
+
)
|
|
100
|
+
for value in values:
|
|
101
|
+
if value in text:
|
|
102
|
+
alias = f"NC_REF_{len(bindings) + 1}"
|
|
103
|
+
text = text.replace(value, alias)
|
|
104
|
+
bindings[alias] = value
|
|
105
|
+
return text, bindings
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Stateless provider protocols. Only a closed vocabulary and projected counts leave."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
import httpx
|
|
8
|
+
from .privacy import model_summary
|
|
9
|
+
|
|
10
|
+
OPERATIONS = (
|
|
11
|
+
"analyze",
|
|
12
|
+
"optimize",
|
|
13
|
+
"convert",
|
|
14
|
+
"unused_objects",
|
|
15
|
+
"routes",
|
|
16
|
+
"interfaces",
|
|
17
|
+
"policy",
|
|
18
|
+
"nat",
|
|
19
|
+
"dependencies",
|
|
20
|
+
"flow",
|
|
21
|
+
"status",
|
|
22
|
+
"artifacts",
|
|
23
|
+
)
|
|
24
|
+
SCHEMA = {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"properties": {"operation": {"type": "string", "enum": list(OPERATIONS)}},
|
|
27
|
+
"required": ["operation"],
|
|
28
|
+
"additionalProperties": False,
|
|
29
|
+
}
|
|
30
|
+
SYSTEM = (
|
|
31
|
+
"Select one NetConverter operation using the request_operation tool. "
|
|
32
|
+
"The operation argument must be a single string, not a schema or list. "
|
|
33
|
+
'For example, finding unused objects requires {"operation":"unused_objects"}. '
|
|
34
|
+
"Parameters and conversion decisions are collected by the terminal. Never invent parameters."
|
|
35
|
+
)
|
|
36
|
+
# Unknown words are opaque references, never sent verbatim. No configuration text,
|
|
37
|
+
# arbitrary prose, filenames, identifiers, addresses, or provider-returned text is retained.
|
|
38
|
+
WORDS = frozenset(
|
|
39
|
+
"find unused objects explain what can be cleaned up clean cleanup analyze analysis optimize optimization convert conversion migrate migration asa cisco palo alto set xml panorama routing routes consolidation recommendations ideas show get list interfaces policy rules nat dependencies where used traffic flow tcp udp port open allow allowed deny denied is do i have status progress job artifacts download reports evidence configuration source target selected please help me the a an and or to from for in on of with how which this that it my does check".split()
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def abstract_intent(text):
|
|
44
|
+
if len(text) > 4000:
|
|
45
|
+
raise ValueError("Question is too long")
|
|
46
|
+
# Keep only individually allowlisted words; everything else becomes a fixed
|
|
47
|
+
# opaque marker. Even a pasted configuration cannot cross this boundary.
|
|
48
|
+
return " ".join(
|
|
49
|
+
word.lower() if word.lower() in WORDS else "REFERENCE"
|
|
50
|
+
for word in re.findall(r"\S+", text)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ProviderError(RuntimeError):
|
|
55
|
+
"""Only locally authored messages may be displayed; never provider bodies."""
|
|
56
|
+
MESSAGES = {
|
|
57
|
+
"authentication": "The provider rejected the API key (401). Enter an API key for this provider; a NetConverter key will not work here.",
|
|
58
|
+
"access": "This API key cannot access the requested model (403). Check project permissions and model access.",
|
|
59
|
+
"model": "The model was not found or is not available to this API key. Check the exact API model ID.",
|
|
60
|
+
"quota": "The provider reports insufficient API quota or credits. Check API billing and project spending limits.",
|
|
61
|
+
"rate_limit": "The provider is rate limiting requests (429). Wait briefly, then retry.",
|
|
62
|
+
"request": "The provider rejected the request (400). Check the model ID and its support for Responses/function tools.",
|
|
63
|
+
"timeout": "The model request timed out. Retry, choose another model, or continue with guided commands.",
|
|
64
|
+
"connection": "Could not reach the model endpoint. Check connectivity; for Ollama, start the local service.",
|
|
65
|
+
"service": "The provider is temporarily unavailable. Retry later or use guided commands.",
|
|
66
|
+
"protocol": "The model did not return one permitted operation. Select a model with function/tool calling support.",
|
|
67
|
+
"configuration": "Choose a supported provider and an explicit API model ID.",
|
|
68
|
+
"missing_key": "Enter the provider's API key in the hidden key field.",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
def __init__(self, code="protocol"):
|
|
72
|
+
self.code = code if code in self.MESSAGES else "protocol"
|
|
73
|
+
super().__init__(self.MESSAGES[self.code])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def response_error(response):
|
|
77
|
+
"""Classify a closed set of error codes without echoing raw provider text."""
|
|
78
|
+
code = None
|
|
79
|
+
try:
|
|
80
|
+
error = response.json().get("error", {})
|
|
81
|
+
if isinstance(error, dict):
|
|
82
|
+
candidate = error.get("code") or error.get("type")
|
|
83
|
+
if candidate in ("insufficient_quota", "model_not_found", "invalid_api_key"):
|
|
84
|
+
code = candidate
|
|
85
|
+
except (ValueError, AttributeError, TypeError):
|
|
86
|
+
pass
|
|
87
|
+
if response.status_code == 401 or code == "invalid_api_key":
|
|
88
|
+
return ProviderError("authentication")
|
|
89
|
+
if code == "insufficient_quota" or response.status_code == 402:
|
|
90
|
+
return ProviderError("quota")
|
|
91
|
+
if code == "model_not_found" or response.status_code == 404:
|
|
92
|
+
return ProviderError("model")
|
|
93
|
+
return ProviderError({400:"request", 403:"access", 429:"rate_limit"}.get(response.status_code, "service"))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Provider:
|
|
97
|
+
def __init__(self, name, model, key="", *, endpoint=None, transport=None):
|
|
98
|
+
if (
|
|
99
|
+
name not in {"openai", "anthropic", "gemini", "ollama"}
|
|
100
|
+
or not model
|
|
101
|
+
or not re.fullmatch(r"[A-Za-z0-9_.:/-]{1,160}", model)
|
|
102
|
+
):
|
|
103
|
+
raise ValueError("Select a supported provider and an explicit model")
|
|
104
|
+
self.name, self.model, self.key = name, model, key
|
|
105
|
+
defaults = {
|
|
106
|
+
"openai": "https://api.openai.com/v1",
|
|
107
|
+
"anthropic": "https://api.anthropic.com/v1",
|
|
108
|
+
"gemini": "https://generativelanguage.googleapis.com/v1beta",
|
|
109
|
+
"ollama": "http://127.0.0.1:11434",
|
|
110
|
+
}
|
|
111
|
+
self.endpoint = endpoint or defaults[name]
|
|
112
|
+
if name != "ollama" and self.endpoint != defaults[name]:
|
|
113
|
+
raise ValueError("Hosted providers use their fixed official endpoint")
|
|
114
|
+
parts = urlsplit(self.endpoint)
|
|
115
|
+
if (
|
|
116
|
+
parts.username
|
|
117
|
+
or parts.password
|
|
118
|
+
or parts.query
|
|
119
|
+
or parts.fragment
|
|
120
|
+
or (
|
|
121
|
+
name == "ollama"
|
|
122
|
+
and (
|
|
123
|
+
parts.scheme != "http"
|
|
124
|
+
or parts.hostname not in {"localhost", "127.0.0.1", "::1"}
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
):
|
|
128
|
+
raise ValueError("Local inference requires an explicit loopback endpoint")
|
|
129
|
+
self.http = httpx.Client(
|
|
130
|
+
timeout=45, transport=transport, follow_redirects=False, trust_env=False
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def close(self):
|
|
134
|
+
self.http.close()
|
|
135
|
+
|
|
136
|
+
def select(self, text, summary=None):
|
|
137
|
+
content = json.dumps(
|
|
138
|
+
{"intent": abstract_intent(text), "context": model_summary(summary or {})}
|
|
139
|
+
)
|
|
140
|
+
function = {
|
|
141
|
+
"name": "request_operation",
|
|
142
|
+
"description": "Request a permitted operation for the selected revision",
|
|
143
|
+
"parameters": SCHEMA,
|
|
144
|
+
}
|
|
145
|
+
headers = {}
|
|
146
|
+
if self.name == "openai":
|
|
147
|
+
url = self.endpoint + "/responses"
|
|
148
|
+
headers = {"Authorization": "Bearer " + self.key}
|
|
149
|
+
body = {
|
|
150
|
+
"model": self.model,
|
|
151
|
+
"store": False,
|
|
152
|
+
"instructions": SYSTEM,
|
|
153
|
+
"input": content,
|
|
154
|
+
"tools": [{"type": "function", **function, "strict": True}],
|
|
155
|
+
"tool_choice": "required",
|
|
156
|
+
"parallel_tool_calls": False,
|
|
157
|
+
}
|
|
158
|
+
elif self.name == "anthropic":
|
|
159
|
+
url = self.endpoint + "/messages"
|
|
160
|
+
headers = {"x-api-key": self.key, "anthropic-version": "2023-06-01"}
|
|
161
|
+
body = {
|
|
162
|
+
"model": self.model,
|
|
163
|
+
"max_tokens": 256,
|
|
164
|
+
"system": SYSTEM,
|
|
165
|
+
"messages": [{"role": "user", "content": content}],
|
|
166
|
+
"tools": [
|
|
167
|
+
{
|
|
168
|
+
"name": function["name"],
|
|
169
|
+
"description": function["description"],
|
|
170
|
+
"input_schema": SCHEMA,
|
|
171
|
+
}
|
|
172
|
+
],
|
|
173
|
+
"tool_choice": {"type": "tool", "name": "request_operation"},
|
|
174
|
+
}
|
|
175
|
+
elif self.name == "gemini":
|
|
176
|
+
if "/" in self.model or ":" in self.model:
|
|
177
|
+
raise ValueError("Use the Gemini model ID without a path")
|
|
178
|
+
url = self.endpoint + "/models/" + self.model + ":generateContent"
|
|
179
|
+
headers = {"x-goog-api-key": self.key}
|
|
180
|
+
body = {
|
|
181
|
+
"systemInstruction": {"parts": [{"text": SYSTEM}]},
|
|
182
|
+
"contents": [{"role": "user", "parts": [{"text": content}]}],
|
|
183
|
+
"tools": [{"functionDeclarations": [function]}],
|
|
184
|
+
"toolConfig": {
|
|
185
|
+
"functionCallingConfig": {
|
|
186
|
+
"mode": "ANY",
|
|
187
|
+
"allowedFunctionNames": ["request_operation"],
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
else:
|
|
192
|
+
url = self.endpoint + "/api/chat"
|
|
193
|
+
body = {
|
|
194
|
+
"model": self.model,
|
|
195
|
+
"stream": False,
|
|
196
|
+
"options": {"num_ctx": 4096, "num_predict": 128, "temperature": 0},
|
|
197
|
+
"messages": [
|
|
198
|
+
{"role": "system", "content": SYSTEM},
|
|
199
|
+
{"role": "user", "content": content},
|
|
200
|
+
],
|
|
201
|
+
"tools": [{"type": "function", "function": function}],
|
|
202
|
+
}
|
|
203
|
+
try:
|
|
204
|
+
response = self.http.post(url, json=body, headers=headers)
|
|
205
|
+
if response.is_error:
|
|
206
|
+
raise response_error(response)
|
|
207
|
+
data = response.json()
|
|
208
|
+
if self.name == "openai":
|
|
209
|
+
calls = [
|
|
210
|
+
(c["name"], json.loads(c["arguments"]))
|
|
211
|
+
for c in data["output"]
|
|
212
|
+
if c["type"] == "function_call"
|
|
213
|
+
]
|
|
214
|
+
elif self.name == "anthropic":
|
|
215
|
+
calls = [
|
|
216
|
+
(c["name"], c["input"])
|
|
217
|
+
for c in data["content"]
|
|
218
|
+
if c["type"] == "tool_use"
|
|
219
|
+
]
|
|
220
|
+
elif self.name == "gemini":
|
|
221
|
+
calls = [
|
|
222
|
+
(p["functionCall"]["name"], p["functionCall"]["args"])
|
|
223
|
+
for p in data["candidates"][0]["content"]["parts"]
|
|
224
|
+
if "functionCall" in p
|
|
225
|
+
]
|
|
226
|
+
else:
|
|
227
|
+
calls = [
|
|
228
|
+
(c["function"]["name"], c["function"]["arguments"])
|
|
229
|
+
for c in data["message"].get("tool_calls", [])
|
|
230
|
+
]
|
|
231
|
+
if len(calls) != 1 or calls[0][0] != "request_operation":
|
|
232
|
+
raise ValueError()
|
|
233
|
+
args = calls[0][1]
|
|
234
|
+
if (
|
|
235
|
+
not isinstance(args, dict)
|
|
236
|
+
or set(args) != {"operation"}
|
|
237
|
+
or args["operation"] not in OPERATIONS
|
|
238
|
+
):
|
|
239
|
+
raise ValueError()
|
|
240
|
+
return args["operation"]
|
|
241
|
+
except httpx.TimeoutException:
|
|
242
|
+
raise ProviderError("timeout") from None
|
|
243
|
+
except httpx.HTTPError:
|
|
244
|
+
raise ProviderError("connection") from None
|
|
245
|
+
except (ValueError, KeyError, IndexError, TypeError):
|
|
246
|
+
raise ProviderError("protocol") from None
|