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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions Sources/AnalyticsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.cloudRewriteAPIKey != nil
if let envKey = ProcessInfo.processInfo.environment["AZURE_OPENAI_API_KEY"], !envKey.isEmpty {
return true
}
return GeneratedSecrets.azureOpenAIAPIKey != nil
}
}
144 changes: 40 additions & 104 deletions Sources/CloudRewriteService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Never>

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 {
Expand All @@ -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 {
Expand All @@ -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))
Expand Down Expand Up @@ -195,18 +137,19 @@ 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.invalidConfiguration
}

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,
maxCompletionTokens: 2048,
messages: [
ChatMessage(role: "system", content: Self.systemPrompt),
ChatMessage(role: "user", content: "<transcript>\(text)</transcript>")
Expand Down Expand Up @@ -273,6 +216,7 @@ final class CloudRewriteService {
}

private enum CloudRewriteError: Error {
case invalidConfiguration
case invalidResponse
case unauthorized
case rateLimited
Expand All @@ -282,6 +226,8 @@ private enum CloudRewriteError: Error {

var logDescription: String {
switch self {
case .invalidConfiguration:
return "invalid_configuration"
case .invalidResponse:
return "invalid_response"
case .unauthorized:
Expand All @@ -299,15 +245,13 @@ private enum CloudRewriteError: Error {
}

private struct ChatCompletionRequest: Encodable {
let model: String
let temperature: Double
let maxTokens: Int
let maxCompletionTokens: Int
let messages: [ChatMessage]

enum CodingKeys: String, CodingKey {
case model
case temperature
case maxTokens = "max_tokens"
case maxCompletionTokens = "max_completion_tokens"
case messages
}
}
Expand All @@ -325,13 +269,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 {}
19 changes: 10 additions & 9 deletions TypelessTests/ASRPipelineBenchmarkTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -130,10 +130,11 @@ 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 = (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")
Expand All @@ -158,7 +159,7 @@ class ASRPipelineBenchmarkTests: XCTestCase {
paraformerRecognizer = nil
punctuator = nil
corrector = nil
cloudRewriter = nil
cloudRewriteService = nil
termNormalizer = nil
itn = nil
super.tearDown()
Expand Down Expand Up @@ -245,9 +246,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
Expand All @@ -271,7 +272,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()
Expand Down
23 changes: 16 additions & 7 deletions TypelessTests/CloudRewriteServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
)

Expand All @@ -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" }
)

Expand All @@ -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" }
)

Expand All @@ -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" }
)

Expand All @@ -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" }
)

Expand All @@ -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" }
)

Expand All @@ -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)
Expand Down
Loading
Loading