nock-cli 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.
- {nock_cli-0.2.0 → nock_cli-0.3.0}/PKG-INFO +18 -1
- {nock_cli-0.2.0 → nock_cli-0.3.0}/README.md +114 -97
- {nock_cli-0.2.0 → nock_cli-0.3.0}/pyproject.toml +1 -1
- {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli/__init__.py +1 -1
- {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli/main.py +251 -222
- nock_cli-0.3.0/src/nock_cli/serve.py +181 -0
- {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli.egg-info/PKG-INFO +18 -1
- {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli.egg-info/SOURCES.txt +1 -0
- {nock_cli-0.2.0 → nock_cli-0.3.0}/tests/test_cli.py +83 -1
- {nock_cli-0.2.0 → nock_cli-0.3.0}/LICENSE +0 -0
- {nock_cli-0.2.0 → nock_cli-0.3.0}/setup.cfg +0 -0
- {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli/__main__.py +0 -0
- {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli/agent.py +0 -0
- {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli/chat.py +0 -0
- {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli/code.py +0 -0
- {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli/download.py +0 -0
- {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli/engine.py +0 -0
- {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli/models.py +0 -0
- {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli/paths.py +0 -0
- {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli/video.py +0 -0
- {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli.egg-info/dependency_links.txt +0 -0
- {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli.egg-info/entry_points.txt +0 -0
- {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli.egg-info/requires.txt +0 -0
- {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nock-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -101,6 +101,23 @@ and edited a test file 0 times. It works on your own files, so **every edit is s
|
|
|
101
101
|
before it runs, and each one needs your yes**. `--yes` skips the questions; use it only in a copy or a git branch.
|
|
102
102
|
It refuses to start on GPUs under about 20 GB (`--force` to try anyway, very slowly).
|
|
103
103
|
|
|
104
|
+
## Use it from your apps: `nock serve`
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
nock serve
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
One local, OpenAI-compatible address for every skill you have installed. In your editor or tool, set the base URL to
|
|
111
|
+
`http://127.0.0.1:8080/v1`, any API key, and the model to `nock-coder`, `nock-video` or `nock-agent`.
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
curl http://127.0.0.1:8080/v1/chat/completions -H "Content-Type: application/json" -d '{"model": "nock-coder", "messages": [{"role": "user", "content": "regex for an EVM address"}]}'
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The model starts on first use, and one model is in memory at a time, so asking for another swaps them (it can take
|
|
118
|
+
a while). Streaming works. It listens on this machine only, refuses requests from websites, and logs no questions or
|
|
119
|
+
answers. `--port` changes the port.
|
|
120
|
+
|
|
104
121
|
## Speed and GPUs
|
|
105
122
|
|
|
106
123
|
`nock doctor` shows which device the engine uses. If it says **software device** or **no GPU device**, the
|
|
@@ -1,97 +1,114 @@
|
|
|
1
|
-
# Nock CLI
|
|
2
|
-
|
|
3
|
-
Open models that live on your machine. One tool for the NockAI family: code, video, and a coding agent, offline.
|
|
4
|
-
|
|
5
|
-
```bash
|
|
6
|
-
nock add coder video
|
|
7
|
-
nock code "regex for an EVM address"
|
|
8
|
-
nock video find meeting.mp4 "when do they talk about the budget"
|
|
9
|
-
nock fix ./my-project "the total is off by one" # experimental, big GPU
|
|
10
|
-
```
|
|
11
|
-
|
|
12
|
-
Everything runs on your computer. The models run in a local llama.cpp server on `127.0.0.1`. Nothing is uploaded.
|
|
13
|
-
|
|
14
|
-
## Install
|
|
15
|
-
|
|
16
|
-
You need Python 3.9 or newer.
|
|
17
|
-
|
|
18
|
-
```bash
|
|
19
|
-
pipx install nock-cli
|
|
20
|
-
nock add coder # or: video, agent
|
|
21
|
-
nock doctor
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
`nock add` downloads, once:
|
|
25
|
-
- the engine: an official [llama.cpp](https://github.com/ggml-org/llama.cpp) build (about 35 MB; Vulkan on Windows
|
|
26
|
-
and Linux, Metal on Apple Silicon). Already have `llama-server`? Nock uses it.
|
|
27
|
-
- the skill's model from Hugging Face.
|
|
28
|
-
|
|
29
|
-
| Skill | Model | Download | Needs |
|
|
30
|
-
|---|---|---|---|
|
|
31
|
-
| `coder` | [Nock Coder 1.5B](https://huggingface.co/Nock-AI/nock-coder-1.5b-GGUF) | 1 GB | any laptop |
|
|
32
|
-
| `video` | [Nock Video 4B](https://huggingface.co/Nock-AI/nock-video-4b-GGUF) (preview) | 3.4 GB | a GPU with 6 GB for 64 frames; [ffmpeg](https://ffmpeg.org/download.html) |
|
|
33
|
-
| `agent` | [Nock Agent 31B](https://huggingface.co/Nock-AI/nock-agent-31b-GGUF) (experimental) | 18.7 GB | a GPU with about 20 GB (a Mac with 32 GB or more may work; not tested) |
|
|
34
|
-
|
|
35
|
-
`nock list` shows what is installed, `nock remove <skill>` deletes a skill's files. Files go to `~/.nock`
|
|
36
|
-
(set `NOCK_HOME` to use another folder).
|
|
37
|
-
|
|
38
|
-
## Nock Coder: `nock code`
|
|
39
|
-
|
|
40
|
-
```bash
|
|
41
|
-
nock code "write a Solidity ERC-20 with a cap"
|
|
42
|
-
cat error.log | nock code "why does this fail?"
|
|
43
|
-
nock code # a chat; type exit to quit
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
Code first, one short line of explanation. The same model and prompt as `ollama run nockai/nock-coder`. It is a
|
|
47
|
-
1.5B model, so review code before you run it. On HumanEval it passes 118 of 164.
|
|
48
|
-
|
|
49
|
-
## Nock Video: `nock video`
|
|
50
|
-
|
|
51
|
-
```bash
|
|
52
|
-
nock video summarize meeting.mp4
|
|
53
|
-
nock video ask clip.mp4 "what error message shows on screen?"
|
|
54
|
-
nock video find clip.mp4 "when the cake comes out"
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
Files or links. Links are downloaded to your own machine with [yt-dlp](https://github.com/yt-dlp/yt-dlp), which
|
|
58
|
-
you install yourself (`pipx inject nock-cli yt-dlp`). Only use videos you have the right to use.
|
|
59
|
-
|
|
60
|
-
Nock Video 4B is a **preview**. On our test of 20 videos, through llama.cpp at 64 frames, it found the moment in
|
|
61
|
-
43 of 56 questions, answered 59 of 60 questions about what is on screen, and gave 6 of 7 good summaries. Full
|
|
62
|
-
results, including the misses, are on the [model card](https://huggingface.co/Nock-AI/nock-video-4b-GGUF).
|
|
63
|
-
|
|
64
|
-
- It reads frames only, not sound.
|
|
65
|
-
- A long video is read as at most 64 frames, so short events in long videos can be missed.
|
|
66
|
-
- 64 frames used about 5.5 GB of GPU memory in our test. On GPUs under 6 GB, Nock uses 32 frames (the first
|
|
67
|
-
preview dropped from 42 to 35 of 56 moments at 32 frames; the current model was not measured at 32).
|
|
68
|
-
`--frames` overrides it.
|
|
69
|
-
|
|
70
|
-
## Nock Agent: `nock fix` (experimental)
|
|
71
|
-
|
|
72
|
-
```bash
|
|
73
|
-
nock fix ./my-project "the total is off by one"
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
Nock Agent reads the code, makes the smallest edit and runs the tests. On our agent test it solved 145 of 150 runs
|
|
77
|
-
and edited a test file 0 times. It works on your own files, so **every edit is shown as a diff and every command
|
|
78
|
-
before it runs, and each one needs your yes**. `--yes` skips the questions; use it only in a copy or a git branch.
|
|
79
|
-
It refuses to start on GPUs under about 20 GB (`--force` to try anyway, very slowly).
|
|
80
|
-
|
|
81
|
-
##
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
1
|
+
# Nock CLI
|
|
2
|
+
|
|
3
|
+
Open models that live on your machine. One tool for the NockAI family: code, video, and a coding agent, offline.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
nock add coder video
|
|
7
|
+
nock code "regex for an EVM address"
|
|
8
|
+
nock video find meeting.mp4 "when do they talk about the budget"
|
|
9
|
+
nock fix ./my-project "the total is off by one" # experimental, big GPU
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Everything runs on your computer. The models run in a local llama.cpp server on `127.0.0.1`. Nothing is uploaded.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
You need Python 3.9 or newer.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pipx install nock-cli
|
|
20
|
+
nock add coder # or: video, agent
|
|
21
|
+
nock doctor
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`nock add` downloads, once:
|
|
25
|
+
- the engine: an official [llama.cpp](https://github.com/ggml-org/llama.cpp) build (about 35 MB; Vulkan on Windows
|
|
26
|
+
and Linux, Metal on Apple Silicon). Already have `llama-server`? Nock uses it.
|
|
27
|
+
- the skill's model from Hugging Face.
|
|
28
|
+
|
|
29
|
+
| Skill | Model | Download | Needs |
|
|
30
|
+
|---|---|---|---|
|
|
31
|
+
| `coder` | [Nock Coder 1.5B](https://huggingface.co/Nock-AI/nock-coder-1.5b-GGUF) | 1 GB | any laptop |
|
|
32
|
+
| `video` | [Nock Video 4B](https://huggingface.co/Nock-AI/nock-video-4b-GGUF) (preview) | 3.4 GB | a GPU with 6 GB for 64 frames; [ffmpeg](https://ffmpeg.org/download.html) |
|
|
33
|
+
| `agent` | [Nock Agent 31B](https://huggingface.co/Nock-AI/nock-agent-31b-GGUF) (experimental) | 18.7 GB | a GPU with about 20 GB (a Mac with 32 GB or more may work; not tested) |
|
|
34
|
+
|
|
35
|
+
`nock list` shows what is installed, `nock remove <skill>` deletes a skill's files. Files go to `~/.nock`
|
|
36
|
+
(set `NOCK_HOME` to use another folder).
|
|
37
|
+
|
|
38
|
+
## Nock Coder: `nock code`
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
nock code "write a Solidity ERC-20 with a cap"
|
|
42
|
+
cat error.log | nock code "why does this fail?"
|
|
43
|
+
nock code # a chat; type exit to quit
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Code first, one short line of explanation. The same model and prompt as `ollama run nockai/nock-coder`. It is a
|
|
47
|
+
1.5B model, so review code before you run it. On HumanEval it passes 118 of 164.
|
|
48
|
+
|
|
49
|
+
## Nock Video: `nock video`
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
nock video summarize meeting.mp4
|
|
53
|
+
nock video ask clip.mp4 "what error message shows on screen?"
|
|
54
|
+
nock video find clip.mp4 "when the cake comes out"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Files or links. Links are downloaded to your own machine with [yt-dlp](https://github.com/yt-dlp/yt-dlp), which
|
|
58
|
+
you install yourself (`pipx inject nock-cli yt-dlp`). Only use videos you have the right to use.
|
|
59
|
+
|
|
60
|
+
Nock Video 4B is a **preview**. On our test of 20 videos, through llama.cpp at 64 frames, it found the moment in
|
|
61
|
+
43 of 56 questions, answered 59 of 60 questions about what is on screen, and gave 6 of 7 good summaries. Full
|
|
62
|
+
results, including the misses, are on the [model card](https://huggingface.co/Nock-AI/nock-video-4b-GGUF).
|
|
63
|
+
|
|
64
|
+
- It reads frames only, not sound.
|
|
65
|
+
- A long video is read as at most 64 frames, so short events in long videos can be missed.
|
|
66
|
+
- 64 frames used about 5.5 GB of GPU memory in our test. On GPUs under 6 GB, Nock uses 32 frames (the first
|
|
67
|
+
preview dropped from 42 to 35 of 56 moments at 32 frames; the current model was not measured at 32).
|
|
68
|
+
`--frames` overrides it.
|
|
69
|
+
|
|
70
|
+
## Nock Agent: `nock fix` (experimental)
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
nock fix ./my-project "the total is off by one"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Nock Agent reads the code, makes the smallest edit and runs the tests. On our agent test it solved 145 of 150 runs
|
|
77
|
+
and edited a test file 0 times. It works on your own files, so **every edit is shown as a diff and every command
|
|
78
|
+
before it runs, and each one needs your yes**. `--yes` skips the questions; use it only in a copy or a git branch.
|
|
79
|
+
It refuses to start on GPUs under about 20 GB (`--force` to try anyway, very slowly).
|
|
80
|
+
|
|
81
|
+
## Use it from your apps: `nock serve`
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
nock serve
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
One local, OpenAI-compatible address for every skill you have installed. In your editor or tool, set the base URL to
|
|
88
|
+
`http://127.0.0.1:8080/v1`, any API key, and the model to `nock-coder`, `nock-video` or `nock-agent`.
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
curl http://127.0.0.1:8080/v1/chat/completions -H "Content-Type: application/json" -d '{"model": "nock-coder", "messages": [{"role": "user", "content": "regex for an EVM address"}]}'
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The model starts on first use, and one model is in memory at a time, so asking for another swaps them (it can take
|
|
95
|
+
a while). Streaming works. It listens on this machine only, refuses requests from websites, and logs no questions or
|
|
96
|
+
answers. `--port` changes the port.
|
|
97
|
+
|
|
98
|
+
## Speed and GPUs
|
|
99
|
+
|
|
100
|
+
`nock doctor` shows which device the engine uses. If it says **software device** or **no GPU device**, the
|
|
101
|
+
engine runs on the CPU and answers take many minutes. This happens inside most containers and VMs. On a machine
|
|
102
|
+
with an NVIDIA GPU, `nock setup --backend cuda` installs the CUDA build instead (a bigger download: about 630 MB on Windows, 740 MB on Linux).
|
|
103
|
+
|
|
104
|
+
| Option | Meaning |
|
|
105
|
+
|---|---|
|
|
106
|
+
| `--backend vulkan\|cuda\|cpu\|metal` | which llama.cpp build `nock add` / `nock setup` installs |
|
|
107
|
+
| `NOCK_LLAMA_SERVER=/path/llama-server` | use your own llama.cpp build |
|
|
108
|
+
| `NOCK_HOME=/path` | where models and the engine are stored |
|
|
109
|
+
|
|
110
|
+
Server logs are in `~/.nock/logs/` if something goes wrong.
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
Apache-2.0. Each model has its own license on its model card (all Apache-2.0).
|
|
@@ -1,222 +1,251 @@
|
|
|
1
|
-
"""Command line entry point: `nock add`, `nock code`, `nock video ...`, `nock fix`, `nock doctor`."""
|
|
2
|
-
|
|
3
|
-
import argparse
|
|
4
|
-
import shutil
|
|
5
|
-
import subprocess
|
|
6
|
-
import sys
|
|
7
|
-
import urllib.error
|
|
8
|
-
|
|
9
|
-
from nock_cli import __version__, agent, code, engine, models, paths, video
|
|
10
|
-
|
|
11
|
-
SKILLS = list(models.MODELS)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def ensure_engine(settings, backend=None):
|
|
15
|
-
"""Installs Nock's llama.cpp build when there is none yet (or when a backend is asked for), and records whether
|
|
16
|
-
it can use a GPU, so every later command can warn without asking the engine again."""
|
|
17
|
-
existing = engine.locate_server(settings)
|
|
18
|
-
if existing and not backend:
|
|
19
|
-
if "uses_gpu" not in settings:
|
|
20
|
-
record_devices(settings, existing)
|
|
21
|
-
return
|
|
22
|
-
print("Installing the engine (llama.cpp, from github.com/ggml-org/llama.cpp)...")
|
|
23
|
-
server, chosen = engine.install(backend)
|
|
24
|
-
settings.update(llama_server=str(server), llama_cpp_build=engine.LLAMA_CPP_BUILD, backend=chosen)
|
|
25
|
-
record_devices(settings, server)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def record_devices(settings, server):
|
|
29
|
-
settings["uses_gpu"], settings["device"], settings["device_id"] = engine.gpu_check(engine.list_devices(server))
|
|
30
|
-
if not settings["uses_gpu"] and settings.get("backend") != "cpu":
|
|
31
|
-
warn_no_gpu(settings)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def warn_no_gpu(settings):
|
|
35
|
-
print(f"\nWarning: {settings.get('device')}.", file=sys.stderr)
|
|
36
|
-
if video.gpu_memory_mb() is not None:
|
|
37
|
-
print(" This machine has an NVIDIA GPU. Try: nock setup --backend cuda", file=sys.stderr)
|
|
38
|
-
print("", file=sys.stderr)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def install_skills(skills, backend=None):
|
|
42
|
-
settings = paths.load_settings()
|
|
43
|
-
print(f"Nock home: {paths.nock_home()}")
|
|
44
|
-
ensure_engine(settings, backend)
|
|
45
|
-
for skill in skills:
|
|
46
|
-
spec = models.MODELS[skill]
|
|
47
|
-
print(f"Adding {spec['title']} ({spec['size']}) from huggingface.co/{spec['repo']}...")
|
|
48
|
-
settings.setdefault("models", {})[skill] = models.install(skill)
|
|
49
|
-
paths.save_settings(settings)
|
|
50
|
-
print(f"Ready. Try: {spec['try']}")
|
|
51
|
-
paths.save_settings(settings)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def command_add(args):
|
|
55
|
-
install_skills(args.skills, args.backend)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def command_setup(args):
|
|
59
|
-
# Kept from 0.1: `nock setup` installs the engine and Nock Video, or the skills named after it.
|
|
60
|
-
install_skills(args.skills or ["video"], args.backend)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def command_remove(args):
|
|
64
|
-
settings = paths.load_settings()
|
|
65
|
-
for skill in args.skills:
|
|
66
|
-
models.remove(skill)
|
|
67
|
-
settings.get("models", {}).pop(skill, None)
|
|
68
|
-
print(f"Removed {models.MODELS[skill]['title']}.")
|
|
69
|
-
paths.save_settings(settings)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def command_list(args):
|
|
73
|
-
installed = paths.load_settings().get("models", {})
|
|
74
|
-
for skill, spec in models.MODELS.items():
|
|
75
|
-
if skill in installed:
|
|
76
|
-
state = f"installed ({models.folder_size_gb(skill):.1f} GB, {installed[skill]['revision'][:7]})"
|
|
77
|
-
else:
|
|
78
|
-
state = f"not installed ({spec['size']}): nock add {skill}"
|
|
79
|
-
print(f" {skill:<6} {spec['title']:<30} {state}")
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def check(label, ok, detail):
|
|
83
|
-
print(f" [{'ok' if ok else '--'}] {label}: {detail}")
|
|
84
|
-
return ok
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def command_doctor(args):
|
|
88
|
-
settings = paths.load_settings()
|
|
89
|
-
print(f"nock {__version__}, home {paths.nock_home()}")
|
|
90
|
-
server = engine.locate_server(settings)
|
|
91
|
-
ready = check("engine", bool(server), f"{server} ({settings.get('backend', 'own build')})" if server else "missing: run `nock add video`")
|
|
92
|
-
if server:
|
|
93
|
-
uses_gpu, device, _ = engine.gpu_check(engine.list_devices(server))
|
|
94
|
-
check("GPU in the engine", uses_gpu, device)
|
|
95
|
-
if not uses_gpu and video.gpu_memory_mb() is not None:
|
|
96
|
-
print(" This machine has an NVIDIA GPU. Try: nock setup --backend cuda")
|
|
97
|
-
ready &= check("ffmpeg (for video)", bool(shutil.which("ffmpeg") and shutil.which("ffprobe")),
|
|
98
|
-
shutil.which("ffmpeg") or "missing: install ffmpeg (https://ffmpeg.org/download.html)")
|
|
99
|
-
check("yt-dlp (optional, for links)", bool(shutil.which("yt-dlp")), shutil.which("yt-dlp") or "not installed: pipx inject nock-cli yt-dlp")
|
|
100
|
-
installed = settings.get("models", {})
|
|
101
|
-
for skill in SKILLS:
|
|
102
|
-
item = installed.get(skill)
|
|
103
|
-
check(f"skill {skill}", bool(item), f"{item['repo']}@{item['revision'][:7]}" if item else f"not installed: nock add {skill}")
|
|
104
|
-
memory = video.gpu_memory_mb()
|
|
105
|
-
check("NVIDIA GPU", memory is not None,
|
|
106
|
-
f"{memory} MB" if memory is not None else "not found (other GPUs work through Vulkan or Metal; CPU only is slow)")
|
|
107
|
-
free_gb = shutil.disk_usage(paths.nock_home().anchor or "/").free / 1e9
|
|
108
|
-
check("free disk", free_gb > 5, f"{free_gb:.0f} GB")
|
|
109
|
-
return 0 if ready else 1
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def start_server_factory(skill):
|
|
113
|
-
settings = paths.load_settings()
|
|
114
|
-
server_binary = engine.locate_server(settings)
|
|
115
|
-
installed = settings.get("models", {}).get(skill)
|
|
116
|
-
if not server_binary or not installed:
|
|
117
|
-
sys.exit(f"{models.MODELS[skill]['title']} is not installed yet. Run: nock add {skill}")
|
|
118
|
-
if settings.get("uses_gpu") is False and settings.get("backend") != "cpu":
|
|
119
|
-
warn_no_gpu(settings)
|
|
120
|
-
log_path = paths.nock_home() / "logs" / f"{skill}-server.log"
|
|
121
|
-
default_context = models.MODELS[skill].get("context")
|
|
122
|
-
|
|
123
|
-
def start(context=None):
|
|
124
|
-
return engine.LocalServer(server_binary, installed["model"], installed.get("mmproj"), context or default_context,
|
|
125
|
-
99, log_path, settings.get("device_id"))
|
|
126
|
-
|
|
127
|
-
return start
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def command_video(args):
|
|
131
|
-
kind = {"summarize": "summary", "ask": "ask", "find": "moment"}[args.action]
|
|
132
|
-
question = video.SUMMARY_QUESTION if kind == "summary" else args.question
|
|
133
|
-
frames = video.choose_frames(args.frames)
|
|
134
|
-
print(video.run(args.source, question, kind, frames, start_server_factory("video")))
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def command_code(args):
|
|
138
|
-
code.run(" ".join(args.prompt).strip(), start_server_factory("coder"))
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def command_fix(args):
|
|
142
|
-
need = models.MODELS["agent"]["min_gpu_mb"]
|
|
143
|
-
memory = video.gpu_memory_mb()
|
|
144
|
-
if memory is not None and memory < need and not args.force:
|
|
145
|
-
sys.exit(f"Nock Agent 31B needs a GPU with about {need // 1000} GB; this one has {memory // 1000} GB. "
|
|
146
|
-
"Use --force to try anyway (it will be very slow).")
|
|
147
|
-
if memory is None:
|
|
148
|
-
print(f"Note: Nock Agent 31B needs about {need // 1000} GB of GPU memory (a Mac with 32 GB or more may work; not tested).", file=sys.stderr)
|
|
149
|
-
approve = agent.Approver(always=args.yes)
|
|
150
|
-
print(agent.run(args.project, args.task, approve, start_server_factory("agent")))
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
1
|
+
"""Command line entry point: `nock add`, `nock code`, `nock video ...`, `nock fix`, `nock doctor`."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import urllib.error
|
|
8
|
+
|
|
9
|
+
from nock_cli import __version__, agent, code, engine, models, paths, serve, video
|
|
10
|
+
|
|
11
|
+
SKILLS = list(models.MODELS)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def ensure_engine(settings, backend=None):
|
|
15
|
+
"""Installs Nock's llama.cpp build when there is none yet (or when a backend is asked for), and records whether
|
|
16
|
+
it can use a GPU, so every later command can warn without asking the engine again."""
|
|
17
|
+
existing = engine.locate_server(settings)
|
|
18
|
+
if existing and not backend:
|
|
19
|
+
if "uses_gpu" not in settings:
|
|
20
|
+
record_devices(settings, existing)
|
|
21
|
+
return
|
|
22
|
+
print("Installing the engine (llama.cpp, from github.com/ggml-org/llama.cpp)...")
|
|
23
|
+
server, chosen = engine.install(backend)
|
|
24
|
+
settings.update(llama_server=str(server), llama_cpp_build=engine.LLAMA_CPP_BUILD, backend=chosen)
|
|
25
|
+
record_devices(settings, server)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def record_devices(settings, server):
|
|
29
|
+
settings["uses_gpu"], settings["device"], settings["device_id"] = engine.gpu_check(engine.list_devices(server))
|
|
30
|
+
if not settings["uses_gpu"] and settings.get("backend") != "cpu":
|
|
31
|
+
warn_no_gpu(settings)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def warn_no_gpu(settings):
|
|
35
|
+
print(f"\nWarning: {settings.get('device')}.", file=sys.stderr)
|
|
36
|
+
if video.gpu_memory_mb() is not None:
|
|
37
|
+
print(" This machine has an NVIDIA GPU. Try: nock setup --backend cuda", file=sys.stderr)
|
|
38
|
+
print("", file=sys.stderr)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def install_skills(skills, backend=None):
|
|
42
|
+
settings = paths.load_settings()
|
|
43
|
+
print(f"Nock home: {paths.nock_home()}")
|
|
44
|
+
ensure_engine(settings, backend)
|
|
45
|
+
for skill in skills:
|
|
46
|
+
spec = models.MODELS[skill]
|
|
47
|
+
print(f"Adding {spec['title']} ({spec['size']}) from huggingface.co/{spec['repo']}...")
|
|
48
|
+
settings.setdefault("models", {})[skill] = models.install(skill)
|
|
49
|
+
paths.save_settings(settings)
|
|
50
|
+
print(f"Ready. Try: {spec['try']}")
|
|
51
|
+
paths.save_settings(settings)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def command_add(args):
|
|
55
|
+
install_skills(args.skills, args.backend)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def command_setup(args):
|
|
59
|
+
# Kept from 0.1: `nock setup` installs the engine and Nock Video, or the skills named after it.
|
|
60
|
+
install_skills(args.skills or ["video"], args.backend)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def command_remove(args):
|
|
64
|
+
settings = paths.load_settings()
|
|
65
|
+
for skill in args.skills:
|
|
66
|
+
models.remove(skill)
|
|
67
|
+
settings.get("models", {}).pop(skill, None)
|
|
68
|
+
print(f"Removed {models.MODELS[skill]['title']}.")
|
|
69
|
+
paths.save_settings(settings)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def command_list(args):
|
|
73
|
+
installed = paths.load_settings().get("models", {})
|
|
74
|
+
for skill, spec in models.MODELS.items():
|
|
75
|
+
if skill in installed:
|
|
76
|
+
state = f"installed ({models.folder_size_gb(skill):.1f} GB, {installed[skill]['revision'][:7]})"
|
|
77
|
+
else:
|
|
78
|
+
state = f"not installed ({spec['size']}): nock add {skill}"
|
|
79
|
+
print(f" {skill:<6} {spec['title']:<30} {state}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def check(label, ok, detail):
|
|
83
|
+
print(f" [{'ok' if ok else '--'}] {label}: {detail}")
|
|
84
|
+
return ok
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def command_doctor(args):
|
|
88
|
+
settings = paths.load_settings()
|
|
89
|
+
print(f"nock {__version__}, home {paths.nock_home()}")
|
|
90
|
+
server = engine.locate_server(settings)
|
|
91
|
+
ready = check("engine", bool(server), f"{server} ({settings.get('backend', 'own build')})" if server else "missing: run `nock add video`")
|
|
92
|
+
if server:
|
|
93
|
+
uses_gpu, device, _ = engine.gpu_check(engine.list_devices(server))
|
|
94
|
+
check("GPU in the engine", uses_gpu, device)
|
|
95
|
+
if not uses_gpu and video.gpu_memory_mb() is not None:
|
|
96
|
+
print(" This machine has an NVIDIA GPU. Try: nock setup --backend cuda")
|
|
97
|
+
ready &= check("ffmpeg (for video)", bool(shutil.which("ffmpeg") and shutil.which("ffprobe")),
|
|
98
|
+
shutil.which("ffmpeg") or "missing: install ffmpeg (https://ffmpeg.org/download.html)")
|
|
99
|
+
check("yt-dlp (optional, for links)", bool(shutil.which("yt-dlp")), shutil.which("yt-dlp") or "not installed: pipx inject nock-cli yt-dlp")
|
|
100
|
+
installed = settings.get("models", {})
|
|
101
|
+
for skill in SKILLS:
|
|
102
|
+
item = installed.get(skill)
|
|
103
|
+
check(f"skill {skill}", bool(item), f"{item['repo']}@{item['revision'][:7]}" if item else f"not installed: nock add {skill}")
|
|
104
|
+
memory = video.gpu_memory_mb()
|
|
105
|
+
check("NVIDIA GPU", memory is not None,
|
|
106
|
+
f"{memory} MB" if memory is not None else "not found (other GPUs work through Vulkan or Metal; CPU only is slow)")
|
|
107
|
+
free_gb = shutil.disk_usage(paths.nock_home().anchor or "/").free / 1e9
|
|
108
|
+
check("free disk", free_gb > 5, f"{free_gb:.0f} GB")
|
|
109
|
+
return 0 if ready else 1
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def start_server_factory(skill):
|
|
113
|
+
settings = paths.load_settings()
|
|
114
|
+
server_binary = engine.locate_server(settings)
|
|
115
|
+
installed = settings.get("models", {}).get(skill)
|
|
116
|
+
if not server_binary or not installed:
|
|
117
|
+
sys.exit(f"{models.MODELS[skill]['title']} is not installed yet. Run: nock add {skill}")
|
|
118
|
+
if settings.get("uses_gpu") is False and settings.get("backend") != "cpu":
|
|
119
|
+
warn_no_gpu(settings)
|
|
120
|
+
log_path = paths.nock_home() / "logs" / f"{skill}-server.log"
|
|
121
|
+
default_context = models.MODELS[skill].get("context")
|
|
122
|
+
|
|
123
|
+
def start(context=None):
|
|
124
|
+
return engine.LocalServer(server_binary, installed["model"], installed.get("mmproj"), context or default_context,
|
|
125
|
+
99, log_path, settings.get("device_id"))
|
|
126
|
+
|
|
127
|
+
return start
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def command_video(args):
|
|
131
|
+
kind = {"summarize": "summary", "ask": "ask", "find": "moment"}[args.action]
|
|
132
|
+
question = video.SUMMARY_QUESTION if kind == "summary" else args.question
|
|
133
|
+
frames = video.choose_frames(args.frames)
|
|
134
|
+
print(video.run(args.source, question, kind, frames, start_server_factory("video")))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def command_code(args):
|
|
138
|
+
code.run(" ".join(args.prompt).strip(), start_server_factory("coder"))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def command_fix(args):
|
|
142
|
+
need = models.MODELS["agent"]["min_gpu_mb"]
|
|
143
|
+
memory = video.gpu_memory_mb()
|
|
144
|
+
if memory is not None and memory < need and not args.force:
|
|
145
|
+
sys.exit(f"Nock Agent 31B needs a GPU with about {need // 1000} GB; this one has {memory // 1000} GB. "
|
|
146
|
+
"Use --force to try anyway (it will be very slow).")
|
|
147
|
+
if memory is None:
|
|
148
|
+
print(f"Note: Nock Agent 31B needs about {need // 1000} GB of GPU memory (a Mac with 32 GB or more may work; not tested).", file=sys.stderr)
|
|
149
|
+
approve = agent.Approver(always=args.yes)
|
|
150
|
+
print(agent.run(args.project, args.task, approve, start_server_factory("agent")))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def command_serve(args):
|
|
154
|
+
installed = paths.load_settings().get("models", {})
|
|
155
|
+
if not installed:
|
|
156
|
+
sys.exit("No skills installed yet. Run: nock add coder")
|
|
157
|
+
memory = video.gpu_memory_mb()
|
|
158
|
+
starters = {}
|
|
159
|
+
for skill in installed:
|
|
160
|
+
need = models.MODELS.get(skill, {}).get("min_gpu_mb")
|
|
161
|
+
if need and memory is not None and memory < need and not args.force:
|
|
162
|
+
print(f"Leaving out {skill}: it needs about {need // 1000} GB of GPU memory (--force to include it).", file=sys.stderr)
|
|
163
|
+
continue
|
|
164
|
+
start = start_server_factory(skill)
|
|
165
|
+
starters[skill] = make_starter(skill, start)
|
|
166
|
+
if not starters:
|
|
167
|
+
sys.exit("None of the installed skills fits this GPU. Run: nock add coder")
|
|
168
|
+
serve.run(starters, args.port)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def make_starter(skill, start):
|
|
172
|
+
# Video has no fixed context in MODELS: its size follows the frame count, and an app can send up to 64 frames.
|
|
173
|
+
context = models.MODELS[skill].get("context") or video.context_for(video.MAX_FRAMES)
|
|
174
|
+
return lambda: start(context)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def build_parser():
|
|
178
|
+
parser = argparse.ArgumentParser(prog="nock", description="Open models that live on your machine.")
|
|
179
|
+
parser.add_argument("--version", action="version", version=f"nock {__version__}")
|
|
180
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
181
|
+
|
|
182
|
+
add = commands.add_parser("add", help="add a skill: coder (1 GB), video (3.4 GB), agent (18.7 GB)")
|
|
183
|
+
add.add_argument("skills", nargs="+", choices=SKILLS)
|
|
184
|
+
add.add_argument("--backend", choices=engine.BACKENDS, help="llama.cpp build: vulkan (default), cuda, cpu, metal (Mac)")
|
|
185
|
+
add.set_defaults(handler=command_add)
|
|
186
|
+
|
|
187
|
+
remove = commands.add_parser("remove", help="delete a skill's files")
|
|
188
|
+
remove.add_argument("skills", nargs="+", choices=SKILLS)
|
|
189
|
+
remove.set_defaults(handler=command_remove)
|
|
190
|
+
|
|
191
|
+
listing = commands.add_parser("list", help="show the skills and what is installed")
|
|
192
|
+
listing.set_defaults(handler=command_list)
|
|
193
|
+
|
|
194
|
+
setup = commands.add_parser("setup", help="install the engine and Nock Video (or the skills you name)")
|
|
195
|
+
setup.add_argument("skills", nargs="*", choices=SKILLS)
|
|
196
|
+
setup.add_argument("--backend", choices=engine.BACKENDS, help="llama.cpp build: vulkan (default), cuda, cpu, metal (Mac)")
|
|
197
|
+
setup.set_defaults(handler=command_setup)
|
|
198
|
+
|
|
199
|
+
doctor = commands.add_parser("doctor", help="check that everything is in place")
|
|
200
|
+
doctor.set_defaults(handler=command_doctor)
|
|
201
|
+
|
|
202
|
+
code_parser = commands.add_parser("code", help="ask Nock Coder; no question starts a chat; pipe a file or log in")
|
|
203
|
+
code_parser.add_argument("prompt", nargs="*")
|
|
204
|
+
code_parser.set_defaults(handler=command_code)
|
|
205
|
+
|
|
206
|
+
video_parser = commands.add_parser("video", help="ask about a video file or link, offline")
|
|
207
|
+
actions = video_parser.add_subparsers(dest="action", required=True)
|
|
208
|
+
summarize = actions.add_parser("summarize", help="a few short points about the video")
|
|
209
|
+
summarize.add_argument("source", help="video file or link")
|
|
210
|
+
ask = actions.add_parser("ask", help="a question about what the video shows")
|
|
211
|
+
ask.add_argument("source", help="video file or link")
|
|
212
|
+
ask.add_argument("question")
|
|
213
|
+
find = actions.add_parser("find", help="when does something happen")
|
|
214
|
+
find.add_argument("source", help="video file or link")
|
|
215
|
+
find.add_argument("question", help='what to find, for example "the error message shows up"')
|
|
216
|
+
for action in (summarize, ask, find):
|
|
217
|
+
action.add_argument("--frames", type=int, help=f"frames to read (default {video.MAX_FRAMES}, "
|
|
218
|
+
f"{video.LOW_MEMORY_FRAMES} on GPUs under 6 GB)")
|
|
219
|
+
video_parser.set_defaults(handler=command_video)
|
|
220
|
+
|
|
221
|
+
serve_parser = commands.add_parser("serve", help="one local OpenAI-style address for your apps (this machine only)")
|
|
222
|
+
serve_parser.add_argument("--port", type=int, default=serve.DEFAULT_PORT)
|
|
223
|
+
serve_parser.add_argument("--force", action="store_true", help="include skills that may not fit this GPU")
|
|
224
|
+
serve_parser.set_defaults(handler=command_serve)
|
|
225
|
+
|
|
226
|
+
fix = commands.add_parser("fix", help="Nock Agent (experimental, big GPU): fix an issue in a project folder")
|
|
227
|
+
fix.add_argument("project", help="the project folder")
|
|
228
|
+
fix.add_argument("task", help='what to fix, for example "the total is off by one"')
|
|
229
|
+
fix.add_argument("--yes", action="store_true", help="approve every edit and command without asking")
|
|
230
|
+
fix.add_argument("--force", action="store_true", help="start even on a GPU that is too small")
|
|
231
|
+
fix.set_defaults(handler=command_fix)
|
|
232
|
+
return parser
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def main(argv=None):
|
|
236
|
+
args = build_parser().parse_args(argv)
|
|
237
|
+
try:
|
|
238
|
+
result = args.handler(args)
|
|
239
|
+
except KeyboardInterrupt:
|
|
240
|
+
sys.exit("Stopped.")
|
|
241
|
+
except (engine.EngineError, models.ModelError, video.VideoError, ValueError) as error:
|
|
242
|
+
sys.exit(f"nock: {error}")
|
|
243
|
+
except subprocess.CalledProcessError as error:
|
|
244
|
+
sys.exit(f"nock: {error.cmd[0]} failed (exit code {error.returncode})")
|
|
245
|
+
except urllib.error.URLError as error:
|
|
246
|
+
sys.exit(f"nock: network error: {error.reason}")
|
|
247
|
+
sys.exit(result or 0)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
if __name__ == "__main__":
|
|
251
|
+
main()
|
|
@@ -0,0 +1,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 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,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nock-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -101,6 +101,23 @@ and edited a test file 0 times. It works on your own files, so **every edit is s
|
|
|
101
101
|
before it runs, and each one needs your yes**. `--yes` skips the questions; use it only in a copy or a git branch.
|
|
102
102
|
It refuses to start on GPUs under about 20 GB (`--force` to try anyway, very slowly).
|
|
103
103
|
|
|
104
|
+
## Use it from your apps: `nock serve`
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
nock serve
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
One local, OpenAI-compatible address for every skill you have installed. In your editor or tool, set the base URL to
|
|
111
|
+
`http://127.0.0.1:8080/v1`, any API key, and the model to `nock-coder`, `nock-video` or `nock-agent`.
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
curl http://127.0.0.1:8080/v1/chat/completions -H "Content-Type: application/json" -d '{"model": "nock-coder", "messages": [{"role": "user", "content": "regex for an EVM address"}]}'
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The model starts on first use, and one model is in memory at a time, so asking for another swaps them (it can take
|
|
118
|
+
a while). Streaming works. It listens on this machine only, refuses requests from websites, and logs no questions or
|
|
119
|
+
answers. `--port` changes the port.
|
|
120
|
+
|
|
104
121
|
## Speed and GPUs
|
|
105
122
|
|
|
106
123
|
`nock doctor` shows which device the engine uses. If it says **software device** or **no GPU device**, the
|
|
@@ -6,12 +6,14 @@ import tarfile
|
|
|
6
6
|
import tempfile
|
|
7
7
|
import threading
|
|
8
8
|
import unittest
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
9
11
|
import zipfile
|
|
10
12
|
from contextlib import contextmanager
|
|
11
13
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
12
14
|
from pathlib import Path
|
|
13
15
|
|
|
14
|
-
from nock_cli import agent, code, engine, main, models, video
|
|
16
|
+
from nock_cli import agent, code, engine, main, models, serve, video
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
class FrameTimesTest(unittest.TestCase):
|
|
@@ -203,6 +205,86 @@ class AgentTest(unittest.TestCase):
|
|
|
203
205
|
self.assertIn("outside the project", output)
|
|
204
206
|
|
|
205
207
|
|
|
208
|
+
class FakeEngine:
|
|
209
|
+
"""Stands in for one llama-server: records its requests and whether it is running."""
|
|
210
|
+
|
|
211
|
+
def __init__(self, name, log):
|
|
212
|
+
self.name, self.log = name, log
|
|
213
|
+
self.fake = None
|
|
214
|
+
|
|
215
|
+
def __enter__(self):
|
|
216
|
+
self.log.append(f"start {self.name}")
|
|
217
|
+
self.fake = FakeModel([{"role": "assistant", "content": f"answer from {self.name}"}] * 5)
|
|
218
|
+
self.url = self.fake.url
|
|
219
|
+
return self
|
|
220
|
+
|
|
221
|
+
def __exit__(self, *args):
|
|
222
|
+
self.log.append(f"stop {self.name}")
|
|
223
|
+
self.fake.close()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class ServeTest(unittest.TestCase):
|
|
227
|
+
def setUp(self):
|
|
228
|
+
self.log = []
|
|
229
|
+
self.engines = {}
|
|
230
|
+
|
|
231
|
+
def starter(name):
|
|
232
|
+
def start():
|
|
233
|
+
self.engines[name] = FakeEngine(name, self.log)
|
|
234
|
+
return self.engines[name]
|
|
235
|
+
return start
|
|
236
|
+
|
|
237
|
+
self.httpd, self.router = serve.create_server({"coder": starter("coder"), "video": starter("video")}, 0)
|
|
238
|
+
self.port = self.httpd.server_address[1]
|
|
239
|
+
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
|
|
240
|
+
|
|
241
|
+
def tearDown(self):
|
|
242
|
+
self.router.stop()
|
|
243
|
+
self.httpd.shutdown()
|
|
244
|
+
self.httpd.server_close()
|
|
245
|
+
|
|
246
|
+
def call(self, path, body=None, headers=None):
|
|
247
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
248
|
+
request = urllib.request.Request(f"http://127.0.0.1:{self.port}{path}", data=data, headers=headers or {})
|
|
249
|
+
try:
|
|
250
|
+
with urllib.request.urlopen(request, timeout=10) as response:
|
|
251
|
+
return response.status, json.load(response)
|
|
252
|
+
except urllib.error.HTTPError as error:
|
|
253
|
+
return error.code, json.load(error)
|
|
254
|
+
|
|
255
|
+
def ask(self, model, **extra):
|
|
256
|
+
return self.call("/v1/chat/completions", {"model": model, "messages": [{"role": "user", "content": "hi"}], **extra})
|
|
257
|
+
|
|
258
|
+
def test_lists_installed_models_only(self):
|
|
259
|
+
_, listing = self.call("/v1/models")
|
|
260
|
+
self.assertEqual([m["id"] for m in listing["data"]], ["nock-coder", "nock-video"])
|
|
261
|
+
|
|
262
|
+
def test_routes_and_swaps_one_model_at_a_time(self):
|
|
263
|
+
self.assertEqual(self.ask("nock-coder")[1]["choices"][0]["message"]["content"], "answer from coder")
|
|
264
|
+
self.ask("nock-coder")
|
|
265
|
+
self.assertEqual(self.ask("nock-video")[1]["choices"][0]["message"]["content"], "answer from video")
|
|
266
|
+
self.assertEqual(self.log, ["start coder", "stop coder", "start video"])
|
|
267
|
+
|
|
268
|
+
def test_adds_coder_prompt_unless_the_app_sends_one(self):
|
|
269
|
+
self.ask("nock-coder")
|
|
270
|
+
sent = self.engines["coder"].fake.requests[0]["messages"]
|
|
271
|
+
self.assertEqual(sent[0]["content"], code.SYSTEM_PROMPT)
|
|
272
|
+
own = serve.prepare_body({"messages": [{"role": "system", "content": "mine"}]}, "coder")
|
|
273
|
+
self.assertEqual(len(own["messages"]), 1)
|
|
274
|
+
|
|
275
|
+
def test_errors_are_clear(self):
|
|
276
|
+
self.assertEqual(self.ask("gpt-5")[0], 400)
|
|
277
|
+
status, reply = self.ask("nock-agent")
|
|
278
|
+
self.assertEqual(status, 404)
|
|
279
|
+
self.assertIn("nock add agent", reply["error"]["message"])
|
|
280
|
+
|
|
281
|
+
def test_websites_are_refused(self):
|
|
282
|
+
self.assertEqual(self.call("/v1/models", headers={"Origin": "https://evil.example"})[0], 403)
|
|
283
|
+
self.assertEqual(self.call("/v1/models", headers={"Host": "evil.example"})[0], 403)
|
|
284
|
+
self.assertEqual(self.call("/v1/models", headers={"Origin": "http://localhost:3000"})[0], 200)
|
|
285
|
+
self.assertEqual(self.log, [])
|
|
286
|
+
|
|
287
|
+
|
|
206
288
|
class ParserTest(unittest.TestCase):
|
|
207
289
|
def test_commands_parse(self):
|
|
208
290
|
parser = main.build_parser()
|
|
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
|