55namespace CarmeloSantana \PHPAgents \Provider ;
66
77use CarmeloSantana \PHPAgents \Config \ModelDefinition ;
8+ use CarmeloSantana \PHPAgents \Contract \ToolInterface ;
9+ use CarmeloSantana \PHPAgents \Provider \Response ;
810use Symfony \Contracts \HttpClient \HttpClientInterface ;
911
1012final class OllamaProvider extends OpenAICompatibleProvider
1113{
14+ /**
15+ * JSON Schema keywords unsupported by Ollama's tool parser.
16+ *
17+ * Ollama's Go backend maps tool schemas to strict internal structs.
18+ * Keywords not in those structs cause 400 errors or silently break
19+ * property parsing. We strip these before sending and demote
20+ * validation constraints into the description field.
21+ */
22+ private const UNSUPPORTED_SCHEMA_KEYWORDS = [
23+ // Validation keywords
24+ 'minimum ' ,
25+ 'maximum ' ,
26+ 'exclusiveMinimum ' ,
27+ 'exclusiveMaximum ' ,
28+ 'minLength ' ,
29+ 'maxLength ' ,
30+ 'pattern ' ,
31+ 'additionalProperties ' ,
32+ 'minItems ' ,
33+ 'maxItems ' ,
34+ 'uniqueItems ' ,
35+ 'format ' ,
36+ // Structural / logic keywords
37+ 'oneOf ' ,
38+ 'anyOf ' ,
39+ 'allOf ' ,
40+ 'const ' ,
41+ '$ref ' ,
42+ '$defs ' ,
43+ 'patternProperties ' ,
44+ 'default ' ,
45+ ];
46+
47+ /**
48+ * Keywords whose values can be demoted into the description field
49+ * so the LLM still sees the constraint as natural language.
50+ */
51+ private const DEMOTABLE_KEYWORDS = [
52+ 'minimum ' => 'Minimum value: %s. ' ,
53+ 'maximum ' => 'Maximum value: %s. ' ,
54+ 'exclusiveMinimum ' => 'Must be greater than %s. ' ,
55+ 'exclusiveMaximum ' => 'Must be less than %s. ' ,
56+ 'minLength ' => 'Minimum length: %s. ' ,
57+ 'maxLength ' => 'Maximum length: %s. ' ,
58+ 'pattern ' => 'Must match pattern: %s. ' ,
59+ 'minItems ' => 'Minimum items: %s. ' ,
60+ 'maxItems ' => 'Maximum items: %s. ' ,
61+ 'const ' => 'Must be exactly: %s. ' ,
62+ 'default ' => 'Default: %s. ' ,
63+ 'format ' => 'Format: %s. ' ,
64+ ];
65+
66+ /**
67+ * Default context window size for Ollama when tools are present.
68+ *
69+ * Tool schemas can easily consume 30-50K tokens. Ollama defaults
70+ * to 8192 which causes silent truncation and 500 errors.
71+ */
72+ private const DEFAULT_NUM_CTX = 65536 ;
73+
1274 public function __construct (
1375 string $ model = 'llama3.2 ' ,
1476 string $ baseUrl = 'http://localhost:11434/v1 ' ,
1577 ?HttpClientInterface $ httpClient = null ,
78+ private int $ numCtx = self ::DEFAULT_NUM_CTX ,
1679 ) {
1780 parent ::__construct (
1881 model: $ model ,
@@ -22,6 +85,28 @@ public function __construct(
2285 );
2386 }
2487
88+ /**
89+ * Override chat to inject Ollama-specific options.
90+ *
91+ * Sets num_ctx to ensure Ollama allocates enough context for tool
92+ * schemas. Without this, Ollama defaults to 8192 tokens and silently
93+ * truncates the prompt, causing corrupted tool definitions and 500s.
94+ */
95+ #[\Override]
96+ public function chat (array $ messages , array $ tools = [], array $ options = []): Response
97+ {
98+ return parent ::chat ($ messages , $ tools , $ this ->injectOllamaOptions ($ options , $ tools ));
99+ }
100+
101+ /**
102+ * Override stream to inject Ollama-specific options.
103+ */
104+ #[\Override]
105+ public function stream (array $ messages , array $ tools = [], array $ options = []): iterable
106+ {
107+ return parent ::stream ($ messages , $ tools , $ this ->injectOllamaOptions ($ options , $ tools ));
108+ }
109+
25110 /**
26111 * List locally available models via Ollama's native API.
27112 *
@@ -89,4 +174,160 @@ public function isAvailable(): bool
89174 return false ;
90175 }
91176 }
177+
178+ /**
179+ * Inject Ollama-specific options into the request payload.
180+ *
181+ * When tools are present, sets num_ctx to ensure Ollama allocates
182+ * enough KV cache for the full tool schema payload. Without this,
183+ * Ollama's default 8192 tokens truncates tool definitions mid-schema,
184+ * causing the model to generate invalid tool calls → 500 errors.
185+ *
186+ * @param array<string, mixed> $options
187+ * @param array<ToolInterface> $tools
188+ * @return array<string, mixed>
189+ */
190+ private function injectOllamaOptions (array $ options , array $ tools ): array
191+ {
192+ if (!empty ($ tools ) && !isset ($ options ['num_ctx ' ])) {
193+ $ options ['num_ctx ' ] = $ this ->numCtx ;
194+ }
195+
196+ return $ options ;
197+ }
198+
199+ /**
200+ * Format tools for Ollama, stripping unsupported JSON Schema keywords.
201+ *
202+ * Ollama's tool schema parser only supports a subset of JSON Schema.
203+ * We generate full schemas via toFunctionSchema() then recursively
204+ * remove keywords that would cause 400 "invalid tool call arguments".
205+ */
206+ #[\Override]
207+ protected function formatTools (array $ tools ): array
208+ {
209+ return array_map (function (ToolInterface $ tool ): array {
210+ $ schema = $ tool ->toFunctionSchema ();
211+
212+ if (isset ($ schema ['function ' ]['parameters ' ])) {
213+ $ schema ['function ' ]['parameters ' ] = $ this ->sanitizeSchema (
214+ $ schema ['function ' ]['parameters ' ],
215+ );
216+ }
217+
218+ return $ schema ;
219+ }, $ tools );
220+ }
221+
222+ /**
223+ * Recursively sanitize a schema node for Ollama compatibility.
224+ *
225+ * Demotes validation constraints into the description field,
226+ * flattens union combinators to the first concrete type, then
227+ * strips all remaining unsupported keywords.
228+ *
229+ * @param array<string, mixed> $schema
230+ * @return array<string, mixed>
231+ */
232+ private function sanitizeSchema (array $ schema ): array
233+ {
234+ // Flatten anyOf / oneOf / allOf → pick first non-null type
235+ foreach (['anyOf ' , 'oneOf ' , 'allOf ' ] as $ combinator ) {
236+ if (isset ($ schema [$ combinator ]) && is_array ($ schema [$ combinator ])) {
237+ $ schema = $ this ->flattenCombinator ($ schema , $ combinator );
238+ }
239+ }
240+
241+ // Demote validation keywords into description
242+ $ schema = $ this ->demoteConstraints ($ schema );
243+
244+ // Strip everything Ollama doesn't understand
245+ foreach (self ::UNSUPPORTED_SCHEMA_KEYWORDS as $ keyword ) {
246+ unset($ schema [$ keyword ]);
247+ }
248+
249+ // Recurse into object properties
250+ if (isset ($ schema ['properties ' ]) && is_array ($ schema ['properties ' ])) {
251+ foreach ($ schema ['properties ' ] as $ key => $ property ) {
252+ if (is_array ($ property )) {
253+ $ schema ['properties ' ][$ key ] = $ this ->sanitizeSchema ($ property );
254+ }
255+ }
256+ }
257+
258+ // Recurse into array items
259+ if (isset ($ schema ['items ' ]) && is_array ($ schema ['items ' ])) {
260+ $ schema ['items ' ] = $ this ->sanitizeSchema ($ schema ['items ' ]);
261+ }
262+
263+ return $ schema ;
264+ }
265+
266+ /**
267+ * Flatten a union combinator (anyOf/oneOf/allOf) into a single type.
268+ *
269+ * Picks the first non-null variant and merges its fields into the
270+ * parent schema, so Ollama sees a simple single-type property.
271+ *
272+ * @param array<string, mixed> $schema
273+ * @return array<string, mixed>
274+ */
275+ private function flattenCombinator (array $ schema , string $ combinator ): array
276+ {
277+ /** @var list<array<string, mixed>> $variants */
278+ $ variants = $ schema [$ combinator ];
279+ unset($ schema [$ combinator ]);
280+
281+ // Find the first variant that isn't just {type: "null"}
282+ foreach ($ variants as $ variant ) {
283+ if (!is_array ($ variant )) {
284+ continue ;
285+ }
286+ if (($ variant ['type ' ] ?? null ) === 'null ' ) {
287+ continue ;
288+ }
289+ // Merge variant fields into parent (type, description, etc.)
290+ $ schema = array_merge ($ schema , $ variant );
291+ return $ schema ;
292+ }
293+
294+ // All variants were null — fall back to string
295+ $ schema ['type ' ] = 'string ' ;
296+
297+ return $ schema ;
298+ }
299+
300+ /**
301+ * Demote validation constraints into the description field.
302+ *
303+ * Before stripping unsupported keywords, append their values as
304+ * human-readable hints so the LLM still respects the constraints.
305+ *
306+ * @param array<string, mixed> $schema
307+ * @return array<string, mixed>
308+ */
309+ private function demoteConstraints (array $ schema ): array
310+ {
311+ $ hints = [];
312+
313+ foreach (self ::DEMOTABLE_KEYWORDS as $ keyword => $ template ) {
314+ if (!isset ($ schema [$ keyword ])) {
315+ continue ;
316+ }
317+
318+ $ value = $ schema [$ keyword ];
319+ $ display = is_scalar ($ value ) ? (string ) $ value : json_encode ($ value );
320+ $ hints [] = sprintf ($ template , $ display );
321+ }
322+
323+ if (!empty ($ hints )) {
324+ $ existing = $ schema ['description ' ] ?? '' ;
325+ $ suffix = implode (' ' , $ hints );
326+ $ schema ['description ' ] = $ existing !== ''
327+ ? $ existing . ' ' . $ suffix
328+ : $ suffix ;
329+ }
330+
331+ return $ schema ;
332+ }
92333}
0 commit comments