1181 lines
44 KiB
C#
1181 lines
44 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net.Http;
|
|
using System.Net.Http.Headers;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Threading.Tasks;
|
|
using D3ROVoice.Api.Data;
|
|
using D3ROVoice.Api.Dtos;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace D3ROVoice.Api.Services;
|
|
|
|
public interface ISttProxyService
|
|
{
|
|
Task<SttTranscribeResponse> TranscribeAsync(
|
|
int userId,
|
|
string userEmail,
|
|
SttTranscribeRequest request,
|
|
byte[]? audioBytes = null,
|
|
string? contentType = null,
|
|
string? fileName = null,
|
|
bool recordUsage = true
|
|
);
|
|
|
|
Task<SttTestResultDto> TestEndpointAsync(int endpointId, string? testApiKey = null, string? testEndpointUrl = null);
|
|
Task<List<SttProviderEndpointDto>> GetAllEndpointsAsync();
|
|
Task<SttProviderEndpointDto> CreateEndpointAsync(CreateSttEndpointDto dto);
|
|
Task<SttProviderEndpointDto> UpdateEndpointAsync(int id, UpdateSttEndpointDto dto);
|
|
Task<bool> DeleteEndpointAsync(int id);
|
|
Task<bool> SetDefaultEndpointAsync(int id);
|
|
Task<SttUsageReportDto> GetUsageReportAsync();
|
|
}
|
|
|
|
public sealed class SttProviderUnavailableException : Exception
|
|
{
|
|
public bool ProviderAttempted { get; }
|
|
|
|
public SttProviderUnavailableException(bool providerAttempted)
|
|
: base("STT service is temporarily unavailable.")
|
|
{
|
|
ProviderAttempted = providerAttempted;
|
|
}
|
|
}
|
|
|
|
public class SttProxyService : ISttProxyService
|
|
{
|
|
private readonly AppDbContext _db;
|
|
private readonly IHttpClientFactory _httpClientFactory;
|
|
private readonly ILogger<SttProxyService> _logger;
|
|
|
|
public SttProxyService(AppDbContext db, IHttpClientFactory httpClientFactory, ILogger<SttProxyService> logger)
|
|
{
|
|
_db = db;
|
|
_httpClientFactory = httpClientFactory;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<SttTranscribeResponse> TranscribeAsync(
|
|
int userId,
|
|
string userEmail,
|
|
SttTranscribeRequest request,
|
|
byte[]? audioBytes = null,
|
|
string? contentType = null,
|
|
string? fileName = null,
|
|
bool recordUsage = true
|
|
)
|
|
{
|
|
// 1. Resolve Audio Bytes
|
|
byte[] finalAudioBytes;
|
|
if (audioBytes != null && audioBytes.Length > 0)
|
|
{
|
|
finalAudioBytes = audioBytes;
|
|
}
|
|
else if (!string.IsNullOrWhiteSpace(request.AudioBase64))
|
|
{
|
|
try
|
|
{
|
|
var cleanBase64 = request.AudioBase64;
|
|
var commaIdx = cleanBase64.IndexOf(',');
|
|
if (commaIdx >= 0)
|
|
{
|
|
cleanBase64 = cleanBase64.Substring(commaIdx + 1);
|
|
}
|
|
finalAudioBytes = Convert.FromBase64String(cleanBase64);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to decode base64 audio data");
|
|
throw new ArgumentException("Invalid AudioBase64 data format.", ex);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
throw new ArgumentException("No audio data provided. Either AudioBase64 or file upload is required.");
|
|
}
|
|
|
|
var effectiveContentType = contentType ?? DetectContentType(finalAudioBytes, fileName);
|
|
var effectiveFileName = fileName ?? GetDefaultFileName(effectiveContentType);
|
|
var durationSeconds = EstimateAudioDuration(finalAudioBytes, effectiveContentType);
|
|
|
|
// 2. Resolve Candidate Endpoints (Primary + Fallbacks)
|
|
var candidates = await GetCandidateEndpointsAsync(request.Provider, request.ModelId);
|
|
if (candidates.Count == 0)
|
|
{
|
|
throw new SttProviderUnavailableException(providerAttempted: false);
|
|
}
|
|
|
|
var totalSw = Stopwatch.StartNew();
|
|
Exception? lastException = null;
|
|
var providerAttempted = false;
|
|
|
|
// 3. Failover Execution Chain
|
|
foreach (var endpoint in candidates)
|
|
{
|
|
var epSw = Stopwatch.StartNew();
|
|
try
|
|
{
|
|
if (!HasRequiredProviderConfiguration(endpoint))
|
|
{
|
|
_logger.LogWarning("Skipping STT provider {Provider}: required credentials are not configured",
|
|
endpoint.ProviderType);
|
|
continue;
|
|
}
|
|
|
|
providerAttempted = true;
|
|
_logger.LogInformation("Attempting STT transcription via provider {Provider} ({Name}, Model: {Model})",
|
|
endpoint.ProviderType, endpoint.Name, endpoint.ModelId);
|
|
|
|
var (transcript, confidence, lang, detectedDuration) = await ExecuteProviderTranscriptionAsync(
|
|
endpoint,
|
|
finalAudioBytes,
|
|
effectiveContentType,
|
|
effectiveFileName,
|
|
request
|
|
);
|
|
|
|
epSw.Stop();
|
|
totalSw.Stop();
|
|
|
|
var finalDuration = detectedDuration > 0 ? detectedDuration : durationSeconds;
|
|
var durationMinutes = (decimal)(finalDuration / 60.0);
|
|
var cost = Math.Max(0.000001m, durationMinutes * endpoint.CostPerMinute);
|
|
|
|
// Supabase stt-proxy owns user quota and usage for internal
|
|
// provider calls. Direct legacy callers may still record here.
|
|
if (recordUsage)
|
|
{
|
|
try
|
|
{
|
|
var usageLog = new SttUsageLog
|
|
{
|
|
UserId = userId,
|
|
UserEmail = userEmail,
|
|
EndpointId = endpoint.Id,
|
|
Provider = endpoint.ProviderType,
|
|
ModelId = endpoint.ModelId,
|
|
AudioDurationSeconds = Math.Round(finalDuration, 2),
|
|
CalculatedCost = cost,
|
|
LatencyMs = (int)epSw.ElapsedMilliseconds,
|
|
StatusCode = 200,
|
|
TranscriptPreview = transcript.Length > 200 ? transcript.Substring(0, 200) + "..." : transcript,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
_db.SttUsageLogs.Add(usageLog);
|
|
await _db.SaveChangesAsync();
|
|
}
|
|
catch (Exception dbEx)
|
|
{
|
|
_logger.LogWarning(dbEx, "Failed to save STT usage log to database");
|
|
}
|
|
}
|
|
|
|
return new SttTranscribeResponse(
|
|
Text: transcript,
|
|
Confidence: confidence > 0 ? confidence : 0.98,
|
|
Language: lang ?? endpoint.Language ?? "ko",
|
|
DurationSeconds: Math.Round(finalDuration, 2),
|
|
Provider: endpoint.ProviderType,
|
|
ModelId: endpoint.ModelId,
|
|
LatencyMs: epSw.ElapsedMilliseconds,
|
|
Cost: cost
|
|
);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
epSw.Stop();
|
|
lastException = ex;
|
|
var failureType = GetSafeFailureType(ex);
|
|
_logger.LogWarning("Provider {Provider} transcription failed after {Ms}ms ({FailureType}). Trying fallback...",
|
|
endpoint.ProviderType, epSw.ElapsedMilliseconds, failureType);
|
|
|
|
// Record error to ErrorLogs
|
|
try
|
|
{
|
|
_db.ErrorLogs.Add(new ServerErrorLog
|
|
{
|
|
ErrorType = "SttProviderError",
|
|
Message = $"STT provider request failed ({failureType}).",
|
|
StackTrace = null,
|
|
Endpoint = null,
|
|
CreatedAt = DateTime.UtcNow
|
|
});
|
|
await _db.SaveChangesAsync();
|
|
}
|
|
catch
|
|
{
|
|
// ignore error log DB failure
|
|
}
|
|
}
|
|
}
|
|
|
|
totalSw.Stop();
|
|
_logger.LogError("All STT candidate endpoints failed. Total duration: {Ms}ms; last failure type: {FailureType}",
|
|
totalSw.ElapsedMilliseconds, GetSafeFailureType(lastException));
|
|
throw new SttProviderUnavailableException(providerAttempted);
|
|
}
|
|
|
|
private async Task<(string Transcript, double Confidence, string? Language, double Duration)> ExecuteProviderTranscriptionAsync(
|
|
SttProviderEndpoint endpoint,
|
|
byte[] audioBytes,
|
|
string contentType,
|
|
string fileName,
|
|
SttTranscribeRequest request
|
|
)
|
|
{
|
|
var client = _httpClientFactory.CreateClient();
|
|
client.Timeout = TimeSpan.FromSeconds(90);
|
|
|
|
var providerType = endpoint.ProviderType.ToLowerInvariant();
|
|
var apiKey = endpoint.ApiKey?.Trim() ?? "";
|
|
|
|
if (RequiresApiKey(providerType) && string.IsNullOrWhiteSpace(apiKey))
|
|
{
|
|
throw new InvalidOperationException("STT provider credentials are not configured.");
|
|
}
|
|
|
|
switch (providerType)
|
|
{
|
|
case "groq":
|
|
case "openai":
|
|
case "custom":
|
|
{
|
|
return await CallOpenAiCompatibleSttAsync(client, endpoint, audioBytes, contentType, fileName, request);
|
|
}
|
|
case "deepgram":
|
|
{
|
|
return await CallDeepgramSttAsync(client, endpoint, audioBytes, contentType, request);
|
|
}
|
|
case "google":
|
|
{
|
|
return await CallGoogleSttAsync(client, endpoint, audioBytes, contentType, request);
|
|
}
|
|
case "assemblyai":
|
|
{
|
|
return await CallAssemblyAiSttAsync(client, endpoint, audioBytes, contentType, request);
|
|
}
|
|
case "azure":
|
|
{
|
|
return await CallAzureSttAsync(client, endpoint, audioBytes, contentType, request);
|
|
}
|
|
case "local-sidecar":
|
|
{
|
|
return await CallLocalSidecarSttAsync(client, endpoint, audioBytes, contentType, fileName, request);
|
|
}
|
|
default:
|
|
{
|
|
return await CallOpenAiCompatibleSttAsync(client, endpoint, audioBytes, contentType, fileName, request);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task<(string, double, string?, double)> CallOpenAiCompatibleSttAsync(
|
|
HttpClient client,
|
|
SttProviderEndpoint endpoint,
|
|
byte[] audioBytes,
|
|
string contentType,
|
|
string fileName,
|
|
SttTranscribeRequest request
|
|
)
|
|
{
|
|
using var form = new MultipartFormDataContent();
|
|
|
|
var audioContent = new ByteArrayContent(audioBytes);
|
|
audioContent.Headers.ContentType = new MediaTypeHeaderValue(contentType);
|
|
form.Add(audioContent, "file", fileName);
|
|
|
|
form.Add(new StringContent(endpoint.ModelId), "model");
|
|
|
|
var language = request.Language ?? endpoint.Language ?? "ko";
|
|
if (!string.IsNullOrWhiteSpace(language) && language != "auto")
|
|
{
|
|
form.Add(new StringContent(language), "language");
|
|
}
|
|
|
|
var prompt = request.InitialPrompt ?? endpoint.Prompt;
|
|
if (!string.IsNullOrWhiteSpace(prompt))
|
|
{
|
|
form.Add(new StringContent(prompt), "prompt");
|
|
}
|
|
|
|
var temp = request.Temperature ?? endpoint.Temperature;
|
|
form.Add(new StringContent(temp.ToString("0.0")), "temperature");
|
|
form.Add(new StringContent("verbose_json"), "response_format");
|
|
|
|
var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint.EndpointUrl)
|
|
{
|
|
Content = form
|
|
};
|
|
|
|
if (!string.IsNullOrWhiteSpace(endpoint.ApiKey))
|
|
{
|
|
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", endpoint.ApiKey);
|
|
}
|
|
|
|
ApplyExtraHeaders(httpRequest, endpoint.ExtraHeadersJson);
|
|
|
|
var httpResponse = await client.SendAsync(httpRequest);
|
|
var responseBody = await httpResponse.Content.ReadAsStringAsync();
|
|
|
|
if (!httpResponse.IsSuccessStatusCode)
|
|
{
|
|
throw new HttpRequestException($"STT upstream returned HTTP {httpResponse.StatusCode}.");
|
|
}
|
|
|
|
using var doc = JsonDocument.Parse(responseBody);
|
|
var root = doc.RootElement;
|
|
|
|
if (!root.TryGetProperty("text", out var textProp) || textProp.ValueKind != JsonValueKind.String)
|
|
{
|
|
throw new JsonException("STT upstream response did not contain a transcript.");
|
|
}
|
|
var text = textProp.GetString() ?? "";
|
|
|
|
var duration = 0.0;
|
|
if (root.TryGetProperty("duration", out var durProp))
|
|
{
|
|
duration = durProp.GetDouble();
|
|
}
|
|
|
|
var detectedLang = language;
|
|
if (root.TryGetProperty("language", out var langProp))
|
|
{
|
|
detectedLang = langProp.GetString() ?? language;
|
|
}
|
|
|
|
return (text.Trim(), 0.98, detectedLang, duration);
|
|
}
|
|
|
|
private async Task<(string, double, string?, double)> CallDeepgramSttAsync(
|
|
HttpClient client,
|
|
SttProviderEndpoint endpoint,
|
|
byte[] audioBytes,
|
|
string contentType,
|
|
SttTranscribeRequest request
|
|
)
|
|
{
|
|
var lang = request.Language ?? endpoint.Language ?? "ko";
|
|
var model = endpoint.ModelId ?? "nova-3";
|
|
var baseUrl = endpoint.EndpointUrl.TrimEnd('/');
|
|
var url = baseUrl.Contains("?")
|
|
? $"{baseUrl}&model={model}&language={lang}&smart_format=true&punctuate=true"
|
|
: $"{baseUrl}?model={model}&language={lang}&smart_format=true&punctuate=true";
|
|
|
|
var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
|
|
{
|
|
Content = new ByteArrayContent(audioBytes)
|
|
};
|
|
httpRequest.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
|
|
|
|
if (!string.IsNullOrWhiteSpace(endpoint.ApiKey))
|
|
{
|
|
httpRequest.Headers.Add("Authorization", $"Token {endpoint.ApiKey}");
|
|
}
|
|
|
|
ApplyExtraHeaders(httpRequest, endpoint.ExtraHeadersJson);
|
|
|
|
var response = await client.SendAsync(httpRequest);
|
|
var responseBody = await response.Content.ReadAsStringAsync();
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
|
|
}
|
|
|
|
using var doc = JsonDocument.Parse(responseBody);
|
|
var root = doc.RootElement;
|
|
|
|
string transcript;
|
|
var confidence = 0.95;
|
|
var duration = 0.0;
|
|
|
|
if (!root.TryGetProperty("results", out var results) ||
|
|
!results.TryGetProperty("channels", out var channels) ||
|
|
channels.GetArrayLength() == 0 ||
|
|
!channels[0].TryGetProperty("alternatives", out var alternatives) ||
|
|
alternatives.GetArrayLength() == 0 ||
|
|
!alternatives[0].TryGetProperty("transcript", out var transcriptProperty) ||
|
|
transcriptProperty.ValueKind != JsonValueKind.String)
|
|
{
|
|
throw new JsonException("STT upstream response did not contain a transcript.");
|
|
}
|
|
transcript = transcriptProperty.GetString() ?? "";
|
|
if (alternatives[0].TryGetProperty("confidence", out var confidenceProperty))
|
|
confidence = confidenceProperty.GetDouble();
|
|
|
|
if (root.TryGetProperty("metadata", out var meta) && meta.TryGetProperty("duration", out var dProp))
|
|
{
|
|
duration = dProp.GetDouble();
|
|
}
|
|
|
|
return (transcript.Trim(), confidence, lang, duration);
|
|
}
|
|
|
|
private async Task<(string, double, string?, double)> CallGoogleSttAsync(
|
|
HttpClient client,
|
|
SttProviderEndpoint endpoint,
|
|
byte[] audioBytes,
|
|
string contentType,
|
|
SttTranscribeRequest request
|
|
)
|
|
{
|
|
var base64Audio = Convert.ToBase64String(audioBytes);
|
|
var lang = request.Language ?? endpoint.Language ?? "ko";
|
|
|
|
// If endpoint is Gemini Flash Multimodal API
|
|
if (endpoint.EndpointUrl.Contains("generativelanguage.googleapis.com") || endpoint.ModelId.Contains("gemini"))
|
|
{
|
|
var apiKey = endpoint.ApiKey;
|
|
|
|
var geminiPayload = new
|
|
{
|
|
contents = new[]
|
|
{
|
|
new
|
|
{
|
|
parts = new object[]
|
|
{
|
|
new
|
|
{
|
|
inline_data = new
|
|
{
|
|
mime_type = contentType,
|
|
data = base64Audio
|
|
}
|
|
},
|
|
new
|
|
{
|
|
text = $"Transcribe this audio recording accurately in {lang}. Output only the transcribed plain text without any introductory or concluding comments."
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint.EndpointUrl)
|
|
{
|
|
Content = new StringContent(JsonSerializer.Serialize(geminiPayload), Encoding.UTF8, "application/json")
|
|
};
|
|
httpRequest.Headers.Add("X-Goog-Api-Key", apiKey);
|
|
|
|
var response = await client.SendAsync(httpRequest);
|
|
var responseBody = await response.Content.ReadAsStringAsync();
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
|
|
}
|
|
|
|
using var doc = JsonDocument.Parse(responseBody);
|
|
var root = doc.RootElement;
|
|
if (!root.TryGetProperty("candidates", out var cands) ||
|
|
cands.GetArrayLength() == 0 ||
|
|
!cands[0].TryGetProperty("content", out var content) ||
|
|
!content.TryGetProperty("parts", out var parts) ||
|
|
parts.GetArrayLength() == 0 ||
|
|
!parts[0].TryGetProperty("text", out var textProperty) ||
|
|
textProperty.ValueKind != JsonValueKind.String)
|
|
{
|
|
throw new JsonException("STT upstream response did not contain a transcript.");
|
|
}
|
|
var text = textProperty.GetString() ?? "";
|
|
|
|
return (text.Trim(), 0.98, lang, EstimateAudioDuration(audioBytes, contentType));
|
|
}
|
|
else
|
|
{
|
|
// Google Cloud Speech-to-Text v1
|
|
var apiKey = endpoint.ApiKey;
|
|
|
|
var gcpPayload = new
|
|
{
|
|
config = new
|
|
{
|
|
encoding = contentType.Contains("wav") ? "LINEAR16" : "WEBM_OPUS",
|
|
sampleRateHertz = 16000,
|
|
languageCode = lang == "ko" ? "ko-KR" : lang,
|
|
enableAutomaticPunctuation = true
|
|
},
|
|
audio = new
|
|
{
|
|
content = base64Audio
|
|
}
|
|
};
|
|
|
|
var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint.EndpointUrl)
|
|
{
|
|
Content = new StringContent(JsonSerializer.Serialize(gcpPayload), Encoding.UTF8, "application/json")
|
|
};
|
|
httpRequest.Headers.Add("X-Goog-Api-Key", apiKey);
|
|
|
|
var response = await client.SendAsync(httpRequest);
|
|
var responseBody = await response.Content.ReadAsStringAsync();
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
|
|
}
|
|
|
|
using var doc = JsonDocument.Parse(responseBody);
|
|
var root = doc.RootElement;
|
|
var transcript = "";
|
|
var confidence = 0.95;
|
|
|
|
if (!root.TryGetProperty("results", out var results) || results.ValueKind != JsonValueKind.Array)
|
|
{
|
|
throw new JsonException("STT upstream response did not contain transcription results.");
|
|
}
|
|
|
|
if (results.GetArrayLength() > 0)
|
|
{
|
|
var sb = new StringBuilder();
|
|
foreach (var res in results.EnumerateArray())
|
|
{
|
|
if (res.TryGetProperty("alternatives", out var alts) && alts.GetArrayLength() > 0)
|
|
{
|
|
var alt = alts[0];
|
|
if (alt.TryGetProperty("transcript", out var t)) sb.Append(t.GetString()).Append(' ');
|
|
if (alt.TryGetProperty("confidence", out var c)) confidence = c.GetDouble();
|
|
}
|
|
}
|
|
transcript = sb.ToString().Trim();
|
|
}
|
|
|
|
return (transcript, confidence, lang, EstimateAudioDuration(audioBytes, contentType));
|
|
}
|
|
}
|
|
|
|
private async Task<(string, double, string?, double)> CallAssemblyAiSttAsync(
|
|
HttpClient client,
|
|
SttProviderEndpoint endpoint,
|
|
byte[] audioBytes,
|
|
string contentType,
|
|
SttTranscribeRequest request
|
|
)
|
|
{
|
|
// 1. Upload audio
|
|
var uploadRequest = new HttpRequestMessage(HttpMethod.Post, "https://api.assemblyai.com/v2/upload")
|
|
{
|
|
Content = new ByteArrayContent(audioBytes)
|
|
};
|
|
uploadRequest.Headers.Add("Authorization", endpoint.ApiKey);
|
|
|
|
var uploadResp = await client.SendAsync(uploadRequest);
|
|
var uploadBody = await uploadResp.Content.ReadAsStringAsync();
|
|
if (!uploadResp.IsSuccessStatusCode)
|
|
{
|
|
throw new HttpRequestException($"STT upstream upload returned HTTP {uploadResp.StatusCode}.");
|
|
}
|
|
|
|
using var uploadDoc = JsonDocument.Parse(uploadBody);
|
|
var uploadUrl = uploadDoc.RootElement.GetProperty("upload_url").GetString()!;
|
|
|
|
// 2. Submit transcript job
|
|
var lang = request.Language ?? endpoint.Language ?? "ko";
|
|
var transcriptPayload = new
|
|
{
|
|
audio_url = uploadUrl,
|
|
language_code = lang,
|
|
punctuate = true,
|
|
format_text = true
|
|
};
|
|
|
|
var transRequest = new HttpRequestMessage(HttpMethod.Post, "https://api.assemblyai.com/v2/transcript")
|
|
{
|
|
Content = new StringContent(JsonSerializer.Serialize(transcriptPayload), Encoding.UTF8, "application/json")
|
|
};
|
|
transRequest.Headers.Add("Authorization", endpoint.ApiKey);
|
|
|
|
var transResp = await client.SendAsync(transRequest);
|
|
var transBody = await transResp.Content.ReadAsStringAsync();
|
|
if (!transResp.IsSuccessStatusCode)
|
|
{
|
|
throw new HttpRequestException($"STT upstream job returned HTTP {transResp.StatusCode}.");
|
|
}
|
|
|
|
using var transDoc = JsonDocument.Parse(transBody);
|
|
var id = transDoc.RootElement.GetProperty("id").GetString()!;
|
|
|
|
// 3. Poll for result (up to 20 seconds)
|
|
for (int i = 0; i < 20; i++)
|
|
{
|
|
await Task.Delay(1000);
|
|
var pollReq = new HttpRequestMessage(HttpMethod.Get, $"https://api.assemblyai.com/v2/transcript/{id}");
|
|
pollReq.Headers.Add("Authorization", endpoint.ApiKey);
|
|
|
|
var pollResp = await client.SendAsync(pollReq);
|
|
var pollBody = await pollResp.Content.ReadAsStringAsync();
|
|
|
|
if (!pollResp.IsSuccessStatusCode)
|
|
{
|
|
throw new HttpRequestException($"STT upstream poll returned HTTP {pollResp.StatusCode}.");
|
|
}
|
|
|
|
using var pollDoc = JsonDocument.Parse(pollBody);
|
|
var status = pollDoc.RootElement.GetProperty("status").GetString();
|
|
|
|
if (status == "completed")
|
|
{
|
|
if (!pollDoc.RootElement.TryGetProperty("text", out var textProperty) ||
|
|
textProperty.ValueKind != JsonValueKind.String)
|
|
{
|
|
throw new JsonException("STT upstream response did not contain a transcript.");
|
|
}
|
|
var text = textProperty.GetString() ?? "";
|
|
var confidence = 0.95;
|
|
if (pollDoc.RootElement.TryGetProperty("confidence", out var c)) confidence = c.GetDouble();
|
|
var duration = 0.0;
|
|
if (pollDoc.RootElement.TryGetProperty("audio_duration", out var d)) duration = d.GetDouble();
|
|
return (text, confidence, lang, duration);
|
|
}
|
|
if (status == "error")
|
|
{
|
|
throw new HttpRequestException("STT upstream processing failed.");
|
|
}
|
|
}
|
|
|
|
throw new TimeoutException("AssemblyAI transcript timed out.");
|
|
}
|
|
|
|
private async Task<(string, double, string?, double)> CallAzureSttAsync(
|
|
HttpClient client,
|
|
SttProviderEndpoint endpoint,
|
|
byte[] audioBytes,
|
|
string contentType,
|
|
SttTranscribeRequest request
|
|
)
|
|
{
|
|
var lang = request.Language ?? endpoint.Language ?? "ko-KR";
|
|
if (lang == "ko") lang = "ko-KR";
|
|
|
|
var url = endpoint.EndpointUrl.Contains("?")
|
|
? $"{endpoint.EndpointUrl}&language={lang}&format=detailed"
|
|
: $"{endpoint.EndpointUrl}?language={lang}&format=detailed";
|
|
|
|
var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
|
|
{
|
|
Content = new ByteArrayContent(audioBytes)
|
|
};
|
|
httpRequest.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType.Contains("wav") ? "audio/wav" : "audio/webm");
|
|
|
|
if (!string.IsNullOrWhiteSpace(endpoint.ApiKey))
|
|
{
|
|
httpRequest.Headers.Add("Ocp-Apim-Subscription-Key", endpoint.ApiKey);
|
|
}
|
|
|
|
var response = await client.SendAsync(httpRequest);
|
|
var responseBody = await response.Content.ReadAsStringAsync();
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
|
|
}
|
|
|
|
using var doc = JsonDocument.Parse(responseBody);
|
|
var root = doc.RootElement;
|
|
string text;
|
|
var confidence = 0.95;
|
|
|
|
if (root.TryGetProperty("DisplayText", out var dt) && dt.ValueKind == JsonValueKind.String)
|
|
{
|
|
text = dt.GetString() ?? "";
|
|
}
|
|
else if (root.TryGetProperty("NBest", out var nbest) && nbest.GetArrayLength() > 0)
|
|
{
|
|
var best = nbest[0];
|
|
if (!best.TryGetProperty("Display", out var display) || display.ValueKind != JsonValueKind.String)
|
|
{
|
|
throw new JsonException("STT upstream response did not contain a transcript.");
|
|
}
|
|
text = display.GetString() ?? "";
|
|
if (best.TryGetProperty("Confidence", out var c)) confidence = c.GetDouble();
|
|
}
|
|
else
|
|
{
|
|
throw new JsonException("STT upstream response did not contain a transcript.");
|
|
}
|
|
|
|
return (text, confidence, lang, EstimateAudioDuration(audioBytes, contentType));
|
|
}
|
|
|
|
private async Task<(string, double, string?, double)> CallLocalSidecarSttAsync(
|
|
HttpClient client,
|
|
SttProviderEndpoint endpoint,
|
|
byte[] audioBytes,
|
|
string contentType,
|
|
string fileName,
|
|
SttTranscribeRequest request
|
|
)
|
|
{
|
|
using var form = new MultipartFormDataContent();
|
|
var audioContent = new ByteArrayContent(audioBytes);
|
|
audioContent.Headers.ContentType = new MediaTypeHeaderValue(contentType);
|
|
form.Add(audioContent, "file", fileName);
|
|
|
|
form.Add(new StringContent(endpoint.ModelId), "model");
|
|
form.Add(new StringContent(request.Language ?? endpoint.Language ?? "ko"), "language");
|
|
|
|
var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint.EndpointUrl)
|
|
{
|
|
Content = form
|
|
};
|
|
|
|
if (!string.IsNullOrWhiteSpace(endpoint.ApiKey))
|
|
{
|
|
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", endpoint.ApiKey);
|
|
}
|
|
|
|
var response = await client.SendAsync(httpRequest);
|
|
var responseBody = await response.Content.ReadAsStringAsync();
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
|
|
}
|
|
|
|
using var doc = JsonDocument.Parse(responseBody);
|
|
var root = doc.RootElement;
|
|
|
|
if (!root.TryGetProperty("text", out var tProp) || tProp.ValueKind != JsonValueKind.String)
|
|
{
|
|
throw new JsonException("STT upstream response did not contain a transcript.");
|
|
}
|
|
var text = tProp.GetString() ?? "";
|
|
var duration = EstimateAudioDuration(audioBytes, contentType);
|
|
if (root.TryGetProperty("duration", out var dProp)) duration = dProp.GetDouble();
|
|
|
|
return (text, 0.99, endpoint.Language, duration);
|
|
}
|
|
|
|
public async Task<SttTestResultDto> TestEndpointAsync(int endpointId, string? testApiKey = null, string? testEndpointUrl = null)
|
|
{
|
|
SttProviderEndpoint? endpoint = null;
|
|
if (endpointId > 0)
|
|
{
|
|
endpoint = await _db.SttProviderEndpoints.FindAsync(endpointId);
|
|
}
|
|
|
|
if (endpoint == null)
|
|
{
|
|
endpoint = new SttProviderEndpoint
|
|
{
|
|
Id = 0,
|
|
Name = "Direct Endpoint Test",
|
|
ProviderType = "groq",
|
|
EndpointUrl = testEndpointUrl ?? "https://api.groq.com/openai/v1/audio/transcriptions",
|
|
ApiKey = testApiKey ?? "",
|
|
ModelId = "whisper-large-v3-turbo",
|
|
Method = "multipart",
|
|
Language = "ko",
|
|
CostPerMinute = 0.000500m
|
|
};
|
|
}
|
|
else
|
|
{
|
|
if (testApiKey != null) endpoint.ApiKey = testApiKey;
|
|
if (testEndpointUrl != null) endpoint.EndpointUrl = testEndpointUrl;
|
|
}
|
|
|
|
var sw = Stopwatch.StartNew();
|
|
try
|
|
{
|
|
// Generate a synthetic 0.5-second silent 16kHz mono WAV sample for testing connection
|
|
var syntheticWav = GenerateSyntheticTestWav(0.5);
|
|
|
|
var (transcript, _, _, _) = await ExecuteProviderTranscriptionAsync(
|
|
endpoint,
|
|
syntheticWav,
|
|
"audio/wav",
|
|
"test_audio.wav",
|
|
new SttTranscribeRequest(Language: endpoint.Language ?? "ko")
|
|
);
|
|
|
|
sw.Stop();
|
|
return new SttTestResultDto(
|
|
Success: true,
|
|
Message: $"연결 및 인증 성공 (응답 시간: {sw.ElapsedMilliseconds}ms)",
|
|
LatencyMs: sw.ElapsedMilliseconds,
|
|
TranscriptPreview: string.IsNullOrWhiteSpace(transcript) ? "[무음 또는 성공적 응답 수신]" : transcript,
|
|
Provider: endpoint.ProviderType,
|
|
ModelId: endpoint.ModelId
|
|
);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
sw.Stop();
|
|
return new SttTestResultDto(
|
|
Success: false,
|
|
Message: $"연결 실패: {GetSafeFailureType(ex)}",
|
|
LatencyMs: sw.ElapsedMilliseconds,
|
|
TranscriptPreview: null,
|
|
Provider: endpoint.ProviderType,
|
|
ModelId: endpoint.ModelId
|
|
);
|
|
}
|
|
}
|
|
|
|
public async Task<List<SttProviderEndpointDto>> GetAllEndpointsAsync()
|
|
{
|
|
var endpoints = await _db.SttProviderEndpoints
|
|
.OrderBy(e => e.FallbackPriority)
|
|
.ThenByDescending(e => e.IsDefault)
|
|
.ToListAsync();
|
|
|
|
return endpoints.Select(MapToDto).ToList();
|
|
}
|
|
|
|
public async Task<SttProviderEndpointDto> CreateEndpointAsync(CreateSttEndpointDto dto)
|
|
{
|
|
if (dto.IsDefault)
|
|
{
|
|
// Unset previous defaults
|
|
var existingDefaults = await _db.SttProviderEndpoints.Where(e => e.IsDefault).ToListAsync();
|
|
foreach (var ep in existingDefaults) ep.IsDefault = false;
|
|
}
|
|
|
|
var endpoint = new SttProviderEndpoint
|
|
{
|
|
Name = dto.Name.Trim(),
|
|
ProviderType = dto.ProviderType.Trim().ToLowerInvariant(),
|
|
EndpointUrl = dto.EndpointUrl.Trim(),
|
|
ApiKey = dto.ApiKey?.Trim() ?? "",
|
|
ModelId = string.IsNullOrWhiteSpace(dto.ModelId) ? "whisper-large-v3-turbo" : dto.ModelId.Trim(),
|
|
Method = string.IsNullOrWhiteSpace(dto.Method) ? "multipart" : dto.Method.Trim(),
|
|
Language = string.IsNullOrWhiteSpace(dto.Language) ? "ko" : dto.Language.Trim(),
|
|
Prompt = dto.Prompt?.Trim(),
|
|
Temperature = dto.Temperature,
|
|
CostPerMinute = dto.CostPerMinute,
|
|
CostPerSecond = dto.CostPerSecond > 0 ? dto.CostPerSecond : dto.CostPerMinute / 60m,
|
|
IsDefault = dto.IsDefault,
|
|
IsActive = dto.IsActive,
|
|
FallbackPriority = dto.FallbackPriority,
|
|
ExtraHeadersJson = dto.ExtraHeadersJson,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
_db.SttProviderEndpoints.Add(endpoint);
|
|
await _db.SaveChangesAsync();
|
|
|
|
return MapToDto(endpoint);
|
|
}
|
|
|
|
public async Task<SttProviderEndpointDto> UpdateEndpointAsync(int id, UpdateSttEndpointDto dto)
|
|
{
|
|
var endpoint = await _db.SttProviderEndpoints.FindAsync(id);
|
|
if (endpoint == null)
|
|
{
|
|
throw new KeyNotFoundException($"STT Endpoint with ID {id} not found.");
|
|
}
|
|
|
|
if (dto.IsDefault && !endpoint.IsDefault)
|
|
{
|
|
var existingDefaults = await _db.SttProviderEndpoints.Where(e => e.IsDefault && e.Id != id).ToListAsync();
|
|
foreach (var ep in existingDefaults) ep.IsDefault = false;
|
|
}
|
|
|
|
endpoint.Name = dto.Name.Trim();
|
|
endpoint.ProviderType = dto.ProviderType.Trim().ToLowerInvariant();
|
|
endpoint.EndpointUrl = dto.EndpointUrl.Trim();
|
|
if (dto.ApiKey != null) endpoint.ApiKey = dto.ApiKey.Trim();
|
|
endpoint.ModelId = dto.ModelId.Trim();
|
|
endpoint.Method = dto.Method.Trim();
|
|
endpoint.Language = dto.Language?.Trim() ?? "ko";
|
|
endpoint.Prompt = dto.Prompt?.Trim();
|
|
endpoint.Temperature = dto.Temperature;
|
|
endpoint.CostPerMinute = dto.CostPerMinute;
|
|
endpoint.CostPerSecond = dto.CostPerSecond > 0 ? dto.CostPerSecond : dto.CostPerMinute / 60m;
|
|
endpoint.IsDefault = dto.IsDefault;
|
|
endpoint.IsActive = dto.IsActive;
|
|
endpoint.FallbackPriority = dto.FallbackPriority;
|
|
endpoint.ExtraHeadersJson = dto.ExtraHeadersJson;
|
|
endpoint.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _db.SaveChangesAsync();
|
|
return MapToDto(endpoint);
|
|
}
|
|
|
|
public async Task<bool> DeleteEndpointAsync(int id)
|
|
{
|
|
var endpoint = await _db.SttProviderEndpoints.FindAsync(id);
|
|
if (endpoint == null) return false;
|
|
|
|
_db.SttProviderEndpoints.Remove(endpoint);
|
|
await _db.SaveChangesAsync();
|
|
return true;
|
|
}
|
|
|
|
public async Task<bool> SetDefaultEndpointAsync(int id)
|
|
{
|
|
var allEndpoints = await _db.SttProviderEndpoints.ToListAsync();
|
|
var target = allEndpoints.FirstOrDefault(e => e.Id == id);
|
|
if (target == null) return false;
|
|
|
|
foreach (var ep in allEndpoints)
|
|
{
|
|
ep.IsDefault = (ep.Id == id);
|
|
}
|
|
|
|
await _db.SaveChangesAsync();
|
|
return true;
|
|
}
|
|
|
|
public async Task<SttUsageReportDto> GetUsageReportAsync()
|
|
{
|
|
var logs = await _db.SttUsageLogs.AsNoTracking().ToListAsync();
|
|
|
|
var totalTranscriptions = logs.Count;
|
|
var totalAudioMinutes = logs.Sum(l => l.AudioDurationSeconds) / 60.0;
|
|
var totalCost = logs.Sum(l => l.CalculatedCost);
|
|
var avgLatencyMs = logs.Count > 0 ? logs.Average(l => l.LatencyMs) : 0;
|
|
|
|
var providerSummaries = logs
|
|
.GroupBy(l => new { l.Provider, l.ModelId })
|
|
.Select(g => new SttProviderUsageSummaryDto(
|
|
Provider: g.Key.Provider,
|
|
ModelId: g.Key.ModelId,
|
|
TotalRequests: g.Count(),
|
|
TotalAudioMinutes: Math.Round(g.Sum(x => x.AudioDurationSeconds) / 60.0, 2),
|
|
TotalCost: g.Sum(x => x.CalculatedCost),
|
|
AvgLatencyMs: Math.Round(g.Average(x => x.LatencyMs), 1)
|
|
))
|
|
.OrderByDescending(p => p.TotalCost)
|
|
.ToList();
|
|
|
|
var userSummaries = logs
|
|
.GroupBy(l => new { l.UserId, l.UserEmail })
|
|
.Select(g => new SttUserUsageSummaryDto(
|
|
UserId: g.Key.UserId,
|
|
Email: g.Key.UserEmail,
|
|
TotalRequests: g.Count(),
|
|
TotalAudioMinutes: Math.Round(g.Sum(x => x.AudioDurationSeconds) / 60.0, 2),
|
|
TotalCost: g.Sum(x => x.CalculatedCost)
|
|
))
|
|
.OrderByDescending(u => u.TotalCost)
|
|
.ToList();
|
|
|
|
return new SttUsageReportDto(
|
|
TotalTranscriptions: totalTranscriptions,
|
|
TotalAudioMinutes: Math.Round(totalAudioMinutes, 2),
|
|
TotalCost: totalCost,
|
|
AvgLatencyMs: Math.Round(avgLatencyMs, 1),
|
|
ProviderSummaries: providerSummaries,
|
|
UserSummaries: userSummaries
|
|
);
|
|
}
|
|
|
|
private async Task<List<SttProviderEndpoint>> GetCandidateEndpointsAsync(string? requestedProvider, string? requestedModel)
|
|
{
|
|
var query = _db.SttProviderEndpoints.Where(e => e.IsActive);
|
|
|
|
if (!string.IsNullOrWhiteSpace(requestedProvider))
|
|
{
|
|
var match = await query.FirstOrDefaultAsync(e => e.ProviderType.ToLower() == requestedProvider.ToLower());
|
|
if (match != null)
|
|
{
|
|
var others = await query.Where(e => e.Id != match.Id).OrderBy(e => e.FallbackPriority).ToListAsync();
|
|
others.Insert(0, match);
|
|
return others;
|
|
}
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(requestedModel))
|
|
{
|
|
var match = await query.FirstOrDefaultAsync(e => e.ModelId.ToLower() == requestedModel.ToLower());
|
|
if (match != null)
|
|
{
|
|
var others = await query.Where(e => e.Id != match.Id).OrderBy(e => e.FallbackPriority).ToListAsync();
|
|
others.Insert(0, match);
|
|
return others;
|
|
}
|
|
}
|
|
|
|
// Ordered by isDefault first, then FallbackPriority
|
|
return await query
|
|
.OrderByDescending(e => e.IsDefault)
|
|
.ThenBy(e => e.FallbackPriority)
|
|
.ToListAsync();
|
|
}
|
|
|
|
private static bool HasRequiredProviderConfiguration(SttProviderEndpoint endpoint)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(endpoint.EndpointUrl)) return false;
|
|
|
|
var providerType = endpoint.ProviderType.Trim().ToLowerInvariant();
|
|
return !RequiresApiKey(providerType) || !string.IsNullOrWhiteSpace(endpoint.ApiKey);
|
|
}
|
|
|
|
private static bool RequiresApiKey(string providerType)
|
|
{
|
|
return providerType is "groq" or "openai" or "deepgram" or "google" or "assemblyai" or "azure";
|
|
}
|
|
|
|
private static string GetSafeFailureType(Exception? exception)
|
|
{
|
|
return exception switch
|
|
{
|
|
TimeoutException or TaskCanceledException => "timeout",
|
|
HttpRequestException => "upstream_http_error",
|
|
JsonException => "invalid_upstream_response",
|
|
InvalidOperationException => "provider_not_configured",
|
|
null => "not_configured",
|
|
_ => "provider_error"
|
|
};
|
|
}
|
|
|
|
private static void ApplyExtraHeaders(HttpRequestMessage req, string? extraHeadersJson)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(extraHeadersJson)) return;
|
|
try
|
|
{
|
|
var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(extraHeadersJson);
|
|
if (dict != null)
|
|
{
|
|
foreach (var (k, v) in dict)
|
|
{
|
|
req.Headers.TryAddWithoutValidation(k, v);
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignore invalid header json
|
|
}
|
|
}
|
|
|
|
private static string DetectContentType(byte[] audioBytes, string? fileName)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(fileName))
|
|
{
|
|
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
|
if (ext == ".wav") return "audio/wav";
|
|
if (ext == ".mp3") return "audio/mp3";
|
|
if (ext == ".ogg") return "audio/ogg";
|
|
if (ext == ".m4a") return "audio/m4a";
|
|
if (ext == ".flac") return "audio/flac";
|
|
if (ext == ".webm") return "audio/webm";
|
|
}
|
|
|
|
if (audioBytes.Length >= 12)
|
|
{
|
|
var header = Encoding.ASCII.GetString(audioBytes, 0, 4);
|
|
if (header == "RIFF") return "audio/wav";
|
|
if (header == "OggS") return "audio/ogg";
|
|
if (audioBytes[0] == 0x1A && audioBytes[1] == 0x45 && audioBytes[2] == 0xDF && audioBytes[3] == 0xA3)
|
|
return "audio/webm";
|
|
if (audioBytes[0] == 0xFF && (audioBytes[1] & 0xE0) == 0xE0)
|
|
return "audio/mp3";
|
|
}
|
|
|
|
return "audio/webm";
|
|
}
|
|
|
|
private static string GetDefaultFileName(string contentType)
|
|
{
|
|
if (contentType.Contains("wav")) return "recording.wav";
|
|
if (contentType.Contains("mp3")) return "recording.mp3";
|
|
if (contentType.Contains("ogg")) return "recording.ogg";
|
|
return "recording.webm";
|
|
}
|
|
|
|
private static double EstimateAudioDuration(byte[] bytes, string contentType)
|
|
{
|
|
if (bytes == null || bytes.Length == 0) return 0.0;
|
|
|
|
// If standard WAV header
|
|
if (bytes.Length >= 44 && Encoding.ASCII.GetString(bytes, 0, 4) == "RIFF")
|
|
{
|
|
try
|
|
{
|
|
var byteRate = BitConverter.ToInt32(bytes, 28);
|
|
if (byteRate > 0)
|
|
{
|
|
var dataLength = bytes.Length - 44;
|
|
return Math.Max(0.1, (double)dataLength / byteRate);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// fallback
|
|
}
|
|
}
|
|
|
|
// Standard 16kHz 16-bit mono PCM assumption (~32,000 bytes/sec) or 32kbps opus (~4,000 bytes/sec)
|
|
if (contentType.Contains("wav"))
|
|
{
|
|
return Math.Max(0.5, (double)bytes.Length / 32000.0);
|
|
}
|
|
else
|
|
{
|
|
// WebM / MP3 compressed audio (~4000 bytes per second average)
|
|
return Math.Max(0.5, (double)bytes.Length / 4000.0);
|
|
}
|
|
}
|
|
|
|
private static byte[] GenerateSyntheticTestWav(double seconds)
|
|
{
|
|
int sampleRate = 16000;
|
|
int numSamples = (int)(sampleRate * seconds);
|
|
int subChunk2Size = numSamples * 2;
|
|
int chunkSize = 36 + subChunk2Size;
|
|
|
|
using var ms = new MemoryStream();
|
|
using var bw = new BinaryWriter(ms);
|
|
|
|
// RIFF header
|
|
bw.Write(Encoding.ASCII.GetBytes("RIFF"));
|
|
bw.Write(chunkSize);
|
|
bw.Write(Encoding.ASCII.GetBytes("WAVE"));
|
|
|
|
// fmt subchunk
|
|
bw.Write(Encoding.ASCII.GetBytes("fmt "));
|
|
bw.Write(16); // Subchunk1Size for PCM
|
|
bw.Write((short)1); // AudioFormat 1 = PCM
|
|
bw.Write((short)1); // NumChannels = 1 (Mono)
|
|
bw.Write(sampleRate); // SampleRate
|
|
bw.Write(sampleRate * 2); // ByteRate
|
|
bw.Write((short)2); // BlockAlign
|
|
bw.Write((short)16); // BitsPerSample
|
|
|
|
// data subchunk
|
|
bw.Write(Encoding.ASCII.GetBytes("data"));
|
|
bw.Write(subChunk2Size);
|
|
|
|
// Write silence / low pulse
|
|
for (int i = 0; i < numSamples; i++)
|
|
{
|
|
short sample = (short)(Math.Sin(2 * Math.PI * 440 * i / sampleRate) * 500); // gentle 440Hz test tone
|
|
bw.Write(sample);
|
|
}
|
|
|
|
return ms.ToArray();
|
|
}
|
|
|
|
private static SttProviderEndpointDto MapToDto(SttProviderEndpoint e)
|
|
{
|
|
return new SttProviderEndpointDto(
|
|
Id: e.Id,
|
|
Name: e.Name,
|
|
ProviderType: e.ProviderType,
|
|
EndpointUrl: e.EndpointUrl,
|
|
ApiKey: string.IsNullOrEmpty(e.ApiKey) ? "" : "••••••••",
|
|
ModelId: e.ModelId,
|
|
Method: e.Method,
|
|
Language: e.Language,
|
|
Prompt: e.Prompt,
|
|
Temperature: e.Temperature,
|
|
CostPerMinute: e.CostPerMinute,
|
|
CostPerSecond: e.CostPerSecond,
|
|
IsDefault: e.IsDefault,
|
|
IsActive: e.IsActive,
|
|
FallbackPriority: e.FallbackPriority,
|
|
ExtraHeadersJson: e.ExtraHeadersJson,
|
|
CreatedAt: e.CreatedAt,
|
|
UpdatedAt: e.UpdatedAt
|
|
);
|
|
}
|
|
}
|