Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 175 additions & 8 deletions lib/llama_cpp_ex/hub.ex
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,32 @@ defmodule LlamaCppEx.Hub do
## Offline Mode

Set `LLAMA_OFFLINE=1` to use only cached files without network access.

## Proxies

Requests honor the standard proxy environment variables automatically:
`HTTPS_PROXY`/`HTTP_PROXY` (and their lowercase forms), falling back to
`ALL_PROXY`, with `NO_PROXY` respected for host bypass. Because HuggingFace is
served over HTTPS, the `HTTPS_PROXY` value is the one that applies; an HTTP
proxy tunnels HTTPS via the `CONNECT` method.

# honored automatically
export HTTPS_PROXY=http://127.0.0.1:8118

Override or disable proxying per call with the `:proxy` (a URL string, a Mint
`{scheme, address, port, opts}` tuple, or `false`) and `:no_proxy` options:

LlamaCppEx.Hub.search("qwen3 gguf", proxy: "http://user:pass@127.0.0.1:8118")
LlamaCppEx.Hub.download("org/model", "model.gguf", proxy: false)

### SOCKS is not supported

The underlying HTTP client (Req → Finch → Mint) supports HTTP/1 proxies only —
plain forwarding and HTTPS-over-`CONNECT` tunneling. It has **no SOCKS
support**, so a `socks5://` value (e.g. from `ALL_PROXY`) is ignored with a
warning. To use a SOCKS upstream, run a local HTTP-to-SOCKS bridge such as
[Privoxy](https://www.privoxy.org) or [gost](https://github.com/go-gost/gost)
and point `HTTPS_PROXY` at the bridge's HTTP port.
"""

require Logger
Expand All @@ -58,6 +84,7 @@ defmodule LlamaCppEx.Hub do
* `:sort` - Sort by `"downloads"`, `"likes"`, or `"lastModified"`. Defaults to `"downloads"`.
* `:direction` - Sort direction, `-1` for descending. Defaults to `-1`.
* `:token` - HuggingFace API token.
* `:proxy`, `:no_proxy` - Proxy overrides. See the "Proxies" section above.

## Examples

Expand All @@ -81,7 +108,9 @@ defmodule LlamaCppEx.Hub do
limit: limit
]

case Req.get(@hf_api_url, headers: headers, params: params) do
req_opts = [headers: headers, params: params] ++ proxy_request_options(@hf_api_url, opts)

case Req.get(@hf_api_url, req_opts) do
{:ok, %{status: 200, body: body}} when is_list(body) ->
models =
Enum.map(body, fn m ->
Expand Down Expand Up @@ -122,6 +151,7 @@ defmodule LlamaCppEx.Hub do
* `:token` - HuggingFace API token. Defaults to `HF_TOKEN` environment variable.
* `:revision` - Git revision (branch, tag, or commit). Defaults to `"main"`.
* `:force` - Force re-download even if cached. Defaults to `false`.
* `:proxy`, `:no_proxy` - Proxy overrides. See the "Proxies" section above.

"""
@spec download(String.t(), String.t(), keyword()) :: {:ok, String.t()} | {:error, String.t()}
Expand All @@ -145,7 +175,7 @@ defmodule LlamaCppEx.Hub do
true ->
url = build_download_url(repo_id, filename, opts)
headers = auth_headers(opts)
do_download_to(url, dest, headers)
do_download_to(url, dest, headers, proxy_request_options(url, opts))
end
end
end
Expand Down Expand Up @@ -178,7 +208,7 @@ defmodule LlamaCppEx.Hub do
url = "#{@hf_api_url}/#{repo_id}/tree/#{revision}"
headers = auth_headers(opts)

case Req.get(url, headers: headers) do
case Req.get(url, [headers: headers] ++ proxy_request_options(url, opts)) do
{:ok, %{status: 200, body: body}} when is_list(body) ->
files =
body
Expand Down Expand Up @@ -225,7 +255,7 @@ defmodule LlamaCppEx.Hub do
url = "#{@hf_api_url}/#{repo_id}"
headers = auth_headers(opts)

case Req.get(url, headers: headers) do
case Req.get(url, [headers: headers] ++ proxy_request_options(url, opts)) do
{:ok, %{status: 200, body: body}} ->
{:ok, body}

Expand Down Expand Up @@ -294,6 +324,31 @@ defmodule LlamaCppEx.Hub do
end
end

@doc false
# Builds the Req `connect_options` needed to route a request through a proxy.
#
# The proxy is resolved from the `:proxy` option (a URL string, a Mint
# `{scheme, address, port, opts}` tuple, or `false` to disable) and otherwise
# from the standard proxy environment variables. Returns `[]` when no usable
# proxy applies. SOCKS proxies are detected and skipped — see the "Proxies"
# section in the module doc for the reasoning and the workaround.
@spec proxy_request_options(String.t(), keyword()) :: keyword()
def proxy_request_options(url, opts \\ []) do
case Keyword.get(opts, :proxy, :auto) do
{scheme, host, port, proxy_opts}
when is_atom(scheme) and is_binary(host) and is_integer(port) and is_list(proxy_opts) ->
[connect_options: [proxy: {scheme, host, port, proxy_opts}]]

proxy_setting ->
target = URI.parse(url)

case resolve_proxy_url(proxy_setting, target.scheme) do
nil -> []
proxy_url -> build_proxy_options(proxy_url, target.host, opts)
end
end
end

@doc """
Build the local cache path for a model file.
"""
Expand Down Expand Up @@ -323,14 +378,124 @@ defmodule LlamaCppEx.Hub do
end
end

defp do_download_to(url, dest, headers) do
# --- Proxy resolution ---

defp resolve_proxy_url(false, _scheme), do: nil
defp resolve_proxy_url(url, _scheme) when is_binary(url), do: url
defp resolve_proxy_url(:auto, scheme), do: env_proxy_url(scheme)

# Scheme-specific vars take precedence over the catch-all ALL_PROXY, so a
# usable HTTP proxy always wins over an (unusable) SOCKS ALL_PROXY.
defp env_proxy_url("https"), do: env_any(["HTTPS_PROXY", "https_proxy"]) || env_all_proxy()
defp env_proxy_url("http"), do: env_any(["HTTP_PROXY", "http_proxy"]) || env_all_proxy()
defp env_proxy_url(_other), do: env_all_proxy()

defp env_all_proxy, do: env_any(["ALL_PROXY", "all_proxy"])

defp env_any(keys) do
Enum.find_value(keys, fn key ->
case System.get_env(key) do
nil -> nil
"" -> nil
value -> value
end
end)
end

defp build_proxy_options(proxy_url, host, opts) do
no_proxy = Keyword.get(opts, :no_proxy) || env_any(["NO_PROXY", "no_proxy"]) || ""

if bypass_proxy?(host, no_proxy) do
[]
else
case parse_proxy(proxy_url) do
{:ok, proxy, []} ->
[connect_options: [proxy: proxy]]

{:ok, proxy, proxy_headers} ->
[connect_options: [proxy: proxy, proxy_headers: proxy_headers]]

{:error, {:socks, scheme}} ->
Logger.warning(
"ignoring #{scheme} proxy #{redact_proxy(proxy_url)}: the HTTP client (Req/Finch/Mint) " <>
"supports HTTP/HTTPS CONNECT proxies only, not SOCKS. Run a local HTTP-to-SOCKS " <>
"bridge (e.g. Privoxy or gost) and point HTTPS_PROXY at it instead."
)

[]

{:error, :invalid} ->
Logger.warning("ignoring malformed proxy URL #{redact_proxy(proxy_url)}")
[]
end
end
end

defp bypass_proxy?(nil, _no_proxy), do: false

defp bypass_proxy?(host, no_proxy) do
no_proxy
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.any?(&host_matches_no_proxy?(host, &1))
end

defp host_matches_no_proxy?(_host, ""), do: false
defp host_matches_no_proxy?(_host, "*"), do: true

defp host_matches_no_proxy?(host, entry) do
entry = String.trim_leading(entry, ".")
host == entry or String.ends_with?(host, "." <> entry)
end

defp parse_proxy(proxy_url) do
uri = proxy_url |> normalize_proxy_url() |> URI.parse()

case uri.scheme do
scheme when scheme in ["http", "https"] ->
proxy = {proxy_scheme_atom(scheme), uri.host, uri.port || default_proxy_port(scheme), []}
{:ok, proxy, proxy_auth_headers(uri.userinfo)}

"socks" <> _ ->
{:error, {:socks, String.upcase(uri.scheme)}}

_other ->
{:error, :invalid}
end
end

# Proxy URLs from `ALL_PROXY` or a bare `host:port` option may omit the scheme.
defp normalize_proxy_url(url) do
if Regex.match?(~r{^[a-zA-Z][a-zA-Z0-9+.\-]*://}, url), do: url, else: "http://" <> url
end

defp proxy_scheme_atom("http"), do: :http
defp proxy_scheme_atom("https"), do: :https

defp default_proxy_port("http"), do: 80
defp default_proxy_port("https"), do: 443

defp proxy_auth_headers(nil), do: []

defp proxy_auth_headers(userinfo),
do: [{"proxy-authorization", "Basic " <> Base.encode64(userinfo)}]

# Strips credentials before a proxy URL is written to the log.
defp redact_proxy(proxy_url) do
case URI.parse(normalize_proxy_url(proxy_url)) do
%URI{userinfo: nil} = uri -> URI.to_string(uri)
%URI{} = uri -> URI.to_string(%{uri | userinfo: "***"})
end
end

defp do_download_to(url, dest, headers, proxy_opts) do
Logger.info("Downloading to #{dest}")
File.mkdir_p!(Path.dirname(dest))

tmp_dest = dest <> ".download"

try do
case do_stream_download(url, tmp_dest, headers) do
case do_stream_download(url, tmp_dest, headers, proxy_opts) do
{:ok, etag} ->
File.rename!(tmp_dest, dest)

Expand All @@ -352,9 +517,11 @@ defmodule LlamaCppEx.Hub do
end
end

defp do_stream_download(url, dest, headers) do
defp do_stream_download(url, dest, headers, proxy_opts) do
# Use Req with output to file — handles redirects correctly
case Req.get(url, headers: headers, max_redirects: 10, into: File.stream!(dest)) do
req_opts = [headers: headers, max_redirects: 10, into: File.stream!(dest)] ++ proxy_opts

case Req.get(url, req_opts) do
{:ok, %{status: 200} = resp} ->
etag = get_header(resp, "etag")
{:ok, etag}
Expand Down
Loading