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
13 changes: 12 additions & 1 deletion src/modeldock/adapters/runtimes/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
)
from modeldock.common.logging import get_logger
from modeldock.domain.model import ModelRef, ModelSpec, RuntimeBackend
from modeldock.ports.runtime import PullResult
from modeldock.ports.runtime import PullResult, RunResult

_AVAILABILITY_TTL = 5.0

Expand Down Expand Up @@ -99,5 +99,16 @@ def remove(self, ref: ModelRef) -> None:
"""Uninstall ``ref``."""
raise NotImplementedError

def run(self, ref: ModelRef, prompt: Optional[str] = None, **opts: Any) -> RunResult:
"""Run an interactive session; unsupported by default.

Concrete runtimes override this when they can drive an interactive
session. The base default signals "not supported" so callers get a
clear, actionable error rather than a silent no-op.
"""
raise NotImplementedError(
f"Runtime {self.backend.value!r} does not support interactive run sessions."
)


__all__ = ["BaseRuntime"]
78 changes: 77 additions & 1 deletion src/modeldock/adapters/runtimes/ollama.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@
from modeldock.common.errors import (
DownloadError,
ModelDockError,
ModelNotInstalledError,
RuntimeUnavailableError,
)
from modeldock.domain.model import ModelRef, RuntimeBackend
from modeldock.ports.runtime import PullResult
from modeldock.ports.runtime import PullResult, RunResult

_OLLAMA_DEFAULT_HOST = "http://localhost:11434"
_PULL_VERIFY_BACKOFF_SECONDS = 0.1
Expand Down Expand Up @@ -195,5 +196,80 @@ def remove(self, ref: ModelRef) -> None:
reason=f"Failed to remove model: {exc}",
) from exc

def run(self, ref: ModelRef, prompt: Optional[str] = None, **opts: Any) -> RunResult:
"""Run an interactive session, streaming tokens to stdout.

When ``prompt`` is given, runs a single completion and returns. When
``prompt`` is ``None``, drops into a read-eval-print loop reading lines
from stdin until EOF or a quit command. Requires the model to be
installed locally.
"""
if not self.is_installed(ref):
raise ModelNotInstalledError(ref.qualified_name())
client = self._ensure_client()
if prompt is not None:
return self._run_single(client, ref, prompt, **opts)
return self._run_repl(client, ref, **opts)

def _run_single(self, client: Any, ref: ModelRef, prompt: str, **opts: Any) -> RunResult:
"""Run one completion and stream tokens to stdout."""
try:
stream = client.generate(ref.qualified_name(), prompt=prompt, stream=True, **opts)
completion_tokens = 0
for event in stream:
token = self._extract_token(event)
if token:
completion_tokens += 1
self._write(token)
self._write("\n")
return RunResult(
ref=ref, success=True, prompt_tokens=0, completion_tokens=completion_tokens
)
except ModelDockError:
raise
except Exception as exc:
return RunResult(ref=ref, success=False, error=str(exc))

def _run_repl(self, client: Any, ref: ModelRef, **opts: Any) -> RunResult:
"""Interactive read-eval-print loop until EOF or a quit command."""
import sys

completion_tokens = 0
self._write(f">>> {ref.qualified_name()} (type 'exit' or Ctrl-D to quit)\n")
while True:
try:
line = sys.stdin.readline()
except EOFError:
break
if not line:
break
text = line.rstrip("\n")
if text.strip().lower() in {"exit", "quit", "/exit", "/quit"}:
break
if not text.strip():
continue
result = self._run_single(client, ref, text, **opts)
if not result.success:
return result
completion_tokens += result.completion_tokens
return RunResult(ref=ref, success=True, completion_tokens=completion_tokens)

@staticmethod
def _extract_token(event: Any) -> str:
"""Pull the incremental token text out of a streamed generate event."""
if hasattr(event, "response"):
return str(getattr(event, "response", "") or "")
if isinstance(event, dict):
return str(event.get("response", "") or "")
return ""

@staticmethod
def _write(text: str) -> None:
"""Write text to stdout without an implicit newline."""
import sys

sys.stdout.write(text)
sys.stdout.flush()


__all__ = ["OllamaRuntime"]
2 changes: 2 additions & 0 deletions src/modeldock/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from modeldock.cli.commands.list import list_cmd
from modeldock.cli.commands.load import load_app
from modeldock.cli.commands.remove import remove_cmd
from modeldock.cli.commands.run import run_cmd
from modeldock.cli.commands.search import search_cmd
from modeldock.cli.commands.update import update_cmd
from modeldock.common.logging import configure_logging
Expand All @@ -43,6 +44,7 @@
app.command("info")(info_cmd)
app.command("update")(update_cmd)
app.command("remove")(remove_cmd)
app.command("run")(run_cmd)


def _version_callback(value: bool) -> None:
Expand Down
29 changes: 29 additions & 0 deletions src/modeldock/cli/commands/run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""CLI command: run."""

from __future__ import annotations

import typer

from modeldock.cli.console import print_error
from modeldock.core.manager import ModelManager


def run_cmd(
model: str = typer.Argument(..., help="Model name or name:tag"),
backend: str = typer.Option(None, "--backend", help="Runtime backend"),
prompt: str = typer.Option(None, "--prompt", help="Single prompt (skip interactive loop)"),
debug: bool = typer.Option(False, "--debug", help="Show traceback"),
) -> None:
"""Run an interactive session with a model, auto-installing if missing."""
try:
mgr = ModelManager()
result = mgr.run(model, prompt=prompt)
if hasattr(result, "success") and not result.success:
print_error(Exception(result.error or "run failed"), debug)
raise typer.Exit(code=1)
except Exception as exc: # noqa: BLE001 - top-level CLI boundary
print_error(exc, debug)
raise typer.Exit(code=1) # noqa: B904


__all__ = ["run_cmd"]
5 changes: 5 additions & 0 deletions src/modeldock/core/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ def remove(self, name: str) -> None:
ref = ModelRef.parse(name, backend=self._backend)
self._runtime.remove(ref)

def run(self, name: str, prompt: Optional[str] = None, **opts: Any) -> Any:
"""Run an interactive session for a model in the active runtime."""
ref = ModelRef.parse(name, backend=self._backend)
return self._runtime.run(ref, prompt=prompt, **opts)

def verify(self, name: str) -> bool:
"""Verify a model is installed in the runtime."""
ref = ModelRef.parse(name, backend=self._backend)
Expand Down
32 changes: 32 additions & 0 deletions src/modeldock/ports/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,18 @@

def default_tag_for(self, spec: ModelSpec) -> str:
"""Resolve the default variant tag for a model spec."""
...

Check notice

Code scanning / CodeQL

Statement has no effect Note

This statement has no effect.

def run(self, ref: ModelRef, prompt: Optional[str] = None, **opts: Any) -> RunResult:
"""Run an interactive session for ``ref``, streaming output.

``prompt`` is an optional initial prompt; when ``None`` the runtime
drops into an interactive read-eval-print loop. Output is streamed to
stdout (or the configured ``ProgressPort``). Runtimes that cannot
support interactive sessions raise ``NotImplementedError``.
"""
...


class PullResult:
"""Result of a pull/install operation (returned by ``RuntimePort.pull``)."""
Expand All @@ -72,3 +82,25 @@
def __repr__(self) -> str:
state = "ok" if self.success else f"failed({self.error})"
return f"PullResult({self.ref.qualified_name()}, {state})"


class RunResult:
"""Result of a ``run`` session (returned by ``RuntimePort.run``)."""

def __init__(
self,
ref: ModelRef,
success: bool,
prompt_tokens: int = 0,
completion_tokens: int = 0,
error: Optional[str] = None,
) -> None:
self.ref = ref
self.success = success
self.prompt_tokens = prompt_tokens
self.completion_tokens = completion_tokens
self.error = error

def __repr__(self) -> str:
state = "ok" if self.success else f"failed({self.error})"
return f"RunResult({self.ref.qualified_name()}, {state})"
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@ def get_model_client(self, ref: ModelRef) -> Any:
def default_tag_for(self, spec: ModelSpec) -> str:
return spec.default_tag

def run(self, ref: ModelRef, prompt: Optional[str] = None, **opts: Any) -> Any:
from modeldock.ports.runtime import RunResult

return RunResult(ref=ref, success=True, completion_tokens=1)


class FakeRegistry(RegistryPort):
"""In-memory registry seeded with a few specs."""
Expand Down
81 changes: 80 additions & 1 deletion tests/unit/test_ollama_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import pytest

from modeldock.adapters.runtimes.ollama import OllamaRuntime
from modeldock.common.errors import DownloadError, RuntimeUnavailableError
from modeldock.common.errors import DownloadError, ModelNotInstalledError, RuntimeUnavailableError
from modeldock.domain.model import ModelRef


Expand Down Expand Up @@ -211,3 +211,82 @@ def Client(self, host: str, timeout: float = 30.0) -> Any:
runtime = OllamaRuntime()
runtime.is_available()
assert built["host"] == "http://envhost:9999"


class _GenerateClient:
"""Fake ollama.Client supporting list() + generate() streaming."""

def __init__(self, installed: bool = True) -> None:
self._installed = installed
self.generated: List[str] = []

def list(self) -> dict[str, Any]:
if self._installed:
return {"models": [{"name": "llama3:latest"}]}
return {"models": []}

def generate(self, model: str, prompt: str = "", stream: bool = False, **opts: Any) -> Any:
self.generated.append(prompt)
if stream:

def _gen() -> Iterator[dict[str, Any]]:
yield {"response": "Hello"}
yield {"response": " world"}

return _gen()
return {"response": "Hello world"}


def _runtime_with_generate(installed: bool = True) -> OllamaRuntime:
runtime = OllamaRuntime()
runtime._client = _GenerateClient(installed=installed)
return runtime


def test_run_single_prompt_streams_tokens(monkeypatch: Any) -> None:
runtime = _runtime_with_generate()
written: List[str] = []
monkeypatch.setattr(OllamaRuntime, "_write", staticmethod(lambda t: written.append(t)))

result = runtime.run(ModelRef.parse("llama3"), prompt="hi")

assert result.success
assert result.completion_tokens == 2
assert "".join(written) == "Hello world\n"


def test_run_raises_when_model_not_installed() -> None:
runtime = _runtime_with_generate(installed=False)
with pytest.raises(ModelNotInstalledError):
runtime.run(ModelRef.parse("llama3"), prompt="hi")


def test_run_repl_reads_stdin_until_exit(monkeypatch: Any) -> None:
runtime = _runtime_with_generate()
written: List[str] = []
monkeypatch.setattr(OllamaRuntime, "_write", staticmethod(lambda t: written.append(t)))
monkeypatch.setattr("sys.stdin", __import__("io").StringIO("first\nsecond\nexit\n"))

result = runtime.run(ModelRef.parse("llama3"))

assert result.success
# Two prompts processed (exit terminates the loop).
assert result.completion_tokens == 4
assert "first" in runtime._client.generated # type: ignore[attr-defined]
assert "second" in runtime._client.generated # type: ignore[attr-defined]


def test_run_single_prompt_wraps_sdk_error(monkeypatch: Any) -> None:
runtime = _runtime_with_generate()

class _BoomClient:
def list(self) -> dict[str, Any]:
return {"models": [{"name": "llama3:latest"}]}

def generate(self, model: str, prompt: str = "", stream: bool = False, **opts: Any) -> Any:
raise RuntimeError("boom")

runtime._client = _BoomClient()
result = runtime.run(ModelRef.parse("llama3"), prompt="hi")
assert not result.success
assert "boom" in result.error
Loading