Skip to content

feat: add LM Studio as first-class LLM provider - #1

Merged
dpsoft merged 5 commits into
mainfrom
feature/lm-studio-provider
Feb 22, 2026
Merged

feat: add LM Studio as first-class LLM provider #1
dpsoft merged 5 commits into
mainfrom
feature/lm-studio-provider

Conversation

@ramirolaso

Copy link
Copy Markdown
Contributor

Summary

  • Adds LlmProvider::LmStudio variant alongside the existing Claude, Ollama, and Custom providers
  • Injects ANTHROPIC_BASE_URL=http://10.0.2.2:1234 and ANTHROPIC_API_KEY=lm-studio into the guest VM so claude-code talks to the local LM Studio server via the SLIRP gateway — no proxy needed (LM Studio 0.3.x+ exposes a native
    Anthropic-compatible Messages API)
  • Adds lm_studio(model) and lm_studio_with_host(model, host) constructors, and extends all match arms: cli_args(), env_vars(), is_local(), description(), and the model() builder
  • Adds LM_STUDIO_MODEL detection in detect_llm_provider() (checked before OLLAMA_MODEL)
  • Adds examples/lm_studio_local.rs — a runnable example mirroring ollama_local.rs, with KVM/mock fallback and LM Studio-specific prerequisites documented
  • Bumps default VM memory to 2048 MB across all local-LLM examples and common::make_box — necessary because claude-code (~250 MB RSS) + a full initramfs (~299 MB decompressed) exceeds 1 GB

Test plan

  • cargo test -p void-box -- llm::tests — all 17 tests pass (5 new LM Studio tests added)
  • cargo build — compiles clean
  • cargo clippy -- -D warnings — no warnings
  • Mock mode: LM_STUDIO_MODEL=deepseek-r1-distill-llama-8b cargo run --example lm_studio_local runs without KVM
  • KVM mode: example boots guest, vsock connects, and claude-code calls LM Studio at http://10.0.2.2:1234

LM Studio prerequisite

In the LM Studio app → Local Server tab → Start Server (default port 1234). The server must listen on 0.0.0.0, not just 127.0.0.1, for the SLIRP gateway to reach it from inside the VM — same constraint as Ollama.

@dpsoft
dpsoft requested a review from Copilot February 22, 2026 15:48
@dpsoft
dpsoft merged commit ca63302 into main Feb 22, 2026
10 checks passed
@dpsoft
dpsoft deleted the feature/lm-studio-provider branch February 22, 2026 15:50

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds LM Studio as a first-class LLM provider alongside the existing Claude, Ollama, and Custom providers. It enables void-box to use local LM Studio instances running on the host machine, leveraging LM Studio 0.3.x+'s native Anthropic-compatible API via SLIRP networking. The implementation also includes a refactoring of the kernel module installation script to support newer .ko.zst compression formats, and bumps VM memory for local LLM examples to accommodate the larger resource requirements.

Changes:

  • Adds LlmProvider::LmStudio variant with constructor methods and full integration into the provider system
  • Refactors kernel module installation in build script to support .ko.xz, .ko.zst, and uncompressed .ko formats
  • Adds comprehensive test coverage for LM Studio provider (5 new tests)
  • Increases VM memory from 1024 MB to 2048 MB for local LLM examples to handle claude-code + initramfs requirements
  • Adds LM_STUDIO_MODEL detection to the provider auto-detection logic

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/llm.rs Adds LmStudio enum variant, constructors, and implementations for all provider methods (cli_args, env_vars, is_local, description, model builder); includes comprehensive test coverage
scripts/build_guest_image.sh Refactors kernel module installation to handle multiple compression formats (.ko.xz, .ko.zst, .ko) with built-in module detection
examples/lm_studio_local.rs New runnable example demonstrating LM Studio integration with KVM/mock fallback, mirroring the ollama_local.rs pattern
examples/ollama_local.rs Updates memory allocation from 256 MB to 2048 MB for local LLM workloads
examples/common/mod.rs Adds LM_STUDIO_MODEL detection in detect_llm_provider() and updates default memory to 2048 MB
.gitignore Adds IDE and local utility directories (.claude, .idea, .local-utils)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread examples/common/mod.rs
Comment on lines 33 to 37
/// Detect the LLM provider from environment variables.
///
/// - `OLLAMA_MODEL=qwen3-coder` -> Ollama with that model
/// - `LLM_BASE_URL=...` -> Custom provider
/// - Otherwise -> Claude (default)

Copilot AI Feb 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation comment should be updated to include the newly added LM Studio detection. It currently lists Ollama and LLM_BASE_URL options, but omits LM_STUDIO_MODEL which is now checked first in the implementation.

Copilot uses AI. Check for mistakes.
//! 4. Build the guest initramfs:
//! ```
//! CLAUDE_CODE_BIN=$(which claude) BUSYBOX=/usr/bin/busybox \
//! scripts/build_claude_rootfs.sh

Copilot AI Feb 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation references scripts/build_claude_rootfs.sh, but the Ollama example uses scripts/build_guest_image.sh. These should be consistent. Verify which script name is correct and update the documentation accordingly.

Suggested change
//! scripts/build_claude_rootfs.sh
//! scripts/build_guest_image.sh

Copilot uses AI. Check for mistakes.
dpsoft added a commit that referenced this pull request May 4, 2026
Two artifacts from the rebase onto the new #69 tip:

1. bind_port_forward_listeners was called twice in with_security
   after the rebase — the second call shadowed the first, which
   was harmless but emitted an unused-variable warning under
   -D warnings. Drop the duplicate.

2. The bench-helpers helper insert_synthetic_lastack_entry was
   missing flow_token in its TcpNatEntry initializer (the field
   was added by the Copilot fix #1 monotonic-token rewrite that
   landed on #69 after the original phase 6.1 commit chain was
   written). Allocate via next_flow_token(PROTO_TAG_TCP).
dpsoft added a commit that referenced this pull request May 6, 2026
…ing (#76)

* docs: Phase 6.1 detailed TDD plan — TCP half-close

9 bite-sized tasks covering the half-close state machine:
- Established → FinWait1 (guest closed first) → LastAck on host EOF
- Established → CloseWait (host closed first) → LastAck on guest FIN
- LastAck → Closed on guest's final ACK or LAST_ACK_TIMEOUT (60s)

Three new pins: tcp_half_close_guest_writes_first (BROKEN_ON_PURPOSE
flips at Task 3), tcp_half_close_host_writes_first (passes after
Task 3), tcp_last_ack_timeout_reaps_stale_entry (bench-helpers
gated, Task 7).

Severity: HIGH — current code drops host's pending response on
guest shutdown(SHUT_WR), causing silent data loss for HTTP, SMTP,
and any protocol with orderly half-close.

* test(network): pin tcp_half_close_guest_writes_first (BROKEN_ON_PURPOSE)

Guest sends FIN after data; current code marks state=Closed immediately
on guest FIN, so the host's response data is dropped. This pin locks
the broken behavior so the Phase 6.1 fix is legible to reviewers.

Flips to PASS when Tasks 2–3 implementation lands.

* feat(slirp): FinWait1 → LastAck and Established → CloseWait on host EOF

Tasks 2+3+4 implemented together: clippy's -D warnings requires our_fin_sent
to be used before committing the field addition, so the relay loop changes
from Task 3 and the ACK handler from Task 4 are bundled with Task 2's struct
changes.

Structural changes (TcpNatEntry):
- Add last_state_change: Instant — tracks when state last changed, used
  for LAST_ACK_TIMEOUT reaping (Task 6).
- Add our_fin_sent: bool — prevents re-sending FIN on repeated epoll
  readiness events for the same half-close transition.
- Initialize both fields at all three TcpNatEntry literal sites.

FIN handler (handle_tcp_frame):
- Established → FinWait1: ACK guest FIN, shutdown(Write) host socket
  so host sees EOF; stay alive for host's pending response data.
- CloseWait → LastAck: host already closed; guest just closed; ACK
  and wait for guest's final ACK of our FIN.

ACK handler (handle_tcp_frame):
- LastAck → Closed: guest's final ACK reaps the entry.
- Extend ACK-driven consume to FinWait1 state so bytes_in_flight
  drains and recv_peek eventually sees Ok(0) (host EOF).

Relay loop (relay_tcp_nat_data):
- Extend data relay to FinWait1 in addition to Established.
- Ok(0) arm now dispatches on state: Established → CloseWait,
  FinWait1 → LastAck (instead of immediate Closed).
- FIN-emit logic uses our_fin_sent guard and needs_fin predicate
  (CloseWait | LastAck) to send FIN exactly once per transition.

Test: update tcp_half_close_guest_writes_first to ACK received data
segments so the ACK-driven consume path drains the kernel buffer
and recv_peek sees EOF. Without ACKs the FinWait1 relay sees the
same bytes on every peek and never reaches Ok(0).

* test(network): pin tcp_half_close_host_writes_first

Symmetric mirror of tcp_half_close_guest_writes_first: host writes
GREETING, shuts down its write side (Established → CloseWait), and waits
for guest to reply; guest sends REPLY data + FIN.

Also fix two implementation gaps found while writing this pin:

1. Guest→host data forwarding in CloseWait: the payload forward guard
   was Established-only. CloseWait must also forward guest data (host
   closed its write side but can still read). Extend the predicate to
   include CloseWait.

2. shutdown(Write) on CloseWait → LastAck: when the guest sends FIN in
   CloseWait the host application is blocked on read_to_end. We must
   call shutdown(Write) on host_stream so the kernel delivers EOF to
   the host. Without this the host blocks forever.

* feat(slirp): LAST_ACK_TIMEOUT reaping prevents LastAck entry leak

Add LAST_ACK_TIMEOUT = 60 s (TCP MSL × 2) as a module-scope constant.

Merge the LastAck-timeout check into the existing idle-timeout sweep in
relay_tcp_nat_data, making a single O(n) pass handle both conditions:

- Standard idle-timeout (300 s of inactivity, any state): unchanged.
- LastAck-timeout (60 s since last_state_change, LastAck state): reaps
  entries where the guest's final ACK never arrived, e.g. after a guest
  crash. Logs at WARN so operators can connect the timeout to a prior
  half-close sequence without a separate debugging session.

The combined sweep avoids a second loop and an extra to_remove Vec
allocation. Using last_state_change (set on every state transition)
rather than last_activity (set on data relay) gives an accurate 60 s
window from the moment we sent our FIN.

* test(network): pin tcp_last_ack_timeout_reaps_stale_entry (bench-helpers)

Add a bench-helpers–gated pin that verifies the LAST_ACK_TIMEOUT reaper:

1. Insert a synthetic LastAck entry via the new bench-helpers helper
   insert_synthetic_lastack_entry (TcpNatState::LastAck, our_fin_sent=true).
2. Back-date last_state_change by 70 s (> LAST_ACK_TIMEOUT = 60 s) via
   set_synthetic_last_state_change.
3. One drain_to_guest cycle runs the timeout sweep.
4. Assert the entry is gone (tcp_flow_state returns None).

Two new bench-helpers methods on SlirpBackend:
- insert_synthetic_lastack_entry: seeds a LastAck flow without a
  full half-close exchange, for timeout reaping tests.
- set_synthetic_last_state_change: back-dates last_state_change on an
  existing entry to simulate an expired LAST_ACK_TIMEOUT.

Also widen TcpNatState and tcp_flow_state to pub so the bench-helpers–
gated external test can reference them by path. Both were previously
pub(crate); they are now observable as part of the bench-helpers surface.

* refactor(slirp): drop allow(dead_code) on TcpNatState; remove unused FinWait2

All TcpNatState variants are now wired into the state machine:
- SynReceived, SynSent: handshake paths (unchanged)
- Established: normal data transfer (unchanged)
- FinWait1: guest half-closed; relay continues forwarding host response
- CloseWait: host half-closed; relay continues forwarding guest data
- LastAck: both sides closed; waiting for guest's final ACK or timeout
- Closed: entry pending removal

Remove FinWait2: distinguishing it from FinWait1 would require
observing per-segment ACKs from the kernel — the relay does not track
those. The relay stays in FinWait1 until host EOF, then jumps to LastAck.
If the distinction is needed later, the variant can be re-added.

Update the TcpNatState doc comment to document the state machine diagram
and the FinWait2 omission rationale, replacing the old per-variant stubs.

* fix(slirp): drop duplicate bind + init flow_token in lastack helper

Two artifacts from the rebase onto the new #69 tip:

1. bind_port_forward_listeners was called twice in with_security
   after the rebase — the second call shadowed the first, which
   was harmless but emitted an unused-variable warning under
   -D warnings. Drop the duplicate.

2. The bench-helpers helper insert_synthetic_lastack_entry was
   missing flow_token in its TcpNatEntry initializer (the field
   was added by the Copilot fix #1 monotonic-token rewrite that
   landed on #69 after the original phase 6.1 commit chain was
   written). Allocate via next_flow_token(PROTO_TAG_TCP).

* fix(slirp): doc-link to bench-helpers method via Self::

A bare intra-doc-link (`[`set_synthetic_last_state_change`]`) doesn't
resolve to a struct method even when both items live in the same
`impl` block — rustdoc looks up bare names in the module scope, not
the enclosing impl. CI's `-D warnings` then elevates the
`broken_intra_doc_links` warning to an error and fails the
Documentation job. Switch to `Self::set_synthetic_last_state_change`
so rustdoc resolves through the impl.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants