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
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,12 @@ class ChatProvider {
messages = choices?.compactMap(\.message?.content) ?? []
dump(chatUsage)
usage = chatUsage
} catch APIError.responseUnsuccessful(let description, let statusCode) {
self.errorMessage = "Network error with status code: \(statusCode) and description: \(description)"
} catch APIError.responseUnsuccessful(let description, let statusCode, let responseBody) {
var message = "Network error with status code: \(statusCode) and description: \(description)"
if let responseBody {
message += " — Response body: \(responseBody)"
}
self.errorMessage = message
} catch {
errorMessage = error.localizedDescription
}
Expand All @@ -50,8 +54,12 @@ class ChatProvider {
let content = result.choices?.first?.delta?.content ?? ""
self.message += content
}
} catch APIError.responseUnsuccessful(let description, let statusCode) {
self.errorMessage = "Network error with status code: \(statusCode) and description: \(description)"
} catch APIError.responseUnsuccessful(let description, let statusCode, let responseBody) {
var message = "Network error with status code: \(statusCode) and description: \(description)"
if let responseBody {
message += " — Response body: \(responseBody)"
}
self.errorMessage = message
} catch {
self.errorMessage = error.localizedDescription
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,12 @@ final class ChatStructuredOutputProvider {
messages = choices.compactMap(\.message?.content).map { $0.asJsonFormatted() }
assert(messages.count == 1)
errorMessage = choices.first?.message?.refusal ?? ""
} catch APIError.responseUnsuccessful(let description, let statusCode) {
self.errorMessage = "Network error with status code: \(statusCode) and description: \(description)"
} catch APIError.responseUnsuccessful(let description, let statusCode, let responseBody) {
var message = "Network error with status code: \(statusCode) and description: \(description)"
if let responseBody {
message += " — Response body: \(responseBody)"
}
self.errorMessage = message
} catch {
errorMessage = error.localizedDescription
}
Expand All @@ -58,8 +62,12 @@ final class ChatStructuredOutputProvider {
self.message = self.message.asJsonFormatted()
}
}
} catch APIError.responseUnsuccessful(let description, let statusCode) {
self.errorMessage = "Network error with status code: \(statusCode) and description: \(description)"
} catch APIError.responseUnsuccessful(let description, let statusCode, let responseBody) {
var message = "Network error with status code: \(statusCode) and description: \(description)"
if let responseBody {
message += " — Response body: \(responseBody)"
}
self.errorMessage = message
} catch {
self.errorMessage = error.localizedDescription
}
Expand Down
58 changes: 52 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ That's all you need to begin accessing the full range of OpenAI endpoints.

You may want to build UI around the type of error that the API returns.
For example, a `429` means that your requests are being rate limited.
The `APIError` type has a case `responseUnsuccessful` with two associated values: a `description` and `statusCode`.
The `APIError` type has a case `responseUnsuccessful` with three associated values: a `description`, `statusCode`, and an optional `responseBody`.
Here is a usage example using the chat completion API:

```swift
Expand All @@ -181,8 +181,11 @@ let parameters = ChatCompletionParameters(messages: [.init(role: .user, content:
do {
let choices = try await service.startChat(parameters: parameters).choices
// Work with choices
} catch APIError.responseUnsuccessful(let description, let statusCode) {
} catch APIError.responseUnsuccessful(let description, let statusCode, let responseBody) {
print("Network error with status code: \(statusCode) and description: \(description)")
if let responseBody {
print("Response body: \(responseBody)")
}
} catch {
print(error.localizedDescription)
}
Expand Down Expand Up @@ -391,10 +394,30 @@ playAudio(from: audioObjectData)
} catch {
// Handle errors
print("Error playing audio: \(error.localizedDescription)")
}
}
}
```

#### Streaming audio responses

```swift
var parameters = AudioSpeechParameters(
model: .tts1,
input: "Streaming sample",
voice: .nova,
stream: true
)

let audioStream = try await service.createStreamingSpeech(parameters: parameters)

for try await chunk in audioStream {
// Each chunk contains audio data you can append or play immediately.
handleAudioChunk(chunk.chunk, isLast: chunk.isLastChunk)
}
```

The `AudioSpeechChunkObject` exposed in each iteration includes the raw `Data` for playback plus lightweight metadata (`isLastChunk`, `chunkIndex`) so callers can manage buffers or gracefully end playback when the stream finishes.

### Chat
Parameters
```swift
Expand Down Expand Up @@ -1017,6 +1040,26 @@ let parameters = ChatCompletionParameters(messages: [.init(role: .user, content:
let chatCompletionObject = try await service.startStreamedChat(parameters: parameters)
```

#### Streaming chat with reasoning overrides

```swift
var parameters = ChatCompletionParameters(
messages: [.init(role: .user, content: .text("Give me a concise summary of the Manhattan project"))],
model: .gpt4o,
reasoning: .init(effort: "medium", maxTokens: 256)
)

parameters.stream = true
parameters.streamOptions = .init(includeUsage: true)

let stream = try await service.startStreamedChat(parameters: parameters)
for try await chunk in stream {
handleChunk(chunk)
}
```

The new `reasoning` override lets you pass provider-specific reasoning hints (e.g., OpenRouter’s `effort`, `exclude`, or `max_tokens`). When you toggle `stream` and `streamOptions.includeUsage`, the service returns streamed chat deltas plus a final usage summary chunk.

### Function Calling

Chat Completion also supports [Function Calling](https://platform.openai.com/docs/guides/function-calling) and [Parallel Function Calling](https://platform.openai.com/docs/guides/function-calling/parallel-function-calling). `functions` has been deprecated in favor of `tools` check [OpenAI Documentation](https://platform.openai.com/docs/api-reference/chat/create) for more.
Expand Down Expand Up @@ -4274,8 +4317,12 @@ do {
self.reasoningMessage += reasoning
}
}
} catch APIError.responseUnsuccessful(let description, let statusCode) {
self.errorMessage = "Network error with status code: \(statusCode) and description: \(description)"
} catch APIError.responseUnsuccessful(let description, let statusCode, let responseBody) {
var message = "Network error with status code: \(statusCode) and description: \(description)"
if let responseBody {
message += " — Response body: \(responseBody)"
}
self.errorMessage = message
} catch {
self.errorMessage = error.localizedDescription
}
Expand Down Expand Up @@ -4328,4 +4375,3 @@ let stream = try await service.startStreamedChat(parameters: parameters)

## Collaboration
Open a PR for any proposed change pointing it to `main` branch. Unit tests are highly appreciated ❤️

16 changes: 16 additions & 0 deletions Sources/OpenAI/AIProxy/AIProxyService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,22 @@ struct AIProxyService: OpenAIService {
return AudioSpeechObject(output: data)
}

func createStreamingSpeech(
parameters: AudioSpeechParameters)
async throws -> AsyncThrowingStream<AudioSpeechChunkObject, Error>
{
var streamingParameters = parameters
streamingParameters.stream = true
let request = try await OpenAIAPI.audio(.speech).request(
aiproxyPartialKey: partialKey,
clientID: clientID,
organizationID: organizationID,
openAIEnvironment: openAIEnvironment,
method: .post,
params: streamingParameters)
return try await fetchAudioStream(debugEnabled: debugEnabled, with: request)
}

// MARK: Chat

func startChat(
Expand Down
10 changes: 6 additions & 4 deletions Sources/OpenAI/AIProxy/Endpoint+AIProxy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ extension Endpoint {
async throws -> URLRequest
{
let finalPath = path(in: openAIEnvironment)
var request = URLRequest(url: urlComponents(serviceURL: openAIEnvironment.baseURL, path: finalPath, queryItems: queryItems)
.url!)
var request = URLRequest(
url: urlComponents(serviceURL: openAIEnvironment.baseURL, path: finalPath, queryItems: queryItems)
.url!)
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.addValue(aiproxyPartialKey, forHTTPHeaderField: "aiproxy-partial-key")
if let organizationID {
Expand Down Expand Up @@ -84,8 +85,9 @@ extension Endpoint {
async throws -> URLRequest
{
let finalPath = path(in: openAIEnvironment)
var request = URLRequest(url: urlComponents(serviceURL: openAIEnvironment.baseURL, path: finalPath, queryItems: queryItems)
.url!)
var request = URLRequest(
url: urlComponents(serviceURL: openAIEnvironment.baseURL, path: finalPath, queryItems: queryItems)
.url!)
request.httpMethod = method.rawValue
request.addValue(aiproxyPartialKey, forHTTPHeaderField: "aiproxy-partial-key")
if let organizationID {
Expand Down
7 changes: 7 additions & 0 deletions Sources/OpenAI/Azure/DefaultOpenAIAzureService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ public final class DefaultOpenAIAzureService: OpenAIService {
"Currently, this API is not supported. We welcome and encourage contributions to our open-source project. Please consider opening an issue or submitting a pull request to add support for this feature.")
}

public func createStreamingSpeech(parameters _: AudioSpeechParameters) async throws
-> AsyncThrowingStream<AudioSpeechChunkObject, Error>
{
fatalError(
"Currently, this API is not supported. We welcome and encourage contributions to our open-source project. Please consider opening an issue or submitting a pull request to add support for this feature.")
}

public func startChat(parameters: ChatCompletionParameters) async throws -> ChatCompletionObject {
var chatParameters = parameters
chatParameters.stream = false
Expand Down
7 changes: 7 additions & 0 deletions Sources/OpenAI/LocalModelService/LocalModelService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ struct LocalModelService: OpenAIService {
"Currently, this API is not supported. We welcome and encourage contributions to our open-source project. Please consider opening an issue or submitting a pull request to add support for this feature.")
}

func createStreamingSpeech(parameters _: AudioSpeechParameters) async throws
-> AsyncThrowingStream<AudioSpeechChunkObject, Error>
{
fatalError(
"Currently, this API is not supported. We welcome and encourage contributions to our open-source project. Please consider opening an issue or submitting a pull request to add support for this feature.")
}

func startChat(
parameters: ChatCompletionParameters)
async throws -> ChatCompletionObject
Expand Down
44 changes: 31 additions & 13 deletions Sources/OpenAI/Private/Networking/AsyncHTTPClientAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,29 +62,47 @@ public class AsyncHTTPClientAdapter: HTTPClient {
let asyncHTTPClientRequest = try createAsyncHTTPClientRequest(from: request)

let response = try await client.execute(asyncHTTPClientRequest, deadline: .now() + .seconds(60))
let contentType = response.headers.first(name: "content-type") ?? ""
let httpResponse = HTTPResponse(
statusCode: Int(response.status.code),
headers: convertHeaders(response.headers))

let stream = AsyncThrowingStream<String, Error> { continuation in
Task {
do {
for try await byteBuffer in response.body {
if let string = byteBuffer.getString(at: 0, length: byteBuffer.readableBytes) {
let lines = string.split(separator: "\n", omittingEmptySubsequences: false)
for line in lines {
continuation.yield(String(line))
if contentType.lowercased().contains("text/event-stream") {
let stream = AsyncThrowingStream<String, Error> { continuation in
Task {
do {
for try await byteBuffer in response.body {
if let string = byteBuffer.getString(at: 0, length: byteBuffer.readableBytes) {
let lines = string.split(separator: "\n", omittingEmptySubsequences: false)
for line in lines {
continuation.yield(String(line))
}
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
return (.lines(stream), httpResponse)
} else {
let byteStream = AsyncThrowingStream<UInt8, Error> { continuation in
Task {
do {
for try await byteBuffer in response.body {
for byte in byteBuffer.readableBytesView {
continuation.yield(byte)
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
return (.bytes(byteStream), httpResponse)
}

return (.lines(stream), httpResponse)
}

/// Properly shutdown the HTTP client
Expand Down
36 changes: 26 additions & 10 deletions Sources/OpenAI/Private/Networking/URLSessionHTTPClientAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,20 +48,36 @@ public class URLSessionHTTPClientAdapter: HTTPClient {
statusCode: httpURLResponse.statusCode,
headers: convertHeaders(httpURLResponse.allHeaderFields))

let stream = AsyncThrowingStream<String, Error> { continuation in
Task {
do {
for try await line in asyncBytes.lines {
continuation.yield(line)
let contentType = httpURLResponse.value(forHTTPHeaderField: "Content-Type") ?? httpURLResponse.mimeType ?? ""
if contentType.lowercased().contains("text/event-stream") {
let stream = AsyncThrowingStream<String, Error> { continuation in
Task {
do {
for try await line in asyncBytes.lines {
continuation.yield(line)
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
return (.lines(stream), response)
} else {
let byteStream = AsyncThrowingStream<UInt8, Error> { continuation in
Task {
do {
for try await byte in asyncBytes {
continuation.yield(byte)
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
return (.bytes(byteStream), response)
}

return (.lines(stream), response)
}

private let urlSession: URLSession
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ public struct AudioSpeechParameters: Encodable {
input: String,
voice: Voice,
responseFormat: ResponseFormat? = nil,
speed: Double? = nil)
speed: Double? = nil,
stream: Bool? = nil)
{
self.model = model.rawValue
self.input = input
self.voice = voice.rawValue
self.responseFormat = responseFormat?.rawValue
self.speed = speed
self.stream = stream
}

public enum TTSModel {
Expand Down Expand Up @@ -59,12 +61,16 @@ public struct AudioSpeechParameters: Encodable {
case flac
}

/// When true, the API will return streaming audio chunks instead of a single response payload.
public var stream: Bool?

enum CodingKeys: String, CodingKey {
case model
case input
case voice
case responseFormat = "response_format"
case speed
case stream
}

/// One of the available [TTS models](https://platform.openai.com/docs/models/tts): tts-1 or tts-1-hd
Expand Down
Loading