feat(agent): add OpenRouter provider support
Some checks failed
macOS CI / PeekabooCore build & tests (push) Has been cancelled
Website (GitHub Pages) / build (push) Has been cancelled
macOS CI / Peekaboo CLI build & tests (push) Has been cancelled
macOS CI / Tachikoma build & tests (push) Has been cancelled
macOS CI / Build macOS apps (Peekaboo + Inspector) (push) Has been cancelled
macOS CI / SwiftLint (core + CLI) (push) Has been cancelled
Website (GitHub Pages) / deploy (push) Has been cancelled

Add OpenRouter provider support to Tachikoma and Peekaboo agent selection and CLI configuration.

- support OPENROUTER_API_KEY env/credential auth and openrouter/<provider>/<model> IDs
- add config status validation/JSON output and docs/changelog
- retain contributor credit from #155

Co-authored-by: Delor Tshimanga <tshimangadelor1@gmail.com>
This commit is contained in:
Peter Steinberger 2026-05-20 04:03:56 +01:00 committed by GitHub
parent fe6548a5d8
commit a9725f89e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 345 additions and 39 deletions

View File

@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [3.2.2] - Unreleased
### Fixed
- `peekaboo agent` now accepts OpenRouter model IDs and can use `OPENROUTER_API_KEY` from env or credentials. Thanks @delort for #155.
## [3.2.1] - 2026-05-18

View File

@ -30,7 +30,7 @@ extension AgentCommand {
if Self.supportedMiniMaxInputs.contains(model) {
return .minimax(model)
}
case .ollama, .lmstudio:
case .ollama, .lmstudio, .openRouter:
return parsed.supportsTools ? parsed : nil
default:
break
@ -88,9 +88,13 @@ extension AgentCommand {
let anthropicModels = Self.supportedAnthropicInputs.map(\.modelId)
let googleModels = Self.supportedGoogleInputs.map(\.userFacingModelId)
let miniMaxModels = Self.supportedMiniMaxInputs.map(\.modelId)
return (openAIModels + anthropicModels + googleModels + miniMaxModels + ["ollama/<model>", "lmstudio/<model>"])
.sorted()
.joined(separator: ", ")
return (openAIModels + anthropicModels + googleModels + miniMaxModels + [
"ollama/<model>",
"lmstudio/<model>",
"openrouter/<provider>/<model>",
])
.sorted()
.joined(separator: ", ")
}
@MainActor
@ -107,6 +111,8 @@ extension AgentCommand {
return configuration.getGeminiAPIKey()?.isEmpty == false
case .minimax:
return configuration.getMiniMaxAPIKey()?.isEmpty == false
case .openRouter:
return configuration.getOpenRouterAPIKey()?.isEmpty == false
default:
return false
}
@ -126,6 +132,8 @@ extension AgentCommand {
"Ollama"
case .lmstudio:
"LM Studio"
case .openRouter:
"OpenRouter"
default:
"the selected provider"
}
@ -145,6 +153,8 @@ extension AgentCommand {
"OLLAMA_BASE_URL or PEEKABOO_OLLAMA_BASE_URL"
case .lmstudio:
"LM Studio local server URL"
case .openRouter:
"OPENROUTER_API_KEY"
default:
"provider API key"
}

View File

@ -337,6 +337,7 @@ extension AgentCommand {
let hasAnthropic = configuration.hasAnthropicAuth()
let hasGemini = configuration.getGeminiAPIKey()?.isEmpty == false
let hasMiniMax = configuration.getMiniMaxAPIKey()?.isEmpty == false
let hasOpenRouter = configuration.getOpenRouterAPIKey()?.isEmpty == false
let hasLocalProvider = configuration.getAIProviders()
.split(separator: ",")
.contains { entry in
@ -347,13 +348,13 @@ extension AgentCommand {
.lowercased()
return provider == "ollama" || provider == "lmstudio" || provider == "lm-studio"
}
return hasOpenAI || hasAnthropic || hasGemini || hasMiniMax || hasLocalProvider
return hasOpenAI || hasAnthropic || hasGemini || hasMiniMax || hasOpenRouter || hasLocalProvider
}
func emitAgentUnavailableMessage() {
if self.jsonOutput {
let message = "Agent service not available. Please set OPENAI_API_KEY, ANTHROPIC_API_KEY, " +
"GEMINI_API_KEY, MINIMAX_API_KEY, or configure ollama/<model> or lmstudio/<model>."
"GEMINI_API_KEY, MINIMAX_API_KEY, OPENROUTER_API_KEY, or configure ollama/<model> or lmstudio/<model>."
let error = [
"success": false,
"error": message
@ -367,7 +368,7 @@ extension AgentCommand {
} else {
let errorPrefix = [
"\(TerminalColor.red)Error: Agent service not available.",
" Please set OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, MINIMAX_API_KEY,",
" Please set OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, MINIMAX_API_KEY, OPENROUTER_API_KEY,",
" or configure ollama/<model> or lmstudio/<model>."
].joined()
let errorMessageLine = [errorPrefix, "\(TerminalColor.reset)"].joined()

View File

@ -11,13 +11,13 @@ extension ConfigCommand {
abstract: "Add and validate a provider credential (API key)"
)
@Argument(help: "Provider id (openai|anthropic|grok|xai|gemini)")
@Argument(help: "Provider id (openai|anthropic|grok|xai|gemini|openrouter)")
var provider: String
@Argument(help: "Secret value (API key)")
var secret: String
@Option(name: .long, help: "Validation timeout in seconds (default 30)")
@Option(name: .customLong("timeout"), help: "Validation timeout in seconds (default 30)")
var timeoutSeconds: Double = 30
@RuntimeStorage var runtime: CommandRuntime?
@ -25,7 +25,10 @@ extension ConfigCommand {
mutating func run(using runtime: CommandRuntime) async throws {
self.prepare(using: runtime)
guard let pid = TKProviderId.normalize(self.provider) else {
self.output.error(code: "INVALID_PROVIDER", message: "Supported: openai, anthropic, grok, xai, gemini")
self.output.error(
code: "INVALID_PROVIDER",
message: "Supported: openai, anthropic, grok, xai, gemini, openrouter"
)
throw ExitCode.failure
}
@ -67,7 +70,7 @@ extension ConfigCommand {
@Argument(help: "Provider id (openai|anthropic)")
var provider: String
@Option(name: .long, help: "Timeout in seconds for token exchange (default 30)")
@Option(name: .customLong("timeout"), help: "Timeout in seconds for token exchange (default 30)")
var timeoutSeconds: Double = 30
@Flag(name: .customLong("no-browser"), help: "Do not auto-open the browser")

View File

@ -24,6 +24,17 @@ extension ConfigCommand.ShowCommand: CommanderBindableCommand {
}
}
@available(macOS 14.0, *)
extension ConfigCommand.StatusCommand: AsyncRuntimeCommand {}
@MainActor
extension ConfigCommand.StatusCommand: CommanderBindableCommand {
mutating func applyCommanderValues(_ values: CommanderBindableValues) throws {
if let timeout = values.singleOption("timeout"), let seconds = Double(timeout) {
self.timeoutSeconds = seconds
}
}
}
@available(macOS 14.0, *)
extension ConfigCommand.EditCommand: AsyncRuntimeCommand {}
@MainActor

View File

@ -16,7 +16,7 @@ extension ConfigCommand {
@Flag(name: .long, help: "Force overwrite existing configuration")
var force = false
@Option(name: .long, help: "Validation timeout in seconds (default 30)")
@Option(name: .customLong("timeout"), help: "Validation timeout in seconds (default 30)")
var timeoutSeconds: Double = 30
@RuntimeStorage var runtime: CommandRuntime?
@ -78,7 +78,7 @@ extension ConfigCommand {
@Flag(name: .long, help: "Show effective configuration (merged with environment)")
var effective = false
@Option(name: .long, help: "Validation timeout in seconds (default 30)")
@Option(name: .customLong("timeout"), help: "Validation timeout in seconds (default 30)")
var timeoutSeconds: Double = 30
@RuntimeStorage var runtime: CommandRuntime?
@ -205,6 +205,34 @@ extension ConfigCommand {
}
}
/// Display configured provider credential status.
struct StatusCommand: ConfigRuntimeCommand {
static let commandDescription = CommandDescription(
commandName: "status",
abstract: "Display provider credential status"
)
@Option(name: .customLong("timeout"), help: "Validation timeout in seconds (default 30)")
var timeoutSeconds: Double = 30
@RuntimeStorage var runtime: CommandRuntime?
mutating func run(using runtime: CommandRuntime) async throws {
self.prepare(using: runtime)
let reporter = ProviderStatusReporter(timeoutSeconds: self.timeoutSeconds)
if self.jsonOutput {
let summary = await reporter.summary()
let response = ProviderStatusResponse(
success: true,
data: summary,
debugLogs: self.logger.getDebugLogs()
)
outputJSON(response, logger: self.logger)
} else {
await reporter.printSummary()
}
}
}
/// Open configuration in an editor.
struct EditCommand: ConfigRuntimeCommand {
static let commandDescription = CommandDescription(
@ -302,3 +330,14 @@ extension ConfigCommand {
}
}
}
private struct ProviderStatusResponse: Encodable {
let success: Bool
let data: ProviderStatusSummary
let debugLogs: [String]
enum CodingKeys: String, CodingKey {
case success, data
case debugLogs = "debug_logs"
}
}

View File

@ -10,15 +10,29 @@ struct ProviderStatusReporter {
self.timeoutSeconds = timeoutSeconds > 0 ? timeoutSeconds : 30
}
func summary() async -> ProviderStatusSummary {
let statuses = await self.providerStatuses()
return ProviderStatusSummary(providers: statuses)
}
func printSummary() async {
let summary = await self.summary()
print("Providers:")
for pid in [TKProviderId.openai, .anthropic, .grok, .gemini] {
let status = await self.status(for: pid)
print(" \(pid.displayName): \(status)")
for provider in summary.providers {
print(" \(provider.name): \(provider.message)")
}
}
private func status(for pid: TKProviderId) async -> String {
private func providerStatuses() async -> [ProviderCredentialStatus] {
var statuses: [ProviderCredentialStatus] = []
for pid in [TKProviderId.openai, .anthropic, .grok, .gemini, .openrouter] {
let status = await self.status(for: pid)
statuses.append(status)
}
return statuses
}
private func status(for pid: TKProviderId) async -> ProviderCredentialStatus {
switch self.source(for: pid) {
case let .env(key, value):
let validation = await TKAuthManager.shared.validate(
@ -26,31 +40,75 @@ struct ProviderStatusReporter {
secret: value,
timeout: self.timeoutSeconds
)
return self.describe(source: "env \(key)", validation: validation)
return self.makeStatus(for: pid, source: .init(type: "env", key: key), validation: validation)
case let .credentials(key, value):
let validation = await TKAuthManager.shared.validate(
provider: pid,
secret: value,
timeout: self.timeoutSeconds
)
return self.describe(source: "credentials \(key)", validation: validation)
return self.makeStatus(for: pid, source: .init(type: "credentials", key: key), validation: validation)
case let .missing(reason):
return reason
return ProviderCredentialStatus(
id: pid.rawValue,
name: pid.displayName,
state: .missing,
source: nil,
validation: nil,
message: reason
)
}
}
private func describe(source: String, validation: TKValidationResult) -> String {
private func makeStatus(
for pid: TKProviderId,
source: ProviderCredentialSource,
validation: TKValidationResult
) -> ProviderCredentialStatus {
switch validation {
case .success:
"ready (\(source), validated)"
ProviderCredentialStatus(
id: pid.rawValue,
name: pid.displayName,
state: .ready,
source: source,
validation: .validated,
message: "ready (\(source.description), validated)"
)
case let .failure(reason):
"stored (\(source), validation failed: \(reason))"
ProviderCredentialStatus(
id: pid.rawValue,
name: pid.displayName,
state: .stored,
source: source,
validation: .failed,
message: "stored (\(source.description), validation failed: \(reason))"
)
case let .timeout(seconds):
"stored (\(source), validation timed out after \(Int(seconds))s)"
ProviderCredentialStatus(
id: pid.rawValue,
name: pid.displayName,
state: .stored,
source: source,
validation: .timedOut,
message: "stored (\(source.description), validation timed out after \(Int(seconds))s)"
)
}
}
private func source(for pid: TKProviderId) -> ProviderSource {
if let source = self.envSource(for: pid) {
return source
}
if let source = self.credentialSource(for: pid) {
return source
}
return .missing("missing")
}
private func envSource(for pid: TKProviderId) -> ProviderSource? {
let env = ProcessInfo.processInfo.environment
switch pid {
case .openai:
@ -63,8 +121,13 @@ struct ProviderStatusReporter {
}
case .gemini:
if let v = env["GEMINI_API_KEY"], !v.isEmpty { return .env("GEMINI_API_KEY", v) }
case .openrouter:
if let v = env["OPENROUTER_API_KEY"], !v.isEmpty { return .env("OPENROUTER_API_KEY", v) }
}
return nil
}
private func credentialSource(for pid: TKProviderId) -> ProviderSource? {
let creds = TKAuthManager.shared
switch pid {
case .openai:
@ -83,9 +146,12 @@ struct ProviderStatusReporter {
}
case .gemini:
if let v = creds.credentialValue(for: "GEMINI_API_KEY") { return .credentials("GEMINI_API_KEY", v) }
case .openrouter:
if let v = creds.credentialValue(for: "OPENROUTER_API_KEY") {
return .credentials("OPENROUTER_API_KEY", v)
}
}
return .missing("missing")
return nil
}
}
@ -94,3 +160,37 @@ private enum ProviderSource {
case credentials(String, String)
case missing(String)
}
struct ProviderStatusSummary: Codable {
let providers: [ProviderCredentialStatus]
}
struct ProviderCredentialStatus: Codable {
let id: String
let name: String
let state: ProviderCredentialState
let source: ProviderCredentialSource?
let validation: ProviderCredentialValidation?
let message: String
}
enum ProviderCredentialState: String, Codable {
case missing
case ready
case stored
}
struct ProviderCredentialSource: Codable {
let type: String
let key: String
var description: String {
"\(self.type) \(self.key)"
}
}
enum ProviderCredentialValidation: String, Codable {
case validated
case failed
case timedOut = "timed_out"
}

View File

@ -35,6 +35,7 @@ struct ConfigCommand: ParsableCommand {
subcommands: [
InitCommand.self,
ShowCommand.self,
StatusCommand.self,
EditCommand.self,
ValidateCommand.self,
AddCommand.self,

View File

@ -77,6 +77,15 @@ struct AgentCommandTests {
#expect(command.parseModelString("minimax") == .minimax(.m27))
}
@Test
func `OpenRouter provider model IDs are accepted`() throws {
let command = try AgentCommand.parse([])
#expect(command
.parseModelString("openrouter/xiaomi/mimo-v2.5-pro") == .openRouter(modelId: "xiaomi/mimo-v2.5-pro"))
#expect(command.parseModelString("xiaomi/mimo-v2.5-pro") == .openRouter(modelId: "xiaomi/mimo-v2.5-pro"))
}
@Test
func `Model string normalization trims whitespace`() throws {
let command = try AgentCommand.parse([])
@ -124,6 +133,7 @@ struct ModelSelectionIntegrationTests {
("gemini-3.1-pro-preview", .google(.gemini31ProPreview)),
("MiniMax-M2.7", .minimax(.m27)),
("ollama/llama3.3", .ollama(.llama33)),
("openrouter/xiaomi/mimo-v2.5-pro", .openRouter(modelId: "xiaomi/mimo-v2.5-pro")),
]
for (input, expected) in testCases {

View File

@ -1,4 +1,5 @@
import Commander
import Foundation
import Testing
@testable import PeekabooCLI
@ -202,6 +203,42 @@ struct CommanderBinderAppConfigTests {
#expect(command.effective == true)
}
@Test
func `Config status binding`() throws {
let parsed = ParsedValues(positional: [], options: ["timeout": ["5"]], flags: [])
let command = try CommanderCLIBinder.instantiateCommand(
ofType: ConfigCommand.StatusCommand.self,
parsedValues: parsed
)
#expect(command.timeoutSeconds == 5)
}
@Test
func `Config status JSON payload is structured`() throws {
let summary = ProviderStatusSummary(providers: [
ProviderCredentialStatus(
id: "openrouter",
name: "OpenRouter",
state: .stored,
source: ProviderCredentialSource(type: "env", key: "OPENROUTER_API_KEY"),
validation: .failed,
message: "stored (env OPENROUTER_API_KEY, validation failed: status 401)"
)
])
let data = try JSONEncoder().encode(summary)
let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
let providers = try #require(json["providers"] as? [[String: Any]])
let openRouter = try #require(providers.first)
let source = try #require(openRouter["source"] as? [String: Any])
#expect(openRouter["id"] as? String == "openrouter")
#expect(openRouter["state"] as? String == "stored")
#expect(openRouter["validation"] as? String == "failed")
#expect(source["type"] as? String == "env")
#expect(source["key"] as? String == "OPENROUTER_API_KEY")
}
@Test
func `Config set credential binding`() throws {
let parsed = ParsedValues(positional: ["OPENAI_API_KEY", "sk-123"], options: [:], flags: [])

View File

@ -3,6 +3,7 @@
## [3.2.2] - Unreleased
### Fixed
- `peekaboo agent` now accepts OpenRouter model IDs and can use `OPENROUTER_API_KEY` from env or credentials. Thanks @delort for #155.
## [3.2.1] - 2026-05-18

View File

@ -150,6 +150,19 @@ extension ConfigurationManager {
return nil
}
/// Get OpenRouter API key with proper precedence.
public func getOpenRouterAPIKey() -> String? {
if let envValue = self.environmentValue(for: "OPENROUTER_API_KEY") {
return envValue
}
if let credValue = credentials["OPENROUTER_API_KEY"] {
return credValue
}
return nil
}
/// Apply Peekaboo-managed provider keys to Tachikoma.
public func applyAIProviderKeys(to configuration: TachikomaConfiguration = .current) {
if let key = self.getOpenAIAPIKey(), !key.isEmpty {
@ -164,6 +177,9 @@ extension ConfigurationManager {
if let key = self.getMiniMaxAPIKey(), !key.isEmpty {
configuration.setAPIKey(key, for: .minimax)
}
if let key = self.getOpenRouterAPIKey(), !key.isEmpty {
configuration.setAPIKey(key, for: "openrouter")
}
let ollamaBaseURL = self.getOllamaBaseURL()
if !ollamaBaseURL.isEmpty {
configuration.setBaseURL(ollamaBaseURL, for: .ollama)

View File

@ -251,6 +251,8 @@ public final class PeekabooAIService {
case "minimax":
if case .minimax = loose { return loose }
return nil
case "openrouter":
return .openRouter(modelId: modelString)
case "mistral":
if case .mistral = loose { return loose }
return nil
@ -310,6 +312,11 @@ public final class PeekabooAIService {
if let key = configuration.getMiniMaxAPIKey(), !key.isEmpty {
return self.appendingGeneratedVisionFallbacks(from: parsed, to: [.minimax(.m27)])
}
if let key = configuration.getOpenRouterAPIKey(), !key.isEmpty {
return self.appendingGeneratedVisionFallbacks(
from: parsed,
to: [.openRouter(modelId: "openai/gpt-oss-120b")])
}
return [.openai(.gpt55), .anthropic(.opus47)]
}
@ -386,6 +393,8 @@ public final class PeekabooAIService {
configuration.getMiniMaxAPIKey()?.isEmpty == false
case .grok:
self.hasAnyCredential(["X_AI_API_KEY", "XAI_API_KEY", "GROK_API_KEY"], configuration: configuration)
case .openRouter:
configuration.getOpenRouterAPIKey()?.isEmpty == false
case .ollama, .lmstudio:
model.supportsTools
default:

View File

@ -22,11 +22,12 @@ extension PeekabooServices {
let hasAnthropic = self.configuration.hasAnthropicAuth()
let hasGemini = self.configuration.getGeminiAPIKey() != nil && !self.configuration.getGeminiAPIKey()!.isEmpty
let hasMiniMax = self.configuration.getMiniMaxAPIKey() != nil && !self.configuration.getMiniMaxAPIKey()!.isEmpty
let hasOpenRouter = self.configuration.getOpenRouterAPIKey()?.isEmpty == false
let hasOllama = Self.providerList(providers, containsToolCapableLocalProvider: "ollama")
let hasLMStudio = Self.providerList(providers, containsToolCapableLocalProvider: "lmstudio") ||
Self.providerList(providers, containsToolCapableLocalProvider: "lm-studio")
if hasOpenAI || hasAnthropic || hasGemini || hasMiniMax || hasOllama || hasLMStudio {
if hasOpenAI || hasAnthropic || hasGemini || hasMiniMax || hasOpenRouter || hasOllama || hasLMStudio {
let agentConfig = self.configuration.getConfiguration()
let environmentProviders = EnvironmentVariables.value(for: "PEEKABOO_AI_PROVIDERS")?
.trimmingCharacters(in: .whitespacesAndNewlines)
@ -38,6 +39,7 @@ extension PeekabooServices {
hasAnthropic: hasAnthropic,
hasGemini: hasGemini,
hasMiniMax: hasMiniMax,
hasOpenRouter: hasOpenRouter,
hasOllama: hasOllama,
hasLMStudio: hasLMStudio,
configuredDefault: agentConfig?.agent?.defaultModel,
@ -131,6 +133,8 @@ extension PeekabooServices {
"gemini-3-flash"
} else if sources.hasMiniMax {
"minimax/MiniMax-M2.7"
} else if sources.hasOpenRouter {
"openrouter/openai/gpt-oss-120b"
} else if sources.hasOllama {
"ollama/llama3.3"
} else if sources.hasLMStudio {
@ -179,6 +183,8 @@ extension PeekabooServices {
return model
case "minimax" where sources.hasMiniMax:
return "minimax/\(model)"
case "openrouter" where sources.hasOpenRouter:
return "openrouter/\(model)"
case "ollama" where sources.hasOllama:
return Self.toolCapableLocalModel("ollama/\(model)")
case "lmstudio" where sources.hasLMStudio,
@ -240,6 +246,8 @@ extension PeekabooServices {
return sources.hasGemini
case .minimax:
return sources.hasMiniMax
case .openRouter:
return sources.hasOpenRouter
case .ollama:
return sources.hasOllama && model.supportsTools
case .lmstudio:
@ -306,6 +314,7 @@ private struct ModelSources {
let hasAnthropic: Bool
let hasGemini: Bool
let hasMiniMax: Bool
let hasOpenRouter: Bool
let hasOllama: Bool
let hasLMStudio: Bool
let configuredDefault: String?

View File

@ -164,6 +164,23 @@ struct ConfigurationAccessorsOAuthTests {
}
}
@Test
func `applyAIProviderKeys propagates OpenRouter API key as custom provider key`() throws {
try withIsolatedConfigurationEnvironment { _ in
self.unsetAllOpenRouterEnv()
self.manager.resetForTesting()
try self.manager.saveCredentials([
"OPENROUTER_API_KEY": "placeholder-openrouter-key",
])
let configuration = TachikomaConfiguration(loadFromEnvironment: false)
self.manager.applyAIProviderKeys(to: configuration)
#expect(self.manager.getOpenRouterAPIKey() == "placeholder-openrouter-key")
#expect(configuration.getAPIKey(for: "openrouter") == "placeholder-openrouter-key")
}
}
// MARK: - Helpers
private func unsetAllAnthropicEnv() {
@ -180,6 +197,10 @@ struct ConfigurationAccessorsOAuthTests {
unsetenv("OPENAI_REFRESH_TOKEN")
unsetenv("OPENAI_ACCESS_EXPIRES")
}
private func unsetAllOpenRouterEnv() {
unsetenv("OPENROUTER_API_KEY")
}
}
private func withIsolatedConfigurationEnvironment(_ body: (URL) throws -> Void) throws {

View File

@ -221,6 +221,36 @@ struct PeekabooAIServiceProviderTests {
}
}
@Test
@MainActor
func `Falls back to OpenRouter when only OpenRouter key is present`() throws {
try self.withIsolatedEnvironment(["OPENROUTER_API_KEY": "key"]) {
let service = PeekabooAIService()
#expect(service.resolvedDefaultModel == .openRouter(modelId: "openai/gpt-oss-120b"))
#expect(service.resolvedDefaultVisionModel == nil)
#expect(service.availableModels() == [.openRouter(modelId: "openai/gpt-oss-120b")])
#expect(TachikomaConfiguration.current.getAPIKey(for: "openrouter") == "key")
}
}
@Test
@MainActor
func `Explicit OpenRouter provider list resolves configured model`() throws {
try self.withIsolatedEnvironment(
["OPENROUTER_API_KEY": "key"],
configurationJSON: """
{
"aiProviders": {
"providers": "openrouter/xiaomi/mimo-v2.5-pro"
}
}
""") {
let service = PeekabooAIService()
#expect(service.resolvedDefaultModel == .openRouter(modelId: "xiaomi/mimo-v2.5-pro"))
#expect(service.availableModels() == [.openRouter(modelId: "xiaomi/mimo-v2.5-pro")])
}
}
@Test
@MainActor
func `Generated default provider list still falls back to MiniMax credentials`() throws {
@ -387,6 +417,7 @@ struct PeekabooAIServiceProviderTests {
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"MINIMAX_API_KEY",
"OPENROUTER_API_KEY",
"API_KEY",
"PEEKABOO_CUSTOM_PROVIDER_KEY",
"PEEKABOO_MISSING_PROVIDER_KEY",
@ -449,6 +480,7 @@ struct PeekabooAIServiceProviderTests {
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"MINIMAX_API_KEY",
"OPENROUTER_API_KEY",
"API_KEY",
"PEEKABOO_CUSTOM_PROVIDER_KEY",
"PEEKABOO_MISSING_PROVIDER_KEY",
@ -492,6 +524,7 @@ struct PeekabooAIServiceProviderTests {
TachikomaConfiguration.current.removeAPIKey(for: .anthropic)
TachikomaConfiguration.current.removeAPIKey(for: .google)
TachikomaConfiguration.current.removeAPIKey(for: .minimax)
TachikomaConfiguration.current.removeAPIKey(for: .custom("openrouter"))
TachikomaConfiguration.current.removeBaseURL(for: .ollama)
}
}

@ -1 +1 @@
Subproject commit d8a774c6aea2787ef5db2ce141f649ad0491b6f9
Subproject commit ef97ba54a3beca68f015b5b84013350c82778169

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|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-opus-4.7|gemini-3-flash|minimax|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. |
@ -51,6 +51,9 @@ peekaboo agent "Check Slack mentions" --model gpt-5.5 --verbose
# Keep the agent loop local through Ollama
peekaboo agent "Check the current window" --model ollama/llama3.3
# Use an OpenRouter-hosted model
peekaboo agent "Check the current window" --model openrouter/xiaomi/mimo-v2.5-pro
# Dry-run the same task without executing any tools
peekaboo agent "Install the nightly build" --dry-run

View File

@ -16,7 +16,8 @@ read_when:
| `show` | Print either the raw file or the fully merged “effective” view (config + env + credentials); human `--effective` also live-validates providers. | `--effective` switches to the merged view; `--timeout` (sec) bounds validation; JSON mode emits a standard `{ success, data }` object with no appended text. |
| `edit` | Opens the config in `$EDITOR` (or the `--editor` you pass) and validates the result after you quit. | `--editor` overrides the detected editor. |
| `validate` | Parses the config without writing anything and surfaces syntax/errors. | None. |
| `add` | Store a provider credential and validate it immediately. | `add openai|anthropic|grok|gemini <secret>`; `--timeout` (sec, default 30). |
| `add` | Store a provider credential and validate it immediately. | `add openai|anthropic|grok|gemini|openrouter <secret>`; `--timeout` (sec, default 30). |
| `status` | Display provider credential readiness. | `--timeout` (sec, default 30). |
| `login` | Run an OAuth flow (no API key stored) for supported providers. | `login openai` (ChatGPT/Codex), `login anthropic` (Claude Pro/Max). |
| `set-credential` | Legacy alias for `add <key> <value>`. | Positional `<key> <value>` pair. |
| `add-provider` | Append or replace a custom AI provider entry. | `--type openai|anthropic`, `--name`, `--base-url`, `--api-key`, `--headers key:value,…`, `--description`, `--force`. |
@ -29,7 +30,7 @@ read_when:
- The underlying auth/config plumbing lives in the shared Tachikoma library and the `tachikoma config` CLI; Peekaboo sets `TachikomaConfiguration.profileDirectoryName = ".peekaboo"` so both tools read/write the same `~/.peekaboo/credentials` without copying environment variables.
- Configuration files are JSON-with-comments: the loader strips `//` / `/* */` comments and interpolates `${VAR}` placeholders before merging with credentials and environment variables (same logic the CLI uses on startup).
- `add`/`login`/`set-credential` write through `ConfigurationManager.shared`, so they use macOS file permissions + atomic temp-file renames; partial writes wont corrupt the store even if the process crashes.
- Provider readiness in human `init`/`show --effective` output is live-validated with per-provider pings (OpenAI/Codex, Anthropic, Grok/xai, Gemini). Timeouts default to 30s and are caller overridable. JSON mode skips appended readiness text so stdout remains parseable.
- Provider readiness in human `init`/`show --effective` output is live-validated with per-provider pings (OpenAI/Codex, Anthropic, Grok/xai, Gemini, OpenRouter). Timeouts default to 30s and are caller overridable. JSON mode skips appended readiness text so stdout remains parseable.
- Provider management commands share the same validation helpers: IDs must match `^[A-Za-z0-9-_]+$`, and provider types are limited to `.openai` or `.anthropic`. Headers passed via `--headers KEY:VALUE,…` are parsed into a `[String:String]` dictionary before being serialized back to disk.
- `test-provider` and `models` invoke the actual HTTP client stack (respecting proxy, TLS, and custom headers) rather than mocking responses, which is why they run on the main actor and surface real latencies.
- All subcommands are `RuntimeOptionsConfigurable`, so global `--json` or `--verbose` flags work uniformly (handy when you script config changes).
@ -40,19 +41,16 @@ read_when:
peekaboo config init --force
peekaboo config show --effective
# Register OpenRouter as a provider and immediately test it
peekaboo config add-provider openrouter \
--type openai \
--name "OpenRouter" \
--base-url https://openrouter.ai/api/v1 \
--api-key '${OPENROUTER_API_KEY}' --force
peekaboo config test-provider --provider-id openrouter
# Add and validate an OpenRouter key
peekaboo config add openrouter sk-or-v1-...
peekaboo agent --model openrouter/xiaomi/mimo-v2.5-pro "summarize this window"
# Add and validate keys (stores even if validation fails; warns on failure)
peekaboo config add openai sk-live-...
peekaboo config add anthropic sk-ant-...
peekaboo config add grok xai-...
peekaboo config add gemini ya29...
peekaboo config add openrouter sk-or-v1-...
# OAuth logins (no API key stored)
peekaboo config login openai

View File

@ -1,7 +1,7 @@
---
title: AI providers
summary: 'Configure model providers and credentials for the Peekaboo agent runtime.'
description: Configure OpenAI, Anthropic Claude, xAI Grok, Google Gemini, MiniMax, and Ollama for the Peekaboo agent.
description: Configure OpenAI, Anthropic Claude, xAI Grok, Google Gemini, MiniMax, OpenRouter, and local providers for the Peekaboo agent.
read_when:
- 'configuring model credentials or provider selection'
- 'debugging agent model, tool-calling, or local Ollama setup'
@ -23,6 +23,7 @@ pages instead of duplicating provider lists in multiple places.
| **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` |
| **OpenRouter** | any tool-calling OpenRouter model ID | `OPENROUTER_API_KEY` |
| **Ollama** | any local model with tool-calling | runs at `http://localhost:11434` |
| **LM Studio** | any local OpenAI-compatible model with tool-calling | runs at `http://localhost:1234/v1` |
@ -37,6 +38,7 @@ peekaboo config set-credential OPENAI_API_KEY <key>
peekaboo config set-credential ANTHROPIC_API_KEY <key>
peekaboo config set-credential GEMINI_API_KEY <key>
peekaboo config set-credential MINIMAX_API_KEY <key>
peekaboo config set-credential OPENROUTER_API_KEY <key>
```
Environment variables override the stored values, which is handy in CI:
@ -53,6 +55,7 @@ See [configuration.md](configuration.md) for the full precedence table.
peekaboo agent --model claude-opus-4-7 "summarize this window"
peekaboo agent --model gemini-3-flash "summarize this window"
peekaboo agent --model minimax "summarize this window"
peekaboo agent --model openrouter/xiaomi/mimo-v2.5-pro "summarize this window"
peekaboo agent --model gpt-5-mini "click Continue and wait for the dialog"
peekaboo agent --model ollama/llama3.1:8b "describe this screenshot"
peekaboo agent --model lmstudio/openai/gpt-oss-120b "summarize this window"