Skip to content

Commit e29ee58

Browse files
fix(ollama): make native num_ctx opt-in (#2589)
* fix(ollama): make native num_ctx opt-in * docs(ollama): add HINDSIGHT_API_LLM_OLLAMA_NUM_CTX to .env.example * fix(config): keep Ollama num_ctx optional for direct config construction --------- Co-authored-by: r266-tech <r266-tech@users.noreply.github.com> Co-authored-by: Ben <ben.bartholomew@vectorize.io>
1 parent 82e6731 commit e29ee58

10 files changed

Lines changed: 322 additions & 2 deletions

File tree

.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,15 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
5959
# HINDSIGHT_API_LLM_BASE_URL=http://localhost:1234/v1
6060
# HINDSIGHT_API_LLM_MODEL=qwen2.5-32b-instruct
6161

62+
# Example: Ollama local configuration (native provider)
63+
# HINDSIGHT_API_LLM_PROVIDER=ollama
64+
# HINDSIGHT_API_LLM_BASE_URL=http://localhost:11434/v1
65+
# HINDSIGHT_API_LLM_MODEL=gemma3:12b
66+
# Native Ollama context-window override (num_ctx). Leave unset to let Ollama use
67+
# the model Modelfile / server default; set a positive integer only to force a
68+
# specific context size (e.g. 16384 to keep the previous request behavior).
69+
# HINDSIGHT_API_LLM_OLLAMA_NUM_CTX=16384
70+
6271
# Multi-LLM strategies: configure extra LLMs by index alongside the primary above,
6372
# then pick a routing strategy. Unset = single primary LLM (default). Members are
6473
# numbered from 1; indices must be contiguous. Each operation can override with a

hindsight-api-slim/hindsight_api/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]:
147147
ENV_LLM_DEFAULT_HEADERS = "HINDSIGHT_API_LLM_DEFAULT_HEADERS"
148148
ENV_LLM_STRICT_SCHEMA = "HINDSIGHT_API_LLM_STRICT_SCHEMA"
149149
ENV_LLM_SEND_BANK_AS_USER = "HINDSIGHT_API_LLM_SEND_BANK_AS_USER"
150+
ENV_LLM_OLLAMA_NUM_CTX = "HINDSIGHT_API_LLM_OLLAMA_NUM_CTX"
150151

151152
# Per-operation sampling temperature. Each internal LLM call uses a temperature
152153
# tuned for its task (deterministic extraction vs. creative reflection). These
@@ -1594,6 +1595,9 @@ class HindsightConfig:
15941595
# LiteLLM, Helicone) key attribution on the OpenAI `user` field. Opt-in; never
15951596
# overrides a `user` the caller already set.
15961597
llm_send_bank_as_user: bool
1598+
# Optional native Ollama context window override. Unset lets Ollama use the
1599+
# model/server default instead of forcing a Hindsight-wide value.
1600+
llm_ollama_num_ctx: int | None = field(default=None, kw_only=True)
15971601

15981602
# Per-operation sampling temperature. None means the temperature parameter is
15991603
# omitted from the call (for models that reject explicit temperatures). See
@@ -2340,6 +2344,10 @@ def from_env(cls) -> "HindsightConfig":
23402344
llm_strict_schema=os.getenv(ENV_LLM_STRICT_SCHEMA, str(DEFAULT_LLM_STRICT_SCHEMA)).lower() in ("true", "1"),
23412345
llm_send_bank_as_user=os.getenv(ENV_LLM_SEND_BANK_AS_USER, str(DEFAULT_LLM_SEND_BANK_AS_USER)).lower()
23422346
in ("true", "1"),
2347+
llm_ollama_num_ctx=_parse_optional_positive_int(
2348+
ENV_LLM_OLLAMA_NUM_CTX,
2349+
os.getenv(ENV_LLM_OLLAMA_NUM_CTX),
2350+
),
23432351
llm_temperature_verification=_resolve_operation_temperature(
23442352
ENV_LLM_TEMPERATURE_VERIFICATION, DEFAULT_LLM_TEMPERATURE_VERIFICATION
23452353
),

hindsight-api-slim/hindsight_api/engine/llm_wrapper.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,17 @@ def requires_api_key(provider: str) -> bool:
235235
return provider.lower() not in _PROVIDERS_WITHOUT_API_KEY
236236

237237

238+
def _validate_ollama_num_ctx(value: Any) -> int | None:
239+
"""Validate a native Ollama context-window override."""
240+
if value is None:
241+
return None
242+
if isinstance(value, bool) or not isinstance(value, int):
243+
raise ValueError(f"ollama_num_ctx must be a positive integer, got {value!r}")
244+
if value < 1:
245+
raise ValueError(f"ollama_num_ctx must be >= 1, got {value}")
246+
return value
247+
248+
238249
def create_llm_provider(
239250
provider: str,
240251
api_key: str,
@@ -254,6 +265,7 @@ def create_llm_provider(
254265
litellmrouter_config: dict[str, Any] | None = None,
255266
gemini_service_tier: str | None = None,
256267
timeout: float | None = None,
268+
ollama_num_ctx: int | None = None,
257269
) -> Any: # Returns LLMInterface
258270
"""
259271
Factory function to create the appropriate LLM provider implementation.
@@ -268,6 +280,8 @@ def create_llm_provider(
268280
openai_service_tier: OpenAI service tier (for OpenAI provider) - None (default) or "flex" (50% cheaper).
269281
bedrock_service_tier: Bedrock service tier (for Bedrock provider) - None (default), "flex", "priority", or "reserved".
270282
gemini_service_tier: Gemini service tier (for Gemini provider) - None (default) or "flex" (50% cheaper).
283+
ollama_num_ctx: Native Ollama context window override. None lets Ollama use the
284+
model/server default.
271285
extra_body: Extra request-body params merged into the provider's native
272286
call. Threaded into OpenAI-compatible, Fireworks, Anthropic, Gemini/
273287
VertexAI and LiteLLM providers (each merges them in its own parameter
@@ -291,6 +305,8 @@ def create_llm_provider(
291305
Returns:
292306
LLMInterface implementation for the specified provider.
293307
"""
308+
ollama_num_ctx = _validate_ollama_num_ctx(ollama_num_ctx)
309+
294310
from .providers import (
295311
AnthropicLLM,
296312
ClaudeCodeLLM,
@@ -494,6 +510,7 @@ def create_llm_provider(
494510
groq_service_tier=groq_service_tier,
495511
openai_service_tier=openai_service_tier,
496512
extra_body=extra_body,
513+
ollama_num_ctx=ollama_num_ctx,
497514
timeout=timeout,
498515
)
499516

@@ -531,6 +548,7 @@ def __init__(
531548
max_retries: int | None = None,
532549
initial_backoff: float | None = None,
533550
max_backoff: float | None = None,
551+
ollama_num_ctx: int | None = None,
534552
):
535553
"""
536554
Initialize LLM provider.
@@ -545,6 +563,8 @@ def __init__(
545563
openai_service_tier: OpenAI service tier (None or "flex") - from config.
546564
bedrock_service_tier: Bedrock service tier (None, "flex", "priority", "reserved") - from config.
547565
gemini_service_tier: Gemini service tier (None or "flex") - from config.
566+
ollama_num_ctx: Native Ollama context window override. ``None`` lets Ollama
567+
use the model/server default.
548568
gemini_safety_settings: Safety settings for Gemini/VertexAI providers.
549569
extra_body: Extra request-body params merged into the provider's native call
550570
(OpenAI-compatible, Fireworks, Anthropic, Gemini/VertexAI, LiteLLM).
@@ -598,6 +618,7 @@ def __init__(
598618
self.openai_service_tier = openai_service_tier
599619
self.bedrock_service_tier = bedrock_service_tier
600620
self.gemini_service_tier = gemini_service_tier
621+
self.ollama_num_ctx = _validate_ollama_num_ctx(ollama_num_ctx)
601622
# Gemini safety settings (instance default; can be overridden per-request via context var)
602623
self.gemini_safety_settings = gemini_safety_settings
603624
# Gemini prompt caching: when True, retain extraction (and any future
@@ -742,6 +763,7 @@ def __init__(
742763
gemini_safety_settings=self.gemini_safety_settings,
743764
prompt_cache_enabled=self.prompt_cache_enabled,
744765
litellmrouter_config=router_config,
766+
ollama_num_ctx=self.ollama_num_ctx,
745767
timeout=self.timeout,
746768
)
747769

@@ -1260,6 +1282,7 @@ def from_env(cls) -> "LLMProvider":
12601282
ENV_LLM_GROQ_SERVICE_TIER,
12611283
ENV_LLM_LITELLMROUTER_CONFIG,
12621284
ENV_LLM_MODEL,
1285+
ENV_LLM_OLLAMA_NUM_CTX,
12631286
ENV_LLM_OPENAI_SERVICE_TIER,
12641287
ENV_LLM_PROMPT_CACHE_ENABLED,
12651288
ENV_LLM_PROVIDER,
@@ -1270,6 +1293,7 @@ def from_env(cls) -> "LLMProvider":
12701293
ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY,
12711294
_get_default_model_for_provider,
12721295
_parse_llm_router_config,
1296+
_parse_optional_positive_int,
12731297
parse_gemini_service_tier,
12741298
)
12751299

@@ -1314,6 +1338,7 @@ def from_env(cls) -> "LLMProvider":
13141338
),
13151339
gemini_safety_settings=json.loads(os.getenv(ENV_LLM_GEMINI_SAFETY_SETTINGS, "null")),
13161340
prompt_cache_enabled=prompt_cache_enabled,
1341+
ollama_num_ctx=_parse_optional_positive_int(ENV_LLM_OLLAMA_NUM_CTX, os.getenv(ENV_LLM_OLLAMA_NUM_CTX)),
13171342
litellmrouter_config=_parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG),
13181343
vertexai_project_id=os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID) or None,
13191344
vertexai_region=os.getenv(ENV_LLM_VERTEXAI_REGION) or None,

hindsight-api-slim/hindsight_api/engine/memory_engine.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,7 @@ def _member_to_llm(member: "LLMMemberConfig", config: HindsightConfig, defaults:
438438
reasoning_effort=member.reasoning_effort or config.llm_reasoning_effort,
439439
extra_body=member.extra_body,
440440
default_headers=member.default_headers or config.llm_default_headers,
441+
ollama_num_ctx=config.llm_ollama_num_ctx,
441442
bedrock_service_tier=member.bedrock_service_tier,
442443
gemini_service_tier=member.gemini_service_tier or config.llm_gemini_service_tier,
443444
gemini_safety_settings=_get_raw_config().llm_gemini_safety_settings,
@@ -1085,6 +1086,7 @@ def pick(field: str) -> Any:
10851086
reasoning_effort=config.llm_reasoning_effort,
10861087
extra_body=config.llm_extra_body,
10871088
default_headers=config.llm_default_headers,
1089+
ollama_num_ctx=config.llm_ollama_num_ctx,
10881090
litellmrouter_config=config.llm_litellmrouter_config,
10891091
bedrock_service_tier=config.llm_bedrock_service_tier,
10901092
gemini_service_tier=config.llm_gemini_service_tier,
@@ -1129,6 +1131,7 @@ def pick(field: str) -> Any:
11291131
reasoning_effort=config.llm_reasoning_effort,
11301132
extra_body=config.llm_extra_body,
11311133
default_headers=config.llm_default_headers,
1134+
ollama_num_ctx=config.llm_ollama_num_ctx,
11321135
litellmrouter_config=config.retain_llm_litellmrouter_config or config.llm_litellmrouter_config,
11331136
bedrock_service_tier=config.llm_bedrock_service_tier,
11341137
gemini_service_tier=config.llm_gemini_service_tier,
@@ -1167,6 +1170,7 @@ def pick(field: str) -> Any:
11671170
reasoning_effort=config.llm_reasoning_effort,
11681171
extra_body=config.llm_extra_body,
11691172
default_headers=config.llm_default_headers,
1173+
ollama_num_ctx=config.llm_ollama_num_ctx,
11701174
litellmrouter_config=config.reflect_llm_litellmrouter_config or config.llm_litellmrouter_config,
11711175
bedrock_service_tier=config.llm_bedrock_service_tier,
11721176
gemini_service_tier=config.llm_gemini_service_tier,
@@ -1205,6 +1209,7 @@ def pick(field: str) -> Any:
12051209
reasoning_effort=config.llm_reasoning_effort,
12061210
extra_body=config.llm_extra_body,
12071211
default_headers=config.llm_default_headers,
1212+
ollama_num_ctx=config.llm_ollama_num_ctx,
12081213
litellmrouter_config=config.consolidation_llm_litellmrouter_config or config.llm_litellmrouter_config,
12091214
bedrock_service_tier=config.llm_bedrock_service_tier,
12101215
gemini_service_tier=config.llm_gemini_service_tier,

hindsight-api-slim/hindsight_api/engine/providers/openai_compatible_llm.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,18 @@
4848
DEFAULT_LLM_SEED = 4242
4949
JSON_MODE_USER_HINT = "Return valid json only."
5050

51+
52+
def _validate_ollama_num_ctx(value: Any) -> int | None:
53+
"""Validate a native Ollama context-window override."""
54+
if value is None:
55+
return None
56+
if isinstance(value, bool) or not isinstance(value, int):
57+
raise ValueError(f"ollama_num_ctx must be a positive integer, got {value!r}")
58+
if value < 1:
59+
raise ValueError(f"ollama_num_ctx must be >= 1, got {value}")
60+
return value
61+
62+
5163
# Self-hosted OpenAI-compatible servers that advertise tool_choice="required"
5264
# but silently ignore it: instead of forcing a tool call they return
5365
# finish_reason "stop"/"tool_calls" with an EMPTY tool_calls array and no error.
@@ -480,6 +492,8 @@ def __init__(
480492
timeout: float | None = None,
481493
groq_service_tier: str | None = None,
482494
extra_body: dict[str, Any] | None = None,
495+
*,
496+
ollama_num_ctx: int | None = None,
483497
**kwargs: Any,
484498
):
485499
"""
@@ -494,6 +508,8 @@ def __init__(
494508
timeout: Request timeout in seconds (uses env var or 120s default).
495509
groq_service_tier: Groq service tier ("on_demand", "flex", "auto").
496510
extra_body: Extra body params merged into every API call.
511+
ollama_num_ctx: Native Ollama context window override. None lets Ollama use
512+
the model/server default.
497513
**kwargs: Additional provider-specific parameters.
498514
"""
499515
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
@@ -574,6 +590,7 @@ def __init__(
574590
# Service tier configuration (from config, not env vars)
575591
self.groq_service_tier = groq_service_tier
576592
self.openai_service_tier = kwargs.get("openai_service_tier")
593+
self.ollama_num_ctx = _validate_ollama_num_ctx(ollama_num_ctx)
577594
# User-configured extra body params (merged into every API call)
578595
self._config_extra_body = extra_body or {}
579596

@@ -1389,9 +1406,10 @@ async def _call_ollama_native(
13891406

13901407
# Add optional parameters with optimized defaults for Ollama
13911408
options: dict[str, Any] = {
1392-
"num_ctx": 16384, # 16k context window for larger prompts
13931409
"num_batch": 512, # Optimal batch size for prompt processing
13941410
}
1411+
if self.ollama_num_ctx is not None:
1412+
options["num_ctx"] = self.ollama_num_ctx
13951413
if max_completion_tokens:
13961414
options["num_predict"] = max_completion_tokens
13971415
if temperature is not None:

hindsight-api-slim/tests/test_config_validation.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,53 @@ def test_retain_structured_chunk_size_reads_from_env():
122122
assert config.retain_structured_chunk_size == 9000
123123

124124

125+
def test_llm_ollama_num_ctx_defaults_to_none(monkeypatch):
126+
"""Unset Ollama num_ctx override lets Ollama use its model/server default."""
127+
from hindsight_api.config import ENV_LLM_OLLAMA_NUM_CTX, HindsightConfig
128+
129+
monkeypatch.delenv(ENV_LLM_OLLAMA_NUM_CTX, raising=False)
130+
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
131+
132+
config = HindsightConfig.from_env()
133+
134+
assert config.llm_ollama_num_ctx is None
135+
136+
137+
def test_llm_ollama_num_ctx_keeps_direct_construction_default():
138+
"""Direct HindsightConfig construction should not require the new field."""
139+
from dataclasses import fields
140+
141+
from hindsight_api.config import HindsightConfig
142+
143+
config_field = next(item for item in fields(HindsightConfig) if item.name == "llm_ollama_num_ctx")
144+
145+
assert config_field.default is None
146+
assert config_field.kw_only
147+
148+
149+
def test_llm_ollama_num_ctx_reads_positive_int(monkeypatch):
150+
"""The native Ollama context override is parsed as a positive integer."""
151+
from hindsight_api.config import ENV_LLM_OLLAMA_NUM_CTX, HindsightConfig
152+
153+
monkeypatch.setenv(ENV_LLM_OLLAMA_NUM_CTX, "65536")
154+
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
155+
156+
config = HindsightConfig.from_env()
157+
158+
assert config.llm_ollama_num_ctx == 65536
159+
160+
161+
def test_llm_ollama_num_ctx_rejects_non_positive_values(monkeypatch):
162+
"""Zero would be accepted by neither Ollama nor downstream range logic."""
163+
from hindsight_api.config import ENV_LLM_OLLAMA_NUM_CTX, HindsightConfig
164+
165+
monkeypatch.setenv(ENV_LLM_OLLAMA_NUM_CTX, "0")
166+
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
167+
168+
with pytest.raises(ValueError, match=ENV_LLM_OLLAMA_NUM_CTX):
169+
HindsightConfig.from_env()
170+
171+
125172
def test_retain_structured_chunk_size_can_be_less_than_chunk_size():
126173
"""Structured-chunk cap can be smaller than the retain chunk target."""
127174
from hindsight_api.config import HindsightConfig

0 commit comments

Comments
 (0)