From cbf81e37039a73f4dfb6ddda09f9036c4c00eb1a Mon Sep 17 00:00:00 2001 From: Chaoqun Zhao Date: Wed, 25 Mar 2026 22:47:47 +0800 Subject: [PATCH 1/3] feat: switch CloudRewriteService from Cerebras to Azure OpenAI gpt-5.4-mini MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Cerebras API with Azure OpenAI for ASR post-processing rewrite. - Remove model probe logic (preferredModels, modelProbeTask, probeAvailableModel, ModelListResponse) — Azure uses fixed deployment names - Change endpoint to Azure format: {endpoint}/openai/deployments/{name}/chat/completions?api-version=2024-10-21 - Change auth header from `Authorization: Bearer` to `api-key` - Remove `model` field from request body (deployment name is in the URL) - Update generate-secrets.sh to handle AZURE_OPENAI_API_KEY and AZURE_OPENAI_ENDPOINT - Update tests (CloudRewriteServiceTests, ASRPipelineBenchmarkTests) for new API - Update AnalyticsService to reference new GeneratedSecrets property Co-Authored-By: Claude Opus 4.6 --- Sources/AnalyticsService.swift | 2 +- Sources/CloudRewriteService.swift | 135 +++++------------- TypelessTests/ASRPipelineBenchmarkTests.swift | 18 +-- TypelessTests/CloudRewriteServiceTests.swift | 23 ++- scripts/generate-secrets.sh | 95 +++++++----- 5 files changed, 117 insertions(+), 156 deletions(-) diff --git a/Sources/AnalyticsService.swift b/Sources/AnalyticsService.swift index 2b8b2a6..4d13995 100644 --- a/Sources/AnalyticsService.swift +++ b/Sources/AnalyticsService.swift @@ -109,6 +109,6 @@ enum AnalyticsService { /// Whether a Cloud Rewrite API key is configured (user-provided or built-in). private static var hasCloudRewriteAPIKey: Bool { let userKey = UserDefaults.standard.string(forKey: "cloudRewriteAPIKey") ?? "" - return !userKey.isEmpty || GeneratedSecrets.cloudRewriteAPIKey != nil + return !userKey.isEmpty || GeneratedSecrets.azureOpenAIAPIKey != nil } } diff --git a/Sources/CloudRewriteService.swift b/Sources/CloudRewriteService.swift index aad454d..c5eaab1 100644 --- a/Sources/CloudRewriteService.swift +++ b/Sources/CloudRewriteService.swift @@ -6,106 +6,40 @@ private let cloudRewriteLogger = Logger(subsystem: "com.typeless.app", category: final class CloudRewriteService { private let session: URLSession private let apiKeyProvider: () -> String? - private let endpoint: URL + private let baseEndpoint: String + private let deploymentName: String + private let apiVersion: String /// Wall-clock timeout for the entire rewrite operation. /// If the API doesn't respond within this duration, the original text is used. private static let rewriteTimeout: Duration = .seconds(5) - /// Models to try in priority order. First available model wins. - private static let preferredModels = [ - "qwen-3-235b-a22b-instruct-2507", - "gpt-oss-120b", - ] - - /// Resolved model name after startup probe completes. - /// Access via `await resolvedModel` which returns the probed result or the default fallback. - private let modelProbeTask: Task - init( session: URLSession = .shared, - endpoint: URL = URL(string: "https://api.cerebras.ai/v1/chat/completions")!, - model: String? = nil, + baseEndpoint: String = { + ProcessInfo.processInfo.environment["AZURE_OPENAI_ENDPOINT"] + ?? GeneratedSecrets.azureOpenAIEndpoint + ?? "" + }(), + deploymentName: String = "gpt-5.4-mini", + apiVersion: String = "2024-10-21", apiKeyProvider: @escaping () -> String? = { // Priority: 1) environment variable (dev/debug override) 2) build-time bundled key - ProcessInfo.processInfo.environment["CLOUD_REWRITE_API_KEY"] - ?? GeneratedSecrets.cloudRewriteAPIKey + ProcessInfo.processInfo.environment["AZURE_OPENAI_API_KEY"] + ?? GeneratedSecrets.azureOpenAIAPIKey } ) { self.session = session - self.endpoint = endpoint + self.baseEndpoint = baseEndpoint.hasSuffix("/") ? String(baseEndpoint.dropLast()) : baseEndpoint + self.deploymentName = deploymentName + self.apiVersion = apiVersion self.apiKeyProvider = apiKeyProvider - - if let model { - // Explicit model provided (e.g. tests) — skip probe - self.modelProbeTask = Task { model } - } else { - // Launch async probe at init time - let probeSession = session - let probeEndpoint = endpoint - let probeApiKeyProvider = apiKeyProvider - self.modelProbeTask = Task { - await CloudRewriteService.probeAvailableModel( - session: probeSession, - endpoint: probeEndpoint, - apiKeyProvider: probeApiKeyProvider - ) - } - } } - /// Probe the `/v1/models` endpoint and return the first available preferred model. - private static func probeAvailableModel( - session: URLSession, - endpoint: URL, - apiKeyProvider: () -> String? - ) async -> String { - let fallback = preferredModels[0] - - guard let apiKey = apiKeyProvider(), !apiKey.isEmpty else { - cloudRewriteLogger.debug("Model probe skipped: no API key, using default \(fallback, privacy: .public)") - return fallback - } - - do { - // Derive models URL from the chat completions endpoint - // e.g. https://api.cerebras.ai/v1/chat/completions → https://api.cerebras.ai/v1/models - let modelsURL: URL = { - var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)! - if let v1Range = components.path.range(of: "/v1/") { - components.path = String(components.path[...v1Range.lowerBound]) + "v1/models" - } else { - components.path = "/v1/models" - } - return components.url! - }() - var request = URLRequest(url: modelsURL) - request.timeoutInterval = 10 - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - - let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { - cloudRewriteLogger.warning("Model probe failed: HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1, privacy: .public), using default \(fallback, privacy: .public)") - return fallback - } - - let listResponse = try JSONDecoder().decode(ModelListResponse.self, from: data) - let availableIds = Set(listResponse.data.map(\.id)) - cloudRewriteLogger.info("Available models: \(availableIds.sorted().joined(separator: ", "), privacy: .public)") - - for candidate in preferredModels { - if availableIds.contains(candidate) { - cloudRewriteLogger.info("Model probe resolved: \(candidate, privacy: .public)") - return candidate - } - } - - cloudRewriteLogger.warning("No preferred model available, using default \(fallback, privacy: .public)") - return fallback - } catch { - cloudRewriteLogger.warning("Model probe error: \(error.localizedDescription, privacy: .public), using default \(fallback, privacy: .public)") - return fallback - } + /// Construct the full Azure OpenAI chat completions URL. + private var completionsURL: URL? { + let urlString = "\(baseEndpoint)/openai/deployments/\(deploymentName)/chat/completions?api-version=\(apiVersion)" + return URL(string: urlString) } func rewriteOrPassthrough(_ text: String) async -> String { @@ -121,6 +55,15 @@ final class CloudRewriteService { return text } + guard !baseEndpoint.isEmpty else { + cloudRewriteLogger.debug("Cloud rewrite skipped: no Azure endpoint configured") + AnalyticsService.track("CloudRewrite.Completed", parameters: [ + "outcome": "noEndpoint", + "inputLengthBucket": AnalyticsService.lengthBucket(count: text.count), + ]) + return text + } + let startTime = ContinuousClock.now do { @@ -146,10 +89,9 @@ final class CloudRewriteService { let elapsed = startTime.duration(to: .now) let elapsedMs = Int(elapsed.components.seconds * 1000) + Int(elapsed.components.attoseconds / 1_000_000_000_000_000) - let resolvedModel = await modelProbeTask.value AnalyticsService.track("CloudRewrite.Completed", parameters: [ "outcome": "rewritten", - "model": resolvedModel, + "model": deploymentName, "changed": "\(result != text)", "inputLengthBucket": AnalyticsService.lengthBucket(count: text.count), ], floatValue: Double(elapsedMs)) @@ -195,16 +137,17 @@ final class CloudRewriteService { } private func rewrite(text: String, apiKey: String) async throws -> String { - let resolvedModel = await modelProbeTask.value + guard let url = completionsURL else { + throw CloudRewriteError.invalidResponse + } - var request = URLRequest(url: endpoint) + var request = URLRequest(url: url) request.httpMethod = "POST" request.timeoutInterval = 8 // Safety net; task group timeout (5s) is the primary enforcer request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue(apiKey, forHTTPHeaderField: "api-key") let body = ChatCompletionRequest( - model: resolvedModel, temperature: 0, maxTokens: 2048, messages: [ @@ -299,13 +242,11 @@ private enum CloudRewriteError: Error { } private struct ChatCompletionRequest: Encodable { - let model: String let temperature: Double let maxTokens: Int let messages: [ChatMessage] enum CodingKeys: String, CodingKey { - case model case temperature case maxTokens = "max_tokens" case messages @@ -325,13 +266,5 @@ private struct ChatCompletionResponse: Decodable { } } -private struct ModelListResponse: Decodable { - let data: [ModelEntry] - - struct ModelEntry: Decodable { - let id: String - } -} - /// Thrown by the timeout racer to signal that the rewrite deadline has been exceeded. private struct RewriteTimeoutError: Error {} diff --git a/TypelessTests/ASRPipelineBenchmarkTests.swift b/TypelessTests/ASRPipelineBenchmarkTests.swift index 1d93531..06b6436 100644 --- a/TypelessTests/ASRPipelineBenchmarkTests.swift +++ b/TypelessTests/ASRPipelineBenchmarkTests.swift @@ -23,7 +23,7 @@ class ASRPipelineBenchmarkTests: XCTestCase { static var paraformerRecognizer: SherpaOnnxOnlineRecognizer? static var punctuator: SherpaOnnxPunctuation? static var corrector: ChineseSpellingCorrector? - static var cloudRewriter: CloudRewriter? + static var cloudRewriteService: CloudRewriteService? static var termNormalizer: TermNormalizer? static var itn: SherpaOnnxITN? @@ -130,10 +130,10 @@ class ASRPipelineBenchmarkTests: XCTestCase { log("[Benchmark] 标点: \(punctuator != nil ? "✓" : "✗")") } - // Cloud Rewriter (Cerebras GPT-OSS-120B) - cloudRewriter = CloudRewriter() - cloudRewriteAvailable = cloudRewriter != nil - log("[Benchmark] Cloud Rewrite: \(cloudRewriteAvailable ? "✓" : "✗") (CLOUD_REWRITE_API_KEY)") + // Cloud Rewrite (Azure OpenAI gpt-5.4-mini) + cloudRewriteService = CloudRewriteService() + cloudRewriteAvailable = true + log("[Benchmark] Cloud Rewrite: ✓ (Azure OpenAI)") // TermNormalizer let bundleURL = Bundle(for: ASRPipelineBenchmarkTests.self).url(forResource: "term_dictionary", withExtension: "json") @@ -158,7 +158,7 @@ class ASRPipelineBenchmarkTests: XCTestCase { paraformerRecognizer = nil punctuator = nil corrector = nil - cloudRewriter = nil + cloudRewriteService = nil termNormalizer = nil itn = nil super.tearDown() @@ -245,9 +245,9 @@ class ASRPipelineBenchmarkTests: XCTestCase { func testParaformerCloudRewritePipeline() throws { try XCTSkipUnless(Self.paraformerAvailable, "Paraformer 模型不可用") - try XCTSkipUnless(Self.cloudRewriteAvailable, "Cloud Rewrite 不可用 (需要 CLOUD_REWRITE_API_KEY)") + try XCTSkipUnless(Self.cloudRewriteAvailable, "Cloud Rewrite 不可用 (需要 AZURE_OPENAI_API_KEY)") let recognizer = Self.paraformerRecognizer! - let rewriter = Self.cloudRewriter! + let rewriteService = Self.cloudRewriteService! let results = runPipeline(name: "Paraformer + Cloud Rewrite") { entry in let samples = try WAVLoader.load(path: entry.audioPath).samples @@ -271,7 +271,7 @@ class ASRPipelineBenchmarkTests: XCTestCase { let semaphore = DispatchSemaphore(value: 0) var rewritten = asrText Task { - rewritten = await rewriter.rewrite(text: asrText) + rewritten = await rewriteService.rewriteOrPassthrough(asrText) semaphore.signal() } semaphore.wait() diff --git a/TypelessTests/CloudRewriteServiceTests.swift b/TypelessTests/CloudRewriteServiceTests.swift index ee1a414..1049e42 100644 --- a/TypelessTests/CloudRewriteServiceTests.swift +++ b/TypelessTests/CloudRewriteServiceTests.swift @@ -6,7 +6,8 @@ final class CloudRewriteServiceTests: XCTestCase { func testRewriteOrPassthroughFallsBackOn401() async { let service = CloudRewriteService( session: makeSession(statusCode: 401, body: "{}"), - model: "test-model", + baseEndpoint: "https://test.openai.azure.com", + deploymentName: "test-model", apiKeyProvider: { "test-key" } ) @@ -18,7 +19,8 @@ final class CloudRewriteServiceTests: XCTestCase { func testRewriteOrPassthroughFallsBackOn429() async { let service = CloudRewriteService( session: makeSession(statusCode: 429, body: "{}"), - model: "test-model", + baseEndpoint: "https://test.openai.azure.com", + deploymentName: "test-model", apiKeyProvider: { "test-key" } ) @@ -30,7 +32,8 @@ final class CloudRewriteServiceTests: XCTestCase { func testRewriteOrPassthroughFallsBackOn5xx() async { let service = CloudRewriteService( session: makeSession(statusCode: 503, body: "{}"), - model: "test-model", + baseEndpoint: "https://test.openai.azure.com", + deploymentName: "test-model", apiKeyProvider: { "test-key" } ) @@ -46,7 +49,8 @@ final class CloudRewriteServiceTests: XCTestCase { let service = CloudRewriteService( session: makeSessionWithMockProtocol(), - model: "test-model", + baseEndpoint: "https://test.openai.azure.com", + deploymentName: "test-model", apiKeyProvider: { "test-key" } ) @@ -62,7 +66,8 @@ final class CloudRewriteServiceTests: XCTestCase { let service = CloudRewriteService( session: makeSessionWithMockProtocol(), - model: "test-model", + baseEndpoint: "https://test.openai.azure.com", + deploymentName: "test-model", apiKeyProvider: { "test-key" } ) @@ -82,7 +87,8 @@ final class CloudRewriteServiceTests: XCTestCase { let service = CloudRewriteService( session: makeSession(statusCode: 200, body: body), - model: "test-model", + baseEndpoint: "https://test.openai.azure.com", + deploymentName: "test-model", apiKeyProvider: { "test-key" } ) @@ -91,7 +97,10 @@ final class CloudRewriteServiceTests: XCTestCase { } func testRewriteOrPassthroughBypassesWhenNoApiKey() async { - let service = CloudRewriteService(apiKeyProvider: { nil }) + let service = CloudRewriteService( + baseEndpoint: "https://test.openai.azure.com", + apiKeyProvider: { nil } + ) let input = "hello world" let output = await service.rewriteOrPassthrough(input) XCTAssertEqual(output, input) diff --git a/scripts/generate-secrets.sh b/scripts/generate-secrets.sh index d9828d2..e83a250 100755 --- a/scripts/generate-secrets.sh +++ b/scripts/generate-secrets.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Generate XOR-obfuscated API key from .env file +# Generate XOR-obfuscated API keys from .env file # Called as an Xcode Run Script Build Phase # Output: Sources/GeneratedSecrets.swift (gitignored) @@ -8,37 +8,26 @@ set -euo pipefail ENV_FILE="${SRCROOT}/.env" OUTPUT_FILE="${SRCROOT}/Sources/GeneratedSecrets.swift" -# Read API key from .env -API_KEY="" +# Read values from .env +AZURE_API_KEY="" +AZURE_ENDPOINT="" if [ -f "$ENV_FILE" ]; then - API_KEY=$(grep -E '^CLOUD_REWRITE_API_KEY=' "$ENV_FILE" | cut -d'=' -f2- | tr -d '"' | tr -d "'" | tr -d '[:space:]') + AZURE_API_KEY=$(grep -E '^AZURE_OPENAI_API_KEY=' "$ENV_FILE" | cut -d'=' -f2- | tr -d '"' | tr -d "'" | tr -d '[:space:]' || true) + AZURE_ENDPOINT=$(grep -E '^AZURE_OPENAI_ENDPOINT=' "$ENV_FILE" | cut -d'=' -f2- | tr -d '"' | tr -d "'" | tr -d '[:space:]' || true) fi -# Generate Swift file -if [ -z "$API_KEY" ]; then - # No key — generate stub that returns nil - cat > "$OUTPUT_FILE" << 'SWIFT' -// Auto-generated — DO NOT EDIT, DO NOT COMMIT -// No API key found in .env -enum GeneratedSecrets { - static var cloudRewriteAPIKey: String? { nil } -} -SWIFT - echo "generate-secrets: no API key found, generated nil stub" -else - # XOR obfuscate the key with a random mask - KEY_LEN=${#API_KEY} - - # Generate random mask bytes - MASK_BYTES="" - CIPHER_BYTES="" - for (( i=0; i "$OUTPUT_FILE" << SWIFT -// Auto-generated — DO NOT EDIT, DO NOT COMMIT -// API key is XOR-obfuscated to avoid plaintext exposure in binary -enum GeneratedSecrets { - private static let _m: [UInt8] = [$MASK_BYTES] - private static let _c: [UInt8] = [$CIPHER_BYTES] + # Azure OpenAI API Key + if [ -n "$AZURE_API_KEY" ]; then + xor_obfuscate "$AZURE_API_KEY" KEY_MASK KEY_CIPHER + cat << SWIFT + private static let _akm: [UInt8] = [$KEY_MASK] + private static let _akc: [UInt8] = [$KEY_CIPHER] - static var cloudRewriteAPIKey: String? { - guard _m.count == _c.count, !_m.isEmpty else { return nil } - let bytes = zip(_c, _m).map { \$0 ^ \$1 } + static var azureOpenAIAPIKey: String? { + guard _akm.count == _akc.count, !_akm.isEmpty else { return nil } + let bytes = zip(_akc, _akm).map { \$0 ^ \$1 } return String(bytes: bytes, encoding: .utf8) } -} SWIFT - echo "generate-secrets: obfuscated ${KEY_LEN}-char key" -fi + else + echo " static var azureOpenAIAPIKey: String? { nil }" + fi + + # Azure OpenAI Endpoint + if [ -n "$AZURE_ENDPOINT" ]; then + xor_obfuscate "$AZURE_ENDPOINT" EP_MASK EP_CIPHER + cat << SWIFT + private static let _epm: [UInt8] = [$EP_MASK] + private static let _epc: [UInt8] = [$EP_CIPHER] + + static var azureOpenAIEndpoint: String? { + guard _epm.count == _epc.count, !_epm.isEmpty else { return nil } + let bytes = zip(_epc, _epm).map { \$0 ^ \$1 } + return String(bytes: bytes, encoding: .utf8) + } +SWIFT + else + echo " static var azureOpenAIEndpoint: String? { nil }" + fi + + echo "}" +} > "$OUTPUT_FILE" + +echo "generate-secrets: Azure API key=${#AZURE_API_KEY} chars, endpoint=${#AZURE_ENDPOINT} chars" From 19143d0cd1a1d9fcc5814237e356cbbe9011700d Mon Sep 17 00:00:00 2001 From: Chaoqun Zhao Date: Wed, 25 Mar 2026 23:14:07 +0800 Subject: [PATCH 2/3] fix: use max_completion_tokens for Azure OpenAI gpt-5.4-mini gpt-5.4-mini does not support the legacy max_tokens parameter, returning HTTP 400. Switch to max_completion_tokens as required by newer Azure OpenAI models. Co-Authored-By: Claude Opus 4.6 --- Sources/CloudRewriteService.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/CloudRewriteService.swift b/Sources/CloudRewriteService.swift index c5eaab1..e54ff69 100644 --- a/Sources/CloudRewriteService.swift +++ b/Sources/CloudRewriteService.swift @@ -149,7 +149,7 @@ final class CloudRewriteService { let body = ChatCompletionRequest( temperature: 0, - maxTokens: 2048, + maxCompletionTokens: 2048, messages: [ ChatMessage(role: "system", content: Self.systemPrompt), ChatMessage(role: "user", content: "\(text)") @@ -243,12 +243,12 @@ private enum CloudRewriteError: Error { private struct ChatCompletionRequest: Encodable { let temperature: Double - let maxTokens: Int + let maxCompletionTokens: Int let messages: [ChatMessage] enum CodingKeys: String, CodingKey { case temperature - case maxTokens = "max_tokens" + case maxCompletionTokens = "max_completion_tokens" case messages } } From 25f107b0903a872f2e90983eb9a84877d4a13c96 Mon Sep 17 00:00:00 2001 From: Chaoqun Zhao Date: Wed, 25 Mar 2026 23:33:18 +0800 Subject: [PATCH 3/3] fix: address PR #24 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Add `invalidConfiguration` error case to CloudRewriteError for when completionsURL fails to construct (was misusing `invalidResponse`) 2. Fix hardcoded `cloudRewriteAvailable = true` in benchmark tests — now checks for actual Azure credentials before marking available 3. Update AnalyticsService.hasCloudRewriteAPIKey to check AZURE_OPENAI_API_KEY env var instead of old UserDefaults key Co-Authored-By: Claude Opus 4.6 --- Sources/AnalyticsService.swift | 8 +++++--- Sources/CloudRewriteService.swift | 5 ++++- TypelessTests/ASRPipelineBenchmarkTests.swift | 5 +++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/Sources/AnalyticsService.swift b/Sources/AnalyticsService.swift index 4d13995..b98c813 100644 --- a/Sources/AnalyticsService.swift +++ b/Sources/AnalyticsService.swift @@ -106,9 +106,11 @@ enum AnalyticsService { // MARK: - Private Helpers - /// Whether a Cloud Rewrite API key is configured (user-provided or built-in). + /// Whether a Cloud Rewrite API key is configured (env var or built-in). private static var hasCloudRewriteAPIKey: Bool { - let userKey = UserDefaults.standard.string(forKey: "cloudRewriteAPIKey") ?? "" - return !userKey.isEmpty || GeneratedSecrets.azureOpenAIAPIKey != nil + if let envKey = ProcessInfo.processInfo.environment["AZURE_OPENAI_API_KEY"], !envKey.isEmpty { + return true + } + return GeneratedSecrets.azureOpenAIAPIKey != nil } } diff --git a/Sources/CloudRewriteService.swift b/Sources/CloudRewriteService.swift index e54ff69..9468b1c 100644 --- a/Sources/CloudRewriteService.swift +++ b/Sources/CloudRewriteService.swift @@ -138,7 +138,7 @@ final class CloudRewriteService { private func rewrite(text: String, apiKey: String) async throws -> String { guard let url = completionsURL else { - throw CloudRewriteError.invalidResponse + throw CloudRewriteError.invalidConfiguration } var request = URLRequest(url: url) @@ -216,6 +216,7 @@ final class CloudRewriteService { } private enum CloudRewriteError: Error { + case invalidConfiguration case invalidResponse case unauthorized case rateLimited @@ -225,6 +226,8 @@ private enum CloudRewriteError: Error { var logDescription: String { switch self { + case .invalidConfiguration: + return "invalid_configuration" case .invalidResponse: return "invalid_response" case .unauthorized: diff --git a/TypelessTests/ASRPipelineBenchmarkTests.swift b/TypelessTests/ASRPipelineBenchmarkTests.swift index 06b6436..5a031e4 100644 --- a/TypelessTests/ASRPipelineBenchmarkTests.swift +++ b/TypelessTests/ASRPipelineBenchmarkTests.swift @@ -132,8 +132,9 @@ class ASRPipelineBenchmarkTests: XCTestCase { // Cloud Rewrite (Azure OpenAI gpt-5.4-mini) cloudRewriteService = CloudRewriteService() - cloudRewriteAvailable = true - log("[Benchmark] Cloud Rewrite: ✓ (Azure OpenAI)") + cloudRewriteAvailable = (ProcessInfo.processInfo.environment["AZURE_OPENAI_API_KEY"] + ?? GeneratedSecrets.azureOpenAIAPIKey) != nil + log("[Benchmark] Cloud Rewrite: \(cloudRewriteAvailable ? "✓" : "✗") (Azure OpenAI)") // TermNormalizer let bundleURL = Bundle(for: ASRPipelineBenchmarkTests.self).url(forResource: "term_dictionary", withExtension: "json")