feat: add Claude Fable 5 support (#186)

This commit is contained in:
Peter Steinberger 2026-06-11 17:59:43 -07:00 committed by GitHub
parent 7c3862b032
commit e44486ff16
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 3481 additions and 175 deletions

View File

@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [3.4.2] - Unreleased
### Added
- `peekaboo agent` now supports explicit Claude Fable 5 (`claude-fable-5`) selection with 1M context and 128K max output while keeping Anthropic defaults on Opus 4.8 for zero-retention compatibility.
## [3.4.1] - 2026-06-10
### Fixed

View File

@ -18,17 +18,21 @@ extension AgentCommand {
.first
.map { String($0).lowercased() }
if let configuration {
if let configuredModel = PeekabooAIService(configuration: configuration).resolveConfiguredModel(trimmed),
case .custom = configuredModel {
return configuredModel.supportsTools ? configuredModel : nil
}
if trimmed.caseInsensitiveCompare("claude") == .orderedSame ||
trimmed.caseInsensitiveCompare("anthropic") == .orderedSame {
return .anthropic(.opus48)
}
if let explicitProvider,
configuration.listCustomProviders().contains(where: { providerID, provider in
provider.enabled && providerID.caseInsensitiveCompare(explicitProvider) == .orderedSame
}) {
return nil
if let configuration {
switch self.parseConfiguredCustomModel(
trimmed,
explicitProvider: explicitProvider,
configuration: configuration
) {
case let .resolved(model):
return model
case .unresolved:
break
}
}
@ -36,6 +40,11 @@ extension AgentCommand {
return nil
}
return self.supportedParsedModel(parsed, explicitProvider: explicitProvider)
}
@MainActor
private func supportedParsedModel(_ parsed: LanguageModel, explicitProvider: String?) -> LanguageModel? {
switch parsed {
case let .openai(model):
if Self.supportedOpenAIInputs.contains(model) {
@ -43,7 +52,7 @@ extension AgentCommand {
}
case let .anthropic(model):
if Self.supportedAnthropicInputs.contains(model) {
return .anthropic(.opus48)
return .anthropic(model)
}
case let .google(model):
if Self.supportedGoogleInputs.contains(model) {
@ -73,6 +82,32 @@ extension AgentCommand {
return nil
}
@MainActor
private func parseConfiguredCustomModel(
_ modelString: String,
explicitProvider: String?,
configuration: PeekabooCore.ConfigurationManager
) -> ConfiguredModelResolution {
if let configuredModel = PeekabooAIService(configuration: configuration).resolveConfiguredModel(modelString),
case .custom = configuredModel {
return .resolved(configuredModel.supportsTools ? configuredModel : nil)
}
if let explicitProvider,
configuration.listCustomProviders().contains(where: { providerID, provider in
provider.enabled && providerID.caseInsensitiveCompare(explicitProvider) == .orderedSame
}) {
return .resolved(nil)
}
return .unresolved
}
private enum ConfiguredModelResolution {
case resolved(LanguageModel?)
case unresolved
}
@MainActor
func validatedModelSelection(configuration: PeekabooCore.ConfigurationManager? = nil) throws -> LanguageModel? {
guard let modelString = self.model else { return nil }
@ -96,6 +131,7 @@ extension AgentCommand {
]
private static let supportedAnthropicInputs: Set<LanguageModel.Anthropic> = [
.fable5,
.opus48,
.opus47,
.opus45,

View File

@ -89,7 +89,7 @@ struct AgentCommand: RuntimeOptionsConfigurable {
@Option(
name: .long,
help: """
AI model to use (for example: gpt-5.5, claude-opus-4-8, \
AI model to use (for example: gpt-5.5, claude-fable-5, \
gemini-3.5-flash, grok-4.3, minimax-m2.7, minimax-cn/m2.7, \
ollama/<model>, lmstudio/<model>, or <custom-provider>/<model>)
"""

View File

@ -28,16 +28,19 @@ struct AgentCommandTests {
}
@Test
func `Supported Anthropic aliases map to Claude Opus 4.8`() throws {
func `Supported Anthropic aliases parse current models`() throws {
let command = try AgentCommand.parse([])
#expect(command.parseModelString("claude-fable-5") == .anthropic(.fable5))
#expect(command.parseModelString("fable") == .anthropic(.fable5))
#expect(command.parseModelString("claude-opus-4.8") == .anthropic(.opus48))
#expect(command.parseModelString("claude-opus-4.7") == .anthropic(.opus48))
#expect(command.parseModelString("claude-sonnet-4.6") == .anthropic(.opus48))
#expect(command.parseModelString("claude-sonnet-4.5") == .anthropic(.opus48))
#expect(command.parseModelString("Claude-Sonnet-4.5") == .anthropic(.opus48))
#expect(command.parseModelString("claude-opus-4.7") == .anthropic(.opus47))
#expect(command.parseModelString("claude-sonnet-4.6") == .anthropic(.sonnet46))
#expect(command.parseModelString("claude-sonnet-4.5") == .anthropic(.sonnet45))
#expect(command.parseModelString("Claude-Sonnet-4.5") == .anthropic(.sonnet45))
#expect(command.parseModelString("claude") == .anthropic(.opus48))
#expect(command.parseModelString("claude-opus-4") == .anthropic(.opus48))
#expect(command.parseModelString("anthropic") == .anthropic(.opus48))
#expect(command.parseModelString("claude-opus-4") == .anthropic(.opus4))
#expect(command.parseModelString("claude-3-sonnet") == nil)
}
@ -104,7 +107,7 @@ struct AgentCommandTests {
#expect(command.parseModelString(" gpt-5 ") == .openai(.gpt55))
#expect(command.parseModelString("\tgpt-5\n") == .openai(.gpt55))
#expect(command.parseModelString(" claude-sonnet-4.5 ") == .anthropic(.opus48))
#expect(command.parseModelString(" claude-sonnet-4.5 ") == .anthropic(.sonnet45))
#expect(command.parseModelString(" gemini-3-flash ") == .google(.gemini3Flash))
#expect(command.parseModelString(" minimax-m2.7 ") == .minimax(.m27))
#expect(command.parseModelString(" minimax-cn/m2.7 ") == .minimaxCN(.m27))
@ -198,7 +201,7 @@ struct AgentCommandTests {
"""
{
"aiProviders": {
"providers": "openai/gpt-5.5,anthropic/claude-opus-4-8"
"providers": "openai/gpt-5.5,anthropic/claude-fable-5"
}
}
""",
@ -207,7 +210,7 @@ struct AgentCommandTests {
let command = try AgentCommand.parse([])
let service = PeekabooAIService(configuration: PeekabooCore.ConfigurationManager.shared)
#expect(command.firstAvailableToolModel(from: service) == .anthropic(.opus48))
#expect(command.firstAvailableToolModel(from: service) == .anthropic(.fable5))
}
}
@ -352,7 +355,7 @@ struct ModelSelectionIntegrationTests {
command.model = "claude-opus-4.7"
let parsedClaude = command.model.flatMap { command.parseModelString($0) }
#expect(parsedClaude == .anthropic(.opus48))
#expect(parsedClaude == .anthropic(.opus47))
command.model = "gpt-4o"
let remapped = command.model.flatMap { command.parseModelString($0) }
@ -369,6 +372,7 @@ struct ModelSelectionIntegrationTests {
let testCases: [(String, LanguageModel)] = [
("gpt-5.5", .openai(.gpt55)),
("claude-fable-5", .anthropic(.fable5)),
("claude-opus-4.8", .anthropic(.opus48)),
("gemini-3.5-flash", .google(.gemini35Flash)),
("MiniMax-M2.7", .minimax(.m27)),

View File

@ -40,7 +40,7 @@ public struct AIAssistantWindow: View {
Picker("Model", selection: self.$selectedModel) {
Text("GPT-5.5").tag(Model.openai(.gpt55))
Text("Claude Opus 4.7").tag(Model.anthropic(.opus47))
Text("Claude Opus 4.8").tag(Model.anthropic(.opus48))
}
.pickerStyle(.menu)
}
@ -104,6 +104,7 @@ public struct AIAssistantWindow: View {
system: self.systemPrompt.isEmpty ? nil : self.systemPrompt,
settings: .default,
tools: nil)
.id(self.selectedModel.description)
}
.navigationTitle("AI Assistant")
.toolbar {
@ -139,7 +140,7 @@ public struct CompactAIAssistant: View {
Picker("Model", selection: self.$model) {
Text("GPT-5.5").tag(Model.openai(.gpt55))
Text("Claude Opus 4.7").tag(Model.anthropic(.opus47))
Text("Claude Opus 4.8").tag(Model.anthropic(.opus48))
}
.pickerStyle(.menu)
.controlSize(.small)
@ -154,6 +155,7 @@ public struct CompactAIAssistant: View {
system: self.systemPrompt.isEmpty ? nil : self.systemPrompt,
settings: .default,
tools: nil)
.id(self.model.description)
}
}
}

View File

@ -239,7 +239,7 @@ public struct MessageBubble: View {
#Preview {
PeekabooChatView(
model: LanguageModel.anthropic(.opus47),
model: LanguageModel.anthropic(.opus48),
system: "You are a helpful assistant specialized in macOS automation and development.")
.frame(width: 400, height: 600)
}

View File

@ -13,6 +13,7 @@ func formatModelName(_ model: String) -> String {
case "gpt-5.4-nano": "GPT-5.4 nano"
case "gpt-5": "GPT-5"
case "gpt-5-mini": "GPT-5 mini"
case "claude-fable-5": "Claude Fable 5"
case "claude-opus-4-8": "Claude Opus 4.8"
case "claude-opus-4-7": "Claude Opus 4.7"
case "claude-sonnet-4-6": "Claude Sonnet 4.6"

View File

@ -242,6 +242,7 @@ struct AISettingsView: View {
("gpt-5-mini", "GPT-5 mini"),
]),
("anthropic", [
("claude-fable-5", "Claude Fable 5"),
("claude-opus-4-8", "Claude Opus 4.8"),
("claude-opus-4-7", "Claude Opus 4.7"),
("claude-sonnet-4-6", "Claude Sonnet 4.6"),
@ -335,6 +336,8 @@ struct AISettingsView: View {
"gpt-5-mini": "Cost-optimized GPT-5 Mini with the same tools + 400K context " +
"at a friendlier price.",
// Anthropic models
"claude-fable-5": "Claude Fable 5 with 1M context for demanding " +
"reasoning and long-horizon agent tasks.",
"claude-opus-4-8": "Claude Opus 4.8 with 1M context for long-running " +
"automation and computer-use tasks.",
"claude-sonnet-4-6": "Claude Sonnet 4.6 with new tools + computer use, " +

View File

@ -49,7 +49,7 @@ final class SessionTitleGenerator {
private static func timeoutTitle() async -> String {
do {
try await Task.sleep(nanoseconds: 3_000_000_000)
try await Task.sleep(nanoseconds: 8_000_000_000)
} catch {
return self.fallbackTitle
}
@ -63,7 +63,7 @@ final class SessionTitleGenerator {
hasAnthropic: Bool) async -> String
{
do {
let model = self.selectModel(
let model = Self.selectModel(
providers: providers,
hasOpenAI: hasOpenAI,
hasAnthropic: hasAnthropic)
@ -72,7 +72,7 @@ final class SessionTitleGenerator {
let result = try await generateText(
model: model,
messages: [.user(prompt)],
settings: GenerationSettings(maxTokens: 20, temperature: 0.3))
settings: self.generationSettings(for: model))
return self.validatedTitle(result.text)
} catch {
@ -80,13 +80,16 @@ final class SessionTitleGenerator {
}
}
private func selectModel(
static func selectModel(
providers: [String],
hasOpenAI: Bool,
hasAnthropic: Bool) -> LanguageModel
{
if providers.contains("anthropic/claude-fable-5"), hasAnthropic {
return .anthropic(.fable5)
}
if providers.contains(where: { $0 == "anthropic" || $0.hasPrefix("anthropic/") }), hasAnthropic {
return .anthropic(.opus47)
return .anthropic(.opus48)
}
if providers.contains(where: { $0 == "openai" || $0.hasPrefix("openai/") }), hasOpenAI {
return .openai(.gpt55)
@ -94,7 +97,16 @@ final class SessionTitleGenerator {
if providers.contains(where: { $0 == "ollama" || $0.hasPrefix("ollama/") }) {
return .ollama(.llama33)
}
return .anthropic(.opus47)
return .anthropic(.opus48)
}
private func generationSettings(for model: LanguageModel) -> GenerationSettings {
switch model {
case .anthropic(.fable5):
GenerationSettings(maxTokens: 256, reasoningEffort: .low)
default:
GenerationSettings(maxTokens: 20, temperature: 0.3)
}
}
private func buildPrompt(for task: String) -> String {

View File

@ -0,0 +1,27 @@
import Tachikoma
import Testing
@testable import Peekaboo
@Suite(.tags(.services, .unit))
@MainActor
struct SessionTitleGeneratorTests {
@Test
func `Explicit Fable title provider selects Fable`() {
let model = SessionTitleGenerator.selectModel(
providers: ["openai/gpt-5.5", "anthropic/claude-fable-5"],
hasOpenAI: false,
hasAnthropic: true)
#expect(model == .anthropic(.fable5))
}
@Test
func `Bare Anthropic title provider keeps Opus default`() {
let model = SessionTitleGenerator.selectModel(
providers: ["anthropic"],
hasOpenAI: false,
hasAnthropic: true)
#expect(model == .anthropic(.opus48))
}
}

View File

@ -2,6 +2,9 @@
## [3.4.2] - Unreleased
### Added
- `peekaboo agent` now supports explicit Claude Fable 5 (`claude-fable-5`) selection with 1M context and 128K max output while keeping Anthropic defaults on Opus 4.8 for zero-retention compatibility.
## [3.4.1] - 2026-06-10
### Fixed

View File

@ -4,22 +4,151 @@
//
import Foundation
import PeekabooAutomation
import Tachikoma
@available(macOS 14.0, *)
extension PeekabooAgentService {
func generationSettings(for model: LanguageModel) -> GenerationSettings {
switch model {
let maxTokens = self.configuredMaxTokens(for: model)
let temperature = self.shouldOmitTemperature(for: model) ? nil : self.configuredTemperature(for: model)
return switch model {
case .openai(.gpt55), .openai(.gpt54), .openai(.gpt54Mini), .openai(.gpt54Nano), .openai(.gpt5):
GenerationSettings(
maxTokens: 4096,
maxTokens: maxTokens,
temperature: temperature,
providerOptions: .init(openai: .init(verbosity: .medium)))
case .anthropic:
GenerationSettings(maxTokens: 4096)
GenerationSettings(maxTokens: maxTokens, temperature: temperature)
case .google:
GenerationSettings(maxTokens: 4096)
GenerationSettings(maxTokens: maxTokens, temperature: temperature)
default:
GenerationSettings(maxTokens: 4096)
GenerationSettings(maxTokens: maxTokens, temperature: temperature)
}
}
private func configuredMaxTokens(for model: LanguageModel) -> Int {
let configuredMaxTokens = self.services.configuration.getAgentMaxTokens()
let providerMaxTokens = self.maxOutputTokens(for: model)
return max(1, min(configuredMaxTokens, providerMaxTokens))
}
private func configuredTemperature(for model: LanguageModel) -> Double {
let configuredTemperature = self.services.configuration.getAgentTemperature()
guard configuredTemperature.isFinite else { return 0.7 }
let maxTemperature = switch model {
case .anthropic, .anthropicCompatible:
1.0
case let .custom(provider):
if self.customProviderUsesAnthropicTemperatureLimit(provider) {
1.0
} else {
2.0
}
default:
2.0
}
return min(maxTemperature, max(0.0, configuredTemperature))
}
private func customProviderUsesAnthropicTemperatureLimit(_ provider: any ModelProvider) -> Bool {
if provider is AnthropicProvider || provider is AnthropicCompatibleProvider {
return true
}
guard let parsed = ProviderParser.parse(provider.modelId) else {
return false
}
if CustomProviderRegistry.shared.get(parsed.provider)?.kind == .anthropic {
return true
}
return self.services.configuration.getCustomProvider(id: parsed.provider)?.type == .anthropic
}
private func shouldOmitTemperature(for model: LanguageModel) -> Bool {
switch model {
case let .openaiCompatible(modelId, _):
return self.isOpenAIGPT5TemperatureExcludedModel(modelId)
case let .custom(provider):
guard let parsed = ProviderParser.parse(provider.modelId) else {
return false
}
let isOpenAICompatible = CustomProviderRegistry.shared.get(parsed.provider)?.kind == .openai ||
self.services.configuration.getCustomProvider(id: parsed.provider)?.type == .openai
return isOpenAICompatible && self.isOpenAIGPT5TemperatureExcludedModel(parsed.model)
default:
return false
}
}
private func isOpenAIGPT5TemperatureExcludedModel(_ modelId: String) -> Bool {
switch self.normalizedOpenAIModelID(modelId) {
case "chat-latest",
"gpt-5.5",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.4-nano",
"gpt-5",
"gpt-5-pro",
"gpt-5-mini",
"gpt-5-nano":
true
default:
false
}
}
private func normalizedOpenAIModelID(_ modelId: String) -> String {
let normalized = modelId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard let parsed = ProviderParser.parse(normalized) else {
return normalized
}
switch parsed.provider.lowercased() {
case "openai", "chatgpt":
return self.normalizedOpenAIModelID(parsed.model)
default:
return normalized
}
}
private func maxOutputTokens(for model: LanguageModel) -> Int {
switch model {
case let .openai(openAIModel):
switch openAIModel {
case .chatLatest,
.gpt55,
.gpt54,
.gpt54Mini,
.gpt54Nano,
.gpt5,
.gpt5Pro,
.gpt5Mini,
.gpt5Nano:
128_000
case .gpt5ChatLatest:
16384
case .custom:
4096
}
case let .anthropic(anthropicModel):
anthropicModel.maxOutputTokens
case let .custom(provider):
provider.capabilities.maxOutputTokens
case .google:
8192
case .minimax, .minimaxCN:
8192
case .mistral, .groq, .grok, .ollama, .lmstudio, .azureOpenAI, .replicate:
4096
case let .openRouter(modelId), let .together(modelId), let .openaiCompatible(modelId, _):
AnthropicModelCapabilityInference.capabilities(for: modelId)?.maxOutputTokens ?? 4096
case let .anthropicCompatible(modelId, _):
AnthropicModelCapabilityInference.capabilities(for: modelId)?.maxOutputTokens ?? 8192
}
}
@ -49,6 +178,7 @@ extension PeekabooAgentService {
let eventTask = Task { @MainActor in
let delegate = unsafeDelegate.wrappedValue
delegate.agentDidEmitEvent(.started(task: input))
for await event in eventStream {
delegate.agentDidEmitEvent(event)
}
@ -58,33 +188,44 @@ extension PeekabooAgentService {
eventContinuation.yield(event)
}
defer {
eventContinuation.finish()
eventTask.cancel()
}
let streamingDelegate = await MainActor.run {
StreamingEventDelegate { chunk in
await eventHandler.send(.assistantMessage(content: chunk))
}
}
let sessionContext = try await self.prepareSession(
task: input,
model: self.defaultLanguageModel,
label: "audio-stream",
logBehavior: .always)
do {
let sessionContext = try await self.prepareSession(
task: input,
model: self.defaultLanguageModel,
label: "audio-stream",
logBehavior: .always)
let result = try await self.executeWithStreaming(
context: sessionContext,
model: self.defaultLanguageModel,
maxSteps: maxSteps,
streamingDelegate: streamingDelegate,
queueMode: queueMode,
eventHandler: eventHandler)
let result = if self.defaultLanguageModel.supportsStreaming {
try await self.executeWithStreaming(
context: sessionContext,
model: self.defaultLanguageModel,
maxSteps: maxSteps,
streamingDelegate: streamingDelegate,
queueMode: queueMode,
eventHandler: eventHandler)
} else {
try await self.executeWithoutStreaming(
context: sessionContext,
model: self.defaultLanguageModel,
maxSteps: maxSteps,
eventHandler: eventHandler)
}
await eventHandler.send(.completed(summary: result.content, usage: result.usage))
return result
await eventHandler.send(.completed(summary: result.content, usage: result.usage))
eventContinuation.finish()
await eventTask.value
return result
} catch {
eventContinuation.finish()
eventTask.cancel()
throw error
}
}
}
@ -180,6 +321,7 @@ extension PeekabooAgentService {
context: SessionContext,
model: LanguageModel,
maxSteps: Int = 20,
eventHandler: EventHandler? = nil,
enhancementOptions: AgentEnhancementOptions? = nil) async throws -> AgentExecutionResult
{
let tools = await self.buildToolset(for: model)
@ -189,7 +331,7 @@ extension PeekabooAgentService {
model: model,
tools: tools,
sessionId: context.id,
eventHandler: nil,
eventHandler: eventHandler,
enhancementOptions: enhancementOptions)
let outcome = try await self.runGenerationLoop(
@ -255,12 +397,22 @@ extension PeekabooAgentService {
}
let request = ProviderRequest(
messages: state.messages,
messages: state.messages.sanitizedForProviderContext(
model: configuration.model,
configuration: resolvedConfiguration,
peekabooConfiguration: self.services.configuration),
tools: configuration.tools.isEmpty ? nil : configuration.tools,
settings: self.generationSettings(for: configuration.model))
let response = try await provider.generateText(request: request)
if response.finishReason == .contentFilter {
throw TachikomaError.apiError("Model refused to answer")
}
state.content += response.text
if !response.text.isEmpty {
await configuration.eventHandler?.send(.assistantMessage(content: response.text))
}
if let usage = response.usage {
hasUsage = true
totalInputTokens += usage.inputTokens
@ -275,13 +427,21 @@ extension PeekabooAgentService {
state.usage = Usage(inputTokens: totalInputTokens, outputTokens: totalOutputTokens, cost: totalCost)
}
let recordedAssistantTurn = self.appendResponseHistory(
from: response,
model: configuration.model,
configuration: resolvedConfiguration,
peekabooConfiguration: self.services.configuration,
to: &state.messages)
let toolCalls = response.toolCalls ?? []
if toolCalls.isEmpty {
self.appendFinalStep(
text: response.text,
to: &state.messages,
steps: &state.steps,
stepIndex: stepIndex)
stepIndex: stepIndex,
appendMessage: !recordedAssistantTurn)
break
}
@ -290,7 +450,9 @@ extension PeekabooAgentService {
toolCalls: toolCalls,
context: toolContext,
currentMessages: &state.messages,
stepIndex: stepIndex)
stepIndex: stepIndex,
appendAssistantMessage: !recordedAssistantTurn,
emitToolStartEvents: true)
state.steps.append(step)
state.toolCallCount += step.toolResults.count

View File

@ -61,26 +61,38 @@ extension PeekabooAgentService {
eventContinuation.yield(event)
}
defer {
eventContinuation.finish()
eventTask.cancel()
}
let streamingDelegate = StreamingEventDelegate { chunk in
await eventHandler.send(.assistantMessage(content: chunk))
}
let result = try await self.executeWithStreaming(
context: sessionContext,
model: selectedModel,
maxSteps: maxSteps,
streamingDelegate: streamingDelegate,
queueMode: queueMode,
eventHandler: eventHandler,
enhancementOptions: enhancementOptions)
do {
let result = if selectedModel.supportsStreaming {
try await self.executeWithStreaming(
context: sessionContext,
model: selectedModel,
maxSteps: maxSteps,
streamingDelegate: streamingDelegate,
queueMode: queueMode,
eventHandler: eventHandler,
enhancementOptions: enhancementOptions)
} else {
try await self.executeWithoutStreaming(
context: sessionContext,
model: selectedModel,
maxSteps: maxSteps,
eventHandler: eventHandler,
enhancementOptions: enhancementOptions)
}
await eventHandler.send(.completed(summary: result.content, usage: result.usage))
return result
await eventHandler.send(.completed(summary: result.content, usage: result.usage))
eventContinuation.finish()
await eventTask.value
return result
} catch {
eventContinuation.finish()
eventTask.cancel()
throw error
}
} else {
return try await self.executeWithoutStreaming(
context: sessionContext,
@ -95,7 +107,8 @@ extension PeekabooAgentService {
sessionId: String,
model: LanguageModel? = nil,
maxSteps: Int = 20,
eventDelegate: (any AgentEventDelegate)? = nil) async throws -> AgentExecutionResult
eventDelegate: (any AgentEventDelegate)? = nil,
enhancementOptions: AgentEnhancementOptions? = .default) async throws -> AgentExecutionResult
{
let continuationPrompt = "Continue from where we left off."
return try await self.continueSession(
@ -105,7 +118,8 @@ extension PeekabooAgentService {
maxSteps: maxSteps,
dryRun: false,
eventDelegate: eventDelegate,
verbose: self.isVerbose)
verbose: self.isVerbose,
enhancementOptions: enhancementOptions)
}
// MARK: - Session Management

View File

@ -4,6 +4,7 @@
//
import Foundation
import PeekabooAutomation
import Tachikoma
@available(macOS 14.0, *)
@ -12,28 +13,40 @@ extension PeekabooAgentService {
let text: String
let toolCalls: [AgentToolCall]
let usage: Usage?
let finishReason: FinishReason?
let reasoningBlocks: [ReasoningBlock]
}
struct ReasoningBlock {
var text: String
let signature: String
let signature: String?
let type: String
}
private enum BufferedStreamEvent {
case event(AgentEvent)
}
func collectStreamOutput(
from streamResult: StreamTextResult,
model: LanguageModel,
eventHandler: EventHandler?,
stepIndex: Int) async throws -> StreamProcessingOutput
{
var stepText = ""
var reasoningBlocks: [ReasoningBlock] = []
var activeReasoningIndex: Int?
var lastSignedReasoningIndex: Int?
var pendingReasoningText = ""
var pendingReasoningType = "thinking"
var stepToolCalls: [AgentToolCall] = []
var seenToolCallIds = Set<String>()
var isThinking = false
var bufferedEvents: [BufferedStreamEvent] = []
let buffersAssistantTextUntilDone = self.buffersAgentTextStreamUntilDone(for: model)
var usage: Usage?
var finishReason: FinishReason?
var didReceiveDone = false
if self.isVerbose {
self.logger.debug("Starting to process stream for step \(stepIndex)")
@ -46,62 +59,141 @@ extension PeekabooAgentService {
switch delta.type {
case .textDelta:
self.flushPendingReasoningText(
&pendingReasoningText,
into: lastSignedReasoningIndex,
type: pendingReasoningType,
reasoningBlocks: &reasoningBlocks)
guard let content = delta.content else { continue }
await self.handleTextDelta(
content,
stepText: &stepText,
isThinking: &isThinking,
bufferedEvents: &bufferedEvents,
buffersAssistantTextUntilDone: buffersAssistantTextUntilDone,
eventHandler: eventHandler)
case .toolCall:
self.flushPendingReasoningText(
&pendingReasoningText,
into: lastSignedReasoningIndex,
type: pendingReasoningType,
reasoningBlocks: &reasoningBlocks)
if let toolCall = delta.toolCall {
try await self.handleToolCallDelta(
toolCall,
stepToolCalls: &stepToolCalls,
seenToolCallIds: &seenToolCallIds,
bufferedEvents: &bufferedEvents,
buffersEventsUntilDone: buffersAssistantTextUntilDone,
eventHandler: eventHandler)
}
case .reasoning:
let reasoningType = delta.reasoningType ?? "thinking"
if let signature = delta.reasoningSignature {
let signedText = pendingReasoningText + (delta.content ?? "")
reasoningBlocks.append(ReasoningBlock(
text: pendingReasoningText,
text: signedText,
signature: signature,
type: delta.reasoningType ?? "thinking"))
activeReasoningIndex = reasoningBlocks.count - 1
type: reasoningType))
activeReasoningIndex = signedText.isEmpty ? reasoningBlocks.count - 1 : nil
lastSignedReasoningIndex = reasoningBlocks.count - 1
pendingReasoningText = ""
pendingReasoningType = "thinking"
} else if reasoningType == "redacted_thinking", let content = delta.content {
reasoningBlocks.append(ReasoningBlock(
text: content,
signature: nil,
type: reasoningType))
activeReasoningIndex = nil
pendingReasoningText = ""
pendingReasoningType = "thinking"
await self.handleReasoningDelta(
nil,
bufferedEvents: &bufferedEvents,
buffersEventsUntilDone: buffersAssistantTextUntilDone,
eventHandler: eventHandler)
continue
}
if let content = delta.content {
if let activeReasoningIndex {
reasoningBlocks[activeReasoningIndex].text += content
if delta.reasoningSignature == nil, let content = delta.content {
if let activeIndex = activeReasoningIndex {
reasoningBlocks[activeIndex].text += content
activeReasoningIndex = nil
} else {
pendingReasoningType = reasoningType
pendingReasoningText += content
}
}
let displayContent = delta.content.flatMap { $0.isEmpty ? nil : $0 }
await self.handleReasoningDelta(displayContent, eventHandler: eventHandler)
await self.handleReasoningDelta(
displayContent,
bufferedEvents: &bufferedEvents,
buffersEventsUntilDone: buffersAssistantTextUntilDone,
eventHandler: eventHandler)
case .done:
didReceiveDone = true
self.flushPendingReasoningText(
&pendingReasoningText,
into: lastSignedReasoningIndex,
type: pendingReasoningType,
reasoningBlocks: &reasoningBlocks)
usage = delta.usage
finishReason = delta.finishReason
default:
break
}
}
if buffersAssistantTextUntilDone, !didReceiveDone {
throw TachikomaError.apiError("Provider stream ended without a terminal event")
}
if buffersAssistantTextUntilDone, finishReason != .contentFilter {
for event in bufferedEvents {
switch event {
case let .event(agentEvent):
await eventHandler?.send(agentEvent)
}
}
}
return StreamProcessingOutput(
text: stepText,
toolCalls: stepToolCalls,
usage: usage,
finishReason: finishReason,
reasoningBlocks: reasoningBlocks)
}
private func flushPendingReasoningText(
_ pendingReasoningText: inout String,
into reasoningIndex: Int?,
type: String,
reasoningBlocks: inout [ReasoningBlock])
{
guard !pendingReasoningText.isEmpty else { return }
if let reasoningIndex, reasoningBlocks.indices.contains(reasoningIndex) {
reasoningBlocks[reasoningIndex].text += pendingReasoningText
} else {
reasoningBlocks.append(ReasoningBlock(
text: pendingReasoningText,
signature: nil,
type: type))
}
pendingReasoningText = ""
}
private func handleTextDelta(
_ content: String,
stepText: inout String,
isThinking: inout Bool,
bufferedEvents: inout [BufferedStreamEvent],
buffersAssistantTextUntilDone: Bool,
eventHandler: EventHandler?) async
{
if self.isVerbose {
@ -109,19 +201,26 @@ extension PeekabooAgentService {
}
stepText += content
let trimmed = content.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
guard !trimmed.isEmpty, let eventHandler else { return }
if content.contains("<thinking>") || content.contains("Let me") ||
content.contains("I need to") || content.contains("I'll")
{
isThinking = true
}
let trimmed = content.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
guard !trimmed.isEmpty, let eventHandler else { return }
if isThinking {
await eventHandler.send(.thinkingMessage(content: content))
let event: AgentEvent = if isThinking {
.thinkingMessage(content: content)
} else {
await eventHandler.send(.assistantMessage(content: content))
.assistantMessage(content: content)
}
if buffersAssistantTextUntilDone {
bufferedEvents.append(.event(event))
} else {
await eventHandler.send(event)
}
}
@ -129,6 +228,8 @@ extension PeekabooAgentService {
_ toolCall: AgentToolCall,
stepToolCalls: inout [AgentToolCall],
seenToolCallIds: inout Set<String>,
bufferedEvents: inout [BufferedStreamEvent],
buffersEventsUntilDone: Bool,
eventHandler: EventHandler?) async throws
{
if self.isVerbose {
@ -148,15 +249,77 @@ extension PeekabooAgentService {
let argumentsData = try JSONEncoder().encode(toolCall.arguments)
let argumentsJSON = AgentToolCallArgumentPreview.redacted(from: argumentsData)
if isFirstOccurrence {
await eventHandler.send(.toolCallStarted(name: toolCall.name, arguments: argumentsJSON))
let event: AgentEvent = if isFirstOccurrence {
.toolCallStarted(name: toolCall.name, arguments: argumentsJSON)
} else {
await eventHandler.send(.toolCallUpdated(name: toolCall.name, arguments: argumentsJSON))
.toolCallUpdated(name: toolCall.name, arguments: argumentsJSON)
}
if buffersEventsUntilDone {
bufferedEvents.append(.event(event))
} else {
await eventHandler.send(event)
}
}
private func handleReasoningDelta(_ content: String?, eventHandler: EventHandler?) async {
private func handleReasoningDelta(
_ content: String?,
bufferedEvents: inout [BufferedStreamEvent],
buffersEventsUntilDone: Bool,
eventHandler: EventHandler?) async
{
guard let content, let eventHandler else { return }
await eventHandler.send(.thinkingMessage(content: content))
let event = AgentEvent.thinkingMessage(content: content)
if buffersEventsUntilDone {
bufferedEvents.append(.event(event))
} else {
await eventHandler.send(event)
}
}
}
@available(macOS 14.0, *)
extension PeekabooAgentService {
func buffersAgentTextStreamUntilDone(for model: LanguageModel) -> Bool {
switch model {
case .openai,
.openaiCompatible,
.openRouter,
.together,
.replicate,
.google,
.mistral,
.groq,
.grok,
.azureOpenAI:
return true
case let .anthropic(model):
return AnthropicModelCapabilityInference.hasStreamingRefusalRisk(modelId: model.modelId)
case let .anthropicCompatible(modelId, _):
return AnthropicModelCapabilityInference.hasStreamingRefusalRisk(modelId: modelId)
case let .custom(provider):
guard let parsed = ProviderParser.parse(provider.modelId) else {
return AnthropicModelCapabilityInference.hasStreamingRefusalRisk(modelId: provider.modelId)
}
if let registeredProvider = CustomProviderRegistry.shared.get(parsed.provider) {
switch registeredProvider.kind {
case .openai:
return true
case .anthropic:
return AnthropicModelCapabilityInference.hasStreamingRefusalRisk(modelId: parsed.model)
}
}
if let configuredProvider = self.services.configuration.getCustomProvider(id: parsed.provider) {
switch configuredProvider.type {
case .openai:
return true
case .anthropic:
return AnthropicModelCapabilityInference.hasStreamingRefusalRisk(modelId: parsed.model)
}
}
return AnthropicModelCapabilityInference.hasStreamingRefusalRisk(modelId: parsed.model)
default:
return false
}
}
}

View File

@ -3,7 +3,9 @@
// PeekabooCore
//
import CryptoKit
import Foundation
import PeekabooAutomation
import Tachikoma
@available(macOS 14.0, *)
@ -68,6 +70,7 @@ extension PeekabooAgentService {
pendingUserMessages: [ModelMessage] = []) async throws -> StreamingLoopOutcome
{
var state = StreamingLoopState(messages: initialMessages)
let resolvedConfiguration = TachikomaConfiguration.resolve(.current)
let toolContext = ToolHandlingContext(
model: configuration.model,
tools: configuration.tools,
@ -106,6 +109,7 @@ extension PeekabooAgentService {
let output = try await self.collectStreamOutput(
from: streamResult,
model: configuration.model,
eventHandler: configuration.eventHandler,
stepIndex: stepIndex)
@ -114,20 +118,32 @@ extension PeekabooAgentService {
state.usage = usage
}
if case .anthropic = configuration.model {
let shouldReplayReasoning = ReasoningReplayTarget(
model: configuration.model,
configuration: resolvedConfiguration,
peekabooConfiguration: self.services.configuration) != nil
if shouldReplayReasoning {
for block in output.reasoningBlocks {
state.messages.append(ModelMessage(
role: .assistant,
content: [.text(block.text)],
channel: .thinking,
metadata: .init(customData: [
"anthropic.thinking.signature": block.signature,
"anthropic.thinking.type": block.type,
])))
self.appendReasoningBlock(
ProviderReasoningBlock(
text: block.text,
signature: block.signature,
type: block.type),
model: configuration.model,
configuration: resolvedConfiguration,
peekabooConfiguration: self.services.configuration,
to: &state.messages)
}
}
if output.finishReason == .contentFilter {
throw TachikomaError.apiError("Model refused to answer")
}
if output.toolCalls.isEmpty {
if shouldReplayReasoning, output.text.isEmpty, !output.reasoningBlocks.isEmpty {
self.appendReasoningOnlyBoundary(to: &state.messages)
}
self.appendFinalStep(
text: output.text,
to: &state.messages,
@ -186,9 +202,10 @@ extension PeekabooAgentService {
text: String,
to messages: inout [ModelMessage],
steps: inout [GenerationStep],
stepIndex: Int)
stepIndex: Int,
appendMessage: Bool = true)
{
if !text.isEmpty {
if appendMessage, !text.isEmpty {
messages.append(ModelMessage.assistant(text))
}
@ -199,23 +216,247 @@ extension PeekabooAgentService {
toolResults: []))
}
func appendReasoningOnlyBoundary(to messages: inout [ModelMessage]) {
messages.append(ModelMessage(
role: .assistant,
content: [.text("")],
metadata: .init(customData: ["tachikoma.internal.boundary": "reasoning_only"])))
}
func appendAnthropicReasoningBlock(
text: String,
signature: String?,
type: String,
model: LanguageModel? = nil,
configuration: TachikomaConfiguration,
peekabooConfiguration: ConfigurationManager? = nil,
to messages: inout [ModelMessage])
{
var customData = ["anthropic.thinking.type": type]
if let model, let target = ReasoningReplayTarget(
model: model,
configuration: configuration,
peekabooConfiguration: peekabooConfiguration)
{
customData["anthropic.thinking.model"] = target.modelId
customData["tachikoma.reasoning.provider"] = target.provider
customData["tachikoma.reasoning.model"] = target.modelId
if let endpointIdentity = target.endpointIdentity {
customData["tachikoma.reasoning.base_url"] = endpointIdentity
}
}
if let signature, !signature.isEmpty {
customData["anthropic.thinking.signature"] = signature
}
messages.append(ModelMessage(
role: .assistant,
content: [.text(text)],
channel: .thinking,
metadata: .init(customData: customData)))
}
func appendReasoningBlock(
_ block: ProviderReasoningBlock,
model: LanguageModel,
configuration: TachikomaConfiguration,
peekabooConfiguration: ConfigurationManager? = nil,
to messages: inout [ModelMessage])
{
messages.append(ModelMessage(
role: .assistant,
content: [.text(block.text)],
channel: .thinking,
metadata: .init(customData: self.reasoningMetadata(
for: block,
model: model,
configuration: configuration,
peekabooConfiguration: peekabooConfiguration))))
}
func appendResponseHistory(
from response: ProviderResponse,
model: LanguageModel,
configuration: TachikomaConfiguration,
peekabooConfiguration: ConfigurationManager? = nil,
to messages: inout [ModelMessage])
-> Bool
{
let historyStart = messages.count
let nativeMessages = response.assistantMessages
messages.append(contentsOf: nativeMessages)
for block in response.reasoning where !nativeMessages.containsReasoningBlock(block) {
messages.append(ModelMessage(
role: .assistant,
content: [.text(block.text)],
channel: .thinking,
metadata: .init(customData: self.reasoningMetadata(
for: block,
model: model,
configuration: configuration,
peekabooConfiguration: peekabooConfiguration))))
}
let toolCalls = response.toolCalls ?? []
let missingToolCalls = toolCalls.filter { !nativeMessages.containsToolCall(id: $0.id) }
let isMissingText = !nativeMessages.containsAssistantText(response.text)
let addedHistory = messages[historyStart...]
let needsReasoningOnlyBoundary = !addedHistory.isEmpty &&
addedHistory.allSatisfy { $0.channel == .thinking } &&
response.text.isEmpty &&
missingToolCalls.isEmpty
guard isMissingText || !missingToolCalls.isEmpty || needsReasoningOnlyBoundary else {
return messages.count > historyStart
}
var fallbackContent: [ModelMessage.ContentPart] = []
if isMissingText || needsReasoningOnlyBoundary {
fallbackContent.append(.text(response.text))
}
fallbackContent.append(contentsOf: missingToolCalls.map { .toolCall($0) })
let metadata: MessageMetadata? = needsReasoningOnlyBoundary
? .init(customData: ["tachikoma.internal.boundary": "reasoning_only"])
: nil
messages.append(ModelMessage(role: .assistant, content: fallbackContent, metadata: metadata))
return true
}
private func reasoningMetadata(
for block: ProviderReasoningBlock,
model: LanguageModel,
configuration: TachikomaConfiguration,
peekabooConfiguration: ConfigurationManager? = nil)
-> [String: String]
{
if let rawJSON = block.rawJSON,
let openRouterMetadata = self.openRouterReasoningMetadata(
key: "openrouter.reasoning_details",
value: rawJSON,
type: block.type,
model: model,
configuration: configuration)
{
return openRouterMetadata
}
if block.type == "openrouter_reasoning",
let openRouterMetadata = self.openRouterReasoningMetadata(
key: "openrouter.reasoning",
value: block.text,
type: block.type,
model: model,
configuration: configuration)
{
return openRouterMetadata
}
var customData: [String: String] = if let target = ReasoningReplayTarget(
model: model,
configuration: configuration,
peekabooConfiguration: peekabooConfiguration)
{
{
var metadata = [
"anthropic.thinking.type": block.type,
"anthropic.thinking.model": target.modelId,
"tachikoma.reasoning.provider": target.provider,
"tachikoma.reasoning.model": target.modelId,
]
if let endpointIdentity = target.endpointIdentity {
metadata["tachikoma.reasoning.base_url"] = endpointIdentity
}
return metadata
}()
} else {
["tachikoma.reasoning.type": block.type]
}
if let signature = block.signature, !signature.isEmpty {
customData["anthropic.thinking.signature"] = signature
customData["tachikoma.reasoning.signature"] = signature
}
return customData
}
private func openRouterReasoningMetadata(
key: String,
value: String,
type: String,
model: LanguageModel,
configuration: TachikomaConfiguration)
-> [String: String]?
{
guard case let .openRouter(modelId) = model else { return nil }
let baseURL = configuration.getBaseURL(for: .custom("openrouter")) ?? "https://openrouter.ai/api/v1"
var metadata = [
key: value,
"tachikoma.reasoning.type": type,
"tachikoma.reasoning.provider": "openrouter",
"tachikoma.reasoning.model": modelId,
]
if let endpointIdentity = self.canonicalReasoningEndpointIdentity(baseURL) {
metadata["tachikoma.reasoning.base_url"] = endpointIdentity
}
return metadata
}
private func canonicalReasoningEndpointIdentity(_ rawValue: String?) -> String? {
guard
let trimmed = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty,
var components = URLComponents(string: trimmed),
let scheme = components.scheme?.lowercased(),
let host = components.host?.lowercased()
else {
return nil
}
components.scheme = scheme
components.host = host
components.user = nil
components.password = nil
components.fragment = nil
while components.path.count > 1, components.path.hasSuffix("/") {
components.path.removeLast()
}
guard let value = components.string, let data = value.data(using: .utf8) else { return nil }
let digest = SHA256.hash(data: data)
.map { String(format: "%02x", $0) }
.joined()
return "sha256:\(digest)"
}
func handleToolCalls(
stepText: String,
toolCalls: [AgentToolCall],
context: ToolHandlingContext,
currentMessages: inout [ModelMessage],
stepIndex: Int) async throws -> GenerationStep
stepIndex: Int,
appendAssistantMessage: Bool = true,
emitToolStartEvents: Bool = false) async throws -> GenerationStep
{
self.appendAssistantMessage(
stepText: stepText,
toolCalls: toolCalls,
to: &currentMessages)
if appendAssistantMessage {
self.appendAssistantMessage(
stepText: stepText,
toolCalls: toolCalls,
to: &currentMessages)
}
var toolResults: [AgentToolResult] = []
for (index, toolCall) in toolCalls.enumerated() {
if emitToolStartEvents {
try await self.sendToolStartEvent(toolCall, eventHandler: context.eventHandler)
}
guard let tool = context.tool(named: toolCall.name) else {
let unavailableResult = self.makeUnavailableToolResult(for: toolCall)
await self.sendToolCompletionEvent(
name: toolCall.name,
payload: self.toolResultPayload(from: unavailableResult.result, toolName: toolCall.name),
eventHandler: context.eventHandler)
currentMessages.append(ModelMessage(role: .tool, content: [.toolResult(unavailableResult)]))
toolResults.append(unavailableResult)
continue
@ -300,7 +541,7 @@ extension PeekabooAgentService {
do {
let executionContext = ToolExecutionContext(
messages: currentMessages,
messages: currentMessages.sanitizedForToolContext(),
model: context.model,
settings: self.generationSettings(for: context.model),
sessionId: context.sessionId,
@ -461,6 +702,13 @@ extension PeekabooAgentService {
await eventHandler.send(.toolCallCompleted(name: name, result: payload))
}
private func sendToolStartEvent(_ toolCall: AgentToolCall, eventHandler: EventHandler?) async throws {
guard let eventHandler else { return }
let argumentsData = try JSONEncoder().encode(toolCall.arguments)
let argumentsJSON = AgentToolCallArgumentPreview.redacted(from: argumentsData)
await eventHandler.send(.toolCallStarted(name: toolCall.name, arguments: argumentsJSON))
}
private func toolResultPayload(from result: AnyAgentToolValue, toolName: String) -> String {
do {
let jsonObject = try result.toJSON()
@ -504,3 +752,236 @@ extension PeekabooAgentService {
return json
}
}
@available(macOS 14.0, *)
extension [ModelMessage] {
func sanitizedForProviderContext(
model: LanguageModel,
configuration: TachikomaConfiguration,
peekabooConfiguration: ConfigurationManager? = nil)
-> [ModelMessage]
{
let target = ReasoningReplayTarget(
model: model,
configuration: configuration,
peekabooConfiguration: peekabooConfiguration)
var previousSourceWasRetainedThinking = false
var sanitizedMessages: [ModelMessage] = []
sanitizedMessages.reserveCapacity(self.count)
for message in self {
guard message.channel == .thinking ||
message.metadata?.customData?["tachikoma.internal.boundary"] == "reasoning_only"
else {
sanitizedMessages.append(message)
previousSourceWasRetainedThinking = false
continue
}
guard message.channel == .thinking else {
if target?.allowsReasoningBoundaries == true, previousSourceWasRetainedThinking {
sanitizedMessages.append(message)
}
previousSourceWasRetainedThinking = false
continue
}
guard let target else {
previousSourceWasRetainedThinking = false
continue
}
let customData = message.metadata?.customData ?? [:]
let shouldKeep = target.matches(customData) ||
(target.allowsLegacyUnknown &&
customData["anthropic.thinking.model"] == nil &&
customData["anthropic.thinking.type"] != nil)
if shouldKeep {
sanitizedMessages.append(message)
}
previousSourceWasRetainedThinking = shouldKeep
}
return sanitizedMessages
}
fileprivate func sanitizedForToolContext() -> [ModelMessage] {
self.filter { message in
message.channel != .thinking &&
message.metadata?.customData?["tachikoma.internal.boundary"] != "reasoning_only"
}
}
fileprivate func containsAssistantText(_ text: String) -> Bool {
guard !text.isEmpty else { return true }
let assistantTexts = self.flatMap { message -> [String] in
guard message.role == .assistant, message.channel != .thinking else {
return []
}
return message.content.compactMap { part in
if case let .text(value) = part {
return value
}
return nil
}
}
return assistantTexts.contains(text) || assistantTexts.joined() == text
}
fileprivate func containsReasoningBlock(_ reasoning: ProviderReasoningBlock) -> Bool {
self.contains { message in
message.role == .assistant && message.channel == .thinking && message.content.contains { part in
guard case let .text(value) = part else { return false }
if let signature = reasoning.signature, !signature.isEmpty {
return message.metadata?.customData?["anthropic.thinking.signature"] == signature ||
message.metadata?.customData?["tachikoma.reasoning.signature"] == signature
}
return value == reasoning.text
}
}
}
fileprivate func containsToolCall(id: String) -> Bool {
self.contains { message in
message.role == .assistant && message.content.contains { part in
if case let .toolCall(toolCall) = part {
return toolCall.id == id
}
return false
}
}
}
}
@available(macOS 14.0, *)
private struct ReasoningReplayTarget {
let provider: String
let modelId: String
let baseURL: String?
let allowsReasoningBoundaries: Bool
let allowsLegacyUnknown: Bool
init?(
model: LanguageModel,
configuration: TachikomaConfiguration,
peekabooConfiguration: ConfigurationManager? = nil)
{
switch model {
case let .anthropic(anthropicModel):
self.provider = "anthropic"
self.modelId = anthropicModel.modelId
self.baseURL = configuration.getBaseURL(for: .anthropic) ?? Provider.anthropic.defaultBaseURL
self.allowsReasoningBoundaries = true
self.allowsLegacyUnknown = !LanguageModel.Anthropic.isFable(modelId: anthropicModel.modelId)
case let .anthropicCompatible(modelId, baseURL):
self.provider = "anthropic-compatible"
self.modelId = modelId
self.baseURL = baseURL
self.allowsReasoningBoundaries = true
self.allowsLegacyUnknown = !LanguageModel.Anthropic.isFable(modelId: modelId)
case let .openRouter(modelId):
self.provider = "openrouter"
self.modelId = modelId
self.baseURL = configuration.getBaseURL(for: .custom("openrouter")) ?? "https://openrouter.ai/api/v1"
self.allowsReasoningBoundaries = true
self.allowsLegacyUnknown = false
case let .minimax(model):
self.provider = "minimax"
self.modelId = model.modelId
self.baseURL = configuration.getBaseURL(for: .minimax) ?? Provider.minimax.defaultBaseURL
self.allowsReasoningBoundaries = true
self.allowsLegacyUnknown = true
case let .minimaxCN(model):
self.provider = "minimax-cn"
self.modelId = model.modelId
self.baseURL = configuration.getBaseURL(for: .minimaxCN) ?? Provider.minimaxCN.defaultBaseURL
self.allowsReasoningBoundaries = true
self.allowsLegacyUnknown = true
case let .custom(provider):
if let anthropicProvider = provider as? AnthropicProvider {
self.provider = "anthropic"
self.modelId = anthropicProvider.modelId
self.baseURL = anthropicProvider.baseURL ?? Provider.anthropic.defaultBaseURL
self.allowsReasoningBoundaries = true
self.allowsLegacyUnknown = !LanguageModel.Anthropic.isFable(modelId: anthropicProvider.modelId)
return
}
if let compatibleProvider = provider as? AnthropicCompatibleProvider {
self.provider = "anthropic-compatible"
self.modelId = compatibleProvider.modelId
self.baseURL = compatibleProvider.baseURL
self.allowsReasoningBoundaries = true
self.allowsLegacyUnknown = !LanguageModel.Anthropic.isFable(modelId: compatibleProvider.modelId)
return
}
guard let parsed = ProviderParser.parse(provider.modelId) else {
return nil
}
if let registeredProvider = CustomProviderRegistry.shared.get(parsed.provider),
registeredProvider.kind == .anthropic
{
self.provider = "custom-anthropic"
self.modelId = parsed.model
self.baseURL = registeredProvider.baseURL
self.allowsReasoningBoundaries = true
self.allowsLegacyUnknown = !LanguageModel.Anthropic.isFable(modelId: parsed.model)
return
}
guard Self.isAnthropicCustomProvider(
providerID: parsed.provider,
peekabooConfiguration: peekabooConfiguration)
else {
return nil
}
self.provider = "custom-anthropic"
self.modelId = provider.modelId
self.baseURL = provider.baseURL
self.allowsReasoningBoundaries = true
self.allowsLegacyUnknown = !LanguageModel.Anthropic.isFable(modelId: provider.modelId)
default:
return nil
}
}
private static func isAnthropicCustomProvider(
providerID: String,
peekabooConfiguration: ConfigurationManager?)
-> Bool
{
if CustomProviderRegistry.shared.get(providerID)?.kind == .anthropic {
return true
}
return peekabooConfiguration?.getCustomProvider(id: providerID)?.type == .anthropic
}
func matches(_ customData: [String: String]) -> Bool {
customData["tachikoma.reasoning.provider"] == self.provider &&
customData["tachikoma.reasoning.model"] == self.modelId &&
customData["tachikoma.reasoning.base_url"] == self.endpointIdentity
}
var endpointIdentity: String? {
guard
let trimmed = self.baseURL?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty,
var components = URLComponents(string: trimmed),
let scheme = components.scheme?.lowercased(),
let host = components.host?.lowercased()
else {
return nil
}
components.scheme = scheme
components.host = host
components.user = nil
components.password = nil
components.fragment = nil
while components.path.count > 1, components.path.hasSuffix("/") {
components.path.removeLast()
}
guard let value = components.string, let data = value.data(using: .utf8) else { return nil }
let digest = SHA256.hash(data: data)
.map { String(format: "%02x", $0) }
.joined()
return "sha256:\(digest)"
}
}

View File

@ -263,11 +263,11 @@ public final class PeekabooAgentService: AgentServiceProtocol {
endTime: Date()))
}
// If we have an event delegate, use streaming
if eventDelegate != nil {
// If we have an event delegate, emit events even for non-streaming models.
if let eventDelegate {
// SAFETY: We ensure that the delegate is only accessed on MainActor
// This is a legacy API pattern that predates Swift's strict concurrency
let unsafeDelegate = UnsafeTransfer<any AgentEventDelegate>(eventDelegate!)
let unsafeDelegate = UnsafeTransfer<any AgentEventDelegate>(eventDelegate)
// Create event stream infrastructure
let (eventStream, eventContinuation) = AsyncStream<AgentEvent>.makeStream()
@ -289,35 +289,46 @@ public final class PeekabooAgentService: AgentServiceProtocol {
eventContinuation.yield(event)
}
defer {
eventContinuation.finish()
eventTask.cancel()
}
// Create event delegate wrapper for streaming
let streamingDelegate = StreamingEventDelegate { chunk in
await eventHandler.send(.assistantMessage(content: chunk))
}
let sessionContext = try await self.prepareSession(
task: task,
model: selectedModel,
label: "streaming",
logBehavior: .always)
do {
let sessionContext = try await self.prepareSession(
task: task,
model: selectedModel,
label: "streaming",
logBehavior: .always)
let result = try await self.executeWithStreaming(
context: sessionContext,
model: selectedModel,
maxSteps: maxSteps,
streamingDelegate: streamingDelegate,
queueMode: queueMode,
eventHandler: eventHandler,
enhancementOptions: enhancementOptions)
let result = if selectedModel.supportsStreaming {
try await self.executeWithStreaming(
context: sessionContext,
model: selectedModel,
maxSteps: maxSteps,
streamingDelegate: streamingDelegate,
queueMode: queueMode,
eventHandler: eventHandler,
enhancementOptions: enhancementOptions)
} else {
try await self.executeWithoutStreaming(
context: sessionContext,
model: selectedModel,
maxSteps: maxSteps,
eventHandler: eventHandler,
enhancementOptions: enhancementOptions)
}
// Send completion event with usage information
await eventHandler.send(.completed(summary: result.content, usage: result.usage))
return result
// Send completion event with usage information
await eventHandler.send(.completed(summary: result.content, usage: result.usage))
eventContinuation.finish()
await eventTask.value
return result
} catch {
eventContinuation.finish()
eventTask.cancel()
throw error
}
} else {
// Non-streaming execution
let sessionContext = try await self.prepareSession(
@ -342,6 +353,20 @@ public final class PeekabooAgentService: AgentServiceProtocol {
{
// Execute a task with streaming output
let selectedModel = self.resolveModel(model)
if !selectedModel.supportsStreaming {
let sessionContext = try await self.prepareSession(
task: task,
model: selectedModel,
label: "(non-streaming)",
logBehavior: .verboseOnly)
let result = try await self.executeWithoutStreaming(
context: sessionContext,
model: selectedModel,
maxSteps: 20)
await streamHandler(result.content)
return result
}
// For streaming without event handler, create a dummy delegate that discards chunks
let dummyDelegate = StreamingEventDelegate { _ in /* discard */ }
let sessionContext = try await self.prepareSession(

View File

@ -32,7 +32,7 @@ public struct AnalyzeTool: MCPTool {
{ "image_path": "/tmp/chart.png", "question": "Which category has the highest value in this bar chart?" }
The AI will analyze the image and attempt to answer your question based on its visual content.
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
"""
}

View File

@ -17,7 +17,7 @@ public struct ClickTool: MCPTool {
Clicks on UI elements or coordinates.
Supports element queries, specific IDs from `see` or `inspect_ui`, or raw coordinates.
Background delivery is the default. Set `foreground` to true when the next step needs keyboard focus.
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
"""
}

View File

@ -18,7 +18,7 @@ public struct DockTool: MCPTool {
Actions: launch, right-click (with menu selection), hide, show, list
Can list all dock items including persistent and running applications.
\(PeekabooMCPVersion.banner) using openai/gpt-5.5
and anthropic/claude-opus-4-7
and anthropic/claude-opus-4-8
"""
}

View File

@ -16,7 +16,7 @@ public struct DragTool: MCPTool {
Perform drag and drop operations between UI elements or coordinates.
Supports element queries, specific IDs, or raw coordinates for both start and end points.
Includes focus options for handling windows in different spaces.
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
"""
}

View File

@ -16,7 +16,7 @@ public struct HotkeyTool: MCPTool {
Presses keyboard shortcuts.
Simulates one primary key plus optional modifiers, like Cmd+C or Ctrl+Shift+T.
If app/pid/window targeting is supplied, sends the hotkey to that process in the background by default.
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
"""
}

View File

@ -21,7 +21,7 @@ public struct ListTool: MCPTool {
- { "item_type": "running_applications" }
- { "item_type": "application_windows", "app": "Notes", "include_window_details": ["ids", "bounds"] }
- { "item_type": "server_status" }
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
"""
public var inputSchema: Value {

View File

@ -27,7 +27,7 @@ public struct MenuTool: MCPTool {
- Save document: { "action": "click", "app": "TextEdit", "path": "File > Save" }
- Copy selection: { "action": "click", "app": "Safari", "path": "Edit > Copy" }
\(PeekabooMCPVersion.banner) using openai/gpt-5.5
and anthropic/claude-opus-4-7
and anthropic/claude-opus-4-8
"""
}

View File

@ -16,7 +16,7 @@ public struct MoveTool: MCPTool {
Move the mouse cursor to a specific position or UI element.
Supports absolute coordinates, UI element targeting, or centering on screen.
Can animate movement smoothly over a specified duration.
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
"""
}

View File

@ -15,7 +15,7 @@ public struct PerformActionTool: MCPTool {
"""
Invokes a named accessibility action on an element, such as AXPress or AXShowMenu.
Use with element IDs from `see` or `inspect_ui` when a semantic action is available.
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
"""
}

View File

@ -12,7 +12,7 @@ public struct PermissionsTool: MCPTool {
Check macOS system permissions required for automation.
Verifies both Screen Recording and Accessibility permissions.
Returns the current permission status for each required permission.
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
"""
public var inputSchema: Value {

View File

@ -20,7 +20,7 @@ public struct ScrollTool: MCPTool {
Can target specific elements or scroll at current mouse position.
Supports smooth scrolling and configurable speed.
\(PeekabooMCPVersion.banner) using openai/gpt-5.5
and anthropic/claude-opus-4-7
and anthropic/claude-opus-4-8
"""
}

View File

@ -24,7 +24,7 @@ public struct SeeTool: MCPTool {
Returns Peekaboo element IDs (B1 for buttons, T1 for text fields, etc.) that can be
used with interaction commands and creates/updates a snapshot that tracks UI state.
\(PeekabooMCPVersion.banner) using openai/gpt-5.5
and anthropic/claude-opus-4-7.
and anthropic/claude-opus-4-8.
"""
}

View File

@ -15,7 +15,7 @@ public struct SetValueTool: MCPTool {
"""
Sets an accessibility element value directly without synthesizing keystrokes.
Use for forms and controls after `see` or `inspect_ui` returns an element ID. Requires a settable AX value.
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
"""
}

View File

@ -9,7 +9,7 @@ public struct SleepTool: MCPTool {
public let description = """
Pauses execution for a specified duration.
Useful for waiting between UI actions or allowing animations to complete.
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
"""
public var inputSchema: Value {

View File

@ -49,7 +49,7 @@ public struct SpaceTool: MCPTool {
- Move window to space 3: { "action": "move-window", "app": "Safari", "to": 3 }
- Move window to current space: { "action": "move-window", "app": "TextEdit", "to_current": true }
- Move and follow: { "action": "move-window", "app": "Terminal", "to": 2, "follow": true }
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
"""
}

View File

@ -16,7 +16,7 @@ public struct SwipeTool: MCPTool {
Performs a swipe/drag gesture from one point to another.
Useful for dragging elements, swiping through content, or gesture-based interactions.
Creates smooth movement with configurable duration.
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
"""
}

View File

@ -19,7 +19,7 @@ public struct TypeTool: MCPTool {
Can target specific elements or type at current keyboard focus. Uses background delivery by default when a
target process is known; set `foreground` when the app must be focused first.
\(PeekabooMCPVersion.banner) using openai/gpt-5.5
and anthropic/claude-opus-4-7
and anthropic/claude-opus-4-8
"""
}

View File

@ -33,7 +33,7 @@ public struct WindowTool: MCPTool {
- { "action": "move", "app": "TextEdit", "x": 100, "y": 100 }
- { "action": "set-bounds", "app": "Terminal", "x": 0, "y": 0, "width": 1280, "height": 720 }
- { "action": "close", "app": "Safari", "title": "Grindr Web" }
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-7
\(PeekabooMCPVersion.banner) using openai/gpt-5.5, anthropic/claude-opus-4-8
"""
}

View File

@ -48,7 +48,7 @@ private enum ConfigurationDefaults {
static let configurationTemplate = """
{
"aiProviders": {
"providers": "openai/gpt-5.5,anthropic/claude-opus-4-8"
"providers": "openai/gpt-5.5,anthropic/claude-opus-4-8"
},
"defaults": {
"savePath": "~/Desktop/Screenshots",

View File

@ -3,6 +3,47 @@ import Foundation
import ImageIO
import Tachikoma
public struct InferredAnthropicModelCapabilities: Sendable {
public let contextLength: Int
public let maxOutputTokens: Int
public let supportsStreaming: Bool
}
public enum AnthropicModelCapabilityInference {
public static func capabilities(for modelId: String) -> InferredAnthropicModelCapabilities? {
guard let model = self.anthropicModel(for: modelId) else { return nil }
return InferredAnthropicModelCapabilities(
contextLength: model.contextLength,
maxOutputTokens: model.maxOutputTokens,
supportsStreaming: model.supportsStreaming)
}
public static func hasStreamingRefusalRisk(modelId: String) -> Bool {
if let capabilities = self.capabilities(for: modelId) {
return !capabilities.supportsStreaming
}
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId)
}
private static func anthropicModel(for modelId: String) -> LanguageModel.Anthropic? {
let trimmed = modelId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let parsedModel = ProviderParser.parse(trimmed)?.model
let candidates = [trimmed, parsedModel].compactMap(\.self)
for candidate in candidates {
guard case let .anthropic(model) = LanguageModel.parse(from: "anthropic/\(candidate)") else {
continue
}
if case .custom = model {
continue
}
return model
}
return nil
}
}
private final class PeekabooCustomProviderModel: ModelProvider, @unchecked Sendable {
enum Kind {
case openai
@ -26,7 +67,8 @@ private final class PeekabooCustomProviderModel: ModelProvider, @unchecked Senda
apiKey: String?,
additionalHeaders: [String: String],
supportsVision: Bool,
supportsTools: Bool)
supportsTools: Bool,
maxOutputTokens: Int?)
{
self.providerID = providerID
self.resolvedModelID = resolvedModelID
@ -35,10 +77,18 @@ private final class PeekabooCustomProviderModel: ModelProvider, @unchecked Senda
self.baseURL = baseURL
self.apiKey = apiKey
self.additionalHeaders = additionalHeaders
let inferredAnthropicCapabilities = kind == .anthropic
? AnthropicModelCapabilityInference.capabilities(for: resolvedModelID)
: nil
let inferredMaxOutputTokens = inferredAnthropicCapabilities?.maxOutputTokens ?? 4096
let hasStreamingRefusalRisk = inferredAnthropicCapabilities.map { !$0.supportsStreaming } ??
LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: resolvedModelID)
self.capabilities = ModelCapabilities(
supportsVision: supportsVision,
supportsTools: supportsTools,
supportsStreaming: true)
supportsStreaming: !hasStreamingRefusalRisk,
contextLength: inferredAnthropicCapabilities?.contextLength ?? 128_000,
maxOutputTokens: maxOutputTokens ?? inferredMaxOutputTokens)
}
func generateText(request: ProviderRequest) async throws -> ProviderResponse {
@ -95,7 +145,10 @@ private final class PeekabooCustomProviderModel: ModelProvider, @unchecked Senda
modelId: self.resolvedModelID,
baseURL: self.baseURL ?? "",
configuration: self.compatibleConfiguration(),
additionalHeaders: self.additionalHeaders)
additionalHeaders: self.additionalHeaders,
reasoningProvider: "custom-anthropic",
reasoningModelId: self.modelId,
reasoningBaseURL: self.baseURL)
}
}
@ -595,7 +648,8 @@ public final class PeekabooAIService {
apiKey: configuration.resolveCredentialReference(provider.options.apiKey),
additionalHeaders: provider.options.headers ?? [:],
supportsVision: model?.supportsVision ?? true,
supportsTools: model?.supportsTools ?? true)
supportsTools: model?.supportsTools ?? true,
maxOutputTokens: model?.maxTokens)
}
private static func customProviderID(matching providerID: String, configuration: ConfigurationManager) -> String? {

View File

@ -32,12 +32,14 @@ struct AnthropicModelTests {
@Test
func `Anthropic default model selection`() {
// Test that Claude Opus is the default
// Tachikoma keeps the library-level Claude shortcut stable; Peekaboo chooses Fable separately.
let defaultModel = Model.default
let claudeModel = Model.claude
#expect(defaultModel.providerName == "Anthropic")
#expect(claudeModel.providerName == "Anthropic")
#expect(defaultModel.modelId == "claude-opus-4-8")
#expect(claudeModel.modelId == "claude-opus-4-8")
// Test model shortcuts
let anthropicModels = [
@ -80,6 +82,7 @@ struct AnthropicModelTests {
@Test
func `Anthropic vision model capabilities`() {
let visionCapableModels = [
Model.anthropic(.fable5),
Model.anthropic(.opus45),
Model.anthropic(.sonnet46),
Model.anthropic(.haiku45),
@ -107,6 +110,7 @@ struct AnthropicModelTests {
#expect(opus45.modelId != haiku45.modelId)
// Current Anthropic context windows are model-specific, not a simple family hierarchy.
#expect(Model.anthropic(.fable5).contextLength == 1_000_000)
#expect(Model.anthropic(.opus48).contextLength == 1_000_000)
#expect(sonnet46.contextLength == 1_000_000)
#expect(opus45.contextLength == 500_000)
@ -116,15 +120,20 @@ struct AnthropicModelTests {
@Test
func `Anthropic current models support tools`() {
let fable5 = Model.anthropic(.fable5)
let opus48 = Model.anthropic(.opus48)
let sonnet46 = Model.anthropic(.sonnet46)
#expect(fable5.providerName == "Anthropic")
#expect(opus48.providerName == "Anthropic")
#expect(sonnet46.providerName == "Anthropic")
#expect(!fable5.modelId.contains("thinking"))
#expect(!opus48.modelId.contains("thinking"))
#expect(!sonnet46.modelId.contains("thinking"))
#expect(fable5.supportsTools == true)
#expect(fable5.supportsStreaming == false)
#expect(opus48.supportsTools == true)
#expect(sonnet46.supportsTools == true)
}

View File

@ -3,6 +3,7 @@ import Tachikoma
import Testing
@testable import PeekabooAutomation
@Suite(.serialized)
struct PeekabooAIServiceProviderTests {
@Test
@MainActor
@ -123,6 +124,119 @@ struct PeekabooAIServiceProviderTests {
}
}
@Test
@MainActor
func `Custom provider advertises configured model max tokens`() throws {
try self.withIsolatedEnvironment(
["PEEKABOO_CUSTOM_PROVIDER_KEY": "resolved-secret"],
configurationJSON: """
{
"aiProviders": { "providers": "local-proxy/large" },
"customProviders": {
"local-proxy": {
"name": "Local Proxy",
"type": "openai",
"enabled": true,
"options": {
"baseURL": "http://localhost:8317/v1",
"apiKey": "${PEEKABOO_CUSTOM_PROVIDER_KEY}"
},
"models": {
"large": {
"name": "Large Local Model",
"supportsVision": true,
"supportsTools": true,
"maxTokens": 8192
}
}
}
}
}
""") {
let service = PeekabooAIService()
let model = try #require(service.availableModels().first)
let provider = try service.tachikomaConfiguration(for: model).makeProvider(for: model)
#expect(provider.capabilities.maxOutputTokens == 8192)
}
}
@Test
@MainActor
func `Custom Opus 4 8 provider advertises nonstreaming capability`() throws {
try self.withIsolatedEnvironment(
["PEEKABOO_CUSTOM_PROVIDER_KEY": "resolved-secret"],
configurationJSON: """
{
"aiProviders": { "providers": "local-anthropic/claude-opus-4-8" },
"customProviders": {
"local-anthropic": {
"name": "Local Anthropic",
"type": "anthropic",
"enabled": true,
"options": {
"baseURL": "http://localhost:8317",
"apiKey": "${PEEKABOO_CUSTOM_PROVIDER_KEY}"
},
"models": {
"claude-opus-4-8": {
"name": "Claude Opus 4.8",
"supportsTools": true
}
}
}
}
}
""") {
let service = PeekabooAIService()
let model = try #require(service.availableModels().first)
let provider = try service.tachikomaConfiguration(for: model).makeProvider(for: model)
#expect(model.modelId == "local-anthropic/claude-opus-4-8")
#expect(!provider.capabilities.supportsStreaming)
#expect(provider.capabilities.contextLength == 1_000_000)
#expect(provider.capabilities.maxOutputTokens == 128_000)
}
}
@Test
@MainActor
func `Custom Opus 4 7 provider advertises inferred current Claude capabilities`() throws {
try self.withIsolatedEnvironment(
["PEEKABOO_CUSTOM_PROVIDER_KEY": "resolved-secret"],
configurationJSON: """
{
"aiProviders": { "providers": "local-anthropic/claude-opus-4-7" },
"customProviders": {
"local-anthropic": {
"name": "Local Anthropic",
"type": "anthropic",
"enabled": true,
"options": {
"baseURL": "http://localhost:8317",
"apiKey": "${PEEKABOO_CUSTOM_PROVIDER_KEY}"
},
"models": {
"claude-opus-4-7": {
"name": "Claude Opus 4.7",
"supportsTools": true
}
}
}
}
}
""") {
let service = PeekabooAIService()
let model = try #require(service.availableModels().first)
let provider = try service.tachikomaConfiguration(for: model).makeProvider(for: model)
#expect(model.modelId == "local-anthropic/claude-opus-4-7")
#expect(provider.capabilities.supportsStreaming)
#expect(provider.capabilities.contextLength == 1_000_000)
#expect(provider.capabilities.maxOutputTokens == 128_000)
}
}
@Test
@MainActor
func `Saved custom provider models become default candidates`() throws {
@ -607,6 +721,24 @@ struct PeekabooAIServiceProviderTests {
}
}
@Test
@MainActor
func `Explicit Fable provider list does not fall back to unrelated credentials`() throws {
try self.withIsolatedEnvironment(
["GEMINI_API_KEY": "key"],
configurationJSON: """
{
"aiProviders": {
"providers": "openai/gpt-5.5,anthropic/claude-fable-5"
}
}
""") {
let service = PeekabooAIService()
#expect(service.resolvedDefaultModel == .openai(.gpt55))
#expect(service.availableModels() == [.openai(.gpt55), .anthropic(.fable5)])
}
}
@Test
@MainActor
func `Explicit Grok provider list preserves server-redirected model slug`() throws {

View File

@ -0,0 +1,200 @@
import Foundation
import Tachikoma
import Testing
@testable import PeekabooAgentRuntime
@testable import PeekabooAutomation
@testable import PeekabooCore
@Suite(.serialized)
struct PeekabooAgentGenerationSettingsTests {
@Test
@MainActor
func `Configured custom Anthropic provider clamps temperature to Anthropic range`() throws {
try self.withTemporaryConfig(
"""
{
"aiProviders": { "providers": "local-anthropic/claude-opus-4-7" },
"agent": { "temperature": 1.8, "maxTokens": 128000 },
"customProviders": {
"local-anthropic": {
"name": "Local Anthropic",
"type": "anthropic",
"enabled": true,
"options": {
"baseURL": "https://anthropic-compatible.example",
"apiKey": "test-key"
},
"models": {
"claude-opus-4-7": {
"name": "Claude Opus 4.7",
"supportsTools": true
}
}
}
}
}
""") {
let configuration = ConfigurationManager.shared
_ = configuration.loadConfiguration()
let aiService = PeekabooAIService(configuration: configuration)
let model = try #require(aiService.availableModels().first)
let agentService = try PeekabooAgentService(services: PeekabooServices())
#expect(model.modelId == "local-anthropic/claude-opus-4-7")
#expect(agentService.generationSettings(for: model).temperature == 1.0)
#expect(agentService.generationSettings(for: model).maxTokens == 128_000)
}
}
@Test
@MainActor
func `Anthropic compatible current Claude models clamp max tokens to inferred provider caps`() throws {
try self.withTemporaryConfig(
"""
{
"agent": { "maxTokens": 128000 }
}
""") {
let agentService = try PeekabooAgentService(services: PeekabooServices())
#expect(agentService.generationSettings(for: .anthropicCompatible(
modelId: "claude-opus-4-7",
baseURL: "https://anthropic-compatible.example")).maxTokens == 128_000)
#expect(agentService.generationSettings(for: .anthropicCompatible(
modelId: "claude-sonnet-4-6",
baseURL: "https://anthropic-compatible.example")).maxTokens == 64000)
}
}
@Test
@MainActor
func `OpenAI compatible GPT 5 provider-qualified model omits unsupported temperature`() throws {
try self.withTemporaryConfig(
"""
{
"agent": { "temperature": 0.7 }
}
""") {
let agentService = try PeekabooAgentService(services: PeekabooServices())
let settings = agentService.generationSettings(for: .openaiCompatible(
modelId: "openai/gpt-5.5",
baseURL: "https://openai-compatible.example"))
#expect(settings.temperature == nil)
}
}
@Test
@MainActor
func `Configured custom OpenAI GPT 5 provider omits unsupported temperature`() throws {
try self.withTemporaryConfig(
"""
{
"aiProviders": { "providers": "local-openai/gpt-5.5" },
"agent": { "temperature": 0.7 },
"customProviders": {
"local-openai": {
"name": "Local OpenAI",
"type": "openai",
"enabled": true,
"options": {
"baseURL": "https://openai-compatible.example",
"apiKey": "test-key"
},
"models": {
"gpt-5.5": {
"name": "GPT-5.5",
"supportsTools": true
}
}
}
}
}
""") {
let configuration = ConfigurationManager.shared
_ = configuration.loadConfiguration()
let aiService = PeekabooAIService(configuration: configuration)
let model = try #require(aiService.availableModels().first)
let agentService = try PeekabooAgentService(services: PeekabooServices())
#expect(model.modelId == "local-openai/gpt-5.5")
#expect(agentService.generationSettings(for: model).temperature == nil)
}
}
@Test
@MainActor
func `Configured custom OpenAI provider-qualified GPT 5 model omits unsupported temperature`() throws {
try self.withTemporaryConfig(
"""
{
"aiProviders": { "providers": "local-openai/openai/gpt-5.5" },
"agent": { "temperature": 0.7 },
"customProviders": {
"local-openai": {
"name": "Local OpenAI",
"type": "openai",
"enabled": true,
"options": {
"baseURL": "https://openai-compatible.example",
"apiKey": "test-key"
},
"models": {
"openai/gpt-5.5": {
"name": "GPT-5.5",
"supportsTools": true
}
}
}
}
}
""") {
let configuration = ConfigurationManager.shared
_ = configuration.loadConfiguration()
let aiService = PeekabooAIService(configuration: configuration)
let model = try #require(aiService.availableModels().first)
let agentService = try PeekabooAgentService(services: PeekabooServices())
#expect(model.modelId == "local-openai/openai/gpt-5.5")
#expect(agentService.generationSettings(for: model).temperature == nil)
}
}
private func withTemporaryConfig(_ configurationJSON: String, body: () throws -> Void) throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("peekaboo-config-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
try configurationJSON.write(
to: tempDir.appendingPathComponent("config.json"),
atomically: true,
encoding: .utf8)
let keys = [
"PEEKABOO_CONFIG_DIR",
"PEEKABOO_CONFIG_DISABLE_MIGRATION",
"PEEKABOO_AI_PROVIDERS",
]
let previous = Dictionary(uniqueKeysWithValues: keys.map { key in
(key, getenv(key).map { String(cString: $0) })
})
setenv("PEEKABOO_CONFIG_DIR", tempDir.path, 1)
setenv("PEEKABOO_CONFIG_DISABLE_MIGRATION", "1", 1)
unsetenv("PEEKABOO_AI_PROVIDERS")
ConfigurationManager.shared.resetForTesting()
defer {
for key in keys {
if case let value?? = previous[key] {
setenv(key, value, 1)
} else {
unsetenv(key)
}
}
ConfigurationManager.shared.resetForTesting()
try? FileManager.default.removeItem(at: tempDir)
}
try body()
}
}

View File

@ -0,0 +1,95 @@
import Foundation
import Tachikoma
import Testing
@testable import PeekabooAgentRuntime
@testable import PeekabooCore
@Suite(.serialized)
struct PeekabooAgentOpenRouterStreamingReasoningTests {
@Test
@MainActor
func `Streaming OpenRouter reasoning is replayed with OpenRouter metadata`() async throws {
let provider = StreamingOpenRouterReasoningReplayProvider()
let configuration = TachikomaConfiguration(loadFromEnvironment: false)
configuration.setProviderFactoryOverride { _, _ in provider }
let previousConfiguration = TachikomaConfiguration.default
TachikomaConfiguration.default = configuration
defer {
TachikomaConfiguration.default = previousConfiguration
}
let agentService = try PeekabooAgentService(
services: PeekabooServices(),
defaultModel: .openRouter(modelId: "openai/gpt-oss-120b"))
_ = try await agentService.executeTask(
"Use a tool, then continue.",
maxSteps: 2,
model: .openRouter(modelId: "openai/gpt-oss-120b"),
eventDelegate: NoopAgentEventDelegate(),
enhancementOptions: nil)
let secondRequestMessages = try #require(provider.secondRequestMessages)
let reasoningMessage = try #require(secondRequestMessages.first { message in
message.channel == .thinking
})
let customData = reasoningMessage.metadata?.customData ?? [:]
#expect(reasoningMessage.content == [.text("streamed openrouter thinking")])
#expect(customData["openrouter.reasoning"] == "streamed openrouter thinking")
#expect(customData["anthropic.thinking.type"] == nil)
#expect(customData["tachikoma.reasoning.provider"] == "openrouter")
#expect(customData["tachikoma.reasoning.model"] == "openai/gpt-oss-120b")
#expect(customData["tachikoma.reasoning.base_url"] != nil)
}
}
private final class StreamingOpenRouterReasoningReplayProvider: ModelProvider, @unchecked Sendable {
let modelId = "streaming-openrouter-reasoning-replay-provider"
let baseURL: String? = nil
let apiKey: String? = nil
let capabilities = ModelCapabilities()
private let lock = NSLock()
private var requestCount = 0
private var capturedSecondRequestMessages: [ModelMessage]?
var secondRequestMessages: [ModelMessage]? {
self.lock.withLock { self.capturedSecondRequestMessages }
}
func generateText(request _: ProviderRequest) async throws -> ProviderResponse {
ProviderResponse(text: "done", finishReason: .stop)
}
func streamText(request: ProviderRequest) async throws -> AsyncThrowingStream<TextStreamDelta, any Error> {
let requestNumber = self.lock.withLock {
self.requestCount += 1
if self.requestCount == 2 {
self.capturedSecondRequestMessages = request.messages
}
return self.requestCount
}
return AsyncThrowingStream { continuation in
if requestNumber == 1 {
continuation.yield(.reasoning("streamed openrouter thinking", type: "openrouter_reasoning"))
continuation.yield(.tool(AgentToolCall(
id: "missing-tool",
name: "missing_test_tool",
arguments: [:])))
continuation.yield(.done(finishReason: .toolCalls))
} else {
continuation.yield(.text("done"))
continuation.yield(.done(finishReason: .stop))
}
continuation.finish()
}
}
}
@MainActor
private final class NoopAgentEventDelegate: AgentEventDelegate {
func agentDidEmitEvent(_: AgentEvent) {}
}

View File

@ -0,0 +1,255 @@
import Tachikoma
import Testing
@testable import PeekabooAgentRuntime
@testable import PeekabooCore
@Suite(.serialized)
struct PeekabooAgentStreamingContentFilterTests {
@Test
@MainActor
func `Buffered provider content filter does not emit blocked reasoning text`() async throws {
let provider = ReasoningContentFilterProvider(reasoning: "blocked private reasoning")
let configuration = TachikomaConfiguration(loadFromEnvironment: false)
configuration.setProviderFactoryOverride { _, _ in provider }
let previousConfiguration = TachikomaConfiguration.default
TachikomaConfiguration.default = configuration
defer {
TachikomaConfiguration.default = previousConfiguration
}
let delegate = CapturingReasoningEventDelegate()
let agentService = try PeekabooAgentService(
services: PeekabooServices(),
defaultModel: .openai(.gpt55))
await #expect(throws: (any Error).self) {
_ = try await agentService.executeTask(
"trigger filter",
maxSteps: 1,
model: .openai(.gpt55),
eventDelegate: delegate,
enhancementOptions: nil)
}
#expect(!delegate.events.containsThinkingMessage("blocked private reasoning"))
}
@Test
@MainActor
func `Anthropic buffering is limited to refusal-risk models`() throws {
let agentService = try PeekabooAgentService(
services: PeekabooServices(),
defaultModel: .anthropic(.opus48))
#expect(agentService.buffersAgentTextStreamUntilDone(for: .anthropic(.fable5)))
#expect(agentService.buffersAgentTextStreamUntilDone(for: .anthropic(.opus48)))
#expect(!agentService.buffersAgentTextStreamUntilDone(for: .anthropic(.opus47)))
#expect(!agentService.buffersAgentTextStreamUntilDone(for: .anthropic(.sonnet46)))
}
@Test
@MainActor
func `Anthropic compatible buffering follows model risk`() throws {
let agentService = try PeekabooAgentService(
services: PeekabooServices(),
defaultModel: .anthropic(.opus48))
#expect(agentService.buffersAgentTextStreamUntilDone(for: .anthropicCompatible(
modelId: "claude-opus-4-8",
baseURL: "https://custom.anthropic.example/v1")))
#expect(!agentService.buffersAgentTextStreamUntilDone(for: .anthropicCompatible(
modelId: "claude-opus-4-7",
baseURL: "https://custom.anthropic.example/v1")))
}
@Test
@MainActor
func `Custom Anthropic buffering follows model risk`() throws {
let agentService = try PeekabooAgentService(
services: PeekabooServices(),
defaultModel: .anthropic(.opus48))
let opus48Provider = ReasoningContentFilterProvider(
modelId: "claude-opus-4-8",
reasoning: "blocked custom reasoning")
let opus47Provider = ReasoningContentFilterProvider(
modelId: "claude-opus-4-7",
reasoning: "safe custom reasoning")
#expect(agentService.buffersAgentTextStreamUntilDone(for: .custom(provider: opus48Provider)))
#expect(!agentService.buffersAgentTextStreamUntilDone(for: .custom(provider: opus47Provider)))
}
@Test
@MainActor
func `Buffered provider missing terminal event does not emit partial output`() async throws {
let provider = TruncatedBufferedProvider()
let configuration = TachikomaConfiguration(loadFromEnvironment: false)
configuration.setProviderFactoryOverride { _, _ in provider }
let previousConfiguration = TachikomaConfiguration.default
TachikomaConfiguration.default = configuration
defer {
TachikomaConfiguration.default = previousConfiguration
}
let delegate = CapturingReasoningEventDelegate()
let agentService = try PeekabooAgentService(
services: PeekabooServices(),
defaultModel: .openai(.gpt55))
await #expect(throws: (any Error).self) {
_ = try await agentService.executeTask(
"trigger truncated stream",
maxSteps: 1,
model: .openai(.gpt55),
eventDelegate: delegate,
enhancementOptions: nil)
}
#expect(!delegate.events.containsAssistantMessage("partial assistant text"))
#expect(delegate.events.firstToolStart(named: "missing_test_tool") == nil)
}
@Test
@MainActor
func `Buffered provider nil finish reason still flushes terminal stream`() async throws {
let provider = NilFinishBufferedProvider()
let configuration = TachikomaConfiguration(loadFromEnvironment: false)
configuration.setProviderFactoryOverride { _, _ in provider }
let previousConfiguration = TachikomaConfiguration.default
TachikomaConfiguration.default = configuration
defer {
TachikomaConfiguration.default = previousConfiguration
}
let delegate = CapturingReasoningEventDelegate()
let agentService = try PeekabooAgentService(
services: PeekabooServices(),
defaultModel: .openai(.gpt55))
let response = try await agentService.executeTask(
"trigger nil finish stream",
maxSteps: 1,
model: .openai(.gpt55),
eventDelegate: delegate,
enhancementOptions: nil)
#expect(response.content == "assistant text")
#expect(delegate.events.containsAssistantMessage("assistant text"))
#expect(delegate.events.firstToolStart(named: "terminal_test_tool") != nil)
}
}
private final class ReasoningContentFilterProvider: ModelProvider, @unchecked Sendable {
let modelId: String
let baseURL: String? = nil
let apiKey: String? = nil
let capabilities = ModelCapabilities()
private let reasoning: String
init(modelId: String = "reasoning-content-filter-provider", reasoning: String) {
self.modelId = modelId
self.reasoning = reasoning
}
func generateText(request _: ProviderRequest) async throws -> ProviderResponse {
ProviderResponse(
text: "blocked partial text",
finishReason: .contentFilter)
}
func streamText(request _: ProviderRequest) async throws -> AsyncThrowingStream<TextStreamDelta, any Error> {
AsyncThrowingStream { continuation in
continuation.yield(.reasoning(self.reasoning))
continuation.yield(.text("blocked partial text"))
continuation.yield(.done(finishReason: .contentFilter))
continuation.finish()
}
}
}
private final class TruncatedBufferedProvider: ModelProvider, @unchecked Sendable {
let modelId = "truncated-buffered-provider"
let baseURL: String? = nil
let apiKey: String? = nil
let capabilities = ModelCapabilities()
func generateText(request _: ProviderRequest) async throws -> ProviderResponse {
ProviderResponse(text: "partial assistant text", finishReason: nil)
}
func streamText(request _: ProviderRequest) async throws -> AsyncThrowingStream<TextStreamDelta, any Error> {
AsyncThrowingStream { continuation in
continuation.yield(.text("partial assistant text"))
continuation.yield(.tool(AgentToolCall(
id: "truncated-tool",
name: "missing_test_tool",
arguments: [:])))
continuation.finish()
}
}
}
private final class NilFinishBufferedProvider: ModelProvider, @unchecked Sendable {
let modelId = "nil-finish-buffered-provider"
let baseURL: String? = nil
let apiKey: String? = nil
let capabilities = ModelCapabilities()
func generateText(request _: ProviderRequest) async throws -> ProviderResponse {
ProviderResponse(text: "assistant text", finishReason: nil)
}
func streamText(request _: ProviderRequest) async throws -> AsyncThrowingStream<TextStreamDelta, any Error> {
AsyncThrowingStream { continuation in
continuation.yield(.text("assistant text"))
continuation.yield(.tool(AgentToolCall(
id: "terminal-tool",
name: "terminal_test_tool",
arguments: [:])))
continuation.yield(.done(finishReason: nil))
continuation.finish()
}
}
}
@MainActor
private final class CapturingReasoningEventDelegate: AgentEventDelegate {
private(set) var events: [AgentEvent] = []
func agentDidEmitEvent(_ event: AgentEvent) {
self.events.append(event)
}
}
extension [AgentEvent] {
fileprivate func containsAssistantMessage(_ expected: String) -> Bool {
self.contains { event in
if case let .assistantMessage(content) = event {
return content == expected
}
return false
}
}
fileprivate func containsThinkingMessage(_ expected: String) -> Bool {
self.contains { event in
if case let .thinkingMessage(content) = event {
return content == expected
}
return false
}
}
fileprivate func firstToolStart(named expected: String) -> Int? {
self.firstIndex { event in
if case let .toolCallStarted(name, _) = event {
return name == expected
}
return false
}
}
}

View File

@ -0,0 +1,87 @@
import Foundation
import Tachikoma
import Testing
@testable import PeekabooAgentRuntime
@testable import PeekabooCore
@Suite(.serialized)
struct PeekabooAgentStreamingReasoningBoundaryTests {
@Test
@MainActor
func `Streaming native reasoning only response records assistant boundary`() async throws {
let provider = StreamingReasoningOnlyProvider()
let configuration = TachikomaConfiguration(loadFromEnvironment: false)
configuration.setProviderFactoryOverride { _, _ in provider }
let previousConfiguration = TachikomaConfiguration.default
TachikomaConfiguration.default = configuration
defer {
TachikomaConfiguration.default = previousConfiguration
}
let agentService = try PeekabooAgentService(
services: PeekabooServices(),
defaultModel: .anthropic(.opus47))
let result = try await agentService.executeTaskStreaming(
"think only",
model: .anthropic(.opus47)) { _ in }
let thinkingIndex = try #require(result.messages.firstIndex { $0.channel == .thinking })
let boundaryMessage = try #require(result.messages.dropFirst(thinkingIndex + 1).first)
#expect(boundaryMessage.role == .assistant)
#expect(boundaryMessage.content == [.text("")])
#expect(boundaryMessage.metadata?.customData?["tachikoma.internal.boundary"] == "reasoning_only")
}
@Test
@MainActor
func `Streaming reasoning clears signature-first block before next pending block`() async throws {
let stream = AsyncThrowingStream<TextStreamDelta, any Error> { continuation in
continuation.yield(.reasoning("", signature: "sig-first", type: "thinking"))
continuation.yield(.reasoning("first", type: "thinking"))
continuation.yield(.reasoning("second", type: "thinking"))
continuation.yield(.reasoning("", signature: "sig-second", type: "thinking"))
continuation.yield(.done(finishReason: .stop))
continuation.finish()
}
let agentService = try PeekabooAgentService(
services: PeekabooServices(),
defaultModel: .anthropic(.opus47))
let output = try await agentService.collectStreamOutput(
from: StreamTextResult(
stream: stream,
model: .anthropic(.opus47),
settings: GenerationSettings()),
model: .anthropic(.opus47),
eventHandler: nil,
stepIndex: 0)
#expect(output.reasoningBlocks.count == 2)
#expect(output.reasoningBlocks.first?.text == "first")
#expect(output.reasoningBlocks.first?.signature == "sig-first")
#expect(output.reasoningBlocks.dropFirst().first?.text == "second")
#expect(output.reasoningBlocks.dropFirst().first?.signature == "sig-second")
}
}
private final class StreamingReasoningOnlyProvider: ModelProvider, @unchecked Sendable {
let modelId = "streaming-reasoning-only-provider"
let baseURL: String? = nil
let apiKey: String? = nil
let capabilities = ModelCapabilities()
func generateText(request _: ProviderRequest) async throws -> ProviderResponse {
ProviderResponse(text: "", finishReason: .stop)
}
func streamText(request _: ProviderRequest) async throws -> AsyncThrowingStream<TextStreamDelta, any Error> {
AsyncThrowingStream { continuation in
continuation.yield(.reasoning("streamed native thinking", signature: "sig-native-stream", type: "thinking"))
continuation.yield(.done(finishReason: .stop))
continuation.finish()
}
}
}

View File

@ -62,7 +62,7 @@ npx -y @steipete/peekaboo
# "command": "npx",
# "args": ["-y", "@steipete/peekaboo"],
# "env": {
# "PEEKABOO_AI_PROVIDERS": "openai/gpt-5.5,anthropic/claude-opus-4-7"
# "PEEKABOO_AI_PROVIDERS": "openai/gpt-5.5,anthropic/claude-opus-4-8"
# }
# }
# }

@ -1 +1 @@
Subproject commit 9754b309dd4bab4d9eef10c120aa5877a34c1c20
Subproject commit 0326be775e5f10831ed9c16b14c0b24229b19285

View File

@ -70,7 +70,7 @@ If your client supports environment variables, add provider and logging settings
"command": "npx",
"args": ["-y", "@steipete/peekaboo", "mcp"],
"env": {
"PEEKABOO_AI_PROVIDERS": "openai/gpt-5.5,anthropic/claude-opus-4-7",
"PEEKABOO_AI_PROVIDERS": "openai/gpt-5.5,anthropic/claude-opus-4-8",
"PEEKABOO_LOG_LEVEL": "info"
}
}

View File

@ -16,7 +16,7 @@ read_when:
| `--chat` | Force the interactive chat loop even when stdin/stdout are not TTYs. |
| `--dry-run` | Emit the planned steps without actually invoking tools. |
| `--max-steps <n>` | Cap how many tool invocations the agent may issue before aborting (default: 100). |
| `--model gpt-5.5|claude-opus-4.7|gemini-3-flash|minimax|minimax-cn/<model>|openrouter/<provider>/<model>|ollama/<model>|lmstudio/<model>` | Override the default model (`gpt-5.5`). Input is validated against supported hosted providers and local model providers. |
| `--model gpt-5.5|claude-fable-5|gemini-3-flash|minimax|minimax-cn/<model>|openrouter/<provider>/<model>|ollama/<model>|lmstudio/<model>` | Override the default model (`gpt-5.5`). Input is validated against supported hosted providers and local model providers. |
| `--resume` / `--resume-session <id>` | Continue the most recent session or a specific session ID. |
| `--list-sessions` | Print cached sessions (id, task, timestamps, message count) instead of running anything. |
| `--no-cache` | Always create a fresh session even if one is already active. |

View File

@ -19,7 +19,7 @@ pages instead of duplicating provider lists in multiple places.
| Provider | Models we test | Credential |
| --- | --- | --- |
| **OpenAI** | gpt-5, gpt-5-mini, gpt-4.1 | `OPENAI_API_KEY` |
| **Anthropic** | claude-opus-4-7, claude-sonnet-4-6, claude-haiku-4-5 | `ANTHROPIC_API_KEY` |
| **Anthropic** | claude-fable-5, claude-opus-4-8, claude-sonnet-4-6, claude-haiku-4-5 | `ANTHROPIC_API_KEY` |
| **xAI** | grok-4 | `XAI_API_KEY` |
| **Google** | gemini-3.1-pro-preview, gemini-3-flash | `GEMINI_API_KEY` |
| **MiniMax** | MiniMax-M2.7, MiniMax-M2.7-highspeed | `MINIMAX_API_KEY` |
@ -54,7 +54,8 @@ See [configuration.md](configuration.md) for the full precedence table.
## Picking a model
```bash
peekaboo agent --model claude-opus-4-7 "summarize this window"
peekaboo agent --model claude-fable-5 "summarize this window"
peekaboo agent --model claude-opus-4-8 "summarize this window"
peekaboo agent --model gemini-3-flash "summarize this window"
peekaboo agent --model minimax "summarize this window"
peekaboo agent --model minimax-cn/MiniMax-M2.7 "summarize this window"
@ -64,7 +65,7 @@ peekaboo agent --model ollama/llama3.1:8b "describe this screenshot"
peekaboo agent --model lmstudio/openai/gpt-oss-120b "summarize this window"
```
Defaults come from `agent.defaultModel` in `~/.peekaboo/config.json`. Set a per-project default with `PEEKABOO_AGENT_MODEL`.
Defaults come from `agent.defaultModel` in `~/.peekaboo/config.json`. Anthropic defaults stay on Opus 4.8 for zero-retention compatibility; select Fable explicitly when your Anthropic organization allows it. Set a per-project default with `PEEKABOO_AGENT_MODEL`.
## Tool calling