Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dayflow/Dayflow/Core/AI/ClaudeProvider+ActivityCards.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ extension ClaudeProvider {
) async throws -> (cards: [ActivityCardData], log: LLMCall) {
let callStart = Date()
let prompt = buildCardsPrompt(observations: observations, context: context)
let modelConfiguration = Self.activityCardModelConfiguration()
let modelConfiguration = resolvedActivityCardModelConfiguration()
let model = modelConfiguration.model
let effort = modelConfiguration.reasoningEffort
var run: ChatCLIRunResult?
Expand Down
9 changes: 7 additions & 2 deletions Dayflow/Dayflow/Core/AI/ClaudeProvider+Text.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ extension ClaudeProvider {
tool: .claude,
prompt: prompt,
workingDirectory: config.workingDirectory,
model: "sonnet",
model: resolvedChatStreamingModel,
reasoningEffort: nil,
sessionId: sessionId
)
Expand Down Expand Up @@ -44,9 +44,14 @@ extension ClaudeProvider {
}

func generateText(prompt: String) async throws -> (text: String, log: LLMCall) {
// One-off text requests (dashboard chat, ad-hoc prompts) intentionally
// ignore `defaultModel`: the user-selected Claude model is reserved for
// timeline-bound operations (transcription + card generation) so we
// don't accidentally let a "fast" model setting degrade richer reasoning
// tasks. Fall back to the same stable default as generateChatStreaming.
try await generateText(
prompt: prompt,
model: "sonnet",
model: resolvedChatStreamingModel,
reasoningEffort: "high",
disableTools: false
)
Expand Down
2 changes: 1 addition & 1 deletion Dayflow/Dayflow/Core/AI/ClaudeProvider+Transcription.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ extension ClaudeProvider {
}

let callStart = Date()
let modelConfiguration = Self.transcriptionModelConfiguration()
let modelConfiguration = resolvedTranscriptionModelConfiguration()
let model = modelConfiguration.model
let effort = modelConfiguration.reasoningEffort
let preparedInput: ClaudePreparedTranscriptionInput
Expand Down
38 changes: 37 additions & 1 deletion Dayflow/Dayflow/Core/AI/ClaudeProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,15 @@ final class ClaudeProvider: AgentCLISupporting {
var cliTool: ChatCLITool { .claude }
let runner = ChatCLIProcessRunner()
let config = ChatCLIConfigManager.shared
/// When non-nil, this overrides whatever the static `*ModelConfiguration`
/// methods would otherwise pick as the canonical Claude model. `LLMService`
/// populates it from `UserDefaults` (key: `llmClaudeModel`) so the model
/// the provider uses stays in sync with the user's Settings selection.
/// When nil, providers fall back to the hardcoded default.
let defaultModel: String?

init() {
init(defaultModel: String? = nil) {
self.defaultModel = defaultModel
config.ensureWorkingDirectory()
}

Expand Down Expand Up @@ -53,6 +60,35 @@ final class ClaudeProvider: AgentCLISupporting {
return first.adding(second)
}

/// Resolved model for activity-card generation. Uses `defaultModel` when the
/// user has configured one in Settings, otherwise falls back to the
/// canonical Claude model returned by `activityCardModelConfiguration()`.
/// Reasoning effort is always taken from the static default because the
/// effort is a property of the Claude profile, not the model id.
func resolvedActivityCardModelConfiguration() -> (
model: String, reasoningEffort: String?
) {
let fallback = Self.activityCardModelConfiguration()
return (model: defaultModel ?? fallback.model, reasoningEffort: fallback.reasoningEffort)
}

/// Resolved model for screenshot transcription. Same shape as
/// `resolvedActivityCardModelConfiguration()`; kept distinct so future per-
/// operation overrides (e.g. a faster transcription-only model) can be
/// added without touching the activity-card path.
func resolvedTranscriptionModelConfiguration() -> (
model: String, reasoningEffort: String?
) {
let fallback = Self.transcriptionModelConfiguration()
return (model: defaultModel ?? fallback.model, reasoningEffort: fallback.reasoningEffort)
}

/// Model used for one-off `generateChatStreaming` calls (dashboard chat,
/// on-demand text). Falls back to a stable Claude default.
var resolvedChatStreamingModel: String {
defaultModel ?? Self.activityCardModelConfiguration().model
}

func validateSuccessfulClaudeProcess(_ run: ChatCLIRunResult) throws {
guard run.exitCode == 0 else {
let message = run.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down
2 changes: 1 addition & 1 deletion Dayflow/Dayflow/Core/AI/CodexProvider+ActivityCards.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ extension CodexProvider {
let basePrompt = buildCardsPrompt(observations: observations, context: context)
var actualPromptUsed = basePrompt

let modelConfiguration = Self.activityCardModelConfiguration()
let modelConfiguration = resolvedActivityCardModelConfiguration()
let legacyModelConfiguration = Self.legacyActivityCardModelConfiguration()
var model = modelConfiguration.model
var effort = modelConfiguration.reasoningEffort
Expand Down
9 changes: 7 additions & 2 deletions Dayflow/Dayflow/Core/AI/CodexProvider+Text.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ extension CodexProvider {
tool: .codex,
prompt: prompt,
workingDirectory: config.workingDirectory,
model: "gpt-5.4",
model: resolvedChatStreamingModel,
reasoningEffort: "low",
sessionId: sessionId
)
Expand Down Expand Up @@ -44,9 +44,14 @@ extension CodexProvider {
}

func generateText(prompt: String) async throws -> (text: String, log: LLMCall) {
// One-off text requests (dashboard chat, ad-hoc prompts) intentionally
// ignore `defaultModel`: the user-selected Codex model is reserved for
// timeline-bound operations (transcription + card generation) so we
// don't accidentally let a "fast" model setting degrade richer reasoning
// tasks. Fall back to the same stable default as generateChatStreaming.
try await generateText(
prompt: prompt,
model: "gpt-5.2",
model: resolvedChatStreamingModel,
reasoningEffort: "high",
disableTools: false
)
Expand Down
2 changes: 1 addition & 1 deletion Dayflow/Dayflow/Core/AI/CodexProvider+Transcription.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ extension CodexProvider {
)
}

let modelConfiguration = Self.transcriptionModelConfiguration()
let modelConfiguration = resolvedTranscriptionModelConfiguration()
let legacyModelConfiguration = Self.legacyTranscriptionModelConfiguration()
var model = modelConfiguration.model
var effort = modelConfiguration.reasoningEffort
Expand Down
40 changes: 39 additions & 1 deletion Dayflow/Dayflow/Core/AI/CodexProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,50 @@ final class CodexProvider: AgentCLISupporting {
var cliTool: ChatCLITool { .codex }
let runner = ChatCLIProcessRunner()
let config = ChatCLIConfigManager.shared
/// When non-nil, this overrides whatever the static `*ModelConfiguration`
/// methods would otherwise pick as the canonical Codex model. `LLMService`
/// populates it from `UserDefaults` (key: `llmCodexModel`) so the model the
/// provider uses stays in sync with the user's Settings selection. When
/// nil, providers fall back to the hardcoded default.
let defaultModel: String?

init() {
init(defaultModel: String? = nil) {
self.defaultModel = defaultModel
config.ensureWorkingDirectory()
}
}

extension CodexProvider {
/// Resolved model for activity-card generation. Uses `defaultModel` when the
/// user has configured one in Settings, otherwise falls back to the
/// canonical Codex model returned by `activityCardModelConfiguration()`.
/// Reasoning effort is always taken from the static default because the
/// effort is a property of the Codex profile, not the model id.
func resolvedActivityCardModelConfiguration() -> (
model: String, reasoningEffort: String?
) {
let fallback = Self.activityCardModelConfiguration()
return (model: defaultModel ?? fallback.model, reasoningEffort: fallback.reasoningEffort)
}

/// Resolved model for screenshot transcription. Same shape as
/// `resolvedActivityCardModelConfiguration()`; kept distinct so future per-
/// operation overrides (e.g. a faster transcription-only model) can be
/// added without touching the activity-card path.
func resolvedTranscriptionModelConfiguration() -> (
model: String, reasoningEffort: String?
) {
let fallback = Self.transcriptionModelConfiguration()
return (model: defaultModel ?? fallback.model, reasoningEffort: fallback.reasoningEffort)
}

/// Model used for one-off `generateChatStreaming` calls (dashboard chat,
/// on-demand text). Falls back to a stable Codex default.
var resolvedChatStreamingModel: String {
defaultModel ?? Self.activityCardModelConfiguration().model
}
}

extension CodexProvider {
static func shouldUseLegacyModel(
after error: Error,
Expand Down
2 changes: 2 additions & 0 deletions Dayflow/Dayflow/Core/AI/DailyRecapModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ enum DailyRecapProvider: String, Codable, CaseIterable, Sendable {
return .local
case .openAICompatible:
return .none
case .minimax:
return .none
}
}

Expand Down
105 changes: 92 additions & 13 deletions Dayflow/Dayflow/Core/AI/LLMService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,63 @@ final class LLMService: LLMServicing {
return OllamaProvider(openAICompatible: runtimeConfiguration)
}

private func makeMiniMaxProvider() -> MiniMaxProvider? {
guard let key = KeychainManager.shared.retrieve(for: MiniMaxProvider.keychainKey),
!key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
print("❌ [LLMService] MiniMax provider unavailable: missing API key")
return nil
}
return MiniMaxProvider()
}

private func providerLabel(for providerID: LLMProviderID) -> String {
providerID.providerLabel
}

/// Returns the model ID the provider should be stamped with on generated
/// cards. Falls back to the canonical provider ID when the user hasn't
/// chosen a specific model yet.
private func providerModelId(for providerID: LLMProviderID) -> String? {
switch providerID {
case .gemini:
return GeminiModelPreference.load().primary.rawValue
case .local:
return UserDefaults.standard.string(forKey: "llmLocalModelId")
case .openAICompatible:
return OpenAICompatiblePreferences.load()?.modelID
case .chatGPT:
return UserDefaults.standard.string(forKey: "llmCodexModel")
case .claude:
return UserDefaults.standard.string(forKey: "llmClaudeModel")
case .minimax:
return MiniMaxModelPreference.load().modelId
case .dayflow:
return nil
}
}

/// Reads the user-selected Chat CLI model from `UserDefaults` for the
/// given provider and returns a trimmed, non-empty value. Returns `nil`
/// when the key is unset or the stored value is whitespace-only so the
/// provider's `defaultModel` is `nil` and the static canonical default
/// is used. Mirrors v1's `makeChatCLIProvider` pass-through so Settings
/// selections actually reach the running provider.
private func resolvedChatCLIDefaultModel(for providerID: LLMProviderID) -> String? {
let key: String
switch providerID {
case .chatGPT:
key = "llmCodexModel"
case .claude:
key = "llmClaudeModel"
default:
return nil
}
guard let raw = UserDefaults.standard.string(forKey: key) else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}

private func noProviderError() -> NSError {
NSError(
domain: "LLMService",
Expand Down Expand Up @@ -246,21 +299,35 @@ final class LLMService: LLMServicing {
), fallbackState: nil
)
case .chatGPT:
let provider = CodexProvider()
let provider = CodexProvider(defaultModel: resolvedChatCLIDefaultModel(for: .chatGPT))
return (
actions: BatchProviderActions(
transcribeScreenshots: provider.transcribeScreenshots,
generateActivityCards: provider.generateActivityCards
), fallbackState: nil
)
case .claude:
let provider = ClaudeProvider()
let provider = ClaudeProvider(defaultModel: resolvedChatCLIDefaultModel(for: .claude))
return (
actions: BatchProviderActions(
transcribeScreenshots: provider.transcribeScreenshots,
generateActivityCards: provider.generateActivityCards
), fallbackState: nil
)
case .minimax:
guard let provider = makeMiniMaxProvider() else { throw noProviderError() }
return (
actions: BatchProviderActions(
transcribeScreenshots: { [provider] screenshots, batchStartTime, batchId in
try await provider.transcribeScreenshots(
screenshots, batchStartTime: batchStartTime, batchId: batchId)
},
generateActivityCards: { [provider] observations, context, batchId in
try await provider.generateActivityCards(
observations: observations, context: context, batchId: batchId)
}
), fallbackState: nil
)
}
}

Expand Down Expand Up @@ -540,21 +607,29 @@ final class LLMService: LLMServicing {
generateTextStreaming: nil
)
case .chatGPT:
let provider = CodexProvider()
let provider = CodexProvider(defaultModel: resolvedChatCLIDefaultModel(for: .chatGPT))
return TextProviderActions(
generateText: { prompt in
try await provider.generateText(prompt: prompt)
},
generateTextStreaming: provider.generateTextStreaming
)
case .claude:
let provider = ClaudeProvider()
let provider = ClaudeProvider(defaultModel: resolvedChatCLIDefaultModel(for: .claude))
return TextProviderActions(
generateText: { prompt in
try await provider.generateText(prompt: prompt)
},
generateTextStreaming: provider.generateTextStreaming
)
case .minimax:
guard let provider = makeMiniMaxProvider() else { throw noProviderError() }
return TextProviderActions(
generateText: { prompt in
try await provider.generateText(prompt: prompt)
},
generateTextStreaming: nil
)
}
}

Expand Down Expand Up @@ -853,7 +928,9 @@ final class LLMService: LLMServicing {
detailedSummary: card.detailedSummary,
distractions: card.distractions,
appSites: card.appSites,
isBackupGenerated: isBackupGenerated ? true : nil
isBackupGenerated: isBackupGenerated ? true : nil,
providerId: activeContext.id.providerLabel,
modelId: providerModelId(for: activeContext.id)
)
},
batchId: batchId
Expand Down Expand Up @@ -1191,15 +1268,17 @@ final class LLMService: LLMServicing {
history: request.history
)
case .codex:
return CodexProvider().generateChatStreaming(
prompt: request.prompt,
sessionId: request.sessionId
)
return CodexProvider(defaultModel: resolvedChatCLIDefaultModel(for: .chatGPT))
.generateChatStreaming(
prompt: request.prompt,
sessionId: request.sessionId
)
case .claude:
return ClaudeProvider().generateChatStreaming(
prompt: request.prompt,
sessionId: request.sessionId
)
return ClaudeProvider(defaultModel: resolvedChatCLIDefaultModel(for: .claude))
.generateChatStreaming(
prompt: request.prompt,
sessionId: request.sessionId
)
}
}
}
5 changes: 5 additions & 0 deletions Dayflow/Dayflow/Core/AI/LLMTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ enum LLMProviderID: String, Codable, CaseIterable {
case claude
case openAICompatible = "openai_compatible"
case local
case minimax

var analyticsName: String {
switch self {
Expand All @@ -100,6 +101,8 @@ enum LLMProviderID: String, Codable, CaseIterable {
return "openai_compatible"
case .local:
return "ollama"
case .minimax:
return "minimax"
}
}

Expand All @@ -112,6 +115,8 @@ enum LLMProviderID: String, Codable, CaseIterable {
case .openAICompatible: return "openai_compatible"
case .local:
return "local"
case .minimax:
return "minimax"
}
}
}
Expand Down
Loading