Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a8b236e
test: point Granite 4.2 eval/CI test fixtures at ibm-granite/granite-…
planetf1 Aug 28, 2026
7c72ad0
fix: use published Granite 4.2 Ollama tags
planetf1 Aug 26, 2026
8394342
fix: create vLLM environments with Python 3.12
planetf1 Aug 26, 2026
9279d26
fix: isolate Ollama and vLLM test lanes
planetf1 Aug 26, 2026
64e76ff
fix: reuse Ollama test URL resolver
planetf1 Aug 26, 2026
e7b7083
fix: preserve custom pytest arguments in test runner
planetf1 Aug 26, 2026
04fc32a
fix: provision compatible Ollama test models
planetf1 Aug 26, 2026
127d36f
fix: make Ollama telemetry test failures retryable and diagnosable in CI
planetf1 Aug 26, 2026
c3f4962
fix: capture the whole-run request story from the Ollama server log
planetf1 Aug 26, 2026
21c41ce
fix: bound live telemetry test output and retry the stream-guard stal…
planetf1 Aug 26, 2026
a7aead4
fix: bound total streaming request time in live telemetry tests
planetf1 Aug 27, 2026
413f44a
fix: disable SDK auto-retries and bound the live OpenAI test client
planetf1 Aug 27, 2026
8fba775
fix: send the live-test output cap as max_tokens and bound avalue()
planetf1 Aug 27, 2026
91f7826
docs: use single backticks in the new regression docstring
planetf1 Aug 27, 2026
23cdf5d
fix: constrain granite4.2:3b context in CI to stop Ollama stalls
planetf1 Aug 27, 2026
ebd5d08
fix: bump CI Ollama pin 0.32.2 -> 0.33.1 to chase the 4.2 stall
planetf1 Aug 27, 2026
c426fe9
fix: disable granite4.2 thinking on /v1 via reasoning_effort=none
planetf1 Aug 27, 2026
162f1a4
test: instrument CI Ollama to diagnose the sustained 300 s stalls
planetf1 Aug 28, 2026
e35910c
test: fix stateless-context telemetry tests and align 4.2 num_ctx
planetf1 Aug 28, 2026
2ce9ea3
test: make streaming chunk-timing assertion robust to short replies
planetf1 Aug 28, 2026
b667812
fix: scope reasoning_effort=none to non-OpenAI targets, fix review fi…
planetf1 Aug 28, 2026
0f7a350
docs: trim verbose CI/backend comments to load-bearing rationale
planetf1 Aug 29, 2026
bef5f13
docs: trim verbose CI-diagnostics comments in conftest.py
planetf1 Aug 29, 2026
f8ce28e
refactor(backends): dedup THINKING-to-param mapping in OpenAIBackend
planetf1 Aug 29, 2026
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
80 changes: 64 additions & 16 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,29 +105,38 @@ jobs:
- name: Download NLTK data
run: uv run python -m nltk.downloader punkt_tab
- name: Install Ollama
# Pinned to a GCC-13 build to fix a segfault loading granite4.1:3b on
# AMX-capable ubuntu-latest runners (#1388). Ollama's Linux builds through
# 0.32.1 were compiled with GCC 11, which miscompiles the AMX CPU kernels in
# libggml-cpu-sapphirerapids.so -- so ~1/3 of runs (the AMX-capable runner
# draws) crashed during model warmup, independent of ollama version and
# unreachable by any env var (the code path is correct; the machine code was
# not). Ollama PR #17244 ("bump Linux toolchain to GCC 13", Fixes ollama#17006
# / #17205) first shipped in the 0.32.2 line. The rc0 tag has since been
# removed (promoted to the v0.32.2 release), so its download URL now 404s and
# the pin is bumped to 0.32.2. Drop this pin once a stable (non-prerelease)
# release includes the GCC-13 build. NB: OLLAMA_VERSION must omit the leading
# "v" -- install.sh prepends it (a "v" yields a broken .../download/vv0.32.2/...
# path).
run: curl -fsSL https://ollama.com/install.sh | OLLAMA_VERSION=0.32.2 sh
# GCC-13 build: fixes a segfault loading Granite models on AMX-capable
# runners (Ollama miscompiled AMX kernels through 0.32.1, #1388).
# Pinned to 0.33.1 (current stable with the fix); also required for
# the reasoning_effort="none" thinking-off support used below.
# NB: OLLAMA_VERSION must omit the leading "v" (install.sh prepends it).
run: curl -fsSL https://ollama.com/install.sh | OLLAMA_VERSION=0.33.1 sh
- name: Start serving ollama
run: nohup ollama serve &
# Debug + per-request logging, dumped on failure below: needed to tell
# "server hung" from "bad request" when a live-model test stalls.
run: OLLAMA_DEBUG=1 OLLAMA_DEBUG_LOG_REQUESTS=1 nohup ollama serve > /tmp/ollama-serve.log 2>&1 &
- name: Start resource sampler
# Memory/swap/CPU every 30s, dumped on failure below — rules out
# memory pressure as a cause of live-model stalls.
run: |
if command -v vmstat >/dev/null; then
(vmstat 30 480) > /tmp/ci-resource-sampler.log 2>&1 &
else
(for i in $(seq 1 480); do
echo "sample $i $(date -u +%FT%TZ)"
free -m
cat /proc/loadavg
echo ---
sleep 30
done) > /tmp/ci-resource-sampler.log 2>&1 &
fi
- name: Pull models
# granite-vision-4.1 is not in the Ollama library, so it is pulled from
# IBM's official GGUF repo on Hugging Face (that build ships the mmproj
# projector Ollama needs for image input). Drop the hf.co prefix if the
# model is ever published to the Ollama library directly.
run: |
for model in granite4.1:3b hf.co/ibm-granite/granite-vision-4.1-4b-GGUF:Q4_K_M; do
for model in granite4.2:3b granite4:micro-h hf.co/ibm-granite/granite-vision-4.1-4b-GGUF:Q4_K_M; do
pulled=false
for i in 1 2 3 4 5; do
ollama pull "$model" && { pulled=true; break; }
Expand All @@ -144,6 +153,20 @@ jobs:
# The names below are what the tests match against; print them so a
# tag-name mismatch is diagnosable from the log alone.
ollama list
- name: Constrain the granite4.2:3b context for the CI runner
# The published tag ships num_ctx=131072, a ~6 GB KV cache at load —
# too much for the 16 GB runner and a cause of the CI stalls. Re-point
# the local tag at an 8192-context build of the same weights (same tag
# name, so no test code changes; 2x what other Ollama tests use).
run: |
ollama cp granite4.2:3b granite4.2:3b-128k
printf 'FROM granite4.2:3b-128k\nPARAMETER num_ctx 8192\n' \
> /tmp/MODELFILE-granite42-ci
ollama create granite4.2:3b -f /tmp/MODELFILE-granite42-ci
# grep exits non-zero (failing the step) if num_ctx did not land at
# exactly 8192 — matching only '^PARAMETER' would still pass on the
# base tag's untouched 131072 value if `ollama create` silently no-oped.
ollama show granite4.2:3b --modelfile | grep -qx 'PARAMETER num_ctx 8192'
- name: Run Tests
id: tests
env:
Expand All @@ -156,6 +179,31 @@ jobs:
- name: Send failure message tests
if: failure() # This step will only run if a previous step failed
run: echo "Tests failed. Please verify that tests are working locally."
- name: Dump Ollama server log on failure
if: failure() # Only the test step can produce a useful server log here.
# Raw log is too verbose to dump whole; extract requests/errors/model
# events plus a short raw tail.
run: |
echo "=== ollama serve log: requests (GIN), errors, model events ==="
grep -iE 'GIN|error|warn|panic|loading|unload' /tmp/ollama-serve.log | tail -n 500 || echo "(no matching lines)"
echo "=== ollama serve log (last 50 raw lines) ==="
tail -n 50 /tmp/ollama-serve.log || echo "(no ollama serve log found)"
echo "=== resource sampler (30 s: memory, swap si/so, CPU) ==="
cat /tmp/ci-resource-sampler.log || echo "(no sampler log found)"
echo "=== ollama request debug logs (request bodies + replay curls) ==="
reqdir=$(grep -oE '/tmp/ollama-request-logs-[0-9a-z]+' /tmp/ollama-serve.log | head -1)
if [ -n "$reqdir" ] && [ -d "$reqdir" ]; then
echo "file count: $(find "$reqdir" -maxdepth 1 -type f | wc -l)"
# Newest 20 files (mtime-descending, "%T@" epoch + path).
find "$reqdir" -maxdepth 1 -type f -printf '%T@ %p\n' \
| sort -rn | head -20 | while IFS= read -r line; do
f=${line#* }
echo "--- $f ---"
cat "$f"
done
else
echo "(no request debug dir found in serve log)"
fi
- name: Write job summary
if: always()
run: |
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/examples/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,4 @@ uv run docs/examples/<folder>/<file>.py

**Default backend:** `start_session()` with no arguments connects to a local
[Ollama](https://ollama.ai) instance running **IBM Granite 4 Micro**
(`granite4.1:3b`). Make sure Ollama is running before you execute any example.
(`granite4.2:3b`). Make sure Ollama is running before you execute any example.
2 changes: 1 addition & 1 deletion docs/docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,5 @@ The default session connects to [Ollama](https://ollama.ai) running locally.
Install Ollama and pull the default model before running any examples:

```bash
ollama pull granite4.1:3b
ollama pull granite4.2:3b
```
4 changes: 2 additions & 2 deletions docs/docs/getting-started/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ description: "Run your first generative program in minutes."
## Hello world

By default, `start_session()` connects to Ollama and uses **IBM Granite 4 Micro**
(`granite4.1:3b`). Make sure Ollama is running before you run this:
(`granite4.2:3b`). Make sure Ollama is running before you run this:

```python
import mellea
Expand Down Expand Up @@ -205,7 +205,7 @@ Hugging Face, and WatsonX are also supported. See

## Troubleshooting

**`granite4.1:3b` not found** — run `ollama pull granite4.1:3b` before starting.
**`granite4.2:3b` not found** — run `ollama pull granite4.2:3b` before starting.

**Python 3.13 `outlines` install failure** — `outlines` requires a Rust compiler.
Either install [Rust](https://www.rust-lang.org/tools/install) or pin Python to 3.12.
Expand Down
6 changes: 3 additions & 3 deletions docs/docs/troubleshooting/common-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@ description: "Common errors, diagnostic steps, and fixes for Mellea programs."

## Installation

### `granite4.1:3b` not found
### `granite4.2:3b` not found

```text
Error: model "granite4.1:3b" not found
Error: model "granite4.2:3b" not found
```

Pull the model before running:

```bash
ollama pull granite4.1:3b
ollama pull granite4.2:3b
```

### Python 3.13: `outlines` install failure
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/troubleshooting/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ m = MelleaSession(
)
```

## How do I use a model other than `granite4.1:3b`?
## How do I use a model other than `granite4.2:3b`?

Pass the `model_id` parameter to `start_session()`:

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/tutorials/01-your-first-generative-program.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ By the end you will have covered:
> see [Tutorial 03: Using Generative Stubs](../tutorials/using-generative-stubs).

**Prerequisites:** [Quick Start](../getting-started/quickstart) complete,
Mellea installed (`uv add mellea`), Ollama running locally with `granite4.1:3b` downloaded.
Mellea installed (`uv add mellea`), Ollama running locally with `granite4.2:3b` downloaded.

---

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/tutorials/02-streaming-and-async.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ By the end you will have covered:
- Context behaviour with concurrent async calls

**Prerequisites:** [Tutorial 01](./your-first-generative-program) complete,
`pip install mellea`, Ollama running locally with `granite4.1:3b` downloaded.
`pip install mellea`, Ollama running locally with `granite4.2:3b` downloaded.

---

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/tutorials/03-using-generative-stubs.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ By the end you will have covered:
- Precondition and postcondition validation patterns

**Prerequisites:** [Tutorial 01](./your-first-generative-program) complete,
`pip install mellea`, Ollama running locally with `granite4.1:3b` downloaded.
`pip install mellea`, Ollama running locally with `granite4.2:3b` downloaded.

---

Expand Down
24 changes: 17 additions & 7 deletions mellea/backends/litellm.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ class LiteLLMBackend(FormatterBackend):
`ollama_chat/` → localhost:11434, `anthropic/` → Anthropic API).
Use `None` for cloud providers; set explicitly for local servers
such as vLLM or a non-default Ollama port.
model_options (dict | None): Default model options for generation requests.
model_options (dict | None): Default model options applied to every
generation request. Per-call options take precedence. Use
`{ModelOption.THINKING: False}` here to suppress the think block
on models that enable it by default.

Attributes:
to_mellea_model_opts_map (dict): Mapping from backend-specific option names to
Expand All @@ -87,7 +90,7 @@ class LiteLLMBackend(FormatterBackend):

def __init__(
self,
model_id: str = "ollama_chat/" + str(model_ids.IBM_GRANITE_4_1_3B.ollama_name),
model_id: str = "ollama_chat/" + str(model_ids.IBM_GRANITE_4_2_3B.ollama_name),
formatter: ChatFormatter | None = None,
base_url: str | None = None,
model_options: dict | None = None,
Expand Down Expand Up @@ -384,9 +387,9 @@ async def _generate_from_chat_context_standard(

# Map THINKING to the correct backend parameter(s). Two mechanisms:
# - chat_template_kwargs.enable_thinking: vLLM/Qwen3/Gemma4 (bool toggle)
# - reasoning_effort: LiteLLM/OpenAI-compatible (string level, or True → "medium")
# Both are set for True so each server picks up whichever it understands.
# NOTE: don't pass reasoning_effort=False — it is invalid; absence disables reasoning.
# - reasoning_effort: LiteLLM/OpenAI-compatible (string level; True → "medium",
# False → "none")
# Both are set so each server picks up whichever it understands.
thinking = model_opts.get(ModelOption.THINKING, None)
original_thinking = thinking # preserve raw caller value for the generate log
reasoning_params: dict[str, Any] = {}
Expand All @@ -399,8 +402,15 @@ async def _generate_from_chat_context_standard(
extra_params["extra_body"] = ctk_body
if thinking:
reasoning_params["reasoning_effort"] = "medium"
# False: do not send reasoning_effort — absent param disables reasoning;
# passing False would be invalid.
elif "ollama" in self._model_id.split("/")[0]:
# Ollama-served thinking models (e.g. granite4.2) default to
# thinking ON when reasoning_effort is absent; "none" is the
# OpenAI enum value their /v1 endpoint maps to think=false
# (Ollama >= 0.33.1). Real OpenAI/other reasoning providers
# reject "none", so this is scoped to Ollama-routed models
# (same provider-prefix check used for the streaming
# tool-call workaround above).
reasoning_params["reasoning_effort"] = "none"
else:
reasoning_params["reasoning_effort"] = thinking

Expand Down
19 changes: 19 additions & 0 deletions mellea/backends/model_ids.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,25 @@ class ModelIdentifier:
context_length=131072,
)

# Granite 4.2 Dense Models
IBM_GRANITE_4_2_3B = ModelIdentifier(
hf_model_name="ibm-granite/granite-4.2-3b",
ollama_name="granite4.2:3b",
context_length=131072,
)

IBM_GRANITE_4_2_8B = ModelIdentifier(
hf_model_name="ibm-granite/granite-4.2-8b",
ollama_name="granite4.2:8b",
context_length=131072,
)

IBM_GRANITE_4_2_30B = ModelIdentifier(
hf_model_name="ibm-granite/granite-4.2-30b",
ollama_name="granite4.2:30b",
context_length=131072,
)

IBM_GRANITE_GUARDIAN_4_1_8B = ModelIdentifier(
hf_model_name="ibm-granite/granite-guardian-4.1-8b", context_length=131072
)
Expand Down
7 changes: 5 additions & 2 deletions mellea/backends/model_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,11 @@ class ModelOption:
silently ignored by those servers.
* `False` — native Ollama backend: sends `think=False`.
OpenAI-compatible backends: sets `chat_template_kwargs.enable_thinking=False`
to suppress the think block. `reasoning_effort` is not sent (passing
`False` would be an invalid value for OpenAI; absence disables reasoning).
and `reasoning_effort="none"` to suppress the think block. Both are sent so
each server type picks up the mechanism it understands: vLLM honours
`chat_template_kwargs`, while Ollama's /v1 endpoint (>= 0.33.1) honours
`reasoning_effort` — and for models that default to thinking on (e.g.
granite4.2) absent `reasoning_effort` means thinking stays on.
* `"low"` / `"medium"` / `"high"` — passed directly as `reasoning_effort`
(OpenAI-compatible backends only; no-op on vLLM).

Expand Down
2 changes: 1 addition & 1 deletion mellea/backends/ollama.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ class OllamaModelBackend(FormatterBackend):

def __init__(
self,
model_id: str | ModelIdentifier = model_ids.IBM_GRANITE_4_1_3B,
model_id: str | ModelIdentifier = model_ids.IBM_GRANITE_4_2_3B,
formatter: ChatFormatter | None = None,
base_url: str | None = None,
model_options: dict | None = None,
Expand Down
79 changes: 46 additions & 33 deletions mellea/backends/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,47 @@ def _make_backend_specific_and_remove(

return model_opts

def _map_thinking_option(
self, thinking: Any, extra_body: dict[str, Any]
) -> dict[str, Any]:
"""Maps `ModelOption.THINKING` to the correct backend parameter(s).

Two mechanisms, both set (when applicable) so the right server picks
up whichever it understands:
- `extra_body["chat_template_kwargs"]["enable_thinking"]`: vLLM/Qwen3
- `reasoning_effort`: OpenAI/DeepSeek/Ollama (string level; True →
"medium", False → "none")

Ollama-served models (e.g. granite4.2) think by default unless
`reasoning_effort="none"` is sent (Ollama >= 0.33.1); real OpenAI
rejects `"none"`, so that value is scoped to non-OpenAI servers.

Args:
thinking: the raw `ModelOption.THINKING` value (bool, string
reasoning-effort level, or None).
extra_body: the in-progress `extra_body` dict for this request;
mutated in place to add `chat_template_kwargs` if `thinking`
is a bool.

Returns:
dict[str, Any]: `reasoning_effort` params to merge into the
request's top-level kwargs, or `{}` if `thinking` is None.
"""
reasoning_params: dict[str, Any] = {}
if thinking is None: # False is a valid value — cannot use `if thinking`
return reasoning_params
if type(thinking) is bool:
ctk = extra_body.get("chat_template_kwargs", {}) or {}
ctk["enable_thinking"] = thinking
extra_body["chat_template_kwargs"] = ctk
if thinking:
reasoning_params["reasoning_effort"] = "medium"
elif self._server_type != _ServerType.OPENAI:
reasoning_params["reasoning_effort"] = "none"
else:
reasoning_params["reasoning_effort"] = thinking
return reasoning_params

def _merge_user_extra_body(
self, base: dict[str, Any], user: dict[str, Any] | None
) -> dict[str, Any]:
Expand Down Expand Up @@ -809,22 +850,8 @@ async def _generate_from_intrinsic(
user_extra_body = user_api_params.pop("extra_body", None)
api_params.update(user_api_params)

# Map THINKING to the correct backend parameter(s). Two mechanisms:
# - chat_template_kwargs.enable_thinking: vLLM/Qwen3 (bool toggle)
# - reasoning_effort: OpenAI/DeepSeek (string level, or True → "medium")
# Both are set for True so the right server picks up whichever it understands.
thinking = model_options.get(ModelOption.THINKING)
if thinking is not None: # False is a valid value — cannot use `if thinking`
if type(thinking) is bool:
ctk = extra_body.get("chat_template_kwargs", {}) or {}
ctk["enable_thinking"] = thinking
extra_body["chat_template_kwargs"] = ctk
if thinking:
api_params["reasoning_effort"] = "medium"
# False: don't send reasoning_effort — OpenAI disables reasoning by
# default when the param is absent; passing False would be invalid.
else:
api_params["reasoning_effort"] = thinking
api_params.update(self._map_thinking_option(thinking, extra_body))

extra_body = self._merge_user_extra_body(extra_body, user_extra_body)

Expand Down Expand Up @@ -1048,25 +1075,11 @@ async def _generate_from_chat_context_standard(
formatted_tools = convert_tools_to_json(tools)
use_tools = len(formatted_tools) > 0

# Map THINKING to the correct backend parameter(s). Two mechanisms:
# - chat_template_kwargs.enable_thinking: vLLM/Qwen3 (bool toggle)
# - reasoning_effort: OpenAI/DeepSeek (string level, or True → "medium")
# NOTE: don't pass reasoning_effort to non-reasoning models (e.g. gpt-4o).
# NOTE: don't pass THINKING to non-reasoning models (e.g. gpt-4o).
thinking = model_opts.get(ModelOption.THINKING)
reasoning_params: dict[str, Any] = {}
if thinking is not None: # False is a valid value — cannot use `if thinking`
if type(thinking) is bool:
ctk_body: dict[str, Any] = extra_params.get("extra_body", {}) or {}
ctk = ctk_body.get("chat_template_kwargs", {}) or {}
ctk["enable_thinking"] = thinking
ctk_body["chat_template_kwargs"] = ctk
extra_params["extra_body"] = ctk_body
if thinking:
reasoning_params["reasoning_effort"] = "medium"
# False: don't send reasoning_effort — OpenAI disables reasoning by
# default when the param is absent; passing False would be invalid.
else:
reasoning_params["reasoning_effort"] = thinking
ctk_body: dict[str, Any] = extra_params.get("extra_body", {}) or {}
reasoning_params = self._map_thinking_option(thinking, ctk_body)
extra_params["extra_body"] = ctk_body

# Request usage information in streaming responses
if model_opts.get(ModelOption.STREAM, False):
Expand Down
Loading
Loading