|
| 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 |
0 commit comments