fast-completions 0.2.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.
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Fast
6
+ module Completions
7
+ # Frozen value objects (Specification §5). Public factory methods
8
+ # freeze; the stream accumulator (§3 `partial`, pi's shared live
9
+ # AssistantMessage) builds its Message via .new and mutates it in
10
+ # place — that instance is the one sanctioned mutable Message.
11
+
12
+ # The consumer-owned model metadata. `provider` is metadata ONLY —
13
+ # it never reaches the wire; it drives same-model replay comparison
14
+ # (Specification §12.3 note, §9.4).
15
+ class Model
16
+ # pi models.ts :1222 EXTENDED_THINKING_LEVELS, restricted to the
17
+ # frozen Specification §9.3 ladder. (pi also carries :minimal;
18
+ # the frozen ladder omits it — documented in the 0.1.0 notes.)
19
+ LADDER = %i[off low medium high xhigh max].freeze
20
+
21
+ attr_reader :id, :reasoning, :context_window, :max_tokens,
22
+ :thinking_level_map, :provider
23
+
24
+ def initialize(id:, reasoning:, context_window:, max_tokens:,
25
+ thinking_level_map: nil, dialect: nil, provider: "fast-completions")
26
+ raise ArgumentError, "id is required" unless id.is_a?(String) && !id.empty?
27
+ raise ArgumentError, "reasoning is required (boolean)" unless reasoning == true || reasoning == false
28
+ raise ArgumentError, "context_window is required (Integer)" unless context_window.is_a?(Integer)
29
+ raise ArgumentError, "max_tokens is required (Integer)" unless max_tokens.is_a?(Integer)
30
+ if thinking_level_map && !thinking_level_map.is_a?(Hash)
31
+ raise ArgumentError, "thinking_level_map must be a Hash"
32
+ end
33
+
34
+ @id = id.dup.freeze
35
+ @reasoning = reasoning
36
+ @context_window = context_window
37
+ @max_tokens = max_tokens
38
+ @thinking_level_map = normalize_map(thinking_level_map)
39
+ @dialect = dialect # Symbol resolves through the registry lazily (last declaration wins).
40
+ @provider = provider.dup.freeze
41
+ freeze
42
+ end
43
+
44
+ # The dialect for THIS model: an explicit Dialect, the registered
45
+ # declaration for the symbol, or the plain default arm.
46
+ def dialect
47
+ case @dialect
48
+ when Dialect then @dialect
49
+ when Symbol then Dialects.fetch(@dialect)
50
+ else Dialects.default
51
+ end
52
+ end
53
+
54
+ # pi getSupportedThinkingLevels models.ts :1224-1232: :off when
55
+ # non-reasoning; else the ladder filtered — a level is supported
56
+ # when the map omits it or maps it non-nil; :xhigh/:max only when
57
+ # the map names them.
58
+ def supported_thinking_levels
59
+ return %i[off].freeze unless reasoning
60
+
61
+ LADDER.filter do |level|
62
+ named = @thinking_level_map&.key?(level)
63
+ mapped = named ? @thinking_level_map[level] : nil
64
+ if level == :xhigh || level == :max
65
+ named && !mapped.nil?
66
+ else
67
+ !named || !mapped.nil? # omitted, or mapped non-nil
68
+ end
69
+ end.freeze
70
+ end
71
+
72
+ # pi clampThinkingLevel models.ts :1235-1256: supported levels
73
+ # pass through; an unknown level snaps to the first supported;
74
+ # otherwise walk UP from the requested rung, then DOWN, else the
75
+ # first supported.
76
+ def clamp_thinking_level(level)
77
+ available = supported_thinking_levels
78
+ return level if available.include?(level)
79
+
80
+ requested_index = LADDER.index(level)
81
+ return available.first if requested_index.nil?
82
+
83
+ LADDER.each_index do |i|
84
+ next if i < requested_index
85
+
86
+ candidate = LADDER[i]
87
+ return candidate if available.include?(candidate)
88
+ end
89
+ requested_index.downto(0) do |i|
90
+ candidate = LADDER[i]
91
+ return candidate if available.include?(candidate)
92
+ end
93
+ available.first
94
+ end
95
+
96
+ private
97
+
98
+ # Symbol-keyed, defensively dup'd — consumers pass {off: "none", ...}.
99
+ def normalize_map(map)
100
+ return nil if map.nil?
101
+
102
+ map.each_with_object({}) { |(k, v), out| out[k.to_sym] = v.dup }.freeze
103
+ end
104
+ end
105
+
106
+ # Token accounting (§10.1, pi parseChunkUsage openai-completions.ts
107
+ # :1519-1547). total_tokens is NATIVE (OQ-9): the parser computes it
108
+ # pi's way (input+output+cacheRead+cacheWrite), never re-summed later.
109
+ class Usage
110
+ attr_reader :input, :output, :cache_read, :cache_write, :reasoning, :total_tokens
111
+
112
+ def initialize(input: 0, output: 0, cache_read: 0, cache_write: 0, reasoning: 0, total_tokens: 0)
113
+ @input = input
114
+ @output = output
115
+ @cache_read = cache_read
116
+ @cache_write = cache_write
117
+ @reasoning = reasoning
118
+ @total_tokens = total_tokens
119
+ freeze
120
+ end
121
+
122
+ def to_h
123
+ {input:, output:, cache_read:, cache_write:, reasoning:, total_tokens:}
124
+ end
125
+
126
+ def ==(other)
127
+ other.is_a?(Usage) && to_h == other.to_h
128
+ end
129
+
130
+ ZERO = new.freeze
131
+ end
132
+
133
+ # History value object (§3, §5). Roles: :system / :user / :assistant
134
+ # / :tool_result. Assistant content is an Array of block Hashes with
135
+ # symbol keys: {type: :text, text:}, {type: :thinking, thinking:,
136
+ # signature:}, {type: :tool_call, id:, name:, arguments:}.
137
+ class Message
138
+ ROLES = %i[system user assistant tool_result].freeze
139
+
140
+ attr_reader :role, :content, :provider, :model, :api, :usage,
141
+ :tool_call_id, :tool_name, :is_error, :response_id, :response_model
142
+
143
+ def initialize(role:, content:, provider: nil, model: nil, api: nil,
144
+ tool_call_id: nil, tool_name: nil, is_error: false, usage: nil)
145
+ unless ROLES.include?(role)
146
+ raise ArgumentError, "role must be one of #{ROLES.inspect}, got #{role.inspect}"
147
+ end
148
+
149
+ @role = role
150
+ @content = deep_dup(content)
151
+ @provider = provider
152
+ @model = model
153
+ @api = api
154
+ @tool_call_id = tool_call_id
155
+ @tool_name = tool_name
156
+ @is_error = is_error == true
157
+ @usage = usage
158
+ @response_id = nil
159
+ @response_model = nil
160
+ end
161
+
162
+ # Stream-internal mutation surface: the §3 shared `partial`
163
+ # accumulator is ONE live Message (pi's output object, stream()
164
+ # :312-327). These only work on unfrozen instances — factory-built
165
+ # messages are frozen and raise FrozenError if mutated.
166
+ def append_block(block)
167
+ @content << block
168
+ end
169
+
170
+ def set_usage(usage)
171
+ @usage = usage
172
+ end
173
+
174
+ def set_response_id(id) # first wins (§10)
175
+ @response_id ||= id
176
+ end
177
+
178
+ def set_response_model(name)
179
+ @response_model ||= name
180
+ end
181
+
182
+ # The public factories freeze (value semantics). The stream
183
+ # accumulator constructs via .new and mutates — §3's shared
184
+ # `partial` contract.
185
+ def self.user(content)
186
+ new(role: :user, content: content).freeze
187
+ end
188
+
189
+ def self.system(content)
190
+ new(role: :system, content: content).freeze
191
+ end
192
+
193
+ def self.assistant(content, provider: nil, model: nil, api: nil)
194
+ unless content.is_a?(Array)
195
+ raise ArgumentError, "assistant content must be an Array of block hashes"
196
+ end
197
+
198
+ new(role: :assistant, content: content, provider: provider, model: model, api: api).freeze
199
+ end
200
+
201
+ def self.tool_result(tool_call_id:, content:, tool_name: nil, is_error: false)
202
+ new(role: :tool_result, content: content, tool_call_id: tool_call_id,
203
+ tool_name: tool_name, is_error: is_error).freeze
204
+ end
205
+
206
+ def user? = role == :user
207
+ def assistant? = role == :assistant
208
+ def system? = role == :system
209
+ def tool_result? = role == :tool_result
210
+
211
+ def to_h
212
+ out = {role:, content: deep_dup(@content)}
213
+ out[:provider] = @provider if @provider
214
+ out[:model] = @model if @model
215
+ out[:api] = @api if @api
216
+ out[:tool_call_id] = @tool_call_id if @tool_call_id
217
+ out[:tool_name] = @tool_name if @tool_name
218
+ out[:is_error] = true if @is_error
219
+ out[:usage] = @usage.to_h if @usage
220
+ out[:response_id] = @response_id if @response_id
221
+ out[:response_model] = @response_model if @response_model
222
+ out
223
+ end
224
+
225
+ def self.from_h(hash)
226
+ hash = hash.transform_keys(&:to_sym)
227
+ message = new(
228
+ role: hash.fetch(:role).to_sym,
229
+ content: hash.fetch(:content),
230
+ provider: hash[:provider],
231
+ model: hash[:model],
232
+ api: hash[:api],
233
+ tool_call_id: hash[:tool_call_id],
234
+ tool_name: hash[:tool_name],
235
+ is_error: hash[:is_error] == true,
236
+ usage: hash[:usage] && Usage.new(**hash[:usage].transform_keys(&:to_sym)),
237
+ )
238
+ message.set_response_id(hash[:response_id]) if hash[:response_id]
239
+ message.set_response_model(hash[:response_model]) if hash[:response_model]
240
+ message
241
+ end
242
+
243
+ private
244
+
245
+ # Plain-Hash/Array deep dup — consumers hand us their structures;
246
+ # the gem never mutates a consumer message (§12).
247
+ def deep_dup(node)
248
+ case node
249
+ when Hash then node.each_with_object({}) { |(k, v), out| out[k] = deep_dup(v) }
250
+ when Array then node.map { |item| deep_dup(item) }
251
+ when String then node.dup
252
+ else node
253
+ end
254
+ end
255
+ end
256
+
257
+ # Stream events (§3; pi types.ts :794-816 AssistantMessageEvent
258
+ # vocabulary, Ruby symbols). Fields are per-type; unknown ones are nil.
259
+ class Event
260
+ attr_reader :type, :content_index, :delta, :content, :tool_call,
261
+ :message, :reason, :partial
262
+
263
+ def initialize(type:, content_index: nil, delta: nil, content: nil,
264
+ tool_call: nil, message: nil, reason: nil, partial: nil)
265
+ @type = type
266
+ @content_index = content_index
267
+ @delta = delta
268
+ @content = content
269
+ @tool_call = tool_call
270
+ @message = message
271
+ @reason = reason
272
+ @partial = partial
273
+ freeze
274
+ end
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fast
4
+ module Completions
5
+ VERSION = "0.2.0"
6
+ end
7
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Fast::Completions — a fiber-native client for the OpenAI-compatible
4
+ # chat-completions wire, byte-faithful to pi. One require loads the whole
5
+ # surface (§1).
6
+ require "json"
7
+
8
+ require_relative "completions/version"
9
+ require_relative "completions/errors"
10
+ require_relative "completions/types"
11
+ require_relative "completions/dialects"
12
+ require_relative "completions/sse"
13
+ require_relative "completions/codec"
14
+ require_relative "completions/protocol"
15
+ require_relative "completions/client"
16
+ require_relative "completions/tool"
17
+ require_relative "completions/cancellation"
18
+ require_relative "completions/chat"
19
+
20
+ module Fast
21
+ # The dialect DSL entry (§4): declare with a block, look up without.
22
+ # Re-declaring a name OVERRIDES — last declaration wins.
23
+ module Completions
24
+ class << self
25
+ # Declare with a block (and/or keyword overrides); look up without —
26
+ # raises KeyError if undeclared (§3). Re-declaring a name OVERRIDES
27
+ # — last declaration wins.
28
+ def dialect(name, **overrides, &block)
29
+ return Dialects.fetch(name) if block.nil? && overrides.empty?
30
+
31
+ Dialects.declare(name, **overrides, &block)
32
+ end
33
+
34
+ def dialects
35
+ Dialects.all
36
+ end
37
+ end
38
+ end
39
+ end
metadata ADDED
@@ -0,0 +1,74 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fast-completions
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Eric Jacobs
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: async-http
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.95'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.95'
26
+ description: |
27
+ Fast::Completions speaks the OpenAI-compatible chat-completions wire.
28
+ One wire protocol, dialects as data, async-http as the only runtime
29
+ dependency. Byte-faithful to pi's request/stream behavior.
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - CHANGELOG.md
35
+ - README.md
36
+ - lib/fast/completions.rb
37
+ - lib/fast/completions/cancellation.rb
38
+ - lib/fast/completions/chat.rb
39
+ - lib/fast/completions/client.rb
40
+ - lib/fast/completions/codec.rb
41
+ - lib/fast/completions/dialects.rb
42
+ - lib/fast/completions/errors.rb
43
+ - lib/fast/completions/protocol.rb
44
+ - lib/fast/completions/sse.rb
45
+ - lib/fast/completions/tool.rb
46
+ - lib/fast/completions/types.rb
47
+ - lib/fast/completions/version.rb
48
+ homepage: https://github.com/jetpks/fast-llm
49
+ licenses:
50
+ - MIT
51
+ metadata:
52
+ homepage_uri: https://github.com/jetpks/fast-llm
53
+ source_code_uri: https://github.com/jetpks/fast-llm
54
+ changelog_uri: https://github.com/jetpks/fast-llm/blob/main/CHANGELOG.md
55
+ rubygems_mfa_required: 'true'
56
+ rdoc_options: []
57
+ require_paths:
58
+ - lib
59
+ required_ruby_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '3.3'
64
+ required_rubygems_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: 3.5.3
69
+ requirements: []
70
+ rubygems_version: 4.0.20
71
+ specification_version: 4
72
+ summary: A fiber-native client for the OpenAI-compatible chat-completions wire, byte-faithful
73
+ to pi.
74
+ test_files: []