ollama-client 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -0
  3. data/README.md +220 -12
  4. data/docs/CLOUD.md +29 -0
  5. data/docs/CONSOLE_IMPROVEMENTS.md +256 -0
  6. data/docs/FEATURES_ADDED.md +145 -0
  7. data/docs/HANDLERS_ANALYSIS.md +190 -0
  8. data/docs/README.md +37 -0
  9. data/docs/SCHEMA_FIXES.md +147 -0
  10. data/docs/TEST_UPDATES.md +107 -0
  11. data/examples/README.md +92 -0
  12. data/examples/advanced_complex_schemas.rb +6 -3
  13. data/examples/advanced_multi_step_agent.rb +13 -7
  14. data/examples/chat_console.rb +143 -0
  15. data/examples/complete_workflow.rb +14 -4
  16. data/examples/dhan_console.rb +843 -0
  17. data/examples/dhanhq/agents/base_agent.rb +0 -2
  18. data/examples/dhanhq/agents/orchestrator_agent.rb +1 -2
  19. data/examples/dhanhq/agents/technical_analysis_agent.rb +67 -49
  20. data/examples/dhanhq/analysis/market_structure.rb +44 -28
  21. data/examples/dhanhq/analysis/pattern_recognizer.rb +64 -47
  22. data/examples/dhanhq/analysis/trend_analyzer.rb +6 -8
  23. data/examples/dhanhq/dhanhq_agent.rb +296 -99
  24. data/examples/dhanhq/indicators/technical_indicators.rb +3 -5
  25. data/examples/dhanhq/scanners/intraday_options_scanner.rb +360 -255
  26. data/examples/dhanhq/scanners/swing_scanner.rb +118 -84
  27. data/examples/dhanhq/schemas/agent_schemas.rb +2 -2
  28. data/examples/dhanhq/services/data_service.rb +5 -7
  29. data/examples/dhanhq/services/trading_service.rb +0 -3
  30. data/examples/dhanhq/technical_analysis_agentic_runner.rb +217 -84
  31. data/examples/dhanhq/technical_analysis_runner.rb +216 -162
  32. data/examples/dhanhq/test_tool_calling.rb +538 -0
  33. data/examples/dhanhq/test_tool_calling_verbose.rb +251 -0
  34. data/examples/dhanhq/utils/trading_parameter_normalizer.rb +12 -17
  35. data/examples/dhanhq_agent.rb +159 -116
  36. data/examples/dhanhq_tools.rb +1158 -251
  37. data/examples/multi_step_agent_with_external_data.rb +368 -0
  38. data/examples/structured_tools.rb +89 -0
  39. data/examples/test_dhanhq_tool_calling.rb +375 -0
  40. data/examples/test_tool_calling.rb +160 -0
  41. data/examples/tool_calling_direct.rb +124 -0
  42. data/examples/tool_dto_example.rb +94 -0
  43. data/exe/dhan_console +4 -0
  44. data/exe/ollama-client +1 -1
  45. data/lib/ollama/agent/executor.rb +116 -15
  46. data/lib/ollama/client.rb +118 -55
  47. data/lib/ollama/config.rb +36 -0
  48. data/lib/ollama/dto.rb +187 -0
  49. data/lib/ollama/embeddings.rb +77 -0
  50. data/lib/ollama/options.rb +104 -0
  51. data/lib/ollama/response.rb +121 -0
  52. data/lib/ollama/tool/function/parameters/property.rb +72 -0
  53. data/lib/ollama/tool/function/parameters.rb +101 -0
  54. data/lib/ollama/tool/function.rb +78 -0
  55. data/lib/ollama/tool.rb +60 -0
  56. data/lib/ollama/version.rb +1 -1
  57. data/lib/ollama_client.rb +3 -0
  58. metadata +31 -3
  59. /data/{PRODUCTION_FIXES.md → docs/PRODUCTION_FIXES.md} +0 -0
  60. /data/{TESTING.md → docs/TESTING.md} +0 -0
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Example: Tool DTO (Data Transfer Object) functionality
5
+ # Demonstrates serialization, deserialization, and equality
6
+
7
+ require "json"
8
+ require_relative "../lib/ollama_client"
9
+
10
+ puts "\n=== TOOL DTO EXAMPLE ===\n"
11
+
12
+ # Create a tool definition
13
+ location_prop = Ollama::Tool::Function::Parameters::Property.new(
14
+ type: "string",
15
+ description: "The city name"
16
+ )
17
+
18
+ unit_prop = Ollama::Tool::Function::Parameters::Property.new(
19
+ type: "string",
20
+ description: "Temperature unit",
21
+ enum: %w[celsius fahrenheit]
22
+ )
23
+
24
+ params = Ollama::Tool::Function::Parameters.new(
25
+ type: "object",
26
+ properties: {
27
+ location: location_prop,
28
+ unit: unit_prop
29
+ },
30
+ required: %w[location unit]
31
+ )
32
+
33
+ function = Ollama::Tool::Function.new(
34
+ name: "get_weather",
35
+ description: "Get weather for a location",
36
+ parameters: params
37
+ )
38
+
39
+ tool = Ollama::Tool.new(type: "function", function: function)
40
+
41
+ # 1. Serialize to JSON
42
+ puts "\n--- Serialization ---"
43
+ json_str = tool.to_json
44
+ puts "JSON: #{json_str}"
45
+
46
+ # 2. Deserialize from hash
47
+ puts "\n--- Deserialization ---"
48
+ hash = JSON.parse(json_str)
49
+ deserialized_tool = Ollama::Tool.from_hash(hash)
50
+ puts "Deserialized tool name: #{deserialized_tool.function.name}"
51
+
52
+ # 3. Equality comparison
53
+ puts "\n--- Equality ---"
54
+ puts "Original == Deserialized: #{tool == deserialized_tool}"
55
+
56
+ # 4. Nested deserialization
57
+ puts "\n--- Nested Deserialization ---"
58
+ function_hash = {
59
+ "name" => "get_time",
60
+ "description" => "Get current time",
61
+ "parameters" => {
62
+ "type" => "object",
63
+ "properties" => {
64
+ "timezone" => {
65
+ "type" => "string",
66
+ "description" => "Timezone (e.g., UTC, EST)"
67
+ }
68
+ },
69
+ "required" => []
70
+ }
71
+ }
72
+
73
+ deserialized_function = Ollama::Tool::Function.from_hash(function_hash)
74
+ puts "Deserialized function: #{deserialized_function.name}"
75
+ puts "Parameters type: #{deserialized_function.parameters.type}"
76
+
77
+ # 5. Property deserialization
78
+ puts "\n--- Property Deserialization ---"
79
+ prop_hash = {
80
+ "type" => "string",
81
+ "description" => "City name",
82
+ "enum" => %w[paris london tokyo]
83
+ }
84
+
85
+ deserialized_prop = Ollama::Tool::Function::Parameters::Property.from_hash(prop_hash)
86
+ puts "Property type: #{deserialized_prop.type}"
87
+ puts "Property enum: #{deserialized_prop.enum.inspect}"
88
+
89
+ # 6. Empty check
90
+ puts "\n--- Empty Check ---"
91
+ empty_params = Ollama::Tool::Function::Parameters.new(type: "object", properties: {}, required: [])
92
+ puts "Empty params? #{empty_params.empty?}"
93
+
94
+ puts "\n=== DONE ===\n"
data/exe/dhan_console ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../examples/dhan_console"
data/exe/ollama-client CHANGED
@@ -1,4 +1,4 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- require "ollama/client"
4
+ require_relative "../examples/chat_console"
@@ -72,8 +72,12 @@ module Ollama
72
72
  args = dig(call, %w[function arguments])
73
73
  args_hash = normalize_arguments(args)
74
74
 
75
- callable = @tools[name]
76
- raise Ollama::Error, "Tool '#{name}' not found. Available: #{@tools.keys.sort.join(", ")}" unless callable
75
+ tool_entry = @tools[name]
76
+ raise Ollama::Error, "Tool '#{name}' not found. Available: #{@tools.keys.sort.join(", ")}" unless tool_entry
77
+
78
+ # Extract callable from tool entry
79
+ callable = extract_callable(tool_entry)
80
+ raise Ollama::Error, "Tool '#{name}' has no associated callable" unless callable
77
81
 
78
82
  @stream&.emit(:state, state: :tool_executing)
79
83
  result = invoke_tool(callable, args_hash)
@@ -99,20 +103,58 @@ module Ollama
99
103
 
100
104
  def tool_definitions
101
105
  @tools.keys.sort.map do |name|
102
- {
103
- type: "function",
104
- function: {
105
- name: name,
106
- description: "Tool: #{name}",
107
- parameters: {
108
- "type" => "object",
109
- "additionalProperties" => true
106
+ tool_entry = @tools[name]
107
+
108
+ # Support both explicit Tool objects and callables
109
+ # Tool objects are schema definitions only
110
+ if tool_entry.is_a?(Ollama::Tool)
111
+ tool_entry.to_h
112
+ elsif tool_entry.is_a?(Hash) && tool_entry[:tool].is_a?(Ollama::Tool)
113
+ # Format: { tool: Tool, callable: proc }
114
+ tool_entry[:tool].to_h
115
+ else
116
+ # Auto-infer from callable signature (default)
117
+ callable = tool_entry.is_a?(Hash) ? tool_entry[:callable] : tool_entry
118
+ parameters = infer_parameters(callable)
119
+ {
120
+ type: "function",
121
+ function: {
122
+ name: name,
123
+ description: "Tool: #{name}",
124
+ parameters: parameters
110
125
  }
111
126
  }
112
- }
127
+ end
113
128
  end
114
129
  end
115
130
 
131
+ def infer_parameters(callable)
132
+ return { "type" => "object", "additionalProperties" => true } unless callable.respond_to?(:parameters)
133
+
134
+ params = callable.parameters
135
+ return { "type" => "object", "additionalProperties" => true } if params.empty?
136
+
137
+ properties = {}
138
+ required = []
139
+
140
+ params.each do |type, name|
141
+ next unless name # Skip anonymous parameters
142
+
143
+ param_name = name.to_s
144
+ properties[param_name] = { "type" => "string", "description" => "Parameter: #{param_name}" }
145
+
146
+ # Required if it's a required keyword argument (:keyreq) or required positional (:req)
147
+ required << param_name if %i[keyreq req].include?(type)
148
+ end
149
+
150
+ schema = { "type" => "object" }
151
+ schema["properties"] = properties unless properties.empty?
152
+ schema["required"] = required unless required.empty?
153
+ schema["additionalProperties"] = false if properties.any?
154
+
155
+ schema
156
+ end
157
+
116
158
  def dig(obj, path)
117
159
  cur = obj
118
160
  path.each do |k|
@@ -137,12 +179,62 @@ module Ollama
137
179
  end
138
180
 
139
181
  def invoke_tool(callable, args_hash)
140
- sym_args = args_hash.transform_keys { |k| k.to_s.to_sym }
182
+ sym_args = normalize_parameter_names(args_hash)
183
+ keyword_result = call_with_keywords(callable, sym_args)
184
+ return keyword_result[:value] if keyword_result[:success]
185
+
186
+ call_with_positional(callable, args_hash)
187
+ end
188
+
189
+ def normalize_parameter_names(args_hash)
190
+ args_hash.transform_keys { |k| k.to_s.to_sym }
191
+ end
141
192
 
142
- # Prefer keyword invocation (common for Ruby tools), fall back to a single hash.
143
- callable.call(**sym_args)
144
- rescue ArgumentError
193
+ def apply_parameter_aliases(args, callable)
194
+ return args unless callable.respond_to?(:parameters)
195
+
196
+ param_names = callable.parameters.map { |_type, name| name }
197
+ aliased = args.dup
198
+
199
+ # Common aliases: directory -> path, file -> path, filename -> path
200
+ if param_names.include?(:path) && !aliased.key?(:path)
201
+ if aliased.key?(:directory)
202
+ aliased[:path] = aliased.delete(:directory)
203
+ elsif aliased.key?(:file)
204
+ aliased[:path] = aliased.delete(:file)
205
+ elsif aliased.key?(:filename)
206
+ aliased[:path] = aliased.delete(:filename)
207
+ end
208
+ end
209
+
210
+ aliased
211
+ end
212
+
213
+ def call_with_keywords(callable, sym_args)
214
+ { success: true, value: callable.call(**sym_args) }
215
+ rescue ArgumentError => e
216
+ return { success: false } unless missing_keyword_error?(e)
217
+
218
+ aliased_args = apply_parameter_aliases(sym_args, callable)
219
+ return { success: false } if aliased_args == sym_args
220
+
221
+ begin
222
+ { success: true, value: callable.call(**aliased_args) }
223
+ rescue ArgumentError
224
+ { success: false }
225
+ end
226
+ end
227
+
228
+ def call_with_positional(callable, args_hash)
145
229
  callable.call(args_hash)
230
+ rescue ArgumentError => e
231
+ raise ArgumentError,
232
+ "Tool invocation failed: #{e.message}. Arguments provided: #{args_hash.inspect}. " \
233
+ "Ensure the tool call includes all required parameters."
234
+ end
235
+
236
+ def missing_keyword_error?(error)
237
+ error.message.include?("required keyword") || error.message.include?("missing keyword")
146
238
  end
147
239
 
148
240
  def encode_tool_result(result)
@@ -152,6 +244,15 @@ module Ollama
152
244
  rescue JSON::GeneratorError
153
245
  result.to_s
154
246
  end
247
+
248
+ def extract_callable(tool_entry)
249
+ case tool_entry
250
+ when Proc, Method
251
+ tool_entry
252
+ when Hash
253
+ tool_entry[:callable] || tool_entry["callable"]
254
+ end
255
+ end
155
256
  end
156
257
  end
157
258
  end
data/lib/ollama/client.rb CHANGED
@@ -6,6 +6,8 @@ require "json"
6
6
  require_relative "errors"
7
7
  require_relative "schema_validator"
8
8
  require_relative "config"
9
+ require_relative "embeddings"
10
+ require_relative "response"
9
11
 
10
12
  module Ollama
11
13
  # Main client class for interacting with Ollama API
@@ -16,8 +18,16 @@ module Ollama
16
18
  @uri = URI("#{@config.base_url}/api/generate")
17
19
  @chat_uri = URI("#{@config.base_url}/api/chat")
18
20
  @base_uri = URI(@config.base_url)
21
+ @embeddings = Embeddings.new(@config)
19
22
  end
20
23
 
24
+ # Access embeddings API
25
+ #
26
+ # Example:
27
+ # client = Ollama::Client.new
28
+ # embedding = client.embeddings.embed(model: "all-minilm", input: "What is Ruby?")
29
+ attr_reader :embeddings
30
+
21
31
  # Chat API method matching JavaScript ollama.chat() interface
22
32
  # Supports structured outputs via format parameter
23
33
  #
@@ -27,18 +37,16 @@ module Ollama
27
37
  # @param model [String] Model name (overrides config.model)
28
38
  # @param messages [Array<Hash>] Array of message hashes with :role and :content
29
39
  # @param format [Hash, nil] JSON Schema for structured outputs
40
+ # @param tools [Tool, Array<Tool>, Array<Hash>, nil] Tool definition(s) - can be Tool object(s) or hash(es)
30
41
  # @param options [Hash, nil] Additional options (temperature, top_p, etc.)
31
42
  # @param strict [Boolean] If true, requires explicit opt-in and disables retries on schema violations
32
43
  # @param include_meta [Boolean] If true, returns hash with :data and :meta keys
33
44
  # @return [Hash] Parsed and validated JSON response matching the format schema
34
45
  # rubocop:disable Metrics/MethodLength
35
46
  # rubocop:disable Metrics/ParameterLists
36
- def chat(messages:, model: nil, format: nil, options: {}, strict: false, allow_chat: false, return_meta: false)
37
- unless allow_chat || strict
38
- raise Error,
39
- "chat() is intentionally gated because it is easy to misuse inside agents. " \
40
- "Prefer generate(). If you really want chat(), pass allow_chat: true (or strict: true)."
41
- end
47
+ def chat(messages:, model: nil, format: nil, tools: nil, options: {}, strict: false, allow_chat: false,
48
+ return_meta: false)
49
+ ensure_chat_allowed!(allow_chat: allow_chat, strict: strict, method_name: "chat")
42
50
 
43
51
  attempts = 0
44
52
  @current_schema = format # Store for validation
@@ -47,42 +55,26 @@ module Ollama
47
55
  begin
48
56
  attempts += 1
49
57
  attempt_started_at = monotonic_time
50
- raw = call_chat_api(model: model, messages: messages, format: format, tools: nil, options: options)
58
+ normalized_tools = normalize_tools(tools)
59
+ raw = call_chat_api(model: model, messages: messages, format: format, tools: normalized_tools, options: options)
51
60
  attempt_latency_ms = elapsed_ms(attempt_started_at)
52
61
 
53
- emit_response_hook(
54
- raw,
55
- {
56
- endpoint: "/api/chat",
57
- model: model || @config.model,
58
- attempt: attempts,
59
- attempt_latency_ms: attempt_latency_ms
60
- }
61
- )
62
+ emit_response_hook(raw, chat_response_meta(model: model, attempt: attempts,
63
+ attempt_latency_ms: attempt_latency_ms))
62
64
 
63
- parsed = parse_json_response(raw)
65
+ empty_response = empty_chat_response(raw: raw,
66
+ return_meta: return_meta,
67
+ model: model,
68
+ attempts: attempts,
69
+ started_at: started_at)
70
+ return empty_response unless empty_response.nil?
64
71
 
65
- # CRITICAL: If format is provided, free-text output is forbidden
66
- if format
67
- if parsed.nil? || parsed.empty?
68
- raise SchemaViolationError,
69
- "Empty or nil response when format schema is required"
70
- end
71
-
72
- SchemaValidator.validate!(parsed, format)
73
- end
72
+ parsed = parse_json_response(raw)
73
+ validate_chat_format!(parsed: parsed, format: format)
74
74
 
75
75
  return parsed unless return_meta
76
76
 
77
- {
78
- "data" => parsed,
79
- "meta" => {
80
- "endpoint" => "/api/chat",
81
- "model" => model || @config.model,
82
- "attempts" => attempts,
83
- "latency_ms" => elapsed_ms(started_at)
84
- }
85
- }
77
+ chat_response_with_meta(data: parsed, model: model, attempts: attempts, started_at: started_at)
86
78
  rescue NotFoundError => e
87
79
  enhanced_error = enhance_not_found_error(e)
88
80
  raise enhanced_error
@@ -112,17 +104,13 @@ module Ollama
112
104
  # @param model [String] Model name (overrides config.model)
113
105
  # @param messages [Array<Hash>] Array of message hashes with :role and :content
114
106
  # @param format [Hash, nil] JSON Schema for structured outputs (validates message.content JSON when present)
115
- # @param tools [Array<Hash>, nil] Tool definitions (OpenAI-style schema) sent to Ollama
107
+ # @param tools [Tool, Array<Tool>, Array<Hash>, nil] Tool definition(s) - can be Tool object(s) or hash(es)
116
108
  # @param options [Hash, nil] Additional options (temperature, top_p, etc.)
117
- # @return [Hash] Full parsed JSON response body from Ollama
109
+ # @return [Hash] Full parsed JSON response body from Ollama with access to message.tool_calls
118
110
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/ParameterLists
119
111
  def chat_raw(messages:, model: nil, format: nil, tools: nil, options: {}, strict: false, allow_chat: false,
120
112
  return_meta: false, stream: false, &on_chunk)
121
- unless allow_chat || strict
122
- raise Error,
123
- "chat_raw() is intentionally gated because it is easy to misuse inside agents. " \
124
- "Prefer generate(). If you really want chat_raw(), pass allow_chat: true (or strict: true)."
125
- end
113
+ ensure_chat_allowed!(allow_chat: allow_chat, strict: strict, method_name: "chat_raw")
126
114
 
127
115
  attempts = 0
128
116
  @current_schema = format # Store for validation
@@ -131,18 +119,20 @@ module Ollama
131
119
  begin
132
120
  attempts += 1
133
121
  attempt_started_at = monotonic_time
122
+ normalized_tools = normalize_tools(tools)
134
123
  raw_body =
135
124
  if stream
136
125
  call_chat_api_raw_stream(
137
126
  model: model,
138
127
  messages: messages,
139
128
  format: format,
140
- tools: tools,
129
+ tools: normalized_tools,
141
130
  options: options,
142
131
  &on_chunk
143
132
  )
144
133
  else
145
- call_chat_api_raw(model: model, messages: messages, format: format, tools: tools, options: options)
134
+ call_chat_api_raw(model: model, messages: messages, format: format, tools: normalized_tools,
135
+ options: options)
146
136
  end
147
137
  attempt_latency_ms = elapsed_ms(attempt_started_at)
148
138
 
@@ -176,10 +166,13 @@ module Ollama
176
166
  SchemaValidator.validate!(parsed_content, format)
177
167
  end
178
168
 
179
- return parsed_body unless return_meta
169
+ # Wrap in Response object for method access (e.g., response.message&.tool_calls)
170
+ response_obj = Response.new(parsed_body)
171
+
172
+ return response_obj unless return_meta
180
173
 
181
174
  {
182
- "data" => parsed_body,
175
+ "data" => response_obj,
183
176
  "meta" => {
184
177
  "endpoint" => "/api/chat",
185
178
  "model" => model || @config.model,
@@ -213,7 +206,7 @@ module Ollama
213
206
  end
214
207
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/ParameterLists
215
208
 
216
- def generate(prompt:, schema:, strict: false, return_meta: false)
209
+ def generate(prompt:, schema:, model: nil, strict: false, return_meta: false)
217
210
  attempts = 0
218
211
  @current_schema = schema # Store for prompt enhancement
219
212
  started_at = monotonic_time
@@ -221,14 +214,14 @@ module Ollama
221
214
  begin
222
215
  attempts += 1
223
216
  attempt_started_at = monotonic_time
224
- raw = call_api(prompt)
217
+ raw = call_api(prompt, model: model)
225
218
  attempt_latency_ms = elapsed_ms(attempt_started_at)
226
219
 
227
220
  emit_response_hook(
228
221
  raw,
229
222
  {
230
223
  endpoint: "/api/generate",
231
- model: @config.model,
224
+ model: model || @config.model,
232
225
  attempt: attempts,
233
226
  attempt_latency_ms: attempt_latency_ms
234
227
  }
@@ -246,7 +239,7 @@ module Ollama
246
239
  "data" => parsed,
247
240
  "meta" => {
248
241
  "endpoint" => "/api/generate",
249
- "model" => @config.model,
242
+ "model" => model || @config.model,
250
243
  "attempts" => attempts,
251
244
  "latency_ms" => elapsed_ms(started_at)
252
245
  }
@@ -274,8 +267,8 @@ module Ollama
274
267
  end
275
268
  # rubocop:enable Metrics/MethodLength
276
269
 
277
- def generate_strict!(prompt:, schema:, return_meta: false)
278
- generate(prompt: prompt, schema: schema, strict: true, return_meta: return_meta)
270
+ def generate_strict!(prompt:, schema:, model: nil, return_meta: false)
271
+ generate(prompt: prompt, schema: schema, model: model, strict: true, return_meta: return_meta)
279
272
  end
280
273
 
281
274
  # Lightweight server health check.
@@ -355,6 +348,75 @@ module Ollama
355
348
 
356
349
  private
357
350
 
351
+ def ensure_chat_allowed!(allow_chat:, strict:, method_name:)
352
+ return if allow_chat || strict
353
+
354
+ raise Error,
355
+ "#{method_name}() is intentionally gated because it is easy to misuse inside agents. " \
356
+ "Prefer generate(). If you really want #{method_name}(), pass allow_chat: true (or strict: true)."
357
+ end
358
+
359
+ # Normalize tools to array of hashes for API
360
+ # Supports: Tool object, Array of Tool objects, Array of hashes, or nil
361
+ def normalize_tools(tools)
362
+ return nil if tools.nil?
363
+
364
+ # Single Tool object
365
+ return [tools.to_h] if tools.is_a?(Tool)
366
+
367
+ # Array of tools
368
+ if tools.is_a?(Array)
369
+ return tools.map { |t| t.is_a?(Tool) ? t.to_h : t }
370
+ end
371
+
372
+ # Already a hash (shouldn't happen, but handle gracefully)
373
+ tools
374
+ end
375
+
376
+ def chat_response_meta(model:, attempt:, attempt_latency_ms:)
377
+ {
378
+ endpoint: "/api/chat",
379
+ model: model || @config.model,
380
+ attempt: attempt,
381
+ attempt_latency_ms: attempt_latency_ms
382
+ }
383
+ end
384
+
385
+ def empty_chat_response(raw:, return_meta:, model:, attempts:, started_at:)
386
+ return nil unless raw.nil? || raw.empty?
387
+ return "" unless return_meta
388
+
389
+ {
390
+ "data" => "",
391
+ "meta" => {
392
+ "endpoint" => "/api/chat",
393
+ "model" => model || @config.model,
394
+ "attempts" => attempts,
395
+ "latency_ms" => elapsed_ms(started_at),
396
+ "note" => "Empty content (likely tool_calls only - use chat_raw() to access tool_calls)"
397
+ }
398
+ }
399
+ end
400
+
401
+ def validate_chat_format!(parsed:, format:)
402
+ return unless format
403
+ raise SchemaViolationError, "Empty or nil response when format schema is required" if parsed.nil? || parsed.empty?
404
+
405
+ SchemaValidator.validate!(parsed, format)
406
+ end
407
+
408
+ def chat_response_with_meta(data:, model:, attempts:, started_at:)
409
+ {
410
+ "data" => data,
411
+ "meta" => {
412
+ "endpoint" => "/api/chat",
413
+ "model" => model || @config.model,
414
+ "attempts" => attempts,
415
+ "latency_ms" => elapsed_ms(started_at)
416
+ }
417
+ }
418
+ end
419
+
358
420
  def handle_http_error(res, requested_model: nil)
359
421
  status_code = res.code.to_i
360
422
  requested_model ||= @config.model
@@ -577,13 +639,13 @@ module Ollama
577
639
  raise Error, "Connection failed: #{e.message}"
578
640
  end
579
641
 
580
- def call_api(prompt)
642
+ def call_api(prompt, model: nil)
581
643
  req = Net::HTTP::Post.new(@uri)
582
644
  req["Content-Type"] = "application/json"
583
645
 
584
646
  # Build request body
585
647
  body = {
586
- model: @config.model,
648
+ model: model || @config.model,
587
649
  prompt: prompt,
588
650
  stream: false,
589
651
  temperature: @config.temperature,
@@ -607,7 +669,7 @@ module Ollama
607
669
  open_timeout: @config.timeout
608
670
  ) { |http| http.request(req) }
609
671
 
610
- handle_http_error(res) unless res.is_a?(Net::HTTPSuccess)
672
+ handle_http_error(res, requested_model: model || @config.model) unless res.is_a?(Net::HTTPSuccess)
611
673
 
612
674
  body = JSON.parse(res.body)
613
675
  body["response"]
@@ -659,6 +721,7 @@ module Ollama
659
721
 
660
722
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
661
723
  def call_chat_api_raw_stream(model:, messages:, format:, tools:, options:)
724
+ # tools should already be normalized by caller
662
725
  req = Net::HTTP::Post.new(@chat_uri)
663
726
  req["Content-Type"] = "application/json"
664
727
 
data/lib/ollama/config.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module Ollama
4
6
  # Configuration class with safe defaults for agent-grade usage
5
7
  #
@@ -25,5 +27,39 @@ module Ollama
25
27
  @num_ctx = 8192
26
28
  @on_response = nil
27
29
  end
30
+
31
+ # Load configuration from JSON file (useful for production deployments)
32
+ #
33
+ # @param path [String] Path to JSON config file
34
+ # @return [Config] New Config instance
35
+ #
36
+ # Example JSON:
37
+ # {
38
+ # "base_url": "http://localhost:11434",
39
+ # "model": "llama3.1:8b",
40
+ # "timeout": 30,
41
+ # "retries": 3,
42
+ # "temperature": 0.2,
43
+ # "top_p": 0.9,
44
+ # "num_ctx": 8192
45
+ # }
46
+ def self.load_from_json(path)
47
+ data = JSON.parse(File.read(path))
48
+ config = new
49
+
50
+ config.base_url = data["base_url"] if data.key?("base_url")
51
+ config.model = data["model"] if data.key?("model")
52
+ config.timeout = data["timeout"] if data.key?("timeout")
53
+ config.retries = data["retries"] if data.key?("retries")
54
+ config.temperature = data["temperature"] if data.key?("temperature")
55
+ config.top_p = data["top_p"] if data.key?("top_p")
56
+ config.num_ctx = data["num_ctx"] if data.key?("num_ctx")
57
+
58
+ config
59
+ rescue JSON::ParserError => e
60
+ raise Error, "Failed to parse config JSON: #{e.message}"
61
+ rescue Errno::ENOENT
62
+ raise Error, "Config file not found: #{path}"
63
+ end
28
64
  end
29
65
  end