Pico AI 代理

简介

Pico AI 代理是一个专为 iOS、macOS、iPadOS 和 VisionOS 开发者设计的反向代理。

Pico AI 代理之前名为 SwiftOpenAIProxy。

亮点

PicoProxy 使用服务器端 Swift 编写,并使用 HummingBird 作为其 HTTP 服务器。

背景

在 2023 年 12 月,我遭遇了一次重大黑客攻击,我的 OpenAI 密钥被盗用。 他们迅速用完了我整个 2,500 美元的每月限额,导致 OpenAI 产生了一笔意外账单。 此次事件还迫使我将我的应用程序 Pico 下线,导致我错过了利润丰厚的圣诞节销售期。

作为对此事件的回应,我开发了 Pico AI Server,这是第一个用服务器端 Swift 创建的 OpenAI 代理。 此工具对于 Swift 开发者来说特别方便,因为它允许轻松进行自定义以满足他们的特定要求。

Pico AI 代理旨在与任何现有的 OpenAI 库兼容。 它可以与 CleverBirdOpenAISwiftOpenAI-KitMacPaw OpenAI 等库无缝协作,也可以与您的自定义代码集成。

主要特性

支持的 API

API Chat 异步 Chat 流式传输 嵌入 (Embeddings) 音频 图像
OpenAI
Anthropic

支持的模型和端点

OpenAI

Anthropic

已实现的功能

要求

如何设置 Pico AI 代理

要设置 Pico AI 代理,您需要:

OpenAI API 密钥和组织

OpenAI 生成一个 OpenAI API 密钥。 可选择在 Anthropic 生成一个 Anthropic Claude API 密钥。

JWT 私钥

使用 openssl rand -base64 32 在 macOS 终端中创建一个新的 JWT 私钥

注意:此 JWT 令牌用于验证您的客户端应用程序。 它是 App Store 服务器库用于与 Apple App Store API 通信的不同 JWT 令牌。

App Ids

https://appstoreconnect.apple.com/apps 上找到您的 App bundle Id、Apple app Id 和团队 Id。 在常规部分的App 信息下,您将找到这些详细信息。

团队 Id 是一个 10 个字符的字符串,可以在 https://developer.apple.com/account会员资格详情下找到。

App Store 服务器 API 密钥

在 App Store Connect 的用户和访问选项卡下的 App 内购买 此处生成密钥。 您还将在同一页面上找到 Issuer Id 和 Key Id。

有关更多详细信息,请参见创建 App Store Connect API 的 API 密钥

从 Xcode 运行 Pico AI 代理

要从 Xcode 运行 Pico AI 代理,请将下面列出的环境变量和参数设置为“如何设置 Pico AI 代理”中列出的信息。

可以使用 Target -> Edit scheme 在 Xcode 中编辑环境变量和参数。

Xcode screenshot of edit scheme menu

启动时传递的参数

参数 默认值 方案中的默认值
--hostname 0.0.0.0
--port 8080 8080
--target https://api.openai.com

从 Xcode 启动时,Pico AI 代理可在 https://:8080 访问。 部署在 Railway 上时,Pico AI 代理将默认为端口 443 (https)。

所有流量都将转发到 target。“target”可以修改为将流量定向到任何 API,无论它是否符合 OpenAI API,只要您的客户端应用程序兼容。 target 是所有流量转发到的站点。 您可以将 target 更改为任何 API,即使 API 不符合 OpenAI(只要您的客户端应用兼容)。

环境变量

LLM 提供商环境变量。

变量 描述 参考
OpenAI-APIKey OpenAI API 密钥 (sk-...) https://platform.openai.com
OpenAI-Organization OpenAI 组织标识符 (org-...) https://platform.openai.com
Anthropic-APIKey Anthropic API 密钥 (sk-ant-api3-...) https://docs.anthropic.com/claude/docs/
allowKeyPassthrough 如果为 1,则带有有效 OpenAI 密钥和 org 头的请求将被转发到 OpenAI,无需修改(已弃用)

App Store Connect 环境变量

变量 描述 参考
appTeamId Apple 团队 ID https://appstoreconnect.apple.com/
appBundleId 例如:com.example.myapp https://appstoreconnect.apple.com/
appAppleId App 信息 -> 常规信息下的 Apple Id https://appstoreconnect.apple.com/

App Store 服务器 API 环境变量

变量 描述 参考
IAPPrivateKey IAP 私钥 https://appstoreconnect.apple.com/access/api/subs
IAPIssuerId IAP 发行者 Id https://appstoreconnect.apple.com/access/api/subs
IAPKeyId IAP 密钥 Id https://appstoreconnect.apple.com/access/api/subs

Pico AI 代理中的 IAPPrivateKey 采用 PKCS #8 格式,这是一种多行格式。 该格式以 -----BEGIN PRIVATE KEY----- 开头,以 -----END PRIVATE KEY----- 结尾。 在这些标记之间,密钥包含四行 base64 编码的数据。 但是,虽然 Xcode 支持带有换行符的环境变量,但许多托管服务(例如 Railway)不支持。

为了确保跨不同环境的兼容性,Pico AI 代理要求将私钥压缩为单行。 这是通过将所有换行符替换为 \\n(双反斜杠后跟 n)来实现的。

Pico AI 代理的正确格式的 IAPPrivateKey 应显示为一行:-----BEGIN PRIVATE KEY-----\\n<LINE1>\\n<LINE2>\\n<LINE3>\\n<LINE4>\\n-----END PRIVATE KEY-----,其中 <LINE1><LINE2><LINE3><LINE4> 代表密钥的 base64 编码数据。

JWT 环境变量

变量 描述 参考
JWTPrivateKey https://jwt.net.cn/introduction

速率限制器环境变量

变量 默认值 描述
enableRateLimiter 0 设置为 1 以激活速率限制器
userMinuteRateLimit 15 每个注册用户每分钟的最大查询次数
userHourlyRateLimit 50 每个注册用户每小时的最大查询次数
userPermanentBlock 50 永久禁止用户的阻止请求阈值
anonMinuteRateLimit 60 所有匿名用户每分钟的组合最大查询次数
anonHourlyRateLimit 200 所有匿名用户每小时的组合最大查询次数
anonPermanentBlock 50 禁止所有匿名用户的阻止请求阈值

指南和行为

默认情况下,速率限制器处于关闭状态。 要激活,请将 enableRateLimiter 设置为 1。

速率限制器会计算请求,并且不区分不同的模型或 LLM 提供商。 它主要用于防范滥用流量。

用户由 StoreKit 2 Transaction.purchase() 调用中的应用程序帐户令牌识别。 未识别的用户被视为匿名用户。 对于所有用户都经过识别的应用程序,请考虑删除匿名用户限制(anonHourlyRateLimitanonMinuteRateLimitanonPermanentBlock)。

速率限制

有三个速率级别可以单独设置或禁用

如果达到 1 分钟限制,用户将被阻止 5 分钟。 如果达到每小时限制,用户将被阻止 60 分钟。 如果用户超过了 userPermanentBlockanonPermanentBlock 中设置的值,他们将被永久禁止。 这些值在 Pico AI Proxy 中是硬编码的。

请注意,Pico AI Proxy 目前不持久化数据。 服务器重启后,所有永久阻止的用户都将被解除阻止。

如何从您的 iOS 或 macOS 应用程序调用 Pico AI Proxy

注意: 强烈建议在用户订阅时,将 appAccountToken 设置为用户标识 UUID,如下所示

let result = try await subscription.product.purchase(options: [.appAccountToken(userIdentifyingUUID)])

设置 appAccountToken 使 Pico Proxy 能够使用该值进行用户特定的速率限制。

使用 StoreKit 2 的客户端应用程序

以下是 Pico Proxy 验证购买并授予用户访问权限的步骤概述

  1. 客户端应用程序在购买后或通过来自 App Store 到 StoreKit 2 的推送通知接收来自 App Store 服务器的 Transaction
  2. 客户端应用程序获取存储在 StoreKit 2 的 VerificationResult.jwsRepresentation 中的已签名 JWS 事务。
  3. 客户端应用程序将原始签名的 JWS 事务在 HTTP POST 请求的正文中发送到 https://<proxy name>.up.railway.app/appstore
  4. 代理服务器使用 Apple 的 App Store Server Library 验证 JWS 事务的真实性和有效性。
  5. 代理服务器创建并将会话令牌返回给客户端应用程序。
  6. 客户端应用程序在每次调用中都包含会话令牌,直到会话令牌过期。 当它过期时,服务器将返回 401 Unauthorized 错误。

注意: 客户端应用程序应始终在收到 401 Unauthorized 错误时获取新的会话令牌。

以下代码基于 WWDC StoreKit 2 Backyard Birds 示例代码。

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
            }
            ...
        }
    }

要验证用户身份,请调用 Pico Proxy 的 appstore 端点

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)
    }
    ...
}

使用已弃用的 App Store 收据的客户端应用程序

注意: 不建议使用此方法,因为 App Store 收据已弃用,并且该过程速度较慢且容易出错。 建议改用上述 StoreKit 2。 虽然在技术上可以结合使用 StoreKit 2 和 App Store 收据,但不建议这样做,因为购买和交易包含在 App Store 收据之间存在延迟,这可能导致 Pico Proxy 出现不正确的 Unauthorized 错误。

此流程略有不同

  1. 客户端从磁盘加载 App Store 收据。
  2. 客户端将 base64 编码的应用程序收据在 HTTP POST 请求的正文中发送到 https://<proxy name>.up.railway.app/appstore
  3. Pico Proxy 从 App Store 收据中提取交易 ID,并使用 Apple 的 App Store Server API 验证交易 ID。
  4. 如果找到该交易,Pico Proxy 将创建并将会话令牌返回给客户端应用程序。
  5. 客户端应用程序在每次调用中都包含会话令牌,直到会话令牌过期。 当它过期时,服务器将返回 401 Unauthorized 错误。

使用 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
    }

可选:使用 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 将自动从收据中提取应用程序帐户令牌。

Pico AI Proxy 可能会生成与授权问题相关的两个不同的错误代码:unauthorized (401) 和 proxyAuthenticationRequired (407)。 unauthorized 错误表明用户缺少有效的 App Store 订阅。 另一方面,proxyAuthenticationRequired 错误表示客户端的身份验证令牌不再有效,这种情况可能在服务器重启后出现。 在后一种情况下,可以通过不需要用户干预的简单重新身份验证过程来实现重新授权。

如何部署

使用下面的链接在 Railway 上部署 Pico AI Proxy。 该链接包含一个推荐代码。

Deploy on Railway

或者,Pico AI Proxy 也可以手动安装在任何其他托管提供商上。

支持

使用 Pico AI Proxy 的应用程序

贡献者