From 9a184731bc0916dd765145c3519be85bf601a449 Mon Sep 17 00:00:00 2001 From: "Dr. Christian Geuer-Pollmann" Date: Tue, 10 Sep 2024 03:05:39 +0200 Subject: [PATCH] Support for Azure OpenAI service (#68) * Support for Azure OpenAI service. - As described in https://github.com/thmsmlr/instructor_ex/pull/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 Co-Authored-By: Dr. Christian Geuer-Pollmann * Allow access_tokens to be fetched dynamically. * Sample for constantly keeping the access_token fresh --------- Co-authored-by: Arman Mirkazemi --- lib/instructor/adapters/openai.ex | 30 ++++-- pages/azure-openai.md | 150 ++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 pages/azure-openai.md diff --git a/lib/instructor/adapters/openai.ex b/lib/instructor/adapters/openai.ex index c752e6d..4b110a3 100644 --- a/lib/instructor/adapters/openai.ex +++ b/lib/instructor/adapters/openai.ex @@ -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 @@ -53,7 +53,7 @@ defmodule Instructor.Adapters.OpenAI do {:cont, {req, resp}} end - ) + ]) Req.post(url(config), options) send(pid, :done) @@ -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} @@ -85,9 +85,25 @@ 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 @@ -95,6 +111,8 @@ defmodule Instructor.Adapters.OpenAI do default_config = [ api_url: "https://api.openai.com", + api_path: "/v1/chat/completions", + auth_mode: :bearer, http_options: [receive_timeout: 60_000] ] diff --git a/pages/azure-openai.md b/pages/azure-openai.md new file mode 100644 index 0000000..49ea121 --- /dev/null +++ b/pages/azure-openai.md @@ -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 + ] + ] +] +``` \ No newline at end of file