From 0d3f121cec7974fedc867a222d44e473395cb30d Mon Sep 17 00:00:00 2001 From: DavertMik Date: Sun, 2 Nov 2025 16:59:45 +0200 Subject: [PATCH 1/3] feat: Add Azure OpenAI enterprise authentication support Add OAuth 2.0 client credentials flow for Azure AD authentication while maintaining backward compatibility with API key authentication. Key changes: - Add tenant_id, client_id, client_secret schema properties - Implement fetch_azure_access_token for OAuth 2.0 flow - Update endpoint format to proper Azure OpenAI deployment structure - Dual authentication: enterprise OAuth (primary) + API key (fallback) - Model name moved from request body to URL path per Azure requirements This enables enterprise-grade authentication for production environments while preserving existing API key functionality. --- examples/azure_enterprise_auth.rb | 57 +++++++++ lib/intelligence/adapters/azure.rb | 181 ++++++++++++++++++----------- 2 files changed, 169 insertions(+), 69 deletions(-) create mode 100644 examples/azure_enterprise_auth.rb diff --git a/examples/azure_enterprise_auth.rb b/examples/azure_enterprise_auth.rb new file mode 100644 index 0000000..a0c1b40 --- /dev/null +++ b/examples/azure_enterprise_auth.rb @@ -0,0 +1,57 @@ +require 'intelligence' + +# Example: Azure OpenAI with Enterprise Authentication (OAuth 2.0 Client Credentials) +# This demonstrates how to use Azure Active Directory for enterprise authentication + +# Configuration using enterprise credentials +azure_enterprise = Intelligence::Adapter.build! :azure do + base_uri ENV['AZURE_OPENAI_ENDPOINT'] + + # Enterprise authentication (preferred for enterprise environments) + tenant_id ENV['AZURE_TENANT_ID'] + client_id ENV['AZURE_CLIENT_ID'] + client_secret ENV['AZURE_CLIENT_SECRET'] + + api_version '2024-02-01' + + chat_options do + model 'gpt-4o' # Azure deployment name + max_tokens 256 + temperature 0.7 + end +end + +# Alternative: Using API key authentication (fallback) +azure_api_key = Intelligence::Adapter.build! :azure do + base_uri ENV['AZURE_OPENAI_ENDPOINT'] + key ENV['AZURE_OPENAI_API_KEY'] + api_version '2024-02-01' + + chat_options do + model 'gpt-4o' + max_tokens 256 + temperature 0.7 + end +end + +# Test the enterprise authentication +conversation = Intelligence::Conversation.build do + system_message do + content text: "You are a helpful AI assistant." + end + + message do + role :user + content text: "Explain the benefits of using enterprise authentication with Azure OpenAI." + end +end + +request = Intelligence::ChatRequest.new(adapter: azure_enterprise) +response = request.chat(conversation) + +if response.success? + puts "Enterprise Authentication Response:" + puts response.result.text +else + puts "Error: #{response.result.error_description}" +end \ No newline at end of file diff --git a/lib/intelligence/adapters/azure.rb b/lib/intelligence/adapters/azure.rb index 56d1453..9d2d696 100644 --- a/lib/intelligence/adapters/azure.rb +++ b/lib/intelligence/adapters/azure.rb @@ -1,90 +1,133 @@ -require_relative 'generic' +require_relative 'generic/adapter' +require 'net/http' +require 'uri' +require 'json' module Intelligence module Azure - class Adapter < Generic::Adapter - CHAT_COMPLETIONS_PATH = 'chat/completions' - - schema do - # normalized properties, used by all endpoints - base_uri String, required: true - key String - api_version String, required: true, default: '2025-01-01-preview' - - # properties for generative text endpoints - chat_options do - - # normalized properties for openai generative text endpoint - model String, requried: true - max_tokens Integer, in: (0...) - temperature Float, in: (0..1) - top_p Float, in: (0..1) - seed Integer - stop String, array: true - stream [ TrueClass, FalseClass ] - - frequency_penalty Float, in: (-2..2) - presence_penalty Float, in: (-2..2) - - modalities String, array: true - response_format do - # 'text' and 'json_schema' are the only supported types - type Symbol, in: [ :text, :json_schema ] - json_schema - end - - # tools - tool array: true, as: :tools, &Tool.schema - # tool choice configuration - # - # `tool_choice :none` - # or - # ``` - # tool_choice :function do - # function :my_function - # end - # ``` - tool_choice arguments: :type do - type Symbol, in: [ :none, :auto, :required ] - function arguments: :name do - name Symbol - end - end - # the parallel_tool_calls parameter is only allowed when 'tools' are specified - parallel_tool_calls [ TrueClass, FalseClass ] - - end - end + class Adapter < Generic::Adapter + + CHAT_COMPLETIONS_PATH = 'openai/deployments' + + schema do + # normalized properties, used by all endpoints + base_uri String, required: true + key String + # Enterprise authentication properties + tenant_id String + client_id String + client_secret String + api_version String, required: true, default: '2024-02-01' + + # properties for generative text endpoints + chat_options do + # normalized properties for openai generative text endpoint + model String, required: true + max_tokens Integer, as: :max_completion_tokens + temperature Float, in: (0..1) + top_p Float, in: (0..1) + seed Integer + stop String, array: true + stream [ TrueClass, FalseClass ] + + frequency_penalty Float, in: (-2..2) + presence_penalty Float, in: (-2..2) + + modalities String, array: true + response_format do + # 'text' and 'json_schema' are the only supported types + type Symbol, in: [ :text, :json_schema ] + json_schema + end + + # Azure OpenAI variant properties + max_completion_tokens Integer + + # tools + tool array: true, as: :tools, &Tool.schema + # tool choice configuration + # + # `tool_choice :none` + # or + # ``` + # tool_choice :function do + # function :my_function + # end + # ``` + tool_choice arguments: :type do + type Symbol, in: [ :none, :auto, :required ] + function arguments: :name do + name Symbol + end + end + # the parallel_tool_calls parameter is only allowed when 'tools' are specified + parallel_tool_calls [ TrueClass, FalseClass ] + + end + end + + def fetch_azure_access_token(tenant_id, client_id, client_secret) + uri = URI("https://login.microsoftonline.com/#{tenant_id}/oauth2/v2.0/token") + res = Net::HTTP.post_form(uri, { + 'client_id' => client_id, + 'client_secret' => client_secret, + 'scope' => 'https://cognitiveservices.azure.com/.default', + 'grant_type' => 'client_credentials' + }) + raise res.body unless res.is_a?(Net::HTTPSuccess) + JSON.parse(res.body)['access_token'] + end def chat_request_uri( options = nil ) options = merge_options( @options, build_options( options ) ) base_uri = options[ :base_uri ] - if base_uri - # because URI join is dumb - base_uri = ( base_uri.end_with?( '/' ) ? base_uri : base_uri + '/' ) - uri = URI.join( base_uri, CHAT_COMPLETIONS_PATH ) + model = options.dig( :chat_options, :model ) + api_version = options[ :api_version ] - api_version = options[ :api_version ] || options[ 'api-version' ] - uri.query = [ uri.query, "api-version=#{ api_version }" ].compact.join( '&' ) + raise ArgumentError.new( "An Azure base_uri is required to build an Azure chat request." ) \ + if base_uri.nil? + raise ArgumentError.new( "A model is required to build an Azure chat request." ) \ + if model.nil? - uri - else - raise 'The Azure adapter requires a base_uri.' - end + # Azure OpenAI endpoint format: https://{endpoint}/openai/deployments/{model}/chat/completions?api-version={api_version} + base_uri = ( base_uri.end_with?( '/' ) ? base_uri : base_uri + '/' ) + "#{base_uri}#{CHAT_COMPLETIONS_PATH}/#{model}/chat/completions?api-version=#{api_version}" end def chat_request_headers( options = {} ) options = merge_options( @options, build_options( options ) ) result = {} - key = options[ :key ] + tenant_id = options[ :tenant_id ] + client_id = options[ :client_id ] + client_secret = options[ :client_secret ] - raise ArgumentError.new( "An Azure key is required to build an Azure request." ) \ - if key.nil? + if tenant_id && client_id && client_secret + # Use enterprise authentication (OAuth 2.0 client credentials) + token = fetch_azure_access_token(tenant_id, client_id, client_secret) + result[ 'Authorization' ] = "Bearer #{token}" + else + # Use API key authentication (fallback) + key = options[ :key ] + raise ArgumentError.new( "An Azure key is required to build an Azure request." ) \ + if key.nil? + result[ 'api-key' ] = key + end result[ 'Content-Type' ] = 'application/json' - result[ 'api-key' ] = key - result + result + end + + def chat_request_body( conversation, options = nil ) + tools = options&.delete( :tools ) || [] + options = merge_options( @options, build_options( options ) ) + + # Remove model from the request body as it's in the URL for Azure OpenAI + chat_options = options[ :chat_options ]&.dup || {} + chat_options.delete( :model ) + + # Use the parent implementation with cleaned options + super( conversation, { tools: tools }.merge( options.merge( chat_options: chat_options ) ) ) end end From ca9ec78e8904c787fb1d7e82de0021dd1a1c162e Mon Sep 17 00:00:00 2001 From: DavertMik Date: Sun, 2 Nov 2025 17:02:49 +0200 Subject: [PATCH 2/3] style: Remove unnecessary comments from Azure adapter --- lib/intelligence/adapters/azure.rb | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/lib/intelligence/adapters/azure.rb b/lib/intelligence/adapters/azure.rb index 9d2d696..22ae1d6 100644 --- a/lib/intelligence/adapters/azure.rb +++ b/lib/intelligence/adapters/azure.rb @@ -10,18 +10,14 @@ class Adapter < Generic::Adapter CHAT_COMPLETIONS_PATH = 'openai/deployments' schema do - # normalized properties, used by all endpoints base_uri String, required: true key String - # Enterprise authentication properties tenant_id String client_id String client_secret String api_version String, required: true, default: '2024-02-01' - # properties for generative text endpoints chat_options do - # normalized properties for openai generative text endpoint model String, required: true max_tokens Integer, as: :max_completion_tokens temperature Float, in: (0..1) @@ -35,32 +31,19 @@ class Adapter < Generic::Adapter modalities String, array: true response_format do - # 'text' and 'json_schema' are the only supported types type Symbol, in: [ :text, :json_schema ] json_schema end - # Azure OpenAI variant properties max_completion_tokens Integer - # tools tool array: true, as: :tools, &Tool.schema - # tool choice configuration - # - # `tool_choice :none` - # or - # ``` - # tool_choice :function do - # function :my_function - # end - # ``` tool_choice arguments: :type do type Symbol, in: [ :none, :auto, :required ] function arguments: :name do name Symbol end end - # the parallel_tool_calls parameter is only allowed when 'tools' are specified parallel_tool_calls [ TrueClass, FalseClass ] end @@ -89,7 +72,6 @@ def chat_request_uri( options = nil ) raise ArgumentError.new( "A model is required to build an Azure chat request." ) \ if model.nil? - # Azure OpenAI endpoint format: https://{endpoint}/openai/deployments/{model}/chat/completions?api-version={api_version} base_uri = ( base_uri.end_with?( '/' ) ? base_uri : base_uri + '/' ) "#{base_uri}#{CHAT_COMPLETIONS_PATH}/#{model}/chat/completions?api-version=#{api_version}" end @@ -103,11 +85,9 @@ def chat_request_headers( options = {} ) client_secret = options[ :client_secret ] if tenant_id && client_id && client_secret - # Use enterprise authentication (OAuth 2.0 client credentials) token = fetch_azure_access_token(tenant_id, client_id, client_secret) result[ 'Authorization' ] = "Bearer #{token}" else - # Use API key authentication (fallback) key = options[ :key ] raise ArgumentError.new( "An Azure key is required to build an Azure request." ) \ if key.nil? @@ -122,11 +102,9 @@ def chat_request_body( conversation, options = nil ) tools = options&.delete( :tools ) || [] options = merge_options( @options, build_options( options ) ) - # Remove model from the request body as it's in the URL for Azure OpenAI chat_options = options[ :chat_options ]&.dup || {} chat_options.delete( :model ) - # Use the parent implementation with cleaned options super( conversation, { tools: tools }.merge( options.merge( chat_options: chat_options ) ) ) end From 5267ee24942fc8aa44487a4fd489fe0360c8e3d4 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Sun, 2 Nov 2025 17:05:31 +0200 Subject: [PATCH 3/3] update: Use latest Azure OpenAI API version and GPT-5 in example - Update default API version to 2024-10-21 (latest GA release) - Update example model from gpt-4o to gpt-5 --- examples/azure_enterprise_auth.rb | 8 ++++---- lib/intelligence/adapters/azure.rb | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/azure_enterprise_auth.rb b/examples/azure_enterprise_auth.rb index a0c1b40..5152953 100644 --- a/examples/azure_enterprise_auth.rb +++ b/examples/azure_enterprise_auth.rb @@ -12,10 +12,10 @@ client_id ENV['AZURE_CLIENT_ID'] client_secret ENV['AZURE_CLIENT_SECRET'] - api_version '2024-02-01' + api_version '2024-10-21' chat_options do - model 'gpt-4o' # Azure deployment name + model 'gpt-5' # Azure deployment name max_tokens 256 temperature 0.7 end @@ -25,10 +25,10 @@ azure_api_key = Intelligence::Adapter.build! :azure do base_uri ENV['AZURE_OPENAI_ENDPOINT'] key ENV['AZURE_OPENAI_API_KEY'] - api_version '2024-02-01' + api_version '2024-10-21' chat_options do - model 'gpt-4o' + model 'gpt-5' max_tokens 256 temperature 0.7 end diff --git a/lib/intelligence/adapters/azure.rb b/lib/intelligence/adapters/azure.rb index 22ae1d6..9e2520c 100644 --- a/lib/intelligence/adapters/azure.rb +++ b/lib/intelligence/adapters/azure.rb @@ -15,7 +15,7 @@ class Adapter < Generic::Adapter tenant_id String client_id String client_secret String - api_version String, required: true, default: '2024-02-01' + api_version String, required: true, default: '2024-10-21' chat_options do model String, required: true