Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixed

- Tab completion after `gmlx image generate` and `gmlx image bench` offered
only files, not the image models of your configuration.
- A large answer relayed into a `gmlx launch --container` session could end
early when the client read it slowly.
- `gmlx distill gen` stopped with Ctrl-C could leave out a reply that had
already arrived.
- A chat template with a `{% generation %}` block was refused by
Expand Down
24 changes: 23 additions & 1 deletion gmlx/commands/completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,25 @@ def _model_candidates(words: list[str]) -> list[str]:
return out


def _image_model_candidates(words: list[str]) -> list[str]:
"""``id<TAB>dit file`` lines for every ``image_models`` entry in the
resolved config."""
path = _config_path_from(words)
if not path or not os.path.exists(os.path.expanduser(path)):
return []
import gmlx.config as cfgmod

try:
cfg = cfgmod.load_config(os.path.expanduser(path))
except Exception: # noqa: BLE001 - a malformed config just yields no model names
return []
out: list[str] = []
for mid, m in cfg.image_models.items():
base = os.path.basename(getattr(m, "path", "") or "")
out.append(f"{mid}\t{base}" if base else mid)
return out


# Launch targets that are not coding agents, grouped as launch.py groups them.
_HARNESS_KINDS = {
"hermes": "agent runtime", "goose": "agent runtime",
Expand Down Expand Up @@ -472,8 +491,11 @@ def _positional_candidates(verb: str, after_verb: list[str]) -> list[str]:
return [] if filled else [f"{a}\timage action" for a in _IMAGE_ACTIONS]
if verb == "comfyui":
return [] if filled else [f"{a}\tcomfyui action" for a in _COMFYUI_ACTIONS]
if verb in ("image generate", "image bench"):
# an image_models id of the config, or a DiT GGUF
return [] if filled else ["::files", *_image_model_candidates(after_verb)]
if verb.startswith("image "):
# generate and bench take a DiT GGUF, pack-vae a safetensors VAE
# pack-vae takes a safetensors VAE
return [] if filled else ["::files"]
return []

Expand Down
7 changes: 5 additions & 2 deletions gmlx/container/relay.py
Original file line number Diff line number Diff line change
Expand Up @@ -769,8 +769,11 @@ def _on_down(self, mask: int) -> None:
if not self.checking:
self._note_down(data)
if mask & _WRITE and self.to_down:
sent = self.down.send(self.to_down)
del self.to_down[:sent]
# A Unix socket can refuse a send that select called ready.
try:
del self.to_down[:self.down.send(self.to_down)]
except (BlockingIOError, InterruptedError):
pass
except OSError:
self.close()
return
Expand Down
24 changes: 24 additions & 0 deletions tests/commands/test_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,30 @@ def test_model_positional_drops_off_once_model_given(tmp_path):
assert completion._complete(["run", "--config", cfg, "qwen-fast", ""]) == []


def test_image_generate_and_bench_list_image_model_ids_and_files(tmp_path):
cfg = tmp_path / "models.yaml"
cfg.write_text(textwrap.dedent("""
server:
model_dirs: []
models:
qwen-fast:
path: /tmp/qwen.gguf
image_models:
moss-image-q4:
path: /tmp/MOSS-Image-SFT-Q4_K_M.gguf
encoder: qwen-fast
vae: /tmp/moss-vae.gguf
""").strip())
for action in ("generate", "bench"):
lines = completion._complete(["image", action, "--config", str(cfg), ""])
assert lines[0] == "::files"
assert "moss-image-q4\tMOSS-Image-SFT-Q4_K_M.gguf" in lines
assert "qwen-fast" not in _vals(lines) # chat models are not image models
assert completion._complete(
["image", action, "--config", str(cfg), "moss-image-q4", ""]) == []
assert completion._complete(["image", "pack-vae", ""]) == ["::files"]


def test_talk_positional_lists_model_ids_without_files(tmp_path):
cfg = _write_cfg(tmp_path)
lines = completion._complete(["talk", "--config", cfg, ""])
Expand Down
33 changes: 33 additions & 0 deletions tests/container/test_container_relay.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,39 @@ def flood():
assert got == total


def test_a_refused_send_to_the_client_keeps_the_stream(loop, tmp_path):
"""macOS can refuse a send on a Unix socket that select reported ready.
The relay keeps the bytes and sends them later instead of closing."""
target = relay.listen_socket(str(tmp_path / "t.sock"))
down, client = socket.socketpair()
try:
down.setblocking(False)
filled = 0
while True: # fill the client buffer
try:
filled += down.send(b"x" * 65536)
except BlockingIOError:
break
pair = _in_loop(loop, lambda: relay._Pair(loop, down, [str(tmp_path / "t.sock")],
"forward 1"))

def refused_send():
pair.to_down += b"y" * 4096
pair._on_down(relay._WRITE)
return pair.closed, len(pair.to_down)
assert _in_loop(loop, refused_send) == (False, 4096)
client.settimeout(10)
got = b""
while len(got) < filled + 4096:
chunk = client.recv(1 << 20)
assert chunk
got += chunk
assert got[filled:] == b"y" * 4096
finally:
client.close()
target.close()


def test_forward_never_falls_back_to_ipv6_loopback(loop, tmp_path):
"""A program on ::1 at the forwarded port can be another one than the
Mac service the user named, so the forward reaches 127.0.0.1 only."""
Expand Down
Loading