diff --git a/languages/elixir/httpoison/Dockerfile b/languages/elixir/httpoison/Dockerfile new file mode 100644 index 0000000..b0d7903 --- /dev/null +++ b/languages/elixir/httpoison/Dockerfile @@ -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"] diff --git a/languages/elixir/httpoison/lib/uncloseai.ex b/languages/elixir/httpoison/lib/uncloseai.ex new file mode 100644 index 0000000..9ad1497 --- /dev/null +++ b/languages/elixir/httpoison/lib/uncloseai.ex @@ -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 diff --git a/languages/elixir/httpoison/mix.exs b/languages/elixir/httpoison/mix.exs new file mode 100644 index 0000000..38d9d5e --- /dev/null +++ b/languages/elixir/httpoison/mix.exs @@ -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 diff --git a/languages/elixir/httpoison/run.exs b/languages/elixir/httpoison/run.exs new file mode 100644 index 0000000..7151ba4 --- /dev/null +++ b/languages/elixir/httpoison/run.exs @@ -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 ===")