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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +220 -12
- data/docs/CLOUD.md +29 -0
- data/docs/CONSOLE_IMPROVEMENTS.md +256 -0
- data/docs/FEATURES_ADDED.md +145 -0
- data/docs/HANDLERS_ANALYSIS.md +190 -0
- data/docs/README.md +37 -0
- data/docs/SCHEMA_FIXES.md +147 -0
- data/docs/TEST_UPDATES.md +107 -0
- data/examples/README.md +92 -0
- data/examples/advanced_complex_schemas.rb +6 -3
- data/examples/advanced_multi_step_agent.rb +13 -7
- data/examples/chat_console.rb +143 -0
- data/examples/complete_workflow.rb +14 -4
- data/examples/dhan_console.rb +843 -0
- data/examples/dhanhq/agents/base_agent.rb +0 -2
- data/examples/dhanhq/agents/orchestrator_agent.rb +1 -2
- data/examples/dhanhq/agents/technical_analysis_agent.rb +67 -49
- data/examples/dhanhq/analysis/market_structure.rb +44 -28
- data/examples/dhanhq/analysis/pattern_recognizer.rb +64 -47
- data/examples/dhanhq/analysis/trend_analyzer.rb +6 -8
- data/examples/dhanhq/dhanhq_agent.rb +296 -99
- data/examples/dhanhq/indicators/technical_indicators.rb +3 -5
- data/examples/dhanhq/scanners/intraday_options_scanner.rb +360 -255
- data/examples/dhanhq/scanners/swing_scanner.rb +118 -84
- data/examples/dhanhq/schemas/agent_schemas.rb +2 -2
- data/examples/dhanhq/services/data_service.rb +5 -7
- data/examples/dhanhq/services/trading_service.rb +0 -3
- data/examples/dhanhq/technical_analysis_agentic_runner.rb +217 -84
- data/examples/dhanhq/technical_analysis_runner.rb +216 -162
- data/examples/dhanhq/test_tool_calling.rb +538 -0
- data/examples/dhanhq/test_tool_calling_verbose.rb +251 -0
- data/examples/dhanhq/utils/trading_parameter_normalizer.rb +12 -17
- data/examples/dhanhq_agent.rb +159 -116
- data/examples/dhanhq_tools.rb +1158 -251
- data/examples/multi_step_agent_with_external_data.rb +368 -0
- data/examples/structured_tools.rb +89 -0
- data/examples/test_dhanhq_tool_calling.rb +375 -0
- data/examples/test_tool_calling.rb +160 -0
- data/examples/tool_calling_direct.rb +124 -0
- data/examples/tool_dto_example.rb +94 -0
- data/exe/dhan_console +4 -0
- data/exe/ollama-client +1 -1
- data/lib/ollama/agent/executor.rb +116 -15
- data/lib/ollama/client.rb +118 -55
- data/lib/ollama/config.rb +36 -0
- data/lib/ollama/dto.rb +187 -0
- data/lib/ollama/embeddings.rb +77 -0
- data/lib/ollama/options.rb +104 -0
- data/lib/ollama/response.rb +121 -0
- data/lib/ollama/tool/function/parameters/property.rb +72 -0
- data/lib/ollama/tool/function/parameters.rb +101 -0
- data/lib/ollama/tool/function.rb +78 -0
- data/lib/ollama/tool.rb +60 -0
- data/lib/ollama/version.rb +1 -1
- data/lib/ollama_client.rb +3 -0
- metadata +31 -3
- /data/{PRODUCTION_FIXES.md → docs/PRODUCTION_FIXES.md} +0 -0
- /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
data/exe/ollama-client
CHANGED
|
@@ -72,8 +72,12 @@ module Ollama
|
|
|
72
72
|
args = dig(call, %w[function arguments])
|
|
73
73
|
args_hash = normalize_arguments(args)
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
raise Ollama::Error, "Tool '#{name}' not found. Available: #{@tools.keys.sort.join(", ")}" unless
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
-
|
|
143
|
-
callable.
|
|
144
|
-
|
|
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,
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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" =>
|
|
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
|