Skip to content

Commit 8309fd9

Browse files
committed
Release v0.2.0: Add HuggingFace model resolution and encoding caching
This commit introduces the ability to load Kimi-compatible encodings directly from HuggingFace repositories. It includes a new resolution mechanism that downloads and caches required files locally. Key changes: Introduced TiktokenEx.HuggingFace for resolving files from HuggingFace. It includes local file system caching under the user cache directory, atomic writes to prevent corruption during concurrent access, and injectable fetchers for network-independent testing. Added TiktokenEx.Cache, an optional ETS-based cache for built encodings. This allows users to reuse built encoding structures across their application by keying them against repository name and revision. Expanded TiktokenEx.Kimi with the from_hf_repo/2 function. This simplifies the workflow for using Kimi-style models by automatically fetching the tiktoken.model and tokenizer_config.json files from the remote repository. Updated dependencies and project configuration. The package now includes Inets, SSL, and Public Key as extra applications to support HTTPS downloads. Credo has been added for static analysis, and the version is bumped to 0.2.0. Comprehensive tests were added for the cache logic, file resolution sanitization, and the integration between Kimi and HuggingFace.
1 parent b31948f commit 8309fd9

11 files changed

Lines changed: 524 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Changelog
2+
3+
## 0.2.0 - 2025-12-27
4+
- Added HuggingFace file resolution with local caching and injectable fetchers.
5+
- Added `Kimi.from_hf_repo/2` with optional ETS encoding caching.
6+
- Added `TiktokenEx.Cache` helper for opt-in encoding reuse.
7+
8+
## 0.1.0 - 2025-12-12
9+
- Initial release.

README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Add `tiktoken_ex` to your dependencies:
2828
```elixir
2929
def deps do
3030
[
31-
{:tiktoken_ex, "~> 0.1.0"}
31+
{:tiktoken_ex, "~> 0.2.0"}
3232
]
3333
end
3434
```
@@ -76,6 +76,26 @@ alias TiktokenEx.{Encoding, Kimi}
7676
{:ok, decoded} = Encoding.decode(enc, ids)
7777
```
7878

79+
### Load a Kimi K2 encoding from a HuggingFace repo (cached)
80+
81+
`from_hf_repo/2` downloads and caches `tiktoken.model` and
82+
`tokenizer_config.json` under your user cache directory.
83+
84+
```elixir
85+
alias TiktokenEx.{Encoding, Kimi}
86+
87+
{:ok, enc} =
88+
Kimi.from_hf_repo(
89+
"moonshotai/Kimi-K2-Thinking",
90+
revision: "main",
91+
encoding_cache: true
92+
)
93+
94+
{:ok, ids} = Encoding.encode(enc, "Say hi")
95+
```
96+
97+
To test without network, inject a `:fetch_fun` (see `TiktokenEx.HuggingFace`).
98+
7999
### Special tokens
80100

81101
Special tokens are recognized by default. To treat them as plain text:

lib/tiktoken_ex/cache.ex

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
defmodule TiktokenEx.Cache do
2+
@moduledoc """
3+
Optional ETS cache for encodings keyed by repo and revision.
4+
5+
This cache is opt-in and safe to ignore in most usage.
6+
"""
7+
8+
@table __MODULE__
9+
10+
@spec get_or_load(term(), (-> {:ok, term()} | {:error, term()})) ::
11+
{:ok, term()} | {:error, term()}
12+
def get_or_load(key, loader) when is_function(loader, 0) do
13+
ensure_table()
14+
15+
case :ets.lookup(@table, key) do
16+
[{^key, value}] ->
17+
{:ok, value}
18+
19+
[] ->
20+
case loader.() do
21+
{:ok, value} ->
22+
true = :ets.insert(@table, {key, value})
23+
{:ok, value}
24+
25+
other ->
26+
other
27+
end
28+
end
29+
end
30+
31+
@spec clear() :: :ok
32+
def clear do
33+
case :ets.whereis(@table) do
34+
:undefined ->
35+
:ok
36+
37+
tid ->
38+
:ets.delete_all_objects(tid)
39+
:ok
40+
end
41+
end
42+
43+
defp ensure_table do
44+
case :ets.whereis(@table) do
45+
:undefined ->
46+
try do
47+
:ets.new(@table, [
48+
:set,
49+
:public,
50+
:named_table,
51+
read_concurrency: true,
52+
write_concurrency: true
53+
])
54+
55+
:ok
56+
rescue
57+
ArgumentError -> :ok
58+
end
59+
60+
_ ->
61+
:ok
62+
end
63+
end
64+
end

lib/tiktoken_ex/encoding.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# credo:disable-for-this-file Credo.Check.Refactor.Nesting
2+
# credo:disable-for-this-file Credo.Check.Refactor.CyclomaticComplexity
13
defmodule TiktokenEx.Encoding do
24
@moduledoc """
35
A TikToken-style encoding: regex-based splitting + byte-pair encoding + specials.

lib/tiktoken_ex/hugging_face.ex

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
defmodule TiktokenEx.HuggingFace do
2+
@moduledoc """
3+
Resolve HuggingFace files with a local cache and injectable fetchers.
4+
5+
The default fetcher uses `:httpc` and writes to the user cache directory.
6+
"""
7+
8+
@base_url "https://huggingface.co"
9+
10+
@spec resolve_file(String.t(), String.t(), String.t(), keyword()) ::
11+
{:ok, String.t()} | {:error, term()}
12+
def resolve_file(repo_id, revision, filename, opts \\ [])
13+
when is_binary(repo_id) and is_binary(revision) and is_binary(filename) and is_list(opts) do
14+
cache_root = Keyword.get(opts, :cache_dir, default_cache_dir())
15+
repo_segment = sanitize_repo_id(repo_id)
16+
path = Path.join([cache_root, "hf", repo_segment, revision, filename])
17+
18+
if File.exists?(path) do
19+
{:ok, path}
20+
else
21+
with :ok <- File.mkdir_p(Path.dirname(path)),
22+
{:ok, body} <- fetch_file(repo_id, revision, filename, opts),
23+
:ok <- write_atomic(path, body) do
24+
{:ok, path}
25+
else
26+
{:error, reason} -> {:error, reason}
27+
end
28+
end
29+
end
30+
31+
defp fetch_file(repo_id, revision, filename, opts) do
32+
case Keyword.get(opts, :fetch_fun) do
33+
fun when is_function(fun, 4) ->
34+
fun.(repo_id, revision, filename, opts)
35+
36+
nil ->
37+
fetch_httpc(repo_id, revision, filename, opts)
38+
39+
other ->
40+
{:error, {:invalid_fetch_fun, other}}
41+
end
42+
end
43+
44+
defp fetch_httpc(repo_id, revision, filename, opts) do
45+
url = "#{@base_url}/#{repo_id}/resolve/#{revision}/#{filename}"
46+
timeout_ms = Keyword.get(opts, :http_timeout_ms, 120_000)
47+
headers = [{~c"user-agent", ~c"tiktoken_ex"}]
48+
49+
with :ok <- ensure_httpc_started() do
50+
ssl_options =
51+
[
52+
verify: :verify_peer,
53+
cacerts: public_key_cacerts(),
54+
depth: 3
55+
]
56+
|> maybe_add_hostname_check()
57+
58+
http_options = [
59+
timeout: timeout_ms,
60+
connect_timeout: timeout_ms,
61+
autoredirect: true,
62+
ssl: ssl_options
63+
]
64+
65+
options = [body_format: :binary, full_result: true]
66+
67+
case :httpc.request(:get, {String.to_charlist(url), headers}, http_options, options) do
68+
{:ok, {{_, status, _}, _resp_headers, body}}
69+
when is_integer(status) and status >= 200 and status < 300 ->
70+
{:ok, body}
71+
72+
{:ok, {{_, 404, _}, _resp_headers, _body}} ->
73+
{:error, {:not_found, repo_id, revision, filename}}
74+
75+
{:ok, {{_, status, _}, _resp_headers, body}} ->
76+
{:error, {:http_status, status, body}}
77+
78+
{:error, reason} ->
79+
{:error, {:http_error, reason}}
80+
end
81+
end
82+
end
83+
84+
defp ensure_httpc_started do
85+
with {:ok, _} <- Application.ensure_all_started(:inets),
86+
{:ok, _} <- Application.ensure_all_started(:public_key),
87+
{:ok, _} <- Application.ensure_all_started(:ssl) do
88+
:ok
89+
else
90+
{:error, reason} -> {:error, {:httpc_start_failed, reason}}
91+
end
92+
end
93+
94+
defp default_cache_dir do
95+
:filename.basedir(:user_cache, "tiktoken_ex")
96+
end
97+
98+
defp sanitize_repo_id(repo_id) do
99+
repo_id
100+
|> String.replace("/", "__")
101+
|> String.replace("..", "_")
102+
end
103+
104+
defp write_atomic(path, body) when is_binary(path) and is_binary(body) do
105+
dir = Path.dirname(path)
106+
tmp_path = Path.join(dir, ".#{Path.basename(path)}.#{System.unique_integer([:positive])}.tmp")
107+
108+
with :ok <- File.write(tmp_path, body),
109+
:ok <- finalize_atomic_write(tmp_path, path) do
110+
:ok
111+
else
112+
{:error, reason} -> {:error, {:cache_write_failed, path, reason}}
113+
end
114+
end
115+
116+
defp finalize_atomic_write(tmp_path, path) do
117+
case File.rename(tmp_path, path) do
118+
:ok ->
119+
:ok
120+
121+
{:error, reason} ->
122+
_ = File.rm(tmp_path)
123+
124+
if File.exists?(path) do
125+
:ok
126+
else
127+
{:error, reason}
128+
end
129+
end
130+
end
131+
132+
defp public_key_cacerts do
133+
if Code.ensure_loaded?(:public_key) and function_exported?(:public_key, :cacerts_get, 0) do
134+
# credo:disable-for-next-line Credo.Check.Refactor.Apply
135+
apply(:public_key, :cacerts_get, [])
136+
else
137+
[]
138+
end
139+
end
140+
141+
defp maybe_add_hostname_check(ssl_options) do
142+
case public_key_hostname_match_fun() do
143+
nil -> ssl_options
144+
match_fun -> Keyword.put(ssl_options, :customize_hostname_check, match_fun: match_fun)
145+
end
146+
end
147+
148+
defp public_key_hostname_match_fun do
149+
if Code.ensure_loaded?(:public_key) and
150+
function_exported?(:public_key, :pkix_verify_hostname_match_fun, 1) do
151+
# credo:disable-for-next-line Credo.Check.Refactor.Apply
152+
apply(:public_key, :pkix_verify_hostname_match_fun, [:https])
153+
else
154+
nil
155+
end
156+
end
157+
end

lib/tiktoken_ex/kimi.ex

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ defmodule TiktokenEx.Kimi do
1010
`TiktokenEx.Encoding` from HuggingFace-style tokenizer configs.
1111
"""
1212

13-
alias TiktokenEx.Encoding
13+
alias TiktokenEx.{Cache, Encoding, HuggingFace}
1414

1515
@num_reserved_special_tokens 256
1616

@@ -50,6 +50,49 @@ defmodule TiktokenEx.Kimi do
5050
|> translate_intersection_classes()
5151
end
5252

53+
@doc """
54+
Load a Kimi-compatible encoding from a HuggingFace repo.
55+
56+
## Options
57+
58+
* `:revision` - Git revision or tag (default: `"main"`).
59+
* `:cache_dir` - Override the HuggingFace cache root.
60+
* `:fetch_fun` - Custom fetcher `fun.(repo_id, revision, filename, opts)`.
61+
* `:http_timeout_ms` - Timeout for the default HTTP fetcher.
62+
* `:encoding_cache` - When true, reuse an ETS cache keyed by repo + revision.
63+
* `:pat_str` - Override the pattern used for splitting.
64+
* `:special_token_matching` - Pass-through to `TiktokenEx.Encoding.new/1`.
65+
"""
66+
@spec from_hf_repo(String.t(), keyword()) :: {:ok, Encoding.t()} | {:error, term()}
67+
def from_hf_repo(repo_id, opts \\ []) when is_binary(repo_id) and is_list(opts) do
68+
revision = Keyword.get(opts, :revision, "main")
69+
pat_str = Keyword.get(opts, :pat_str, pat_str())
70+
special_token_matching = Keyword.get(opts, :special_token_matching, :parity)
71+
use_encoding_cache = Keyword.get(opts, :encoding_cache, false)
72+
73+
hf_opts = Keyword.take(opts, [:cache_dir, :fetch_fun, :http_timeout_ms])
74+
75+
loader = fn ->
76+
with {:ok, model_path} <-
77+
HuggingFace.resolve_file(repo_id, revision, "tiktoken.model", hf_opts),
78+
{:ok, config_path} <-
79+
HuggingFace.resolve_file(repo_id, revision, "tokenizer_config.json", hf_opts) do
80+
from_hf_files(
81+
tiktoken_model_path: model_path,
82+
tokenizer_config_path: config_path,
83+
pat_str: pat_str,
84+
special_token_matching: special_token_matching
85+
)
86+
end
87+
end
88+
89+
if use_encoding_cache do
90+
Cache.get_or_load({repo_id, revision, pat_str, special_token_matching}, loader)
91+
else
92+
loader.()
93+
end
94+
end
95+
5396
@doc """
5497
Load a Kimi-compatible encoding from local HuggingFace files.
5598
@@ -67,15 +110,13 @@ defmodule TiktokenEx.Kimi do
67110

68111
with {:ok, mergeable_ranks} <- load_tiktoken_model(model_path),
69112
{:ok, config} <- load_json(config_path),
70-
{:ok, special_tokens} <- build_special_tokens(config, map_size(mergeable_ranks)),
71-
{:ok, encoding} <-
72-
Encoding.new(
73-
pat_str: pat_str,
74-
mergeable_ranks: mergeable_ranks,
75-
special_tokens: special_tokens,
76-
special_token_matching: special_token_matching
77-
) do
78-
{:ok, encoding}
113+
{:ok, special_tokens} <- build_special_tokens(config, map_size(mergeable_ranks)) do
114+
Encoding.new(
115+
pat_str: pat_str,
116+
mergeable_ranks: mergeable_ranks,
117+
special_tokens: special_tokens,
118+
special_token_matching: special_token_matching
119+
)
79120
end
80121
end
81122

0 commit comments

Comments
 (0)