Current section

Files

Jump to
ex_openai lib ex_openai.ex
Raw

lib/ex_openai.ex

defmodule ExOpenAI do
@moduledoc """
Provides API wrappers for OpenAI API
See https://beta.openai.com/docs/api-reference/introduction for further info on REST endpoints
"""
use Application
alias ExOpenAI.Config
def start(_type, _args) do
children = [Config]
opts = [strategy: :one_for_one, name: ExOpenAI.Supervisor]
Supervisor.start_link(children, opts)
end
defp parse_property(%{"name" => name, "description" => desc, "type" => "array"}) do
%{
name: name,
description: desc,
type: "array"
}
end
defp parse_property(%{"name" => name, "description" => desc, "oneOf" => oneOf}) do
# parse oneOf array into a list of schemas
# "oneOf" => [
# %{
# "default" => "",
# "example" => "I want to kill them.",
# "type" => "string"
# },
# %{
# "items" => %{
# "default" => "",
# "example" => "I want to kill them.",
# "type" => "string"
# },
# "type" => "array"
# }
# ],
%{
name: name,
description: desc,
type: "oneOf",
oneOf:
Enum.map(oneOf, fn item -> Map.put(parse_get_schema(item), :default, item["default"]) end)
}
end
defp parse_property(
%{
"name" => name,
"type" => type
} = args
) do
%{
type: type,
name: name,
# optional
description: Map.get(args, "description", ""),
# optional
example: Map.get(args, "example", "")
}
end
defp parse_property(args) do
IO.puts("Unknown property")
IO.inspect(args)
end
@doc """
Pattern 1:
properties:
object:
type: string
model:
type: string
data:
type: array
items:
type: object
properties:
index:
type: integer
object:
type: string
embedding:
type: array
items:
type: number
required:
- index
- object
- embedding
Pattern 2:
properties:
model: *model_configuration
input:
description: Desc
example: The quick brown fox jumped over the lazy dog
oneOf:
- type: string
default: ''
example: "This is a test."
- type: array
items:
type: string
default: ''
example: "This is a test."
- type: array
minItems: 1
items:
type: integer
example: [1212, 318, 257, 1332, 13]
- type: array
minItems: 1
items:
type: array
minItems: 1
items:
type: integer
example: "[[1212, 318, 257, 1332, 13]]"
user: *end_user_param_configuration
"""
defp parse_properties(props) when is_list(props) do
Enum.map(props, &parse_property(&1))
end
@doc """
In yaml:
```
CreateEmbeddingRequest:
type: object
additionalProperties: false
properties:
model: *model_configuration
input:
description: Desc
example: The quick brown fox jumped over the lazy dog
oneOf:
- type: string
default: ''
example: "This is a test."
- type: array
items:
type: string
default: ''
example: "This is a test."
- type: array
minItems: 1
items:
type: integer
example: "[1212, 318, 257, 1332, 13]"
- type: array
minItems: 1
items:
type: array
minItems: 1
items:
type: integer
example: "[[1212, 318, 257, 1332, 13]]"
user: *end_user_param_configuration
required:
- model
- input
```
"""
defp parse_component_schema(%{"properties" => props, "required" => required}) do
# optional params go into kw list
# required params go into arguments
# turn required stuf into hashmap for quicker access and merge into actual properties
required_map = required |> Enum.reduce(%{}, fn item, acc -> Map.put(acc, item, true) end)
merged_props =
props
|> Enum.map(fn {key, val} ->
case Map.has_key?(required_map, key) do
is_required -> Map.put(val, "required", is_required) |> Map.put("name", key)
end
end)
required_props = merged_props |> Enum.filter(&(Map.get(&1, "required") == true))
optional_props = merged_props |> Enum.filter(&(Map.get(&1, "required") == false))
%{
required_props: parse_properties(required_props),
optional_props: parse_properties(optional_props)
}
end
defp parse_component_schema(%{"properties" => props}),
do: parse_component_schema(%{"properties" => props, "required" => []})
@doc """
Converts a GET field schema definition into a %{type: "string", example: "string"}-like map
In yaml:
type: string
example:
text-davinci-001
"""
@spec parse_get_schema(map()) :: %{type: String.t(), example: String.t()}
defp parse_get_schema(%{"type" => type, "example" => example}) do
%{type: type, example: example}
end
defp parse_get_schema(%{"type" => _type} = args),
do: parse_get_schema(Map.put(args, "example", ""))
@doc """
Parses the given body construct into a map
In yaml:
requestBody:
required: true
content:
multipart/form-data:
schema:
$ref: '#/components/schemas/CreateFileRequest'
"""
defp parse_request_body(%{"required" => required, "content" => content}, component_mapping) do
{content_type, rest} =
content
|> Map.to_list()
|> List.first()
# resolve the object ref to the actual component to get the schema
ref =
rest["schema"]["$ref"]
|> String.replace_prefix("#/components/schemas/", "")
|> Macro.underscore()
case content_type do
"application/json" ->
%{
required?: required,
content_type: String.to_atom(content_type),
# rest: rest,
# ref: ref,
request_schema: Map.get(component_mapping, ref)
}
# TODO: other types like multipart/form-data is not supported yet
_ ->
:unsupported_content_type
end
end
defp parse_request_body(nil, _) do
nil
end
@doc """
Parses a list of properties into usable function arguments
Properties is a list of [%{"name" => "xxx", "in" => "xxx"}}], or im yaml
parameters:
- in: path
name: model
required: true
schema:
type: string
example:
text-davinci-001
description:
The ID of the model to use for this request
"""
@spec parse_get_arguments(any()) :: %{
name: String.t(),
in: String.t(),
type: String.t(),
example: String.t(),
required?: boolean()
}
defp parse_get_arguments(%{"name" => name, "schema" => schema, "in" => inarg} = args) do
Map.merge(
%{name: name, in: inarg, required?: Map.get(args, "required", false)},
parse_get_schema(schema)
)
end
defp parse_path(
path,
%{
"post" =>
%{
"operationId" => id,
"summary" => summary,
"requestBody" => body,
"responses" => responses,
"x-oaiMeta" => %{"group" => group}
} = args
},
component_mapping
) do
%{
endpoint: path,
name: Macro.underscore(id),
summary: summary,
deprecated?: Map.has_key?(args, "deprecated"),
arguments: Map.get(args, "parameters", []) |> Enum.map(&parse_get_arguments(&1)),
method: :post,
request_body: parse_request_body(body, component_mapping),
group: group
}
end
defp parse_path(
path,
%{
"post" =>
%{
"operationId" => id,
"summary" => summary,
"responses" => responses,
"x-oaiMeta" => meta
} = args
},
component_mapping
) do
parse_path(path, %{"post" => Map.put(args, "requestBody", nil)}, component_mapping)
end
defp parse_path(path, %{"post" => args}, component_mapping) do
# IO.puts("unhandled")
# IO.inspect(args)
nil
end
defp parse_path(path, %{"delete" => post}, component_mapping) do
end
@doc "parse GET functions and generate function definition"
defp parse_path(
path,
%{
"get" =>
%{
"operationId" => id,
"summary" => summary,
"responses" => responses,
"x-oaiMeta" => %{"group" => group}
} = args
},
component_mapping
) do
%{
endpoint: path,
name: Macro.underscore(id),
summary: summary,
deprecated?: Map.has_key?(args, "deprecated"),
arguments: Map.get(args, "parameters", []) |> Enum.map(&parse_get_arguments(&1)),
method: :get,
group: group
}
end
def get_documentation do
{:ok, yml} =
File.read!("docs.yaml")
|> YamlElixir.read_from_string()
component_mapping =
yml["components"]["schemas"]
|> Enum.reduce(%{}, fn {name, value}, acc ->
Map.put(acc, Macro.underscore(name), parse_component_schema(value))
end)
yml["paths"]
|> Enum.map(fn {path, field_data} -> parse_path(path, field_data, component_mapping) end)
|> Enum.filter(&(!is_nil(&1)))
# TODO: implement form-data
|> Enum.filter(&Kernel.!=(Map.get(&1, :request_body, nil), :unsupported_content_type))
end
def type_to_spec("number"), do: quote(do: float())
def type_to_spec("integer"), do: quote(do: integer())
def type_to_spec("boolean"), do: quote(do: boolean())
def type_to_spec("string"), do: quote(do: String.t())
# TODO: handle these types here better
def type_to_spec("array"), do: quote(do: list())
def type_to_spec("object"), do: quote(do: map())
def type_to_spec("oneOf"), do: quote(do: any())
def type_to_spec(x) do
IO.puts("unhandled: #{x}")
quote(do: any())
end
def generate_get_function() do
end
end
# Generate modules
ExOpenAI.get_documentation()
|> Enum.reduce(%{}, fn fx, acc ->
Map.put(acc, fx.group, [fx | Map.get(acc, fx.group, [])])
end)
|> Enum.each(fn {modname, functions} ->
# some-name -> ExOpenAI.SomeName
modname =
modname
|> String.replace("-", "_")
|> Macro.camelize()
|> String.to_atom()
|> (&Module.concat(ExOpenAI, &1)).()
defmodule modname do
functions
|> Enum.each(fn fx ->
%{
name: name,
summary: summary,
arguments: args,
endpoint: endpoint,
deprecated?: deprecated,
method: method
} = fx
name = String.to_atom(name)
merged_required_args =
case method do
:post ->
args ++
if(is_nil(fx.request_body),
do: [],
else: fx.request_body.request_schema.required_props
)
:get ->
Enum.filter(args, &Map.get(&1, :required?))
end
required_args_docstring =
Enum.map(merged_required_args, fn i ->
s = "- `#{i.name}`"
s = if Map.has_key?(i, :description), do: "#{s}: #{Map.get(i, :description)}", else: s
s =
if Map.get(i, :example, "") != "",
do: "#{s}\n\n*Example*: `#{Map.get(i, :example)}`",
else: s
s
end)
|> Enum.join("\n\n")
merged_optional_args =
case method do
:post ->
Enum.filter(args, &(!Map.get(&1, :required?))) ++
if(is_nil(fx.request_body),
do: [],
else: fx.request_body.request_schema.optional_props
)
:get ->
Enum.filter(args, &(!Map.get(&1, :required?)))
end
optional_args_docstring =
Enum.map(merged_optional_args, fn i ->
s = "- `#{i.name}`"
s = if Map.has_key?(i, :description), do: "#{s}: #{Map.get(i, :description)}", else: s
s =
if Map.get(i, :example, "") != "",
do: "#{s}\n\n*Example*: `#{Map.get(i, :example)}`",
else: s
s
end)
|> Enum.join("\n\n")
# convert non-optional args into [arg1, arg2, arg3] representation
arg_names =
merged_required_args
|> Enum.map(&(Map.get(&1, :name) |> String.to_atom() |> Macro.var(nil)))
# convert non-optional args into spec definition [String.t(), String.t(), etc.] representation
spec =
merged_required_args
|> Enum.map(fn item -> quote do: unquote(ExOpenAI.type_to_spec(item.type)) end)
# convert optional args into keyword list
optional_args =
merged_optional_args
|> Enum.reduce(nil, fn item, acc ->
name = item.name
type = item.type
case acc do
nil ->
quote do: {unquote(String.to_atom(name)), unquote(ExOpenAI.type_to_spec(type))}
val ->
quote do:
unquote(val)
| {unquote(String.to_atom(name)), unquote(ExOpenAI.type_to_spec(type))}
end
end)
@doc """
Endpoint `#{endpoint}`
#{summary}
---
### Required Arguments:
#{required_args_docstring}
### Optional Arguments:
#{optional_args_docstring}
"""
if deprecated, do: @deprecated("Deprecated by OpenAI")
@spec unquote(name)(unquote_splicing(spec), unquote([optional_args])) ::
{:ok, any()} | {:error, any()}
def unquote(name)(unquote_splicing(arg_names), opts \\ []) do
# store binding so we can't access args of the function later
binding = binding()
required_arguments = unquote(Macro.escape(merged_required_args))
optional_arguments = unquote(Macro.escape(merged_optional_args))
arguments = required_arguments ++ optional_arguments
url = "#{unquote(endpoint)}"
method = unquote(method)
# merge all passed args together, so opts + passed
all_passed_args = Keyword.merge(binding, opts) |> Keyword.drop([:opts])
# replace all args in the URL that are specified as 'path'
# for example: /model/{model_id} -> /model/123
url =
arguments
|> Enum.filter(&Kernel.==(Map.get(&1, :in, ""), "path"))
|> Enum.reduce(
url,
&String.replace(
&2,
"{#{&1.name}}",
Keyword.get(all_passed_args, String.to_atom(&1.name))
)
)
# iterate over all other arguments marked with in: "query", and append them to the query
# for example /model/123?foo=bar
query =
Enum.filter(arguments, &Kernel.==(Map.get(&1, :in, ""), "query"))
|> Enum.reduce(%{}, fn item, acc ->
Map.put(acc, item.name, Keyword.get(all_passed_args, String.to_atom(item.name)))
end)
|> URI.encode_query()
url = url <> "?" <> query
# construct body with the remaining args
body_params =
arguments
# filter by all the rest, so neither query nor path
|> Enum.filter(&Kernel.==(Map.get(&1, :in, ""), ""))
|> Enum.filter(&(!is_nil(Keyword.get(all_passed_args, String.to_atom(&1.name)))))
|> Enum.reduce(
[],
&Keyword.merge(&2, [
{
String.to_atom(&1.name),
Keyword.get(all_passed_args, String.to_atom(&1.name))
}
])
)
# construct body
case method do
:post ->
ExOpenAI.Client.api_post(url, body_params, opts)
:get ->
ExOpenAI.Client.api_get(url, opts)
end
end
# Module.create(Hoge1, c, Macro.Env.location(__ENV__))
end)
end
end)