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.
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/PKG-INFO +53 -16
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/README.md +49 -14
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/pyproject.toml +7 -2
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/__init__.py +1 -1
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/main.py +25 -0
- virtuai_cli-1.0.3/src/virtuai_cli/ollama_state.py +245 -0
- virtuai_cli-1.0.3/src/virtuai_cli/omlx_state.py +83 -0
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/pull.py +23 -2
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/runner.py +127 -56
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli.egg-info/PKG-INFO +53 -16
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli.egg-info/SOURCES.txt +5 -0
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli.egg-info/requires.txt +3 -1
- virtuai_cli-1.0.3/tests/test_ollama_options.py +136 -0
- virtuai_cli-1.0.3/tests/test_ollama_state.py +146 -0
- virtuai_cli-1.0.3/tests/test_omlx.py +112 -0
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/setup.cfg +0 -0
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/browser.py +0 -0
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/chat/__init__.py +0 -0
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/chat/agents.py +0 -0
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/chat/ask.py +0 -0
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/chat/command.py +0 -0
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/chat/history.py +0 -0
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/chat/sse.py +0 -0
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/chat/tui.py +0 -0
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/chat/widgets.py +0 -0
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/config.py +0 -0
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/executor.py +0 -0
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/opencode.py +0 -0
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli/security.py +0 -0
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/src/virtuai_cli.egg-info/top_level.txt +0 -0
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/tests/test_browser.py +0 -0
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/tests/test_frame_concurrency.py +0 -0
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/tests/test_process_group_kill.py +0 -0
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/tests/test_pull_fallback.py +0 -0
- {virtuai_cli-1.0.1 → virtuai_cli-1.0.3}/tests/test_run_single_instance.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
94
|
+
## Local model inference
|
|
93
95
|
|
|
94
|
-
Agents can run inference on **your own machine**
|
|
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
|
-
|
|
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
|
|
104
|
+
2. Install Ollama **0.40 or newer** and pull a validated model:
|
|
103
105
|
```bash
|
|
104
|
-
ollama
|
|
105
|
-
ollama pull qwen3:8b # instruct/agentic models work best for tool calling
|
|
106
|
+
ollama pull qwen3:8b
|
|
106
107
|
```
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
112
|
+
### Which models
|
|
111
113
|
|
|
112
|
-
|
|
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
|
|
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
|
|
65
|
+
## Local model inference
|
|
66
66
|
|
|
67
|
-
Agents can run inference on **your own machine**
|
|
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
|
-
|
|
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
|
|
75
|
+
2. Install Ollama **0.40 or newer** and pull a validated model:
|
|
76
76
|
```bash
|
|
77
|
-
ollama
|
|
78
|
-
ollama pull qwen3:8b # instruct/agentic models work best for tool calling
|
|
77
|
+
ollama pull qwen3:8b
|
|
79
78
|
```
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
83
|
+
### Which models
|
|
84
84
|
|
|
85
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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)
|