Pico AI Proxy is a reverse proxy created specifically for iOS, macOS, iPadOS, and VisionOS developers.
Pico AI Proxy was previously called SwiftOpenAIProxy.
- Prevents hackers from stealing your API keys
- Makes sure every user has a in-app subscription by validating App Store store receipts using Apple's App Store Server Library
- Accepts the standard OpenAI Chat API your chat app already uses, so no changes need to be made to your client app
- Supports multiple LLM providers such as OpenAI and Anthropic (more to come, stay tuned). PicoProxy automatically converts OpenAI Chat API calls to the different providers
- One click install on Railway and can be installed manually on many other hosting providers. See How to Deploy
PicoProxy is written in server-side Swift and uses HummingBird for its HTTP-server.
In December 2023, I faced a significant hack when my OpenAI key was compromised. They quickly used up my entire $2,500 monthly limit, resulting in an unexpected bill from OpenAI. This incident also forced me to take my app, Pico, offline, causing me to miss the lucrative Christmas sales period.
As a response to this incident, I developed Pico AI Server, the first OpenAI proxy created in Server-side Swift. This tool is especially convenient for Swift developers, as it allows easy customization to meet their specific requirements.
Pico AI Proxy is designed to be compatible with any existing OpenAI library. It works seamlessly with libraries such as CleverBird, OpenAISwift, OpenAI-Kit, MacPaw OpenAI, and can also integrate with your custom code.
- Pico AI Proxy uses Apple's App Store Server Library for receipt validation
- Once the receipt is validated, Pico AI Proxy issues a JWT token the client can use for subsequent calls
- Pico AI Proxy is API agnostic and forwards any request to https://api.openai.com.
- The forwarding endpoint is customizable, allowing redirection to various non-OpenAI API endpoints
- Optionally forward calls with a valid OpenAI key and org without validation
- Pico AI Server can optionally track individual users through App Account IDs. This requires the client app to send a unique UUID to the purchase method.
API | Chat async | Chat streaming | Embeddings | Audio | Images |
---|---|---|---|---|---|
OpenAI | âś… | âś… | âś… | âś… | âś… |
Anthropic | ❌ | ✅ | ❌ | ❌ | ❌ |
OpenAI:
- Pico AI Proxy supports all OpenAI models and endpoints:
chat
,audio
,embeddings
,fine-tune
,image
Anthropic:
- API version
2023-06-01
claude-3-opus-20240229
claude-3-sonnet-20240229
claude-3-haiku-20240307
- Reverse proxy server forwarding calls to OpenAI (or any other endpoint)
- Authenticates using App Store receipt validation
- Rate limiter and black list
- Automatically translate and forward traffic to other AI APIs based on model setting in API call
- App Attestation is on hold, as macOS doesn't support app attestation
- Account management
To set up Pico AI Proxy, you need:
- Your OpenAI API key and organization
- A JWT private key, which can be generated in the terminal
- Your app bundle Id, Apple app Id and team Id
- App Store Server API key, Issuer Id, and Key Id
- Apple root certificates, which are included in the repository but should be updated if Apple updates their certificates
Generate an OpenAI API key at OpenAI Optionally generate an Anthropic Claude API key at Anthropic
Create a new JWT private key in macOS Terminal using openssl rand -base64 32
Note: This JWT token is used to authenticate your client app. It is a different JWT token the App Store Server Library uses to communicate with the Apple App Store API.
Find your App bundle Id, Apple app Id, and team Id on https://appstoreconnect.apple.com/apps Under App Information in the General section, you will find these details.
Team Id is a 10-character string and can be found in https://developer.apple.com/account under Membership Details.
Generate the key under the Users and Access tab in App Store Connect, specifically under In-app Purchase here. You will also find the Issuer Id and Key Id on the same page.
See for more details Creating API Keys for App Store Connect API
To run Pico AI Proxy from Xcode, set the environment variables and arguments listed below to the information listed in How to Set Up Pico AI Proxy
Both environment variables and arguments can be edited in Xcode using the Target -> Edit scheme.
Argument | Default value | Default in scheme |
---|---|---|
--hostname | 0.0.0.0 | |
--port | 8080 | 8080 |
--target | https://api.openai.com |
When launched from Xcode, Pico AI Proxy is accessible at http://localhost:8080. When deployed on Railway, Pico AI Proxy will default to port 443 (https).
All traffic will be forwarded to target
. The 'target' can be modified to direct traffic to any API, regardless of whether it conforms to the OpenAI API. , as long as your client application is compatible.
The target is the site where all traffic is forwarded to. You can change the target to any API, even if the API doesn't conform OpenAI (so long as your client app does).
Variable | Description | reference |
---|---|---|
OpenAI-APIKey | OpenAI API key (sk-...) | https://platform.openai.com |
OpenAI-Organization | OpenAI org identifier (org-...) | https://platform.openai.com |
Anthropic-APIKey | Anthropic API key (sk-ant-api3-...) | https://docs.anthropic.com/claude/docs/ |
if 1, requests with a valid OpenAI key and org in the header will be forwarded to OpenAI without modifications (deprecated) |
Variable | Description | reference |
---|---|---|
appTeamId | Apple Team ID | https://appstoreconnect.apple.com/ |
appBundleId | E.g. com.example.myapp | https://appstoreconnect.apple.com/ |
appAppleId | Apple Id under App Information -> General Information | https://appstoreconnect.apple.com/ |
Variable | Description | reference |
---|---|---|
IAPPrivateKey | IAP private key | https://appstoreconnect.apple.com/access/api/subs |
IAPIssuerId | IAP Issuer Id | https://appstoreconnect.apple.com/access/api/subs |
IAPKeyId | IAP Key Id | https://appstoreconnect.apple.com/access/api/subs |
The IAPPrivateKey
in Pico AI Proxy is formatted in PKCS #8, which is a multi-line format. The format begins with -----BEGIN PRIVATE KEY-----
and ends with -----END PRIVATE KEY-----
. Between these markers, the key comprises four lines of base64-encoded data. However, while Xcode supports environment variables with newlines, many hosting services, such as Railway, do not.
To ensure compatibility across different environments, Pico AI Proxy requires the private key to be condensed into a single line. This is achieved by replacing all newline characters with \\n
(double backslash followed by n
).
A correctly formatted IAPPrivateKey
for Pico AI Proxy should appear as a single line: -----BEGIN PRIVATE KEY-----\\n<LINE1>\\n<LINE2>\\n<LINE3>\\n<LINE4>\\n-----END PRIVATE KEY-----
, where <LINE1>
, <LINE2>
, <LINE3>
, and <LINE4>
represent the base64-encoded data of the key.
Variable | Description | reference |
---|---|---|
JWTPrivateKey | https://jwt.io/introduction |
Variable | Default value | Description |
---|---|---|
enableRateLimiter | 0 | Set to 1 to activate the rate limiter |
userMinuteRateLimit | 15 | Max queries per minute per registered user |
userHourlyRateLimit | 50 | Max queries per hour per registered user |
userPermanentBlock | 50 | Blocked request threshold for permanent user ban |
anonMinuteRateLimit | 60 | Combined max queries per minute for all anonymous users |
anonHourlyRateLimit | 200 | Combined max queries per hour for all anonymous users |
anonPermanentBlock | 50 | Blocked request threshold for banning all anonymous users |
By default, the rate limiter is off. To activate, set enableRateLimiter
to 1.
The rate limiter counts requests and doesn't distinguish between different models or LLM providers. It's primarily a safeguard against abusive traffic.
Users are identified by their app account tokens from the StoreKit 2 Transaction.purchase() call. Unidentified users are considered anonymous. For apps where all users are identified, consider removing the anonymous user limits (anonHourlyRateLimit
, anonMinuteRateLimit
, and anonPermanentBlock
).
There are three rate levels that can be individually set or disabled:
- A maximum number queries per hour (
userMinuteRateLimit
andanonHourlyRateLimit
) - A maximum number of queries per minute (
userHourlyRateLimit
andanonMinuteRateLimit
) - A maximum number of blocked messages (
userPermanentBlock
andanonPermanentBlock
)
If the 1 minute limit is reached, the user will be blocked for 5 minutes. If the hourly limit is reached, the user will be blocked for 60 minutes. If a user has exceeded the value set in userPermanentBlock
or anonPermanentBlock
they will be banned permanently. These values are hardcoded in Pico AI Proxy.
Note Pico AI Proxy currently does not persist data. Upon server restart, any permanently blocked users will be unblocked.
Note: It is highly recommended to set the appAccountToken
to a user-identifying UUID when the user subscribes, like so:
let result = try await subscription.product.purchase(options: [.appAccountToken(userIdentifyingUUID)])
Setting the appAccountToken
enables Pico Proxy to use the value for user-specific rate limiting.
Here is an overview of the steps to have Pico Proxy validate a purchase and grant a user access:
- The client app receives a
Transaction
from the App Store Server after a purchase or via a push notification from the App Store to StoreKit 2. - The client app fetches the signed JWS transaction stored in StoreKit 2's
VerificationResult.jwsRepresentation
. - The client app sends the raw signed JWS transaction in the body of an HTTP POST request to
https://<proxy name>.up.railway.app/appstore
. - The proxy server validates the authenticity and validity of the JWS transaction using Apple's App Store Server Library.
- The proxy server creates and returns the session token to the client app.
- The client app includes the session token in every call until the session token expires. When it expires, the server will return a 401 Unauthorized error.
Note: The client app should always fetch a new session token when it receives a 401 Unauthorized error.
The code below is based on the WWDC StoreKit 2 Backyard Birds example code.
import StoreKit
@MainActor
public final class StoreSubscriptionController: ObservableObject {
@Published public private(set) var jwsTransaction: String?
public func purchase(option subscription: Subscription) async -> PurchaseFinishedAction {
let action: PurchaseFinishedAction
do {
// Add user identifier to transaction
let idUUID = UUID()
let result = try await subscription.product.purchase(options: [.appAccountToken(idUUID)])
switch result {
case .success(let verificationResult):
// Set the JWS token after purchase
jwsTransaction = verificationResult.jwsRepresentation
...
}
}
}
// Handle push notification from App Store
internal func handle(update status: Product.SubscriptionInfo.Status) {
guard case .verified(let transaction) = status.transaction,
case .verified(let renewalInfo) = status.renewalInfo else {
return
}
if status.state == .subscribed || status.state == .inGracePeriod {
jwsTransaction = status.transaction.jwsRepresentation
}
...
}
// Handle updated entitlement
func updateEntitlement(groupID: String) async {
guard let statuses = try? await Product.SubscriptionInfo.status(for: groupID) else {
return
}
for status in statuses {
guard case .verified(let transaction) = status.transaction,
case .verified(let renewalInfo) = status.renewalInfo else {
continue
}
if status.state == .subscribed || status.state == .inGracePeriod {
jwsTransaction = status.transaction.jwsRepresentation
}
...
}
}
To authenticate a user, call the appstore
endpoint of Pico Proxy:
class PicoClient {
var authToken: String?
func authenticate() async throws {
// Set body to `jwsTransaction` property of `StoreSubscriptionController`
guard let body = await StoreActor.shared.subscriptionController.jwsTransaction else {
// User has no subscription
throw YourClientError.noSubscription
}
let tokenRequest = Request<Token>(
path: "appstore",
method: .post,
body: body,
headers: nil)
let clientConfiguration = APIClient.Configuration(baseURL: "<Pico Proxy URL here>")
let client = APIClient(configuration: clientConfiguration)
let tokenResponse = try await client.send(tokenRequest)
self.authToken = tokenResponse.value.token
}
func chatConnection() -> OpenAIAPIConnection {
return OpenAIAPIConnection(apiKey: authToken ?? "NO_KEY",
organization: organization,
scheme: scheme.rawValue,
host: host,
chatCompletionPath: chatCompletionPath,
port: port)
}
...
}
Note: This method is not recommended as App Store receipts are deprecated, and the process is slower and more error-prone. Instead, use StoreKit 2 as described above. While it is technically possible to use StoreKit 2 in combination with App Store receipts, it is not recommended because there is a delay between a purchase and the transaction being included in the App Store receipt, which can lead to incorrect Unauthorized errors from Pico Proxy.
This flow is slightly different:
- The client loads the App Store receipt from disk.
- The client sends the base64-encoded app receipt in the body of an HTTP POST request to
https://<proxy name>.up.railway.app/appstore
. - Pico Proxy extracts the transaction ID from the App Store receipt and verifies the transaction ID with Apple's App Store Server API.
- If the transaction is found, Pico Proxy will create and return a session token to the client app.
- The client app includes the session token in every call until the session token expires. When it expires, the server will return a 401 Unauthorized error.
Using CleverBird
import Get
import CleverBird
var token: Token? = nil
func completion(prompt: String) async await {
let openAIConnection = OpenAIAPIConnection(apiKey: token.token, organization: "", scheme: "http", host: "localhost", port: 8080)
let chatThread = ChatThread()
.addSystemMessage(content: "You are a helpful assistant.")
.addUserMessage(content: "Who won the world series in 2020?")
do {
let completion = try await chatThread.complete(using: openAIAPIConnection)
} catch CleverBird.proxyAuthenticationRequired {
// Client needs to re-authenticate
token = try await fetchToken()
try await completion(prompt: String)
} catch CleverBirdError.unauthorized {
// Prompt user to buy a subscription
}
}
func fetchToken() async throws -> Token {
let body: String?
/*
// Fetch app store receipt
if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL,
FileManager.default.fileExists(atPath: appStoreReceiptURL.path),
let receiptData = try? Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped) {
body = receiptData.base64EncodedString(options: [])
} else {
// when running the app in Xcode Sandbox, there will be no receipt. In sandbox, Pico AI Proxy will accept
// the receipt Id.
body = "transaction Id here"
}
*/
// Validating receipts is temporary disabled
body = "transaction Id here"
let tokenRequest = Request<Token>(
path: "appstore",
method: .post,
body: body,
headers: nil)
let tokenResponse = try await AIClient.openAIAPIConnection.client.send(tokenRequest)
return tokenResponse.value
}
struct Token: Codable {
let token: String
}
Optionally: Track users using app account token
// Create new UUID
let id = UUID()
// Add id to user account
// Purchase subscription
let result = try await product.purchase(options: [.appAccountToken(idUUID)])
Pico AI Proxy will automatically extract the app account token from the receipts.
Pico AI Proxy may generate two distinct error codes related to authorization issues: unauthorized (401) and proxyAuthenticationRequired (407). The unauthorized error indicates a lack of a valid App Store subscription on the user's part. On the other hand, the proxyAuthenticationRequired error signifies that the client's authentication token is no longer valid, a situation that may arise following a server reboot. In the latter case, reauthorization can be achieved through a straightforward re-authentication process that does not require user intervention.
Use link below to deploy Pico AI Proxy on Railway. The link includes a referral code.
Alternatively, Pico AI Proxy can be installed manually on any other hosting provider.