ollama-client 1.3.0 → 1.4.0
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/.env.example +11 -0
- data/API_CONTRACT.md +79 -8
- data/CHANGELOG.md +45 -0
- data/CONTRIBUTING.md +8 -0
- data/README.md +205 -273
- data/ROADMAP.md +23 -0
- data/docs/API_GAPS.md +18 -141
- data/docs/ARCHITECTURE.md +141 -0
- data/docs/AREAS_FOR_CONSIDERATION.md +13 -1
- data/docs/CLOUD.md +19 -0
- data/docs/CONSOLE_IMPROVEMENTS.md +20 -1
- data/docs/ECOSYSTEM_GEMS.md +41 -0
- data/docs/ECOSYSTEM_STRATEGY.md +151 -0
- data/docs/GETTING_STARTED.md +16 -8
- data/docs/INTEGRATION_TESTING.md +23 -5
- data/docs/PRODUCTION_FIXES.md +15 -3
- data/docs/QUICK_START.md +1 -1
- data/docs/README.md +1 -0
- data/docs/RUBYLLM_ADOPTION_MATRIX.md +273 -0
- data/docs/adr/001-openai-boundary.md +12 -0
- data/docs/adr/002-transport-abstraction.md +12 -0
- data/docs/adr/003-response-normalization.md +12 -0
- data/docs/adr/004-mock-transport.md +16 -0
- data/docs/adr/005-error-taxonomy.md +16 -0
- data/docs/adr/006-stream-runtime.md +16 -0
- data/docs/ecosystem/BOUNDARIES.md +19 -0
- data/docs/ecosystem/DEPENDENCY_GRAPH.md +14 -0
- data/docs/ecosystem/DESIGN_PRINCIPLES.md +9 -0
- data/docs/ecosystem/EXISTING_REPOS.md +31 -0
- data/docs/ecosystem/EXPERIMENTAL_LABS.md +24 -0
- data/docs/ecosystem/OVERVIEW.md +28 -0
- data/docs/ecosystem/RELEASE_ORDER.md +17 -0
- data/docs/observability/README.md +8 -0
- data/docs/rails/README.md +7 -0
- data/docs/rfcs/0001-stream-runtime.md +6 -0
- data/docs/rfcs/0002-schema-system.md +6 -0
- data/docs/rfcs/0003-observability-hooks.md +6 -0
- data/docs/rfcs/0004-async-runtime.md +6 -0
- data/docs/rfcs/README.md +16 -0
- data/docs/runtime/ERROR_CONTRACT.md +14 -0
- data/docs/runtime/SCHEMA_CONTRACT.md +28 -0
- data/docs/runtime/STREAM_CONTRACT.md +17 -0
- data/docs/runtime/STREAM_RUNTIME.md +20 -0
- data/docs/runtime/TRANSPORT_CONTRACT.md +26 -0
- data/docs/schema/README.md +8 -0
- data/docs/schema/STRUCTURED_OUTPUTS.md +22 -0
- data/docs/streaming/README.md +8 -0
- data/docs/testing/README.md +7 -0
- data/docs/testing/REPLAY_SYSTEM.md +17 -0
- data/docs/transport/README.md +7 -0
- data/examples/README.md +44 -0
- data/examples/basic_chat.rb +9 -0
- data/examples/cloud_models.rb +162 -0
- data/examples/embeddings.rb +10 -0
- data/examples/free_catalog.json +290 -0
- data/examples/generate.rb +9 -0
- data/examples/streaming.rb +12 -0
- data/examples/structured_tools.rb +90 -0
- data/examples/tool_calling_direct.rb +101 -0
- data/examples/tool_dto_example.rb +94 -0
- data/exe/ollama-client +5 -1
- data/lib/ollama/agent/executor.rb +243 -0
- data/lib/ollama/agent/messages.rb +31 -0
- data/lib/ollama/agent/planner.rb +45 -0
- data/lib/ollama/api_key_pool.rb +61 -0
- data/lib/ollama/attachment.rb +74 -0
- data/lib/ollama/capabilities.rb +1 -1
- data/lib/ollama/chat_response.rb +33 -0
- data/lib/ollama/client/chat/request_preparer.rb +82 -0
- data/lib/ollama/client/chat.rb +58 -68
- data/lib/ollama/client/chat_stream_processor.rb +85 -25
- data/lib/ollama/client/generate/request_preparer.rb +118 -0
- data/lib/ollama/client/generate/response_formatter.rb +95 -0
- data/lib/ollama/client/generate.rb +83 -165
- data/lib/ollama/client/model_management.rb +182 -84
- data/lib/ollama/client/openai_compat.rb +185 -0
- data/lib/ollama/client/raw.rb +66 -0
- data/lib/ollama/client/tool_intent.rb +38 -0
- data/lib/ollama/client/web_search.rb +39 -0
- data/lib/ollama/client.rb +83 -35
- data/lib/ollama/config.rb +122 -17
- data/lib/ollama/embeddings.rb +67 -29
- data/lib/ollama/errors.rb +55 -1
- data/lib/ollama/events.rb +55 -0
- data/lib/ollama/generate_stream_handler.rb +23 -4
- data/lib/ollama/http_error_handler.rb +42 -0
- data/lib/ollama/messages.rb +109 -0
- data/lib/ollama/middleware/cache.rb +73 -0
- data/lib/ollama/middleware/logger.rb +74 -0
- data/lib/ollama/middleware/metrics.rb +84 -0
- data/lib/ollama/middleware/tracing.rb +101 -0
- data/lib/ollama/middleware.rb +39 -0
- data/lib/ollama/model_profile.rb +1 -1
- data/lib/ollama/openai.rb +16 -0
- data/lib/ollama/options.rb +63 -1
- data/lib/ollama/params.rb +139 -0
- data/lib/ollama/parsers/base.rb +24 -0
- data/lib/ollama/parsers/chat.rb +22 -0
- data/lib/ollama/parsers/embeddings.rb +23 -0
- data/lib/ollama/parsers/generate.rb +38 -0
- data/lib/ollama/parsers/list_running.rb +15 -0
- data/lib/ollama/parsers/show_model.rb +14 -0
- data/lib/ollama/parsers/version.rb +15 -0
- data/lib/ollama/pipeline.rb +169 -0
- data/lib/ollama/plugins.rb +83 -0
- data/lib/ollama/policies/auto_pull.rb +69 -0
- data/lib/ollama/policies/base.rb +70 -0
- data/lib/ollama/policies/capability_validation.rb +88 -0
- data/lib/ollama/policies/fallback.rb +57 -0
- data/lib/ollama/policies/rate_limit.rb +115 -0
- data/lib/ollama/policies/repair_json.rb +137 -0
- data/lib/ollama/policies/retry/strategies/exponential.rb +27 -0
- data/lib/ollama/policies/retry/strategies/fixed.rb +27 -0
- data/lib/ollama/policies/retry/strategies/jitter.rb +30 -0
- data/lib/ollama/policies/retry/strategies/linear.rb +27 -0
- data/lib/ollama/policies/retry.rb +152 -0
- data/lib/ollama/policies/schema_repair.rb +180 -0
- data/lib/ollama/policies/timeout.rb +43 -0
- data/lib/ollama/policies.rb +24 -0
- data/lib/ollama/prompt.rb +86 -0
- data/lib/ollama/prompt_adapters/base.rb +3 -2
- data/lib/ollama/prompt_adapters/gemma4.rb +22 -14
- data/lib/ollama/prompts/tool_planner.rb +35 -0
- data/lib/ollama/providers/base.rb +70 -0
- data/lib/ollama/providers/llama_cpp.rb +131 -0
- data/lib/ollama/providers/ollama.rb +54 -0
- data/lib/ollama/providers/openai.rb +134 -0
- data/lib/ollama/providers.rb +28 -0
- data/lib/ollama/rate_limit_handler.rb +48 -0
- data/lib/ollama/request.rb +169 -0
- data/lib/ollama/response.rb +3 -2
- data/lib/ollama/responses/base.rb +11 -0
- data/lib/ollama/responses/chat.rb +11 -0
- data/lib/ollama/responses/embeddings.rb +14 -0
- data/lib/ollama/responses/generate.rb +22 -0
- data/lib/ollama/schema_dsl.rb +97 -0
- data/lib/ollama/schema_validator.rb +90 -53
- data/lib/ollama/schemas/tool_intent.json +15 -0
- data/lib/ollama/schemas/tool_intent.rb +16 -0
- data/lib/ollama/serializers/base.rb +44 -0
- data/lib/ollama/serializers/chat.rb +42 -0
- data/lib/ollama/serializers/embeddings.rb +36 -0
- data/lib/ollama/serializers/generate.rb +47 -0
- data/lib/ollama/streaming_observer.rb +22 -0
- data/lib/ollama/testing.rb +103 -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/tool_dsl.rb +93 -0
- data/lib/ollama/tool_intent.rb +19 -0
- data/lib/ollama/transport/base.rb +42 -0
- data/lib/ollama/transport/mock.rb +48 -0
- data/lib/ollama/transport/net_http.rb +76 -0
- data/lib/ollama/transport/request.rb +20 -0
- data/lib/ollama/transport/response.rb +41 -0
- data/lib/ollama/transport.rb +26 -0
- data/lib/ollama/version.rb +1 -1
- data/lib/ollama_client.rb +35 -0
- data/script/live_branch_smoke/chat_exercises.rb +232 -0
- data/script/live_branch_smoke/generation_exercises.rb +128 -0
- data/script/live_branch_smoke/model_exercises.rb +105 -0
- data/script/live_branch_smoke/utility_exercises.rb +375 -0
- data/script/live_branch_smoke.rb +159 -174
- data/test_all_features.rb +315 -0
- metadata +158 -24
- data/.cursor/.gitignore +0 -1
- data/RELEASE_NOTES_v0.2.6.md +0 -41
- data/devagent_proper.rb +0 -430
- data/docs/TESTING.md +0 -508
- data/examples/agent_loop.rb +0 -120
- data/examples/failure_modes/invalid_json_repair.rb +0 -42
- data/examples/production/rails_agent.rb +0 -62
- data/market.jpg +0 -0
- data/print_capabilities.rb +0 -20
- data/schema.json +0 -1
- data/test_tool.rb +0 -26
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Example: Using Structured Tool Definitions
|
|
5
|
+
# Demonstrates explicit Tool classes for type-safe tool schemas
|
|
6
|
+
|
|
7
|
+
require "json"
|
|
8
|
+
require_relative "../lib/ollama_client"
|
|
9
|
+
require_relative "../lib/ollama/agent/executor"
|
|
10
|
+
|
|
11
|
+
puts "\n=== STRUCTURED TOOLS EXAMPLE ===\n"
|
|
12
|
+
|
|
13
|
+
client = Ollama::Client.new
|
|
14
|
+
|
|
15
|
+
# Define tools with explicit schemas using Tool classes
|
|
16
|
+
location_property = Ollama::Tool::Function::Parameters::Property.new(
|
|
17
|
+
type: "string",
|
|
18
|
+
description: "The city and state, e.g. San Francisco, CA"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
unit_property = Ollama::Tool::Function::Parameters::Property.new(
|
|
22
|
+
type: "string",
|
|
23
|
+
description: "The unit to return temperature in",
|
|
24
|
+
enum: %w[celsius fahrenheit]
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
weather_parameters = Ollama::Tool::Function::Parameters.new(
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: {
|
|
30
|
+
location: location_property,
|
|
31
|
+
unit: unit_property
|
|
32
|
+
},
|
|
33
|
+
required: %w[location unit]
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
weather_function = Ollama::Tool::Function.new(
|
|
37
|
+
name: "get_current_weather",
|
|
38
|
+
description: "Get the current weather for a location",
|
|
39
|
+
parameters: weather_parameters
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
weather_tool = Ollama::Tool.new(
|
|
43
|
+
type: "function",
|
|
44
|
+
function: weather_function
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Define the actual callable
|
|
48
|
+
weather_callable = lambda do |location:, unit:|
|
|
49
|
+
{
|
|
50
|
+
location: location,
|
|
51
|
+
unit: unit,
|
|
52
|
+
temperature: unit == "celsius" ? "22" : "72",
|
|
53
|
+
condition: "sunny"
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Use structured tool definition with callable
|
|
58
|
+
tools = {
|
|
59
|
+
"get_current_weather" => {
|
|
60
|
+
tool: weather_tool,
|
|
61
|
+
callable: weather_callable
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# Alternative: Simple callable (auto-inferred schema)
|
|
66
|
+
# This still works for backward compatibility - just pass the callable directly
|
|
67
|
+
simple_tools = {
|
|
68
|
+
"get_time" => lambda do |timezone: "UTC"|
|
|
69
|
+
{ timezone: timezone, time: Time.now.utc.iso8601 }
|
|
70
|
+
end
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Combine both approaches
|
|
74
|
+
# Simple callables can be passed directly (auto-inferred)
|
|
75
|
+
all_tools = tools.merge(simple_tools)
|
|
76
|
+
|
|
77
|
+
executor = Ollama::Agent::Executor.new(client, tools: all_tools)
|
|
78
|
+
|
|
79
|
+
begin
|
|
80
|
+
answer = executor.run(
|
|
81
|
+
system: "You are a helpful assistant. Use tools when needed.",
|
|
82
|
+
user: "What's the weather in Paris, France? Use celsius."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
puts "\nAnswer: #{answer}"
|
|
86
|
+
rescue Ollama::Error => e
|
|
87
|
+
puts "Error: #{e.message}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
puts "\n=== DONE ===\n"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Example: Direct Tool Calling (matching ollama-ruby pattern)
|
|
5
|
+
# Demonstrates using Tool objects directly with chat()
|
|
6
|
+
|
|
7
|
+
require "json"
|
|
8
|
+
require_relative "../lib/ollama_client"
|
|
9
|
+
require_relative "../lib/ollama/agent/messages"
|
|
10
|
+
|
|
11
|
+
puts "\n=== DIRECT TOOL CALLING EXAMPLE ===\n"
|
|
12
|
+
|
|
13
|
+
client = Ollama::Client.new
|
|
14
|
+
|
|
15
|
+
# Define tool using Tool classes (matching ollama-ruby pattern)
|
|
16
|
+
tool = Ollama::Tool.new(
|
|
17
|
+
type: "function",
|
|
18
|
+
function: Ollama::Tool::Function.new(
|
|
19
|
+
name: "get_current_weather",
|
|
20
|
+
description: "Get the current weather for a location",
|
|
21
|
+
parameters: Ollama::Tool::Function::Parameters.new(
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
location: Ollama::Tool::Function::Parameters::Property.new(
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "The location to get the weather for, e.g. San Francisco, CA"
|
|
27
|
+
),
|
|
28
|
+
temperature_unit: Ollama::Tool::Function::Parameters::Property.new(
|
|
29
|
+
type: "string",
|
|
30
|
+
description: "The unit to return the temperature in, either 'celsius' or 'fahrenheit'",
|
|
31
|
+
enum: %w[celsius fahrenheit]
|
|
32
|
+
)
|
|
33
|
+
},
|
|
34
|
+
required: %w[location temperature_unit]
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Create message
|
|
40
|
+
def message(location)
|
|
41
|
+
Ollama::Agent::Messages.user("What is the weather today in #{location}?")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
puts "\n--- Using chat() to access tool_calls ---"
|
|
45
|
+
|
|
46
|
+
# chat() always returns a full Ollama::Response — message.tool_calls is
|
|
47
|
+
# populated whenever the model calls a tool. tools: takes an array.
|
|
48
|
+
response = client.chat(
|
|
49
|
+
model: "llama3.1:8b",
|
|
50
|
+
messages: [message("The City of Love")],
|
|
51
|
+
tools: [tool]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Access tool_calls from response (using method access like ollama-ruby)
|
|
55
|
+
tool_calls = response.message&.tool_calls
|
|
56
|
+
if tool_calls && !tool_calls.empty?
|
|
57
|
+
puts "\nTool calls detected:"
|
|
58
|
+
tool_calls.each do |call|
|
|
59
|
+
name = call.name
|
|
60
|
+
args = call.arguments
|
|
61
|
+
puts " - #{name}: #{args}"
|
|
62
|
+
end
|
|
63
|
+
else
|
|
64
|
+
puts "\nNo tool calls in response"
|
|
65
|
+
puts "Response: #{response.message&.content}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# You can also use hash access if preferred:
|
|
69
|
+
# tool_calls = response.to_h.dig("message", "tool_calls")
|
|
70
|
+
|
|
71
|
+
puts "\n--- Multiple tools (array) ---"
|
|
72
|
+
|
|
73
|
+
# You can also pass an array of tools
|
|
74
|
+
tool2 = Ollama::Tool.new(
|
|
75
|
+
type: "function",
|
|
76
|
+
function: Ollama::Tool::Function.new(
|
|
77
|
+
name: "get_time",
|
|
78
|
+
description: "Get the current time",
|
|
79
|
+
parameters: Ollama::Tool::Function::Parameters.new(
|
|
80
|
+
type: "object",
|
|
81
|
+
properties: {},
|
|
82
|
+
required: []
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
response2 = client.chat(
|
|
88
|
+
model: "llama3.1:8b",
|
|
89
|
+
messages: [Ollama::Agent::Messages.user("What time is it? Use the get_time tool.")],
|
|
90
|
+
tools: [tool, tool2] # Array of Tool objects
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
tool_calls2 = response2.message&.tool_calls
|
|
94
|
+
if tool_calls2 && !tool_calls2.empty?
|
|
95
|
+
puts "\nTool calls from multiple tools:"
|
|
96
|
+
tool_calls2.each do |call|
|
|
97
|
+
puts " - #{call.name}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
puts "\n=== DONE ===\n"
|
|
@@ -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/ollama-client
CHANGED
|
@@ -163,7 +163,9 @@ module OllamaCLI
|
|
|
163
163
|
def self.build_client(model = nil)
|
|
164
164
|
config = Ollama::Config.new
|
|
165
165
|
config.base_url = ENV["OLLAMA_BASE_URL"] if ENV["OLLAMA_BASE_URL"]
|
|
166
|
-
config.
|
|
166
|
+
config.api_keys = ENV.fetch("OLLAMA_API_KEYS", nil) if ENV.fetch("OLLAMA_API_KEYS", nil)
|
|
167
|
+
config.api_key = ENV.fetch("OLLAMA_API_KEY", nil) if config.api_keys.empty? && ENV.fetch("OLLAMA_API_KEY", nil)
|
|
168
|
+
config.enable_multi_key_concurrency = Ollama::Config.truthy_env?(ENV.fetch("ENABLE_MULTI_KEY_CONCURRENCY", nil))
|
|
167
169
|
config.model = model if model
|
|
168
170
|
Ollama::Client.new(config: config)
|
|
169
171
|
end
|
|
@@ -201,6 +203,8 @@ module OllamaCLI
|
|
|
201
203
|
Environment:
|
|
202
204
|
OLLAMA_BASE_URL Override base URL (default: http://localhost:11434)
|
|
203
205
|
OLLAMA_API_KEY Optional Bearer token for Ollama Cloud (https://ollama.com)
|
|
206
|
+
OLLAMA_API_KEYS Optional comma-separated Bearer token pool for 429 failover
|
|
207
|
+
ENABLE_MULTI_KEY_CONCURRENCY Set true/1 to distribute initial keys across threads
|
|
204
208
|
USAGE
|
|
205
209
|
end
|
|
206
210
|
end
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "messages"
|
|
5
|
+
|
|
6
|
+
module Ollama
|
|
7
|
+
module Agent
|
|
8
|
+
# Stateful executor-style agent using /api/chat + tool-calling loop.
|
|
9
|
+
#
|
|
10
|
+
# The LLM never executes tools. It can only request tool calls; this class
|
|
11
|
+
# executes Ruby callables and feeds results back as role: "tool" messages.
|
|
12
|
+
class Executor
|
|
13
|
+
attr_reader :messages
|
|
14
|
+
|
|
15
|
+
def initialize(client, tools:, max_steps: 20, stream: nil)
|
|
16
|
+
@client = client
|
|
17
|
+
@tools = tools || {}
|
|
18
|
+
@max_steps = max_steps
|
|
19
|
+
@stream = stream
|
|
20
|
+
@messages = []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
24
|
+
def run(system:, user:)
|
|
25
|
+
@messages = [
|
|
26
|
+
Messages.system(system),
|
|
27
|
+
Messages.user(user)
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
last_assistant_content = nil
|
|
31
|
+
|
|
32
|
+
@max_steps.times do
|
|
33
|
+
@stream&.emit(:state, state: :assistant_streaming)
|
|
34
|
+
|
|
35
|
+
response = request_chat
|
|
36
|
+
|
|
37
|
+
message = response.message
|
|
38
|
+
content = message&.content
|
|
39
|
+
tool_calls = message&.tool_calls || []
|
|
40
|
+
|
|
41
|
+
# Preserve the assistant turn in history (including tool_calls if present).
|
|
42
|
+
@messages << Messages.assistant(content.to_s, tool_calls: tool_calls.map(&:to_h)) if content || !tool_calls.empty?
|
|
43
|
+
last_assistant_content = content if content && !content.empty?
|
|
44
|
+
|
|
45
|
+
break if tool_calls.empty?
|
|
46
|
+
|
|
47
|
+
tool_calls.each do |call|
|
|
48
|
+
name = call.name
|
|
49
|
+
raise Ollama::Error, "Tool call missing function name: #{call.inspect}" if name.nil? || name.empty?
|
|
50
|
+
|
|
51
|
+
args_hash = call.arguments.is_a?(Hash) ? call.arguments : {}
|
|
52
|
+
|
|
53
|
+
tool_entry = @tools[name] || @tools[name.to_sym]
|
|
54
|
+
raise Ollama::Error, "Tool '#{name}' not found. Available: #{@tools.keys.sort.join(", ")}" unless tool_entry
|
|
55
|
+
|
|
56
|
+
# Extract callable from tool entry
|
|
57
|
+
callable = extract_callable(tool_entry)
|
|
58
|
+
raise Ollama::Error, "Tool '#{name}' has no associated callable" unless callable
|
|
59
|
+
|
|
60
|
+
@stream&.emit(:state, state: :tool_executing)
|
|
61
|
+
result = invoke_tool(callable, args_hash)
|
|
62
|
+
tool_content = encode_tool_result(result)
|
|
63
|
+
|
|
64
|
+
tool_call_id = call.id
|
|
65
|
+
@messages << Messages.tool(content: tool_content, name: name, tool_call_id: tool_call_id)
|
|
66
|
+
@stream&.emit(:state, state: :tool_result_injected)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if last_assistant_content.nil?
|
|
71
|
+
raise Ollama::Error,
|
|
72
|
+
"Executor exceeded max_steps=#{@max_steps} (possible infinite tool loop)"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
@stream&.emit(:final, text: last_assistant_content.to_s)
|
|
76
|
+
last_assistant_content
|
|
77
|
+
end
|
|
78
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def request_chat
|
|
83
|
+
hooks = streaming_hooks
|
|
84
|
+
@client.chat(messages: @messages, tools: tool_definitions, stream: !hooks.nil?, hooks: hooks || {})
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def streaming_hooks
|
|
88
|
+
return nil unless @stream
|
|
89
|
+
|
|
90
|
+
{
|
|
91
|
+
on_token: lambda { |delta, _logprobs = nil|
|
|
92
|
+
@stream.emit(:token, text: delta.to_s) if delta && !delta.to_s.empty?
|
|
93
|
+
},
|
|
94
|
+
on_tool_call: lambda { |call|
|
|
95
|
+
name = dig(call, %w[function name]) || call["name"]
|
|
96
|
+
@stream.emit(:tool_call_detected, name: name, data: call) if name
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def tool_definitions
|
|
102
|
+
@tools.keys.sort.map do |key|
|
|
103
|
+
name = key.to_s
|
|
104
|
+
tool_entry = @tools[key]
|
|
105
|
+
|
|
106
|
+
# Support both explicit Tool objects and callables
|
|
107
|
+
# Tool objects are schema definitions only
|
|
108
|
+
if tool_entry.is_a?(Ollama::Tool)
|
|
109
|
+
tool_entry.to_h
|
|
110
|
+
elsif tool_entry.is_a?(Hash) && tool_entry[:tool].is_a?(Ollama::Tool)
|
|
111
|
+
# Format: { tool: Tool, callable: proc }
|
|
112
|
+
tool_entry[:tool].to_h
|
|
113
|
+
else
|
|
114
|
+
# Auto-infer from callable signature (default)
|
|
115
|
+
callable = tool_entry.is_a?(Hash) ? tool_entry[:callable] : tool_entry
|
|
116
|
+
parameters = infer_parameters(callable)
|
|
117
|
+
{
|
|
118
|
+
type: "function",
|
|
119
|
+
function: {
|
|
120
|
+
name: name,
|
|
121
|
+
description: "Tool: #{name}",
|
|
122
|
+
parameters: parameters
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def infer_parameters(callable)
|
|
130
|
+
return { "type" => "object", "additionalProperties" => true } unless callable.respond_to?(:parameters)
|
|
131
|
+
|
|
132
|
+
params = callable.parameters
|
|
133
|
+
return { "type" => "object", "additionalProperties" => true } if params.empty?
|
|
134
|
+
|
|
135
|
+
properties = {}
|
|
136
|
+
required = []
|
|
137
|
+
|
|
138
|
+
params.each do |type, name|
|
|
139
|
+
next unless name # Skip anonymous parameters
|
|
140
|
+
|
|
141
|
+
param_name = name.to_s
|
|
142
|
+
properties[param_name] = { "type" => "string", "description" => "Parameter: #{param_name}" }
|
|
143
|
+
|
|
144
|
+
# Required if it's a required keyword argument (:keyreq) or required positional (:req)
|
|
145
|
+
required << param_name if %i[keyreq req].include?(type)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
schema = { "type" => "object" }
|
|
149
|
+
schema["properties"] = properties unless properties.empty?
|
|
150
|
+
schema["required"] = required unless required.empty?
|
|
151
|
+
schema["additionalProperties"] = false if properties.any?
|
|
152
|
+
|
|
153
|
+
schema
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def dig(obj, path)
|
|
157
|
+
cur = obj
|
|
158
|
+
path.each do |k|
|
|
159
|
+
return nil unless cur.is_a?(Hash)
|
|
160
|
+
|
|
161
|
+
cur = cur[k] || cur[k.to_sym]
|
|
162
|
+
end
|
|
163
|
+
cur
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def invoke_tool(callable, args_hash)
|
|
167
|
+
sym_args = normalize_parameter_names(args_hash)
|
|
168
|
+
keyword_result = call_with_keywords(callable, sym_args)
|
|
169
|
+
return keyword_result[:value] if keyword_result[:success]
|
|
170
|
+
|
|
171
|
+
call_with_positional(callable, args_hash)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def normalize_parameter_names(args_hash)
|
|
175
|
+
args_hash.transform_keys { |k| k.to_s.to_sym }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def apply_parameter_aliases(args, callable)
|
|
179
|
+
return args unless callable.respond_to?(:parameters)
|
|
180
|
+
|
|
181
|
+
param_names = callable.parameters.map { |_type, name| name }
|
|
182
|
+
aliased = args.dup
|
|
183
|
+
|
|
184
|
+
# Common aliases: directory -> path, file -> path, filename -> path
|
|
185
|
+
if param_names.include?(:path) && !aliased.key?(:path)
|
|
186
|
+
if aliased.key?(:directory)
|
|
187
|
+
aliased[:path] = aliased.delete(:directory)
|
|
188
|
+
elsif aliased.key?(:file)
|
|
189
|
+
aliased[:path] = aliased.delete(:file)
|
|
190
|
+
elsif aliased.key?(:filename)
|
|
191
|
+
aliased[:path] = aliased.delete(:filename)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
aliased
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def call_with_keywords(callable, sym_args)
|
|
199
|
+
{ success: true, value: callable.call(**sym_args) }
|
|
200
|
+
rescue ArgumentError => e
|
|
201
|
+
return { success: false } unless missing_keyword_error?(e)
|
|
202
|
+
|
|
203
|
+
aliased_args = apply_parameter_aliases(sym_args, callable)
|
|
204
|
+
return { success: false } if aliased_args == sym_args
|
|
205
|
+
|
|
206
|
+
begin
|
|
207
|
+
{ success: true, value: callable.call(**aliased_args) }
|
|
208
|
+
rescue ArgumentError
|
|
209
|
+
{ success: false }
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def call_with_positional(callable, args_hash)
|
|
214
|
+
callable.call(args_hash)
|
|
215
|
+
rescue ArgumentError => e
|
|
216
|
+
raise ArgumentError,
|
|
217
|
+
"Tool invocation failed: #{e.message}. Arguments provided: #{args_hash.inspect}. " \
|
|
218
|
+
"Ensure the tool call includes all required parameters."
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def missing_keyword_error?(error)
|
|
222
|
+
error.message.include?("required keyword") || error.message.include?("missing keyword")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def encode_tool_result(result)
|
|
226
|
+
return result if result.is_a?(String)
|
|
227
|
+
|
|
228
|
+
JSON.generate(result)
|
|
229
|
+
rescue JSON::GeneratorError
|
|
230
|
+
result.to_s
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def extract_callable(tool_entry)
|
|
234
|
+
case tool_entry
|
|
235
|
+
when Proc, Method
|
|
236
|
+
tool_entry
|
|
237
|
+
when Hash
|
|
238
|
+
tool_entry[:callable] || tool_entry["callable"]
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ollama
|
|
4
|
+
module Agent
|
|
5
|
+
# Small helpers for building chat message hashes.
|
|
6
|
+
module Messages
|
|
7
|
+
def self.system(content)
|
|
8
|
+
{ role: "system", content: content.to_s }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.user(content)
|
|
12
|
+
{ role: "user", content: content.to_s }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.assistant(content, tool_calls: nil)
|
|
16
|
+
msg = { role: "assistant", content: content.to_s }
|
|
17
|
+
msg[:tool_calls] = tool_calls if tool_calls
|
|
18
|
+
msg
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Tool results are sent back as role: "tool".
|
|
22
|
+
# Some APIs require `tool_call_id` to associate results with calls.
|
|
23
|
+
def self.tool(content:, name: nil, tool_call_id: nil)
|
|
24
|
+
msg = { role: "tool", content: content.to_s }
|
|
25
|
+
msg[:name] = name if name
|
|
26
|
+
msg[:tool_call_id] = tool_call_id if tool_call_id
|
|
27
|
+
msg
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "messages"
|
|
5
|
+
|
|
6
|
+
module Ollama
|
|
7
|
+
module Agent
|
|
8
|
+
# Stateless planner-style agent using /api/generate.
|
|
9
|
+
#
|
|
10
|
+
# Intended for planning, classification, routing, and deterministic structured outputs.
|
|
11
|
+
class Planner
|
|
12
|
+
ANY_JSON_SCHEMA = {
|
|
13
|
+
"anyOf" => [
|
|
14
|
+
{ "type" => "object", "additionalProperties" => true },
|
|
15
|
+
{ "type" => "array" },
|
|
16
|
+
{ "type" => "string" },
|
|
17
|
+
{ "type" => "number" },
|
|
18
|
+
{ "type" => "integer" },
|
|
19
|
+
{ "type" => "boolean" },
|
|
20
|
+
{ "type" => "null" }
|
|
21
|
+
]
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def initialize(client)
|
|
25
|
+
@client = client
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param prompt [String]
|
|
29
|
+
# @param context [Hash, nil]
|
|
30
|
+
# @param schema [Hash, nil]
|
|
31
|
+
# @return [Object] Parsed JSON (Hash/Array/String/Number/Boolean/Nil)
|
|
32
|
+
def run(prompt:, context: nil, schema: nil)
|
|
33
|
+
full_prompt = prompt.to_s
|
|
34
|
+
|
|
35
|
+
full_prompt = "#{full_prompt}\n\nContext (JSON):\n#{JSON.pretty_generate(context)}" if context && !context.empty?
|
|
36
|
+
|
|
37
|
+
@client.generate(
|
|
38
|
+
prompt: full_prompt,
|
|
39
|
+
schema: schema || ANY_JSON_SCHEMA,
|
|
40
|
+
strict: true
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ollama
|
|
4
|
+
# Thread-safe immutable API key pool for rate-limit failover.
|
|
5
|
+
#
|
|
6
|
+
# The pool never mutates the configured key list after construction. Request
|
|
7
|
+
# state is represented as a short-lived context hash so retry counters and
|
|
8
|
+
# rotation offsets remain isolated to the caller's execution path.
|
|
9
|
+
class ApiKeyPool
|
|
10
|
+
attr_reader :keys
|
|
11
|
+
|
|
12
|
+
# @param keys [Array<String>] API keys in configured priority order
|
|
13
|
+
# @param concurrency_enabled [Boolean] whether initial keys should be distributed round-robin
|
|
14
|
+
def initialize(keys, concurrency_enabled: false)
|
|
15
|
+
@keys = keys.map(&:to_s).map(&:strip).reject(&:empty?).freeze
|
|
16
|
+
@concurrency_enabled = concurrency_enabled
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
@next_index = 0
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [Integer] number of configured keys
|
|
22
|
+
def size
|
|
23
|
+
@keys.size
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Boolean] true when no keys are configured
|
|
27
|
+
def empty?
|
|
28
|
+
@keys.empty?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Build request-local rotation context.
|
|
32
|
+
#
|
|
33
|
+
# @return [Hash] isolated context for one logical request attempt
|
|
34
|
+
def request_context
|
|
35
|
+
{ start_index: initial_index }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Resolve a key from a request-local context and rotation offset.
|
|
39
|
+
#
|
|
40
|
+
# @param context [Hash]
|
|
41
|
+
# @param offset [Integer]
|
|
42
|
+
# @return [String, nil]
|
|
43
|
+
def key_for(context, offset: 0)
|
|
44
|
+
return nil if empty?
|
|
45
|
+
|
|
46
|
+
@keys[(context.fetch(:start_index) + offset) % size]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def initial_index
|
|
52
|
+
return 0 unless @concurrency_enabled
|
|
53
|
+
|
|
54
|
+
@mutex.synchronize do
|
|
55
|
+
current = @next_index
|
|
56
|
+
@next_index = (@next_index + 1) % size if size.positive?
|
|
57
|
+
current
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|