oc_chat
Loading...
Searching...
No Matches
provider.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# providers/ollama/provider.py
4
5"""
6OllamaProvider — :class:`ProviderPlugin` implementation for Ollama.
7
8Purpose
9-------
10Connects OC Chat to a local (or LAN) Ollama daemon via the native
11``/api/chat`` endpoint. No API key required. Base URL defaults to
12``http://localhost:11434`` and is overridable via ``OLLAMA_BASE_URL``.
13
14Responsibilities
15----------------
16- :class:`OllamaProvider` — implement every abstract method on
17 :class:`ProviderPlugin` for the Ollama provider.
18- :meth:`build_client` — return an :class:`OllamaClient`.
19- :meth:`models` / :meth:`tier` — surface the static registries.
20- :meth:`build_extra_body` — always ``None`` (no thinking payload).
21- :meth:`transform_outgoing_messages` — pass-through (no sentinel).
22- :meth:`parse_usage` — read ``prompt_eval_count`` / ``eval_count``
23 from the Ollama-shaped usage dict yielded by :class:`OllamaClient`.
24- :meth:`fetch_live_models` — delegate to ``client.list_models()``.
25- :meth:`default_caps` — permissive local defaults for any live model
26 not in the curated registry.
27
28Diagnostics
29-----------
30Domain: OC_CHAT.CLIENT
31Levels:
32 L2 — lifecycle events
33 L3 — semantic details
34 L4 — deep tracing
35
36Contracts
37---------
38- ``api_key_env_var = ""`` — Ollama does not use API keys.
39- ``transform_outgoing_messages`` is a true pass-through (no sentinel
40 embedding — Ollama has no disk-based prefix cache that benefits from
41 it).
42- ``parse_usage`` handles both the Ollama-native field names
43 (``prompt_eval_count`` / ``eval_count``) and the normalised names
44 (``prompt_tokens`` / ``completion_tokens``) so it works regardless of
45 which shape the caller passes.
46
47Dependencies
48------------
49- oc_diagnostics (_dbg)
50- providers.ollama.client (OllamaClient)
51- providers.ollama.models (MODELS, TIER_MAP)
52- option_c_agent_contracts (ProviderPlugin base class and the ModelCaps, ModelInfo, PerformanceStats, UsageStats and BaseClient types)
53"""
54from __future__ import annotations
55
56from oc_diagnostics import _dbg
57
58from option_c_agent_contracts import (
59 ProviderPlugin,
60 ModelCaps,
61 ModelInfo,
62 PerformanceStats,
63 UsageStats,
64 BaseClient,
65)
66
67from .client import OllamaClient
68from .models import MODELS, TIER_MAP
69
70
71class OllamaProvider(ProviderPlugin):
72 """Ollama LLM provider — native /api/chat, no API key."""
73
74 name = "ollama"
75 # OCT-LINT: disable=no_hardcoded_secrets reason="Empty env-var-name; Ollama uses no API key, not a credential"
76 api_key_env_var = ""
77 base_url_env_var = "OLLAMA_BASE_URL"
78 default_base_url = "http://localhost:11434"
79 default_model = "llama3.2"
80
81 # ── Client construction ──────────────────────────────────────────────────
82
83 def build_client(self, api_key: str, base_url: str) -> BaseClient:
84 func = "build_client"
85 _dbg("OC_CHAT.CLIENT", func, f"OllamaProvider base_url={base_url!r}", 3)
86 return OllamaClient(api_key=api_key, base_url=base_url)
87
88 # ── Model registry ───────────────────────────────────────────────────────
89
90 def models(self) -> dict[str, ModelCaps]:
91 return MODELS
92
93 def fetch_live_models(self, client: BaseClient) -> list[str]:
94 return client.list_models()
95
96 def default_caps(self) -> ModelCaps:
97 return ModelCaps(
98 supports_thinking=False,
99 supports_reasoning=False,
100 supports_tools=True,
101 max_context_tokens=8_192,
102 )
103
104 # ── Tier mapping ─────────────────────────────────────────────────────────
105
106 def tier(self, name: str) -> str | None:
107 return TIER_MAP.get(name)
108
109 # ── Per-call shaping ─────────────────────────────────────────────────────
110
112 self,
113 model: str,
114 *,
115 enable_thinking: bool,
116 effort: str | None,
117 ) -> dict | None:
118 return None # Ollama has no extra_body concept
119
121 self,
122 messages: list[dict],
123 tool_schemas: list[dict] | None,
124 ) -> list[dict]:
125 return messages # Ollama uses the messages array as-is
126
127 # ── Optional extension hooks ─────────────────────────────────────────────
128
129 def translate_generation_options(self, opts: "GenerationOptions") -> dict | None: # type: ignore[override]
130 from option_c_agent_contracts import GenerationOptions
131 d: dict = {}
132 if opts.temperature is not None: d["temperature"] = opts.temperature
133 if opts.top_p is not None: d["top_p"] = opts.top_p
134 if opts.top_k is not None: d["top_k"] = opts.top_k
135 if opts.num_predict is not None: d["num_predict"] = opts.num_predict
136 if opts.repeat_penalty is not None: d["repeat_penalty"] = opts.repeat_penalty
137 if opts.seed is not None: d["seed"] = opts.seed
138 return {"options": d} if d else None
139
140 def parse_performance(self, chunk: dict) -> PerformanceStats:
141 return PerformanceStats(
142 prompt_eval_count=chunk.get("prompt_eval_count", 0),
143 prompt_eval_duration_ns=chunk.get("prompt_eval_duration", 0),
144 eval_count=chunk.get("eval_count", 0),
145 eval_duration_ns=chunk.get("eval_duration", 0),
146 total_duration_ns=chunk.get("total_duration", 0),
147 )
148
149 def model_info(self, model: str, client: BaseClient) -> ModelInfo | None:
150 func = "model_info"
151 try:
152 data = client.show_model(model) # type: ignore[attr-defined]
153 except Exception as exc:
154 _dbg("OC_CHAT.CLIENT", func, f"show_model failed: {exc}", 2)
155 return None
156 details = data.get("details") or {}
157 return ModelInfo(
158 name=model,
159 family=details.get("family", ""),
160 parameter_count=details.get("parameter_size", ""),
161 quantization=details.get("quantization_level", ""),
162 context_window=details.get("context_length", 0),
163 raw_metadata=data,
164 )
165
166 # ── Response parsing ─────────────────────────────────────────────────────
167
168 def parse_usage(self, usage: dict) -> UsageStats:
169 if not isinstance(usage, dict):
170 return UsageStats()
171 # Accept both Ollama-native names and normalised names
172 prompt = int(
173 usage.get("prompt_eval_count")
174 or usage.get("prompt_tokens")
175 or 0
176 )
177 completion = int(
178 usage.get("eval_count")
179 or usage.get("completion_tokens")
180 or 0
181 )
182 total = int(usage.get("total_tokens") or 0) or (prompt + completion)
183 return UsageStats(
184 prompt_tokens=prompt,
185 completion_tokens=completion,
186 total_tokens=total,
187 )
list[str] fetch_live_models(self, BaseClient client)
Definition provider.py:93
str|None tier(self, str name)
Definition provider.py:106
list[dict] transform_outgoing_messages(self, list[dict] messages, list[dict]|None tool_schemas)
Definition provider.py:124
PerformanceStats parse_performance(self, dict chunk)
Definition provider.py:140
dict|None translate_generation_options(self, "GenerationOptions" opts)
Definition provider.py:129
UsageStats parse_usage(self, dict usage)
Definition provider.py:168
BaseClient build_client(self, str api_key, str base_url)
Definition provider.py:83
ModelInfo|None model_info(self, str model, BaseClient client)
Definition provider.py:149
dict|None build_extra_body(self, str model, *, bool enable_thinking, str|None effort)
Definition provider.py:117