From ff73f95b9f17ed4c92d862dad15e371cfb53326c Mon Sep 17 00:00:00 2001 From: Kyle Rubenok Date: Thu, 13 Aug 2026 20:22:00 -0700 Subject: [PATCH 1/3] Support deployment-scoped transcription Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Localizable.xcstrings | 96 ++++++++++- .../OpenAICompatiblePlugin.swift | 98 ++++++++++- .../Plugins/OpenAICompatiblePlugin/README.md | 32 ++++ .../Tests/OpenAICompatiblePluginTests.swift | 152 +++++++++++++++++- 4 files changed, 367 insertions(+), 11 deletions(-) create mode 100644 TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/README.md diff --git a/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/Localizable.xcstrings b/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/Localizable.xcstrings index 43945a93e..f36fe8437 100644 --- a/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/Localizable.xcstrings +++ b/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/Localizable.xcstrings @@ -111,24 +111,24 @@ } } }, - "Azure OpenAI and Microsoft Foundry may require an API version such as preview for audio transcription. Leave blank for standard OpenAI-compatible servers.": { + "Some servers require an API version. Deployment-scoped batch endpoints require a dated version; realtime endpoints may require a preview version. Leave blank when the server does not require one.": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Azure OpenAI und Microsoft Foundry benötigen für die Audiotranskription möglicherweise eine API-Version wie preview. Für standardmäßige OpenAI-kompatible Server leer lassen." + "value": "Einige Server erfordern eine API-Version. Bereitstellungsbezogene Batch-Endpunkte benötigen eine datierte Version; Echtzeitendpunkte möglicherweise eine Vorschauversion. Leer lassen, wenn der Server keine Version erfordert." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Azure OpenAI および Microsoft Foundry では、音声文字起こしに preview などの API バージョンが必要な場合があります。標準の OpenAI 互換サーバーでは空欄のままにしてください。" + "value": "一部のサーバーでは API バージョンが必要です。デプロイスコープのバッチエンドポイントには日付付きバージョン、リアルタイムエンドポイントにはプレビューバージョンが必要な場合があります。サーバーがバージョンを必要としない場合は空欄にします。" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Azure OpenAI 和 Microsoft Foundry 进行音频转写时可能需要指定 API 版本,例如 preview。标准 OpenAI 兼容服务器请留空。" + "value": "某些服务器需要 API 版本。部署范围的批量终结点需要带日期的版本;实时终结点可能需要预览版本。服务器不需要版本时请留空。" } } } @@ -155,6 +155,28 @@ } } }, + "Batch Transcription Endpoint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Endpunkt für Batch-Transkription" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バッチ文字起こしエンドポイント" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "批量转录终结点" + } + } + } + }, "Connected": { "localizations": { "de": { @@ -711,6 +733,72 @@ } } }, + "Deployment-scoped": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bereitstellungsbezogen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デプロイスコープ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "部署范围" + } + } + } + }, + "Standard v1": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Standard v1" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "標準 v1" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标准 v1" + } + } + } + }, + "Standard v1 uses /v1/audio/transcriptions. Deployment-scoped uses /deployments/{model}/audio/transcriptions and requires a dated API version. Realtime transcription continues to use /v1/realtime.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Standard v1 verwendet /v1/audio/transcriptions. Bereitstellungsbezogen verwendet /deployments/{model}/audio/transcriptions und erfordert eine datierte API-Version. Die Echtzeittranskription verwendet weiterhin /v1/realtime." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "標準 v1 は /v1/audio/transcriptions を使用します。デプロイスコープは /deployments/{model}/audio/transcriptions を使用し、日付付き API バージョンが必要です。リアルタイム文字起こしは引き続き /v1/realtime を使用します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标准 v1 使用 /v1/audio/transcriptions。部署范围使用 /deployments/{model}/audio/transcriptions,并需要带日期的 API 版本。实时转录继续使用 /v1/realtime。" + } + } + } + }, "High": { "localizations": { "de": { diff --git a/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/OpenAICompatiblePlugin.swift b/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/OpenAICompatiblePlugin.swift index c78ef3adf..719671412 100644 --- a/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/OpenAICompatiblePlugin.swift +++ b/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/OpenAICompatiblePlugin.swift @@ -57,6 +57,13 @@ enum OpenAICompatibleResolvedTranscriptionTransport: Sendable, Equatable { case realtime } +/// Per-profile URL shape for batch transcription. Standard OpenAI-compatible +/// servers use `/v1/audio/...`; some providers require a deployment-scoped route. +enum OpenAICompatibleBatchEndpoint: String, Codable, CaseIterable, Sendable { + case standard + case deploymentScoped = "deployment-scoped" +} + /// Per-profile LLM endpoint selection. Chat Completions remains the default so /// profiles created before Responses API support keep their existing behavior. enum OpenAICompatibleLLMAPI: String, Codable, CaseIterable, Sendable { @@ -157,6 +164,7 @@ struct OpenAICompatibleProfile: Codable, Equatable, Identifiable, Sendable { var chatRequestTimeoutSeconds: TimeInterval? var thinkingEnabled: Bool var transcriptionTransportRaw: String + var batchEndpointRaw: String var llmAPIModeRaw: String var reasoningEffortRaw: String @@ -177,6 +185,7 @@ struct OpenAICompatibleProfile: Codable, Equatable, Identifiable, Sendable { case chatRequestTimeoutSeconds case thinkingEnabled case transcriptionTransportRaw + case batchEndpointRaw case llmAPIModeRaw case reasoningEffortRaw } @@ -194,6 +203,7 @@ struct OpenAICompatibleProfile: Codable, Equatable, Identifiable, Sendable { chatRequestTimeoutSeconds: TimeInterval? = nil, thinkingEnabled: Bool = false, transcriptionTransportRaw: String = OpenAICompatibleTranscriptTransport.auto.rawValue, + batchEndpointRaw: String = OpenAICompatibleBatchEndpoint.standard.rawValue, llmAPIModeRaw: String = OpenAICompatibleLLMAPI.chatCompletions.rawValue, reasoningEffortRaw: String = OpenAICompatibleReasoningEffort.providerDefault.rawValue ) { @@ -209,6 +219,7 @@ struct OpenAICompatibleProfile: Codable, Equatable, Identifiable, Sendable { self.chatRequestTimeoutSeconds = chatRequestTimeoutSeconds self.thinkingEnabled = thinkingEnabled self.transcriptionTransportRaw = transcriptionTransportRaw + self.batchEndpointRaw = batchEndpointRaw self.llmAPIModeRaw = llmAPIModeRaw self.reasoningEffortRaw = reasoningEffortRaw } @@ -231,6 +242,8 @@ struct OpenAICompatibleProfile: Codable, Equatable, Identifiable, Sendable { // and only the known realtime model IDs switch transport. transcriptionTransportRaw = try container.decodeIfPresent(String.self, forKey: .transcriptionTransportRaw) ?? OpenAICompatibleTranscriptTransport.auto.rawValue + batchEndpointRaw = try container.decodeIfPresent(String.self, forKey: .batchEndpointRaw) + ?? OpenAICompatibleBatchEndpoint.standard.rawValue // Profiles saved before Responses API support always used Chat Completions. llmAPIModeRaw = try container.decodeIfPresent(String.self, forKey: .llmAPIModeRaw) ?? OpenAICompatibleLLMAPI.chatCompletions.rawValue @@ -256,6 +269,10 @@ struct OpenAICompatibleProfile: Codable, Equatable, Identifiable, Sendable { OpenAICompatibleTranscriptTransport(rawValue: transcriptionTransportRaw) ?? .auto } + var batchEndpoint: OpenAICompatibleBatchEndpoint { + OpenAICompatibleBatchEndpoint(rawValue: batchEndpointRaw) ?? .standard + } + var llmAPI: OpenAICompatibleLLMAPI { OpenAICompatibleLLMAPI(rawValue: llmAPIModeRaw) ?? .chatCompletions } @@ -288,6 +305,7 @@ struct OpenAICompatibleProfile: Codable, Equatable, Identifiable, Sendable { chatRequestTimeoutSeconds: TimeInterval? = nil, thinkingEnabled: Bool = false, transcriptionTransportRaw: String = OpenAICompatibleTranscriptTransport.auto.rawValue, + batchEndpointRaw: String = OpenAICompatibleBatchEndpoint.standard.rawValue, llmAPIModeRaw: String = OpenAICompatibleLLMAPI.chatCompletions.rawValue, reasoningEffortRaw: String = OpenAICompatibleReasoningEffort.providerDefault.rawValue ) -> OpenAICompatibleProfile { @@ -304,6 +322,7 @@ struct OpenAICompatibleProfile: Codable, Equatable, Identifiable, Sendable { chatRequestTimeoutSeconds: chatRequestTimeoutSeconds, thinkingEnabled: thinkingEnabled, transcriptionTransportRaw: transcriptionTransportRaw, + batchEndpointRaw: batchEndpointRaw, llmAPIModeRaw: llmAPIModeRaw, reasoningEffortRaw: reasoningEffortRaw ) @@ -790,11 +809,18 @@ final class OpenAICompatiblePlugin: NSObject, } } + if profile.batchEndpoint == .deploymentScoped, + !Self.isDatedAPIVersion(profile.apiVersion) { + throw PluginTranscriptionError.apiError( + "Deployment-scoped batch transcription requires a dated API version." + ) + } + guard let helper = makeTranscriptionHelper(for: profile) else { throw PluginTranscriptionError.notConfigured } let apiKey = apiKey(for: profileId) ?? "" - if profile.apiVersion.isEmpty { + if profile.apiVersion.isEmpty, profile.batchEndpoint == .standard { return try await helper.transcribeCompressedAudioWithWavFallback( audio: audio, apiKey: apiKey, @@ -809,7 +835,7 @@ final class OpenAICompatiblePlugin: NSObject, // TypeWhisper 1.6 RC1 does not export the SDK's apiVersion overload. // Keep this JSON request path in the plugin until that host is no longer supported. return try await PluginAudioUploadEncoder.withCompressedM4AUploadWavFallback(from: audio) { uploadFile in - try await self.performVersionedTranscriptionRequest( + try await self.performBatchTranscriptionRequest( profile: profile, uploadFile: uploadFile, apiKey: apiKey, @@ -833,6 +859,16 @@ final class OpenAICompatiblePlugin: NSObject, } } + func batchEndpoint(for profileId: String) -> OpenAICompatibleBatchEndpoint { + profile(for: profileId)?.batchEndpoint ?? .standard + } + + func setBatchEndpoint(_ endpoint: OpenAICompatibleBatchEndpoint, for profileId: String) { + updateProfile(profileId) { profile in + profile.batchEndpointRaw = endpoint.rawValue + } + } + /// Resolves the effective transport for the profile's *currently selected* /// transcription model, applying the `auto` heuristic (known realtime IDs /// use realtime, everything else stays batch) when the profile hasn't @@ -1491,7 +1527,7 @@ final class OpenAICompatiblePlugin: NSObject, throw PluginChatError.apiError("Failed to parse response text") } - private func performVersionedTranscriptionRequest( + private func performBatchTranscriptionRequest( profile: OpenAICompatibleProfile, uploadFile: PluginAudioUploadFile, apiKey: String, @@ -1500,7 +1536,14 @@ final class OpenAICompatiblePlugin: NSObject, translate: Bool, prompt: String? ) async throws -> PluginTranscriptionResult { - let path = translate ? "/v1/audio/translations" : "/v1/audio/transcriptions" + let operation = translate ? "translations" : "transcriptions" + let path: String + switch profile.batchEndpoint { + case .standard: + path = "/v1/audio/\(operation)" + case .deploymentScoped: + path = "/deployments/\(Self.percentEncodedPathSegment(modelName))/audio/\(operation)" + } guard let url = Self.requestURL( baseURL: profile.baseURL, path: path, @@ -1643,6 +1686,19 @@ final class OpenAICompatiblePlugin: NSObject, apiVersion.trimmingCharacters(in: .whitespacesAndNewlines) } + private static func isDatedAPIVersion(_ apiVersion: String) -> Bool { + normalizedAPIVersion(apiVersion).range( + of: #"^\d{4}-\d{2}-\d{2}"#, + options: .regularExpression + ) != nil + } + + private static func percentEncodedPathSegment(_ value: String) -> String { + var allowed = CharacterSet.alphanumerics + allowed.insert(charactersIn: "-._~") + return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value + } + private static func requestURL(baseURL: String, path: String, apiVersion: String) -> URL? { guard var components = URLComponents(string: baseURL) else { return nil } let basePath = components.percentEncodedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) @@ -1859,6 +1915,7 @@ private struct OpenAICompatibleSettingsView: View { @State private var thinkingEnabled = false @State private var chatTimeoutInput = "" @State private var transcriptionTransport: OpenAICompatibleTranscriptTransport = .auto + @State private var batchEndpoint: OpenAICompatibleBatchEndpoint = .standard @State private var llmAPI: OpenAICompatibleLLMAPI = .chatCompletions @State private var reasoningEffort: OpenAICompatibleReasoningEffort = .providerDefault @@ -2022,7 +2079,7 @@ private struct OpenAICompatibleSettingsView: View { saveApiVersion() } - Text("Azure OpenAI and Microsoft Foundry may require an API version such as preview for audio transcription. Leave blank for standard OpenAI-compatible servers.", bundle: bundle) + Text("Some servers require an API version. Deployment-scoped batch endpoints require a dated version; realtime endpoints may require a preview version. Leave blank when the server does not require one.", bundle: bundle) .font(.caption) .foregroundStyle(.secondary) } @@ -2118,6 +2175,7 @@ private struct OpenAICompatibleSettingsView: View { llmAPISection transportSection + batchEndpointSection } } @@ -2173,6 +2231,28 @@ private struct OpenAICompatibleSettingsView: View { } } + private var batchEndpointSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Batch Transcription Endpoint", bundle: bundle) + .font(.subheadline) + .foregroundStyle(.secondary) + + Picker("Batch Transcription Endpoint", selection: $batchEndpoint) { + Text("Standard v1", bundle: bundle).tag(OpenAICompatibleBatchEndpoint.standard) + Text("Deployment-scoped", bundle: bundle).tag(OpenAICompatibleBatchEndpoint.deploymentScoped) + } + .pickerStyle(.segmented) + .labelsHidden() + .onChange(of: batchEndpoint) { + saveBatchEndpoint() + } + + Text("Standard v1 uses /v1/audio/transcriptions. Deployment-scoped uses /deployments/{model}/audio/transcriptions and requires a dated API version. Realtime transcription continues to use /v1/realtime.", bundle: bundle) + .font(.caption) + .foregroundStyle(.secondary) + } + } + private func modelPickerSection(profile: OpenAICompatibleProfile) -> some View { VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 4) { @@ -2413,6 +2493,7 @@ private struct OpenAICompatibleSettingsView: View { thinkingEnabled = profile.thinkingEnabled chatTimeoutInput = String(Int(profile.resolvedChatRequestTimeout)) transcriptionTransport = profile.transcriptionTransport + batchEndpoint = profile.batchEndpoint llmAPI = profile.llmAPI reasoningEffort = profile.reasoningEffort connectionResult = nil @@ -2447,6 +2528,13 @@ private struct OpenAICompatibleSettingsView: View { reloadProfiles(selecting: selectedProfile.id, preserveInputs: true) } + private func saveBatchEndpoint() { + guard let selectedProfile else { return } + guard batchEndpoint != selectedProfile.batchEndpoint else { return } + plugin.setBatchEndpoint(batchEndpoint, for: selectedProfile.id) + reloadProfiles(selecting: selectedProfile.id, preserveInputs: true) + } + private func saveProfileName() { guard let selectedProfile else { return } let trimmed = nameInput.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/README.md b/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/README.md new file mode 100644 index 000000000..6e70c6482 --- /dev/null +++ b/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/README.md @@ -0,0 +1,32 @@ +# OpenAI Compatible plugin + +The OpenAI Compatible plugin supports standard OpenAI-style endpoints and +provider-specific deployment-scoped batch transcription routes. + +## Batch transcription endpoint styles + +- **Standard v1** sends multipart requests to + `/v1/audio/transcriptions`. +- **Deployment-scoped** sends multipart requests to + `/deployments/{selected-model}/audio/transcriptions` and requires a dated + API version. + +The selected transcription model is included in the multipart `model` field. +The plugin keeps Standard v1 as the default for existing profiles. + +## Azure OpenAI and Microsoft Foundry + +Some Azure offline transcription deployments require the deployment-scoped +route rather than the OpenAI v1 route. Configure a dedicated profile with: + +- Base URL: the resource endpoint ending in `/openai` +- API Version: the dated version supported by the deployment, such as + `2025-03-01-preview` +- Transcription Model: the Azure deployment name +- Transcription Transport: `Batch` or `Auto` for a batch model +- Batch Transcription Endpoint: `Deployment-scoped` + +For realtime transcription, use the Standard v1 endpoint style and the API +version required by the provider's `/v1/realtime` endpoint. A separate profile +is recommended when batch and realtime endpoints require different API +versions. diff --git a/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/Tests/OpenAICompatiblePluginTests.swift b/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/Tests/OpenAICompatiblePluginTests.swift index b5eb91928..a47c65b1c 100644 --- a/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/Tests/OpenAICompatiblePluginTests.swift +++ b/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/Tests/OpenAICompatiblePluginTests.swift @@ -128,6 +128,7 @@ final class OpenAICompatiblePluginTests: XCTestCase { let profile = try XCTUnwrap(plugin.profileSnapshot(for: plugin.providerId)) XCTAssertEqual(profile.apiVersion, "") XCTAssertFalse(profile.thinkingEnabled) + XCTAssertEqual(profile.batchEndpoint, .standard) XCTAssertEqual(profile.llmAPI, .chatCompletions) XCTAssertEqual(profile.reasoningEffort, .providerDefault) XCTAssertEqual(profile.resolvedChatRequestTimeout, 45) @@ -487,6 +488,126 @@ final class OpenAICompatiblePluginTests: XCTestCase { XCTAssertEqual(segment.end, 1) } + func testAzureDeploymentBatchEndpointUsesDeploymentRouteAuthAndMultipartModel() async throws { + let host = try PluginTestHostServices( + defaults: [ + "baseURL": "https://foundry-example.services.ai.azure.com/openai", + "selectedModel": "gpt-transcribe", + ], + secrets: ["api-key": "azure-key"] + ) + let plugin = OpenAICompatiblePlugin() + plugin.activate(host: host) + plugin.setApiVersion("2025-03-01-preview", for: plugin.providerId) + plugin.setBatchEndpoint(.deploymentScoped, for: plugin.providerId) + + let store = PluginHTTPClientSessionStore() + PluginHTTPClientTestHarness.configure { _ in + store.makeSession(outcomes: [ + .success( + Data(#"{"text":"transcribed"}"#.utf8), + Self.httpResponse( + url: "https://foundry-example.services.ai.azure.com/openai/deployments/gpt-transcribe/audio/transcriptions?api-version=2025-03-01-preview", + statusCode: 200 + ) + ) + ]) + } + + let result = try await plugin.transcribe( + audio: AudioData(samples: [0, 0, 0], wavData: Data("wav".utf8), duration: 1.0), + language: "en", + translate: false, + prompt: nil + ) + + XCTAssertEqual(result.text, "transcribed") + let request = try XCTUnwrap(store.sessions[0].requestedRequests.first) + XCTAssertEqual( + request.url?.path, + "/openai/deployments/gpt-transcribe/audio/transcriptions" + ) + XCTAssertEqual(request.url?.query, "api-version=2025-03-01-preview") + XCTAssertEqual(request.value(forHTTPHeaderField: "api-key"), "azure-key") + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer azure-key") + let body = String(decoding: try XCTUnwrap(request.httpBody), as: UTF8.self) + XCTAssertTrue(body.contains("name=\"model\"\r\n\r\ngpt-transcribe\r\n")) + } + + func testAzureDeploymentBatchEndpointRequiresDatedAPIVersion() async throws { + let host = try PluginTestHostServices( + defaults: [ + "baseURL": "https://foundry-example.services.ai.azure.com/openai", + "selectedModel": "gpt-transcribe", + ] + ) + let plugin = OpenAICompatiblePlugin() + plugin.activate(host: host) + plugin.setApiVersion("preview", for: plugin.providerId) + plugin.setBatchEndpoint(.deploymentScoped, for: plugin.providerId) + + do { + _ = try await plugin.transcribe( + audio: AudioData(samples: [0, 0, 0], wavData: Data("wav".utf8), duration: 1.0), + language: nil, + translate: false, + prompt: nil + ) + XCTFail("Expected a dated API version error") + } catch let error as PluginTranscriptionError { + guard case .apiError(let message) = error else { + return XCTFail("Unexpected error: \(error)") + } + XCTAssertEqual( + message, + "Deployment-scoped batch transcription requires a dated API version." + ) + } + } + + func testDeploymentScopedEndpointEncodesModelAsSinglePathSegment() async throws { + let deploymentName = "transcription east/1%prod" + let host = try PluginTestHostServices( + defaults: [ + "baseURL": "https://example.test/openai", + "selectedModel": deploymentName, + ] + ) + let plugin = OpenAICompatiblePlugin() + plugin.activate(host: host) + plugin.setApiVersion("2025-03-01-preview", for: plugin.providerId) + plugin.setBatchEndpoint(.deploymentScoped, for: plugin.providerId) + + let store = PluginHTTPClientSessionStore() + PluginHTTPClientTestHarness.configure { _ in + store.makeSession(outcomes: [ + .success( + Data(#"{"text":"transcribed"}"#.utf8), + Self.httpResponse( + url: "https://example.test/openai/deployments/transcription%20east%2F1%25prod/audio/transcriptions?api-version=2025-03-01-preview", + statusCode: 200 + ) + ) + ]) + } + + _ = try await plugin.transcribe( + audio: AudioData(samples: [0, 0, 0], wavData: Data("wav".utf8), duration: 1.0), + language: nil, + translate: false, + prompt: nil + ) + + let request = try XCTUnwrap(store.sessions[0].requestedRequests.first) + XCTAssertEqual( + URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)? + .percentEncodedPath, + "/openai/deployments/transcription%20east%2F1%25prod/audio/transcriptions" + ) + let body = String(decoding: try XCTUnwrap(request.httpBody), as: UTF8.self) + XCTAssertTrue(body.contains("name=\"model\"\r\n\r\n\(deploymentName)\r\n")) + } + func testAzureSovereignEndpointUsesAzureAuthenticationHeaders() async throws { let host = try PluginTestHostServices( defaults: ["baseURL": "https://resource.openai.azure.us"], @@ -1040,6 +1161,22 @@ final class OpenAICompatiblePluginTests: XCTestCase { XCTAssertNoThrow(try JSONEncoder().encode(plugin.profileSnapshots)) } + func testBatchEndpointPersistsPerProfile() throws { + let host = try PluginTestHostServices(defaults: ["baseURL": "https://example.test"]) + let plugin = OpenAICompatiblePlugin() + plugin.activate(host: host) + let custom = plugin.addProfile(named: "Azure Foundry") + + plugin.setBatchEndpoint(.deploymentScoped, for: custom.id) + plugin.deactivate() + + let reloaded = OpenAICompatiblePlugin() + reloaded.activate(host: host) + + XCTAssertEqual(reloaded.batchEndpoint(for: reloaded.providerId), .standard) + XCTAssertEqual(reloaded.batchEndpoint(for: custom.id), .deploymentScoped) + } + func testProcessFailsWithoutSelectedModel() async throws { let host = try PluginTestHostServices(defaults: ["baseURL": "https://example.test"]) let plugin = OpenAICompatiblePlugin() @@ -1434,8 +1571,19 @@ final class OpenAICompatiblePluginTests: XCTestCase { let catalog = try XCTUnwrap(JSONSerialization.jsonObject(with: catalogData) as? [String: Any]) let strings = try XCTUnwrap(catalog["strings"] as? [String: Any]) let helpText = "Auto uses realtime streaming only for known realtime model IDs (gpt-live-transcribe, gpt-realtime-whisper) and batch upload otherwise. Choose Realtime to force streaming for any OpenAI-compatible server that supports the /v1/realtime WebSocket API, including custom deployment aliases (e.g. an Azure OpenAI or Microsoft Foundry gpt-live-transcribe deployment) — some providers, including Azure, require a preview API version for realtime transcription. Choose Batch to always use /v1/audio/transcriptions." - - for key in ["Transcription Transport", "Auto", "Batch", "Realtime", helpText] { + let batchEndpointHelp = "Standard v1 uses /v1/audio/transcriptions. Deployment-scoped uses /deployments/{model}/audio/transcriptions and requires a dated API version. Realtime transcription continues to use /v1/realtime." + + for key in [ + "Transcription Transport", + "Auto", + "Batch", + "Realtime", + helpText, + "Batch Transcription Endpoint", + "Standard v1", + "Deployment-scoped", + batchEndpointHelp, + ] { let entry = try XCTUnwrap(strings[key] as? [String: Any], "Missing localization key: \(key)") let localizations = try XCTUnwrap(entry["localizations"] as? [String: Any]) XCTAssertNotNil(localizations["de"], "Missing German localization for \(key)") From 93fa6825f962a74f646dcdfce8282b5888ea36fc Mon Sep 17 00:00:00 2001 From: Kyle Rubenok Date: Sat, 15 Aug 2026 09:17:23 -0700 Subject: [PATCH 2/3] Clarify realtime endpoint documentation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Plugins/OpenAICompatiblePlugin/README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/README.md b/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/README.md index 6e70c6482..fd71974a6 100644 --- a/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/README.md +++ b/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/README.md @@ -26,7 +26,8 @@ route rather than the OpenAI v1 route. Configure a dedicated profile with: - Transcription Transport: `Batch` or `Auto` for a batch model - Batch Transcription Endpoint: `Deployment-scoped` -For realtime transcription, use the Standard v1 endpoint style and the API -version required by the provider's `/v1/realtime` endpoint. A separate profile -is recommended when batch and realtime endpoints require different API -versions. +For realtime transcription, select the `Realtime` transport and configure the +API version required by the provider's `/v1/realtime` endpoint. Realtime +requests always use `/v1/realtime` and ignore the Batch Transcription Endpoint +setting. A separate profile is recommended when batch and realtime endpoints +require different API versions. From 025fcac162792507fcbbe38d19a37a555ca373b2 Mon Sep 17 00:00:00 2001 From: Kyle Rubenok Date: Sun, 16 Aug 2026 09:32:48 -0700 Subject: [PATCH 3/3] Address compatible transcription review Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- TypeWhisperPluginSDK/Package.swift | 2 +- .../Plugins/OpenAICompatiblePlugin/Localizable.xcstrings | 8 ++++---- .../OpenAICompatiblePlugin/OpenAICompatiblePlugin.swift | 2 +- .../Tests/OpenAICompatiblePluginTests.swift | 9 +++++---- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/TypeWhisperPluginSDK/Package.swift b/TypeWhisperPluginSDK/Package.swift index beb934b0d..3605cca89 100644 --- a/TypeWhisperPluginSDK/Package.swift +++ b/TypeWhisperPluginSDK/Package.swift @@ -29,7 +29,7 @@ let package = Package( name: "OpenAICompatiblePlugin", dependencies: ["TypeWhisperPluginSDK"], path: "Plugins/OpenAICompatiblePlugin", - exclude: ["Tests"], + exclude: ["README.md", "Tests"], resources: [ .process("Localizable.xcstrings"), .process("manifest.json"), diff --git a/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/Localizable.xcstrings b/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/Localizable.xcstrings index f36fe8437..ee8efa067 100644 --- a/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/Localizable.xcstrings +++ b/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/Localizable.xcstrings @@ -89,24 +89,24 @@ } } }, - "Auto uses realtime streaming only for known realtime model IDs (gpt-live-transcribe, gpt-realtime-whisper) and batch upload otherwise. Choose Realtime to force streaming for any OpenAI-compatible server that supports the /v1/realtime WebSocket API, including custom deployment aliases (e.g. an Azure OpenAI or Microsoft Foundry gpt-live-transcribe deployment) — some providers, including Azure, require a preview API version for realtime transcription. Choose Batch to always use /v1/audio/transcriptions.": { + "Auto uses realtime streaming only for known realtime model IDs (gpt-live-transcribe, gpt-realtime-whisper) and batch upload otherwise. Choose Realtime to force streaming for any OpenAI-compatible server that supports the /v1/realtime WebSocket API, including custom deployment aliases (e.g. an Azure OpenAI or Microsoft Foundry gpt-live-transcribe deployment) — some providers, including Azure, require a preview API version for realtime transcription. Choose Batch to use the selected Batch Transcription Endpoint.": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Automatisch verwendet Echtzeit-Streaming nur für bekannte Echtzeit-Modell-IDs (gpt-live-transcribe, gpt-realtime-whisper) und ansonsten den Batch-Upload. Wählen Sie Echtzeit, um Streaming für jeden OpenAI-kompatiblen Server zu erzwingen, der die WebSocket-API /v1/realtime unterstützt, einschließlich benutzerdefinierter Bereitstellungsaliase (z. B. eine gpt-live-transcribe-Bereitstellung in Azure OpenAI oder Microsoft Foundry). Einige Anbieter, darunter Azure, erfordern für die Echtzeittranskription eine Vorschau-API-Version. Wählen Sie Batch, um immer /v1/audio/transcriptions zu verwenden." + "value": "Automatisch verwendet Echtzeit-Streaming nur für bekannte Echtzeit-Modell-IDs (gpt-live-transcribe, gpt-realtime-whisper) und ansonsten den Batch-Upload. Wählen Sie Echtzeit, um Streaming für jeden OpenAI-kompatiblen Server zu erzwingen, der die WebSocket-API /v1/realtime unterstützt, einschließlich benutzerdefinierter Bereitstellungsaliase (z. B. eine gpt-live-transcribe-Bereitstellung in Azure OpenAI oder Microsoft Foundry). Einige Anbieter, darunter Azure, erfordern für die Echtzeittranskription eine Vorschau-API-Version. Wählen Sie Batch, um den ausgewählten Batch-Transkriptionsendpunkt zu verwenden." } }, "ja": { "stringUnit": { "state": "translated", - "value": "自動では、既知のリアルタイムモデル ID(gpt-live-transcribe、gpt-realtime-whisper)のみリアルタイムストリーミングを使用し、それ以外はバッチアップロードを使用します。/v1/realtime WebSocket API をサポートする任意の OpenAI 互換サーバーでストリーミングを強制するには、リアルタイムを選択します。これには、カスタムデプロイ名(Azure OpenAI または Microsoft Foundry の gpt-live-transcribe デプロイなど)も含まれます。Azure など一部のプロバイダーでは、リアルタイム文字起こしにプレビュー API バージョンが必要です。常に /v1/audio/transcriptions を使用するには、バッチを選択します。" + "value": "自動では、既知のリアルタイムモデル ID(gpt-live-transcribe、gpt-realtime-whisper)のみリアルタイムストリーミングを使用し、それ以外はバッチアップロードを使用します。/v1/realtime WebSocket API をサポートする任意の OpenAI 互換サーバーでストリーミングを強制するには、リアルタイムを選択します。これには、カスタムデプロイ名(Azure OpenAI または Microsoft Foundry の gpt-live-transcribe デプロイなど)も含まれます。Azure など一部のプロバイダーでは、リアルタイム文字起こしにプレビュー API バージョンが必要です。選択したバッチ文字起こしエンドポイントを使用するには、バッチを選択します。" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "“自动”仅对已知的实时模型 ID(gpt-live-transcribe、gpt-realtime-whisper)使用实时流式传输,其他模型则使用批量上传。选择“实时”可强制任何支持 /v1/realtime WebSocket API 的 OpenAI 兼容服务器使用流式传输,包括自定义部署别名(例如 Azure OpenAI 或 Microsoft Foundry 的 gpt-live-transcribe 部署)。部分提供商(包括 Azure)需要预览版 API 版本才能进行实时转写。选择“批量”则始终使用 /v1/audio/transcriptions。" + "value": "“自动”仅对已知的实时模型 ID(gpt-live-transcribe、gpt-realtime-whisper)使用实时流式传输,其他模型则使用批量上传。选择“实时”可强制任何支持 /v1/realtime WebSocket API 的 OpenAI 兼容服务器使用流式传输,包括自定义部署别名(例如 Azure OpenAI 或 Microsoft Foundry 的 gpt-live-transcribe 部署)。部分提供商(包括 Azure)需要预览版 API 版本才能进行实时转写。选择“批量”以使用所选的批量转写终结点。" } } } diff --git a/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/OpenAICompatiblePlugin.swift b/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/OpenAICompatiblePlugin.swift index 719671412..3fc60d0f7 100644 --- a/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/OpenAICompatiblePlugin.swift +++ b/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/OpenAICompatiblePlugin.swift @@ -2225,7 +2225,7 @@ private struct OpenAICompatibleSettingsView: View { saveTranscriptionTransport() } - Text("Auto uses realtime streaming only for known realtime model IDs (gpt-live-transcribe, gpt-realtime-whisper) and batch upload otherwise. Choose Realtime to force streaming for any OpenAI-compatible server that supports the /v1/realtime WebSocket API, including custom deployment aliases (e.g. an Azure OpenAI or Microsoft Foundry gpt-live-transcribe deployment) — some providers, including Azure, require a preview API version for realtime transcription. Choose Batch to always use /v1/audio/transcriptions.", bundle: bundle) + Text("Auto uses realtime streaming only for known realtime model IDs (gpt-live-transcribe, gpt-realtime-whisper) and batch upload otherwise. Choose Realtime to force streaming for any OpenAI-compatible server that supports the /v1/realtime WebSocket API, including custom deployment aliases (e.g. an Azure OpenAI or Microsoft Foundry gpt-live-transcribe deployment) — some providers, including Azure, require a preview API version for realtime transcription. Choose Batch to use the selected Batch Transcription Endpoint.", bundle: bundle) .font(.caption) .foregroundStyle(.secondary) } diff --git a/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/Tests/OpenAICompatiblePluginTests.swift b/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/Tests/OpenAICompatiblePluginTests.swift index a47c65b1c..d460a6bbb 100644 --- a/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/Tests/OpenAICompatiblePluginTests.swift +++ b/TypeWhisperPluginSDK/Plugins/OpenAICompatiblePlugin/Tests/OpenAICompatiblePluginTests.swift @@ -1562,7 +1562,7 @@ final class OpenAICompatiblePluginTests: XCTestCase { ) } - func testTransportSettingsHaveGermanAndJapaneseLocalizations() throws { + func testTransportSettingsHaveLocalizations() throws { let catalogURL = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() @@ -1570,7 +1570,7 @@ final class OpenAICompatiblePluginTests: XCTestCase { let catalogData = try Data(contentsOf: catalogURL) let catalog = try XCTUnwrap(JSONSerialization.jsonObject(with: catalogData) as? [String: Any]) let strings = try XCTUnwrap(catalog["strings"] as? [String: Any]) - let helpText = "Auto uses realtime streaming only for known realtime model IDs (gpt-live-transcribe, gpt-realtime-whisper) and batch upload otherwise. Choose Realtime to force streaming for any OpenAI-compatible server that supports the /v1/realtime WebSocket API, including custom deployment aliases (e.g. an Azure OpenAI or Microsoft Foundry gpt-live-transcribe deployment) — some providers, including Azure, require a preview API version for realtime transcription. Choose Batch to always use /v1/audio/transcriptions." + let helpText = "Auto uses realtime streaming only for known realtime model IDs (gpt-live-transcribe, gpt-realtime-whisper) and batch upload otherwise. Choose Realtime to force streaming for any OpenAI-compatible server that supports the /v1/realtime WebSocket API, including custom deployment aliases (e.g. an Azure OpenAI or Microsoft Foundry gpt-live-transcribe deployment) — some providers, including Azure, require a preview API version for realtime transcription. Choose Batch to use the selected Batch Transcription Endpoint." let batchEndpointHelp = "Standard v1 uses /v1/audio/transcriptions. Deployment-scoped uses /deployments/{model}/audio/transcriptions and requires a dated API version. Realtime transcription continues to use /v1/realtime." for key in [ @@ -1586,8 +1586,9 @@ final class OpenAICompatiblePluginTests: XCTestCase { ] { let entry = try XCTUnwrap(strings[key] as? [String: Any], "Missing localization key: \(key)") let localizations = try XCTUnwrap(entry["localizations"] as? [String: Any]) - XCTAssertNotNil(localizations["de"], "Missing German localization for \(key)") - XCTAssertNotNil(localizations["ja"], "Missing Japanese localization for \(key)") + for locale in ["de", "ja", "zh-Hans"] { + XCTAssertNotNil(localizations[locale], "Missing \(locale) localization for \(key)") + } } }