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
+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