diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 403b19e..72277a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: + # subactor-subllm (the vision transport) requires Python 3.11+. python-version: ["3.11", "3.12"] steps: diff --git a/README.md b/README.md index 4c3cde4..761cf33 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # NLP2CMD -[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) +[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/) [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![PyPI Version](https://img.shields.io/pypi/v/nlp2cmd.svg)](https://pypi.org/project/nlp2cmd/) [![1543+ Tests](https://img.shields.io/badge/tests-1543%2B-brightgreen.svg)](https://github.com/wronai/nlp2cmd) diff --git a/docs/README.md b/docs/README.md index 314eda9..0029fff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3451,7 +3451,7 @@ nlp2cmd/ ## Requirements -- Python >= >=3.10 +- Python >= 3.11 - pyyaml >=6.0- pydantic >=2.0- rich >=13.0- click >=8.0- httpx >=0.25.0- jinja2 >=3.0- jsonschema >=4.0- python-dotenv >=1.0- watchdog >=3.0- numpy >=1.24.0- psutil >=5.9.0- rapidfuzz >=3.0 ## Contributing @@ -3488,4 +3488,4 @@ pytest | `CONTRIBUTING.md` | Contribution guidelines | [View](./CONTRIBUTING.md) | | `examples` | Usage examples and code samples | [View](./examples) | - \ No newline at end of file + diff --git a/pyproject.toml b/pyproject.toml index 215d104..d88cc6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ dependencies = [ "psutil>=5.9.0", "rapidfuzz>=3.0", "nlp2cmd-intent>=0.1.2", - "subactor-subllm @ git+https://github.com/subactor/subllm.git@505d89f505e90c7b40d98cafdc9104cc61072460", + "subactor-subllm @ git+https://github.com/subactor/subllm.git@40726bb9399eb5cf9dfc038c1c948b7d2fbdca3b", ] [project.optional-dependencies] @@ -253,7 +253,7 @@ ignore = [ "tests/*" = ["S101"] [tool.mypy] -python_version = "3.10" +python_version = "3.11" warn_return_any = true warn_unused_configs = true disallow_untyped_defs = true diff --git a/src/nlp2cmd/llm/openrouter.py b/src/nlp2cmd/llm/openrouter.py index fb34122..d482277 100644 --- a/src/nlp2cmd/llm/openrouter.py +++ b/src/nlp2cmd/llm/openrouter.py @@ -22,9 +22,8 @@ from typing import Any, Optional try: - from subllm import available_routes, complete as subllm_complete + from subllm import complete as subllm_complete except ImportError: - available_routes = None subllm_complete = None _DEBUG = os.environ.get("NLP2CMD_DEBUG", "").lower() in ("1", "true", "yes") @@ -95,15 +94,8 @@ def __init__( @property def is_configured(self) -> bool: - """Check whether the central NLP2CMD route has a usable credential.""" - if available_routes is None: - return False - credentials = {"OPENROUTER_API_KEY": self.api_key} if self.api_key else None - return bool( - available_routes( - "autogrammar-nlp2cmd", "generate", credentials=credentials - ) - ) + """Check whether the client can submit a request through SubLLM.""" + return subllm_complete is not None and bool(self.api_key) def _headers(self) -> dict[str, str]: return { @@ -190,6 +182,8 @@ async def vision( Returns: LLMResponse """ + if subllm_complete is None: + return LLMResponse(success=False, error="subactor-subllm is not available") if not self.api_key: return LLMResponse(success=False, error="OPENROUTER_API_KEY not set") @@ -207,14 +201,24 @@ async def vision( ], }) - body: dict[str, Any] = { - "model": model or self.MODELS["vision"], - "messages": messages, - "max_tokens": max_tokens, - "temperature": temperature, - } - - return await self._request(body) + credentials = {"openrouter": self.api_key} + try: + response = await asyncio.to_thread( + subllm_complete, + "autogrammar-nlp2cmd", + "vision", + messages, + timeout_seconds=self.timeout, + credentials=credentials, + ) + return LLMResponse( + content=response.content, + model=response.model, + usage=dict(response.usage), + finish_reason=response.finish_reason, + ) + except Exception as exc: + return LLMResponse(success=False, error=f"SubLLM vision request failed: {exc}") async def plan_actions( self, diff --git a/tests/unit/test_subllm_client.py b/tests/unit/test_subllm_client.py index 5fae81e..ae96934 100644 --- a/tests/unit/test_subllm_client.py +++ b/tests/unit/test_subllm_client.py @@ -21,3 +21,26 @@ def fake_complete(application, function, messages, **kwargs): assert response.model == "glm-5.3" assert captured["application"] == "autogrammar-nlp2cmd" assert captured["function"] == "generate" + + +def test_vision_uses_central_subllm(monkeypatch): + captured = {} + + def fake_complete(application, function, messages, **kwargs): + captured.update(application=application, function=function, messages=messages, kwargs=kwargs) + return type( + "Response", + (), + {"content": "cat", "model": "z-ai/glm-4.5v", "usage": {}, "finish_reason": "stop"}, + )() + + monkeypatch.setattr(openrouter, "subllm_complete", fake_complete) + response = asyncio.run( + openrouter.OpenRouterClient(api_key="test-key").vision("aaa", "what is this?") + ) + + assert response.content == "cat" + assert captured["application"] == "autogrammar-nlp2cmd" + assert captured["function"] == "vision" + assert captured["messages"][0]["content"][0]["type"] == "image_url" + assert captured["kwargs"]["credentials"] == {"openrouter": "test-key"}