nock-cli 0.3.0__tar.gz → 0.3.1__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.
- {nock_cli-0.3.0 → nock_cli-0.3.1}/PKG-INFO +1 -1
- {nock_cli-0.3.0 → nock_cli-0.3.1}/pyproject.toml +1 -1
- {nock_cli-0.3.0 → nock_cli-0.3.1}/src/nock_cli/__init__.py +1 -1
- {nock_cli-0.3.0 → nock_cli-0.3.1}/src/nock_cli/serve.py +195 -181
- {nock_cli-0.3.0 → nock_cli-0.3.1}/src/nock_cli.egg-info/PKG-INFO +1 -1
- {nock_cli-0.3.0 → nock_cli-0.3.1}/LICENSE +0 -0
- {nock_cli-0.3.0 → nock_cli-0.3.1}/README.md +0 -0
- {nock_cli-0.3.0 → nock_cli-0.3.1}/setup.cfg +0 -0
- {nock_cli-0.3.0 → nock_cli-0.3.1}/src/nock_cli/__main__.py +0 -0
- {nock_cli-0.3.0 → nock_cli-0.3.1}/src/nock_cli/agent.py +0 -0
- {nock_cli-0.3.0 → nock_cli-0.3.1}/src/nock_cli/chat.py +0 -0
- {nock_cli-0.3.0 → nock_cli-0.3.1}/src/nock_cli/code.py +0 -0
- {nock_cli-0.3.0 → nock_cli-0.3.1}/src/nock_cli/download.py +0 -0
- {nock_cli-0.3.0 → nock_cli-0.3.1}/src/nock_cli/engine.py +0 -0
- {nock_cli-0.3.0 → nock_cli-0.3.1}/src/nock_cli/main.py +0 -0
- {nock_cli-0.3.0 → nock_cli-0.3.1}/src/nock_cli/models.py +0 -0
- {nock_cli-0.3.0 → nock_cli-0.3.1}/src/nock_cli/paths.py +0 -0
- {nock_cli-0.3.0 → nock_cli-0.3.1}/src/nock_cli/video.py +0 -0
- {nock_cli-0.3.0 → nock_cli-0.3.1}/src/nock_cli.egg-info/SOURCES.txt +0 -0
- {nock_cli-0.3.0 → nock_cli-0.3.1}/src/nock_cli.egg-info/dependency_links.txt +0 -0
- {nock_cli-0.3.0 → nock_cli-0.3.1}/src/nock_cli.egg-info/entry_points.txt +0 -0
- {nock_cli-0.3.0 → nock_cli-0.3.1}/src/nock_cli.egg-info/requires.txt +0 -0
- {nock_cli-0.3.0 → nock_cli-0.3.1}/src/nock_cli.egg-info/top_level.txt +0 -0
- {nock_cli-0.3.0 → nock_cli-0.3.1}/tests/test_cli.py +0 -0
|
@@ -1,181 +1,195 @@
|
|
|
1
|
-
"""`nock serve`: one local, OpenAI-compatible address for every installed skill.
|
|
2
|
-
|
|
3
|
-
Editors and tools point at http://127.0.0.1:8080/v1 and choose a model by name. The right llama-server starts on
|
|
4
|
-
first use; only one model is in memory at a time, so asking for another one swaps them. Everything stays on this
|
|
5
|
-
machine: it binds to 127.0.0.1, and refuses requests from websites (a page you visit could otherwise call it).
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
import json
|
|
9
|
-
import
|
|
10
|
-
import
|
|
11
|
-
import urllib.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
from nock_cli
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
prompt
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
self.
|
|
57
|
-
self.
|
|
58
|
-
self.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
server.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
self.
|
|
87
|
-
self.send_header("Content-
|
|
88
|
-
self.
|
|
89
|
-
self.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
self.
|
|
98
|
-
self.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
1
|
+
"""`nock serve`: one local, OpenAI-compatible address for every installed skill.
|
|
2
|
+
|
|
3
|
+
Editors and tools point at http://127.0.0.1:8080/v1 and choose a model by name. The right llama-server starts on
|
|
4
|
+
first use; only one model is in memory at a time, so asking for another one swaps them. Everything stays on this
|
|
5
|
+
machine: it binds to 127.0.0.1, and refuses requests from websites (a page you visit could otherwise call it).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
14
|
+
|
|
15
|
+
from nock_cli import __version__
|
|
16
|
+
from nock_cli.code import SYSTEM_PROMPT as CODER_SYSTEM_PROMPT
|
|
17
|
+
|
|
18
|
+
DEFAULT_PORT = 8080
|
|
19
|
+
MODEL_NAMES = {"nock-coder": "coder", "nock-video": "video", "nock-agent": "agent"}
|
|
20
|
+
SYSTEM_PROMPTS = {"coder": CODER_SYSTEM_PROMPT} # same answers as `nock code`, unless the app sends its own
|
|
21
|
+
MAX_BODY_BYTES = 64 * 1024 * 1024 # room for many video frames as images
|
|
22
|
+
UPSTREAM_TIMEOUT = 1800
|
|
23
|
+
LOCAL_HOSTS = ("127.0.0.1", "localhost", "[::1]")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_local_host(value):
|
|
27
|
+
"""True for 127.0.0.1, localhost and [::1], with or without a port."""
|
|
28
|
+
if not value:
|
|
29
|
+
return False
|
|
30
|
+
value = value.split("://", 1)[-1].split("/", 1)[0].lower()
|
|
31
|
+
host = value if value.startswith("[") and value.endswith("]") else value.rsplit(":", 1)[0] if ":" in value else value
|
|
32
|
+
return host in LOCAL_HOSTS
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def allowed_request(headers):
|
|
36
|
+
"""Blocks other websites (Origin) and DNS rebinding (Host). Apps and scripts send no Origin and pass."""
|
|
37
|
+
if not is_local_host(headers.get("Host")):
|
|
38
|
+
return False
|
|
39
|
+
origin = headers.get("Origin")
|
|
40
|
+
return origin is None or is_local_host(origin)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def prepare_body(body, skill):
|
|
44
|
+
"""Adds the skill's system prompt when the app sent none. The app's other settings pass through unchanged."""
|
|
45
|
+
prompt = SYSTEM_PROMPTS.get(skill)
|
|
46
|
+
messages = body.get("messages")
|
|
47
|
+
if prompt and isinstance(messages, list) and not any(isinstance(m, dict) and m.get("role") == "system" for m in messages):
|
|
48
|
+
body = {**body, "messages": [{"role": "system", "content": prompt}, *messages]}
|
|
49
|
+
return body
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ModelRouter:
|
|
53
|
+
"""Keeps at most one llama-server running. `starters` maps a skill to a function returning an unstarted server."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, starters):
|
|
56
|
+
self.starters = starters
|
|
57
|
+
self.lock = threading.Lock() # held for a whole request: one answer at a time, and no swap in the middle
|
|
58
|
+
self.current_skill = None
|
|
59
|
+
self.current_server = None
|
|
60
|
+
|
|
61
|
+
def load(self, skill):
|
|
62
|
+
"""Call with the lock held. Returns the running server for this skill."""
|
|
63
|
+
if self.current_skill != skill:
|
|
64
|
+
self.stop()
|
|
65
|
+
server = self.starters[skill]()
|
|
66
|
+
server.__enter__()
|
|
67
|
+
self.current_skill, self.current_server = skill, server
|
|
68
|
+
return self.current_server
|
|
69
|
+
|
|
70
|
+
def stop(self):
|
|
71
|
+
if self.current_server:
|
|
72
|
+
self.current_server.__exit__(None, None, None)
|
|
73
|
+
self.current_skill, self.current_server = None, None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def make_handler(router):
|
|
77
|
+
class Handler(BaseHTTPRequestHandler):
|
|
78
|
+
server_version = f"nock/{__version__}"
|
|
79
|
+
protocol_version = "HTTP/1.1"
|
|
80
|
+
|
|
81
|
+
def log_message(self, *args):
|
|
82
|
+
pass # questions and answers are never logged
|
|
83
|
+
|
|
84
|
+
def send_json(self, status, payload):
|
|
85
|
+
data = json.dumps(payload).encode()
|
|
86
|
+
self.send_response(status)
|
|
87
|
+
self.send_header("Content-Type", "application/json")
|
|
88
|
+
self.send_header("Content-Length", str(len(data)))
|
|
89
|
+
self.end_headers()
|
|
90
|
+
self.wfile.write(data)
|
|
91
|
+
|
|
92
|
+
def send_error_json(self, status, message):
|
|
93
|
+
self.send_json(status, {"error": {"message": message, "type": "nock_error"}})
|
|
94
|
+
|
|
95
|
+
def do_OPTIONS(self):
|
|
96
|
+
# No CORS headers: a browser's preflight from a website fails here.
|
|
97
|
+
self.send_response(403)
|
|
98
|
+
self.send_header("Content-Length", "0")
|
|
99
|
+
self.end_headers()
|
|
100
|
+
|
|
101
|
+
def do_GET(self):
|
|
102
|
+
if not allowed_request(self.headers):
|
|
103
|
+
return self.send_error_json(403, "Requests from websites are not allowed.")
|
|
104
|
+
if self.path == "/health":
|
|
105
|
+
return self.send_json(200, {"status": "ok", "loaded": router.current_skill})
|
|
106
|
+
if self.path == "/v1/models":
|
|
107
|
+
names = [name for name, skill in MODEL_NAMES.items() if skill in router.starters]
|
|
108
|
+
return self.send_json(200, {"object": "list", "data": [{"id": name, "object": "model", "owned_by": "nockai"} for name in names]})
|
|
109
|
+
self.send_error_json(404, "Not found. Try /v1/models or /v1/chat/completions.")
|
|
110
|
+
|
|
111
|
+
def do_POST(self):
|
|
112
|
+
if not allowed_request(self.headers):
|
|
113
|
+
return self.send_error_json(403, "Requests from websites are not allowed.")
|
|
114
|
+
if self.path != "/v1/chat/completions":
|
|
115
|
+
return self.send_error_json(404, "Only /v1/chat/completions is available.")
|
|
116
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
117
|
+
if length > MAX_BODY_BYTES:
|
|
118
|
+
return self.send_error_json(413, "Request is too large.")
|
|
119
|
+
try:
|
|
120
|
+
body = json.loads(self.rfile.read(length) or b"{}")
|
|
121
|
+
except ValueError:
|
|
122
|
+
return self.send_error_json(400, "The request body is not valid JSON.")
|
|
123
|
+
skill = MODEL_NAMES.get(body.get("model"))
|
|
124
|
+
if not skill:
|
|
125
|
+
return self.send_error_json(400, f"Unknown model. Use one of: {', '.join(MODEL_NAMES)}.")
|
|
126
|
+
if skill not in router.starters:
|
|
127
|
+
return self.send_error_json(404, f"{body['model']} is not installed. Run: nock add {skill}")
|
|
128
|
+
with router.lock:
|
|
129
|
+
try:
|
|
130
|
+
server = router.load(skill)
|
|
131
|
+
except Exception as error:
|
|
132
|
+
router.stop()
|
|
133
|
+
return self.send_error_json(503, f"Could not start {body['model']}: {error}")
|
|
134
|
+
self.forward(server.url, prepare_body(body, skill))
|
|
135
|
+
|
|
136
|
+
def forward(self, upstream_url, body):
|
|
137
|
+
request = urllib.request.Request(f"{upstream_url}/v1/chat/completions", data=json.dumps(body).encode(),
|
|
138
|
+
headers={"Content-Type": "application/json"})
|
|
139
|
+
try:
|
|
140
|
+
upstream = urllib.request.urlopen(request, timeout=UPSTREAM_TIMEOUT)
|
|
141
|
+
except urllib.error.HTTPError as error:
|
|
142
|
+
upstream = error # the engine's own error, passed on with its status
|
|
143
|
+
except OSError as error:
|
|
144
|
+
return self.send_error_json(502, f"The engine did not answer: {error}")
|
|
145
|
+
try:
|
|
146
|
+
with upstream:
|
|
147
|
+
self.send_response(upstream.status)
|
|
148
|
+
self.send_header("Content-Type", upstream.headers.get("Content-Type", "application/json"))
|
|
149
|
+
# Chunked so streamed answers reach the app word by word.
|
|
150
|
+
self.send_header("Transfer-Encoding", "chunked")
|
|
151
|
+
self.end_headers()
|
|
152
|
+
while True:
|
|
153
|
+
chunk = upstream.read1(65536)
|
|
154
|
+
if not chunk:
|
|
155
|
+
break
|
|
156
|
+
self.wfile.write(f"{len(chunk):X}\r\n".encode() + chunk + b"\r\n")
|
|
157
|
+
self.wfile.flush()
|
|
158
|
+
self.wfile.write(b"0\r\n\r\n")
|
|
159
|
+
except ConnectionError:
|
|
160
|
+
self.close_connection = True # the app hung up (a stop button, for example); closing `upstream` ends the answer
|
|
161
|
+
|
|
162
|
+
return Handler
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class QuietServer(ThreadingHTTPServer):
|
|
166
|
+
"""Apps hanging up is normal, so it prints nothing. Other errors still print."""
|
|
167
|
+
|
|
168
|
+
daemon_threads = True
|
|
169
|
+
|
|
170
|
+
def handle_error(self, request, client_address):
|
|
171
|
+
if not isinstance(sys.exc_info()[1], ConnectionError):
|
|
172
|
+
super().handle_error(request, client_address)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def create_server(starters, port=DEFAULT_PORT):
|
|
176
|
+
router = ModelRouter(starters)
|
|
177
|
+
httpd = QuietServer(("127.0.0.1", port), make_handler(router))
|
|
178
|
+
return httpd, router
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def run(starters, port=DEFAULT_PORT):
|
|
182
|
+
try:
|
|
183
|
+
httpd, router = create_server(starters, port)
|
|
184
|
+
except OSError:
|
|
185
|
+
raise ValueError(f"Port {port} is already in use. Try: nock serve --port {port + 1}")
|
|
186
|
+
print(f"nock serve: http://127.0.0.1:{port}/v1 (this machine only)")
|
|
187
|
+
print(f"Models: {', '.join(name for name, skill in MODEL_NAMES.items() if skill in starters)}")
|
|
188
|
+
print("In your app: base URL as above, any API key, model as listed. Ctrl+C to stop.")
|
|
189
|
+
try:
|
|
190
|
+
httpd.serve_forever()
|
|
191
|
+
except KeyboardInterrupt:
|
|
192
|
+
pass
|
|
193
|
+
finally:
|
|
194
|
+
router.stop()
|
|
195
|
+
httpd.server_close()
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|