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,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ollama
|
|
4
|
+
# Typed attachment payload for multimodal inputs.
|
|
5
|
+
#
|
|
6
|
+
# Each variant normalizes to a single hash accepted by Ollama as the
|
|
7
|
+
# +:images+ entry on a user message: +{ type: "...", data: "..." }+.
|
|
8
|
+
#
|
|
9
|
+
# Example:
|
|
10
|
+
#
|
|
11
|
+
# Ollama::Attachment.file("photo.png")
|
|
12
|
+
# # => { type: "file", data: "photo.png" }
|
|
13
|
+
#
|
|
14
|
+
# Ollama::Attachment.base64(b64_string)
|
|
15
|
+
# # => { type: "base64", data: b64_string }
|
|
16
|
+
#
|
|
17
|
+
# Ollama::Attachment.url("https://...")
|
|
18
|
+
# # => { type: "url", data: "https://..." }
|
|
19
|
+
class Attachment
|
|
20
|
+
attr_reader :type, :data
|
|
21
|
+
|
|
22
|
+
# @param type [String, Symbol] one of +file+, +base64+, +url+
|
|
23
|
+
# @param data [String, #read]
|
|
24
|
+
def initialize(type, data)
|
|
25
|
+
@type = type.to_s
|
|
26
|
+
@data = coerce(data)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# +type:+ +"file"+, +data:+ +path+ (string or +Pathname+). Reads as base64 when a file path is supplied.
|
|
30
|
+
# Uses base64-encoded file when a file path is supplied.
|
|
31
|
+
def self.file(path)
|
|
32
|
+
path = path.to_s
|
|
33
|
+
data = if path.include?("\n") || path.include?("+")
|
|
34
|
+
path
|
|
35
|
+
else
|
|
36
|
+
base64_from_file(path)
|
|
37
|
+
end
|
|
38
|
+
new("file", data)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# +type:+ +"base64"+, +data:+ +base64-encoded string+
|
|
42
|
+
def self.base64(data)
|
|
43
|
+
new("base64", data)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# +type:+ +"url"+, +data:+ +remote URL+
|
|
47
|
+
def self.url(url)
|
|
48
|
+
new("url", url.to_s)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_h
|
|
52
|
+
{ "type" => type, "data" => data }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def coerce(value)
|
|
58
|
+
return value if value.is_a?(String)
|
|
59
|
+
return value.read if value.respond_to?(:read)
|
|
60
|
+
|
|
61
|
+
value.to_s
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class << self
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def base64_from_file(path)
|
|
68
|
+
File.file?(path) && File.exist?(path) ? [File.binread(path)].pack("m") : path
|
|
69
|
+
rescue SystemCallError
|
|
70
|
+
path
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
data/lib/ollama/capabilities.rb
CHANGED
|
@@ -26,7 +26,7 @@ module Ollama
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
TOOLS_FAMILIES = %w[llama qwen2 qwen3 qwen3vl command-r mistral gemma2 gemma4].freeze
|
|
29
|
-
THINKING_MODELS = [/deepseek-r1/i, /-r1/i, /qwq/i, /
|
|
29
|
+
THINKING_MODELS = [/deepseek-r1/i, /-r1/i, /qwq/i, /\bqwen3(?![.\d])/i, /gemma[_-]?4/i].freeze
|
|
30
30
|
VISION_FAMILIES = %w[llava clip qwen3vl mllama].freeze
|
|
31
31
|
VISION_MODELS = [/vision/i, /vl/i].freeze
|
|
32
32
|
EMBEDDING_FAMILIES = %w[nomic-bert bert mxbai-embed-large].freeze
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ollama
|
|
4
|
+
# Wraps a normalized chat response in a Ruby surface that supports
|
|
5
|
+
# traditional attribute access and state checks.
|
|
6
|
+
class ChatResponse
|
|
7
|
+
attr_reader :content, :thinking, :role, :tool_calls, :images, :done, :model
|
|
8
|
+
|
|
9
|
+
# rubocop:disable Metrics/ParameterLists
|
|
10
|
+
def initialize(
|
|
11
|
+
content: nil,
|
|
12
|
+
thinking: nil,
|
|
13
|
+
role: "assistant",
|
|
14
|
+
tool_calls: [],
|
|
15
|
+
images: [],
|
|
16
|
+
done: true,
|
|
17
|
+
model: nil
|
|
18
|
+
)
|
|
19
|
+
# rubocop:enable Metrics/ParameterLists
|
|
20
|
+
@content = content
|
|
21
|
+
@thinking = thinking
|
|
22
|
+
@role = role
|
|
23
|
+
@tool_calls = Array(tool_calls)
|
|
24
|
+
@images = Array(images)
|
|
25
|
+
@done = done
|
|
26
|
+
@model = model
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def done?
|
|
30
|
+
done
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../multimodal_input"
|
|
4
|
+
require_relative "../../prompt_adapters"
|
|
5
|
+
|
|
6
|
+
module Ollama
|
|
7
|
+
class Client
|
|
8
|
+
module Chat
|
|
9
|
+
# Prepares Chat Request objects from client configuration and inputs
|
|
10
|
+
class RequestPreparer
|
|
11
|
+
def initialize(config:, provider:)
|
|
12
|
+
@config = config
|
|
13
|
+
@provider = provider
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# rubocop:disable Metrics/MethodLength
|
|
17
|
+
def build_request(params, client_instance)
|
|
18
|
+
target_model = params.model || @config.model
|
|
19
|
+
active_profile = client_instance.send(:resolve_profile, target_model, params.profile)
|
|
20
|
+
adapter = PromptAdapters.for(active_profile) if active_profile
|
|
21
|
+
|
|
22
|
+
messages = if params.inputs
|
|
23
|
+
apply_inputs(params.messages, params.inputs, active_profile)
|
|
24
|
+
else
|
|
25
|
+
params.messages
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
adapted_messages = if adapter
|
|
29
|
+
adapter.adapt_messages(messages, think: !params.think.nil?, tools: params.tools)
|
|
30
|
+
else
|
|
31
|
+
messages
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
effective_think = resolve_think_flag(params.think, adapter)
|
|
35
|
+
stream_enabled = params.stream.nil? ? hooks_present?(params.hooks) : params.stream
|
|
36
|
+
|
|
37
|
+
Request.new(
|
|
38
|
+
endpoint: :chat,
|
|
39
|
+
model: target_model,
|
|
40
|
+
messages: adapted_messages,
|
|
41
|
+
tools: params.tools,
|
|
42
|
+
format: params.format,
|
|
43
|
+
think: effective_think,
|
|
44
|
+
keep_alive: params.keep_alive,
|
|
45
|
+
options: client_instance.send(:build_options_with_profile, params.options, active_profile),
|
|
46
|
+
stream: stream_enabled,
|
|
47
|
+
metadata: {
|
|
48
|
+
base_url: @config.base_url,
|
|
49
|
+
timeout: @config.timeout,
|
|
50
|
+
hooks: params.hooks,
|
|
51
|
+
uri: @provider.chat_endpoint
|
|
52
|
+
},
|
|
53
|
+
raw_options: {
|
|
54
|
+
logprobs: params.logprobs,
|
|
55
|
+
top_logprobs: params.top_logprobs
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
# rubocop:enable Metrics/MethodLength
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def hooks_present?(hooks)
|
|
64
|
+
[hooks[:on_token], hooks[:on_thought], hooks[:on_error],
|
|
65
|
+
hooks[:on_complete], hooks[:on_tool_call]].any?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def apply_inputs(messages, inputs, active_profile)
|
|
69
|
+
input_obj = MultimodalInput.build(inputs, profile: active_profile || ModelProfile.for("generic"))
|
|
70
|
+
messages + [input_obj.to_message]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def resolve_think_flag(think, adapter)
|
|
74
|
+
return nil if think.nil?
|
|
75
|
+
return nil if adapter&.inject_think_flag? == false && adapter.is_a?(PromptAdapters::Gemma4)
|
|
76
|
+
|
|
77
|
+
think
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/ollama/client/chat.rb
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "chat_stream_processor"
|
|
4
|
+
require_relative "../responses/chat"
|
|
5
|
+
require_relative "../serializers/chat"
|
|
6
|
+
require_relative "../parsers/chat"
|
|
7
|
+
require_relative "chat/request_preparer"
|
|
4
8
|
|
|
5
9
|
module Ollama
|
|
6
10
|
class Client
|
|
@@ -25,97 +29,83 @@ module Ollama
|
|
|
25
29
|
# :on_error ->(error) — stream or connection error
|
|
26
30
|
# :on_complete -> — stream finished
|
|
27
31
|
# @return [Ollama::Response] Response wrapper with message, tool_calls, timing, etc.
|
|
28
|
-
# rubocop:disable Metrics/
|
|
32
|
+
# rubocop:disable Metrics/ParameterLists
|
|
29
33
|
def chat(messages:, model: nil, format: nil, tools: nil, stream: nil,
|
|
30
34
|
think: nil, keep_alive: nil, options: nil, logprobs: nil,
|
|
31
35
|
top_logprobs: nil, hooks: {}, profile: :auto, inputs: nil)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
# Apply prompt adapter (e.g. Gemma 4 prepends the family think tag to the system prompt)
|
|
42
|
-
adapted_messages = adapter ? adapter.adapt_messages(messages, think: !think.nil?) : messages
|
|
43
|
-
|
|
44
|
-
# Resolve think flag: adapter may handle it via prompt tag instead of API flag
|
|
45
|
-
effective_think = resolve_think_flag(think, adapter)
|
|
46
|
-
|
|
47
|
-
chat_uri = URI("#{@config.base_url}/api/chat")
|
|
48
|
-
req = Net::HTTP::Post.new(chat_uri)
|
|
49
|
-
req["Content-Type"] = "application/json"
|
|
36
|
+
# rubocop:enable Metrics/ParameterLists
|
|
37
|
+
params = Params::Chat.new(
|
|
38
|
+
messages: messages, model: model, format: format, tools: tools,
|
|
39
|
+
stream: stream, think: think, keep_alive: keep_alive, options: options,
|
|
40
|
+
logprobs: logprobs, top_logprobs: top_logprobs, hooks: hooks,
|
|
41
|
+
profile: profile, inputs: inputs
|
|
42
|
+
)
|
|
43
|
+
chat_with_params(params)
|
|
44
|
+
end
|
|
50
45
|
|
|
51
|
-
|
|
46
|
+
# @param params [Ollama::Params::Chat] Chat parameters
|
|
47
|
+
# @return [Ollama::Response] Response wrapper with message, tool_calls, timing, etc.
|
|
48
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
49
|
+
def chat_with_params(params)
|
|
50
|
+
raise ArgumentError, "messages is required" if params.messages.nil? || params.messages.empty?
|
|
52
51
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
body[:tools] = tools if tools
|
|
56
|
-
body[:think] = effective_think unless effective_think.nil?
|
|
57
|
-
body[:keep_alive] = keep_alive if keep_alive
|
|
58
|
-
body[:logprobs] = logprobs unless logprobs.nil?
|
|
59
|
-
body[:top_logprobs] = top_logprobs if top_logprobs
|
|
60
|
-
body[:options] = build_options_with_profile(options, active_profile)
|
|
52
|
+
preparer = RequestPreparer.new(config: @config, provider: @provider)
|
|
53
|
+
request = preparer.build_request(params, self)
|
|
61
54
|
|
|
62
|
-
|
|
63
|
-
|
|
55
|
+
serializer = Serializers::Chat.new
|
|
56
|
+
transport_req = serializer.call(request)
|
|
64
57
|
response_data = nil
|
|
58
|
+
stream_enabled = request.stream
|
|
59
|
+
config_timeout = request.metadata[:timeout]
|
|
60
|
+
target_model = request.model
|
|
61
|
+
chat_uri = request.metadata[:uri]
|
|
65
62
|
|
|
66
63
|
begin
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
64
|
+
with_rate_limit_key_rotation do |api_key|
|
|
65
|
+
response_data = nil
|
|
66
|
+
@config.apply_auth_to(transport_req, api_key: api_key) if transport_req.is_a?(Transport::Request)
|
|
67
|
+
|
|
68
|
+
if stream_enabled
|
|
69
|
+
buffer = +""
|
|
70
|
+
processor = ChatStreamProcessor.new(params.hooks, provider: @provider)
|
|
71
|
+
processor.send(:reset_accumulators!)
|
|
72
|
+
|
|
73
|
+
@pipeline.stream(transport_req) do |chunk|
|
|
74
|
+
processor.send(:drain_chunk, buffer, chunk)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
response_data = processor.send(:build_result)
|
|
78
|
+
else
|
|
79
|
+
transport_response = @pipeline.call(transport_req)
|
|
80
|
+
if transport_response.raw && !transport_response.raw.is_a?(Net::HTTPSuccess)
|
|
81
|
+
handle_http_error(transport_response.raw,
|
|
82
|
+
requested_model: target_model)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
parser = Parsers::Chat.new(provider: @provider)
|
|
86
|
+
parsed_response = parser.call(transport_response)
|
|
87
|
+
response_data = parsed_response.to_h
|
|
77
88
|
end
|
|
78
89
|
end
|
|
79
90
|
rescue Net::ReadTimeout, Net::OpenTimeout => e
|
|
80
|
-
hooks[:on_error]&.call(e)
|
|
81
|
-
raise TimeoutError, "Request timed out after #{
|
|
91
|
+
params.hooks[:on_error]&.call(e)
|
|
92
|
+
raise TimeoutError, "Request timed out after #{config_timeout}s"
|
|
82
93
|
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError => e
|
|
83
|
-
hooks[:on_error]&.call(e)
|
|
94
|
+
params.hooks[:on_error]&.call(e)
|
|
84
95
|
raise Error, "Connection failed: #{e.message}"
|
|
85
96
|
rescue Error => e
|
|
86
|
-
hooks[:on_error]&.call(e)
|
|
97
|
+
params.hooks[:on_error]&.call(e)
|
|
87
98
|
raise e
|
|
88
99
|
end
|
|
89
100
|
|
|
90
101
|
emit_response_hook(response_data.is_a?(Hash) ? response_data.to_json : response_data,
|
|
91
|
-
endpoint:
|
|
102
|
+
endpoint: chat_uri.path, model: target_model)
|
|
92
103
|
|
|
93
|
-
|
|
104
|
+
Responses::Chat.new(response_data)
|
|
94
105
|
rescue JSON::ParserError => e
|
|
95
106
|
raise InvalidJSONError, "Failed to parse chat response: #{e.message}"
|
|
96
107
|
end
|
|
97
|
-
# rubocop:enable Metrics/
|
|
98
|
-
|
|
99
|
-
private
|
|
100
|
-
|
|
101
|
-
def hooks_present?(hooks)
|
|
102
|
-
[hooks[:on_token], hooks[:on_thought], hooks[:on_error],
|
|
103
|
-
hooks[:on_complete], hooks[:on_tool_call]].any?
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def apply_inputs(messages, inputs, active_profile)
|
|
107
|
-
input_obj = MultimodalInput.build(inputs, profile: active_profile || ModelProfile.for("generic"))
|
|
108
|
-
messages + [input_obj.to_message]
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
# Gemma 4 uses the system-prompt tag — do not send think: true to the API.
|
|
112
|
-
# Other adapters that inject_think_flag? pass the user's think value through.
|
|
113
|
-
def resolve_think_flag(think, adapter)
|
|
114
|
-
return nil if think.nil?
|
|
115
|
-
return nil if adapter&.inject_think_flag? == false && adapter.is_a?(PromptAdapters::Gemma4)
|
|
116
|
-
|
|
117
|
-
think
|
|
118
|
-
end
|
|
108
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
119
109
|
end
|
|
120
110
|
end
|
|
121
111
|
end
|
|
@@ -5,10 +5,24 @@ module Ollama
|
|
|
5
5
|
# Consumes an NDJSON /api/chat stream, aggregates message fields, and dispatches hooks.
|
|
6
6
|
# Line buffering matches Ollama::GenerateStreamHandler so both NDJSON parsers stay parallel.
|
|
7
7
|
class ChatStreamProcessor
|
|
8
|
-
|
|
8
|
+
# Convenience class method to process a stream.
|
|
9
|
+
# @param res [Net::HTTPResponse]
|
|
10
|
+
# @param hooks [Hash]
|
|
11
|
+
# @param provider [Ollama::Providers::Base]
|
|
12
|
+
# @return [Hash] the aggregated result
|
|
13
|
+
def self.call(res, hooks, provider: nil)
|
|
14
|
+
new(hooks, provider: provider).call(res)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param hooks [Hash]
|
|
18
|
+
# @param provider [Ollama::Providers::Base]
|
|
19
|
+
def initialize(hooks, provider: nil)
|
|
9
20
|
@hooks = hooks
|
|
21
|
+
@provider = provider
|
|
10
22
|
end
|
|
11
23
|
|
|
24
|
+
# @param res [Net::HTTPResponse]
|
|
25
|
+
# @return [Hash] the aggregated result
|
|
12
26
|
def call(res)
|
|
13
27
|
reset_accumulators!
|
|
14
28
|
|
|
@@ -41,9 +55,13 @@ module Ollama
|
|
|
41
55
|
end
|
|
42
56
|
|
|
43
57
|
def handle_line(line)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
58
|
+
# OpenAI SSE uses "data: " prefix and ends with "data: [DONE]"
|
|
59
|
+
return if line == "data: [DONE]"
|
|
60
|
+
|
|
61
|
+
json_text = line.start_with?("data: ") ? line.sub(/^data: /, "") : line
|
|
62
|
+
handle_event(JSON.parse(json_text))
|
|
63
|
+
rescue JSON::ParserError => e
|
|
64
|
+
@hooks[:on_error]&.call(MalformedStreamError.new("Failed to parse JSON line: #{e.message}"))
|
|
47
65
|
end
|
|
48
66
|
|
|
49
67
|
def build_result
|
|
@@ -57,39 +75,81 @@ module Ollama
|
|
|
57
75
|
end
|
|
58
76
|
|
|
59
77
|
def handle_event(obj)
|
|
60
|
-
if
|
|
61
|
-
|
|
62
|
-
@hooks[:on_error]&.call(error)
|
|
63
|
-
raise error
|
|
64
|
-
end
|
|
78
|
+
# Normalize event if it's from OpenAI
|
|
79
|
+
obj = normalize_openai_delta(obj) if @provider.is_a?(Providers::OpenAI)
|
|
65
80
|
|
|
66
|
-
if obj["
|
|
67
|
-
|
|
68
|
-
|
|
81
|
+
handle_error_event(obj) if obj["error"]
|
|
82
|
+
process_message_field(obj) if obj["message"]
|
|
83
|
+
finalize_stream_if_done(obj) if obj["done"]
|
|
84
|
+
end
|
|
69
85
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
86
|
+
def handle_error_event(obj)
|
|
87
|
+
error = StreamError.new(obj["error"])
|
|
88
|
+
@hooks[:on_error]&.call(error)
|
|
89
|
+
raise error
|
|
90
|
+
end
|
|
75
91
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
end
|
|
92
|
+
def process_message_field(obj)
|
|
93
|
+
msg = obj["message"]
|
|
94
|
+
content = msg["content"]
|
|
95
|
+
thinking = msg["thinking"]
|
|
81
96
|
|
|
82
|
-
|
|
83
|
-
|
|
97
|
+
process_thinking(thinking) if thinking && !thinking.empty?
|
|
98
|
+
process_content(content, obj["logprobs"]) if content && !content.empty?
|
|
99
|
+
|
|
100
|
+
@full_logprobs.concat(obj["logprobs"]) if obj["logprobs"]
|
|
101
|
+
end
|
|
84
102
|
|
|
85
|
-
|
|
103
|
+
def process_thinking(thinking)
|
|
104
|
+
start_thought_block unless @thinking_started
|
|
105
|
+
@full_thinking << thinking
|
|
106
|
+
@hooks[:on_thought]&.call(StreamEvent.new(type: :thought_delta, data: thinking))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def process_content(content, logprobs)
|
|
110
|
+
end_thought_block_if_needed
|
|
111
|
+
@full_content << content
|
|
112
|
+
emit_token(content, logprobs)
|
|
113
|
+
end
|
|
86
114
|
|
|
115
|
+
def finalize_stream_if_done(obj)
|
|
87
116
|
Array(obj.dig("message", "tool_calls")).each { |tc| @hooks[:on_tool_call]&.call(tc) }
|
|
88
117
|
|
|
89
118
|
@hooks[:on_complete]&.call
|
|
90
119
|
@last_data = obj
|
|
91
120
|
end
|
|
92
121
|
|
|
122
|
+
def normalize_openai_delta(obj)
|
|
123
|
+
return obj unless obj.key?("choices")
|
|
124
|
+
|
|
125
|
+
choice = obj["choices"][0]
|
|
126
|
+
delta = choice["delta"] || {}
|
|
127
|
+
|
|
128
|
+
{
|
|
129
|
+
"model" => obj["model"],
|
|
130
|
+
"message" => {
|
|
131
|
+
"role" => delta["role"],
|
|
132
|
+
"content" => delta["content"],
|
|
133
|
+
"tool_calls" => translate_openai_tool_calls(delta["tool_calls"])
|
|
134
|
+
},
|
|
135
|
+
"done" => !choice["finish_reason"].nil?,
|
|
136
|
+
"done_reason" => choice["finish_reason"]
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def translate_openai_tool_calls(openai_tool_calls)
|
|
141
|
+
return nil unless openai_tool_calls
|
|
142
|
+
|
|
143
|
+
openai_tool_calls.map do |tc|
|
|
144
|
+
{
|
|
145
|
+
"function" => {
|
|
146
|
+
"name" => tc.dig("function", "name"),
|
|
147
|
+
"arguments" => tc.dig("function", "arguments")
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
93
153
|
def start_thought_block
|
|
94
154
|
@hooks[:on_thought]&.call(StreamEvent.new(type: :thought_start, data: nil))
|
|
95
155
|
@thinking_started = true
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../request"
|
|
4
|
+
|
|
5
|
+
module Ollama
|
|
6
|
+
class Client
|
|
7
|
+
module Generate
|
|
8
|
+
# Prepares Generate Request objects from client configuration and inputs
|
|
9
|
+
class RequestPreparer
|
|
10
|
+
def initialize(config:, provider:, schema_cache: {})
|
|
11
|
+
@config = config
|
|
12
|
+
@provider = provider
|
|
13
|
+
@schema_cache = schema_cache
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
|
|
17
|
+
def build_request(prompt:, context:, schema:, model:, hooks:, system: nil, images: nil,
|
|
18
|
+
think: nil, keep_alive: nil, suffix: nil, raw: nil, options: nil, client_instance: nil)
|
|
19
|
+
config_model = @config.model
|
|
20
|
+
config_timeout = @config.timeout
|
|
21
|
+
base_url = @config.base_url
|
|
22
|
+
|
|
23
|
+
stream_enabled = streaming_requested?(hooks)
|
|
24
|
+
|
|
25
|
+
request_params = {
|
|
26
|
+
model: model || config_model,
|
|
27
|
+
prompt: prompt,
|
|
28
|
+
stream: stream_enabled,
|
|
29
|
+
options: client_instance.send(:build_options, options)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
request_params[:context] = context if context
|
|
33
|
+
request_params[:system] = system if system
|
|
34
|
+
request_params[:images] = images if images
|
|
35
|
+
request_params[:think] = think unless think.nil?
|
|
36
|
+
request_params[:keep_alive] = keep_alive if keep_alive
|
|
37
|
+
request_params[:suffix] = suffix if suffix
|
|
38
|
+
request_params[:raw] = raw unless raw.nil?
|
|
39
|
+
|
|
40
|
+
if schema
|
|
41
|
+
request_params[:format] = schema
|
|
42
|
+
request_params[:prompt] = enhance_prompt_for_json(prompt, schema)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
Request.new(
|
|
46
|
+
endpoint: :generate,
|
|
47
|
+
model: model || config_model,
|
|
48
|
+
prompt: request_params[:prompt],
|
|
49
|
+
schema: schema,
|
|
50
|
+
options: request_params[:options],
|
|
51
|
+
stream: stream_enabled,
|
|
52
|
+
keep_alive: keep_alive,
|
|
53
|
+
think: think,
|
|
54
|
+
format: request_params[:format],
|
|
55
|
+
images: images,
|
|
56
|
+
metadata: {
|
|
57
|
+
base_url: base_url,
|
|
58
|
+
timeout: config_timeout,
|
|
59
|
+
hooks: hooks,
|
|
60
|
+
uri: @provider.generate_endpoint
|
|
61
|
+
},
|
|
62
|
+
raw_options: {
|
|
63
|
+
system: system,
|
|
64
|
+
suffix: suffix,
|
|
65
|
+
raw: raw,
|
|
66
|
+
context: context
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
# rubocop:enable Metrics/MethodLength, Metrics/ParameterLists
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def streaming_requested?(hooks)
|
|
75
|
+
!(hooks[:on_token] || hooks[:on_error] || hooks[:on_complete]).nil?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def enhance_prompt_for_json(prompt, schema)
|
|
79
|
+
return prompt if prompt.match?(/json|JSON/i)
|
|
80
|
+
|
|
81
|
+
schema_summary = summarize_schema(schema)
|
|
82
|
+
json_instruction = "CRITICAL: Respond with ONLY valid JSON (no markdown code blocks, no explanations). " \
|
|
83
|
+
"The JSON must include these exact required fields: #{schema_summary}"
|
|
84
|
+
"#{prompt}\n\n#{json_instruction}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def summarize_schema(schema)
|
|
88
|
+
return "object" unless schema.is_a?(Hash)
|
|
89
|
+
|
|
90
|
+
cache_key = schema.hash
|
|
91
|
+
return @schema_cache[cache_key] if @schema_cache.key?(cache_key)
|
|
92
|
+
|
|
93
|
+
required = schema["required"] || []
|
|
94
|
+
properties = schema["properties"] || {}
|
|
95
|
+
return "object" if required.empty? && properties.empty?
|
|
96
|
+
|
|
97
|
+
example = {}
|
|
98
|
+
required.each do |key|
|
|
99
|
+
prop = properties[key] || {}
|
|
100
|
+
example[key] = case prop["type"]
|
|
101
|
+
when "string" then "string_value"
|
|
102
|
+
when "number" then 0
|
|
103
|
+
when "boolean" then true
|
|
104
|
+
when "array" then []
|
|
105
|
+
else {}
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
required_list = required.map { |k| "\"#{k}\"" }.join(", ")
|
|
110
|
+
example_json = JSON.pretty_generate(example)
|
|
111
|
+
result = "Required fields: [#{required_list}]. Example structure:\n#{example_json}"
|
|
112
|
+
@schema_cache[cache_key] = result
|
|
113
|
+
result
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|