Skip to content

Commit

Permalink
Fixed crash on startup if Ollama is not available
Browse files Browse the repository at this point in the history
Fixed markdown display issues around fences
Added "thinking" fence for deepseek thought output
Much better support for displaying max input context size
Updated deps
  • Loading branch information
paulrobello committed Feb 11, 2025
1 parent 9e4c9f3 commit 2e98ed8
Show file tree
Hide file tree
Showing 14 changed files with 170 additions and 131 deletions.
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
* [Prerequisites for running](#prerequisites-for-running)
* [Prerequisites for dev](#prerequisites-for-dev)
* [Prerequisites for huggingface model quantization](#prerequisites-for-huggingface-model-quantization)
* [Installing using pipx](#installing-from-mypi-using-pipx)
* [Installing from mypi using pip](#installing-from-mypi-using-pip)
* [Installing using pipx](#pipx)
* [Installing using uv](#using-uv)
* [Installing for dev mode](#installing-for-dev-mode)
* [Command line arguments](#command-line-arguments)
* [Environment Variables](#environment-variables)
Expand All @@ -30,6 +30,7 @@
* [Where we are](#where-we-are)Ï
* [Where we're going](#where-were-going)
* [What's new](#whats-new)
* [v0.3.17](#v0317)
* [v0.3.16](#v0316)
* [v0.3.15](#v0315)
* [v0.3.14](#v0314)
Expand Down Expand Up @@ -117,7 +118,7 @@ If you don't have uv installed you can run the following:
curl -LsSf https://astral.sh/uv/install.sh | sh
```

### MyPi install
### PyPi install
```shell
uv tool install parllama
```
Expand Down Expand Up @@ -383,7 +384,7 @@ Theme can be changed via command line with the ```--theme-name``` option.
## Contributing
Start by following the instructions in the section **Installing for dev mode**.

Please ensure that all pull requests are formatted with black, pass mypy and pylint with 10/10 checks.
Please ensure that all pull requests are formatted with ruff, pass ruff lint and pyright.
You can run the make target **pre-commit** to ensure the pipeline will pass with your changes.
There is also a pre-commit config to that will assist with formatting and checks.
The easiest way to setup your environment to ensure smooth pull requests is:
Expand Down Expand Up @@ -445,13 +446,20 @@ if anything remains to be fixed before the commit is allowed.

## What's new

### v0.3.17

* Fixed crash on startup if Ollama is not available
* Fixed markdown display issues around fences
* Added "thinking" fence for deepseek thought output
* Much better support for displaying max input context size

### v0.3.16

* Added providers xAI, OpenRouter, Deepseek and LiteLLM

### v0.3.15

* Added copy button to the fence blocks in chat markdown for easy code copy.
* Added copy button to the fence blocks in chat markdown for easy code copy

### v0.3.14

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ dependencies = [
"urllib3>=2.3.0",
"rich-pixels>=3.0.1",
"orjson>=3.10.15",
"par-ai-core>=0.1.16",
"par-ai-core>=0.1.17",
"clipman>=3.3.1",
]
packages = [
Expand Down
7 changes: 4 additions & 3 deletions src/parllama/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
import warnings

import clipman
from langchain._api import LangChainDeprecationWarning
from langchain_core._api import LangChainBetaWarning

warnings.simplefilter("ignore", category=LangChainDeprecationWarning)
warnings.simplefilter("ignore", category=LangChainBetaWarning)
warnings.simplefilter("ignore", category=DeprecationWarning)

try:
clipman.init()
Expand All @@ -20,7 +21,7 @@
__credits__ = ["Paul Robello"]
__maintainer__ = "Paul Robello"
__email__ = "[email protected]"
__version__ = "0.3.16"
__version__ = "0.3.17"
__licence__ = "MIT"
__application_title__ = "PAR LLAMA"
__application_binary__ = "parllama"
Expand Down
3 changes: 2 additions & 1 deletion src/parllama/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,10 +184,11 @@ async def show_first_run(self) -> None:
message="""
Thank your for trying ParLlama!
Please take a moment to familiarize yourself with the the various options.
New options are being added all the time. Check out Whats New on the repo.
New options are being added all the time! Check out Whats New on the repo.
[link]https://github.com/paulrobello/parllama?tab=readme-ov-file#whats-new[/link]
By default ParLlama makes not attempt to connect to the internet.
If you would like to auto check for updates, you can enable it in the Startup section of Options.
If you want to use other providers, you can set them up in the Providers section of the Options tab.
""",
)
)
Expand Down
9 changes: 6 additions & 3 deletions src/parllama/ollama_data_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,14 @@ def enrich_model_details(self, model: FullModel) -> None:
model.modelinfo = msp.modelinfo
model.license = msp.license

@staticmethod
def _get_all_model_data() -> list[LocalModelListItem]:
def _get_all_model_data(self) -> list[LocalModelListItem]:
"""Get all model data."""
all_models: list[LocalModelListItem] = []
res = ollama.Client(host=settings.ollama_host).list()
try:
res = ollama.Client(host=settings.ollama_host).list()
except Exception as e:
self.log_it(f"Error loading Ollama Models: {e}")
return []

for model in res.models:
if not model.model:
Expand Down
100 changes: 61 additions & 39 deletions src/parllama/provider_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,15 @@
from dotenv import load_dotenv
from groq import Groq
from openai import OpenAI
from par_ai_core.llm_providers import LlmProvider, is_provider_api_key_set, llm_provider_types, provider_base_urls, \
provider_env_key_names
from par_ai_core.pricing_lookup import pricing_lookup
from par_ai_core.llm_providers import (
LlmProvider,
is_provider_api_key_set,
llm_provider_types,
provider_base_urls,
provider_env_key_names,
provider_name_to_enum,
)
from par_ai_core.pricing_lookup import get_api_cost_model_name, get_model_metadata, get_model_mode
from textual.app import App

from parllama.messages.messages import ProviderModelsChanged, RefreshProviderModelsRequested
Expand Down Expand Up @@ -68,7 +74,7 @@ def set_app(self, app: App[Any] | None) -> None:

# pylint: disable=too-many-branches
def refresh_models(self):
"""Refresh the models."""
"""Refresh model lists from all available configured providers."""
self.log_it("Refreshing provider models")
for p in llm_provider_types:
try:
Expand All @@ -84,35 +90,55 @@ def refresh_models(self):
for m in data:
new_list.append(m.id or "default")
elif p in [LlmProvider.OPENAI, LlmProvider.OPENROUTER, LlmProvider.XAI, LlmProvider.DEEPSEEK]:
models = list(OpenAI(base_url=settings.provider_base_urls[p] or provider_base_urls[p], api_key=settings.provider_api_keys[p] or os.environ.get(provider_env_key_names[p])).models.list().data)
models = list(
OpenAI(
base_url=settings.provider_base_urls[p] or provider_base_urls[p],
api_key=settings.provider_api_keys[p] or os.environ.get(provider_env_key_names[p]),
)
.models.list()
.data
)
if models:
models = [m for m in models if get_model_mode(p, m.id) in ["chat", "unknown"]]
if models[0].created is not None:
data = sorted(models, key=lambda m: m.created, reverse=True)
else:
data = sorted(models, key=lambda m: m.id)
for m in data:
new_list.append(m.id)
elif p == LlmProvider.GROQ:
models = Groq(base_url=settings.provider_base_urls[p] or provider_base_urls[p]).models.list()
data = sorted(models.data, key=lambda m: m.created, reverse=True)
for m in data:
new_list.append(m.id)
models = Groq(base_url=settings.provider_base_urls[p] or provider_base_urls[p]).models.list().data
if models:
models = [m for m in models if get_model_mode(p, m.id) in ["chat", "unknown"]]
data = sorted(models, key=lambda m: m.created, reverse=True)
for m in data:
new_list.append(m.id)
elif p == LlmProvider.ANTHROPIC:
import anthropic

models = list(anthropic.Anthropic().models.list(limit=50))
data = sorted(models, key=lambda m: m.created_at, reverse=True)
for m in data:
new_list.append(m.id)
if models:
models = [m for m in models if get_model_mode(p, m.id) in ["chat", "unknown"]]
data = sorted(models, key=lambda m: m.created_at, reverse=True)
for m in data:
new_list.append(m.id)
elif p == LlmProvider.LITELLM:
models = requests.get(f"{settings.provider_base_urls[p] or provider_base_urls[p]}/models", timeout=5).json()["data"]
data = sorted(models, key=lambda m: m["created"], reverse=True)
for m in data:
new_list.append(m["id"])
elif p == LlmProvider.GOOGLE:
models = requests.get(
f"{settings.provider_base_urls[p] or provider_base_urls[p]}/models", timeout=5
).json()["data"]
if models:
models = [m for m in models if get_model_mode(p, m["id"]) in ["chat", "unknown"]]
data = sorted(models, key=lambda m: m["created"], reverse=True)
for m in data:
new_list.append(m["id"])
elif p == LlmProvider.GEMINI:
genai.configure(api_key=settings.provider_api_keys[p] or os.environ.get(provider_env_key_names[p])) # type: ignore
data = sorted(list(genai.list_models()), key=lambda m: m.name) # type: ignore
for m in data:
new_list.append(m.name.split("/")[1])
models = list(genai.list_models()) # type: ignore
if models:
models = [m for m in models if get_model_mode(p, m.name) in ["chat", "unknown"]]
data = sorted(models, key=lambda m: m.name) # type: ignore
for m in data:
new_list.append(m.name.split("/")[1])
else:
raise ValueError(f"Unknown provider: {p}")
# print(new_list)
Expand All @@ -127,26 +153,22 @@ def refresh_models(self):
self.save_models()

# pylint: disable=too-many-return-statements, too-many-branches
@staticmethod
def get_model_context_length(provider: LlmProvider, model_name: str) -> int:
def get_model_context_length(self, provider: LlmProvider, model_name: str) -> int:
"""Get model cntext length. Return 0 if unknown."""
if provider == LlmProvider.OPENAI:
if model_name in openai_model_context_windows:
return openai_model_context_windows[model_name]
elif provider == LlmProvider.GROQ:
if model_name in openai_model_context_windows:
return openai_model_context_windows[model_name]
return 128_000 # this is just a guess
elif provider == LlmProvider.ANTHROPIC:
return 200_000 # this can vary depending on provider load
elif provider == LlmProvider.GOOGLE:
return 128_000
elif provider == LlmProvider.OLLAMA:
return ollama_dm.get_model_context_length(model_name)
return 0
try:
if provider == LlmProvider.OLLAMA:
return ollama_dm.get_model_context_length(model_name)
metadata = get_model_metadata(provider.value.lower(), model_name)
return metadata.get("max_input_tokens") or metadata.get("max_tokens") or 0
except Exception as e:
self.log_it(
f"Error getting model metadata {get_api_cost_model_name(provider_name=provider.value.lower(), model_name=model_name)}: {e}",
severity="error",
)
return 0

def save_models(self):
"""Save the models."""
"""Save the models to json cache file."""
self.cache_file.write_bytes(
json.dumps(
{k.value: v for k, v in self.provider_models.items()},
Expand All @@ -156,7 +178,7 @@ def save_models(self):
)

def load_models(self, refresh: bool = False) -> None:
"""Load the models."""
"""Load the models from json cache file if exist and not expired, otherwise fetch new data."""
if not self.cache_file.exists():
self.log_it("Models file does not exist, requesting refresh")
if self.app:
Expand All @@ -172,7 +194,7 @@ def load_models(self, refresh: bool = False) -> None:
return

provider_models = json.loads(self.cache_file.read_bytes())
self.provider_models = {LlmProvider(k): v for k, v in provider_models.items()}
self.provider_models = {provider_name_to_enum(k): v for k, v in provider_models.items()}
self.provider_models[LlmProvider.LLAMACPP] = ["default"]

if self.app:
Expand Down
6 changes: 3 additions & 3 deletions src/parllama/settings_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ class Settings(BaseModel):
LlmProvider.OPENROUTER: None,
LlmProvider.GROQ: None,
LlmProvider.ANTHROPIC: None,
LlmProvider.GOOGLE: None,
LlmProvider.GEMINI: None,
LlmProvider.BEDROCK: None,
LlmProvider.GITHUB: None,
LlmProvider.DEEPSEEK: None,
Expand Down Expand Up @@ -266,7 +266,7 @@ def load_from_file(self) -> None:

self.use_last_tab_on_startup = data.get("use_last_tab_on_startup", self.use_last_tab_on_startup)
last_llm_config = data.get("last_llm_config", {})
self.last_llm_config.provider = LlmProvider(
self.last_llm_config.provider = provider_name_to_enum(
data.get(
"last_chat_provider",
last_llm_config.get("provider", self.last_llm_config.provider.value),
Expand Down Expand Up @@ -296,7 +296,7 @@ def load_from_file(self) -> None:
},
)
if self.auto_name_session_llm_config and isinstance(self.auto_name_session_llm_config["provider"], str):
self.auto_name_session_llm_config["provider"] = LlmProvider(
self.auto_name_session_llm_config["provider"] = provider_name_to_enum(
self.auto_name_session_llm_config["provider"]
)

Expand Down
6 changes: 1 addition & 5 deletions src/parllama/widgets/chat_message_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,6 @@ class ChatMessageWidget(Vertical, can_focus=True):
ParMarkdown {
margin: 0;
padding: 1 1 0 1;
ParMarkdownFence {
max-height: initial;
}
}
#image{
width: 19;
Expand Down Expand Up @@ -156,7 +152,7 @@ def role(self) -> MessageRoles:
@property
def markdown_raw(self) -> str:
"""The raw markdown."""
return self.raw_text
return self.raw_text.replace("<think>", "```thinking").replace("</think>", "```")

async def update(self) -> None:
"""Update the document with new Markdown."""
Expand Down
31 changes: 17 additions & 14 deletions src/parllama/widgets/par_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,20 +93,24 @@ class ParMarkdownFence(MarkdownBlock):
ParMarkdownFence {
margin: 1 0;
overflow: auto;
width: 100%;
width: 1fr;
height: auto;
max-height: 20;
color: rgb(210,210,210);
layer: below;
}
ParMarkdownFence > * {
width: auto;
layer: below;
}
ParMarkdownFence.thinking {
border: solid green;
max-height: 20;
}
"""

def __init__(self, markdown: Markdown, code: str, lexer: str) -> None:
super().__init__(markdown)
super().__init__(markdown, classes="thinking" if lexer == "thinking" else "")
self.border_title = lexer.capitalize()
self.code = code
self.lexer = lexer
self.theme = self._markdown.code_dark_theme if self.app.current_theme.dark else self._markdown.code_light_theme
Expand All @@ -115,8 +119,8 @@ def __init__(self, markdown: Markdown, code: str, lexer: str) -> None:
def _block(self) -> Syntax:
return Syntax(
self.code,
lexer=self.lexer,
word_wrap=False,
lexer=self.lexer if self.lexer != "thinking" else "text",
word_wrap=self.lexer == "thinking",
indent_guides=True,
padding=(1, 2),
theme=self.theme,
Expand All @@ -132,11 +136,7 @@ def _retheme(self) -> None:
self.get_child_by_type(Static).update(self._block())

def compose(self) -> ComposeResult:
yield Static(
self._block(),
expand=True,
shrink=False,
)
yield Static(self._block(), expand=True, shrink=False, classes=self.lexer)
yield self.btn

@on(FenceCopyButton.Pressed, "#copy")
Expand All @@ -152,14 +152,17 @@ def on_copy_pressed(self, event: FenceCopyButton.Pressed) -> None:

class ParMarkdown(Markdown):
DEFAULT_CSS = """
Markdown {
ParMarkdown {
height: auto;
padding: 0 2 1 2;
layout: vertical;
color: $foreground;
background: $surface;
overflow-y: auto;
layers: below above;
layers: below above;
& > * {
layer: below;
}
&:focus {
background-tint: $foreground 5%;
Expand Down
Loading

0 comments on commit 2e98ed8

Please sign in to comment.