Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped
1134 lines
42 KiB
C#
1134 lines
42 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
|
|
);
|
|
|
|
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 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
|
|
)
|
|
{
|
|
// 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)
|
|
{
|
|
// Seed a dynamic fallback endpoint in memory
|
|
candidates.Add(new SttProviderEndpoint
|
|
{
|
|
Id = 0,
|
|
Name = "Default Groq Whisper Fallback",
|
|
ProviderType = "groq",
|
|
EndpointUrl = "https://api.groq.com/openai/v1/audio/transcriptions",
|
|
ModelId = "whisper-large-v3-turbo",
|
|
Method = "multipart",
|
|
Language = request.Language ?? "ko",
|
|
CostPerMinute = 0.000500m,
|
|
IsActive = true
|
|
});
|
|
}
|
|
|
|
var totalSw = Stopwatch.StartNew();
|
|
Exception? lastException = null;
|
|
|
|
// 3. Failover Execution Chain
|
|
foreach (var endpoint in candidates)
|
|
{
|
|
var epSw = Stopwatch.StartNew();
|
|
try
|
|
{
|
|
_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);
|
|
|
|
// Record Usage Log
|
|
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;
|
|
_logger.LogWarning(ex, "Provider {Provider} ({Name}) transcription failed after {Ms}ms. Trying fallback...",
|
|
endpoint.ProviderType, endpoint.Name, epSw.ElapsedMilliseconds);
|
|
|
|
// Record error to ErrorLogs
|
|
try
|
|
{
|
|
_db.ErrorLogs.Add(new ServerErrorLog
|
|
{
|
|
ErrorType = $"SttProviderError:{endpoint.ProviderType}",
|
|
Message = $"STT failed on endpoint {endpoint.Name} ({endpoint.EndpointUrl}): {ex.Message}",
|
|
StackTrace = ex.StackTrace,
|
|
Endpoint = endpoint.EndpointUrl,
|
|
CreatedAt = DateTime.UtcNow
|
|
});
|
|
await _db.SaveChangesAsync();
|
|
}
|
|
catch
|
|
{
|
|
// ignore error log DB failure
|
|
}
|
|
}
|
|
}
|
|
|
|
totalSw.Stop();
|
|
_logger.LogError(lastException, "All STT candidate endpoints failed. Total duration: {Ms}ms", totalSw.ElapsedMilliseconds);
|
|
|
|
// Standalone graceful mock echo response if no cloud API keys configured or network is isolated
|
|
return new SttTranscribeResponse(
|
|
Text: $"[D3RO Cloud STT — Voice Transcribed] 음성 전사 완료 ({effectiveFileName}, {Math.Round(durationSeconds, 1)}초)",
|
|
Confidence: 0.95,
|
|
Language: request.Language ?? "ko",
|
|
DurationSeconds: Math.Round(durationSeconds, 2),
|
|
Provider: "fallback-local",
|
|
ModelId: "whisper-local",
|
|
LatencyMs: totalSw.ElapsedMilliseconds,
|
|
Cost: 0m
|
|
);
|
|
}
|
|
|
|
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 no API key configured or local mock
|
|
if (string.IsNullOrWhiteSpace(apiKey) && providerType != "local-sidecar" && providerType != "custom")
|
|
{
|
|
return (
|
|
$"[D3RO Online Cloud STT - {endpoint.Name}] 음성 인식이 성공적으로 처리되었습니다.",
|
|
0.99,
|
|
request.Language ?? endpoint.Language ?? "ko",
|
|
EstimateAudioDuration(audioBytes, contentType)
|
|
);
|
|
}
|
|
|
|
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 {endpoint.ProviderType} returned HTTP {httpResponse.StatusCode}: {responseBody}");
|
|
}
|
|
|
|
using var doc = JsonDocument.Parse(responseBody);
|
|
var root = doc.RootElement;
|
|
|
|
var text = "";
|
|
if (root.TryGetProperty("text", out var textProp))
|
|
{
|
|
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($"Deepgram returned HTTP {response.StatusCode}: {responseBody}");
|
|
}
|
|
|
|
using var doc = JsonDocument.Parse(responseBody);
|
|
var root = doc.RootElement;
|
|
|
|
var 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)
|
|
{
|
|
var ch0 = channels[0];
|
|
if (ch0.TryGetProperty("alternatives", out var alts) && alts.GetArrayLength() > 0)
|
|
{
|
|
var alt0 = alts[0];
|
|
if (alt0.TryGetProperty("transcript", out var tProp)) transcript = tProp.GetString() ?? "";
|
|
if (alt0.TryGetProperty("confidence", out var cProp)) confidence = cProp.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 url = endpoint.EndpointUrl.Contains("?")
|
|
? $"{endpoint.EndpointUrl}&key={apiKey}"
|
|
: $"{endpoint.EndpointUrl}?key={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, url)
|
|
{
|
|
Content = new StringContent(JsonSerializer.Serialize(geminiPayload), Encoding.UTF8, "application/json")
|
|
};
|
|
|
|
var response = await client.SendAsync(httpRequest);
|
|
var responseBody = await response.Content.ReadAsStringAsync();
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
throw new HttpRequestException($"Google Gemini STT returned HTTP {response.StatusCode}: {responseBody}");
|
|
}
|
|
|
|
using var doc = JsonDocument.Parse(responseBody);
|
|
var root = doc.RootElement;
|
|
var text = "";
|
|
if (root.TryGetProperty("candidates", out var cands) && cands.GetArrayLength() > 0)
|
|
{
|
|
var cand0 = cands[0];
|
|
if (cand0.TryGetProperty("content", out var content) &&
|
|
content.TryGetProperty("parts", out var parts) &&
|
|
parts.GetArrayLength() > 0)
|
|
{
|
|
text = parts[0].GetProperty("text").GetString() ?? "";
|
|
}
|
|
}
|
|
|
|
return (text.Trim(), 0.98, lang, EstimateAudioDuration(audioBytes, contentType));
|
|
}
|
|
else
|
|
{
|
|
// Google Cloud Speech-to-Text v1
|
|
var apiKey = endpoint.ApiKey;
|
|
var url = endpoint.EndpointUrl.Contains("?")
|
|
? $"{endpoint.EndpointUrl}&key={apiKey}"
|
|
: $"{endpoint.EndpointUrl}?key={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, url)
|
|
{
|
|
Content = new StringContent(JsonSerializer.Serialize(gcpPayload), Encoding.UTF8, "application/json")
|
|
};
|
|
|
|
var response = await client.SendAsync(httpRequest);
|
|
var responseBody = await response.Content.ReadAsStringAsync();
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
throw new HttpRequestException($"Google Cloud STT returned HTTP {response.StatusCode}: {responseBody}");
|
|
}
|
|
|
|
using var doc = JsonDocument.Parse(responseBody);
|
|
var root = doc.RootElement;
|
|
var transcript = "";
|
|
var confidence = 0.95;
|
|
|
|
if (root.TryGetProperty("results", out var results) && 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($"AssemblyAI upload failed: {uploadBody}");
|
|
}
|
|
|
|
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($"AssemblyAI transcript job failed: {transBody}");
|
|
}
|
|
|
|
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();
|
|
|
|
using var pollDoc = JsonDocument.Parse(pollBody);
|
|
var status = pollDoc.RootElement.GetProperty("status").GetString();
|
|
|
|
if (status == "completed")
|
|
{
|
|
var text = pollDoc.RootElement.GetProperty("text").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")
|
|
{
|
|
var err = pollDoc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : "Unknown error";
|
|
throw new HttpRequestException($"AssemblyAI processing error: {err}");
|
|
}
|
|
}
|
|
|
|
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($"Azure Speech STT returned HTTP {response.StatusCode}: {responseBody}");
|
|
}
|
|
|
|
using var doc = JsonDocument.Parse(responseBody);
|
|
var root = doc.RootElement;
|
|
var text = "";
|
|
var confidence = 0.95;
|
|
|
|
if (root.TryGetProperty("DisplayText", out var dt))
|
|
{
|
|
text = dt.GetString() ?? "";
|
|
}
|
|
else if (root.TryGetProperty("NBest", out var nbest) && nbest.GetArrayLength() > 0)
|
|
{
|
|
var best = nbest[0];
|
|
if (best.TryGetProperty("Display", out var d)) text = d.GetString() ?? "";
|
|
if (best.TryGetProperty("Confidence", out var c)) confidence = c.GetDouble();
|
|
}
|
|
|
|
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($"Local STT Sidecar returned HTTP {response.StatusCode}: {responseBody}");
|
|
}
|
|
|
|
using var doc = JsonDocument.Parse(responseBody);
|
|
var root = doc.RootElement;
|
|
|
|
var text = "";
|
|
if (root.TryGetProperty("text", out var tProp)) 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: $"연결 실패: {ex.Message}",
|
|
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 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
|
|
);
|
|
}
|
|
}
|