rllama 1.1.0-x86_64-linux-gnu → 1.2.0-x86_64-linux-gnu

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +18 -0
  3. data/README.md +100 -8
  4. data/lib/rllama/common.rb +134 -0
  5. data/lib/rllama/context.rb +220 -51
  6. data/lib/rllama/cpp.rb +8 -10
  7. data/lib/rllama/model.rb +56 -58
  8. data/lib/rllama/version.rb +1 -1
  9. data/lib/rllama/x86_64-linux/libggml-base.so +1 -0
  10. data/lib/rllama/x86_64-linux/libggml-base.so.0 +1 -0
  11. data/lib/rllama/x86_64-linux/libggml-base.so.0.17.0 +0 -0
  12. data/lib/rllama/x86_64-linux/libggml-cpu-alderlake.so +0 -0
  13. data/lib/rllama/x86_64-linux/libggml-cpu-cannonlake.so +0 -0
  14. data/lib/rllama/x86_64-linux/libggml-cpu-cascadelake.so +0 -0
  15. data/lib/rllama/x86_64-linux/libggml-cpu-cooperlake.so +0 -0
  16. data/lib/rllama/x86_64-linux/libggml-cpu-haswell.so +0 -0
  17. data/lib/rllama/x86_64-linux/libggml-cpu-icelake.so +0 -0
  18. data/lib/rllama/x86_64-linux/libggml-cpu-ivybridge.so +0 -0
  19. data/lib/rllama/x86_64-linux/libggml-cpu-piledriver.so +0 -0
  20. data/lib/rllama/x86_64-linux/libggml-cpu-sandybridge.so +0 -0
  21. data/lib/rllama/x86_64-linux/libggml-cpu-sapphirerapids.so +0 -0
  22. data/lib/rllama/x86_64-linux/libggml-cpu-skylakex.so +0 -0
  23. data/lib/rllama/x86_64-linux/libggml-cpu-sse42.so +0 -0
  24. data/lib/rllama/x86_64-linux/libggml-cpu-x64.so +0 -0
  25. data/lib/rllama/x86_64-linux/libggml-cpu-zen4.so +0 -0
  26. data/lib/rllama/x86_64-linux/libggml-rpc.so +0 -0
  27. data/lib/rllama/x86_64-linux/libggml.so +1 -0
  28. data/lib/rllama/x86_64-linux/libggml.so.0 +1 -0
  29. data/lib/rllama/x86_64-linux/libggml.so.0.17.0 +0 -0
  30. data/lib/rllama/x86_64-linux/libllama-common.so +1 -0
  31. data/lib/rllama/x86_64-linux/libllama-common.so.0 +1 -0
  32. data/lib/rllama/x86_64-linux/libllama-common.so.0.0.10067 +0 -0
  33. data/lib/rllama/x86_64-linux/libllama.so +1 -0
  34. data/lib/rllama/x86_64-linux/libllama.so.0 +1 -0
  35. data/lib/rllama/x86_64-linux/libllama.so.0.0.10067 +0 -0
  36. data/lib/rllama/x86_64-linux/libllama_common.so +0 -0
  37. data/lib/rllama.rb +2 -1
  38. data/licenses/LICENSE-boringssl +238 -0
  39. data/licenses/LICENSE-cpp-httplib +22 -0
  40. data/licenses/LICENSE-json +21 -0
  41. data/licenses/LICENSE-llama.cpp +21 -0
  42. metadata +18 -2
  43. data/lib/rllama/x86_64-linux/libggml-base.so +0 -0
  44. data/lib/rllama/x86_64-linux/libggml.so +0 -0
  45. data/lib/rllama/x86_64-linux/libllama.so +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1f90defedb89874f6c356bc57e33f78122eb12ab3581161ec2a585a1de354fa1
4
- data.tar.gz: 02d4fd8601f8624efb6a80f122574b40fd83706158f4656a20f85d369b79248c
3
+ metadata.gz: 592b171a8e0aefe8eeda202d06f0c63f79cd6e23ac32e826e31aedc53a399dfa
4
+ data.tar.gz: 65ea9118cd98b74cd92c1692c11354dbdd68201c594e293356535235962739a7
5
5
  SHA512:
6
- metadata.gz: 53440e4b7a6f411c15cfe9d97931d3ef8278d671a68208266b28dd37203a586bb8c19d07d4144127a053a0b14bd684fc9c537d9b1d75cc42c7c04135662be1cc
7
- data.tar.gz: d99fdf22266da73a99b8e299520e14ce9a78a1057da9dd158df3dc28b90609a24352ec6cb67362cf8c1d3e05a208304188cfbda62b2660784c5b41de30bb7a37
6
+ metadata.gz: 92a75c7a2528c42b3a1457e26b969cae6ee5e14b524d6c9c796f5265dea4e749aebc36327d0c55ef0e4d315d2c1ccb3fda353e020e2ace76ea41ed7175371a35
7
+ data.tar.gz: 37e02906be2644ee0fe024f78e89f1882d86cd66cf958b710b4798aae1bf99f182810fb16dd86ca950a1516a638d787cdf0f7eb256a4c44d7803bb75e6f856a6
data/LICENSE ADDED
@@ -0,0 +1,18 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DocuSeal, LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
6
+ associated documentation files (the "Software"), to deal in the Software without restriction, including
7
+ without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
9
+ following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or substantial
12
+ portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
15
+ LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
16
+ EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
18
+ USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md CHANGED
@@ -24,12 +24,6 @@ Or install it yourself as:
24
24
  gem install rllama
25
25
  ```
26
26
 
27
- ### Troubleshooting
28
-
29
- #### `llama_model_load_from_file_impl: no backends are loaded`
30
-
31
- If you're running on an Intel (x86_64) CPU and encounter this error while loading a model, make sure you're using the latest version of the gem. Rllama now preloads the bundled GGML backend libraries and automatically augments the `GGML_BACKEND_PATH`, so upgrading resolves the issue without any manual steps. If you build from source, ensure that directory is included in `GGML_BACKEND_PATH` before booting your Ruby process.
32
-
33
27
  ## CLI Chat
34
28
 
35
29
  The `rllama` command-line utility provides an interactive chat interface for conversing with language models. After installing the gem, you can start chatting immediately:
@@ -93,7 +87,14 @@ model.close
93
87
 
94
88
  #### Generation parameters
95
89
 
96
- Adjust the generation with parameters:
90
+ By default the sampling parameters are taken from the model's own
91
+ `general.sampling.*` GGUF metadata when present (falling back to llama.cpp's
92
+ standard defaults: `temperature: 0.8`, `top_k: 40`, `top_p: 0.95`,
93
+ `min_p: 0.05`). You can inspect the resolved values with
94
+ `model.sampling_defaults`, or read any metadata key directly with
95
+ `model.meta('general.sampling.temp')`.
96
+
97
+ Pass any parameter explicitly to override the model default:
97
98
 
98
99
  ```ruby
99
100
  result = model.generate(
@@ -106,6 +107,9 @@ result = model.generate(
106
107
  )
107
108
  ```
108
109
 
110
+ The parameters actually used are reported back in `result.stats`
111
+ (`temperature`, `top_k`, `top_p`, `min_p`, `seed`).
112
+
109
113
  #### Streaming generation
110
114
 
111
115
  Stream generated text token-by-token:
@@ -141,6 +145,95 @@ result = model.generate([
141
145
  puts result.text
142
146
  ```
143
147
 
148
+ #### Tool definitions
149
+
150
+ Pass tool definitions to include them in the model's prompt for function calling:
151
+
152
+ ```ruby
153
+ tools = [
154
+ {
155
+ type: 'function',
156
+ function: {
157
+ name: 'get_weather',
158
+ description: 'Get the current weather for a location',
159
+ parameters: {
160
+ type: 'object',
161
+ properties: {
162
+ location: { type: 'string', description: 'City name' }
163
+ },
164
+ required: ['location']
165
+ }
166
+ }
167
+ }
168
+ ]
169
+
170
+ result = model.generate('What is the weather in Paris?', tools:)
171
+ puts result.text
172
+ ```
173
+
174
+ #### Tool call results
175
+
176
+ Messages are passed to the model's chat template in the OpenAI format, so tool
177
+ calls and their results are wired through the standard message schema: the
178
+ assistant message carries a structured `tool_calls:` array, and the tool output
179
+ is sent back as a `role: 'tool'` message.
180
+
181
+ Pass `tools:` (and optionally `system:`) once when creating the context — they
182
+ are remembered for all subsequent turns. When the model emits a tool call, it
183
+ is parsed into `result.tool_calls` and stored in the conversation history in
184
+ structured form automatically. Execute the calls and send each result back as
185
+ a `role: 'tool'` message:
186
+
187
+ ```ruby
188
+ context = model.init_context(tools:)
189
+
190
+ result = context.generate('What is the weather in Paris?')
191
+ result.tool_calls
192
+ # => [{ name: 'get_weather', arguments: { 'location' => 'Paris' }, id: 'call_1' }]
193
+
194
+ result.tool_calls.each do |call|
195
+ weather = get_weather(call[:arguments]['location'])
196
+
197
+ result = context.generate(
198
+ { role: 'tool', tool_call_id: call[:id], name: call[:name], content: weather.to_json }
199
+ )
200
+ end
201
+
202
+ puts result.text
203
+ # => "The weather in Paris right now is 21°C."
204
+ ```
205
+
206
+ The tool call markup is parsed by llama.cpp's chat parser, which derives the
207
+ format automatically from the model's own chat template — any model with a
208
+ tool-capable template is supported. If no tool call is detected,
209
+ `result.tool_calls` is empty and `result.text` contains the raw output.
210
+
211
+ The context detects the edited history and automatically rolls the KV cache
212
+ back to the common prefix, so only the changed suffix is re-processed.
213
+
214
+ ### Reasoning
215
+
216
+ For models with reasoning support, pass `reasoning: true` to
217
+ enable the model's reasoning phase. The reasoning block is parsed out of the
218
+ output: `result.text` contains only the final answer and the reasoning is
219
+ available separately:
220
+
221
+ ```ruby
222
+ result = model.generate('Why is the sky blue?', reasoning: true)
223
+
224
+ result.reasoning
225
+ # => "Thinking Process:\n\n1. Analyze the request..."
226
+
227
+ result.text
228
+ # => "The sky appears blue because..."
229
+ ```
230
+
231
+ Like `tools:`, the flag can also be set once per context via
232
+ `model.init_context(reasoning: true)`. It defaults to off; `result.reasoning`
233
+ is `nil` when reasoning is disabled or the model produced no reasoning block.
234
+ Note that when streaming with a block, the raw reasoning tokens are yielded
235
+ as they are generated.
236
+
144
237
  ### Chat
145
238
 
146
239
  For ongoing conversations, use a context object that maintains the conversation history:
@@ -213,7 +306,6 @@ You can download GGUF format models from various sources:
213
306
 
214
307
  - [Hugging Face](https://huggingface.co/models?library=gguf) - Search for models with "GGUF" format
215
308
 
216
-
217
309
  ## License
218
310
 
219
311
  MIT
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ffi'
4
+ require 'json'
5
+
6
+ module Rllama
7
+ module Common
8
+ extend FFI::Library
9
+
10
+ REASONING_FORMAT_AUTO = 1
11
+
12
+ lib_file =
13
+ case FFI::Platform::OS
14
+ when 'darwin'
15
+ 'libllama_common.dylib'
16
+ when 'windows', 'mingw32'
17
+ 'llama_common.dll'
18
+ else
19
+ 'libllama_common.so'
20
+ end
21
+
22
+ ffi_lib File.join(Cpp::PLATFORM_DIR, lib_file)
23
+
24
+ attach_function :rlc_free, [:pointer], :void
25
+ attach_function :rlc_chat_templates_init, %i[pointer string string string pointer], :pointer
26
+ attach_function :rlc_chat_templates_free, [:pointer], :void
27
+ attach_function :rlc_chat_apply, %i[pointer string pointer], :pointer
28
+ attach_function :rlc_chat_parse, %i[string bool string pointer], :pointer
29
+ attach_function :rlc_sampler_init, %i[pointer string pointer], :pointer
30
+ attach_function :rlc_sampler_free, [:pointer], :void
31
+ attach_function :rlc_sampler_sample, %i[pointer pointer int32], :int32
32
+ attach_function :rlc_sampler_accept, %i[pointer int32 bool], :void
33
+ attach_function :rlc_sampler_reset, [:pointer], :void
34
+ attach_function :rlc_sampler_print, [:pointer], :pointer
35
+
36
+ module_function
37
+
38
+ def chat_templates_init(model, source, bos_token, eos_token)
39
+ with_err do |err|
40
+ handle = rlc_chat_templates_init(model, source, bos_token, eos_token, err)
41
+
42
+ raise Error, "Failed to parse chat template: #{read_err(err)}" if handle.null?
43
+
44
+ handle
45
+ end
46
+ end
47
+
48
+ def chat_templates_free(handle)
49
+ rlc_chat_templates_free(handle)
50
+ end
51
+
52
+ def chat_apply(handle, messages:, tools: nil, add_generation_prompt: true, enable_thinking: false,
53
+ add_bos: false, add_eos: false)
54
+ inputs = { messages:, tools:, add_generation_prompt:, enable_thinking:, add_bos:, add_eos:,
55
+ reasoning_format: enable_thinking ? REASONING_FORMAT_AUTO : 0 }.to_json
56
+
57
+ with_err do |err|
58
+ result = rlc_chat_apply(handle, inputs, err)
59
+
60
+ raise Error, "Failed to apply chat template: #{read_err(err)}" if result.null?
61
+
62
+ JSON.parse(take(result))
63
+ end
64
+ end
65
+
66
+ def chat_parse(text, params, is_partial: false, reasoning: false)
67
+ parse_params = params.slice('format', 'parser', 'generation_prompt')
68
+ parse_params['reasoning_format'] = REASONING_FORMAT_AUTO if reasoning
69
+ params_json = parse_params.to_json
70
+
71
+ with_err do |err|
72
+ result = rlc_chat_parse(text, is_partial, params_json, err)
73
+
74
+ raise Error, "Failed to parse output: #{read_err(err)}" if result.null?
75
+
76
+ JSON.parse(take(result))
77
+ end
78
+ end
79
+
80
+ def sampler_init(model, params)
81
+ with_err do |err|
82
+ handle = rlc_sampler_init(model, params.to_json, err)
83
+
84
+ raise Error, "Failed to initialize sampler: #{read_err(err)}" if handle.null?
85
+
86
+ handle
87
+ end
88
+ end
89
+
90
+ def sampler_free(handle)
91
+ rlc_sampler_free(handle)
92
+ end
93
+
94
+ def sampler_sample(handle, ctx, idx = -1)
95
+ rlc_sampler_sample(handle, ctx, idx)
96
+ end
97
+
98
+ def sampler_accept(handle, token, accept_grammar: true)
99
+ rlc_sampler_accept(handle, token, accept_grammar)
100
+ end
101
+
102
+ def sampler_reset(handle)
103
+ rlc_sampler_reset(handle)
104
+ end
105
+
106
+ def sampler_print(handle)
107
+ take(rlc_sampler_print(handle))
108
+ end
109
+
110
+ def with_err
111
+ yield FFI::MemoryPointer.new(:pointer)
112
+ end
113
+
114
+ def take(ptr)
115
+ str = ptr.read_string
116
+
117
+ rlc_free(ptr)
118
+
119
+ str
120
+ end
121
+
122
+ def read_err(err)
123
+ msg_ptr = err.read_pointer
124
+
125
+ return 'unknown error' if msg_ptr.null?
126
+
127
+ msg = msg_ptr.read_string
128
+
129
+ rlc_free(msg_ptr)
130
+
131
+ msg
132
+ end
133
+ end
134
+ end
@@ -1,13 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'etc'
4
+ require 'json'
4
5
 
5
6
  module Rllama
6
7
  class Context
8
+ TOOL_CALL_ID_PREFIX = 'call_'
9
+ GRAMMAR_TYPE_TOOL_CALLS = 3
10
+
7
11
  attr_reader :messages, :n_ctx, :n_batch, :n_past
8
12
 
9
- def initialize(model, embeddings: false, n_ctx: nil, n_batch: nil, n_threads: Etc.nprocessors)
13
+ def initialize(model, embeddings: false, n_ctx: nil, n_batch: nil, n_threads: Etc.nprocessors,
14
+ system: nil, tools: nil, reasoning: false)
10
15
  @model = model
16
+ @tools = tools
17
+ @reasoning = reasoning
11
18
  @n_ctx = n_ctx
12
19
  @n_batch = n_batch
13
20
  @embeddings = embeddings
@@ -38,13 +45,32 @@ module Rllama
38
45
 
39
46
  raise Error, 'Failed to create the llama_context' if @pointer.null?
40
47
 
48
+ @n_ctx = Cpp.llama_n_ctx(@pointer)
49
+ @n_batch = Cpp.llama_n_batch(@pointer)
50
+
41
51
  @n_past = 0
52
+ @cache_tokens = []
53
+ @tool_call_count = 0
42
54
  @messages = []
55
+ @messages << { role: 'system', content: system } if system
56
+
57
+ prefill if !@embeddings && (system || tools)
43
58
  end
44
59
 
45
- def generate(message, role: 'user', max_tokens: @n_ctx, temperature: 0.8, top_k: 40, top_p: 0.95, min_p: 0.05,
46
- seed: nil, system: nil)
47
- @messages << { role: 'system', content: system } if system && @messages.empty?
60
+ def generate(message, role: 'user', max_tokens: @n_ctx, temperature: nil, top_k: nil, top_p: nil, min_p: nil,
61
+ seed: nil, system: nil, tools: nil, reasoning: nil, &block)
62
+ temperature, top_k, top_p, min_p = resolve_sampling(temperature, top_k, top_p, min_p)
63
+
64
+ @tools = tools unless tools.nil?
65
+ @reasoning = reasoning unless reasoning.nil?
66
+
67
+ if system
68
+ if @messages.dig(0, :role).to_s == 'system'
69
+ @messages.first[:content] = system
70
+ else
71
+ @messages.unshift(role: 'system', content: system)
72
+ end
73
+ end
48
74
 
49
75
  if message.is_a?(Array)
50
76
  @messages.push(*message)
@@ -54,51 +80,19 @@ module Rllama
54
80
  @messages << { role: role, content: message }
55
81
  end
56
82
 
57
- prompt_string = @model.build_chat_template(@messages)
58
-
59
- n_prompt_tokens = -Cpp.llama_tokenize(@model.vocab, prompt_string, prompt_string.bytesize, nil, 0, true, true)
60
-
61
- raise Error, 'Prompt is too long.' if n_prompt_tokens.negative?
62
-
63
- prompt_tokens_ptr = FFI::MemoryPointer.new(:int32, n_prompt_tokens)
64
- tokens_written = Cpp.llama_tokenize(@model.vocab, prompt_string, prompt_string.bytesize, prompt_tokens_ptr,
65
- n_prompt_tokens, true, true)
66
-
67
- raise Error, 'Failed to tokenize prompt.' if tokens_written.negative?
68
-
69
- new_token_count = tokens_written - @n_past
70
-
71
- if new_token_count.positive?
72
- new_tokens_ptr = prompt_tokens_ptr + (@n_past * FFI.type_size(:int32))
73
-
74
- batch = Cpp.llama_batch_get_one(new_tokens_ptr, new_token_count)
75
-
76
- raise Error, 'llama_decode failed.' if Cpp.llama_decode(@pointer, batch) != 0
77
-
78
- @n_past = tokens_written
79
- end
80
-
81
- chain_params = Cpp.llama_sampler_chain_default_params
82
- sampler_chain = Cpp.llama_sampler_chain_init(chain_params)
83
+ applied = @model.apply_chat_template(@messages, tools: @tools, enable_thinking: @reasoning)
83
84
 
84
- Cpp.llama_sampler_chain_add(sampler_chain, Cpp.llama_sampler_init_min_p(min_p, 1)) if min_p
85
- Cpp.llama_sampler_chain_add(sampler_chain, Cpp.llama_sampler_init_top_k(top_k)) if top_k&.positive?
86
- Cpp.llama_sampler_chain_add(sampler_chain, Cpp.llama_sampler_init_top_p(top_p, 1)) if top_p && top_p < 1.0
87
- if temperature&.positive?
88
- Cpp.llama_sampler_chain_add(sampler_chain,
89
- Cpp.llama_sampler_init_temp(temperature))
90
- end
85
+ decode_prompt(applied['prompt'])
91
86
 
92
- is_probabilistic = temperature&.positive? || top_k&.positive? || (top_p && top_p < 1.0) || !min_p.nil?
93
87
  rng_seed = seed || (Random.new_seed & 0xFFFFFFFF)
94
88
 
95
- if is_probabilistic
96
- Cpp.llama_sampler_chain_add(sampler_chain, Cpp.llama_sampler_init_dist(rng_seed))
97
- else
98
- Cpp.llama_sampler_chain_add(sampler_chain, Cpp.llama_sampler_init_greedy)
99
- end
89
+ sampler = Common.sampler_init(@model.pointer, sampler_params(temperature, top_k, top_p, min_p, rng_seed, applied))
90
+
91
+ stops = applied['additional_stops'].to_a.map(&:b)
92
+ max_stop = stops.map(&:bytesize).max || 0
100
93
 
101
94
  n_decoded = 0
95
+ n_yielded = 0
102
96
 
103
97
  generated_text = ''.b
104
98
 
@@ -111,7 +105,9 @@ module Rllama
111
105
  loop do
112
106
  break if n_decoded >= max_tokens
113
107
 
114
- new_token_id = Cpp.llama_sampler_sample(sampler_chain, @pointer, -1)
108
+ new_token_id = Common.sampler_sample(sampler, @pointer)
109
+
110
+ Common.sampler_accept(sampler, new_token_id)
115
111
 
116
112
  break if Cpp.llama_vocab_is_eog(@model.vocab, new_token_id)
117
113
 
@@ -119,10 +115,10 @@ module Rllama
119
115
  n_chars = Cpp.llama_token_to_piece(@model.vocab, new_token_id, buffer, buffer.size, 0, true)
120
116
 
121
117
  if n_chars >= 0
122
- piece_bytes = buffer.read_string(n_chars)
123
- utf8_piece = piece_bytes.force_encoding(Encoding::UTF_8)
124
- generated_text << utf8_piece
125
- yield utf8_piece if block_given?
118
+ stop_at, n_yielded = emit_piece(generated_text, buffer.read_string(n_chars), stops, max_stop, n_yielded,
119
+ &block)
120
+
121
+ break if stop_at
126
122
  end
127
123
 
128
124
  token_ptr = FFI::MemoryPointer.new(:int32, 1).put_int32(0, new_token_id)
@@ -132,24 +128,54 @@ module Rllama
132
128
  raise Error, 'llama_decode failed.' if Cpp.llama_decode(@pointer, batch) != 0
133
129
 
134
130
  @n_past += 1
131
+ @cache_tokens << new_token_id
135
132
  n_decoded += 1
136
133
  end
137
134
 
135
+ if block_given? && n_yielded < generated_text.bytesize
136
+ yield generated_text.byteslice(n_yielded..).force_encoding(Encoding::UTF_8)
137
+ end
138
+
139
+ generated_text.force_encoding(Encoding::UTF_8)
140
+
138
141
  end_time = Time.now
139
142
 
140
143
  duration = end_time - start_time
141
144
 
142
145
  tps = n_decoded.positive? && duration.positive? ? n_decoded / duration : 0
143
146
 
144
- Cpp.llama_sampler_free(sampler_chain)
147
+ Common.sampler_free(sampler)
148
+
149
+ text = generated_text
150
+ reasoning = nil
151
+ tool_calls = []
152
+
153
+ if @tools || @reasoning
154
+ parsed = Common.chat_parse(generated_text, applied, reasoning: @reasoning)
155
+
156
+ reasoning = parsed['reasoning_content']
157
+ reasoning = nil if reasoning&.empty?
158
+ tool_calls = extract_tool_calls(parsed, assistant_message) if @tools
159
+
160
+ if @reasoning && tool_calls.empty?
161
+ text = parsed['content'].to_s
162
+ assistant_message[:content] = text
163
+ end
164
+ end
145
165
 
146
166
  Result.new(
147
- text: generated_text,
167
+ text:,
168
+ reasoning:,
169
+ tool_calls:,
148
170
  stats: {
149
171
  duration:,
150
172
  tokens_generated: n_decoded,
151
173
  tps:,
152
- seed: rng_seed
174
+ seed: rng_seed,
175
+ temperature:,
176
+ top_k:,
177
+ top_p:,
178
+ min_p:
153
179
  }
154
180
  )
155
181
  end
@@ -248,6 +274,149 @@ module Rllama
248
274
  Cpp.llama_free(@pointer)
249
275
  end
250
276
 
277
+ def prefill
278
+ decode_prompt(@model.build_chat_template(@messages, tools: @tools, add_generation_prompt: false,
279
+ enable_thinking: @reasoning))
280
+ rescue StandardError
281
+ nil
282
+ end
283
+
284
+ def extract_tool_calls(parsed, assistant_message)
285
+ calls = (parsed['tool_calls'] || []).map do |call|
286
+ id = call['id'].to_s
287
+ id = "#{TOOL_CALL_ID_PREFIX}#{@tool_call_count += 1}" if id.empty?
288
+
289
+ arguments = begin
290
+ JSON.parse(call.dig('function', 'arguments').to_s)
291
+ rescue JSON::ParserError
292
+ {}
293
+ end
294
+
295
+ { name: call.dig('function', 'name'), arguments:, id: }
296
+ end
297
+
298
+ return [] if calls.empty?
299
+
300
+ assistant_message.replace(
301
+ role: 'assistant',
302
+ content: nil,
303
+ tool_calls: calls.map do |call|
304
+ { type: 'function', id: call[:id],
305
+ function: { name: call[:name], arguments: call[:arguments].to_json } }
306
+ end
307
+ )
308
+
309
+ calls
310
+ end
311
+
312
+ def decode_prompt(prompt_string)
313
+ n_prompt_tokens = -Cpp.llama_tokenize(@model.vocab, prompt_string, prompt_string.bytesize, nil, 0, true, true)
314
+
315
+ raise Error, 'Prompt is too long.' if n_prompt_tokens.negative?
316
+
317
+ prompt_tokens_ptr = FFI::MemoryPointer.new(:int32, n_prompt_tokens)
318
+ tokens_written = Cpp.llama_tokenize(@model.vocab, prompt_string, prompt_string.bytesize, prompt_tokens_ptr,
319
+ n_prompt_tokens, true, true)
320
+
321
+ raise Error, 'Failed to tokenize prompt.' if tokens_written.negative?
322
+
323
+ prompt_tokens = prompt_tokens_ptr.read_array_of_int32(tokens_written)
324
+
325
+ common = common_prefix_length(prompt_tokens)
326
+
327
+ if common < @cache_tokens.length
328
+ memory = Cpp.llama_get_memory(@pointer)
329
+
330
+ if memory.null? || !Cpp.llama_memory_seq_rm(memory, 0, common, -1)
331
+ Cpp.llama_memory_clear(memory, true) unless memory.null?
332
+
333
+ common = 0
334
+ end
335
+
336
+ @n_past = common
337
+ end
338
+
339
+ while tokens_written > @n_past
340
+ n_eval = [tokens_written - @n_past, @n_batch].min
341
+
342
+ new_tokens_ptr = prompt_tokens_ptr + (@n_past * FFI.type_size(:int32))
343
+
344
+ batch = Cpp.llama_batch_get_one(new_tokens_ptr, n_eval)
345
+
346
+ raise Error, 'llama_decode failed.' if Cpp.llama_decode(@pointer, batch) != 0
347
+
348
+ @n_past += n_eval
349
+ end
350
+
351
+ @cache_tokens = prompt_tokens
352
+ end
353
+
354
+ def common_prefix_length(tokens)
355
+ limit = [@cache_tokens.length, tokens.length - 1].min
356
+
357
+ common = 0
358
+ common += 1 while common < limit && @cache_tokens[common] == tokens[common]
359
+
360
+ common
361
+ end
362
+
363
+ def sampler_params(temperature, top_k, top_p, min_p, seed, applied)
364
+ params = {
365
+ temp: temperature || 0.0,
366
+ top_k: top_k&.positive? ? top_k : 0,
367
+ top_p: top_p || 1.0,
368
+ min_p: min_p || 0.0,
369
+ seed:
370
+ }
371
+
372
+ grammar = applied['grammar'].to_s
373
+
374
+ unless grammar.empty?
375
+ params.merge!(grammar:, grammar_type: GRAMMAR_TYPE_TOOL_CALLS,
376
+ grammar_lazy: applied['grammar_lazy'],
377
+ grammar_triggers: applied['grammar_triggers'],
378
+ generation_prompt: applied['generation_prompt'])
379
+ end
380
+
381
+ params
382
+ end
383
+
384
+ def emit_piece(text, piece, stops, max_stop, n_yielded, &block)
385
+ tail_from = [text.bytesize - max_stop + 1, 0].max
386
+ text << piece
387
+
388
+ stop_at = max_stop.positive? ? stops.filter_map { |stop| text.index(stop, tail_from) }.min : nil
389
+ text.slice!(stop_at..) if stop_at
390
+
391
+ if block
392
+ safe = text.bytesize - (stop_at ? 0 : stop_holdback(text, stops))
393
+
394
+ if safe > n_yielded
395
+ yield(text.byteslice(n_yielded, safe - n_yielded).force_encoding(Encoding::UTF_8))
396
+ n_yielded = safe
397
+ end
398
+ end
399
+
400
+ [stop_at, n_yielded]
401
+ end
402
+
403
+ def stop_holdback(text, stops)
404
+ stops.map do |stop|
405
+ (stop.bytesize - 1).downto(1).find { |n| text.end_with?(stop.byteslice(0, n)) } || 0
406
+ end.max || 0
407
+ end
408
+
409
+ def resolve_sampling(temperature, top_k, top_p, min_p)
410
+ defaults = @model.sampling_defaults
411
+
412
+ [
413
+ temperature.nil? ? defaults[:temperature] : temperature,
414
+ top_k.nil? ? defaults[:top_k] : top_k,
415
+ top_p.nil? ? defaults[:top_p] : top_p,
416
+ min_p.nil? ? defaults[:min_p] : min_p
417
+ ]
418
+ end
419
+
251
420
  def norm(vec)
252
421
  Math.sqrt(vec.sum { |x| x**2 })
253
422
  end