Phase 2 seam: expose pure helpers for testing (no logic changes)

Every change here is either dropping `private` (still invisible
outside the module, @testable import just needs internal-or-wider)
or converting a self-independent instance method to `static func`
(ChatViewModel.inferProvider/calculateCost/detectGoodbyePhrase --
none of the three ever touched `self`, and constructing a real
ChatViewModel triggers a real network call in init, so static-ifying
them sidesteps that entirely rather than fighting it). Call sites
updated to `Self.foo(...)` where the static conversion required it.

Touches: GitSyncService's URL/secret-scanning helpers,
EmbeddingService's embedding (de)serialization, ContextSelectionService's
importance scoring, and the request-building/response-parsing helpers
on all four providers (OpenRouter, Ollama, OpenAI, Anthropic) -- the
core AI request/response layer, previously 100% untested.

Verified: full existing test suite (27 tests) still green, no
regressions. Tests for these functions land in the next commit.
Phase 2 of the test-suite rollout plan (peaceful-baking-kurzweil).
This commit is contained in:
2026-07-23 13:48:41 +02:00
parent 1f4a2caf67
commit f39527b1d8
8 changed files with 32 additions and 32 deletions
+3 -3
View File
@@ -571,7 +571,7 @@ class AnthropicProvider: AIProvider {
// MARK: - Request Building
private func buildURLRequest(from request: ChatRequest, stream: Bool) throws -> (URLRequest, Data) {
func buildURLRequest(from request: ChatRequest, stream: Bool) throws -> (URLRequest, Data) {
let url = messagesURL
// Separate system message
@@ -685,7 +685,7 @@ class AnthropicProvider: AIProvider {
return (urlRequest, bodyData)
}
private func parseResponse(data: Data) throws -> ChatResponse {
func parseResponse(data: Data) throws -> ChatResponse {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw ProviderError.invalidResponse
}
@@ -741,7 +741,7 @@ class AnthropicProvider: AIProvider {
)
}
private func convertParametersToDict(_ params: Tool.Function.Parameters) -> [String: Any] {
func convertParametersToDict(_ params: Tool.Function.Parameters) -> [String: Any] {
var props: [String: Any] = [:]
for (key, prop) in params.properties {
var propDict: [String: Any] = [
+2 -2
View File
@@ -275,7 +275,7 @@ class OllamaProvider: AIProvider {
// MARK: - Helpers
private func buildRequestBody(from request: ChatRequest, stream: Bool) -> [String: Any] {
func buildRequestBody(from request: ChatRequest, stream: Bool) -> [String: Any] {
var messages: [[String: Any]] = []
// Add system prompt as a system message
@@ -301,7 +301,7 @@ class OllamaProvider: AIProvider {
return body
}
private func parseOllamaResponse(_ json: [String: Any], model: String) -> ChatResponse {
func parseOllamaResponse(_ json: [String: Any], model: String) -> ChatResponse {
let message = json["message"] as? [String: Any]
let content = message?["content"] as? String ?? ""
let promptTokens = json["prompt_eval_count"] as? Int ?? 0
+3 -3
View File
@@ -117,7 +117,7 @@ class OpenAIProvider: AIProvider {
}
}
private func fallbackModels() -> [ModelInfo] {
func fallbackModels() -> [ModelInfo] {
Self.knownModels.map { id, info in
ModelInfo(
id: id,
@@ -283,7 +283,7 @@ class OpenAIProvider: AIProvider {
// MARK: - Helpers
private func buildURLRequest(from request: ChatRequest, stream: Bool) throws -> URLRequest {
func buildURLRequest(from request: ChatRequest, stream: Bool) throws -> URLRequest {
let url = URL(string: "\(baseURL)/chat/completions")!
var apiMessages: [[String: Any]] = []
@@ -358,7 +358,7 @@ class OpenAIProvider: AIProvider {
return urlRequest
}
private func convertToChatResponse(_ apiResponse: OpenRouterChatResponse) -> ChatResponse {
func convertToChatResponse(_ apiResponse: OpenRouterChatResponse) -> ChatResponse {
guard let choice = apiResponse.choices.first else {
return ChatResponse(id: apiResponse.id, model: apiResponse.model, content: "", role: "assistant", finishReason: nil, usage: nil, created: Date())
}
+4 -4
View File
@@ -416,7 +416,7 @@ class OpenRouterProvider: AIProvider {
// MARK: - Helper Methods
private func buildAPIRequest(from request: ChatRequest) throws -> OpenRouterChatRequest {
func buildAPIRequest(from request: ChatRequest) throws -> OpenRouterChatRequest {
let apiMessages = request.messages.map { message -> OpenRouterChatRequest.APIMessage in
let hasAttachments = message.attachments?.contains(where: { $0.data != nil }) ?? false
@@ -500,7 +500,7 @@ class OpenRouterProvider: AIProvider {
)
}
private func convertToChatResponse(_ apiResponse: OpenRouterChatResponse) throws -> ChatResponse {
func convertToChatResponse(_ apiResponse: OpenRouterChatResponse) throws -> ChatResponse {
guard let choice = apiResponse.choices.first else {
throw ProviderError.invalidResponse
}
@@ -540,7 +540,7 @@ class OpenRouterProvider: AIProvider {
)
}
private func convertToStreamChunk(_ apiChunk: OpenRouterStreamChunk) throws -> StreamChunk {
func convertToStreamChunk(_ apiChunk: OpenRouterStreamChunk) throws -> StreamChunk {
guard let choice = apiChunk.choices.first else {
throw ProviderError.invalidResponse
}
@@ -579,7 +579,7 @@ class OpenRouterProvider: AIProvider {
}
/// Decode base64 data URL images from API response
private func decodeImageOutputs(_ outputs: [OpenRouterChatResponse.ImageOutput]) -> [Data]? {
func decodeImageOutputs(_ outputs: [OpenRouterChatResponse.ImageOutput]) -> [Data]? {
let decoded = outputs.compactMap { output -> Data? in
let url = output.imageUrl.url
// Strip "data:image/...;base64," prefix
+2 -2
View File
@@ -214,7 +214,7 @@ final class ContextSelectionService {
// MARK: - Importance Scoring
/// Calculate importance score (0.0 - 1.0) for a message
private func getImportanceScore(_ message: Message) -> Double {
func getImportanceScore(_ message: Message) -> Double {
var score = 0.0
// Factor 1: Cost (expensive calls are important)
@@ -248,7 +248,7 @@ final class ContextSelectionService {
// MARK: - Token Estimation
/// Estimate token count for messages (rough approximation)
private func estimateTokens(_ messages: [Message]) -> Int {
func estimateTokens(_ messages: [Message]) -> Int {
var total = 0
for message in messages {
if let tokens = message.tokens {
+2 -2
View File
@@ -348,7 +348,7 @@ final class EmbeddingService {
// MARK: - Serialization
/// Serialize embedding to binary data (4 bytes per float, little-endian)
private func serializeEmbedding(_ embedding: [Float]) -> Data {
func serializeEmbedding(_ embedding: [Float]) -> Data {
var data = Data(capacity: embedding.count * 4)
for value in embedding {
var littleEndian = value.bitPattern.littleEndian
@@ -360,7 +360,7 @@ final class EmbeddingService {
}
/// Deserialize embedding from binary data
private func deserializeEmbedding(_ data: Data) -> [Float] {
func deserializeEmbedding(_ data: Data) -> [Float] {
var embedding: [Float] = []
embedding.reserveCapacity(data.count / 4)
+4 -4
View File
@@ -507,7 +507,7 @@ class GitSyncService {
}
}
private func detectSecretsInText(_ text: String) -> [String] {
func detectSecretsInText(_ text: String) -> [String] {
let patterns: [(name: String, pattern: String)] = [
("OpenAI Key", "sk-[a-zA-Z0-9]{32,}"),
("Anthropic Key", "sk-ant-[a-zA-Z0-9_-]+"),
@@ -610,7 +610,7 @@ class GitSyncService {
}
}
private func convertToSSH(_ url: String) -> String {
func convertToSSH(_ url: String) -> String {
// If already SSH format, return as-is
if url.hasPrefix("git@") {
return url
@@ -642,7 +642,7 @@ class GitSyncService {
return url
}
private func injectCredentials(_ url: String, username: String, password: String) -> String {
func injectCredentials(_ url: String, username: String, password: String) -> String {
// Convert https://github.com/user/repo.git
// To: https://username:password@github.com/user/repo.git
@@ -697,7 +697,7 @@ class GitSyncService {
}
}
private func sanitizeFilename(_ name: String) -> String {
func sanitizeFilename(_ name: String) -> String {
// Remove invalid filename characters
let invalid = CharacterSet(charactersIn: "/\\:*?\"<>|")
return name.components(separatedBy: invalid).joined(separator: "-")
+12 -12
View File
@@ -413,15 +413,15 @@ Don't narrate future actions ("Let me...") - just use the tools.
/// Update the selected model and keep currentProvider + settings in sync.
/// Call this whenever the user picks a model in the model selector.
func selectModel(_ model: ModelInfo) {
let newProvider = inferProvider(from: model.id) ?? currentProvider
let newProvider = Self.inferProvider(from: model.id) ?? currentProvider
selectedModel = model
currentProvider = newProvider
MCPService.shared.resetBashSessionApproval()
}
func inferProviderPublic(from modelId: String) -> Settings.Provider? { inferProvider(from: modelId) }
func inferProviderPublic(from modelId: String) -> Settings.Provider? { Self.inferProvider(from: modelId) }
private func inferProvider(from modelId: String) -> Settings.Provider? {
static func inferProvider(from modelId: String) -> Settings.Provider? {
// OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet")
if modelId.contains("/") { return .openrouter }
// Anthropic direct (e.g. "claude-sonnet-4-5-20250929")
@@ -437,7 +437,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
/// Shows a system message only on failure or on a successful switch.
@MainActor
private func switchToConversationModel(_ modelId: String) async {
guard let targetProvider = inferProvider(from: modelId) else {
guard let targetProvider = Self.inferProvider(from: modelId) else {
showSystemMessage("⚠️ Could not determine provider for model '\(modelId)' — keeping current model")
return
}
@@ -942,7 +942,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
messages[index].tokens = usage.completionTokens
if let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
}
@@ -1006,7 +1006,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
messages[index].tokens = usage.completionTokens
if let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
}
@@ -1306,7 +1306,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
}
}
_ = detectGoodbyePhrase(in: "")
_ = Self.detectGoodbyePhrase(in: "")
} catch {
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = "❌ Image generation failed: \(error.localizedDescription)"
@@ -1582,7 +1582,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
// Nothing worth showing yet still record usage/cost for this turn.
if let usage = totalUsage, let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
sessionStats.addMessage(
inputTokens: usage.promptTokens,
outputTokens: usage.completionTokens,
@@ -1607,7 +1607,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
// Calculate cost
if let usage = totalUsage, let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) {
messages[index].cost = cost
}
@@ -2038,7 +2038,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
}
/// Detect goodbye phrases in user message
func detectGoodbyePhrase(in text: String) -> Bool {
static func detectGoodbyePhrase(in text: String) -> Bool {
let lowercased = text.lowercased()
let goodbyePhrases = [
"bye", "goodbye", "bye bye", "good bye",
@@ -2078,7 +2078,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
updateConversationTracking()
// Check for goodbye phrase
if detectGoodbyePhrase(in: text) {
if Self.detectGoodbyePhrase(in: text) {
Log.ui.info("Goodbye phrase detected - triggering auto-save")
// Wait a bit to see if user continues
try? await Task.sleep(for: .seconds(30))
@@ -2259,7 +2259,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
/// pricing when present: cache writes cost 1.25x the base input rate, cache
/// reads cost 0.1x. `usage.promptTokens` is already the uncached remainder
/// it does not need cache tokens subtracted from it.
private func calculateCost(usage: ChatResponse.Usage, pricing: ModelInfo.Pricing) -> Double {
static func calculateCost(usage: ChatResponse.Usage, pricing: ModelInfo.Pricing) -> Double {
let inputCost = Double(usage.promptTokens) * pricing.prompt / 1_000_000
let cacheReadCost = Double(usage.cacheReadInputTokens ?? 0) * pricing.prompt * 0.1 / 1_000_000
let cacheWriteCost = Double(usage.cacheCreationInputTokens ?? 0) * pricing.prompt * 1.25 / 1_000_000