Skip to content

Commit cd84518

Browse files
authored
Merge pull request #2 from Compiler-Inc/add/more-models
feat: add support for more providers and models
2 parents be25cf1 + 5e7b9c2 commit cd84518

12 files changed

+440
-134
lines changed

Sources/CompilerSwiftAI/Auth/CompilerCient+AppleAuth.swift

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import AuthenticationServices
44

55
extension CompilerClient {
6-
public func handleSignInWithApple(_ result: Result<ASAuthorization, Error>) async throws -> Bool {
6+
public func handleSignInWithApple(_ result: Result<ASAuthorization, Error>, nonce: String?) async throws -> Bool {
77
switch result {
88
case .success(let auth):
99
guard let appleIDCredential = auth.credential as? ASAuthorizationAppleIDCredential,
@@ -12,11 +12,15 @@ extension CompilerClient {
1212
throw AuthError.invalidToken
1313
}
1414

15-
// Store Apple ID token - this acts as our "refresh token"
16-
// Apple ID tokens can be reused for a while (usually days to weeks)
15+
// Store Apple ID token
1716
await keychain.save(idToken, service: "apple-id-token", account: "user")
1817

19-
let accessToken = try await authenticateWithServer(idToken: idToken)
18+
// Store the nonce for verification
19+
if let nonce = nonce {
20+
await keychain.save(nonce, service: "apple-nonce", account: "user")
21+
}
22+
23+
let accessToken = try await authenticateWithServer(idToken: idToken, nonce: nonce)
2024
await keychain.save(accessToken, service: "access-token", account: "user")
2125

2226
return true

Sources/CompilerSwiftAI/Auth/CompilerClient+Auth.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,21 +28,23 @@ extension CompilerClient {
2828
throw AuthError.invalidToken
2929
}
3030

31-
func authenticateWithServer(idToken: String) async throws -> String {
31+
func authenticateWithServer(idToken: String, nonce: String? = nil) async throws -> String {
3232
let lowercasedAppID = appID.uuidString.lowercased()
3333
let endpoint = "\(baseURL)/v1/apps/\(lowercasedAppID)/end-users/apple"
3434
guard let url = URL(string: endpoint) else {
3535
authLogger.error("Invalid URL: \(self.baseURL)")
3636
throw AuthError.invalidResponse
3737
}
3838

39+
var body: [String: String] = ["id_token": idToken]
40+
if let nonce {
41+
body["nonce"] = nonce
42+
}
3943
authLogger.debug("Making auth request to: \(endpoint)")
4044

4145
var request = URLRequest(url: url)
4246
request.httpMethod = "POST"
4347
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
44-
45-
let body = ["id_token": idToken]
4648
request.httpBody = try JSONEncoder().encode(body)
4749

4850
authLogger.debug("Request body: \(body)")
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import CryptoKit
2+
import AuthenticationServices
3+
4+
extension CompilerClient {
5+
// Generate a random nonce for authentication
6+
public static func randomNonceString(length: Int = 32) -> String {
7+
precondition(length > 0)
8+
let charset: [Character] = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._")
9+
var result = ""
10+
var remainingLength = length
11+
12+
while remainingLength > 0 {
13+
let randoms: [UInt8] = (0 ..< 16).map { _ in
14+
var random: UInt8 = 0
15+
let errorCode = SecRandomCopyBytes(kSecRandomDefault, 1, &random)
16+
if errorCode != errSecSuccess {
17+
fatalError("Unable to generate nonce. SecRandomCopyBytes failed with OSStatus \(errorCode)")
18+
}
19+
return random
20+
}
21+
22+
randoms.forEach { random in
23+
if remainingLength == 0 {
24+
return
25+
}
26+
27+
if random < charset.count {
28+
result.append(charset[Int(random)])
29+
remainingLength -= 1
30+
}
31+
}
32+
}
33+
34+
return result
35+
}
36+
37+
// Compute the SHA256 hash of a string
38+
public static func sha256(_ input: String) -> String {
39+
let inputData = Data(input.utf8)
40+
let hashedData = SHA256.hash(data: inputData)
41+
let hashString = hashedData.compactMap {
42+
String(format: "%02x", $0)
43+
}.joined()
44+
45+
return hashString
46+
}
47+
}

Sources/CompilerSwiftAI/CompilerClient.swift

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,29 @@ import OSLog
44

55
/// Primary interface for interacting with Compiler's Back End
66
public final actor CompilerClient {
7+
public struct Configuration {
8+
/// Current streaming configuration
9+
public var streamingChat: StreamConfiguration
10+
/// Whether to enable debug logging
11+
public var enableDebugLogging: Bool
12+
13+
/// Initialize a new Configuration instance
14+
/// - Parameters:
15+
/// - streamingChat: The streaming configuration to use for chat interactions, defaults to OpenAI GPT-4
16+
/// - enableDebugLogging: Whether to enable detailed debug logging output, defaults to false
17+
public init(
18+
streamingChat: StreamConfiguration = .openAI(.gpt4o),
19+
enableDebugLogging: Bool = false
20+
) {
21+
self.streamingChat = streamingChat
22+
self.enableDebugLogging = enableDebugLogging
23+
}
24+
}
25+
726
/// Application ID (retrievable from the Comiler Developer Dashboard)
827
let appID: UUID
28+
29+
private(set) var configuration: Configuration
930

1031
internal let baseURL: String = "https://backend.compiler.inc"
1132
internal let keychain: KeychainHelper = KeychainHelper.standard
@@ -16,11 +37,29 @@ public final actor CompilerClient {
1637
/// Initialize the Compiler Client
1738
/// - Parameters:
1839
/// - appID: Application ID (retrievable from the Comiler Developer Dashboard)
19-
/// - enableDebugLogging: Whether or not to log debug info
20-
public init(appID: UUID, enableDebugLogging: Bool = false) {
40+
/// - configuration: Client configuration including streaming chat settings and debug options
41+
public init(
42+
appID: UUID,
43+
configuration: Configuration = Configuration()
44+
) {
2145
self.appID = appID
22-
self.functionLogger = DebugLogger(Logger.functionCalls, isEnabled: enableDebugLogging)
23-
self.modelLogger = DebugLogger(Logger.modelCalls, isEnabled: enableDebugLogging)
24-
self.authLogger = DebugLogger(Logger.auth, isEnabled: enableDebugLogging)
46+
self.configuration = configuration
47+
self.functionLogger = DebugLogger(Logger.functionCalls, isEnabled: configuration.enableDebugLogging)
48+
self.modelLogger = DebugLogger(Logger.modelCalls, isEnabled: configuration.enableDebugLogging)
49+
self.authLogger = DebugLogger(Logger.auth, isEnabled: configuration.enableDebugLogging)
50+
}
51+
52+
/// Update streaming chat configuration
53+
/// - Parameter update: Closure that takes an inout StreamConfiguration parameter
54+
public func updateStreamingChat(
55+
_ update: (inout StreamConfiguration) -> Void
56+
) {
57+
update(&configuration.streamingChat)
58+
}
59+
60+
/// Creates an immutable streaming session configuration
61+
/// This captures the current streaming configuration at a point in time
62+
public func makeStreamingSession() -> StreamConfiguration {
63+
configuration.streamingChat
2564
}
2665
}

Sources/CompilerSwiftAI/Model Calling/CompilerClient+ModelCalling.swift

Lines changed: 14 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,31 +6,32 @@ extension CompilerClient {
66

77
func makeModelCall(
88
using metadata: ModelMetadata,
9-
messages: [Message],
9+
systemPrompt: String? = nil,
10+
userPrompt: String,
1011
state: (any Codable & Sendable)? = nil
1112
) async throws -> String {
1213
let response = try await makeModelCallWithResponse(
1314
using: metadata,
14-
messages: messages,
15+
systemPrompt: systemPrompt,
16+
userPrompt: userPrompt,
1517
state: state
1618
)
1719
return response.content
1820
}
1921

2022
func makeModelCallWithResponse(
2123
using metadata: ModelMetadata,
22-
messages: [Message],
24+
systemPrompt: String? = nil,
25+
userPrompt: String,
2326
state: (any Codable & Sendable)? = nil
24-
) async throws -> ModelCallResponse {
27+
) async throws -> CompletionResponse {
2528
let endpoint = "\(baseURL)/v1/apps/\(appID.uuidString)/end-users/model-call"
2629
guard let url = URL(string: endpoint) else {
2730
modelLogger.error("Invalid URL: \(self.baseURL)")
2831
throw URLError(.badURL)
2932
}
3033

31-
modelLogger.debug("Making model call to: \(endpoint)")
32-
modelLogger.debug("Using \(messages.count) messages")
33-
modelLogger.debug("Message roles: \(messages.map { $0.role.rawValue })")
34+
modelLogger.debug("Making completion model call to: \(endpoint)")
3435

3536
var request = URLRequest(url: url)
3637
request.httpMethod = "POST"
@@ -39,25 +40,13 @@ extension CompilerClient {
3940
let token = try await getValidToken()
4041
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
4142

42-
// If state is provided, append it to the last user message
43-
let finalMessages: [Message]
44-
if let state = state,
45-
let lastUserMessageIndex = messages.lastIndex(where: { $0.role == .user }) {
46-
var modifiedMessages = messages
47-
let lastUserMessage = modifiedMessages[lastUserMessageIndex]
48-
modifiedMessages[lastUserMessageIndex] = Message(
49-
id: lastUserMessage.id,
50-
role: .user,
51-
content: "\(lastUserMessage.content)\n\nThe current app state is: \(state)"
52-
)
53-
finalMessages = modifiedMessages
54-
} else {
55-
finalMessages = messages
56-
}
43+
// If state is provided, append it to the userPrompt
44+
let finalUserPrompt = state.map { "\(userPrompt)\n\nThe current app state is: \($0)" } ?? userPrompt
5745

58-
let body = ModelCallRequest(
46+
let body = CompletionRequest(
5947
using: metadata,
60-
messages: finalMessages
48+
systemPrompt: systemPrompt,
49+
userPrompt: finalUserPrompt
6150
)
6251

6352
let encoder = JSONEncoder()
@@ -86,6 +75,6 @@ extension CompilerClient {
8675
throw AuthError.serverError("Model call failed with status \(httpResponse.statusCode)")
8776
}
8877

89-
return try JSONDecoder().decode(ModelCallResponse.self, from: data)
78+
return try JSONDecoder().decode(CompletionResponse.self, from: data)
9079
}
9180
}

Sources/CompilerSwiftAI/Model Calling/CompilerClient+Streaming.swift

Lines changed: 63 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22

33
import OSLog
44

5+
struct ChatResponseDataTransferObject: Decodable {
6+
let content: String
7+
}
8+
59
extension CompilerClient {
6-
var streamingProviders: [ModelProvider] { [.openai, .anthropic] }
10+
var streamingProviders: [ModelProvider] { [.openai, .anthropic, .google] }
711

812
// Specialized String streaming version
913
func makeStreamingModelCall(
@@ -35,28 +39,30 @@ extension CompilerClient {
3539
let lastUserMessageIndex = messages.lastIndex(where: { $0.role == .user }) {
3640
var modifiedMessages = messages
3741
let lastUserMessage = modifiedMessages[lastUserMessageIndex]
42+
let stateContent = "\(lastUserMessage.content)\n\nThe current app state is: \(state)"
3843
modifiedMessages[lastUserMessageIndex] = Message(
3944
id: lastUserMessage.id,
4045
role: .user,
41-
content: "\(lastUserMessage.content)\n\nThe current app state is: \(state)"
46+
content: stateContent
4247
)
4348
finalMessages = modifiedMessages
4449
} else {
4550
finalMessages = messages
4651
}
4752

48-
let body = ModelCallRequest(
53+
let body = StreamRequest(
4954
using: metadata,
5055
messages: finalMessages
5156
)
5257

5358
do {
5459
let encoder = JSONEncoder()
5560
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
56-
let jsonData = try encoder.encode(body)
57-
request.httpBody = jsonData
5861

59-
modelLogger.debug("Streaming request body JSON: \(String(data: jsonData, encoding: .utf8) ?? "nil")")
62+
// AppId is only in the endpoint URL, not in query params or body
63+
request.httpBody = try encoder.encode(body)
64+
65+
modelLogger.debug("Streaming request body JSON: \(String(data: request.httpBody ?? Data(), encoding: .utf8) ?? "nil")")
6066
} catch {
6167
modelLogger.error("Failed to encode request: \(error)")
6268
return AsyncThrowingStream { $0.finish(throwing: error) }
@@ -83,30 +89,17 @@ extension CompilerClient {
8389
}
8490

8591
for try await line in asyncBytes.lines {
86-
modelLogger.debug("Raw SSE line [\(line.count) bytes]: \(line)")
87-
88-
// Skip non-SSE lines (like id: lines)
89-
guard line.hasPrefix("data:") else {
90-
modelLogger.debug("Skipping non-SSE line")
91-
continue
92-
}
93-
94-
// Get everything after "data:"
95-
let content = String(line.dropFirst("data:".count))
92+
modelLogger.debug("Raw SSE line: \(line)")
9693

97-
// If it's just a space or empty after "data:", yield a newline
98-
if content.trimmingCharacters(in: .whitespaces).isEmpty {
99-
modelLogger.debug("Empty data line - yielding newline")
100-
continuation.yield("\n")
94+
guard let content = try parseChatResponse(from: line) else {
10195
continue
10296
}
10397

104-
// For non-empty content, trim just the leading space after "data:"
105-
let trimmedContent = content.hasPrefix(" ") ? String(content.dropFirst()) : content
106-
modelLogger.debug("Content: \(trimmedContent.debugDescription)")
98+
modelLogger.debug("Content: \(content.debugDescription)")
10799

108-
// Yield the content
109-
continuation.yield(trimmedContent)
100+
continuation.yield(content)
101+
102+
modelLogger.debug("Content yielded successfully")
110103
}
111104

112105
modelLogger.debug("SSE stream complete")
@@ -131,7 +124,15 @@ extension CompilerClient {
131124
let provider = metadata.provider
132125
let model = metadata.model
133126
let capabilities = metadata.capabilities
134-
let capturedMetadata = ModelMetadata(provider: provider, capabilities: capabilities, model: model)
127+
let temperature = metadata.temperature
128+
let maxTokens = metadata.maxTokens
129+
let capturedMetadata = ModelMetadata(
130+
provider: provider,
131+
capabilities: capabilities,
132+
model: model,
133+
temperature: temperature,
134+
maxTokens: maxTokens
135+
)
135136

136137
return AsyncThrowingStream { continuation in
137138
Task {
@@ -162,4 +163,40 @@ extension CompilerClient {
162163
}
163164
}
164165
}
166+
167+
private func parseChatResponse(from line: String) throws -> String? {
168+
// Skip empty lines and comments
169+
guard !line.isEmpty, !line.hasPrefix(":") else {
170+
return nil
171+
}
172+
173+
// Extract the data part from the SSE format
174+
guard line.hasPrefix("data: ") else {
175+
return nil
176+
}
177+
178+
let jsonString = String(line.dropFirst(6))
179+
180+
guard let parsedResponse = try? parseEventMessage(from: jsonString) else {
181+
print("Couldn't parse repsonse")
182+
return nil
183+
}
184+
185+
return parsedResponse.content
186+
}
187+
188+
private func parseEventMessage(from line: String) throws -> ChatResponseDataTransferObject? {
189+
guard let data = line.data(using: .utf8) else {
190+
print("[ChatStreamer] ❌ Failed to convert string to data: \(line)")
191+
return nil
192+
}
193+
194+
do {
195+
let message = try JSONDecoder().decode(ChatResponseDataTransferObject.self, from: data)
196+
return message
197+
} catch {
198+
print("[ChatStreamer] ❌ JSON decode error: \(error)")
199+
return nil
200+
}
201+
}
165202
}

0 commit comments

Comments
 (0)