Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/SmartTalk.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@
"OpenAi": {
"BaseUrl": "",
"ApiKey": "",
"Organization": ""
"ApiKeySlave": "",
"ApiKeySlave2": ""
},
"PhoneOrder": {
"Robots": ""
Expand Down Expand Up @@ -142,7 +143,8 @@
"OpenAiForHk": {
"BaseUrl": "",
"ApiKey": "",
"Organization": ""
"ApiKeySlave": "",
"ApiKeySlave2": ""
},
"SchedulingRefreshCustomerItemsCacheRecurringJobCronExpression": "",
"SchedulingRefreshCustomerInfoCacheRecurringJobCronExpression": "",
Expand Down
71 changes: 71 additions & 0 deletions src/SmartTalk.Core/Extensions/OpenAiSettingsExtension.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using Serilog;
using SmartTalk.Core.Settings.OpenAi;

namespace SmartTalk.Core.Extensions;

public static class OpenAiSettingsExtension
{
public static async Task<T> ExecuteWithApiKeyFailoverAsync<T>(
this OpenAiSettings settings,
Func<string, Task<T>> executeAsync,
Func<T, bool> isSuccess = null,
bool isHk = false,
string operationName = "OpenAI request",
bool throwIfAllFailed = false,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(executeAsync);

var candidates = settings.GetApiKeyCandidates(isHk);
if (candidates.Count == 0)
{
var message = $"{operationName}: no OpenAI api keys configured.";

Log.Error(message);

if (throwIfAllFailed)
throw new InvalidOperationException(message);

return default;
}

var success = isSuccess ?? (result => result != null);
Exception lastException = null;

for (var index = 0; index < candidates.Count; index++)
{
cancellationToken.ThrowIfCancellationRequested();

try
{
var result = await executeAsync(candidates[index]).ConfigureAwait(false);

if (success(result))
{
if (index > 0)
Log.Information("{OperationName}: fallback to backup OpenAI key succeeded at index {Index}.", operationName, index);

return result;
}

Log.Warning("{OperationName}: OpenAI response was invalid with key index {Index}, trying next key.", operationName, index);
}
catch (Exception ex)
{
lastException = ex;
Log.Warning(ex, "{OperationName}: OpenAI call failed with key index {Index}, trying next key.", operationName, index);
}
}

if (throwIfAllFailed)
{
if (lastException != null)
throw new InvalidOperationException($"{operationName}: all OpenAI keys failed.", lastException);

throw new InvalidOperationException($"{operationName}: all OpenAI keys returned invalid results.");
}

return default;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Serilog;
using OpenAI.Chat;
using SmartTalk.Core.Extensions;
using SmartTalk.Core.Services.Caching;
using SmartTalk.Core.Utils;
using SmartTalk.Messages.Commands.AiSpeechAssistant;
Expand Down Expand Up @@ -102,8 +103,6 @@ private TranscriptionLanguage ConvertLanguageCode(string languageCode)

private async Task<string> DetectAudioLanguageAsync(byte[] audioContent, CancellationToken cancellationToken)
{
ChatClient client = new("gpt-4o-audio-preview", _openAiSettings.ApiKey);

var audioData = BinaryData.FromBytes(audioContent);
List<ChatMessage> messages =
[
Expand Down Expand Up @@ -149,11 +148,22 @@ If the audio is in Taiwanese Mandarin (Traditional Chinese), return: zh-TW

ChatCompletionOptions options = new() { ResponseModalities = ChatResponseModalities.Text };

ChatCompletion completion = await client.CompleteChatAsync(messages, options, cancellationToken);
var result = await _openAiSettings.ExecuteWithApiKeyFailoverAsync(
async apiKey =>
{
ChatClient client = new("gpt-4o-audio-preview", apiKey);
ChatCompletion completion = await client.CompleteChatAsync(messages, options, cancellationToken);

return completion.Content.FirstOrDefault()?.Text;
},
isSuccess: text => !string.IsNullOrWhiteSpace(text),
operationName: nameof(DetectAudioLanguageAsync),
throwIfAllFailed: true,
cancellationToken: cancellationToken).ConfigureAwait(false);

Log.Information("Detect the audio language: " + completion.Content.FirstOrDefault()?.Text);
Log.Information("Detect the audio language: " + result);

return completion.Content.FirstOrDefault()?.Text ?? "en";
return result ?? "en";
}

private async Task SendServerRestoreMessageIfNecessaryAsync(CancellationToken cancellationToken)
Expand All @@ -176,4 +186,4 @@ private async Task SendServerRestoreMessageIfNecessaryAsync(CancellationToken ca
// ignored
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using OpenAI.Chat;
using SmartTalk.Core.Extensions;
using SmartTalk.Core.Services.Http;
using SmartTalk.Core.Settings.OpenAi;
using SmartTalk.Messages.Commands.SpeechMatics;
Expand All @@ -21,8 +22,6 @@ public OpenAiAudioModelProvider(OpenAiSettings openAiSettings, ISmartTalkHttpCli

public async Task<string> ExtractAudioDataFromModelProviderAsync(AnalyzeAudioCommand command, BinaryData audioData, CancellationToken cancellationToken)
{
var client = new ChatClient("gpt-4o-audio-preview", _openAiSettings.ApiKey);

var messages = new List<ChatMessage>();
if (!string.IsNullOrWhiteSpace(command.SystemPrompt))
messages.Add(new SystemChatMessage(command.SystemPrompt));
Expand All @@ -34,13 +33,20 @@ public async Task<string> ExtractAudioDataFromModelProviderAsync(AnalyzeAudioCom
messages.Add(new UserChatMessage(command.UserPrompt));

var options = new ChatCompletionOptions { ResponseModalities = ChatResponseModalities.Text };

ChatCompletion completion = await client
.CompleteChatAsync(messages, options, cancellationToken)
.ConfigureAwait(false);

var resultText = completion.Content.FirstOrDefault()?.Text ?? string.Empty;

return resultText;

return await _openAiSettings.ExecuteWithApiKeyFailoverAsync(
async apiKey =>
{
var client = new ChatClient("gpt-4o-audio-preview", apiKey);
ChatCompletion completion = await client
.CompleteChatAsync(messages, options, cancellationToken)
.ConfigureAwait(false);

return completion.Content.FirstOrDefault()?.Text;
},
isSuccess: text => !string.IsNullOrWhiteSpace(text),
operationName: nameof(ExtractAudioDataFromModelProviderAsync),
throwIfAllFailed: true,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
using Microsoft.IdentityModel.Tokens;
using NAudio.Wave;
using Smarties.Messages.Enums.OpenAi;
using SmartTalk.Core.Extensions;
using SmartTalk.Core.Settings.OpenAi;
using Smarties.Messages.Requests.Ask;
using SmartTalk.Core.Domain.AutoTest;
Expand Down Expand Up @@ -487,8 +488,6 @@ private async Task<byte[]> ProcessAudioConversationAsync(List<byte[]> customerWa
new SystemChatMessage(prompt)
};

var client = new ChatClient("gpt-4o-audio-preview", _openAiSettings.ApiKey);

var options = new ChatCompletionOptions
{
ResponseModalities = ChatResponseModalities.Text | ChatResponseModalities.Audio,
Expand All @@ -513,7 +512,16 @@ private async Task<byte[]> ProcessAudioConversationAsync(List<byte[]> customerWa
BinaryData.FromBytes(await File.ReadAllBytesAsync(userWavFile, cancellationToken)),
ChatInputAudioFormat.Wav)));

var completion = await client.CompleteChatAsync(conversationHistory, options, cancellationToken);
var completion = await _openAiSettings.ExecuteWithApiKeyFailoverAsync(
async apiKey =>
{
var client = new ChatClient("gpt-4o-audio-preview", apiKey);
return await client.CompleteChatAsync(conversationHistory, options, cancellationToken);
},
isSuccess: response => response?.Value?.OutputAudio?.AudioBytes is { Length: > 0 },
operationName: $"{nameof(AutoTestSalesPhoneOrderProcessJobService)}.{nameof(ProcessAudioConversationAsync)}",
throwIfAllFailed: true,
cancellationToken: cancellationToken).ConfigureAwait(false);

var aiWavFile = Path.GetTempFileName() + ".wav";
await File.WriteAllBytesAsync(aiWavFile, completion.Value.OutputAudio.AudioBytes.ToArray(), cancellationToken);
Expand Down Expand Up @@ -612,13 +620,21 @@ private async Task<byte[]> MergeWavBytesAsync(List<byte[]> wavBytes, Cancellatio
private async Task<(string Report, List<AutoTestInputDetail> Order)> GenerateSalesAiOrderAsync(Domain.AISpeechAssistant.AiSpeechAssistant assistant, byte[] audio, CancellationToken cancellationToken)
{
var messages = await ConfigureRecordAnalyzePromptAsync(assistant, audio, cancellationToken).ConfigureAwait(false);

ChatClient client = new("gpt-4o-audio-preview", _openAiSettings.ApiKey);

ChatCompletionOptions options = new() { ResponseModalities = ChatResponseModalities.Text };

ChatCompletion completion = await client.CompleteChatAsync(messages, options, cancellationToken);
var report = completion.Content.FirstOrDefault()?.Text;
var report = await _openAiSettings.ExecuteWithApiKeyFailoverAsync(
async apiKey =>
{
ChatClient client = new("gpt-4o-audio-preview", apiKey);
ChatCompletion completion = await client.CompleteChatAsync(messages, options, cancellationToken);
return completion.Content.FirstOrDefault()?.Text;
},
isSuccess: text => !string.IsNullOrWhiteSpace(text),
operationName: $"{nameof(AutoTestSalesPhoneOrderProcessJobService)}.{nameof(GenerateSalesAiOrderAsync)}",
throwIfAllFailed: true,
cancellationToken: cancellationToken).ConfigureAwait(false);

Log.Information("sales record analyze report:" + report);

var soldToIds = new List<string>();
Expand Down Expand Up @@ -686,8 +702,6 @@ private async Task<List<ChatMessage>> ConfigureRecordAnalyzePromptAsync(

private async Task<List<ExtractedOrderDto>> ExtractAndMatchOrderItemsFromReportAsync(string reportText, List<(string Material, string MaterialDesc, DateTime? invoiceDate)> historyItems, CancellationToken cancellationToken)
{
var client = new ChatClient("gpt-4.1", _openAiSettings.ApiKey);

var materialListText = string.Join("\n",
historyItems.Select(x => $"{x.MaterialDesc} ({x.Material})【{x.invoiceDate}】"));

Expand All @@ -710,13 +724,22 @@ private async Task<List<ExtractedOrderDto>> ExtractAndMatchOrderItemsFromReportA
new UserChatMessage("客戶分析報告文本:\n" + reportText + "\n\n")
};

var completion = await client.CompleteChatAsync(messages,
new ChatCompletionOptions
var jsonResponse = await _openAiSettings.ExecuteWithApiKeyFailoverAsync(
async apiKey =>
{
ResponseModalities = ChatResponseModalities.Text,
ResponseFormat = ChatResponseFormat.CreateJsonObjectFormat()
}, cancellationToken).ConfigureAwait(false);
var jsonResponse = completion.Value.Content.FirstOrDefault()?.Text ?? "";
var client = new ChatClient("gpt-4.1", apiKey);
var completion = await client.CompleteChatAsync(messages,
new ChatCompletionOptions
{
ResponseModalities = ChatResponseModalities.Text,
ResponseFormat = ChatResponseFormat.CreateJsonObjectFormat()
}, cancellationToken).ConfigureAwait(false);
return completion.Value.Content.FirstOrDefault()?.Text;
},
isSuccess: text => !string.IsNullOrWhiteSpace(text),
operationName: $"{nameof(AutoTestSalesPhoneOrderProcessJobService)}.{nameof(ExtractAndMatchOrderItemsFromReportAsync)}",
throwIfAllFailed: true,
cancellationToken: cancellationToken).ConfigureAwait(false) ?? "";

Log.Information("AI JSON Response: {JsonResponse}", jsonResponse);

Expand Down
72 changes: 44 additions & 28 deletions src/SmartTalk.Core/Services/Http/Clients/OpenaiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Serilog;
using SmartTalk.Core.Ioc;
using System.Net.Http.Headers;
using SmartTalk.Core.Extensions;
using SmartTalk.Core.Settings.OpenAi;
using SmartTalk.Messages.Dto.OpenAi;

Expand Down Expand Up @@ -29,19 +30,26 @@ public OpenaiClient(OpenAiSettings openAiSettings, ISmartTalkHttpClientFactory s

public async Task<string> InitialRealtimeSessionsAsync(OpenAiRealtimeSessionDto request, CancellationToken cancellationToken)
{
var headers = new Dictionary<string, string>
{
{ "Authorization", $"Bearer {_openAiSettings.ApiKey}" }
};

var requestUrl = $"{_openAiSettings.BaseUrl}/v1/realtime/sessions";

var response = await _smartTalkHttpClientFactory.PostAsJsonAsync<OpenAiRealtimeSessionsResponseDto>(
requestUrl, request, headers: headers, cancellationToken: cancellationToken).ConfigureAwait(false);

Log.Information("Initial realtime session response: {@Response}", response);

return response?.ClientSecret?.Value;

return await _openAiSettings.ExecuteWithApiKeyFailoverAsync(
async apiKey =>
{
var headers = new Dictionary<string, string>
{
{ "Authorization", $"Bearer {apiKey}" }
};

var response = await _smartTalkHttpClientFactory.PostAsJsonAsync<OpenAiRealtimeSessionsResponseDto>(
requestUrl, request, headers: headers, cancellationToken: cancellationToken).ConfigureAwait(false);

Log.Information("Initial realtime session response: {@Response}", response);

return response?.ClientSecret?.Value;
},
isSuccess: token => !string.IsNullOrWhiteSpace(token),
operationName: nameof(InitialRealtimeSessionsAsync),
cancellationToken: cancellationToken).ConfigureAwait(false);
}

public async Task<string> RealtimeChatAsync(string sdp, string ephemeralToken, CancellationToken cancellationToken)
Expand All @@ -66,23 +74,31 @@ public async Task<string> RealtimeChatAsync(string sdp, string ephemeralToken, C

public async Task<byte[]> GenerateAudioChatCompletionAsync(BinaryData audioData, string prompt, string voice, CancellationToken cancellationToken)
{
ChatClient client = new("gpt-4o-audio-preview", _openAiSettings.ApiKey);
List<ChatMessage> messages =
[
new UserChatMessage(ChatMessageContentPart.CreateInputAudioPart(audioData, ChatInputAudioFormat.Wav)),
new UserChatMessage(prompt)
];

ChatCompletionOptions options = new()
{
ResponseModalities = ChatResponseModalities.Text | ChatResponseModalities.Audio,
AudioOptions = new ChatAudioOptions(new ChatOutputAudioVoice(voice), ChatOutputAudioFormat.Wav)
};
return await _openAiSettings.ExecuteWithApiKeyFailoverAsync(
async apiKey =>
{
ChatClient client = new("gpt-4o-audio-preview", apiKey);
List<ChatMessage> messages =
[
new UserChatMessage(ChatMessageContentPart.CreateInputAudioPart(audioData, ChatInputAudioFormat.Wav)),
new UserChatMessage(prompt)
];

ChatCompletionOptions options = new()
{
ResponseModalities = ChatResponseModalities.Text | ChatResponseModalities.Audio,
AudioOptions = new ChatAudioOptions(new ChatOutputAudioVoice(voice), ChatOutputAudioFormat.Wav)
};

ChatCompletion completion = await client.CompleteChatAsync(messages, options, cancellationToken);
ChatCompletion completion = await client.CompleteChatAsync(messages, options, cancellationToken);

Log.Information("Analyze record to repeat order: {@completion}", completion);
Log.Information("Analyze record to repeat order: {@completion}", completion);

return completion.OutputAudio.AudioBytes.ToArray();
return completion.OutputAudio?.AudioBytes?.ToArray();
},
isSuccess: bytes => bytes is { Length: > 0 },
operationName: nameof(GenerateAudioChatCompletionAsync),
throwIfAllFailed: true,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
}
Loading