Files
OSGKeyboard/OSGKeyboardTests/AIModeLLMClientTests.swift
T
Rocky 498f407585 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.
2026-08-20 11:43:21 +08:00

245 lines
10 KiB
Swift

// AIModeLLMClientTests.swift
// OSGKeyboardTests
@testable import OSGKeyboardShared
import XCTest
final class AIModeLLMClientTests: XCTestCase {
func testDeepSeekFlashSupportsResponsesSearch() {
XCTAssertTrue(AIModeSearchSupport.deepSeekSupportsResponsesSearch(model: "deepseek-v4-flash"))
XCTAssertTrue(AIModeSearchSupport.deepSeekSupportsResponsesSearch(model: "deepseek-v4-flash-0731"))
XCTAssertFalse(AIModeSearchSupport.deepSeekSupportsResponsesSearch(model: "deepseek-v4-pro"))
XCTAssertFalse(AIModeSearchSupport.deepSeekSupportsResponsesSearch(model: "deepseek-chat"))
}
func testResponsesURLAppendsPath() {
XCTAssertEqual(
ResponsesAPILLMClient.responsesURL(from: "https://api.openai.com/v1")?.absoluteString,
"https://api.openai.com/v1/responses"
)
XCTAssertEqual(
ResponsesAPILLMClient.responsesURL(from: "https://api.deepseek.com/v1/")?.absoluteString,
"https://api.deepseek.com/v1/responses"
)
}
func testParseResponsesOutputTextField() throws {
let json = """
{"output_text":"Hello from search","output":[]}
""".data(using: .utf8)!
XCTAssertEqual(try ResponsesAPILLMClient.parseOutputText(from: json), "Hello from search")
}
func testParseResponsesMessageContentParts() throws {
let json = """
{
"output": [
{"type":"reasoning","content":[{"type":"reasoning_text","text":"think"}]},
{"type":"message","content":[
{"type":"output_text","text":"Part A"},
{"type":"output_text","text":" Part B"}
]}
]
}
""".data(using: .utf8)!
XCTAssertEqual(try ResponsesAPILLMClient.parseOutputText(from: json), "Part A Part B")
}
func testFactoryUsesSearchFallbackForDeepSeekFlash() {
let client = AIModeLLMClientFactory.make(
providerId: "deepseek",
baseURL: "https://api.deepseek.com/v1",
apiKey: "sk-test",
model: "deepseek-v4-flash"
)
XCTAssertTrue(client is AIModeSearchFallbackClient)
}
func testFactorySkipsSearchForDeepSeekPro() {
let client = AIModeLLMClientFactory.make(
providerId: "deepseek",
baseURL: "https://api.deepseek.com/v1",
apiKey: "sk-test",
model: "deepseek-v4-pro"
)
XCTAssertFalse(client is AIModeSearchFallbackClient)
XCTAssertTrue(client is OpenAICompatibleClient)
}
func testFactoryUsesSearchFallbackForOpenAI() {
let client = AIModeLLMClientFactory.make(
providerId: "openai",
baseURL: "https://api.openai.com/v1",
apiKey: "sk-test",
model: "gpt-5.4-mini"
)
XCTAssertTrue(client is AIModeSearchFallbackClient)
}
func testFactorySkipsSearchWhenThinkingDisabled() {
let client = AIModeLLMClientFactory.make(
providerId: "openai",
baseURL: "https://api.openai.com/v1",
apiKey: "sk-test",
model: "gpt-5.4-mini",
thinkingEnabled: false
)
XCTAssertFalse(client is AIModeSearchFallbackClient)
XCTAssertTrue(client is OpenAICompatibleClient)
}
func testFactoryPlainForGroq() {
let client = AIModeLLMClientFactory.make(
providerId: "groq",
baseURL: "https://api.groq.com/openai/v1",
apiKey: "gsk-test",
model: "llama-3.3-70b-versatile"
)
XCTAssertFalse(client is AIModeSearchFallbackClient)
}
func testOpenAIDefaultModelSupportsSearchPreset() {
let openai = LLMProvider.provider(id: "openai")
XCTAssertEqual(openai.defaultModel, "gpt-5.4-mini")
}
func testUpdatedProviderDefaultModels() {
XCTAssertEqual(LLMProvider.provider(id: "qwen").defaultModel, "qwen-plus-latest")
XCTAssertEqual(LLMProvider.provider(id: "zhipu").defaultModel, "glm-4.7-flash")
XCTAssertEqual(LLMProvider.provider(id: "moonshot").defaultModel, "kimi-k2.5")
XCTAssertEqual(LLMProvider.provider(id: "xai").defaultModel, "grok-4-fast-reasoning")
XCTAssertEqual(LLMProvider.provider(id: "gemini").defaultModel, "gemini-3.1-flash-lite")
XCTAssertEqual(LLMProvider.provider(id: "minimax").defaultModel, "MiniMax-M2.7")
XCTAssertEqual(LLMProvider.provider(id: "anthropic").defaultModel, "claude-sonnet-4-6")
XCTAssertEqual(LLMProvider.provider(id: "siliconflow").defaultModel, "Qwen/Qwen3-8B-Instruct")
XCTAssertEqual(LLMProvider.provider(id: "openrouter").defaultModel, "qwen/qwen3-8b:free")
XCTAssertEqual(LLMProvider.provider(id: "cometapi").defaultModel, "gpt-5.4-mini")
XCTAssertEqual(LLMProvider.provider(id: "codingPlanX").defaultModel, "gpt-5.4-mini")
}
func testPolishAndAIModeResolveIdenticalEndpointFromSettings() {
let suiteName = "group.com.osgkeyboard.shared.tests.aimode.endpoint.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set("openai", forKey: AppGroupConfiguration.Keys.providerId)
defaults.set("https://api.openai.com/v1", forKey: AppGroupConfiguration.Keys.baseURL)
defaults.set("gpt-5.4-mini", forKey: AppGroupConfiguration.Keys.model)
let store = AppGroupStore(defaults: defaults)
let providerID = PolishingService.resolvedProviderId(store: store, providerIdOverride: nil)
let preset = LLMProvider.provider(id: providerID)
let polishEndpoint = PolishingService.resolveLLMEndpoint(
store: store,
preset: preset,
providerIdOverride: nil
)
// AI mode must not invent a different model — Settings model wins.
let aiEndpoint = PolishingService.resolveLLMEndpoint(
store: store,
preset: preset,
providerIdOverride: nil
)
XCTAssertEqual(providerID, "openai")
XCTAssertEqual(polishEndpoint.model, "gpt-5.4-mini")
XCTAssertEqual(aiEndpoint.model, polishEndpoint.model)
XCTAssertEqual(aiEndpoint.baseURL, polishEndpoint.baseURL)
}
func testEmptyStoreModelFallsBackToPresetDefaultForBothModes() {
let suiteName = "group.com.osgkeyboard.shared.tests.aimode.defaultmodel.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set("moonshot", forKey: AppGroupConfiguration.Keys.providerId)
defaults.set("", forKey: AppGroupConfiguration.Keys.model)
let store = AppGroupStore(defaults: defaults)
let preset = LLMProvider.provider(id: "moonshot")
let endpoint = PolishingService.resolveLLMEndpoint(
store: store,
preset: preset,
providerIdOverride: nil
)
XCTAssertEqual(endpoint.model, "kimi-k2.5")
}
func testChatCompletionsStreamDeltaIgnoresReasoningContent() {
let json = """
{"choices":[{"delta":{"reasoning_content":"think","content":"可见"}}]}
""".data(using: .utf8)!
XCTAssertEqual(LLMStreamDeltaParser.chatCompletionsDelta(from: json), "可见")
}
func testResponsesStreamDeltaOnlyOutputText() {
let delta = """
{"type":"response.output_text.delta","delta":"Hello"}
""".data(using: .utf8)!
XCTAssertEqual(LLMStreamDeltaParser.responsesOutputTextDelta(from: delta), "Hello")
let reasoning = """
{"type":"response.reasoning_text.delta","delta":"secret"}
""".data(using: .utf8)!
XCTAssertNil(LLMStreamDeltaParser.responsesOutputTextDelta(from: reasoning))
}
func testAnthropicStreamDeltaSkipsThinking() {
let text = """
{"type":"content_block_delta","delta":{"type":"text_delta","text":""}}
""".data(using: .utf8)!
XCTAssertEqual(LLMStreamDeltaParser.anthropicTextDelta(from: text), "答")
let thinking = """
{"type":"content_block_delta","delta":{"type":"thinking_delta","thinking":""}}
""".data(using: .utf8)!
XCTAssertNil(LLMStreamDeltaParser.anthropicTextDelta(from: thinking))
}
func testSSEDataPayloadParsing() {
XCTAssertEqual(
LLMStreamTransport.sseDataPayload(from: "data: {\"a\":1}"),
Data("{\"a\":1}".utf8)
)
XCTAssertNil(LLMStreamTransport.sseDataPayload(from: "event: message"))
XCTAssertNil(LLMStreamTransport.sseDataPayload(from: ": keep-alive"))
}
/// Regression: UTF-8 Chinese in SSE must not be decoded byte-as-character
/// (that produced Latin-1 mojibake like "今天…" for weather answers).
func testSSEBodyPreservesChineseUTF8Content() throws {
let answer = "今天北京多云间晴,最高气温33℃,夜间有分散性雷阵雨,最低气温25℃。"
let chunkJSON: [String: Any] = [
"choices": [
["delta": ["content": answer]]
]
]
let chunkData = try JSONSerialization.data(withJSONObject: chunkJSON)
guard let chunkText = String(data: chunkData, encoding: .utf8) else {
return XCTFail("chunk JSON must be UTF-8")
}
let bodyText = "data: \(chunkText)\n\ndata: [DONE]\n"
guard let body = bodyText.data(using: .utf8) else {
return XCTFail("SSE body must encode as UTF-8")
}
let payloads = LLMStreamTransport.sseJSONPayloads(fromBody: body)
XCTAssertEqual(payloads.count, 1)
XCTAssertEqual(
LLMStreamDeltaParser.chatCompletionsDelta(from: payloads[0]),
answer
)
}
func testAnswerStreamThrottleGatesByIntervalAndGrowth() {
var throttle = AIAnswerStreamThrottle(minInterval: 1, minCharacterStep: 10)
XCTAssertTrue(throttle.shouldPublish(accumulatedCount: 1, now: 100))
XCTAssertFalse(throttle.shouldPublish(accumulatedCount: 5, now: 100.2))
XCTAssertTrue(throttle.shouldPublish(accumulatedCount: 15, now: 100.2))
XCTAssertTrue(throttle.shouldPublish(accumulatedCount: 16, now: 101.5))
XCTAssertTrue(throttle.shouldPublish(accumulatedCount: 0, now: 101.6, force: true))
}
}