move Elixir HTTPoison client to httpoison subdirectory
This commit is contained in:
parent
bdf7875f31
commit
df3d76738e
4 changed files with 377 additions and 0 deletions
37
languages/elixir/httpoison/Dockerfile
Normal file
37
languages/elixir/httpoison/Dockerfile
Normal 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"]
|
||||
252
languages/elixir/httpoison/lib/uncloseai.ex
Normal file
252
languages/elixir/httpoison/lib/uncloseai.ex
Normal 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
|
||||
26
languages/elixir/httpoison/mix.exs
Normal file
26
languages/elixir/httpoison/mix.exs
Normal 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
|
||||
62
languages/elixir/httpoison/run.exs
Normal file
62
languages/elixir/httpoison/run.exs
Normal 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 ===")
|
||||
Loading…
Add table
Add a link
Reference in a new issue