diff --git a/CHANGELOG.md b/CHANGELOG.md index 9988bca9..20cdebe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ This project uses semantic versioning for public releases. Use `MAJOR.MINOR.PATC - `MINOR` changes add user-visible features and improvements. - `PATCH` changes fix bugs, polish existing behavior, or make small internal improvements. +## [Unreleased] + +### Fixed + +- Fixed the configured transcription, post-processing, and context timeouts being silently capped at 30 seconds by a session-level resource timeout, which broke long transfers to slow local models. + ## [1.1.0] - 2026-06-03 ### Added diff --git a/Sources/LLMAPITransport.swift b/Sources/LLMAPITransport.swift index e414d13b..698010c9 100644 --- a/Sources/LLMAPITransport.swift +++ b/Sources/LLMAPITransport.swift @@ -1,23 +1,49 @@ import Foundation enum LLMAPITransport { - private static let requestSession: URLSession = { - makeEphemeralSession() - }() + /// Floor for the whole-transfer budget so requests that never set an + /// explicit timeout still fail within a reasonable window. + private static let minimumResourceTimeout: TimeInterval = 30 - private static func makeEphemeralSession() -> URLSession { + /// Reusable sessions keyed by resource timeout, so connection reuse is + /// preserved while each request still gets a whole-transfer budget that + /// honors the caller's configured timeout. Only a handful of distinct + /// timeout values ever exist (one per timeout setting). + private static var sessionsByResourceTimeout: [TimeInterval: URLSession] = [:] + private static let sessionsLock = NSLock() + + private static func makeEphemeralSession(resourceTimeout: TimeInterval) -> URLSession { let configuration = URLSessionConfiguration.ephemeral configuration.requestCachePolicy = .reloadIgnoringLocalCacheData configuration.urlCache = nil configuration.timeoutIntervalForRequest = 20 - configuration.timeoutIntervalForResource = 30 + configuration.timeoutIntervalForResource = resourceTimeout return URLSession(configuration: configuration) } + /// timeoutIntervalForResource caps the entire transfer and has no + /// per-request override, so derive it from the caller's configured + /// timeout (e.g. post_processing_timeout_seconds) instead of a fixed cap. + private static func resourceTimeout(for request: URLRequest) -> TimeInterval { + max(request.timeoutInterval, minimumResourceTimeout) + } + + private static func sharedSession(for request: URLRequest) -> URLSession { + let timeout = resourceTimeout(for: request) + sessionsLock.lock() + defer { sessionsLock.unlock() } + if let existing = sessionsByResourceTimeout[timeout] { + return existing + } + let session = makeEphemeralSession(resourceTimeout: timeout) + sessionsByResourceTimeout[timeout] = session + return session + } + static func data( for request: URLRequest ) async throws -> (Data, URLResponse) { - try await requestSession.data(for: request) + try await sharedSession(for: request).data(for: request) } static func upload( @@ -26,7 +52,7 @@ enum LLMAPITransport { ) async throws -> (Data, URLResponse) { // Use a fresh session for each upload so a bad reused connection cannot // poison subsequent transcription uploads. - let session = makeEphemeralSession() + let session = makeEphemeralSession(resourceTimeout: resourceTimeout(for: request)) defer { session.finishTasksAndInvalidate() } return try await session.upload(for: request, from: bodyData) }