Skip to content

Commit

Permalink
Support for Azure OpenAI service (#68)
Browse files Browse the repository at this point in the history
* Support for Azure OpenAI service.

- As described in #56, it is necessary to be able to control the full request path. The current implementation always adds `/v1/chat/completions` at the end, which doesn't work for Azure.
- On Azure, the `Authorization: Bearer` header must convey an Entra ID token, while an API key goes into the `api-key` HTTP header.

This commit is for the lastest version of instructor.

Co-Authored-By: Arman Mirkazemi <[email protected]>
Co-Authored-By: Dr. Christian Geuer-Pollmann <[email protected]>

* Allow access_tokens to be fetched dynamically.

* Sample for constantly keeping the access_token fresh

---------

Co-authored-by: Arman Mirkazemi <[email protected]>
  • Loading branch information
chgeuer and armanm authored Sep 10, 2024
1 parent c760841 commit 9a18473
Show file tree
Hide file tree
Showing 2 changed files with 174 additions and 6 deletions.
30 changes: 24 additions & 6 deletions lib/instructor/adapters/openai.ex
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ defmodule Instructor.Adapters.OpenAI do
fn ->
Task.async(fn ->
options =
Keyword.merge(options,
Keyword.merge(options, [
auth_header(config),
json: params,
auth: {:bearer, api_key(config)},
into: fn {:data, data}, {req, resp} ->
chunks =
data
Expand All @@ -53,7 +53,7 @@ defmodule Instructor.Adapters.OpenAI do

{:cont, {req, resp}}
end
)
])

Req.post(url(config), options)
send(pid, :done)
Expand All @@ -76,7 +76,7 @@ defmodule Instructor.Adapters.OpenAI do
end

defp do_chat_completion(params, config) do
options = Keyword.merge(http_options(config), json: params, auth: {:bearer, api_key(config)})
options = Keyword.merge(http_options(config), [auth_header(config), json: params])

case Req.post(url(config), options) do
{:ok, %{status: 200, body: body}} -> {:ok, body}
Expand All @@ -85,16 +85,34 @@ defmodule Instructor.Adapters.OpenAI do
end
end

defp url(config), do: api_url(config) <> "/v1/chat/completions"
defp url(config), do: api_url(config) <> api_path(config)
defp api_url(config), do: Keyword.fetch!(config, :api_url)
defp api_key(config), do: Keyword.fetch!(config, :api_key)
defp api_path(config), do: Keyword.fetch!(config, :api_path)

defp api_key(config) do
case Keyword.fetch!(config, :api_key) do
string when is_binary(string) -> string
fun when is_function(fun, 0) -> fun.()
end
end

defp auth_header(config) do
case Keyword.fetch!(config, :auth_mode) do
# https://learn.microsoft.com/en-us/azure/ai-services/openai/reference
:api_key_header -> {:headers, %{"api-key" => api_key(config)}}
_ -> {:auth, {:bearer, api_key(config)}}
end
end

defp http_options(config), do: Keyword.fetch!(config, :http_options)

defp config() do
base_config = Application.get_env(:instructor, :openai, [])

default_config = [
api_url: "https://api.openai.com",
api_path: "/v1/chat/completions",
auth_mode: :bearer,
http_options: [receive_timeout: 60_000]
]

Expand Down
150 changes: 150 additions & 0 deletions pages/azure-openai.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# Azure OpenAI

Configure your project like so to [issue requests against Azure OpenAI](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions), according to the [docs](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#authentication).

```elixir
azure_openai_endpoint = "https://contoso.openai.azure.com"
azure_openai_deployment_name = "contosodeployment123"
azure_openai_api_path = "/openai/deployments/#{azure_openai_deployment_name}/chat/completions?api-version=2024-02-01"
```

The Azure OpenAI service supports two authentication methods, API key and Entra ID. API key-based authN is conveyed in the `api-key` HTTP header, while Entra ID-issued access tokens go into the `Authorization: Bearer` header:

## API Key Authentication

```elixir
config: [
instructor: [
adapter: Instructor.Adapters.OpenAI,
openai: [
auth_mode: :api_key_header,
api_key: System.get_env("LB_AZURE_OPENAI_API_KEY"), # e.g. "c3829729deadbeef382938acdfee2987"
api_url: azure_openai_endpoint,
api_path:azure_openai_api_path
]
]
]
```

## Microsoft Entra ID authentication

The code below contains a simple GenServer that continuously refreshes the access token for a service principal. Instead of setting the configuration to a fixed access token (that would expire after an hour), the `api_key` is set to a /0-arity function that returns the most recently fetched access token.

```elixir
defmodule AzureServicePrincipalTokenRefresher do
use GenServer

@derive {Inspect,
only: [:tenant_id, :client_id, :scope, :error], except: [:client_secret, :access_token]}
@enforce_keys [:tenant_id, :client_id, :client_secret, :scope]
defstruct [:tenant_id, :client_id, :client_secret, :scope, :access_token, :error]

def get_token_func!(tenant_id, client_id, client_secret, scope) do
{:ok, pid} = __MODULE__.start_link(tenant_id, client_id, client_secret, scope)

fn ->
case __MODULE__.get_access_token(pid) do
{:ok, access_token} -> access_token
{:error, error} -> raise "Could not fetch Microsoft Entra ID token: #{inspect(error)}"
end
end
end

def start_link(tenant_id, client_id, client_secret, scope) do
GenServer.start_link(__MODULE__, %__MODULE__{
tenant_id: tenant_id,
client_id: client_id,
client_secret: client_secret,
scope: scope
})
end

def get_access_token(pid) do
GenServer.call(pid, :get_access_token)
end

@impl GenServer
def init(%__MODULE__{} = state) do
{:ok, state, {:continue, :fetch_token}}
end

@impl GenServer
def handle_call(:get_access_token, _from, %__MODULE__{} = state) do
case state do
%__MODULE__{access_token: access_token, error: nil} ->
{:reply, {:ok, access_token}, state}

%__MODULE__{access_token: nil, error: error} ->
{:reply, {:error, error}, state}
end
end

@impl GenServer
def handle_continue(:fetch_token, %__MODULE__{} = state) do
{:noreply, fetch_token(state)}
end

@impl GenServer
def handle_info(:refresh_token, %__MODULE__{} = state) do
{:noreply, fetch_token(state)}
end

defp fetch_token(%__MODULE__{} = state) do
%__MODULE__{
tenant_id: tenant_id,
client_id: client_id,
client_secret: client_secret,
scope: scope
} = state

case Req.post(
url: "https://login.microsoftonline.com/#{tenant_id}/oauth2/v2.0/token",
form: [
grant_type: "client_credentials",
scope: scope,
client_id: client_id,
client_secret: client_secret
]
) do
{:ok,
%Req.Response{
status: 200,
body: %{
"access_token" => access_token,
"expires_in" => expires_in
}
}} ->
fetch_new_token_timeout = to_timeout(%Duration{second: expires_in - 60})
Process.send_after(self(), :refresh_token, fetch_new_token_timeout)
%__MODULE__{state | access_token: access_token, error: nil}

{:ok, response} ->
%__MODULE__{state | access_token: nil, error: response}

{:error, error} ->
%__MODULE__{state | access_token: nil, error: error}
end
end
end
```

Then use the helper class to configure the dynamic credential:

```elixir
config: [
instructor: [
adapter: Instructor.Adapters.OpenAI,
openai: [
auth_mode: :bearer,
api_key: AzureServicePrincipalTokenRefresher.get_token_func!(
System.get_env("LB_AZURE_ENTRA_TENANT_ID"), # e.g. "contoso.onmicrosoft.com"
System.get_env("LB_AZURE_OPENAI_CLIENT_ID"), # e.g. "deadbeef-0000-4f13-afa9-c8a1e4087f97"
System.get_env("LB_AZURE_OPENAI_CLIENT_SECRET"), # e.g. "mEf8Q~.e2e8URInwinsermNe8wDewsedRitsen.."},
"https://cognitiveservices.azure.com/.default"
),
api_url: azure_openai_endpoint,
api_path: azure_openai_api_path
]
]
]
```

0 comments on commit 9a18473

Please sign in to comment.