feat(account): add managed credits and cloud gateway

Introduce optional Apple account-backed credits with scoped gateway access while preserving local and BYOK paths. Refresh assistant behavior, tests, privacy disclosures, docs, and the website for the 2.0 experience.
This commit is contained in:
Rocky
2026-08-20 11:43:21 +08:00
parent 0f9280bd00
commit 498f407585
301 changed files with 19221 additions and 10891 deletions
@@ -1,8 +1,8 @@
// CandidatePanelExpandTests.swift
// OSGKeyboard · Ext unit tests
import XCTest
@testable import OSGKeyboardShared
import XCTest
@MainActor
final class CandidatePanelExpandTests: XCTestCase {
@@ -5,8 +5,8 @@
// and one keyboard presentation; clipboard history remains independently usable.
import Combine
import XCTest
@testable import OSGKeyboardShared
import XCTest
@MainActor
final class ClipboardSuggestionLifecycleTests: XCTestCase {
@@ -1,8 +1,8 @@
// EditHintSchedulerTests.swift
// OSGKeyboard · Keyboard Extension Tests
import XCTest
@testable import OSGKeyboardShared
import XCTest
@MainActor
final class EditHintSchedulerTests: XCTestCase {
+1 -1
View File
@@ -3,8 +3,8 @@
//
// Lexicon / accent / suggestion ranking for the English typing path.
import XCTest
@testable import OSGKeyboardShared
import XCTest
final class EnglishTypingTests: XCTestCase {
func testLexiconLoadsAndCompletesPrefix() {
@@ -1,8 +1,8 @@
// FinalChunkRecoveryTests.swift
// OSGKeyboardExtTests
import XCTest
@testable import OSGKeyboardShared
import XCTest
final class FinalChunkRecoveryTests: XCTestCase {
+1 -1
View File
@@ -3,8 +3,8 @@
//
// Phase 1 / 3: gap fill, nearest-center, intent offset, edge expansion.
import XCTest
@testable import OSGKeyboardShared
import XCTest
final class KeyHitTestingTests: XCTestCase {
private func makePairTargets(
+1 -1
View File
@@ -13,8 +13,8 @@
// *don't* attempt that here the State class is what we care about
// for correctness.
import XCTest
@testable import OSGKeyboardShared
import XCTest
@MainActor
final class KeyboardStateTests: XCTestCase {
@@ -1,8 +1,8 @@
// KeyboardSurfaceStateTests.swift
// OSGKeyboard · Ext unit tests
import XCTest
@testable import OSGKeyboardShared
import XCTest
@MainActor
final class KeyboardSurfaceStateTests: XCTestCase {
@@ -2,8 +2,8 @@
// OSGKeyboard · Ext unit tests
import Darwin
import XCTest
@testable import OSGKeyboardShared
import XCTest
final class LibrimeIntegrationTests: XCTestCase {
func testFullPinyinAndBothDoublePinyinSchemasProducePhraseCandidates() throws {
@@ -0,0 +1,592 @@
// ManagedGatewayTests.swift
// OSGKeyboard · Extension Tests
//
// Hermetic grant rotation and managed LLM tests using a private URLProtocol stub.
import Foundation
@testable import OSGKeyboardShared
import XCTest
final class ManagedGatewayTests: XCTestCase {
private let baseURL = URL(string: "https://gateway.test")!
override func setUp() {
super.setUp()
GatewayStub.shared.reset()
}
override func tearDown() {
GatewayStub.shared.reset()
super.tearDown()
}
func testFiveMinuteAccessBoundaryAndScope() {
let start = Date(timeIntervalSince1970: 1_000)
let value = credentials(accessExpiresAt: start.addingTimeInterval(3_600), receivedAt: start)
XCTAssertEqual(value.effectiveAccessExpiresAt, start.addingTimeInterval(300))
XCTAssertTrue(value.hasUsableAccessToken(for: .polish, at: start.addingTimeInterval(269)))
XCTAssertFalse(value.hasUsableAccessToken(for: .polish, at: start.addingTimeInterval(270)))
XCTAssertFalse(value.hasUsableAccessToken(for: .asr, at: start))
}
func testScopePolicyExcludesASRForLocalEngineAndIncludesItForCloud() {
let localScopes = ManagedGatewayScopePolicy.scopes(engineMode: "local")
XCTAssertEqual(localScopes, [.polish, .assistant, .agent])
XCTAssertFalse(localScopes.contains(.asr))
let cloudScopes = ManagedGatewayScopePolicy.scopes(engineMode: "cloud")
XCTAssertEqual(cloudScopes, [.polish, .assistant, .agent, .asr])
}
func testCreateGrantSendsAccountTokenButStoresOnlyGrant() async throws {
GatewayStub.shared.enqueue(
201,
grantJSON(
access: "grant-access",
refresh: "grant-refresh",
scopes: ["polish", "ai"]
)
)
let store = MemoryGrantStore()
let coordinator = makeCoordinator(store: store)
_ = try await coordinator.createGrant(
accountAccessToken: "account-secret",
scopes: [.polish, .assistant],
lifetimeSeconds: 3_600,
idempotencyKey: "create-idempotency"
)
let stored = await store.value()
XCTAssertEqual(stored?.refreshToken, "grant-refresh")
let request = try XCTUnwrap(GatewayStub.shared.requests().first)
XCTAssertEqual(request.url?.path, "/v1/gateway/grants")
XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer account-secret")
XCTAssertEqual(request.value(forHTTPHeaderField: "Idempotency-Key"), "create-idempotency")
let body = try jsonBody(request)
XCTAssertEqual(Set(body["scopes"] as? [String] ?? []), Set(["polish", "ai"]))
XCTAssertEqual(body["lifetimeSeconds"] as? Int, 3_600)
let persisted = try JSONEncoder().encode(stored)
XCTAssertFalse(
(String(bytes: persisted, encoding: .utf8) ?? "").contains("account-secret")
)
}
func testCreateGrantRejectsBroaderReturnedScopes() async throws {
GatewayStub.shared.enqueue(
201,
grantJSON(
access: "grant-access",
refresh: "grant-refresh",
scopes: ["polish", "ai", "agent"]
)
)
let store = MemoryGrantStore()
let coordinator = makeCoordinator(store: store)
await XCTAssertThrowsManaged(.invalidGrant) {
_ = try await coordinator.createGrant(
accountAccessToken: "account-secret",
scopes: [.polish],
idempotencyKey: "create-scope-boundary"
)
}
let stored = await store.value()
XCTAssertNil(stored)
}
func testConcurrentRefreshIsMergedAndHashesOldTokenForIdempotency() async throws {
let now = Date(timeIntervalSince1970: 2_000)
let oldRefresh = "old-refresh-token-that-is-at-least-32-characters"
let store = MemoryGrantStore(
credentials(
accessToken: "expired",
refreshToken: oldRefresh,
accessExpiresAt: now.addingTimeInterval(-1),
receivedAt: now.addingTimeInterval(-600)
)
)
GatewayStub.shared.enqueue(
200,
grantJSON(access: "new-access", refresh: "new-refresh"),
delay: 0.05
)
let coordinator = makeCoordinator(store: store, now: { now })
async let first = coordinator.accessToken(for: .assistant)
async let second = coordinator.accessToken(for: .assistant)
let values = try await (first, second)
XCTAssertEqual(values.0, "new-access")
XCTAssertEqual(values.1, "new-access")
let requests = GatewayStub.shared.requests()
XCTAssertEqual(requests.count, 1)
let request = try XCTUnwrap(requests.first)
let key = try XCTUnwrap(request.value(forHTTPHeaderField: "Idempotency-Key"))
XCTAssertEqual(key, GatewayGrantCoordinator.refreshTokenIdempotencyKey(oldRefresh))
XCTAssertFalse(key.contains(oldRefresh))
XCTAssertEqual(try jsonBody(request)["refreshToken"] as? String, oldRefresh)
}
func testManagedClientMapsAllCapabilitiesHeadersBodyAndRequestIds() async throws {
let now = Date(timeIntervalSince1970: 3_000)
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
let coordinator = makeCoordinator(store: store, now: { now })
let ids = LockedValues(["request-0001", "request-0002", "request-0003"])
let cases: [(ManagedLLMClient.Capability, ManagedGatewayTaskKind)] = [
(.polish, .dictationPolish),
(.assistant, .aiQuestion),
(.agent, .agentPlanning)
]
for (capability, _) in cases {
GatewayStub.shared.enqueue(200, Data(#"{"choices":[{"message":{"content":"ok"}}]}"#.utf8))
let client = ManagedLLMClient(
capability: capability,
grants: coordinator,
baseURL: baseURL,
session: stubSession(),
requestId: { ids.next() }
)
let result = try await client.polish(
"input",
systemPrompt: "context",
timeout: 7,
options: LLMGenerationOptions(temperature: 0.4, maxTokens: 321)
)
XCTAssertEqual(result, "ok")
}
let requests = GatewayStub.shared.requests()
XCTAssertEqual(
requests.compactMap(\.url?.path),
[
"/v1/gateway/llm/polish",
"/v1/gateway/llm/ai",
"/v1/gateway/llm/agent"
]
)
XCTAssertEqual(Set(requests.compactMap { $0.value(forHTTPHeaderField: "X-Request-ID") }).count, 3)
for (request, expectedCase) in zip(requests, cases) {
XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer access")
let body = try jsonBody(request)
XCTAssertEqual(body["input"] as? String, "input")
XCTAssertEqual(body["context"] as? String, "context")
XCTAssertEqual(body["maxOutputTokens"] as? Int, 321)
XCTAssertEqual(body["temperature"] as? Double, 0.4)
XCTAssertEqual(body["stream"] as? Bool, false)
XCTAssertEqual(body["taskKind"] as? String, expectedCase.1.rawValue)
XCTAssertEqual(request.timeoutInterval, 7)
}
}
func testManagedClientSerializesFineGrainedTaskKinds() async throws {
let now = Date(timeIntervalSince1970: 3_500)
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
let coordinator = makeCoordinator(store: store, now: { now })
let cases: [(ManagedLLMClient.Capability, ManagedGatewayTaskKind)] = [
(.polish, .translation),
(.polish, .editLastInput),
(.assistant, .clipboardTransform),
(.assistant, .customSkill)
]
for (capability, taskKind) in cases {
GatewayStub.shared.enqueue(200, Data(#"{"output_text":"ok"}"#.utf8))
let client = ManagedLLMClient(
capability: capability,
taskKind: taskKind,
grants: coordinator,
baseURL: baseURL,
session: stubSession()
)
_ = try await client.polish("input", systemPrompt: "context")
}
let taskKinds = try GatewayStub.shared.requests().map {
try XCTUnwrap(jsonBody($0)["taskKind"] as? String)
}
XCTAssertEqual(taskKinds, cases.map { $0.1.rawValue })
}
func testManagedTaskKindWireValuesMatchServerContract() {
XCTAssertEqual(
ManagedGatewayTaskKind.allCases.map(\.rawValue),
[
"dictation_polish",
"translation",
"edit_last_input",
"ai_question",
"clipboard_transform",
"custom_skill",
"agent_planning"
]
)
}
func testConversationMappingAndRawTextFallback() async throws {
let now = Date(timeIntervalSince1970: 4_000)
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
GatewayStub.shared.enqueue(200, Data("raw answer".utf8), contentType: "text/plain")
let client = ManagedLLMClient(
capability: .assistant,
grants: makeCoordinator(store: store, now: { now }),
baseURL: baseURL,
session: stubSession()
)
let answer = try await client.complete(
messages: [.system("system"), .user("old"), .assistant("reply"), .user("latest")],
timeout: nil,
options: .polishDefault
)
XCTAssertEqual(answer, "raw answer")
let request = try XCTUnwrap(GatewayStub.shared.requests().first)
let body = try jsonBody(request)
XCTAssertEqual(body["input"] as? String, "latest")
XCTAssertEqual(body["context"] as? String, "system:\nsystem\n\nuser:\nold\n\nassistant:\nreply")
}
func testUnauthorizedRequestRefreshesOnceWithSameRequestId() async throws {
let now = Date(timeIntervalSince1970: 5_000)
let oldRefresh = "old-refresh-value-123456789012345678901"
let store = MemoryGrantStore(
credentials(accessToken: "old-access", refreshToken: oldRefresh, receivedAt: now)
)
GatewayStub.shared.enqueue(401, errorJSON("unauthorized"))
GatewayStub.shared.enqueue(200, grantJSON(access: "new-access", refresh: "new-refresh"))
GatewayStub.shared.enqueue(200, Data(#"{"output_text":"done"}"#.utf8))
let client = ManagedLLMClient(
capability: .polish,
grants: makeCoordinator(store: store, now: { now }),
baseURL: baseURL,
session: stubSession(),
requestId: { "logical-request" }
)
let result = try await client.polish("input", systemPrompt: "prompt")
XCTAssertEqual(result, "done")
let requests = GatewayStub.shared.requests()
XCTAssertEqual(requests.count, 3)
XCTAssertEqual(requests[0].value(forHTTPHeaderField: "Authorization"), "Bearer old-access")
XCTAssertEqual(requests[1].url?.path, "/v1/gateway/grants/refresh")
XCTAssertEqual(requests[2].value(forHTTPHeaderField: "Authorization"), "Bearer new-access")
XCTAssertEqual(
requests[0].value(forHTTPHeaderField: "X-Request-ID"),
requests[2].value(forHTTPHeaderField: "X-Request-ID")
)
XCTAssertEqual(
requests[1].value(forHTTPHeaderField: "Idempotency-Key"),
GatewayGrantCoordinator.refreshTokenIdempotencyKey(oldRefresh)
)
}
func testCreditAndInvalidRefreshErrorsAreMapped() async throws {
let now = Date(timeIntervalSince1970: 6_000)
let creditStore = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
GatewayStub.shared.enqueue(402, errorJSON("insufficient_credits"))
let creditClient = ManagedLLMClient(
capability: .assistant,
grants: makeCoordinator(store: creditStore, now: { now }),
baseURL: baseURL,
session: stubSession()
)
await XCTAssertThrowsManaged(.insufficientCredits) {
_ = try await creditClient.complete(
messages: [.user("question")],
timeout: nil,
options: .polishDefault
)
}
let expiredStore = MemoryGrantStore(
credentials(
accessToken: "expired",
accessExpiresAt: now.addingTimeInterval(-1),
receivedAt: now.addingTimeInterval(-600)
)
)
GatewayStub.shared.enqueue(401, errorJSON("invalid_gateway_refresh"))
let coordinator = makeCoordinator(store: expiredStore, now: { now })
await XCTAssertThrowsManaged(.invalidGrant) {
_ = try await coordinator.accessToken(for: .assistant)
}
let remaining = await expiredStore.value()
XCTAssertNil(remaining)
}
func testTransportTimeoutMapsToManagedTimeout() async {
let now = Date(timeIntervalSince1970: 6_500)
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
GatewayStub.shared.enqueue(
0,
Data(),
transportError: .timedOut
)
let client = ManagedLLMClient(
capability: .polish,
grants: makeCoordinator(store: store, now: { now }),
baseURL: baseURL,
session: stubSession()
)
await XCTAssertThrowsManaged(.timeout) {
_ = try await client.polish("input", systemPrompt: "prompt", timeout: 0.1)
}
}
func testCancellationAndStreaming() async throws {
let now = Date(timeIntervalSince1970: 7_000)
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
let coordinator = makeCoordinator(store: store, now: { now })
GatewayStub.shared.enqueue(200, Data(#"{"output_text":"late"}"#.utf8), delay: 5)
let cancelledClient = ManagedLLMClient(
capability: .polish,
grants: coordinator,
baseURL: baseURL,
session: stubSession()
)
let task = Task { try await cancelledClient.polish("input", systemPrompt: "prompt") }
try await Task.sleep(for: .milliseconds(50))
task.cancel()
do {
_ = try await task.value
XCTFail("expected cancellation")
} catch let error as LLMError {
XCTAssertEqual(error, .cancelled)
} catch is CancellationError {
// Foundation may surface native cancellation before client mapping.
}
let sse = """
data: {"choices":[{"delta":{"reasoning_content":"hidden","content":"Hello"}}]}
data: {"type":"response.output_text.delta","delta":" world"}
data: [DONE]
"""
GatewayStub.shared.enqueue(200, Data(sse.utf8), contentType: "text/event-stream")
let streamClient = ManagedLLMClient(
capability: .assistant,
grants: coordinator,
baseURL: baseURL,
session: stubSession()
)
var answer = ""
for try await event in streamClient.completeStreaming(
messages: [.user("question")],
timeout: 2,
options: .polishDefault
) {
if case .delta(let value) = event { answer += value }
}
XCTAssertEqual(answer, "Hello world")
let streamRequest = try XCTUnwrap(GatewayStub.shared.requests().last)
XCTAssertEqual(try jsonBody(streamRequest)["stream"] as? Bool, true)
}
private func makeCoordinator(
store: MemoryGrantStore,
now: @escaping @Sendable () -> Date = Date.init
) -> GatewayGrantCoordinator {
GatewayGrantCoordinator(baseURL: baseURL, store: store, session: stubSession(), now: now)
}
private func stubSession() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [GatewayURLProtocol.self]
return URLSession(configuration: config)
}
private func credentials(
accessToken: String = "access-token",
refreshToken: String = "refresh-token-value-12345678901234567890",
accessExpiresAt: Date = Date(timeIntervalSince1970: 20_000),
refreshExpiresAt: Date = Date(timeIntervalSince1970: 40_000),
receivedAt: Date = Date(timeIntervalSince1970: 10_000)
) -> ManagedGatewayGrantCredentials {
ManagedGatewayGrantCredentials(
grantId: "11111111-1111-1111-1111-111111111111",
scopes: [.polish, .assistant, .agent],
accessToken: accessToken,
accessExpiresAt: accessExpiresAt,
refreshToken: refreshToken,
refreshExpiresAt: refreshExpiresAt,
receivedAt: receivedAt
)
}
private func grantJSON(
access: String,
refresh: String,
scopes: [String] = ["polish", "ai", "agent"]
) -> Data {
let encodedScopes = scopes.map { "\"\($0)\"" }.joined(separator: ",")
return Data(
"""
{"grantId":"11111111-1111-1111-1111-111111111111",
"scopes":[\(encodedScopes)],"accessToken":"\(access)",
"accessExpiresAt":"2030-01-01T00:05:00Z","refreshToken":"\(refresh)",
"refreshExpiresAt":"2030-01-02T00:00:00Z"}
""".utf8
)
}
private func errorJSON(_ code: String) -> Data {
Data(#"{"code":"\#(code)","message":"error","requestId":"request-id"}"#.utf8)
}
private func jsonBody(_ request: URLRequest) throws -> [String: Any] {
try XCTUnwrap(
JSONSerialization.jsonObject(with: XCTUnwrap(request.httpBody)) as? [String: Any]
)
}
private func XCTAssertThrowsManaged(
_ expected: ManagedGatewayError,
operation: () async throws -> Void
) async {
do {
try await operation()
XCTFail("expected \(expected)")
} catch let error as ManagedGatewayError {
XCTAssertEqual(error, expected)
} catch {
XCTFail("unexpected error: \(error)")
}
}
}
private actor MemoryGrantStore: GatewayGrantCredentialStore {
private var credentials: ManagedGatewayGrantCredentials?
init(_ credentials: ManagedGatewayGrantCredentials? = nil) {
self.credentials = credentials
}
func load() async throws -> ManagedGatewayGrantCredentials? { credentials }
func save(_ credentials: ManagedGatewayGrantCredentials) async throws { self.credentials = credentials }
func delete() async throws { credentials = nil }
func value() -> ManagedGatewayGrantCredentials? { credentials }
}
private final class LockedValues: @unchecked Sendable {
private let lock = NSLock()
private var values: [String]
init(_ values: [String]) { self.values = values }
func next() -> String {
lock.withLock { values.isEmpty ? UUID().uuidString : values.removeFirst() }
}
}
private final class GatewayStub: @unchecked Sendable {
struct Response {
let status: Int
let body: Data
let contentType: String
let delay: TimeInterval
let transportError: URLError.Code?
}
static let shared = GatewayStub()
private let lock = NSLock()
private var responses: [Response] = []
private var captured: [URLRequest] = []
func enqueue(
_ status: Int,
_ body: Data,
contentType: String = "application/json",
delay: TimeInterval = 0,
transportError: URLError.Code? = nil
) {
lock.withLock {
responses.append(
Response(
status: status,
body: body,
contentType: contentType,
delay: delay,
transportError: transportError
)
)
}
}
func take(_ request: URLRequest) -> Response {
lock.withLock {
captured.append(Self.materialize(request))
return responses.isEmpty
? Response(
status: 500,
body: Data(),
contentType: "application/json",
delay: 0,
transportError: nil
)
: responses.removeFirst()
}
}
func requests() -> [URLRequest] { lock.withLock { captured } }
func reset() {
lock.withLock {
responses.removeAll()
captured.removeAll()
}
}
private static func materialize(_ source: URLRequest) -> URLRequest {
var request = source
guard request.httpBody == nil, let stream = request.httpBodyStream else { return request }
stream.open()
defer { stream.close() }
var data = Data()
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: 4_096)
defer { buffer.deallocate() }
while stream.hasBytesAvailable {
let count = stream.read(buffer, maxLength: 4_096)
if count <= 0 { break }
data.append(buffer, count: count)
}
request.httpBody = data
return request
}
}
private final class GatewayURLProtocol: URLProtocol, @unchecked Sendable {
private let lock = NSLock()
private var stopped = false
override static func canInit(with request: URLRequest) -> Bool { true }
override static func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
let response = GatewayStub.shared.take(request)
DispatchQueue.global().asyncAfter(deadline: .now() + response.delay) { [weak self] in
guard let self, !self.lock.withLock({ self.stopped }) else { return }
if let errorCode = response.transportError {
self.client?.urlProtocol(self, didFailWithError: URLError(errorCode))
return
}
let http = HTTPURLResponse(
url: self.request.url!,
statusCode: response.status,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": response.contentType]
)!
self.client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed)
self.client?.urlProtocol(self, didLoad: response.body)
self.client?.urlProtocolDidFinishLoading(self)
}
}
override func stopLoading() {
lock.withLock { stopped = true }
}
}
@@ -3,8 +3,8 @@
//
// Phase 4: legal next-key sets and weighted ambiguous hit resolution.
import XCTest
@testable import OSGKeyboardShared
import XCTest
final class PinyinNextKeyResolverTests: XCTestCase {
func testZhongPrefixAllowsG() {
@@ -1,8 +1,8 @@
// RimePersonalDictionaryExporterTests.swift
// OSGKeyboard · Ext unit tests
import XCTest
@testable import OSGKeyboardShared
import XCTest
final class RimePersonalDictionaryExporterTests: XCTestCase {
private let annotator = RimePinyinAnnotator(
@@ -1,8 +1,8 @@
// RimeSchemaGeneratorTests.swift
// OSGKeyboard · Ext unit tests
import XCTest
@testable import OSGKeyboardShared
import XCTest
final class RimeSchemaGeneratorTests: XCTestCase {
func testFuzzyRulesDefaultToOff() {
@@ -3,8 +3,8 @@
//
// Multi-finger overlap: press order commits, Shift hold + type, slide reselect.
import XCTest
@testable import OSGKeyboardShared
import XCTest
final class TypingTouchTrackerTests: XCTestCase {
private final class Token {}