move Elixir HTTPoison client to httpoison subdirectory

This commit is contained in:
Russell Ballestrini 2025-10-15 15:50:12 -04:00
parent bdf7875f31
commit df3d76738e
4 changed files with 377 additions and 0 deletions

View file

@ -0,0 +1,37 @@
FROM elixir:1.17-alpine AS build
WORKDIR /app
# Install hex and rebar
RUN mix local.hex --force && \
mix local.rebar --force
# Copy mix files for dependency resolution
COPY mix.exs mix.lock* ./
RUN mix deps.get --only prod
# Copy source code
COPY lib ./lib
COPY run.exs ./
# Compile the project (don't run yet)
RUN MIX_ENV=prod mix compile
# Runtime stage
FROM elixir:1.17-alpine
WORKDIR /app
# Install hex and rebar in runtime
RUN mix local.hex --force && \
mix local.rebar --force
# Copy built application from build stage
COPY --from=build /app/_build /app/_build
COPY --from=build /app/deps /app/deps
COPY --from=build /app/lib /app/lib
COPY --from=build /app/run.exs /app/run.exs
COPY --from=build /app/mix.exs /app/mix.exs
# Run the application
CMD ["elixir", "run.exs"]

View file

@ -0,0 +1,252 @@
defmodule UncloseAI do
@moduledoc """
UncloseAI Elixir Library
OpenAI-compatible API client with streaming support
Compatible with vLLM, Ollama, and OpenAI-compatible endpoints
"""
defmodule ModelInfo do
@moduledoc "Struct to hold model information"
defstruct [:id, :endpoint, :max_tokens]
end
defmodule Client do
@moduledoc "Client struct to hold discovered models and endpoints"
defstruct models: [], tts_endpoints: [], timeout: 30_000
@doc """
Initialize a new UncloseAI client with auto-discovery
"""
def new(opts \\ []) do
timeout = Keyword.get(opts, :timeout, 30_000)
IO.puts("Initializing UncloseAI client...")
# Discover chat/code models
models =
Stream.iterate(1, &(&1 + 1))
|> Stream.take_while(fn i ->
System.get_env("MODEL_ENDPOINT_#{i}") != nil
end)
|> Stream.map(fn i ->
endpoint = System.get_env("MODEL_ENDPOINT_#{i}")
IO.puts("Endpoint #{i}: #{endpoint}")
discover_models_from_endpoint(endpoint)
end)
|> Enum.to_list()
|> List.flatten()
# Discover TTS endpoints
tts_endpoints =
Stream.iterate(1, &(&1 + 1))
|> Stream.take_while(fn i ->
System.get_env("TTS_ENDPOINT_#{i}") != nil
end)
|> Stream.map(fn i ->
System.get_env("TTS_ENDPOINT_#{i}")
end)
|> Enum.to_list()
IO.puts("Discovered #{length(models)} models, #{length(tts_endpoints)} TTS endpoints\n")
%Client{models: models, tts_endpoints: tts_endpoints, timeout: timeout}
end
defp discover_models_from_endpoint(endpoint) do
case HTTPoison.get("#{endpoint}/models", [], recv_timeout: 10_000) do
{:ok, %HTTPoison.Response{body: body}} ->
data = Jason.decode!(body)
case data["data"] do
nil ->
[]
models ->
Enum.filter(models, fn model ->
# Skip modelperm-* entries
not String.starts_with?(model["id"], "modelperm-")
end)
|> Enum.map(fn model ->
model_id = model["id"]
max_tokens = model["max_model_len"] || 8192
%ModelInfo{id: model_id, endpoint: endpoint, max_tokens: max_tokens}
end)
end
{:error, _reason} ->
# Silently skip failed endpoints
[]
end
end
@doc """
Non-streaming chat completion
"""
def chat(client, messages, opts \\ []) do
model_idx = Keyword.get(opts, :model_idx, 0)
max_tokens = Keyword.get(opts, :max_tokens, 100)
temperature = Keyword.get(opts, :temperature, 0.7)
model = Enum.at(client.models, model_idx)
if model == nil do
{:error, "Invalid model index"}
else
url = "#{model.endpoint}/chat/completions"
request = %{
model: model.id,
messages: messages,
stream: false,
max_tokens: max_tokens,
temperature: temperature
}
case HTTPoison.post(
url,
Jason.encode!(request),
[{"Content-Type", "application/json"}],
recv_timeout: client.timeout
) do
{:ok, %HTTPoison.Response{body: body}} ->
data = Jason.decode!(body)
{:ok, get_in(data, ["choices", Access.at(0), "message", "content"])}
{:error, reason} ->
{:error, reason}
end
end
end
@doc """
Streaming chat completion - returns a Stream that yields content chunks
"""
def chat_stream(client, messages, opts \\ []) do
model_idx = Keyword.get(opts, :model_idx, 0)
max_tokens = Keyword.get(opts, :max_tokens, 500)
temperature = Keyword.get(opts, :temperature, 0.7)
model = Enum.at(client.models, model_idx)
if model == nil do
raise "Invalid model index"
end
url = "#{model.endpoint}/chat/completions"
request = %{
model: model.id,
messages: messages,
stream: true,
max_tokens: max_tokens,
temperature: temperature
}
# Use HTTPoison stream with async response handling
Stream.resource(
fn ->
{:ok, response} =
HTTPoison.post(
url,
Jason.encode!(request),
[{"Content-Type", "application/json"}],
stream_to: self(),
async: :once,
recv_timeout: client.timeout
)
{response, ""}
end,
fn {response, buffer} ->
receive do
%HTTPoison.AsyncStatus{} ->
HTTPoison.stream_next(response)
{[], {response, buffer}}
%HTTPoison.AsyncHeaders{} ->
HTTPoison.stream_next(response)
{[], {response, buffer}}
%HTTPoison.AsyncChunk{chunk: chunk} ->
# Append chunk to buffer and process lines
new_buffer = buffer <> chunk
{lines, remaining} = extract_lines(new_buffer)
contents =
lines
|> Enum.filter(&String.starts_with?(&1, "data: "))
|> Enum.map(&String.slice(&1, 6..-1))
|> Enum.reject(&(&1 == "[DONE]"))
|> Enum.map(&extract_content/1)
|> Enum.reject(&is_nil/1)
HTTPoison.stream_next(response)
{contents, {response, remaining}}
%HTTPoison.AsyncEnd{} ->
{:halt, {response, buffer}}
after
client.timeout ->
{:halt, {response, buffer}}
end
end,
fn {response, _buffer} ->
:hackney.close(response.id)
end
)
end
defp extract_lines(buffer) do
lines = String.split(buffer, "\n")
case List.last(lines) do
"" -> {Enum.drop(lines, -1), ""}
partial -> {Enum.drop(lines, -1), partial}
end
end
defp extract_content(data) do
case Jason.decode(data) do
{:ok, json} ->
get_in(json, ["choices", Access.at(0), "delta", "content"])
{:error, _} ->
nil
end
end
@doc """
Text-to-speech generation
"""
def tts(client, text, opts \\ []) do
voice = Keyword.get(opts, :voice, "alloy")
output_file = Keyword.get(opts, :output_file, "/tmp/speech.mp3")
if Enum.empty?(client.tts_endpoints) do
{:error, "No TTS endpoints available"}
else
endpoint = List.first(client.tts_endpoints)
url = "#{endpoint}/audio/speech"
request = %{
model: "tts-1",
voice: voice,
input: text
}
case HTTPoison.post(
url,
Jason.encode!(request),
[{"Content-Type", "application/json"}],
recv_timeout: client.timeout
) do
{:ok, %HTTPoison.Response{body: body}} ->
File.write!(output_file, body)
{:ok, output_file}
{:error, reason} ->
{:error, reason}
end
end
end
end
end

View file

@ -0,0 +1,26 @@
defmodule AIExamples.MixProject do
use Mix.Project
def project do
[
app: :ai_examples,
version: "0.1.0",
elixir: "~> 1.17",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
def application do
[
extra_applications: [:logger]
]
end
defp deps do
[
{:httpoison, "~> 2.2"},
{:jason, "~> 1.4"}
]
end
end

View file

@ -0,0 +1,62 @@
#!/usr/bin/env elixir
# Load dependencies
Mix.install([
{:httpoison, "~> 2.2"},
{:jason, "~> 1.4"}
])
# Load the module
Code.require_file("lib/uncloseai.ex", __DIR__)
# Demo program showing library usage
alias UncloseAI.Client
IO.puts("=== UncloseAI Elixir Client (with Streaming) ===\n")
# Initialize client
client = Client.new()
if Enum.empty?(client.models) do
IO.puts("ERROR: No models discovered")
System.halt(1)
end
# Non-streaming chat example
IO.puts("=== Non-Streaming Chat ===")
IO.puts("Model: #{Enum.at(client.models, 0).id}")
messages = [%{role: "user", content: "Explain quantum computing in one sentence"}]
case Client.chat(client, messages) do
{:ok, response} -> IO.puts("Response: #{response}\n")
{:error, reason} -> IO.puts("Error: #{inspect(reason)}\n")
end
# Streaming chat example
model_idx = if length(client.models) >= 2, do: 1, else: 0
IO.puts("=== Streaming Chat ===")
IO.puts("Model: #{Enum.at(client.models, model_idx).id}")
IO.write("Response: ")
messages = [%{role: "user", content: "Write a hello world program in Elixir"}]
client
|> Client.chat_stream(messages, model_idx: model_idx)
|> Enum.each(&IO.write/1)
IO.puts("\n")
# TTS example
if !Enum.empty?(client.tts_endpoints) do
IO.puts("=== TTS Speech Generation ===")
IO.puts("Model: tts-1")
case Client.tts(client, "Hello from UncloseAI Elixir client!",
output_file: "/tmp/speech.mp3") do
{:ok, file} -> IO.puts("Audio saved to #{file}")
{:error, reason} -> IO.puts("TTS failed: #{inspect(reason)}")
end
end
IO.puts("\n=== Examples Complete ===")