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