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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +51 -0
- data/README.md +132 -0
- data/lib/fast/completions/cancellation.rb +22 -0
- data/lib/fast/completions/chat.rb +257 -0
- data/lib/fast/completions/client.rb +193 -0
- data/lib/fast/completions/codec.rb +335 -0
- data/lib/fast/completions/dialects.rb +170 -0
- data/lib/fast/completions/errors.rb +149 -0
- data/lib/fast/completions/protocol.rb +476 -0
- data/lib/fast/completions/sse.rb +84 -0
- data/lib/fast/completions/tool.rb +50 -0
- data/lib/fast/completions/types.rb +277 -0
- data/lib/fast/completions/version.rb +7 -0
- data/lib/fast/completions.rb +39 -0
- metadata +74 -0
|
@@ -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,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: []
|