virtuai-cli 1.0.1__tar.gz → 1.0.3__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 (38) hide show
  1. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/PKG-INFO +53 -16
  2. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/README.md +49 -14
  3. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/pyproject.toml +7 -2
  4. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/__init__.py +1 -1
  5. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/main.py +25 -0
  6. virtuai_cli-1.0.3/src/virtuai_cli/ollama_state.py +245 -0
  7. virtuai_cli-1.0.3/src/virtuai_cli/omlx_state.py +83 -0
  8. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/pull.py +23 -2
  9. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/runner.py +127 -56
  10. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli.egg-info/PKG-INFO +53 -16
  11. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli.egg-info/SOURCES.txt +5 -0
  12. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli.egg-info/requires.txt +3 -1
  13. virtuai_cli-1.0.3/tests/test_ollama_options.py +136 -0
  14. virtuai_cli-1.0.3/tests/test_ollama_state.py +146 -0
  15. virtuai_cli-1.0.3/tests/test_omlx.py +112 -0
  16. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/setup.cfg +0 -0
  17. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/browser.py +0 -0
  18. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/chat/__init__.py +0 -0
  19. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/chat/agents.py +0 -0
  20. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/chat/ask.py +0 -0
  21. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/chat/command.py +0 -0
  22. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/chat/history.py +0 -0
  23. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/chat/sse.py +0 -0
  24. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/chat/tui.py +0 -0
  25. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/chat/widgets.py +0 -0
  26. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/config.py +0 -0
  27. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/executor.py +0 -0
  28. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/opencode.py +0 -0
  29. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/security.py +0 -0
  30. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
  31. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
  32. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli.egg-info/top_level.txt +0 -0
  33. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/tests/test_browser.py +0 -0
  34. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/tests/test_frame_concurrency.py +0 -0
  35. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/tests/test_process_group_kill.py +0 -0
  36. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/tests/test_pull_fallback.py +0 -0
  37. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/tests/test_run_single_instance.py +0 -0
  38. {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/tests/test_session_isolation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: virtuai-cli
3
- Version: 1.0.1
3
+ Version: 1.0.3
4
4
  Summary: Run VirtuAI deep agents on your local machine
5
5
  Author-email: uCloudStore <lmoreno@ucloudstore.com>
6
6
  License: Proprietary
@@ -21,7 +21,9 @@ Requires-Dist: typer>=0.12
21
21
  Requires-Dist: rich>=13.0
22
22
  Requires-Dist: textual>=0.86
23
23
  Provides-Extra: local
24
- Requires-Dist: langchain-ollama>=0.2; extra == "local"
24
+ Requires-Dist: langchain-ollama>=0.3.4; extra == "local"
25
+ Requires-Dist: llmfit>=1.1.16; extra == "local"
26
+ Requires-Dist: langchain-openai>=0.3; extra == "local"
25
27
  Provides-Extra: browser
26
28
  Requires-Dist: browser-use>=0.1.40; extra == "browser"
27
29
 
@@ -41,7 +43,7 @@ Or with [pipx](https://pipx.pypa.io/) (recommended for CLI tools — handles PAT
41
43
  pipx install virtuai-cli
42
44
  ```
43
45
 
44
- For **local model inference** (running agents against [Ollama](https://ollama.com) on your machine — see below), install the `local` extra:
46
+ For **local model inference** (running agents against [Ollama](https://ollama.com) or [oMLX](https://github.com/jundot/omlx) on your machine — see below), install the `local` extra:
45
47
 
46
48
  ```bash
47
49
  pip install 'virtuai-cli[local]' # or: pipx install 'virtuai-cli[local]'
@@ -89,27 +91,58 @@ virtuai ask --json "find any bugs" > events.jsonl
89
91
 
90
92
  `ask` is non-interactive: it prints the response to stdout, then exits.
91
93
 
92
- ## Local model inference (Ollama)
94
+ ## Local model inference
93
95
 
94
- Agents can run inference on **your own machine** via [Ollama](https://ollama.com) instead of a cloud provider — useful for cost, data locality, or running open models. Only the model turns are proxied to your machine over the CLI's WebSocket; the agent loop, tools, knowledge bases, and chat history stay on the server.
96
+ Agents can run inference on **your own machine** — with [Ollama](https://ollama.com), or [oMLX](https://github.com/jundot/omlx) on Apple Silicon — instead of a cloud provider: for cost, data locality, or open models. Only the model calls go to the machine; the agent loop, tools, knowledge bases and chat history stay on the server. It works from **every channel** — web chat, Slack, Telegram, WhatsApp, Google Chat, OpenCode — not only the TUI, as long as a runner that can serve the model is connected.
95
97
 
96
- To use it:
98
+ ### Set up
97
99
 
98
- 1. Install the CLI with the local extra (pulls `langchain-ollama`):
100
+ 1. Install the CLI with the local extra (pulls `langchain-ollama`, `langchain-openai` for oMLX, and `llmfit`):
99
101
  ```bash
100
102
  pip install 'virtuai-cli[local]'
101
103
  ```
102
- 2. Install [Ollama](https://ollama.com), start it, and pull a **tool-capable** model:
104
+ 2. Install Ollama **0.40 or newer** and pull a validated model:
103
105
  ```bash
104
- ollama serve
105
- ollama pull qwen3:8b # instruct/agentic models work best for tool calling
106
+ ollama pull qwen3:8b
106
107
  ```
107
- 3. In the VirtuAI portal, set the agent's **Provider** to **Local (Ollama)**, enter the model tag you pulled, and pick a **cloud fallback** model.
108
- 4. Connect the CLI (`virtuai chat`, `virtuai ask`, or `virtuai run`) — model turns are now answered by your local Ollama.
108
+ Ollama before 0.40 held the same models in two to three times the memory; `virtuai run` warns when it finds one.
109
+ 3. In the VirtuAI portal, set the agent's **Provider** to **Local (Ollama)** (or **Local (oMLX)**), enter the model, and pick a **cloud fallback** model.
110
+ 4. Keep a runner connected: `virtuai run`, or `virtuai chat` / `virtuai ask`, which start their own.
109
111
 
110
- The CLI talks to Ollama at `http://localhost:11434` by default; override with the `OLLAMA_BASE_URL` environment variable.
112
+ ### Which models
111
113
 
112
- If the CLI isn't connected, Ollama isn't running, or the model isn't pulled, the agent **falls back to its configured cloud model** and shows a notice. For reliable tool use, prefer instruct/agentic models (e.g. `qwen3:*`, `qwen2.5:*`); some code-tuned or non-instruct models emit tool calls as plain text and won't drive the agent's tools.
114
+ Validated — every one of 10 tool-calling tasks right, 8k context, 16 GB Apple M3 with Ollama 0.40:
115
+
116
+ | Model | Memory | Median answer |
117
+ |---|---|---|
118
+ | `qwen3:8b` | 6.6 GB | 2.2 s |
119
+ | `qwen2.5:7b` | 5.0 GB | 3.5 s |
120
+ | `qwen2.5-coder:7b` | ~5 GB | 4.4 s |
121
+ | `qwen2.5:14b` | 10.3 GB | 6.0 s |
122
+
123
+ Other models run, but are not vouched for. `qwen3:4b` is accurate but slow (it reasons before every call). Gemma 3 is never used: Ollama cannot call tools with it, so the cloud fallback answers.
124
+
125
+ ### Whose turns this machine answers
126
+
127
+ ```bash
128
+ virtuai run # your own turns only (the default)
129
+ virtuai run --serve-models workspace # anyone's in the workspace: a shared Mac mini or GPU box
130
+ virtuai run --serve-models none # tools only, no model turns
131
+ ```
132
+
133
+ Each model call goes to the runner of the person the turn is for; if they have none, to a runner started with `--serve-models workspace`; if there is none either, the agent's **cloud fallback** answers that call and every channel shows "Modelo local no disponible: responde …". A runner that fails before it answers (Ollama stopped, the model unloaded) costs the call a cloud answer, not an error.
134
+
135
+ > **Upgrading from 1.0.2:** with a current server, an older runner serves only the person who paired it. A machine that serves a whole team needs 1.0.3 and `--serve-models workspace`.
136
+
137
+ ### What the runner tells the server
138
+
139
+ At connect and then every minute: the models it has, the Ollama version, what is loaded and how much memory it really took. With `llmfit` installed it also estimates which pulled models fit this machine, so the server does not send a model to a runner that cannot hold it until it has actually run there. A model that spills to the CPU gets half the context on its next call on that machine (down to 4k).
140
+
141
+ The context sent to Ollama is 8k by default (set on the server); `OLLAMA_NUM_CTX` overrides it on a machine with less memory. For qwen3 the runner turns Ollama's thinking mode off: left on, it reasoned for minutes before every tool call.
142
+
143
+ ### oMLX
144
+
145
+ The runner finds oMLX through its own settings (`~/.omlx/settings.json`: port and API key) or `VIRTUAI_OMLX_URL` / `OMLX_API_KEY`; the key never leaves the machine. Measured with `Qwen3-8B-4bit`: 10/10, 2.5 s per answer — on par with Ollama for one person. Its case is a shared host serving several people at once; raise `VIRTUAI_MAX_CONCURRENT_MODEL` there, since the runner otherwise sends one generation at a time.
113
146
 
114
147
  ## Environment variables
115
148
 
@@ -124,11 +157,15 @@ mean "allow more commands at once" silently launched more browsers.
124
157
  | `VIRTUAI_ISOLATE_SESSIONS` | off | Give each conversation its own directory under the workdir, and each person their own browser. Turn it on when one runner serves several people. Also set by `--isolate-sessions`. |
125
158
  | `VIRTUAI_MAX_NAMESPACES` | `5` | Browsers held at once — one per person. Each is a real headful Chrome (roughly 0.4–1 GB resident), so size this to the machine's memory: an OOM-killed Chrome is a worse failure than a clean refusal. Only applies with isolation on. Also set by `--max-namespaces`. |
126
159
  | `VIRTUAI_MAX_CONCURRENT_WORK` | `4` | Commands running at once. Each conversation is already capped at one by its own queue, so in practice this is how many conversations may be executing at the same instant. |
127
- | `VIRTUAI_MAX_CONCURRENT_MODEL` | `1` | Local model generations at once. Ollama does one well; its own parallelism lives in `OLLAMA_NUM_PARALLEL` on your machine, which the server cannot see. |
160
+ | `VIRTUAI_MAX_CONCURRENT_MODEL` | `1` | Local model generations at once. Ollama does one well; its own parallelism lives in `OLLAMA_NUM_PARALLEL` on your machine, which the server cannot see. Raise it for oMLX on a shared host, which batches several at once. |
128
161
  | `VIRTUAI_MAX_QUEUED_FRAMES` | `64` | Requests waiting for a slot. Past this the runner answers "busy" rather than queueing without bound. |
129
162
  | `VIRTUAI_SESSION_IDLE_TIMEOUT` | off (`0`) | Seconds before an idle browser stops holding its slot. Off by default: a slot normally frees only when its browser closes or the runner restarts. |
130
163
  | `VIRTUAI_SANDBOX_OWNER` | unset | The one person this runner serves, reported to the server so a browser action reaches *their* browser. Leave it unset on a laptop or a container shared by a team: the server then routes to it for anyone, which is the behaviour that existed before per-person sandboxes. Set it when a workspace has a sandbox per person, or actions land on whichever runner connected last. |
164
+ | `VIRTUAI_SERVE_MODELS` | `own` | Whose local-model turns this runner answers: `own`, `workspace` or `none`. Also set by `--serve-models`. |
131
165
  | `OLLAMA_BASE_URL` | `http://localhost:11434` | Where to reach the local Ollama. |
166
+ | `OLLAMA_NUM_CTX` | unset | The context to ask Ollama for, instead of the server's (8k). Lower it on a machine with little memory. |
167
+ | `VIRTUAI_OMLX_URL` | from `~/.omlx/settings.json`, else `http://127.0.0.1:8000` | Where to reach oMLX. |
168
+ | `OMLX_API_KEY` | from `~/.omlx/settings.json` | oMLX's API key. Stays on this machine. |
132
169
 
133
170
  Server-side, `VIRTUAI_ENV_WAIT_SECONDS` (default `45`) is how long the server
134
171
  holds a message waiting for a runner to reconnect before telling the user the
@@ -143,7 +180,7 @@ environment is not responding.
143
180
  | `virtuai opencode` | Merge this workspace's agents into the local OpenCode TUI config |
144
181
  | `virtuai pair <code>` | Pair this machine with a workspace |
145
182
  | `virtuai unpair` | Revoke the current pairing |
146
- | `virtuai run` | Headless WebSocket runner — let the web app/channels invoke local tools without opening the TUI |
183
+ | `virtuai run` | Headless runner — let the web app and channels use this machine's tools and local models without opening the TUI (`--serve-models own\|workspace\|none`) |
147
184
  | `virtuai status` | Show pairing and connection status |
148
185
  | `virtuai logs` | Show recent commands the agent executed locally |
149
186
  | `virtuai login` | Authenticate with your VirtuAI account (browser flow) |
@@ -14,7 +14,7 @@ Or with [pipx](https://pipx.pypa.io/) (recommended for CLI tools — handles PAT
14
14
  pipx install virtuai-cli
15
15
  ```
16
16
 
17
- For **local model inference** (running agents against [Ollama](https://ollama.com) on your machine — see below), install the `local` extra:
17
+ For **local model inference** (running agents against [Ollama](https://ollama.com) or [oMLX](https://github.com/jundot/omlx) on your machine — see below), install the `local` extra:
18
18
 
19
19
  ```bash
20
20
  pip install 'virtuai-cli[local]' # or: pipx install 'virtuai-cli[local]'
@@ -62,27 +62,58 @@ virtuai ask --json "find any bugs" > events.jsonl
62
62
 
63
63
  `ask` is non-interactive: it prints the response to stdout, then exits.
64
64
 
65
- ## Local model inference (Ollama)
65
+ ## Local model inference
66
66
 
67
- Agents can run inference on **your own machine** via [Ollama](https://ollama.com) instead of a cloud provider — useful for cost, data locality, or running open models. Only the model turns are proxied to your machine over the CLI's WebSocket; the agent loop, tools, knowledge bases, and chat history stay on the server.
67
+ Agents can run inference on **your own machine** — with [Ollama](https://ollama.com), or [oMLX](https://github.com/jundot/omlx) on Apple Silicon — instead of a cloud provider: for cost, data locality, or open models. Only the model calls go to the machine; the agent loop, tools, knowledge bases and chat history stay on the server. It works from **every channel** — web chat, Slack, Telegram, WhatsApp, Google Chat, OpenCode — not only the TUI, as long as a runner that can serve the model is connected.
68
68
 
69
- To use it:
69
+ ### Set up
70
70
 
71
- 1. Install the CLI with the local extra (pulls `langchain-ollama`):
71
+ 1. Install the CLI with the local extra (pulls `langchain-ollama`, `langchain-openai` for oMLX, and `llmfit`):
72
72
  ```bash
73
73
  pip install 'virtuai-cli[local]'
74
74
  ```
75
- 2. Install [Ollama](https://ollama.com), start it, and pull a **tool-capable** model:
75
+ 2. Install Ollama **0.40 or newer** and pull a validated model:
76
76
  ```bash
77
- ollama serve
78
- ollama pull qwen3:8b # instruct/agentic models work best for tool calling
77
+ ollama pull qwen3:8b
79
78
  ```
80
- 3. In the VirtuAI portal, set the agent's **Provider** to **Local (Ollama)**, enter the model tag you pulled, and pick a **cloud fallback** model.
81
- 4. Connect the CLI (`virtuai chat`, `virtuai ask`, or `virtuai run`) — model turns are now answered by your local Ollama.
79
+ Ollama before 0.40 held the same models in two to three times the memory; `virtuai run` warns when it finds one.
80
+ 3. In the VirtuAI portal, set the agent's **Provider** to **Local (Ollama)** (or **Local (oMLX)**), enter the model, and pick a **cloud fallback** model.
81
+ 4. Keep a runner connected: `virtuai run`, or `virtuai chat` / `virtuai ask`, which start their own.
82
82
 
83
- The CLI talks to Ollama at `http://localhost:11434` by default; override with the `OLLAMA_BASE_URL` environment variable.
83
+ ### Which models
84
84
 
85
- If the CLI isn't connected, Ollama isn't running, or the model isn't pulled, the agent **falls back to its configured cloud model** and shows a notice. For reliable tool use, prefer instruct/agentic models (e.g. `qwen3:*`, `qwen2.5:*`); some code-tuned or non-instruct models emit tool calls as plain text and won't drive the agent's tools.
85
+ Validated — every one of 10 tool-calling tasks right, 8k context, 16 GB Apple M3 with Ollama 0.40:
86
+
87
+ | Model | Memory | Median answer |
88
+ |---|---|---|
89
+ | `qwen3:8b` | 6.6 GB | 2.2 s |
90
+ | `qwen2.5:7b` | 5.0 GB | 3.5 s |
91
+ | `qwen2.5-coder:7b` | ~5 GB | 4.4 s |
92
+ | `qwen2.5:14b` | 10.3 GB | 6.0 s |
93
+
94
+ Other models run, but are not vouched for. `qwen3:4b` is accurate but slow (it reasons before every call). Gemma 3 is never used: Ollama cannot call tools with it, so the cloud fallback answers.
95
+
96
+ ### Whose turns this machine answers
97
+
98
+ ```bash
99
+ virtuai run # your own turns only (the default)
100
+ virtuai run --serve-models workspace # anyone's in the workspace: a shared Mac mini or GPU box
101
+ virtuai run --serve-models none # tools only, no model turns
102
+ ```
103
+
104
+ Each model call goes to the runner of the person the turn is for; if they have none, to a runner started with `--serve-models workspace`; if there is none either, the agent's **cloud fallback** answers that call and every channel shows "Modelo local no disponible: responde …". A runner that fails before it answers (Ollama stopped, the model unloaded) costs the call a cloud answer, not an error.
105
+
106
+ > **Upgrading from 1.0.2:** with a current server, an older runner serves only the person who paired it. A machine that serves a whole team needs 1.0.3 and `--serve-models workspace`.
107
+
108
+ ### What the runner tells the server
109
+
110
+ At connect and then every minute: the models it has, the Ollama version, what is loaded and how much memory it really took. With `llmfit` installed it also estimates which pulled models fit this machine, so the server does not send a model to a runner that cannot hold it until it has actually run there. A model that spills to the CPU gets half the context on its next call on that machine (down to 4k).
111
+
112
+ The context sent to Ollama is 8k by default (set on the server); `OLLAMA_NUM_CTX` overrides it on a machine with less memory. For qwen3 the runner turns Ollama's thinking mode off: left on, it reasoned for minutes before every tool call.
113
+
114
+ ### oMLX
115
+
116
+ The runner finds oMLX through its own settings (`~/.omlx/settings.json`: port and API key) or `VIRTUAI_OMLX_URL` / `OMLX_API_KEY`; the key never leaves the machine. Measured with `Qwen3-8B-4bit`: 10/10, 2.5 s per answer — on par with Ollama for one person. Its case is a shared host serving several people at once; raise `VIRTUAI_MAX_CONCURRENT_MODEL` there, since the runner otherwise sends one generation at a time.
86
117
 
87
118
  ## Environment variables
88
119
 
@@ -97,11 +128,15 @@ mean "allow more commands at once" silently launched more browsers.
97
128
  | `VIRTUAI_ISOLATE_SESSIONS` | off | Give each conversation its own directory under the workdir, and each person their own browser. Turn it on when one runner serves several people. Also set by `--isolate-sessions`. |
98
129
  | `VIRTUAI_MAX_NAMESPACES` | `5` | Browsers held at once — one per person. Each is a real headful Chrome (roughly 0.4–1 GB resident), so size this to the machine's memory: an OOM-killed Chrome is a worse failure than a clean refusal. Only applies with isolation on. Also set by `--max-namespaces`. |
99
130
  | `VIRTUAI_MAX_CONCURRENT_WORK` | `4` | Commands running at once. Each conversation is already capped at one by its own queue, so in practice this is how many conversations may be executing at the same instant. |
100
- | `VIRTUAI_MAX_CONCURRENT_MODEL` | `1` | Local model generations at once. Ollama does one well; its own parallelism lives in `OLLAMA_NUM_PARALLEL` on your machine, which the server cannot see. |
131
+ | `VIRTUAI_MAX_CONCURRENT_MODEL` | `1` | Local model generations at once. Ollama does one well; its own parallelism lives in `OLLAMA_NUM_PARALLEL` on your machine, which the server cannot see. Raise it for oMLX on a shared host, which batches several at once. |
101
132
  | `VIRTUAI_MAX_QUEUED_FRAMES` | `64` | Requests waiting for a slot. Past this the runner answers "busy" rather than queueing without bound. |
102
133
  | `VIRTUAI_SESSION_IDLE_TIMEOUT` | off (`0`) | Seconds before an idle browser stops holding its slot. Off by default: a slot normally frees only when its browser closes or the runner restarts. |
103
134
  | `VIRTUAI_SANDBOX_OWNER` | unset | The one person this runner serves, reported to the server so a browser action reaches *their* browser. Leave it unset on a laptop or a container shared by a team: the server then routes to it for anyone, which is the behaviour that existed before per-person sandboxes. Set it when a workspace has a sandbox per person, or actions land on whichever runner connected last. |
135
+ | `VIRTUAI_SERVE_MODELS` | `own` | Whose local-model turns this runner answers: `own`, `workspace` or `none`. Also set by `--serve-models`. |
104
136
  | `OLLAMA_BASE_URL` | `http://localhost:11434` | Where to reach the local Ollama. |
137
+ | `OLLAMA_NUM_CTX` | unset | The context to ask Ollama for, instead of the server's (8k). Lower it on a machine with little memory. |
138
+ | `VIRTUAI_OMLX_URL` | from `~/.omlx/settings.json`, else `http://127.0.0.1:8000` | Where to reach oMLX. |
139
+ | `OMLX_API_KEY` | from `~/.omlx/settings.json` | oMLX's API key. Stays on this machine. |
105
140
 
106
141
  Server-side, `VIRTUAI_ENV_WAIT_SECONDS` (default `45`) is how long the server
107
142
  holds a message waiting for a runner to reconnect before telling the user the
@@ -116,7 +151,7 @@ environment is not responding.
116
151
  | `virtuai opencode` | Merge this workspace's agents into the local OpenCode TUI config |
117
152
  | `virtuai pair <code>` | Pair this machine with a workspace |
118
153
  | `virtuai unpair` | Revoke the current pairing |
119
- | `virtuai run` | Headless WebSocket runner — let the web app/channels invoke local tools without opening the TUI |
154
+ | `virtuai run` | Headless runner — let the web app and channels use this machine's tools and local models without opening the TUI (`--serve-models own\|workspace\|none`) |
120
155
  | `virtuai status` | Show pairing and connection status |
121
156
  | `virtuai logs` | Show recent commands the agent executed locally |
122
157
  | `virtuai login` | Authenticate with your VirtuAI account (browser flow) |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "virtuai-cli"
7
- version = "1.0.1"
7
+ version = "1.0.3"
8
8
  description = "Run VirtuAI deep agents on your local machine"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -35,7 +35,12 @@ dependencies = [
35
35
  # Ollama. Install with: pip install 'virtuai-cli[local]'
36
36
  [project.optional-dependencies]
37
37
  local = [
38
- "langchain-ollama>=0.2",
38
+ "langchain-ollama>=0.3.4", # `reasoning` (think on/off)
39
+ # Which pulled models fit this machine, before they are first loaded
40
+ # (virtuai_cli/ollama_state.py). Ships its own binary in the wheel.
41
+ "llmfit>=1.1.16",
42
+ # oMLX (MLX on Apple Silicon) speaks the OpenAI API (virtuai_cli/omlx_state.py).
43
+ "langchain-openai>=0.3",
39
44
  ]
40
45
  browser = [
41
46
  "browser-use>=0.1.40",
@@ -1,2 +1,2 @@
1
1
  """VirtuAI local CLI."""
2
- __version__ = "1.0.1"
2
+ __version__ = "1.0.3"
@@ -242,6 +242,13 @@ def run(
242
242
  "per person. Each is a real Chrome (roughly half a gigabyte), so "
243
243
  "size this to the machine's memory. Default 5.",
244
244
  ),
245
+ serve_models: Optional[str] = typer.Option(
246
+ None,
247
+ "--serve-models",
248
+ help="Whose local-model turns this machine answers (Ollama or oMLX): "
249
+ "'own' (yours — the default), 'workspace' (anyone's: a shared "
250
+ "inference host such as a Mac mini or a GPU box) or 'none'.",
251
+ ),
245
252
  ):
246
253
  """Start the local runner. Keep this running while using VirtuAI."""
247
254
  lock = None
@@ -276,6 +283,11 @@ def run(
276
283
  # None means "not passed", so the env var a container already set survives.
277
284
  if max_namespaces is not None:
278
285
  os.environ["VIRTUAI_MAX_NAMESPACES"] = str(max_namespaces)
286
+ if serve_models is not None:
287
+ if serve_models not in ("own", "workspace", "none"):
288
+ console.print("[red]--serve-models must be own, workspace or none.[/red]")
289
+ raise typer.Exit(2)
290
+ os.environ["VIRTUAI_SERVE_MODELS"] = serve_models
279
291
  console.print(f"[bold]VirtuAI CLI[/bold] v{__version__} — Press Ctrl+C to stop.")
280
292
  # Re-read rather than testing the flags, so the line reports the effective
281
293
  # value whichever way it was set.
@@ -289,6 +301,19 @@ def run(
289
301
  )
290
302
  except ImportError: # the browser extra is not installed
291
303
  pass
304
+ from virtuai_cli.runner import _serve_models
305
+ if _serve_models() != "none":
306
+ from virtuai_cli import ollama_state
307
+ _ollama = asyncio.run(ollama_state.probe())
308
+ if ollama_state.outdated(_ollama.get("version")):
309
+ console.print(
310
+ f"[yellow]Ollama {_ollama['version']} is old: before 0.40 it held the same "
311
+ "models in two to three times the memory. Update it for local models.[/yellow]"
312
+ )
313
+ if _serve_models() == "workspace":
314
+ console.print("[dim]Serving local-model turns for the whole workspace[/dim]")
315
+ elif _serve_models() == "none":
316
+ console.print("[dim]Not serving local-model turns[/dim]")
292
317
 
293
318
  from virtuai_cli.runner import run_forever
294
319
  try:
@@ -0,0 +1,245 @@
1
+ """What this machine's Ollama can serve, kept current for the server.
2
+
3
+ The runner used to report Ollama once, at connect: a model pulled afterwards,
4
+ or Ollama stopped, was invisible to the server until the runner reconnected.
5
+ And what a model needs was assumed, not measured — on a 16 GB Mac, Ollama
6
+ 0.35 held qwen3:8b in 15 GB with a quarter on CPU (0.1 tokens/s), while 0.40
7
+ holds it in 6.6 GB.
8
+
9
+ - `probe()` — reachable, models, version, and what is loaded with its real
10
+ footprint (`/api/ps`).
11
+ - `status_loop()` — re-probes every STATUS_SECONDS and sends what changed.
12
+ - `fit_report()` — with llmfit installed (the `[local]` extra), an estimate
13
+ of which pulled models fit this hardware, before they are first loaded.
14
+ - `after_call()` / `context_for()` — a model that spilled to CPU gets half
15
+ the context on its next call here, down to MIN_NUM_CTX.
16
+
17
+ docs/workitems/local-models.md (F5).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import json
24
+ import logging
25
+ import os
26
+ import shutil
27
+ import sys
28
+ from pathlib import Path
29
+ from typing import Any, Awaitable, Callable, Optional
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # The same endpoint the runner answers model_generate frames from.
34
+ OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
35
+
36
+ STATUS_SECONDS = 60.0
37
+ #: Below this, Ollama held the same tags in two to three times the memory.
38
+ MIN_GOOD_VERSION = (0, 40)
39
+ MIN_NUM_CTX = 4_096
40
+ _LLMFIT_TIMEOUT = 90.0
41
+
42
+ #: model tag -> the context it is capped at here, after it spilled to CPU.
43
+ _ctx_cap: dict[str, int] = {}
44
+ #: The last fit report, recomputed only when the pulled models change.
45
+ _fit: dict[str, Any] = {"models": None, "report": {}}
46
+
47
+
48
+ def _url(path: str) -> str:
49
+ return f"{OLLAMA_BASE_URL.rstrip('/')}{path}"
50
+
51
+
52
+ async def probe() -> dict:
53
+ """{"reachable", "models", "version", "loaded"}; never raises."""
54
+ try:
55
+ import httpx
56
+ except Exception:
57
+ return {"reachable": False, "models": []}
58
+ try:
59
+ async with httpx.AsyncClient(timeout=2.0) as client:
60
+ resp = await client.get(_url("/api/tags"))
61
+ resp.raise_for_status()
62
+ models = [m.get("name", "") for m in resp.json().get("models", []) if m.get("name")]
63
+ state: dict = {"reachable": True, "models": models}
64
+ # The version decides more than the model's size: 0.35 needed two
65
+ # to three times the memory 0.40 does for the same tag.
66
+ state["version"] = await _get_json(client, "/api/version", "version")
67
+ state["loaded"] = _loaded(await _get_json(client, "/api/ps", "models") or [])
68
+ if _ctx_cap:
69
+ state["context_cap"] = dict(_ctx_cap)
70
+ return state
71
+ except Exception:
72
+ return {"reachable": False, "models": []}
73
+
74
+
75
+ async def _get_json(client: Any, path: str, key: str) -> Any:
76
+ try:
77
+ resp = await client.get(_url(path))
78
+ return resp.json().get(key) if resp.status_code == 200 else None
79
+ except Exception:
80
+ return None
81
+
82
+
83
+ def _loaded(running: list) -> dict:
84
+ """{tag: {"size_gb", "on_gpu_pct", "num_ctx"}} for each loaded model."""
85
+ out = {}
86
+ for m in running:
87
+ size, vram = m.get("size") or 0, m.get("size_vram") or 0
88
+ if not m.get("name") or not size:
89
+ continue
90
+ out[m["name"]] = {"size_gb": round(size / 1e9, 2),
91
+ "on_gpu_pct": round(100 * vram / size),
92
+ "num_ctx": m.get("context_length")}
93
+ return out
94
+
95
+
96
+ def outdated(version: Optional[str]) -> bool:
97
+ """True for an Ollama older than MIN_GOOD_VERSION; False when unknown."""
98
+ try:
99
+ parts = tuple(int(p) for p in (version or "").split(".")[:2])
100
+ except ValueError:
101
+ return False
102
+ return len(parts) == 2 and parts < MIN_GOOD_VERSION
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Context per model, from what it really took
107
+ # ---------------------------------------------------------------------------
108
+
109
+ def context_for(model: str, requested: Optional[int]) -> Optional[int]:
110
+ """The context to ask for: the server's, unless this model spilled here."""
111
+ cap = _ctx_cap.get(model)
112
+ if cap is None:
113
+ return requested
114
+ return min(requested, cap) if requested else cap
115
+
116
+
117
+ async def after_call(model: str) -> None:
118
+ """Halve a model's context here if it did not fit on the GPU."""
119
+ state = await probe()
120
+ info = (state.get("loaded") or {}).get(model)
121
+ if not info or info.get("on_gpu_pct", 100) >= 100:
122
+ return
123
+ current = info.get("num_ctx") or _ctx_cap.get(model) or 8_192
124
+ lower = max(MIN_NUM_CTX, current // 2)
125
+ if lower < current:
126
+ _ctx_cap[model] = lower
127
+ logger.warning("%s ran %s%% on GPU at %s tokens of context; next calls use %s",
128
+ model, info.get("on_gpu_pct"), current, lower)
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # llmfit: an estimate before a model is first loaded
133
+ # ---------------------------------------------------------------------------
134
+
135
+ def _llmfit() -> Optional[str]:
136
+ """The llmfit binary: on PATH, or beside this interpreter (the pip wheel)."""
137
+ found = shutil.which("llmfit")
138
+ if found:
139
+ return found
140
+ beside = Path(sys.executable).parent / ("llmfit.exe" if os.name == "nt" else "llmfit")
141
+ return str(beside) if beside.exists() else None
142
+
143
+
144
+ async def _run_json(*args: str) -> Optional[dict]:
145
+ try:
146
+ proc = await asyncio.create_subprocess_exec(
147
+ *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
148
+ out, _ = await asyncio.wait_for(proc.communicate(), timeout=_LLMFIT_TIMEOUT)
149
+ return json.loads(out) if proc.returncode == 0 else None
150
+ except Exception as exc:
151
+ logger.debug("llmfit %s failed: %s", args[1:3], exc)
152
+ return None
153
+
154
+
155
+ async def _quantization(model: str) -> Optional[str]:
156
+ try:
157
+ import httpx
158
+
159
+ async with httpx.AsyncClient(timeout=5.0) as client:
160
+ resp = await client.post(_url("/api/show"), json={"model": model})
161
+ return (resp.json().get("details") or {}).get("quantization_level") if resp.status_code == 200 else None
162
+ except Exception:
163
+ return None
164
+
165
+
166
+ async def fit_report(models: list[str], num_ctx: int) -> dict:
167
+ """{tag: {"fits", "memory_gb", "tps"}} for the pulled models llmfit knows.
168
+
169
+ Estimated at the quantization Ollama actually holds (llmfit's default is
170
+ Q8_0; Ollama's tags are mostly Q4_K_M). Measured on a 16 GB M3 against
171
+ Ollama 0.40: within ~0.5 GB. Empty without llmfit.
172
+ """
173
+ binary = _llmfit()
174
+ if not binary or not models:
175
+ return {}
176
+ catalog = await _run_json(binary, "--max-context", str(num_ctx), "recommend", "--no-dashboard",
177
+ "-n", "100000", "--force-runtime", "llamacpp", "--json")
178
+ names = {}
179
+ for m in (catalog or {}).get("models") or []:
180
+ tag = m.get("ollama_name")
181
+ if tag and tag not in names:
182
+ names[tag] = m.get("name")
183
+ report = {}
184
+ for tag in models:
185
+ hf = names.get(tag) or names.get(tag.split(":")[0] + ":latest")
186
+ if not hf:
187
+ continue
188
+ args = [binary, "plan", hf, "--context", str(num_ctx), "--json", "--no-dashboard"]
189
+ quant = await _quantization(tag)
190
+ if quant:
191
+ args += ["--quant", quant]
192
+ plan = await _run_json(*args)
193
+ if not plan:
194
+ continue
195
+ current = plan.get("current") or {}
196
+ level = current.get("fit_level")
197
+ report[tag] = {"fits": level in ("Perfect", "Good", "Marginal"), "fit_level": level,
198
+ "memory_gb": round((plan.get("minimum") or {}).get("vram_gb") or 0, 2),
199
+ "tps": round(current.get("estimated_tps") or 0, 1)}
200
+ return report
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # Keeping the server current
205
+ # ---------------------------------------------------------------------------
206
+
207
+ async def current(num_ctx: int) -> dict:
208
+ """The probe, with the fit report for the models pulled now."""
209
+ state = await probe()
210
+ models = state.get("models") or []
211
+ if state.get("reachable") and models != _fit["models"]:
212
+ _fit["models"], _fit["report"] = list(models), await fit_report(models, num_ctx)
213
+ if _fit["report"]:
214
+ state["fit"] = _fit["report"]
215
+ return state
216
+
217
+
218
+ def _num_ctx() -> int:
219
+ try:
220
+ return int(os.environ.get("OLLAMA_NUM_CTX") or 8_192)
221
+ except ValueError:
222
+ return 8_192
223
+
224
+
225
+ async def snapshot() -> dict:
226
+ """{"ollama": ..., "omlx": ...}: what a runner_status carries."""
227
+ from virtuai_cli import omlx_state
228
+
229
+ return {"ollama": await current(_num_ctx()), "omlx": await omlx_state.probe()}
230
+
231
+
232
+ async def status_loop(send: Callable[[dict], Awaitable[None]], interval: float = STATUS_SECONDS) -> None:
233
+ """Send the local model servers' state whenever it changes; runs until cancelled."""
234
+ last = None
235
+ while True:
236
+ try:
237
+ state = await snapshot()
238
+ if state != last:
239
+ await send(state)
240
+ last = state
241
+ except asyncio.CancelledError:
242
+ raise
243
+ except Exception as exc: # a failed report must not take the runner down
244
+ logger.debug("Ollama status not sent: %s", exc)
245
+ await asyncio.sleep(interval)
@@ -0,0 +1,83 @@
1
+ """oMLX on this machine: where it listens, what it serves (F6).
2
+
3
+ oMLX (https://github.com/jundot/omlx) serves MLX models on Apple Silicon
4
+ behind an OpenAI-compatible API. It used to be called from the *server*, at
5
+ localhost — which is the server's own machine, so it only ever worked in
6
+ development. Now it is a runner-side provider like Ollama: the runner calls
7
+ it, and its API key stays on this machine.
8
+
9
+ Where it listens and its key: VIRTUAI_OMLX_URL and OMLX_API_KEY, else what
10
+ oMLX's own settings say (~/.omlx/settings.json), else localhost:8000.
11
+
12
+ Thinking: a model the server says has a thinking mode to turn off (qwen3)
13
+ gets `chat_template_kwargs: {"enable_thinking": false}` on every call — the
14
+ same value every time, since an MLX server's prompt cache is keyed on the
15
+ rendered template and a value that changed between calls would miss it.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+ _SETTINGS = Path.home() / ".omlx" / "settings.json"
26
+ _DEFAULT_URL = "http://127.0.0.1:8000"
27
+
28
+
29
+ def _settings() -> dict:
30
+ try:
31
+ return json.loads(_SETTINGS.read_text())
32
+ except Exception:
33
+ return {}
34
+
35
+
36
+ def base_url() -> str:
37
+ """oMLX's root URL, without /v1."""
38
+ env = (os.environ.get("VIRTUAI_OMLX_URL") or "").strip()
39
+ if env:
40
+ return env.rstrip("/").removesuffix("/v1")
41
+ server = _settings().get("server") or {}
42
+ if server.get("port"):
43
+ return f"http://{server.get('host') or '127.0.0.1'}:{server['port']}"
44
+ return _DEFAULT_URL
45
+
46
+
47
+ def api_key() -> Optional[str]:
48
+ key = (os.environ.get("OMLX_API_KEY") or "").strip()
49
+ return key or ((_settings().get("auth") or {}).get("api_key") or None)
50
+
51
+
52
+ async def probe() -> dict:
53
+ """{"reachable", "models"}; never raises."""
54
+ try:
55
+ import httpx
56
+
57
+ headers = {"Authorization": f"Bearer {api_key()}"} if api_key() else {}
58
+ async with httpx.AsyncClient(timeout=2.0) as client:
59
+ resp = await client.get(f"{base_url()}/v1/models", headers=headers)
60
+ resp.raise_for_status()
61
+ data = resp.json()
62
+ items = data if isinstance(data, list) else data.get("data") or []
63
+ return {"reachable": True,
64
+ "models": [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]}
65
+ except Exception:
66
+ return {"reachable": False, "models": []}
67
+
68
+
69
+ def chat_model(model: str, temperature: Optional[float], max_tokens: Optional[int],
70
+ think: Optional[bool] = None):
71
+ """The LangChain model the runner streams an oMLX call from."""
72
+ from langchain_openai import ChatOpenAI
73
+
74
+ kwargs = {"model": model, "base_url": f"{base_url()}/v1",
75
+ # oMLX may skip key checks; the client still wants one.
76
+ "api_key": api_key() or "omlx", "stream_usage": True}
77
+ if think is not None:
78
+ kwargs["extra_body"] = {"chat_template_kwargs": {"enable_thinking": bool(think)}}
79
+ if temperature is not None:
80
+ kwargs["temperature"] = temperature
81
+ if max_tokens is not None:
82
+ kwargs["max_tokens"] = max_tokens
83
+ return ChatOpenAI(**kwargs)