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
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:
parent
fe6548a5d8
commit
a9725f89e6
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -35,6 +35,7 @@ struct ConfigCommand: ParsableCommand {
|
||||
subcommands: [
|
||||
InitCommand.self,
|
||||
ShowCommand.self,
|
||||
StatusCommand.self,
|
||||
EditCommand.self,
|
||||
ValidateCommand.self,
|
||||
AddCommand.self,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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: [])
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 won’t 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
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user