Skip to content

Commit 2dac6b8

Browse files
authored
Add Brave web search support (#103)
1 parent a4eaf5e commit 2dac6b8

13 files changed

+1143
-11
lines changed

README.md

+46
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ included:
1919
- OpenRouter
2020
- DeepSeek
2121
- Fireworks AI
22+
- Brave
2223

2324
Your initialization code determines whether requests go straight to the provider or are
2425
protected through the [AIProxy](https://www.aiproxy.pro) backend.
@@ -100,6 +101,7 @@ offer full demo apps to jump-start your development. Please see the [AIProxyBoot
100101
* [OpenRouter](#openrouter)
101102
* [DeepSeek](#deepseek)
102103
* [Fireworks AI](#fireworks-ai)
104+
* [Brave](#brave)
103105
* [Advanced Settings](#advanced-settings)
104106

105107

@@ -3825,6 +3827,8 @@ not on the messages's `reasoningContent` property. Instead, the reasoning conten
38253827
`message.content` enclosed in `<think></think>` tags:
38263828
38273829
```swift
3830+
import AIProxy
3831+
38283832
/* Uncomment for BYOK use cases */
38293833
// let fireworksAIService = AIProxy.fireworksAIDirectService(
38303834
// unprotectedAPIKey: "your-fireworks-key"
@@ -3874,6 +3878,48 @@ not on the messages's `reasoningContent` property. Instead, the reasoning conten
38743878
38753879
***
38763880
3881+
## Brave
3882+
3883+
When you create a service in the AIProxy dashboard, use `https://api.search.brave.com` as the
3884+
proxy base URL.
3885+
3886+
```swift
3887+
import AIProxy
3888+
3889+
/* Uncomment for BYOK use cases */
3890+
// let braveService = AIProxy.braveDirectService(
3891+
// unprotectedAPIKey: "your-brave-key"
3892+
// )
3893+
3894+
/* Uncomment for all other production use cases */
3895+
// let braveService = AIProxy.braveService(
3896+
// partialKey: "partial-key-from-your-developer-dashboard",
3897+
// serviceURL: "service-url-from-your-developer-dashboard"
3898+
// )
3899+
3900+
do {
3901+
let searchResult = try await braveService.webSearchRequest(query: "How does concurrency work in Swift 6")
3902+
let resultCount = searchResult.web?.results?.count ?? 0
3903+
let urls = searchResult.web?.results?.compactMap { $0.url }
3904+
print(
3905+
"""
3906+
Brave responded with \(resultCount) search results.
3907+
The search returned these urls: \(urls ?? [])
3908+
"""
3909+
)
3910+
} catch AIProxyError.unsuccessfulRequest(let statusCode, let responseBody) {
3911+
print("Receivedt non-200 status code: \(statusCode) with response body: \(responseBody)")
3912+
} catch {
3913+
// You may want to catch additional Foundation errors and pop the appropriate UI
3914+
// to the user. See "How to catch Foundation errors for specific conditions" here:
3915+
// https://www.aiproxy.com/docs/integration-options.html
3916+
print("Could not make brave search: \(error.localizedDescription)")
3917+
}
3918+
```
3919+
3920+
***
3921+
3922+
38773923
## OpenMeteo
38783924
38793925
### How to fetch the weather with OpenMeteo

Sources/AIProxy/AIProxy.swift

+45-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ let aiproxyLogger = Logger(
1313
public struct AIProxy {
1414

1515
/// The current sdk version
16-
public static let sdkVersion = "0.68.0"
16+
public static let sdkVersion = "0.69.0"
1717

1818
/// - Parameters:
1919
/// - partialKey: Your partial key is displayed in the AIProxy dashboard when you submit your provider's key.
@@ -783,6 +783,50 @@ public struct AIProxy {
783783
)
784784
}
785785

786+
/// AIProxy's Brave service
787+
///
788+
/// - Parameters:
789+
/// - partialKey: Your partial key is displayed in the AIProxy dashboard when you submit your Brave key.
790+
/// AIProxy takes your Brave key, encrypts it, and stores part of the result on our servers. The part that you include
791+
/// here is the other part. Both pieces are needed to decrypt your key and fulfill the request to Brave.
792+
///
793+
/// - serviceURL: The service URL is displayed in the AIProxy dashboard when you submit your Brave key.
794+
///
795+
/// - clientID: An optional clientID to attribute requests to specific users or devices. It is OK to leave this blank for
796+
/// most applications. You would set this if you already have an analytics system, and you'd like to annotate AIProxy
797+
/// requests with IDs that are known to other parts of your system.
798+
///
799+
/// If you do not supply your own clientID, the internals of this lib will generate UUIDs for you. The default UUIDs are
800+
/// persistent on macOS and can be accurately used to attribute all requests to the same device. The default UUIDs
801+
/// on iOS are pesistent until the end user chooses to rotate their vendor identification number.
802+
///
803+
/// - Returns: An instance of BraveService configured and ready to make requests
804+
public static func braveService(
805+
partialKey: String,
806+
serviceURL: String,
807+
clientID: String? = nil
808+
) -> BraveService {
809+
return BraveProxiedService(
810+
partialKey: partialKey,
811+
serviceURL: serviceURL,
812+
clientID: clientID
813+
)
814+
}
815+
816+
/// Service that makes request directly to Brave. No protections are built-in for this service.
817+
/// Please only use this for BYOK use cases.
818+
///
819+
/// - Parameters:
820+
/// - unprotectedAPIKey: Your Brave API key
821+
/// - Returns: An instance of Brave configured and ready to make requests
822+
public static func braveDirectService(
823+
unprotectedAPIKey: String
824+
) -> BraveService {
825+
return BraveDirectService(
826+
unprotectedAPIKey: unprotectedAPIKey
827+
)
828+
}
829+
786830
#if canImport(AppKit) && !targetEnvironment(macCatalyst)
787831
public static func encodeImageAsJpeg(
788832
image: NSImage,

Sources/AIProxy/AIProxyURLRequest.swift

+10
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ struct AIProxyURLRequest {
2222
) async throws -> URLRequest {
2323
let deviceCheckToken = await AIProxyDeviceCheck.getToken()
2424

25+
var proxyPath = proxyPath
26+
if !proxyPath.starts(with: "/") {
27+
proxyPath = "/\(proxyPath)"
28+
}
29+
2530
guard var urlComponents = URLComponents(string: serviceURL),
2631
let proxyPathComponents = URLComponents(string: proxyPath) else {
2732
throw AIProxyError.assertion(
@@ -85,6 +90,11 @@ struct AIProxyURLRequest {
8590
contentType: String? = nil,
8691
additionalHeaders: [String: String] = [:]
8792
) throws -> URLRequest {
93+
var path = path
94+
if !path.starts(with: "/") {
95+
path = "/\(path)"
96+
}
97+
8898
guard var urlComponents = URLComponents(string: baseURL),
8999
let pathComponents = URLComponents(string: path) else {
90100
throw AIProxyError.assertion(

Sources/AIProxy/AnonymousAccount/AIProxyStorage.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ final class AIProxyStorage {
5757
timestamp: Date().timeIntervalSince1970
5858
)]
5959
let data: Data = try accountChain.serialize()
60-
let createStatus = try await keychain.create(data: data, scope: .local(keychainAccount: kAIProxyLocalAccount))
60+
let createStatus = await keychain.create(data: data, scope: .local(keychainAccount: kAIProxyLocalAccount))
6161
if createStatus != noErr {
6262
throw AIProxyError.assertion("Could not write a local account to keychain")
6363
}
@@ -80,7 +80,7 @@ final class AIProxyStorage {
8080
throw AIProxyError.assertion("Keychain is not available")
8181
}
8282
let data: Data = try account.serialize()
83-
return try await keychain.create(data: data, scope: .remote(keychainAccount: kAIProxyRemoteAccount))
83+
return await keychain.create(data: data, scope: .remote(keychainAccount: kAIProxyRemoteAccount))
8484
}
8585

8686
static func clear() async throws {

Sources/AIProxy/AnonymousAccount/AnonymousAccountStorage.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ final class AnonymousAccountStorage {
5454
if _localAccountChain == nil {
5555
_localAccountChain = try await AIProxyStorage.writeNewLocalAccountChain()
5656
}
57-
guard var _localAccountChain = _localAccountChain,
57+
guard let _localAccountChain = _localAccountChain,
5858
var localAccount = _localAccountChain.last else {
5959
throw AIProxyError.assertion("Broken invariant. localAccount must be populated with at least one element.")
6060
}
@@ -147,7 +147,7 @@ final class AnonymousAccountStorage {
147147
return
148148
}
149149

150-
guard var resolvedAccount = self.resolvedAccount else {
150+
guard let resolvedAccount = self.resolvedAccount else {
151151
return
152152
}
153153

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//
2+
// BraveDirectService.swift
3+
// AIProxy
4+
//
5+
// Created by Lou Zell on 2/7/25.
6+
//
7+
8+
import Foundation
9+
10+
open class BraveDirectService: BraveService, DirectService {
11+
private let unprotectedAPIKey: String
12+
13+
/// This initializer is not public on purpose.
14+
/// Customers are expected to use the factory `AIProxy.braveDirectService` defined in AIProxy.swift
15+
internal init(unprotectedAPIKey: String) {
16+
self.unprotectedAPIKey = unprotectedAPIKey
17+
}
18+
19+
/// Makes a web search through Brave. See this reference:
20+
/// https://api-dashboard.search.brave.com/app/documentation/web-search/get-started
21+
///
22+
/// - Parameters:
23+
/// - query: The query to send to Brave
24+
/// - secondsToWait: Seconds to wait before raising `URLError.timedOut`
25+
/// - Returns: The search result. There are many properties on this result, so take some time with
26+
/// BraveWebSearchResponseBody to understand how to get the information you want out of it.
27+
public func webSearchRequest(
28+
query: String,
29+
secondsToWait: Int
30+
) async throws -> BraveWebSearchResponseBody {
31+
guard let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
32+
throw AIProxyError.assertion("Could not create an encoded version of query params for brave search")
33+
}
34+
var request = try AIProxyURLRequest.createDirect(
35+
baseURL: "https://api.search.brave.com",
36+
path: "/res/v1/web/search?q=" + encodedQuery,
37+
body: nil,
38+
verb: .get,
39+
contentType: "application/json",
40+
additionalHeaders: [
41+
"X-Subscription-Token": self.unprotectedAPIKey
42+
]
43+
)
44+
request.timeoutInterval = TimeInterval(secondsToWait)
45+
return try await self.makeRequestAndDeserializeResponse(request)
46+
}
47+
}
48+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//
2+
// BraveProxiedService.swift
3+
// AIProxy
4+
//
5+
// Created by Lou Zell on 2/7/25.
6+
//
7+
8+
import Foundation
9+
10+
open class BraveProxiedService: BraveService, ProxiedService {
11+
12+
private let partialKey: String
13+
private let serviceURL: String
14+
private let clientID: String?
15+
16+
/// This initializer is not public on purpose.
17+
/// Customers are expected to use the factory `AIProxy.braveService` defined in AIProxy.swift
18+
internal init(
19+
partialKey: String,
20+
serviceURL: String,
21+
clientID: String?
22+
) {
23+
self.partialKey = partialKey
24+
self.serviceURL = serviceURL
25+
self.clientID = clientID
26+
}
27+
28+
/// Makes a web search through Brave. See this reference:
29+
/// https://api-dashboard.search.brave.com/app/documentation/web-search/get-started
30+
///
31+
/// - Parameters:
32+
/// - query: The query to send to Brave
33+
/// - secondsToWait: Seconds to wait before raising `URLError.timedOut`
34+
/// - Returns: The search result. There are many properties on this result, so take some time with
35+
/// BraveWebSearchResponseBody to understand how to get the information you want out of it.
36+
public func webSearchRequest(
37+
query: String,
38+
secondsToWait: Int
39+
) async throws -> BraveWebSearchResponseBody {
40+
guard let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
41+
throw AIProxyError.assertion("Could not create an encoded version of query params for brave search")
42+
}
43+
var request = try await AIProxyURLRequest.create(
44+
partialKey: self.partialKey,
45+
serviceURL: self.serviceURL,
46+
clientID: self.clientID,
47+
proxyPath: "/res/v1/web/search?q=" + encodedQuery,
48+
body: nil,
49+
verb: .get,
50+
contentType: "application/json"
51+
)
52+
request.timeoutInterval = TimeInterval(secondsToWait)
53+
return try await self.makeRequestAndDeserializeResponse(request)
54+
}
55+
}
+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
//
2+
// BraveService.swift
3+
// AIProxy
4+
//
5+
// Created by Lou Zell on 2/7/25.
6+
//
7+
8+
import Foundation
9+
10+
public protocol BraveService {
11+
12+
/// Makes a web search through Brave. See this reference:
13+
/// https://api-dashboard.search.brave.com/app/documentation/web-search/get-started
14+
///
15+
/// - Parameters:
16+
/// - query: The query to send to Brave
17+
/// - secondsToWait: Seconds to wait before raising `URLError.timedOut`
18+
/// - Returns: The search result. There are many properties on this result, so take some time with
19+
/// BraveWebSearchResponseBody to understand how to get the information you want out of it.
20+
func webSearchRequest(
21+
query: String,
22+
secondsToWait: Int
23+
) async throws -> BraveWebSearchResponseBody
24+
}
25+
26+
extension BraveService {
27+
public func webSearchRequest(query: String) async throws -> BraveWebSearchResponseBody {
28+
return try await self.webSearchRequest(query: query, secondsToWait: 60)
29+
}
30+
}

0 commit comments

Comments
 (0)