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.
Files changed (178) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +11 -0
  3. data/API_CONTRACT.md +79 -8
  4. data/CHANGELOG.md +45 -0
  5. data/CONTRIBUTING.md +8 -0
  6. data/README.md +205 -273
  7. data/ROADMAP.md +23 -0
  8. data/docs/API_GAPS.md +18 -141
  9. data/docs/ARCHITECTURE.md +141 -0
  10. data/docs/AREAS_FOR_CONSIDERATION.md +13 -1
  11. data/docs/CLOUD.md +19 -0
  12. data/docs/CONSOLE_IMPROVEMENTS.md +20 -1
  13. data/docs/ECOSYSTEM_GEMS.md +41 -0
  14. data/docs/ECOSYSTEM_STRATEGY.md +151 -0
  15. data/docs/GETTING_STARTED.md +16 -8
  16. data/docs/INTEGRATION_TESTING.md +23 -5
  17. data/docs/PRODUCTION_FIXES.md +15 -3
  18. data/docs/QUICK_START.md +1 -1
  19. data/docs/README.md +1 -0
  20. data/docs/RUBYLLM_ADOPTION_MATRIX.md +273 -0
  21. data/docs/adr/001-openai-boundary.md +12 -0
  22. data/docs/adr/002-transport-abstraction.md +12 -0
  23. data/docs/adr/003-response-normalization.md +12 -0
  24. data/docs/adr/004-mock-transport.md +16 -0
  25. data/docs/adr/005-error-taxonomy.md +16 -0
  26. data/docs/adr/006-stream-runtime.md +16 -0
  27. data/docs/ecosystem/BOUNDARIES.md +19 -0
  28. data/docs/ecosystem/DEPENDENCY_GRAPH.md +14 -0
  29. data/docs/ecosystem/DESIGN_PRINCIPLES.md +9 -0
  30. data/docs/ecosystem/EXISTING_REPOS.md +31 -0
  31. data/docs/ecosystem/EXPERIMENTAL_LABS.md +24 -0
  32. data/docs/ecosystem/OVERVIEW.md +28 -0
  33. data/docs/ecosystem/RELEASE_ORDER.md +17 -0
  34. data/docs/observability/README.md +8 -0
  35. data/docs/rails/README.md +7 -0
  36. data/docs/rfcs/0001-stream-runtime.md +6 -0
  37. data/docs/rfcs/0002-schema-system.md +6 -0
  38. data/docs/rfcs/0003-observability-hooks.md +6 -0
  39. data/docs/rfcs/0004-async-runtime.md +6 -0
  40. data/docs/rfcs/README.md +16 -0
  41. data/docs/runtime/ERROR_CONTRACT.md +14 -0
  42. data/docs/runtime/SCHEMA_CONTRACT.md +28 -0
  43. data/docs/runtime/STREAM_CONTRACT.md +17 -0
  44. data/docs/runtime/STREAM_RUNTIME.md +20 -0
  45. data/docs/runtime/TRANSPORT_CONTRACT.md +26 -0
  46. data/docs/schema/README.md +8 -0
  47. data/docs/schema/STRUCTURED_OUTPUTS.md +22 -0
  48. data/docs/streaming/README.md +8 -0
  49. data/docs/testing/README.md +7 -0
  50. data/docs/testing/REPLAY_SYSTEM.md +17 -0
  51. data/docs/transport/README.md +7 -0
  52. data/examples/README.md +44 -0
  53. data/examples/basic_chat.rb +9 -0
  54. data/examples/cloud_models.rb +162 -0
  55. data/examples/embeddings.rb +10 -0
  56. data/examples/free_catalog.json +290 -0
  57. data/examples/generate.rb +9 -0
  58. data/examples/streaming.rb +12 -0
  59. data/examples/structured_tools.rb +90 -0
  60. data/examples/tool_calling_direct.rb +101 -0
  61. data/examples/tool_dto_example.rb +94 -0
  62. data/exe/ollama-client +5 -1
  63. data/lib/ollama/agent/executor.rb +243 -0
  64. data/lib/ollama/agent/messages.rb +31 -0
  65. data/lib/ollama/agent/planner.rb +45 -0
  66. data/lib/ollama/api_key_pool.rb +61 -0
  67. data/lib/ollama/attachment.rb +74 -0
  68. data/lib/ollama/capabilities.rb +1 -1
  69. data/lib/ollama/chat_response.rb +33 -0
  70. data/lib/ollama/client/chat/request_preparer.rb +82 -0
  71. data/lib/ollama/client/chat.rb +58 -68
  72. data/lib/ollama/client/chat_stream_processor.rb +85 -25
  73. data/lib/ollama/client/generate/request_preparer.rb +118 -0
  74. data/lib/ollama/client/generate/response_formatter.rb +95 -0
  75. data/lib/ollama/client/generate.rb +83 -165
  76. data/lib/ollama/client/model_management.rb +182 -84
  77. data/lib/ollama/client/openai_compat.rb +185 -0
  78. data/lib/ollama/client/raw.rb +66 -0
  79. data/lib/ollama/client/tool_intent.rb +38 -0
  80. data/lib/ollama/client/web_search.rb +39 -0
  81. data/lib/ollama/client.rb +83 -35
  82. data/lib/ollama/config.rb +122 -17
  83. data/lib/ollama/embeddings.rb +67 -29
  84. data/lib/ollama/errors.rb +55 -1
  85. data/lib/ollama/events.rb +55 -0
  86. data/lib/ollama/generate_stream_handler.rb +23 -4
  87. data/lib/ollama/http_error_handler.rb +42 -0
  88. data/lib/ollama/messages.rb +109 -0
  89. data/lib/ollama/middleware/cache.rb +73 -0
  90. data/lib/ollama/middleware/logger.rb +74 -0
  91. data/lib/ollama/middleware/metrics.rb +84 -0
  92. data/lib/ollama/middleware/tracing.rb +101 -0
  93. data/lib/ollama/middleware.rb +39 -0
  94. data/lib/ollama/model_profile.rb +1 -1
  95. data/lib/ollama/openai.rb +16 -0
  96. data/lib/ollama/options.rb +63 -1
  97. data/lib/ollama/params.rb +139 -0
  98. data/lib/ollama/parsers/base.rb +24 -0
  99. data/lib/ollama/parsers/chat.rb +22 -0
  100. data/lib/ollama/parsers/embeddings.rb +23 -0
  101. data/lib/ollama/parsers/generate.rb +38 -0
  102. data/lib/ollama/parsers/list_running.rb +15 -0
  103. data/lib/ollama/parsers/show_model.rb +14 -0
  104. data/lib/ollama/parsers/version.rb +15 -0
  105. data/lib/ollama/pipeline.rb +169 -0
  106. data/lib/ollama/plugins.rb +83 -0
  107. data/lib/ollama/policies/auto_pull.rb +69 -0
  108. data/lib/ollama/policies/base.rb +70 -0
  109. data/lib/ollama/policies/capability_validation.rb +88 -0
  110. data/lib/ollama/policies/fallback.rb +57 -0
  111. data/lib/ollama/policies/rate_limit.rb +115 -0
  112. data/lib/ollama/policies/repair_json.rb +137 -0
  113. data/lib/ollama/policies/retry/strategies/exponential.rb +27 -0
  114. data/lib/ollama/policies/retry/strategies/fixed.rb +27 -0
  115. data/lib/ollama/policies/retry/strategies/jitter.rb +30 -0
  116. data/lib/ollama/policies/retry/strategies/linear.rb +27 -0
  117. data/lib/ollama/policies/retry.rb +152 -0
  118. data/lib/ollama/policies/schema_repair.rb +180 -0
  119. data/lib/ollama/policies/timeout.rb +43 -0
  120. data/lib/ollama/policies.rb +24 -0
  121. data/lib/ollama/prompt.rb +86 -0
  122. data/lib/ollama/prompt_adapters/base.rb +3 -2
  123. data/lib/ollama/prompt_adapters/gemma4.rb +22 -14
  124. data/lib/ollama/prompts/tool_planner.rb +35 -0
  125. data/lib/ollama/providers/base.rb +70 -0
  126. data/lib/ollama/providers/llama_cpp.rb +131 -0
  127. data/lib/ollama/providers/ollama.rb +54 -0
  128. data/lib/ollama/providers/openai.rb +134 -0
  129. data/lib/ollama/providers.rb +28 -0
  130. data/lib/ollama/rate_limit_handler.rb +48 -0
  131. data/lib/ollama/request.rb +169 -0
  132. data/lib/ollama/response.rb +3 -2
  133. data/lib/ollama/responses/base.rb +11 -0
  134. data/lib/ollama/responses/chat.rb +11 -0
  135. data/lib/ollama/responses/embeddings.rb +14 -0
  136. data/lib/ollama/responses/generate.rb +22 -0
  137. data/lib/ollama/schema_dsl.rb +97 -0
  138. data/lib/ollama/schema_validator.rb +90 -53
  139. data/lib/ollama/schemas/tool_intent.json +15 -0
  140. data/lib/ollama/schemas/tool_intent.rb +16 -0
  141. data/lib/ollama/serializers/base.rb +44 -0
  142. data/lib/ollama/serializers/chat.rb +42 -0
  143. data/lib/ollama/serializers/embeddings.rb +36 -0
  144. data/lib/ollama/serializers/generate.rb +47 -0
  145. data/lib/ollama/streaming_observer.rb +22 -0
  146. data/lib/ollama/testing.rb +103 -0
  147. data/lib/ollama/tool/function/parameters/property.rb +72 -0
  148. data/lib/ollama/tool/function/parameters.rb +101 -0
  149. data/lib/ollama/tool/function.rb +78 -0
  150. data/lib/ollama/tool.rb +60 -0
  151. data/lib/ollama/tool_dsl.rb +93 -0
  152. data/lib/ollama/tool_intent.rb +19 -0
  153. data/lib/ollama/transport/base.rb +42 -0
  154. data/lib/ollama/transport/mock.rb +48 -0
  155. data/lib/ollama/transport/net_http.rb +76 -0
  156. data/lib/ollama/transport/request.rb +20 -0
  157. data/lib/ollama/transport/response.rb +41 -0
  158. data/lib/ollama/transport.rb +26 -0
  159. data/lib/ollama/version.rb +1 -1
  160. data/lib/ollama_client.rb +35 -0
  161. data/script/live_branch_smoke/chat_exercises.rb +232 -0
  162. data/script/live_branch_smoke/generation_exercises.rb +128 -0
  163. data/script/live_branch_smoke/model_exercises.rb +105 -0
  164. data/script/live_branch_smoke/utility_exercises.rb +375 -0
  165. data/script/live_branch_smoke.rb +159 -174
  166. data/test_all_features.rb +315 -0
  167. metadata +158 -24
  168. data/.cursor/.gitignore +0 -1
  169. data/RELEASE_NOTES_v0.2.6.md +0 -41
  170. data/devagent_proper.rb +0 -430
  171. data/docs/TESTING.md +0 -508
  172. data/examples/agent_loop.rb +0 -120
  173. data/examples/failure_modes/invalid_json_repair.rb +0 -42
  174. data/examples/production/rails_agent.rb +0 -62
  175. data/market.jpg +0 -0
  176. data/print_capabilities.rb +0 -20
  177. data/schema.json +0 -1
  178. 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
@@ -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, /qwen3/i, /gemma[_-]?4/i].freeze
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
@@ -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/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/ParameterLists, Metrics/AbcSize
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
- raise ArgumentError, "messages is required" if messages.nil? || messages.empty?
33
-
34
- target_model = model || @config.model
35
- active_profile = resolve_profile(target_model, profile)
36
- adapter = PromptAdapters.for(active_profile) if active_profile
37
-
38
- # Apply multimodal inputs: build typed message and append to history
39
- messages = apply_inputs(messages, inputs, active_profile) if inputs
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
- stream_enabled = stream.nil? ? hooks_present?(hooks) : stream
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
- body = { model: target_model, messages: adapted_messages, stream: stream_enabled }
54
- body[:format] = format if format
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
- req.body = body.to_json
63
- @config.apply_auth_to(req)
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
- Net::HTTP.start(chat_uri.hostname, chat_uri.port,
68
- **@config.http_connection_options(chat_uri)) do |h|
69
- h.request(req) do |res|
70
- handle_http_error(res, requested_model: target_model) unless res.is_a?(Net::HTTPSuccess)
71
-
72
- response_data = if stream_enabled
73
- ChatStreamProcessor.new(hooks).call(res)
74
- else
75
- JSON.parse(res.body)
76
- end
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 #{@config.timeout}s"
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: "/api/chat", model: target_model)
102
+ endpoint: chat_uri.path, model: target_model)
92
103
 
93
- Response.new(response_data)
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/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/ParameterLists, Metrics/AbcSize
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
- def initialize(hooks)
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
- handle_event(JSON.parse(line))
45
- rescue JSON::ParserError
46
- nil
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 obj["error"]
61
- error = StreamError.new(obj["error"])
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["message"]
67
- content = obj["message"]["content"]
68
- thinking = obj["message"]["thinking"]
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
- if thinking && !thinking.empty?
71
- start_thought_block unless @thinking_started
72
- @full_thinking << thinking
73
- @hooks[:on_thought]&.call(StreamEvent.new(type: :thought_delta, data: thinking))
74
- end
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
- if content && !content.empty?
77
- end_thought_block_if_needed
78
- @full_content << content
79
- emit_token(content, obj["logprobs"])
80
- end
92
+ def process_message_field(obj)
93
+ msg = obj["message"]
94
+ content = msg["content"]
95
+ thinking = msg["thinking"]
81
96
 
82
- @full_logprobs.concat(obj["logprobs"]) if obj["logprobs"]
83
- end
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
- return unless obj["done"]
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