Current section
Files
Jump to
Current section
Files
lib/honchox.ex
defmodule Honchox do
@moduledoc """
Req-based Elixir client for the Honcho v3 HTTP API.
`Honchox` is the main entry point for interacting with the
[Honcho](https://honcho.dev) API. It wraps [`Req`](https://hexdocs.pm/req)
with built-in authentication, retry logic, and structured error handling.
## Creating a client
Use `new/1` to build a client struct. The API key can be passed directly or
read from the `HONCHO_API_KEY` environment variable:
client = Honchox.new(api_key: "sk-...")
The base URL defaults to `https://api.honcho.ai` and can be overridden via
the `:base_url` option or the `HONCHO_URL` environment variable.
## Workspace-scoped resources
Most resource endpoints (peers, sessions, conclusions) are scoped to a
workspace. Instead of storing the workspace ID in the client, you pass it
explicitly via the `:workspace_id` option on each call:
Honchox.Peers.get_or_create(client, "alice", workspace_id: "my-workspace")
## Resource modules
* `Honchox.Workspaces` — workspace lifecycle, search, queue status, dream scheduling
* `Honchox.Peers` — peer lifecycle, chat, context, representation, cards
* `Honchox.Sessions` — session lifecycle, messages, peers, context, files
* `Honchox.Conclusions` — conclusion CRUD and semantic search
* `Honchox.Observations` — backward-compatible aliases for conclusions
## Low-level HTTP
The `get/3`, `post/3`, `put/3`, `patch/3`, `delete/3`, and `upload/4`
functions are available for making direct API calls when the resource
modules don't cover a specific endpoint.
"""
@default_base_url "https://api.honcho.ai"
@default_timeout 60_000
@default_max_retries 2
@typedoc "A configured Honcho API client."
@type t :: %__MODULE__{
api_key: String.t() | nil,
jwt: String.t() | nil,
base_url: String.t(),
workspace_id: String.t() | nil,
req: Req.Request.t(),
timeout: pos_integer(),
max_retries: non_neg_integer()
}
@typedoc "Options accepted by `new/1`."
@type client_option ::
{:api_key, String.t()}
| {:jwt, String.t()}
| {:base_url, String.t()}
| {:timeout, pos_integer()}
| {:max_retries, non_neg_integer()}
defstruct [:api_key, :jwt, :base_url, :workspace_id, :req, :timeout, :max_retries]
@doc """
Builds a new Honcho API client.
Returns a `%Honchox{}` struct with a pre-configured `Req.Request` that
carries authentication and transport defaults.
## Options
* `:api_key` — Honcho API key. Falls back to the `HONCHO_API_KEY` env var
when `:jwt` is not provided.
* `:jwt` — scoped JWT bearer token. When provided, it is used for auth
instead of `:api_key`.
* `:base_url` — API base URL. Falls back to `HONCHO_URL` env var, then
`#{@default_base_url}`. (default: `"#{@default_base_url}"`)
* `:timeout` — receive timeout in milliseconds (default: `#{@default_timeout}`)
* `:max_retries` — max retries on transient failures (default: `#{@default_max_retries}`)
Any additional keyword options are forwarded to `Req.new/1`.
## Examples
client = Honchox.new(api_key: "sk-...")
client.base_url
#=> "https://api.honcho.ai"
client = Honchox.new(api_key: "sk-...", base_url: "https://api.honcho.dev")
client.base_url
#=> "https://api.honcho.dev"
"""
@spec new([client_option]) :: t()
def new(opts \\ []) when is_list(opts) do
jwt = Keyword.get(opts, :jwt)
api_key = if jwt, do: nil, else: required_config_value!(opts, :api_key, "HONCHO_API_KEY")
token = jwt || api_key
base_url = config_value(opts, :base_url, "HONCHO_URL", @default_base_url)
timeout = Keyword.get(opts, :timeout, @default_timeout)
max_retries = Keyword.get(opts, :max_retries, @default_max_retries)
req_opts =
opts
|> Keyword.drop([:api_key, :jwt, :workspace_id, :base_url, :timeout, :max_retries])
|> Keyword.put_new(:base_url, base_url)
|> Keyword.put_new(:auth, {:bearer, token})
|> Keyword.put_new(:receive_timeout, timeout)
|> Keyword.put_new(:retry, :transient)
|> Keyword.put_new(:max_retries, max_retries)
%__MODULE__{
api_key: api_key,
jwt: jwt,
base_url: base_url,
workspace_id: nil,
timeout: timeout,
max_retries: max_retries,
req: Req.new(req_opts)
}
end
@doc """
Sends a GET request to the given `path` with optional query `params`.
Returns `{:ok, body}` on success or `{:error, %Honchox.Error{}}` on failure.
"""
@spec get(t(), String.t(), keyword()) :: {:ok, term()} | {:error, Honchox.Error.t()}
def get(%__MODULE__{} = client, path, params \\ []) do
request(client, :get, path, params: params)
end
@doc """
Sends a POST request to the given `path` with a JSON `body`.
Returns `{:ok, body}` on success or `{:error, %Honchox.Error{}}` on failure.
"""
@spec post(t(), String.t(), term()) :: {:ok, term()} | {:error, Honchox.Error.t()}
def post(%__MODULE__{} = client, path, body) do
request(client, :post, path, json: body)
end
@doc """
Sends a PUT request to the given `path` with a JSON `body`.
Returns `{:ok, body}` on success or `{:error, %Honchox.Error{}}` on failure.
"""
@spec put(t(), String.t(), term()) :: {:ok, term()} | {:error, Honchox.Error.t()}
def put(%__MODULE__{} = client, path, body) do
request(client, :put, path, json: body)
end
@doc """
Sends a PATCH request to the given `path` with a JSON `body`.
Returns `{:ok, body}` on success or `{:error, %Honchox.Error{}}` on failure.
"""
@spec patch(t(), String.t(), term()) :: {:ok, term()} | {:error, Honchox.Error.t()}
def patch(%__MODULE__{} = client, path, body) do
request(client, :patch, path, json: body)
end
@doc """
Sends a DELETE request to the given `path` with optional query `params`.
Returns `{:ok, body}` on success or `{:error, %Honchox.Error{}}` on failure.
"""
@spec delete(t(), String.t(), keyword()) :: {:ok, term()} | {:error, Honchox.Error.t()}
def delete(%__MODULE__{} = client, path, params \\ []) do
request(client, :delete, path, params: params)
end
@doc """
Sends a multipart upload (POST) to the given `path`.
`fields` is a keyword list of form fields passed to Req's `:form_multipart`
option. Any extra `opts` are merged into the Req request options.
Returns `{:ok, body}` on success or `{:error, %Honchox.Error{}}` on failure.
"""
@spec upload(t(), String.t(), keyword(), keyword()) :: {:ok, term()} | {:error, Honchox.Error.t()}
def upload(%__MODULE__{} = client, path, fields, opts \\ []) do
request(client, :post, path, Keyword.merge(opts, form_multipart: fields))
end
defp request(%__MODULE__{} = client, method, path, request_opts) do
case Req.request(
client.req,
Keyword.put(request_opts, :method, method) |> Keyword.put(:url, path)
) do
{:ok, %Req.Response{} = response} ->
handle_response(response)
{:error, exception} ->
{:error, to_error(exception)}
end
end
defp handle_response(%Req.Response{status: status, body: body}) when status in 200..299 do
case {status, body} do
{204, ""} -> {:ok, nil}
{204, nil} -> {:ok, nil}
_ -> {:ok, body}
end
end
defp handle_response(%Req.Response{status: status, body: body}) do
{:error, Honchox.Error.http_error(status, body)}
end
defp to_error(%Req.TransportError{reason: reason} = exception) do
Honchox.Error.transport_error(reason, Exception.message(exception))
end
defp to_error(%Req.HTTPError{} = exception) do
%Honchox.Error{
message: Exception.message(exception),
status: nil,
code: exception.reason,
body: nil,
kind: :http_error
}
end
defp to_error(exception) do
Honchox.Error.request_error(exception)
end
defp config_value(opts, key, env_var, default \\ nil) do
case Keyword.fetch(opts, key) do
{:ok, value} when not is_nil(value) -> value
_ -> System.get_env(env_var) || default
end
end
defp required_config_value!(opts, key, env_var) do
value = config_value(opts, key, env_var)
if is_nil(value) do
raise ArgumentError, "missing required Honchox config: #{key}"
end
value
end
end