From 888c4c2fcf158df89f6fef47f85fffb2a55155d2 Mon Sep 17 00:00:00 2001 From: Himanshu Kumar Date: Sat, 18 Jul 2026 17:18:44 +0530 Subject: [PATCH 1/2] feat(ollama): add support for interactive ollama run sessions (issue #9) Add un(model, **opts) to RuntimePort with a RunResult value object. Implement it for Ollama by streaming tokens to stdout via client.generate(stream=True), supporting both a single-prompt mode and an interactive REPL loop. BaseRuntime provides a default raising NotImplementedError for unsupported runtimes. Wire a modeldock run CLI command and a ModelManager.run facade. Add unit tests with a fake RuntimePort covering the run path. --- src/modeldock/adapters/runtimes/base.py | 13 +++- src/modeldock/adapters/runtimes/ollama.py | 80 +++++++++++++++++++++- src/modeldock/cli/app.py | 2 + src/modeldock/cli/commands/run.py | 29 ++++++++ src/modeldock/core/manager.py | 5 ++ src/modeldock/ports/runtime.py | 32 +++++++++ tests/conftest.py | 5 ++ tests/unit/test_ollama_runtime.py | 81 ++++++++++++++++++++++- 8 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 src/modeldock/cli/commands/run.py diff --git a/src/modeldock/adapters/runtimes/base.py b/src/modeldock/adapters/runtimes/base.py index 5521f5a..5a22c6f 100644 --- a/src/modeldock/adapters/runtimes/base.py +++ b/src/modeldock/adapters/runtimes/base.py @@ -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 @@ -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"] diff --git a/src/modeldock/adapters/runtimes/ollama.py b/src/modeldock/adapters/runtimes/ollama.py index 15b5958..7bda632 100644 --- a/src/modeldock/adapters/runtimes/ollama.py +++ b/src/modeldock/adapters/runtimes/ollama.py @@ -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 @@ -195,5 +196,82 @@ 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"] diff --git a/src/modeldock/cli/app.py b/src/modeldock/cli/app.py index 3020298..e6b7673 100644 --- a/src/modeldock/cli/app.py +++ b/src/modeldock/cli/app.py @@ -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 @@ -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: diff --git a/src/modeldock/cli/commands/run.py b/src/modeldock/cli/commands/run.py new file mode 100644 index 0000000..38564ed --- /dev/null +++ b/src/modeldock/cli/commands/run.py @@ -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"] diff --git a/src/modeldock/core/manager.py b/src/modeldock/core/manager.py index 5331d91..b32d675 100644 --- a/src/modeldock/core/manager.py +++ b/src/modeldock/core/manager.py @@ -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) diff --git a/src/modeldock/ports/runtime.py b/src/modeldock/ports/runtime.py index 32fb1bc..c44c38b 100644 --- a/src/modeldock/ports/runtime.py +++ b/src/modeldock/ports/runtime.py @@ -49,6 +49,16 @@ def default_tag_for(self, spec: ModelSpec) -> str: """Resolve the default variant tag for a model spec.""" ... + 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``).""" @@ -72,3 +82,25 @@ def __init__( 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})" diff --git a/tests/conftest.py b/tests/conftest.py index 6895579..027aabd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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.""" diff --git a/tests/unit/test_ollama_runtime.py b/tests/unit/test_ollama_runtime.py index 33832fc..0c64a0f 100644 --- a/tests/unit/test_ollama_runtime.py +++ b/tests/unit/test_ollama_runtime.py @@ -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 @@ -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 From 18c67fbb5347a462106993514a8f39b691091076 Mon Sep 17 00:00:00 2001 From: Himanshu Kumar Date: Sat, 18 Jul 2026 17:23:19 +0530 Subject: [PATCH 2/2] style(ollama): apply ruff format to run() implementation Fix CI uff format --check failure on ollama.py by collapsing the _run_single signature to a single line. --- src/modeldock/adapters/runtimes/ollama.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/modeldock/adapters/runtimes/ollama.py b/src/modeldock/adapters/runtimes/ollama.py index 7bda632..f47dd95 100644 --- a/src/modeldock/adapters/runtimes/ollama.py +++ b/src/modeldock/adapters/runtimes/ollama.py @@ -211,9 +211,7 @@ def run(self, ref: ModelRef, prompt: Optional[str] = None, **opts: Any) -> RunRe 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: + 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)