Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 33 additions & 7 deletions Sources/LLMAPITransport.swift
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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)
}
Expand Down