gigatoken 0.3.0 → 0.5.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.
data/lib/gigatoken/hub.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "async"
4
4
  require "async/http"
5
+ require "async/http/proxy"
5
6
  require "fileutils"
6
7
  require "pathname"
7
8
 
@@ -32,6 +33,39 @@ module Gigatoken
32
33
  MAX_REDIRECTS = 10
33
34
  private_constant :MAX_REDIRECTS
34
35
 
36
+ # Connect/read timeout in seconds, and the bound on the request phase —
37
+ # huggingface_hub's HF_HUB_ETAG_TIMEOUT / HF_HUB_DOWNLOAD_TIMEOUT
38
+ # default.
39
+ DEFAULT_TIMEOUT = 10
40
+ private_constant :DEFAULT_TIMEOUT
41
+
42
+ # A "." or ".." path segment: the traversal that must never reach a URL
43
+ # or the cache, wherever it comes from.
44
+ DOT_SEGMENT = /\A\.\.?\z/
45
+ private_constant :DOT_SEGMENT
46
+
47
+ # Everything outside RFC 3986's unreserved set, which is what
48
+ # huggingface_hub's `quote` percent-encodes.
49
+ RESERVED = /[^A-Za-z0-9\-._~]/
50
+ private_constant :RESERVED
51
+
52
+ # How a request, or the body read that follows it, fails underneath
53
+ # async-http: a connect/read timeout, a refused or reset connection, a
54
+ # proxy refusing the CONNECT tunnel, DNS resolution, a body cut short, a
55
+ # malformed endpoint, TLS. All of them reach the caller as
56
+ # Gigatoken::HubError.
57
+ TRANSPORT_ERRORS = [
58
+ Async::TimeoutError,
59
+ Async::HTTP::Proxy::ConnectFailure,
60
+ IOError,
61
+ SocketError,
62
+ SystemCallError,
63
+ URI::InvalidURIError,
64
+ Protocol::HTTP::Error,
65
+ OpenSSL::SSL::SSLError
66
+ ].freeze
67
+ private_constant :TRANSPORT_ERRORS
68
+
35
69
  class << self
36
70
  # Whether `name` is shaped like a HuggingFace Hub repo id: `org/name`,
37
71
  # or a bare legacy repo name like `gpt2`. At most one slash, and not
@@ -105,15 +139,44 @@ module Gigatoken
105
139
  env("HF_ENDPOINT") || DEFAULT_ENDPOINT
106
140
  end
107
141
 
142
+ # Whether `value` is usable as a URL path and a cache path component:
143
+ # not empty, not absolute, no NUL byte, no "." or ".." segment.
144
+ # huggingface_hub rejects the same traversals in validate_repo_id.
145
+ def safe_component?(value)
146
+ !value.empty? && !value.include?("\0") && !value.start_with?("/") &&
147
+ value.split("/", -1).none? { |segment| segment.match?(DOT_SEGMENT) }
148
+ end
149
+
150
+ # The proxy URL the environment names for a `scheme` request to
151
+ # `hostname`, or nil: http_proxy/https_proxy, lowercase spelling
152
+ # first, suppressed by a matching no_proxy entry — what requests does,
153
+ # and so what huggingface_hub inherits.
154
+ def proxy_url(scheme, hostname)
155
+ url = env("#{scheme}_proxy") || env("#{scheme.upcase}_PROXY")
156
+ url unless url.nil? || no_proxy?(hostname)
157
+ end
158
+
108
159
  private
109
160
 
161
+ # no_proxy is a comma-separated list of host suffixes, or "*" for
162
+ # everything.
163
+ def no_proxy?(hostname)
164
+ host = hostname.downcase
165
+ (env("no_proxy") || env("NO_PROXY")).to_s.split(",").any? do |entry|
166
+ entry = entry.strip.downcase.delete_prefix(".")
167
+ next true if entry == "*"
168
+
169
+ !entry.empty? && (host == entry || host.end_with?(".#{entry}"))
170
+ end
171
+ end
172
+
110
173
  def env(key)
111
174
  value = ENV[key]
112
175
  value unless value.nil? || value.empty?
113
176
  end
114
177
 
115
178
  def word_part?(part, first_alnum:)
116
- return false if part.nil? || part.empty?
179
+ return false if part.nil? || part.empty? || part.match?(DOT_SEGMENT)
117
180
 
118
181
  first_ok = first_alnum ? part[0].match?(/[A-Za-z0-9]/) : word_char?(part[0])
119
182
  first_ok && part[1..].chars.all? { |c| word_char?(c) }
@@ -133,14 +196,26 @@ module Gigatoken
133
196
  # for pointing at a local server in tests (dependency injection, not a
134
197
  # mock); defaults to Hub.default_endpoint (HF_ENDPOINT, then
135
198
  # huggingface.co).
136
- def initialize(endpoint: self.class.default_endpoint)
199
+ # @parameter timeout [Numeric] connect/read timeout in seconds, applied
200
+ # to every request and to the request phase as a whole.
201
+ def initialize(endpoint: self.class.default_endpoint, timeout: DEFAULT_TIMEOUT)
137
202
  @endpoint = endpoint.chomp("/")
138
- @internet = Async::HTTP::Internet.new
203
+ @timeout = timeout
139
204
  end
140
205
 
141
206
  # Path of `filename` from Hub repo `repo_id` at `revision`, served from
142
- # the standard HF cache, downloading into it first when absent.
207
+ # the standard HF cache, downloading into it first when absent. The
208
+ # three caller-supplied components are checked before any request or
209
+ # filesystem access: one of them carrying `..` would otherwise read and
210
+ # overwrite files outside the cache.
143
211
  def hub_file(repo_id, filename = "tokenizer.json", revision: "main")
212
+ {"repo id" => repo_id, "filename" => filename, "revision" => revision}.each do |what, value|
213
+ next if self.class.safe_component?(value)
214
+
215
+ raise HubError, "#{what} #{value.inspect}: must not be empty or absolute, " \
216
+ "or contain a NUL byte or a \".\" or \"..\" path segment"
217
+ end
218
+
144
219
  self.class.cached_file(repo_id, filename, revision) ||
145
220
  Sync { fetch(repo_id, filename, revision) }
146
221
  end
@@ -152,31 +227,127 @@ module Gigatoken
152
227
  # recording the branch ref so later lookups (ours and
153
228
  # huggingface_hub's) resolve it.
154
229
  def fetch(repo_id, filename, revision)
155
- url = "#{@endpoint}/#{repo_id}/resolve/#{revision}/#{filename}"
230
+ clients = []
231
+ url = resolve_url(repo_id, filename, revision)
156
232
  token = self.class.hf_token
157
- response = @internet.get(url, auth_headers(token))
233
+ headers = auth_headers(token)
234
+ response = get(url, headers, clients)
158
235
  # Unlisted headers parse as a Header::Generic (an Array of values);
159
236
  # x-repo-commit is always a single value, so flatten it to a String.
160
237
  commit = response.headers["x-repo-commit"]&.to_s
161
238
 
162
- # Redirects are followed by hand: resolve/ URLs answer with the
163
- # x-repo-commit header and a redirect to a CDN for LFS files, and the
164
- # Authorization header must not travel to the other host.
239
+ # Redirects are followed by hand: a resolve/ URL answers an LFS file
240
+ # with a redirect to a CDN, a renamed repo with one to its new name,
241
+ # and both headers below need a rule of their own across the hop.
165
242
  hops = 0
166
243
  while (300...400).cover?(response.status)
167
- ensure_ok!(url, response.status, !!token)
168
- hops += 1
169
- raise Error, "#{url}: too many redirects" if hops > MAX_REDIRECTS
170
-
171
- location = response.headers["location"] ||
172
- raise(Error, "#{url}: redirect with no Location header")
244
+ location = response.headers["location"]
173
245
  response.close
174
- url = absolutize(location, url)
175
- response = @internet.get(url, {"user-agent" => "gigatoken"})
246
+ raise HubError, "#{url}: redirect with no Location header" unless location
247
+ raise HubError, "#{url}: too many redirects" if (hops += 1) > MAX_REDIRECTS
248
+
249
+ target = absolutize(location, url)
250
+ # A renamed repo answers with a same-origin redirect that still needs
251
+ # the token; the LFS CDN elsewhere authenticates by signed URL and
252
+ # must never see it — huggingface_hub drops the header on the same
253
+ # rule.
254
+ headers = auth_headers(nil) unless same_origin?(target, url)
255
+ url = target
256
+ response = get(url, headers, clients)
257
+ # An LFS file carries x-repo-commit on the resolve/ hop; a renamed
258
+ # repo carries it only on the hop that finally answers 200.
259
+ commit ||= response.headers["x-repo-commit"]&.to_s
176
260
  end
177
- ensure_ok!(url, response.status, !!token)
261
+ ensure_ok!(url, response, !!token)
262
+ ensure_commit!(url, commit)
263
+
264
+ write_to_cache(repo_id, filename, revision, commit, response)
265
+ rescue *TRANSPORT_ERRORS => e
266
+ raise HubError, "#{url}: #{e.message} (#{e.class})"
267
+ ensure
268
+ close_all(response, clients)
269
+ end
178
270
 
179
- write_to_cache(repo_id, filename, revision, commit || revision, response)
271
+ # Release everything the fetch holds, in the one place every exit passes
272
+ # through. Order matters: an unread body keeps its connection checked
273
+ # out, and Async::HTTP::Client#close waits for its pool to drain, so the
274
+ # response goes first and the clients innermost-first — a tunnel client's
275
+ # connection is held by the proxy client opened under it. Bounded, and
276
+ # deaf to the ways a broken connection fails to close: a connection some
277
+ # failure left mid-stream would otherwise stall the drain, and with it
278
+ # the Sync this all runs inside, for good. A leaked socket is worth less
279
+ # than the caller's result or exception.
280
+ def close_all(response, clients)
281
+ Async::Task.current.with_timeout(@timeout) do
282
+ response&.close
283
+ clients.reverse_each(&:close)
284
+ end
285
+ rescue *TRANSPORT_ERRORS
286
+ nil
287
+ end
288
+
289
+ # `endpoint/repo/resolve/revision/filename`, percent-encoded the way
290
+ # huggingface_hub's `quote` does it: `revision` whole (`safe=""`), so
291
+ # `refs/pr/1` travels as `refs%2Fpr%2F1`, while the repo id and the
292
+ # filename keep their slashes.
293
+ def resolve_url(repo_id, filename, revision)
294
+ "#{@endpoint}/#{escape_path(repo_id)}/resolve/#{escape(revision)}/#{escape_path(filename)}"
295
+ end
296
+
297
+ def escape_path(value)
298
+ value.split("/", -1).map { |segment| escape(segment) }.join("/")
299
+ end
300
+
301
+ def escape(value)
302
+ value.gsub(RESERVED) { |char| char.bytes.map { |byte| format("%%%02X", byte) }.join }
303
+ end
304
+
305
+ # GET `url`, through the proxy the environment names for it when there
306
+ # is one: an http request travels to the proxy with the absolute URI in
307
+ # its request line, an https one through a CONNECT tunnel — how requests,
308
+ # and so huggingface_hub, routes them. The clients stay open (the body
309
+ # is still to be streamed); #fetch closes them.
310
+ def get(url, headers, clients)
311
+ endpoint = endpoint_for(url)
312
+ proxy = self.class.proxy_url(endpoint.scheme, endpoint.hostname)
313
+ proxy &&= endpoint_for(proxy)
314
+
315
+ client =
316
+ if proxy.nil?
317
+ open_client(endpoint, clients)
318
+ elsif endpoint.secure?
319
+ # https tunnels through the proxy with CONNECT, then speaks TLS to
320
+ # the origin as if it had connected to it directly.
321
+ open_client(open_client(proxy, clients).proxied_endpoint(endpoint), clients)
322
+ else
323
+ open_client(proxy, clients)
324
+ end
325
+ # An http proxy is addressed with the absolute URI in the request line;
326
+ # a direct or tunnelled request carries just the path.
327
+ target = (proxy && !endpoint.secure?) ? url : endpoint.path
328
+
329
+ request = Protocol::HTTP::Request["GET", target, headers, scheme: endpoint.scheme, authority: endpoint.authority]
330
+ # A CONNECT tunnel's socket carries no timeout of its own, so the
331
+ # request phase is bounded here whichever way it was routed; the body
332
+ # then streams under the endpoint's own connect/read timeout.
333
+ Async::Task.current.with_timeout(@timeout) { client.call(request) }
334
+ end
335
+
336
+ # A malformed URL comes back as URI::InvalidURIError, one that cannot be
337
+ # routed (no scheme or host) as ArgumentError; both mean the same thing
338
+ # to the caller, and neither is worth a backtrace.
339
+ def endpoint_for(url)
340
+ Async::HTTP::Endpoint.parse(url, timeout: @timeout)
341
+ rescue ArgumentError => e
342
+ raise HubError, "#{url}: #{e.message} (#{e.class})"
343
+ end
344
+
345
+ # A client for one hop, remembered in `clients` so #fetch can close it
346
+ # once the body is written. retries: 1 — a client built for this hop has
347
+ # no stale pooled connection for a retry to rescue, and async-http's
348
+ # default of 3 would pay @timeout three times over.
349
+ def open_client(endpoint, clients)
350
+ Async::HTTP::Client.new(endpoint, retries: 1).tap { |client| clients << client }
180
351
  end
181
352
 
182
353
  def auth_headers(token)
@@ -199,51 +370,75 @@ module Gigatoken
199
370
  rescue
200
371
  FileUtils.rm_f(tmp)
201
372
  raise
202
- ensure
203
- response.close
204
373
  end
205
374
  File.rename(tmp, target)
206
375
 
207
- if !self.class.commit_hash?(revision) && revision != commit
208
- refs_dir = repo_dir.join("refs")
209
- FileUtils.mkdir_p(refs_dir)
210
- File.write(refs_dir.join(revision), commit)
376
+ # A nested revision like refs/pr/1 is a nested ref file, so its parent
377
+ # has to exist — huggingface_hub mkdir -p's the same path.
378
+ if revision != commit
379
+ ref_path = repo_dir.join("refs", revision)
380
+ FileUtils.mkdir_p(ref_path.dirname)
381
+ File.write(ref_path, commit)
211
382
  end
212
383
 
213
384
  target
214
385
  end
215
386
 
216
- def ensure_ok!(url, status, had_token)
387
+ # Raise on a non-success status. The unread body is left to #close_all,
388
+ # which every exit from #fetch passes through: it keeps the HTTP/1.x
389
+ # connection — and the Sync around it — alive until it is closed, so the
390
+ # exception would otherwise never reach the caller.
391
+ def ensure_ok!(url, response, had_token)
392
+ status = response.status
217
393
  return if (200...400).cover?(status)
218
394
 
219
395
  case status
220
396
  when 404
221
- raise Error, "#{url}: HTTP 404 — no such repo with that file, and no such local file either"
397
+ raise HubError, "#{url}: HTTP 404 — no such repo with that file, and no such local file either"
222
398
  when 401, 403
223
399
  token_note = had_token ? "the request used the discovered token" : "no token was found"
224
- raise Error,
400
+ raise HubError,
225
401
  "#{url}: HTTP #{status} — the repo may be private or gated (#{token_note}; set HF_TOKEN or run " \
226
402
  "`hf auth login`, and accept the repo's terms on huggingface.co if it is gated)"
227
403
  else
228
- raise Error, "#{url}: HTTP #{status}"
404
+ raise HubError, "#{url}: HTTP #{status}"
229
405
  end
230
406
  end
231
407
 
408
+ # The snapshot directory is named by a server-controlled header, so only
409
+ # a real commit hash may become one: anything else would write the body
410
+ # wherever the header points. A missing header also means the endpoint
411
+ # isn't a Hub, whose snapshot would never be found again.
412
+ def ensure_commit!(url, commit)
413
+ return if self.class.commit_hash?(commit.to_s)
414
+
415
+ raise HubError, "#{url}: response is missing a usable x-repo-commit header (#{commit.inspect}) — it does not " \
416
+ "seem to be served by a HuggingFace Hub endpoint; if HF_ENDPOINT is set, check that it points to a " \
417
+ "Hub-compatible endpoint, and otherwise check your firewall and proxy settings"
418
+ end
419
+
232
420
  # A redirect Location resolved against the request URL: absolute URLs
233
421
  # pass through, host-relative (`/x/y`) and path-relative ones join the
234
422
  # base.
235
423
  def absolutize(location, base)
236
424
  return location if location.include?("://")
237
425
 
238
- origin_end = base.index("://") ? base.index("://") + 3 : 0
239
- origin_end = base.index("/", origin_end) || base.length
426
+ base_origin = origin(base)
427
+ return "#{base_origin}#{location}" if location.start_with?("/")
240
428
 
241
- if location.start_with?("/")
242
- "#{base[0...origin_end]}#{location}"
243
- else
244
- dir_end = base.rindex("/") || base.length
245
- "#{base[0...[dir_end, origin_end].max]}/#{location}"
246
- end
429
+ dir_end = base.rindex("/") || base.length
430
+ "#{base[0...[dir_end, base_origin.length].max]}/#{location}"
431
+ end
432
+
433
+ # Scheme and authority of a URL — everything before its path. Two URLs
434
+ # sharing one may pass the Authorization header between them.
435
+ def origin(url)
436
+ scheme_end = url.index("://")
437
+ url[0...(url.index("/", scheme_end ? scheme_end + 3 : 0) || url.length)]
438
+ end
439
+
440
+ def same_origin?(url, other)
441
+ origin(url).casecmp?(origin(other))
247
442
  end
248
443
  end
249
444
  end
@@ -29,8 +29,12 @@ module Gigatoken
29
29
  end
30
30
 
31
31
  # Array of token ids for document `i`, materialized on demand; negative
32
- # indices count from the end and out-of-range ones give nil, like Array.
33
- def [](i)
32
+ # indices count from the end, out-of-range ones give nil, and a
33
+ # non-Integer index is a TypeError, all like Array.
34
+ def [](index)
35
+ i = Integer.try_convert(index)
36
+ raise TypeError, "no implicit conversion of #{index.class} into Integer" if i.nil?
37
+
34
38
  i += size if i.negative?
35
39
  return unless i >= 0 && i < size
36
40
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "zlib"
4
5
 
5
6
  module Gigatoken
6
7
  # A tokenizer: encode, batch encode, decode, and vocabulary introspection
@@ -10,18 +11,35 @@ module Gigatoken
10
11
  FILE_SOURCE_CLASSES = [Native::TextFileSource, Native::JsonlFileSource, Native::ParquetFileSource].freeze
11
12
  private_constant :FILE_SOURCE_CLASSES
12
13
 
14
+ # Encodings whose bytes reach the native call untouched. UTF-8 is checked
15
+ # by identity first — it is the hot path, see #encode — and these are the
16
+ # rest: US-ASCII already is UTF-8 bytes, and ASCII-8BIT is deliberately
17
+ # raw bytes. Anything else — ISO-8859-1, Windows-1252, UTF-16LE — is a
18
+ # real encoding whose bytes are not the text's UTF-8 bytes, so encoding it
19
+ # raw would give different ids than the same text read as UTF-8. (Invalid
20
+ # bytes in a UTF-8-tagged String stay raw: a documented difference from
21
+ # tiktoken, which rejects them.)
22
+ BYTEWISE_ENCODINGS = [Encoding::US_ASCII, Encoding::BINARY].freeze
23
+ private_constant :BYTEWISE_ENCODINGS
24
+
13
25
  # Load from in-memory tokenizer.json contents (String or bytes). Backed
14
26
  # by a BPETokenizer or a SentencePieceTokenizer, per the model's
15
- # byte_fallback flag.
27
+ # byte_fallback flag. The document is parsed twice, deliberately: the Ruby
28
+ # parse runs first because JSON.parse's depth limit is what keeps hostile
29
+ # nesting away from the native parser (see parse_json), and it is also
30
+ # where the special-token table is read from.
16
31
  def self.from_json(data)
17
- native = Native.load_hf_json(data)
18
- new(native, special_tokens: special_tokens_from_json(data))
32
+ json = String.try_convert(data) or raise TypeError, "no implicit conversion of #{data.class} into String"
33
+ parsed = parse_json(json)
34
+ new(Native.load_hf_json(json), special_tokens: special_tokens_from(parsed))
19
35
  end
20
36
 
21
37
  # Load from a tokenizer.json path, or a directory containing one.
22
38
  def self.from_file(path)
23
- path = File.join(path, "tokenizer.json") if File.directory?(path)
24
- from_json(File.binread(path))
39
+ file = File.directory?(path) ? File.join(path, "tokenizer.json") : path.to_s
40
+ raise ModelError, "#{file.inspect}: no such file" unless File.file?(file)
41
+
42
+ from_json(File.binread(file))
25
43
  end
26
44
 
27
45
  # Load from a .tiktoken mergeable-ranks file. The file carries neither a
@@ -35,16 +53,18 @@ module Gigatoken
35
53
  new(native, special_tokens: special_tokens)
36
54
  end
37
55
 
38
- # Load one of the tiktoken encodings gigatoken vendors ranks for, by
39
- # name — see Gigatoken::Encodings::NAMES — entirely from the vendored
40
- # files: no network, no writable cache.
56
+ # Load one of the encodings gigatoken vendors a file for, by name — see
57
+ # Gigatoken::Encodings::NAMES — entirely from the vendored files: no
58
+ # network, no writable cache. A tiktoken entry loads its ranks; a
59
+ # tokenizer.json entry is gunzipped and loaded by from_json.
41
60
  def self.from_encoding(name)
42
61
  encoding = Encodings[name]
62
+ return from_json(Zlib.gunzip(File.binread(encoding[:json_file]))) if encoding&.key?(:json_file)
43
63
  return from_tiktoken(encoding[:rank_file], pretokenizer: encoding[:pretokenizer], special_tokens: encoding[:special_tokens]) if encoding
44
64
 
45
65
  reason = Encodings.unpackable_reason(name)
46
66
  detail = reason ? " — #{reason}" : ""
47
- raise Error, "#{name.inspect}: not a packaged encoding#{detail} (packaged encodings: #{Encodings::NAMES.join(", ")})"
67
+ raise ModelError, "#{name.inspect}: not a packaged encoding#{detail} (packaged encodings: #{Encodings::NAMES.join(", ")})"
48
68
  end
49
69
 
50
70
  # Load tokenizer.json from HuggingFace Hub repo `repo_id` at `revision`
@@ -71,7 +91,7 @@ module Gigatoken
71
91
  source = source.to_s
72
92
  if source.end_with?(".tiktoken")
73
93
  unless pretokenizer
74
- raise Error, "#{source.inspect}: a .tiktoken file carries no pretokenizer scheme of its own — " \
94
+ raise ModelError, "#{source.inspect}: a .tiktoken file carries no pretokenizer scheme of its own — " \
75
95
  "pass pretokenizer: (one of #{Native.pretokenizer_names.join(", ")})"
76
96
  end
77
97
  return from_tiktoken(source, pretokenizer: pretokenizer, special_tokens: special_tokens)
@@ -80,25 +100,44 @@ module Gigatoken
80
100
  return from_encoding(source) if Encodings::NAMES.include?(source) || Encodings.unpackable_reason(source)
81
101
  return from_hub(source, revision: revision, hub: hub) if Hub.looks_like_repo_id?(source)
82
102
 
83
- raise Error, "#{source.inspect}: no such file or directory, not a .tiktoken path, and doesn't look like a HuggingFace Hub repo id"
103
+ raise ModelError, "#{source.inspect}: no such file or directory, not a .tiktoken path, and doesn't look like a HuggingFace Hub repo id"
84
104
  end
85
105
 
86
106
  # `data` is UTF-8 JSON whatever its encoding tag says (File.binread tags
87
107
  # binary; a US-ASCII default_external tags that), so retag rather than let
88
108
  # JSON.parse transcode from the tag; a UTF-8-tagged String needs no copy.
89
- def self.special_tokens_from_json(data)
109
+ # JSON.parse's default max_nesting of 100 is the guard: the native parser
110
+ # is recursive with no depth limit, and deep enough nesting overflows its
111
+ # stack rather than raising.
112
+ def self.parse_json(data)
90
113
  data = data.dup.force_encoding(Encoding::UTF_8) unless data.encoding == Encoding::UTF_8
91
- added = JSON.parse(data)["added_tokens"] || []
114
+ JSON.parse(data)
115
+ rescue JSON::ParserError => e
116
+ raise ModelError, "failed to parse tokenizer JSON: #{e.message}"
117
+ end
118
+ private_class_method :parse_json
119
+
120
+ def self.special_tokens_from(parsed)
121
+ added = parsed["added_tokens"] || []
92
122
  added.each_with_object({}) { |t, h| h[t["content"]] = t["id"] if t["special"] }
93
123
  end
94
- private_class_method :special_tokens_from_json
124
+ private_class_method :special_tokens_from
95
125
 
126
+ # The table is frozen so `#special_tokens` can neither alias the
127
+ # deep-frozen registry's Hash nor a `from_tiktoken` caller's; an
128
+ # already-frozen one is safe to share as-is.
96
129
  def initialize(native, special_tokens: {})
97
130
  @native = native
98
- @special_tokens = special_tokens
131
+ @special_tokens = special_tokens.frozen? ? special_tokens : special_tokens.dup.freeze
99
132
  end
100
133
 
134
+ # A String already tagged UTF-8 — every ordinary document — reaches the
135
+ # native call over one inline identity check. A helper frame here is
136
+ # measurable against the encode itself at document sizes this small, so
137
+ # the check is spelled out rather than delegated to #transcode?; anything
138
+ # else takes the slow path in #utf8.
101
139
  def encode(text)
140
+ text = utf8(text) unless text.is_a?(String) && text.encoding == Encoding::UTF_8
102
141
  @native.encode(text)
103
142
  end
104
143
 
@@ -107,6 +146,7 @@ module Gigatoken
107
146
  # token ids plus per-document lengths), avoiding the per-token Ruby
108
147
  # array materialization the ragged shape costs.
109
148
  def encode_batch(texts, packed: false)
149
+ texts = utf8_batch(texts)
110
150
  if packed
111
151
  PackedResult.new(*@native.encode_batch_packed(texts))
112
152
  else
@@ -154,5 +194,34 @@ module Gigatoken
154
194
  end
155
195
 
156
196
  attr_reader :special_tokens
197
+
198
+ private
199
+
200
+ # UTF-8 first and by identity, so the batch walk below costs the same one
201
+ # comparison per document that #encode costs; the list is only consulted
202
+ # for the encodings that aren't it.
203
+ def transcode?(text)
204
+ text.is_a?(String) && text.encoding != Encoding::UTF_8 && !BYTEWISE_ENCODINGS.include?(text.encoding)
205
+ end
206
+
207
+ # A transcode that can't be done — a dummy encoding like UTF-7, bytes the
208
+ # tag doesn't allow, a character the target can't hold — is a fact about
209
+ # the document, not about the tokenizer, and reaches the caller as one.
210
+ def utf8(text)
211
+ transcode?(text) ? text.encode(Encoding::UTF_8) : text
212
+ rescue EncodingError => e
213
+ raise InputError, e.message
214
+ end
215
+
216
+ # The same rule per document, without copying the Array when — as in
217
+ # every UTF-8 batch — there is nothing to transcode. Elements are only
218
+ # type-checked, never coerced: a `to_str` object is left for the native
219
+ # side to convert under its own snapshot of the input.
220
+ def utf8_batch(texts)
221
+ array = Array.try_convert(texts)
222
+ return texts if array.nil? || array.none? { |text| transcode?(text) }
223
+
224
+ array.map { |text| utf8(text) }
225
+ end
157
226
  end
158
227
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gigatoken
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/gigatoken.rb CHANGED
@@ -4,9 +4,25 @@ require_relative "gigatoken/version"
4
4
 
5
5
  module Gigatoken
6
6
  # Raised for tokenizer load and encode failures surfaced from the native
7
- # extension — never a raw Rust panic across the Ruby boundary.
7
+ # extension — never a raw Rust panic across the Ruby boundary. The base of
8
+ # the three below: rescue this to catch everything gigatoken raises.
9
+ # Anything narrower than the three has no class of its own — CLI usage
10
+ # errors and the odd leftover are this one directly.
8
11
  class Error < StandardError; end
9
12
 
13
+ # Everything Gigatoken::Hub raises: HTTP status, transport, timeout, repo-id
14
+ # / revision / filename / x-repo-commit validation.
15
+ class HubError < Error; end
16
+
17
+ # A document the tokenizer cannot take: an untranscodable or invalid-byte
18
+ # String, invalid UTF-8 on the SentencePiece path, an id outside the
19
+ # vocabulary in #decode.
20
+ class InputError < Error; end
21
+
22
+ # A tokenizer that cannot be loaded: bad or hostile JSON, a missing file or
23
+ # directory, an unknown or unpackable encoding name, a malformed .tiktoken.
24
+ class ModelError < Error; end
25
+
10
26
  class << self
11
27
  # The process-global encode-cache budget in bytes per worker (a parallel
12
28
  # batch encode may use up to workers x budget), applied to tokenizers of